fix: close button and CSV import parsing for Clockify/Harvest

Close button did nothing when "close to tray" was disabled - the
onCloseRequested handler lacked an explicit destroy call for the
non-tray path.

Clockify CSV import threw RangeError because locale-dependent date
formats (MM/DD/YYYY, DD.MM.YYYY, 12h time) were passed straight
to the Date constructor. Added flexible date/time parsers that
handle all Clockify export variants without relying on Date parsing.

Added dedicated Clockify mapper that prefers Duration (decimal)
column and a new Harvest CSV importer (date + decimal hours, no
start/end times).

Bump version to 1.0.1.
This commit is contained in:
Your Name
2026-02-21 14:56:53 +02:00
parent eb0c65c29a
commit f4f964140b
6 changed files with 162 additions and 28 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "zeroclock",
"version": "1.0.0",
"version": "1.0.1",
"description": "Time tracking desktop application",
"type": "module",
"scripts": {

View File

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

View File

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

View File

@@ -324,6 +324,8 @@ onMounted(async () => {
if (settingsStore.settings.close_to_tray === 'true') {
event.preventDefault()
await win.hide()
} else {
await win.destroy()
}
})
} catch (e) {

View File

@@ -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<string, number>)
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()
}

View File

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