diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 69a1eb1..5f2b75f 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,16 +1,7 @@ name: Claude Code Review -on: - pull_request: - types: [opened, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - issue_comment: - types: [created] +# Temporarily disabled: no triggers. +on: [] jobs: claude-review: @@ -44,4 +35,3 @@ jobs: --allowed-tools "Read,Grep,Glob,Bash,Task,mcp__github_inline_comment__create_inline_comment" # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # for available options - diff --git a/CLAUDE.md b/CLAUDE.md index c4b66cc..0ce1f13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,12 @@ Chrome Extension (Manifest V3) - Snooze tabs and automatically restore them at a | `npm test` | Run all tests | | `npm run typecheck` | Type check | +## Code Style + +- TypeScript strict mode, no `any` types +- Use named exports, not default exports +- CSS: use Tailwind utility classes, no custom CSS files + ## Testing - External APIs must be mocked diff --git a/dev-docs/ARCHITECTURE.md b/dev-docs/ARCHITECTURE.md index 700b83b..ba03398 100644 --- a/dev-docs/ARCHITECTURE.md +++ b/dev-docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -> Implementation details (structure, algorithms, file organization) +> Implementation details (structure, algorithms, data flow, invariants) Chrome Extension (Manifest V3) - Snooze tabs and restore at scheduled time diff --git a/dev-docs/SPEC.md b/dev-docs/SPEC.md index bb8fb90..37f1109 100644 --- a/dev-docs/SPEC.md +++ b/dev-docs/SPEC.md @@ -1,6 +1,6 @@ # Functional Specifications -> User requirements (behavior, timing, constraints) +> Feature requirements for product/QA (user behavior, timing, constraints) ## Terminology @@ -40,10 +40,12 @@ All times use user's timezone (settings → system fallback). ## Scope & Shortcuts **Scope:** + - **Selected Tabs**: Highlighted tabs, no `groupId` → restore in last-focused window - **Current Window**: All tabs, shared `groupId` → restore in new window **Keyboard:** + - Single key (e.g., `T`) → Snooze selected tabs - `Shift` + key → Snooze entire window - `Shift + P` or window scope → DatePicker preserves scope @@ -61,6 +63,7 @@ All times use user's timezone (settings → system fallback). ## UI Themes Defined in `constants.ts`: + - **Default**: Blue/Indigo monochrome - **Vivid**: Semantic colors (Tomorrow=Blue, Weekend=Green) - **Heatmap**: Urgency colors (Later Today=Red, Tomorrow=Orange) @@ -68,11 +71,13 @@ Defined in `constants.ts`: ## Data Integrity **Backup:** + - 3 rotating backups: `snoozedTabs_backup_` - Debounced 2s - Validates before backup **Recovery:** + - On startup: Validate `snoooze_v2` - If invalid → `recoverFromBackup` (valid → sanitized with most items → empty reset) - `ensureValidStorage` sanitizes invalid entries diff --git a/src/background/serviceWorker.test.ts b/src/background/serviceWorker.test.ts index 617e9ce..d0fe927 100644 --- a/src/background/serviceWorker.test.ts +++ b/src/background/serviceWorker.test.ts @@ -229,10 +229,12 @@ describe('serviceWorker onInstalled event', () => { expect(popCheck).toHaveBeenCalledOnce(); }); - it('checks for pending recovery notification', async () => { - const sessionGetMock = vi.fn().mockResolvedValue({ - pendingRecoveryNotification: 5, - }); + // Helper function to reduce test duplication + async function setupRecoveryNotificationTest(sessionData: { + pendingRecoveryNotification: number; + lastRecoveryNotifiedAt?: number; + }) { + const sessionGetMock = vi.fn().mockResolvedValue(sessionData); const sessionSetMock = vi.fn().mockResolvedValue(undefined); const sessionRemoveMock = vi.fn().mockResolvedValue(undefined); const notificationsCreateMock = vi.fn().mockResolvedValue(undefined); @@ -243,12 +245,16 @@ describe('serviceWorker onInstalled event', () => { (globalThis.chrome.notifications.create as ReturnType) = notificationsCreateMock; await importServiceWorker(); - expect(installedHandler).not.toBeNull(); - - // Trigger onInstalled event await installedHandler!(); + return { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock }; + } + + it('checks for pending recovery notification', async () => { + const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } = + await setupRecoveryNotificationTest({ pendingRecoveryNotification: 5 }); + // Should check for pending recovery notification expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']); @@ -267,6 +273,158 @@ describe('serviceWorker onInstalled event', () => { // Should clear pending flag expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification'); }); + + it('suppresses notification when within 5-minute cooldown', async () => { + const now = Date.now(); + const recentNotification = now - (3 * 60 * 1000); // 3 minutes ago (within 5-min cooldown) + + const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } = + await setupRecoveryNotificationTest({ + pendingRecoveryNotification: 5, + lastRecoveryNotifiedAt: recentNotification, + }); + + // Should check for pending recovery notification + expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']); + + // Should NOT create notification (within cooldown) + expect(notificationsCreateMock).not.toHaveBeenCalled(); + + // Should NOT update timestamp (notification suppressed) + expect(sessionSetMock).not.toHaveBeenCalled(); + + // Should still clear pending flag + expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification'); + }); + + it('shows notification when cooldown has expired', async () => { + const now = Date.now(); + const oldNotification = now - (6 * 60 * 1000); // 6 minutes ago (exceeds 5-min cooldown) + + const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } = + await setupRecoveryNotificationTest({ + pendingRecoveryNotification: 3, + lastRecoveryNotifiedAt: oldNotification, + }); + + // Should check for pending recovery notification + expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']); + + // Should create notification (cooldown expired) + expect(notificationsCreateMock).toHaveBeenCalledWith('recovery-notification', { + type: 'basic', + iconUrl: 'assets/icon128.png', + title: 'Snooooze Data Recovered', + message: 'Recovered 3 snoozed tabs from backup.', + priority: 1 + }); + + // Should update timestamp + expect(sessionSetMock).toHaveBeenCalledWith({ lastRecoveryNotifiedAt: expect.any(Number) }); + + // Should clear pending flag + expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification'); + }); + + it('shows notification when lastRecoveryNotifiedAt is undefined (first time)', async () => { + const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } = + await setupRecoveryNotificationTest({ + pendingRecoveryNotification: 2, + // lastRecoveryNotifiedAt is undefined (first time) + }); + + // Should check for pending recovery notification + expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']); + + // Should create notification (first time, no previous notification) + expect(notificationsCreateMock).toHaveBeenCalledWith('recovery-notification', { + type: 'basic', + iconUrl: 'assets/icon128.png', + title: 'Snooooze Data Recovered', + message: 'Recovered 2 snoozed tabs from backup.', + priority: 1 + }); + + // Should update timestamp + expect(sessionSetMock).toHaveBeenCalledWith({ lastRecoveryNotifiedAt: expect.any(Number) }); + + // Should clear pending flag + expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification'); + }); + + it('suppresses notification at exactly 5-minute boundary', async () => { + const now = Date.now(); + const NOTIFICATION_COOLDOWN = 5 * 60 * 1000; + const exactBoundary = now - NOTIFICATION_COOLDOWN; // Exactly 5 minutes + + const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } = + await setupRecoveryNotificationTest({ + pendingRecoveryNotification: 4, + lastRecoveryNotifiedAt: exactBoundary, + }); + + // Should check for pending recovery notification + expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']); + + // Should NOT create notification (boundary case: condition uses > not >=) + expect(notificationsCreateMock).not.toHaveBeenCalled(); + + // Should NOT update timestamp + expect(sessionSetMock).not.toHaveBeenCalled(); + + // Should still clear pending flag + expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification'); + }); + + it('shows corruption message when zero tabs recovered', async () => { + const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } = + await setupRecoveryNotificationTest({ + pendingRecoveryNotification: 0, // Corruption case + }); + + // Should check for pending recovery notification + expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']); + + // Should create notification with corruption message + expect(notificationsCreateMock).toHaveBeenCalledWith('recovery-notification', { + type: 'basic', + iconUrl: 'assets/icon128.png', + title: 'Snooooze Data Recovered', + message: 'Snoozed tabs data was reset due to corruption.', + priority: 1 + }); + + // Should update timestamp + expect(sessionSetMock).toHaveBeenCalledWith({ lastRecoveryNotifiedAt: expect.any(Number) }); + + // Should clear pending flag + expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification'); + }); + + it('uses singular "tab" when recovering one tab', async () => { + const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } = + await setupRecoveryNotificationTest({ + pendingRecoveryNotification: 1, // Singular case + }); + + // Should check for pending recovery notification + expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']); + + // Should create notification with singular "tab" (no 's') + expect(notificationsCreateMock).toHaveBeenCalledWith('recovery-notification', { + type: 'basic', + iconUrl: 'assets/icon128.png', + title: 'Snooooze Data Recovered', + message: 'Recovered 1 snoozed tab from backup.', + priority: 1 + }); + + // Should update timestamp + expect(sessionSetMock).toHaveBeenCalledWith({ lastRecoveryNotifiedAt: expect.any(Number) }); + + // Should clear pending flag + expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification'); + }); }); describe('serviceWorker onStartup event', () => {