Plan: Toggl Track import/export
Backlog entry: 910-backlog - “Toggl Track import/export”.
Source data
Reference file: Toggl_time_entries_2026-01-01_to_2026-12-31.csv, 547 rows.
Header (13 columns, every field quoted, UTF-8 BOM at the start of the file):
"User","Email","Client","Project","Task","Description","Billable","Start date","Start time","End date","End time","Duration","Tags" Measured fill rates over the reference file:
| Column | Filled | Distinct | Notes |
|---|---|---|---|
User | 547/547 | 1 | constant, no information |
Email | 547/547 | 1 | constant, no information |
Client | 0/547 | 0 | always empty |
Project | 453/547 | 5 | avito-pii, avito-general, avito-edu, avito-hr, avito-onboarding |
Task | 0/547 | 0 | always empty |
Description | 544/547 | 109 | free text, maps to comment |
Billable | 547/547 | 1 | always No |
Start date | 547/547 | 120 | YYYY-MM-DD |
Start time | 547/547 | 479 | HH:mm:ss |
End date | 547/547 | 120 | YYYY-MM-DD |
End time | 547/547 | 504 | HH:mm:ss |
Duration | 547/547 | 516 | HH:mm:ss, derivable from start/end |
Tags | 358/547 | 6 | meeting, investigation, docs, backend, coding, learning |
Two facts that drive the design:
- No row carries more than one Toggl tag. The
Tagscolumn is comma-separated in Toggl’s format but is single-valued throughout this file. ProjectxTagsyields only 18 distinct combinations, including the empty/empty pair.
Decision: flatten the taxonomy, keep the rest as metadata
Two complementary moves:
Project+Tagsflatten into a single tag name (below) — the taxonomy that is usable today in filters, reports, and colors.- Every other source column is preserved verbatim in a new
metadata: [String: String]onTimeRecord— a holding area so nothing is lost before those fields get real homes.
The metadata is deliberately inert for now: not filterable, not reportable, not shown in the editor. It exists so that when Billable becomes a checkbox in the record form, and Client/Project become first-class tag concepts, the data for already-imported records is already there and a migration can promote it in place instead of asking the user to re-import.
Decision: flatten both dimensions into one tag name
Toggl has two taxonomy dimensions (Project and Tags); a Minuta TimeRecord has a single optional tagId. Rather than change the CRDT schema, the import composes a single tag name from both:
Project + Tags -> tag name
"avito-pii" + "docs" -> "avito-pii / docs"
"avito-pii" + "" -> "avito-pii"
"" + "docs" -> "docs"
"" + "" -> untagged (tagId = nil) Separator: " / ".
Why this over the alternatives:
- No schema change.
TimeRecord,AutomergeTimeRecord,AutomergeStorageService, filters, reports, editor UI, and every CLI subcommand stay untouched. Multi-tag support (tagId->tagIds: [UUID]) would be an app-wide refactor plus a migration for every existing record, and the source data does not need it. - It round-trips. Both Toggl columns can be reconstructed on export by splitting the tag name on
" / ". MappingProjectalone would discard the tag dimension permanently and make Toggl-shaped export lossy by construction. - It stays legible. 18 combinations, each getting its own deterministic OKLCH color from
TagColorGenerator, sorted soavito-*names cluster together.
Metadata: [String: String] on TimeRecord
Everything not consumed by the tag name is stored as key-value pairs. Namespaced keys, so a future second importer cannot collide:
"source" -> "toggl"
"toggl.project" -> "avito-pii"
"toggl.tags" -> "docs"
"toggl.billable" -> "No"
"toggl.client" -> "..." // omitted when empty
"toggl.task" -> "..." // omitted when empty User and Email are not stored: they identify a person, are constant across the file, and carry no per-record information. Storing them would put a real name and email address into every record file for no benefit.
Empty source columns are omitted rather than stored as "", so the map stays small — for the reference file most records hold 4 entries.
Note that toggl.project and toggl.tags duplicate what the tag name already encodes. That redundancy is intentional: it makes export exact (no " / " splitting guesswork) and gives the future promotion migration an unambiguous source.
Model change
TimeRecord gains public var metadata: [String: String], defaulting to [:] — the same shape as the existing images: [String], which is the precedent to follow throughout:
initgets ametadata: [String: String] = [:]parameter, so every existing call site keeps compiling.CodingKeysgainsmetadata;init(from:)usesdecodeIfPresent(...) ?? [:], matching howimagesandTag.isArchivedalready tolerate older files. Records written before this change decode fine.
Automerge change
Additive, following the existing [String] precedent exactly:
AutomergeUtilities.swift— newextension AutomergeProperty where T == [String: String], storing a.Mapobject (as the[String]extension stores a.List). Read viadocument.mapEntries(obj:), keeping only.Scalar(.String)values; write byputObject(ty: .Map)thenputper key.AutomergeTimeRecord.swift—metadataproperty +setMetadata, wired intoasTimeRecord()andcreate(in:at:record:).AutomergeStorageService.swift:274-276— one addedtry wrapper.setMetadata(record.metadata).
A record written by an older build simply has no metadata key; the getter returns [:]. No migration needed.
CRDT semantics: storing metadata as a .Map means concurrent edits merge per key (two devices setting different keys both survive), which is the desired behavior and strictly better than the whole-value last-writer-wins a serialized JSON string would give. Note the [String] extension’s set deletes and recreates the object wholesale, which discards concurrent list edits; for the metadata map, prefer updating changed keys and deleting removed ones in place, so per-key merge is preserved. Add a test that merges two documents which each set a different metadata key and asserts both survive.
Round-trip
With metadata present, a Toggl -> Minuta -> Toggl cycle is lossless for every column except User and Email (deliberately dropped), for records that came from a Toggl import.
Export reads each column from metadata when available and falls back to the tag name otherwise:
Project/Tags— fromtoggl.project/toggl.tagsif present; otherwise split the tag name on the first" / ".Client/Task/Billable— from metadata; otherwise empty, empty,No.User/Email— always empty.
For records created inside Minuta (no metadata), the fallback applies: a tag name with no " / " goes to Project leaving Tags empty, and a name that happens to contain " / " splits at the first occurrence, which may not be what the user meant. State this in the docs.
Prerequisite: fix CSVParser.parseLine
Shared/Sources/MinutaShared/Utilities/CSVParser.swift is not RFC 4180 correct and will mangle this file:
- Escaped quotes. It toggles
inQuoteson every"with no lookahead, so a field containing""(an escaped quote) desynchronizes the parser for the rest of the line. - BOM. The file starts with
EF BB BF, so the first header field parses as\u{FEFF}User, notUser. - Whitespace stripping. Every field is passed through
trimmingCharacters(in: .whitespaces), which silently mutates quoted content that legitimately begins or ends with a space.
The reference file has 9 rows with commas inside quoted Description fields (the current parser handles those correctly) and non-ASCII content (Cyrillic) that must survive as UTF-8.
Changes, in CSVParser:
- handle
""inside a quoted field as a single literal"; - strip a leading BOM from the first field of the first line (or from the content before parsing);
- only trim whitespace on unquoted fields, preserving quoted content verbatim.
parseLine is shared with the HEY importer, so its existing tests must keep passing. Add parser unit tests for each of the three cases above before touching consumers.
Multi-line quoted fields (a \n inside quotes) are out of scope: parseLine is line-oriented and the reference file has none. Note the limitation in the parser doc comment rather than restructuring the reader.
Implementation
1. Shared: import service
Shared/Sources/MinutaShared/Services/ImportService.swift
ParsedRecord gains metadata: [String: String] (defaulted to [:], so the HEY path is unchanged) alongside its existing startTime, endTime, categoryName, notes. Both the CLI and SettingsSheet construct TimeRecords from ParsedRecord, so both must pass it through.
Add TogglImportService: ImportServiceProtocol. The protocol currently declares only parseHEYCSV; widen it to a format-neutral entry point (e.g. parse(_:existingTags:)) and keep parseHEYCSV as the HEY implementation, so SettingsSheet and ImportCommand can hold either service behind the protocol.
Parsing rules:
- Locate columns by header name, not by index. Toggl varies column order between export configurations; hardcoded indices would break silently.
- Combine
Start date+Start timeandEnd date+End timeintoDates. Add atogglCSVverbatim format style (yyyy-MM-dd HH:mm:ss) toDateFormatters, parallel to the existingheyCSV. - Ignore
Durationentirely - recompute from start/end, matching what the HEY importer already does. - Build the tag name per the mapping above; create tags for names not already present (case-insensitive), coloring via
TagColorGenerator.color(for:). - Populate
metadataper the key list above, omitting empty source columns and never storingUser/Email. Unknown extra columns (Toggl adds some depending on export settings) are carried intometadataundertoggl.<lowercased header>rather than dropped. - Skip rows that fail to parse a start or end timestamp, and rows where
endTime <= startTime(TimeRecord.validateTimeRange). Count and report skips rather than failing the whole import. - Timestamps are wall-clock with no zone; parse in
Calendar.current/local time, same as HEY.
2. Shared: export
Shared/Sources/MinutaShared/Models/ExportOptions.swift - add a CSV layout selector:
public enum CSVLayout: String, CaseIterable, Sendable {
case minuta // Date,Start Time,End Time,Duration,Tag,Comment
case toggl // 13-column Toggl layout
} Default .minuta so existing callers and the current export UI are unaffected.
Shared/Sources/MinutaShared/Services/ExportService.swift - branch on the layout. The Toggl branch ignores includeColumns (the layout is fixed at 13 columns) and emits:
User,Email- always emptyClient,Task,Billable- frommetadata, falling back to empty/empty/NoProject,Tags- frommetadata, falling back to a split of the tag name on the first" / "Description-commentStart date/Start time/End date/End time-yyyy-MM-dd/HH:mm:ssDuration-HH:mm:ss
Every field goes through csvParser.escapeField. Note the existing bug that the Minuta branch escapes only comment and not tag - fix that while here, since a tag name now routinely contains " / " and could contain a comma.
Running records: Toggl has no representation for an open entry. Honor includeRunning by skipping them in the Toggl layout regardless, and report the count.
3. CLI
Shared/Sources/MinutaCLI/Commands/ImportCommand.swift - add a toggl subcommand mirroring hey, including --dry-run and --resume. The --resume key is the (start, end, tag-name) tuple already used by the HEY path; it works unchanged because the composed tag name is deterministic.
Shared/Sources/MinutaCLI/Commands/ExportCommand.swift - add --layout <minuta|toggl> to export csv.
minuta import toggl entries.csv [--dry-run] [--resume]
minuta export csv --layout toggl -o entries.csv 4. App UI
Minuta/Sources/Views/Settings/SettingsSheet.swift currently hardcodes HEYImportService and a single “Import from HEY” button. Add “Import from Toggl” alongside it, routing to the same import path with the Toggl service. parseAndImportHEYCSV becomes format-parameterized.
Export UI: add a Toggl option to the CSV export path. Keep .minuta as the default so the existing flow is unchanged.
Tests
Shared package (swift test):
CSVParser- escaped"", BOM, quoted-field whitespace preservation, plus the existing cases.TogglImportService- header-name lookup with reordered columns; the four tag-composition cases;Durationignored in favor of computed duration; malformed-row skipping; Cyrillic content preserved; commas inside quotedDescription; metadata keys populated, empty columns omitted,User/Emailabsent, unknown columns carried.TimeRecordCodable - a record JSON withoutmetadatadecodes to[:]; round-trips with entries.AutomergeTimeRecord- metadata survives write/read; a document written without the key reads[:]; merge test - two documents each setting a different metadata key merge to both keys present.DefaultExportServicewith.toggl- 13-column header, metadata-sourced columns, tag-name-split fallback when metadata is absent, escaping of names containing" / "and commas, running records skipped.- Round-trip: import the reference fixture, export as
.toggl, re-import, assert record count, timestamps, comments, tag names, and all 11 non-identity columns are stable.
CLI integration tests (Shared/Tests/MinutaCLITests/) - import toggl against a temp storage folder, --dry-run writes nothing, --resume is idempotent across two runs.
Fixture: a trimmed copy of the reference file (~20 rows) covering an empty project, an empty tag, both empty, a comma inside Description, and Cyrillic text. Do not commit the full personal file - it contains a real name and email in User/Email.
Docs to update
minuta.tools/src/routes/docs/300-services/311-cli/+page.md- Import and Export sections.- Root
CLAUDE.md- the “Import/Export” section, the CLI command list, and “Architecture > Models” forTimeRecord.metadata. minuta.tools/src/routes/docs/architecture pages describing the Automerge record schema, for the newmetadatamap.minuta.tools/src/routes/docs/900-log/910-backlog/+page.md- tick the entry on completion.
Sequencing
- Fix
CSVParser+ tests (blocks everything; touches the HEY path, so verify it first). TimeRecord.metadata+ Automerge map property + wrapper/storage wiring + Codable and merge tests. Purely additive and independent of Toggl - lands on its own.TogglImportService+DateFormatters.togglCSV+ParsedRecord.metadata+ tests.- CLI
import toggl+ integration tests. - Export
.toggllayout + tests + round-trip test. - CLI
export csv --layout. - App UI for both directions.
- Docs.
Steps 3-4 and 5-6 are independently shippable; commit after each.
Deferred (not this task)
The metadata is written but inert. Promoting it into the UI is separate follow-up work, to be filed in the backlog when this lands:
- Billable flag - a
BoolonTimeRecordplus a checkbox in the record editor; migration readstoggl.billablefor existing records. - Client/Project as tag concepts - whatever structure this takes (tag groups, hierarchy, or a second tag axis), the migration source is
toggl.client/toggl.project, which is exactly why they are stored redundantly with the flattened name.
Until then: metadata does not appear in the editor, does not participate in filters or reports, and is not searchable.