# The `.pumapack` file format (PumaTimer, schema 2)

This document describes PumaTimer's `.pumapack` backup files in enough detail
to **edit one** or **generate one from scratch** so that it imports cleanly.
The app opens the result with no warnings, nothing renumbered and nothing
moved. It is written for a reader, human or AI, who has no access to the
app's source.

A `.pumapack` is a UTF-8 JSON file. PumaTimer writes one from the topbar
**Export** button (or <kbd>⌘/Ctrl</kbd>+<kbd>S</kbd>), named
`pumatimer-backup.pumapack`, and reads one back in either of two ways:

- from the topbar **Import** button;
- by dropping the file anywhere on the window.

**Importing a backup replaces every presentation in the app.** It never adds
to or merges with what is already there, and it does not ask first. To add a
presentation to someone's existing set, start from their full export, append
the new presentation to it, and import the whole file.

---

## 1. The short version

If you only read one section, read this one.

1. Wrap your presentations in the envelope from §2. Import reads
   `data.workspaces`. A file without it is **not refused**: it silently
   replaces everything with the app's sample presentation. See §8.
2. Name the file `.pumapack` or `.json`. A file ending in `.md`, `.txt`,
   `.markdown` or `.outline` is read as a text outline, not as a backup.
3. One presentation is one object in `data.workspaces` (§3). Its running
   order is a tree in `plan` (§4): a root, then **sections** and **items**,
   **two levels at most**.
4. Write **every field** of every node, using the shapes in §4. Durations are
   **milliseconds** (10 minutes is `600000`). Clock times are **minutes since
   midnight** (09:30 is `570`). Never write a time as a string.
5. Items that belong to no section must come **before** the first section.
   An item after a section belongs to that section.
6. Give sections `"plannedMs": null`. A section's length is the sum of its
   items.
7. Set the day's start (`startsAt` on the presentation) and make any pinned
   time (`startsAt` on an item or break) equal to the time the running order
   actually reaches it. §6 shows the arithmetic.
8. Every `id` must be unique within the presentation, and every `slug` unique
   within the file. `cursorId` names the first item.
9. Write a fresh, idle presentation: no run record (§7.2).
10. Check the result against the checklist in §9.

§11 is a complete, valid example you can copy and adapt.

---

## 2. The envelope

```json
{
  "$schema": "https://greykit.com/schema/pumapack-v1.json",
  "puma": { "app": "pumatimer", "schema": 2, "exported_at": "2026-10-05T08:00:00.000Z" },
  "data": {
    "workspaces": [ { "...one presentation object, see §3..." } ],
    "activeSlug": "analyst-onboarding"
  }
}
```

| Key | Value | Notes |
|---|---|---|
| `$schema` | `"https://greykit.com/schema/pumapack-v1.json"` | Not read on import. Write it; the app does. |
| `puma.app` | `"pumatimer"` | If present and different, the import is refused (below). |
| `puma.schema` | `2` | The current schema. Not checked on import, but write `2`. |
| `puma.exported_at` | ISO 8601 datetime | Informational. |
| `data.workspaces` | array of presentation objects | One or more. |
| `data.activeSlug` | a `slug` from `data.workspaces` | The presentation shown after import. If it matches none, the first one is shown. |

What the importer actually does:

- The file is not valid JSON: refused with *"Not valid JSON"*. Nothing
  changes.
- `puma.app` is set to anything other than `pumatimer`: refused with *"This
  backup is from pumaplanner, not PumaTimer"* (with the app named in the
  file). Nothing changes. The one exception is `pumattx`, see §10.
- Otherwise the presentations are read from `data.workspaces`, or from a
  top-level `workspaces` array, or from a top-level array of presentations.
- If none of those holds a non-empty array, **the import still succeeds**:
  every presentation is replaced by the app's built-in sample ("Threat
  Modelling Workshop"), and the toast says *"Imported 1 presentation(s)"*.
  A bare presentation object with no wrapper does exactly this.
- On success the toast is *"Imported N presentation(s)"*, where N is the
  number of presentations in the file. There is no confirmation step.
- The imported file is taken as the whole state. The undo history is
  cleared.

---

## 3. The presentation object

The app calls a presentation a *workspace* in the file, and shows each one as
a tab.

