rename(taktik): rebrand to Times

Rename taktik → times across the entire app: package names (@taktik →
@times), appId, localStorage keys, export filenames, type names
(TaktikSettings → TimesSettings), monorepo scripts, shared-branding,
mana-auth trustedOrigins, docker-compose, and documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-30 15:44:18 +02:00
parent 1eb370eaaa
commit c33339b0cf
92 changed files with 970 additions and 1263 deletions

207
apps/times/CLAUDE.md Normal file
View file

@ -0,0 +1,207 @@
# Times
Zeiterfassung & Timetracking - Dein Arbeitsrhythmus, messbar gemacht.
**Web App Port:** 5197
## Project Overview
Times 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
| Layer | Technology |
|-------|------------|
| 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) |
| Testing | Vitest |
## Development
```bash
# From monorepo root
pnpm dev:times:web # Start web app on port 5197
pnpm dev:times:full # Start with auth + sync server
# Tests
pnpm --filter @times/web test # Run all tests
pnpm --filter @times/web test:unit # Run in watch mode
# Type checking
pnpm --filter @times/web type-check
pnpm --filter @times/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 | 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/times/
├── apps/
│ └── web/ # SvelteKit web client (port 5197)
│ ├── src/
│ │ ├── routes/
│ │ │ ├── (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/
│ │ ├── 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/ # @times/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,53 @@
# syntax=docker/dockerfile:1
# Build stage - inherits pre-built shared packages from sveltekit-base
FROM sveltekit-base:local AS builder
# Build arguments for SvelteKit static env vars
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
# Set as environment variables for build
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
# Copy app-specific packages
COPY apps/times/packages/shared ./apps/times/packages/shared
COPY apps/times/apps/web ./apps/times/apps/web
# Install app-specific dependencies
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --no-frozen-lockfile --ignore-scripts
# Build the web app
WORKDIR /app/apps/times/apps/web
RUN pnpm exec svelte-kit sync
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
# Production stage
FROM node:20-alpine AS production
# Keep same directory structure as builder so pnpm symlinks resolve correctly
WORKDIR /app/apps/times/apps/web
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
# Copy the app's node_modules (contains symlinks to the pnpm store)
COPY --from=builder /app/apps/times/apps/web/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/apps/times/apps/web/build ./build
COPY --from=builder /app/apps/times/apps/web/package.json ./
# Expose port
EXPOSE 5027
# Set environment variables
ENV NODE_ENV=production
ENV PORT=5027
ENV HOST=0.0.0.0
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5027/health || exit 1
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,52 @@
{
"name": "@times/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"test:unit": "vitest"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.21.0",
"@sveltejs/vite-plugin-svelte": "^5.1.0",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^22.15.29",
"svelte": "^5.41.0",
"svelte-check": "^4.2.1",
"tailwindcss": "^4.1.7",
"typescript": "^5.9.3",
"vite": "^6.3.5",
"vitest": "^3.2.1",
"@vite-pwa/sveltekit": "^1.1.0",
"@manacore/shared-vite-config": "workspace:*",
"@manacore/shared-tailwind": "workspace:*"
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-stores": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/local-store": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-landing-ui": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-stores": "workspace:*",
"@manacore/subscriptions": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-types": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@times/shared": "workspace:*",
"date-fns": "^4.1.0",
"svelte-i18n": "^4.0.1"
},
"type": "module"
}

View file

@ -0,0 +1,43 @@
@import 'tailwindcss';
@import '@manacore/shared-tailwind/themes.css';
/* Scan shared packages for Tailwind classes */
@source "../../../packages/shared/src";
@source "../../../../../packages/shared-ui/src";
@source "../../../../../packages/shared-theme-ui/src";
@source "../../../../../packages/shared-theme-ui/src/components";
@source "../../../../../packages/shared-theme-ui/src/pages";
:root {
--primary: 38 92% 50%;
--primary-foreground: 0 0% 100%;
--accent: 38 92% 50%;
--accent-foreground: 0 0% 100%;
}
/* Timer card glow */
.timer-active {
box-shadow: 0 0 20px rgba(245, 158, 11, 0.15);
}
/* Entry item transitions */
.entry-item {
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.entry-item:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
/* Duration display monospace */
.duration-display {
font-variant-numeric: tabular-nums;
}
/* Project color dot */
.project-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}

View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#f59e0b" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
<title>Times</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
let {
visible = false,
title = '',
message = '',
confirmLabel,
cancelLabel,
destructive = true,
onConfirm,
onCancel,
}: {
visible: boolean;
title: string;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
} = $props();
</script>
{#if visible}
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm"
role="dialog"
aria-modal="true"
>
<div
class="mx-4 w-full max-w-sm rounded-2xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 shadow-xl"
>
<h3 class="text-lg font-semibold text-[hsl(var(--foreground))]">{title}</h3>
{#if message}
<p class="mt-2 text-sm text-[hsl(var(--muted-foreground))]">{message}</p>
{/if}
<div class="mt-5 flex gap-2">
<button
onclick={onCancel}
class="flex-1 rounded-lg border border-[hsl(var(--border))] py-2.5 text-sm text-[hsl(var(--muted-foreground))] transition-colors hover:text-[hsl(var(--foreground))]"
>
{cancelLabel || $_('common.cancel')}
</button>
<button
onclick={onConfirm}
class="flex-1 rounded-lg py-2.5 text-sm font-medium transition-colors {destructive
? 'bg-red-500 text-white hover:bg-red-600'
: 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] hover:opacity-90'}"
>
{confirmLabel || $_('common.delete')}
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,240 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { timeEntryCollection } from '$lib/data/local-store';
import type { Project, Client } from '@times/shared';
let {
visible = false,
onClose,
}: {
visible: boolean;
onClose: () => void;
} = $props();
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');
let description = $state('');
let projectId = $state('');
let date = $state(new Date().toISOString().split('T')[0]);
let durationHours = $state(1);
let durationMinutes = $state(0);
let isBillable = $state(false);
let activeProjects = $derived(
allProjects.value.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order)
);
function resetForm() {
description = '';
projectId = '';
date = new Date().toISOString().split('T')[0];
durationHours = 1;
durationMinutes = 0;
isBillable = false;
}
async function handleSubmit() {
const totalSeconds = durationHours * 3600 + durationMinutes * 60;
if (totalSeconds <= 0) return;
const project = projectId ? allProjects.value.find((p) => p.id === projectId) : null;
await timeEntryCollection.insert({
id: crypto.randomUUID(),
projectId: projectId || null,
clientId: project?.clientId ?? null,
description,
date,
startTime: null,
endTime: null,
duration: totalSeconds,
isBillable,
isRunning: false,
tags: [],
billingRate: null,
visibility: 'private',
guildId: null,
source: { app: 'manual' },
});
resetForm();
onClose();
}
function handleProjectChange(id: string) {
projectId = id;
const project = allProjects.value.find((p) => p.id === id);
if (project) {
isBillable = project.isBillable;
}
}
// Quick duration buttons
const quickDurations = [
{ label: '15m', h: 0, m: 15 },
{ label: '30m', h: 0, m: 30 },
{ label: '1h', h: 1, m: 0 },
{ label: '1.5h', h: 1, m: 30 },
{ label: '2h', h: 2, m: 0 },
{ label: '4h', h: 4, m: 0 },
];
</script>
{#if visible}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 backdrop-blur-sm sm:items-center"
role="dialog"
aria-modal="true"
>
<div
class="w-full max-w-lg rounded-t-2xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 shadow-xl sm:rounded-2xl"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-[hsl(var(--foreground))]">
{$_('entry.manual')}
</h2>
<button
onclick={onClose}
class="rounded-lg p-1 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-4"
>
<!-- Description -->
<input
type="text"
bind:value={description}
placeholder={$_('entry.description')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))] focus:border-[hsl(var(--primary))] focus:outline-none"
/>
<!-- Project -->
<select
value={projectId}
onchange={(e) => handleProjectChange((e.target as HTMLSelectElement).value)}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2.5 text-sm text-[hsl(var(--foreground))]"
>
<option value="">{$_('project.internal')}</option>
{#each activeProjects as proj}
<option value={proj.id}>
{proj.name}
{#if proj.clientId}
{@const client = allClients.value.find((c) => c.id === proj.clientId)}
{#if client}
· {client.name}{/if}
{/if}
</option>
{/each}
</select>
<!-- Date -->
<input
type="date"
bind:value={date}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))]"
/>
<!-- Quick Duration Buttons -->
<div>
<label class="mb-1.5 block text-xs font-medium text-[hsl(var(--muted-foreground))]">
{$_('entry.duration')}
</label>
<div class="mb-2 flex flex-wrap gap-2">
{#each quickDurations as qd}
<button
type="button"
onclick={() => {
durationHours = qd.h;
durationMinutes = qd.m;
}}
class="rounded-lg border px-3 py-1.5 text-xs transition-colors {durationHours ===
qd.h && durationMinutes === qd.m
? 'border-[hsl(var(--primary))] bg-[hsl(var(--primary)/0.1)] text-[hsl(var(--primary))]'
: 'border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))] hover:border-[hsl(var(--primary)/0.5)]'}"
>
{qd.label}
</button>
{/each}
</div>
<!-- Custom Duration -->
<div class="flex items-center gap-2">
<input
type="number"
bind:value={durationHours}
min="0"
max="24"
class="w-16 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm text-[hsl(var(--foreground))]"
/>
<span class="text-xs text-[hsl(var(--muted-foreground))]">h</span>
<input
type="number"
bind:value={durationMinutes}
min="0"
max="59"
step="5"
class="w-16 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm text-[hsl(var(--foreground))]"
/>
<span class="text-xs text-[hsl(var(--muted-foreground))]">min</span>
</div>
</div>
<!-- Billable -->
<div class="flex items-center gap-3">
<button
type="button"
onclick={() => (isBillable = !isBillable)}
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm transition-colors {isBillable
? 'bg-[hsl(var(--primary)/0.1)] text-[hsl(var(--primary))]'
: 'text-[hsl(var(--muted-foreground))]'}"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{isBillable ? $_('entry.billable') : $_('entry.notBillable')}
</button>
</div>
<!-- Submit -->
<div class="flex gap-2">
<button
type="button"
onclick={onClose}
class="flex-1 rounded-lg border border-[hsl(var(--border))] py-2.5 text-sm text-[hsl(var(--muted-foreground))]"
>
{$_('common.cancel')}
</button>
<button
type="submit"
class="flex-1 rounded-lg bg-[hsl(var(--primary))] py-2.5 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
{$_('common.create')}
</button>
</div>
</form>
</div>
</div>
{/if}

View file

@ -0,0 +1,215 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { timeEntryCollection } from '$lib/data/local-store';
import { formatDurationCompact } from '$lib/data/queries';
import type { TimeEntry, Project, Client } from '@times/shared';
import ConfirmDialog from './ConfirmDialog.svelte';
let {
entry,
isExpanded = false,
onExpand,
onCollapse,
}: {
entry: TimeEntry;
isExpanded?: boolean;
onExpand?: () => void;
onCollapse?: () => void;
} = $props();
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');
let editDescription = $state(entry.description);
let editProjectId = $state(entry.projectId ?? '');
let editIsBillable = $state(entry.isBillable);
let editDurationMinutes = $state(Math.round(entry.duration / 60));
// Sync when entry changes
$effect(() => {
editDescription = entry.description;
editProjectId = entry.projectId ?? '';
editIsBillable = entry.isBillable;
editDurationMinutes = Math.round(entry.duration / 60);
});
let project = $derived(
entry.projectId ? allProjects.value.find((p) => p.id === entry.projectId) : undefined
);
let client = $derived(
entry.clientId ? allClients.value.find((c) => c.id === entry.clientId) : undefined
);
let showDeleteConfirm = $state(false);
let saveDebounce: ReturnType<typeof setTimeout> | null = null;
function autoSave(updates: Record<string, unknown>) {
if (saveDebounce) clearTimeout(saveDebounce);
saveDebounce = setTimeout(async () => {
await timeEntryCollection.update(entry.id, updates);
}, 500);
}
function handleDescriptionChange(value: string) {
editDescription = value;
autoSave({ description: value });
}
function handleProjectChange(projectId: string) {
editProjectId = projectId;
const proj = allProjects.value.find((p) => p.id === projectId);
autoSave({
projectId: projectId || null,
clientId: proj?.clientId ?? null,
});
}
function handleBillableToggle() {
editIsBillable = !editIsBillable;
autoSave({ isBillable: editIsBillable });
}
function handleDurationChange(minutes: number) {
editDurationMinutes = minutes;
autoSave({ duration: minutes * 60 });
}
async function handleDelete() {
showDeleteConfirm = true;
}
async function confirmDelete() {
await timeEntryCollection.delete(entry.id);
showDeleteConfirm = false;
onCollapse?.();
}
let startTimeStr = $derived(
entry.startTime
? new Date(entry.startTime).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})
: ''
);
let endTimeStr = $derived(
entry.endTime
? new Date(entry.endTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
: ''
);
</script>
<div
class="entry-item rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] transition-all {isExpanded
? 'ring-1 ring-[hsl(var(--primary)/0.3)]'
: ''}"
>
<!-- Compact row (always visible) -->
<button
class="flex w-full items-center gap-3 px-4 py-3 text-left"
onclick={() => (isExpanded ? onCollapse?.() : onExpand?.())}
>
{#if project}
<div class="project-dot" style="background-color: {project.color}"></div>
{:else}
<div class="project-dot" style="background-color: #9ca3af"></div>
{/if}
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-[hsl(var(--foreground))]">
{entry.description || $_('timer.noDescription')}
</p>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
{project?.name || $_('project.internal')}
{#if client}· {client.name}{/if}
{#if startTimeStr && endTimeStr}
· {startTimeStr} {endTimeStr}
{/if}
</p>
</div>
<div class="text-right shrink-0">
<p class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
{formatDurationCompact(entry.duration)}
</p>
{#if entry.isBillable}
<span class="text-xs text-[hsl(var(--primary))]">$</span>
{/if}
</div>
</button>
<!-- Expanded edit form -->
{#if isExpanded}
<div class="border-t border-[hsl(var(--border))] px-4 py-3 space-y-3">
<!-- Description -->
<input
type="text"
value={editDescription}
oninput={(e) => handleDescriptionChange((e.target as HTMLInputElement).value)}
placeholder={$_('entry.description')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:border-[hsl(var(--primary))] focus:outline-none"
/>
<!-- Project + Duration row -->
<div class="flex gap-2">
<select
value={editProjectId}
onchange={(e) => handleProjectChange((e.target as HTMLSelectElement).value)}
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
>
<option value="">{$_('project.internal')}</option>
{#each allProjects.value.filter((p) => !p.isArchived) as proj}
<option value={proj.id}>{proj.name}</option>
{/each}
</select>
<div class="flex items-center gap-1">
<input
type="number"
value={editDurationMinutes}
oninput={(e) =>
handleDurationChange(parseInt((e.target as HTMLInputElement).value) || 0)}
min="0"
class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm text-[hsl(var(--foreground))]"
/>
<span class="text-xs text-[hsl(var(--muted-foreground))]">min</span>
</div>
</div>
<!-- Billable + Delete row -->
<div class="flex items-center justify-between">
<button
onclick={handleBillableToggle}
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs transition-colors {editIsBillable
? 'bg-[hsl(var(--primary)/0.1)] text-[hsl(var(--primary))]'
: 'text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]'}"
>
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{editIsBillable ? $_('entry.billable') : $_('entry.notBillable')}
</button>
<button
onclick={handleDelete}
class="rounded-lg px-3 py-1.5 text-xs text-red-500 transition-colors hover:bg-red-500/10"
>
{$_('common.delete')}
</button>
</div>
</div>
{/if}
</div>
<ConfirmDialog
visible={showDeleteConfirm}
title={$_('common.delete')}
message={$_('entry.deleteConfirm')}
onConfirm={confirmDelete}
onCancel={() => (showDeleteConfirm = false)}
/>

View file

@ -0,0 +1,79 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import EntryItem from './EntryItem.svelte';
import { groupEntriesByDate, getTotalDuration, formatDurationCompact } from '$lib/data/queries';
import type { TimeEntry } from '@times/shared';
let { entries }: { entries: TimeEntry[] } = $props();
let expandedEntryId = $state<string | null>(null);
let groupedEntries = $derived(() => {
const groups = groupEntriesByDate(entries);
// Sort dates descending (newest first)
return [...groups.entries()].sort(([a], [b]) => b.localeCompare(a));
});
function formatDateHeader(dateStr: string): string {
const today = new Date().toISOString().split('T')[0];
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
if (dateStr === today) return $_('entry.today');
if (dateStr === yesterday) return 'Gestern';
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
});
}
</script>
{#if entries.length === 0}
<div
class="rounded-xl border border-dashed border-[hsl(var(--border))] p-8 text-center text-[hsl(var(--muted-foreground))]"
>
<svg
class="mx-auto mb-3 h-10 w-10 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p>{$_('entry.noEntries')}</p>
</div>
{:else}
<div class="space-y-6">
{#each groupedEntries() as [date, dayEntries]}
<div>
<!-- Day Header -->
<div class="mb-2 flex items-center justify-between">
<h3 class="text-sm font-medium text-[hsl(var(--muted-foreground))]">
{formatDateHeader(date)}
</h3>
<span class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
{formatDurationCompact(getTotalDuration(dayEntries))}
</span>
</div>
<!-- Entries for this day -->
<div class="space-y-2">
{#each dayEntries as entry (entry.id)}
<EntryItem
{entry}
isExpanded={expandedEntryId === entry.id}
onExpand={() => (expandedEntryId = entry.id)}
onCollapse={() => (expandedEntryId = null)}
/>
{/each}
</div>
</div>
{/each}
</div>
{/if}

View file

@ -0,0 +1,56 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { timerStore } from '$lib/stores/timer.svelte';
let {
onNewEntry,
}: {
onNewEntry?: () => void;
} = $props();
function handleKeydown(e: KeyboardEvent) {
// Don't trigger when typing in inputs
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT'
) {
// Escape still works in inputs
if (e.key === 'Escape') {
(target as HTMLInputElement).blur();
}
return;
}
switch (e.key) {
case 's':
e.preventDefault();
if (timerStore.isRunning) {
timerStore.stop();
} else {
timerStore.start();
}
break;
case 'n':
e.preventDefault();
onNewEntry?.();
break;
case 'g':
// g then t = go to timer, g then e = entries, etc.
break;
case '?':
// Show keyboard shortcuts help (future)
break;
}
}
onMount(() => {
window.addEventListener('keydown', handleKeydown);
});
onDestroy(() => {
window.removeEventListener('keydown', handleKeydown);
});
</script>

View file

@ -0,0 +1,59 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { timerStore } from '$lib/stores/timer.svelte';
import type { TimeEntry, Project } from '@times/shared';
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
const allProjects = getContext<{ value: Project[] }>('projects');
// Get unique recent entries (by description+project, deduplicated)
let recentEntries = $derived(() => {
const seen = new Set<string>();
return allTimeEntries.value
.filter((e) => !e.isRunning && e.description)
.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''))
.filter((e) => {
const key = `${e.description}|${e.projectId}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.slice(0, 5);
});
async function startFromEntry(entry: TimeEntry) {
await timerStore.start({
projectId: entry.projectId,
clientId: entry.clientId,
description: entry.description,
isBillable: entry.isBillable,
tags: entry.tags,
});
}
</script>
{#if recentEntries().length > 0}
<div>
<h3 class="mb-2 text-xs font-medium text-[hsl(var(--muted-foreground))]">Quick Start</h3>
<div class="flex flex-wrap gap-2">
{#each recentEntries() as entry}
{@const project = entry.projectId
? allProjects.value.find((p) => p.id === entry.projectId)
: undefined}
<button
onclick={() => startFromEntry(entry)}
disabled={timerStore.isRunning}
class="flex items-center gap-1.5 rounded-full border border-[hsl(var(--border))] px-3 py-1.5 text-xs transition-colors hover:border-[hsl(var(--primary)/0.5)] hover:bg-[hsl(var(--primary)/0.05)] disabled:opacity-50"
>
{#if project}
<div class="h-2 w-2 rounded-full" style="background-color: {project.color}"></div>
{/if}
<span class="max-w-[150px] truncate text-[hsl(var(--foreground))]"
>{entry.description}</span
>
</button>
{/each}
</div>
</div>
{/if}

View file

@ -0,0 +1,199 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { timerStore } from '$lib/stores/timer.svelte';
import { formatDuration } from '$lib/data/queries';
import type { Project, Client } from '@times/shared';
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');
let description = $state('');
let selectedProjectId = $state<string | null>(null);
let isBillable = $state(false);
// Sync description with running entry
$effect(() => {
if (timerStore.runningEntry) {
description = timerStore.runningEntry.description || '';
selectedProjectId = timerStore.runningEntry.projectId ?? null;
isBillable = timerStore.runningEntry.isBillable;
}
});
let activeProjects = $derived(
allProjects.value.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order)
);
let selectedProject = $derived(
selectedProjectId ? allProjects.value.find((p) => p.id === selectedProjectId) : null
);
let selectedClient = $derived(
selectedProject?.clientId
? allClients.value.find((c) => c.id === selectedProject!.clientId)
: null
);
let formattedTime = $derived(formatDuration(timerStore.elapsedSeconds));
async function handleStartStop() {
if (timerStore.isRunning) {
await timerStore.stop();
description = '';
selectedProjectId = null;
isBillable = false;
} else {
const clientId = selectedProject?.clientId ?? undefined;
await timerStore.start({
description,
projectId: selectedProjectId ?? undefined,
clientId,
isBillable,
});
}
}
let descriptionDebounce: ReturnType<typeof setTimeout> | null = null;
function handleDescriptionChange(value: string) {
description = value;
if (!timerStore.isRunning) return;
if (descriptionDebounce) clearTimeout(descriptionDebounce);
descriptionDebounce = setTimeout(() => {
timerStore.updateRunning({ description: value });
}, 500);
}
async function handleProjectChange(projectId: string | null) {
selectedProjectId = projectId;
if (!timerStore.isRunning) return;
const project = projectId ? allProjects.value.find((p) => p.id === projectId) : null;
await timerStore.updateRunning({
projectId: projectId ?? undefined,
clientId: project?.clientId ?? undefined,
isBillable: project?.isBillable ?? isBillable,
});
if (project) {
isBillable = project.isBillable;
}
}
async function handleBillableToggle() {
isBillable = !isBillable;
if (timerStore.isRunning) {
await timerStore.updateRunning({ isBillable });
}
}
</script>
<div
class="rounded-2xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 {timerStore.isRunning
? 'timer-active'
: ''}"
>
<!-- Timer Display -->
<div class="mb-4 text-center">
<div
class="duration-display text-5xl font-bold {timerStore.isRunning
? 'text-[hsl(var(--primary))]'
: 'text-[hsl(var(--foreground))]'}"
>
{formattedTime}
</div>
</div>
<!-- Description Input -->
<div class="mb-4">
<input
type="text"
value={description}
oninput={(e) => handleDescriptionChange((e.target as HTMLInputElement).value)}
placeholder={$_('timer.whatAreYouWorkingOn')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))] placeholder:text-[hsl(var(--muted-foreground))] focus:border-[hsl(var(--primary))] focus:outline-none focus:ring-1 focus:ring-[hsl(var(--primary))]"
/>
</div>
<!-- Project & Billable Row -->
<div class="mb-4 flex items-center gap-3">
<!-- Project Selector -->
<div class="flex-1">
<select
value={selectedProjectId ?? ''}
onchange={(e) => {
const val = (e.target as HTMLSelectElement).value;
handleProjectChange(val || null);
}}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
>
<option value="">{$_('project.noProjects')}</option>
{#each activeProjects as project}
<option value={project.id}>
{project.name}
{#if project.clientId}
{@const client = allClients.value.find((c) => c.id === project.clientId)}
{#if client}
· {client.name}{/if}
{/if}
</option>
{/each}
</select>
</div>
<!-- Billable Toggle -->
<button
onclick={handleBillableToggle}
class="flex h-9 w-9 items-center justify-center rounded-lg border transition-colors {isBillable
? 'border-[hsl(var(--primary))] bg-[hsl(var(--primary)/0.1)] text-[hsl(var(--primary))]'
: 'border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))]'}"
title={isBillable ? $_('entry.billable') : $_('entry.notBillable')}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
</div>
<!-- Start/Stop Button -->
<button
onclick={handleStartStop}
class="w-full rounded-xl py-3 text-lg font-medium transition-all {timerStore.isRunning
? 'bg-red-500 text-white hover:bg-red-600'
: 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] hover:opacity-90'}"
>
{#if timerStore.isRunning}
<span class="flex items-center justify-center gap-2">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
{$_('timer.stop')}
</span>
{:else}
<span class="flex items-center justify-center gap-2">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
{$_('timer.start')}
</span>
{/if}
</button>
<!-- Running info -->
{#if timerStore.isRunning && selectedProject}
<div
class="mt-3 flex items-center justify-center gap-2 text-xs text-[hsl(var(--muted-foreground))]"
>
<div class="project-dot" style="background-color: {selectedProject.color}"></div>
<span>{selectedProject.name}</span>
{#if selectedClient}
<span>· {selectedClient.name}</span>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,62 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { timerStore } from '$lib/stores/timer.svelte';
import { formatDuration } from '$lib/data/queries';
import type { Project } from '@times/shared';
const allProjects = getContext<{ value: Project[] }>('projects');
let project = $derived(
timerStore.runningEntry?.projectId
? allProjects.value.find((p) => p.id === timerStore.runningEntry!.projectId)
: undefined
);
let formattedTime = $derived(formatDuration(timerStore.elapsedSeconds));
async function handleStop() {
await timerStore.stop();
}
</script>
{#if timerStore.isRunning}
<div
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary)/0.1)] px-3 py-1.5 border border-[hsl(var(--primary)/0.2)]"
>
<!-- Pulsing dot -->
<div class="relative flex h-2 w-2">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-[hsl(var(--primary))] opacity-75"
></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-[hsl(var(--primary))]"></span>
</div>
<!-- Project dot + Description -->
{#if project}
<div
class="h-2.5 w-2.5 rounded-full shrink-0"
style="background-color: {project.color}"
></div>
{/if}
<span class="hidden sm:inline max-w-[120px] truncate text-xs text-[hsl(var(--foreground))]">
{timerStore.runningEntry?.description || $_('timer.running')}
</span>
<!-- Elapsed time -->
<span class="duration-display text-xs font-medium text-[hsl(var(--primary))]">
{formattedTime}
</span>
<!-- Stop button -->
<button
onclick={handleStop}
class="flex h-5 w-5 items-center justify-center rounded bg-red-500 text-white transition-colors hover:bg-red-600"
title={$_('timer.stop')}
>
<svg class="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="1" />
</svg>
</button>
</div>
{/if}

View file

@ -0,0 +1,183 @@
/**
* Guest seed data for the Times app.
*
* Provides demo clients, projects, and time entries for the guest experience.
*/
import type {
LocalClient,
LocalProject,
LocalTimeEntry,
LocalTag,
LocalSettings,
} from './local-store';
const DEMO_CLIENT_ID = 'demo-client-acme';
const DEMO_PROJECT_ID = 'demo-project-redesign';
const DEMO_INTERNAL_PROJECT_ID = 'demo-project-internal';
function todayStr(): string {
return new Date().toISOString().split('T')[0];
}
function yesterdayStr(): string {
const d = new Date();
d.setDate(d.getDate() - 1);
return d.toISOString().split('T')[0];
}
export const guestClients: LocalClient[] = [
{
id: DEMO_CLIENT_ID,
name: 'Acme Corp',
shortCode: 'ACME',
email: 'kontakt@acme.de',
color: '#3b82f6',
isArchived: false,
billingRate: { amount: 95, currency: 'EUR', per: 'hour' },
order: 0,
},
{
id: 'demo-client-startup',
name: 'TechStartup GmbH',
shortCode: 'TS',
color: '#8b5cf6',
isArchived: false,
billingRate: { amount: 85, currency: 'EUR', per: 'hour' },
order: 1,
},
];
export const guestProjects: LocalProject[] = [
{
id: DEMO_PROJECT_ID,
clientId: DEMO_CLIENT_ID,
name: 'Website Redesign',
description: 'Kompletter Relaunch der Unternehmenswebsite',
color: '#3b82f6',
isArchived: false,
isBillable: true,
billingRate: { amount: 95, currency: 'EUR', per: 'hour' },
budget: { type: 'hours', amount: 120 },
visibility: 'private',
order: 0,
},
{
id: DEMO_INTERNAL_PROJECT_ID,
name: 'Intern / Meetings',
description: 'Interne Meetings, Orga, Admin',
color: '#6b7280',
isArchived: false,
isBillable: false,
visibility: 'private',
order: 1,
},
{
id: 'demo-project-app',
clientId: 'demo-client-startup',
name: 'Mobile App',
description: 'React Native App Entwicklung',
color: '#8b5cf6',
isArchived: false,
isBillable: true,
budget: { type: 'hours', amount: 200 },
visibility: 'private',
order: 2,
},
];
export const guestTimeEntries: LocalTimeEntry[] = [
{
id: 'entry-1',
projectId: DEMO_PROJECT_ID,
clientId: DEMO_CLIENT_ID,
description: 'Homepage Layout erstellen',
date: todayStr(),
startTime: new Date(new Date().setHours(9, 0, 0, 0)).toISOString(),
endTime: new Date(new Date().setHours(11, 30, 0, 0)).toISOString(),
duration: 9000,
isBillable: true,
isRunning: false,
tags: ['design'],
visibility: 'private',
source: { app: 'manual' },
},
{
id: 'entry-2',
projectId: DEMO_INTERNAL_PROJECT_ID,
description: 'Sprint Planning',
date: todayStr(),
startTime: new Date(new Date().setHours(11, 30, 0, 0)).toISOString(),
endTime: new Date(new Date().setHours(12, 15, 0, 0)).toISOString(),
duration: 2700,
isBillable: false,
isRunning: false,
tags: ['meeting'],
visibility: 'private',
source: { app: 'manual' },
},
{
id: 'entry-3',
projectId: DEMO_PROJECT_ID,
clientId: DEMO_CLIENT_ID,
description: 'API Integration',
date: todayStr(),
startTime: new Date(new Date().setHours(13, 0, 0, 0)).toISOString(),
endTime: new Date(new Date().setHours(15, 0, 0, 0)).toISOString(),
duration: 7200,
isBillable: true,
isRunning: false,
tags: ['development'],
visibility: 'private',
source: { app: 'timer' },
},
{
id: 'entry-4',
projectId: 'demo-project-app',
clientId: 'demo-client-startup',
description: 'Login Screen implementieren',
date: yesterdayStr(),
startTime: new Date(new Date().setHours(9, 0, 0, 0)).toISOString(),
endTime: new Date(new Date().setHours(12, 0, 0, 0)).toISOString(),
duration: 10800,
isBillable: true,
isRunning: false,
tags: ['development'],
visibility: 'private',
source: { app: 'timer' },
},
{
id: 'entry-5',
projectId: DEMO_PROJECT_ID,
clientId: DEMO_CLIENT_ID,
description: 'Code Review & Testing',
date: yesterdayStr(),
duration: 5400,
isBillable: true,
isRunning: false,
tags: ['review'],
visibility: 'private',
source: { app: 'manual' },
},
];
export const guestTags: LocalTag[] = [
{ id: 'tag-design', name: 'design', color: '#f59e0b', order: 0 },
{ id: 'tag-dev', name: 'development', color: '#3b82f6', order: 1 },
{ id: 'tag-meeting', name: 'meeting', color: '#6b7280', order: 2 },
{ id: 'tag-review', name: 'review', color: '#22c55e', order: 3 },
];
export const guestSettings: LocalSettings[] = [
{
id: 'default-settings',
workingHoursPerDay: 8,
workingDaysPerWeek: 5,
roundingIncrement: 0,
roundingMethod: 'none',
defaultVisibility: 'private',
weekStartsOn: 1,
timerReminderMinutes: 0,
autoStopTimerHours: 0,
},
];

View file

@ -0,0 +1,153 @@
/**
* Times Local-First Data Layer
*
* IndexedDB (Dexie.js) with sync support for time tracking.
* Clients, projects, time entries, tags, templates, and settings.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import {
guestClients,
guestProjects,
guestTimeEntries,
guestTags,
guestSettings,
} from './guest-seed';
import type { BillingRate, ProjectVisibility, EntrySourceRef } from '@times/shared';
// ─── Types ──────────────────────────────────────────────────
export interface LocalClient extends BaseRecord {
name: string;
shortCode?: string | null;
contactId?: string | null;
email?: string | null;
color: string;
isArchived: boolean;
billingRate?: BillingRate | null;
notes?: string | null;
order: number;
}
export interface LocalProject extends BaseRecord {
clientId?: string | null;
name: string;
description?: string | null;
color: string;
isArchived: boolean;
isBillable: boolean;
billingRate?: BillingRate | null;
budget?: {
type: 'hours' | 'fixed';
amount: number;
currency?: string;
} | null;
visibility: ProjectVisibility;
guildId?: string | null;
order: number;
}
export interface LocalTimeEntry extends BaseRecord {
projectId?: string | null;
clientId?: string | null;
description: string;
date: string;
startTime?: string | null;
endTime?: string | null;
duration: number;
isBillable: boolean;
isRunning: boolean;
tags: string[];
billingRate?: BillingRate | null;
visibility: ProjectVisibility;
guildId?: string | null;
source?: EntrySourceRef | null;
}
export interface LocalTag extends BaseRecord {
name: string;
color: string;
order: number;
}
export interface LocalTemplate extends BaseRecord {
name: string;
projectId?: string | null;
clientId?: string | null;
description: string;
isBillable: boolean;
tags: string[];
usageCount: number;
lastUsedAt?: string | null;
}
export interface LocalSettings extends BaseRecord {
defaultBillingRate?: BillingRate | null;
workingHoursPerDay: number;
workingDaysPerWeek: number;
roundingIncrement: number;
roundingMethod: 'none' | 'up' | 'down' | 'nearest';
defaultVisibility: ProjectVisibility;
weekStartsOn: 0 | 1;
timerReminderMinutes: number;
autoStopTimerHours: number;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const timesStore = createLocalStore({
appId: 'times',
collections: [
{
name: 'clients',
indexes: ['order', 'isArchived', 'shortCode'],
guestSeed: guestClients,
},
{
name: 'projects',
indexes: ['clientId', 'isArchived', 'isBillable', 'guildId', 'visibility', 'order'],
guestSeed: guestProjects,
},
{
name: 'timeEntries',
indexes: [
'projectId',
'clientId',
'date',
'isRunning',
'[date+projectId]',
'[date+clientId]',
'guildId',
'visibility',
],
guestSeed: guestTimeEntries,
},
{
name: 'tags',
indexes: ['name', 'order'],
guestSeed: guestTags,
},
{
name: 'templates',
indexes: ['usageCount', 'lastUsedAt', 'projectId'],
},
{
name: 'settings',
indexes: [],
guestSeed: guestSettings,
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const clientCollection = timesStore.collection<LocalClient>('clients');
export const projectCollection = timesStore.collection<LocalProject>('projects');
export const timeEntryCollection = timesStore.collection<LocalTimeEntry>('timeEntries');
export const tagCollection = timesStore.collection<LocalTag>('tags');
export const templateCollection = timesStore.collection<LocalTemplate>('templates');
export const settingsCollection = timesStore.collection<LocalSettings>('settings');

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 '@times/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,338 @@
/**
* Reactive Queries & Pure Helpers for Times
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs).
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
clientCollection,
projectCollection,
timeEntryCollection,
tagCollection,
templateCollection,
settingsCollection,
type LocalClient,
type LocalProject,
type LocalTimeEntry,
type LocalTag,
type LocalTemplate,
type LocalSettings,
} from './local-store';
import type {
Client,
Project,
TimeEntry,
Tag,
EntryTemplate,
TimesSettings,
FilterCriteria,
SortOption,
} from '@times/shared';
// ─── Type Converters ───────────────────────────────────────
export function toClient(local: LocalClient): Client {
return {
id: local.id,
name: local.name,
shortCode: local.shortCode ?? undefined,
contactId: local.contactId ?? undefined,
email: local.email ?? undefined,
color: local.color,
isArchived: local.isArchived,
billingRate: local.billingRate ?? undefined,
notes: local.notes ?? undefined,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toProject(local: LocalProject): Project {
return {
id: local.id,
clientId: local.clientId ?? undefined,
name: local.name,
description: local.description ?? undefined,
color: local.color,
isArchived: local.isArchived,
isBillable: local.isBillable,
billingRate: local.billingRate ?? undefined,
budget: local.budget ?? undefined,
visibility: local.visibility,
guildId: local.guildId ?? undefined,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toTimeEntry(local: LocalTimeEntry): TimeEntry {
return {
id: local.id,
projectId: local.projectId ?? undefined,
clientId: local.clientId ?? undefined,
description: local.description,
date: local.date,
startTime: local.startTime ?? undefined,
endTime: local.endTime ?? undefined,
duration: local.duration,
isBillable: local.isBillable,
isRunning: local.isRunning,
tags: local.tags,
billingRate: local.billingRate ?? undefined,
visibility: local.visibility,
guildId: local.guildId ?? undefined,
source: local.source ?? undefined,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toTag(local: LocalTag): Tag {
return {
id: local.id,
name: local.name,
color: local.color,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toTemplate(local: LocalTemplate): EntryTemplate {
return {
id: local.id,
name: local.name,
projectId: local.projectId ?? undefined,
clientId: local.clientId ?? undefined,
description: local.description,
isBillable: local.isBillable,
tags: local.tags,
usageCount: local.usageCount,
lastUsedAt: local.lastUsedAt ?? undefined,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toSettings(local: LocalSettings): TimesSettings {
return {
id: local.id,
defaultBillingRate: local.defaultBillingRate ?? undefined,
workingHoursPerDay: local.workingHoursPerDay,
workingDaysPerWeek: local.workingDaysPerWeek,
roundingIncrement: local.roundingIncrement,
roundingMethod: local.roundingMethod,
defaultVisibility: local.defaultVisibility,
weekStartsOn: local.weekStartsOn,
timerReminderMinutes: local.timerReminderMinutes,
autoStopTimerHours: local.autoStopTimerHours,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks ──────────────────────────────────────
export function useAllClients() {
return useLiveQueryWithDefault(async () => {
const locals = await clientCollection.getAll();
return locals.map(toClient);
}, [] as Client[]);
}
export function useAllProjects() {
return useLiveQueryWithDefault(async () => {
const locals = await projectCollection.getAll();
return locals.map(toProject);
}, [] as Project[]);
}
export function useAllTimeEntries() {
return useLiveQueryWithDefault(async () => {
const locals = await timeEntryCollection.getAll();
return locals.map(toTimeEntry);
}, [] as TimeEntry[]);
}
export function useAllTags() {
return useLiveQueryWithDefault(async () => {
const locals = await tagCollection.getAll();
return locals.map(toTag);
}, [] as Tag[]);
}
export function useAllTemplates() {
return useLiveQueryWithDefault(async () => {
const locals = await templateCollection.getAll();
return locals.map(toTemplate);
}, [] as EntryTemplate[]);
}
export function useSettings() {
return useLiveQueryWithDefault(
async () => {
const locals = await settingsCollection.getAll();
return locals.length > 0 ? toSettings(locals[0]) : null;
},
null as TimesSettings | null
);
}
// ─── Pure Helpers ──────────────────────────────────────────
/** Format duration in seconds to HH:MM:SS */
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
/** Format duration in seconds to compact form (e.g., "2h 30m") */
export function formatDurationCompact(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h === 0) return `${m}m`;
if (m === 0) return `${h}h`;
return `${h}h ${m}m`;
}
/** Format duration in seconds to decimal hours (e.g., "2.50") */
export function formatDurationDecimal(seconds: number): string {
return (seconds / 3600).toFixed(2);
}
/** Get entries for a specific date */
export function getEntriesByDate(entries: TimeEntry[], date: string): TimeEntry[] {
return entries
.filter((e) => e.date === date)
.sort((a, b) => {
if (a.startTime && b.startTime) return a.startTime.localeCompare(b.startTime);
return 0;
});
}
/** Get entries for a date range */
export function getEntriesByDateRange(entries: TimeEntry[], from: string, to: string): TimeEntry[] {
return entries.filter((e) => e.date >= from && e.date <= to);
}
/** Get total duration for a list of entries */
export function getTotalDuration(entries: TimeEntry[]): number {
return entries.reduce((sum, e) => sum + e.duration, 0);
}
/** Get billable duration for a list of entries */
export function getBillableDuration(entries: TimeEntry[]): number {
return entries.filter((e) => e.isBillable).reduce((sum, e) => sum + e.duration, 0);
}
/** Group entries by date */
export function groupEntriesByDate(entries: TimeEntry[]): Map<string, TimeEntry[]> {
const groups = new Map<string, TimeEntry[]>();
for (const entry of entries) {
const existing = groups.get(entry.date) || [];
existing.push(entry);
groups.set(entry.date, existing);
}
return groups;
}
/** Group entries by project */
export function groupEntriesByProject(entries: TimeEntry[]): Map<string, TimeEntry[]> {
const groups = new Map<string, TimeEntry[]>();
for (const entry of entries) {
const key = entry.projectId || 'no-project';
const existing = groups.get(key) || [];
existing.push(entry);
groups.set(key, existing);
}
return groups;
}
/** Filter entries by criteria */
export function getFilteredEntries(entries: TimeEntry[], filters: FilterCriteria): TimeEntry[] {
let result = entries;
if (filters.projectId) {
result = result.filter((e) => e.projectId === filters.projectId);
}
if (filters.clientId) {
result = result.filter((e) => e.clientId === filters.clientId);
}
if (filters.isBillable !== undefined) {
result = result.filter((e) => e.isBillable === filters.isBillable);
}
if (filters.tagIds?.length) {
result = result.filter((e) => filters.tagIds!.some((t) => e.tags.includes(t)));
}
if (filters.dateFrom) {
result = result.filter((e) => e.date >= filters.dateFrom!);
}
if (filters.dateTo) {
result = result.filter((e) => e.date <= filters.dateTo!);
}
if (filters.search) {
const q = filters.search.toLowerCase();
result = result.filter((e) => e.description.toLowerCase().includes(q));
}
return result;
}
/** Sort entries */
export function getSortedEntries(entries: TimeEntry[], sort: SortOption): TimeEntry[] {
return [...entries].sort((a, b) => {
let cmp = 0;
switch (sort.field) {
case 'date':
cmp = a.date.localeCompare(b.date);
if (cmp === 0 && a.startTime && b.startTime) {
cmp = a.startTime.localeCompare(b.startTime);
}
break;
case 'duration':
cmp = a.duration - b.duration;
break;
case 'project':
cmp = (a.projectId || '').localeCompare(b.projectId || '');
break;
case 'client':
cmp = (a.clientId || '').localeCompare(b.clientId || '');
break;
case 'createdAt':
cmp = (a.createdAt || '').localeCompare(b.createdAt || '');
break;
}
return sort.direction === 'desc' ? -cmp : cmp;
});
}
/** Get active projects (not archived) */
export function getActiveProjects(projects: Project[]): Project[] {
return projects.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order);
}
/** Get active clients (not archived) */
export function getActiveClients(clients: Client[]): Client[] {
return clients.filter((c) => !c.isArchived).sort((a, b) => a.order - b.order);
}
/** Get project by ID */
export function getProjectById(projects: Project[], id: string): Project | undefined {
return projects.find((p) => p.id === id);
}
/** Get client by ID */
export function getClientById(clients: Client[], id: string): Client | undefined {
return clients.find((c) => c.id === id);
}
/** Get projects for a client */
export function getProjectsByClient(projects: Project[], clientId: string): Project[] {
return projects.filter((p) => p.clientId === clientId);
}

View file

@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import type {
Client,
Project,
TimeEntry,
Tag,
EntryTemplate,
TimesSettings,
BillingRate,
FilterCriteria,
SortOption,
} from '@times/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,38 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
export const supportedLocales = ['de', 'en'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
const defaultLocale = 'de';
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
function getInitialLocale(): SupportedLocale {
if (browser) {
const stored = localStorage.getItem('times_locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
const browserLang = navigator.language.split('-')[0];
if (supportedLocales.includes(browserLang as SupportedLocale)) {
return browserLang as SupportedLocale;
}
}
return defaultLocale;
}
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
});
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('times_locale', newLocale);
}
}
export { waitLocale };

