feat(times): add NL time entry parser with multi-entry and quick-input

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-30 15:48:17 +02:00
parent c33339b0cf
commit ff19c7f062
4 changed files with 649 additions and 0 deletions

View file

@ -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

View file

@ -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 @@
</button>
</div>
<!-- Quick Input Bar -->
<div class="mb-4">
<input
type="text"
value={quickInput}
oninput={handleQuickInput}
onkeydown={handleQuickKeydown}
placeholder="Schnelleingabe: Meeting 2h @Projekt $; Review 1h"
class="w-full rounded-lg border border-dashed border-[hsl(var(--border))] bg-[hsl(var(--muted)/0.3)] px-4 py-2.5 text-sm text-[hsl(var(--foreground))] placeholder:text-xs placeholder:text-[hsl(var(--muted-foreground))] focus:border-solid focus:border-[hsl(var(--primary)/0.5)] focus:bg-[hsl(var(--input))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary)/0.1)]"
/>
{#if quickPreview}
<div class="mt-1 px-1 text-[0.7rem] text-[hsl(var(--muted-foreground))]">
{quickPreview}
</div>
{/if}
</div>
<div class="mb-3 flex items-center gap-2">
<div class="h-px flex-1 bg-[hsl(var(--border))]"></div>
<span class="text-[0.65rem] text-[hsl(var(--muted-foreground))]">oder manuell</span>
<div class="h-px flex-1 bg-[hsl(var(--border))]"></div>
</div>
<form
onsubmit={(e) => {
e.preventDefault();

View file

@ -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('');
});
});

View file

@ -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(' · ');
}