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

Re-checked 2026-10-16 against a fresh copy of the same file (547 rows, 2026-01-12 to 2026-07-22, same counts as above):

  • 3 rows have zero duration (end equals start); TimeRecord.validateTimeRange rejects them.
  • No field contains "", a newline or leading/trailing spaces; 126 descriptions contain Cyrillic. Of the CSVParser problems below only the BOM affects this file.
  • No row crosses midnight; Duration always equals end minus start.
  • Against the user’s storage: no row matches an existing record exactly, 3 start within 2 minutes of one, 134 overlap one in time (see Overlaps below).
  • Existing tags in the user’s storage use a prefix/name scheme without spaces (i/modulate, t/app, r/minuta); decided: the tag is Project alone (below).
  • 3 zero-duration rows: decided to import them as 1-second records (below).

Overlaps with the user’s records

Toggl times read as local time (UTC+5). 137 Toggl rows overlap a Minuta record of January-July 2026. 32 of them only because of 7 Minuta records 14-33 hours long (timers left running overnight: ov/work, r/minuta, r/sveleton, r/website, r, viza stuff). The other 105 rows overlap ordinary records for 165 of the file’s 913 hours, mostly t/app, t/magazine, ov/work, i/modulate: other work tracked in Minuta during the same hours, not the same entries (no row matches a record’s start and end). Overlapping records are valid in Minuta, so the import adds them as they are; it does no duplicate detection beyond --resume.

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: tag from Project, the rest as metadata

Decided 2026-10-16 (replaces the earlier Project + Tags flattening):

  1. Project alone becomes the tag name - the taxonomy that is usable today in filters, reports, and colors. An empty Project imports untagged (tagId = nil). The reference file yields 5 tags (avito-pii, avito-general, avito-onboarding, avito-edu, avito-hr).
  2. Every other source column is preserved verbatim in a new metadata: [String: String] on TimeRecord, the Toggl Tags column included (toggl.tags) - 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/Toggl tags become first-class 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.

Why Project alone:

  • No schema change to the tag model. tagId stays single-valued; filters, reports, editor UI and the CLI stay untouched. Multi-tag support (tagId -> tagIds: [UUID]) would be an app-wide refactor plus a migration for every existing record.
  • Few, legible tags. 5 tags instead of the 18 Project x Tags combinations; the user’s existing tags follow a prefix/name scheme, and a composed "avito-pii / docs" name would not.
  • Export stays exact for imported records: Tags comes back from toggl.tags.

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 duplicates the tag name. That redundancy is intentional: the tag can be renamed in Minuta, and the export and a future promotion migration still have the original value.

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.swift — metadata 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 — from toggl.project if present; otherwise the tag name.
  • Tags — from toggl.tags; otherwise empty.
  • Client/Task/Billable — from metadata; otherwise empty, empty, No.
  • User/Email — always empty.

For records created inside Minuta (no metadata), the fallback applies: the tag name goes to Project and Tags is empty. 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. A zero-duration row (endTime == startTime, 3 in the reference file) is imported with endTime = startTime + 1s, since TimeRecord.validateTimeRange rejects an empty range (decided 2026-10-16); the import report counts them. 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 - from metadata, falling back to the tag name; Tags - from metadata, falling back to empty
  • 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 (the Minuta branch already escapes every field, tag names included).

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 (decided 2026-10-16/17): the Toggl export lives in Settings only. In the Data section the export button is preceded by a dropdown (Picker, menu style) choosing the format: CSV (the Minuta layout, .minuta, default) or Toggl; the button opens ExportView (date range) for the chosen layout. The History section’s export (save/share) stays the Minuta layout. 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; tag from Project (empty Project imports untagged, Tags kept in metadata only); zero-duration row imported as 1 second; 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 fallback when metadata is absent, escaping of names containing 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.