Skip to content

Commit 4bc32ec

Browse files
Mitsuki FukunagaMitsuki Fukunaga
authored andcommitted
chore: merge main into pr-147
2 parents 21de2f7 + 68470cf commit 4bc32ec

5 files changed

Lines changed: 180 additions & 19 deletions

File tree

.github/workflows/claude-code-review.yml

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
11
name: Claude Code Review
22

3-
on:
4-
pull_request:
5-
types: [opened, synchronize, ready_for_review, reopened]
6-
# Optional: Only run on specific file changes
7-
# paths:
8-
# - "src/**/*.ts"
9-
# - "src/**/*.tsx"
10-
# - "src/**/*.js"
11-
# - "src/**/*.jsx"
3+
# Temporarily disabled: no triggers.
4+
on: []
125

136
jobs:
147
claude-review:
@@ -41,4 +34,3 @@ jobs:
4134
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
4235
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
4336
# or https://code.claude.com/docs/en/cli-reference for available options
44-

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ Chrome Extension (Manifest V3) - Snooze tabs and automatically restore them at a
2828
| `npm test` | Run all tests |
2929
| `npm run typecheck` | Type check |
3030

31+
## Code Style
32+
33+
- TypeScript strict mode, no `any` types
34+
- Use named exports, not default exports
35+
- CSS: use Tailwind utility classes, no custom CSS files
36+
3137
## Testing
3238

3339
- External APIs must be mocked

dev-docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Architecture
22

3-
> Implementation details (structure, algorithms, file organization)
3+
> Implementation details (structure, algorithms, data flow, invariants)
44
55
Chrome Extension (Manifest V3) - Snooze tabs and restore at scheduled time
66

dev-docs/SPEC.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Functional Specifications
22

3-
> User requirements (behavior, timing, constraints)
3+
> Feature requirements for product/QA (user behavior, timing, constraints)
44
55
## Terminology
66

@@ -40,10 +40,12 @@ All times use user's timezone (settings → system fallback).
4040
## Scope & Shortcuts
4141

4242
**Scope:**
43+
4344
- **Selected Tabs**: Highlighted tabs, no `groupId` → restore in last-focused window
4445
- **Current Window**: All tabs, shared `groupId` → restore in new window
4546

4647
**Keyboard:**
48+
4749
- Single key (e.g., `T`) → Snooze selected tabs
4850
- `Shift` + key → Snooze entire window
4951
- `Shift + P` or window scope → DatePicker preserves scope
@@ -61,18 +63,21 @@ All times use user's timezone (settings → system fallback).
6163
## UI Themes
6264

6365
Defined in `constants.ts`:
66+
6467
- **Default**: Blue/Indigo monochrome
6568
- **Vivid**: Semantic colors (Tomorrow=Blue, Weekend=Green)
6669
- **Heatmap**: Urgency colors (Later Today=Red, Tomorrow=Orange)
6770

6871
## Data Integrity
6972

7073
**Backup:**
74+
7175
- 3 rotating backups: `snoozedTabs_backup_<ts>`
7276
- Debounced 2s
7377
- Validates before backup
7478

7579
**Recovery:**
80+
7681
- On startup: Validate `snoooze_v2`
7782
- If invalid → `recoverFromBackup` (valid → sanitized with most items → empty reset)
7883
- `ensureValidStorage` sanitizes invalid entries

src/background/serviceWorker.test.ts

Lines changed: 165 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -229,10 +229,12 @@ describe('serviceWorker onInstalled event', () => {
229229
expect(popCheck).toHaveBeenCalledOnce();
230230
});
231231

