🤖 AI-Readable JSON for this phase
All 10 sub-phases of Business Apps as JSON — perfect for AI agents.
{
"phase": {
"id": 5,
"name": "Business Apps",
"icon": "💼",
"tagline": "Productivity + commerce",
"color": "#10b981",
"slug": "business-apps"
},
"sub_phases": [
{
"id": "5.1",
"title": "Kanban Board",
"icon": "📋",
"description": "Lists of cards with drag-drop, multi-board, WIP limits, filters. FlowBoard คือ reference implementation ใน CodeHub.",
"when_to_use": "Project mgmt (sprint board), GTD personal workflow, content pipeline (blog post stages), sales pipeline lite, learning roadmap (flowboard base)",
"when_not_to_use": "Real-time multi-user (>5 concurrent cursors ต้อง CRDT/Yjs), gantt-heavy project (need timeline), dependency graph (use dedicated PM tool)",
"pros": [
"Visual columns = spatial memory (ตาเห็น state ทันที ไม่ต้อง filter เหมือน table)",
"Drag-drop เป็น natural mental model (physical sticky notes) = user เรียนรู้ 0 doc",
"localStorage + JSON export = offline-first, ไม่ต้อง server, GDPR-safe โดย default",
"WIP limits per column = บังคับให้ team focus (ตามหลัก Kanban ของ Toyota)",
"FlowBoard architecture = DOM diff เฉพาะการ์ดที่ย้าย (move 200 cards = 1 repaint ไม่ใช่ 200)",
"Tag/label บนการ์ด = filter SQL-like (multi-tag AND/OR) โดยไม่ต้อง query engine"
],
"cons": [
"HTML5 Drag-and-Drop API เก่า (2009 spec) = ไม่มี touch support, ghost image customization จำกัด",
"Sortable.js (~40KB) ตอบโจทย์ mobile แต่ add bundle 1 lib = ต้องชั่งน้ำหนัก",
"Real-time sync 5+ user = conflict (2 คนลากการ์ดเดียวกัน) ต้อง OT/CRDT layer",
"Board > 1000 cards = DOM heavy, scroll lag (ใช้ virtual list แทน แต่ DnD กับ virtual scroll ขัดกัน)",
"Save state ใน localStorage 5-10MB limit = board ใหญ่พร้อม description ยาว quota error",
"Multi-board ใน URL hash (board#id) = SEO ไม่ได้, share link ต้อง snapshot state"
],
"gotchas": [
"iOS Safari: HTML5 DnD touch broken ใช้ Sortable.js หรือ pointer events (touchstart + touchmove)",
"dragstart setData('text/plain', id) เพื่อส่ง id ของการ์ด มิงั้น drop event ไม่รู้ว่าลากอะไร",
"drop target = column ไม่ใช่การ์ดอื่น — ใช้ closest('.list') หา column id",
"Animation: transition: transform 200ms บนการ์ดที่ย้าย ไม่ใช่ 200ms ทั้งหมด",
"Optimistic UI: อัปเดต state ก่อน save แล้ว rollback ถ้า server fail (UX feel snappy)",
"Undo: เก็บ ops stack {type:'move',cardId,from,to} ไม่ใช่ snapshot ทั้ง board (1KB vs 500KB)",
"Mobile drag: dragover ต้อง preventDefault() มิงั้น drop ไม่ fire (Chrome quirk)",
"localStorage quota: ใช้ IndexedDB เมื่อ board > 2MB (description ยาว + attachments)"
],
"anti_patterns": [
"Re-render ทั้ง board ทุกครั้งที่ย้ายการ์ด — ใช้ DOM diff (Immer + key-based render) ลด reflow 100x",
"เก็บ state ใน DOM data-attribute — DOM = render layer, state = JS object เท่านั้น",
"Bind dragend แทน drop เพื่อบันทึก — ถ้า drop นอก valid target state จะ corrupt",
"ใช้ <iframe> แยกต่อ board เพื่อ isolate CSS — overkill, ใช้ Shadow DOM หรือ scope CSS",
"Save ทันทีทุก mousemove (debounce 0) — ใช้ throttle 500ms หรือ save ตอน drop เสร็จ"
],
"prereqs": [
"1.3",
"1.4"
],
"next_steps": [
"5.3",
"5.7"
],
"examples": [
"flowboard",
"habit-streak",
"knowledge-garden",
"mavis-home"
],
"snippet": "document.addEventListener('dragstart',e=>e.dataTransfer.setData('text/plain',e.target.dataset.id)); document.addEventListener('drop',e=>{const id=e.dataTransfer.getData('text/plain');const listId=e.target.closest('.list').dataset.listId;state.moveCard(id,listId);render();});",
"snippet_full": "// FlowBoard-style: Sortable.js (mobile-friendly) + optimistic state\\nimport Sortable from 'sortablejs';\\nconst board = {lists: [{id:'todo',cards:[]},{id:'doing',cards:[]},{id:'done',cards:[]}]};\\nfunction render(){\\n board.lists.forEach(list => {\\n const el = document.querySelector(`[data-list-id=\"${list.id}\"] .cards`);\\n el.innerHTML = list.cards.map(c =>\\n `<div class=\"card\" data-id=\"${c.id}\">${c.title}</div>`).join('');\\n });\\n}\\nboard.lists.forEach(list => {\\n const el = document.querySelector(`[data-list-id=\"${list.id}\"] .cards`);\\n Sortable.create(el, {\\n group: 'kanban',\\n animation: 180,\\n onEnd: (e) => {\\n const {id} = e.item.dataset;\\n const fromList = board.lists.find(l => l.id === e.from.dataset.listId);\\n const toList = board.lists.find(l => l.id === e.to.dataset.listId);\\n const card = fromList.cards.splice(e.oldIndex, 1)[0];\\n toList.cards.splice(e.newIndex, 0, card);\\n localStorage.setItem('board', JSON.stringify(board));\\n }\\n });\\\n});\\nrender();",
"tags": [
"kanban",
"drag-drop",
"บอร์ด",
"task",
"flowboard",
"wip",
"sortable"
],
"difficulty": "Intermediate",
"time_to_learn": "4 hours",
"docs_url": "https://github.com/SortableJS/Sortable",
"compare_with": [
"Trello (closed, paid)",
"Notion (heavy, multi-tool)",
"Jira (overkill, dev-focused)"
]
},
{
"id": "5.2",
"title": "Calendar (ICS Export)",
"icon": "📅",
"description": "Month/week view + iCal (.ics) export + RFC 5545 recurrence. Use UTC storage + local display ตามหลัก iCalendar.",
"when_to_use": "Schedule view, booking time slots, content calendar, lesson planner, event RSVP, medbook/flowboard ใช้พื้นฐานนี้",
"when_not_to_use": "Resource scheduling (rooms/equipment ต้อง conflict detection), team availability (round-robin), Gantt/dependency (use MS Project, ClickUp)",
"pros": [
"iCal .ics = text file 50-200 lines เปิดได้ใน Apple Calendar, Google Calendar, Outlook (universal)",
"Month grid view = scan 30+ วันใน 1 หน้าจอ (better than list view เมื่อ date-dense)",
"RFC 5545 RRULE string = 'FREQ=WEEKLY;BYDAY=MO,WE,FR' = recurrence 1 บรรทัด (no library)",
"UTC storage + local display = หลีกเลี่ยง DST bug ที่พบบ่อยที่สุดใน calendar app",
"UID field per event = email server ใช้ update/delete specific event โดยไม่ซ้ำ",
"WebCal subscription (webcal://) = subscribe 1 ครั้ง, calendar auto-update ทุกครั้งที่ server เปลี่ยน"
],
"cons": [
"RFC 5545 recurrence 50+ rules (RRULE exceptions, RDATE, EXDATE) — implement เองเจ็บปวด",
"iOS WKWebView parse .ics ไม่ได้ (ต้อง download → open in Calendar)",
"DST: 'America/New_York' เปลี่ยนวัน — ใช้ 'TZID' หรือ UTC + local convert (ซับซ้อน)",
"Calendar app แต่ละเจ้า render .ics ต่างกัน (Outlook มี escape character ต่างจาก Google)",
"All-day event = DTSTART;VALUE=DATE ไม่มีเวลา แต่ถ้าใส่ 'T000000' บาง app แสดงเป็น 24-hour event",
"VEVENT ไม่มี attendee RSVP built-in (ต้องใช้ ATTENDEE + iTIP protocol — heavy)"
],
"gotchas": [
"DTSTART ต้องเป็น UTC 'YYYYMMDDTHHMMSSZ' แล้ว client app convert local — ห้ามเก็บ local time",
"iCal line fold: บรรทัดยาว 75 octets ต้อง fold ด้วย CRLF + space (RFC 5545 section 3.1)",
"Text escape: comma, semicolon, backslash ใน SUMMARY/DESCRIPTION ต้อง escape (\\, \\; \\\\ )",
"UID: unique per event — ใช้ `${event.id}@yourdomain.com` ไม่ใช่ random เพราะ update ต้อง match UID",
"Recurrence ID (RECURRENCE-ID) = reschedule 1 instance โดยไม่ทำลาย RRULE ทั้งชุด",
"DTSTAMP = เวลาที่สร้างไฟล์ (not event time) — ใช้ track version ตอน subscribe update",
"iOS 17+: .ics จาก webcal:// จะ open Calendar โดยตรง ไม่ต้อง download ก่อน",
"Thai Buddhist year = +543 (พ.ศ. 2567 = ค.ศ. 2024) — convert ก่อน serialize ไม่งั้น import ผิดปี"
],
"anti_patterns": [
"เก็บ local time ใน database — DST + timezone change = event shift ±1 ชม. ทุกปี, ใช้ UTC + IANA tz string",
"Parse RRULE เอง (regex) — ใช้ rrule.js (~10KB) ที่ RFC-compliant, edge case 50+ rules",
"สร้าง .ics ด้วย JSON template แล้ว stringify — escape character ผิด, ใช้ library ics (npm) ดีกว่า",
"Embed HTML ใน DESCRIPTION แบบ inline — ใช้ X-ALT-DESC property (RFC 9265) แทน",
"Save ไฟล์ .ics แล้ว expect browser 'add to calendar' button เด้ง — ต้อง data:text/calendar URL หรือ webcal:// scheme"
],
"prereqs": [
"1.3"
],
"next_steps": [
"5.4",
"5.7"
],
"examples": [
"flowboard",
"medbook",
"dinebook",
"cospace",
"fitbook"
],
"snippet": "function exportICS(events){const lines=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//CodeHub//EN']; events.forEach(e=>{lines.push('BEGIN:VEVENT',`UID:${e.id}@codehub.app`,`DTSTAMP:${formatICS(Date.now())}`,`DTSTART:${formatICS(e.start)}`,`DTEND:${formatICS(e.end)}`,`SUMMARY:${esc(e.title)}`,'END:VEVENT')}); return [...lines,'END:VCALENDAR'].join('\\r\\n')}",
"snippet_full": "// FlowBoard/medbook style: ICS export with RFC 5545 escape + UTC\\nfunction esc(t){return String(t).replace(/[\\\\,;]/g, c=>'\\\\'+c).replace(/\\n/g,'\\\\n');}\\nfunction fmtICS(ts){return new Date(ts).toISOString().replace(/[-:]/g,'').replace(/\\.\\d{3}/,'');}\\nfunction exportICS(events){\\n const lines = ['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//CodeHub//FlowBoard//EN','CALSCALE:GREGORIAN'];\\n for (const e of events) {\\n lines.push('BEGIN:VEVENT',\\n `UID:${e.id}@flowboard.codehub.app`,\\n `DTSTAMP:${fmtICS(Date.now())}`,\\n `DTSTART:${fmtICS(e.start)}`,\\n `DTEND:${fmtICS(e.end)}`,\\n `SUMMARY:${esc(e.title)}`,\\n `DESCRIPTION:${esc(e.desc||'')}`,\\n e.rrule ? `RRULE:${e.rrule}` : '',\\n 'END:VEVENT');\\n }\\n lines.push('END:VCALENDAR');\\n return lines.filter(Boolean).join('\\r\\n');\\n}\\nconst blob = new Blob([exportICS(myEvents)], {type:'text/calendar;charset=utf-8'});\\nwindow.location.href = URL.createObjectURL(blob);",
"tags": [
"calendar",
"ics",
"icalendar",
"ปฏิทิน",
"rfc5545",
"rrule",
"utc",
"recurrence"
],
"difficulty": "Intermediate",
"time_to_learn": "1 day",
"docs_url": "https://datatracker.ietf.org/doc/html/rfc5545",
"compare_with": [
"FullCalendar (heavy, ~200KB)",
"react-big-calendar (React-only)",
"Luxon (date math, no UI)"
]
},
{
"id": "5.3",
"title": "CRM (Pipeline)",
"icon": "🤝",
"description": "Contacts + deals + activities + stages + weighted pipeline value. People-crm บน CodeHub ใช้พื้นฐานนี้",
"when_to_use": "Sales pipeline, freelancer client tracking, real-estate lead mgmt, partnership outreach, B2B SaaS early-stage (< 1000 contacts)",
"when_not_to_use": "Enterprise CRM (>50 seats, need territory mgmt), marketing automation (HubSpot territory), helpdesk + ticket routing (Freshdesk, Zendesk)",
"pros": [
"Pipeline stages = probability-weighted forecast (Lead 5% → Won 100%) = predict revenue ง่ายขึ้น",
"Single customer view (contact + deals + activities) = no context switching between tools",
"localStorage / IndexedDB = PII never leaves device (GDPR Art. 25 'data minimization by default')",
"Activity timeline = chronological log (email/call/meeting) ให้ context ก่อน follow-up",
"People-crm pattern: 1 contact ↔ N deals ↔ N activities = normalized schema, query ง่าย",
"Tag system (VIP, decision-maker, churned) = segmentation โดยไม่ต้องสร้าง custom field"
],
"cons": [
"PII = sensitive (email/phone/address) เก็บใน localStorage = XSS ขโมยได้ทั้งก้อน, encrypt at rest",
"GDPR/CCPA: 'right to be forgotten' = ต้องลบ contact + deals + activities ทั้งหมด 1-shot (cascade delete)",
"Email integration (Gmail/Outlook OAuth) = token refresh + scope approval + rate limit 250/day/user",
"Bulk import 1000+ contacts = duplicate detection fuzzy match (Levenshtein) ไม่งั้น DB ซ้ำซ้อน",
"Lead scoring manual rules ขยายไม่ได้ — 5+ signal ต้อง decision tree หรือ ML model",
"Multi-user 5+ = row-level lock conflict (2 คนแก้ contact เดียวกัน) ต้อง version field"
],
"gotchas": [
"Encrypt at rest: crypto.subtle.encrypt AES-GCM ด้วย PBKDF2-derived key จาก user passphrase (ไม่ใช่ password)",
"PII field mask: แสดง 'som***@gm***.com' ใน list view, full value ใน detail modal (UI pattern)",
"Cascade delete: ลบ contact → confirm modal + delete deals + activities (3 queries ใน transaction)",
"Import CSV: detect duplicate ด้วย email (lowercase + trim) ก่อน insert, แสดง 'merge?' modal",
"Activity type enum: 'call', 'email', 'meeting', 'note' — enum ไม่ใช่ free-text เพื่อ filter ทำงาน",
"Deal currency: JPY ไม่มี decimal, KWD 3 decimals — ใช้ BigInt cents แทน float (avoid 0.1+0.2 bug)",
"iOS Safari: IndexedDB persist ต้อง navigator.storage.persist() ไม่งั้น iOS ลบเมื่อ storage pressure",
"Search: client-side < 10k contacts = Fuse.js fuzzy, > 10k ต้อง server (Algolia/Meilisearch)"
],
"anti_patterns": [
"เก็บ password ใน localStorage plaintext — ใช้ Web Crypto API + PBKDF2 100k+ iterations",
"Allow export ทุก field เป็น CSV โดย default — PII leak risk, opt-in + audit log",
"ใช้ <table> แสดง 10k rows — virtualize (react-window) หรือ paginate 50/page + filter",
"Activity log = free-text textarea — enum + structured fields (outcome/duration) เพื่อ analytics",
"Build custom 'tags system' เป็น many-to-many เต็มรูปแบบ — start ใช้ comma-separated string แล้ว migrate ทีหลัง"
],
"prereqs": [
"1.3",
"1.4"
],
"next_steps": [
"5.4",
"5.7"
],
"examples": [
"people-crm",
"flowboard",
"wallet",
"mood-garden"
],
"snippet": "const STAGES=[{id:'lead',prob:.05},{id:'qualified',prob:.2},{id:'proposal',prob:.5},{id:'negotiation',prob:.7},{id:'won',prob:1}]; function pipelineValue(deals){return deals.reduce((s,d)=>s+d.amount*(STAGES.find(s=>s.id===d.stage)?.prob||0),0)}",
"snippet_full": "// People-crm style: weighted pipeline + activity timeline + encrypted at rest\\nconst STAGES = [{id:'lead',label:'Lead',prob:.05},{id:'qualified',label:'Qualified',prob:.2},{id:'proposal',label:'Proposal',prob:.5},{id:'negotiation',label:'Negotiation',prob:.7},{id:'won',label:'Won',prob:1},{id:'lost',label:'Lost',prob:0}];\\nconst crm = {contacts:[], deals:[], activities:[]};\\nfunction pipelineValue(){return crm.deals.filter(d=>d.stage!=='lost'&&d.stage!=='won').reduce((sum,d)=>sum+(d.amount*STAGES.find(s=>s.id===d.stage).prob),0);}\\nfunction wonValue(){return crm.deals.filter(d=>d.stage==='won').reduce((s,d)=>s+d.amount,0);}\\nfunction addActivity(contactId, type, note){\\n crm.activities.push({id:crypto.randomUUID(),contactId,type,note,ts:Date.now()});\\n}\\nfunction contactTimeline(id){\\n return crm.activities\\n .filter(a=>a.contactId===id)\\n .sort((a,b)=>b.ts-a.ts);\\n}\\n// Encrypt at rest demo (production: PBKDF2 100k iter + AES-GCM)\\nasync function encrypt(data, key){const iv=crypto.getRandomValues(new Uint8Array(12));const buf=new TextEncoder().encode(JSON.stringify(data));const cipher=await crypto.subtle.encrypt({name:'AES-GCM',iv},key,buf);return {iv,cipher};}",
"tags": [
"crm",
"pipeline",
"sales",
"lead",
"contact",
"gdpr",
"deal",
"people-crm"
],
"difficulty": "Intermediate",
"time_to_learn": "2 days",
"docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto",
"compare_with": [
"HubSpot (heavy, paid)",
"Notion CRM (general-purpose)",
"Twenty (open source, server needed)"
]
},
{
"id": "5.4",
"title": "Booking System",
"icon": "📆",
"description": "Slot picker + confirmation + 24h reminder + no-show handling. CodeHub มี medbook/dinebook/cospace/fitbook เป็น reference 4 ตัว",
"when_to_use": "Restaurant (dinebook), clinic (medbook), co-working (cospace), gym (fitbook), spa (glowbook), tutor, salon",
"when_not_to_use": "Multi-resource (room + therapist + equipment — conflict graph), 1000+ concurrent bookings (need queue + DB), recurring subscription booking (use Stripe Subscriptions)",
"pros": [
"Grid view 7 days × 8 slots = scan 56 options ใน 1 หน้าจอ (faster than calendar picker)",
"24h + 1h reminder (2-stage) = no-show rate ลด 35-50% (Hospitality industry data)",
"UTC storage + user TZ display = same booking shows 09:00 BKK ในไทย, 02:00 UTC ใน US",
"Conflict detection: server-side double-book check (SELECT FOR UPDATE) ก่อน INSERT",
"Web Push reminder = user ไม่ต้อง install app, แค่ allow notification ครั้งเดียว",
"Slot duration adaptive: 15/30/60 min ตาม service type (massage 60, lab 15, consult 30)"
],
"cons": [
"No-show 5-15% baseline (industry avg) — ต้อง prepay/deposit เพื่อลด, แต่ flow ซับซ้อนขึ้น",
"Time zone = ปวดหัวตลอด: server UTC, client local, DST changes ±1h, user travel across tz",
"Concurrency: 2 user จอง slot เดียวกัน = race condition, ต้อง DB lock หรือ optimistic versioning",
"Cancellation policy: refund logic (full 24h+ before, 50% 4-24h, 0% < 4h) = business rule ต่างกันทุกที่",
"Resource utilization: idle slot = lost revenue, dynamic pricing (off-peak discount) ต้อง ML/heuristic",
"Recurring booking (every Monday 9am × 12 weeks) = series + exceptions + skip-holiday logic"
],
"gotchas": [
"Web Push: ต้อง service worker + VAPID key + user gesture ก่อน subscribe, iOS 16.4+ only",
"Reminder timing: Date.now() + leadTime เป็น wall clock — server time skew ทำให้ notification เพี้ยน",
"Slot generation: query existing bookings → filter out taken → return free slots (O(n) per request)",
"Cross-day booking: spa 8pm จบ 10pm = next day, render edge case บน month view",
"Capacity > 1: yoga class 20 seats, 18 booked → 2 free, decrement atomic ไม่งั้น overbook",
"Past slot disable: เปรียบเทียบ slot < now() ก่อน render, แต่ 'today 23:00' ต้อง timezone-aware",
"Time picker 24h vs 12h: locale 'th-TH' = 24h, 'en-US' = 12h AM/PM, ใช้ Intl.DateTimeFormat",
"Email confirm: SPF/DKIM/DMARC record ของ domain = inbox ไม่ใช่ spam 100%, ตรวจก่อน go-live"
],
"anti_patterns": [
"เก็บ booking ใน localStorage — 2 device = 2 state, sync conflict, ใช้ server (Postgres/Supabase)",
"Confirm booking ทันทีหลังกด submit โดยไม่ conflict-check — double-book bug บ่อยที่สุดใน booking system",
"Reminder setTimeout 1 ชม. ก่อน — ถ้า tab close = reminder หาย, ใช้ server-side cron + push",
"ใช้ <input type='datetime-local'> เป็น slot picker — UX แย่, สร้าง grid เอง + custom click handler",
"Assume timezone จาก IP — VPN/CDN ผิด, ใช้ Intl.DateTimeFormat().resolvedOptions().timeZone จาก browser"
],
"prereqs": [
"5.2",
"1.7"
],
"next_steps": [
"5.3",
"5.5",
"5.6"
],
"examples": [
"medbook",
"dinebook",
"cospace",
"fitbook",
"glowbook",
"venuehub",
"studiobook"
],
"snippet": "const SLOTS=['09:00','10:00','11:00','12:00','13:00','14:00','15:00','16:00']; function book(slot,name,dateISO){if(bookings.find(b=>b.slot===slot&&b.date===dateISO))return 'taken'; bookings.push({slot,name,date:dateISO,ts:Date.now()}); return 'ok'}",
"snippet_full": "// medbook/cospace style: grid slot picker + UTC + double-book check + push reminder\\nconst SLOTS = ['09:00','10:00','11:00','12:00','14:00','15:00','16:00','17:00'];\\nconst DURATION_MIN = 60;\\nfunction isSlotFree(dateISO, slot) {\\n const start = new Date(`${dateISO}T${slot}:00+07:00`).getTime();\\n return !bookings.some(b =>\\n b.date === dateISO && b.slot === slot\\n );\\n}\\nasync function book({dateISO, slot, name, email}) {\\n if (!isSlotFree(dateISO, slot)) return {ok:false, err:'SLOT_TAKEN'};\\n const startTs = new Date(`${dateISO}T${slot}:00+07:00`).getTime();\\n const booking = {id: crypto.randomUUID(), date:dateISO, slot, name, email, startTs, createdAt: Date.now()};\\n bookings.push(booking);\\n // Schedule reminders (server-side cron in real app)\\n schedulePush(email, startTs - 86400000, `${name}, นัดพรุ่งนี้ ${slot}`);\\n schedulePush(email, startTs - 3600000, `${name}, นัดอีก 1 ชม.`);\\n return {ok:true, booking};\\n}\\n// Render 7-day grid\\nfor (let d = 0; d < 7; d++) {\\n const date = new Date(Date.now() + d*86400000).toISOString().slice(0,10);\\n for (const slot of SLOTS) {\\n if (isSlotFree(date, slot)) renderSlotButton(date, slot);\\n }\\n}",
"tags": [
"booking",
"appointment",
"นัดหมาย",
"slot",
"reminder",
"medbook",
"dinebook",
"timezone"
],
"difficulty": "Intermediate",
"time_to_learn": "3 hours",
"docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/Push_API",
"compare_with": [
"Cal.com (open source, heavy)",
"Calendly (paid, vendor lock)",
"Spine (medical only)"
]
},
{
"id": "5.5",
"title": "POS (Point of Sale)",
"icon": "💳",
"description": "Cart + barcode scan + tax/VAT per region + receipt print/PDF + cash/card prompt. Noodle-shop + pos-grocery เป็น reference บน CodeHub",
"when_to_use": "Restaurant table-side order, retail checkout, kiosk self-order, food truck, pop-up store, noodle-shop / pos-grocery เป็น 2 ตัวอย่างจริง",
"when_not_to_use": "E-commerce online (need shipping/tax/shipping calc), wholesale B2B (PO + net terms), high-volume supermarket (need scale + barcode hardware spec)",
"pros": [
"Cart เก็บ localStorage = tab crash ไม่เสีย order (recovery 1-click restore)",
"Barcode input via <input> + 'rapid mode' = 60-80 items/min (faster than touch screen picker)",
"Tax per region: TH 7% VAT, US state tax (5-10%), EU 19% MwSt = lookup table ไม่ใช่ hardcode",
"Receipt 2 output: thermal printer (ESC/POS 58/80mm) + PDF email backup = redundancy",
"Offline-first = ต่อเน็ตหลุดก็ขายได้, sync queue เมื่อกลับมา online (Supabase/Firebase)",
"Keyboard shortcut: F1=search, F2=pay-cash, F3=pay-card, F12=close-shift = cashier speed +40%"
],
"cons": [
"Tax calculation ผิด = legal liability (audit) — เก็บ rate + rounding rule + tax ID ต่อ transaction",
"Hardware integration: barcode scanner = HID keyboard input (ต้อง focus <input>), receipt printer = WebUSB/Serial",
"PCI DSS scope: รับบัตรเครดิต = tokenize ผ่าน Stripe/Omise (ห้ามเก็บ PAN ใน localStorage เด็ดขาด)",
"Refund/exchange: original transaction id lookup + negative line items + restock (inventory sync)",
"Shift close: cash count vs system total = ±฿5 acceptable, ±฿100 = investigation (audit trail)",
"Offline sync conflict: 2 cashier same product sale while offline = negative stock, alert on sync"
],
"gotchas": [
"iOS 17+: WebUSB block ใน Safari (เฉพาะ Chrome Android/Desktop) — fallback Bluetooth ESC/POS printer",
"Tax rounding: TH 7% on 99.50 = 6.965 → display 6.97 vs round-down 6.96 (POS choice), เลือก 1 วิธีทั้งระบบ",
"Receipt font: thermal printer 58mm = 32 chars/line, 80mm = 42 chars/line, word-wrap ตามนี้",
"Cash payment change: amount - sum(items) แสดง realtime, ป้องกัน over-tender typo",
"Card payment flow: tap → wait → print slip → 'approved?' confirm → receipt (timeout 30s = cancel)",
"Tip screen: 15/18/20/25% preset + custom, หลัง payment success, ก่อน receipt (US/UK common)",
"Discount: line-level (item 10% off) vs cart-level (cart 100฿ off) vs BOGO — apply ตามลำดับ",
"Audit log: every cart add/remove/price change เก็บ + user id + ts (7 years retention ในบางประเทศ)"
],
"anti_patterns": [
"เก็บ credit card PAN ใน localStorage — PCI DSS violation, fine $5k-100k/mo, ใช้ Stripe Element/Omise tokenize",
"Float money = Number — 0.1 + 0.2 = 0.30000000000000004 bug, ใช้ integer cents (satang) หรือ Dinero.js",
"Print receipt แล้วลบ cart ทันที — printer fail = no receipt, await print ack แล้วค่อย clear",
"Re-use same order id ใน refund — generate new id + 'originalRef: xyz' link, เก็บ lineage ตลอด",
"Use <form> submit reload page เพื่อ confirm payment — SPA + optimistic UI + rollback on error"
],
"prereqs": [
"5.6",
"5.9"
],
"next_steps": [
"5.6",
"5.9"
],
"examples": [
"noodle-shop",
"pos-grocery",
"flowboard"
],
"snippet": "class POS{constructor(){this.cart=[]}add(item,qty=1){const e=this.cart.find(c=>c.id===item.id);if(e)e.qty+=qty;else this.cart.push({...item,qty})} total(taxRate=0.07){const sub=this.cart.reduce((s,c)=>s+c.price*c.qty,0);return{sub,tax:sub*taxRate,total:sub*(1+taxRate)}} pay(method){const t=this.total();saveReceipt({ts:Date.now(),items:[...this.cart],...t,method});this.cart=[]}}",
"snippet_full": "// noodle-shop / pos-grocery style: cart + Thai VAT + receipt\\nclass POS {\\n constructor(taxRate = 0.07) { this.cart = []; this.taxRate = taxRate; }\\n add(item, qty = 1) {\\n const ex = this.cart.find(c => c.id === item.id);\\n if (ex) ex.qty += qty;\\n else this.cart.push({...item, qty});\\n this.persist();\\n }\\n total() {\\n const sub = this.cart.reduce((s, c) => s + c.price * c.qty, 0);\\n const tax = Math.round(sub * this.taxRate * 100) / 100;\\n return { sub, tax, total: Math.round((sub + tax) * 100) / 100 };\\n }\\n persist() { localStorage.setItem('cart', JSON.stringify(this.cart)); }\\n async pay(method = 'cash', tendered = 0) {\\n const t = this.total();\\n const order = {\\n id: crypto.randomUUID(), ts: Date.now(),\\n items: [...this.cart], ...t, method, tendered,\\n change: method === 'cash' ? tendered - t.total : 0\\n };\\n await db.orders.add(order);\\n this.cart = []; this.persist();\\n return order;\\n }\\n}\\nconst pos = new POS(0.07);\\npos.add({id:'n1', name:'ก๋วยเตี๋ยว', price:50});\\nconst receipt = await pos.pay('cash', 100);\\nconsole.log(receipt.change); // 50 - 3.5 (tax) = 46.50",
"tags": [
"pos",
"cart",
"ขาย",
"vat",
"receipt",
"tax",
"noodle-shop",
"pos-grocery",
"checkout"
],
"difficulty": "Intermediate",
"time_to_learn": "6 hours",
"docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/Web_USB_API",
"compare_with": [
"Square (closed, paid)",
"Stripe Terminal (cloud-first)",
"Vend (heavy, multi-store)"
]
},
{
"id": "5.6",
"title": "Inventory (SKU + Stock Alert)",
"icon": "📦",
"description": "SKU + quantity + reorder point + low-stock alert + multi-location. Pos-grocery + noodle-shop ใช้พื้นฐานนี้ในการจัดการ stock",
"when_to_use": "Retail store, warehouse mgmt, restaurant ingredient tracking, e-commerce fulfillment, pos-grocery ingredient + product",
"when_not_to_use": "Manufacturing BOM (need multi-level explode), serialized lot tracking (pharma/food expiry), consignment (3rd-party owned stock)",
"pros": [
"Reorder point = avg_daily_sales × lead_time_days + safety_stock = auto trigger PO ก่อน stockout",
"SKU encoding: 'CAT-COL-SIZE' (category-color-size) = sort + filter ง่าย, barcode prefix map 1-1",
"Low-stock alert ผ่าน Web Push/email = proactive ไม่ใช่ reactive (หลังของหมด)",
"Multi-location (warehouse/store) = transfer stock in/out, inter-location balance",
"Stocktake: physical count vs system = variance report (shrinkage/theft tracking)",
"Batch/expiry tracking = FEFO (first-expiry-first-out) ใน food/pharma (ลดของเสีย)"
],
"cons": [
"Race condition: 2 cashier deduct same item = negative stock, ต้อง atomic UPDATE ... WHERE qty >= n",
"Multi-location sync: transfer A→B ต้อง transaction 2-step พร้อม rollback ถ้า fail ฝั่งหนึ่ง",
"FIFO/LIFO/Average cost: เลือก 1 วิธีต่อ org เปลี่ยนยาก, COGS calculation ต้อง consistent",
"Negative stock prevention: ตรวจก่อน deduct แต่ถ้า offline 2 cashier = bypass, reconcile ตอน sync",
"Barcode collision: 2 product share barcode (supplier mistake) = scanner ambiguity, manual override",
"Scale: 100k SKU = full table scan ช้า, ใช้ index (sku, location, expiry) + materialized view"
],
"gotchas": [
"Atomic decrement: SQL `UPDATE stock SET qty = qty - 1 WHERE sku=? AND qty > 0` — affected rows = 0 = out of stock",
"Reorder point formula: (avg_30d × lead_time_days) + (1.65 × stddev_daily) = 95% service level",
"Stock adjustment reason code: 'recount', 'damage', 'theft', 'expired' = audit trail (ตาม ภ.พ.บัญชี)",
"Currency cost = average moving: (old_qty × old_cost + new_qty × new_cost) / total_qty (perpetual avg)",
"Negative prevention in JS: check qty >= n ใน transaction ไม่พอ, server-side check authoritative",
"iOS: barcode via camera ใช้ BarcodeDetector API + ZXing-js fallback (iOS 17+ native support)",
"Expiry: FEFO sort expiry ASC, pick min expiry, alert 30/7/1 day before expired",
"Offline queue: cart.add() เก็บใน IndexedDB เป็น 'pending txn' → sync เมื่อ online → resolve conflict"
],
"anti_patterns": [
"เก็บ stock ใน localStorage แล้ว multi-cashier = state divergence, ใช้ server (Postgres + RLS) เป็น source of truth",
"Sync stock ทันทีที่ add to cart (reserve) — UX ดี แต่ timeout/cancel ต้อง release (cron job)",
"Reorder alert แบบ 'qty < 10' fixed — seasonal item (ice cream winter) = overstock, ใช้ moving average",
"Track cost เป็น float — 0.1+0.2 bug, ใช้ BigInt satang (TH) หรือ integer cents (USD)",
"Show 'In stock' label ตอน qty=0 — explicit 'Out of stock' + hide 'Add to cart' button"
],
"prereqs": [
"1.3",
"1.4"
],
"next_steps": [
"5.5",
"5.9"
],
"examples": [
"pos-grocery",
"noodle-shop",
"flowboard"
],
"snippet": "class Inventory{remove(sku,qty){const item=items.get(sku);if(!item||item.qty<qty)return{ok:false,err:'OOS'};item.qty-=qty;if(item.qty<item.reorderPoint)notify(`Low stock: ${item.name} (${item.qty})`);return{ok:true}}}",
"snippet_full": "// pos-grocery style: SKU + reorder point + low-stock + atomic decrement\\nconst items = new Map([\\n ['SKU-001', {name:'น้ำมันพืช 1L', qty:50, reorderPoint:20, cost:45, location:'A-01'}],\\n ['SKU-002', {name:'ข้าวสาร 5kg', qty:8, reorderPoint:15, cost:120, location:'A-02'}],\\n]);\\nconst lowStockAlerts = [];\\nfunction remove(sku, qty) {\\n const item = items.get(sku);\\n if (!item) return {ok:false, err:'NOT_FOUND'};\\n if (item.qty < qty) return {ok:false, err:'OUT_OF_STOCK'};\\n item.qty -= qty;\\n if (item.qty <= item.reorderPoint) {\\n lowStockAlerts.push({sku, qty:item.qty, ts:Date.now()});\\n notify(`⚠️ Low stock: ${item.name} (${item.qty} left, reorder ≤${item.reorderPoint})`);\\n }\\n return {ok:true, remaining:item.qty};\\n}\\n// Server-side atomic version (Postgres)\\n// UPDATE inventory SET qty = qty - $1 WHERE sku = $2 AND qty >= $1 RETURNING qty;\\n// If 0 rows returned -> OUT_OF_STOCK",
"tags": [
"inventory",
"sku",
"stock",
"คลัง",
"reorder",
"pos-grocery",
"low-stock",
"alert"
],
"difficulty": "Intermediate",
"time_to_learn": "3 hours",
"docs_url": "https://www.postgresql.org/docs/current/sql-update.html",
"compare_with": [
"Sortly (paid SaaS)",
"inFlow (desktop only)",
"Zoho Inventory (full ERP)"
]
},
{
"id": "5.7",
"title": "Form Builder",
"icon": "📝",
"description": "User-defined schema (fields array) → render form + validate + save JSON. Schema-as-data = no-code สำหรับ non-tech user",
"when_to_use": "Survey, configurable admin form, dynamic intake (medbook pre-screening), quiz/exam builder, lead-gen form, knowledge-garden schema",
"when_not_to_use": "Fixed forms (login, signup — overkill), 50+ field types (PDF AcroForm territory), conditional UI heavy (use React Hook Form + Zod schema)",
"pros": [
"Schema as JSON = version control friendly (git diff), export/import, clone form",
"Validation rules ใน schema (required, min, max, regex) = 1 source of truth, render + validate ใช้กฎเดียวกัน",
"Field types: text/email/number/date/select/radio/checkbox/file/signature = 9 types ครอบ 90% use case",
"Drag-drop field reordering = native HTML5 DnD หรือ react-beautiful-dnd (~50KB)",
"Conditional logic: 'show field B if A === \"yes\"' = schema rule, render evaluation in 5 lines",
"JSON export → Google Forms / Typeform alternative = self-hosted, no per-response fee"
],
"cons": [
"Conditional logic ซับซ้อน: A && (B || C) → !D = parser เอง 50+ LOC, ใช้ json-rules-engine (~80KB)",
"Field type จำกัด: ไม่มี rich text/multi-step/calculated field ใน 1-day build, ต้อง extension point",
"Validation 2 ชั้น: client (UX ทันที) + server (authoritative) — ห้ามพึ่ง client เดียว",
"File upload: schema declared แต่ storage (S3/Supabase Storage) ต้อง wiring เพิ่ม แยก",
"Multi-page form = state mgmt ยากขึ้น (state เก็บไหน ระหว่าง page), context API หรือ store",
"i18n: field label/error message = 2-3 ภาษา = schema ใหญ่ขึ้น 2-3x, ใช้ i18n key + lookup table"
],
"gotchas": [
"Unique field name: enforce ใน builder UI ก่อน save (duplicate name = render conflict)",
"Field id = stable key (uuid) ไม่ใช่ label — เปลี่ยน label ในภายหลัง ไม่ break submission data",
"Validation timing: onBlur (per field) + onSubmit (all) — onChange รำคาญ user",
"Required + initial empty: ใช้ empty string check ไม่ใช่ null check (form input default)",
"Date input: <input type='date'> return 'YYYY-MM-DD' (no TZ) ≠ Date object, เก็บ string หรือ parse เป็น UTC",
"Signature canvas: Pointer Events ต้อง touch+mouse, pressure ไม่ support (iOS Safari quirk)",
"File upload preview: URL.createObjectURL(file) — revoke เมื่อ unmount มิงั้น memory leak",
"Submission quota: form 100k response = DB 1GB+ (ถ้าเก็บ JSON blob), ใช้ normalized table แทน"
],
"anti_patterns": [
"Render form ด้วย eval('if ('+condition+') ...') — XSS vector, ใช้ json-rules-engine หรือ custom AST",
"Save form schema เป็น JavaScript object literal ใน code — version control nightmare, JSON ใน .json file",
"Validate ฝั่ง client อย่างเดียว — DevTools bypass ได้, server validate เสมอ (Zod, Joi, Yup)",
"เก็บ submission เป็น JSON blob ใน 1 column — query/aggregate ทำไม่ได้, ใช้ normalized table (field_value ต่อ row)",
"ใช้ innerHTML render user-supplied field label — XSS, ใช้ textContent หรือ DOMPurify sanitize"
],
"prereqs": [
"1.3",
"1.4"
],
"next_steps": [
"5.3",
"5.10"
],
"examples": [
"flowboard",
"knowledge-garden",
"people-crm",
"mood-garden",
"habit-streak"
],
"snippet": "const TYPES={text:{render:f=>`<input name='${f.id}' type='text'>`},select:{render:f=>`<select name='${f.id}'>${f.options.map(o=>`<option value='${o}'>${o}</option>`).join('')}</select>`}}; function renderForm(s){return s.fields.map(f=>`<label>${f.label}${TYPES[f.type].render(f)}</label>`).join('')}",
"snippet_full": "// FlowBoard / knowledge-garden style: schema-driven form + validation\\nconst schema = {\\n id: 'intake-v1',\\n fields: [\\n {id:'name', label:'ชื่อ-นามสกุล', type:'text', required:true, minLength:2},\\n {id:'age', label:'อายุ', type:'number', required:true, min:1, max:120},\\n {id:'topic', label:'หัวข้อ', type:'select', options:['ทั่วไป','ติดตาม','ฉุกเฉิน']},\\n {id:'consent', label:'ยินยอม', type:'checkbox', required:true},\\n ]\\n};\\nfunction validate(values) {\\n const errs = {};\\n for (const f of schema.fields) {\\n const v = values[f.id];\\n if (f.required && !v) errs[f.id] = 'จำเป็นต้องระบุ';\\n if (f.type === 'number' && v != null && (v < f.min || v > f.max)) errs[f.id] = `${f.min}-${f.max}`;\\n }\\n return errs;\\n}\\nfunction render(values = {}) {\\n return schema.fields.map(f => {\\n const v = values[f.id] || '';\\n const input = f.type === 'select'\\n ? `<select name=\"${f.id}\">${f.options.map(o=>`<option ${o===v?'selected':''}>${o}</option>`).join('')}</select>`\\n : f.type === 'checkbox'\\n ? `<input type=\"checkbox\" name=\"${f.id}\" ${v?'checked':''}>`\\n : `<input type=\"${f.type}\" name=\"${f.id}\" value=\"${v}\">`;\\n return `<label>${f.label} ${input}</label>`;\\n }).join('');\\n}",
"tags": [
"form",
"builder",
"ฟอร์ม",
"schema",
"no-code",
"validation",
"dynamic",
"survey"
],
"difficulty": "Intermediate",
"time_to_learn": "3 hours",
"docs_url": "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input",
"compare_with": [
"Form.io (heavy enterprise)",
"SurveyJS (paid)",
"react-hook-form (React-only)"
]
},
{
"id": "5.8",
"title": "Dashboard Charts (ECharts)",
"icon": "📊",
"description": "ECharts interactive (line/bar/pie/scatter/heatmap) + responsive + a11y palette. Ai-data-analyzer ใช้พื้นฐานนี้",
"when_to_use": "Admin dashboard, KPI visualization, sales report, data exploration, ai-data-analyzer + flowboard analytics",
"when_not_to_use": "Print-only chart (PDF report — static SVG), 10k+ points streaming (D3 + canvas), sparkline inline (custom SVG <50 LOC)",
"pros": [
"ECharts ~1MB gzipped = 30+ chart types (line/bar/pie/scatter/heatmap/sankey/treemap) ใน bundle เดียว",
"Responsive default: resize ไม่ต้อง setOption ใหม่, container query ใช้ได้",
"Color-blind safe palette (ColorBrewer) = a11y + สวย, contrast ratio 4.5:1 ผ่าน WCAG AA",
"Data zoom + brush select = interactive exploration (zoom range, select subset, drill-down)",
"Tooltips rich: HTML content, formatter function, dynamic per series (currency/percent/ratio)",
"Theme JSON: switch dark/light 1 line (echarts.init(el, 'dark')), brand color override"
],
"cons": [
"ECharts 1MB gzipped หนักกว่า Chart.js (~150KB) — ใช้ light build หรือ import เฉพาะ type ที่ใช้",
"Accessibility: ECharts = canvas-based, screen reader ไม่อ่าน (ต้อง aria-label + data table alternative)",
"Performance 1k+ series points: downsample ก่อน render (LTTB algorithm) หรือ dataZoom default = 1st 100",
"Mobile touch: tooltip ตามนิ้ว แต่ pinch-zoom ต้อง enable: {x: true, y: false} เพื่อ UX ดี",
"3D charts: ใช้ WebGL แต่ performance drop บน mobile 5-10x เทียบ 2D",
"Export PNG/SVG: client-side ใช้ echarts.getDataURL() แต่ resolution = screen, PDF = ink scale"
],
"gotchas": [
"Resize observer: ECharts ไม่ auto-detect sidebar toggle, wrap container ใน ResizeObserver",
"Color palette: ใช้ colorblind-safe อย่าง Wong/Okabe-Ito (8 colors) แทน rainbow default",
"Tooltips: trigger='axis' สำหรับ multi-series, 'item' สำหรับ single point, เลือกตาม use case",
"Animation: animationDuration 800ms default, ปิด 0 เมื่อ redraw บ่อย (real-time dashboard)",
"Stacked bar: stack: 'total' ทุก series ในกลุ่มเดียวกัน, ข้าม series = grouped bar แทน",
"Negative value: y-axis 'dataMin' ไม่ใช่ 0, ใช้ yAxis.min = 'dataMin' (auto-fit)",
"Time axis: xAxis.type='time' + ISO string, ห้าม Date object (timezone bug)",
"iOS Safari: canvas chart ไม่ support backdrop-filter blur — ใช้ solid background tooltip"
],
"anti_patterns": [
"Render chart ใหม่ทั้ง option ทุก data update — merge เฉพาะ series.data ใช้ setOption({series:[{data:newData}]}, {notMerge:false})",
"ใช้ 3D pie chart — 2D pie + label better, 3D distort perception (most data viz research)",
"Pie chart > 5 slices — ใช้ bar chart แทน, pie ใช้ได้ 2-3 categories เท่านั้น",
"Skip axis label (อ่านยาก) เพื่อ 'สวย' — label every nth + tooltip detail, a11y > aesthetic",
"Load ECharts ทั้ง bundle — ใช้ tree-shake import (import * as echarts from 'echarts/core'; import {BarChart} from 'echarts/charts')"
],
"prereqs": [
"1.7"
],
"next_steps": [
"5.10"
],
"examples": [
"ai-data-analyzer",
"flowboard",
"pos-grocery",
"mood-garden"
],
"snippet": "const chart=echarts.init(document.getElementById('chart')); chart.setOption({xAxis:{type:'category',data:['Mon','Tue','Wed']},yAxis:{type:'value'},series:[{data:[120,200,150],type:'bar',itemStyle:{color:'#10b981'}}]}); new ResizeObserver(()=>chart.resize()).observe(document.getElementById('chart'));",
"snippet_full": "// ai-data-analyzer style: ECharts bar + line combo + responsive + tooltip\\nimport * as echarts from 'echarts/core';\\nimport {BarChart,LineChart} from 'echarts/charts';\\nimport {GridComponent,TooltipComponent,LegendComponent,DataZoomComponent} from 'echarts/components';\\nimport {CanvasRenderer} from 'echarts/renderers';\\necharts.use([BarChart,LineChart,GridComponent,TooltipComponent,LegendComponent,DataZoomComponent,CanvasRenderer]);\\nconst chart = echarts.init(document.getElementById('chart'), null, {renderer:'canvas'});\\nchart.setOption({\\n color: ['#10b981','#6366f1','#f59e0b'],\\n tooltip: {trigger:'axis', axisPointer:{type:'shadow'}},\\n legend: {data:['Revenue','Orders']},\\n xAxis: {type:'category', data:['Mon','Tue','Wed','Thu','Fri','Sat','Sun']},\\n yAxis: [{type:'value', name:'฿'},{type:'value', name:'Orders'}],\\n series: [\\n {name:'Revenue', type:'bar', data:[12400,18200,15600,22100,28400,35300,29800], yAxisIndex:0},\\n {name:'Orders', type:'line', data:[42,58,49,71,92,118,97], yAxisIndex:1, smooth:true}\\n ]\\n});\\nnew ResizeObserver(() => chart.resize()).observe(chart.getDom());\\nwindow.addEventListener('beforeunload', () => chart.dispose());",
"tags": [
"chart",
"echarts",
"dashboard",
"กราฟ",
"visualization",
"responsive",
"analytics"
],
"difficulty": "Beginner",
"time_to_learn": "30 min",
"docs_url": "https://echarts.apache.org/en/option.html",
"compare_with": [
"Chart.js (lighter, simpler)",
"D3.js (low-level, flexible)",
"Plotly.js (3D, scientific)",
"Recharts (React-only)"
]
},
{
"id": "5.9",
"title": "Invoice (PDF)",
"icon": "🧾",
"description": "jsPDF client-side (zero install) + Thai font embed (Sarabun) + line items + tax + auto-numbering. Pos-grocery ใช้ receipt pattern เดียวกัน",
"when_to_use": "Freelancer billing, SMB invoice, retail receipt, e-ticket, contract, pos-grocery receipt, certificate of completion",
"when_not_to_use": "Recurring billing (Stripe Subscriptions / Omise), multi-page legal contract (use DocuSign), tax compliance 100% (need certified accounting software)",
"pros": [
"jsPDF client-side = no server cost, GDPR-friendly (data ไม่ออกจาก browser), offline-capable",
"Thai/VI/ZH/JA/KR font embed base64 = ใบเสร็จ/ใบแจ้งหนี้ภาษา local render ถูกต้อง",
"PDF is final-form: recipient เปิดใน Adobe/Preview/browser ได้, ไม่ต้อง install",
"Auto-numbering INV-2024-001 → INV-2024-002 = watermark + sequential + year prefix",
"Tax breakdown: subtotal + VAT line (7% TH, 19% DE) + total = audit-ready, ไม่ใช่แค่ราคารวม",
"QR code payment (PromptPay TH): jsPDF + qrcode.js = scan จ่ายได้ทันที (1-2 second transfer)"
],
"cons": [
"Email deliverability: invoice PDF เข้า spam 50%+ ถ้า SPF/DKIM/DMARC ไม่ตั้ง — ใช้ SendGrid/Resend",
"CJK font base64 = 1-3MB inline → PDF ใหญ่ขึ้น, แต่ละ font subset ต้อง license (Sarabun OFL ok)",
"jsPDF no CSS engine: ต้องคำนวณ position เอง (x, y, fontSize), ไม่มี flexbox/grid",
"Recurring monthly invoice: client-side generate 12 PDF ต่อเดือน = UX ลำบาก, ใช้ Stripe/Omise subscription",
"Late fee / interest calc: business rule แต่ละที่ต่างกัน, ต้อง config layer",
"Digital signature: PDF เซ็นชื่อ legally = X.509 cert + pkcs12 (server-side sign), ไม่ใช่ jsPDF"
],
"gotchas": [
"Thai font broken: jsPDF default Helvetica ไม่รองรับ unicode ภาษาไทย, embed Sarabun/Noto Sans TH base64",
"CORS: external image (logo) ต้อง fetch → toDataURL ก่อน addImage มิงั้น canvas taint",
"jsPDF unit: 'mm' (default 'pt') — A4 = 210×297mm, ใช้ mm อ่านง่ายกว่า pixel",
"Page break: invoice 20+ items = overflow 1 page, เช็ค y > 280mm แล้ว doc.addPage()",
"Font subset: โหลด Sarabun เต็ม 3MB เพื่อ 50 คำ = สิ้นเปลือง, ใช้ pyftsubset ตอน build",
"QR code size: 25×25mm = scan ได้ทันที, 20mm = scan fail บน iPhone บางรุ่น",
"Tax rounding: 99.50 × 1.07 = 106.465 → display 106.47 vs 106.46 = เลือก rounding mode 1 ที่เดียว",
"iOS 17 Mail preview: PDF เปิด inline ถ้า < 5MB, > 5MB = download prompt (size cap)"
],
"anti_patterns": [
"Upload HTML ไป server, render ด้วย Puppeteer, ส่ง PDF link — slower, server cost, no offline",
"Embed base64 ใน HTML แล้ว browser print to PDF — ไม่มี line items table, format ไม่ consistent",
"Generate invoice แล้ว save as Blob URL ตลอดไป — URL.revokeObjectURL หลัง download เพื่อ free memory",
"Use Times New Roman default = ไม่ render ภาษาไทย, embed Sarabun ก่อน text() เสมอ",
"Re-generate PDF ทุกครั้งที่ view = cache (IndexedDB blob) ครั้งแรก, serve cache ครั้งต่อไป"
],
"prereqs": [
"1.7",
"5.5"
],
"next_steps": [
"5.3"
],
"examples": [
"pos-grocery",
"noodle-shop",
"people-crm"
],
"snippet": "const doc=new jsPDF({unit:'mm',format:'a4'}); doc.addFileToVFS('Sarabun.ttf',sarabunB64); doc.addFont('Sarabun.ttf','Sarabun','normal'); doc.setFont('Sarabun'); doc.text('ใบเสร็จรับเงิน',20,20); doc.save(`inv-${Date.now()}.pdf`);",
"snippet_full": "// pos-grocery / freelance-invoice style: jsPDF + Thai Sarabun + QR PromptPay + line items\\nimport jsPDF from 'jspdf';\\nimport QRCode from 'qrcode';\\nimport sarabunB64 from './Sarabun-Regular.base64.txt';\\nasync function generateInvoice({orderId, items, customer, promptPayId}) {\\n const doc = new jsPDF({unit:'mm', format:'a4'});\\n doc.addFileToVFS('Sarabun.ttf', sarabunB64);\\n doc.addFont('Sarabun.ttf','Sarabun','normal');\\n doc.setFont('Sarabun');\\n doc.setFontSize(20); doc.text('ใบเสร็จรับเงิน / Receipt', 20, 20);\\n doc.setFontSize(11);\\n doc.text(`เลขที่ / No: INV-${orderId}`, 20, 30);\\n doc.text(`วันที่ / Date: ${new Date().toLocaleDateString('th-TH')}`, 20, 37);\\n doc.text(`ลูกค้า / Customer: ${customer.name}`, 20, 44);\\n let y = 60;\\n doc.setFontSize(10);\\n items.forEach((it, i) => {\\n doc.text(`${i+1}. ${it.name} x${it.qty}`, 20, y);\\n doc.text(`${(it.price*it.qty).toFixed(2)} ฿`, 160, y, {align:'right'});\\n y += 7;\\n });\\n const sub = items.reduce((s,i)=>s+i.price*i.qty,0);\\n const vat = sub * 0.07;\\n y += 5;\\n doc.text(`Subtotal: ${sub.toFixed(2)} ฿`, 130, y); y += 6;\\n doc.text(`VAT 7%: ${vat.toFixed(2)} ฿`, 130, y); y += 6;\\n doc.setFontSize(14); doc.text(`Total: ${(sub+vat).toFixed(2)} ฿`, 130, y);\\n // QR PromptPay\\n const qrDataUrl = await QRCode.toDataURL(`000201010211...${promptPayId}...${(sub+vat).toFixed(2)}5303764540`);\\n doc.addImage(qrDataUrl, 'PNG', 150, y+10, 35, 35);\\n doc.save(`inv-${orderId}.pdf`);\\n}",
"tags": [
"invoice",
"pdf",
"ใบเสร็จ",
"receipt",
"jspdf",
"thai",
"sarabun",
"promptpay",
"qr"
],
"difficulty": "Intermediate",
"time_to_learn": "2 hours",
"docs_url": "https://artskydj.github.io/jsPDF/docs/",
"compare_with": [
"pdfmake (declarative, larger)",
"pdf-lib (edit existing)",
"react-pdf (React-only)",
"Puppeteer (server, full HTML)"
]
},
{
"id": "5.10",
"title": "Analytics",
"icon": "📈",
"description": "Capture events + funnels + retention + cohort + anonymize IP + batched send. Ai-data-analyzer ใช้ self-hosted analytics เป็น layer หนึ่ง",
"when_to_use": "Product analytics, marketing site conversion funnel, A/B test results, feature adoption tracking, SaaS retention curve",
"when_not_to_use": "Embedded analytics (customer-facing dashboard = Metabase/Preset), real-time sub-second (need Kafka + ClickHouse), mobile app (use Mixpanel/Amplitude SDK)",
"pros": [
"Funnel analysis: signup → activate → purchase = conversion % แต่ละ step, drop-off insight",
"Retention cohort: week-0 100% → week-4 35% = visualize churn ใน heatmap (ผู้ใช้ return แค่ไหน)",
"Event batching (10 events ต่อ 1 request) = 10x network efficiency vs fire-each-event",
"Anonymize IP: strip last octet (192.168.1.xxx) = GDPR Art. 5 'data minimization' compliant",
"Self-hosted (PostHog/Plausible) = ไม่ต้องพึ่ง GA4 cookie banner, EU data residency",
"Funnel + cohort + path = 3 query pattern ครอบ 80% product decision making"
],
"cons": [
"GDPR cookie banner: GA4/Facebook Pixel ใช้ cookie = EU ต้อง consent (opt-in), โอกาส 30% user decline",
"Data warehouse cost: 100M events/month = $200-500/mo (BigQuery/Snowflake), ใช้ ClickHouse self-host ถูกกว่า 5x",
"Real-time (sub-second) = Kafka + ClickHouse + materialized view, ไม่ใช่ Postgres + cron",
"Privacy: fingerprinting (canvas + audio + fonts) ผิด GDPR — ใช้ server-set first-party cookie เท่านั้น",
"Funnel re-order: user ข้ามขั้น (signup → purchase โดยไม่ผ่าน onboarding) = funnel undercount",
"Identity stitching: anonymous → user after login = event merge heuristic (IP+UA+session) ไม่แม่น 100%"
],
"gotchas": [
"Send on beforeunload: fetch with keepalive:true ไม่ cancel เมื่อ tab close (event ไม่หาย)",
"Batch size: 10-20 events ต่อ 1 POST = balance latency vs request count (10 = 100KB, 1 = 10KB)",
"Anonymize IP client-side: ip.split('.').slice(0,3).join('.') + '.0' (IPv4 only, IPv6 ต้อง /48 mask)",
"Session ID: crypto.randomUUID() เก็บ sessionStorage (ลบเมื่อ tab close) = 1 session = 1 funnel entry",
"User ID หลัง login: set(userId) merge anonymous + identified events (PostHog distinct_id pattern)",
"Sampling: 100% event ตอน dev, 10% production (เก็บ sample rate flag ใน event meta)",
"Time zone: event.ts = Date.now() UTC ms, แสดง user TZ ใน dashboard (ไม่เก็บ local)",
"Schema migration: เพิ่ม property ใหม่ต้อง default null ใน query เก่า (no breaking change)"
],
"anti_patterns": [
"Track every click — noisy, ใช้ auto-track เฉพาะ data-attribute (data-track='cta-click') explicit",
"Block analytics script ด้วย cookie gate ก่อน consent — script load delay = first event miss 100%",
"ใช้ Google Analytics แล้ว assume 'anonymous IP' = GDPR safe — GA4 still need consent, EU DPA ฟ้องได้",
"Fire event ผ่าน setInterval flush ทุก 1s — burst 100 events = 100 req, batch 10 = 10 req",
"เก็บ PII (email, name) ใน event property — anonymize เฉพาะ aggregate metric, ไม่ raw event"
],
"prereqs": [
"1.7"
],
"next_steps": [
"5.8"
],
"examples": [
"ai-data-analyzer",
"flowboard",
"mood-garden",
"habit-streak"
],
"snippet": "function track(event,props={}){queue.push({event,props,ts:Date.now(),anonId:getAnonId()});if(queue.length>=10)flush()} window.addEventListener('beforeunload',()=>{navigator.sendBeacon('/api/event',new Blob([JSON.stringify(queue)],{type:'application/json'}))});",
"snippet_full": "// Self-hosted analytics: event batch + anonymize IP + sendBeacon\\nconst queue = [];\\nconst sessionId = sessionStorage.getItem('sid') || (() => {\\n const id = crypto.randomUUID();\\n sessionStorage.setItem('sid', id);\\n return id;\\n})();\\nfunction anonymizeIP(ip) {\\n if (!ip) return null;\\n if (ip.includes(':')) return ip.split(':').slice(0,3).join(':') + ':0:0:0:0:0:0';\\n return ip.split('.').slice(0,3).join('.') + '.0';\\n}\\nfunction track(event, props = {}) {\\n queue.push({event, props, ts:Date.now(), sid:sessionId});\\n if (queue.length >= 10) flush();\\n}\\nasync function flush() {\\n if (queue.length === 0) return;\\n const batch = queue.splice(0, queue.length);\\n const blob = new Blob([JSON.stringify({events:batch})], {type:'application/json'});\\n if (navigator.sendBeacon) {\\n navigator.sendBeacon('/api/events', blob);\\n } else {\\n await fetch('/api/events', {method:'POST', body:blob, keepalive:true});\\n }\\n}\\nwindow.addEventListener('beforeunload', flush);\\nsetInterval(flush, 30000); // 30s safety net\\n// Funnel: track each step\\n['view_landing','click_signup','complete_form','verify_email','first_purchase'].forEach(step => {\\n track(step, {utm: new URLSearchParams(location.search).get('utm_source')});\\n});",
"tags": [
"analytics",
"event",
"funnel",
"cohort",
"gdpr",
"tracking",
"ai-data-analyzer",
"retention"
],
"difficulty": "Intermediate",
"time_to_learn": "2 hours",
"docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon",
"compare_with": [
"PostHog (self-host OSS)",
"Plausible (privacy-first paid)",
"GA4 (free, cookie banner)",
"Mixpanel (mobile-focused paid)"
]
}
]
}