eCourt-note/docs/PLAN-BOOKMARK.md

20 KiB
Raw Permalink Blame History

แผน 5.3.11 + 5.3.12 — Bookmark เรียงตามลำดับหน้า + ค้นหาพร้อมเงื่อนไข (N5 + N6)

สถานะ: implement เสร็จสมบูรณ์ (2026-09-16) — ครบทั้ง 6 task, tsc ผ่าน, vitest 120/120, plan-reviewer + code-reviewer ผ่าน (findings ทั้งหมดแก้แล้ว: swap วันที่ต้องทำที่ string ก่อนคำนวณ Date boundary — swap ที่ Date ทำให้ช่วงกลับด้านเหลือ 1 ms จนได้ 0 ผลเสมอ, แถววันที่ล้นแผงข้าง 288px ถูกตัด → stack แนวตั้ง + date คู่แบ่ง flex-1, ค่าช่วงหน้า invalid (0/1.5) หายเงียว → validate ก่อน submit + แสดง error จริงแทนกลืนเป็น "ไม่พบ", dateBound ส่งชื่อ field ใน error ให้ถูก) — Playwright ผ่าน 17/17 (เรียงต้นไม้ 1→4→4→9 + ซับใต้แม่, ค้นครบ 4 เงื่อนไข + ผสม AND, คลิกผลลัพธ์กระโดดหน้า, สร้างคีย์ B แล้ว user_name ลง DB, F14 regression, จอ tablet 820px) อ้างอิง: requirement 5.3.11 / 5.3.12 (gap analysis docs/requirement/gap-analysis.md กลุ่ม B — "ฟังก์ชันที่มีอยู่แล้ว ต่อยอดเล็กน้อยได้เลย") ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง docs/PLAN-FEAT.md §7 (N5 เรียงตามหน้า, N6 ค้นหา bookmark) + ขยาย whitelist ใน CLAUDE.md ก่อน — เป็น task ที่ 1 ของแผนนี้

Context

  • 5.3.11 (⚠️): แท็บ Bookmarks แสดงเลขหน้าต่อรายการ แต่เรียงตามเวลาสร้าง — query ที่ app/annotate/[id]/page.tsx:43 (orderBy asc(bookmarks.createdAt)) และ sidebar render ตามลำดับ array ของ state (AnnotationSidebar.tsx:764 roots, :339 children ของแต่ละโหนด)
  • 5.3.12 (❌): ช่องค้นหา F14 ครอบเฉพาะ annotations (searchAnnotations ใน app/actions/annotations.ts:972) — ค้นหา bookmark ไม่ได้เลย ยังไม่มีการกำหนดเงื่อนไข

การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-16)

  1. 5.3.11 เรียงตามหน้าเสมอ — ไม่มีปุ่มสลับโหมด; ทั้ง root และ sub ใช้เกณฑ์เดียวกัน: pageIndex → yOffset (ตำแหน่งบนหน้า บน→ล่าง) → createdAt (tiebreak ให้เสถียร)
  2. 5.3.12 UI อยู่ในแท็บ "ที่คั่นหน้า" เอง — แยกจากช่องค้นหาหมายเหตุ F14 ที่อยู่เหนือแท็บ (ช่องเดียวค้นคนละอย่างตามแท็บ = สับสน)
  3. เงื่อนไขครบ 4: คำค้น (ชื่อ+โน้ต) + ช่วงหน้า + ผู้บันทึก + ช่วงวันที่บันทึก

หลักตั้งต้น (จากการสำรวจโค้ด)

  • ตาราง bookmarks ไม่มี column user_name (ต่างจาก annotations ที่มีคู่ user_id+user_name) — เงื่อนไข "ผู้บันทึก" ต้องแสดงชื่อใน dropdown และผลลัพธ์ จึงต้องเพิ่ม column (จุดเดียวที่แตะ schema)
  • users ที่ sidebar ได้รับ (PDFViewer.tsx:1257) derive จาก annotations เท่านั้น และผูกกับ dropdown กรอง F4 — ห้ามปน รายชื่อผู้ทำ bookmark เข้าไป (option ที่กรองแล้วว่างเปล่า) → sidebar คำนวณรายชื่อจาก bookmarks prop เอง
  • จุดเรียก createBookmark มี 2 จุดใน PDFViewer.tsx — handleSaveBookmark (:2111 popup 🔖/คีย์ B/ปุ่มในแผง selection) และ handleAddSubBookmark (:2142 ปุ่ม + ที่หัวข้อ) — ทั้งคู่มี user ในมืออยู่แล้ว
  • realtime: useAnnotationRealtime แทรก/แทน row ตาม id ไม่คุมลำดับ → ต้อง sort ตอน render ฝั่ง client แล้วอย่าเชื่อลำดับจาก serverquery อย่างเดียว (realtime create จะต่อท้าย array)
  • BookmarkDTO ไหลไปเองทั้ง SSE event + optimistic append (PDFViewer ใช้ DTO ที่ action คืน) — เติม field แล้วไม่ต้องแก้ pipeline sync เลย

