mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 06:41:08 +02:00
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:
parent
4a91fd7e97
commit
79a270a8d4
4 changed files with 769 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
370
apps/taktik/apps/web/src/lib/data/queries.test.ts
Normal file
370
apps/taktik/apps/web/src/lib/data/queries.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
68
apps/taktik/apps/web/src/lib/data/types.test.ts
Normal file
68
apps/taktik/apps/web/src/lib/data/types.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
162
apps/taktik/apps/web/src/lib/utils/export.test.ts
Normal file
162
apps/taktik/apps/web/src/lib/utils/export.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue