diff --git a/package.json b/package.json index fc82e44..a6ca95e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zeroclock", - "version": "1.0.0", + "version": "1.0.1", "description": "Time tracking desktop application", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a9d5f9d..142d590 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zeroclock" -version = "1.0.0" +version = "1.0.1" description = "A local time tracking app with invoicing" authors = ["you"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8b778d4..2301948 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ZeroClock", - "version": "1.0.0", + "version": "1.0.1", "identifier": "com.localtimetracker.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.vue b/src/App.vue index 9531389..83852e2 100644 --- a/src/App.vue +++ b/src/App.vue @@ -324,6 +324,8 @@ onMounted(async () => { if (settingsStore.settings.close_to_tray === 'true') { event.preventDefault() await win.hide() + } else { + await win.destroy() } }) } catch (e) { diff --git a/src/utils/import.ts b/src/utils/import.ts index 58f5f92..e094994 100644 --- a/src/utils/import.ts +++ b/src/utils/import.ts @@ -31,6 +31,7 @@ export function parseCSV(text: string): string[][] { } export function parseDurationString(dur: string): number { + if (!dur) return 0 // Handle HH:MM:SS, HH:MM, or decimal hours if (dur.includes(':')) { const parts = dur.split(':').map(Number) @@ -42,26 +43,158 @@ export function parseDurationString(dur: string): number { return 0 } +// Parse locale-variable date strings into YYYY-MM-DD +function parseFlexDate(dateStr: string): string { + if (!dateStr) return new Date().toISOString().split('T')[0] + const s = dateStr.trim() + + // YYYY-MM-DD (ISO) + if (/^\d{4}-\d{1,2}-\d{1,2}$/.test(s)) { + const [y, m, d] = s.split('-') + return `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}` + } + + // Slash or dot separated: could be MM/DD/YYYY, DD/MM/YYYY, DD.MM.YYYY + const match = s.match(/^(\d{1,2})[\/.](\d{1,2})[\/.](\d{2,4})$/) + if (match) { + let [, a, b, year] = match + if (year.length === 2) year = '20' + year + // If first part > 12, must be day-first (DD/MM/YYYY) + if (parseInt(a) > 12) return `${year}-${b.padStart(2, '0')}-${a.padStart(2, '0')}` + // If second part > 12, must be month-first (MM/DD/YYYY) + if (parseInt(b) > 12) return `${year}-${a.padStart(2, '0')}-${b.padStart(2, '0')}` + // Ambiguous - assume MM/DD/YYYY (US default, most common Clockify setting) + return `${year}-${a.padStart(2, '0')}-${b.padStart(2, '0')}` + } + + // Last resort: try Date constructor + const d = new Date(s) + if (!isNaN(d.getTime())) return d.toISOString().split('T')[0] + return new Date().toISOString().split('T')[0] +} + +// Parse 12h or 24h time strings into HH:MM:SS +function parseFlexTime(timeStr: string): string { + if (!timeStr) return '00:00:00' + const s = timeStr.trim() + + // 12-hour: "1:00 PM", "11:30:00 AM" + const ampm = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)$/i) + if (ampm) { + let hour = parseInt(ampm[1]) + const min = ampm[2] + const sec = ampm[3] || '00' + const period = ampm[4].toUpperCase() + if (period === 'PM' && hour !== 12) hour += 12 + if (period === 'AM' && hour === 12) hour = 0 + return `${hour.toString().padStart(2, '0')}:${min}:${sec}` + } + + // 24-hour: "13:00", "09:30:00" + const h24 = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/) + if (h24) { + return `${h24[1].padStart(2, '0')}:${h24[2]}:${h24[3] || '00'}` + } + + return '00:00:00' +} + +function safeDateTime(date: string, time?: string): string { + const d = parseFlexDate(date) + const t = time ? parseFlexTime(time) : '00:00:00' + return `${d}T${t}` +} + +function findCol(header: string[], ...terms: string[]): number { + for (const term of terms) { + const idx = header.findIndex(h => h === term) + if (idx >= 0) return idx + } + for (const term of terms) { + const idx = header.findIndex(h => h.includes(term)) + if (idx >= 0) return idx + } + return -1 +} + +function col(row: string[], idx: number): string { + return idx >= 0 ? (row[idx] || '') : '' +} + 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')) + const header = rows[0].map(h => h.toLowerCase().trim()) + const descIdx = findCol(header, 'description') + const projIdx = findCol(header, 'project') + const clientIdx = findCol(header, 'client') + const taskIdx = findCol(header, 'task') + const tagIdx = findCol(header, 'tags', 'tag') + const startDateIdx = findCol(header, 'start date') + const startTimeIdx = findCol(header, 'start time') + const durationIdx = findCol(header, '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, + project_name: col(row, projIdx) || 'Imported', + client_name: col(row, clientIdx) || undefined, + task_name: col(row, taskIdx) || undefined, + description: col(row, descIdx) || undefined, + start_time: safeDateTime(col(row, startDateIdx), col(row, startTimeIdx) || undefined), + duration: parseDurationString(col(row, durationIdx)), + tags: tagIdx >= 0 && row[tagIdx] ? row[tagIdx].split(',').map(t => t.trim()).filter(Boolean) : undefined, + })) +} + +export function mapClockifyCSV(rows: string[][]): ImportEntry[] { + const header = rows[0].map(h => h.toLowerCase().trim()) + const descIdx = findCol(header, 'description') + const projIdx = findCol(header, 'project') + const clientIdx = findCol(header, 'client') + const taskIdx = findCol(header, 'task') + const tagIdx = findCol(header, 'tags', 'tag') + const startDateIdx = findCol(header, 'start date') + const startTimeIdx = findCol(header, 'start time') + // Prefer decimal duration - always a simple number, no format ambiguity + const durDecIdx = header.findIndex(h => h.includes('duration (decimal)')) + const durHIdx = header.findIndex(h => h.includes('duration (h)')) + const durPlainIdx = header.findIndex(h => h === 'duration') + + return rows.slice(1).map(row => { + let duration = 0 + if (durDecIdx >= 0 && row[durDecIdx]) { + duration = Math.round(parseFloat(row[durDecIdx]) * 3600) + } else if (durHIdx >= 0 && row[durHIdx]) { + duration = parseDurationString(row[durHIdx]) + } else if (durPlainIdx >= 0 && row[durPlainIdx]) { + duration = parseDurationString(row[durPlainIdx]) + } + + return { + project_name: col(row, projIdx) || 'Imported', + client_name: col(row, clientIdx) || undefined, + task_name: col(row, taskIdx) || undefined, + description: col(row, descIdx) || undefined, + start_time: safeDateTime(col(row, startDateIdx), col(row, startTimeIdx) || undefined), + duration, + tags: tagIdx >= 0 && row[tagIdx] ? row[tagIdx].split(',').map(t => t.trim()).filter(Boolean) : undefined, + } + }) +} + +export function mapHarvestCSV(rows: string[][]): ImportEntry[] { + const header = rows[0].map(h => h.toLowerCase().trim()) + const dateIdx = findCol(header, 'date') + const projIdx = header.findIndex(h => h === 'project') + const clientIdx = findCol(header, 'client') + const taskIdx = findCol(header, 'task') + const notesIdx = findCol(header, 'notes') + const hoursIdx = header.findIndex(h => h === 'hours') + + return rows.slice(1).map(row => ({ + project_name: col(row, projIdx) || 'Imported', + client_name: col(row, clientIdx) || undefined, + task_name: col(row, taskIdx) || undefined, + description: col(row, notesIdx) || undefined, + start_time: safeDateTime(col(row, dateIdx)), + duration: hoursIdx >= 0 && row[hoursIdx] ? Math.round(parseFloat(row[hoursIdx]) * 3600) : 0, })) } @@ -75,9 +208,3 @@ export function mapGenericCSV(rows: string[][], mapping: Record) 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() -} diff --git a/src/views/Settings.vue b/src/views/Settings.vue index ec9d043..4900788 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -1541,7 +1541,7 @@ import AppSelect from '../components/AppSelect.vue' import AppShortcutRecorder from '../components/AppShortcutRecorder.vue' import AppTimePicker from '../components/AppTimePicker.vue' import { LOCALES, getCurrencies, getCurrencySymbol } from '../utils/locale' -import { parseCSV, mapTogglCSV, mapGenericCSV, type ImportEntry } from '../utils/import' +import { parseCSV, mapTogglCSV, mapClockifyCSV, mapHarvestCSV, mapGenericCSV, type ImportEntry } from '../utils/import' import { TIMER_FONTS, loadGoogleFont, loadAndApplyTimerFont } from '../utils/fonts' import { UI_FONTS, loadUIFont } from '../utils/uiFonts' import { useFocusTrap } from '../utils/focusTrap' @@ -1982,6 +1982,7 @@ const isImporting = ref(false) const importFormats = [ { label: 'Toggl CSV', value: 'toggl' }, { label: 'Clockify CSV', value: 'clockify' }, + { label: 'Harvest CSV', value: 'harvest' }, { label: 'Generic CSV', value: 'generic' }, { label: 'ZeroClock JSON', value: 'json' }, ] @@ -2379,8 +2380,12 @@ async function executeImport() { const rows = parseCSV(importFileContent.value) let entries: ImportEntry[] - if (importFormat.value === 'toggl' || importFormat.value === 'clockify') { + if (importFormat.value === 'toggl') { entries = mapTogglCSV(rows) + } else if (importFormat.value === 'clockify') { + entries = mapClockifyCSV(rows) + } else if (importFormat.value === 'harvest') { + entries = mapHarvestCSV(rows) } else { entries = mapGenericCSV(rows, { project: 0, description: 1, start_time: 2, duration: 3, client: -1, task: -1 }) }