Skip to main content
TimeRetain
Open App

Import your data into TimeRetain

TimeRetain imports time data from a JSON file. The fastest way to get one is to have an AI assistant convert your existing data for you.

  1. Export your data from your current tool as CSV or a spreadsheet.
  2. Send that file to an AI assistant with the prompt below to produce a TimeRetain JSON file.
  3. Open Data & Sync, choose Import, and upload the JSON file.

Re-importing is safe: existing entries are updated, not duplicated.

Agent prompt
Convert the attached file to TimeRetain v0.12 JSON with the specification below.
Use deterministic IDs, which stay the same for the same input.
Derive each ID from a stable source row key.
Repeated imports then update existing entries instead of duplicating them.
Import the resulting file through Data & Sync -> Import.

## TimeRetain v0.12 JSON import

Use this specification to convert external time tracking data into a TimeRetain JSON file.

## Conversion recipe

Follow these steps:

1. Choose a stable external row key, such as the original row ID. If none exists, build one from stable source columns. These can include date, start time, end time, client, project, task, and description.
2. Derive each TimeRetain ID from `first 16 bytes of SHA-256(utf8(externalId))`. Encode it as URL-safe Base64 without padding. Use distinct seeds for related rows. Examples are `stopwatch:<externalId>`, `interval:<externalId>`, `timeline:<client>/<project>`, `tag:<tagName>`, and `tagStopwatch:<externalId>:<tagName>`.
3. Set the envelope `version` to `"0.12"`. The envelope is the outer object around `data`. Set its `timestamp` to an ISO-8601 string.
4. Include all seven arrays inside `data` in this order: `interval`, `preferences`, `stopwatch`, `tag`, `timeline`, `tagStopwatch`, `exportOptions`. Arrays can be empty except `preferences` and `timeline`. `exportOptions` is usually `[]`.
5. Represent clients, projects, workspaces, accounts, and other billing or reporting groups as `timeline` rows. Group related timelines under broader timelines. Set each stopwatch's `timelineId` to the best matching specific timeline.
6. Represent activity labels such as meetings, coding, writing, admin, research, and support as flat `tag` rows. Tags cannot form groups in v0.12.
7. Use Unix time in milliseconds for `interval.start` and `interval.end`. If the source uses local dates or times, apply its timezone before conversion. Only for entries still in progress, use `null` for `interval.end`.
8. Use `0` or `1` for every `Bool` field. Do not use JSON booleans.
9. Compute `checksum` as lowercase hex SHA-256 of `JSON.stringify(data)`. Here, `data` is the inner object with the key order above. Do not include envelope fields in the checksum.

The import inserts new rows or updates existing rows by `id`.
Deterministic IDs make repeated imports safe from duplicates.

## Timeline rules

Apply these rules:

- If the source has no grouped client or project timelines, include a default Personal timeline row.
- A timeline outside a group is already a billing boundary, a group that can bill independently. Only when a grouped timeline can bill independently, set `isBillingBoundary: 1`.
- Set `parentTimelineId` to the `timeline.id` of the parent group, or `null` when there is no parent. Do not create grouping cycles or references to missing timelines.
- Archived timelines remain valid for history but cannot be selected for new entries. For normal imports, use `archivedAt: null`.
- Archived tags remain valid for history but cannot be selected for new entries. For normal imports, use `archivedAt: null`.
- For default project/client rates, use `timeline.hourlyRate`. Put the resolved rate for each entry on `stopwatch.hourlyRate`.
- When `stopwatch.hourlyRate` is non-null, set `rateCurrencyCodeAtWrite` to the workspace currency for the import. When the entry has no resolved rate, set both fields to `null`.
- Only when an entry intentionally belongs to No timeline, use `timelineId: null`.
- Set `preferences.defaultTimelineId` and `preferences.lastUsedTimelineId` to an existing timeline that is not archived, usually Personal.

## Types

Use these value types:

