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/.github/workflows/release.yml b/.github/workflows/release.yml index 702866e..d25904b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,10 @@ jobs: include: - os: macos-latest platform: mac - arch: universal + arch: arm64 + - os: macos-13 + platform: mac + arch: x64 - os: ubuntu-latest platform: linux arch: x64 @@ -49,10 +52,6 @@ jobs: - name: Install dependencies run: npm install - - name: Install macOS x64 native module - if: matrix.platform == 'mac' - run: npm install --save-dev @miniben90/x-win-darwin-x64 --force - - name: Build run: npm run build @@ -73,7 +72,7 @@ jobs: if: matrix.platform == 'mac' run: | echo "$API_KEY" > apple.p8 - npx electron-builder --mac --universal --publish always + npx electron-builder --mac --${{ matrix.arch }} --publish always env: GH_TOKEN: ${{ secrets.GH_TOKEN }} CSC_LINK: ${{ secrets.MAC_CERTS }} diff --git a/drizzle/0000_panoramic_catseye.sql b/drizzle/0000_panoramic_catseye.sql index ed61626..a0b3a3c 100644 --- a/drizzle/0000_panoramic_catseye.sql +++ b/drizzle/0000_panoramic_catseye.sql @@ -33,3 +33,7 @@ CREATE TABLE `window_activities` ( `process_id` integer, `created_at` text NOT NULL ); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_activity_periods_start_end` ON `activity_periods` (`start`, `end`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_window_activities_timestamp` ON `window_activities` (`timestamp`); 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..dbc92c0 --- /dev/null +++ b/e2e/mocks/api-handler.ts @@ -0,0 +1,206 @@ +/** + * 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, + createProject, + createClient, + createTag, +} 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() + } + + // /api/v1/organizations/:org/projects + if (pathname.match(/\/organizations\/[^/]+\/projects$/)) { + if (method === 'GET') { + return jsonResponse(route, { data: state.projects }) + } + if (method === 'POST') { + const body = route.request().postDataJSON() + const newProject = createProject(state.organization.id, { ...body }) + state.projects.push(newProject) + return jsonResponse(route, { data: newProject }, 201) + } + return route.fallback() + } + + // /api/v1/organizations/:org/tags + if (pathname.match(/\/organizations\/[^/]+\/tags$/)) { + if (method === 'GET') { + return jsonResponse(route, { data: state.tags }) + } + if (method === 'POST') { + const body = route.request().postDataJSON() + const newTag = createTag({ ...body }) + state.tags.push(newTag) + return jsonResponse(route, { data: newTag }, 201) + } + return route.fallback() + } + + // GET /api/v1/organizations/:org/tasks + if (pathname.match(/\/organizations\/[^/]+\/tasks$/) && method === 'GET') { + return jsonResponse(route, { data: state.tasks }) + } + + // /api/v1/organizations/:org/clients + if (pathname.match(/\/organizations\/[^/]+\/clients$/)) { + if (method === 'GET') { + return jsonResponse(route, { data: state.clients }) + } + if (method === 'POST') { + const body = route.request().postDataJSON() + const newClient = createClient({ ...body }) + state.clients.push(newClient) + return jsonResponse(route, { data: newClient }, 201) + } + return route.fallback() + } + + // 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..cc34372 --- /dev/null +++ b/e2e/tests/settings.spec.ts @@ -0,0 +1,129 @@ +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() + }) +}) + +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 new file mode 100644 index 0000000..a217f3b --- /dev/null +++ b/e2e/tests/statistics.spec.ts @@ -0,0 +1,75 @@ +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) + }) +}) + +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 new file mode 100644 index 0000000..24393b7 --- /dev/null +++ b/e2e/tests/time-tracking.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '../fixtures/electron-test' + +test.describe('Time tracking', () => { + test('displays time entries', async ({ page }) => { + const entry = page.getByText('Implement navigation component').first() + 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 }) => { + const project = page.getByText('Website Redesign').first() + await expect(project).toBeVisible({ timeout: 10000 }) + }) + + 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 }) + + 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/package-lock.json b/package-lock.json index b0b5dcc..981b506 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,11 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", - "@miniben90/x-win": "^3.2.0", + "@miniben90/x-win": "^3.4.0", "@sentry/electron": "^5.7.0", "@sentry/vite-plugin": "^2.22.8", "@solidtime/api": "^0.0.6", - "@solidtime/ui": "^0.0.15", + "@solidtime/ui": "^0.0.19", "better-sqlite3": "^11.7.0", "drizzle-orm": "^0.44.7", "electron-updater": "^6.6.2", @@ -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", @@ -1393,7 +1394,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1410,7 +1410,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1427,7 +1426,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1444,7 +1442,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1461,7 +1458,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1478,7 +1474,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1495,7 +1490,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1512,7 +1506,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1529,7 +1522,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1546,7 +1538,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1563,7 +1554,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1580,7 +1570,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1597,7 +1586,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1614,7 +1602,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1631,7 +1618,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1648,7 +1634,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1665,7 +1650,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1682,7 +1666,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1699,7 +1682,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1716,7 +1698,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1733,7 +1714,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1750,7 +1730,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1767,7 +1746,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1784,7 +1762,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1801,7 +1778,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1818,7 +1794,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1916,42 +1891,42 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "peer": true, "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "peer": true, "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT", "peer": true }, "node_modules/@floating-ui/vue": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.9.tgz", - "integrity": "sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz", + "integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==", "license": "MIT", "peer": true, "dependencies": { - "@floating-ui/dom": "^1.7.4", - "@floating-ui/utils": "^0.2.10", + "@floating-ui/dom": "^1.7.6", + "@floating-ui/utils": "^0.2.11", "vue-demi": ">=0.13.0" } }, @@ -2061,9 +2036,9 @@ "license": "BSD-3-Clause" }, "node_modules/@internationalized/date": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz", - "integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==", + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz", + "integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -2519,28 +2494,28 @@ } }, "node_modules/@miniben90/x-win": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@miniben90/x-win/-/x-win-3.2.0.tgz", - "integrity": "sha512-RNmCzJsj4dZ+izOkK6sIdtjdSAx1HSHiU1UG3h5go2rYz8qXgRtOrHvgDsGblLljWNxhB/eiyc6XumyQ5u33vw==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@miniben90/x-win/-/x-win-3.4.0.tgz", + "integrity": "sha512-xd45wq2ml0n69CnhzBSE8CYuSRt6A1Nv4wJA+A4JlpglRzy6PRSfodDmUprBqfwPqQJd5S1WeTw9Dk6WOqhcuA==", "license": "MIT", "engines": { - "node": ">= 14" + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" }, "optionalDependencies": { - "@miniben90/x-win-darwin-arm64": "3.2.0", - "@miniben90/x-win-darwin-universal": "3.2.0", - "@miniben90/x-win-darwin-x64": "3.2.0", - "@miniben90/x-win-linux-x64-gnu": "3.2.0", - "@miniben90/x-win-linux-x64-musl": "3.2.0", - "@miniben90/x-win-win32-arm64-msvc": "3.2.0", - "@miniben90/x-win-win32-ia32-msvc": "3.2.0", - "@miniben90/x-win-win32-x64-msvc": "3.2.0" + "@miniben90/x-win-darwin-arm64": "3.4.0", + "@miniben90/x-win-darwin-universal": "3.4.0", + "@miniben90/x-win-darwin-x64": "3.4.0", + "@miniben90/x-win-linux-x64-gnu": "3.4.0", + "@miniben90/x-win-linux-x64-musl": "3.4.0", + "@miniben90/x-win-win32-arm64-msvc": "3.4.0", + "@miniben90/x-win-win32-ia32-msvc": "3.4.0", + "@miniben90/x-win-win32-x64-msvc": "3.4.0" } }, "node_modules/@miniben90/x-win-darwin-arm64": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@miniben90/x-win-darwin-arm64/-/x-win-darwin-arm64-3.2.0.tgz", - "integrity": "sha512-BUe30HOMowiwIrdym01VYqD1ptP9l4mrk6DfIljjwcm6jom/dQi4wWNkPlzC4+JdUlL24EGEL+R3ItWjdvVDbQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@miniben90/x-win-darwin-arm64/-/x-win-darwin-arm64-3.4.0.tgz", + "integrity": "sha512-IYao+7bdnW5TyvBTKLaeSKKmRLZzRmqAtjg78MUIvMe25+YvrRiQXsXiO+OW3NsMu6O2B43RWNe8AtSB17N1ww==", "cpu": [ "arm64" ], @@ -2554,9 +2529,9 @@ } }, "node_modules/@miniben90/x-win-darwin-universal": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@miniben90/x-win-darwin-universal/-/x-win-darwin-universal-3.2.0.tgz", - "integrity": "sha512-E3zKtAmijeXee56qhbIVJDKeuqiR1eq8Y6z02M1vU6CSny/EBqqn1wTHPzjmEoCMHYJIsjl+xY/HTBujiv/ZMA==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@miniben90/x-win-darwin-universal/-/x-win-darwin-universal-3.4.0.tgz", + "integrity": "sha512-64tUZ/pV/fYmYIoR8m9tWXaXnMYUu/RpNr3hvtCDidrR9OvbtcTQyCqzFDDvEO2SUWapKuLRPBWwPb9+fHi2jw==", "license": "MIT", "optional": true, "os": [ @@ -2567,9 +2542,9 @@ } }, "node_modules/@miniben90/x-win-darwin-x64": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@miniben90/x-win-darwin-x64/-/x-win-darwin-x64-3.2.0.tgz", - "integrity": "sha512-guPkn56pz/ZWd18RErsyyu1WfP1YcomeFxJvN7F8UrI00A6Gw1UoSUn/SCHWteklwVMkFejGbCrc6DvJCWnrRw==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@miniben90/x-win-darwin-x64/-/x-win-darwin-x64-3.4.0.tgz", + "integrity": "sha512-tVrWgPBcKuqNZKkgqGVaXxPsvj0HNPkMR5oIYCdbpkJPxeNL+2fOJMnDW/Hx3pqwAKIGAzVrS9I6wgOztEgGFg==", "cpu": [ "x64" ], @@ -2583,9 +2558,9 @@ } }, "node_modules/@miniben90/x-win-linux-x64-gnu": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@miniben90/x-win-linux-x64-gnu/-/x-win-linux-x64-gnu-3.2.0.tgz", - "integrity": "sha512-Gp45fT9sj1OSFRQff5AMKPZyv1ZApbtntARf21fCdjD5T7sHLby7TNNjkzp96EK2dkPY7cYAZk8ySv6lY6vtHw==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@miniben90/x-win-linux-x64-gnu/-/x-win-linux-x64-gnu-3.4.0.tgz", + "integrity": "sha512-uFBOBzkCJ9M0ysUhOaa+bwR447Wo2Dfg/rkLAH3QgkH76FJeluA4xrAnZ/kboXChLN9+WvKGKDYvKL63Tvx0CA==", "cpu": [ "x64" ], @@ -2599,9 +2574,9 @@ } }, "node_modules/@miniben90/x-win-linux-x64-musl": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@miniben90/x-win-linux-x64-musl/-/x-win-linux-x64-musl-3.2.0.tgz", - "integrity": "sha512-Xuj+4nOXdTPQQc4Bo1GJ+SM08jNfoBmv/c3sIYuxcH8U7Jz8WMxLufpBQskK/TDrwBzgem6d/zZylqp2D5AGSg==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@miniben90/x-win-linux-x64-musl/-/x-win-linux-x64-musl-3.4.0.tgz", + "integrity": "sha512-HmzxN7pjpiFRh7dNsdyYPRRz8t0PJXRtGXbmby1jQ+Gl2f/yWxNd7TwsrK9MHThkrb9ayMd/q92s3M/t7+jJsA==", "cpu": [ "x64" ], @@ -2615,9 +2590,9 @@ } }, "node_modules/@miniben90/x-win-win32-arm64-msvc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@miniben90/x-win-win32-arm64-msvc/-/x-win-win32-arm64-msvc-3.2.0.tgz", - "integrity": "sha512-G1SqQvnTPW87O4rk5fGRvvZESa42e4RLbXRN/d/XGBhuyjEJKRNk8YR4UqWXLvJiwkNg1kxisCtWc2qr3b09Fg==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@miniben90/x-win-win32-arm64-msvc/-/x-win-win32-arm64-msvc-3.4.0.tgz", + "integrity": "sha512-+3yuNJYrww7IYTSeI/R+8s7805RdWxnCSMRTtcgzjINWBp+8bvjJs2OusspH2KrtnKv03bKPXdw2yung1Db5qg==", "cpu": [ "arm64" ], @@ -2631,9 +2606,9 @@ } }, "node_modules/@miniben90/x-win-win32-ia32-msvc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@miniben90/x-win-win32-ia32-msvc/-/x-win-win32-ia32-msvc-3.2.0.tgz", - "integrity": "sha512-DmRcTqsCZDzUk6l7SWmt8YyeipoZvHOo0l42tDJn5A35PwB9T8RNNyIaw4iJ8H1H2ynf6+K7dWboOo07s9wTBQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@miniben90/x-win-win32-ia32-msvc/-/x-win-win32-ia32-msvc-3.4.0.tgz", + "integrity": "sha512-kBwMQ27rh8hSRmjdEtqna9ZKYM0cRZXyp3+YhQOTVzRf5TO2aNGKmc5OhhJ4OilfmtFp5rEpDluHwXITkw5NpA==", "cpu": [ "ia32" ], @@ -2647,9 +2622,9 @@ } }, "node_modules/@miniben90/x-win-win32-x64-msvc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@miniben90/x-win-win32-x64-msvc/-/x-win-win32-x64-msvc-3.2.0.tgz", - "integrity": "sha512-4ictKu0u2wOQMFqH6qFs8zNkxT+fxlLOrpIO9dZ3+AaouqziqFEdFrMyGFNNanwaM5eiSnN3e8mOrKm9W+roBA==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@miniben90/x-win-win32-x64-msvc/-/x-win-win32-x64-msvc-3.4.0.tgz", + "integrity": "sha512-IGOKd+FxLgZSnAk2YGQCcWaC2tYaE2QNHTiiHLIX+Cw9QNIi2Nli/cqWcH8fvga3Z5eKNGn0Qg2O1h+2QX6O1Q==", "cpu": [ "x64" ], @@ -3369,6 +3344,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", @@ -3419,7 +3410,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3433,7 +3423,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3447,7 +3436,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3461,7 +3449,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3475,7 +3462,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3489,7 +3475,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3503,7 +3488,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3517,7 +3501,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3531,7 +3514,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3545,7 +3527,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3559,7 +3540,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3573,7 +3553,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3587,7 +3566,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3601,7 +3579,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3615,7 +3592,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3629,7 +3605,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3643,7 +3618,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3657,7 +3631,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3671,7 +3644,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3685,7 +3657,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3699,7 +3670,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3713,7 +3683,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4110,30 +4079,38 @@ } }, "node_modules/@solidtime/ui": { - "version": "0.0.15", - "resolved": "https://registry.npmjs.org/@solidtime/ui/-/ui-0.0.15.tgz", - "integrity": "sha512-hQJ0v5FCm3wK8t/MO9QGCeccK6UqipbGZ/SFRvVvyqOCiDPBQXy5h9j2z1FVH2Obr2JzW4NN/r0Ewnapp9IqxA==", + "version": "0.0.19", + "resolved": "https://registry.npmjs.org/@solidtime/ui/-/ui-0.0.19.tgz", + "integrity": "sha512-P4dHFJB1pUX3yq+ItQRfBxdrrhIsYZmhy2WOLYotnc/MkK3oTToizVo5v/tleQneMXJ6ru3DIclUGsj/SUXMpg==", "license": "AGPL-3.0", "peerDependencies": { "@floating-ui/vue": "^1.1.4", "@heroicons/vue": "^2.1.5", - "@vueuse/core": "^12.5.0", - "@zodios/core": "^10.9.6", + "@internationalized/date": "^3.0.0", + "@vitejs/plugin-vue": "^5.1.2 || ^6.0.0", + "@vueuse/core": "^12.5.0 || ^14.0.0", + "@vueuse/integrations": "^12.5.0 || ^14.0.0", + "chroma-js": "^3.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.13", + "focus-trap": "^7.0.0 || ^8.0.0", + "lucide-vue-next": ">=0.453.0", "parse-duration": "^2.0.1", + "radix-vue": "^1.9.0", "reka-ui": "^2.2.0", "tailwind-merge": "^2.5.2", "tailwindcss": "^3.1.0", + "typescript": "^5.5.4", + "vite": "^5.4.1 || ^6.0.0 || ^7.0.0", "vue": "^3.5.0", - "vue-tsc": "^2.2.0" + "vue-tsc": "^2.2.0 || ^3.0.0" } }, "node_modules/@swc/helpers": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", - "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.20.tgz", + "integrity": "sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -4242,9 +4219,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.13.13", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.13.tgz", - "integrity": "sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==", + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", + "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", "license": "MIT", "peer": true, "funding": { @@ -4324,13 +4301,13 @@ } }, "node_modules/@tanstack/vue-virtual": { - "version": "3.13.13", - "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.13.tgz", - "integrity": "sha512-Cf2xIEE8nWAfsX0N5nihkPYMeQRT+pHt4NEkuP8rNCn6lVnLDiV8rC8IeIxbKmQC0yPnj4SIBLwXYVf86xxKTQ==", + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.23.tgz", + "integrity": "sha512-b5jPluAR6U3eOq6GWAYSpj3ugnAIZgGR0e6aGAgyRse0Yu6MVQQ0ZWm9SArSXWtageogn6bkVD8D//c4IjW3xQ==", "license": "MIT", "peer": true, "dependencies": { - "@tanstack/virtual-core": "3.13.13" + "@tanstack/virtual-core": "3.13.23" }, "funding": { "type": "github", @@ -4385,7 +4362,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/fs-extra": { @@ -4740,7 +4716,6 @@ "version": "5.2.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, "license": "MIT", "engines": { "node": "^18.0.0 || >=20.0.0" @@ -5011,6 +4986,114 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@vueuse/integrations": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.2.1.tgz", + "integrity": "sha512-2LIUpBi/67PoXJGqSDQUF0pgQWpNHh7beiA+KG2AbybcNm+pTGWT6oPGlBgUoDWmYwfeQqM/uzOHqcILpKL7nA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vueuse/core": "14.2.1", + "@vueuse/shared": "14.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7 || ^8", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7 || ^8", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/integrations/node_modules/@vueuse/core": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", + "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.2.1", + "@vueuse/shared": "14.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/integrations/node_modules/@vueuse/metadata": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", + "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations/node_modules/@vueuse/shared": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", + "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/@vueuse/metadata": { "version": "12.8.2", "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", @@ -5994,6 +6077,13 @@ "node": ">=10" } }, + "node_modules/chroma-js": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz", + "integrity": "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==", + "license": "(BSD-3-Clause AND Apache-2.0)", + "peer": true + }, "node_modules/chromium-pickle-js": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", @@ -7411,7 +7501,6 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -7811,7 +7900,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -7998,6 +8086,16 @@ "dev": true, "license": "ISC" }, + "node_modules/focus-trap": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-8.0.1.tgz", + "integrity": "sha512-9ptSG6z51YQOstI/oN4XuVGP/03u2nh0g//qz7L6zX0i6PZiPnkcf3GenXq7N2hZnASXaMxTPpbKwdI+PFvxlw==", + "license": "MIT", + "peer": true, + "dependencies": { + "tabbable": "^6.4.0" + } + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -9299,6 +9397,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-vue-next": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz", + "integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==", + "license": "ISC", + "peer": true, + "peerDependencies": { + "vue": ">=3.0.1" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -10199,9 +10307,9 @@ } }, "node_modules/parse-duration": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/parse-duration/-/parse-duration-2.1.4.tgz", - "integrity": "sha512-b98m6MsCh+akxfyoz9w9dt0AlH2dfYLOBss5SdDsr9pkhKNvkWBXU/r8A4ahmIGByBOLV2+4YwfCuFxbDDaGyg==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/parse-duration/-/parse-duration-2.1.5.tgz", + "integrity": "sha512-/IX1KRw6zHDOOJrgIz++gvFASbFl7nc8GEXaLdD7d1t1x/GnrK6hh5Fgk8G3RLpkIEi4tsGj9pupGLWNg0EiJA==", "license": "MIT", "peer": true }, @@ -10430,6 +10538,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", @@ -10862,6 +11017,148 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/radix-vue": { + "version": "1.9.17", + "resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.9.17.tgz", + "integrity": "sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@floating-ui/dom": "^1.6.7", + "@floating-ui/vue": "^1.1.0", + "@internationalized/date": "^3.5.4", + "@internationalized/number": "^3.5.3", + "@tanstack/vue-virtual": "^3.8.1", + "@vueuse/core": "^10.11.0", + "@vueuse/shared": "^10.11.0", + "aria-hidden": "^1.2.4", + "defu": "^6.1.4", + "fast-deep-equal": "^3.1.3", + "nanoid": "^5.0.7" + }, + "peerDependencies": { + "vue": ">= 3.2.0" + } + }, + "node_modules/radix-vue/node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT", + "peer": true + }, + "node_modules/radix-vue/node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/radix-vue/node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/radix-vue/node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/radix-vue/node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "peer": true, + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/radix-vue/node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/radix-vue/node_modules/nanoid": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", + "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -11045,9 +11342,9 @@ } }, "node_modules/reka-ui": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.1.tgz", - "integrity": "sha512-XK7cJDQoNuGXfCNzBBo/81Yg/OgjPwvbabnlzXG2VsdSgNsT6iIkuPBPr+C0Shs+3bb0x0lbPvgQAhMSCKm5Ww==", + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.2.tgz", + "integrity": "sha512-/t4e6y1hcG+uDuRfpg6tbMz3uUEvRzNco6NeYTufoJeUghy5Iosxos5YL/p+ieAsid84sdMX9OrgDqpEuCJhBw==", "license": "MIT", "peer": true, "dependencies": { @@ -11056,14 +11353,59 @@ "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", - "@vueuse/core": "^12.5.0", - "@vueuse/shared": "^12.5.0", + "@vueuse/core": "^14.1.0", + "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "ohash": "^2.0.11" }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/zernonia" + }, "peerDependencies": { - "vue": ">= 3.2.0" + "vue": ">= 3.4.0" + } + }, + "node_modules/reka-ui/node_modules/@vueuse/core": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", + "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.2.1", + "@vueuse/shared": "14.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/reka-ui/node_modules/@vueuse/metadata": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", + "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/reka-ui/node_modules/@vueuse/shared": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", + "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" } }, "node_modules/remove-accents": { @@ -11298,7 +11640,6 @@ "version": "4.53.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -11932,10 +12273,17 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT", + "peer": true + }, "node_modules/tailwind-merge": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", - "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", "license": "MIT", "peer": true, "funding": { @@ -12600,7 +12948,6 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -12675,7 +13022,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -12693,7 +13039,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" diff --git a/package.json b/package.json index 705d86d..4c7f210 100644 --- a/package.json +++ b/package.json @@ -14,15 +14,17 @@ "typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false", "typecheck": "npm run typecheck:node && npm run typecheck:web", "start": "electron-vite preview", + "preview": "electron-vite preview", "dev": "electron-vite dev", "build": "npm run typecheck && electron-vite build", "postinstall": "electron-builder install-app-deps", "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:": { + "build": { "generateUpdatesFilesForAllChannels": true, "afterSign": "electron-builder-notarize", "mac": { @@ -32,11 +34,11 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", - "@miniben90/x-win": "^3.2.0", + "@miniben90/x-win": "^3.4.0", "@sentry/electron": "^5.7.0", "@sentry/vite-plugin": "^2.22.8", "@solidtime/api": "^0.0.6", - "@solidtime/ui": "^0.0.15", + "@solidtime/ui": "^0.0.19", "better-sqlite3": "^11.7.0", "drizzle-orm": "^0.44.7", "electron-updater": "^6.6.2", @@ -48,6 +50,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/activityPeriods.ts b/src/main/activityPeriods.ts index 91b46ef..774bb5a 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' @@ -11,7 +11,6 @@ import { getCurrentActivityPeriod } from './idleMonitor' async function deleteAllActivityPeriods(): Promise<{ success: boolean; error?: string }> { try { await db.delete(activityPeriods) - console.log('All activity periods deleted successfully') return { success: true } } catch (error) { console.error('Failed to delete activity periods:', error) @@ -22,10 +21,31 @@ 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))) + 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 - url: string | null + label: string | null count: number } @@ -38,74 +58,256 @@ interface ActivityPeriodResponse { interface ActivityPeriodsResult { success: boolean - data?: ActivityPeriodResponse[] + data?: ActivityPeriodResponse[] | string 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 DEFAULT_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 bucket boundary. + */ +function floorToInterval(date: Date, intervalMs: number): Date { + const ms = date.getTime() + return new Date(ms - (ms % intervalMs)) +} - const date = new Date(dateString) - if (!(date instanceof Date) || isNaN(date.getTime())) { - return false - } +/** + * Rounds a timestamp up to the nearest bucket boundary. + */ +function ceilToInterval(date: Date, intervalMs: number): Date { + const ms = date.getTime() + const remainder = ms % intervalMs + return remainder === 0 ? new Date(ms) : new Date(ms + (intervalMs - 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 + windowTitle: 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})`, + windowTitle: windowActivities.windowTitle, }) .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, + windowTitle: a.windowTitle, })) } 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 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, + bucketIntervalMs: number +): ActivityPeriodResponse[] { + if (rawPeriods.length === 0) { + return [] + } + + // Pre-parse period timestamps to avoid repeated Date parsing + const parsedPeriods = rawPeriods.map((period) => ({ + start: new Date(period.start).getTime(), + end: new Date(period.end).getTime(), + isIdle: period.isIdle, + })) + + // Find the overall time range from all periods + let minTime = Infinity + let maxTime = -Infinity + for (const period of parsedPeriods) { + if (period.start < minTime) minTime = period.start + if (period.end > maxTime) maxTime = period.end + } + + // Generate clock-aligned bucket boundaries + const bucketStart = floorToInterval(new Date(minTime), bucketIntervalMs).getTime() + const bucketEnd = ceilToInterval(new Date(maxTime), bucketIntervalMs).getTime() + + const nowMs = now.getTime() + const result: ActivityPeriodResponse[] = [] + + for (let bStart = bucketStart; bStart < bucketEnd; bStart += bucketIntervalMs) { + const bEnd = bStart + bucketIntervalMs + + // Calculate overlap with each period + let activeMs = 0 + let idleMs = 0 + + for (const period of parsedPeriods) { + const overlapStart = Math.max(bStart, period.start) + const overlapEnd = Math.min(bEnd, period.end) + 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 = aggregateWindowActivitiesFromList( + allWindowActivities.filter( + (a) => a.timestamp >= bucketStartISO && a.timestamp <= bucketEndISO + ) + ) + + result.push({ + start: bucketStartISO, + end: bucketEndISO, + isIdle, + windowActivities: bucketActivities.length > 0 ? bucketActivities : undefined, + }) + } + + return result +} + +/** + * Extracts the hostname from a URL, or returns null if invalid. + */ +function extractDomain(url: string | null): string | null { + if (!url) return null + try { + return new URL(url).hostname + } catch { + return null + } +} + +/** + * Aggregates a pre-filtered list of window activities for a bucket. + * Groups by display name + window title. For websites, the display name is + * the domain (e.g. "github.com - Pull Request #42"). For regular apps, it's + * the app name (e.g. "VS Code - myproject/file.ts"). Returns top 5 activities + * by total duration. + */ +function aggregateWindowActivitiesFromList( + activities: RawWindowActivity[] +): WindowActivityInPeriod[] { + if (activities.length === 0) { + return [] + } + + const aggregated = new Map< + string, + { appName: string; label: string | null; totalDuration: number } + >() + + for (const activity of activities) { + const domain = extractDomain(activity.url) + const windowTitle = activity.windowTitle === 'Untitled' ? null : activity.windowTitle + const displayName = domain || activity.appName + const label = windowTitle + const key = `${displayName}::${label ?? ''}` + const existing = aggregated.get(key) + if (existing) { + existing.totalDuration += activity.durationSeconds + } else { + aggregated.set(key, { + appName: displayName, + label, + 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, + label: a.label, + 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 */ async function getActivityPeriods( startDate: unknown, - endDate: unknown + endDate: unknown, + bucketIntervalMinutes?: number ): Promise { try { // Validate input types and formats @@ -133,66 +335,59 @@ 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 buckets matching the calendar grid interval + const intervalMs = + bucketIntervalMinutes && bucketIntervalMinutes > 0 + ? bucketIntervalMinutes * 60 * 1000 + : DEFAULT_BUCKET_INTERVAL_MS + const bucketedPeriods = bucketizeActivityPeriods( + rawPeriods, + allWindowActivities, + new Date(), + intervalMs + ) + + // Serialize as JSON string to avoid Electron's slow structured clone on nested objects + const jsonString = JSON.stringify(bucketedPeriods) + + return { success: true, data: jsonString } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' console.error('Failed to fetch activity periods:', errorMessage, error) @@ -211,8 +406,13 @@ export function registerActivityPeriodListeners(): void { // IPC handler to fetch activity periods for a date range ipcMain.handle( 'getActivityPeriods', - async (_event, startDate: unknown, endDate: unknown): Promise => { - return getActivityPeriods(startDate, endDate) + async ( + _event, + startDate: unknown, + endDate: unknown, + bucketIntervalMinutes?: number + ): Promise => { + return getActivityPeriods(startDate, endDate, bucketIntervalMinutes) } ) @@ -220,4 +420,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/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..7751705 100644 --- a/src/main/appIcons.ts +++ b/src/main/appIcons.ts @@ -1,8 +1,9 @@ -import { ipcMain, app } from 'electron' +import { ipcMain, app, net } from 'electron' 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 @@ -228,6 +229,88 @@ async function getAppIcons(appNames: string[]): Promise { + try { + const url = `https://${domain}/favicon.ico` + const response = await net.fetch(url, { + headers: { + Accept: 'image/x-icon,image/png,image/*', + }, + }) + if (!response.ok) return null + + const buffer = Buffer.from(await response.arrayBuffer()) + if (buffer.length === 0) return null + + const contentType = response.headers.get('content-type') || 'image/x-icon' + const base64 = buffer.toString('base64') + return `data:${contentType};base64,${base64}` + } catch (error) { + console.error(`Failed to fetch favicon for ${domain}:`, error) + return null + } +} + +/** + * Get favicon for a domain (from cache or fetch) + */ +async function getFavicon(domain: string): Promise { + const safeName = domain.replace(/[^a-z0-9.]/gi, '_').toLowerCase() + const cachePath = path.join(ICON_CACHE_DIR, `favicon_${safeName}.txt`) + + // Check cache + try { + const stats = await fs.stat(cachePath) + const ageInDays = (Date.now() - stats.mtimeMs) / (1000 * 60 * 60 * 24) + if (ageInDays <= ICON_CACHE_EXPIRY_DAYS) { + return await fs.readFile(cachePath, 'utf-8') + } + await fs.unlink(cachePath).catch(() => {}) + } catch { + // Not cached + } + + const iconData = await fetchFavicon(domain) + if (iconData) { + try { + await fs.writeFile(cachePath, iconData, 'utf-8') + } catch (error) { + console.error(`Failed to cache favicon for ${domain}:`, error) + } + } + return iconData +} + +/** + * Get icons for a mixed list of app names and domains. + * Domains get favicons, app names get native app icons. + */ +async function getIcons(names: string[]): Promise> { + const domains = names.filter(isDomain) + const appNames = names.filter((n) => !isDomain(n)) + + const [faviconResults, appIconResults] = await Promise.all([ + Promise.all(domains.map(async (domain) => [domain, await getFavicon(domain)] as const)), + appNames.length > 0 ? getAppIcons(appNames) : Promise.resolve({}), + ]) + + const icons: Record = { ...appIconResults } + for (const [domain, icon] of faviconResults) { + icons[domain] = icon + } + return icons +} + /** * Clear icon cache */ @@ -256,19 +339,17 @@ export function registerAppIconHandlers(): void { return await getAppIcon(appName) }) - // Get multiple app icons - ipcMain.handle('getAppIcons', async (_event, appNames: string[]) => { - // Validate input array - if (!Array.isArray(appNames) || appNames.length > 100) { - throw new Error('Invalid app names array') + // Get icons for mixed app names and domains + ipcMain.handle('getIcons', async (_event, names: string[]) => { + if (!Array.isArray(names) || names.length > 100) { + throw new Error('Invalid names array') } - // Validate each app name - for (const name of appNames) { + for (const name of names) { if (typeof name !== 'string' || name.length === 0 || name.length > 255) { - throw new Error('Invalid app name in array') + throw new Error('Invalid name in array') } } - return await getAppIcons(appNames) + return await getIcons(names) }) // Clear icon cache 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/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/idleMonitor.ts b/src/main/idleMonitor.ts index b2061e5..9ae682a 100644 --- a/src/main/idleMonitor.ts +++ b/src/main/idleMonitor.ts @@ -39,6 +39,7 @@ let idleThreshold = 300 let idleDetectionEnabled = true let isTimerRunning = false let waitingForUserResponse = false // Track if we're waiting for idle dialog response +let powerEventsRegistered = false export async function initializeIdleMonitor() { // Load settings from database @@ -52,6 +53,7 @@ export async function initializeIdleMonitor() { }) registerIdleMonitorListeners() + registerPowerMonitorEvents() // Start monitoring if idle detection is enabled (regardless of timer state) if (idleDetectionEnabled) { @@ -89,6 +91,117 @@ function registerIdleMonitorListeners() { }) } +function transitionToIdle(idleStart: Dayjs) { + if (isIdle) return // Guard against double-fire (e.g. macOS suspend firing twice) + + isIdle = true + idleStartTime = idleStart + + console.log(`System became idle at ${idleStartTime.toISOString()}`) + + // Save the active period that just ended + if (activeStartTime) { + // Ensure the end time is not before the start time due to timing precision + const endTime = idleStartTime.isBefore(activeStartTime) ? activeStartTime : idleStartTime + + saveActivityPeriod(activeStartTime.utc().format(), endTime.utc().format(), false) + activeStartTime = null + } +} + +function transitionToActive() { + if (!isIdle || !idleStartTime) return // Guard against double-fire + + const idleEnd = dayjs() + const idleDurationSeconds = idleEnd.diff(idleStartTime, 'seconds') + + console.log( + `System became active at ${idleEnd.toISOString()}, idle duration: ${idleDurationSeconds}s` + ) + + // Capture the idle period info before resetting state + const capturedIdleStart = idleStartTime.utc().format() + const capturedIdleEnd = idleEnd.utc().format() + const capturedDuration = idleDurationSeconds + + // Reset idle state and resume activity tracking immediately + isIdle = false + idleStartTime = null + activeStartTime = idleEnd + + // Only show dialog if timer is running and we're not already waiting for a response + // This prevents multiple dialogs from appearing + if (isTimerRunning && !waitingForUserResponse) { + waitingForUserResponse = true + + // Show dialog asynchronously without blocking the interval + showIdleDialog(capturedIdleStart, capturedIdleEnd, capturedDuration) + .then(() => { + waitingForUserResponse = false + }) + .catch((error) => { + console.error('Error showing idle dialog:', error) + waitingForUserResponse = false + }) + } else if (!isTimerRunning) { + // If timer is not running, just save the idle period automatically + saveActivityPeriod(capturedIdleStart, capturedIdleEnd, true) + } +} + +function registerPowerMonitorEvents() { + if (powerEventsRegistered) return + powerEventsRegistered = true + + 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') @@ -117,76 +230,7 @@ function startIdleMonitoring() { } // Check idle state every second - idleCheckInterval = setInterval(() => { - const idleTime = powerMonitor.getSystemIdleTime() - - if (idleTime >= idleThreshold) { - // System has been idle for longer than threshold - if (!isIdle) { - // Transition to idle state - isIdle = true - const now = dayjs() - idleStartTime = now.subtract(idleTime, 'seconds') - - console.log(`System became idle at ${idleStartTime.toISOString()}`) - - // Save the active period that just ended - if (activeStartTime) { - // Ensure the end time is not before the start time due to timing precision - const endTime = idleStartTime.isBefore(activeStartTime) - ? activeStartTime - : idleStartTime - - saveActivityPeriod( - activeStartTime.utc().format(), - endTime.utc().format(), - false - ) - activeStartTime = null - } - } - } else { - // System is active - if (isIdle && idleStartTime) { - // Transition from idle to active - const idleEnd = dayjs() - const idleDurationSeconds = idleEnd.diff(idleStartTime, 'seconds') - - console.log( - `System became active at ${idleEnd.toISOString()}, idle duration: ${idleDurationSeconds}s` - ) - - // Capture the idle period info before resetting state - const capturedIdleStart = idleStartTime.utc().format() - const capturedIdleEnd = idleEnd.utc().format() - const capturedDuration = idleDurationSeconds - - // Reset idle state and resume activity tracking immediately - isIdle = false - idleStartTime = null - activeStartTime = idleEnd - - // Only show dialog if timer is running and we're not already waiting for a response - // This prevents multiple dialogs from appearing - if (isTimerRunning && !waitingForUserResponse) { - waitingForUserResponse = true - - // Show dialog asynchronously without blocking the interval - showIdleDialog(capturedIdleStart, capturedIdleEnd, capturedDuration) - .then(() => { - waitingForUserResponse = false - }) - .catch((error) => { - console.error('Error showing idle dialog:', error) - waitingForUserResponse = false - }) - } else if (!isTimerRunning) { - // If timer is not running, just save the idle period automatically - saveActivityPeriod(capturedIdleStart, capturedIdleEnd, true) - } - } - } - }, 1000) + restartIdleCheckInterval() } async function saveActivityPeriod(start: string, end: string, isIdlePeriod: boolean) { @@ -271,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 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/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..b6942be 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 @@ -54,12 +55,20 @@ export interface IElectronAPI { getWindowActivities: (startDate: string, endDate: string) => Promise getWindowActivityStats: (startDate: string, endDate: string) => Promise getAppIcon: (appName: string) => Promise - getAppIcons: (appNames: string[]) => Promise> + getIcons: (names: string[]) => Promise> clearIconCache: () => Promise<{ success: boolean }> checkScreenRecordingPermission: () => Promise 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..8fe6bcd 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) => @@ -43,7 +44,7 @@ if (process.contextIsolated || true) { getWindowActivityStats: (startDate: string, endDate: string) => ipcRenderer.invoke('getWindowActivityStats', startDate, endDate), getAppIcon: (appName: string) => ipcRenderer.invoke('getAppIcon', appName), - getAppIcons: (appNames: string[]) => ipcRenderer.invoke('getAppIcons', appNames), + getIcons: (names: string[]) => ipcRenderer.invoke('getIcons', names), clearIconCache: () => ipcRenderer.invoke('clearIconCache'), onIdleDialogResponse: (callback) => { const listener = (_event, value) => callback(value) @@ -59,6 +60,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/App.vue b/src/renderer/src/App.vue index bbab6ea..40c6a07 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 } } @@ -164,8 +164,7 @@ whenever(cmdComma, () => {
- - + diff --git a/src/renderer/src/components/AutoUpdaterOverlay.vue b/src/renderer/src/components/AutoUpdaterOverlay.vue index 8b04d02..01fc93b 100644 --- a/src/renderer/src/components/AutoUpdaterOverlay.vue +++ b/src/renderer/src/components/AutoUpdaterOverlay.vue @@ -1,52 +1,34 @@ 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
-
+
-
+
{ 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 @@ -101,7 +102,11 @@ watch(timeEntries, () => { watch(currentTimeEntryResponseIsError, () => { if (currentTimeEntryResponseIsError.value) { - currentTimeEntry.value = { ...emptyTimeEntry } + // Only reset if we had a previously started timer (has an ID) + // Don't reset if user is preparing a new time entry (no ID yet) + if (currentTimeEntry.value.id !== '') { + currentTimeEntry.value = { ...emptyTimeEntry } + } } }) @@ -110,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 } } }) @@ -265,6 +274,9 @@ async function clearSelectionAndState() { // TODO: Fix me const currency = 'EUR' +const organizationBillableRate = computed( + () => (currentMembership.value?.organization?.billable_rate as number | null) ?? null +) const canCreateProjects = computed(() => { if (currentMembership.value) { @@ -298,7 +310,7 @@ watch(isLoadMoreVisible, async (isVisible) => {
-
+
{ @update-time-entry="updateCurrentTimeEntry">
-
+
{ v-model:show="showManualTimeEntryModal" :enableEstimatedTime="false" :currency + :organizationBillableRate :canCreateProject="canCreateProjects" :createProject="createProject" :createClient="createClient" @@ -357,6 +371,7 @@ watch(isLoadMoreVisible, async (isVisible) => { :tasks :tags :currency + :organizationBillableRate :enableEstimatedTime="false" :canCreateProject="canCreateProjects" :projects @@ -385,6 +400,7 @@ watch(isLoadMoreVisible, async (isVisible) => { :createProject :createClient :currency="currency" + :organizationBillableRate :enableEstimatedTime="false" :canCreateProject="canCreateProjects" :updateTimeEntry=" @@ -408,7 +424,7 @@ watch(isLoadMoreVisible, async (isVisible) => {

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!
@@ -428,7 +444,9 @@ watch(isLoadMoreVisible, async (isVisible) => {
- Fetching data + Fetching data
diff --git a/src/renderer/src/components/OrganizationSwitcher.vue b/src/renderer/src/components/OrganizationSwitcher.vue index b16cba5..32a1075 100644 --- a/src/renderer/src/components/OrganizationSwitcher.vue +++ b/src/renderer/src/components/OrganizationSwitcher.vue @@ -1,9 +1,14 @@ diff --git a/src/renderer/src/components/SidebarNavigation.vue b/src/renderer/src/components/SidebarNavigation.vue index a69ea70..c5aadbc 100644 --- a/src/renderer/src/components/SidebarNavigation.vue +++ b/src/renderer/src/components/SidebarNavigation.vue @@ -42,7 +42,7 @@ function navigateTo(path: string) {