diff --git a/.gitignore b/.gitignore index 1714050..493dcd0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,9 @@ CLAUDE.local.md # Problem data (imported via scripts/build_database.py) data/ -# Claude Code implementation plans (session artifacts) +# Claude Code implementation plans / specs (session artifacts) docs/plans/ +docs/superpowers/ # Python __pycache__/ diff --git a/docs/plans/2026-03-08-list-coverage-resume-design.md b/docs/plans/2026-03-08-list-coverage-resume-design.md deleted file mode 100644 index 0503de0..0000000 --- a/docs/plans/2026-03-08-list-coverage-resume-design.md +++ /dev/null @@ -1,108 +0,0 @@ -# List Coverage & Resume Position Design - -## Goal - -1. Ensure every problem with content belongs to at least one curated list (zero orphans). -2. When subscribing to a previously visited list, auto-resume from last position instead of resetting to 0. -3. Allow users to manually set a starting position from the list detail page. - -## Context - -- 810 problems with content, but only 604 unique problems across 33 lists → 206 orphan problems invisible on the problems page (`!inner` join on `problem_content` already covers this, but orphans aren't in `problems` table either since `build_database.py` only imports list-referenced problems). -- `user_list_progress` already tracks `current_position` per user per list, but `upsertListProgress` resets position to 0 on every list switch. - ---- - -## Part 1: List Coverage — 12 New Lists + 4 Existing Expansions - -### New Lists - -| Slug | Name | Estimated Count | Type | -|------|------|----------------|------| -| `segment-tree-bit` | Segment Tree & BIT | ~38 | algorithm | -| `design-patterns` | System Design Patterns | ~40 | topic | -| `graph-advanced` | Graph Advanced | ~53 | topic | -| `number-theory` | Number Theory & Combinatorics | ~38 | topic | -| `string-advanced` | String Advanced | ~27 | topic | -| `queue-deque` | Queue & Deque Patterns | ~30 | algorithm | -| `sorting-patterns` | Sorting & Ordered Set | ~51 | algorithm | -| `simulation` | Simulation & Implementation | ~25 | topic | -| `divide-conquer` | Divide & Conquer | ~17 | algorithm | -| `tree-bst` | BST Patterns | ~13 | topic | -| `game-theory` | Game Theory | ~7 | topic | -| `geometry` | Geometry & Math | ~6 | topic | - -### Existing List Expansions - -| List | Problem Added | Reason | -|------|--------------|--------| -| `dp-patterns` | #256 Paint House | dynamic-programming | -| `linked-list-patterns` | #2487 Remove Nodes From Linked List | linked-list | -| `bit-manipulation` | #1863 Sum of All Subset XOR Totals | bit-manipulation | -| `union-find` | #2334 Subarray With Elements Greater Than Varying Threshold | union-find | - -### Assignment Rules - -- Problems are assigned to ALL lists whose topics they match (cross-listing allowed). -- Assignment priority: most specific topic first (segment-tree > array). -- Topic matching uses the problem's full topic array, not just the primary topic. -- Result: 45 total lists, 0 orphan problems. - -### CLAUDE.md Rule (permanent) - -> Every problem with content MUST belong to at least one curated list. When adding new problems, create or expand lists to maintain this invariant. The `build_database.py --list all` command only imports list-referenced problems — orphan problems will be invisible on the site. - ---- - -## Part 2: List Resume & Start Position - -### Industry References - -- **Duolingo**: Auto-resumes skill tree position; can tap any lesson to start there. -- **Netflix**: "Continue Watching" is default; can browse and pick any episode. -- **Kindle**: Syncs last reading position; table of contents allows jumping to any chapter. - -### Behavior Changes - -#### Auto-Resume (default) - -**Current:** `upsertListProgress` always passes `current_position: 0` when switching lists. - -**New:** When switching to a list the user has subscribed to before: -- The existing `user_list_progress` row already has `current_position` from last time. -- `upsertListProgress` should NOT pass `current_position` for re-subscriptions, preserving the old value. -- For first-time subscriptions, `current_position` defaults to 0 (DB default). - -**Code changes:** -- `updateLearningMode` in `settings.ts`: remove `current_position: 0` from upsert (already not passed, but verify). -- `onboarding.ts`: keep `current_position: 0` for first-time setup (user has no history). - -#### Manual Start Position - -**Where:** List detail page (`/lists/[slug]`) — only for authenticated users. - -**UI elements:** -1. **Subscribe button** at the top of the list page: - - Not subscribed: "Subscribe to this list" → starts from position 0 - - Previously subscribed: "Continue from #N" (primary) + "Start over" (secondary) - - Currently active list: shows current position indicator -2. **Per-row "Start from here"**: each problem row shows its sequence number; clicking a "start from here" action subscribes and sets `current_position = sequence_number - 1`. - -**Data flow:** -- New Server Action: `subscribeToList(listId: number, startPosition?: number)` - - Calls `deactivateAllLists` + `upsertListProgress` with optional `current_position` - - Updates `active_mode: 'list'` on user -- List page fetches `user_list_progress` for the current user + list to show resume state. - -**Impact analysis:** -- Worker `selectProblemForUser`: reads `current_position` → queries `sequence_number = current_position + 1`. No change needed — it already uses whatever position is stored. -- `advance_list_positions` RPC: updates `current_position = sequence_number` after delivery. No change needed. -- Dashboard: shows active list progress. No change needed — reads from `user_list_progress`. - ---- - -## Non-Goals - -- Reordering problems within a list (out of scope). -- Creating lists from the web UI (admin-only, uses data files). -- Changing the `!inner` join on the problems page (all problems will be in lists, so this is moot). diff --git a/docs/plans/2026-03-08-list-coverage-resume-plan.md b/docs/plans/2026-03-08-list-coverage-resume-plan.md deleted file mode 100644 index 1591796..0000000 --- a/docs/plans/2026-03-08-list-coverage-resume-plan.md +++ /dev/null @@ -1,746 +0,0 @@ -# List Coverage & Resume Position Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Ensure every problem belongs to at least one curated list (206 orphans → 0), and let users resume or set a custom start position when subscribing to lists. - -**Architecture:** Generate 12 new list JSON files via a Python script that groups orphan problems by topic. Add `subscribeToList` Server Action that accepts optional `startPosition`. Update `/lists/[slug]` page with subscribe/resume/start-from-here UI. Import all lists to Supabase via existing `build_database.py`. - -**Tech Stack:** Python (list generation), Next.js 16, React 19, Supabase, Vitest - ---- - -### Task 1: Generate 12 new list JSON files + expand 4 existing lists - -**Files:** -- Create: `scripts/generate_topic_lists.py` -- Modify: `data/lists/dp-patterns.json` -- Modify: `data/lists/linked-list-patterns.json` -- Modify: `data/lists/bit-manipulation.json` -- Modify: `data/lists/union-find.json` -- Create: 12 new files in `data/lists/` - -**Step 1: Write the list generation script** - -Create `scripts/generate_topic_lists.py`: - -```python -#!/usr/bin/env python3 -""" -Generate topic-based curated list JSON files for orphan problems. - -Reads all problem files and existing lists, finds problems not in any list, -and assigns them to topic-based lists. Problems can appear in multiple lists -(cross-listing by topic match). - -Also appends specific orphans to existing lists. - -Usage: - python3 scripts/generate_topic_lists.py [--dry-run] -""" - -import json -import glob -import argparse -from pathlib import Path -from collections import defaultdict - -DATA_DIR = Path(__file__).parent.parent / "data" -PROBLEMS_DIR = DATA_DIR / "problems" -LISTS_DIR = DATA_DIR / "lists" - -CONTENT_FIELDS = [ - "explanation", "solution_code", "complexity_analysis", - "pseudocode", "alternative_approaches", "follow_up", -] - -# New lists: slug → (name, description, type, matching topics) -NEW_LISTS = { - "segment-tree-bit": ( - "Segment Tree & BIT", - "Master segment trees and binary indexed trees for range queries and updates.", - "algorithm", - {"segment-tree", "binary-indexed-tree"}, - ), - "design-patterns": ( - "System Design Patterns", - "Practice data structure design problems commonly asked in interviews.", - "topic", - {"design"}, - ), - "graph-advanced": ( - "Graph Advanced", - "Advanced graph algorithms including topological sort, shortest paths, and network flow.", - "topic", - {"graph", "topological-sort"}, - ), - "number-theory": ( - "Number Theory & Combinatorics", - "Number theory, counting, combinatorics, and probability problems.", - "topic", - {"number-theory", "counting"}, - ), - "string-advanced": ( - "String Advanced", - "Advanced string manipulation, matching, and parsing problems.", - "topic", - {"string"}, - ), - "queue-deque": ( - "Queue & Deque Patterns", - "Queue, deque, and monotonic queue pattern problems.", - "algorithm", - {"queue", "monotonic-queue"}, - ), - "sorting-patterns": ( - "Sorting & Ordered Set", - "Sorting algorithms, ordered sets, and related data structure problems.", - "algorithm", - {"sorting", "ordered-set"}, - ), - "simulation": ( - "Simulation & Implementation", - "Simulation and step-by-step implementation problems.", - "topic", - {"simulation"}, - ), - "divide-conquer": ( - "Divide & Conquer", - "Divide and conquer algorithm problems.", - "algorithm", - {"divide-and-conquer"}, - ), - "tree-bst": ( - "BST Patterns", - "Binary search tree construction, traversal, and manipulation patterns.", - "topic", - {"binary-search-tree", "binary-tree"}, - ), - "game-theory": ( - "Game Theory", - "Game theory and minimax strategy problems.", - "topic", - {"game-theory"}, - ), - "geometry": ( - "Geometry & Math", - "Computational geometry and spatial math problems.", - "topic", - {"geometry"}, - ), -} - -# Orphans to append to existing lists: leetcode_id → list slug -EXISTING_LIST_EXPANSIONS = { - 256: "dp-patterns", - 2487: "linked-list-patterns", - 1863: "bit-manipulation", - 2334: "union-find", -} - - -def load_all_list_ids() -> set[int]: - """Return all problem IDs already in any list.""" - ids = set() - for f in LISTS_DIR.glob("*.json"): - with open(f) as fh: - for pid in json.load(fh).get("problem_ids", []): - ids.add(pid) - return ids - - -def load_orphans(existing_ids: set[int]) -> list[dict]: - """Load problems with content that aren't in any list.""" - orphans = [] - for f in PROBLEMS_DIR.glob("*.json"): - with open(f) as fh: - p = json.load(fh) - if ( - any(p.get(field) for field in CONTENT_FIELDS) - and p["leetcode_id"] not in existing_ids - ): - orphans.append(p) - return orphans - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--dry-run", action="store_true", help="Print assignments without writing files") - args = parser.parse_args() - - existing_ids = load_all_list_ids() - orphans = load_orphans(existing_ids) - print(f"Found {len(orphans)} orphan problems") - - # Assign orphans to new lists (cross-listing allowed) - assignments: dict[str, list[int]] = defaultdict(list) - covered = set() - - for p in orphans: - topics = set(p.get("topics", [])) - for slug, (_, _, _, match_topics) in NEW_LISTS.items(): - if topics & match_topics: - assignments[slug].append(p["leetcode_id"]) - covered.add(p["leetcode_id"]) - - # Handle specific expansions - for lid, list_slug in EXISTING_LIST_EXPANSIONS.items(): - if lid not in covered: - covered.add(lid) - - uncovered = [p for p in orphans if p["leetcode_id"] not in covered] - if uncovered: - print(f"WARNING: {len(uncovered)} problems still uncovered:") - for p in uncovered: - print(f" #{p['leetcode_id']} {p['title']} topics={p['topics']}") - return - - print(f"\nAll {len(orphans)} orphans covered.") - - # Write new list files - for slug, (name, description, list_type, _) in NEW_LISTS.items(): - ids = sorted(assignments.get(slug, [])) - if not ids: - print(f" SKIP {slug}: 0 problems") - continue - - list_obj = { - "slug": slug, - "name": name, - "description": description, - "type": list_type, - "problem_ids": ids, - } - - out = LISTS_DIR / f"{slug}.json" - print(f" {slug}: {len(ids)} problems → {out}") - if not args.dry_run: - out.write_text(json.dumps(list_obj, indent=2, ensure_ascii=False) + "\n") - - # Expand existing lists - for lid, list_slug in EXISTING_LIST_EXPANSIONS.items(): - list_file = LISTS_DIR / f"{list_slug}.json" - with open(list_file) as fh: - lst = json.load(fh) - if lid not in lst["problem_ids"]: - lst["problem_ids"].append(lid) - print(f" +#{lid} → {list_slug} (now {len(lst['problem_ids'])} problems)") - if not args.dry_run: - list_file.write_text(json.dumps(lst, indent=2, ensure_ascii=False) + "\n") - - if args.dry_run: - print("\n(dry-run — no files written)") - else: - print("\nDone. Run: python3 scripts/build_database.py --list all") - - -if __name__ == "__main__": - main() -``` - -**Step 2: Run with --dry-run to verify** - -Run: `cd /home/bolin8017/Documents/repositories/caffecode && /home/bolin8017/.pyenv/shims/python3 scripts/generate_topic_lists.py --dry-run` -Expected: All 206 orphans covered, 12 new lists printed, 4 existing expansions printed, 0 uncovered. - -**Step 3: Run for real** - -Run: `/home/bolin8017/.pyenv/shims/python3 scripts/generate_topic_lists.py` -Expected: 12 new JSON files created in `data/lists/`, 4 existing list files updated. - -**Step 4: Import to Supabase** - -Run: `/home/bolin8017/.pyenv/shims/python3 scripts/build_database.py --list all` -Expected: 45 lists imported, all problems upserted. - -**Step 5: Verify database counts** - -Run SQL via Supabase MCP: -```sql -SELECT - (SELECT COUNT(*) FROM problems) AS total_problems, - (SELECT COUNT(*) FROM problem_content) AS with_content, - (SELECT COUNT(*) FROM curated_lists) AS total_lists, - (SELECT COUNT(*) FROM problems p - WHERE NOT EXISTS ( - SELECT 1 FROM list_problems lp WHERE lp.problem_id = p.id - )) AS orphan_problems -``` -Expected: `total_problems >= 810`, `orphan_problems = 0`, `total_lists = 45`. - -**Step 6: Commit** - -``` -feat(data): add 12 topic lists and expand 4 existing lists - -Ensures every problem with content belongs to at least one curated -list. Cross-listing by topic is allowed for accurate categorization. -``` - -Note: `data/` is in `.gitignore` so only `scripts/generate_topic_lists.py` will be committed to git. - ---- - -### Task 2: Add subscribeToList Server Action (TDD) - -**Files:** -- Modify: `apps/web/lib/actions/settings.ts` -- Create: `apps/web/lib/__tests__/subscribe-list.test.ts` - -**Step 1: Write failing test** - -Create `apps/web/lib/__tests__/subscribe-list.test.ts`: - -```typescript -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('next/cache', () => ({ - revalidatePath: vi.fn(), -})) - -vi.mock('@/lib/auth', () => ({ - getAuthUser: vi.fn(), -})) - -vi.mock('@/lib/logger', () => ({ - logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, -})) - -const mockUpsert = vi.fn().mockResolvedValue({ error: null }) -const mockUpdate = vi.fn().mockReturnValue({ - eq: vi.fn().mockResolvedValue({ error: null }), -}) -const mockFrom = vi.fn().mockReturnValue({ - upsert: mockUpsert, - update: mockUpdate, -}) -const mockSupabase = { from: mockFrom } - -import { getAuthUser } from '@/lib/auth' - -beforeEach(() => { - vi.clearAllMocks() - vi.mocked(getAuthUser).mockResolvedValue({ - supabase: mockSupabase as unknown, - user: { id: 'user-123' } as unknown, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) -}) - -describe('subscribeToList', () => { - it('subscribes without startPosition (preserves existing position)', async () => { - const { subscribeToList } = await import('@/lib/actions/settings') - await subscribeToList(5) - - // Should upsert WITHOUT current_position - expect(mockUpsert).toHaveBeenCalledWith( - expect.not.objectContaining({ current_position: expect.anything() }), - expect.anything() - ) - expect(mockUpsert).toHaveBeenCalledWith( - expect.objectContaining({ user_id: 'user-123', list_id: 5, is_active: true }), - expect.anything() - ) - }) - - it('subscribes with startPosition', async () => { - const { subscribeToList } = await import('@/lib/actions/settings') - await subscribeToList(5, 10) - - expect(mockUpsert).toHaveBeenCalledWith( - expect.objectContaining({ current_position: 10 }), - expect.anything() - ) - }) - - it('rejects negative startPosition', async () => { - const { subscribeToList } = await import('@/lib/actions/settings') - await expect(subscribeToList(5, -1)).rejects.toThrow() - }) - - it('rejects non-integer listId', async () => { - const { subscribeToList } = await import('@/lib/actions/settings') - await expect(subscribeToList(1.5)).rejects.toThrow() - }) -}) -``` - -**Step 2: Run test to verify it fails** - -Run: `cd apps/web && pnpm exec vitest run lib/__tests__/subscribe-list.test.ts` -Expected: FAIL — `subscribeToList` not found. - -**Step 3: Write implementation** - -In `apps/web/lib/actions/settings.ts`, add the following import and function after the existing `updateLearningMode`: - -```typescript -import { revalidatePath } from 'next/cache' -``` - -(Add to existing imports if not present.) - -```typescript -const subscribeSchema = z.object({ - listId: z.number().int().positive(), - startPosition: z.number().int().min(0).optional(), -}) - -export async function subscribeToList(listId: number, startPosition?: number) { - const { supabase, user } = await getAuthUser() - subscribeSchema.parse({ listId, startPosition }) - - await updateUser(supabase, user.id, { active_mode: 'list' }) - await deactivateAllLists(supabase, user.id) - - const progressData: { - user_id: string - list_id: number - is_active: boolean - current_position?: number - } = { - user_id: user.id, - list_id: listId, - is_active: true, - } - - if (startPosition !== undefined) { - progressData.current_position = startPosition - } - - await upsertListProgress(supabase, progressData) - - revalidatePath('/dashboard') - revalidatePath('/settings/learning') -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd apps/web && pnpm exec vitest run lib/__tests__/subscribe-list.test.ts` -Expected: All 4 tests PASS. - -**Step 5: Run all web tests** - -Run: `cd apps/web && pnpm exec vitest run` -Expected: All tests pass (no regressions). - -**Step 6: Commit** - -``` -feat(web): add subscribeToList Server Action with optional start position -``` - ---- - -### Task 3: Add list subscription UI to list detail page - -**Files:** -- Create: `apps/web/app/(public)/lists/[slug]/list-subscribe-bar.tsx` -- Modify: `apps/web/app/(public)/lists/[slug]/page.tsx:89-120` - -**Step 1: Create ListSubscribeBar client component** - -Create `apps/web/app/(public)/lists/[slug]/list-subscribe-bar.tsx`: - -```tsx -'use client' - -import { useState, useTransition } from 'react' -import { Button } from '@/components/ui/button' -import { subscribeToList } from '@/lib/actions/settings' - -interface Props { - listId: number - listName: string - problemCount: number - userProgress: { - current_position: number - is_active: boolean - } | null -} - -export function ListSubscribeBar({ listId, listName, problemCount, userProgress }: Props) { - const [isPending, startTransition] = useTransition() - const [message, setMessage] = useState(null) - - const handleSubscribe = (startPosition?: number) => { - setMessage(null) - startTransition(async () => { - try { - await subscribeToList(listId, startPosition) - setMessage(startPosition !== undefined - ? `已從第 ${startPosition + 1} 題開始學習「${listName}」` - : '已開始學習此清單') - } catch { - setMessage('訂閱失敗,請重試') - } - }) - } - - // Currently active on this list - if (userProgress?.is_active) { - return ( -
- - 目前學習中 · {userProgress.current_position} / {problemCount} 題 - - - {message && {message}} -
- ) - } - - // Has previous progress (returning user) - if (userProgress && userProgress.current_position > 0) { - return ( -
- - - {message && {message}} -
- ) - } - - // Never subscribed - return ( -
- - {message && {message}} -
- ) -} -``` - -**Step 2: Create StartFromHereButton client component** - -Add to the same file `list-subscribe-bar.tsx`: - -```tsx -export function StartFromHereButton({ - listId, - sequenceNumber, -}: { - listId: number - sequenceNumber: number -}) { - const [isPending, startTransition] = useTransition() - - const handleClick = () => { - startTransition(async () => { - await subscribeToList(listId, sequenceNumber - 1) - }) - } - - return ( - - ) -} -``` - -**Step 3: Update list detail page** - -In `apps/web/app/(public)/lists/[slug]/page.tsx`: - -Add import at top: -```tsx -import { ListSubscribeBar, StartFromHereButton } from './list-subscribe-bar' -``` - -Replace the progress bar section (lines 102-119) with: -```tsx -{/* Subscribe bar + Progress for logged-in users */} -{user && ( -
- - {userProgress && ( -
-
-
-
-
- )} -
-)} -``` - -In the table row, after the solved checkmark column, add a "start from here" column for authenticated users. Add to the ``: -```tsx -{user && } -``` - -And in the `` row, after the solved checkmark ``: -```tsx -{user && ( - - - -)} -``` - -**Step 4: Verify build** - -Run: `cd apps/web && pnpm build` -Expected: Build succeeds. - -**Step 5: Run lint** - -Run: `cd apps/web && pnpm lint` -Expected: 0 errors. - -**Step 6: Commit** - -``` -feat(web): add list subscribe/resume UI on list detail page - -Users can now subscribe to lists from the list page with auto-resume, -start-over, or start-from-any-position options. -``` - ---- - -### Task 4: Update hardcoded counts and CLAUDE.md rules - -**Files:** -- Modify: `CLAUDE.md` -- Modify: `README.md` -- Modify: `apps/web/app/page.tsx` -- Modify: `apps/web/app/(public)/lists/page.tsx` - -**Step 1: Update CLAUDE.md** - -Add to the "Content" line: update list count from 33 to 45. - -Update "Data & Scripts" section: update list count. - -Add new rule under "Key Patterns" → after "List position indexing": - -```markdown -- **List coverage invariant**: Every problem with content MUST belong to at least one curated list. `build_database.py` only imports list-referenced problems — orphans are invisible on the site. When adding new problems, create or expand topic lists to maintain zero orphans. Use `scripts/generate_topic_lists.py` to verify coverage. -``` - -Add `scripts/generate_topic_lists.py` to Key Files → Data & Scripts: - -```markdown -- `scripts/generate_topic_lists.py` — Assigns orphan problems (with content but not in any list) to topic-based curated lists. Cross-lists by topic match. Usage: `python3 scripts/generate_topic_lists.py [--dry-run]` -``` - -Update test count in Development Notes if changed. - -**Step 2: Update hardcoded counts in pages** - -In `apps/web/app/page.tsx`: change `33 份精選清單` → `45 份精選清單`. - -In `apps/web/app/(public)/lists/page.tsx`: change `33 份精選刷題清單` → `45 份精選刷題清單`. - -Update `README.md` list count. - -**Step 3: Commit** - -``` -docs: update list counts and add list coverage invariant rule -``` - ---- - -### Task 5: Final verification - -**Step 1: Run full test suite** - -```bash -cd apps/web && pnpm exec vitest run -cd ../../packages/shared && pnpm exec vitest run -cd ../../apps/worker && pnpm exec vitest run -``` - -Expected: All tests pass. - -**Step 2: Lint** - -```bash -cd apps/web && pnpm lint -``` - -Expected: 0 errors. - -**Step 3: Full build** - -```bash -pnpm turbo build -``` - -Expected: All packages build successfully. - -**Step 4: Verify database** - -Run SQL: -```sql -SELECT - (SELECT COUNT(*) FROM problems) AS total_problems, - (SELECT COUNT(*) FROM problem_content) AS with_content, - (SELECT COUNT(*) FROM curated_lists) AS total_lists, - (SELECT COUNT(*) FROM problems p - WHERE NOT EXISTS (SELECT 1 FROM list_problems lp WHERE lp.problem_id = p.id) - ) AS orphan_problems -``` - -Expected: `orphan_problems = 0`, `total_lists = 45`, `total_problems >= 810`. - ---- - -## Dependency Graph - -``` -Task 1 (list generation + DB import) ← independent - ↓ -Task 2 (subscribeToList action) ← independent of Task 1 - ↓ -Task 3 (list detail page UI) ← depends on Task 2 - ↓ -Task 4 (counts + CLAUDE.md) ← depends on Task 1 (final counts) - ↓ -Task 5 (verification) ← depends on all -``` - -Parallelizable: Task 1 + Task 2 can run simultaneously. diff --git a/docs/superpowers/plans/2026-03-19-seo-enhancement.md b/docs/superpowers/plans/2026-03-19-seo-enhancement.md deleted file mode 100644 index 7818459..0000000 --- a/docs/superpowers/plans/2026-03-19-seo-enhancement.md +++ /dev/null @@ -1,972 +0,0 @@ -# SEO Enhancement Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking. - -**Goal:** Add complete traditional SEO to all public CaffeCode pages: structured data, OG images, canonical URLs, manifest, and complete metadata. - -**Architecture:** Pure metadata/Server Component additions. No client JS, no new runtime dependencies. JSON-LD via a shared JsonLd Server Component. Dynamic OG images via Next.js ImageResponse (built-in Satori). All changes scoped to apps/web/. - -**Tech Stack:** Next.js 16 App Router metadata API, Satori/ImageResponse, schema.org JSON-LD - -**Spec:** docs/superpowers/specs/2026-03-19-seo-enhancement-design.md - ---- - -## File Map - -### New Files - -| File | Responsibility | -|------|----------------| -| `apps/web/app/manifest.ts` | Web app manifest (MetadataRoute) | -| `apps/web/components/seo/json-ld.tsx` | Shared JSON-LD script renderer (Server Component) | -| `apps/web/lib/seo/schemas.ts` | Schema.org object builders | -| `apps/web/app/(public)/problems/[slug]/opengraph-image.tsx` | Dynamic OG image for problem pages | -| `apps/web/public/og-default.png` | Static brand OG image (1200x630) | -| `apps/web/public/fonts/NotoSansTC-Bold.subset.woff` | Subsetted CJK font for Satori | -| `apps/web/__tests__/seo/schemas.test.ts` | Tests for schema helpers | -| `apps/web/__tests__/seo/json-ld.test.ts` | Tests for JsonLd component | - -### Modified Files - -| File | Changes | -|------|---------| -| `apps/web/app/layout.tsx` | Enhanced metadata + JsonLd (Organization, WebSite) | -| `apps/web/app/page.tsx` | Add canonical | -| `apps/web/app/login/page.tsx` | Strip suffix, add description + canonical | -| `apps/web/app/(public)/problems/page.tsx` | Strip suffix, add canonical | -| `apps/web/app/(public)/lists/page.tsx` | Strip suffix, add canonical | -| `apps/web/app/(public)/problems/[slug]/page.tsx` | Strip suffix, enhance metadata, add JsonLd | -| `apps/web/app/(public)/lists/[slug]/page.tsx` | Strip suffix, enhance metadata, add JsonLd | - ---- - -### Task 1: JSON-LD Component + Schema Helpers - -**Files:** -- Create: `apps/web/components/seo/json-ld.tsx` -- Create: `apps/web/lib/seo/schemas.ts` -- Create: `apps/web/__tests__/seo/schemas.test.ts` -- Create: `apps/web/__tests__/seo/json-ld.test.ts` - -- [ ] **Step 1: Write tests for schema helpers** - -Create `apps/web/__tests__/seo/schemas.test.ts`: - -```ts -import { describe, it, expect } from 'vitest' -import { - organizationSchema, - webSiteSchema, - breadcrumbSchema, - learningResourceSchema, - itemListSchema, -} from '@/lib/seo/schemas' - -describe('organizationSchema', () => { - it('returns valid Organization schema', () => { - const schema = organizationSchema() - expect(schema['@context']).toBe('https://schema.org') - expect(schema['@type']).toBe('Organization') - expect(schema.name).toBe('CaffeCode') - expect(schema.url).toBe('https://caffecode.net') - expect(schema.logo).toBe('https://caffecode.net/logo.png') - expect(schema.sameAs).toContain('https://github.com/bolin8017/caffecode') - }) -}) - -describe('webSiteSchema', () => { - it('returns valid WebSite schema with SearchAction', () => { - const schema = webSiteSchema() - expect(schema['@type']).toBe('WebSite') - expect(schema.potentialAction['@type']).toBe('SearchAction') - expect(schema.potentialAction.target).toContain('{search_term_string}') - }) -}) - -describe('breadcrumbSchema', () => { - it('returns BreadcrumbList with correct positions', () => { - const schema = breadcrumbSchema([ - { name: 'Home', url: 'https://caffecode.net' }, - { name: 'Problems', url: 'https://caffecode.net/problems' }, - { name: 'Two Sum', url: 'https://caffecode.net/problems/two-sum' }, - ]) - expect(schema['@type']).toBe('BreadcrumbList') - expect(schema.itemListElement).toHaveLength(3) - expect(schema.itemListElement[0].position).toBe(1) - expect(schema.itemListElement[2].position).toBe(3) - expect(schema.itemListElement[2].name).toBe('Two Sum') - }) -}) - -describe('learningResourceSchema', () => { - it('maps Easy to Beginner', () => { - const schema = learningResourceSchema({ - title: 'Two Sum', slug: 'two-sum', - difficulty: 'Easy', topics: ['Array', 'Hash Table'], - }) - expect(schema['@type']).toBe('LearningResource') - expect(schema.educationalLevel).toBe('Beginner') - expect(schema.keywords).toBe('Array, Hash Table') - }) - - it('maps Medium to Intermediate', () => { - const schema = learningResourceSchema({ - title: 'LRU Cache', slug: 'lru-cache', - difficulty: 'Medium', topics: ['Design'], - }) - expect(schema.educationalLevel).toBe('Intermediate') - }) - - it('maps Hard to Advanced', () => { - const schema = learningResourceSchema({ - title: 'Median', slug: 'median', - difficulty: 'Hard', topics: ['Binary Search'], - }) - expect(schema.educationalLevel).toBe('Advanced') - }) -}) - -describe('itemListSchema', () => { - it('returns ItemList with numbered elements', () => { - const schema = itemListSchema('Blind 75', [ - { title: 'Two Sum', slug: 'two-sum' }, - { title: 'Valid Parentheses', slug: 'valid-parentheses' }, - ]) - expect(schema['@type']).toBe('ItemList') - expect(schema.name).toBe('Blind 75') - expect(schema.numberOfItems).toBe(2) - expect(schema.itemListElement[0].position).toBe(1) - expect(schema.itemListElement[1].url).toBe( - 'https://caffecode.net/problems/valid-parentheses' - ) - }) -}) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd apps/web && pnpm exec vitest run __tests__/seo/schemas.test.ts` -Expected: FAIL (module not found) - -- [ ] **Step 3: Implement schema helpers** - -Create `apps/web/lib/seo/schemas.ts`: - -```ts -const BASE_URL = 'https://caffecode.net' - -export function organizationSchema() { - return { - '@context': 'https://schema.org', - '@type': 'Organization', - name: 'CaffeCode', - url: BASE_URL, - logo: `${BASE_URL}/logo.png`, - sameAs: [ - 'https://github.com/bolin8017/caffecode', - 'https://t.me/CaffeCodeBot', - ], - } -} - -export function webSiteSchema() { - return { - '@context': 'https://schema.org', - '@type': 'WebSite', - name: 'CaffeCode', - url: BASE_URL, - potentialAction: { - '@type': 'SearchAction', - target: `${BASE_URL}/problems?q={search_term_string}`, - 'query-input': 'required name=search_term_string', - }, - } -} - -export function breadcrumbSchema(items: { name: string; url: string }[]) { - return { - '@context': 'https://schema.org', - '@type': 'BreadcrumbList', - itemListElement: items.map((item, i) => ({ - '@type': 'ListItem', - position: i + 1, - name: item.name, - item: item.url, - })), - } -} - -export function learningResourceSchema(problem: { - title: string - slug: string - difficulty: string - topics: string[] - description?: string -}) { - return { - '@context': 'https://schema.org', - '@type': 'LearningResource', - name: problem.title, - url: `${BASE_URL}/problems/${problem.slug}`, - description: problem.description, - educationalLevel: difficultyToLevel(problem.difficulty), - keywords: problem.topics.join(', '), - provider: { - '@type': 'Organization', - name: 'CaffeCode', - url: BASE_URL, - }, - } -} - -function difficultyToLevel(d: string): string { - switch (d) { - case 'Easy': return 'Beginner' - case 'Medium': return 'Intermediate' - case 'Hard': return 'Advanced' - default: return 'Intermediate' - } -} - -export function itemListSchema( - listName: string, - problems: { title: string; slug: string }[] -) { - return { - '@context': 'https://schema.org', - '@type': 'ItemList', - name: listName, - numberOfItems: problems.length, - itemListElement: problems.map((p, i) => ({ - '@type': 'ListItem', - position: i + 1, - name: p.title, - url: `${BASE_URL}/problems/${p.slug}`, - })), - } -} -``` - -- [ ] **Step 4: Run schema tests, verify pass** - -Run: `cd apps/web && pnpm exec vitest run __tests__/seo/schemas.test.ts` -Expected: All 7 tests PASS - -- [ ] **Step 5: Write tests for JsonLd component** - -Create `apps/web/__tests__/seo/json-ld.test.ts`: - -```ts -import { describe, it, expect } from 'vitest' -import { renderToStaticMarkup } from 'react-dom/server' -import { JsonLd } from '@/components/seo/json-ld' -import { createElement } from 'react' - -describe('JsonLd', () => { - it('renders script tag with application/ld+json type', () => { - const data = { '@context': 'https://schema.org', '@type': 'Organization', name: 'Test' } - const html = renderToStaticMarkup(createElement(JsonLd, { data })) - expect(html).toContain('type="application/ld+json"') - expect(html).toContain('"@type":"Organization"') - expect(html).toContain('"name":"Test"') - }) -}) -``` - -- [ ] **Step 6: Implement JsonLd component** - -Create `apps/web/components/seo/json-ld.tsx`: - -```tsx -type JsonLdProps = { data: Record } - -export function JsonLd({ data }: JsonLdProps) { - return ( -