Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
67556e2
feat(dashboard): init next.js app with required packages (#1)
BODMAT May 13, 2026
acb8c5c
feat(db): add postgres docker setup and prisma models (#2)
BODMAT May 13, 2026
ecf5425
chore(ai): add CLAUDE.md ruleset and skill recipes (#3)
BODMAT May 13, 2026
00ef787
feat(api): events endpoint with zod and cors (AC 3, AC 7) (#4)
BODMAT May 14, 2026
067887e
feat(extension): init manifest v3 with vite + crxjs (AC 4) (#5)
BODMAT May 14, 2026
d01cf27
chore(repo): finalize week 1 - readme, governance, ci, lint, husky (A…
BODMAT May 15, 2026
3ca1aea
feat(extension): google oauth - jwt auth flow (Week 2 AC 1) (#7)
BODMAT May 21, 2026
0cfaf7c
feat(extension): content script parser + session manager (Week 2 AC 2…
BODMAT May 21, 2026
68bc2ab
feat(extension): popup UI with session controls, auth flow, and notes…
BODMAT May 21, 2026
c627da8
feat(extension): batched data sync with retry (Week 2 AC 5) (#10)
BODMAT May 23, 2026
06f4dd7
feat(AC6): music capture - YTM + SoundCloud track tracking with liste…
BODMAT May 24, 2026
6b0007c
feat(extension): privacy domain blocklist (Week 2 AC7) (#12)
BODMAT May 25, 2026
fbd6814
feat(extension): parsing modes + SW-sleep & track-timer fixes (Week 2…
BODMAT May 25, 2026
52fa00b
feat(dashboard): week 3 AC 1 - google login, session cookie and middl…
BODMAT May 26, 2026
d79ed5d
feat(dashboard): Week 3 AC 2 - event feed, charts, top sessions (#15)
BODMAT May 27, 2026
2920748
feat(dashboard): Week 3 AC 3 - AI session report with Groq LLM, token…
BODMAT May 28, 2026
8e181d8
feat(dashboard): Week 3 AC 4 (bonus) - music analytics page with Char…
BODMAT May 28, 2026
438173c
feat(dashboard): Week 3 AC 5 (bonus) - typed API errors, toast notifi…
BODMAT May 31, 2026
68d4b75
fix(stale-data): close stale sessions and music timers via cron + pag…
BODMAT Jun 2, 2026
d253fce
chore: JWT unification (jose everywhere) + logout cache clear (#20)
BODMAT Jun 2, 2026
f2dfb3a
feat(week4): AC 1 CI/CD + AC 2 Deploy - extension zip, auto release, …
BODMAT Jun 2, 2026
fb80bbd
feat(dashboard): Week 4 AC 4 - Framer Motion animations + scroll-reac…
BODMAT Jun 2, 2026
f490844
fix(dashboard): report localStorage cache + delete account flow (#23)
BODMAT Jun 2, 2026
a5c0c49
fix: code review follow-ups - flush() race + extension/dashboard hard…
BODMAT Jun 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .claude/skills/api-route.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Skill: add a Next.js Route Handler

Use when adding or modifying a `dashboard/app/api/**/route.ts` endpoint.

## Location

- App Router: `dashboard/app/api/<resource>/route.ts`
- For versioned APIs: `dashboard/app/api/v1/<resource>/route.ts`
- Subresource: `dashboard/app/api/v1/<resource>/[id]/route.ts`

## Structure (every handler)

1. Parse the request — `await req.json()`, query params via `req.nextUrl.searchParams`, route params via the second arg.
2. Validate with a Zod schema. Schemas live in `dashboard/server/schemas/<domain>.ts` — never inline in the route handler. **Derive from the generated schemas at `@/generated/zod/schemas/objects/<Model>UncheckedCreateInput.schema`** (or similar) using `.omit()` / `.extend()` / `.pick()`. The `Unchecked*` variants expose foreign keys as scalars (e.g., `sessionId: string`), matching what API clients send.
3. Delegate to a `dashboard/server/<domain>.ts` module — never inline business logic.
4. Return typed JSON via `NextResponse.json(...)`.

## Example: POST /api/v1/events

`dashboard/server/schemas/events.ts`:
```ts
import { z } from "zod";
import { EventUncheckedCreateInputObjectZodSchema } from "@/generated/zod/schemas/objects/EventUncheckedCreateInput.schema";

export const CreateEventInput = EventUncheckedCreateInputObjectZodSchema
.omit({ id: true, createdAt: true })
.extend({
sessionId: z.string().regex(/^c[a-z0-9]{24}$/, "must be a CUID"),
url: z.url(),
title: z.string().min(1),
});

export type CreateEventInput = z.infer<typeof CreateEventInput>;
```

Notes on the derived pattern:
- **Base from generated:** all fields and types come from Prisma — no manual duplication.
- **`.omit()` server-managed fields:** clients don't set `id` or `createdAt`.
- **`.extend()` for stricter validation:** the generated schema just says `z.string()` for FKs and URLs; we tighten with a CUID regex, `z.url()`, and `z.string().min(1)`.
- **CUID regex** not `z.cuid()`: Zod 4 deprecated `z.cuid()`. Our Prisma uses cuid v1 (`@default(cuid())`), validated by `/^c[a-z0-9]{24}$/`.

`dashboard/app/api/v1/events/route.ts`:
```ts
import { NextRequest, NextResponse } from "next/server";
import { createEvent } from "@/server/events";
import { CreateEventInput } from "@/server/schemas/events";

export async function POST(req: NextRequest) {
const body = await req.json();
const parsed = CreateEventInput.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.format() }, { status: 400 });
}

const event = await createEvent(parsed.data);
return NextResponse.json(event, { status: 201 });
}
```

## HTTP semantics

- `200` OK — successful GET / PATCH / DELETE-with-body
- `201` Created — successful POST that created a resource
- `204` No Content — DELETE / accepted POST with no response body
- `400` Bad Request — Zod validation error
- `401` Unauthorized — missing/invalid JWT
- `403` Forbidden — authenticated but not allowed
- `404` Not Found — resource missing

## CORS for the Chrome Extension

Endpoints called from `chrome-extension://*` need CORS headers. Use the shared helpers in `@/server/cors`:

```ts
import { withCors, corsPreflight } from "@/server/cors";

export const POST = withCors(async (req) => { /* ... */ });
export function OPTIONS(req: NextRequest) { return corsPreflight(req); }
```

`withCors` echoes the request's `Origin` back in `Access-Control-Allow-Origin` if it matches the Chrome extension format (`^chrome-extension://[a-p]{32}$`); otherwise it sends `null` (browsers block reading the response; Postman/curl ignore CORS and still see the body).

Always pair non-GET handlers with an `OPTIONS` handler for the CORS preflight.

## Anti-patterns

- ❌ Doing DB queries directly in the handler — delegate to `server/`.
- ❌ Swallowing errors with `try/catch` that returns `200` — let real errors bubble or map them to typed responses.
- ❌ `req.json()` without `safeParse` — always validate.
- ❌ Adding business logic to the handler to "make it easier" — refactor the server module instead.
85 changes: 85 additions & 0 deletions .claude/skills/extension-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Skill: messaging inside the Chrome Extension

The extension has 3 isolated execution contexts that communicate via Chrome message APIs.

## Topology

```
+----------+ +-------------------+ +-----------+
| popup | <--> | background (SW) | <--> | content |
+----------+ +-------------------+ +-----------+
|
v
+---------------+
| dashboard API |
+---------------+
```

## Rules

- **All `fetch` calls to the dashboard API live in `background.ts`.**
Never `fetch` from `content.ts` or `popup.ts` — they don't hold the JWT and CORS blocks them anyway.
- **JWT is stored in `chrome.storage.local`, read only by the service worker.**
Content scripts and popup never touch the token directly.
- **content.ts** parses the current page (DOM, metadata, tracks) and sends events to background.
- **popup.ts** controls UI; it queries background for state and triggers actions.

## Typed message envelope

Define a single discriminated union and a matching Zod schema in `extension/shared/messages.ts`:

```ts
import { z } from "zod";

export const ExtensionMessage = z.discriminatedUnion("type", [
z.object({ type: z.literal("PAGE_PARSED"), data: ParsedPage }),
z.object({ type: z.literal("TRACK_CAPTURED"), data: TrackInfo }),
z.object({ type: z.literal("GET_SESSION_STATE") }),
z.object({ type: z.literal("START_SESSION") }),
z.object({ type: z.literal("STOP_SESSION") }),
z.object({ type: z.literal("SYNC_NOW") }),
]);
export type ExtensionMessage = z.infer<typeof ExtensionMessage>;
```

Always validate incoming messages — content scripts from other extensions can post to your listener:

```ts
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
const parsed = ExtensionMessage.safeParse(msg);
if (!parsed.success) return;
handleMessage(parsed.data).then(sendResponse);
return true; // keep channel open for async response
});
```

## Send direction

- popup → background: `chrome.runtime.sendMessage(msg)`
- background → content (specific tab): `chrome.tabs.sendMessage(tabId, msg)`
- content → background: `chrome.runtime.sendMessage(msg)`
- background → popup: response to the popup's `sendMessage` promise

## Service worker lifecycle

Chrome may unload a Manifest V3 service worker at any time when idle.

- Persist all state in `chrome.storage.local` — never in module-level variables.
- For periodic work (sync polling, session timer), use `chrome.alarms`, not `setInterval` (it dies on unload).
- On every event the worker handles, re-read state from storage; don't trust cached in-memory state.

## Network failures

Events from content scripts must not be lost when the network drops:

1. Try to send the event/batch via `fetch`.
2. On failure, queue it in `chrome.storage.local` under a `pendingEvents` key.
3. Re-try on `online` event with exponential backoff (1s, 2s, 4s, …, cap at 60s).
4. After N failed attempts, surface a sync-error indicator to the popup.

## Anti-patterns

- ❌ `fetch` directly from `content.ts` — JWT not available; CORS blocks the request anyway.
- ❌ Storing JWT in any tab's `localStorage` — per-origin and gets cleared.
- ❌ `setInterval` in service worker — unreliable in MV3.
- ❌ Importing TypeScript types from `dashboard/` — breaks isolation, complicates bundling.
94 changes: 94 additions & 0 deletions .claude/skills/prisma-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Skill: add or change a Prisma model

Use when editing `dashboard/prisma/schema.prisma`.

## Steps

1. Edit `dashboard/prisma/schema.prisma` — add/modify model, field, or relation.
2. Add `@@index([fk_field])` on every foreign-key column.
Postgres does NOT auto-index FKs (unlike MySQL); without this, joins do full table scans.
3. Add `@unique` (single column) or `@@unique([a, b])` (compound) for natural keys.
4. For owned children (e.g., `Session` owned by `User`), set `onDelete: Cascade`:
```prisma
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
```
5. Ensure local Postgres is running: `docker compose up -d` (from repo root).
6. Generate the migration AND apply it locally:
```
cd dashboard
npx prisma migrate dev --name <descriptive_name_in_snake_case>
```
7. Inspect `dashboard/prisma/migrations/<timestamp>_<name>/migration.sql` — make sure the SQL matches what you expected.
8. Commit BOTH the schema change AND the migration files.

## Conventions

- IDs: `String @id @default(cuid())`
- Server-set timestamps: `createdAt DateTime @default(now())`, `updatedAt DateTime @updatedAt`
- Client-set timestamps (e.g., when an event happened in the browser): explicit, no default
- Optional fields: `?` suffix — `content String?`
- Arrays of primitives: `tags String[]` (Postgres native array)

## Generated artifacts

Two generators run on every `prisma generate`:

- `dashboard/generated/prisma/` — Prisma client (entry: `client.ts`)
- `dashboard/generated/zod/` — Zod schemas via `prisma-zod-generator` (one schema file per Prisma input/output type, under `schemas/objects/`)

Both folders are gitignored.

## Imports

```ts
// Prisma client (DB access)
import { PrismaClient } from "@/generated/prisma/client";

// Zod base schemas (for derived API schemas in server/schemas/)
import { EventUncheckedCreateInputObjectZodSchema } from "@/generated/zod/schemas/objects/EventUncheckedCreateInput.schema";
```

NOT `@prisma/client` — that path is for Prisma 6 and older. Prisma 7 outputs to the custom folder defined by `generator client { output = "..." }` in `schema.prisma`.

## When schema changes

Running `npx prisma migrate dev --name <name>` regenerates BOTH the Prisma client and the Zod schemas. Any `server/schemas/<domain>.ts` derived schemas will automatically pick up the new fields via `.omit()` / `.extend()` chains — no manual updates needed unless field semantics changed.

## Singleton in dev

Next.js dev hot-reload would create a new `PrismaClient` on every reload, exhausting connections. Use a singleton in `dashboard/server/db.ts`.

Prisma 7's new `prisma-client` generator requires a driver adapter at runtime (no built-in engine). Use `@prisma/adapter-pg` for local Postgres / Neon direct connection. For Neon production with pooled connection, swap to `@prisma/adapter-neon` (in a future deploy PR).

```ts
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

function createPrismaClient() {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL is not set");
}
const adapter = new PrismaPg({ connectionString });
return new PrismaClient({ adapter, log: ["warn", "error"] });
}

export const prisma = globalForPrisma.prisma ?? createPrismaClient();

if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
```

## Production migrations

On Vercel deploy, run `npx prisma migrate deploy` (not `migrate dev`) — it applies pending migrations without prompting and never alters the schema.

## Anti-patterns

- ❌ Editing schema without running `migrate dev` — DB drifts from code.
- ❌ Editing existing migration SQL files — generate a new migration instead.
- ❌ Using `db push` in production — it skips migration history.
- ❌ Importing `@prisma/client` — wrong path for Prisma 7.
14 changes: 14 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Normalize line endings — LF in repo, LF in working tree on all platforms.
* text=auto eol=lf

# Binary file types — never touch line endings.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.zip binary
*.pdf binary
*.woff binary
*.woff2 binary
44 changes: 44 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
## What

<!-- 1–3 sentences: what this PR does and why. If it closes one AC, say so here. -->

## Changes

### Code

<!-- file or module per bullet; what changed and why in one line. Skip if PR has no code. -->
-

### Tooling / config

<!-- package.json, tsconfig, vite/next config, env, gitignore, docker-compose, etc. Skip if none. -->
-

### Docs / plan

<!-- plan file, README, CLAUDE.md, .claude/skills/*, docs/*. Always include the plan link here. -->
- `plans/weekN/<name>.md` — design rationale
-

## Design decisions

<!-- 2–6 bullets of the key calls and their rationale. Cite the plan for full reasoning. -->
-

## Verification

<!-- checklist of what was actually tested locally. Sub-sections (Smoke tests / Manual UI / Screenshots) are optional. -->
- [ ] `npx tsc --noEmit` (dashboard) — clean
- [ ] `npm run lint` (dashboard) — clean
- [ ] `npm run typecheck` (extension) — clean
- [ ] Manual test: <!-- what you actually did, or "n/a" if docs/config only -->

## Out of scope (future PRs)

<!-- intentionally not in this PR — keeps reviewers from asking. Link the AC if it's already planned. -->
-

## Closes

<!-- e.g. Week 1 AC 5 (docs/Task.md#L59) — or "n/a" for repo-meta PRs without an AC. -->
-
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: CI

on:
pull_request:
branches: [development, main]
push:
branches: [development, main]

jobs:
dashboard:
runs-on: ubuntu-latest
defaults:
run:
working-directory: dashboard
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: dashboard/package-lock.json
- run: npm ci
- run: npx prisma generate
env:
# prisma.config.ts uses prisma/config's strict env() helper, which
# throws if DATABASE_URL is unset. `prisma generate` does not connect
# to the database — it only reads the schema and writes the client —
# so a dummy URL satisfies the config loader.
DATABASE_URL: "postgresql://prisma:prisma@localhost:5432/prisma"
- run: npm run lint
- run: npx tsc --noEmit

extension:
runs-on: ubuntu-latest
defaults:
run:
working-directory: extension
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: extension/package-lock.json
- run: npm ci
- run: npm run typecheck
17 changes: 17 additions & 0 deletions .github/workflows/close-stale.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: Close stale sessions

on:
schedule:
- cron: "*/15 * * * *"
workflow_dispatch:

jobs:
close-stale:
runs-on: ubuntu-latest
steps:
- name: Call cron endpoint
run: |
curl --fail --silent --show-error \
-X GET \
-H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
"${{ secrets.VERCEL_APP_URL }}/api/cron/close-stale"
Loading
Loading