bookmark เรียงตามลำดับหน้า (N5) + ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ พร้อม bookmarks.user_name (N6) + UX audit sidebar 2026-09-16: เมนู ⋯ แทนไอคอนชุดติดกัน, มุมมองรอง ตามหน้า หัวกลุ่มหุบได้, focus ring, ปุ่ม sidebar จิ้มง่าย >=32px, Escape ปิด dialog ประวัติ + แผนใหม่ docs/PLAN-BOOKMARK.md + agent ui-ux-designer แทน ui-designer + skill frontend-design

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-16 13:24:33 +07:00
parent 79b38fa152
commit a020e96bda
14 changed files with 2029 additions and 362 deletions

151
docs/PLAN-BOOKMARK.md Normal file
View file

@ -0,0 +1,151 @@
# แผน 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:
```ts
/** 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`):
```sql
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):
```ts
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 เรียงตามเดิม