Compare commits
134
Commits
v1.0.1
..
ae02cba958
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae02cba958 | ||
|
|
ad0bdc05be | ||
|
|
7b118c1a1c | ||
|
|
03eff89947 | ||
|
|
ee82abe63e | ||
|
|
ef6255042d | ||
|
|
b7b1789380 | ||
|
|
0ae431b8ac | ||
|
|
e97bc0f640 | ||
|
|
d22d6e844f | ||
|
|
f5223d7a0b | ||
|
|
9d0ab92afc | ||
|
|
ea2d0cba7f | ||
|
|
a4fce3b7ab | ||
|
|
7c8effb0a7 | ||
|
|
f337f8c28f | ||
|
|
96ef48000c | ||
|
|
c6f6b61503 | ||
|
|
ebadbbc2a6 | ||
|
|
f504241fc9 | ||
|
|
1e324ef0ca | ||
|
|
2292e4ff9e | ||
|
|
2b47f3412a | ||
|
|
fb41f67145 | ||
|
|
0ddf8aa14e | ||
|
|
eb58794555 | ||
|
|
f0d8e066cb | ||
|
|
349b9eb95d | ||
|
|
37751eb0c8 | ||
|
|
4faac61901 | ||
|
|
83a3ca1e93 | ||
|
|
6c961b6c9f | ||
|
|
8bcd81b4f0 | ||
|
|
6179c8fee8 | ||
|
|
6df5485813 | ||
|
|
d83a832cb1 | ||
|
|
6757b7d800 | ||
|
|
03c1157683 | ||
|
|
2a1ed8d875 | ||
|
|
3dcbd4a888 | ||
|
|
47eb1af7ab | ||
|
|
28eb7a2639 | ||
|
|
8c8de6a2a7 | ||
|
|
8b8d451806 | ||
|
|
f0571fb1bb | ||
|
|
92749bdb63 | ||
|
|
e3450ff92e | ||
|
|
efc9fce811 | ||
|
|
c499acd17d | ||
|
|
056333c31c | ||
|
|
59e713f1ec | ||
|
|
ed8b0c0776 | ||
|
|
6a252facf6 | ||
|
|
4a45713c77 | ||
|
|
de5f65aed0 | ||
|
|
0ecb4d80c1 | ||
|
|
d739033463 | ||
|
|
cf4d64eced | ||
|
|
98152984c1 | ||
|
|
b05bd415fb | ||
|
|
185b20cab2 | ||
|
|
a05e7555e8 | ||
|
|
2e4143edc0 | ||
|
|
291429e1b8 | ||
|
|
a3a6ab2fdf | ||
|
|
c66e71f57d | ||
|
|
31bb66dbfd | ||
|
|
78a0537632 | ||
|
|
55505b2b6b | ||
|
|
8c56867764 | ||
|
|
5e608a98e6 | ||
|
|
8eb2d135c8 | ||
|
|
bd3e0ba5a6 | ||
|
|
5ac890aad4 | ||
|
|
8d0f6c6c7d | ||
|
|
46ce6d119d | ||
|
|
838cb55c8e | ||
|
|
72a86cf2c9 | ||
|
|
d585f449db | ||
|
|
0fe491c15f | ||
|
|
99bca0709b | ||
|
|
c6cb26553a | ||
|
|
6892bf8b98 | ||
|
|
68ce724980 | ||
|
|
ee30647b44 | ||
|
|
6049536284 | ||
|
|
9dbd6992e0 | ||
|
|
5af8661b83 | ||
|
|
9a894bbc40 | ||
|
|
8dc915c8aa | ||
|
|
d3709c170b | ||
|
|
d0961c93fd | ||
|
|
952e41ef01 | ||
|
|
ef5eecd711 | ||
|
|
dbea5658c2 | ||
|
|
1c05b690ad | ||
|
|
4a40c22515 | ||
|
|
d33159594d | ||
|
|
8ee45cdefc | ||
|
|
89d121bbea | ||
|
|
c0ad93a758 | ||
|
|
a478aba6ec | ||
|
|
5fd1d8cb77 | ||
|
|
b646dcd801 | ||
|
|
5fea155332 | ||
|
|
19f0813d2a | ||
|
|
0b04e5016e | ||
|
|
9602630f18 | ||
|
|
64f04db2f2 | ||
|
|
f9542b6b7e | ||
|
|
f3d9a938ac | ||
|
|
228a8cd6b4 | ||
|
|
83a812d5b0 | ||
|
|
9eda8aaa99 | ||
|
|
94a035d0bf | ||
|
|
5daa426182 | ||
|
|
c218dc1db5 | ||
|
|
740b9f0e4b | ||
|
|
ed6e10efd3 | ||
|
|
90bb035b72 | ||
|
|
a9a7b0aceb | ||
|
|
e92c445782 | ||
|
|
b3f852a460 | ||
|
|
db9bacf310 | ||
|
|
27152985f7 | ||
|
|
fba9fab4d6 | ||
|
|
bb9f329fec | ||
|
|
da1b2f3f8c | ||
|
|
90d557487f | ||
|
|
d2fc871427 | ||
|
|
6b83f795d4 | ||
|
|
ce6c9c23b9 | ||
|
|
051de9604b | ||
|
|
e3070461c7 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "zeroclock",
|
"name": "zeroclock",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"description": "Time tracking desktop application",
|
"description": "Time tracking desktop application",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -5743,7 +5743,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zeroclock"
|
name = "zeroclock"
|
||||||
version = "1.0.0"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "zeroclock"
|
name = "zeroclock"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
description = "A local time tracking app with invoicing"
|
description = "A local time tracking app with invoicing"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ZeroClock",
|
"productName": "ZeroClock",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"identifier": "com.localtimetracker.app",
|
"identifier": "com.localtimetracker.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+30
-11
@@ -9,6 +9,23 @@ export interface ImportEntry {
|
|||||||
tags?: string[]
|
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[][] {
|
export function parseCSV(text: string): string[][] {
|
||||||
const lines = text.split('\n').filter(l => l.trim())
|
const lines = text.split('\n').filter(l => l.trim())
|
||||||
return lines.map(line => {
|
return lines.map(line => {
|
||||||
@@ -44,7 +61,7 @@ export function parseDurationString(dur: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse locale-variable date strings into YYYY-MM-DD
|
// 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]
|
if (!dateStr) return new Date().toISOString().split('T')[0]
|
||||||
const s = dateStr.trim()
|
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 (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 second part > 12, must be month-first (MM/DD/YYYY)
|
||||||
if (parseInt(b) > 12) return `${year}-${a.padStart(2, '0')}-${b.padStart(2, '0')}`
|
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')}`
|
return `${year}-${a.padStart(2, '0')}-${b.padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,13 +118,13 @@ function parseFlexTime(timeStr: string): string {
|
|||||||
return '00:00:00'
|
return '00:00:00'
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeDateTime(date: string, time?: string): string {
|
function safeDateTime(date: string, time?: string, dateFormat?: DateFormat): string {
|
||||||
const d = parseFlexDate(date)
|
const d = parseFlexDate(date, dateFormat)
|
||||||
const t = time ? parseFlexTime(time) : '00:00:00'
|
const t = time ? parseFlexTime(time) : '00:00:00'
|
||||||
return `${d}T${t}`
|
return `${d}T${t}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function findCol(header: string[], ...terms: string[]): number {
|
export function findCol(header: string[], ...terms: string[]): number {
|
||||||
for (const term of terms) {
|
for (const term of terms) {
|
||||||
const idx = header.findIndex(h => h === term)
|
const idx = header.findIndex(h => h === term)
|
||||||
if (idx >= 0) return idx
|
if (idx >= 0) return idx
|
||||||
@@ -121,7 +140,7 @@ function col(row: string[], idx: number): string {
|
|||||||
return idx >= 0 ? (row[idx] || '') : ''
|
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 header = rows[0].map(h => h.toLowerCase().trim())
|
||||||
const descIdx = findCol(header, 'description')
|
const descIdx = findCol(header, 'description')
|
||||||
const projIdx = findCol(header, 'project')
|
const projIdx = findCol(header, 'project')
|
||||||
@@ -137,13 +156,13 @@ export function mapTogglCSV(rows: string[][]): ImportEntry[] {
|
|||||||
client_name: col(row, clientIdx) || undefined,
|
client_name: col(row, clientIdx) || undefined,
|
||||||
task_name: col(row, taskIdx) || undefined,
|
task_name: col(row, taskIdx) || undefined,
|
||||||
description: col(row, descIdx) || 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)),
|
duration: parseDurationString(col(row, durationIdx)),
|
||||||
tags: tagIdx >= 0 && row[tagIdx] ? row[tagIdx].split(',').map(t => t.trim()).filter(Boolean) : undefined,
|
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 header = rows[0].map(h => h.toLowerCase().trim())
|
||||||
const descIdx = findCol(header, 'description')
|
const descIdx = findCol(header, 'description')
|
||||||
const projIdx = findCol(header, 'project')
|
const projIdx = findCol(header, 'project')
|
||||||
@@ -172,14 +191,14 @@ export function mapClockifyCSV(rows: string[][]): ImportEntry[] {
|
|||||||
client_name: col(row, clientIdx) || undefined,
|
client_name: col(row, clientIdx) || undefined,
|
||||||
task_name: col(row, taskIdx) || undefined,
|
task_name: col(row, taskIdx) || undefined,
|
||||||
description: col(row, descIdx) || 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,
|
duration,
|
||||||
tags: tagIdx >= 0 && row[tagIdx] ? row[tagIdx].split(',').map(t => t.trim()).filter(Boolean) : undefined,
|
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 header = rows[0].map(h => h.toLowerCase().trim())
|
||||||
const dateIdx = findCol(header, 'date')
|
const dateIdx = findCol(header, 'date')
|
||||||
const projIdx = header.findIndex(h => h === 'project')
|
const projIdx = header.findIndex(h => h === 'project')
|
||||||
@@ -193,7 +212,7 @@ export function mapHarvestCSV(rows: string[][]): ImportEntry[] {
|
|||||||
client_name: col(row, clientIdx) || undefined,
|
client_name: col(row, clientIdx) || undefined,
|
||||||
task_name: col(row, taskIdx) || undefined,
|
task_name: col(row, taskIdx) || undefined,
|
||||||
description: col(row, notesIdx) || 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,
|
duration: hoursIdx >= 0 && row[hoursIdx] ? Math.round(parseFloat(row[hoursIdx]) * 3600) : 0,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-5
@@ -1445,6 +1445,32 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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
|
<button
|
||||||
@click="executeImport"
|
@click="executeImport"
|
||||||
:disabled="isImporting"
|
:disabled="isImporting"
|
||||||
@@ -1541,7 +1567,7 @@ import AppSelect from '../components/AppSelect.vue'
|
|||||||
import AppShortcutRecorder from '../components/AppShortcutRecorder.vue'
|
import AppShortcutRecorder from '../components/AppShortcutRecorder.vue'
|
||||||
import AppTimePicker from '../components/AppTimePicker.vue'
|
import AppTimePicker from '../components/AppTimePicker.vue'
|
||||||
import { LOCALES, getCurrencies, getCurrencySymbol } from '../utils/locale'
|
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 { TIMER_FONTS, loadGoogleFont, loadAndApplyTimerFont } from '../utils/fonts'
|
||||||
import { UI_FONTS, loadUIFont } from '../utils/uiFonts'
|
import { UI_FONTS, loadUIFont } from '../utils/uiFonts'
|
||||||
import { useFocusTrap } from '../utils/focusTrap'
|
import { useFocusTrap } from '../utils/focusTrap'
|
||||||
@@ -1978,6 +2004,13 @@ const importFileName = ref('')
|
|||||||
const importPreview = ref<string[][]>([])
|
const importPreview = ref<string[][]>([])
|
||||||
const importStatus = ref('')
|
const importStatus = ref('')
|
||||||
const isImporting = ref(false)
|
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 = [
|
const importFormats = [
|
||||||
{ label: 'Toggl CSV', value: 'toggl' },
|
{ label: 'Toggl CSV', value: 'toggl' },
|
||||||
@@ -2358,8 +2391,23 @@ async function handleImportFile() {
|
|||||||
|
|
||||||
if (importFormat.value === 'json') {
|
if (importFormat.value === 'json') {
|
||||||
importPreview.value = []
|
importPreview.value = []
|
||||||
|
importDateDetected.value = 'ambiguous'
|
||||||
|
importDateFormat.value = 'MDY'
|
||||||
} else {
|
} 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) {
|
} catch (e) {
|
||||||
console.error('Failed to read file:', e)
|
console.error('Failed to read file:', e)
|
||||||
@@ -2380,12 +2428,13 @@ async function executeImport() {
|
|||||||
const rows = parseCSV(importFileContent.value)
|
const rows = parseCSV(importFileContent.value)
|
||||||
let entries: ImportEntry[]
|
let entries: ImportEntry[]
|
||||||
|
|
||||||
|
const df = importDateFormat.value
|
||||||
if (importFormat.value === 'toggl') {
|
if (importFormat.value === 'toggl') {
|
||||||
entries = mapTogglCSV(rows)
|
entries = mapTogglCSV(rows, df)
|
||||||
} else if (importFormat.value === 'clockify') {
|
} else if (importFormat.value === 'clockify') {
|
||||||
entries = mapClockifyCSV(rows)
|
entries = mapClockifyCSV(rows, df)
|
||||||
} else if (importFormat.value === 'harvest') {
|
} else if (importFormat.value === 'harvest') {
|
||||||
entries = mapHarvestCSV(rows)
|
entries = mapHarvestCSV(rows, df)
|
||||||
} else {
|
} else {
|
||||||
entries = mapGenericCSV(rows, { project: 0, description: 1, start_time: 2, duration: 3, client: -1, task: -1 })
|
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 = ''
|
importFileContent.value = ''
|
||||||
importFileName.value = ''
|
importFileName.value = ''
|
||||||
importPreview.value = []
|
importPreview.value = []
|
||||||
|
importDateDetected.value = 'ambiguous'
|
||||||
|
importDateFormat.value = 'MDY'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
importStatus.value = `Error: ${e}`
|
importStatus.value = `Error: ${e}`
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user