232-
it('checks for pending recovery notification', async () => {
233-
const sessionGetMock = vi.fn().mockResolvedValue({
234-
pendingRecoveryNotification: 5,
235-
});
232+
// Helper function to reduce test duplication
233+
async function setupRecoveryNotificationTest(sessionData: {
234+
pendingRecoveryNotification: number;
235+
lastRecoveryNotifiedAt?: number;
236+
}) {
237+
const sessionGetMock = vi.fn().mockResolvedValue(sessionData);
236238
const sessionSetMock = vi.fn().mockResolvedValue(undefined);
237239
const sessionRemoveMock = vi.fn().mockResolvedValue(undefined);
238240
const notificationsCreateMock = vi.fn().mockResolvedValue(undefined);
@@ -243,12 +245,16 @@ describe('serviceWorker onInstalled event', () => {
243245
(globalThis.chrome.notifications.create as ReturnType<typeof vi.fn>) = notificationsCreateMock;
244246

245247
await importServiceWorker();
246-
247248
expect(installedHandler).not.toBeNull();
248-
249-
// Trigger onInstalled event
250249
await installedHandler!();
251250

251+
return { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock };
252+
}
253+
254+
it('checks for pending recovery notification', async () => {
255+
const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } =
256+
await setupRecoveryNotificationTest({ pendingRecoveryNotification: 5 });
257+
252258
// Should check for pending recovery notification
253259
expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']);
254260

@@ -267,6 +273,158 @@ describe('serviceWorker onInstalled event', () => {
267273
// Should clear pending flag
268274
expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification');
269275
});
276+
277+
it('suppresses notification when within 5-minute cooldown', async () => {
278+
const now = Date.now();
279+
const recentNotification = now - (3 * 60 * 1000); // 3 minutes ago (within 5-min cooldown)
280+
281+
const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } =
282+
await setupRecoveryNotificationTest({
283+
pendingRecoveryNotification: 5,
284+
lastRecoveryNotifiedAt: recentNotification,
285+
});
286+
287+
// Should check for pending recovery notification
288+
expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']);
289+
290+
// Should NOT create notification (within cooldown)
291+
expect(notificationsCreateMock).not.toHaveBeenCalled();
292+
293+
// Should NOT update timestamp (notification suppressed)
294+
expect(sessionSetMock).not.toHaveBeenCalled();
295+
296+
// Should still clear pending flag
297+
expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification');
298+
});
299+
300+
it('shows notification when cooldown has expired', async () => {
301+
const now = Date.now();
302+
const oldNotification = now - (6 * 60 * 1000); // 6 minutes ago (exceeds 5-min cooldown)
303+
304+
const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } =
305+
await setupRecoveryNotificationTest({
306+
pendingRecoveryNotification: 3,
307+
lastRecoveryNotifiedAt: oldNotification,
308+
});
309+
310+
// Should check for pending recovery notification
311+
expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']);
312+
313+
// Should create notification (cooldown expired)
314+
expect(notificationsCreateMock).toHaveBeenCalledWith('recovery-notification', {
315+
type: 'basic',
316+
iconUrl: 'assets/icon128.png',
317+
title: 'Snooooze Data Recovered',
318+
message: 'Recovered 3 snoozed tabs from backup.',
319+
priority: 1
320+
});
321+
322+
// Should update timestamp
323+
expect(sessionSetMock).toHaveBeenCalledWith({ lastRecoveryNotifiedAt: expect.any(Number) });
324+
325+
// Should clear pending flag
326+
expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification');
327+
});
328+
329+
it('shows notification when lastRecoveryNotifiedAt is undefined (first time)', async () => {
330+
const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } =
331+
await setupRecoveryNotificationTest({
332+
pendingRecoveryNotification: 2,
333+
// lastRecoveryNotifiedAt is undefined (first time)
334+
});
335+
336+
// Should check for pending recovery notification
337+
expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']);
338+
339+
// Should create notification (first time, no previous notification)
340+
expect(notificationsCreateMock).toHaveBeenCalledWith('recovery-notification', {
341+
type: 'basic',
342+
iconUrl: 'assets/icon128.png',
343+
title: 'Snooooze Data Recovered',
344+
message: 'Recovered 2 snoozed tabs from backup.',
345+
priority: 1
346+
});
347+
348+
// Should update timestamp
349+
expect(sessionSetMock).toHaveBeenCalledWith({ lastRecoveryNotifiedAt: expect.any(Number) });
350+
351+
// Should clear pending flag
352+
expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification');
353+
});
354+
355+
it('suppresses notification at exactly 5-minute boundary', async () => {
356+
const now = Date.now();
357+
const NOTIFICATION_COOLDOWN = 5 * 60 * 1000;
358+
const exactBoundary = now - NOTIFICATION_COOLDOWN; // Exactly 5 minutes
359+
360+
const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } =
361+
await setupRecoveryNotificationTest({
362+
pendingRecoveryNotification: 4,
363+
lastRecoveryNotifiedAt: exactBoundary,
364+
});
365+
366+
// Should check for pending recovery notification
367+
expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']);
368+
369+
// Should NOT create notification (boundary case: condition uses > not >=)
370+
expect(notificationsCreateMock).not.toHaveBeenCalled();
371+
372+
// Should NOT update timestamp
373+
expect(sessionSetMock).not.toHaveBeenCalled();
374+
375+
// Should still clear pending flag
376+
expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification');
377+
});
378+
379+
it('shows corruption message when zero tabs recovered', async () => {
380+
const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } =
381+
await setupRecoveryNotificationTest({
382+
pendingRecoveryNotification: 0, // Corruption case
383+
});
384+
385+
// Should check for pending recovery notification
386+
expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']);
387+
388+
// Should create notification with corruption message
389+
expect(notificationsCreateMock).toHaveBeenCalledWith('recovery-notification', {
390+
type: 'basic',
391+
iconUrl: 'assets/icon128.png',
392+
title: 'Snooooze Data Recovered',
393+
message: 'Snoozed tabs data was reset due to corruption.',
394+
priority: 1
395+
});
396+
397+
// Should update timestamp
398+
expect(sessionSetMock).toHaveBeenCalledWith({ lastRecoveryNotifiedAt: expect.any(Number) });
399+
400+
// Should clear pending flag
401+
expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification');
402+
});
403+
404+
it('uses singular "tab" when recovering one tab', async () => {
405+
const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } =
406+
await setupRecoveryNotificationTest({
407+
pendingRecoveryNotification: 1, // Singular case
408+
});
409+
410+
// Should check for pending recovery notification
411+
expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']);
412+
413+
// Should create notification with singular "tab" (no 's')
414+
expect(notificationsCreateMock).toHaveBeenCalledWith('recovery-notification', {
415+
type: 'basic',
416+
iconUrl: 'assets/icon128.png',
417+
title: 'Snooooze Data Recovered',
418+
message: 'Recovered 1 snoozed tab from backup.',
419+
priority: 1
420+
});
421+
422+
// Should update timestamp
423+
expect(sessionSetMock).toHaveBeenCalledWith({ lastRecoveryNotifiedAt: expect.any(Number) });
424+
425+
// Should clear pending flag
426+
expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification');
427+
});
270428
});
271429

272430
describe('serviceWorker onStartup event', () => {

0 commit comments

Comments
 (0)