test(taktik): add unit tests and comprehensive documentation

- 49 unit tests across 3 test files (all passing)
- queries.test.ts: duration formatting, date filtering, aggregation,
  grouping, multi-criteria filtering, sorting, entity helpers
- export.test.ts: CSV generation, headers, quote escaping, edge cases
- types.test.ts: compile-time type validation
- CLAUDE.md: full feature docs, architecture diagrams, project structure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 23:33:43 +01:00
parent 4a91fd7e97
commit 79a270a8d4
4 changed files with 769 additions and 35 deletions

View file

@ -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

View file

@ -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> = {}): 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> = {}): 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> = {}): 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);
});
});

View file

@ -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');
});
});

View file

@ -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
});
});