- `Id` is a 22-character URL-safe Base64 string without padding. It uses `A-Z`, `a-z`, `0-9`, `-`, and `_`, and encodes 16 bytes.
- `Bool` is `0` or `1`, never `false` or `true`.
- Row timestamps (`interval.start`, `interval.end`) use Unix time in milliseconds. The envelope `timestamp` is an ISO-8601 string. All numbers must be finite.

## Envelope

```json
{
  "version": "0.12",
  "timestamp": "<ISO-8601 string>",
  "checksum": "<sha256 hex of JSON.stringify(data)>",
  "data": {
    "interval": [],
    "preferences": [],
    "stopwatch": [],
    "tag": [],
    "timeline": [],
    "tagStopwatch": [],
    "exportOptions": []
  }
}
```

Include all seven keys inside `data` in this exact order: `interval`, `preferences`, `stopwatch`, `tag`, `timeline`, `tagStopwatch`, `exportOptions`.

## Schema

```ts
interval: {
  id: Id;
  stopwatchId: Id; // -> stopwatch.id
  start: number; // Unix ms
  end: number | null; // Unix ms, or null if in progress
}

preferences: {
  // exactly one row
  id: Id;
  useMilitaryTime: Bool;
  mergeOverlappingStopwatches: Bool; // legacy field; usually 0
  useAdjust: Bool;
  adjustDefault: number; // minutes; may be 0 or negative
  backupReminderIntervalInDays: number;
  currencyCode: string; // ISO 4217, for example "USD"
  earningsTrackingEnabled: Bool;
  defaultHourlyRate: number | null;
  weekStartsOn: number; // 0 = Sunday ... 6 = Saturday
  alternativeColor: string | null; // Open Color token, or null for gray
  defaultTimelineId: Id | null; // -> timeline.id
  lastUsedTimelineId: Id | null; // -> timeline.id
  forgottenRemindersMode: "both" | "start" | "stop" | "off";
}

stopwatch: {
  id: Id;
  description: string;
  adjustMinutes: number; // integer; may be negative
  isActive: Bool;
  isExpanded: Bool;
  hourlyRate: number | null; // stored rate for this stopwatch
  rateCurrencyCodeAtWrite: string | null; // workspace currency when rate was stored
  isHourlyRateManual: Bool | null;
  targetMinutes: number | null;
  timelineId: Id | null; // -> timeline.id; null means No timeline
}

tag: {
  id: Id;
  name: string;
  hourlyRate: number | null;
  color: string | null; // Open Color token like "blue.6", or null
  isPinned: Bool | null; // pinned in the tag list; null is treated as pinned
  archivedAt: number | null; // Unix ms when archived; null keeps the tag active
}

timeline: {
  id: Id;
  name: string;
  parentTimelineId: Id | null; // -> timeline.id grouped under, or null when not grouped
  hourlyRate: number | null;
  isBillingBoundary: Bool;
  archivedAt: number | null; // Unix ms when archived; null keeps the timeline active
}

tagStopwatch: {
  id: Id;
  tagId: Id; // -> tag.id
  stopwatchId: Id; // -> stopwatch.id
}

exportOptions: {
  // at most one row; most imports use an empty array instead
  // Prefer id "4WnR17vMjH50XmwttC218Q" (the app singleton). Other ids are
  // rewritten to that value on import.
  id: Id;
  format: "csv";
  explodeIntervals: 0 | 1;
  explodeTags: 0 | 1;
  durationBreakdown: 0 | 1;
  roundTarget: "off" | "interval" | "total";
  roundIncrement: "5" | "6" | "10" | "15" | "30" | "60";
  roundDirection: "nearest" | "up" | "down";
  timeColumns: "utc" | "split";
}
```

## Checksum

`envelope.checksum` is the lowercase hex SHA-256 digest of `JSON.stringify(data)`.
Here, `data` is the inner object, not the whole envelope.

JavaScript:

```js
import { createHash } from "node:crypto";

const checksum = createHash("sha256")
  .update(JSON.stringify(data))
  .digest("hex");
```