```json
{
  "slug": "analyst-onboarding",
  "name": "Analyst onboarding day",
  "accent_color": "#4a90e2",
  "created_at": "2026-10-05T08:00:00.000Z",
  "updated_at": "2026-10-05T08:00:00.000Z",
  "view": "plan",
  "plan": { "...the root node, see §4..." },
  "cursorId": "welcome",
  "running": false,
  "runStartedAt": null,
  "finishedAt": null,
  "editedWhileRunning": false,
  "startsAt": 540,
  "stopAt": 795,
  "hardStopAt": 810,
  "autoAdvance": true,
  "zones": { "debtRedMs": 300000 },
  "zoneColors": null
}
```

| Field | Type | Notes |
|---|---|---|
| `slug` | string | The presentation's identity. Must be unique in the file; the app does not check, and when two share a slug, selecting either tab shows the first. Short lower-case words with hyphens work well. The app generates values like `d-mulx8lki-0qkdu`. |
| `name` | string | The tab label, and the heading of the exported run-sheet. Empty or missing becomes `"Presentation"`. |
| `accent_color` | `"#rrggbb"` | The color of the tab and of ordinary items on the dial. Missing or empty becomes `"#4a90e2"`. Write a plain hex color. |
| `created_at`, `updated_at` | ISO 8601 datetime | Kept exactly as written. The app sets `updated_at` again when the presentation is next edited. |
| `view` | `"paste"`, `"plan"` or `"run"` | The screen the presentation opens on. Anything else becomes `"plan"`. A presentation with no items opens on Paste unless this is `"plan"`. |
| `plan` | object | The root of the running order. See §4. |
| `cursorId` | an item `id`, or `null` | The item the run starts from. Write the first item's id. If it names no item, the app uses the first item. |
| `running`, `runStartedAt`, `finishedAt`, `editedWhileRunning` | run state | Write `false`, `null`, `null`, `false`. See §7.2. |
| `startsAt` | minutes since midnight, or `null` | When the day starts, shown as **Starts** on the Plan screen. Every calculated Start and End time counts from it. **Set it.** |
| `stopAt` | minutes since midnight, or `null` | When you must stop presenting, shown as **Stops**. The Plan reports *"contingency"* (time to spare) or *"past the stop"* against it. |
| `hardStopAt` | minutes since midnight, or `null` | When you must be out of the room, shown as **Out of the room**. Display only; it enters no calculation. |
| `autoAdvance` | boolean | `true` moves the run on to the next item by itself when an item's time runs out. It never advances off a break. Missing becomes `true`. |
| `zones` | `{ "debtRedMs": number }` | How far behind (in ms) the Bank readout goes from *debt* to *deep debt* during a run. Default `300000` (5 minutes). Other keys are dropped. |
| `zoneColors` | `null` or `{ "green": "#rrggbb", "red": "#rrggbb" }` | Custom dial colors. `null` follows the theme. Write `null`. |

The three clock fields are whole numbers from `0` to `1439`. A value outside
that range, or a string such as `"09:00"`, becomes `null`. Decimals are
rounded.

**Any other key on a presentation is silently dropped.**

---

## 4. The plan tree

`plan` is a tree of **nodes**. The root holds the running order. Below it
there are only two levels:

```
root
├── item          (an item before the first section belongs to no section)
├── section
│   ├── item
│   ├── break
│   └── buffer
└── section
    └── item
```

- **Sections** are headings that group the items after them. They never
  carry time of their own.
- **Items** are the things the clock runs through, one at a time, in array
  order.
- A **break** and a **buffer** are items with a `kind` (§4.4, §4.5).

Array order is running order. The app reads the tree as rows, top to
bottom, and puts each item into **the last section above it**. So:

- An item placed in `root.children` **after** a section is moved into that
  section on import.
- A node with children that sits inside a section (a third level) is turned
  into a section of its own, and every item after it, up to the next
  section, moves into it. The section it came from is left behind, possibly
  empty.

### Conventions for every node

- **`id`** is any non-empty string, unique within the presentation. The app
  generates ids like `n-mulx8lkh-5mlh3`. Short readable ids (`welcome`,
  `s-hands`) work just as well. Ids need not be unique across presentations.
- **Write every field.** A missing field falls back to its default, but the
  defaults below are the only values that give an exact round trip.
