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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 23:28:18 +01:00
parent 3f0e330884
commit 4a91fd7e97
8 changed files with 693 additions and 25 deletions

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

View file

@ -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}
</div>
<!-- Timer Indicator (visible when timer running) -->
<TimerIndicator />
<!-- Right side -->
<div class="flex items-center gap-2">
<select

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, onDestroy, getContext } from 'svelte';
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import type { TimeEntry, Project, Client } from '@taktik/shared';
import {
@ -13,6 +13,7 @@
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');
@ -25,14 +26,6 @@
let todayBillable = $derived(getBillableDuration(todayEntries));
let showEntryForm = $state(false);
onMount(async () => {
await timerStore.initialize();
});
onDestroy(() => {
timerStore.destroy();
});
</script>
<svelte:head>
@ -82,3 +75,4 @@
<!-- Manual Entry Form -->
<EntryForm visible={showEntryForm} onClose={() => (showEntryForm = false)} />
<KeyboardShortcuts onNewEntry={() => (showEntryForm = true)} />

View file

@ -2,6 +2,7 @@
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import type { TimeEntry, Project, Client } from '@taktik/shared';
import { exportEntriesToCSV } from '$lib/utils/export';
import {
getTotalDuration,
getBillableDuration,
@ -87,17 +88,25 @@
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.reports')}</h1>
<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 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>

View file

@ -1,12 +1,290 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { settingsCollection } from '$lib/data/local-store';
import type { TaktikSettings } from '@taktik/shared';
import { CURRENCIES, ROUNDING_INCREMENTS } from '@taktik/shared/constants';
const settings = getContext<{ value: TaktikSettings | 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>{$_('nav.settings')} | Taktik</title>
<title>{$_('settings.title')} | Taktik</title>
</svelte:head>
<div>
<h1 class="mb-6 text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.settings')}</h1>
<p class="text-[hsl(var(--muted-foreground))]">Einstellungen kommen bald.</p>
<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 '@taktik/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')} | Taktik</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>