Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 4 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,13 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8

- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
node-version: '24'

- name: Enable Corepack
run: corepack enable

- name: Install dependencies
run: pnpm install
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v24
13 changes: 11 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"version": "1.0.0",
"description": "[**🏆 Live Demo →**](https://ccombe.github.io/emom-timer/)",
"main": "src/js/app.js",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
Expand All @@ -22,13 +25,19 @@
},
"homepage": "https://github.com/ccombe/emom-timer#readme",
"devDependencies": {
"@types/node": "^20.19.33",
"@types/node": "^24.10.15",
"@vitest/coverage-v8": "^4.0.18",
"fake-indexeddb": "^6.2.5",
"idb": "^8.0.3",
"jsdom": "^27.4.0",
"jsdom": "^28.1.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.18"
},
"packageManager": "pnpm@10.30.3",
"pnpm": {
"overrides": {
"rollup@>=4.0.0 <4.59.0": ">=4.59.0"
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pnpm override value ">=4.59.0" is open-ended, which can make installs non-reproducible (it may float to newer major versions) and can unexpectedly change behavior in CI/local dev. Consider pinning to a specific version (e.g., 4.59.0) or a bounded range (e.g., ^4.59.0) depending on your upgrade policy.

Suggested change
"rollup@>=4.0.0 <4.59.0": ">=4.59.0"
"rollup@>=4.0.0 <4.59.0": "^4.59.0"

Copilot uses AI. Check for mistakes.
}
}
}
412 changes: 207 additions & 205 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

107 changes: 106 additions & 1 deletion src/ts/google-fit.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GoogleFitService } from './google-fit.ts';

// Mock Fetch
Expand Down Expand Up @@ -30,10 +30,100 @@ describe('GoogleFitService', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
// Mock Date.now() for consistent testing
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
service = new GoogleFitService();
service.accessToken = 'mock-token'; // Simulate connected
});

afterEach(() => {
vi.useRealTimers();
});

describe('constructor and token expiry', () => {
it('clears expired token on initialization', () => {
localStorage.setItem('google_fit_token', 'old-token');
localStorage.setItem('google_fit_token_expiry', (Date.now() - 1000).toString());

const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
const newService = new GoogleFitService();
expect(newService.accessToken).toBeNull();
expect(localStorage.getItem('google_fit_token')).toBeNull();
logSpy.mockRestore();
Comment on lines +50 to +53
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logSpy.mockRestore() (and similar console spies later in this file) is called after assertions; if an assertion throws, the spy won't be restored and can leak into subsequent tests. Prefer wrapping the spy usage in try/finally, or add an afterEach that calls vi.restoreAllMocks() so the original console methods are always restored.

Suggested change
const newService = new GoogleFitService();
expect(newService.accessToken).toBeNull();
expect(localStorage.getItem('google_fit_token')).toBeNull();
logSpy.mockRestore();
try {
const newService = new GoogleFitService();
expect(newService.accessToken).toBeNull();
expect(localStorage.getItem('google_fit_token')).toBeNull();
} finally {
logSpy.mockRestore();
}

Copilot uses AI. Check for mistakes.
});

it('retains valid token on initialization', () => {
const future = Date.now() + 3600000;
localStorage.setItem('google_fit_token', 'valid-token');
localStorage.setItem('google_fit_token_expiry', future.toString());

const newService = new GoogleFitService();
expect(newService.accessToken).toBe('valid-token');
});
});

describe('initialize', () => {
it('does nothing if window.google is missing', () => {
const hadGoogle = 'google' in window;
const originalGoogle = (window as any).google;
try {
delete (window as any).google;
service.initialize();
expect(service.tokenClient).toBeNull();
} finally {
if (hadGoogle) {
(window as any).google = originalGoogle;
} else {
delete (window as any).google;
}
}
});

it('initializes token client when window.google exists', () => {
const hadGoogle = 'google' in window;
const originalGoogle = (window as any).google;
const mockInitTokenClient = vi.fn().mockReturnValue({ requestAccessToken: vi.fn() });

try {
(window as any).google = {
accounts: {
oauth2: {
initTokenClient: mockInitTokenClient
}
}
};

service.initialize();
expect(mockInitTokenClient).toHaveBeenCalled();
expect(service.tokenClient).toBeDefined();
} finally {
if (hadGoogle) {
(window as any).google = originalGoogle;
} else {
delete (window as any).google;
}
}
});
});

describe('connect', () => {
it('calls requestAccessToken if client exists', () => {
const requestMock = vi.fn();
service.tokenClient = { requestAccessToken: requestMock };
service.connect();
expect(requestMock).toHaveBeenCalled();
});

it('logs error if client does not exist', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
service.tokenClient = null;
service.connect();
expect(spy).toHaveBeenCalledWith("Google Identity Services not initialized.");
spy.mockRestore();
});
});