- **`kind` values are case-sensitive and closed.** Anything other than the
  values listed here becomes `null`, which makes a node an ordinary item.

### 4.1 Every node

```json
{
  "id": "tooling", "title": "Tour of the tooling",
  "plannedMs": 1800000, "startsAt": null, "endsAt": null, "kind": null,
  "leafMs": 1800000, "children": [],
  "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending"
}
```

| Field | Type | Meaning |
|---|---|---|
| `id` | string | See above. |
| `title` | string | Shown on the Plan, the dial and the run-sheet. Empty becomes `"Untitled"`. |
| `plannedMs` | number (ms) or `null` | An item's planned length. `null` on sections and on the root. See §6. |
| `startsAt` | minutes since midnight, or `null` | A pinned start. See §6. |
| `endsAt` | minutes since midnight, or `null` | A pinned end. See §6. |
| `kind` | `null`, `"section"`, `"break"` or `"buffer"` | What the node is. |
| `leafMs` | number (ms) | On an item, the same value as `plannedMs`; the app sets it on every import. On a section or the root, `0`. |
| `children` | array of nodes | `[]` on an item. **Must be an array.** |
| `actualMs`, `startedAt`, `startedFirstAt`, `status` | run record | Write `0`, `null`, `null`, `"pending"`. See §7.2. |

### 4.2 The root

```json
{
  "id": "root", "title": "Analyst onboarding day",
  "plannedMs": null, "startsAt": null, "endsAt": null, "kind": null,
  "leafMs": 0, "children": [ "...sections and items..." ],
  "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending"
}
```

The root is a container, never an item. Its `title` is usually the
presentation's name (the app writes `"Plan"` for a plan built in the table).
`plan.children: []` is a valid, empty presentation.

### 4.3 A section

```json
{
  "id": "s-soc", "title": "How the SOC works",
  "plannedMs": null, "startsAt": null, "endsAt": null, "kind": "section",
  "leafMs": 0, "children": [ "...items..." ],
  "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending"
}
```

- `kind` is `"section"`. Any node with children is treated as a section,
  and the app writes `"section"` onto it.
- A section with `children: []` is an empty heading. It costs no time.
- **`plannedMs: null`.** A section's length is always the sum of its items.
  If you give a section a `plannedMs` larger than its items add up to, the
  app adds an item titled `"Buffer"` at the end of the section holding the
  difference, with an id of its own. If it is smaller, it is ignored.
- A section may carry `startsAt` (and `endsAt`). The app then copies that
  time onto the section's first item (and `endsAt` onto its last), marked
  `"anchorInherited": true`. It is simpler to pin the item itself.

### 4.4 An item

The shape in §4.1, with `kind: null`.

- `plannedMs` is a positive number of milliseconds. Whole minutes
  (multiples of `60000`) read best, because the Plan shows minutes.
- If `plannedMs` is missing or not a number, the app uses `leafMs` if that
  is above 0, and otherwise 5 minutes.

### 4.5 A break

```json
{ "id": "lunch", "title": "Lunch", "plannedMs": 2700000, "startsAt": 720, "endsAt": null,
  "kind": "break", "leafMs": 2700000, "children": [], "actualMs": 0,
  "startedAt": null, "startedFirstAt": null, "status": "pending", "isBreak": true }
```

- `kind` is `"break"`. Also write `"isBreak": true`; the app derives it
  from `kind` on import anyway.
- Breaks are painted in the break color and never auto-advance: the
  presenter moves off a break by hand.
- There are three kinds of break, told apart by their times:

| Break | `startsAt` | `endsAt` | Behavior |
|---|---|---|---|
| Floating | `null` | `null` | Happens wherever it sits in the running order. |
| Pinned | a time | `null` | Happens at that time. When the plan is next edited, the app moves it to the point in the running order where the clock reaches that time, splitting an item in two if the time falls inside one (§7.3). |
| Windowed | a time | a time | Happens between the two times. Its length **is** the window: `plannedMs` is overwritten with `(endsAt − startsAt)` minutes. Also moved by time, like a pinned break. |

### 4.6 A buffer

```json
{ "id": "slack", "title": "Room to overrun", "plannedMs": 600000, "startsAt": null, "endsAt": null,
  "kind": "buffer", "leafMs": 600000, "children": [], "actualMs": 0,
  "startedAt": null, "startedFirstAt": null, "status": "pending", "isBuffer": true }
```

