From 33e1918a31cea050bb31a10e125c2a73a034cdec Mon Sep 17 00:00:00 2001 From: Bohdan Date: Fri, 22 May 2026 00:32:42 +0300 Subject: [PATCH 1/6] docs(plans): add week 2 AC 4 popup UI plan --- plans/week2/AC4-popup.md | 120 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 plans/week2/AC4-popup.md diff --git a/plans/week2/AC4-popup.md b/plans/week2/AC4-popup.md new file mode 100644 index 0000000..dec090f --- /dev/null +++ b/plans/week2/AC4-popup.md @@ -0,0 +1,120 @@ +# Plan: Week 2 AC 4 — Popup UI + +**Branch:** `feature/extension-popup` +**Closes:** Week 2 AC 4 ([docs/Task.md L77](../../docs/Task.md#L77)) +**Status:** plan, awaiting review + +--- + +## Scope + +**AC 4 — Popup UI:** відображає статус сесії, таймер, індикатор синхронізації, кнопку паузи, поле нотаток і тегів. + +Також закриває **Gap 1** з AC 3 плану: `SESSION_PAUSE` / `SESSION_RESUME` — логіка в `session.ts`, тригер у попапі. + +--- + +## Commits (ordered) + +``` +docs(plans): add week 2 AC 4 popup UI plan +feat(extension): add session pause/resume to session manager (Gap 1 from AC 3) +feat(extension): implement popup UI with session controls, timer, notes and tags (AC 4) +``` + +--- + +## Files + +| File | Action | Commit | +|---|---|---| +| `plans/week2/AC4-popup.md` | **new** | commit 1 | +| `extension/src/types/session.ts` | modify — add `pausedAt`, `SESSION_PAUSE/RESUME` | commit 2 | +| `extension/src/background/session.ts` | modify — add `pauseSession`, `resumeSession` | commit 2 | +| `extension/src/background/index.ts` | modify — add PAUSE/RESUME message handlers | commit 2 | +| `extension/src/popup/index.html` | **rewrite** | commit 3 | +| `extension/src/popup/popup.css` | **rewrite** | commit 3 | +| `extension/src/popup/popup.ts` | **rewrite** | commit 3 | + +--- + +## Design decisions + +### 1. Pause/Resume — розширення Session + +```ts +interface Session { + id: string; + startedAt: number; // Unix ms, null поки на паузі + totalActiveMs: number; // накопичений час до паузи + pausedAt: number | null; // null = активна, число = на паузі +} +``` + +При паузі: `totalActiveMs += Date.now() - startedAt`, `pausedAt = Date.now()`, `startedAt` зберігаємо (для відновлення). +При відновленні: `startedAt = Date.now()`, `pausedAt = null`. + +`getElapsedMs` враховує стан паузи: +``` +якщо pausedAt → повертаємо totalActiveMs (фіксований) +інакше → totalActiveMs + (Date.now() - startedAt) +``` + +### 2. Таймер у popup — polling через `setInterval` + +Popup не може підписатися на зміни storage. Найпростіше рішення: `setInterval` кожну секунду надсилає `SESSION_GET_STATE` і оновлює DOM. Інтервал очищається при закритті попапа (`window.addEventListener("unload")`). + +Альтернатива — `chrome.storage.onChanged` — складніша і надлишкова для таймера. + +### 3. Нотатки — надсилаються як `NOTE_ADD` повідомлення + +Кнопка "Додати нотатку" → `chrome.runtime.sendMessage({ type: "NOTE_ADD", text, tags })` → background зберігає в `pendingEvents` як `{ ...note, tags: ["note", ...userTags], timestamp }`. Вже узгоджено з Gap 3 з AC 3 плану. + +Нотатка надсилається тільки якщо сесія активна (не на паузі і не зупинена). + +### 4. Теги — comma-separated input + +Поле `` → `value.split(",").map(t => t.trim()).filter(Boolean)`. Прості, без autocomplete (AC 4 MVP). + +### 5. Індикатор синхронізації + +Відображає кількість `pendingEvents` з `chrome.storage.local` — це показує скільки подій чекають на AC 5 sync. Формат: `⏳ 12 events pending`. Оновлюється разом з таймером (той самий `setInterval`). + +### 6. Структура popup HTML + +``` +┌─────────────────────────┐ +│ WorkTrace [●] │ ← статус (active / paused / idle) +├─────────────────────────┤ +│ 00:12:34 │ ← таймер +│ [▶ Start] [⏸ Pause] │ ← або [■ Stop] залежно від стану +├─────────────────────────┤ +│ ⏳ 5 events pending │ ← sync indicator +├─────────────────────────┤ +│ Note: [____________] │ +│ Tags: [____________] │ +│ [+ Add note] │ +└─────────────────────────┘ +``` + +--- + +## Verification checklist + +- [ ] `npx tsc --noEmit` — без помилок +- [ ] `npm run build` — без помилок +- [ ] Start → таймер тікає, статус "active" +- [ ] Pause → таймер зупиняється на поточному значенні, статус "paused" +- [ ] Resume → таймер продовжує з того ж місця +- [ ] Stop → таймер скидається, статус "idle" +- [ ] `SESSION_PAUSE` → `pausedAt` з'являється в `chrome.storage.local` +- [ ] Note + tags → з'являються в `pendingEvents` з `tags: ["note", ...]` +- [ ] Sync indicator показує реальну кількість `pendingEvents` + +--- + +## Out of scope + +- Фактична відправка подій на API — AC 5 +- Стилізація анімацій — Week 4 +- Autocomplete для тегів — Week 4 From 3539a5bbfd67cb85e8db0ec45821fd328fae215a Mon Sep 17 00:00:00 2001 From: Bohdan Date: Fri, 22 May 2026 00:34:29 +0300 Subject: [PATCH 2/6] feat(extension): add session pause/resume to session manager (Gap 1 from AC 3) --- extension/src/background/index.ts | 68 ++++++++++++++++++++++++++++- extension/src/background/session.ts | 28 ++++++++++++ extension/src/types/session.ts | 16 +++++-- 3 files changed, 107 insertions(+), 5 deletions(-) diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index c627ae0..3921012 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -5,6 +5,8 @@ import { getSession, startSession, stopSession, + pauseSession, + resumeSession, getElapsedMs, } from "./session"; @@ -158,7 +160,12 @@ chrome.runtime.onMessage.addListener( if (message.type === "SESSION_START") { startSession() - .then((session) => sendResponse({ success: true, session })) + .then((session) => + sendResponse({ + success: true, + session: { ...session, elapsedMs: getElapsedMs(session) }, + }), + ) .catch((err: unknown) => sendResponse({ success: false, @@ -170,7 +177,12 @@ chrome.runtime.onMessage.addListener( if (message.type === "SESSION_STOP") { stopSession() - .then((session) => sendResponse({ success: true, session })) + .then((session) => + sendResponse({ + success: true, + session: session ? { ...session, elapsedMs: getElapsedMs(session) } : null, + }), + ) .catch((err: unknown) => sendResponse({ success: false, @@ -203,6 +215,58 @@ chrome.runtime.onMessage.addListener( return true; } + if (message.type === "SESSION_PAUSE") { + pauseSession() + .then((session) => + sendResponse({ + success: true, + session: session ? { ...session, elapsedMs: getElapsedMs(session) } : null, + }), + ) + .catch((err: unknown) => + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }), + ); + return true; + } + + if (message.type === "SESSION_RESUME") { + resumeSession() + .then((session) => + sendResponse({ + success: true, + session: session ? { ...session, elapsedMs: getElapsedMs(session) } : null, + }), + ) + .catch((err: unknown) => + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }), + ); + return true; + } + + if (message.type === "NOTE_ADD") { + getSession().then((session) => { + if (!session || session.pausedAt !== null) return; + chrome.storage.local.get("pendingEvents").then((r) => { + const pending = (r["pendingEvents"] as unknown[]) ?? []; + pending.push({ + url: "", + title: "Note", + content: message.text, + tags: ["note", ...message.tags], + timestamp: new Date().toISOString(), + }); + chrome.storage.local.set({ pendingEvents: pending }); + }); + }); + return false; + } + // ─── Content script messages ───────────────────────────────────────────── if (message.type === "PAGE_METADATA") { diff --git a/extension/src/background/session.ts b/extension/src/background/session.ts index a9b6a27..fa27fc3 100644 --- a/extension/src/background/session.ts +++ b/extension/src/background/session.ts @@ -21,6 +21,7 @@ async function clearSession(): Promise { // Elapsed ms is derived from stored values — safe after SW restart export function getElapsedMs(session: Session): number { + if (session.pausedAt !== null) return session.totalActiveMs; // frozen while paused return session.totalActiveMs + (Date.now() - session.startedAt); } @@ -34,6 +35,7 @@ export async function startSession(): Promise { id: crypto.randomUUID(), startedAt: Date.now(), totalActiveMs: 0, + pausedAt: null, }; await saveSession(session); return session; @@ -45,3 +47,29 @@ export async function stopSession(): Promise { await clearSession(); return session; } + +export async function pauseSession(): Promise { + const session = await getSession(); + if (!session || session.pausedAt !== null) return session; // idempotent + + const updated: Session = { + ...session, + totalActiveMs: session.totalActiveMs + (Date.now() - session.startedAt), + pausedAt: Date.now(), + }; + await saveSession(updated); + return updated; +} + +export async function resumeSession(): Promise { + const session = await getSession(); + if (!session || session.pausedAt === null) return session; // idempotent + + const updated: Session = { + ...session, + startedAt: Date.now(), + pausedAt: null, + }; + await saveSession(updated); + return updated; +} diff --git a/extension/src/types/session.ts b/extension/src/types/session.ts index 5da74da..93597b3 100644 --- a/extension/src/types/session.ts +++ b/extension/src/types/session.ts @@ -1,14 +1,24 @@ export interface Session { id: string; startedAt: number; // Unix ms - totalActiveMs: number; // accumulated active time (for future pause support) + totalActiveMs: number; // accumulated active time + pausedAt: number | null; // null = active, number = paused since +} + +// Returned by SESSION_GET_STATE — includes computed elapsed +export interface SessionState extends Session { + elapsedMs: number; } export type SessionMessage = | { type: "SESSION_START" } | { type: "SESSION_STOP" } - | { type: "SESSION_GET_STATE" }; + | { type: "SESSION_GET_STATE" } + | { type: "SESSION_PAUSE" } + | { type: "SESSION_RESUME" } + | { type: "NOTE_ADD"; text: string; tags: string[] }; export type SessionResponse = - | { success: true; session: Session | null } + | { success: true; session: SessionState | null } + | { success: true } | { success: false; error: string }; From eeb80ea7bf333a1fc809d1886cef4b59383ff754 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Fri, 22 May 2026 01:00:27 +0300 Subject: [PATCH 3/6] feat(extension): implement popup UI with session controls and timer (AC 4) --- extension/src/popup/index.html | 55 +++++- extension/src/popup/popup.css | 298 ++++++++++++++++++++++++++++++--- extension/src/popup/popup.ts | 198 +++++++++++++++++++--- 3 files changed, 500 insertions(+), 51 deletions(-) diff --git a/extension/src/popup/index.html b/extension/src/popup/index.html index 068fed1..8cbd2ca 100644 --- a/extension/src/popup/index.html +++ b/extension/src/popup/index.html @@ -7,14 +7,55 @@ -
-

