From 5ac9d74fec938bcb6b8cc3a029418c03dbf84c24 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Tue, 27 Jan 2026 17:08:51 +0100 Subject: [PATCH 01/29] add basic e2e tests with api mocking --- .github/workflows/e2e.yml | 32 +++++ e2e/fixtures/electron-test.ts | 207 ++++++++++++++++++++++++++++++++ e2e/mocks/api-handler.ts | 173 ++++++++++++++++++++++++++ e2e/mocks/data.ts | 202 +++++++++++++++++++++++++++++++ e2e/playwright.config.ts | 12 ++ e2e/tests/auth.spec.ts | 41 +++++++ e2e/tests/navigation.spec.ts | 48 ++++++++ e2e/tests/organization.spec.ts | 8 ++ e2e/tests/settings.spec.ts | 46 +++++++ e2e/tests/statistics.spec.ts | 26 ++++ e2e/tests/time-tracking.spec.ts | 20 +++ package-lock.json | 64 ++++++++++ package.json | 4 +- src/main/activityTracker.ts | 5 +- src/main/appIcons.ts | 1 + src/main/env.ts | 3 + src/main/index.ts | 27 +++-- src/main/mainWindow.ts | 13 +- src/main/miniWindow.ts | 7 +- src/renderer/src/utils/tasks.ts | 2 +- 20 files changed, 923 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 e2e/fixtures/electron-test.ts create mode 100644 e2e/mocks/api-handler.ts create mode 100644 e2e/mocks/data.ts create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/auth.spec.ts create mode 100644 e2e/tests/navigation.spec.ts create mode 100644 e2e/tests/organization.spec.ts create mode 100644 e2e/tests/settings.spec.ts create mode 100644 e2e/tests/statistics.spec.ts create mode 100644 e2e/tests/time-tracking.spec.ts create mode 100644 src/main/env.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..f4d9100 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,32 @@ +name: E2E Tests + +on: + push: + branches: [main] + pull_request: + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: 'Checkout code' + uses: actions/checkout@v4 + + - name: 'Use Node.js' + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: 'Install npm dependencies' + run: npm ci + + - name: 'Install Playwright Electron dependencies' + run: npx playwright install --with-deps + + - name: 'Build Electron app' + run: npx electron-vite build + + - name: 'Run E2E tests' + run: xvfb-run --auto-servernum npx playwright test --config=e2e/playwright.config.ts diff --git a/e2e/fixtures/electron-test.ts b/e2e/fixtures/electron-test.ts new file mode 100644 index 0000000..ae25982 --- /dev/null +++ b/e2e/fixtures/electron-test.ts @@ -0,0 +1,207 @@ +/** + * Custom Playwright test fixture for Electron E2E testing. + * + * Provides: + * - `electronApp`: The launched Electron application instance + * - `page`: The main window's Page object (with API mocks and auth pre-seeded) + * - `mockState`: Mutable mock state object for controlling API responses per-test + */ + +import { test as base, type ElectronApplication, type Page } from '@playwright/test' +import { _electron as electron } from 'playwright' +import { setupApiMocks, type MockState } from '../mocks/api-handler' +import path from 'path' +import fs from 'fs' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const appPath = path.resolve(__dirname, '../..') +const isCI = !!process.env.CI + +function createTempUserDataDir(): string { + return fs.mkdtempSync(path.join(appPath, '.e2e-userdata-')) +} + +function cleanupUserDataDir(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }) + } catch { + // Ignore cleanup errors + } +} + +/** + * Find the main window (index.html, not index-mini.html). + * Waits until a window with the main index.html URL is available. + */ +async function getMainWindow(electronApp: ElectronApplication): Promise { + const maxAttempts = 20 + for (let i = 0; i < maxAttempts; i++) { + for (const win of electronApp.windows()) { + const url = win.url() + if (url.includes('index.html') && !url.includes('index-mini.html')) { + return win + } + } + await new Promise((r) => setTimeout(r, 500)) + } + + // Fallback: wait for a new window event + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Main window not found')), 10000) + electronApp.on('window', (page) => { + if (page.url().includes('index.html') && !page.url().includes('index-mini.html')) { + clearTimeout(timeout) + resolve(page) + } + }) + }) +} + +type ElectronTestFixtures = { + electronApp: ElectronApplication + page: Page + mockState: MockState +} + +/** + * Base fixture for authenticated Electron tests. + */ +export const test = base.extend({ + // eslint-disable-next-line no-empty-pattern + electronApp: async ({}, use) => { + const userDataDir = createTempUserDataDir() + const app = await electron.launch({ + args: [...(isCI ? ['--no-sandbox'] : []), '--user-data-dir=' + userDataDir, appPath], + env: { + ...process.env, + NODE_ENV: 'test', + E2E_TESTING: 'true', + }, + }) + await use(app) + await app.close() + cleanupUserDataDir(userDataDir) + }, + + mockState: [ + async ({ electronApp }, use) => { + const mainPage = await getMainWindow(electronApp) + await mainPage.waitForLoadState('domcontentloaded') + + // Wait for mini window to be ready too + await mainPage.waitForTimeout(500) + + const allWindows = electronApp.windows() + + // 1. Set up API route mocks on ALL windows. + // Both main and mini windows make API calls, so both need interception. + const state = await setupApiMocks(mainPage) + for (const win of allWindows) { + if (win !== mainPage) { + await setupApiMocks(win) + } + } + + // 2. Seed localStorage with auth tokens (shared across same-origin windows) + await mainPage.evaluate( + (data) => { + localStorage.setItem('access_token', 'mock-access-token') + localStorage.setItem('refresh_token', 'mock-refresh-token') + localStorage.setItem('instance_endpoint', 'https://mock.solidtime.io') + localStorage.setItem('currentMembershipId', JSON.stringify(data.membershipId)) + }, + { membershipId: state.membership.id } + ) + + // 3. Add init scripts ONLY on non-main windows (mini windows). + // IMPORTANT: addInitScript on the main page breaks page.route() interception, + // causing API mocks to stop working. Only mini windows need it to prevent + // their useStorage initialization from clearing the auth tokens. + for (const win of allWindows) { + if (win !== mainPage) { + await win.addInitScript( + (data) => { + localStorage.setItem('access_token', 'mock-access-token') + localStorage.setItem('refresh_token', 'mock-refresh-token') + localStorage.setItem('instance_endpoint', 'https://mock.solidtime.io') + localStorage.setItem( + 'currentMembershipId', + JSON.stringify(data.membershipId) + ) + }, + { membershipId: state.membership.id } + ) + } + } + + // 4. Reload windows sequentially: mini windows first, then main. + // Mini windows must reload first so their addInitScript seeds localStorage + // before the main window reads it during bootstrap. + for (const win of allWindows) { + if (win !== mainPage) { + await win.reload() + await win.waitForLoadState('domcontentloaded') + } + } + await mainPage.reload() + await mainPage.waitForLoadState('domcontentloaded') + + // 5. Wait for the main window app to fully render + await mainPage.waitForURL(/.*#\/.*/, { timeout: 10000 }) + + await use(state) + }, + { auto: false }, + ], + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + page: async ({ electronApp, mockState }, use) => { + // Depend on mockState so mocks are set up before page is used + const page = await getMainWindow(electronApp) + await use(page) + }, +}) + +/** + * Fixture for unauthenticated tests (login page, instance settings). + */ +export const unauthenticatedTest = base.extend<{ electronApp: ElectronApplication; page: Page }>({ + // eslint-disable-next-line no-empty-pattern + electronApp: async ({}, use) => { + const userDataDir = createTempUserDataDir() + const app = await electron.launch({ + args: [...(isCI ? ['--no-sandbox'] : []), '--user-data-dir=' + userDataDir, appPath], + env: { + ...process.env, + NODE_ENV: 'test', + E2E_TESTING: 'true', + }, + }) + await use(app) + await app.close() + cleanupUserDataDir(userDataDir) + }, + + page: async ({ electronApp }, use) => { + const page = await getMainWindow(electronApp) + await page.waitForLoadState('domcontentloaded') + + // Ensure no auth tokens are present + await page.evaluate(() => { + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + localStorage.removeItem('currentMembershipId') + }) + + await page.reload() + await page.waitForLoadState('domcontentloaded') + await page.waitForTimeout(1000) + + await use(page) + }, +}) + +export { expect } from '@playwright/test' diff --git a/e2e/mocks/api-handler.ts b/e2e/mocks/api-handler.ts new file mode 100644 index 0000000..166190b --- /dev/null +++ b/e2e/mocks/api-handler.ts @@ -0,0 +1,173 @@ +/** + * Centralized API route handler for E2E tests. + * Intercepts all API requests from the Electron renderer and returns mock data. + * + * Uses a single catch-all route to avoid glob pattern issues with query parameters. + */ + +import type { Page, Route } from '@playwright/test' +import { createDefaultMockData, createTimeEntry } from './data' + +export interface MockState { + user: ReturnType['user'] + organization: ReturnType['organization'] + membership: ReturnType['membership'] + projects: ReturnType['projects'] + tags: ReturnType['tags'] + tasks: ReturnType['tasks'] + clients: ReturnType['clients'] + timeEntries: ReturnType['timeEntries'] + activeTimeEntry: ReturnType | null +} + +function jsonResponse(route: Route, data: unknown, status = 200) { + return route.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify(data), + }) +} + +/** + * Extract the pathname from a URL (without query params). + */ +function getPathname(url: string): string { + try { + return new URL(url).pathname + } catch { + return url + } +} + +/** + * Register API route handlers on a Playwright page. + * Returns a mutable state object that tests can modify to change mock responses. + */ +export async function setupApiMocks(page: Page): Promise { + const defaultData = createDefaultMockData() + + const state: MockState = { + ...defaultData, + activeTimeEntry: null, + } + + // Single catch-all handler for all API and OAuth requests. + // This avoids glob pattern issues with query parameters. + await page.route('**/*', (route) => { + const url = route.request().url() + const method = route.request().method() + const pathname = getPathname(url) + + // Only intercept API and OAuth requests + if (!pathname.includes('/api/v1/') && !pathname.includes('/oauth/')) { + return route.fallback() + } + + // POST /oauth/token (token refresh) + if (pathname.endsWith('/oauth/token') && method === 'POST') { + return jsonResponse(route, { + access_token: 'mock-refreshed-access-token', + refresh_token: 'mock-refreshed-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + }) + } + + // GET /api/v1/users/me/time-entries/active + if (pathname.endsWith('/users/me/time-entries/active') && method === 'GET') { + if (state.activeTimeEntry) { + return jsonResponse(route, { data: state.activeTimeEntry }) + } + return jsonResponse(route, { + data: { + id: '', + description: null, + user_id: '', + start: '', + end: null, + duration: null, + task_id: null, + project_id: null, + tags: [], + billable: false, + organization_id: '', + }, + }) + } + + // GET /api/v1/users/me/memberships + if (pathname.endsWith('/users/me/memberships') && method === 'GET') { + return jsonResponse(route, { data: [state.membership] }) + } + + // GET /api/v1/users/me + if (pathname.endsWith('/users/me') && method === 'GET') { + return jsonResponse(route, { data: state.user }) + } + + // /api/v1/organizations/:org/time-entries/:id (specific entry) + const timeEntryMatch = pathname.match(/\/organizations\/[^/]+\/time-entries\/([^/]+)$/) + if (timeEntryMatch && timeEntryMatch[1] !== 'active') { + if (method === 'PUT') { + const body = route.request().postDataJSON() + const updatedEntry = { ...state.activeTimeEntry, ...body } + state.activeTimeEntry = null + return jsonResponse(route, { data: updatedEntry }) + } + if (method === 'DELETE') { + return route.fulfill({ status: 204 }) + } + return route.fallback() + } + + // /api/v1/organizations/:org/time-entries (collection) + if (pathname.match(/\/organizations\/[^/]+\/time-entries$/)) { + if (method === 'GET') { + return jsonResponse(route, { data: state.timeEntries }) + } + if (method === 'POST') { + const body = route.request().postDataJSON() + const newEntry = createTimeEntry(state.organization.id, state.user.id, { + ...body, + end: null, + duration: null, + }) + state.activeTimeEntry = newEntry + return jsonResponse(route, { data: newEntry }, 201) + } + if (method === 'PATCH') { + return jsonResponse(route, { data: state.timeEntries }) + } + if (method === 'DELETE') { + return route.fulfill({ status: 204 }) + } + return route.fallback() + } + + // GET /api/v1/organizations/:org/projects + if (pathname.match(/\/organizations\/[^/]+\/projects$/) && method === 'GET') { + return jsonResponse(route, { data: state.projects }) + } + + // GET /api/v1/organizations/:org/tags + if (pathname.match(/\/organizations\/[^/]+\/tags$/) && method === 'GET') { + return jsonResponse(route, { data: state.tags }) + } + + // GET /api/v1/organizations/:org/tasks + if (pathname.match(/\/organizations\/[^/]+\/tasks$/) && method === 'GET') { + return jsonResponse(route, { data: state.tasks }) + } + + // GET /api/v1/organizations/:org/clients + if (pathname.match(/\/organizations\/[^/]+\/clients$/) && method === 'GET') { + return jsonResponse(route, { data: state.clients }) + } + + // Unhandled API request — let it through (will likely fail with net error) + console.warn(`Unhandled API request: ${method} ${url}`) + return route.fallback() + }) + + return state +} diff --git a/e2e/mocks/data.ts b/e2e/mocks/data.ts new file mode 100644 index 0000000..ec919b0 --- /dev/null +++ b/e2e/mocks/data.ts @@ -0,0 +1,202 @@ +/** + * Mock data factories for E2E tests. + * Produces realistic data matching @solidtime/api response shapes. + */ + +let counter = 0 +function nextId(): string { + counter++ + return `00000000-0000-0000-0000-${String(counter).padStart(12, '0')}` +} + +export function resetIds(): void { + counter = 0 +} + +export function createUser(overrides: Record = {}) { + return { + id: nextId(), + name: 'Test User', + email: 'test@example.com', + profile_photo_url: null, + timezone: 'Europe/Vienna', + week_start: 'monday', + ...overrides, + } +} + +export function createOrganization(overrides: Record = {}) { + return { + id: nextId(), + name: 'Test Organization', + is_personal: false, + billable_rate: null, + employees_can_see_billable_rates: false, + employees_can_manage_tasks: true, + prevent_overlapping_time_entries: false, + currency: 'EUR', + currency_symbol: '\u20ac', + number_format: 'point-comma' as const, + currency_format: 'symbol-after-with-space' as const, + date_format: 'point-separated-d-m-yyyy' as const, + interval_format: 'hours-minutes-colon-separated' as const, + time_format: '24-hours' as const, + ...overrides, + } +} + +export function createMembership( + user: ReturnType, + organization: ReturnType, + overrides: Record = {} +) { + return { + id: nextId(), + user_id: user.id, + organization_id: organization.id, + role: 'owner', + is_placeholder: false, + billable_rate: null, + organization: { + id: organization.id, + name: organization.name, + is_personal: organization.is_personal, + billable_rate: organization.billable_rate, + employees_can_see_billable_rates: organization.employees_can_see_billable_rates, + employees_can_manage_tasks: organization.employees_can_manage_tasks, + prevent_overlapping_time_entries: organization.prevent_overlapping_time_entries, + currency: organization.currency, + currency_symbol: organization.currency_symbol, + number_format: organization.number_format, + currency_format: organization.currency_format, + date_format: organization.date_format, + interval_format: organization.interval_format, + time_format: organization.time_format, + }, + ...overrides, + } +} + +export function createProject(organizationId: string, overrides: Record = {}) { + return { + id: nextId(), + name: 'Test Project', + color: '#3b82f6', + client_id: null, + is_archived: false, + billable_rate: null, + is_billable: false, + estimated_time: null, + spent_time: 3600, + is_public: true, + organization_id: organizationId, + ...overrides, + } +} + +export function createTag(overrides: Record = {}) { + return { + id: nextId(), + name: 'Test Tag', + ...overrides, + } +} + +export function createTask(projectId: string, overrides: Record = {}) { + return { + id: nextId(), + name: 'Test Task', + is_done: false, + project_id: projectId, + estimated_time: null, + spent_time: 0, + ...overrides, + } +} + +export function createClient(overrides: Record = {}) { + return { + id: nextId(), + name: 'Test Client', + is_archived: false, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + ...overrides, + } +} + +export function createTimeEntry( + organizationId: string, + userId: string, + overrides: Record = {} +) { + const id = nextId() + return { + id, + description: 'Working on feature', + user_id: userId, + start: '2025-01-20T09:00:00Z', + end: '2025-01-20T10:00:00Z', + duration: 3600, + task_id: null, + project_id: null, + tags: [] as string[], + billable: false, + organization_id: organizationId, + ...overrides, + } +} + +/** + * Creates a complete set of mock data for a typical authenticated session. + */ +export function createDefaultMockData() { + resetIds() + + const user = createUser() + const organization = createOrganization() + const membership = createMembership(user, organization) + const project = createProject(organization.id, { name: 'Website Redesign', color: '#3b82f6' }) + const project2 = createProject(organization.id, { name: 'API Development', color: '#ef4444' }) + const tag = createTag({ name: 'frontend' }) + const tag2 = createTag({ name: 'backend' }) + const task = createTask(project.id, { name: 'Implement landing page' }) + const client = createClient({ name: 'Acme Corp' }) + + const timeEntries = [ + createTimeEntry(organization.id, user.id, { + description: 'Implement navigation component', + start: '2025-01-20T09:00:00Z', + end: '2025-01-20T11:30:00Z', + duration: 9000, + project_id: project.id, + tags: [tag.id], + }), + createTimeEntry(organization.id, user.id, { + description: 'Code review', + start: '2025-01-20T13:00:00Z', + end: '2025-01-20T14:00:00Z', + duration: 3600, + project_id: project2.id, + tags: [tag2.id], + }), + createTimeEntry(organization.id, user.id, { + description: 'API endpoint development', + start: '2025-01-19T10:00:00Z', + end: '2025-01-19T12:00:00Z', + duration: 7200, + project_id: project2.id, + }), + ] + + return { + user, + organization, + membership, + projects: [project, project2], + tags: [tag, tag2], + tasks: [task], + clients: [client], + timeEntries, + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..f05ea76 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests', + timeout: 30000, + retries: process.env.CI ? 2 : 0, + workers: 1, // Electron tests must run serially + reporter: process.env.CI ? 'github' : 'list', + use: { + trace: 'on-first-retry', + }, +}) diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts new file mode 100644 index 0000000..53b78cf --- /dev/null +++ b/e2e/tests/auth.spec.ts @@ -0,0 +1,41 @@ +import { unauthenticatedTest, test, expect } from '../fixtures/electron-test' + +unauthenticatedTest.describe('Login page (unauthenticated)', () => { + unauthenticatedTest('shows the login button when not authenticated', async ({ page }) => { + const loginButton = page.getByText(/log in with solidtime/i) + await expect(loginButton).toBeVisible() + }) + + unauthenticatedTest('shows the welcome text on the login page', async ({ page }) => { + const welcomeText = page.getByText(/welcome to the solidtime desktop client/i) + await expect(welcomeText).toBeVisible() + }) + + unauthenticatedTest('can open instance settings modal', async ({ page }) => { + const settingsButton = page.getByText(/instance settings/i) + await expect(settingsButton).toBeVisible() + await settingsButton.click() + + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible({ timeout: 5000 }) + }) +}) + +test.describe('Authenticated state', () => { + test('shows the main app when authenticated', async ({ page }) => { + // The footer shows "No timer running" when authenticated with no active timer + const footer = page.getByText(/no timer running/i) + await expect(footer).toBeVisible({ timeout: 10000 }) + }) + + test('shows the time page by default', async ({ page }) => { + await page.waitForURL(/#\/time/, { timeout: 5000 }) + expect(page.url()).toContain('#/time') + }) + + test('shows the sidebar navigation', async ({ page }) => { + // The sidebar contains navigation buttons (4 icon buttons) + const sidebarButtons = page.locator('.w-14.border-r button, .w-14.border-r [role="button"]') + await expect(sidebarButtons.first()).toBeVisible({ timeout: 5000 }) + }) +}) diff --git a/e2e/tests/navigation.spec.ts b/e2e/tests/navigation.spec.ts new file mode 100644 index 0000000..f5b3c66 --- /dev/null +++ b/e2e/tests/navigation.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '../fixtures/electron-test' + +test.describe('Navigation', () => { + test('starts on the time page by default', async ({ page }) => { + expect(page.url()).toContain('#/time') + }) + + test('can navigate to the calendar page via sidebar', async ({ page }) => { + // Sidebar buttons are icon-only in order: Time, Calendar, Statistics, Settings + // Click the 2nd button (Calendar) + const sidebarButtons = page.locator('.w-14.border-r button, .w-14.border-r [role="button"]') + await sidebarButtons.nth(1).click() + await page.waitForTimeout(500) + + expect(page.url()).toContain('#/calendar') + }) + + test('can navigate to the statistics page via sidebar', async ({ page }) => { + // Click the 3rd button (Statistics) + const sidebarButtons = page.locator('.w-14.border-r button, .w-14.border-r [role="button"]') + await sidebarButtons.nth(2).click() + await page.waitForTimeout(500) + + expect(page.url()).toContain('#/statistics') + }) + + test('can navigate to the settings page via sidebar', async ({ page }) => { + // Click the 4th button (Settings) + const sidebarButtons = page.locator('.w-14.border-r button, .w-14.border-r [role="button"]') + await sidebarButtons.nth(3).click() + await page.waitForTimeout(500) + + expect(page.url()).toContain('#/settings') + }) + + test('can navigate back to the time page via sidebar', async ({ page }) => { + // Navigate away first + await page.evaluate(() => (window.location.hash = '#/settings')) + await page.waitForTimeout(500) + + // Click the 1st button (Time) + const sidebarButtons = page.locator('.w-14.border-r button, .w-14.border-r [role="button"]') + await sidebarButtons.nth(0).click() + await page.waitForTimeout(500) + + expect(page.url()).toContain('#/time') + }) +}) diff --git a/e2e/tests/organization.spec.ts b/e2e/tests/organization.spec.ts new file mode 100644 index 0000000..eb3acf5 --- /dev/null +++ b/e2e/tests/organization.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from '../fixtures/electron-test' + +test.describe('Organization switcher', () => { + test('displays the current organization name', async ({ page, mockState }) => { + const orgName = page.getByText(mockState.organization.name) + await expect(orgName).toBeVisible({ timeout: 10000 }) + }) +}) diff --git a/e2e/tests/settings.spec.ts b/e2e/tests/settings.spec.ts new file mode 100644 index 0000000..c7bc8cd --- /dev/null +++ b/e2e/tests/settings.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from '../fixtures/electron-test' + +test.describe('Settings page', () => { + test.beforeEach(async ({ page }) => { + // Navigate to settings via URL hash + await page.evaluate(() => (window.location.hash = '#/settings')) + await page.waitForTimeout(1000) + }) + + test('displays user information', async ({ page }) => { + const userName = page.getByText('Test User') + await expect(userName).toBeVisible({ timeout: 5000 }) + + const userEmail = page.getByText('test@example.com') + await expect(userEmail).toBeVisible() + }) + + test('shows the settings heading', async ({ page }) => { + const heading = page.getByText('Settings', { exact: true }).first() + await expect(heading).toBeVisible({ timeout: 5000 }) + }) + + test('shows the logout button', async ({ page }) => { + const logoutButton = page.getByText(/logout/i) + await expect(logoutButton).toBeVisible() + }) + + test('logout returns to login page', async ({ page }) => { + const logoutButton = page.getByText(/logout/i) + await logoutButton.click() + + // Should show the login page + const loginButton = page.getByText(/log in with solidtime/i) + await expect(loginButton).toBeVisible({ timeout: 5000 }) + }) + + test('displays preferences section', async ({ page }) => { + const preferencesHeading = page.getByText('Preferences') + await expect(preferencesHeading).toBeVisible() + }) + + test('displays widget toggle', async ({ page }) => { + const widgetLabel = page.getByText(/show timetracker widget/i) + await expect(widgetLabel).toBeVisible() + }) +}) diff --git a/e2e/tests/statistics.spec.ts b/e2e/tests/statistics.spec.ts new file mode 100644 index 0000000..8a155bf --- /dev/null +++ b/e2e/tests/statistics.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from '../fixtures/electron-test' + +test.describe('Statistics page', () => { + test.beforeEach(async ({ page }) => { + // Navigate to statistics via URL hash + await page.evaluate(() => (window.location.hash = '#/statistics')) + await page.waitForTimeout(1000) + }) + + test('statistics page loads', async ({ page }) => { + expect(page.url()).toContain('#/statistics') + }) + + test('shows window activity statistics heading', async ({ page }) => { + // The statistics page shows either the stats content or a message about + // activity tracking being disabled + const heading = page.getByText(/window activity statistics/i) + const disabledMsg = page.getByText(/activity tracking is disabled/i) + + // One of these should be visible + const headingVisible = await heading.isVisible().catch(() => false) + const disabledVisible = await disabledMsg.isVisible().catch(() => false) + + expect(headingVisible || disabledVisible).toBe(true) + }) +}) diff --git a/e2e/tests/time-tracking.spec.ts b/e2e/tests/time-tracking.spec.ts new file mode 100644 index 0000000..658b756 --- /dev/null +++ b/e2e/tests/time-tracking.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from '../fixtures/electron-test' + +test.describe('Time tracking', () => { + test('displays time entries', async ({ page }) => { + // Verify time entry descriptions are visible + const entry = page.getByText('Implement navigation component') + await expect(entry).toBeVisible({ timeout: 10000 }) + }) + + test('shows "No timer running" in footer when no active timer', async ({ page }) => { + const footer = page.getByText(/no timer running/i) + await expect(footer).toBeVisible({ timeout: 10000 }) + }) + + test('displays project names in time entries', async ({ page }) => { + // The mock data includes projects "Website Redesign" and "API Development" + const project = page.getByText('Website Redesign') + await expect(project).toBeVisible({ timeout: 10000 }) + }) +}) diff --git a/package-lock.json b/package-lock.json index b0b5dcc..2cc9996 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@electron-toolkit/eslint-config-ts": "^2.0.0", "@electron-toolkit/tsconfig": "^2.0.0", "@heroicons/vue": "^2.1.5", + "@playwright/test": "^1.58.0", "@rushstack/eslint-patch": "^1.10.3", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.7", @@ -3369,6 +3370,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz", + "integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@prisma/instrumentation": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-5.22.0.tgz", @@ -10430,6 +10447,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz", + "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", + "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", diff --git a/package.json b/package.json index 705d86d..24d6d36 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "build:unpack": "npm run build && electron-builder --dir", "build:win": "npm run build && electron-builder --win", "build:mac": "npm run build && electron-builder --mac", - "build:linux": "npm run build && electron-builder --linux" + "build:linux": "npm run build && electron-builder --linux", + "test:e2e": "npx playwright test --config=e2e/playwright.config.ts" }, "build:": { "generateUpdatesFilesForAllChannels": true, @@ -48,6 +49,7 @@ "@electron-toolkit/eslint-config-ts": "^2.0.0", "@electron-toolkit/tsconfig": "^2.0.0", "@heroicons/vue": "^2.1.5", + "@playwright/test": "^1.58.0", "@rushstack/eslint-patch": "^1.10.3", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.7", diff --git a/src/main/activityTracker.ts b/src/main/activityTracker.ts index 3ce696c..58ef97c 100644 --- a/src/main/activityTracker.ts +++ b/src/main/activityTracker.ts @@ -2,10 +2,11 @@ import { db } from './db/client' import { windowActivities, validateNewWindowActivity } from './db/schema' import { getAppSettings } from './settings' import { hasScreenRecordingPermission } from './permissions' -import { ipcMain } from 'electron' +import { ipcMain, app } from 'electron' import { logger } from './logger' // Lazy-load x-win module with detailed error reporting +// eslint-disable-next-line @typescript-eslint/no-explicit-any let xWinModule: any = null let xWinLoadError: Error | null = null @@ -20,7 +21,7 @@ async function loadXWinModule() { console.log('Process versions:', JSON.stringify(process.versions, null, 2)) console.log('__dirname:', __dirname) console.log('process.cwd():', process.cwd()) - console.log('app.isPackaged:', require('electron').app.isPackaged) + console.log('app.isPackaged:', app.isPackaged) xWinModule = await import('@miniben90/x-win') console.log('=== @miniben90/x-win LOADED SUCCESSFULLY ===') diff --git a/src/main/appIcons.ts b/src/main/appIcons.ts index b56eb89..01a5499 100644 --- a/src/main/appIcons.ts +++ b/src/main/appIcons.ts @@ -3,6 +3,7 @@ import * as fs from 'fs/promises' import * as path from 'path' // Lazy-load x-win module with detailed error reporting +// eslint-disable-next-line @typescript-eslint/no-explicit-any let xWinModule: any = null let xWinLoadError: Error | null = null diff --git a/src/main/env.ts b/src/main/env.ts new file mode 100644 index 0000000..7d2603e --- /dev/null +++ b/src/main/env.ts @@ -0,0 +1,3 @@ +export function isE2ETesting(): boolean { + return process.env.E2E_TESTING === 'true' +} diff --git a/src/main/index.ts b/src/main/index.ts index d309c7c..223d241 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,6 +19,8 @@ import * as Sentry from '@sentry/electron/main' import path from 'node:path' import { stopIdleMonitoring } from './idleMonitor' +import { isE2ETesting } from './env' + // Global error handlers to capture full error details process.on('uncaughtException', (error) => { console.error('=== UNCAUGHT EXCEPTION ===') @@ -27,11 +29,13 @@ process.on('uncaughtException', (error) => { console.error('Error stack:', error.stack) console.error('Full error object:', JSON.stringify(error, Object.getOwnPropertyNames(error), 2)) - // Show error dialog - dialog.showErrorBox( - 'A JavaScript error occurred in the main process', - `${error.name}: ${error.message}\n\nStack:\n${error.stack}` - ) + // Show error dialog (skip in E2E testing to avoid blocking) + if (!isE2ETesting()) { + dialog.showErrorBox( + 'A JavaScript error occurred in the main process', + `${error.name}: ${error.message}\n\nStack:\n${error.stack}` + ) + } }) process.on('unhandledRejection', (reason, promise) => { @@ -43,9 +47,11 @@ process.on('unhandledRejection', (reason, promise) => { } }) -const gotTheLock = app.requestSingleInstanceLock() -if (!gotTheLock) { - app.quit() +if (!isE2ETesting()) { + const gotTheLock = app.requestSingleInstanceLock() + if (!gotTheLock) { + app.quit() + } } initializeAutoUpdater() @@ -191,6 +197,11 @@ app.on('window-all-closed', () => { // Save active periods before the app quits app.on('before-quit', async (event) => { + // Skip cleanup during E2E testing to avoid slow shutdown + if (isE2ETesting()) { + return + } + event.preventDefault() try { diff --git a/src/main/mainWindow.ts b/src/main/mainWindow.ts index e7d85b8..d281fbc 100644 --- a/src/main/mainWindow.ts +++ b/src/main/mainWindow.ts @@ -1,5 +1,6 @@ import { join } from 'path' import { app, BrowserWindow, ipcMain, shell } from 'electron' +import { isE2ETesting } from './env' let mainWindowInstance: BrowserWindow | null = null @@ -28,8 +29,10 @@ export function initializeMainWindow(icon: string) { }) app.on('activate', () => { - mainWindow.show() - mainWindow.focus() + if (!isE2ETesting()) { + mainWindow.show() + mainWindow.focus() + } }) let forcequit = false @@ -44,7 +47,9 @@ export function initializeMainWindow(icon: string) { }) mainWindow.on('ready-to-show', () => { - mainWindow.show() + if (!isE2ETesting()) { + mainWindow.show() + } }) mainWindowInstance = mainWindow @@ -59,7 +64,7 @@ export function registerMainWindowListeners(mainWindow: BrowserWindow) { mainWindow.webContents.send('stopTimer') }) ipcMain.on('showMainWindow', () => { - if (mainWindow) { + if (mainWindow && !isE2ETesting()) { mainWindow.show() mainWindow.focus() } diff --git a/src/main/miniWindow.ts b/src/main/miniWindow.ts index 364476b..a8db118 100644 --- a/src/main/miniWindow.ts +++ b/src/main/miniWindow.ts @@ -1,5 +1,6 @@ import { join } from 'path' import { app, BrowserWindow, ipcMain } from 'electron' +import { isE2ETesting } from './env' export function initializeMiniWindow(icon: string) { const miniWindow = new BrowserWindow({ @@ -27,8 +28,10 @@ export function initializeMiniWindow(icon: string) { export function registerMiniWindowListeners(miniWindow: BrowserWindow) { ipcMain.on('showMiniWindow', () => { - miniWindow.show() - miniWindow.focus() + if (!isE2ETesting()) { + miniWindow.show() + miniWindow.focus() + } }) ipcMain.on('hideMiniWindow', () => { miniWindow.hide() diff --git a/src/renderer/src/utils/tasks.ts b/src/renderer/src/utils/tasks.ts index efd653f..f79b0e3 100644 --- a/src/renderer/src/utils/tasks.ts +++ b/src/renderer/src/utils/tasks.ts @@ -1,7 +1,7 @@ import { apiClient } from './api.ts' export function getAllTasks(currentOrganizationId: string | null) { - if (currentOrganizationId === null) { + if (!currentOrganizationId) { throw new Error('No current organization id - all tasks') } return apiClient.value.getTasks({ From 970a4dc51776e4d2e36283786fd62d953fee0eaa Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Tue, 27 Jan 2026 18:51:09 +0100 Subject: [PATCH 02/29] split startTimer into startTimer (UI values) and continueLastTimer (last entry values) - startTimer() uses currentTimeEntry values from the UI - continueLastTimer() uses lastTimeEntry values (for widget/tray/idle) - Updated App.vue to use continueLastTimer for backend events and idle dialog - Added e2e tests for both functions --- e2e/tests/time-tracking.spec.ts | 53 ++++++++++++++++++++++++++++-- src/renderer/src/App.vue | 10 +++--- src/renderer/src/utils/useTimer.ts | 34 ++++++++++++++++--- 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/e2e/tests/time-tracking.spec.ts b/e2e/tests/time-tracking.spec.ts index 658b756..8db2a4a 100644 --- a/e2e/tests/time-tracking.spec.ts +++ b/e2e/tests/time-tracking.spec.ts @@ -2,7 +2,6 @@ import { test, expect } from '../fixtures/electron-test' test.describe('Time tracking', () => { test('displays time entries', async ({ page }) => { - // Verify time entry descriptions are visible const entry = page.getByText('Implement navigation component') await expect(entry).toBeVisible({ timeout: 10000 }) }) @@ -13,8 +12,58 @@ test.describe('Time tracking', () => { }) test('displays project names in time entries', async ({ page }) => { - // The mock data includes projects "Website Redesign" and "API Development" const project = page.getByText('Website Redesign') await expect(project).toBeVisible({ timeout: 10000 }) }) + + test('startTimer uses current UI values, not last entry values', async ({ + page, + electronApp, + }) => { + const timer = page.getByTestId('dashboard_timer') + const descriptionInput = timer.getByTestId('time_entry_description') + await expect(descriptionInput).toBeVisible({ timeout: 10000 }) + + const createRequestPromise = page.waitForRequest((req) => { + return req.url().includes('/time-entries') && req.method() === 'POST' + }) + + const newDescription = 'My new task description' + await descriptionInput.fill(newDescription) + await descriptionInput.press('Enter') + + const createRequest = await createRequestPromise + const body = createRequest.postDataJSON() + expect(body.description).toBe(newDescription) + + await expect(descriptionInput).toHaveValue(newDescription) + }) + + test('continueLastTimer uses last entry values when triggered from backend', async ({ + page, + electronApp, + }) => { + const timer = page.getByTestId('dashboard_timer') + const descriptionInput = timer.getByTestId('time_entry_description') + await expect(descriptionInput).toBeVisible({ timeout: 10000 }) + + // Wait for time entries to load so lastTimeEntry is populated + await page.waitForTimeout(2000) + + const createRequestPromise = page.waitForRequest((req) => { + return req.url().includes('/time-entries') && req.method() === 'POST' + }) + + const mainWindow = await electronApp.browserWindow(page) + await mainWindow.evaluate((win) => { + win.webContents.send('startTimer') + }) + + const createRequest = await createRequestPromise + const body = createRequest.postDataJSON() + expect(body.description).toBe('Implement navigation component') + expect(body.project_id).not.toBeNull() + + await expect(descriptionInput).toHaveValue('Implement navigation component') + }) }) diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index bbab6ea..2df0226 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -38,7 +38,7 @@ const router = useRouter() const queryClient = useQueryClient() // Use the timer composable for shared timer logic -const { stopTimer, startTimer, isActive } = useTimer() +const { stopTimer, continueLastTimer, isActive } = useTimer() // Live timer for bottom row display const { liveTimer, startLiveTimer, stopLiveTimer } = useLiveTimer() @@ -92,9 +92,9 @@ onMounted(async () => { // Initialize settings from database await initializeSettings() - // Listen for timer events from mini window + // Listen for timer events from mini window / tray await listenForBackendEvent('startTimer', () => { - startTimer() + continueLastTimer() }) await listenForBackendEvent('stopTimer', () => { stopTimer() @@ -120,8 +120,8 @@ async function handleIdleDialogResponse(choice: number, idleStartTime: string) { case 2: // Discard & Start New Timer // Stop the timer and set the end time to when idle started await stopTimer(idleStartTime) - // Start a new timer after the stop completes - startTimer() + // Continue with the last timer's values after the stop completes + continueLastTimer() break } } diff --git a/src/renderer/src/utils/useTimer.ts b/src/renderer/src/utils/useTimer.ts index ebd4d85..c42fdba 100644 --- a/src/renderer/src/utils/useTimer.ts +++ b/src/renderer/src/utils/useTimer.ts @@ -65,14 +65,40 @@ export function useTimer() { } /** - * Start a new timer - * Copies properties from the last time entry if available + * Start a new timer using the current UI values. + * Takes whatever is currently set on currentTimeEntry (description, project, task, etc.) + * and starts a timer with those values. Does not fall back to lastTimeEntry. */ function startTimer() { const startTime = dayjs().utc().format() + const current = currentTimeEntry.value + + currentTimeEntry.value = { + ...emptyTimeEntry, + project_id: current.project_id, + task_id: current.task_id, + description: current.description, + tags: current.tags, + billable: current.billable, + start: startTime, + } + + const timeEntryToCreate: CreateTimeEntryBody = { + ...currentTimeEntry.value, + member_id: currentMembershipId.value!, + } + timeEntryCreate.mutate(timeEntryToCreate) + } + + /** + * Continue the last timer. + * Starts a new timer using the values from lastTimeEntry (description, project, task, etc.). + * Used when starting a timer from the widget, tray, or after discarding idle time. + */ + function continueLastTimer() { + const startTime = dayjs().utc().format() if (lastTimeEntry.value && lastTimeEntry.value.start) { - // Copy properties from last entry currentTimeEntry.value = { ...emptyTimeEntry, project_id: lastTimeEntry.value.project_id, @@ -83,7 +109,6 @@ export function useTimer() { start: startTime, } } else { - // First timer - start fresh currentTimeEntry.value = { ...emptyTimeEntry, start: startTime, @@ -103,6 +128,7 @@ export function useTimer() { isActive, stopTimer, startTimer, + continueLastTimer, timeEntryStop, timeEntryCreate, } From c302ab57ac1b49817a41934302e641d2f2c62a54 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Tue, 27 Jan 2026 20:57:02 +0100 Subject: [PATCH 03/29] add 10 minute bucket grouping for activities --- src/main/activityPeriods.ts | 302 +++++++++++++++++++++++++++--------- 1 file changed, 227 insertions(+), 75 deletions(-) diff --git a/src/main/activityPeriods.ts b/src/main/activityPeriods.ts index 91b46ef..7cf74bb 100644 --- a/src/main/activityPeriods.ts +++ b/src/main/activityPeriods.ts @@ -1,7 +1,7 @@ import { ipcMain } from 'electron' import { db } from './db/client' import { activityPeriods, windowActivities } from './db/schema' -import { gte, lte, and, sql } from 'drizzle-orm' +import { gte, lte, and } from 'drizzle-orm' import * as Sentry from '@sentry/electron/main' import { getCurrentActivityPeriod } from './idleMonitor' @@ -42,64 +42,231 @@ interface ActivityPeriodsResult { error?: string } -// Helper function to validate ISO date strings with strict UTC format -function isValidISODate(dateString: unknown): dateString is string { - if (typeof dateString !== 'string' || dateString.length === 0) { - return false - } +const BUCKET_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes in milliseconds - // Check for ISO 8601 format with 'T' separator - if (!dateString.includes('T')) { - return false - } +/** + * Rounds a timestamp down to the nearest 10-minute boundary. + * E.g., 10:03 → 10:00, 10:17 → 10:10 + */ +function floorToInterval(date: Date): Date { + const ms = date.getTime() + return new Date(ms - (ms % BUCKET_INTERVAL_MS)) +} - const date = new Date(dateString) - if (!(date instanceof Date) || isNaN(date.getTime())) { - return false - } +/** + * Rounds a timestamp up to the nearest 10-minute boundary. + * E.g., 10:03 → 10:10, 10:20 → 10:20 (no change if already on boundary) + */ +function ceilToInterval(date: Date): Date { + const ms = date.getTime() + const remainder = ms % BUCKET_INTERVAL_MS + return remainder === 0 ? new Date(ms) : new Date(ms + (BUCKET_INTERVAL_MS - remainder)) +} - // Verify the string can be parsed back to the same value - return date.toISOString() !== 'Invalid Date' +interface RawWindowActivity { + timestamp: string + durationSeconds: number + appName: string + url: string | null } /** - * Fetches window activities for a specific activity period - * Note: Icons are NOT included to reduce memory usage. Load them separately in the UI. - * Only returns top 5 activities to reduce data transfer (UI only displays top 5 in tooltip). + * Fetches all window activities in the given date range in a single query. */ -async function getWindowActivitiesForPeriod( - periodStart: string, - periodEnd: string -): Promise { +async function fetchAllWindowActivitiesInRange( + startDate: string, + endDate: string +): Promise { try { const activities = await db .select({ + timestamp: windowActivities.timestamp, + durationSeconds: windowActivities.durationSeconds, appName: windowActivities.appName, url: windowActivities.url, - count: sql`SUM(${windowActivities.durationSeconds})`, }) .from(windowActivities) .where( and( - gte(windowActivities.timestamp, periodStart), - lte(windowActivities.timestamp, periodEnd) + gte(windowActivities.timestamp, startDate), + lte(windowActivities.timestamp, endDate) ) ) - .groupBy(windowActivities.appName, windowActivities.url) - .orderBy(sql`SUM(${windowActivities.durationSeconds}) DESC`) - .limit(5) // Only return top 5 activities per period - - return activities.map((activity) => ({ - appName: activity.appName, - url: activity.url, - count: Number(activity.count), + + return activities.map((a) => ({ + timestamp: a.timestamp, + durationSeconds: a.durationSeconds, + appName: a.appName, + url: a.url, })) } catch (error) { - console.error('Failed to get window activities for period:', error) + console.error('Failed to fetch window activities in range:', error) return [] } } +interface RawPeriod { + start: string + end: string + isIdle: boolean +} + +/** + * Transforms variable-length activity periods into clock-aligned 10-minute buckets. + * Each bucket's idle/active state is determined by majority overlap. + * Window activities are aggregated per bucket with top 5 by duration. + */ +function bucketizeActivityPeriods( + rawPeriods: RawPeriod[], + allWindowActivities: RawWindowActivity[], + now: Date +): ActivityPeriodResponse[] { + if (rawPeriods.length === 0) { + return [] + } + + // Find the overall time range from all periods + let minTime = Infinity + let maxTime = -Infinity + for (const period of rawPeriods) { + const s = new Date(period.start).getTime() + const e = new Date(period.end).getTime() + if (s < minTime) minTime = s + if (e > maxTime) maxTime = e + } + + // Generate clock-aligned bucket boundaries + const bucketStart = floorToInterval(new Date(minTime)).getTime() + const bucketEnd = ceilToInterval(new Date(maxTime)).getTime() + + const nowMs = now.getTime() + const result: ActivityPeriodResponse[] = [] + + for (let bStart = bucketStart; bStart < bucketEnd; bStart += BUCKET_INTERVAL_MS) { + const bEnd = bStart + BUCKET_INTERVAL_MS + + // Calculate overlap with each raw period, split by idle/active + let activeMs = 0 + let idleMs = 0 + + for (const period of rawPeriods) { + const pStart = new Date(period.start).getTime() + const pEnd = new Date(period.end).getTime() + + // Calculate overlap between bucket [bStart, bEnd) and period [pStart, pEnd) + const overlapStart = Math.max(bStart, pStart) + const overlapEnd = Math.min(bEnd, pEnd) + const overlap = overlapEnd - overlapStart + + if (overlap > 0) { + if (period.isIdle) { + idleMs += overlap + } else { + activeMs += overlap + } + } + } + + // Skip buckets with no overlap (gaps in tracking) + if (activeMs === 0 && idleMs === 0) { + continue + } + + // Determine state: majority wins, active on tie + const isIdle = idleMs > activeMs + + // Determine the actual end of this bucket (cap at now for in-progress periods) + const effectiveEnd = Math.min(bEnd, nowMs) + const bucketStartISO = new Date(bStart).toISOString() + const bucketEndISO = new Date(effectiveEnd).toISOString() + + // Aggregate window activities for this bucket + const bucketActivities = aggregateWindowActivitiesForBucket( + allWindowActivities, + bucketStartISO, + bucketEndISO + ) + + result.push({ + start: bucketStartISO, + end: bucketEndISO, + isIdle, + windowActivities: bucketActivities.length > 0 ? bucketActivities : undefined, + }) + } + + return result +} + +/** + * Filters and aggregates window activities for a specific bucket time range. + * Returns top 5 activities by total duration. + */ +function aggregateWindowActivitiesForBucket( + allActivities: RawWindowActivity[], + bucketStart: string, + bucketEnd: string +): WindowActivityInPeriod[] { + // Filter activities that fall within this bucket + const filtered = allActivities.filter( + (a) => a.timestamp >= bucketStart && a.timestamp <= bucketEnd + ) + + if (filtered.length === 0) { + return [] + } + + // Aggregate by appName + url + const aggregated = new Map< + string, + { appName: string; url: string | null; totalDuration: number } + >() + + for (const activity of filtered) { + const key = `${activity.appName}::${activity.url ?? ''}` + const existing = aggregated.get(key) + if (existing) { + existing.totalDuration += activity.durationSeconds + } else { + aggregated.set(key, { + appName: activity.appName, + url: activity.url, + totalDuration: activity.durationSeconds, + }) + } + } + + // Sort by duration descending and take top 5 + return Array.from(aggregated.values()) + .sort((a, b) => b.totalDuration - a.totalDuration) + .slice(0, 5) + .map((a) => ({ + appName: a.appName, + url: a.url, + count: a.totalDuration, + })) +} + +// Helper function to validate ISO date strings with strict UTC format +function isValidISODate(dateString: unknown): dateString is string { + if (typeof dateString !== 'string' || dateString.length === 0) { + return false + } + + // Check for ISO 8601 format with 'T' separator + if (!dateString.includes('T')) { + return false + } + + const date = new Date(dateString) + if (!(date instanceof Date) || isNaN(date.getTime())) { + return false + } + + // Verify the string can be parsed back to the same value + return date.toISOString() !== 'Invalid Date' +} + /** * Fetches activity periods from the database for a given date range */ @@ -133,66 +300,51 @@ async function getActivityPeriods( return { success: false, error } } + // Fetch raw periods from database const periods = await db .select() .from(activityPeriods) .where(and(gte(activityPeriods.start, startDate), lte(activityPeriods.end, endDate))) .orderBy(activityPeriods.start) - // Validate returned data structure and fetch window activities for each period - const validatedPeriods: ActivityPeriodResponse[] = await Promise.all( - periods.map(async (period) => { - if (!isValidISODate(period.start) || !isValidISODate(period.end)) { - throw new Error( - `Invalid date format in database record: ${JSON.stringify(period)}` - ) - } - - // Fetch window activities for this period - const windowActivitiesForPeriod = await getWindowActivitiesForPeriod( - period.start, - period.end - ) - - return { - start: period.start, - end: period.end, - isIdle: Boolean(period.isIdle), - windowActivities: - windowActivitiesForPeriod.length > 0 - ? windowActivitiesForPeriod - : undefined, - } - }) - ) + // Validate and collect raw periods + const rawPeriods: RawPeriod[] = periods.map((period) => { + if (!isValidISODate(period.start) || !isValidISODate(period.end)) { + throw new Error(`Invalid date format in database record: ${JSON.stringify(period)}`) + } + return { + start: period.start, + end: period.end, + isIdle: Boolean(period.isIdle), + } + }) - // Include the current ongoing activity period if it exists and overlaps with the requested range + // Include the current ongoing activity period if it overlaps with the requested range const currentPeriod = getCurrentActivityPeriod() if (currentPeriod) { const currentStart = new Date(currentPeriod.start).getTime() const currentEnd = new Date(currentPeriod.end).getTime() - // Check if current period overlaps with requested date range if (currentEnd >= startDateTime && currentStart <= endDateTime) { - // Fetch window activities for the current period - const windowActivitiesForCurrentPeriod = await getWindowActivitiesForPeriod( - currentPeriod.start, - currentPeriod.end - ) - - validatedPeriods.push({ + rawPeriods.push({ start: currentPeriod.start, end: currentPeriod.end, isIdle: currentPeriod.isIdle, - windowActivities: - windowActivitiesForCurrentPeriod.length > 0 - ? windowActivitiesForCurrentPeriod - : undefined, }) } } - return { success: true, data: validatedPeriods } + // Fetch all window activities in the date range in a single query + const allWindowActivities = await fetchAllWindowActivitiesInRange(startDate, endDate) + + // Transform into clock-aligned 10-minute buckets + const bucketedPeriods = bucketizeActivityPeriods( + rawPeriods, + allWindowActivities, + new Date() + ) + + return { success: true, data: bucketedPeriods } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' console.error('Failed to fetch activity periods:', errorMessage, error) From 697bcc6b98f836043dd82bb343232229bb731f28 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Tue, 27 Jan 2026 21:10:14 +0100 Subject: [PATCH 04/29] fix electron focus behaviour on navigation items showing unnecessary tooltip --- src/renderer/src/components/SidebarNavigation.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/SidebarNavigation.vue b/src/renderer/src/components/SidebarNavigation.vue index a69ea70..a6a9702 100644 --- a/src/renderer/src/components/SidebarNavigation.vue +++ b/src/renderer/src/components/SidebarNavigation.vue @@ -42,7 +42,7 @@ function navigateTo(path: string) { diff --git a/src/renderer/src/components/InstanceSettingsModal.vue b/src/renderer/src/components/InstanceSettingsModal.vue index f5c8262..e429c14 100644 --- a/src/renderer/src/components/InstanceSettingsModal.vue +++ b/src/renderer/src/components/InstanceSettingsModal.vue @@ -42,7 +42,7 @@ function submit() {
Settings
-
+
-
+
{
-
+
-
+
{

No time entries found

-

Create your first time entry now!

+

Create your first time entry now!

{
+ class="flex justify-center items-center py-5 text-muted-foreground font-medium"> All time entries are loaded!
@@ -432,7 +432,9 @@ watch(isLoadMoreVisible, async (isVisible) => {
- Fetching data + Fetching data
diff --git a/src/renderer/src/components/UpdateStatusBar.vue b/src/renderer/src/components/UpdateStatusBar.vue index 15dd22f..e4a0bd6 100644 --- a/src/renderer/src/components/UpdateStatusBar.vue +++ b/src/renderer/src/components/UpdateStatusBar.vue @@ -29,7 +29,7 @@ function scheduleHideUpdateProgressBar() {
+ class="text-muted-foreground text-sm font-medium flex items-center space-x-2 px-2"> {{ variables.length }} Updates pending diff --git a/src/renderer/src/main.ts b/src/renderer/src/main.ts index 9899719..43baae4 100644 --- a/src/renderer/src/main.ts +++ b/src/renderer/src/main.ts @@ -1,7 +1,6 @@ import { createApp } from 'vue' import App from './App.vue' import './style.css' -import '@solidtime/ui/style.css' import { VueQueryPlugin, type VueQueryPluginOptions } from '@tanstack/vue-query' import router from './router' diff --git a/src/renderer/src/style.css b/src/renderer/src/style.css index 47c64c2..a518251 100644 --- a/src/renderer/src/style.css +++ b/src/renderer/src/style.css @@ -1,5 +1,6 @@ /* Import shared solidtime styles from UI package */ @import '@solidtime/ui/styles.css'; +@import '@solidtime/ui/style.css'; /* Desktop app specific styles - Inter font */ @font-face { From f524fff623abc4bdc9edd240c9c25482492d3aa9 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Wed, 4 Mar 2026 15:22:54 +0100 Subject: [PATCH 08/29] add activity data delete options with time range --- e2e/tests/settings.spec.ts | 83 ++++++ e2e/tests/statistics.spec.ts | 49 +++ e2e/tests/time-tracking.spec.ts | 5 +- src/main/activityPeriods.ts | 30 ++ src/main/windowActivities.ts | 35 +++ src/preload/interface.d.ts | 8 + src/preload/main.ts | 4 + src/renderer/src/pages/SettingsPage.vue | 332 ++++++++++++++++----- src/renderer/src/pages/StatisticsPage.vue | 120 +++++++- src/renderer/src/utils/windowActivities.ts | 41 +++ 10 files changed, 623 insertions(+), 84 deletions(-) diff --git a/e2e/tests/settings.spec.ts b/e2e/tests/settings.spec.ts index c7bc8cd..cc34372 100644 --- a/e2e/tests/settings.spec.ts +++ b/e2e/tests/settings.spec.ts @@ -44,3 +44,86 @@ test.describe('Settings page', () => { await expect(widgetLabel).toBeVisible() }) }) + +test.describe('Settings page - Data Management', () => { + test.beforeEach(async ({ page }) => { + await page.evaluate(() => (window.location.hash = '#/settings')) + await page.waitForTimeout(1000) + }) + + test('window activities dropdown shows time range options with correct labels', async ({ + page, + }) => { + const button = page.getByTestId('window-activities-range-button') + await expect(button).toBeVisible({ timeout: 5000 }) + await button.click() + + await expect(page.getByTestId('wa-range-15')).toHaveText('Last 15 minutes') + await expect(page.getByTestId('wa-range-60')).toHaveText('Last hour') + await expect(page.getByTestId('wa-range-1440')).toHaveText('Last 24 hours') + await expect(page.getByTestId('wa-range-10080')).toHaveText('Last 7 days') + await expect(page.getByTestId('wa-range-40320')).toHaveText('Last 4 weeks') + }) + + test('window activities range option opens confirmation modal that can be cancelled', async ({ + page, + }) => { + await page.getByTestId('window-activities-range-button').click() + await page.getByTestId('wa-range-60').click() + + const modalHeading = page.getByRole('heading', { name: 'Delete Window Activities' }) + await expect(modalHeading).toBeVisible({ timeout: 3000 }) + await expect(page.locator('strong', { hasText: 'last hour' })).toBeVisible() + await expect(page.getByText('This action cannot be undone.')).toBeVisible() + + await page.getByText('Cancel').click() + await expect(modalHeading).not.toBeVisible({ timeout: 3000 }) + }) + + test('window activities Delete All button opens confirmation modal', async ({ page }) => { + await page.locator('button', { hasText: 'Delete All' }).first().click() + + const modalHeading = page.getByRole('heading', { name: 'Delete Window Activities' }) + await expect(modalHeading).toBeVisible({ timeout: 3000 }) + await expect( + page.getByText('Are you sure you want to delete all window activities') + ).toBeVisible() + }) + + test('activity periods dropdown shows time range options', async ({ page }) => { + const button = page.getByTestId('activity-periods-range-button') + await expect(button).toBeVisible({ timeout: 5000 }) + await button.click() + + await expect(page.getByTestId('ap-range-15')).toBeVisible({ timeout: 3000 }) + await expect(page.getByTestId('ap-range-60')).toBeVisible() + await expect(page.getByTestId('ap-range-1440')).toBeVisible() + await expect(page.getByTestId('ap-range-10080')).toBeVisible() + await expect(page.getByTestId('ap-range-40320')).toBeVisible() + }) + + test('activity periods range option opens confirmation modal that can be cancelled', async ({ + page, + }) => { + await page.getByTestId('activity-periods-range-button').click() + await page.getByTestId('ap-range-10080').click() + + const modalHeading = page.getByRole('heading', { name: 'Delete Activity Periods' }) + await expect(modalHeading).toBeVisible({ timeout: 3000 }) + await expect(page.locator('strong', { hasText: 'last 7 days' })).toBeVisible() + await expect(page.getByText('This action cannot be undone.')).toBeVisible() + + await page.getByText('Cancel').click() + await expect(modalHeading).not.toBeVisible({ timeout: 3000 }) + }) + + test('activity periods Delete All button opens confirmation modal', async ({ page }) => { + await page.locator('button', { hasText: 'Delete All' }).nth(1).click() + + const modalHeading = page.getByRole('heading', { name: 'Delete Activity Periods' }) + await expect(modalHeading).toBeVisible({ timeout: 3000 }) + await expect( + page.getByText('Are you sure you want to delete all activity periods') + ).toBeVisible() + }) +}) diff --git a/e2e/tests/statistics.spec.ts b/e2e/tests/statistics.spec.ts index 8a155bf..a217f3b 100644 --- a/e2e/tests/statistics.spec.ts +++ b/e2e/tests/statistics.spec.ts @@ -24,3 +24,52 @@ test.describe('Statistics page', () => { expect(headingVisible || disabledVisible).toBe(true) }) }) + +test.describe('Statistics page - actions dropdown', () => { + test.beforeEach(async ({ page }) => { + // Enable activity tracking so the statistics header is rendered + await page.evaluate(() => (window.location.hash = '#/settings')) + await page.waitForTimeout(1000) + + const checkbox = page.getByText(/enable window activity tracking/i) + const isChecked = await page + .locator('input[name="activityTracking"]') + .isChecked() + .catch(() => false) + if (!isChecked) { + await checkbox.click() + await page.waitForTimeout(500) + const continueBtn = page.getByText('Continue Without Permission') + if (await continueBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await continueBtn.click() + } + } + + await page.evaluate(() => (window.location.hash = '#/statistics')) + await page.waitForTimeout(1000) + }) + + test('actions dropdown shows refresh and delete options', async ({ page }) => { + const actionsButton = page.getByTestId('statistics-actions-button') + await expect(actionsButton).toBeVisible({ timeout: 5000 }) + await actionsButton.click() + + await expect(page.getByTestId('refresh-button')).toHaveText('Refresh statistics') + await expect(page.getByTestId('delete-range-button')).toHaveText('Delete activity in range') + }) + + test('delete option opens confirmation modal that can be cancelled', async ({ page }) => { + const actionsButton = page.getByTestId('statistics-actions-button') + await expect(actionsButton).toBeVisible({ timeout: 5000 }) + await actionsButton.click() + + await page.getByTestId('delete-range-button').click() + + const modalHeading = page.getByRole('heading', { name: 'Delete Window Activities' }) + await expect(modalHeading).toBeVisible({ timeout: 3000 }) + await expect(page.getByText('This action cannot be undone.')).toBeVisible() + + await page.getByText('Cancel').click() + await expect(modalHeading).not.toBeVisible({ timeout: 3000 }) + }) +}) diff --git a/e2e/tests/time-tracking.spec.ts b/e2e/tests/time-tracking.spec.ts index 8db2a4a..587941a 100644 --- a/e2e/tests/time-tracking.spec.ts +++ b/e2e/tests/time-tracking.spec.ts @@ -16,10 +16,7 @@ test.describe('Time tracking', () => { await expect(project).toBeVisible({ timeout: 10000 }) }) - test('startTimer uses current UI values, not last entry values', async ({ - page, - electronApp, - }) => { + test('startTimer uses current UI values, not last entry values', async ({ page }) => { const timer = page.getByTestId('dashboard_timer') const descriptionInput = timer.getByTestId('time_entry_description') await expect(descriptionInput).toBeVisible({ timeout: 10000 }) diff --git a/src/main/activityPeriods.ts b/src/main/activityPeriods.ts index 7cf74bb..be91c0f 100644 --- a/src/main/activityPeriods.ts +++ b/src/main/activityPeriods.ts @@ -22,6 +22,28 @@ async function deleteAllActivityPeriods(): Promise<{ success: boolean; error?: s } } +/** + * Deletes activity periods within a specific date range + */ +async function deleteActivityPeriodsInRange( + startDate: string, + endDate: string +): Promise<{ success: boolean; error?: string }> { + try { + await db + .delete(activityPeriods) + .where(and(gte(activityPeriods.start, startDate), lte(activityPeriods.end, endDate))) + console.log(`Activity periods deleted for range ${startDate} - ${endDate}`) + return { success: true } + } catch (error) { + console.error('Failed to delete activity periods in range:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + } + } +} + // Type definitions for activity period responses interface WindowActivityInPeriod { appName: string @@ -372,4 +394,12 @@ export function registerActivityPeriodListeners(): void { ipcMain.handle('deleteAllActivityPeriods', async () => { return deleteAllActivityPeriods() }) + + // Delete activity periods in a date range + ipcMain.handle( + 'deleteActivityPeriodsInRange', + async (_event, startDate: string, endDate: string) => { + return deleteActivityPeriodsInRange(startDate, endDate) + } + ) } diff --git a/src/main/windowActivities.ts b/src/main/windowActivities.ts index c93e579..c3258fb 100644 --- a/src/main/windowActivities.ts +++ b/src/main/windowActivities.ts @@ -20,6 +20,33 @@ async function deleteAllWindowActivities(): Promise<{ success: boolean; error?: } } +/** + * Deletes window activities within a specific date range + */ +async function deleteWindowActivitiesInRange( + startDate: string, + endDate: string +): Promise<{ success: boolean; error?: string }> { + try { + await db + .delete(windowActivities) + .where( + and( + gte(windowActivities.timestamp, startDate), + lte(windowActivities.timestamp, endDate) + ) + ) + console.log(`Window activities deleted for range ${startDate} - ${endDate}`) + return { success: true } + } catch (error) { + console.error('Failed to delete window activities in range:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + } + } +} + /** * Registers IPC handlers for window activities */ @@ -82,4 +109,12 @@ export function registerWindowActivitiesHandlers() { ipcMain.handle('deleteAllWindowActivities', async () => { return deleteAllWindowActivities() }) + + // Delete window activities in a date range + ipcMain.handle( + 'deleteWindowActivitiesInRange', + async (_event, startDate: string, endDate: string) => { + return deleteWindowActivitiesInRange(startDate, endDate) + } + ) } diff --git a/src/preload/interface.d.ts b/src/preload/interface.d.ts index ca195bb..6e4679d 100644 --- a/src/preload/interface.d.ts +++ b/src/preload/interface.d.ts @@ -60,6 +60,14 @@ export interface IElectronAPI { requestScreenRecordingPermission: () => Promise deleteAllWindowActivities: () => Promise<{ success: boolean; error?: string }> deleteAllActivityPeriods: () => Promise<{ success: boolean; error?: string }> + deleteWindowActivitiesInRange: ( + startDate: string, + endDate: string + ) => Promise<{ success: boolean; error?: string }> + deleteActivityPeriodsInRange: ( + startDate: string, + endDate: string + ) => Promise<{ success: boolean; error?: string }> } declare global { diff --git a/src/preload/main.ts b/src/preload/main.ts index aeb0a16..aebf3b1 100644 --- a/src/preload/main.ts +++ b/src/preload/main.ts @@ -59,6 +59,10 @@ if (process.contextIsolated || true) { ipcRenderer.invoke('requestScreenRecordingPermission'), deleteAllWindowActivities: () => ipcRenderer.invoke('deleteAllWindowActivities'), deleteAllActivityPeriods: () => ipcRenderer.invoke('deleteAllActivityPeriods'), + deleteWindowActivitiesInRange: (startDate: string, endDate: string) => + ipcRenderer.invoke('deleteWindowActivitiesInRange', startDate, endDate), + deleteActivityPeriodsInRange: (startDate: string, endDate: string) => + ipcRenderer.invoke('deleteActivityPeriodsInRange', startDate, endDate), }) } catch (error) { console.error(error) diff --git a/src/renderer/src/pages/SettingsPage.vue b/src/renderer/src/pages/SettingsPage.vue index 15b4df4..4ed1105 100644 --- a/src/renderer/src/pages/SettingsPage.vue +++ b/src/renderer/src/pages/SettingsPage.vue @@ -1,5 +1,16 @@ From 4b7bd55e4c06abde1bc69a1e1d3fb5624e2a05e5 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Thu, 5 Mar 2026 14:35:17 +0100 Subject: [PATCH 10/29] fix navigation item hover color consistency on active item --- src/renderer/src/components/SidebarNavigation.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/SidebarNavigation.vue b/src/renderer/src/components/SidebarNavigation.vue index a6a9702..407f539 100644 --- a/src/renderer/src/components/SidebarNavigation.vue +++ b/src/renderer/src/components/SidebarNavigation.vue @@ -51,7 +51,8 @@ function navigateTo(path: string) { variant="ghost" :class="[ 'transition-colors text-text-tertiary w-11 h-11 [&_svg]:size-5', - isActive(item.path) && 'text-text-primary shadow-xs bg-quaternary', + isActive(item.path) && + 'text-text-primary shadow-xs bg-quaternary hover:!bg-quaternary', ]" @click="navigateTo(item.path)"> From 095e3f3a5e174efc2da7d9d21168f2904d3e2fc6 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Tue, 10 Mar 2026 15:00:53 +0100 Subject: [PATCH 11/29] load updates in the background --- src/main/autoUpdater.ts | 14 +++---- src/preload/interface.d.ts | 3 +- src/preload/main.ts | 3 +- .../src/components/AutoUpdaterOverlay.vue | 42 +++++-------------- src/renderer/src/pages/SettingsPage.vue | 30 +++++++++++-- 5 files changed, 49 insertions(+), 43 deletions(-) diff --git a/src/main/autoUpdater.ts b/src/main/autoUpdater.ts index dad34d0..149e45c 100644 --- a/src/main/autoUpdater.ts +++ b/src/main/autoUpdater.ts @@ -13,7 +13,7 @@ export function getAutoUpdater(): AppUpdater { } export function initializeAutoUpdater() { - getAutoUpdater().autoDownload = false + getAutoUpdater().autoDownload = true getAutoUpdater().autoInstallOnAppQuit = false getAutoUpdater().allowDowngrade = true } @@ -33,17 +33,17 @@ export function registerAutoUpdateListeners(mainWindow: Electron.BrowserWindow) }) getAutoUpdater().addListener('update-downloaded', () => { - app.emit('before-quit') - setTimeout(() => { - getAutoUpdater().quitAndInstall() - }, 500) + mainWindow.webContents.send('updateDownloaded') }) getAutoUpdater().addListener('error', (error) => { mainWindow.webContents.send('updateError', error.message) }) - ipcMain.on('triggerUpdate', () => { - getAutoUpdater().downloadUpdate() + ipcMain.on('installUpdate', () => { + app.emit('before-quit') + setTimeout(() => { + getAutoUpdater().quitAndInstall() + }, 500) }) } diff --git a/src/preload/interface.d.ts b/src/preload/interface.d.ts index 6e4679d..16d284a 100644 --- a/src/preload/interface.d.ts +++ b/src/preload/interface.d.ts @@ -30,8 +30,9 @@ export interface IElectronAPI { showMiniWindow: () => void hideMiniWindow: () => void onUpdateAvailable: (callback: () => void) => void + onUpdateDownloaded: (callback: () => void) => void onUpdateNotAvailable: (callback: () => void) => void - triggerUpdate: () => void + installUpdate: () => void startTimer: () => void stopTimer: () => void onOpenDeeplink: (callback: (url: string) => Promise) => void diff --git a/src/preload/main.ts b/src/preload/main.ts index aebf3b1..7382f95 100644 --- a/src/preload/main.ts +++ b/src/preload/main.ts @@ -22,8 +22,9 @@ if (process.contextIsolated || true) { showMiniWindow: () => ipcRenderer.send('showMiniWindow'), hideMiniWindow: () => ipcRenderer.send('hideMiniWindow'), showMainWindow: () => ipcRenderer.send('showMainWindow'), - triggerUpdate: () => ipcRenderer.send('triggerUpdate'), + installUpdate: () => ipcRenderer.send('installUpdate'), onUpdateAvailable: (callback) => ipcRenderer.on('updateAvailable', () => callback()), + onUpdateDownloaded: (callback) => ipcRenderer.on('updateDownloaded', () => callback()), onUpdateNotAvailable: (callback) => ipcRenderer.on('updateNotAvailable', () => callback()), onAutoUpdaterError: (callback) => diff --git a/src/renderer/src/components/AutoUpdaterOverlay.vue b/src/renderer/src/components/AutoUpdaterOverlay.vue index 6149553..01fc93b 100644 --- a/src/renderer/src/components/AutoUpdaterOverlay.vue +++ b/src/renderer/src/components/AutoUpdaterOverlay.vue @@ -1,54 +1,34 @@ diff --git a/src/renderer/src/pages/SettingsPage.vue b/src/renderer/src/pages/SettingsPage.vue index 4ed1105..d2a0b05 100644 --- a/src/renderer/src/pages/SettingsPage.vue +++ b/src/renderer/src/pages/SettingsPage.vue @@ -43,6 +43,8 @@ const { data } = useQuery({ const showUpdateNotAvailable = ref(false) const checkingForUpdate = ref(false) +const downloadingUpdate = ref(false) +const updateReadyToInstall = ref(false) const showErrorOnUpdateRequest = ref(false) const showPermissionModal = ref(false) const showManualInstructionsModal = ref(false) @@ -125,8 +127,14 @@ function onLogoutClick() { router.push('/time') } +let checkingForUpdateTimeout: ReturnType | null = null + function triggerUpdate() { checkingForUpdate.value = true + if (checkingForUpdateTimeout) clearTimeout(checkingForUpdateTimeout) + checkingForUpdateTimeout = setTimeout(() => { + checkingForUpdate.value = false + }, 15000) window.electronAPI.updateAutoUpdater() } @@ -197,7 +205,17 @@ onMounted(async () => { hasPermission.value = await window.electronAPI.checkScreenRecordingPermission() } + window.electronAPI.onUpdateAvailable(() => { + if (checkingForUpdateTimeout) clearTimeout(checkingForUpdateTimeout) + checkingForUpdate.value = false + downloadingUpdate.value = true + }) + window.electronAPI.onUpdateDownloaded(() => { + downloadingUpdate.value = false + updateReadyToInstall.value = true + }) window.electronAPI.onUpdateNotAvailable(() => { + if (checkingForUpdateTimeout) clearTimeout(checkingForUpdateTimeout) showUpdateNotAvailable.value = true checkingForUpdate.value = false setTimeout(() => { @@ -205,9 +223,11 @@ onMounted(async () => { }, 5000) }) window.electronAPI.onAutoUpdaterError(async () => { + if (checkingForUpdateTimeout) clearTimeout(checkingForUpdateTimeout) showUpdateNotAvailable.value = true showErrorOnUpdateRequest.value = true checkingForUpdate.value = false + downloadingUpdate.value = false setTimeout(() => { showUpdateNotAvailable.value = false showErrorOnUpdateRequest.value = false @@ -313,10 +333,14 @@ watch(activityTrackingEnabled, (enabled) => { class="bg-card-background rounded-lg border border-card-background-separator p-6 mb-6">
Updates
- + + Restart & Update + +
- - Check for updates + + Downloading update... + Check for updates
From 8209ea60dae9b35f686bbbba79ebfc86dce86da7 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Thu, 12 Mar 2026 16:17:22 +0100 Subject: [PATCH 12/29] fix stale active timer not resetting correctly --- src/renderer/src/components/MainTimeEntryTable.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/renderer/src/components/MainTimeEntryTable.vue b/src/renderer/src/components/MainTimeEntryTable.vue index 2eedd2e..7ef1d3b 100644 --- a/src/renderer/src/components/MainTimeEntryTable.vue +++ b/src/renderer/src/components/MainTimeEntryTable.vue @@ -90,6 +90,7 @@ const timeEntries = computed(() => { const { data: currentTimeEntryResponse, isError: currentTimeEntryResponseIsError } = useQuery({ queryKey: ['currentTimeEntry'], queryFn: () => getCurrentTimeEntry(), + staleTime: 0, // Always refetch on window focus to catch external changes }) // Update lastTimeEntry when timeEntries change @@ -114,6 +115,10 @@ watch(currentTimeEntryResponse, () => { console.log(currentTimeEntryResponse.value) if (currentTimeEntryResponse.value?.data) { currentTimeEntry.value = { ...currentTimeEntryResponse.value?.data } + } else if (currentTimeEntry.value.id !== '') { + // Server says no active time entry, but we have one locally + // (e.g. stopped from another app) — clear it + currentTimeEntry.value = { ...emptyTimeEntry } } }) From 5965a91a7030aa3006140acad3deed6a8e1c5c62 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Thu, 12 Mar 2026 16:29:19 +0100 Subject: [PATCH 13/29] mount and unmout listeners on power monitor events to avoid race conditions --- src/main/idleMonitor.ts | 44 ++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/main/idleMonitor.ts b/src/main/idleMonitor.ts index a25e93b..9ae682a 100644 --- a/src/main/idleMonitor.ts +++ b/src/main/idleMonitor.ts @@ -155,25 +155,53 @@ function registerPowerMonitorEvents() { powerMonitor.on('suspend', () => { console.log('powerMonitor: system suspend') + // Stop the polling interval BEFORE transitioning to idle + // to prevent a final tick from flipping state back to active + clearIdleCheckInterval() transitionToIdle(dayjs()) }) powerMonitor.on('lock-screen', () => { console.log('powerMonitor: screen locked') + clearIdleCheckInterval() transitionToIdle(dayjs()) }) powerMonitor.on('resume', () => { console.log('powerMonitor: system resume') transitionToActive() + restartIdleCheckInterval() }) powerMonitor.on('unlock-screen', () => { console.log('powerMonitor: screen unlocked') transitionToActive() + restartIdleCheckInterval() }) } +function clearIdleCheckInterval() { + if (idleCheckInterval) { + clearInterval(idleCheckInterval) + idleCheckInterval = null + } +} + +function restartIdleCheckInterval() { + if (!idleDetectionEnabled) return + clearIdleCheckInterval() + idleCheckInterval = setInterval(() => { + const idleTime = powerMonitor.getSystemIdleTime() + + if (idleTime >= idleThreshold) { + const now = dayjs() + transitionToIdle(now.subtract(idleTime, 'seconds')) + } else { + transitionToActive() + } + }, 1000) +} + function startIdleMonitoring() { if (idleCheckInterval) { console.log('Idle monitoring already running, skipping start') @@ -202,16 +230,7 @@ function startIdleMonitoring() { } // Check idle state every second - idleCheckInterval = setInterval(() => { - const idleTime = powerMonitor.getSystemIdleTime() - - if (idleTime >= idleThreshold) { - const now = dayjs() - transitionToIdle(now.subtract(idleTime, 'seconds')) - } else { - transitionToActive() - } - }, 1000) + restartIdleCheckInterval() } async function saveActivityPeriod(start: string, end: string, isIdlePeriod: boolean) { @@ -296,10 +315,7 @@ async function stopIdleMonitoring() { await saveActivityPeriod(idleStartTime.toISOString(), now.toISOString(), true) } - if (idleCheckInterval) { - clearInterval(idleCheckInterval) - idleCheckInterval = null - } + clearIdleCheckInterval() isIdle = false idleStartTime = null activeStartTime = null From e138f0e7a53949fc54c3b5335eb7d833f1b37905 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Thu, 26 Mar 2026 01:10:29 +0100 Subject: [PATCH 14/29] fix page and query invalidation --- src/renderer/src/App.vue | 3 +-- src/renderer/src/pages/CalendarPage.vue | 7 ++++++- src/renderer/src/pages/StatisticsPage.vue | 9 +++++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 2df0226..40c6a07 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -164,8 +164,7 @@ whenever(cmdComma, () => {
- - + diff --git a/src/renderer/src/pages/CalendarPage.vue b/src/renderer/src/pages/CalendarPage.vue index 6c7ae4c..bcc9ffa 100644 --- a/src/renderer/src/pages/CalendarPage.vue +++ b/src/renderer/src/pages/CalendarPage.vue @@ -1,6 +1,6 @@