- `kind` is `"buffer"`, with `"isBuffer": true`. It is spare time you have
  planned in, shown as a faint row.
- It counts as working time, not as a break.
- A buffer the app added itself (§4.3) has `kind: null`, title `"Buffer"`
  and `"isBuffer": true`. Keep those as they are.

---

## 5. Cross-references

There are only two:

| From | Field | To |
|---|---|---|
| file | `data.activeSlug` | a presentation's `slug` in the same file |
| presentation | `cursorId` | an item `id` in that presentation's `plan` (not a section, not the root) |

A reference that matches nothing is not an error: the app falls back to the
first presentation, or the first item. Items also refer to each other
through `splitOf` (§7.3), which only the app writes.

---

## 6. Time: durations, pinned times and the shape of the day

- **Durations** are milliseconds: 1 minute is `60000`, 45 minutes is
  `2700000`, 1 hour is `3600000`.
- **Clock times** are minutes since midnight in the presenter's local time:
  09:00 is `540`, 12:00 is `720`, 13:15 is `795`. They carry no date and no
  time zone.
- **How the day is laid out.** The Plan's Start and End columns are
  calculated. The first item starts at the presentation's `startsAt`. Each
  item after it starts when the one before it ends, **unless** it is pinned
  to a later time with its own `startsAt`, in which case it starts then.
  - A pinned `startsAt` is "not before". If the items above it run later
    than that time, it starts late.
  - An item with `endsAt` must be over by then. If the running order would
    carry it past, it is cut short.
  - An item with both is a window, and its length is the window (§4.5).
  - A window whose `endsAt` is at or before its `startsAt` runs past
    midnight.
- **What the Plan flags.** When the time above a pinned item does not match
  its pin, the Plan inserts a divider row above it: *"10m free"* (time
  nobody planned for) or *"30m over"* (the items above it overrun the pin).
  A pinned item that starts late is marked *late*, one cut short is marked
  *cut*, and a window that is blown through entirely is marked *missed*. Two
  pins that contradict each other (a later row pinned earlier, or pins that
  overlap) are marked red. **A pack that imports with none of these is one
  where every pin equals the running total at that point.**
- **Contingency** is `stopAt` minus the time the last item ends. The Plan
  header says, for example, *"5m contingency"*, or *"10m past the stop"*.

So, when generating a plan:

- set the presentation's `startsAt`;
- give every item a `plannedMs`;
- pin only what is genuinely fixed (lunch, a hard start after a break), and
  make each pin equal to the sum of everything above it;
- put a pinned break at the row where the clock reaches it, never inside
  the time of an item;
- set `stopAt` at or after the last item's end.

Worked through for §11: 09:00 + 15 + 30 + 45 minutes reaches 10:30, which is
where the Coffee window opens.

---

## 7. Editing an existing export

### 7.1 What to keep, what the app sets

| Field | On import |
|---|---|
| `slug`, every `id` | Kept exactly. Never change them; the run position and split pieces refer to ids. |
| `created_at`, `updated_at` | Kept exactly. `updated_at` moves on the next edit in the app. |
| `puma.exported_at` | Ignored. |
| `isBreak` | Recomputed from `kind`. |
| `leafMs` | Set equal to `plannedMs` on every item. |
| `plannedMs` on a windowed item | Set to the window's length. |
| `plannedMs` on a section | Set to `null`, after any buffer is added (§4.3). |
| `splitIndex`, `splitCount` | Dropped, and recomputed at the next edit. |
| Unknown keys, anywhere | Dropped. A pack from an older version may carry `color`, `notes` or `mustEndBy`; these are dropped or converted. |

The app does **not** rearrange anything on import. Pinned breaks are moved
to their time, and items are split around them, only when the plan is next
edited in the app. So an export of an untouched import is the file you gave
it, apart from the fields above.

### 7.2 The run record

A presentation that has been run carries a record of it:

- on the presentation: `running`, `runStartedAt` and `finishedAt` (epoch
  milliseconds or `null`), `editedWhileRunning`;
- on each item: `actualMs` (time spent), `startedAt`, `startedFirstAt`
  (epoch milliseconds or `null`), `status` (`"pending"` or `"done"`), and
  on a break that was running, `returnAt` and `askedMs`.