View file

@ -0,0 +1,138 @@
{
"app": {
"name": "Times",
"loading": "Laden...",
"tagline": "Dein Arbeitsrhythmus, messbar gemacht."
},
"nav": {
"timer": "Timer",
"entries": "Einträge",
"projects": "Projekte",
"clients": "Kunden",
"reports": "Reports",
"settings": "Einstellungen",
"templates": "Vorlagen"
},
"timer": {
"start": "Timer starten",
"stop": "Timer stoppen",
"resume": "Fortsetzen",
"running": "Läuft",
"noDescription": "Keine Beschreibung",
"whatAreYouWorkingOn": "Woran arbeitest du?"
},
"entry": {
"create": "Eintrag erstellen",
"edit": "Eintrag bearbeiten",
"delete": "Eintrag löschen",
"description": "Beschreibung",
"date": "Datum",
"startTime": "Startzeit",
"endTime": "Endzeit",
"duration": "Dauer",
"billable": "Abrechenbar",
"notBillable": "Nicht abrechenbar",
"noEntries": "Keine Einträge",
"today": "Heute",
"thisWeek": "Diese Woche",
"thisMonth": "Dieser Monat",
"manual": "Manuell erfassen",
"deleteConfirm": "Möchtest du diesen Zeiteintrag wirklich löschen? Dies kann nicht rückgängig gemacht werden."
},
"project": {
"create": "Projekt erstellen",
"edit": "Projekt bearbeiten",
"delete": "Projekt löschen",
"name": "Name",
"description": "Beschreibung",
"client": "Kunde",
"billable": "Abrechenbar",
"budget": "Budget",
"noProjects": "Keine Projekte",
"archived": "Archiviert",
"internal": "Intern",
"deleteConfirm": "Möchtest du dieses Projekt wirklich löschen? Dies kann nicht rückgängig gemacht werden."
},
"client": {
"create": "Kunde erstellen",
"edit": "Kunde bearbeiten",
"delete": "Kunde löschen",
"name": "Name",
"shortCode": "Kürzel",
"email": "E-Mail",
"billingRate": "Stundensatz",
"noClients": "Keine Kunden",
"deleteConfirm": "Möchtest du diesen Kunden wirklich löschen? Dies kann nicht rückgängig gemacht werden."
},
"report": {
"title": "Reports",
"totalHours": "Gesamtstunden",
"billableHours": "Abrechenbare Stunden",
"avgPerDay": "Durchschnitt/Tag",
"topProject": "Top-Projekt",
"byProject": "Nach Projekt",
"byClient": "Nach Kunde",
"byDay": "Nach Tag",
"export": "Exportieren",
"dateRange": "Zeitraum"
},
"template": {
"create": "Vorlage erstellen",
"edit": "Vorlage bearbeiten",
"delete": "Vorlage löschen",
"noTemplates": "Keine Vorlagen",
"useTemplate": "Vorlage verwenden"
},
"settings": {
"title": "Einstellungen",
"workingHours": "Arbeitsstunden/Tag",
"workingDays": "Arbeitstage/Woche",
"rounding": "Rundung",
"roundingMethod": "Rundungsmethode",
"none": "Keine",
"up": "Aufrunden",
"down": "Abrunden",
"nearest": "Nächster Wert",
"currency": "Währung",
"billingRate": "Standard-Stundensatz",
"weekStart": "Woche beginnt am",
"monday": "Montag",
"sunday": "Sonntag",
"timerReminder": "Timer-Erinnerung (Min.)",
"autoStop": "Auto-Stop (Std.)"
},
"auth": {
"login": "Anmelden",
"logout": "Abmelden",
"register": "Registrieren",
"email": "E-Mail",
"password": "Passwort",
"forgotPassword": "Passwort vergessen?"
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"add": "Hinzufügen",
"close": "Schließen",
"search": "Suchen",
"error": "Fehler",
"success": "Erfolgreich",
"loading": "Laden...",
"noResults": "Keine Ergebnisse",
"confirm": "Bestätigen",
"back": "Zurück",
"next": "Weiter",
"create": "Erstellen",
"archive": "Archivieren",
"unarchive": "Wiederherstellen",
"total": "Gesamt",
"hours": "Stunden",
"minutes": "Minuten"
},
"error": {
"notFound": "Seite nicht gefunden",
"backToHome": "Zurück zur Startseite"
}
}

