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:

ColumnFilledDistinctNotes
User547/5471constant, no information
Email547/5471constant, no information
Client0/5470always empty
Project453/5475avito-pii, avito-general, avito-edu, avito-hr, avito-onboarding
Task0/5470always empty
Description544/547109free text, maps to comment
Billable547/5471always No
Start date547/547120YYYY-MM-DD
Start time547/547479HH:mm:ss
End date547/547120YYYY-MM-DD
End time547/547504HH:mm:ss
Duration547/547516HH:mm:ss, derivable from start/end
Tags358/5476meeting, investigation, docs, backend, coding, learning

Two facts that drive the design:

  • No row carries more than one Toggl tag. The Tags column is comma-separated in Toggl’s format but is single-valued throughout this file.
  • Project x Tags yields only 18 distinct combinations, including the empty/empty pair.

Decision: flatten the taxonomy, keep the rest as metadata

Two complementary moves:

  1. Project + Tags flatten into a single tag name (below) — the taxonomy that is usable today in filters, reports, and colors.
  2. Every other source column is preserved verbatim in a new metadata: [String: String] on TimeRecord — 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 " / ". Mapping Project alone 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 so avito-* 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:

  • init gets a metadata: [String: String] = [:] parameter, so every existing call site keeps compiling.
  • CodingKeys gains metadata; init(from:) uses decodeIfPresent(...) ?? [:], matching how images and Tag.isArchived already tolerate older files. Records written before this change decode fine.

Automerge change

Additive, following the existing [String] precedent exactly:

  • AutomergeUtilities.swift — new extension AutomergeProperty where T == [String: String], storing a .Map object (as the [String] extension stores a .List). Read via document.mapEntries(obj:), keeping only .Scalar(.String) values; write by putObject(ty: .Map) then put per key.
  • AutomergeTimeRecord.swiftmetadata property + setMetadata, wired into asTimeRecord() and create(in:at:record:).
  • AutomergeStorageService.swift:274-276 — one added try 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 — from toggl.project/toggl.tags if 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:

  1. Escaped quotes. It toggles inQuotes on every " with no lookahead, so a field containing "" (an escaped quote) desynchronizes the parser for the rest of the line.
  2. BOM. The file starts with EF BB BF, so the first header field parses as \u{FEFF}User, not User.
  3. 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 time and End date + End time into Dates. Add a togglCSV verbatim format style (yyyy-MM-dd HH:mm:ss) to DateFormatters, parallel to the existing heyCSV.
  • Ignore Duration entirely - 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 metadata per the key list above, omitting empty source columns and never storing User/Email. Unknown extra columns (Toggl adds some depending on export settings) are carried into metadata under toggl.<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 empty
  • Client, Task, Billable - from metadata, falling back to empty/empty/No
  • Project, Tags - from metadata, falling back to a split of the tag name on the first " / "
  • Description - comment
  • Start date/Start time/End date/End time - yyyy-MM-dd / HH:mm:ss
  • Duration - 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; Duration ignored in favor of computed duration; malformed-row skipping; Cyrillic content preserved; commas inside quoted Description; metadata keys populated, empty columns omitted, User/Email absent, unknown columns carried.
  • TimeRecord Codable - a record JSON without metadata decodes 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.
  • DefaultExportService with .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” for TimeRecord.metadata.
  • minuta.tools/src/routes/docs/ architecture pages describing the Automerge record schema, for the new metadata map.
  • minuta.tools/src/routes/docs/900-log/910-backlog/+page.md - tick the entry on completion.

Sequencing

  1. Fix CSVParser + tests (blocks everything; touches the HEY path, so verify it first).
  2. TimeRecord.metadata + Automerge map property + wrapper/storage wiring + Codable and merge tests. Purely additive and independent of Toggl - lands on its own.
  3. TogglImportService + DateFormatters.togglCSV + ParsedRecord.metadata + tests.
  4. CLI import toggl + integration tests.
  5. Export .toggl layout + tests + round-trip test.
  6. CLI export csv --layout.
  7. App UI for both directions.
  8. 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 Bool on TimeRecord plus a checkbox in the record editor; migration reads toggl.billable for 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.