From ff19c7f062aec640f6d3a503072d73fc85c9f52a Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 30 Mar 2026 15:48:17 +0200 Subject: [PATCH] feat(times): add NL time entry parser with multi-entry and quick-input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create entry-parser.ts with duration extraction (2h, 30min, 1h30m), time range parsing (9-12, 14:00-16:30), project (@), tags (#), billable ($), and date recognition. Multi-entry splitting via semicolons with context inheritance. Integrate quick-input bar into EntryForm — type "Meeting 2h @Client $; Review 1h" and press Enter to create multiple entries at once. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/times/CLAUDE.md | 22 ++ .../web/src/lib/components/EntryForm.svelte | 103 ++++++ .../web/src/lib/utils/entry-parser.test.ts | 208 ++++++++++++ .../apps/web/src/lib/utils/entry-parser.ts | 316 ++++++++++++++++++ 4 files changed, 649 insertions(+) create mode 100644 apps/times/apps/web/src/lib/utils/entry-parser.test.ts create mode 100644 apps/times/apps/web/src/lib/utils/entry-parser.ts 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 @@ + +
+ + {#if quickPreview} +
+ {quickPreview} +
+ {/if} +
+ +
+
+ oder manuell +
+
+
{ e.preventDefault(); diff --git a/apps/times/apps/web/src/lib/utils/entry-parser.test.ts b/apps/times/apps/web/src/lib/utils/entry-parser.test.ts new file mode 100644 index 000000000..1be2bb907 --- /dev/null +++ b/apps/times/apps/web/src/lib/utils/entry-parser.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect } from 'vitest'; +import { + parseEntryInput, + parseMultiEntryInput, + resolveEntryIds, + formatParsedEntryPreview, + formatDuration, +} from './entry-parser'; + +describe('parseEntryInput', () => { + it('should parse simple description', () => { + const result = parseEntryInput('Code Review'); + expect(result.description).toBe('Code Review'); + expect(result.duration).toBeUndefined(); + expect(result.tagNames).toEqual([]); + }); + + it('should parse duration "2h"', () => { + const result = parseEntryInput('Meeting 2h'); + expect(result.description).toBe('Meeting'); + expect(result.duration).toBe(7200); + }); + + it('should parse duration "30min"', () => { + const result = parseEntryInput('Standup 30min'); + expect(result.description).toBe('Standup'); + expect(result.duration).toBe(1800); + }); + + it('should parse duration "1h30m"', () => { + const result = parseEntryInput('Workshop 1h30m'); + expect(result.description).toBe('Workshop'); + expect(result.duration).toBe(5400); + }); + + it('should parse duration "1.5h"', () => { + const result = parseEntryInput('Coding 1.5h'); + expect(result.duration).toBe(5400); + }); + + it('should parse duration "1,5 Stunden"', () => { + const result = parseEntryInput('Recherche 1,5 Stunden'); + expect(result.duration).toBe(5400); + }); + + it('should parse @project', () => { + const result = parseEntryInput('Feature bauen 2h @WebApp'); + expect(result.projectName).toBe('WebApp'); + expect(result.duration).toBe(7200); + expect(result.description).toBe('Feature bauen'); + }); + + it('should parse #tags', () => { + const result = parseEntryInput('Meeting #client #important'); + expect(result.tagNames).toEqual(['client', 'important']); + }); + + it('should parse billable with $', () => { + const result = parseEntryInput('Beratung 2h @Client $'); + expect(result.isBillable).toBe(true); + expect(result.projectName).toBe('Client'); + }); + + it('should parse "billable" keyword', () => { + const result = parseEntryInput('Workshop billable 4h'); + expect(result.isBillable).toBe(true); + expect(result.duration).toBe(14400); + }); + + it('should parse "abrechenbar" keyword', () => { + const result = parseEntryInput('Schulung abrechenbar 3h'); + expect(result.isBillable).toBe(true); + }); + + it('should parse time range "9-12"', () => { + const result = parseEntryInput('Workshop 9-12'); + expect(result.startTime).toBe('09:00'); + expect(result.endTime).toBe('12:00'); + expect(result.duration).toBe(10800); // 3h in seconds + }); + + it('should parse time range "14:00-16:30"', () => { + const result = parseEntryInput('Meeting 14:00-16:30'); + expect(result.startTime).toBe('14:00'); + expect(result.endTime).toBe('16:30'); + expect(result.duration).toBe(9000); // 2.5h + }); + + it('should parse complex input', () => { + const result = parseEntryInput('Sprint Review 1.5h @ProjectX #team $ morgen'); + expect(result.description).toBe('Sprint Review'); + expect(result.duration).toBe(5400); + expect(result.projectName).toBe('ProjectX'); + expect(result.tagNames).toEqual(['team']); + expect(result.isBillable).toBe(true); + expect(result.date).toBeDefined(); + }); + + it('should handle empty input', () => { + const result = parseEntryInput(''); + expect(result.description).toBe(''); + expect(result.tagNames).toEqual([]); + }); +}); + +describe('parseMultiEntryInput', () => { + it('should return single entry for simple input', () => { + const entries = parseMultiEntryInput('Meeting 2h'); + expect(entries).toHaveLength(1); + }); + + it('should split on semicolon', () => { + const entries = parseMultiEntryInput('Meeting 1h; Code Review 2h; Mails 30min'); + expect(entries).toHaveLength(3); + expect(entries[0].description).toBe('Meeting'); + expect(entries[0].duration).toBe(3600); + expect(entries[1].description).toBe('Code Review'); + expect(entries[1].duration).toBe(7200); + expect(entries[2].description).toBe('Mails'); + expect(entries[2].duration).toBe(1800); + }); + + it('should split on "danach"', () => { + const entries = parseMultiEntryInput('Meeting 1h danach Protokoll 30min'); + expect(entries).toHaveLength(2); + }); + + it('should inherit project from first entry', () => { + const entries = parseMultiEntryInput('Meeting 1h @Client; Review 2h; Doku 30min'); + expect(entries[0].projectName).toBe('Client'); + expect(entries[1].projectName).toBe('Client'); + expect(entries[2].projectName).toBe('Client'); + }); + + it('should inherit date from first entry', () => { + const entries = parseMultiEntryInput('Gestern Meeting 2h; Review 1h'); + expect(entries[0].date).toBeDefined(); + expect(entries[1].date).toBeDefined(); + expect(entries[0].date!.toDateString()).toBe(entries[1].date!.toDateString()); + }); +}); + +describe('resolveEntryIds', () => { + const projects = [ + { id: 'p1', name: 'WebApp' }, + { id: 'p2', name: 'Mobile' }, + ]; + const tags = [ + { id: 't1', name: 'client' }, + { id: 't2', name: 'internal' }, + ]; + + it('should resolve project name to ID', () => { + const parsed = parseEntryInput('Fix 2h @WebApp'); + const resolved = resolveEntryIds(parsed, projects, tags); + expect(resolved.projectId).toBe('p1'); + }); + + it('should resolve tag names to IDs', () => { + const parsed = parseEntryInput('Call #client'); + const resolved = resolveEntryIds(parsed, projects, tags); + expect(resolved.tagIds).toEqual(['t1']); + }); + + it('should skip unknown project', () => { + const parsed = parseEntryInput('Fix @Unknown'); + const resolved = resolveEntryIds(parsed, projects, tags); + expect(resolved.projectId).toBeUndefined(); + }); +}); + +describe('formatDuration', () => { + it('should format seconds to hours', () => { + expect(formatDuration(3600)).toBe('1h'); + expect(formatDuration(7200)).toBe('2h'); + }); + + it('should format seconds to minutes', () => { + expect(formatDuration(1800)).toBe('30min'); + expect(formatDuration(900)).toBe('15min'); + }); + + it('should format mixed hours and minutes', () => { + expect(formatDuration(5400)).toBe('1h 30min'); + }); +}); + +describe('formatParsedEntryPreview', () => { + it('should format duration', () => { + const parsed = parseEntryInput('Meeting 2h'); + expect(formatParsedEntryPreview(parsed)).toContain('2h'); + }); + + it('should format project', () => { + const parsed = parseEntryInput('Fix @WebApp'); + expect(formatParsedEntryPreview(parsed)).toContain('WebApp'); + }); + + it('should format billable', () => { + const parsed = parseEntryInput('Call $'); + expect(formatParsedEntryPreview(parsed)).toContain('💰'); + }); + + it('should return empty for description-only', () => { + const parsed = parseEntryInput('Just a note'); + expect(formatParsedEntryPreview(parsed)).toBe(''); + }); +}); diff --git a/apps/times/apps/web/src/lib/utils/entry-parser.ts b/apps/times/apps/web/src/lib/utils/entry-parser.ts new file mode 100644 index 000000000..ef181d215 --- /dev/null +++ b/apps/times/apps/web/src/lib/utils/entry-parser.ts @@ -0,0 +1,316 @@ +/** + * Time Entry Parser for Times App + * + * Parses natural language time tracking input: + * - Duration: 2h, 30min, 1.5h, 1h30m + * - Project: @ProjectName + * - Tags: #tag1 #tag2 + * - Billable: $, billable, abrechenbar + * - Date: heute, morgen, gestern, montag (via shared base parser) + * - Time range: 9-12, 14:00-16:30 + * + * Examples: + * - "Meeting 2h @ClientX #billable" + * - "Code Review 1.5h @Projekt-A" + * - "9-12 Workshop @Schulung; 13-15 Nachbereitung" + */ + +import { + parseBaseInput, + extractAtReference, + extractTags, + combineDateAndTime, + formatDatePreview, + type ParserLocale, +} from '@manacore/shared-utils'; + +export interface ParsedEntry { + description: string; + duration?: number; // seconds + date?: Date; + startTime?: string; // HH:mm + endTime?: string; // HH:mm + projectName?: string; + tagNames: string[]; + isBillable?: boolean; +} + +interface Project { + id: string; + name: string; +} + +interface Tag { + id: string; + name: string; +} + +export interface ParsedEntryWithIds { + description: string; + duration?: number; + date?: string; // ISO + startTime?: string; + endTime?: string; + projectId?: string; + tagIds: string[]; + isBillable?: boolean; +} + +// ─── Duration Extraction ─────────────────────────────────── + +const DURATION_PATTERNS: { pattern: RegExp; getSeconds: (m: RegExpMatchArray) => number }[] = [ + // 2h30m, 1h 30min + { + pattern: /\b(\d+)\s*h\s*(\d+)\s*(?:m(?:in)?)\b/i, + getSeconds: (m) => parseInt(m[1]) * 3600 + parseInt(m[2]) * 60, + }, + // 1.5h, 2,5h + { + pattern: /\b(\d+(?:[.,]\d+)?)\s*h\b/i, + getSeconds: (m) => Math.round(parseFloat(m[1].replace(',', '.')) * 3600), + }, + // 30min, 45 Minuten + { + pattern: /\b(\d+)\s*min(?:uten?)?\b/i, + getSeconds: (m) => parseInt(m[1]) * 60, + }, + // 1.5 Stunden + { + pattern: /\b(\d+(?:[.,]\d+)?)\s*(?:stunden?)\b/i, + getSeconds: (m) => Math.round(parseFloat(m[1].replace(',', '.')) * 3600), + }, +]; + +function extractDuration(text: string): { duration?: number; remaining: string } { + for (const { pattern, getSeconds } of DURATION_PATTERNS) { + const match = text.match(pattern); + if (match) { + const seconds = getSeconds(match); + if (seconds > 0) { + return { + duration: seconds, + remaining: text + .replace(match[0], '') + .replace(/\s{2,}/g, ' ') + .trim(), + }; + } + } + } + return { remaining: text }; +} + +// ─── Time Range Extraction ───────────────────────────────── + +const TIME_RANGE_PATTERN = + /\b(?:um\s*)?(\d{1,2})(?::(\d{2}))?\s*[-–]\s*(\d{1,2})(?::(\d{2}))?\s*(?:uhr)?\b/i; + +function extractTimeRange(text: string): { + startTime?: string; + endTime?: string; + duration?: number; + remaining: string; +} { + const match = text.match(TIME_RANGE_PATTERN); + if (match) { + const sh = parseInt(match[1]); + const sm = match[2] ? parseInt(match[2]) : 0; + const eh = parseInt(match[3]); + const em = match[4] ? parseInt(match[4]) : 0; + + if (sh >= 0 && sh <= 23 && eh >= 0 && eh <= 23) { + const startMinutes = sh * 60 + sm; + const endMinutes = eh * 60 + em; + const durationSeconds = (endMinutes - startMinutes) * 60; + + return { + startTime: `${String(sh).padStart(2, '0')}:${String(sm).padStart(2, '0')}`, + endTime: `${String(eh).padStart(2, '0')}:${String(em).padStart(2, '0')}`, + duration: durationSeconds > 0 ? durationSeconds : undefined, + remaining: text.replace(TIME_RANGE_PATTERN, '').trim(), + }; + } + } + return { remaining: text }; +} + +// ─── Billable Detection ──────────────────────────────────── + +const BILLABLE_PATTERNS = [/\$/, /\bbillable\b/i, /\babrechenbar\b/i]; + +function extractBillable(text: string): { isBillable?: boolean; remaining: string } { + for (const pattern of BILLABLE_PATTERNS) { + if (pattern.test(text)) { + return { + isBillable: true, + remaining: text + .replace(pattern, '') + .replace(/\s{2,}/g, ' ') + .trim(), + }; + } + } + return { remaining: text }; +} + +// ─── Multi-Entry Splitting ───────────────────────────────── + +const ENTRY_SPLITTERS = + /\s*(?:,\s*(?:danach|dann|und dann|anschließend|außerdem)\s+|;\s*|\s+(?:danach|dann|und dann|anschließend)\s+)/i; + +// ─── Main Parser ─────────────────────────────────────────── + +export function parseEntryInput(input: string, locale: ParserLocale = 'de'): ParsedEntry { + let text = input.trim(); + + // Extract billable flag + const billableResult = extractBillable(text); + text = billableResult.remaining; + const isBillable = billableResult.isBillable; + + // Extract time range (before duration, since "9-12" could conflict) + const timeRangeResult = extractTimeRange(text); + text = timeRangeResult.remaining; + + // Extract duration (if no time range gave us one) + let duration = timeRangeResult.duration; + let startTime = timeRangeResult.startTime; + let endTime = timeRangeResult.endTime; + + if (!duration) { + const durationResult = extractDuration(text); + text = durationResult.remaining; + duration = durationResult.duration; + } + + // Extract @project + const projectResult = extractAtReference(text); + text = projectResult.remaining; + const projectName = projectResult.value; + + // Extract #tags + const tagsResult = extractTags(text); + text = tagsResult.remaining; + const tagNames = tagsResult.value || []; + + // Use base parser for date extraction + const base = parseBaseInput(text, locale); + const date = base.date ? combineDateAndTime(base.date, undefined) : undefined; + + return { + description: base.title, + duration, + date, + startTime, + endTime, + projectName, + tagNames, + isBillable, + }; +} + +/** + * Parse input with multiple entries separated by keywords/semicolons. + * Subsequent entries inherit date and project from the first. + */ +export function parseMultiEntryInput(input: string, locale: ParserLocale = 'de'): ParsedEntry[] { + const parts = input.split(ENTRY_SPLITTERS).filter((s) => s.trim().length > 0); + + if (parts.length <= 1) { + return [parseEntryInput(input, locale)]; + } + + const results: ParsedEntry[] = []; + let contextDate: Date | undefined; + let contextProject: string | undefined; + + for (let i = 0; i < parts.length; i++) { + const parsed = parseEntryInput(parts[i].trim(), locale); + + if (i === 0) { + contextDate = parsed.date; + contextProject = parsed.projectName; + } else { + if (!parsed.date && contextDate) parsed.date = contextDate; + if (!parsed.projectName && contextProject) parsed.projectName = contextProject; + } + + results.push(parsed); + } + + return results; +} + +// ─── ID Resolution ───────────────────────────────────────── + +export function resolveEntryIds( + parsed: ParsedEntry, + projects: Project[], + tags: Tag[] +): ParsedEntryWithIds { + let projectId: string | undefined; + const tagIds: string[] = []; + + if (parsed.projectName) { + const project = projects.find( + (p) => p.name.toLowerCase() === parsed.projectName!.toLowerCase() + ); + if (project) projectId = project.id; + } + + for (const tagName of parsed.tagNames) { + const tag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase()); + if (tag) tagIds.push(tag.id); + } + + return { + description: parsed.description, + duration: parsed.duration, + date: parsed.date?.toISOString(), + startTime: parsed.startTime, + endTime: parsed.endTime, + projectId, + tagIds, + isBillable: parsed.isBillable, + }; +} + +// ─── Preview Formatting ──────────────────────────────────── + +export function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0 && m > 0) return `${h}h ${m}min`; + if (h > 0) return `${h}h`; + return `${m}min`; +} + +export function formatParsedEntryPreview(parsed: ParsedEntry): string { + const parts: string[] = []; + + if (parsed.date) { + parts.push(`📅 ${formatDatePreview(parsed.date)}`); + } + + if (parsed.startTime && parsed.endTime) { + parts.push(`🕐 ${parsed.startTime}–${parsed.endTime}`); + } + + if (parsed.duration) { + parts.push(`⏱️ ${formatDuration(parsed.duration)}`); + } + + if (parsed.projectName) { + parts.push(`📁 ${parsed.projectName}`); + } + + if (parsed.isBillable) { + parts.push('💰'); + } + + if (parsed.tagNames.length > 0) { + parts.push(`🏷️ ${parsed.tagNames.join(', ')}`); + } + + return parts.join(' · '); +}