Design

D1 (N5) — เรียงต้นไม้ bookmark ตามลำดับหน้า

components/sidebar/AnnotationSidebar.tsx — comparator เดียวใช้ทั้ง root และ children:

/** N5: เรียงตามลำดับหน้า — หน้า → ตำแหน่งบนหน้า (บน→ล่าง) → เวลาสร้าง (เสถียร) */
function byPageOrder(a: BookmarkDTO, b: BookmarkDTO): number {
  return (
    a.pageIndex - b.pageIndex ||
    a.yOffset - b.yOffset ||
    a.createdAt.localeCompare(b.createdAt) // ISO string เทียบตามตัวอักษรได้
  );
}
  • roots (:764): bookmarks.filter((b) => !b.parentId).sort(byPageOrder)
  • children ใน renderBookmarkNode (:339): bookmarks.filter((x) => x.parentId === b.id).sort(byPageOrder)
  • filter() คืน array ใหม่เสมอ → .sort() กลาง ๆ ไม่แตะ state (ปลอดภัยกับ React)
  • ไม่แตะ query ฝั่ง server (page.tsx เรียง createdAt ตามเดิม — client sort ทับเสมอ) และไม่แตะ export outline / F15 / CSV

D2 (N6 ตอนที่ 1) — schema: bookmarks.user_name

  • db/schema.ts: userName: text("user_name").notNull().default("") (mirror annotations; default("") เพื่อ drizzle push ผ่านกับ row เดิม) + BookmarkDTO เพิ่ม userName: string + toBookmarkDTO map เพิ่ม
  • CreateBookmarkInput + createBookmark เพิ่ม userName (validate vString(raw?.userName, "userName", MAX_USER_NAME_CHARS)) — updateBookmark ไม่แตะ (เจ้าของเปลี่ยนไม่ได้อยู่แล้ว)
  • ผู้เรียกทั้ง 2 จุดใน PDFViewer.tsx ส่ง userName: user.userName
  • backfill รันครั้งเดียวหลัง /sync-db (psql ที่ container judes-note-db):
UPDATE bookmarks b
SET user_name = COALESCE((
  SELECT a.user_name FROM annotations a
  WHERE a.user_id = b.user_id AND a.user_name <> ''
  ORDER BY a.created_at DESC LIMIT 1
), '')
WHERE b.user_name = '';
  • แถวที่ backfill ไม่เจอชื่อ (ผู้ใช้ไม่เคยทำ annotation) เหลือ '' → ฝั่ง UI แสดง fallback "ไม่ทราบชื่อ"

D3 (N6 ตอนที่ 2) — Server Action searchBookmarks

ไว้ใน app/actions/annotations.ts (แถว actions bookmark เดิม) — read-only: ไม่ publish event ไม่ revalidate (ไม่เขียน DB):

export type SearchBookmarksInput = {
  fileId: string;
  q?: string;          // คำค้น ILIKE บน title + note (ว่างได้ถ้ามีเงื่อนไขอื่น)
  pageFrom?: number;   // 1-based ตรงที่ผู้ใช้เห็น ("หน้า N")
  pageTo?: number;
  userId?: string;     // ผู้บันทึก
  dateFrom?: string;   // YYYY-MM-DD (เขตเวลา +07:00 คงที่ — ไทยไม่มี DST)
  dateTo?: string;
};
export async function searchBookmarks(input: SearchBookmarksInput): Promise<BookmarkDTO[]>;
  • validate: fileId = vUuid · q ถ้ามี = vString(q, "q", MAX_QUERY_CHARS) + escape %/_/\ เหมือน searchAnnotations · pageFrom/pageTo = integer ≥ 1 · userId = vString · date ผ่าน ^\d{4}-\d{2}-\d{2}$ + new Date(${d}T00:00:00+07:00) (dateTo ใช้ T23:59:59.999+07:00) แล้วตรวจ !Number.isNaN(getTime()) · อย่างน้อย 1 เงื่อนไข ไม่งั้น throw (client ก็ disable ปุ่มอยู่แล้ว)
  • คู่กลับด้าน swap เงียบ ๆ (pageFrom>pageTo, dateFrom>dateTo) — ไม่ throw เพราะ UX งงน้อยกว่า
  • query: WHERE file_id = ? AND (title ILIKE ? OR note ILIKE ?) AND page_index ≥ pageFrom-1 AND page_index ≤ pageTo-1 AND user_id = ? AND created_at ≥ ? AND created_at ≤ ? — เฉพาะเงื่อนไขที่ส่งมา (AND รวมกัน), note เป็น NULL ไม่ match ILIKE แต่ยังเข้า OR ทาง title ได้ (SQL NULL semantics ถูกต้องอยู่แล้ว)
  • orderBy asc(pageIndex), asc(yOffset), asc(createdAt) — ลำดับเดียวกับที่แสดงบนจอ (D1)
  • imports เพิ่ม: gte, lte, ilike (จาก drizzle-orm)

