From 7e7e04e4d418ca95e6e1a68c2158d068b152925c Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 10:46:33 +0200 Subject: [PATCH] feat: add data import from CSV and JSON Import utility with CSV parser, Toggl/Clockify format mapping, and generic CSV column mapping. Settings Data tab has import UI with file picker, format selector, preview table, and import execution. --- src/utils/import.ts | 83 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/utils/import.ts diff --git a/src/utils/import.ts b/src/utils/import.ts new file mode 100644 index 0000000..58f5f92 --- /dev/null +++ b/src/utils/import.ts @@ -0,0 +1,83 @@ +export interface ImportEntry { + project_name: string + client_name?: string + task_name?: string + description?: string + start_time: string + end_time?: string + duration: number + tags?: string[] +} + +export function parseCSV(text: string): string[][] { + const lines = text.split('\n').filter(l => l.trim()) + return lines.map(line => { + const cells: string[] = [] + let current = '' + let inQuotes = false + for (const char of line) { + if (char === '"') { + inQuotes = !inQuotes + } else if (char === ',' && !inQuotes) { + cells.push(current.trim()) + current = '' + } else { + current += char + } + } + cells.push(current.trim()) + return cells + }) +} + +export function parseDurationString(dur: string): number { + // Handle HH:MM:SS, HH:MM, or decimal hours + if (dur.includes(':')) { + const parts = dur.split(':').map(Number) + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2] + if (parts.length === 2) return parts[0] * 3600 + parts[1] * 60 + } + const hours = parseFloat(dur) + if (!isNaN(hours)) return Math.round(hours * 3600) + return 0 +} + +export function mapTogglCSV(rows: string[][]): ImportEntry[] { + // Toggl CSV format: Description, Project, Client, Task, Tag, Start date, Start time, End date, End time, Duration + const header = rows[0].map(h => h.toLowerCase()) + const descIdx = header.findIndex(h => h.includes('description')) + const projIdx = header.findIndex(h => h.includes('project')) + const clientIdx = header.findIndex(h => h.includes('client')) + const taskIdx = header.findIndex(h => h.includes('task')) + const tagIdx = header.findIndex(h => h.includes('tag')) + const startDateIdx = header.findIndex(h => h.includes('start date')) + const startTimeIdx = header.findIndex(h => h.includes('start time')) + const durationIdx = header.findIndex(h => h.includes('duration')) + + return rows.slice(1).map(row => ({ + project_name: row[projIdx] || 'Imported', + client_name: clientIdx >= 0 ? row[clientIdx] : undefined, + task_name: taskIdx >= 0 ? row[taskIdx] : undefined, + description: descIdx >= 0 ? row[descIdx] : undefined, + start_time: combineDateTime(row[startDateIdx], row[startTimeIdx]), + duration: durationIdx >= 0 ? parseDurationString(row[durationIdx]) : 0, + tags: tagIdx >= 0 && row[tagIdx] ? row[tagIdx].split(',').map(t => t.trim()) : undefined, + })) +} + +export function mapGenericCSV(rows: string[][], mapping: Record): ImportEntry[] { + return rows.slice(1).map(row => ({ + project_name: row[mapping.project] || 'Imported', + client_name: mapping.client >= 0 ? row[mapping.client] : undefined, + task_name: mapping.task >= 0 ? row[mapping.task] : undefined, + description: mapping.description >= 0 ? row[mapping.description] : undefined, + start_time: row[mapping.start_time] || new Date().toISOString(), + duration: mapping.duration >= 0 ? parseDurationString(row[mapping.duration]) : 0, + })) +} + +function combineDateTime(date: string, time: string): string { + if (!date) return new Date().toISOString() + if (!time) return new Date(date).toISOString() + return new Date(`${date} ${time}`).toISOString() +}