diff --git a/apps/times/CLAUDE.md b/apps/times/CLAUDE.md index 694e36c08..9b131dede 100644 --- a/apps/times/CLAUDE.md +++ b/apps/times/CLAUDE.md @@ -46,12 +46,34 @@ pnpm --filter @times/shared type-check - Quick Start from recent entries or templates ### Time Entries +- **Quick Input (NL)**: Type `"Meeting 2h @Projekt $; Review 1h; Mails 30min"` → creates 3 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) +### Quick Input Syntax + +The EntryForm includes a NL quick-input bar (press Enter to create): + +``` +"Meeting 2h @ClientX #team $" +→ description: Meeting, duration: 2h, project: ClientX, tags: [team], billable: true + +"9-12 Workshop @Schulung; 13-15 Nachbereitung; Mails 30min" +→ 3 entries with time ranges and context inheritance +``` + +Recognized patterns: +- **Duration**: `30min`, `2h`, `1.5h`, `1h30m`, `1.5 Stunden` +- **Time Range**: `9-12`, `14:00-16:30` (auto-calculates duration) +- **Project**: `@ProjectName` +- **Tags**: `#tag1 #tag2` +- **Billable**: `$`, `billable`, `abrechenbar` +- **Date**: `heute`, `morgen`, `gestern`, `montag` +- **Multi-Entry**: Split with `;` or `danach`/`dann` (inherits date + project) + ### Projects - Color-coded project cards with budget progress bars - Client assignment with inherited billing rates diff --git a/apps/times/apps/web/src/lib/components/EntryForm.svelte b/apps/times/apps/web/src/lib/components/EntryForm.svelte index 401b6a0bc..d71005f78 100644 --- a/apps/times/apps/web/src/lib/components/EntryForm.svelte +++ b/apps/times/apps/web/src/lib/components/EntryForm.svelte @@ -3,6 +3,12 @@ import { _ } from 'svelte-i18n'; import { timeEntryCollection } from '$lib/data/local-store'; import type { Project, Client } from '@times/shared'; + import { + parseMultiEntryInput, + resolveEntryIds, + formatParsedEntryPreview, + } from '$lib/utils/entry-parser'; + import { getContext as getCtx } from 'svelte'; let { visible = false, @@ -14,6 +20,7 @@ const allProjects = getContext<{ value: Project[] }>('projects'); const allClients = getContext<{ value: Client[] }>('clients'); + const allTags = getContext<{ value: { id: string; name: string }[] }>('tags'); let description = $state(''); let projectId = $state(''); @@ -22,10 +29,83 @@ let durationMinutes = $state(0); let isBillable = $state(false); + // Quick-input state + let quickInput = $state(''); + let quickPreview = $state(''); + let quickEntryCount = $state(0); + let activeProjects = $derived( allProjects.value.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order) ); + function handleQuickInput(e: Event) { + const text = (e.target as HTMLInputElement).value; + quickInput = text; + + if (!text.trim()) { + quickPreview = ''; + quickEntryCount = 0; + return; + } + + const entries = parseMultiEntryInput(text); + quickEntryCount = entries.length; + + const previews = entries.map((e) => formatParsedEntryPreview(e)).filter(Boolean); + if (entries.length > 1) previews.unshift(`${entries.length} Einträge`); + quickPreview = previews.join(' · '); + } + + async function handleQuickSubmit() { + if (!quickInput.trim()) return; + + const entries = parseMultiEntryInput(quickInput); + const projects = allProjects.value.map((p) => ({ id: p.id, name: p.name })); + const tags = allTags?.value?.map((t) => ({ id: t.id, name: t.name })) ?? []; + + for (const parsed of entries) { + const resolved = resolveEntryIds(parsed, projects, tags); + + const totalSeconds = resolved.duration || durationHours * 3600 + durationMinutes * 60; + if (totalSeconds <= 0) continue; + + const project = resolved.projectId + ? allProjects.value.find((p) => p.id === resolved.projectId) + : null; + + await timeEntryCollection.insert({ + id: crypto.randomUUID(), + projectId: resolved.projectId || null, + clientId: project?.clientId ?? null, + description: resolved.description, + date: resolved.date ? new Date(resolved.date).toISOString().split('T')[0] : date, + startTime: resolved.startTime || null, + endTime: resolved.endTime || null, + duration: totalSeconds, + isBillable: resolved.isBillable ?? isBillable, + isRunning: false, + tags: resolved.tagIds, + billingRate: null, + visibility: 'private', + guildId: null, + source: { app: 'manual' }, + }); + } + + quickInput = ''; + quickPreview = ''; + quickEntryCount = 0; + resetForm(); + onClose(); + } + + function handleQuickKeydown(e: KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault(); + handleQuickSubmit(); + } + } + function resetForm() { description = ''; projectId = ''; @@ -111,6 +191,29 @@ + +