mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
c33339b0cf
commit
ff19c7f062
4 changed files with 649 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
208
apps/times/apps/web/src/lib/utils/entry-parser.test.ts
Normal file
208
apps/times/apps/web/src/lib/utils/entry-parser.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
316
apps/times/apps/web/src/lib/utils/entry-parser.ts
Normal file
316
apps/times/apps/web/src/lib/utils/entry-parser.ts
Normal 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(' · ');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue