diff --git a/apps/taktik/CLAUDE.md b/apps/taktik/CLAUDE.md index 25172062a..4a5e10bc0 100644 --- a/apps/taktik/CLAUDE.md +++ b/apps/taktik/CLAUDE.md @@ -6,7 +6,7 @@ Zeiterfassung & Timetracking - Dein Arbeitsrhythmus, messbar gemacht. ## Project Overview -Taktik is a professional time tracking app with timer, manual entry, projects, clients, reports, and guild (team) integration. Built local-first for offline capability and instant UI. +Taktik is a professional time tracking app with timer, manual entry, projects, clients, reports, templates, and guild (team) integration. Built local-first for offline capability and instant UI. ### Tech Stack @@ -14,60 +14,194 @@ Taktik is a professional time tracking app with timer, manual entry, projects, c |-------|------------| | Frontend | SvelteKit 2, Svelte 5 (runes), Tailwind CSS 4 | | Data | @manacore/local-store (Dexie.js + mana-sync) | +| Auth | @manacore/shared-auth + AuthGate (guest mode supported) | | Icons | @manacore/shared-icons (Phosphor) | | PWA | @vite-pwa/sveltekit + Workbox | | i18n | svelte-i18n (de, en) | - -## Key Concepts - -- **Timer**: Start/stop time tracking with live counter, persists in IndexedDB -- **Time Entries**: Core entity with duration, project, client, tags, billable flag -- **Projects**: Client projects or internal, with budgets and billing rates -- **Clients**: Customer management with billing rates and short codes -- **Templates**: Saved entry patterns for quick start -- **Reports**: Charts and statistics (ActivityHeatmap, DonutChart, TrendLineChart) -- **Gilden**: Team time tracking with shared projects and visibility controls +| Testing | Vitest | ## Development ```bash # From monorepo root -pnpm dev:taktik:web # Start web app on port 5197 -pnpm dev:taktik:full # Start with auth + sync server +pnpm dev:taktik:web # Start web app on port 5197 +pnpm dev:taktik:full # Start with auth + sync server + +# Tests +pnpm --filter @taktik/web test # Run all tests +pnpm --filter @taktik/web test:unit # Run in watch mode + +# Type checking +pnpm --filter @taktik/web type-check +pnpm --filter @taktik/shared type-check ``` +## Key Features + +### Timer +- Start/stop with one click, live HH:MM:SS counter +- Persists in IndexedDB (survives page reload/crash) +- Auto-save every 10 seconds +- Compact indicator in navbar when running (visible on all pages) +- Quick Start from recent entries or templates + +### Time Entries +- Manual entry with quick-duration buttons (15m, 30m, 1h, 1.5h, 2h, 4h) +- Inline-expand editing (click to expand, auto-save on change) +- Day grouping with totals +- Filter by week/month/all +- CSV export (semicolon-delimited, UTF-8 BOM for Excel) + +### Projects +- Color-coded project cards with budget progress bars +- Client assignment with inherited billing rates +- Billable/non-billable toggle +- Archive/unarchive, inline CRUD + +### Clients +- Billing rates (per hour/day) with currency selection +- Short codes for quick reference +- Project and hours rollup + +### Reports +- Stats: total hours, billable hours, avg/day, entry count +- Billable vs non-billable breakdown bar +- Hours by project (horizontal bar chart) +- Hours by day (vertical bar chart, last 7 days) +- Week/month toggle +- CSV export + +### Templates +- Save frequent entries as reusable templates +- One-click timer start from template +- Sorted by usage count + +### Settings +- Working hours/day, working days/week +- Week start (Monday/Sunday) +- Rounding increment (0/1/5/6/10/15 min) and method (none/up/down/nearest) +- Default billing rate with currency (EUR/CHF/USD/GBP) +- Timer reminder and auto-stop configuration + +### Keyboard Shortcuts +| Key | Action | +|-----|--------| +| `s` | Start/Stop timer | +| `n` | New manual entry | +| `Escape` | Close modal / blur input | + ## Data Collections -| Collection | Purpose | -|------------|---------| -| clients | Customer/client management | -| projects | Project tracking with budgets | -| timeEntries | Core time entry records | -| tags | Entry categorization | -| templates | Quick-start entry templates | -| settings | App configuration | +| Collection | Purpose | Key Indexes | +|------------|---------|-------------| +| clients | Customer management | order, isArchived, shortCode | +| projects | Project tracking | clientId, isArchived, isBillable, guildId | +| timeEntries | Core time records | projectId, date, isRunning, [date+projectId] | +| tags | Entry categorization | name, order | +| templates | Quick-start templates | usageCount, lastUsedAt | +| settings | App configuration | (single record) | ## Project Structure ``` apps/taktik/ ├── apps/ -│ └── web/ # SvelteKit web client +│ └── web/ # SvelteKit web client (port 5197) │ ├── src/ │ │ ├── routes/ -│ │ │ ├── (auth)/ # Login flow -│ │ │ └── (app)/ # Authenticated app -│ │ │ ├── entries/ -│ │ │ ├── projects/ -│ │ │ ├── clients/ -│ │ │ ├── reports/ -│ │ │ └── settings/ +│ │ │ ├── (auth)/ # Login/register flow +│ │ │ │ └── login/ +│ │ │ ├── (app)/ # Authenticated app +│ │ │ │ ├── +layout.svelte # AuthGate, PillNav, TimerIndicator, contexts +│ │ │ │ ├── +page.svelte # Timer home page +│ │ │ │ ├── entries/ # Time entry list +│ │ │ │ ├── projects/ # Project management +│ │ │ │ ├── clients/ # Client management +│ │ │ │ ├── reports/ # Dashboard & charts +│ │ │ │ ├── templates/ # Entry templates +│ │ │ │ ├── settings/ # App configuration +│ │ │ │ ├── mana/ # Credits & subscription +│ │ │ │ ├── feedback/ # Feedback form +│ │ │ │ ├── profile/ # User profile +│ │ │ │ ├── themes/ # Theme selection +│ │ │ │ └── help/ # Help & docs +│ │ │ ├── +layout.svelte # Root layout (i18n, theme, auth init) +│ │ │ ├── +layout.ts # SSR disabled +│ │ │ ├── +error.svelte # Error page +│ │ │ ├── health/+server.ts # Health check +│ │ │ └── offline/ # Offline fallback │ │ └── lib/ -│ │ ├── stores/ # Svelte 5 rune stores -│ │ ├── components/ # UI components -│ │ ├── i18n/ # Translations (de, en) -│ │ └── data/ # Local-store, queries, guest seed +│ │ ├── data/ +│ │ │ ├── local-store.ts # 6 collections + typed accessors +│ │ │ ├── queries.ts # Live queries + pure helpers +│ │ │ ├── queries.test.ts # Unit tests +│ │ │ └── guest-seed.ts # Demo data (2 clients, 3 projects, 5 entries) +│ │ ├── stores/ +│ │ │ ├── auth.svelte.ts # Mana auth factory +│ │ │ ├── timer.svelte.ts # Timer start/stop/resume/auto-save +│ │ │ ├── view.svelte.ts # View mode, filters, sort +│ │ │ ├── theme.ts # Theme store (ocean default) +│ │ │ ├── navigation.ts # Nav collapse state +│ │ │ └── user-settings.svelte.ts +│ │ ├── components/ +│ │ │ ├── TimerCard.svelte # Main timer widget +│ │ │ ├── TimerIndicator.svelte # Compact navbar indicator +│ │ │ ├── EntryItem.svelte # Inline-expandable entry +│ │ │ ├── EntryList.svelte # Day-grouped entry list +│ │ │ ├── EntryForm.svelte # Manual entry modal +│ │ │ ├── QuickStart.svelte # Recent entry pills +│ │ │ └── KeyboardShortcuts.svelte +│ │ ├── utils/ +│ │ │ ├── export.ts # CSV export +│ │ │ └── export.test.ts # Export tests +│ │ ├── i18n/ +│ │ │ ├── index.ts # svelte-i18n setup +│ │ │ └── locales/ # de.json, en.json +│ │ └── version.ts │ └── static/ -└── packages/ - └── shared/ # Shared types & constants +├── packages/ +│ └── shared/ # @taktik/shared +│ └── src/ +│ ├── types/index.ts # All TypeScript types +│ ├── constants/index.ts # Currencies, colors, defaults +│ └── index.ts +├── CLAUDE.md +└── package.json ``` + +## Architecture + +### Timer Flow +``` +User clicks Start → timerStore.start() → Insert timeEntry (isRunning=true) → IndexedDB + → Start 1s tick interval (UI counter) + → Start 10s auto-save interval + +User clicks Stop → timerStore.stop() → Update timeEntry (isRunning=false, endTime, duration) + → Stop intervals + → Entry appears in today's list +``` + +### Data Flow (Local-First) +``` +Guest: App → IndexedDB (Dexie.js) → UI (no sync) +Logged in: App → IndexedDB → UI → SyncEngine → mana-sync → PostgreSQL + ← WebSocket push ← +``` + +### Context Providers (set in app layout) +All data is provided via Svelte context from `(app)/+layout.svelte`: +- `clients` - Live query of all clients +- `projects` - Live query of all projects +- `timeEntries` - Live query of all time entries +- `tags` - Live query of all tags +- `templates` - Live query of all templates +- `settings` - Live query of settings (single record) + +## Gilden Integration (Planned v2) + +- Projects with `visibility: 'guild'` + `guildId` are shared with team +- Time entries inherit visibility from project +- Team dashboard: hours per member, budget tracking +- Manager vs member views +- Credit consumption from guild pool for AI/PDF features diff --git a/apps/taktik/apps/web/src/lib/data/queries.test.ts b/apps/taktik/apps/web/src/lib/data/queries.test.ts new file mode 100644 index 000000000..36244d127 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/data/queries.test.ts @@ -0,0 +1,370 @@ +import { describe, it, expect } from 'vitest'; +import { + formatDuration, + formatDurationCompact, + formatDurationDecimal, + getEntriesByDate, + getEntriesByDateRange, + getTotalDuration, + getBillableDuration, + groupEntriesByDate, + groupEntriesByProject, + getFilteredEntries, + getSortedEntries, + getActiveProjects, + getActiveClients, + getProjectById, + getClientById, + getProjectsByClient, +} from './queries'; +import type { TimeEntry, Project, Client } from '@taktik/shared'; + +// ─── Test Factories ────────────────────────────────────── + +function makeEntry(overrides: Partial = {}): TimeEntry { + return { + id: crypto.randomUUID(), + description: 'Test entry', + date: '2024-01-15', + duration: 3600, + isBillable: false, + isRunning: false, + tags: [], + visibility: 'private', + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-01-15T10:00:00Z', + ...overrides, + }; +} + +function makeProject(overrides: Partial = {}): Project { + return { + id: crypto.randomUUID(), + name: 'Test Project', + color: '#3b82f6', + isArchived: false, + isBillable: true, + visibility: 'private', + order: 0, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ...overrides, + }; +} + +function makeClient(overrides: Partial = {}): Client { + return { + id: crypto.randomUUID(), + name: 'Test Client', + color: '#3b82f6', + isArchived: false, + order: 0, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ...overrides, + }; +} + +// ─── Duration Formatting ───────────────────────────────── + +describe('formatDuration', () => { + it('formats zero seconds', () => { + expect(formatDuration(0)).toBe('00:00:00'); + }); + + it('formats seconds only', () => { + expect(formatDuration(45)).toBe('00:00:45'); + }); + + it('formats minutes and seconds', () => { + expect(formatDuration(125)).toBe('00:02:05'); + }); + + it('formats hours, minutes, seconds', () => { + expect(formatDuration(3661)).toBe('01:01:01'); + }); + + it('formats large durations', () => { + expect(formatDuration(36000)).toBe('10:00:00'); + }); +}); + +describe('formatDurationCompact', () => { + it('formats zero as 0m', () => { + expect(formatDurationCompact(0)).toBe('0m'); + }); + + it('formats minutes only', () => { + expect(formatDurationCompact(1800)).toBe('30m'); + }); + + it('formats hours only', () => { + expect(formatDurationCompact(7200)).toBe('2h'); + }); + + it('formats hours and minutes', () => { + expect(formatDurationCompact(5400)).toBe('1h 30m'); + }); + + it('formats partial minutes (rounds down)', () => { + expect(formatDurationCompact(3660)).toBe('1h 1m'); + }); +}); + +describe('formatDurationDecimal', () => { + it('formats 1 hour', () => { + expect(formatDurationDecimal(3600)).toBe('1.00'); + }); + + it('formats 1.5 hours', () => { + expect(formatDurationDecimal(5400)).toBe('1.50'); + }); + + it('formats 0 hours', () => { + expect(formatDurationDecimal(0)).toBe('0.00'); + }); + + it('formats 2h 15m', () => { + expect(formatDurationDecimal(8100)).toBe('2.25'); + }); +}); + +// ─── Entry Queries ─────────────────────────────────────── + +describe('getEntriesByDate', () => { + const entries = [ + makeEntry({ date: '2024-01-15', startTime: '2024-01-15T10:00:00Z' }), + makeEntry({ date: '2024-01-15', startTime: '2024-01-15T09:00:00Z' }), + makeEntry({ date: '2024-01-16' }), + ]; + + it('filters by date', () => { + const result = getEntriesByDate(entries, '2024-01-15'); + expect(result).toHaveLength(2); + }); + + it('sorts by startTime', () => { + const result = getEntriesByDate(entries, '2024-01-15'); + expect(result[0].startTime).toBe('2024-01-15T09:00:00Z'); + expect(result[1].startTime).toBe('2024-01-15T10:00:00Z'); + }); + + it('returns empty for no matches', () => { + expect(getEntriesByDate(entries, '2024-01-20')).toHaveLength(0); + }); +}); + +describe('getEntriesByDateRange', () => { + const entries = [ + makeEntry({ date: '2024-01-14' }), + makeEntry({ date: '2024-01-15' }), + makeEntry({ date: '2024-01-16' }), + makeEntry({ date: '2024-01-17' }), + ]; + + it('filters inclusive range', () => { + const result = getEntriesByDateRange(entries, '2024-01-15', '2024-01-16'); + expect(result).toHaveLength(2); + }); +}); + +describe('getTotalDuration', () => { + it('sums durations', () => { + const entries = [ + makeEntry({ duration: 3600 }), + makeEntry({ duration: 1800 }), + makeEntry({ duration: 900 }), + ]; + expect(getTotalDuration(entries)).toBe(6300); + }); + + it('returns 0 for empty array', () => { + expect(getTotalDuration([])).toBe(0); + }); +}); + +describe('getBillableDuration', () => { + it('sums only billable entries', () => { + const entries = [ + makeEntry({ duration: 3600, isBillable: true }), + makeEntry({ duration: 1800, isBillable: false }), + makeEntry({ duration: 900, isBillable: true }), + ]; + expect(getBillableDuration(entries)).toBe(4500); + }); +}); + +describe('groupEntriesByDate', () => { + it('groups correctly', () => { + const entries = [ + makeEntry({ date: '2024-01-15' }), + makeEntry({ date: '2024-01-15' }), + makeEntry({ date: '2024-01-16' }), + ]; + const groups = groupEntriesByDate(entries); + expect(groups.size).toBe(2); + expect(groups.get('2024-01-15')).toHaveLength(2); + expect(groups.get('2024-01-16')).toHaveLength(1); + }); +}); + +describe('groupEntriesByProject', () => { + it('groups by projectId', () => { + const entries = [ + makeEntry({ projectId: 'p1' }), + makeEntry({ projectId: 'p1' }), + makeEntry({ projectId: 'p2' }), + makeEntry({}), + ]; + const groups = groupEntriesByProject(entries); + expect(groups.get('p1')).toHaveLength(2); + expect(groups.get('p2')).toHaveLength(1); + expect(groups.get('no-project')).toHaveLength(1); + }); +}); + +// ─── Filtering ─────────────────────────────────────────── + +describe('getFilteredEntries', () => { + const entries = [ + makeEntry({ + projectId: 'p1', + clientId: 'c1', + isBillable: true, + description: 'API work', + tags: ['dev'], + date: '2024-01-15', + }), + makeEntry({ + projectId: 'p2', + clientId: 'c2', + isBillable: false, + description: 'Meeting', + tags: ['meeting'], + date: '2024-01-16', + }), + makeEntry({ + projectId: 'p1', + isBillable: true, + description: 'Testing', + tags: ['dev'], + date: '2024-01-17', + }), + ]; + + it('filters by projectId', () => { + expect(getFilteredEntries(entries, { projectId: 'p1' })).toHaveLength(2); + }); + + it('filters by clientId', () => { + expect(getFilteredEntries(entries, { clientId: 'c1' })).toHaveLength(1); + }); + + it('filters by isBillable', () => { + expect(getFilteredEntries(entries, { isBillable: true })).toHaveLength(2); + expect(getFilteredEntries(entries, { isBillable: false })).toHaveLength(1); + }); + + it('filters by tags', () => { + expect(getFilteredEntries(entries, { tagIds: ['dev'] })).toHaveLength(2); + expect(getFilteredEntries(entries, { tagIds: ['meeting'] })).toHaveLength(1); + }); + + it('filters by date range', () => { + expect(getFilteredEntries(entries, { dateFrom: '2024-01-16' })).toHaveLength(2); + expect(getFilteredEntries(entries, { dateTo: '2024-01-15' })).toHaveLength(1); + }); + + it('filters by search text', () => { + expect(getFilteredEntries(entries, { search: 'api' })).toHaveLength(1); + expect(getFilteredEntries(entries, { search: 'MEETING' })).toHaveLength(1); + }); + + it('combines multiple filters', () => { + expect(getFilteredEntries(entries, { projectId: 'p1', isBillable: true })).toHaveLength(2); + expect(getFilteredEntries(entries, { projectId: 'p1', search: 'test' })).toHaveLength(1); + }); + + it('returns all with empty filters', () => { + expect(getFilteredEntries(entries, {})).toHaveLength(3); + }); +}); + +// ─── Sorting ───────────────────────────────────────────── + +describe('getSortedEntries', () => { + const entries = [ + makeEntry({ date: '2024-01-16', duration: 1800, createdAt: '2024-01-16T10:00:00Z' }), + makeEntry({ date: '2024-01-15', duration: 3600, createdAt: '2024-01-15T10:00:00Z' }), + makeEntry({ date: '2024-01-17', duration: 900, createdAt: '2024-01-17T10:00:00Z' }), + ]; + + it('sorts by date ascending', () => { + const result = getSortedEntries(entries, { field: 'date', direction: 'asc' }); + expect(result[0].date).toBe('2024-01-15'); + expect(result[2].date).toBe('2024-01-17'); + }); + + it('sorts by date descending', () => { + const result = getSortedEntries(entries, { field: 'date', direction: 'desc' }); + expect(result[0].date).toBe('2024-01-17'); + }); + + it('sorts by duration', () => { + const result = getSortedEntries(entries, { field: 'duration', direction: 'desc' }); + expect(result[0].duration).toBe(3600); + }); +}); + +// ─── Project/Client Helpers ────────────────────────────── + +describe('getActiveProjects', () => { + it('excludes archived and sorts by order', () => { + const projects = [ + makeProject({ name: 'B', isArchived: false, order: 1 }), + makeProject({ name: 'A', isArchived: false, order: 0 }), + makeProject({ name: 'C', isArchived: true, order: 2 }), + ]; + const result = getActiveProjects(projects); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('A'); + }); +}); + +describe('getActiveClients', () => { + it('excludes archived and sorts by order', () => { + const clients = [ + makeClient({ name: 'B', isArchived: false, order: 1 }), + makeClient({ name: 'A', isArchived: false, order: 0 }), + makeClient({ name: 'C', isArchived: true }), + ]; + const result = getActiveClients(clients); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('A'); + }); +}); + +describe('getProjectById', () => { + const projects = [makeProject({ id: 'p1', name: 'One' }), makeProject({ id: 'p2', name: 'Two' })]; + + it('finds by id', () => { + expect(getProjectById(projects, 'p1')?.name).toBe('One'); + }); + + it('returns undefined for missing', () => { + expect(getProjectById(projects, 'p99')).toBeUndefined(); + }); +}); + +describe('getProjectsByClient', () => { + const projects = [ + makeProject({ clientId: 'c1' }), + makeProject({ clientId: 'c1' }), + makeProject({ clientId: 'c2' }), + ]; + + it('filters by clientId', () => { + expect(getProjectsByClient(projects, 'c1')).toHaveLength(2); + expect(getProjectsByClient(projects, 'c2')).toHaveLength(1); + }); +}); diff --git a/apps/taktik/apps/web/src/lib/data/types.test.ts b/apps/taktik/apps/web/src/lib/data/types.test.ts new file mode 100644 index 000000000..0021d0b35 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/data/types.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import type { + Client, + Project, + TimeEntry, + Tag, + EntryTemplate, + TaktikSettings, + BillingRate, + FilterCriteria, + SortOption, +} from '@taktik/shared'; + +describe('Shared Types', () => { + it('BillingRate has correct shape', () => { + const rate: BillingRate = { amount: 95, currency: 'EUR', per: 'hour' }; + expect(rate.amount).toBe(95); + expect(rate.per).toBe('hour'); + }); + + it('Client has required fields', () => { + const client: Client = { + id: '1', + name: 'Test', + color: '#000', + isArchived: false, + order: 0, + createdAt: '', + updatedAt: '', + }; + expect(client.name).toBe('Test'); + }); + + it('TimeEntry has required fields', () => { + const entry: TimeEntry = { + id: '1', + description: 'Work', + date: '2024-01-15', + duration: 3600, + isBillable: true, + isRunning: false, + tags: ['dev'], + visibility: 'private', + createdAt: '', + updatedAt: '', + }; + expect(entry.duration).toBe(3600); + expect(entry.tags).toContain('dev'); + }); + + it('FilterCriteria supports all filter types', () => { + const filter: FilterCriteria = { + search: 'test', + projectId: 'p1', + clientId: 'c1', + tagIds: ['t1'], + isBillable: true, + dateFrom: '2024-01-01', + dateTo: '2024-12-31', + }; + expect(filter.search).toBe('test'); + }); + + it('SortOption has valid fields', () => { + const sort: SortOption = { field: 'date', direction: 'desc' }; + expect(sort.field).toBe('date'); + }); +}); diff --git a/apps/taktik/apps/web/src/lib/utils/export.test.ts b/apps/taktik/apps/web/src/lib/utils/export.test.ts new file mode 100644 index 000000000..83bb6aa4e --- /dev/null +++ b/apps/taktik/apps/web/src/lib/utils/export.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from 'vitest'; +import type { TimeEntry, Project, Client } from '@taktik/shared'; + +// We test the CSV generation logic without triggering DOM download. +// This mirrors the core logic from export.ts. + +function generateCSVContent(entries: TimeEntry[], projects: Project[], clients: Client[]): string { + const projectMap = new Map(projects.map((p) => [p.id, p])); + const clientMap = new Map(clients.map((c) => [c.id, c])); + + const headers = [ + 'Datum', + 'Beschreibung', + 'Projekt', + 'Kunde', + 'Dauer (h)', + 'Dauer (min)', + 'Abrechenbar', + 'Tags', + 'Startzeit', + 'Endzeit', + ]; + + const rows = entries.map((e) => { + const project = e.projectId ? projectMap.get(e.projectId) : undefined; + const client = e.clientId ? clientMap.get(e.clientId) : undefined; + const hours = Math.floor(e.duration / 3600); + const minutes = Math.floor((e.duration % 3600) / 60); + + return [ + e.date, + `"${(e.description || '').replace(/"/g, '""')}"`, + `"${(project?.name || '').replace(/"/g, '""')}"`, + `"${(client?.name || '').replace(/"/g, '""')}"`, + hours.toString(), + (hours * 60 + minutes).toString(), + e.isBillable ? 'Ja' : 'Nein', + `"${e.tags.join(', ')}"`, + '', + '', + ]; + }); + + return [headers.join(';'), ...rows.map((r) => r.join(';'))].join('\n'); +} + +// ─── Test Data ─────────────────────────────────────────── + +const projects: Project[] = [ + { + id: 'p1', + name: 'Website Redesign', + color: '#3b82f6', + isArchived: false, + isBillable: true, + visibility: 'private', + order: 0, + createdAt: '', + updatedAt: '', + }, +]; + +const clients: Client[] = [ + { + id: 'c1', + name: 'Acme Corp', + color: '#3b82f6', + isArchived: false, + order: 0, + createdAt: '', + updatedAt: '', + }, +]; + +// ─── Tests ─────────────────────────────────────────────── + +describe('CSV Export', () => { + it('generates correct CSV headers', () => { + const csv = generateCSVContent([], projects, clients); + expect(csv).toContain('Datum;Beschreibung;Projekt;Kunde'); + }); + + it('generates correct row data', () => { + const entries: TimeEntry[] = [ + { + id: 'e1', + projectId: 'p1', + clientId: 'c1', + description: 'API work', + date: '2024-01-15', + duration: 5400, + isBillable: true, + isRunning: false, + tags: ['dev', 'api'], + visibility: 'private', + createdAt: '', + updatedAt: '', + }, + ]; + + const csv = generateCSVContent(entries, projects, clients); + const lines = csv.split('\n'); + expect(lines).toHaveLength(2); + + const row = lines[1]; + expect(row).toContain('2024-01-15'); + expect(row).toContain('"API work"'); + expect(row).toContain('"Website Redesign"'); + expect(row).toContain('"Acme Corp"'); + expect(row).toContain('1'); // 1 hour + expect(row).toContain('90'); // 90 minutes + expect(row).toContain('Ja'); + expect(row).toContain('"dev, api"'); + }); + + it('escapes quotes in descriptions', () => { + const entries: TimeEntry[] = [ + { + id: 'e1', + description: 'Fix "bug" in API', + date: '2024-01-15', + duration: 3600, + isBillable: false, + isRunning: false, + tags: [], + visibility: 'private', + createdAt: '', + updatedAt: '', + }, + ]; + + const csv = generateCSVContent(entries, projects, clients); + expect(csv).toContain('"Fix ""bug"" in API"'); + }); + + it('handles entries without project or client', () => { + const entries: TimeEntry[] = [ + { + id: 'e1', + description: 'Internal work', + date: '2024-01-15', + duration: 1800, + isBillable: false, + isRunning: false, + tags: [], + visibility: 'private', + createdAt: '', + updatedAt: '', + }, + ]; + + const csv = generateCSVContent(entries, projects, clients); + expect(csv).toContain('""'); // empty project/client + expect(csv).toContain('Nein'); + }); + + it('handles empty entries', () => { + const csv = generateCSVContent([], projects, clients); + const lines = csv.split('\n'); + expect(lines).toHaveLength(1); // headers only + }); +});