20 KiB
แผน 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 analysisdocs/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:764roots,:339children ของแต่ละโหนด) - 5.3.12 (❌): ช่องค้นหา F14 ครอบเฉพาะ annotations (
searchAnnotationsในapp/actions/annotations.ts:972) — ค้นหา bookmark ไม่ได้เลย ยังไม่มีการกำหนดเงื่อนไข
การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-16)
- 5.3.11 เรียงตามหน้าเสมอ — ไม่มีปุ่มสลับโหมด; ทั้ง root และ sub ใช้เกณฑ์เดียวกัน:
pageIndex→yOffset(ตำแหน่งบนหน้า บน→ล่าง) →createdAt(tiebreak ให้เสถียร) - 5.3.12 UI อยู่ในแท็บ "ที่คั่นหน้า" เอง — แยกจากช่องค้นหาหมายเหตุ F14 ที่อยู่เหนือแท็บ (ช่องเดียวค้นคนละอย่างตามแท็บ = สับสน)
- เงื่อนไขครบ 4: คำค้น (ชื่อ+โน้ต) + ช่วงหน้า + ผู้บันทึก + ช่วงวันที่บันทึก
หลักตั้งต้น (จากการสำรวจโค้ด)
- ตาราง
bookmarksไม่มี columnuser_name(ต่างจากannotationsที่มีคู่user_id+user_name) — เงื่อนไข "ผู้บันทึก" ต้องแสดงชื่อใน dropdown และผลลัพธ์ จึงต้องเพิ่ม column (จุดเดียวที่แตะ schema) usersที่ sidebar ได้รับ (PDFViewer.tsx:1257) derive จาก annotations เท่านั้น และผูกกับ dropdown กรอง F4 — ห้ามปน รายชื่อผู้ทำ bookmark เข้าไป (option ที่กรองแล้วว่างเปล่า) → sidebar คำนวณรายชื่อจากbookmarksprop เอง- จุดเรียก
createBookmarkมี 2 จุดในPDFViewer.tsx—handleSaveBookmark(:2111popup 🔖/คีย์ 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("")(mirrorannotations;default("")เพื่อ drizzle push ผ่านกับ row เดิม) +BookmarkDTOเพิ่มuserName: string+toBookmarkDTOmap เพิ่มCreateBookmarkInput+createBookmarkเพิ่มuserName(validatevString(raw?.userName, "userName", MAX_USER_NAME_CHARS)) —updateBookmarkไม่แตะ (เจ้าของเปลี่ยนไม่ได้อยู่แล้ว)- ผู้เรียกทั้ง 2 จุดใน
PDFViewer.tsxส่งuserName: user.userName - backfill รันครั้งเดียวหลัง
/sync-db(psql ที่ containerjudes-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 จาก
bookmarksprop (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 (ออกแบบไว้ล่วงหน้า)
- หน้าเดียวกันหลาย bookmark → tiebreak
yOffset(บน→ล่างของหน้า) แล้วcreatedAt— deterministic เสมอ - หัวข้อใหญ่อยู่หน้าท้าย แต่ sub อยู่หน้ากลาง → root เรียงตามหน้าของตัวเอง sub ข้างในเรียงตามหน้าของ sub — พฤติกรรมนี้ตั้งใจ (โครงสร้างนำ ไม่ใช่ลำดับหน้านำ)
- realtime ระหว่างดูผลลัพธ์ → ผลลัพธ์เป็น snapshot ตอนกดค้น (เหมือน F14); แถวที่ถูกลบไปแล้วยังกดได้ (แค่ scroll ไปหน้านั้น ไม่พัง)
user_name = ''(ข้อมูลเก่าที่ backfill ไม่เจอ) → แสดง "ไม่ทราบชื่อ"- คู่ช่วงกลับด้าน (from > to ทั้งหน้าและวันที่) → server swap เงียบ ๆ ไม่ throw
- note เป็น NULL → ไม่ match ILIKE ของโน้ต แต่ title ยังทำให้เข้าผลลัพธ์ได้ (SQL
OR+ NULL ถูกต้องอยู่แล้ว) - คำค้นภาษาไทย → ILIKE ใช้ได้ (ไทยไม่มี case); escape
%/_/\กัน wildcard หลุด - จำนวนผลลัพธ์ → ไม่ 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)
npm run dev+/sync-db+ backfill — รายการ/ข้อมูลเดิมไม่หาย (dev DB ผู้ใช้ใช้ร่วม — ห้าม reset)- Playwright: เปิดไฟล์ที่มี bookmark กระจัดกระจาย → ตรวจลำดับตาม D1 → ค้นหาครบ 4 เงื่อนไข (รายเงื่อนไข + รวมกัน) → คลิกผลลัพธ์กระโดดหน้าถูก → แท็บที่ 2 สร้าง bookmark ใหม่ แทรกตำแหน่งถูกทั้งสองแท็บ
- Regression: F14 ค้นหาหมายเหตุ + ต้นไม้ bookmark (สร้าง/แก้/ย้าย/ลบ/+) + export PDF outline เรียงตามเดิม