**Keep these as they are, or reset all of them together.** A run in progress
freezes the shape of the plan: the app refuses to move breaks or split items
until the run is reset. To reset by hand, set on the presentation
`running: false`, `runStartedAt: null`, `finishedAt: null`,
`editedWhileRunning: false`, and on every node `actualMs: 0`,
`startedAt: null`, `startedFirstAt: null`, `status: "pending"`, removing
`returnAt` and `askedMs`. Any `status` other than `"done"` is read as
`"pending"`.

### 7.3 Split items

When a pinned break falls inside an item, the app cuts that item into
pieces with the break between them:

```json
{ "id": "sandbox", "title": "Triage practice in the sandbox (1 of 2)", "plannedMs": 2700000,
  "splitOf": "sandbox", "splitTitle": "Triage practice in the sandbox", "...": "..." },
{ "id": "lunch", "title": "Lunch", "startsAt": 690, "kind": "break", "...": "..." },
{ "id": "n-mulxfhih-kb100", "title": "Triage practice in the sandbox (2 of 2)", "plannedMs": 900000,
  "splitOf": "sandbox", "splitTitle": "Triage practice in the sandbox", "...": "..." }
```

- The first piece keeps the item's own `id`; the others get new ids. All of
  them carry `splitOf` (the first piece's id) and `splitTitle` (the title as
  written).
- The item's real length is the **sum** of its pieces' `plannedMs`.
- Every time the plan is edited, the app folds the pieces back into one
  item titled `splitTitle`, and splits it again wherever the breaks now
  fall. So:
  - **to rename a split item**, change `splitTitle` on every piece (and the
    `title`s, which are rebuilt as *"splitTitle (k of n)"*);
  - **to change its length**, change the pieces' `plannedMs` so they add up
    to the new total;
  - never give two unrelated items the same `splitOf`.
- Alternatively, fold it yourself: replace the pieces with one item that has
  the first piece's `id`, the `splitTitle` as `title`, the total `plannedMs`,
  and no `splitOf`/`splitTitle`. Until the next edit re-splits it, the Plan
  shows a *free* or *over* divider above the break.

### 7.4 Adding and removing

- **Add an item or break:** insert a node with every field from §4 and a new,
  unique `id`, at the row where it happens. Then re-check every pin below it
  (§6), since each later time moves by the new item's length.
- **Remove an item:** delete its node. If it was the `cursorId`, set
  `cursorId` to another item's id. If it was one piece of a split, remove all
  its pieces.
- **Add a presentation:** append an object to `data.workspaces` with a new
  `slug`.

---

## 8. Things that go wrong

| Mistake | What happens |
|---|---|
| A presentation with no `data.workspaces` wrapper, or `{}` | Accepted with *"Imported 1 presentation(s)"*, and every presentation is replaced by the app's sample. |
| Importing a backup to "add" a presentation | Every presentation already in the app is replaced by the file's. |
| The file is named `.txt` or `.md` | Read as a text outline: a new presentation whose items are the lines of the JSON. |
| Invalid JSON | Refused: *"Not valid JSON"*. |
| `puma.app` names another app | Refused: *"This backup is from …, not PumaTimer"*. |
| An item after a section, in `root.children` | Moved into that section. |
| Three levels of nesting | The inner group becomes a section, and the items after it move into it. |
| `plannedMs` as a string, e.g. `"600000"`, or `null` on an item | Falls back to `leafMs`, or to 5 minutes. |
| A `plannedMs` on a section larger than its items | An extra `"Buffer"` item is added to hold the difference. |
| A windowed break whose `plannedMs` disagrees with its window | `plannedMs` is overwritten with the window's length. |
| A time as a string, e.g. `"startsAt": "09:15"`, or outside 0–1439 | Dropped: the item is no longer pinned. |
| A pinned break whose time falls inside an item | Imported where it sits; the Plan shows *"N over"* above it. At the next edit the app moves it and splits the item (§7.3). |
| A pin that the running total does not reach exactly | A *"free"* or *"over"* divider on the Plan. |
| `kind` with other spelling or case, e.g. `"Break"` | Becomes an ordinary item. |
| `status` other than `"done"`, e.g. `"doing"` | Becomes `"pending"`. |
| `null` inside a `children` array | Becomes an item titled `"Untitled"`, 5 minutes long. |
| `children: null` | Treated as `[]`. On the root, the presentation is empty. |
| Two presentations with the same `slug` | Both are kept; selecting either tab shows the first. |
| No `startsAt` on the presentation | Calculated Start and End times count from whatever time it is when the Plan is open, and no pin is checked. |
| `accent_color` that is not a hex color | Kept as written. Write `#rrggbb`. |
| An unknown key | Dropped. |