describe('uploadSession', () => {
it('uploads valid session data', async () => {
const session = {
Expand Down Expand Up @@ -82,8 +172,14 @@ describe('GoogleFitService', () => {
json: async () => ({ error: "Bad request" })
});

// Mock console.error to avoid noisy stderr in tests
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });

const result = await service.uploadSession({ duration: 60, interval: 60, activityType: 114 });
expect(result).toBe(false);

expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});

Expand Down Expand Up @@ -118,6 +214,15 @@ describe('GoogleFitService', () => {
expect(dates).toEqual([]);
});

it('handles fetch exceptions gracefully', async () => {
(fetch as any).mockRejectedValueOnce(new Error('Network failure'));
const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
const dates = await service.fetchWorkoutHistory();
expect(dates).toEqual([]);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});

it('handles malformed/empty buckets gracefully', async () => {
const mockResponse = {
bucket: [
Expand Down
20 changes: 20 additions & 0 deletions src/ts/logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ describe('Core Logic', () => {
it('handles finished state', () => {
expect(getCurrentRound({ elapsed: 300, intervalDuration: intervalSecs, totalDuration })).toEqual({ current: 5, total: 5 });
});

it('handles very large elapsed time gracefully', () => {
expect(getCurrentRound({ elapsed: 1000, intervalDuration: intervalSecs, totalDuration })).toEqual({ current: 5, total: 5 });
});
});

describe('isFinished', () => {
Expand Down Expand Up @@ -116,6 +120,22 @@ describe('Core Logic', () => {
const result = getCountdownBeep(context);
expect(result.shouldBeep).toBe(false);
});

it('should beep for different beep codes when lastBeepId is different', () => {
// Test 3 seconds remaining (57s)
const res1 = getCountdownBeep({ elapsed: 57, intervalDuration: 60, lastBeepId: '0-2' });
expect(res1.shouldBeep).toBe(true);
if (res1.shouldBeep) {
expect(res1.frequency).toBe(440);
}

// Test 2 seconds remaining (58s)
const res2 = getCountdownBeep({ elapsed: 58, intervalDuration: 60, lastBeepId: '0-3' });
expect(res2.shouldBeep).toBe(true);
if (res2.shouldBeep) {
expect(res2.frequency).toBe(554);
}
});
});

describe('normalizeDate', () => {
Expand Down
41 changes: 38 additions & 3 deletions src/ts/storage.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { StorageService } from './storage.ts';
import 'fake-indexeddb/auto';

describe('StorageService', () => {
let storage: StorageService;

beforeEach(() => {
beforeEach(async () => {
// Clear IndexedDB to ensure test isolation
const DB_NAME = 'emom-timer-db';
const request = indexedDB.deleteDatabase(DB_NAME);
await new Promise((resolve, reject) => {
request.onsuccess = resolve;
request.onerror = reject;
Comment on lines +11 to +13
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this Promise wrapper around indexedDB.deleteDatabase, request.onerror = reject will reject with the raw event rather than the underlying request.error, which makes failures harder to diagnose. Consider rejecting with request.error (or a new Error that includes request.error?.message) so test failures are actionable.

Suggested change
await new Promise((resolve, reject) => {
request.onsuccess = resolve;
request.onerror = reject;
await new Promise<void>((resolve, reject) => {
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
const error = request.error;
if (error) {
reject(error);
} else {
reject(new Error(`IndexedDB deleteDatabase("${DB_NAME}") failed for an unknown reason.`));
}
};

Copilot uses AI. Check for mistakes.
request.onblocked = () => {
// If blocked, surface the issue so leaked connections and isolation problems are visible
reject(new Error(`IndexedDB deleteDatabase("${DB_NAME}") was blocked; there may be open connections.`));
};
});
storage = new StorageService();
});

afterEach(async () => {
if (storage) {
await storage.close();
}
});

it('saves and retrieves session', async () => {
const session = {
duration: 300,
Expand Down Expand Up @@ -64,4 +80,23 @@ describe('StorageService', () => {
const streak = await storage.getStreak();
expect(streak).toBe(2);
});

it('saves and loads settings', async () => {
const settings = {
intervalCount: 8,
intervalSecs: 45,
activityType: 114,
includeLocation: true,
setupComplete: true
};

await storage.saveSettings(settings);
const loaded = await storage.loadSettings();
expect(loaded).toEqual(settings);
});

it('returns undefined for non-existent settings', async () => {
const settings = await storage.loadSettings();
expect(settings).toBeUndefined();
});
});
5 changes: 5 additions & 0 deletions src/ts/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,9 @@ export class StorageService {
const dates = sessions.map(s => s.date);
return calculateStreak(dates);
}

async close(): Promise<void> {
const db = await this.dbPromise;
db.close();
}
}