From 4a91fd7e9720b3b119ba728c1c2783f7017aa320 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 28 Mar 2026 23:28:18 +0100 Subject: [PATCH] feat(taktik): add timer indicator, settings, templates, CSV export, keyboard shortcuts - TimerIndicator: compact navbar bar with pulsing dot, elapsed time, stop button - Settings page: working hours, rounding, billing rate, currency, timer config - Templates page: save/use entry templates, sorted by usage count - CSV export: semicolon-delimited with UTF-8 BOM for Excel compatibility - Keyboard shortcuts: s=start/stop timer, n=new entry, Escape=blur - Timer initialization moved to layout (available on all pages) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/components/KeyboardShortcuts.svelte | 56 ++++ .../src/lib/components/TimerIndicator.svelte | 62 ++++ apps/taktik/apps/web/src/lib/utils/export.ts | 61 ++++ .../apps/web/src/routes/(app)/+layout.svelte | 13 +- .../apps/web/src/routes/(app)/+page.svelte | 12 +- .../web/src/routes/(app)/reports/+page.svelte | 31 +- .../src/routes/(app)/settings/+page.svelte | 286 +++++++++++++++++- .../src/routes/(app)/templates/+page.svelte | 197 ++++++++++++ 8 files changed, 693 insertions(+), 25 deletions(-) create mode 100644 apps/taktik/apps/web/src/lib/components/KeyboardShortcuts.svelte create mode 100644 apps/taktik/apps/web/src/lib/components/TimerIndicator.svelte create mode 100644 apps/taktik/apps/web/src/lib/utils/export.ts create mode 100644 apps/taktik/apps/web/src/routes/(app)/templates/+page.svelte diff --git a/apps/taktik/apps/web/src/lib/components/KeyboardShortcuts.svelte b/apps/taktik/apps/web/src/lib/components/KeyboardShortcuts.svelte new file mode 100644 index 000000000..db5a6a3b1 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/components/KeyboardShortcuts.svelte @@ -0,0 +1,56 @@ + diff --git a/apps/taktik/apps/web/src/lib/components/TimerIndicator.svelte b/apps/taktik/apps/web/src/lib/components/TimerIndicator.svelte new file mode 100644 index 000000000..4cbb34796 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/components/TimerIndicator.svelte @@ -0,0 +1,62 @@ + + +{#if timerStore.isRunning} +
+ +
+ + +
+ + + {#if project} +
+ {/if} + + + + + {formattedTime} + + + + +
+{/if} diff --git a/apps/taktik/apps/web/src/lib/utils/export.ts b/apps/taktik/apps/web/src/lib/utils/export.ts new file mode 100644 index 000000000..588d3be9f --- /dev/null +++ b/apps/taktik/apps/web/src/lib/utils/export.ts @@ -0,0 +1,61 @@ +/** + * CSV Export utility for time entries + */ + +import type { TimeEntry, Project, Client } from '@taktik/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 = `taktik-export-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/apps/taktik/apps/web/src/routes/(app)/+layout.svelte b/apps/taktik/apps/web/src/routes/(app)/+layout.svelte index c90473a78..18a9f3999 100644 --- a/apps/taktik/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/taktik/apps/web/src/routes/(app)/+layout.svelte @@ -2,9 +2,11 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { _ } from 'svelte-i18n'; - import { setContext } from 'svelte'; + 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'; @@ -51,6 +53,7 @@ } viewStore.initialize(); + await timerStore.initialize(); initialized = true; if (!authStore.isAuthenticated && shouldShowGuestWelcome('taktik')) { @@ -64,11 +67,16 @@ { 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'); @@ -116,6 +124,9 @@ {/each} + + +
{ + 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))]" + /> +
+
+ + { + 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))]" + /> +
+ + +
+ +
+ + +
+
+ + + +
+

{$_('settings.rounding')}

+ +
+
+ + +
+
+ + +
+
+
+ + +
+

+ {$_('settings.billingRate')} +

+ +
+
+ + { + 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))]" + /> +
+
+ + +
+ /h +
+
+ + +
+

Timer

+ +
+
+ +

0 = aus

+ { + 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))]" + /> +
+
+ +

0 = aus

+ { + 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))]" + /> +
+
+
+ + +
+

{$_('settings.visibility')}

+

{$_('settings.visibilityDesc')}

+ +
+ + +
+
diff --git a/apps/taktik/apps/web/src/routes/(app)/templates/+page.svelte b/apps/taktik/apps/web/src/routes/(app)/templates/+page.svelte new file mode 100644 index 000000000..7c04d45da --- /dev/null +++ b/apps/taktik/apps/web/src/routes/(app)/templates/+page.svelte @@ -0,0 +1,197 @@ + + + + {$_('nav.templates')} | Taktik + + +
+
+

{$_('nav.templates')}

+ +
+ + {#if showCreateForm} +
{ + e.preventDefault(); + handleCreate(); + }} + class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3" + > + + +
+ + +
+
+ + +
+
+ {/if} + + {#if sortedTemplates.length === 0 && !showCreateForm} +
+

{$_('template.noTemplates')}

+
+ {:else} +
+ {#each sortedTemplates as template (template.id)} + {@const project = template.projectId + ? allProjects.value.find((p) => p.id === template.projectId) + : undefined} +
+ {#if project} +
+ {:else} +
+ {/if} +
+

{template.name}

+

+ {template.description || $_('timer.noDescription')} + {#if project} + · {project.name}{/if} + {#if template.isBillable} + · ${/if} + {#if template.usageCount > 0} + · {template.usageCount}x{/if} +

+
+ + +
+ {/each} +
+ {/if} +