D4 (N6 ตอนที่ 3) — UI ค้นหาในแท็บ "ที่คั่นหน้า"

AnnotationSidebar.tsx — block shrink-0 border-b border-slate-200 p-2 แสดงเฉพาะ tab === "bookmarks" วางระหว่างแถบแท็บกับพื้นที่ list (pattern เดียวกับฟอร์ม F14 — ไม่อยู่ใน scroll container จะได้ไม่หลุดจอเวลา scroll):

[🔍 ชื่อ/โน้ต…        ] [หา]  [เงื่อนไข ▾]   ← ปุ่มกาง/พับ + badge จำนวนเงื่อนไขที่ตั้ง
┌ เมื่อกาง ────────────────────────────────┐
│ หน้า [จาก]–[ถึง] · ผู้บันทึก [Select]        │
│ บันทึกวันที่ [จาก date]–[ถึง date] [ล้างเงื่อนไข] │
└──────────────────────────────────────────┘
  • ฟอร์ม + state local ทั้งหมด (bmQuery, bmConditions, bmResults: BookmarkDTO[] | null, bmSearching) — submit จาก form (Enter ได้) เหมือน F14; onKeyDown ของ input ทุกตัว stopPropagation กันชนคีย์ลัด H/D/C/B
  • ปุ่ม "หา" disabled เมื่อไม่มีเงื่อนไขเลย (q ว่าง + ไม่มีเงื่อนไขใด) — ต่างจาก F14: q ว่างแต่ตั้งเงื่อนไขอื่นค้นได้
  • ผู้บันทึก dropdown: unique จาก bookmarks prop (Map<userId, userName || "ไม่ทราบชื่อ"> เรียงตามชื่อ — รวมตัวเอง ไม่มี option "ของฉัน" แยก) ใช้ components/Select เดิม
  • ผลลัพธ์แสดงแทนต้นไม่ ขณะ bmResults !== null: list แบน — แถว = ไอคอน Bookmark + title + "หน้า N · ผู้บันทึก" + ถ้าเป็น sub บรรทัดบริบท ใน: <ชื่อแม่> (หาจาก bookmarks.find(x => x.id === b.parentId)) + โน้ต clamp; คลิก = onGoToBookmark(b); หัว panel มี "ผลการค้นหา (n)" + ปุ่ม ✕ ปิดกลับต้นไม้; ไม่พบ = "ไม่พบ" เหมือน F14
  • จอเล็ก: แถบเงื่อนไข flex-wrap, ปุ่ม/input ขนาดเท่า F14 (แตะได้สบาย)

Docs/rules sync (plan-first — ทำเป็น task แรก ก่อนแตะโค้ด)

ไฟล์ สิ่งที่แก้
docs/PLAN-FEAT.md §7 หัวข้อใหม่ N5 เรียง bookmark ตามลำดับหน้า + N6 ค้นหา bookmark พร้อมเงื่อนไข (format ตาม N1/N2) + แก้ bullet ท้าย N2 "เรียงตาม createdAt" → เรียงตามหน้า (อ้าง N5)
docs/PLAN.md §3 ตาราง bookmarks เพิ่ม column user_name (rule ห้ามเพิ่ม column ที่ไม่มีในแผน — ต้องแก้ที่นี่ก่อนเสมอ)
CLAUDE.md whitelist ฟีเจอร์เสริมหลังแผน เพิ่ม N5/N6
.claude/rules/data-layer.md bookmarks user_name (create ส่งเสมอ + backfill ครั้งเดียว) + searchBookmarks เป็น read-only (ไม่ publish/revalidate เพราะไม่เขียน DB)

ลำดับ task + verify (ทุก task: npx tsc --noEmit ผ่าน)