---

## 9. Checklist before handing a pack over

A pack that passes all of these imports with no warnings and nothing moved.

**Structure**
- [ ] The envelope matches §2: `puma.app` is `"pumatimer"`, and
      `data.workspaces` is a non-empty array.
- [ ] The file name ends in `.pumapack` (or `.json`).
- [ ] If this is going into an app that already has presentations, the file
      contains **all** of them, because import replaces.
- [ ] Every node has every field from §4.1; `children` is always an array.
- [ ] The tree is at most root → section → item, and loose items come before
      the first section.
- [ ] Every `id` is unique within its presentation; every `slug` is unique
      in the file.

**References**
- [ ] `data.activeSlug` names a presentation in the file.
- [ ] `cursorId` names an item (not a section) in the same presentation.

**Values**
- [ ] Every `kind` is `null`, `"section"`, `"break"` or `"buffer"`, with
      `isBreak: true` on breaks and `isBuffer: true` on buffers.
- [ ] Durations are numbers in milliseconds; items have `leafMs` equal to
      `plannedMs`; sections and the root have `plannedMs: null`,
      `leafMs: 0`.
- [ ] Clock times are whole numbers from 0 to 1439.
- [ ] `created_at`, `updated_at` and `exported_at` are full ISO datetimes.
- [ ] The run record is idle (§7.2), unless you are deliberately preserving
      one.

**Time**
- [ ] The presentation has a `startsAt`.
- [ ] Every pinned `startsAt` equals the start time plus everything above it.
- [ ] Every windowed break's `plannedMs` equals its window.
- [ ] No pinned break falls inside an item.
- [ ] `stopAt`, if set, is at or after the last item's end.

---

## 10. Other files Import accepts

- **A text outline.** A file ending in `.md`, `.markdown`, `.txt` or
  `.outline` is read as a plan in the same syntax as the Paste screen (one
  item per line, indented under its section, for example
  `Lunch ~45 @12:00 #break`). It is **added** as a new presentation rather
  than replacing anything, and the app lays out the day itself. For a
  brand-new plan this is often the easier thing to generate; the Paste
  screen shows the syntax.
- **A PumaTTX pack.** A `.pumapack` whose `puma.app` is `"pumattx"` (from the
  tabletop exercise planner) opens a picker to bring one exercise in as a
  plan. It does not replace anything. Its format is PumaTTX's own and is not
  described here.

---

## 11. A complete example

A half-day onboarding session: one loose opening item, three sections, a
windowed coffee break, a floating break, a pinned lunch, and a planned
buffer. Every pin falls exactly where the running order reaches it. It
imports with no warnings and is stored exactly as written.