WorkTrace

-
-

Checking...

- - +
+ + + + + diff --git a/extension/src/popup/popup.css b/extension/src/popup/popup.css index cc10e08..6463e88 100644 --- a/extension/src/popup/popup.css +++ b/extension/src/popup/popup.css @@ -1,39 +1,287 @@ +/* ─── Tokens ─────────────────────────────────────────────────────────────────── */ :root { - color-scheme: light dark; - font-family: - system-ui, - -apple-system, - "Segoe UI", - Roboto, - sans-serif; + --c-cyan: #00e5b0; + --c-purple: #7c3aff; + --c-yellow: #f0d000; + --c-pink: #ff0070; + --c-bg: #080812; + --c-surface:#0f0f20; + --c-border: #1c1c38; + --c-text: #c8d0f0; + --c-muted: #3e3e60; } -html, -body { - margin: 0; - padding: 0; -} +/* ─── Reset ──────────────────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body { width: 320px; - min-height: 200px; - background: #0f172a; - color: #e2e8f0; + background: var(--c-bg); + color: var(--c-text); + font-family: "Courier New", Courier, monospace; + font-size: 12px; + letter-spacing: 0.03em; } -main { - padding: 20px 24px; +/* ═══════════════════════════════════════════════════════════════════════════════ + BLOCK: popup +═══════════════════════════════════════════════════════════════════════════════ */ +.popup { + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; } -h1 { - margin: 0 0 8px; - font-size: 18px; - font-weight: 600; - letter-spacing: -0.01em; +/* ── popup__header ─────────────────────────────────────────────────────────── */ +.popup__header { + display: flex; + align-items: center; + justify-content: space-between; } -p { - margin: 0; +.popup__logo { + display: flex; + align-items: center; + gap: 6px; +} + +.popup__logo-icon { + font-size: 17px; + color: var(--c-cyan); + filter: drop-shadow(0 0 4px var(--c-cyan)); +} + +.popup__logo-text { font-size: 13px; - color: #94a3b8; + font-weight: 700; + color: var(--c-cyan); + text-shadow: 0 0 6px color-mix(in srgb, var(--c-cyan) 60%, transparent); +} + +/* ── popup__status ─────────────────────────────────────────────────────────── */ +.popup__status { + display: flex; + align-items: center; + gap: 6px; + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: 20px; + padding: 3px 10px; +} + +.popup__status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--c-muted); + transition: background 0.3s, box-shadow 0.3s; +} + +.popup__status-dot--active { + background: var(--c-cyan); + box-shadow: 0 0 5px var(--c-cyan); + animation: dot-pulse 2s infinite; +} + +.popup__status-dot--paused { + background: var(--c-yellow); + box-shadow: 0 0 5px var(--c-yellow); +} + +.popup__status-label { + font-size: 10px; + font-weight: 700; + color: var(--c-muted); + transition: color 0.3s; +} + +.popup__status-label--active { color: var(--c-cyan); } +.popup__status-label--paused { color: var(--c-yellow); } + +@keyframes dot-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +/* ── popup__auth ───────────────────────────────────────────────────────────── */ +.popup__auth { + display: flex; + justify-content: center; + padding: 6px 0; +} + +/* ── popup__timer ──────────────────────────────────────────────────────────── */ +.popup__timer { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 8px 0 2px; +} + +/* ── popup__time ───────────────────────────────────────────────────────────── */ +.popup__time { + font-size: 42px; + font-weight: 700; + letter-spacing: 0.05em; + color: var(--c-muted); + transition: color 0.3s, text-shadow 0.3s; +} + +.popup__time--active { + color: var(--c-cyan); + text-shadow: 0 0 12px color-mix(in srgb, var(--c-cyan) 50%, transparent); +} + +.popup__time--paused { + color: var(--c-yellow); + text-shadow: 0 0 10px color-mix(in srgb, var(--c-yellow) 40%, transparent); +} + +/* ── popup__controls ───────────────────────────────────────────────────────── */ +.popup__controls { + display: flex; + gap: 8px; +} + +/* ── popup__sync ───────────────────────────────────────────────────────────── */ +.popup__sync { + display: flex; + align-items: center; + gap: 6px; + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: 4px; + padding: 6px 10px; + font-size: 11px; + color: var(--c-muted); + transition: border-color 0.3s, color 0.3s; +} + +.popup__sync-icon { color: var(--c-yellow); } + +.popup__sync--has-events { + border-color: color-mix(in srgb, var(--c-yellow) 25%, transparent); + color: var(--c-yellow); +} + +/* ── popup__notes ──────────────────────────────────────────────────────────── */ +.popup__notes { + display: flex; + flex-direction: column; + gap: 8px; + border-top: 1px solid var(--c-border); + padding-top: 12px; +} + +/* ── popup__field ──────────────────────────────────────────────────────────── */ +.popup__field { + display: grid; + grid-template-columns: 40px 1fr; + align-items: center; + gap: 8px; +} + +.popup__field-label { + font-size: 10px; + font-weight: 700; + color: var(--c-muted); +} + +.popup__field-input { + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: 4px; + color: var(--c-text); + font-family: inherit; + font-size: 11px; + padding: 5px 8px; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.popup__field-input::placeholder { color: var(--c-muted); } + +.popup__field-input:focus { + border-color: color-mix(in srgb, var(--c-purple) 70%, transparent); + box-shadow: 0 0 6px color-mix(in srgb, var(--c-purple) 20%, transparent); +} + +/* ═══════════════════════════════════════════════════════════════════════════════ + BLOCK: btn +═══════════════════════════════════════════════════════════════════════════════ */ +.btn { + background: transparent; + border: 1px solid var(--c-muted); + color: var(--c-muted); + border-radius: 4px; + padding: 6px 12px; + font-family: inherit; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s; +} + +.btn:disabled { + opacity: 0.2; + cursor: not-allowed; +} + +/* btn--primary */ +.btn--primary { + border-color: var(--c-purple); + color: var(--c-purple); + width: 100%; + padding: 10px; + font-size: 12px; +} + +.btn--primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--c-purple) 10%, transparent); + box-shadow: 0 0 10px color-mix(in srgb, var(--c-purple) 20%, transparent); +} + +/* btn--start */ +.btn--start:not(:disabled) { + border-color: var(--c-cyan); + color: var(--c-cyan); +} + +.btn--start:hover:not(:disabled) { + background: color-mix(in srgb, var(--c-cyan) 8%, transparent); + box-shadow: 0 0 8px color-mix(in srgb, var(--c-cyan) 20%, transparent); +} + +/* btn--pause */ +.btn--pause:not(:disabled) { + border-color: var(--c-yellow); + color: var(--c-yellow); +} + +.btn--pause:hover:not(:disabled) { + background: color-mix(in srgb, var(--c-yellow) 8%, transparent); +} + +/* btn--stop */ +.btn--stop:not(:disabled) { + border-color: var(--c-pink); + color: var(--c-pink); +} + +.btn--stop:hover:not(:disabled) { + background: color-mix(in srgb, var(--c-pink) 8%, transparent); +} + +/* btn--note */ +.btn--note { + border-color: var(--c-purple); + color: var(--c-purple); + width: 100%; +} + +.btn--note:hover:not(:disabled) { + background: color-mix(in srgb, var(--c-purple) 8%, transparent); } diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index b5d394d..1030279 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -1,31 +1,191 @@ import type { AuthMessage, AuthResponse } from "../types/auth"; +import type { SessionMessage, SessionResponse, SessionState } from "../types/session"; -function sendMessage(message: AuthMessage): Promise { - return new Promise((resolve) => { - chrome.runtime.sendMessage(message, resolve); - }); +// ─── Messaging helpers ───────────────────────────────────────────────────────── + +function sendAuth(msg: AuthMessage): Promise { + return new Promise((resolve, reject) => + chrome.runtime.sendMessage(msg, (res: AuthResponse | undefined) => { + if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message)); + if (!res) return reject(new Error("No response from background")); + resolve(res); + }), + ); +} + +function sendSession(msg: SessionMessage): Promise { + return new Promise((resolve, reject) => + chrome.runtime.sendMessage(msg, (res: SessionResponse | undefined) => { + if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message)); + if (!res) return reject(new Error("No response from background")); + resolve(res); + }), + ); } -const statusEl = document.getElementById("auth-status") as HTMLParagraphElement; -const btnLogin = document.getElementById("btn-login") as HTMLButtonElement; -const btnLogout = document.getElementById("btn-logout") as HTMLButtonElement; +// ─── DOM refs ────────────────────────────────────────────────────────────────── + +const statusDot = document.getElementById("status-dot") as HTMLSpanElement; +const statusLabel = document.getElementById("status-label") as HTMLSpanElement; +const authSection = document.getElementById("auth-section") as HTMLElement; +const timerSection= document.getElementById("timer-section") as HTMLElement; +const timerEl = document.getElementById("timer") as HTMLDivElement; +const btnLogin = document.getElementById("btn-login") as HTMLButtonElement; +const btnStart = document.getElementById("btn-start") as HTMLButtonElement; +const btnPause = document.getElementById("btn-pause") as HTMLButtonElement; +const btnStop = document.getElementById("btn-stop") as HTMLButtonElement; +const syncEl = document.getElementById("sync-indicator") as HTMLDivElement; +const syncLabel = document.getElementById("sync-label") as HTMLSpanElement; +const noteInput = document.getElementById("note-input") as HTMLInputElement; +const tagsInput = document.getElementById("tags-input") as HTMLInputElement; +const btnNote = document.getElementById("btn-note") as HTMLButtonElement; + +// ─── Timer formatting ────────────────────────────────────────────────────────── + +function formatMs(ms: number): string { + const totalSec = Math.floor(ms / 1000); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":"); +} + +// ─── UI state ────────────────────────────────────────────────────────────────── + +type UIState = "idle" | "active" | "paused"; + +function applyState(state: UIState, elapsedMs = 0): void { + timerEl.textContent = state === "idle" ? "00:00:00" : formatMs(elapsedMs); + + timerEl.className = `popup__time popup__time--${state}`; + statusDot.className = `popup__status-dot${state !== "idle" ? ` popup__status-dot--${state}` : ""}`; + statusLabel.className = `popup__status-label${state !== "idle" ? ` popup__status-label--${state}` : ""}`; + statusLabel.textContent = state.toUpperCase(); + + btnStart.disabled = state !== "idle"; + btnPause.disabled = state === "idle"; + btnStop.disabled = state === "idle"; + btnPause.textContent = state === "paused" ? "▶ RESUME" : "⏸ PAUSE"; + btnNote.disabled = state !== "active"; +} + +async function refreshSyncIndicator(): Promise { + const r = await chrome.storage.local.get("pendingEvents"); + const count = ((r["pendingEvents"] as unknown[]) ?? []).length; + syncLabel.textContent = `${count} event${count !== 1 ? "s" : ""} queued`; + syncEl.classList.toggle("popup__sync--has-events", count > 0); +} + +// ─── Session polling ─────────────────────────────────────────────────────────── + +let pollInterval: ReturnType | null = null; + +function startPolling(): void { + if (pollInterval) return; + pollInterval = setInterval(async () => { + const res = await sendSession({ type: "SESSION_GET_STATE" }).catch(() => null); + if (!res || !res.success || !("session" in res)) return; + const session = res.session as SessionState | null; + if (!session) { + applyState("idle"); + } else if (session.pausedAt !== null) { + applyState("paused", session.elapsedMs); + } else { + applyState("active", session.elapsedMs); + } + await refreshSyncIndicator(); + }, 1000); +} + +function stopPolling(): void { + if (pollInterval) clearInterval(pollInterval); + pollInterval = null; +} -// Check auth status when popup opens -sendMessage({ type: "AUTH_GET_STATUS" }).then((res) => { - if (res.success && "isAuthenticated" in res) { - statusEl.textContent = res.isAuthenticated ? "✅ Signed in" : "❌ Not signed in"; +// ─── Auth flow ───────────────────────────────────────────────────────────────── + +async function init(): Promise { + const authRes = await sendAuth({ type: "AUTH_GET_STATUS" }); + + if (!authRes.success || !("isAuthenticated" in authRes) || !authRes.isAuthenticated) { + authSection.style.display = "flex"; + timerSection.style.display = "none"; + return; } -}); + + authSection.style.display = "none"; + timerSection.style.display = "flex"; + + // Load initial state + const res = await sendSession({ type: "SESSION_GET_STATE" }); + if (res.success && "session" in res) { + const session = res.session as SessionState | null; + if (!session) applyState("idle"); + else if (session.pausedAt !== null) applyState("paused", session.elapsedMs); + else applyState("active", session.elapsedMs); + } + + await refreshSyncIndicator(); + startPolling(); +} + +// ─── Controls ───────────────────────────────────────────────────────────────── btnLogin.addEventListener("click", async () => { btnLogin.disabled = true; - statusEl.textContent = "Signing in..."; - const res = await sendMessage({ type: "AUTH_LOGIN" }); - statusEl.textContent = res.success ? "✅ Signed in" : `❌ ${!res.success ? res.error : ""}`; - btnLogin.disabled = false; + btnLogin.textContent = "Signing in..."; + const res = await sendAuth({ type: "AUTH_LOGIN" }); + if (res.success) { + await init(); + } else { + btnLogin.disabled = false; + btnLogin.textContent = "Sign in with Google"; + } }); -btnLogout.addEventListener("click", async () => { - await sendMessage({ type: "AUTH_LOGOUT" }); - statusEl.textContent = "❌ Not signed in"; +btnStart.addEventListener("click", async () => { + await sendSession({ type: "SESSION_START" }); + applyState("active", 0); + startPolling(); }); + +btnPause.addEventListener("click", async () => { + const isPaused = btnPause.textContent?.includes("RESUME"); + if (isPaused) { + const res = await sendSession({ type: "SESSION_RESUME" }); + if (res.success && "session" in res && res.session) { + applyState("active", res.session.elapsedMs); + } + } else { + const res = await sendSession({ type: "SESSION_PAUSE" }); + if (res.success && "session" in res && res.session) { + applyState("paused", res.session.elapsedMs); + } + } +}); + +btnStop.addEventListener("click", async () => { + await sendSession({ type: "SESSION_STOP" }); + stopPolling(); + applyState("idle"); + await refreshSyncIndicator(); +}); + +btnNote.addEventListener("click", async () => { + const text = noteInput.value.trim(); + if (!text) return; + const tags = tagsInput.value + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + + await sendSession({ type: "NOTE_ADD", text, tags }); + noteInput.value = ""; + tagsInput.value = ""; + await refreshSyncIndicator(); +}); + +// ─── Boot ────────────────────────────────────────────────────────────────────── + +window.addEventListener("unload", stopPolling); +void init(); From a73f3db2c63081f3c4d9cf38f0b128784f8d0c36 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Fri, 22 May 2026 01:00:43 +0300 Subject: [PATCH 4/6] docs(backlog): add email and password auth plan with smtp verification --- plans/backlog/email-password-auth.md | 134 +++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 plans/backlog/email-password-auth.md diff --git a/plans/backlog/email-password-auth.md b/plans/backlog/email-password-auth.md new file mode 100644 index 0000000..43dd200 --- /dev/null +++ b/plans/backlog/email-password-auth.md @@ -0,0 +1,134 @@ +# Backlog: Email + Password Auth (альтернатива Google OAuth) + +**Пріоритет:** обов'язково, після Week 2–3 +**Залежить від:** Week 3 AC 1 (middleware + JWT вже є) +**Статус:** не розпочато + +--- + +## Мета + +Дати можливість реєструватись і логінитись через email + пароль без Google акаунту. Один акаунт на один email. Реєстрація підтверджується листом на пошту. + +--- + +## Scope + +### Dashboard (Next.js) + +**Нові Route Handlers:** + +| Endpoint | Що робить | +| -------------------------------- | ---------------------------------------------------------------------------- | +| `POST /api/auth/register` | Реєстрація: email + password → хеш → User у БД → надсилає verification email | +| `POST /api/auth/verify-email` | Приймає токен з листа → позначає email як підтверджений | +| `POST /api/auth/login` | email + password → перевіряє хеш → видає JWT | +| `POST /api/auth/login/extension` | Те саме але для extension (повертає JWT в тілі відповіді) | + +**Зміни в Prisma-схемі:** +```prisma +model User { + // ... існуючі поля + passwordHash String? // null якщо реєстрація через Google + emailVerified Boolean @default(false) + verificationToken String? // одноразовий токен для підтвердження + verificationExp DateTime? // термін дії токену +} +``` +→ потрібна міграція + +**Нові server-модулі:** +- `dashboard/server/password.ts` — `hashPassword`, `verifyPassword` (bcrypt або argon2) +- `dashboard/server/mailer.ts` — SMTP клієнт (nodemailer), `sendVerificationEmail` + +### Extension + +Альтернативний UI в popup: форма email + password замість (або поруч з) "Sign in with Google". + +--- + +## Технічний стек + +| Що | Пакет | +| ----------------- | ------------------------------------------- | +| Хешування паролів | `bcryptjs` (pure JS, без native deps) | +| SMTP | `nodemailer` | +| Email-провайдер | Mailtrap (dev) / Resend або SendGrid (prod) | + +--- + +## SMTP — що треба налаштувати + +**Dev (Mailtrap):** +```env +SMTP_HOST=sandbox.smtp.mailtrap.io +SMTP_PORT=2525 +SMTP_USER= +SMTP_PASS= +SMTP_FROM=noreply@worktrace.dev +``` +Mailtrap перехоплює всі листи — не відправляє реальним юзерам. Безпечно для розробки. + +**Prod (Resend — безкоштовний tier: 3000 листів/міс):** +```env +SMTP_HOST=smtp.resend.com +SMTP_PORT=465 +SMTP_USER=resend +SMTP_PASS= +``` + +--- + +## Сценарій: реєстрація з підтвердженням email + +``` +1. Юзер вводить email + password → POST /api/auth/register +2. Сервер: bcrypt.hash(password) → зберігає User { emailVerified: false, verificationToken: uuid, verificationExp: now+24h } +3. Сервер: nodemailer надсилає лист з посиланням: + https://worktrace.app/api/auth/verify-email?token= +4. Юзер клікає посилання → GET /api/auth/verify-email?token=... +5. Сервер: перевіряє token + exp → User.emailVerified = true, видаляє token +6. Юзер логіниться: POST /api/auth/login → email + password → bcrypt.compare → JWT +``` + +--- + +## Сценарій: логін в extension + +``` +1. Popup показує форму: email + password +2. Юзер вводить → popup надсилає NOTE_ADD (або новий тип) до background +3. Background: POST /api/auth/login/extension { email, password } +4. Dashboard: верифікує → повертає { token: JWT } +5. Background: зберігає JWT в chrome.storage.local (той самий формат що і Google OAuth) +``` + +--- + +## Зміни в popup + +Додати вкладки або toggle: +``` +[Google] [Email] +───────────────── +Email: [__________] +Password: [__________] + [Sign In] + [Register] +``` + +--- + +## Out of scope для цього backlog-item + +- Forgot password / reset (окремий backlog) +- 2FA +- OAuth провайдери крім Google +- Rate limiting на auth endpoints (хоча бажано) + +--- + +## Коли робити + +Після Week 3 (коли middleware і JWT flow вже стабільні). Не блокує Week 2–3. +Орієнтовно: між Week 3 і Week 4, або як окремий PR після Week 4. From 89e4f0e36aa94a7d980d95c4c37fde694e044227 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Fri, 22 May 2026 01:06:24 +0300 Subject: [PATCH 5/6] feat(extension): add logout button and note save feedback to popup --- extension/src/popup/index.html | 9 ++-- extension/src/popup/popup.css | 19 +++++++++ extension/src/popup/popup.ts | 77 +++++++++++++++++++++++----------- 3 files changed, 77 insertions(+), 28 deletions(-) diff --git a/extension/src/popup/index.html b/extension/src/popup/index.html index 8cbd2ca..dafcacf 100644 --- a/extension/src/popup/index.html +++ b/extension/src/popup/index.html @@ -15,9 +15,12 @@ WORKTRACE -