Python:

```python
import hashlib
import json

payload = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
checksum = hashlib.sha256(payload.encode("utf-8")).hexdigest()
```

## Minimal example shape

```json
{
  "version": "0.12",
  "timestamp": "2026-04-17T12:00:00.000Z",
  "checksum": "3149c7d5240911d6fe1112839e7511b43d4b8e93500a3173c309771769c83f3a",
  "data": {
    "interval": [
      {
        "id": "YpC0F378hvhs9cwPrPZqjA",
        "stopwatchId": "uHDqdpI_ehZ1fWTWH7I1dw",
        "start": 1763884800000,
        "end": 1763890200000
      }
    ],
    "preferences": [
      {
        "id": "NymWULO4G6GjGMp6_e5MOA",
        "useMilitaryTime": 0,
        "mergeOverlappingStopwatches": 0,
        "useAdjust": 0,
        "adjustDefault": 0,
        "backupReminderIntervalInDays": 7,
        "currencyCode": "USD",
        "earningsTrackingEnabled": 1,
        "defaultHourlyRate": null,
        "weekStartsOn": 1,
        "alternativeColor": null,
        "defaultTimelineId": "nM-FuMhKJ-Qc34ihF_5GAQ",
        "lastUsedTimelineId": "nM-FuMhKJ-Qc34ihF_5GAQ",
        "forgottenRemindersMode": "both"
      }
    ],
    "stopwatch": [
      {
        "id": "uHDqdpI_ehZ1fWTWH7I1dw",
        "description": "Client work",
        "adjustMinutes": 0,
        "isActive": 0,
        "isExpanded": 0,
        "hourlyRate": 125,
        "rateCurrencyCodeAtWrite": "USD",
        "isHourlyRateManual": 0,
        "targetMinutes": null,
        "timelineId": "C_MCKuU1uvIkXqlM2cuvvA"
      }
    ],
    "tag": [
      {
        "id": "-PpIeZugec_a_57XR7os3A",
        "name": "writing",
        "hourlyRate": null,
        "color": null,
        "isPinned": null,
        "archivedAt": null
      }
    ],
    "timeline": [
      {
        "id": "nM-FuMhKJ-Qc34ihF_5GAQ",
        "name": "Personal",
        "parentTimelineId": null,
        "hourlyRate": null,
        "isBillingBoundary": 0,
        "archivedAt": null
      },
      {
        "id": "C_MCKuU1uvIkXqlM2cuvvA",
        "name": "Client A",
        "parentTimelineId": null,
        "hourlyRate": 125,
        "isBillingBoundary": 0,
        "archivedAt": null
      }
    ],
    "tagStopwatch": [
      {
        "id": "Z5XfUVYDOcXMuFH02iHMxQ",
        "tagId": "-PpIeZugec_a_57XR7os3A",
        "stopwatchId": "uHDqdpI_ehZ1fWTWH7I1dw"
      }
    ],
    "exportOptions": []
  }
}
```

## Common mistakes

Avoid these errors:

- Do not use `true` or `false` for `Bool` fields. Use `0` or `1`.
- Do not use seconds for `interval.start` or `interval.end`. Use milliseconds.
- Do not use ISO strings for row timestamps. Only the envelope `timestamp` is an ISO string.
- Do not add a `timezone` field to the envelope. Conversion can require the source timezone, but TimeRetain stores row times as Unix milliseconds.
- Do not omit the checksum. Compute it from the inner `data` object, not the whole envelope.
- Do not pretty-print or alphabetically sort `data` before computing the checksum. Use the exact object key order above.
- Do not omit any of the seven `data` arrays, even when empty.
- Do not group clients or projects with tags. Use `timeline` rows to group them under related timelines.
- Do not add `tag.parentTagId`. Tags cannot form groups.
- Do not create timelines with missing group targets or grouping cycles.
- If the source has a known client, project, account, or workspace, set `stopwatch.timelineId`.
- For tagged stopwatches, create matching `tagStopwatch` rows.