View file

@ -0,0 +1,138 @@
{
"app": {
"name": "Times",
"loading": "Loading...",
"tagline": "Your work rhythm, made measurable."
},
"nav": {
"timer": "Timer",
"entries": "Entries",
"projects": "Projects",
"clients": "Clients",
"reports": "Reports",
"settings": "Settings",
"templates": "Templates"
},
"timer": {
"start": "Start Timer",
"stop": "Stop Timer",
"resume": "Resume",
"running": "Running",
"noDescription": "No description",
"whatAreYouWorkingOn": "What are you working on?"
},
"entry": {
"create": "Create Entry",
"edit": "Edit Entry",
"delete": "Delete Entry",
"description": "Description",
"date": "Date",
"startTime": "Start Time",
"endTime": "End Time",
"duration": "Duration",
"billable": "Billable",
"notBillable": "Not Billable",
"noEntries": "No entries",
"today": "Today",
"thisWeek": "This Week",
"thisMonth": "This Month",
"manual": "Manual Entry",
"deleteConfirm": "Are you sure you want to delete this time entry? This cannot be undone."
},
"project": {
"create": "Create Project",
"edit": "Edit Project",
"delete": "Delete Project",
"name": "Name",
"description": "Description",
"client": "Client",
"billable": "Billable",
"budget": "Budget",
"noProjects": "No projects",
"archived": "Archived",
"internal": "Internal",
"deleteConfirm": "Are you sure you want to delete this project? This cannot be undone."
},
"client": {
"create": "Create Client",
"edit": "Edit Client",
"delete": "Delete Client",
"name": "Name",
"shortCode": "Short Code",
"email": "Email",
"billingRate": "Billing Rate",
"noClients": "No clients",
"deleteConfirm": "Are you sure you want to delete this client? This cannot be undone."
},
"report": {
"title": "Reports",
"totalHours": "Total Hours",
"billableHours": "Billable Hours",
"avgPerDay": "Avg/Day",
"topProject": "Top Project",
"byProject": "By Project",
"byClient": "By Client",
"byDay": "By Day",
"export": "Export",
"dateRange": "Date Range"
},
"template": {
"create": "Create Template",
"edit": "Edit Template",
"delete": "Delete Template",
"noTemplates": "No templates",
"useTemplate": "Use Template"
},
"settings": {
"title": "Settings",
"workingHours": "Working Hours/Day",
"workingDays": "Working Days/Week",
"rounding": "Rounding",
"roundingMethod": "Rounding Method",
"none": "None",
"up": "Round Up",
"down": "Round Down",
"nearest": "Nearest",
"currency": "Currency",
"billingRate": "Default Billing Rate",
"weekStart": "Week Starts On",
"monday": "Monday",
"sunday": "Sunday",
"timerReminder": "Timer Reminder (min)",
"autoStop": "Auto-Stop (hours)"
},
"auth": {
"login": "Login",
"logout": "Logout",
"register": "Register",
"email": "Email",
"password": "Password",
"forgotPassword": "Forgot password?"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"close": "Close",
"search": "Search",
"error": "Error",
"success": "Success",
"loading": "Loading...",
"noResults": "No results",
"confirm": "Confirm",
"back": "Back",
"next": "Next",
"create": "Create",
"archive": "Archive",
"unarchive": "Restore",
"total": "Total",
"hours": "Hours",
"minutes": "Minutes"
},
"error": {
"notFound": "Page not found",
"backToHome": "Back to home"
}
}

View file

