From 563ac7adcce050426b24f9664c9e0576b5d56422 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Fri, 22 May 2026 00:13:24 +0300 Subject: [PATCH 1/4] docs(plans): add week 2 AC 2+3 content script and session manager plan --- plans/week2/AC2-AC3-content-sessions.md | 140 ++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 plans/week2/AC2-AC3-content-sessions.md diff --git a/plans/week2/AC2-AC3-content-sessions.md b/plans/week2/AC2-AC3-content-sessions.md new file mode 100644 index 0000000..c2a3816 --- /dev/null +++ b/plans/week2/AC2-AC3-content-sessions.md @@ -0,0 +1,140 @@ +# Plan: Week 2 AC 2+3 — Content Script + Session Manager + +**Branch:** `feature/extension-content-sessions` +**Closes:** Week 2 AC 2, AC 3 ([docs/Task.md L75–L77](../../docs/Task.md#L75)) +**Status:** plan, awaiting review + +--- + +## Scope + +**AC 2 — Content Script:** парсить технічний контент поточної сторінки (url, title, meta description, h1–h3) і надсилає до service worker. + +**AC 3 — Session Manager:** старт/стоп сесії вручну, таймер активного часу, збереження стану у `chrome.storage.local` з відновленням після рестарту service worker. + +Разом вони формують **pipeline збору даних**: content script знає *що* зібрати, session manager знає *коли* це актуально. AC 5 (Data Sync) з'єднає їх з API. + +--- + +## Commits (ordered) + +``` +docs(plans): add week 2 AC 2+3 content script and session manager plan +feat(extension): add content script page metadata parser (AC 2) +feat(extension): add session manager with start/stop/timer and storage (AC 3) +``` + +--- + +## Files + +| File | Action | Commit | +|---|---|---| +| `plans/week2/AC2-AC3-content-sessions.md` | **new** | commit 1 | +| `extension/src/types/content.ts` | **new** | commit 2 | +| `extension/src/content/index.ts` | **rewrite** | commit 2 | +| `extension/src/types/session.ts` | **new** | commit 3 | +| `extension/src/background/session.ts` | **new** | commit 3 | +| `extension/src/background/index.ts` | modify — add session message handlers | commit 3 | + +--- + +## Design decisions + +### AC 2 — Content Script + +#### 1. Що парсимо і як + +``` +url → window.location.href +title → document.title +metaDescription → document.querySelector('meta[name="description"]')?.content ?? null +headings → Array.from(document.querySelectorAll("h1, h2, h3")) + .map(el => el.textContent?.trim()) + .filter(Boolean) +``` + +Все в один об'єкт `PageMetadata`, надсилається через `chrome.runtime.sendMessage`. + +#### 2. Коли надсилати + +- При завантаженні (`document_idle` — вже є в manifest) +- При зміні URL у SPA: слухаємо `popstate` + `hashchange` + +Без polling — event-driven підхід, без зайвих запитів. + +#### 3. Чи відправляємо якщо немає активної сесії? + +Ні — background ігнорує `PAGE_METADATA` якщо `session === null`. Це означає content script завжди надсилає (дешево), а фільтрація на стороні background. + +--- + +### AC 3 — Session Manager + +#### 4. Структура Session + +```ts +interface Session { + id: string; // crypto.randomUUID() + startedAt: number; // Unix ms + totalActiveMs: number; // накопичений активний час (для паузи в AC 4) +} +``` + +Таймер **не зберігає `endedAt`** — поточний elapsed розраховується як: +``` +elapsed = totalActiveMs + (Date.now() - startedAt) +``` + +Це безпечно після рестарту SW: значення `startedAt` і `totalActiveMs` є в storage. + +#### 5. Persistence: чому storage, а не тільки пам'ять + +MV3 service worker може бути зупинений браузером у будь-який момент. Якщо сесія тільки в пам'яті — після рестарту вона зникає. Тому `Session` одразу пишеться в `chrome.storage.local` і читається при старті. + +#### 6. Модульність: `background/session.ts` окремо від `index.ts` + +`index.ts` залишається orchestrator (слухає повідомлення, делегує). Вся логіка сесій — у `session.ts`. Так само `apiFetch` / auth — теж уже там. Це підготовка до AC 5 де буде ще батчинг подій. + +#### 7. `chrome.storage.local` ключі для сесії + +``` +activeSession: Session | null +``` + +Один ключ із повним об'єктом — атомарний read/write, без race conditions між кількома `set` викликами. + +#### 8. Message types + +```ts +type SessionMessage = + | { type: "SESSION_START" } + | { type: "SESSION_STOP" } + | { type: "SESSION_GET_STATE" }; + +type SessionResponse = + | { success: true; session: Session | null } + | { success: false; error: string }; +``` + +Popup (AC 4) використовуватиме `SESSION_GET_STATE` для відображення таймера і статусу. + +--- + +## Verification checklist + +- [ ] `cd extension && npx tsc --noEmit` — без помилок +- [ ] `npm run build` — без помилок +- [ ] `SESSION_START` → session з'являється в `chrome.storage.local` +- [ ] `SESSION_STOP` → `activeSession: null` в storage +- [ ] `SESSION_GET_STATE` → повертає поточну сесію або null +- [ ] `PAGE_METADATA` надсилається при load і при навігації в SPA +- [ ] `PAGE_METADATA` ігнорується якщо сесія не активна + +--- + +## Out of scope + +- Пауза сесії — AC 4 (потрібна кнопка в popup) +- Відправка зібраних подій на API — AC 5 +- Відображення таймера і статусу — AC 4 From e55c36834d917f8794c974ae31ef3ce428a9d7f1 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Fri, 22 May 2026 00:19:21 +0300 Subject: [PATCH 2/4] feat(extension): add content script page metadata parser (AC 2) --- extension/src/content/index.ts | 34 +++++++++++++++++++++++++++++++++- extension/src/types/content.ts | 8 ++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 extension/src/types/content.ts diff --git a/extension/src/content/index.ts b/extension/src/content/index.ts index 208a5dd..99292e2 100644 --- a/extension/src/content/index.ts +++ b/extension/src/content/index.ts @@ -1 +1,33 @@ -console.log("[worktrace] content script loaded", location.href); +import type { ContentMessage, PageMetadata } from "../types/content"; + +function parseMetadata(): PageMetadata { + const headings = Array.from(document.querySelectorAll("h1, h2, h3")) + .map((el) => el.textContent?.trim() ?? "") + .filter((text) => text.length > 0); + + return { + url: window.location.href, + title: document.title, + metaDescription: + document.querySelector('meta[name="description"]') + ?.content ?? null, + headings, + }; +} + +function sendMetadata(): void { + const message: ContentMessage = { + type: "PAGE_METADATA", + payload: parseMetadata(), + }; + chrome.runtime.sendMessage(message).catch(() => { + // Service worker may be inactive — silently ignore + }); +} + +// Send on initial load +sendMetadata(); + +// Re-send on SPA navigation +window.addEventListener("popstate", sendMetadata); +window.addEventListener("hashchange", sendMetadata); diff --git a/extension/src/types/content.ts b/extension/src/types/content.ts new file mode 100644 index 0000000..bccae5c --- /dev/null +++ b/extension/src/types/content.ts @@ -0,0 +1,8 @@ +export interface PageMetadata { + url: string; + title: string; + metaDescription: string | null; + headings: string[]; // h1–h3 text content +} + +export type ContentMessage = { type: "PAGE_METADATA"; payload: PageMetadata }; From 8c27d276d0c78057474933f70997cdd821bb1632 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Fri, 22 May 2026 00:19:29 +0300 Subject: [PATCH 3/4] feat(extension): add session manager with start/stop/timer and storage (AC 3) --- extension/src/background/index.ts | 77 ++++++++++++++++++++++++++++- extension/src/background/session.ts | 47 ++++++++++++++++++ extension/src/types/session.ts | 14 ++++++ 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 extension/src/background/session.ts create mode 100644 extension/src/types/session.ts diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index aef3cc0..c627ae0 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -1,4 +1,12 @@ import type { AuthMessage, AuthResponse, StoredAuth } from "../types/auth"; +import type { ContentMessage } from "../types/content"; +import type { SessionMessage, SessionResponse } from "../types/session"; +import { + getSession, + startSession, + stopSession, + getElapsedMs, +} from "./session"; const DASHBOARD_URL = import.meta.env.VITE_DASHBOARD_URL as string; const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID as string; @@ -101,8 +109,10 @@ export async function apiFetch( // ─── Message listener ────────────────────────────────────────────────────────── +type IncomingMessage = AuthMessage | SessionMessage | ContentMessage; + chrome.runtime.onMessage.addListener( - (message: AuthMessage, _sender, sendResponse: (r: AuthResponse) => void) => { + (message: IncomingMessage, _sender, sendResponse: (r: AuthResponse | SessionResponse) => void) => { if (message.type === "AUTH_LOGIN") { ensureAuthenticated() .then((jwt) => sendResponse({ success: true, jwt })) @@ -144,6 +154,71 @@ chrome.runtime.onMessage.addListener( return true; } + // ─── Session messages ──────────────────────────────────────────────────── + + if (message.type === "SESSION_START") { + startSession() + .then((session) => sendResponse({ success: true, session })) + .catch((err: unknown) => + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }), + ); + return true; + } + + if (message.type === "SESSION_STOP") { + stopSession() + .then((session) => sendResponse({ success: true, session })) + .catch((err: unknown) => + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }), + ); + return true; + } + + if (message.type === "SESSION_GET_STATE") { + getSession() + .then((session) => { + if (session) { + // Attach live elapsed for the popup timer + const sessionWithElapsed = { + ...session, + elapsedMs: getElapsedMs(session), + }; + sendResponse({ success: true, session: sessionWithElapsed }); + } else { + sendResponse({ success: true, session: null }); + } + }) + .catch((err: unknown) => + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }), + ); + return true; + } + + // ─── Content script messages ───────────────────────────────────────────── + + if (message.type === "PAGE_METADATA") { + // Store metadata only when a session is active (AC 5 will batch-send it) + getSession().then((session) => { + if (!session) return; + // Stored for AC 5 batch sync — no response needed + chrome.storage.local.get("pendingEvents").then((r) => { + const pending = (r["pendingEvents"] as unknown[]) ?? []; + pending.push({ ...message.payload, timestamp: new Date().toISOString() }); + chrome.storage.local.set({ pendingEvents: pending }); + }); + }); + return false; // no async response needed + } + return false; }, ); diff --git a/extension/src/background/session.ts b/extension/src/background/session.ts new file mode 100644 index 0000000..a9b6a27 --- /dev/null +++ b/extension/src/background/session.ts @@ -0,0 +1,47 @@ +import type { Session } from "../types/session"; + +const STORAGE_KEY = "activeSession"; + +// ─── Storage ─────────────────────────────────────────────────────────────────── + +export async function getSession(): Promise { + const r = await chrome.storage.local.get(STORAGE_KEY); + return (r[STORAGE_KEY] as Session) ?? null; +} + +async function saveSession(session: Session): Promise { + await chrome.storage.local.set({ [STORAGE_KEY]: session }); +} + +async function clearSession(): Promise { + await chrome.storage.local.remove(STORAGE_KEY); +} + +// ─── Timer ───────────────────────────────────────────────────────────────────── + +// Elapsed ms is derived from stored values — safe after SW restart +export function getElapsedMs(session: Session): number { + return session.totalActiveMs + (Date.now() - session.startedAt); +} + +// ─── Actions ────────────────────────────────────────────────────────────────── + +export async function startSession(): Promise { + const existing = await getSession(); + if (existing) return existing; // idempotent + + const session: Session = { + id: crypto.randomUUID(), + startedAt: Date.now(), + totalActiveMs: 0, + }; + await saveSession(session); + return session; +} + +export async function stopSession(): Promise { + const session = await getSession(); + if (!session) return null; + await clearSession(); + return session; +} diff --git a/extension/src/types/session.ts b/extension/src/types/session.ts new file mode 100644 index 0000000..5da74da --- /dev/null +++ b/extension/src/types/session.ts @@ -0,0 +1,14 @@ +export interface Session { + id: string; + startedAt: number; // Unix ms + totalActiveMs: number; // accumulated active time (for future pause support) +} + +export type SessionMessage = + | { type: "SESSION_START" } + | { type: "SESSION_STOP" } + | { type: "SESSION_GET_STATE" }; + +export type SessionResponse = + | { success: true; session: Session | null } + | { success: false; error: string }; From 786e9160cd077b0a936f932d33bc79bc49c1afb3 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Fri, 22 May 2026 00:21:29 +0300 Subject: [PATCH 4/4] docs(plans): update AC 2+3 plan with identified gaps for AC 4 and AC 5 --- plans/week2/AC2-AC3-content-sessions.md | 59 ++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/plans/week2/AC2-AC3-content-sessions.md b/plans/week2/AC2-AC3-content-sessions.md index c2a3816..aed7d10 100644 --- a/plans/week2/AC2-AC3-content-sessions.md +++ b/plans/week2/AC2-AC3-content-sessions.md @@ -2,7 +2,7 @@ **Branch:** `feature/extension-content-sessions` **Closes:** Week 2 AC 2, AC 3 ([docs/Task.md L75–L77](../../docs/Task.md#L75)) -**Status:** plan, awaiting review +**Status:** implemented — gaps identified, see section below --- @@ -138,3 +138,60 @@ Popup (AC 4) використовуватиме `SESSION_GET_STATE` для ві - Пауза сесії — AC 4 (потрібна кнопка в popup) - Відправка зібраних подій на API — AC 5 - Відображення таймера і статусу — AC 4 + +--- + +## ⚠️ Gaps identified after implementation (впливають на AC 4 і AC 5) + +### Gap 1 — PAUSE/RESUME відсутні в session.ts → блокер для AC 4 + +AC 4 вимагає "кнопку паузи збору". Логіка паузи — в `background/session.ts`, а не в попапі. `totalActiveMs` вже готовий для цього, але `SESSION_PAUSE` / `SESSION_RESUME` повідомлень і методів у `session.ts` немає. + +**Рішення для AC 4:** у тому самому `session.ts` додати: +```ts +export async function pauseSession(): Promise +export async function resumeSession(): Promise +``` +При паузі: `totalActiveMs += Date.now() - startedAt`, `startedAt = null`. +При відновленні: `startedAt = Date.now()`. +Потрібно розширити тип `Session` полем `pausedAt: number | null`. + +--- + +### Gap 2 — Немає `POST /api/v1/sessions` → блокер для AC 5 + +Task.md не описує цей endpoint, але він необхідний: `Event.sessionId` — FK до `Session` у БД. Локальний `session.id = crypto.randomUUID()` ≠ CUID з БД. + +**Рішення для AC 5:** +- Додати `POST /api/v1/sessions` (створює Session в БД, повертає `{ id }`) +- Додати `PATCH /api/v1/sessions/:id` (закриває сесію — записує `endedAt`) +- При `SESSION_START` у service worker → одразу викликати `apiFetch("/api/v1/sessions", { method: "POST" })` і зберегти DB-шний `sessionId` поруч з локальною сесією + +Розширення локального типу: +```ts +interface Session { + id: string; // локальний UUID + dbSessionId: string | null; // CUID з БД, null до першого sync + ... +} +``` + +--- + +### Gap 3 — Нотатки з AC 4 popup нікуди не потраплять + +AC 4: "поле для швидких нотаток і тегів". Модель `Session` не має поля `notes`. Модель `Event` має `content: String?` і `tags: String[]`. + +**Рішення:** нотатку зберігати як окремий `Event` з `tags: ["note"]` і `content: <текст нотатки>`. Не потрібна міграція схеми. + +--- + +### Gap 4 — Формат `Event.content` не визначено + +`Event.content: String?` є в схемі, але Task.md не описує що туди класти. По логіці з `PageMetadata`: + +``` +content = [metaDescription, ...headings].filter(Boolean).join(" | ") +``` + +Фіксуємо цей формат тут, щоб AC 5 і Week 3 dashboard читали однаково.