fix: auto-detect date format (DD/MM vs MM/DD) in CSV imports

Scans all date values in imported CSVs to determine whether the file
uses DD/MM/YYYY or MM/DD/YYYY format. When the format is ambiguous
(all day and month values are <= 12), shows an inline dropdown for the
user to choose. Bump version to 1.0.2.
This commit is contained in:
Your Name
2026-02-21 16:56:27 +02:00
parent f4f964140b
commit 507fa33be8
6 changed files with 90 additions and 20 deletions

View File

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

2
src-tauri/Cargo.lock generated
View File

@@ -5743,7 +5743,7 @@ dependencies = [
[[package]]
name = "zeroclock"
version = "1.0.0"
version = "1.0.1"
dependencies = [
"chrono",
"env_logger",

View File

@@ -1,6 +1,6 @@
[package]
name = "zeroclock"
version = "1.0.1"
version = "1.0.2"
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.1",
"version": "1.0.2",
"identifier": "com.localtimetracker.app",
"build": {
"beforeDevCommand": "npm run dev",

View File

@@ -9,6 +9,23 @@ export interface ImportEntry {
tags?: string[]
}
export type DateFormat = 'DMY' | 'MDY'
export type DateDetectResult = DateFormat | 'ambiguous'
export function detectDateFormat(rows: string[][], dateColIndex: number): DateDetectResult {
if (dateColIndex < 0) return 'ambiguous'
for (let i = 1; i < rows.length; i++) {
const val = (rows[i]?.[dateColIndex] || '').trim()
const match = val.match(/^(\d{1,2})[\/.](\d{1,2})[\/.](\d{2,4})$/)
if (!match) continue
const a = parseInt(match[1])
const b = parseInt(match[2])
if (a > 12) return 'DMY'
if (b > 12) return 'MDY'
}
return 'ambiguous'
}
export function parseCSV(text: string): string[][] {
const lines = text.split('\n').filter(l => l.trim())
return lines.map(line => {
@@ -44,7 +61,7 @@ export function parseDurationString(dur: string): number {
}
// Parse locale-variable date strings into YYYY-MM-DD
function parseFlexDate(dateStr: string): string {
function parseFlexDate(dateStr: string, formatHint?: DateFormat): string {
if (!dateStr) return new Date().toISOString().split('T')[0]
const s = dateStr.trim()
@@ -63,7 +80,9 @@ function parseFlexDate(dateStr: string): string {
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)
// Use format hint if provided
if (formatHint === 'DMY') return `${year}-${b.padStart(2, '0')}-${a.padStart(2, '0')}`
// Default to MDY
return `${year}-${a.padStart(2, '0')}-${b.padStart(2, '0')}`
}
@@ -99,13 +118,13 @@ function parseFlexTime(timeStr: string): string {
return '00:00:00'
}
function safeDateTime(date: string, time?: string): string {
const d = parseFlexDate(date)
function safeDateTime(date: string, time?: string, dateFormat?: DateFormat): string {
const d = parseFlexDate(date, dateFormat)
const t = time ? parseFlexTime(time) : '00:00:00'
return `${d}T${t}`
}
function findCol(header: string[], ...terms: string[]): number {
export function findCol(header: string[], ...terms: string[]): number {
for (const term of terms) {
const idx = header.findIndex(h => h === term)
if (idx >= 0) return idx
@@ -121,7 +140,7 @@ function col(row: string[], idx: number): string {
return idx >= 0 ? (row[idx] || '') : ''
}
export function mapTogglCSV(rows: string[][]): ImportEntry[] {
export function mapTogglCSV(rows: string[][], dateFormat?: DateFormat): ImportEntry[] {
const header = rows[0].map(h => h.toLowerCase().trim())
const descIdx = findCol(header, 'description')
const projIdx = findCol(header, 'project')
@@ -137,13 +156,13 @@ export function mapTogglCSV(rows: string[][]): ImportEntry[] {
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),
start_time: safeDateTime(col(row, startDateIdx), col(row, startTimeIdx) || undefined, dateFormat),
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[] {
export function mapClockifyCSV(rows: string[][], dateFormat?: DateFormat): ImportEntry[] {
const header = rows[0].map(h => h.toLowerCase().trim())
const descIdx = findCol(header, 'description')
const projIdx = findCol(header, 'project')
@@ -172,14 +191,14 @@ export function mapClockifyCSV(rows: string[][]): ImportEntry[] {
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),
start_time: safeDateTime(col(row, startDateIdx), col(row, startTimeIdx) || undefined, dateFormat),
duration,
tags: tagIdx >= 0 && row[tagIdx] ? row[tagIdx].split(',').map(t => t.trim()).filter(Boolean) : undefined,
}
})
}
export function mapHarvestCSV(rows: string[][]): ImportEntry[] {
export function mapHarvestCSV(rows: string[][], dateFormat?: DateFormat): ImportEntry[] {
const header = rows[0].map(h => h.toLowerCase().trim())
const dateIdx = findCol(header, 'date')
const projIdx = header.findIndex(h => h === 'project')
@@ -193,7 +212,7 @@ export function mapHarvestCSV(rows: string[][]): ImportEntry[] {
client_name: col(row, clientIdx) || undefined,
task_name: col(row, taskIdx) || undefined,
description: col(row, notesIdx) || undefined,
start_time: safeDateTime(col(row, dateIdx)),
start_time: safeDateTime(col(row, dateIdx), undefined, dateFormat),
duration: hoursIdx >= 0 && row[hoursIdx] ? Math.round(parseFloat(row[hoursIdx]) * 3600) : 0,
}))
}

View File

@@ -1445,6 +1445,32 @@
</table>
</div>
<!-- Date format detection -->
<div v-if="importFormat !== 'json' && importFormat !== 'generic'" class="flex items-center gap-3 mb-3">
<template v-if="importDateDetected !== 'ambiguous'">
<span class="text-[0.6875rem] text-text-tertiary">
Date format detected: {{ importDateFormat === 'DMY' ? 'DD/MM/YYYY' : 'MM/DD/YYYY' }}
</span>
<button
@click="importDateDetected = 'ambiguous'"
class="text-[0.6875rem] text-accent hover:text-accent-hover transition-colors"
>
Change
</button>
</template>
<template v-else>
<label class="text-[0.6875rem] text-text-tertiary">Date format:</label>
<div class="w-36">
<AppSelect
v-model="importDateFormat"
:options="dateFormatOptions"
label-key="label"
value-key="value"
/>
</div>
</template>
</div>
<button
@click="executeImport"
:disabled="isImporting"
@@ -1541,7 +1567,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, mapClockifyCSV, mapHarvestCSV, mapGenericCSV, type ImportEntry } from '../utils/import'
import { parseCSV, mapTogglCSV, mapClockifyCSV, mapHarvestCSV, mapGenericCSV, detectDateFormat, findCol, type ImportEntry, type DateFormat, type DateDetectResult } from '../utils/import'
import { TIMER_FONTS, loadGoogleFont, loadAndApplyTimerFont } from '../utils/fonts'
import { UI_FONTS, loadUIFont } from '../utils/uiFonts'
import { useFocusTrap } from '../utils/focusTrap'
@@ -1978,6 +2004,13 @@ const importFileName = ref('')
const importPreview = ref<string[][]>([])
const importStatus = ref('')
const isImporting = ref(false)
const importDateDetected = ref<DateDetectResult>('ambiguous')
const importDateFormat = ref<DateFormat>('MDY')
const dateFormatOptions = [
{ label: 'MM/DD/YYYY', value: 'MDY' },
{ label: 'DD/MM/YYYY', value: 'DMY' },
]
const importFormats = [
{ label: 'Toggl CSV', value: 'toggl' },
@@ -2358,8 +2391,23 @@ async function handleImportFile() {
if (importFormat.value === 'json') {
importPreview.value = []
importDateDetected.value = 'ambiguous'
importDateFormat.value = 'MDY'
} else {
importPreview.value = parseCSV(content).slice(0, 6)
const allRows = parseCSV(content)
importPreview.value = allRows.slice(0, 6)
// Detect date format from CSV data
const header = allRows[0]?.map(h => h.toLowerCase().trim()) || []
let dateColIdx = -1
if (importFormat.value === 'harvest') {
dateColIdx = findCol(header, 'date')
} else {
dateColIdx = findCol(header, 'start date')
}
const detected = detectDateFormat(allRows, dateColIdx)
importDateDetected.value = detected
importDateFormat.value = detected === 'ambiguous' ? 'MDY' : detected
}
} catch (e) {
console.error('Failed to read file:', e)
@@ -2380,12 +2428,13 @@ async function executeImport() {
const rows = parseCSV(importFileContent.value)
let entries: ImportEntry[]
const df = importDateFormat.value
if (importFormat.value === 'toggl') {
entries = mapTogglCSV(rows)
entries = mapTogglCSV(rows, df)
} else if (importFormat.value === 'clockify') {
entries = mapClockifyCSV(rows)
entries = mapClockifyCSV(rows, df)
} else if (importFormat.value === 'harvest') {
entries = mapHarvestCSV(rows)
entries = mapHarvestCSV(rows, df)
} else {
entries = mapGenericCSV(rows, { project: 0, description: 1, start_time: 2, duration: 3, client: -1, task: -1 })
}
@@ -2397,6 +2446,8 @@ async function executeImport() {
importFileContent.value = ''
importFileName.value = ''
importPreview.value = []
importDateDetected.value = 'ambiguous'
importDateFormat.value = 'MDY'
} catch (e) {
importStatus.value = `Error: ${e}`
} finally {