```json
{
  "$schema": "https://greykit.com/schema/pumapack-v1.json",
  "puma": { "app": "pumatimer", "schema": 2, "exported_at": "2026-10-05T08:00:00.000Z" },
  "data": {
    "workspaces": [
      {
        "slug": "analyst-onboarding",
        "name": "Analyst onboarding day",
        "accent_color": "#4a90e2",
        "created_at": "2026-10-05T08:00:00.000Z",
        "updated_at": "2026-10-05T08:00:00.000Z",
        "view": "plan",
        "plan": {
          "id": "root", "title": "Analyst onboarding day",
          "plannedMs": null, "startsAt": null, "endsAt": null, "kind": null, "leafMs": 0,
          "children": [
            { "id": "welcome", "title": "Welcome and introductions",
              "plannedMs": 900000, "startsAt": null, "endsAt": null, "kind": null, "leafMs": 900000,
              "children": [], "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending" },
            { "id": "s-soc", "title": "How the SOC works",
              "plannedMs": null, "startsAt": null, "endsAt": null, "kind": "section", "leafMs": 0,
              "children": [
                { "id": "tooling", "title": "Tour of the tooling",
                  "plannedMs": 1800000, "startsAt": null, "endsAt": null, "kind": null, "leafMs": 1800000,
                  "children": [], "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending" },
                { "id": "triage-walk", "title": "Alert triage walkthrough",
                  "plannedMs": 2700000, "startsAt": null, "endsAt": null, "kind": null, "leafMs": 2700000,
                  "children": [], "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending" },
                { "id": "coffee", "title": "Coffee",
                  "plannedMs": 900000, "startsAt": 630, "endsAt": 645, "kind": "break", "leafMs": 900000,
                  "children": [], "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending",
                  "isBreak": true }
              ],
              "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending" },
            { "id": "s-hands", "title": "Hands-on",
              "plannedMs": null, "startsAt": null, "endsAt": null, "kind": "section", "leafMs": 0,
              "children": [
                { "id": "sandbox", "title": "Triage practice in the sandbox",
                  "plannedMs": 3600000, "startsAt": null, "endsAt": null, "kind": null, "leafMs": 3600000,
                  "children": [], "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending" },
                { "id": "slack", "title": "Room to overrun",
                  "plannedMs": 600000, "startsAt": null, "endsAt": null, "kind": "buffer", "leafMs": 600000,
                  "children": [], "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending",
                  "isBuffer": true },
                { "id": "stretch", "title": "Stretch",
                  "plannedMs": 300000, "startsAt": null, "endsAt": null, "kind": "break", "leafMs": 300000,
                  "children": [], "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending",
                  "isBreak": true },
                { "id": "lunch", "title": "Lunch",
                  "plannedMs": 2700000, "startsAt": 720, "endsAt": null, "kind": "break", "leafMs": 2700000,
                  "children": [], "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending",
                  "isBreak": true }
              ],
              "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending" },
            { "id": "s-wrap", "title": "Wrap-up",
              "plannedMs": null, "startsAt": null, "endsAt": null, "kind": "section", "leafMs": 0,
              "children": [
                { "id": "questions", "title": "Questions",
                  "plannedMs": 900000, "startsAt": null, "endsAt": null, "kind": null, "leafMs": 900000,
                  "children": [], "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending" },
                { "id": "next-steps", "title": "Next steps and buddies",
                  "plannedMs": 600000, "startsAt": null, "endsAt": null, "kind": null, "leafMs": 600000,
                  "children": [], "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending" }
              ],
              "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending" }
          ],
          "actualMs": 0, "startedAt": null, "startedFirstAt": null, "status": "pending"
        },
        "cursorId": "welcome",
        "running": false,
        "runStartedAt": null,
        "finishedAt": null,
        "editedWhileRunning": false,
        "startsAt": 540,
        "stopAt": 795,
        "hardStopAt": 810,
        "autoAdvance": true,
        "zones": { "debtRedMs": 300000 },
        "zoneColors": null
      }
    ],
    "activeSlug": "analyst-onboarding"
  }
}
```

What the app shows for this, as a check on your own arithmetic. These
figures were read from the app after importing this exact file:

| Row | Start | End |
|---|---|---|
| Welcome and introductions | 09:00 | 09:15 |
| **How the SOC works** | | |
| Tour of the tooling | 09:15 | 09:45 |
| Alert triage walkthrough | 09:45 | 10:30 |
| Coffee (window 10:30–10:45) | 10:30 | 10:45 |
| **Hands-on** | | |
| Triage practice in the sandbox | 10:45 | 11:45 |
| Room to overrun | 11:45 | 11:55 |
| Stretch | 11:55 | 12:00 |
| Lunch (pinned 12:00) | 12:00 | 12:45 |
| **Wrap-up** | | |
| Questions | 12:45 | 13:00 |
| Next steps and buddies | 13:00 | 13:10 |

- The Plan header reads *"10 items · 3h 5m material · 1h 5m breaks · ends
  13:10 · 5m contingency"*. The buffer counts as material; the three breaks
  do not.
- There are no *free* or *over* dividers, and no row is marked late, cut,
  missed or red.
- Editing any row leaves Coffee and Lunch where they are, because each pin
  already equals the running total.
- The **Out of the room** time, 13:30, is shown and used in no calculation.