@ -0,0 +1,7 @@
/**
* Auth Store uses centralized Mana auth factory.
*/
import { createManaAuthStore } from '@manacore/shared-auth-stores';
export const authStore = createManaAuthStore();

View file

@ -0,0 +1,6 @@
import { createSimpleNavigationStores } from '@manacore/shared-stores';
export const { isNavCollapsed, isToolbarCollapsed } = createSimpleNavigationStores({
withToolbar: true,
toolbarCollapsedDefault: true,
});

View file

@ -0,0 +1,6 @@
import { createThemeStore } from '@manacore/shared-theme';
export const theme = createThemeStore({
appId: 'times',
defaultVariant: 'ocean',
});

View file

@ -0,0 +1,176 @@
/**
* Timer Store manages the active time tracking timer.
*
* The timer state persists in IndexedDB via the timeEntries collection.
* When a timer is running, there's a timeEntry with isRunning=true.
* This store provides reactive access to the running entry and elapsed time.
*/
import { browser } from '$app/environment';
import {
timeEntryCollection,
settingsCollection,
type LocalTimeEntry,
} from '$lib/data/local-store';
import { roundDuration } from '$lib/utils/rounding';
let runningEntry = $state<LocalTimeEntry | null>(null);
let elapsedSeconds = $state(0);
let tickInterval: ReturnType<typeof setInterval> | null = null;
let autoSaveInterval: ReturnType<typeof setInterval> | null = null;
function startTicking() {
stopTicking();
tickInterval = setInterval(() => {
if (runningEntry?.startTime) {
elapsedSeconds = Math.floor((Date.now() - new Date(runningEntry.startTime).getTime()) / 1000);
}
}, 1000);
}
function stopTicking() {
if (tickInterval) {
clearInterval(tickInterval);
tickInterval = null;
}
if (autoSaveInterval) {
clearInterval(autoSaveInterval);
autoSaveInterval = null;
}
}
function startAutoSave() {
if (autoSaveInterval) clearInterval(autoSaveInterval);
autoSaveInterval = setInterval(async () => {
if (runningEntry) {
await timeEntryCollection.update(runningEntry.id, {
duration: elapsedSeconds,
});
}
}, 10000); // Auto-save every 10 seconds
}
export const timerStore = {
get runningEntry() {
return runningEntry;
},
get elapsedSeconds() {
return elapsedSeconds;
},
get isRunning() {
return runningEntry !== null;
},
/** Initialize: check for any running entry in IndexedDB */
async initialize() {
if (!browser) return;
const entries = await timeEntryCollection.getAll();
const running = entries.find((e) => e.isRunning);
if (running) {
runningEntry = running;
if (running.startTime) {
elapsedSeconds = Math.floor((Date.now() - new Date(running.startTime).getTime()) / 1000);
}
startTicking();
startAutoSave();
}
},
/** Start a new timer */
async start(options?: {
projectId?: string;
clientId?: string;
description?: string;
isBillable?: boolean;
tags?: string[];
}) {
// Stop any existing timer first
if (runningEntry) {
await timerStore.stop();
}
const now = new Date();
const entry: LocalTimeEntry = {
id: crypto.randomUUID(),
projectId: options?.projectId ?? null,
clientId: options?.clientId ?? null,
description: options?.description ?? '',
date: now.toISOString().split('T')[0],
startTime: now.toISOString(),
endTime: null,
duration: 0,
isBillable: options?.isBillable ?? false,
isRunning: true,
tags: options?.tags ?? [],
billingRate: null,
visibility: 'private',
guildId: null,
source: { app: 'timer' },
};
await timeEntryCollection.insert(entry);
runningEntry = entry;
elapsedSeconds = 0;
startTicking();
startAutoSave();
},
/** Stop the running timer */
async stop(): Promise<LocalTimeEntry | null> {
if (!runningEntry) return null;
const now = new Date();
const finalDuration = runningEntry.startTime
? Math.floor((now.getTime() - new Date(runningEntry.startTime).getTime()) / 1000)
: elapsedSeconds;
// Apply rounding from settings
const settings = await settingsCollection.getAll();
const s = settings[0];
const roundedDuration = s
? roundDuration(finalDuration, s.roundingIncrement, s.roundingMethod)
: finalDuration;
await timeEntryCollection.update(runningEntry.id, {
isRunning: false,
endTime: now.toISOString(),
duration: roundedDuration,
});
const stoppedEntry = {
...runningEntry,
isRunning: false,
endTime: now.toISOString(),
duration: roundedDuration,
};
stopTicking();
runningEntry = null;
elapsedSeconds = 0;
return stoppedEntry as LocalTimeEntry;
},
/** Discard the running timer without saving */
async discard() {
if (!runningEntry) return;
await timeEntryCollection.delete(runningEntry.id);
stopTicking();
runningEntry = null;
elapsedSeconds = 0;
},
/** Update the running entry's metadata (project, description, etc.) */
async updateRunning(
updates: Partial<
Pick<LocalTimeEntry, 'projectId' | 'clientId' | 'description' | 'isBillable' | 'tags'>
>
) {
if (!runningEntry) return;
await timeEntryCollection.update(runningEntry.id, updates);
runningEntry = { ...runningEntry, ...updates };
},
/** Cleanup on unmount */
destroy() {
stopTicking();
},
};

View file

@ -0,0 +1,18 @@
import { browser } from '$app/environment';
import { createUserSettingsStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
if (injectedUrl) return injectedUrl;
}
return import.meta.env.DEV ? 'http://localhost:3001' : '';
}
export const userSettings = createUserSettingsStore({
appId: 'times',
authUrl: getAuthUrl,
getAccessToken: () => authStore.getAccessToken(),
});

View file

@ -0,0 +1,106 @@
import { browser } from '$app/environment';
import type { ViewMode, SortOption, FilterCriteria, SavedFilter } from '@times/shared';
const VIEW_KEY = 'times_view_mode';
const SORT_KEY = 'times_sort';
const FILTERS_KEY = 'times_saved_filters';
function load<T>(key: string, fallback: T): T {
if (!browser) return fallback;
try {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : fallback;
} catch {
return fallback;
}
}
function save(key: string, value: unknown) {
if (!browser) return;
localStorage.setItem(key, JSON.stringify(value));
}
let viewMode = $state<ViewMode>('week');
let sort = $state<SortOption>({ field: 'date', direction: 'desc' });
let activeFilters = $state<FilterCriteria>({});
let savedFilters = $state<SavedFilter[]>([]);
let initialized = $state(false);
export const viewStore = {
get viewMode() {
return viewMode;
},
get sort() {
return sort;
},
get activeFilters() {
return activeFilters;
},
get savedFilters() {
return savedFilters;
},
get hasActiveFilters() {
return !!(
activeFilters.search ||
activeFilters.projectId ||
activeFilters.clientId ||
activeFilters.tagIds?.length ||
activeFilters.isBillable !== undefined ||
activeFilters.dateFrom ||
activeFilters.dateTo
);
},
initialize() {
if (initialized) return;
viewMode = load<ViewMode>(VIEW_KEY, 'week');
sort = load<SortOption>(SORT_KEY, { field: 'date', direction: 'desc' });
savedFilters = load<SavedFilter[]>(FILTERS_KEY, []);
initialized = true;
},
setViewMode(mode: ViewMode) {
viewMode = mode;
save(VIEW_KEY, mode);
},
setSort(newSort: SortOption) {
sort = newSort;
save(SORT_KEY, newSort);
},
setFilters(filters: FilterCriteria) {
activeFilters = filters;
},
updateFilter<K extends keyof FilterCriteria>(key: K, value: FilterCriteria[K]) {
activeFilters = { ...activeFilters, [key]: value };
},
clearFilters() {
activeFilters = {};
},
saveFilter(name: string) {
const filter: SavedFilter = {
id: crypto.randomUUID(),
name,
criteria: { ...activeFilters },
createdAt: new Date().toISOString(),
};
savedFilters = [...savedFilters, filter];
save(FILTERS_KEY, savedFilters);
},
loadFilter(id: string) {
const filter = savedFilters.find((f) => f.id === id);
if (filter) {
activeFilters = { ...filter.criteria };
}
},
deleteSavedFilter(id: string) {
savedFilters = savedFilters.filter((f) => f.id !== id);
save(FILTERS_KEY, savedFilters);
},
};

View file

@ -0,0 +1,162 @@
import { describe, it, expect } from 'vitest';
import type { TimeEntry, Project, Client } from '@times/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
});
});

View file

