Phase 5 of 10

💼 Business Apps

Productivity + commerce

10 sub-phases
60 pros
60 cons
80 gotchas
50 anti-patterns
11590 chars code
5.1

📋 Kanban Board

Intermediate⏱ 4 hours

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 (6)
• 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 (6)
• 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 (8)
• 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 เสร็จ
🔀 Alternatives
vs Trello (closed, paid)
vs Notion (heavy, multi-tool)
vs Jira (overkill, dev-focused)
📚 Prereqs: 1.3, 1.4
➡️ Next: 5.3, 5.7
💻 Minimal (275 chars) ▶ Full example (1036 chars)
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();});
// 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  });\
});\nrender();
kanbandrag-dropบอร์ดtaskflowboardwipsortable
5.2

📅 Calendar (ICS Export)

Intermediate⏱ 1 day

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 (6)
• 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 (6)
• 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 (8)
• 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
🔀 Alternatives
vs FullCalendar (heavy, ~200KB)
vs react-big-calendar (React-only)
vs Luxon (date math, no UI)
📚 Prereqs: 1.3
➡️ Next: 5.4, 5.7
💻 Minimal (354 chars) ▶ Full example (964 chars)
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')}
// 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);
calendaricsicalendarปฏิทินrfc5545rruleutc
5.3

🤝 CRM (Pipeline)

Intermediate⏱ 2 days

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 (6)
• 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 (6)
• 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 (8)
• 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 ทีหลัง
🔀 Alternatives
vs HubSpot (heavy, paid)
vs Notion CRM (general-purpose)
vs Twenty (open source, server needed)
📚 Prereqs: 1.3, 1.4
➡️ Next: 5.4, 5.7
💻 Minimal (244 chars) ▶ Full example (1189 chars)
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)}
// 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};}
crmpipelinesalesleadcontactgdprdeal
5.4

📆 Booking System

Intermediate⏱ 3 hours

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 (6)
• 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 (6)
• 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 (8)
• 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
🔀 Alternatives
vs Cal.com (open source, heavy)
vs Calendly (paid, vendor lock)
vs Spine (medical only)
📚 Prereqs: 5.2, 1.7
➡️ Next: 5.3, 5.5, 5.6
💻 Minimal (248 chars) ▶ Full example (1179 chars)
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'}
// 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}
bookingappointmentนัดหมายslotremindermedbookdinebook
5.5

💳 POS (Point of Sale)

Intermediate⏱ 6 hours

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 (6)
• 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 (6)
• 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 (8)
• 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
🔀 Alternatives
vs Square (closed, paid)
vs Stripe Terminal (cloud-first)
vs Vend (heavy, multi-store)
📚 Prereqs: 5.6, 5.9
➡️ Next: 5.6, 5.9
💻 Minimal (380 chars) ▶ Full example (1156 chars)
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=[]}}
// 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
poscartขายvatreceipttaxnoodle-shop
5.6

📦 Inventory (SKU + Stock Alert)

Intermediate⏱ 3 hours

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 (6)
• 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 (6)
• 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 (8)
• 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
🔀 Alternatives
vs Sortly (paid SaaS)
vs inFlow (desktop only)
vs Zoho Inventory (full ERP)
📚 Prereqs: 1.3, 1.4
➡️ Next: 5.5, 5.9
💻 Minimal (217 chars) ▶ Full example (911 chars)
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}}}
// 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
inventoryskustockคลังreorderpos-grocerylow-stock
5.7

📝 Form Builder

Intermediate⏱ 3 hours

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 (6)
• 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 (6)
• 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 (8)
• 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
🔀 Alternatives
vs Form.io (heavy enterprise)
vs SurveyJS (paid)
vs react-hook-form (React-only)
📚 Prereqs: 1.3, 1.4
➡️ Next: 5.3, 5.10
💻 Minimal (299 chars) ▶ Full example (1245 chars)
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('')}
// 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}
formbuilderฟอร์มschemano-codevalidationdynamic
5.8

📊 Dashboard Charts (ECharts)

Beginner⏱ 30 min

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 (6)
• 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 (6)
• 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 (8)
• 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')
🔀 Alternatives
vs Chart.js (lighter, simpler)
vs D3.js (low-level, flexible)
vs Plotly.js (3D, scientific)
vs Recharts (React-only)
📚 Prereqs: 1.7
➡️ Next: 5.10
💻 Minimal (300 chars) ▶ Full example (1180 chars)
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'));
// 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());
chartechartsdashboardกราฟvisualizationresponsiveanalytics
5.9

🧾 Invoice (PDF)

Intermediate⏱ 2 hours

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 (6)
• 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 (6)
• 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 (8)
• 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 ครั้งต่อไป
🔀 Alternatives
vs pdfmake (declarative, larger)
vs pdf-lib (edit existing)
vs react-pdf (React-only)
vs Puppeteer (server, full HTML)
📚 Prereqs: 1.7, 5.5
➡️ Next: 5.3
💻 Minimal (229 chars) ▶ Full example (1482 chars)
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`);
// 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}
invoicepdfใบเสร็จreceiptjspdfthaisarabun
5.10

📈 Analytics

Intermediate⏱ 2 hours

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 (6)
• 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 (6)
• 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 (8)
• 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
🔀 Alternatives
vs PostHog (self-host OSS)
vs Plausible (privacy-first paid)
vs GA4 (free, cookie banner)
vs Mixpanel (mobile-focused paid)
📚 Prereqs: 1.7
➡️ Next: 5.8
💻 Minimal (260 chars) ▶ Full example (1248 chars)
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'}))});
// 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});
analyticseventfunnelcohortgdprtrackingai-data-analyzer

🤖 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)"
      ]
    }
  ]
}
🔗 /api/skill (all 100) 💾 Download business-apps.json