# งาน ไฟล์หลัก Verify เพิ่มเติม
1 Docs sync N5/N6 docs/PLAN-FEAT.md, docs/PLAN.md, CLAUDE.md, .claude/rules/data-layer.md สเปกครบตาม format §7; PLAN.md §3 มี user_name
2 Schema user_name + DTO + callers + /sync-db + backfill db/schema.ts, app/actions/annotations.ts, components/pdf/PDFViewer.tsx สร้าง bookmark บน UI → row มี user_name; แท็บที่ 2 เห็น SSE event มี userName; รายการเก่าถูก backfill (ตรวจด้วย psql); tsc + vitest ผ่าน
3 N5 เรียงตามหน้า components/sidebar/AnnotationSidebar.tsx Playwright: สร้าง root หน้า 5→2→9 = แสดง 2/5/9; หน้าเดียวกันเรียกตาม yOffset; sub ในหัวข้อเรียงตามหน้า; สร้างผ่านแท็บที่ 2 (realtime) แทรกตำแหน่งถูก; regression: กาง/หุบหัวข้อ + ย้ายหัวข้อ + ปุ่ม + ยังทำงาน
4 searchBookmarks action app/actions/annotations.ts ค้นผ่าน UI (ข้อ 5); คำค้นมี %/_ ถูก escape; ผสมหลายเงื่อนไข = AND; q ว่าง + เฉพาะช่วงหน้าทำงาน; ไม่มีเงื่อนไขเลย = throw
5 N6 UI ค้นหาในแท็บ bookmarks components/sidebar/AnnotationSidebar.tsx Playwright: ค้นคำในชื่อ/โน้ต; ช่วงหน้า; ผู้บันทึก; ช่วงวันที่; คลิกผลลัพธ์กระโดดหน้า + scroll ตำแหน่ง yOffset; ปิดผลลัพธ์กลับต้นไม้; ล้างเงื่อนไข; แถบเงื่อนไขพับ/กางบนจอ touch ไม่ทับต้นไม้; regression: ช่อง F14 ยังค้น annotation ได้เหมือนเดิม
6 ปิดงาน — plan-reviewer ผ่านทุก invariant ที่ N5/N6 แตะ + code-reviewer + vitest + tsc

Edge cases (ออกแบบไว้ล่วงหน้า)

  1. หน้าเดียวกันหลาย bookmark → tiebreak yOffset (บน→ล่างของหน้า) แล้ว createdAt — deterministic เสมอ
  2. หัวข้อใหญ่อยู่หน้าท้าย แต่ sub อยู่หน้ากลาง → root เรียงตามหน้าของตัวเอง sub ข้างในเรียงตามหน้าของ sub — พฤติกรรมนี้ตั้งใจ (โครงสร้างนำ ไม่ใช่ลำดับหน้านำ)
  3. realtime ระหว่างดูผลลัพธ์ → ผลลัพธ์เป็น snapshot ตอนกดค้น (เหมือน F14); แถวที่ถูกลบไปแล้วยังกดได้ (แค่ scroll ไปหน้านั้น ไม่พัง)
  4. user_name = '' (ข้อมูลเก่าที่ backfill ไม่เจอ) → แสดง "ไม่ทราบชื่อ"
  5. คู่ช่วงกลับด้าน (from > to ทั้งหน้าและวันที่) → server swap เงียบ ๆ ไม่ throw
  6. note เป็น NULL → ไม่ match ILIKE ของโน้ต แต่ title ยังทำให้เข้าผลลัพธ์ได้ (SQL OR + NULL ถูกต้องอยู่แล้ว)
  7. คำค้นภาษาไทย → ILIKE ใช้ได้ (ไทยไม่มี case); escape %/_/\ กัน wildcard หลุด
  8. จำนวนผลลัพธ์ → ไม่ cap (bookmark ต่อไฟล์ระดับหลายสิบ และ F14 ก็ไม่ cap — เท่ากัน)

Out of scope (ไม่ทำในแผนนี้)

  • bookmark ในหน้าสรุป F15 / export CSV / รายงาน PDF ของ bookmark — คือ gap 5.3.15 (กลุ่ม B ข้ออื่น)
  • ชุดของเอกสาร/แสดง bookmark รวมหลายไฟล์ — gap 5.3.10 (กลุ่ม C)
  • ปุ่มสลับโหมดเรียง (ตามเวลาสร้าง) — ผู้ใช้เลือกไม่ทำ
  • แสดงชื่อผู้บันทึกในแถวของต้นไม้ bookmark ปกติ (มีเฉพาะในผลค้นหา — จุดที่ requirement ถาม)
  • drag จัดลำดับ bookmark (out of scope เดิมของ N2)

Verification (ภาพรวม end-to-end)

  1. npm run dev + /sync-db + backfill — รายการ/ข้อมูลเดิมไม่หาย (dev DB ผู้ใช้ใช้ร่วม — ห้าม reset)
  2. Playwright: เปิดไฟล์ที่มี bookmark กระจัดกระจาย → ตรวจลำดับตาม D1 → ค้นหาครบ 4 เงื่อนไข (รายเงื่อนไข + รวมกัน) → คลิกผลลัพธ์กระโดดหน้าถูก → แท็บที่ 2 สร้าง bookmark ใหม่ แทรกตำแหน่งถูกทั้งสองแท็บ
  3. Regression: F14 ค้นหาหมายเหตุ + ต้นไม้ bookmark (สร้าง/แก้/ย้าย/ลบ/+) + export PDF outline เรียงตามเดิม