@ -0,0 +1,61 @@
/**
* CSV Export utility for time entries
*/
import type { TimeEntry, Project, Client } from '@times/shared';
export function exportEntriesToCSV(
entries: TimeEntry[],
projects: Project[],
clients: Client[]
): void {
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(', ')}"`,
e.startTime
? new Date(e.startTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
: '',
e.endTime
? new Date(e.endTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
: '',
];
});
const csv = [headers.join(';'), ...rows.map((r) => r.join(';'))].join('\n');
const BOM = '\uFEFF'; // UTF-8 BOM for Excel compatibility
const blob = new Blob([BOM + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `times-export-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}

View file

@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import { roundDuration } from './rounding';
describe('roundDuration', () => {
describe('no rounding', () => {
it('returns original when increment is 0', () => {
expect(roundDuration(3661, 0, 'up')).toBe(3661);
});
it('returns original when method is none', () => {
expect(roundDuration(3661, 5, 'none')).toBe(3661);
});
});
describe('round up', () => {
it('rounds 7 min up to 15 min', () => {
expect(roundDuration(7 * 60, 15, 'up')).toBe(15 * 60);
});
it('rounds 16 min up to 30 min', () => {
expect(roundDuration(16 * 60, 15, 'up')).toBe(30 * 60);
});
it('does not round exact values', () => {
expect(roundDuration(15 * 60, 15, 'up')).toBe(15 * 60);
});
it('rounds 1 min up to 5 min', () => {
expect(roundDuration(60, 5, 'up')).toBe(5 * 60);
});
it('rounds 61 min up to 66 min (6 min increment)', () => {
expect(roundDuration(61 * 60, 6, 'up')).toBe(66 * 60);
});
});
describe('round down', () => {
it('rounds 7 min down to 0 min', () => {
expect(roundDuration(7 * 60, 15, 'down')).toBe(0);
});
it('rounds 22 min down to 15 min', () => {
expect(roundDuration(22 * 60, 15, 'down')).toBe(15 * 60);
});
it('does not round exact values', () => {
expect(roundDuration(30 * 60, 15, 'down')).toBe(30 * 60);
});
});
describe('round nearest', () => {
it('rounds 7 min nearest to 10 (with 10 min increment)', () => {
expect(roundDuration(7 * 60, 10, 'nearest')).toBe(10 * 60);
});
it('rounds 3 min nearest to 0 (with 10 min increment)', () => {
expect(roundDuration(3 * 60, 10, 'nearest')).toBe(0);
});
it('rounds 5 min nearest to 10 (midpoint rounds up)', () => {
expect(roundDuration(5 * 60, 10, 'nearest')).toBe(10 * 60);
});
it('rounds 8 min nearest to 10 (with 5 min increment)', () => {
expect(roundDuration(8 * 60, 5, 'nearest')).toBe(10 * 60);
});
it('rounds 2 min nearest to 0 (with 5 min increment)', () => {
expect(roundDuration(2 * 60, 5, 'nearest')).toBe(0);
});
});
describe('edge cases', () => {
it('handles 0 seconds', () => {
expect(roundDuration(0, 15, 'up')).toBe(0);
});
it('handles 1 minute increment', () => {
expect(roundDuration(90, 1, 'up')).toBe(120); // 1.5 min -> 2 min
});
it('handles negative increment as no rounding', () => {
expect(roundDuration(3661, -5, 'up')).toBe(3661);
});
});
});

View file

@ -0,0 +1,36 @@
/**
* Duration rounding utility
*
* Applies rounding based on user settings (increment + method).
*/
import type { RoundingMethod } from '@times/shared';
/**
* Round a duration in seconds based on settings.
* @param seconds - Duration in seconds
* @param increment - Rounding increment in minutes (0 = no rounding)
* @param method - Rounding method: 'none' | 'up' | 'down' | 'nearest'
* @returns Rounded duration in seconds
*/
export function roundDuration(seconds: number, increment: number, method: RoundingMethod): number {
if (increment <= 0 || method === 'none') return seconds;
const incrementSeconds = increment * 60;
const remainder = seconds % incrementSeconds;
if (remainder === 0) return seconds;
switch (method) {
case 'up':
return seconds - remainder + incrementSeconds;
case 'down':
return seconds - remainder;
case 'nearest':
return remainder >= incrementSeconds / 2
? seconds - remainder + incrementSeconds
: seconds - remainder;
default:
return seconds;
}
}

View file

@ -0,0 +1,7 @@
declare const __BUILD_TIME__: string;
declare const __BUILD_HASH__: string;
export const APP_VERSION = '1.0.0';
export const BUILD_TIME: string =
typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : new Date().toISOString();
export const BUILD_HASH: string = typeof __BUILD_HASH__ !== 'undefined' ? __BUILD_HASH__ : 'dev';

View file

@ -0,0 +1,214 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { _ } from 'svelte-i18n';
import { setContext, onDestroy } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { timerStore } from '$lib/stores/timer.svelte';
import TimerIndicator from '$lib/components/TimerIndicator.svelte';
import { theme } from '$lib/stores/theme';
import { setLocale, supportedLocales } from '$lib/i18n';
import { SyncIndicator } from '@manacore/shared-ui';
import { getPillAppItems } from '@manacore/shared-branding';
import { AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { timesStore } from '$lib/data/local-store';
import {
useAllClients,
useAllProjects,
useAllTimeEntries,
useAllTags,
useAllTemplates,
useSettings,
} from '$lib/data/queries';
let { children } = $props();
let showNav = $state(true);
let initialized = $state(false);
let showGuestWelcome = $state(false);
// Live queries
const allClients = useAllClients();
const allProjects = useAllProjects();
const allTimeEntries = useAllTimeEntries();
const allTags = useAllTags();
const allTemplates = useAllTemplates();
const settings = useSettings();
// Provide data to child components
setContext('clients', allClients);
setContext('projects', allProjects);
setContext('timeEntries', allTimeEntries);
setContext('tags', allTags);
setContext('templates', allTemplates);
setContext('settings', settings);
async function handleAuthReady() {
await timesStore.initialize();
if (authStore.isAuthenticated) {
timesStore.startSync(() => authStore.getValidToken());
}
viewStore.initialize();
await timerStore.initialize();
initialized = true;
if (!authStore.isAuthenticated && shouldShowGuestWelcome('times')) {
showGuestWelcome = true;
}
}
const navItems = [
{ href: '/', label: $_('nav.timer'), icon: 'play-circle' },
{ href: '/entries', label: $_('nav.entries'), icon: 'list' },
{ href: '/projects', label: $_('nav.projects'), icon: 'folder' },
{ href: '/clients', label: $_('nav.clients'), icon: 'buildings' },
{ href: '/reports', label: $_('nav.reports'), icon: 'chart-bar' },
{ href: '/templates', label: $_('nav.templates'), icon: 'bookmark' },
{ href: '/settings', label: $_('nav.settings'), icon: 'settings' },
{ href: '/mana', label: 'Mana', icon: 'star' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
onDestroy(() => {
timerStore.destroy();
});
function handleLogout() {
authStore.signOut();
goto('/login');
}
</script>
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
<div class="flex min-h-screen flex-col">
<!-- Top Navigation -->
{#if showNav}
<nav
class="sticky top-0 z-40 border-b border-[hsl(var(--border))] bg-[hsl(var(--background))/0.95] backdrop-blur"
>
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-2">
<!-- Logo -->
<a href="/" class="flex items-center gap-2">
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-[hsl(var(--primary))]"
>
<svg class="h-4 w-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<span class="text-lg font-bold text-[hsl(var(--foreground))]">Times</span>
</a>
<!-- Nav Items -->
<div class="hidden items-center gap-1 md:flex">
{#each navItems.slice(0, 5) as item}
<a
href={item.href}
class="rounded-lg px-3 py-1.5 text-sm transition-colors {$page.url.pathname ===
item.href ||
($page.url.pathname.startsWith(item.href) && item.href !== '/')
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--accent)/0.1)] hover:text-[hsl(var(--foreground))]'}"
>
{item.label}
</a>
{/each}
</div>
<!-- Timer Indicator (visible when timer running) -->
<TimerIndicator />
<!-- Right side -->
<div class="flex items-center gap-2">
<select
class="rounded-lg border border-[hsl(var(--border))] bg-transparent px-2 py-1 text-xs text-[hsl(var(--muted-foreground))]"
onchange={(e) => setLocale((e.target as HTMLSelectElement).value as any)}
>
{#each supportedLocales as loc}
<option value={loc}>{loc.toUpperCase()}</option>
{/each}
</select>
<a
href="/profile"
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</a>
<button
onclick={handleLogout}
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:text-red-500"
title="Abmelden"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</button>
</div>
</div>
<!-- Mobile nav -->
<div class="flex gap-1 overflow-x-auto px-4 pb-2 md:hidden">
{#each navItems.slice(0, 5) as item}
<a
href={item.href}
class="shrink-0 rounded-full px-3 py-1 text-xs transition-colors {$page.url
.pathname === item.href
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'bg-[hsl(var(--muted))] text-[hsl(var(--muted-foreground))]'}"
>
{item.label}
</a>
{/each}
</div>
</nav>
{/if}
<!-- Content -->
<main class="mx-auto w-full max-w-7xl flex-1 px-4 py-6">
{@render children()}
</main>
</div>
<!-- FAB for mobile - Start Timer -->
<button
onclick={() => goto('/?action=start')}
class="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] shadow-lg transition-transform hover:scale-105 md:hidden"
>
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</button>
<GuestWelcomeModal
appId="times"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale="de"
/>
<SyncIndicator />
</AuthGate>

View file

@ -0,0 +1,78 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import type { TimeEntry, Project, Client } from '@times/shared';
import {
getEntriesByDate,
getTotalDuration,
getBillableDuration,
formatDurationCompact,
} from '$lib/data/queries';
import { timerStore } from '$lib/stores/timer.svelte';
import TimerCard from '$lib/components/TimerCard.svelte';
import EntryList from '$lib/components/EntryList.svelte';
import EntryForm from '$lib/components/EntryForm.svelte';
import QuickStart from '$lib/components/QuickStart.svelte';
import KeyboardShortcuts from '$lib/components/KeyboardShortcuts.svelte';
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
const today = new Date().toISOString().split('T')[0];
let todayEntries = $derived(
getEntriesByDate(allTimeEntries.value, today).filter((e) => !e.isRunning)
);
let todayTotal = $derived(getTotalDuration(todayEntries));
let todayBillable = $derived(getBillableDuration(todayEntries));
let showEntryForm = $state(false);
</script>
<svelte:head>
<title>Timer | Times</title>
</svelte:head>
<div class="space-y-6">
<!-- Timer Card -->
<TimerCard />
<!-- Today's Summary -->
<div class="flex gap-4">
<div class="flex-1 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('common.total')}</p>
<p class="duration-display text-2xl font-bold text-[hsl(var(--foreground))]">
{formatDurationCompact(todayTotal)}
</p>
</div>
<div class="flex-1 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('entry.billable')}</p>
<p class="duration-display text-2xl font-bold text-[hsl(var(--primary))]">
{formatDurationCompact(todayBillable)}
</p>
</div>
</div>
<!-- Quick Start -->
<QuickStart />
<!-- Today's Entries -->
<div>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-medium text-[hsl(var(--muted-foreground))]">
{$_('entry.today')} ({formatDurationCompact(todayTotal)})
</h2>
<button
onclick={() => (showEntryForm = true)}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-xs text-[hsl(var(--muted-foreground))] transition-colors hover:border-[hsl(var(--primary)/0.5)] hover:text-[hsl(var(--foreground))]"
>
+ {$_('entry.manual')}
</button>
</div>
<EntryList entries={todayEntries} />
</div>
</div>
<!-- Manual Entry Form -->
<EntryForm visible={showEntryForm} onClose={() => (showEntryForm = false)} />
<KeyboardShortcuts onNewEntry={() => (showEntryForm = true)} />

View file

@ -0,0 +1,363 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { clientCollection } from '$lib/data/local-store';
import { getTotalDuration, formatDurationCompact } from '$lib/data/queries';
import type { Client, Project, TimeEntry } from '@times/shared';
import { PROJECT_COLORS } from '@times/shared/constants';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
const allClients = getContext<{ value: Client[] }>('clients');
const allProjects = getContext<{ value: Project[] }>('projects');
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
let showCreateForm = $state(false);
let editingClientId = $state<string | null>(null);
let showArchived = $state(false);
let deleteConfirmId = $state<string | null>(null);
let newName = $state('');
let newShortCode = $state('');
let newEmail = $state('');
let newColor = $state(PROJECT_COLORS[4]);
let newRate = $state(0);
let activeClients = $derived(
allClients.value.filter((c) => !c.isArchived).sort((a, b) => a.order - b.order)
);
let archivedClients = $derived(allClients.value.filter((c) => c.isArchived));
function getClientProjects(clientId: string): Project[] {
return allProjects.value.filter((p) => p.clientId === clientId && !p.isArchived);
}
function getClientHours(clientId: string): number {
return getTotalDuration(
allTimeEntries.value.filter((e) => e.clientId === clientId && !e.isRunning)
);
}
async function handleCreate() {
if (!newName.trim()) return;
await clientCollection.insert({
id: crypto.randomUUID(),
name: newName.trim(),
shortCode: newShortCode || null,
contactId: null,
email: newEmail || null,
color: newColor,
isArchived: false,
billingRate: newRate > 0 ? { amount: newRate, currency: 'EUR', per: 'hour' } : null,
notes: null,
order: activeClients.length,
});
newName = '';
newShortCode = '';
newEmail = '';
newRate = 0;
showCreateForm = false;
}
async function handleArchive(id: string, archive: boolean) {
await clientCollection.update(id, { isArchived: archive });
}
function handleDelete(id: string) {
deleteConfirmId = id;
}
async function confirmDelete() {
if (!deleteConfirmId) return;
await clientCollection.delete(deleteConfirmId);
editingClientId = null;
deleteConfirmId = null;
}
// Edit state
let editName = $state('');
let editShortCode = $state('');
let editEmail = $state('');
let editColor = $state('');
let editRate = $state(0);
function startEditing(client: Client) {
editingClientId = client.id;
editName = client.name;
editShortCode = client.shortCode ?? '';
editEmail = client.email ?? '';
editColor = client.color;
editRate = client.billingRate?.amount ?? 0;
}
let editDebounce: ReturnType<typeof setTimeout> | null = null;
function autoSave(updates: Record<string, unknown>) {
if (!editingClientId) return;
if (editDebounce) clearTimeout(editDebounce);
const id = editingClientId;
editDebounce = setTimeout(() => {
clientCollection.update(id, updates);
}, 500);
}
</script>
<svelte:head>
<title>{$_('nav.clients')} | Times</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.clients')}</h1>
<button
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
+ {$_('client.create')}
</button>
</div>
{#if showCreateForm}
<form
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3"
>
<div class="flex gap-2">
<input
type="text"
bind:value={newName}
placeholder={$_('client.name')}
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))] focus:border-[hsl(var(--primary))] focus:outline-none"
/>
<input
type="text"
bind:value={newShortCode}
placeholder={$_('client.shortCode')}
class="w-24 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2.5 text-sm text-[hsl(var(--foreground))]"
/>
</div>
<div class="flex gap-2">
<input
type="email"
bind:value={newEmail}
placeholder={$_('client.email')}
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))]"
/>
<div class="flex items-center gap-1">
<input
type="number"
bind:value={newRate}
min="0"
step="5"
placeholder="0"
class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2.5 text-sm text-center text-[hsl(var(--foreground))]"
/>
<span class="text-xs text-[hsl(var(--muted-foreground))]">/h</span>
</div>
</div>
<div class="flex flex-wrap gap-2">
{#each PROJECT_COLORS as color}
<button
type="button"
onclick={() => (newColor = color)}
class="h-6 w-6 rounded-full border-2 transition-transform {newColor === color
? 'scale-125 border-white'
: 'border-transparent'}"
style="background-color: {color}"
></button>
{/each}
</div>
<div class="flex gap-2">
<button
type="button"
onclick={() => (showCreateForm = false)}
class="flex-1 rounded-lg border border-[hsl(var(--border))] py-2 text-sm text-[hsl(var(--muted-foreground))]"
>{$_('common.cancel')}</button
>
<button
type="submit"
class="flex-1 rounded-lg bg-[hsl(var(--primary))] py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>{$_('common.create')}</button
>
</div>
</form>
{/if}
{#if activeClients.length === 0 && !showCreateForm}
<div
class="rounded-xl border border-dashed border-[hsl(var(--border))] p-8 text-center text-[hsl(var(--muted-foreground))]"
>
<p>{$_('client.noClients')}</p>
</div>
{:else}
<div class="space-y-2">
{#each activeClients as client (client.id)}
{@const projects = getClientProjects(client.id)}
{@const hours = getClientHours(client.id)}
<div
class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] overflow-hidden"
>
{#if editingClientId === client.id}
<div class="p-4 space-y-3">
<div class="flex gap-2">
<input
type="text"
value={editName}
oninput={(e) => {
editName = (e.target as HTMLInputElement).value;
autoSave({ name: editName });
}}
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm"
/>
<input
type="text"
value={editShortCode}
oninput={(e) => {
editShortCode = (e.target as HTMLInputElement).value;
autoSave({ shortCode: editShortCode || null });
}}
placeholder={$_('client.shortCode')}
class="w-24 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm"
/>
</div>
<div class="flex gap-2">
<input
type="email"
value={editEmail}
oninput={(e) => {
editEmail = (e.target as HTMLInputElement).value;
autoSave({ email: editEmail || null });
}}
placeholder={$_('client.email')}
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm"
/>
<div class="flex items-center gap-1">
<input
type="number"
value={editRate}
min="0"
step="5"
oninput={(e) => {
editRate = parseInt((e.target as HTMLInputElement).value) || 0;
autoSave({
billingRate:
editRate > 0 ? { amount: editRate, currency: 'EUR', per: 'hour' } : null,
});
}}
class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-center"
/>
<span class="text-xs text-[hsl(var(--muted-foreground))]">/h</span>
</div>
</div>
<div class="flex flex-wrap gap-1.5">
{#each PROJECT_COLORS as color}
<button
type="button"
onclick={() => {
editColor = color;
autoSave({ color });
}}
class="h-5 w-5 rounded-full border-2 {editColor === color
? 'border-white scale-110'
: 'border-transparent'}"
style="background-color: {color}"
></button>
{/each}
</div>
<div class="flex justify-end gap-2">
<button
onclick={() => handleArchive(client.id, true)}
class="text-xs text-[hsl(var(--muted-foreground))]">{$_('common.archive')}</button
>
<button onclick={() => handleDelete(client.id)} class="text-xs text-red-500"
>{$_('common.delete')}</button
>
<button
onclick={() => (editingClientId = null)}
class="text-xs text-[hsl(var(--primary))]">{$_('common.close')}</button
>
</div>
</div>
{:else}
<button
class="flex w-full items-center gap-3 p-4 text-left"
onclick={() => startEditing(client)}
>
<div
class="h-10 w-10 shrink-0 rounded-lg flex items-center justify-center text-white text-sm font-bold"
style="background-color: {client.color}"
>
{client.shortCode || client.name.charAt(0).toUpperCase()}
</div>
<div class="min-w-0 flex-1">
<p class="font-medium text-[hsl(var(--foreground))]">{client.name}</p>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
{projects.length}
{$_('nav.projects')}
{#if client.billingRate}
· {client.billingRate.amount}
{client.billingRate.currency}/{$_('common.hours').toLowerCase().charAt(0)}
{/if}
</p>
</div>
<span class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
{formatDurationCompact(hours)}
</span>
</button>
{/if}
</div>
{/each}
</div>
{/if}
{#if archivedClients.length > 0}
<div>
<button
onclick={() => (showArchived = !showArchived)}
class="flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))]"
>
<svg
class="h-4 w-4 transition-transform {showArchived ? 'rotate-90' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{$_('project.archived')} ({archivedClients.length})
</button>
{#if showArchived}
<div class="mt-3 space-y-2">
{#each archivedClients as client}
<div
class="flex items-center justify-between rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3 opacity-60"
>
<div class="flex items-center gap-2">
<div
class="h-6 w-6 rounded flex items-center justify-center text-white text-xs font-bold"
style="background-color: {client.color}"
>
{client.shortCode || client.name.charAt(0)}
</div>
<span class="text-sm">{client.name}</span>
</div>
<button
onclick={() => handleArchive(client.id, false)}
class="text-xs text-[hsl(var(--primary))]">{$_('common.unarchive')}</button
>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<ConfirmDialog
visible={deleteConfirmId !== null}
title={$_('common.delete')}
message={$_('client.deleteConfirm')}
onConfirm={confirmDelete}
onCancel={() => (deleteConfirmId = null)}
/>

View file

@ -0,0 +1,160 @@
<script lang="ts">
import { page } from '$app/stores';
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { clientCollection } from '$lib/data/local-store';
import {
getTotalDuration,
getBillableDuration,
formatDurationCompact,
formatDurationDecimal,
} from '$lib/data/queries';
import EntryList from '$lib/components/EntryList.svelte';
import type { Project, Client, TimeEntry } from '@times/shared';
const allClients = getContext<{ value: Client[] }>('clients');
const allProjects = getContext<{ value: Project[] }>('projects');
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
let clientId = $derived($page.params.id);
let client = $derived(allClients.value.find((c) => c.id === clientId));
let clientProjects = $derived(
allProjects.value.filter((p) => p.clientId === clientId).sort((a, b) => a.order - b.order)
);
let clientEntries = $derived(
allTimeEntries.value
.filter((e) => e.clientId === clientId && !e.isRunning)
.sort((a, b) => b.date.localeCompare(a.date))
);
let totalDuration = $derived(getTotalDuration(clientEntries));
let billableDuration = $derived(getBillableDuration(clientEntries));
function getProjectHours(projectId: string): number {
return getTotalDuration(clientEntries.filter((e) => e.projectId === projectId));
}
let billingValue = $derived(() => {
if (!client?.billingRate) return null;
return (billableDuration / 3600) * client.billingRate.amount;
});
</script>
<svelte:head>
<title>{client?.name || 'Kunde'} | Times</title>
</svelte:head>
{#if !client}
<div class="flex flex-col items-center justify-center py-20">
<p class="text-[hsl(var(--muted-foreground))]">Kunde nicht gefunden.</p>
<a href="/clients" class="mt-4 text-sm text-[hsl(var(--primary))]">{$_('common.back')}</a>
</div>
{:else}
<div class="space-y-6">
<!-- Back + Header -->
<div>
<a
href="/clients"
class="mb-3 inline-flex items-center gap-1 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
{$_('nav.clients')}
</a>
<div class="flex items-center gap-4">
<div
class="flex h-14 w-14 items-center justify-center rounded-xl text-xl font-bold text-white"
style="background-color: {client.color}"
>
{client.shortCode || client.name.charAt(0).toUpperCase()}
</div>
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{client.name}</h1>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{#if client.shortCode}{client.shortCode} ·
{/if}
{#if client.email}{client.email} ·
{/if}
{#if client.billingRate}
{client.billingRate.amount} {client.billingRate.currency}/h
{/if}
</p>
</div>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.totalHours')}</p>
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--foreground))]">
{formatDurationDecimal(totalDuration)}h
</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.billableHours')}</p>
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--primary))]">
{formatDurationDecimal(billableDuration)}h
</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('nav.projects')}</p>
<p class="mt-1 text-xl font-bold text-[hsl(var(--foreground))]">{clientProjects.length}</p>
</div>
{#if billingValue() !== null}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">Wert</p>
<p class="mt-1 text-xl font-bold text-[hsl(var(--primary))]">
{billingValue()!.toFixed(0)}
{client.billingRate!.currency}
</p>
</div>
{/if}
</div>
<!-- Projects -->
{#if clientProjects.length > 0}
<div>
<h2 class="mb-3 text-sm font-medium text-[hsl(var(--muted-foreground))]">
{$_('nav.projects')}
</h2>
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each clientProjects as proj}
{@const hours = getProjectHours(proj.id)}
<a
href="/projects/{proj.id}"
class="entry-item flex items-center gap-3 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4"
>
<div class="h-3 w-3 rounded-full" style="background-color: {proj.color}"></div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-[hsl(var(--foreground))]">{proj.name}</p>
{#if proj.isBillable}
<span class="text-xs text-[hsl(var(--primary))]">{$_('project.billable')}</span>
{/if}
</div>
<span class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
{formatDurationCompact(hours)}
</span>
</a>
{/each}
</div>
</div>
{/if}
<!-- Entries -->
<div>
<h2 class="mb-3 text-sm font-medium text-[hsl(var(--muted-foreground))]">
{$_('nav.entries')} ({formatDurationCompact(totalDuration)})
</h2>
<EntryList entries={clientEntries} />
</div>
</div>
{/if}

View file

@ -0,0 +1,113 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import type { TimeEntry } from '@times/shared';
import {
getFilteredEntries,
getSortedEntries,
getTotalDuration,
getBillableDuration,
formatDurationCompact,
} from '$lib/data/queries';
import { viewStore } from '$lib/stores/view.svelte';
import EntryList from '$lib/components/EntryList.svelte';
import EntryForm from '$lib/components/EntryForm.svelte';
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
let showEntryForm = $state(false);
let dateFilter = $state<'week' | 'month' | 'all'>('week');
let dateRange = $derived(() => {
const now = new Date();
const today = now.toISOString().split('T')[0];
if (dateFilter === 'week') {
const weekAgo = new Date(now.getTime() - 7 * 86400000);
return { from: weekAgo.toISOString().split('T')[0], to: today };
}
if (dateFilter === 'month') {
const monthAgo = new Date(now.getTime() - 30 * 86400000);
return { from: monthAgo.toISOString().split('T')[0], to: today };
}
return { from: '', to: '' };
});
let filteredEntries = $derived(() => {
const range = dateRange();
let entries = allTimeEntries.value.filter((e) => !e.isRunning);
// Apply date range
if (range.from) {
entries = entries.filter((e) => e.date >= range.from);
}
if (range.to) {
entries = entries.filter((e) => e.date <= range.to);
}
// Apply view filters
entries = getFilteredEntries(entries, viewStore.activeFilters);
return getSortedEntries(entries, viewStore.sort);
});
let totalDuration = $derived(getTotalDuration(filteredEntries()));
let billableDuration = $derived(getBillableDuration(filteredEntries()));
</script>
<svelte:head>
<title>{$_('nav.entries')} | Times</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.entries')}</h1>
<button
onclick={() => (showEntryForm = true)}
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
>
+ {$_('entry.manual')}
</button>
</div>
<!-- Filters -->
<div class="flex items-center gap-2">
{#each ['week', 'month', 'all'] as period}
<button
onclick={() => (dateFilter = period as any)}
class="rounded-lg px-3 py-1.5 text-sm transition-colors {dateFilter === period
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--accent)/0.1)]'}"
>
{period === 'week'
? $_('entry.thisWeek')
: period === 'month'
? $_('entry.thisMonth')
: 'Alle'}
</button>
{/each}
<!-- Totals -->
<div class="ml-auto flex items-center gap-4 text-sm">
<span class="text-[hsl(var(--muted-foreground))]">
{$_('common.total')}:
<span class="duration-display font-medium text-[hsl(var(--foreground))]"
>{formatDurationCompact(totalDuration)}</span
>
</span>
<span class="text-[hsl(var(--muted-foreground))]">
{$_('entry.billable')}:
<span class="duration-display font-medium text-[hsl(var(--primary))]"
>{formatDurationCompact(billableDuration)}</span
>
</span>
</div>
</div>
<!-- Entry List -->
<EntryList entries={filteredEntries()} />
</div>
<!-- Manual Entry Form -->
<EntryForm visible={showEntryForm} onClose={() => (showEntryForm = false)} />

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
</script>
<svelte:head>
<title>Feedback | Times</title>
</svelte:head>
<div>
<h1 class="mb-6 text-2xl font-bold text-[hsl(var(--foreground))]">Feedback</h1>
<p class="text-[hsl(var(--muted-foreground))]">Feedback-Formular kommt bald.</p>
</div>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
</script>
<svelte:head>
<title>Hilfe | Times</title>
</svelte:head>
<div>
<h1 class="mb-6 text-2xl font-bold text-[hsl(var(--foreground))]">Hilfe</h1>
<p class="text-[hsl(var(--muted-foreground))]">Hilfe & Dokumentation.</p>
</div>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
</script>
<svelte:head>
<title>Mana | Times</title>
</svelte:head>
<div>
<h1 class="mb-6 text-2xl font-bold text-[hsl(var(--foreground))]">Mana</h1>
<p class="text-[hsl(var(--muted-foreground))]">Mana Credits & Abo-Verwaltung.</p>
</div>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
</script>
<svelte:head>
<title>Profil | Times</title>
</svelte:head>
<div>
<h1 class="mb-6 text-2xl font-bold text-[hsl(var(--foreground))]">Profil</h1>
<p class="text-[hsl(var(--muted-foreground))]">Profil-Einstellungen.</p>
</div>

View file

@ -0,0 +1,372 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { projectCollection } from '$lib/data/local-store';
import { getTotalDuration, formatDurationCompact } from '$lib/data/queries';
import type { Project, Client, TimeEntry } from '@times/shared';
import { PROJECT_COLORS } from '@times/shared/constants';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
let showCreateForm = $state(false);
let editingProjectId = $state<string | null>(null);
let showArchived = $state(false);
let deleteConfirmId = $state<string | null>(null);
// New project form
let newName = $state('');
let newClientId = $state('');
let newColor = $state(PROJECT_COLORS[0]);
let newIsBillable = $state(true);
let newDescription = $state('');
let activeProjects = $derived(
allProjects.value.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order)
);
let archivedProjects = $derived(
allProjects.value.filter((p) => p.isArchived).sort((a, b) => a.order - b.order)
);
function getProjectHours(projectId: string): number {
const entries = allTimeEntries.value.filter((e) => e.projectId === projectId && !e.isRunning);
return getTotalDuration(entries);
}
function getBudgetPercent(project: Project): number | null {
if (!project.budget || project.budget.type !== 'hours') return null;
const hoursLogged = getProjectHours(project.id) / 3600;
return Math.min(100, Math.round((hoursLogged / project.budget.amount) * 100));
}
async function handleCreate() {
if (!newName.trim()) return;
const client = newClientId ? allClients.value.find((c) => c.id === newClientId) : null;
await projectCollection.insert({
id: crypto.randomUUID(),
clientId: newClientId || null,
name: newName.trim(),
description: newDescription || null,
color: newColor,
isArchived: false,
isBillable: newIsBillable,
billingRate: client?.billingRate ?? null,
budget: null,
visibility: 'private',
guildId: null,
order: activeProjects.length,
});
newName = '';
newClientId = '';
newDescription = '';
newColor = PROJECT_COLORS[0];
newIsBillable = true;
showCreateForm = false;
}
async function handleArchive(id: string, archive: boolean) {
await projectCollection.update(id, { isArchived: archive });
}
function handleDelete(id: string) {
deleteConfirmId = id;
}
async function confirmDelete() {
if (!deleteConfirmId) return;
await projectCollection.delete(deleteConfirmId);
editingProjectId = null;
deleteConfirmId = null;
}
// Inline edit state
let editName = $state('');
let editClientId = $state('');
let editColor = $state('');
let editIsBillable = $state(false);
let editDescription = $state('');
function startEditing(project: Project) {
editingProjectId = project.id;
editName = project.name;
editClientId = project.clientId ?? '';
editColor = project.color;
editIsBillable = project.isBillable;
editDescription = project.description ?? '';
}
let editDebounce: ReturnType<typeof setTimeout> | null = null;
function autoSaveProject(updates: Record<string, unknown>) {
if (!editingProjectId) return;
if (editDebounce) clearTimeout(editDebounce);
const id = editingProjectId;
editDebounce = setTimeout(() => {
projectCollection.update(id, updates);
}, 500);
}
</script>
<svelte:head>
<title>{$_('nav.projects')} | Times</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.projects')}</h1>
<button
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
+ {$_('project.create')}
</button>
</div>
<!-- Create Form -->
{#if showCreateForm}
<form
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3"
>
<input
type="text"
bind:value={newName}
placeholder={$_('project.name')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))] focus:border-[hsl(var(--primary))] focus:outline-none"
/>
<div class="flex gap-2">
<select
bind:value={newClientId}
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
>
<option value="">{$_('project.internal')}</option>
{#each allClients.value.filter((c) => !c.isArchived) as client}
<option value={client.id}>{client.name}</option>
{/each}
</select>
<label
class="flex items-center gap-2 rounded-lg border border-[hsl(var(--border))] px-3 py-2 text-sm"
>
<input
type="checkbox"
bind:checked={newIsBillable}
class="accent-[hsl(var(--primary))]"
/>
{$_('project.billable')}
</label>
</div>
<!-- Color Picker -->
<div class="flex flex-wrap gap-2">
{#each PROJECT_COLORS as color}
<button
type="button"
onclick={() => (newColor = color)}
class="h-6 w-6 rounded-full border-2 transition-transform {newColor === color
? 'scale-125 border-white'
: 'border-transparent'}"
style="background-color: {color}"
></button>
{/each}
</div>
<div class="flex gap-2">
<button
type="button"
onclick={() => (showCreateForm = false)}
class="flex-1 rounded-lg border border-[hsl(var(--border))] py-2 text-sm text-[hsl(var(--muted-foreground))]"
>{$_('common.cancel')}</button
>
<button
type="submit"
class="flex-1 rounded-lg bg-[hsl(var(--primary))] py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>{$_('common.create')}</button
>
</div>
</form>
{/if}
<!-- Active Projects -->
{#if activeProjects.length === 0 && !showCreateForm}
<div
class="rounded-xl border border-dashed border-[hsl(var(--border))] p-8 text-center text-[hsl(var(--muted-foreground))]"
>
<p>{$_('project.noProjects')}</p>
</div>
{:else}
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each activeProjects as project (project.id)}
{@const client = project.clientId
? allClients.value.find((c) => c.id === project.clientId)
: undefined}
{@const hours = getProjectHours(project.id)}
{@const budgetPct = getBudgetPercent(project)}
<div
class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] overflow-hidden"
>
<!-- Color bar -->
<div class="h-1" style="background-color: {project.color}"></div>
{#if editingProjectId === project.id}
<!-- Editing -->
<div class="p-4 space-y-3">
<input
type="text"
value={editName}
oninput={(e) => {
editName = (e.target as HTMLInputElement).value;
autoSaveProject({ name: editName });
}}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none"
/>
<select
value={editClientId}
onchange={(e) => {
editClientId = (e.target as HTMLSelectElement).value;
autoSaveProject({ clientId: editClientId || null });
}}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm"
>
<option value="">{$_('project.internal')}</option>
{#each allClients.value.filter((c) => !c.isArchived) as c}
<option value={c.id}>{c.name}</option>
{/each}
</select>
<div class="flex flex-wrap gap-1.5">
{#each PROJECT_COLORS as color}
<button
type="button"
onclick={() => {
editColor = color;
autoSaveProject({ color });
}}
class="h-5 w-5 rounded-full border-2 {editColor === color
? 'border-white scale-110'
: 'border-transparent'}"
style="background-color: {color}"
></button>
{/each}
</div>
<div class="flex items-center justify-between">
<label class="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={editIsBillable}
onchange={() => {
editIsBillable = !editIsBillable;
autoSaveProject({ isBillable: editIsBillable });
}}
class="accent-[hsl(var(--primary))]"
/>
{$_('project.billable')}
</label>
<div class="flex gap-2">
<button
onclick={() => handleArchive(project.id, true)}
class="text-xs text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>{$_('common.archive')}</button
>
<button onclick={() => handleDelete(project.id)} class="text-xs text-red-500"
>{$_('common.delete')}</button
>
<button
onclick={() => (editingProjectId = null)}
class="text-xs text-[hsl(var(--primary))]">{$_('common.close')}</button
>
</div>
</div>
</div>
{:else}
<!-- Display -->
<button class="w-full p-4 text-left" onclick={() => startEditing(project)}>
<div class="flex items-start justify-between">
<div>
<p class="font-medium text-[hsl(var(--foreground))]">{project.name}</p>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
{client?.name || $_('project.internal')}
{#if project.isBillable}
· {$_('project.billable')}
{/if}
</p>
</div>
<span class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
{formatDurationCompact(hours)}
</span>
</div>
{#if budgetPct !== null}
<div class="mt-3">
<div
class="flex items-center justify-between text-xs text-[hsl(var(--muted-foreground))]"
>
<span>{$_('project.budget')}</span>
<span>{budgetPct}%</span>
</div>
<div class="mt-1 h-1.5 rounded-full bg-[hsl(var(--muted))]">
<div
class="h-full rounded-full transition-all {budgetPct > 90
? 'bg-red-500'
: budgetPct > 75
? 'bg-amber-500'
: 'bg-[hsl(var(--primary))]'}"
style="width: {budgetPct}%"
></div>
</div>
</div>
{/if}
</button>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Archived Projects -->
{#if archivedProjects.length > 0}
<div>
<button
onclick={() => (showArchived = !showArchived)}
class="flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))]"
>
<svg
class="h-4 w-4 transition-transform {showArchived ? 'rotate-90' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{$_('project.archived')} ({archivedProjects.length})
</button>
{#if showArchived}
<div class="mt-3 space-y-2">
{#each archivedProjects as project}
<div
class="flex items-center justify-between rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3 opacity-60"
>
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded-full" style="background-color: {project.color}"></div>
<span class="text-sm text-[hsl(var(--foreground))]">{project.name}</span>
</div>
<button
onclick={() => handleArchive(project.id, false)}
class="text-xs text-[hsl(var(--primary))]">{$_('common.unarchive')}</button
>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<ConfirmDialog
visible={deleteConfirmId !== null}
title={$_('common.delete')}
message={$_('project.deleteConfirm')}
onConfirm={confirmDelete}
onCancel={() => (deleteConfirmId = null)}
/>

View file

@ -0,0 +1,330 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { projectCollection } from '$lib/data/local-store';
import {
getTotalDuration,
getBillableDuration,
formatDurationCompact,
formatDurationDecimal,
} from '$lib/data/queries';
import EntryList from '$lib/components/EntryList.svelte';
import type { Project, Client, TimeEntry } from '@times/shared';
import { PROJECT_COLORS } from '@times/shared/constants';
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
let projectId = $derived($page.params.id);
let project = $derived(allProjects.value.find((p) => p.id === projectId));
let client = $derived(
project?.clientId ? allClients.value.find((c) => c.id === project!.clientId) : undefined
);
let projectEntries = $derived(
allTimeEntries.value
.filter((e) => e.projectId === projectId && !e.isRunning)
.sort((a, b) => b.date.localeCompare(a.date))
);
let totalDuration = $derived(getTotalDuration(projectEntries));
let billableDuration = $derived(getBillableDuration(projectEntries));
let budgetPercent = $derived(() => {
if (!project?.budget || project.budget.type !== 'hours') return null;
const hoursLogged = totalDuration / 3600;
return Math.min(100, Math.round((hoursLogged / project.budget.amount) * 100));
});
let budgetHoursUsed = $derived(totalDuration / 3600);
let budgetHoursTotal = $derived(project?.budget?.type === 'hours' ? project.budget.amount : null);
// Edit state
let isEditing = $state(false);
let editName = $state('');
let editDescription = $state('');
let editClientId = $state('');
let editColor = $state('');
let editIsBillable = $state(false);
let editBudgetHours = $state(0);
let editRateAmount = $state(0);
function startEditing() {
if (!project) return;
isEditing = true;
editName = project.name;
editDescription = project.description ?? '';
editClientId = project.clientId ?? '';
editColor = project.color;
editIsBillable = project.isBillable;
editBudgetHours = project.budget?.type === 'hours' ? project.budget.amount : 0;
editRateAmount = project.billingRate?.amount ?? 0;
}
let debounce: ReturnType<typeof setTimeout> | null = null;
function save(updates: Record<string, unknown>) {
const id = projectId;
if (!id) return;
if (debounce) clearTimeout(debounce);
debounce = setTimeout(() => {
projectCollection.update(id, updates);
}, 500);
}
async function handleArchive() {
const id = projectId;
if (!project || !id) return;
await projectCollection.update(id, { isArchived: !project.isArchived });
}
</script>
<svelte:head>
<title>{project?.name || 'Projekt'} | Times</title>
</svelte:head>
{#if !project}
<div class="flex flex-col items-center justify-center py-20">
<p class="text-[hsl(var(--muted-foreground))]">Projekt nicht gefunden.</p>
<a href="/projects" class="mt-4 text-sm text-[hsl(var(--primary))]">{$_('common.back')}</a>
</div>
{:else}
<div class="space-y-6">
<!-- Back + Header -->
<div>
<a
href="/projects"
class="mb-3 inline-flex items-center gap-1 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
{$_('nav.projects')}
</a>
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="h-4 w-4 rounded-full" style="background-color: {project.color}"></div>
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{project.name}</h1>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{client?.name || $_('project.internal')}
{#if project.isBillable}
<span
class="ml-2 rounded bg-[hsl(var(--primary)/0.1)] px-1.5 py-0.5 text-xs text-[hsl(var(--primary))]"
>
{$_('project.billable')}
</span>
{/if}
{#if project.isArchived}
<span
class="ml-2 rounded bg-[hsl(var(--muted))] px-1.5 py-0.5 text-xs text-[hsl(var(--muted-foreground))]"
>
{$_('project.archived')}
</span>
{/if}
</p>
</div>
</div>
<div class="flex gap-2">
<button
onclick={() => (isEditing ? (isEditing = false) : startEditing())}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
{isEditing ? $_('common.close') : $_('common.edit')}
</button>
<button
onclick={handleArchive}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--muted-foreground))]"
>
{project.isArchived ? $_('common.unarchive') : $_('common.archive')}
</button>
</div>
</div>
</div>
<!-- Edit Form -->
{#if isEditing}
<div
class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3"
>
<input
type="text"
value={editName}
oninput={(e) => {
editName = (e.target as HTMLInputElement).value;
save({ name: editName });
}}
placeholder={$_('project.name')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none"
/>
<input
type="text"
value={editDescription}
oninput={(e) => {
editDescription = (e.target as HTMLInputElement).value;
save({ description: editDescription || null });
}}
placeholder={$_('project.description')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
/>
<div class="flex gap-2">
<select
value={editClientId}
onchange={(e) => {
editClientId = (e.target as HTMLSelectElement).value;
save({ clientId: editClientId || null });
}}
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm"
>
<option value="">{$_('project.internal')}</option>
{#each allClients.value.filter((c) => !c.isArchived) as c}
<option value={c.id}>{c.name}</option>
{/each}
</select>
<label
class="flex items-center gap-2 rounded-lg border border-[hsl(var(--border))] px-3 py-2 text-sm"
>
<input
type="checkbox"
checked={editIsBillable}
onchange={() => {
editIsBillable = !editIsBillable;
save({ isBillable: editIsBillable });
}}
class="accent-[hsl(var(--primary))]"
/>
{$_('project.billable')}
</label>
</div>
<div class="flex gap-2">
<div class="flex items-center gap-1">
<label class="text-xs text-[hsl(var(--muted-foreground))]"
>{$_('project.budget')} (h):</label
>
<input
type="number"
value={editBudgetHours}
min="0"
oninput={(e) => {
editBudgetHours = parseInt((e.target as HTMLInputElement).value) || 0;
save({
budget: editBudgetHours > 0 ? { type: 'hours', amount: editBudgetHours } : null,
});
}}
class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm"
/>
</div>
<div class="flex items-center gap-1">
<label class="text-xs text-[hsl(var(--muted-foreground))]">Rate:</label>
<input
type="number"
value={editRateAmount}
min="0"
step="5"
oninput={(e) => {
editRateAmount = parseInt((e.target as HTMLInputElement).value) || 0;
save({
billingRate:
editRateAmount > 0
? { amount: editRateAmount, currency: 'EUR', per: 'hour' }
: null,
});
}}
class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm"
/>
<span class="text-xs text-[hsl(var(--muted-foreground))]">/h</span>
</div>
</div>
<div class="flex flex-wrap gap-1.5">
{#each PROJECT_COLORS as color}
<button
type="button"
onclick={() => {
editColor = color;
save({ color });
}}
class="h-5 w-5 rounded-full border-2 {editColor === color
? 'border-white scale-110'
: 'border-transparent'}"
style="background-color: {color}"
></button>
{/each}
</div>
</div>
{/if}
<!-- Stats -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.totalHours')}</p>
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--foreground))]">
{formatDurationDecimal(totalDuration)}h
</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.billableHours')}</p>
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--primary))]">
{formatDurationDecimal(billableDuration)}h
</p>
</div>
{#if budgetHoursTotal}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('project.budget')}</p>
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--foreground))]">
{budgetHoursUsed.toFixed(1)} / {budgetHoursTotal}h
</p>
</div>
{/if}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('nav.entries')}</p>
<p class="mt-1 text-xl font-bold text-[hsl(var(--foreground))]">{projectEntries.length}</p>
</div>
</div>
<!-- Budget Progress -->
{#if budgetPercent() !== null}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<div class="flex items-center justify-between text-sm">
<span class="text-[hsl(var(--muted-foreground))]">{$_('project.budget')}</span>
<span class="font-medium text-[hsl(var(--foreground))]">{budgetPercent()}%</span>
</div>
<div class="mt-2 h-2.5 rounded-full bg-[hsl(var(--muted))]">
<div
class="h-full rounded-full transition-all {budgetPercent()! > 90
? 'bg-red-500'
: budgetPercent()! > 75
? 'bg-amber-500'
: 'bg-[hsl(var(--primary))]'}"
style="width: {budgetPercent()}%"
></div>
</div>
{#if project.billingRate}
<p class="mt-2 text-xs text-[hsl(var(--muted-foreground))]">
{project.billingRate.amount}
{project.billingRate.currency}/h · Wert: {(
(billableDuration / 3600) *
project.billingRate.amount
).toFixed(0)}
{project.billingRate.currency}
</p>
{/if}
</div>
{/if}
<!-- Entries -->
<div>
<h2 class="mb-3 text-sm font-medium text-[hsl(var(--muted-foreground))]">
{$_('nav.entries')} ({formatDurationCompact(totalDuration)})
</h2>
<EntryList entries={projectEntries} />
</div>
</div>
{/if}

View file

@ -0,0 +1,215 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import type { TimeEntry, Project, Client } from '@times/shared';
import { exportEntriesToCSV } from '$lib/utils/export';
import {
getTotalDuration,
getBillableDuration,
formatDurationCompact,
formatDurationDecimal,
groupEntriesByProject,
getEntriesByDateRange,
} from '$lib/data/queries';
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');
let period = $state<'week' | 'month'>('week');
let dateRange = $derived(() => {
const now = new Date();
const today = now.toISOString().split('T')[0];
const daysBack = period === 'week' ? 7 : 30;
const from = new Date(now.getTime() - daysBack * 86400000).toISOString().split('T')[0];
return { from, to: today, days: daysBack };
});
let entries = $derived(() => {
const range = dateRange();
return allTimeEntries.value.filter(
(e) => !e.isRunning && e.date >= range.from && e.date <= range.to
);
});
let totalDuration = $derived(getTotalDuration(entries()));
let billableDuration = $derived(getBillableDuration(entries()));
let nonBillableDuration = $derived(totalDuration - billableDuration);
let entryCount = $derived(entries().length);
let avgPerDay = $derived(dateRange().days > 0 ? totalDuration / dateRange().days : 0);
// Hours by project
let projectBreakdown = $derived(() => {
const groups = groupEntriesByProject(entries());
const result: { projectId: string; name: string; color: string; duration: number }[] = [];
for (const [projectId, projectEntries] of groups) {
const project = allProjects.value.find((p) => p.id === projectId);
result.push({
projectId,
name: project?.name || $_('project.internal'),
color: project?.color || '#9ca3af',
duration: getTotalDuration(projectEntries),
});
}
return result.sort((a, b) => b.duration - a.duration);
});
let maxProjectDuration = $derived(
Math.max(...(projectBreakdown().map((p) => p.duration) || [1]))
);
// Hours by day (last 7 days for week, grouped by week for month)
let dailyBreakdown = $derived(() => {
const days: { date: string; label: string; duration: number }[] = [];
const range = dateRange();
const numDays = period === 'week' ? 7 : 30;
for (let i = numDays - 1; i >= 0; i--) {
const d = new Date(Date.now() - i * 86400000);
const dateStr = d.toISOString().split('T')[0];
const dayEntries = entries().filter((e) => e.date === dateStr);
days.push({
date: dateStr,
label: d.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric' }),
duration: getTotalDuration(dayEntries),
});
}
return days;
});
let maxDailyDuration = $derived(Math.max(...(dailyBreakdown().map((d) => d.duration) || [1])));
</script>
<svelte:head>
<title>{$_('nav.reports')} | Times</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.reports')}</h1>
<div class="flex items-center gap-2">
<div class="flex gap-1">
{#each ['week', 'month'] as p}
<button
onclick={() => (period = p as any)}
class="rounded-lg px-3 py-1.5 text-sm transition-colors {period === p
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--accent)/0.1)]'}"
>
{p === 'week' ? $_('entry.thisWeek') : $_('entry.thisMonth')}
</button>
{/each}
</div>
<button
onclick={() => exportEntriesToCSV(entries(), allProjects.value, allClients.value)}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
CSV Export
</button>
</div>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.totalHours')}</p>
<p class="duration-display mt-1 text-2xl font-bold text-[hsl(var(--foreground))]">
{formatDurationDecimal(totalDuration)}h
</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.billableHours')}</p>
<p class="duration-display mt-1 text-2xl font-bold text-[hsl(var(--primary))]">
{formatDurationDecimal(billableDuration)}h
</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.avgPerDay')}</p>
<p class="duration-display mt-1 text-2xl font-bold text-[hsl(var(--foreground))]">
{formatDurationCompact(Math.round(avgPerDay))}
</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('nav.entries')}</p>
<p class="mt-1 text-2xl font-bold text-[hsl(var(--foreground))]">{entryCount}</p>
</div>
</div>
<!-- Billable Breakdown -->
{#if totalDuration > 0}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<h3 class="mb-3 text-sm font-medium text-[hsl(var(--foreground))]">
{$_('entry.billable')} vs. {$_('entry.notBillable')}
</h3>
<div class="flex h-4 overflow-hidden rounded-full">
<div
class="bg-[hsl(var(--primary))] transition-all"
style="width: {(billableDuration / totalDuration) * 100}%"
></div>
<div
class="bg-[hsl(var(--muted))] transition-all"
style="width: {(nonBillableDuration / totalDuration) * 100}%"
></div>
</div>
<div class="mt-2 flex justify-between text-xs text-[hsl(var(--muted-foreground))]">
<span>{$_('entry.billable')}: {formatDurationCompact(billableDuration)}</span>
<span>{$_('entry.notBillable')}: {formatDurationCompact(nonBillableDuration)}</span>
</div>
</div>
{/if}
<!-- Hours by Project -->
{#if projectBreakdown().length > 0}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<h3 class="mb-3 text-sm font-medium text-[hsl(var(--foreground))]">
{$_('report.byProject')}
</h3>
<div class="space-y-3">
{#each projectBreakdown() as item}
<div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded-full" style="background-color: {item.color}"></div>
<span class="text-[hsl(var(--foreground))]">{item.name}</span>
</div>
<span class="duration-display text-[hsl(var(--muted-foreground))]">
{formatDurationCompact(item.duration)}
</span>
</div>
<div class="mt-1 h-2 rounded-full bg-[hsl(var(--muted))]">
<div
class="h-full rounded-full transition-all"
style="width: {(item.duration / maxProjectDuration) *
100}%; background-color: {item.color}"
></div>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Daily Hours -->
{#if period === 'week' && dailyBreakdown().length > 0}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<h3 class="mb-3 text-sm font-medium text-[hsl(var(--foreground))]">{$_('report.byDay')}</h3>
<div class="flex items-end gap-2" style="height: 120px;">
{#each dailyBreakdown() as day}
<div class="flex flex-1 flex-col items-center gap-1">
<div class="w-full flex flex-col justify-end" style="height: 100px;">
<div
class="w-full rounded-t bg-[hsl(var(--primary))] transition-all"
style="height: {maxDailyDuration > 0
? (day.duration / maxDailyDuration) * 100
: 0}%"
title={formatDurationCompact(day.duration)}
></div>
</div>
<span class="text-[10px] text-[hsl(var(--muted-foreground))]">{day.label}</span>
</div>
{/each}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,290 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { settingsCollection } from '$lib/data/local-store';
import type { TimesSettings } from '@times/shared';
import { CURRENCIES, ROUNDING_INCREMENTS } from '@times/shared/constants';
const settings = getContext<{ value: TimesSettings | null }>('settings');
// Local edit state, synced from settings
let workingHoursPerDay = $state(8);
let workingDaysPerWeek = $state(5);
let roundingIncrement = $state(0);
let roundingMethod = $state<'none' | 'up' | 'down' | 'nearest'>('none');
let defaultRate = $state(0);
let defaultCurrency = $state('EUR');
let weekStartsOn = $state<0 | 1>(1);
let timerReminderMinutes = $state(0);
let autoStopTimerHours = $state(0);
let defaultVisibility = $state<'private' | 'guild'>('private');
let initialized = $state(false);
$effect(() => {
const s = settings.value;
if (s && !initialized) {
workingHoursPerDay = s.workingHoursPerDay;
workingDaysPerWeek = s.workingDaysPerWeek;
roundingIncrement = s.roundingIncrement;
roundingMethod = s.roundingMethod;
defaultRate = s.defaultBillingRate?.amount ?? 0;
defaultCurrency = s.defaultBillingRate?.currency ?? 'EUR';
weekStartsOn = s.weekStartsOn;
timerReminderMinutes = s.timerReminderMinutes;
autoStopTimerHours = s.autoStopTimerHours;
defaultVisibility = s.defaultVisibility;
initialized = true;
}
});
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
function save(updates: Record<string, unknown>) {
if (!settings.value) return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
settingsCollection.update(settings.value!.id, updates);
}, 500);
}
</script>
<svelte:head>
<title>{$_('settings.title')} | Times</title>
</svelte:head>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('settings.title')}</h1>
<!-- Working Time -->
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 space-y-4">
<h2 class="text-sm font-semibold text-[hsl(var(--foreground))]">Arbeitszeit</h2>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
>{$_('settings.workingHours')}</label
>
<input
type="number"
value={workingHoursPerDay}
min="1"
max="24"
step="0.5"
oninput={(e) => {
workingHoursPerDay = parseFloat((e.target as HTMLInputElement).value) || 8;
save({ workingHoursPerDay });
}}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
/>
</div>
<div>
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
>{$_('settings.workingDays')}</label
>
<input
type="number"
value={workingDaysPerWeek}
min="1"
max="7"
oninput={(e) => {
workingDaysPerWeek = parseInt((e.target as HTMLInputElement).value) || 5;
save({ workingDaysPerWeek });
}}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
/>
</div>
</div>
<div>
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
>{$_('settings.weekStart')}</label
>
<div class="flex gap-2">
<button
onclick={() => {
weekStartsOn = 1;
save({ weekStartsOn: 1 });
}}
class="rounded-lg px-4 py-2 text-sm transition-colors {weekStartsOn === 1
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'border border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))]'}"
>{$_('settings.monday')}</button
>
<button
onclick={() => {
weekStartsOn = 0;
save({ weekStartsOn: 0 });
}}
class="rounded-lg px-4 py-2 text-sm transition-colors {weekStartsOn === 0
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'border border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))]'}"
>{$_('settings.sunday')}</button
>
</div>
</div>
</div>
<!-- Rounding -->
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 space-y-4">
<h2 class="text-sm font-semibold text-[hsl(var(--foreground))]">{$_('settings.rounding')}</h2>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]">Intervall</label>
<select
value={roundingIncrement}
onchange={(e) => {
roundingIncrement = parseInt((e.target as HTMLSelectElement).value);
save({ roundingIncrement });
}}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
>
{#each ROUNDING_INCREMENTS as inc}
<option value={inc}>{inc === 0 ? $_('settings.none') : `${inc} min`}</option>
{/each}
</select>
</div>
<div>
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
>{$_('settings.roundingMethod')}</label
>
<select
value={roundingMethod}
onchange={(e) => {
roundingMethod = (e.target as HTMLSelectElement).value as any;
save({ roundingMethod });
}}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
>
<option value="none">{$_('settings.none')}</option>
<option value="up">{$_('settings.up')}</option>
<option value="down">{$_('settings.down')}</option>
<option value="nearest">{$_('settings.nearest')}</option>
</select>
</div>
</div>
</div>
<!-- Billing -->
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 space-y-4">
<h2 class="text-sm font-semibold text-[hsl(var(--foreground))]">
{$_('settings.billingRate')}
</h2>
<div class="flex items-end gap-2">
<div class="flex-1">
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
>{$_('settings.billingRate')}</label
>
<input
type="number"
value={defaultRate}
min="0"
step="5"
oninput={(e) => {
defaultRate = parseFloat((e.target as HTMLInputElement).value) || 0;
save({
defaultBillingRate:
defaultRate > 0
? { amount: defaultRate, currency: defaultCurrency, per: 'hour' }
: null,
});
}}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
/>
</div>
<div>
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
>{$_('settings.currency')}</label
>
<select
value={defaultCurrency}
onchange={(e) => {
defaultCurrency = (e.target as HTMLSelectElement).value;
if (defaultRate > 0)
save({
defaultBillingRate: { amount: defaultRate, currency: defaultCurrency, per: 'hour' },
});
}}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
>
{#each CURRENCIES as curr}
<option value={curr.code}>{curr.symbol} {curr.code}</option>
{/each}
</select>
</div>
<span class="pb-2 text-sm text-[hsl(var(--muted-foreground))]">/h</span>
</div>
</div>
<!-- Timer -->
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 space-y-4">
<h2 class="text-sm font-semibold text-[hsl(var(--foreground))]">Timer</h2>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
>{$_('settings.timerReminder')}</label
>
<p class="mb-1.5 text-xs text-[hsl(var(--muted-foreground))]">0 = aus</p>
<input
type="number"
value={timerReminderMinutes}
min="0"
step="5"
oninput={(e) => {
timerReminderMinutes = parseInt((e.target as HTMLInputElement).value) || 0;
save({ timerReminderMinutes });
}}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
/>
</div>
<div>
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
>{$_('settings.autoStop')}</label
>
<p class="mb-1.5 text-xs text-[hsl(var(--muted-foreground))]">0 = aus</p>
<input
type="number"
value={autoStopTimerHours}
min="0"
step="1"
oninput={(e) => {
autoStopTimerHours = parseInt((e.target as HTMLInputElement).value) || 0;
save({ autoStopTimerHours });
}}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
/>
</div>
</div>
</div>
<!-- Visibility -->
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 space-y-4">
<h2 class="text-sm font-semibold text-[hsl(var(--foreground))]">{$_('settings.visibility')}</h2>
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('settings.visibilityDesc')}</p>
<div class="flex gap-2">
<button
onclick={() => {
defaultVisibility = 'private';
save({ defaultVisibility: 'private' });
}}
class="rounded-lg px-4 py-2 text-sm transition-colors {defaultVisibility === 'private'
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'border border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))]'}"
>{$_('settings.private')}</button
>
<button
onclick={() => {
defaultVisibility = 'guild';
save({ defaultVisibility: 'guild' });
}}
class="rounded-lg px-4 py-2 text-sm transition-colors {defaultVisibility === 'guild'
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'border border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))]'}"
>{$_('settings.guild')}</button
>
</div>
</div>
</div>

View file

@ -0,0 +1,197 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { templateCollection, timeEntryCollection } from '$lib/data/local-store';
import { timerStore } from '$lib/stores/timer.svelte';
import type { EntryTemplate, Project, Client } from '@times/shared';
const allTemplates = getContext<{ value: EntryTemplate[] }>('templates');
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');
let showCreateForm = $state(false);
let newName = $state('');
let newDescription = $state('');
let newProjectId = $state('');
let newIsBillable = $state(false);
let sortedTemplates = $derived(
[...allTemplates.value].sort((a, b) => b.usageCount - a.usageCount)
);
async function handleCreate() {
if (!newName.trim()) return;
await templateCollection.insert({
id: crypto.randomUUID(),
name: newName.trim(),
projectId: newProjectId || null,
clientId: newProjectId
? (allProjects.value.find((p) => p.id === newProjectId)?.clientId ?? null)
: null,
description: newDescription,
isBillable: newIsBillable,
tags: [],
usageCount: 0,
lastUsedAt: null,
});
newName = '';
newDescription = '';
newProjectId = '';
newIsBillable = false;
showCreateForm = false;
}
async function useTemplate(template: EntryTemplate) {
// Update usage stats
await templateCollection.update(template.id, {
usageCount: template.usageCount + 1,
lastUsedAt: new Date().toISOString(),
});
// Start timer with template data
await timerStore.start({
projectId: template.projectId ?? undefined,
clientId: template.clientId ?? undefined,
description: template.description,
isBillable: template.isBillable,
tags: template.tags,
});
}
async function deleteTemplate(id: string) {
await templateCollection.delete(id);
}
</script>
<svelte:head>
<title>{$_('nav.templates')} | Times</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.templates')}</h1>
<button
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
+ {$_('template.create')}
</button>
</div>
{#if showCreateForm}
<form
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3"
>
<input
type="text"
bind:value={newName}
placeholder="Vorlagenname"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))] focus:border-[hsl(var(--primary))] focus:outline-none"
/>
<input
type="text"
bind:value={newDescription}
placeholder={$_('entry.description')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))]"
/>
<div class="flex gap-2">
<select
bind:value={newProjectId}
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
>
<option value="">{$_('project.internal')}</option>
{#each allProjects.value.filter((p) => !p.isArchived) as proj}
<option value={proj.id}>{proj.name}</option>
{/each}
</select>
<label
class="flex items-center gap-2 rounded-lg border border-[hsl(var(--border))] px-3 py-2 text-sm"
>
<input
type="checkbox"
bind:checked={newIsBillable}
class="accent-[hsl(var(--primary))]"
/>
{$_('entry.billable')}
</label>
</div>
<div class="flex gap-2">
<button
type="button"
onclick={() => (showCreateForm = false)}
class="flex-1 rounded-lg border border-[hsl(var(--border))] py-2 text-sm text-[hsl(var(--muted-foreground))]"
>{$_('common.cancel')}</button
>
<button
type="submit"
class="flex-1 rounded-lg bg-[hsl(var(--primary))] py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>{$_('common.create')}</button
>
</div>
</form>
{/if}
{#if sortedTemplates.length === 0 && !showCreateForm}
<div
class="rounded-xl border border-dashed border-[hsl(var(--border))] p-8 text-center text-[hsl(var(--muted-foreground))]"
>
<p>{$_('template.noTemplates')}</p>
</div>
{:else}
<div class="space-y-2">
{#each sortedTemplates as template (template.id)}
{@const project = template.projectId
? allProjects.value.find((p) => p.id === template.projectId)
: undefined}
<div
class="flex items-center gap-3 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3"
>
{#if project}
<div
class="h-3 w-3 shrink-0 rounded-full"
style="background-color: {project.color}"
></div>
{:else}
<div class="h-3 w-3 shrink-0 rounded-full bg-gray-400"></div>
{/if}
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-[hsl(var(--foreground))]">{template.name}</p>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
{template.description || $_('timer.noDescription')}
{#if project}
· {project.name}{/if}
{#if template.isBillable}
· ${/if}
{#if template.usageCount > 0}
· {template.usageCount}x{/if}
</p>
</div>
<button
onclick={() => useTemplate(template)}
disabled={timerStore.isRunning}
class="rounded-lg bg-[hsl(var(--primary))] px-3 py-1.5 text-xs font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>
{$_('timer.start')}
</button>
<button
onclick={() => deleteTemplate(template.id)}
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:text-red-500"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
</script>
<svelte:head>
<title>Themes | Times</title>
</svelte:head>
<div>
<h1 class="mb-6 text-2xl font-bold text-[hsl(var(--foreground))]">Themes</h1>
<p class="text-[hsl(var(--muted-foreground))]">Theme-Auswahl.</p>
</div>

View file

@ -0,0 +1,5 @@
<script lang="ts">
let { children } = $props();
</script>
{@render children()}

View file

@ -0,0 +1,270 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { getPillAppItems } from '@manacore/shared-branding';
let email = $state('');
let password = $state('');
let error = $state('');
let isLoading = $state(false);
let showRegister = $state(false);
let showForgotPassword = $state(false);
let show2FA = $state(false);
let twoFactorCode = $state('');
let verificationSent = $state(false);
async function handleLogin() {
if (!email || !password) return;
isLoading = true;
error = '';
try {
const result = await authStore.signIn(email, password);
if (result.success) {
goto('/');
} else if (result.error?.includes('two-factor') || result.error?.includes('2FA')) {
show2FA = true;
} else {
error = result.error || 'Login fehlgeschlagen';
}
} catch {
error = 'Ein Fehler ist aufgetreten';
} finally {
isLoading = false;
}
}
async function handleRegister() {
if (!email || !password) return;
isLoading = true;
error = '';
try {
const result = await authStore.signUp(email, password);
if (result.success && !result.needsVerification) {
goto('/');
} else if (result.needsVerification) {
verificationSent = true;
} else {
error = result.error || 'Registrierung fehlgeschlagen';
}
} catch {
error = 'Ein Fehler ist aufgetreten';
} finally {
isLoading = false;
}
}
async function handleForgotPassword() {
if (!email) {
error = 'Bitte E-Mail eingeben';
return;
}
isLoading = true;
error = '';
const result = await authStore.resetPassword(email);
if (result.success) {
verificationSent = true;
} else {
error = result.error || 'Fehler beim Zurücksetzen';
}
isLoading = false;
}
async function handle2FA() {
if (!twoFactorCode) return;
isLoading = true;
const result = await authStore.verifyTwoFactor(twoFactorCode, true);
if (result.success) {
goto('/');
} else {
error = 'Ungültiger Code';
}
isLoading = false;
}
async function handlePasskey() {
isLoading = true;
error = '';
const result = await authStore.signInWithPasskey();
if (result.success) {
goto('/');
} else {
error = result.error || 'Passkey fehlgeschlagen';
}
isLoading = false;
}
</script>
<svelte:head>
<title>{showRegister ? $_('auth.register') : $_('auth.login')} | Times</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))] p-4">
<div class="w-full max-w-sm">
<!-- Logo -->
<div class="mb-8 text-center">
<div
class="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-2xl bg-[hsl(var(--primary))]"
>
<svg class="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Times</h1>
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))]">Zeiterfassung</p>
</div>
{#if verificationSent}
<div
class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 text-center"
>
<p class="text-[hsl(var(--foreground))]">
{showForgotPassword ? 'Link zum Zurücksetzen gesendet!' : 'Bestätigungsmail gesendet!'}
</p>
<p class="mt-2 text-sm text-[hsl(var(--muted-foreground))]">Bitte prüfe dein Postfach.</p>
<button
onclick={() => {
verificationSent = false;
showForgotPassword = false;
showRegister = false;
}}
class="mt-4 text-sm text-[hsl(var(--primary))]"
>
Zurück zum Login
</button>
</div>
{:else if show2FA}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6">
<h2 class="mb-4 text-lg font-semibold text-[hsl(var(--foreground))]">
Zwei-Faktor-Authentifizierung
</h2>
<input
type="text"
bind:value={twoFactorCode}
placeholder="Code eingeben"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))]"
onkeydown={(e) => e.key === 'Enter' && handle2FA()}
/>
{#if error}<p class="mt-2 text-sm text-red-500">{error}</p>{/if}
<button
onclick={handle2FA}
disabled={isLoading}
class="mt-4 w-full rounded-lg bg-[hsl(var(--primary))] py-3 font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>
{isLoading ? 'Prüfe...' : 'Bestätigen'}
</button>
</div>
{:else}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6">
<h2 class="mb-4 text-lg font-semibold text-[hsl(var(--foreground))]">
{showForgotPassword
? 'Passwort zurücksetzen'
: showRegister
? $_('auth.register')
: $_('auth.login')}
</h2>
<div class="space-y-3">
<input
type="email"
bind:value={email}
placeholder={$_('auth.email')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))]"
/>
{#if !showForgotPassword}
<input
type="password"
bind:value={password}
placeholder={$_('auth.password')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))]"
onkeydown={(e) =>
e.key === 'Enter' && (showRegister ? handleRegister() : handleLogin())}
/>
{/if}
</div>
{#if error}<p class="mt-2 text-sm text-red-500">{error}</p>{/if}
<button
onclick={showForgotPassword
? handleForgotPassword
: showRegister
? handleRegister
: handleLogin}
disabled={isLoading}
class="mt-4 w-full rounded-lg bg-[hsl(var(--primary))] py-3 font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>
{isLoading
? $_('common.loading')
: showForgotPassword
? 'Link senden'
: showRegister
? $_('auth.register')
: $_('auth.login')}
</button>
{#if !showForgotPassword && authStore.isPasskeyAvailable()}
<button
onclick={handlePasskey}
disabled={isLoading}
class="mt-2 w-full rounded-lg border border-[hsl(var(--border))] py-3 text-sm text-[hsl(var(--foreground))]"
>
Mit Passkey anmelden
</button>
{/if}
<div class="mt-4 flex justify-between text-sm">
{#if showForgotPassword}
<button
onclick={() => {
showForgotPassword = false;
error = '';
}}
class="text-[hsl(var(--primary))]"
>
Zurück zum Login
</button>
{:else}
<button
onclick={() => {
showForgotPassword = true;
error = '';
}}
class="text-[hsl(var(--muted-foreground))]"
>
{$_('auth.forgotPassword')}
</button>
<button
onclick={() => {
showRegister = !showRegister;
error = '';
}}
class="text-[hsl(var(--primary))]"
>
{showRegister ? $_('auth.login') : $_('auth.register')}
</button>
{/if}
</div>
</div>
{/if}
<!-- App switcher -->
<div class="mt-6 flex flex-wrap justify-center gap-2">
{#each getPillAppItems() as app}
{#if app.id !== 'times'}
<a
href={app.url}
class="rounded-full border border-[hsl(var(--border))] px-3 py-1 text-xs text-[hsl(var(--muted-foreground))] transition-colors hover:bg-[hsl(var(--accent))] hover:text-[hsl(var(--accent-foreground))]"
>
{app.name}
</a>
{/if}
{/each}
</div>
</div>
</div>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { page } from '$app/stores';
import { _ } from 'svelte-i18n';
</script>
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<h1 class="mb-4 text-6xl font-bold text-[hsl(var(--primary))]">{$page.status}</h1>
<p class="mb-8 text-lg text-[hsl(var(--muted-foreground))]">
{$page.error?.message || $_('error.notFound')}
</p>
<a
href="/"
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-3 text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
>
{$_('error.backToHome')}
</a>
</div>
</div>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import '../app.css';
import '$lib/i18n';
import { waitLocale } from '$lib/i18n';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
let { children } = $props();
let ready = $state(false);
onMount(async () => {
await waitLocale();
theme.initialize();
await authStore.initialize();
ready = true;
});
</script>
{#if ready}
<div class="min-h-screen bg-[hsl(var(--background))] text-[hsl(var(--foreground))]">
{@render children()}
</div>
{:else}
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))]">
<div class="text-center">
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-[hsl(var(--primary))] border-t-transparent"
></div>
<p class="text-sm text-[hsl(var(--muted-foreground))]">Laden...</p>
</div>
</div>
{/if}

View file

@ -0,0 +1,2 @@
// Disable SSR — all data is local-first (IndexedDB + mana-sync)
export const ssr = false;

View file

@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json({
status: 'ok',
timestamp: new Date().toISOString(),
service: 'times-web',
});
};

View file

@ -0,0 +1,39 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
</script>
<svelte:head>
<title>Offline | Times</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))] p-4">
<div class="text-center">
<div
class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-2xl bg-[hsl(var(--muted))]"
>
<svg
class="h-10 w-10 text-[hsl(var(--muted-foreground))]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h1 class="mb-2 text-2xl font-bold text-[hsl(var(--foreground))]">Offline</h1>
<p class="mb-6 text-[hsl(var(--muted-foreground))]">
Du bist gerade nicht mit dem Internet verbunden.
</p>
<button
onclick={() => window.location.reload()}
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-3 font-medium text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
>
Erneut versuchen
</button>
</div>
</div>

View file

@ -0,0 +1,14 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
}),
},
};
export default config;

View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

@ -0,0 +1,60 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { defineConfig } from 'vite';
import { getBuildDefines } from '@manacore/shared-vite-config';
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit(),
SvelteKitPWA({
registerType: 'autoUpdate',
manifest: {
name: 'Times',
short_name: 'Times',
description: 'Zeiterfassung & Timetracking',
theme_color: '#f59e0b',
background_color: '#0f172a',
display: 'standalone',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
shortcuts: [
{
name: 'Timer starten',
short_name: 'Timer',
url: '/?action=start',
icons: [{ src: 'icons/icon.svg', sizes: '96x96' }],
},
{
name: 'Neuer Eintrag',
short_name: 'Eintrag',
url: '/entries?action=new',
icons: [{ src: 'icons/icon.svg', sizes: '96x96' }],
},
],
},
workbox: {
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'],
},
}),
],
server: {
port: 5197,
strictPort: true,
},
preview: {
port: 5197,
},
define: getBuildDefines(),
});

14
apps/times/package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "times",
"version": "1.0.0",
"private": true,
"description": "Times - Zeiterfassung & Timetracking",
"scripts": {
"dev": "pnpm --filter @times/web dev",
"dev:web": "pnpm --filter @times/web dev"
},
"devDependencies": {
"typescript": "^5.9.3"
},
"packageManager": "pnpm@9.15.0"
}

View file

@ -0,0 +1,22 @@
{
"name": "@times/shared",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./types": "./src/types/index.ts",
"./constants": "./src/constants/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/shared-types": "workspace:*"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,40 @@
export const CURRENCIES = [
{ code: 'EUR', symbol: '€', name: 'Euro' },
{ code: 'CHF', symbol: 'CHF', name: 'Schweizer Franken' },
{ code: 'USD', symbol: '$', name: 'US Dollar' },
{ code: 'GBP', symbol: '£', name: 'Britisches Pfund' },
] as const;
export const DEFAULT_CURRENCY = 'EUR';
export const ROUNDING_INCREMENTS = [0, 1, 5, 6, 10, 15] as const;
export const PROJECT_COLORS: string[] = [
'#ef4444',
'#f97316',
'#f59e0b',
'#eab308',
'#84cc16',
'#22c55e',
'#14b8a6',
'#06b6d4',
'#0ea5e9',
'#3b82f6',
'#6366f1',
'#8b5cf6',
'#a855f7',
'#d946ef',
'#ec4899',
'#f43f5e',
];
export const DEFAULT_SETTINGS = {
workingHoursPerDay: 8,
workingDaysPerWeek: 5,
roundingIncrement: 0,
roundingMethod: 'none' as const,
defaultVisibility: 'private' as const,
weekStartsOn: 1 as const,
timerReminderMinutes: 0,
autoStopTimerHours: 0,
};

View file

@ -0,0 +1,2 @@
export * from './types/index.js';
export * from './constants/index.js';

View file

@ -0,0 +1,154 @@
// ─── Billing Rate ────────────────────────────────────────
export interface BillingRate {
amount: number;
currency: string;
per: 'hour' | 'day';
}
// ─── Client ──────────────────────────────────────────────
export interface Client {
id: string;
name: string;
shortCode?: string;
contactId?: string;
email?: string;
color: string;
isArchived: boolean;
billingRate?: BillingRate;
notes?: string;
order: number;
createdAt: string;
updatedAt: string;
}
// ─── Project ─────────────────────────────────────────────
export interface ProjectBudget {
type: 'hours' | 'fixed';
amount: number;
currency?: string;
}
export type ProjectVisibility = 'private' | 'guild';
export interface Project {
id: string;
clientId?: string;
name: string;
description?: string;
color: string;
isArchived: boolean;
isBillable: boolean;
billingRate?: BillingRate;
budget?: ProjectBudget;
visibility: ProjectVisibility;
guildId?: string;
order: number;
createdAt: string;
updatedAt: string;
}
// ─── Time Entry ──────────────────────────────────────────
export type EntrySource = 'todo' | 'calendar' | 'manual' | 'timer';
export interface EntrySourceRef {
app: EntrySource;
refId?: string;
}
export interface TimeEntry {
id: string;
projectId?: string;
clientId?: string;
description: string;
date: string;
startTime?: string;
endTime?: string;
duration: number;
isBillable: boolean;
isRunning: boolean;
tags: string[];
billingRate?: BillingRate;
visibility: ProjectVisibility;
guildId?: string;
source?: EntrySourceRef;
createdAt: string;
updatedAt: string;
}
// ─── Tag ─────────────────────────────────────────────────
export interface Tag {
id: string;
name: string;
color: string;
order: number;
createdAt: string;
updatedAt: string;
}
// ─── Template ────────────────────────────────────────────
export interface EntryTemplate {
id: string;
name: string;
projectId?: string;
clientId?: string;
description: string;
isBillable: boolean;
tags: string[];
usageCount: number;
lastUsedAt?: string;
createdAt: string;
updatedAt: string;
}
// ─── Settings ────────────────────────────────────────────
export type RoundingMethod = 'none' | 'up' | 'down' | 'nearest';
export interface TimesSettings {
id: string;
defaultBillingRate?: BillingRate;
workingHoursPerDay: number;
workingDaysPerWeek: number;
roundingIncrement: number;
roundingMethod: RoundingMethod;
defaultVisibility: ProjectVisibility;
weekStartsOn: 0 | 1;
timerReminderMinutes: number;
autoStopTimerHours: number;
createdAt: string;
updatedAt: string;
}
// ─── View & Filter ───────────────────────────────────────
export type ViewMode = 'day' | 'week' | 'month';
export type SortField = 'date' | 'duration' | 'project' | 'client' | 'createdAt';
export type SortDirection = 'asc' | 'desc';
export interface SortOption {
field: SortField;
direction: SortDirection;
}
export interface FilterCriteria {
search?: string;
projectId?: string;
clientId?: string;
tagIds?: string[];
isBillable?: boolean;
dateFrom?: string;
dateTo?: string;
}
export interface SavedFilter {
id: string;
name: string;
criteria: FilterCriteria;
createdAt: string;
}

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"]
}