# Gantora — Portable Project Planning

Standalone, single-file project step & dependency tracker. No installation, no server,
no external libraries — open `index.html` in **Microsoft Edge or Google Chrome** and it
works fully offline. Data lives in plain text files (delimiter `|`) named `*.gantora` in
the `/DB` subfolder, so files can be versioned, e-mailed, or opened in Excel
(Data → From Text, delimiter `|`). Older `*.csv` plans still open unchanged.

## Getting started

1. Open `index.html` in Edge or Chrome (double-click).
2. It **asks for the folder straight away**: select this `Gantora` folder
   (selecting the `DB` folder directly also works) and grant "Edit files"
   permission. The same thing sits on the **📁 DB** menu as **Open DB folder…**
   for any time after that.
3. Pick a plan from the dropdown, or **📁 DB → New file…** — new plans are
   written as `name.gantora`.
4. The first time you change anything it asks **your name**, so a plan you send
   on says who did what. It can be skipped, and changed later in **About**.

The folder and the last opened file are remembered: on the next visit the app
reconnects **automatically** if the browser kept the permission, and you land
back on the plan you were in. When the browser has let the permission lapse it
asks again on the way in — one click, no folder picking, since the folder itself
is remembered.

A page cannot open a folder by itself, and that is the browser rather than a
choice: a file picker only runs inside a real click, and a page opened by
double-clicking has no handle on the folder it is sitting in. Asking on the way
in is as close to "open /DB by itself" as a browser allows.

To open the app as a **viewer** instead — no "Edit files" prompt, no editing —
use **📁 DB → 🔒 Read-only mode**, or launch it with
`index.html?mode=readonly` (optionally `&file=switchover.csv` to open one plan
straight away). See *Read-only mode* under Features.

To let **someone else** see a plan, use **📁 DB → 🔗 Share as read-only page…**.
It writes one `.html` file that opens by double-clicking, with no folder to pick
and no permission to grant — the folder permission is not something that can be
handed over, so the plan travels inside the page instead.

## The two bars at the top

The **app bar** holds what belongs to the app and never moves: the 📁 DB menu
(open folder, new file, import from Excel, export, share as a read-only page, combined view, compare & merge, reload, read-only mode), the file dropdown, 💾 Save, ↶ ↷ undo
and redo, ➕ New task, the five plan views that stay on the bar
(**📊 Dashboard · ☰ List · 📐 Design · 🔭 Zoom · 👤 Res**) with the four you go
to in order to *read* the plan behind the ⠿ grip at their right
(**🧑‍🤝‍🧑 Team · 📋 Report · 🛫 Cockpit · 🖼 Presentation**),
then the 🗂 **Lists** menu (**👥 Teams · 🖥 Systems · 🙍 People**) and the
Gantora logo, which opens *About*. The save-state dot sits on the right.
Teams, Systems and People are management *pages* rather than ways of looking at
the plan, which is why they sit in a menu of their own outside the view
switcher: they are the same kind of thing, the lists a task is filled in from.
The sixth plan view, 🧑‍🤝‍🧑 **Team**, is the team planning board described
under *Features*.

The app bar carries the **logo** rather than the square mark it used to — the
whole wordmark, at the size a header has room for. The tab keeps the mark: a
tab is sixteen pixels of a letter, and a wordmark shrunk into it is a smudge.

Next to the save-state dot there used to be a line reading *Loaded — plan.csv
(214 tasks)*, and *Saved — plan.csv (214 tasks)* after every write. Both are
gone: the file name is in the picker a few inches to the left and the row count
is at the foot of the list, so it was the same three facts said a third time, in
the one corner of the app reserved for saying whether anything is unsaved.
**That is what the dot is for.** Words appear there now only when there is
something a dot cannot say — *Unsaved changes*, *Read-only*, *Combined*, or
*Folder opened — choose or create a file* — and never the file name with them.

The **context bar** underneath carries the controls of whatever view is on
screen — sort and filters in the list, the zoom slider in the charts, the
breadcrumb in the Zoom view — plus the search box, which is there in all of
them. It disappears on the management pages and before a file is opened, and it
**folds away** on the wide, shallow tab centred under the app bar: on a screen
being read rather than worked in it is a strip of controls nobody is touching,
and it is the only thing between the toolbar and the plan. The tab is the *edge*
of the bar it opens and closes, which is why it sits there rather than among the
toolbar's own buttons — where it read as a control of the app instead. It stays
visible while the bar is shut, since it is the way back. The choice is remembered
in the browser rather than in the file — it is about this screen, not about this
plan — and <kbd>Ctrl</kbd>+<kbd>F</kbd> brings the bar back by itself, since
asking for the search box is asking for the bar it lives in.

**Narrow screens get the phone layout, and it starts at 820px.** There used to
be a band between 640 and 820 that kept the desktop's shape and let the app bar
wrap: two lines of toolbar read acceptably, but by the time it needed three the
bar was most of the screen and the filters under it were unusable. Three lines is
where a narrow window stops being a small desktop, so that is where the phone
layout begins — the same number the script's `PHONE_W` carries, and a test holds
the two together.

**There the filter bar stacks and starts shut.** Six filters, two fold buttons
and a search box do not fit across a narrow screen: they used to sit in a strip
you pushed sideways, and everything past the Team filter was unreachable — a
sideways scroll inside a bar one control tall is a gesture nobody finds. Each
dropdown is a full-width line of its own instead, which is also the size a finger
wants. That makes the bar half a screen tall, so it comes up **folded** there on
every load, whatever a wider screen last chose: the answer is one value for the
whole browser, and a tab pressed open on a desktop used to arrive on the phone as
half a screen of filters. The tab is one tap away for the times it is wanted, and
nothing is stored from a phone.

## File format (`DB/*.gantora`)

A plan is **plain text with a `|` between the cells** — that has not changed and
is not going to. What changed is the name: a new plan is written as
**`name.gantora`** rather than `name.csv`, because the extension should say what
the file *is* rather than what it is made of, and because a double-click on a
`.csv` opens Excel, which is not where a plan is edited.

**`.csv` still opens**, is listed beside the new ones, and keeps its own name
when it is saved — renaming somebody's file underneath them is not this app's
business. Only *new* files take the new extension. The old one will be dropped
in a later version, not in the middle of a plan.

Taking an old plan across is a **rename in Explorer** and nothing else — the
bytes inside the file are the same either way, so `plan.csv` renamed to
`plan.gantora` opens exactly as it did. There is no menu item for it: a
conversion command would be one more thing on the DB menu, and a confusing one
once `.csv` is dropped.

```
Seq|Activity Name|Duration|Start|Hour (Start)|End|Hour (End)|Predecessors|Team|System|Accountable|Responsible|Parent|Milestone|Notes|Status|History|Event|Emoji|Not Finalized|Baseline End|Date History|UID|Created|Modified|History By|Reported|Labels
```

*(Format 2, 2026-08-23. Files written with the older names — `ID` and a
parent-side `Group` list — open forever; the app upgrades them on their next
save and says so.)*

| Column       | Meaning |
|--------------|---------|
| Seq          | The row's place in the plan, shown as `#` — managed by the app, renumbered into time order (see below). For identity, see UID |
| Activity Name| Step name |
| Duration     | Calendar days, **calculated automatically** on save |
| Start / End  | Dates, `YYYY-MM-DD` |
| Hour (Start/End) | `HH:MM`; blank = any time of that day |
| Predecessors | IDs of preceding tasks, separated by `;` |
| Team         | Responsible team |
| System       | Affected system |
| Accountable  | The **one** person accountable for the task, optionally with a workload: `Anna Kovacs:2` |
| Responsible  | The people doing the work, separated by `;`, each optionally with a workload: `Bela:6;Csaba:4` |
| Parent       | The parent row's **UID**, on the member's own row — a row others name as their parent is a **group** |
| Milestone    | `yes` / `no` — a milestone is a project-wide point in time |
| Notes        | Free text, up to 500 characters |
| Status       | Where the task stands: `Planned`, `In Progress`, `On Hold`, `Blocked` or `Done`. Empty on an event |
| History      | Every status change, oldest first: `2026-03-02 Planned;2026-03-09 In Progress`. Empty on an event |
| Event        | `yes` / `no` — an **event** is a milestone the plan does not own (see below); `Milestone` is `yes` on it too. Unlike a milestone it may carry an End later than its Start, and is then a period |
| Emoji        | The character an event is drawn with, e.g. `🎄`. Empty on anything else |
| Not Finalized| `yes` / `no` — the dates on this task are still a guess. Never `yes` on a milestone or an event |
| Baseline End | The end date this task is answerable to, **only when it has been typed by hand** to overrule the recorded one. Empty means "work it out from Date History" |
| Date History | Where the End date has been: `2026-03-02 2026-03-06;2026-03-09 2026-03-11` — the day each entry was written, then the End date it wrote. One entry per day, oldest first. Written by the app, never filled in. Empty on an event |
| UID          | The row's own name, minted once when the row is created and never touched again — `ak7-mfa3k9x2-1c-7hq4`. **Shown nowhere.** See *A row's own name* below |
| Created      | When the row entered the plan and who put it there: `2026-08-11T18:40:03Z ak7 Anna Kovacs` — UTC, then the browser id, then the name |
| Modified     | The same three things for the last change that actually changed something |
| History By   | Who made each status change, one entry per History entry and in the same order: `2026-03-02 Anna Kovacs;2026-03-09 Bela Nagy` |
| Reported     | `yes` / `no` — whether the management report speaks about this row. Always `yes` on a main task |
| Labels       | The words this plan puts on the row, separated by `;` — `risk;wave-2`. Free text: there is no list of them to maintain anywhere, and any row may carry any number, milestones and events included |

**Everything from Status rightwards was appended at the end**, not
inserted, so a file written by this build still reads correctly in an earlier one
— the extra cells simply fall off the end of what that build knows about. An
event read by a build that has never heard of one arrives as the plain milestone
it is a flavour of: still a marker, still on the right day. A file written *before* they
existed has neither, and every row in it is read as `Planned` with a history
starting on the day the file is first opened here. That is a default for a
missing column rather than a repair, so it is not reported: an old plan of four
hundred rows must not open behind four hundred warnings. A status word this app
does not know, or a history that ends somewhere other than where the Status cell
says the task stands, *is* a repair and is reported like any other.

**Accountable and Responsible were added after System**, which moved the
membership column, Milestone and Notes one place along — and format 2 later
renamed `ID` to `Seq` and turned the parent-side `Group` list into the child's
`Parent` cell. Reading is therefore driven by the **header row**, not by fixed
positions: a file written by an earlier build says in its own header which
names it uses and where, and loads unchanged. It is rewritten in the current
layout the next time it is saved. (A file with no header row at all —
hand-made or truncated — is read as the oldest layout.)

**A cell may hold a pipe or a line break, escaped.** `|` is the delimiter and a
row is one line, so inside a cell they are written as `~p` and `~n`, and a `~`
of its own as `~t` — the same scheme the `#VIEW` lines use. A note typed on
three lines comes back on three lines. (Until August 2026 both were flattened on
every save; a build from before then shows the escape codes as they stand.)

### IDs follow the time order

The numbering is not a label a row keeps — it is **where the row sits in the
plan**. The plan is held in time order and the IDs count up with it, 1, 2, 3
down the page, always and not only until the next date is edited. A **group
takes the number directly above its own members**, so the numbering walks the
tree and the list reads as an outline:

```
1  Analysis & Design      (group)
2    Requirements workshop
3    Technical design
4  Vendor contract signed
```

Rows that start and end at the same moment keep the order they already had, and
a row with no start date goes last.

The price is that the number is not permanent: **move a task's dates and its number
moves with it.** Everything that names a task by number travels along —
predecessors and the view's own bookkeeping — what is selected, what is folded
shut, the row being edited. (Membership itself rides on UIDs since format 2,
so a renumbering never touches it in the file.) A file numbered some other
way — written by an older build, or edited by hand — is brought into line when
it is opened, and says so in the same place the other repairs are reported.

Messages that name a row by its number (“Milestone 7 moved 10 days later…”) are
written *before* the renumbering, so they name the number that was on screen
when you made the change.

### A row's own name: `UID`

Because the number (`Seq`, shown as `#`) is a *position*, it cannot answer the one question two copies of
a plan ask of each other: **is this the same row?** "Task 7" in the plan you
were sent is not "task 7" in the plan you have, and no care at either end can
make it so.

So every row also carries a **UID**, minted once when the row is created and
never touched again. It survives renumbering, a group move, an export, a
reload, and the duplication of everything around it. A **duplicate gets a new
one** — a copy of the work is not the same row twice.

It is **shown nowhere**: not a column, not in the search, not in the Excel
export. It says nothing to a reader that the number does not say better. What it is
for is the thing that cannot be done without it — reading two copies of one plan
and knowing which rows are the same row. Nothing in this version does that yet:
the field has to exist first, and it has to have existed for a while before a
merge can lean on it.

A plan written before the column existed is given uids as it is read, one per
row, and they reach the file with the next ordinary save. That is deliberately
not treated as an unsaved change of its own: opening a file is not a change to
it, and an amber dot on every old plan the moment it opens would say it was.

The uid begins with **this browser's own id** — four characters minted once and
kept beside the remembered folder. That is what makes two people's uids unable
to collide *by construction* rather than by a probability, which is the only
guarantee worth having when the whole point of the field is comparing two
copies of one plan.

### Who is at the keyboard

Three things make a row comparable across two copies: the **uid** says *which
row*, a **timestamp** says *when it was last touched*, and a **name** says *who
touched it*. So the app asks for your name — once, at the first edit of a
session, never on a read-only page or a shared copy, where nothing is written
and there is no author. It can be skipped: an unnamed author writes the browser
id alone. It is changed in **About**, which is already the panel about who owns
and runs this thing.

**The name and the identity are two different things, on purpose.** The identity
is the browser id: never shown, never changed, and what the uid is built from.
The name is a label — typed once, corrected whenever you like, and correcting it
disturbs no row that has already been written. A name would have made a poor
identity anyway: two people can type the same one.

Both live in the browser, **not** in `DB/_lists.csv`: that file is shared by
everyone who opens the folder, and a name written there would be overwritten by
whoever saved last. If the name is not on the People list yet, the app offers to
add it — that list is about the same people.

Three columns carry it into the file:

- **`Created`** — when the row entered the plan and who put it there. Written
  once and never rewritten. A plan from before this existed carries the day its
  history starts and **no name**, because nobody knows; a plan with no history at
  all carries the day this app first saw it, again with no name. Crediting the
  reader with a plan somebody else wrote would be worse than saying nothing.
- **`Modified`** — the same three facts for the last change that actually
  changed something. It is written from `markDirty()`, which already holds the
  plan exactly as it was a moment earlier, so the twenty editing paths needed no
  changes at all. Two things it deliberately does **not** count as a change: a
  **renumber** (the IDs follow the dates, so one new task would otherwise put
  your name on two hundred rows — the comparison is keyed by uid, and
  predecessor and group references are compared as uids too) and an **undo**.
- **`History By`** — who made each status change, one entry per History entry,
  in the same order and repeating the date so the two can be checked against each
  other. It is a column of its own rather than more text inside `History`,
  because History is parsed as `date status` and an older build would drop every
  entry it could not read; a column it has never heard of costs it nothing. If
  the two fall out of step — a hand-edited file — the names are dropped and the
  history is kept, which is the right way round.

`Created` and `Modified` are also **foldable columns in the list**, off by
default like the other records, so you can see who last touched what without
opening a file in a text editor.

One thing to know: these names travel **in every plan file and inside every
shared read-only page**. That is the point of them — but it means a plan sent
outside says who did what, so it is worth knowing before sending one.

### Compare & merge

Two people take a copy of one plan each, edit for a week, and then have to put
the two back together. **📁 DB → 🔀 Compare & merge…** is where they meet: pick
another file in `/DB`, and every row of the two plans is matched **by its uid**,
so renumbering, reordering and renaming on either side make no difference at all.

Six kinds of row, which is the honest set when there is no stored ancestor:

| | What it means | What it offers |
|---|---|---|
| **Same** | identical on both sides | nothing to decide |
| **Differs** | both have it and they disagree | *Keep mine* / *Take theirs*, with the fields that differ named |
| **Only here** | in this plan and not in theirs, and their file does not say why | *Keep it* / *Delete it* — **keep** by default |
| **Only there** | in theirs and not here, and this plan does not say why | *Add it* / *Leave it out* — **add** by default |
| **Deleted there** | you still have it; their file records that they deleted it | *Keep it* / *Delete it here too* — the **newer** of your last edit and their deletion is suggested |
| **Deleted here** | you deleted it; they still have it | *Leave it deleted* / *Bring it back* — **stays deleted**, unless they edited it after you let it go |

The last two are what a deleted row **staying deleted** rests on: since August
2026 a plan records its own deletions (see `#DEL` under the file format), so the
merge can tell a deletion from an addition instead of guessing *keep*.

**The app does not know which side changed** — that would need a copy of the
plan as it was when it was handed over, which this app does not keep. What it
does know is who touched each row last and when, from the `Modified` stamp, so
the newer side is *suggested* and named in the row. Clocks on two machines
disagree by minutes, so newer is a suggestion and never a verdict: every row is
a choice you can see and change, and **nothing is applied until you press
Apply**, which says exactly what it is about to do (*3 updated, 1 added, 0
deleted*).

*Only here* defaults to **keep** on purpose. A row missing from their file with
no record of a deletion means either that you added it or that they deleted it
in a build from before deletions were recorded, and those two look exactly
alike — so the app never deletes somebody's work because a file did not mention
it. There are bulk buttons for the common answers: take all theirs, keep all
mine, **take the newer side** row by row, add all of their new rows, add none.
The first three reach the two deletion kinds as well; the last two do not, since
bringing back a row you deleted is not adding one.

**Only the plan on screen is written.** The other file is read once and never
touched: merging into somebody else's copy behind their back is not a thing this
app will do. The merge is one undo step, like any other change.

Two things worth knowing about what travels:

- **References travel through the uid.** Their `Predecessors` hold
  *their* numbers, which mean nothing here, so each one is resolved as their id →
  that row's uid → the row with that uid in this plan. A reference to a row this
  plan does not have at all is **dropped and counted** — a predecessor pointing
  at a stranger is worse than no predecessor.
- **The record travels with the row.** Taking their version brings their
  `History`, their date trail and their `Modified` stamp with it, so the row
  goes on saying who really made that version. Stamping it with the name of
  whoever ran the merge would erase the one fact that made the choice decidable —
  and that the *next* merge will need.

A plan written before the uid existed carries none, so nothing in it can be
matched: the dialog says so, and offers its rows as additions rather than
pretending. That is the one good reason to open and save an old plan once before
sending it anywhere.

The **column order in the file never changes.** The list view has an order of
its own — the one you arrange by dragging its headers about — and its own idea
of which columns to show; neither touches the order the rows are written in
here. A reader arranges columns; a file keeps its shape.

A file may carry extra lines **at the very end**, remembering how the plan was
being looked at. They are deliberately not task rows — they start with `#`, so
every reader skips them: the loader wants a number in the first cell, the
importer skips `#` outright, and an older build of the app simply ignores them.
One line per setting, so a build that knows one key and not the other reads what
it recognises and passes over the rest.

```
#VIEW|hiddenColumns|duration;team
#VIEW|shownColumns|history
#VIEW|columnOrder|id;name;status;start;end;…
#VIEW|collapsed|3;7
#VIEW|savedView|sample-project - ZoomView 1|view=zoom;level=day;by=task;px=24;lvl=4;top=7;at=2026-04-15;hour=09:30
```

`hiddenColumns` is which columns the list folds away; `shownColumns` is the
other half of that — the columns that **start** folded away and this plan has
brought back out (History is the only one so far). Both say what the plan does
*differently* from the way the list comes up, so a file that has never had a
column touched carries neither, and a column left at its default is written
nowhere. The second key exists because "hidden" alone cannot express "shown": a
file that simply left History out of its hidden list would get it back the next
time it was opened, the default quietly winning over the choice.

`columnOrder` is the order the list draws its columns in, as keys, written only
when the plan has been arranged into one — a plan whose columns are in the order
they come in writes no line, exactly as a plan with nothing folded away writes
none. The **whole** order is written rather than the moves that made it, because
that is what the next reader has to be given. A key the line does not mention
keeps the declared order and follows the ones it does, so a column added by a
later build turns up at the end instead of disappearing; a key naming a column
this build has never heard of is dropped, since a name that is not a column
cannot be one.

`collapsed` is which **groups** are folded shut. Each key is written only when
there is something to say, so a file without them hides nothing and shows every
group open.

`savedView` is the one key that **repeats**: one line per saved view, so adding
one lengthens the file by a line rather than rewriting a long one. The name is a
cell of its own; the settings follow as `key=value` pairs. `~` escapes the two
characters the format needs back — `~s` for the `;` between pairs and `~p` for
the `|` between cells — so a team called `Sales; EU` survives the trip, and a
value may contain `=` freely because the reader splits on the first one only. A
line naming a view this build cannot show, or a name already taken, is left out
rather than guessed at.

Only the *closed* groups are listed, which is the right way round twice over: it
is the shorter list in any plan anybody actually folds, and a group added later
comes up open rather than hidden behind a fold nobody chose. The ids follow the
plan's own renumbering, so a fold stays on its row rather than on its number.
Both settings belong to the plan rather than to the browser — open the file on
another machine, or in a shared read-only page, and it is folded the way you
left it.

### `#DEL` — the rows a plan has let go of

After the `#VIEW` lines, one line per deleted row: `#DEL|<uid>|<stamp>|<name>`
— which row, when and by whom (the same stamp the Modified column carries), and
the name it had, so the merge screen can say what was deleted rather than only
that something was. It exists for **Compare & merge**: a row missing on one side
is either *deleted here* or *added there*, and without a record of the deletion
the two look identical — so the merge used to keep every such row, and a row
somebody had deleted came quietly back, with its old uid, invisible to the next
merge because it matched again. With the record, the merge shows *Deleted here*
/ *Deleted there* as their own kind, weighs the deletion against the row's last
edit like any other difference (newer side suggested), and a deleted row **stays
deleted** unless the other side edited it after it was let go of, or you choose
to bring it back. Undo puts the row back and takes the line with it.

A line rather than a column, on purpose: a deleted row kept in the table with a
*Deleted* cell would be read as a live task by every build from before this, and
the deletion would undo itself in every older copy. A `#DEL` line is skipped by
every reader there has ever been, and simply not written back by one that does
not know it — the record is lost there, but nothing comes back to life. The
lines accumulate for the life of the plan; they are short.

## Two machines, one folder in the cloud

Put `/DB` inside a folder Google Drive for Desktop, OneDrive or Dropbox keeps in
step, and the same plans open on both of your machines. Gantora itself never
speaks to any of them — there is no API, no account, no network call of any kind:
the cloud client moves the files and the app simply finds them there. That is why
this works at all in a product that makes no network requests.

It also means the app cannot see what the cloud client is doing, so the two steps
below are not optional.

**1. Make the folder available offline. This is the one step people skip.**
Drive for Desktop runs in *Stream files* mode by default and OneDrive uses *Files
On-Demand*, which means the files in the folder are **placeholders** — the names
are there and the contents are not. Reading one either stalls while it downloads
or fails outright.

- **Google Drive:** right-click the Gantora folder → *Offline access* → *Available
  offline*. (Or switch Drive itself to *Mirror files*, which does the same for
  everything.)
- **OneDrive:** right-click the folder → *Always keep on this device*.
- **Dropbox:** right-click the folder → *Smart Sync* / *Make available offline*.

If a plan is only in the cloud, the app says so and gives these steps rather than
a browser error. It cannot fix it itself: a page cannot change a sync setting.

**2. Open the folder in Gantora on each machine.** Press **📁 DB → Open DB
folder** and pick the same folder on both. The choice is remembered per machine,
so it is asked once.

**3. Put your name in.** **ℹ️ About → Your name.** It costs nothing and it is what
makes the rest legible: every row you touch is stamped with your name, so the two
machines' copies can be told apart, the merge screen can say who changed what,
and a conflict can name a person instead of "somebody".

**Do not edit the same plan on both machines at the same time.** Cloud sync is not
live — a copy takes seconds to minutes to travel, and until it arrives each
machine believes it holds the only version. Working on one plan at a time on one
machine is the whole of the discipline; what follows is for when it goes wrong
anyway, not a licence to ignore this.

### What happens when it goes wrong anyway

**A plan that is still arriving is never opened.** A cloud client replaces a file
in place, so a read can catch half of one. Gantora looks at the file up to four
times, checks that its size and timestamp are unchanged either side of the read,
and refuses text that is visibly part of a file — a row wider than its own header,
a last row cut off mid-way, a file with no readable rows in it. When it refuses,
**the plan on screen stays exactly as it was**, and it says why. Wait a moment and
open it again.

**A save that would land on somebody else's work stops and asks.** If the file on
disk has changed since you opened it — because the other machine's copy has
arrived — you get three answers, and the two that lose nothing come first:

| | What it does |
|---|---|
| **🔀 Compare & merge** | the two versions side by side, row by row; nothing is written until you say so. The default. |
| **💾 Save mine as a copy** | your version goes to `plan__<you>__<date-time>.gantora` and **the file on disk is left alone**, so neither side is lost and the two can be merged later. |
| **⚠️ Overwrite** | your version replaces theirs. It says so, and asks once more. Their changes are gone. |

The copy's name carries your name, your machine's four-character id and the time
to the second, so two copies can never collide and you can see at a glance which
machine made which. There is nothing to fill in — the app builds it from the name
you gave in About.

Nothing is saved while that question is on screen, and there is no way to dismiss
it: a conflict is real until one of the three answers resolves it.

**Conflicted copies the cloud client makes itself.** When a client cannot
reconcile two versions it quietly leaves a second file beside the first —
`plan-DESKTOP-A1B2C3.gantora`, `plan (Daniel's conflicted copy 2026-08-13).gantora`,
`plan (1).gantora`. Gantora does not point these out yet; they appear in the file
list as ordinary plans, and **🔀 Compare & merge** is how you fold one back into
the real plan. Worth a look in the folder now and then.

**Free edition:** it keeps one fixed file, so *Save mine as a copy* is not
offered — a copy beside `project.gantora` would be a file this edition could never
open again. Comparing and overwriting both work.

## Where to open from at start

With two places to keep plans, the start asks: **Where are your plans?** — a
DB folder on this computer, or a Dropbox folder — with a tick to remember the
answer. Remembered, the app either reconnects the local folder without a click
(as it always did) or opens the Dropbox dialog, and asks no more. **About →
Default open** is where the answer lives: *Ask each time*, *Dropbox*, or *Local
DB folder*; changing it takes effect at the next start. The free edition, a
shared page and restricted mode have one place only and are never asked.

## Dropbox — plans in a Dropbox folder

**DB → ☁️ Dropbox…** connects the session to a Dropbox account, in three steps:
a button opens dropbox.com in a new tab, where you allow access and are shown a
code; you paste the code back and press *Connect*; then you choose the folder —
one of the account's **shared folders**, or a path in your own Dropbox. There is
no redirect and no address to come back to because a page opened by
double-clicking has none — the code is the way back. The dialog then says who is
connected and where the plans are, and *Disconnect* forgets it and puts the app
back on a local folder with nothing open.

The folder follows the same rule as a picked local folder: if it is called `DB`
it is the store, otherwise the plans go in a `DB` folder inside it, made if it
is not there (and required to be there in read-only mode). A shared folder is
addressed by its **namespace id**, which is the same for every member — so two
people choosing the same shared folder are on the same plans whatever they each
called it. The DB button wears a ☁️ while the store is Dropbox and names the
folder in its tooltip; *Open DB folder…* becomes *Dropbox folder…* and leads
back to the dialog.

Every write is a **compare-and-swap** on the server's version id: a save over a
version somebody else has changed since you read it is refused by Dropbox
itself, writes nothing, and opens the same conflict dialog a synced local folder
does — merge, keep a copy, or overwrite. Reads are one download each, whole; the
Cockpit's ten-second look costs one metadata call and downloads only when the
version moved.

**Nothing is stored between sessions — unless you ask.** On `file://` every local
page shares one origin, so anything Gantora kept on this machine any other local
HTML file could read — and a Dropbox refresh token is the whole account, for
ever. Connecting again next time is the default and the one arrangement with no
failure mode: nothing to forget, nothing to corrupt, nothing to migrate. The
access token it buys lasts about four hours and is renewed on its own while the
page is open. The tokens live in a closure, not on the app's state, so no export
or debug dump can carry them.

**Staying connected** is the opt-in, once connected and on a folder: a password
of your own — *not* your Dropbox password — and *Save the connection*. What is
kept is the connection and the folder it is on, encrypted with a key derived
from that password (PBKDF2-SHA256, 600 000 rounds, a fresh salt each time; AES-
GCM), under one entry in this browser: without the password it is noise, and a
wrong password fails on the cipher's own check rather than opening anything.
Next time, the dialog opens on one password box: enter it and the app reconnects
and opens that folder — no trip to dropbox.com. *Forget it* removes the saved
connection; a connection Dropbox no longer accepts removes itself and says so.
Forgetting the password costs one more trip to dropbox.com, nothing else.
*Disconnect* is for the session and leaves the saved connection where it is.

**Saves are gathered on Dropbox.** The app saves after every change, and on a
disk that is free; on Dropbox each save is a round trip under a namespace-level
write lock, and enough in a row are refused with a `429`. So an automatic save
on the Dropbox store waits two and a half seconds after the *last* change and
goes once; pressing Save (or Ctrl+S, or applying a merge) goes at once and takes
anything pending with it. The dot stays amber until the write has landed and
breathes while it is in the air. A pending save is written before the plan
closes, when the tab is hidden, and when the page is on its way out; a `429`
waits the `Retry-After` the server names and tries again, twice at most, then
reports the failure and keeps the plan in memory. On the local folder nothing
changed: a save is immediate, as it always was.

**Offline, the plan stays here.** Dropbox mode is online mode — nothing is
cached for later. When a request cannot reach Dropbox at all, a save says so
once ("nothing was lost"), asks nothing, keeps the plan in memory with the dot
amber and the reason in its tooltip, and tries again on its own every fifteen
seconds and at once when the browser says the network is back — then "Back
online — saved". A read offline says it is the connection, not the file, and
leaves the plan on screen untouched. The About box carries a note on all of
this. **Local-folder mode still makes no request of any kind**: the test suite runs with a network that throws, and a second test holds
`fetch` and `window.open` to the Dropbox code and nowhere else. Not offered in
the free edition or a shared page, which have no second place to keep plans.

## Features

- **List view** — Excel-like grid of all tasks; predecessor IDs are clickable
  links. **Group** is the last column before the actions: it is a list of IDs,
  read far less often than the dates and names it used to sit among.
- **Choosing the columns** — right-click **any** column header (or the double
  rule a folded column left behind) and the whole list of columns opens, each
  one ticked where it is on screen. Tick to fold a column away, tick again to
  bring it back; the menu **stays open**, so folding four columns is one visit
  rather than four. **Show all columns** is offered wherever anything is hidden,
  and **Reset column order** wherever the columns have been arranged.

  On a wide plan the chooser is longer than the window and **scrolls inside
  itself**. It used to shut the moment you reached for the column you were
  looking for: the wheel over a menu that could not scroll was handed on to the
  list behind it, and the list moving under a menu is what dismisses the menu.
  The scroll now stays in the menu, and a scroll that starts inside it is the
  menu working rather than something to close it.

  **Five entries are in the list but cannot be unticked**: *ID*, *Activity
  Name*, *Start*, *End* and *Actions*. The first four are what a row **is** — a
  list with no name or no dates is not a shorter list, it is a different thing —
  and the actions are how you work on it. They are shown greyed rather than left
  out, so nothing looks missing.

  These come up **folded away** on a plan that has never said otherwise:
  *Duration* (arithmetic on the two dates beside it), *Hour (Start)* and
  *Hour (End)* (most plans are kept in whole days), *History*, *Baseline End*
  and *Drift*. What is left in a folded column's place is a narrow **double
  rule** between the two headers that remain; neighbouring hidden columns share
  one rule, so six folded columns leave one marker per run, not six.
  The setting belongs to the plan rather than to the browser, so it **travels in
  the CSV** (see the `#VIEW` line above) and is there again on the next machine
  that opens the file. Editing a row with columns hidden keeps their values:
  their fields are still in the editor, parked out of sight.
- **Arranging the columns** — **drag any header onto another** and the column
  lands there: dropped to the right of where it was it goes *after* the target,
  to the left *before* it, which is the same sentence as "it lands where you
  dropped it" and is the only reading that lets a column reach either end of the
  table. Every header is a handle, the four that cannot be hidden included.

  The order **belongs to the plan**, not to the browser: a plan people read
  across a table is a plan whose columns have been put in the order that reading
  wants, so it travels in the CSV beside the folded columns (`#VIEW|columnOrder`)
  and is there again on the next machine that opens the file. A hidden column
  keeps its place in the order while it is away, so bringing it back does not
  also move it. The way back is *Reset column order* in the header menu, offered
  only once there is something to go back from. Not offered in a read-only or
  combined view: there is no one file to keep the answer in.
- **Inline editing** — double-click a row to edit it in place (💾 Save at the end
  of the row, Enter saves, Esc discards); the “➕ Add new task…” row at the bottom
  adds tasks directly in the list. Clicking any other function while a row is
  being edited asks whether to save or discard. All validation rules apply;
  the ✏️ button still opens the full edit dialog.
- **Milestones** — tick the ★ checkbox (dialog or inline row): only the start
  needs to be filled, the end is automatically the same, and
  team/system/accountable/responsible/group do not apply — a milestone is a
  moment in the plan, not work somebody does. Milestones are project-wide: they
  always stay visible regardless of the filters,
  and appear as a ★ star with the name — in the Design view as
  a star marker at their date, in the list with a ★ before the bold name.
  In the Design view a **grey rule** runs down the whole chart on a milestone's
  own day, so what the plan is aiming at is readable against every bar.
- **Events** — tick the ⌚ *Event* checkbox instead. An event is a date the plan
  has to **work around** rather than one it aims at: Christmas, a code freeze,
  an audit week. It is a *flavour* of milestone, so most milestone rules hold
  over it — no team, no system, no people, never a group and never inside one,
  always on screen whatever the Team filter says. Where it differs:
  · it is drawn with **an emoji of your own** rather than the star, picked from
    a row of presets in the dialog or typed/pasted into the box beside them
    (<kbd>Win</kbd>+<kbd>.</kbd> opens Windows' own emoji picker), and its
    **date is shown beside its name** — what an event says is *when*;
  · its rule down the Design view is **blue** where a milestone's is grey, and
    the Zoom and helicopter views draw its line and its label in the same blue;
  · it carries **no status and no history**, and is left out of **every
    dashboard number** — no health bucket, no Milestone Health window, no
    trend. Nothing is "in progress" about Christmas;
  · **it sits at the bottom of every list**, below every task and group,
    whatever its date. An event dated in the middle of the plan would otherwise
    break it in half with something nobody has to do; held together at the foot
    they read as what they are — the calendar the plan is working around. Since
    the ID is a row's place in the plan, that is where their numbers are too.
    (Milestones do **not** move: a milestone is the plan's own date and reads in
    sequence with the work leading to it.);
  · **an event may be a period.** Give it an End later than its Start and it is
    drawn as **two rules with the stretch between them washed very light blue**,
    its emoji and name centred in that band — a freeze week, a holiday
    shutdown, an audit. In the Design view the band sits *behind* the bars, so
    it colours the ground the plan is drawn on rather than covering it; in the
    Zoom and helicopter views the label sits between the same two lines. Leave
    the End alone and it stays the single rule it was. This is the one place an
    event parts company with the milestone it is a flavour of — a milestone's
    end always mirrors its start, because a moment has no length, and its End
    field stays disabled in both editors while an event's does not.
  Moving an event moves nothing else, either: a milestone drags the work
  scheduled against it, but an event slipping is news about somebody else's
  calendar, not a reschedule of your plan.
- **Not finalized** — a tick on a task saying **these dates are still a guess**.
  It is a property of the plan rather than of the work: nothing derives from it,
  nothing validates against it, and it changes no date. It only makes the row
  look provisional — the bar keeps the colour its schedule health earned it and
  is drawn **pale and dashed** over it, in the Design view, both Zoom levels and
  the helicopter, so a late task nobody has agreed yet still reads as late. It
  is a column in the list you answer by clicking it, like the Status cell, and
  it **clears itself the moment the task's status moves**: whoever moved the row
  on has settled the question the tick was asking. Milestones and events do not
  carry it — a date you are unsure of is simply a date you have not set.
- **Order** — by **Time**: start date and hour, with undated tasks last. There
  is no picker for it. There used to be one offering *Time* or *ID*, and since
  the IDs are kept in time order the two settings produced the same list — a
  control whose options look identical is one that makes the reader doubt what
  they are seeing. (The one case where they could differ is a file whose
  numbering a hand edit has knocked out of step, and that is repaired on the way
  in rather than looked at through a sort order.)
- **Filtering** — Team, System and **Person** dropdowns in the context bar narrow
  the list, the Design view and the resource view; opening another file clears them.
  The Person list is everybody named anywhere in the plan, as **Accountable or
  as Responsible**, and picking one keeps every task that person is on — either
  role counts as being on it. A group stays on screen when anything inside it is
  kept, so a filtered list never orphans a task from its group, and milestones
  ignore the filters as they always have.
- **Right-click a list row** — the same actions the row's own buttons carry
  (edit, duplicate, insert above, add into the group, take out of the group,
  delete), offered at the pointer instead of at the far right of a wide table.
  Which items appear depends on the row: only a group can be added into, only a
  member can be taken out. Opening the menu **selects that row**, so the
  highlight says what the menu is about. Escape or a click anywhere closes it.
- **Right-click inside a multi-row selection** — the menu is then about the
  whole selection and keeps it: **Mass change *n* tasks…**, **Duplicate *n*
  tasks**, **Insert new task above** (at the row you right-clicked), **Remove *n*
  tasks from their group** where any of them is in one, and **Delete *n* tasks**
  behind a single confirmation that names every row. **Edit is not offered** — a
  name, a predecessor list and a group membership belong to one row.
- **Dashboard** — the first button in the view switcher, and the page to look at
  before any other: how the plan is doing, in one screen, derived from the plan
  on every render and stored nowhere.

  **What it counts is leaf tasks** — not groups, not subgroups, not milestones.
  A group at any depth is a container whose dates and status are its members',
  so counting it as well would inflate every total and every percentage twice
  over; a milestone is a moment rather than work, and has a section of its own.
  The cards say so under the number.

  · **Scope** — the picker in the context bar narrows the whole board to one
  **top-level group**, and then every number, bar, chart and table on it counts
  only the tasks inside that group, however deep they sit. A plan of four
  hundred rows is usually several streams of work sharing a file, and "how is
  this plan doing" is then the wrong question. Level 1 only: a top-level group
  is a stream, a subgroup three levels down is a detail, and a picker listing
  every group would be a list of everything. The scope is held **by name**, not
  by ID — IDs follow the dates, so a number would start answering about a
  different group after the next edit — and a banner above the cards says which
  stream is being counted and how much of the plan that is. Milestones are not
  narrowed: they are project-wide, so Milestone Health still lists them all;
  what changes is the work counted inside each one's window.

  This is the dashboard's *only* filter, and deliberately so: Team, System,
  Person and Status cut **across** the plan, and a board narrowed to one team
  would be answering a different question from the one its title asks. Scope
  cuts it **structurally**.

  · **Nine cards** across the top: total, and one per bucket. **Click any of
  them** to open the list filtered to exactly those rows.

  · **Schedule Health** — the seven buckets as one 100% bar, with the counts
  under it.

  · **Planned vs Actual Progress** — planned is what the dates promise (the
  share of the plan that *should* be finished by a given day), actual is what
  the status history says is. The **schedule variance** between them is the
  headline number. The picker chooses whether a task counts as one task or as
  its own length in days; duration-weighted is the default, because a six-week
  task and a one-day task are not the same promise. Lines are solid to today and
  dashed after it — the planned dash is the real schedule, and the actual dash
  is the one projection on the page: today's figure carried forward at the last
  four weeks' rate, and labelled as such. **Both dashes start on the today line
  itself**, at the point the solid half reached, so the projection reads as the
  continuation of the Actual line rather than as a separate line about something
  else — it used to begin a week to the right of the red line, leaving a gap
  exactly where the eye goes. **Today is a point of its own** on the line, not
  just the day the red rule is drawn on: the chart's other points are a grid
  stepped off the plan's span, and the last one before today could be weeks old.
  On a plan whose history begins the day it was imported, every one of those days
  really is 0% Done — so the Actual line sat flat on the axis while the figure
  under the chart read the true percentage, the same function answering two
  different days. It now ends on the number the figure states.

  · **Tasks Status Trend** and **Overdue Tasks Trend** — the last eight weeks,
  one point a week, **reconstructed rather than recorded**: `healthAt()` takes
  the day to judge against, so a past week is the same question asked with a
  different "today". The overdue line is exact from the first day, since an end
  date in the past is a fact whatever the history says. The status lines can
  only reach back as far as the plan's own histories do, and the card says where
  that is until there are eight weeks of them.

  · **Overdue by Duration** — a donut splitting the overdue tasks by how far
  past their end they are: 0–1 day, 1–3, 3–7, more than a week. "41 overdue" and
  "41 overdue, a quarter of them by more than a week" are different problems.

  · **Milestone Health** — Completed / On Track / At Risk / Overdue, and a table
  of the live edge of the plan. **A milestone owns the work that leads up to
  it**: every task whose end date falls *after the previous milestone* and on or
  before this one. That window needs no new field — the plan already says when
  everything ends, and the milestones already cut the calendar into stretches.
  Its progress is the share of its own window that is Done, and it is **At Risk**
  when its date is still ahead but something in that window is already Overdue
  or Blocked, which is the earliest honest warning there is: the date has not
  slipped yet, and the work behind it has. *View all milestones* opens every one
  of them on a page of its own.

  · **Workload by Team** — the **six busiest teams by task count**, with the
  rest behind *View full resource report*. Tasks and Overdue are counts.
  **Workload is capacity**: the team's busiest day in the next 30, as a share of
  what that team could do in a day — every person named on its tasks, at the
  hours they are named for. A team whose tasks name nobody has no capacity to
  divide by, so its workload reads "—" rather than 0% or 100%; both would be a
  lie, and one of them would be a lie people act on. Clicking a row opens the
  list filtered to that team.

  Every chart is hand-drawn inline SVG, like every other mark in this app — no
  library, nothing fetched. See the licensing note at the end.
- **Status, and the history behind it** — every task carries a **Status**
  (`Planned`, `In Progress`, `On Hold`, `Blocked`, `Done`) and a **History** of
  every change it has been through, each dated. A task is `Planned` the moment
  it exists, and its history starts with that.

  **Changing it is a quick edit, not a dialog:** click a row's Status cell and
  the five statuses open at the pointer — one click moves the task on and writes
  a dated entry behind it. The same block sits at the top of the row's
  right-click menu, and with several rows picked it moves the whole selection at
  once. The status a row is already at is ticked; picking it again is not a
  change and records nothing. Milestones have a status like everything else — a
  milestone being `Done` is one of the more useful things a plan can say.

  The status is not derived from the dates. A task whose end date has passed is
  not `Done` until somebody says so, and that gap is exactly what a plan is
  followed for.

  **Everything outside the list is coloured by schedule health** — the status
  read against a date. The list keeps its status pills, which say the status and
  nothing about the calendar; every chart, and every number on the dashboard,
  answers the more useful question: *is this task where it should be by now?*

  There are **seven buckets and every task is in exactly one**, which is what
  makes the dashboard's stacked bar add up and its cards agree with its charts.
  The order below is the precedence:

  | Bucket | Colour | When |
  |--------|--------|------|
  | **Done** | green | `Done` — whatever the dates say; finished is finished |
  | **Blocked** | maroon | `Blocked` — stopped by something outside the task |
  | **On Hold** | orange | `On Hold` — paused on purpose |
  | **Overdue** | red | The end date is behind us and the task is neither `Done` nor stopped |
  | **In Progress** | blue | Somebody is working on it |
  | **Upcoming** | grey | It has not started and it does not need to have |
  | **Late Start** | amber | Everything left: its start is behind us, its end is not, and nobody has moved it off `Planned` |

  The rule in one line: **stopped work says why it stopped; running work says
  whether it is late.** `Blocked` and `On Hold` are answers somebody gave by
  hand, and they are the *reason* the date went by rather than another casualty
  of it — so they are asked before the dates, in both directions. A task stopped
  now is worth seeing now even if it was not due to start until next month, and
  a task that is stopped stays counted as stopped after its end date passes.
  `In Progress` is deliberately **not** in that group: work that is merely
  running is the normal state of most of a plan, and its date passing is exactly
  what Overdue exists to say.

  (This **reverses** the order used up to v3.1.0, where Overdue was tested
  first. That made a blocked task with a past end date count as overdue, so the
  Blocked card could read 0 with a blocked task plainly on screen. A stopped
  task past its end date now says both — its bucket in the tooltip, and the date
  it went by beside it.)

  Every coloured bar says which bucket it is in, and why, in its tooltip.

  This applies wherever the thing drawn **is one task**: the **Design** view,
  the Zoom view's **day** and **hour** levels, and the **helicopter**'s month
  segments. A **Team / System lane** holds many tasks at once and a **resource**
  cell holds none (it is a person's day, not a task), so both keep the colours
  they had.

  **The helicopter keeps its density shading for Upcoming tasks.** It is the one
  view whose colour already meant something — how many of a month's subtasks are
  running — and Upcoming is the one bucket with nothing to report, so a task
  still ahead of us keeps its shade there and everything else takes its colour.
  The coming months are exactly where the density reading earns its keep.

  **Milestones are coloured too**, by the same rule — a milestone is a single
  day, so `End < today` and not `Done` means the date went past and nobody
  marked it, which is the most useful red in a plan. A milestone is drawn as ink
  rather than as a filled box, so the colour lands on the ★ in the Design view
  and on the rule and its name in the Zoom views; the date underneath stays
  muted, and the name beside a ★ keeps the ordinary text colour — it is a name,
  not a signal.

  **The key is in the Design view**, on the top line of the left-hand header,
  and again along the foot of the dashboard. Both are built from the same list,
  so an eighth bucket would appear in both without an edit.

  **The Status filter** in the context bar narrows the list to one bucket. It is
  the fourth filter, next to Team / System / Person, and the one the dashboard
  drives: click any card and you land in the list with that bucket selected. It
  is also the one filter **milestones are not exempt from** — Team, System and
  Person stay project-wide, but a milestone that is not one of the 41 overdue
  rows has no business in the answer to "show me the 41 overdue rows".

  **The Label filter** is the fifth, and **Level** the sixth. A label is a word
  this plan put on the row itself, so that filter behaves like Status rather
  than like Team: a milestone can carry a label, so a milestone can miss one and
  be left out. Level is not a value out of the plan at all but a place in it —
  see below.

- **Labels** — the words a plan uses about its own rows: `risk`, `wave-2`,
  `external`. Several per task, separated by `;`, in the task dialog, in the
  list's own **Labels** column, and in the mass change.

  **There is no list of labels to maintain.** A label is invented in the middle
  of thinking about a task, and a registry you have to open first is a registry
  nobody fills in — so the box simply offers every label already used in the
  plan, which is what keeps them spelled the same. Two labels differing only in
  case are one label, and the first spelling in the plan is the one offered.

  Anything may carry one, **milestones and events included**: "which rows belong
  to wave 2" is a question about the plan, and the go-live is part of the answer.
  In the **mass change** labels are the one field that **adds** rather than
  overwrites — a row can hold any number of them, and tagging twenty rows must
  not untag them.

- **One level, on its own** — the *Level* filter shows the rows sitting at one
  depth of the plan and nothing else: no parents above them, no members
  underneath, drawn flat. It answers "show me the level 3 work" — a list of
  comparable rows — and it is a different question from the chart's **Lvl**
  picker beside the zoom, which cuts everything *deeper* away and keeps every
  level above. A row's level is where the plan puts it, not where the other
  filters leave it.

  **Not in the list view.** The list shows the hierarchy and folds any part of
  it away, which is the better answer to the same question; it is the charts,
  where there are no carets and every row is a bar, that need a way to say "just
  this level". The list does not merely hide the control — it ignores the
  setting, because a view still being narrowed by a control that is no longer on
  the bar is a plan with rows missing and nothing saying why. What was picked is
  kept, so the Design view is as it was left when you come back to it.

  **Tasks only (no groups)** is the same filter asked without a number: every
  row that is not a container, at whatever depth it sits. A plan is never nested
  evenly — one stream is three levels deep and the next is one — so "the work
  itself, with the headings taken out" cannot be said as a level. Milestones come
  with it: a moment is not a container, and the work without the dates it answers
  to would be the wrong list.

  **A group's status is calculated from its members**, like its dates and for
  the same reason: a group is not work, it is what its members add up to, and
  saying a group is `Done` while something inside it is `Blocked` is a statement
  a plan should not be able to make. **The loudest member wins:**

  ```
  Blocked  >  On Hold  >  In Progress  >  Planned  >  Done
  ```

  Read it as *what is the worst thing anybody could tell you about this group*.

  **It goes all the way down.** The roll-up runs deepest group first, so a
  subgroup has settled before the group holding it is asked, and what a parent
  reads is its child's *rolled-up* answer rather than the word that child
  happened to be carrying. A task blocked four levels down therefore blocks the
  level-4 group above it, and the level-3, level-2 and level-1 groups above
  that, in one pass and on every route that can change a status.
  Anything blocked makes the group blocked; only when every member is `Done` is
  the group `Done`. Subgroups settle first, so a group reads its child's
  rolled-up answer rather than the one it had before.

  **Work that has begun outranks work that has not.** One member `In Progress`
  makes the group `In Progress` even if everything else is still `Planned` — and
  so does one member `Done`, which is the same statement about the same group
  made a day later. A group of ten with nine `Done` and one not yet started is
  therefore `In Progress`; only a group where *nothing* has begun is `Planned`.
  The order used to have `Planned` above `In Progress`, which left a group of
  tasks all running and all on track reading "not started" — and the dashboard
  counted it as delay. `Done` is the quietest of the five: it is reached only
  when there is nothing else left to say, which is what *every member is `Done`*
  means. The group row's tooltip spells the order out.

  A group's status therefore **cannot be set by hand** on any route: its cell is
  not a click target and is drawn in outline rather than filled, the row menu
  shows the rolled-up value instead of the five statuses, both editors disable
  the picker, and a mass change or a multi-row status pick moves the *members*
  and lets the groups follow — saying how many it left alone. The roll-up goes
  through the same path a hand edit does, so **a group's History records the day
  it changed** exactly as a task's does.

  **The History column starts folded away** (right-click any header and *Show
  all columns* to bring it out; the choice travels in the file). It is a record,
  not a field: nothing edits it, nothing deletes from it, and the task dialog
  shows the trail read-only beside the picker. A **duplicate** carries the
  original's status but starts its own history — it did not live through the
  steps the original took.
- **Mass change** — one edit written into every selected task: **Start, Hour
  (Start), End, Hour (End), Team, System, Accountable, Responsible and Status**.
  What a set of tasks shares is *when* the work happens, *who* is on it and
  *where it stands*, so those are the fields on offer and nothing else. Status
  goes in dated, exactly as the quick edit does, so twenty rows moving on leave
  twenty entries behind them.

  **A field left blank is left alone.** That is the whole rule, and it is what
  makes the dialog safe to open with thirty rows picked: nothing changes until
  something is typed. The consequence worth knowing is that it can *set* a field
  but never *empty* one — clearing a value is something you mean for one row, and
  the task dialog is where you do it.

  Two kinds of row are held back, and the dialog says so before you type:

  · **Milestones are not touched at all.** A milestone is a point in time with no
  team, no system and nobody on it, so every field here either means nothing to
  it or would silently drag the work hanging off it to a new date. A selection of
  nothing but milestones is not offered the item at all.

  · **A group keeps its own dates** — they are its members' first start and last
  end, so writing them here would only be recalculated away. Team, system and the
  people fields do apply to group rows.

  **Nothing is ever half-applied.** The change is made, the rows it touched are
  validated against the plan it produced, and if any come out worse than they
  went in the whole thing is rolled back and the dialog says which row and why. A
  problem the plan was already carrying does not block an unrelated change. The
  whole thing is **one step on the undo stack**.
  Duplicating a selection copies the rows as **one block** directly after the
  last row copied, and **rewrites the dependencies that ran between them** to run
  between the copies: copy a three-step pattern and you get the pattern, not
  three loose rows still pointing at the originals. A link to a task *outside*
  the selection still points at that task, and a copied group is an empty row —
  the same two rules the single-row duplicate follows.
- **Search (Ctrl+F)** — one box over **name, ID, team, system, the people on the
  task and notes**.
  Case and accents are ignored on both sides, so `megelozok` finds `Megelőzők`
  and `dev team` finds `Dev Team A`. It stacks with the Team/System filters.

  | Where | What it does |
  |-------|--------------|
  | List | narrows the grid to the matches, keeping each match's **group rows** visible so you can see where it sits; the matching rows themselves are tinted |
  | Design, Zoom | **marks** the matches and leaves everything else drawn — a chart with holes in it is no longer a timeline. **⏎** jumps to the next match and centres it, **Shift+⏎** goes back, and the counter shows `3 / 12` |
  | Zoom, Y = Teams/Systems | a lane is a match when its **name or any of its tasks** matches, so searching a task shows you which team is carrying it |

  Milestones ignore the Team/System filters but not the search — otherwise
  looking for one task would leave every milestone in the plan beside it.
  **Esc** empties the box; opening another file clears it.
- **Undo / redo (Ctrl+Z, Ctrl+Y or Ctrl+Shift+Z)** — the last **30** changes,
  covering everything that edits the plan: the dialog, inline edits, delete,
  duplicate, drag-to-group, mass moves, milestone shifts and CSV append. The
  ↶ ↷ buttons do the same and grey out when there is nothing to do. An undo is
  a change like any other, so it marks the file dirty and auto-saves — the copy
  on disk never disagrees with what is on screen. History is per file: opening
  another file starts a fresh one. Inside an inline row or a dialog, Ctrl+Z is
  left to the text field, where it means "undo my typing".
- **Ctrl+S** saves (committing an open inline row first), and closing the tab
  with unsaved changes asks the browser's own "leave site?" question — which
  only comes up if a save actually failed, since every change writes through.
- **Teams, Systems & People pages** — the 🗂 **Lists** menu in the app bar opens
  the three of them: *👥 Teams*, *🖥 Systems* and *🙍 People*. They are one page
  three times, because they are one kind of thing — the lists a task is filled in
  from. Each lists every known name with the number of tasks carrying it in the
  current file. Names with tasks have **Show tasks** (jumps to the list view
  filtered to them); names without tasks can be **deleted**; every name can be
  **renamed**. New entries are added there (or with the ➕ buttons in the task
  editor) and are stored in `DB/_lists.csv` (`Team|Name`, `System|Name` and
  `Person|Name` lines, shared by every project file). Editor suggestions combine
  these lists with values already used in tasks; free text is still allowed.

  **A person can be put on a team**, in a picker on their row of the People page:
  `Person|Anna Kovacs|Development`, a third cell on the line. A person with no
  team writes two cells, which is every line written before this existed. It is
  deliberately **not** a field on the task — a task already says which team is
  responsible for it; this says which team a *person* belongs to, which is a fact
  about the organisation rather than about any one plan. The **team board** is
  what reads it.

  **Renaming rewrites the plan, not just the list.** A name in this app is the
  word itself, written into every task that carries it — a task's team *is* the
  string `Infrastructure` — so correcting the list and leaving the tasks alone
  would rename nobody: it would invent a second name that no task is on and
  quietly orphan the first. One gesture therefore reaches both: the list entry
  and **every task in the open file** that carries the old name, in one undoable
  step. Renaming onto a name that already exists is a **merge**, and says so
  before it does it. The other files in the folder are not touched — they are
  other plans, and this app opens one at a time; the shared list simply offers
  them the new name from now on.

  For a person the name is the awkward one, because a task holds a name and
  possibly a workload (`Anna Kovacs:6`). **The hours belong to the task, not to
  the name**, and stay exactly as they are; renaming somebody onto a name the
  same task already carries merges the two entries, as typing it would have.
  A person is counted once per task, whether they are accountable for it,
  responsible for it, or both.

  **People had no list of their own until now** — the list of people simply *was*
  whoever the plan named. That is still the larger half of the answer (the page
  shows names from the plan alongside the saved ones, marked *(from tasks)*), but
  a list you can only add to by typing a name into a task is a list nobody can
  maintain.
- **Design view (📐)** — the zoomable timeline (slider, +/− buttons, or
  **Shift / Ctrl + mouse wheel**), with dependency arrows, weekend shading, a
  today line, a draggable splitter, and double-click a bar to edit. It is called
  the Design view because you **plan** in it: a bar is a handle, not a picture.
  Three gestures, and each one moves the whole subtree under the row it is used
  on, so a group is rescheduled as one thing.

  | Gesture | What it does |
  |---------|--------------|
  | Drag a bar **sideways** | Reschedules it. Start and end both move by the same whole number of days, so the length and every gap inside a group survive. Drag a group's bar and its members go with it. |
  | Drag a bar's **left or right end** | Extends or shortens it — one end moves, the other stays. **Not offered on a group**: a group's dates are its members' first start and last end, so there is nothing on a group bar to pull that is not really a member's date. Not on a milestone either — a point in time has no length. |
  | Drop a bar (or a **name** in the left pane) **on a plain task** | **Put it after that task.** The whole subtree slides so it **starts on the day the target ends**, keeping every length and every gap. Who belongs to which group is not touched at all — and the target does not become a group. |
  | Drop it **on a group** | **Put it in there.** It becomes a member and **keeps its dates exactly as they are**. What moves instead is the group, whose start and end are recalculated from the members it now has — so absorbing a row can stretch it at either end. |
  | Drop it on the **date bar** — the header strip of months and days above the chart, or the column titles above the task list | **Take it out.** It leaves its group for the top level, **with every date exactly as it was**. The header is not a date you can aim at, it is the whole timeline, so up there means "out of everything". A subgroup comes out whole, keeping its own members. The list view has the same move in its own idiom — drag a row onto the ID column. |

  **What the target is decides what the drop means**, and the three never do each
  other's job: aiming at a task changes *when*, aiming at a group changes
  *where*, and aiming at the date bar changes *where* in the other direction.
  Nothing about a drop ever does both.

  The consequence worth knowing: **a plain task no longer becomes a group by
  being dropped on.** Make the group first — in the list view, by dropping one
  row onto another — and then drop into it here.

  **Schedule impact preview.** While a bar is being dragged sideways, or its
  right-hand end pulled, the tag carries a second line saying what the move
  would cost the rest of the plan:

  ```
  +5 days → 2 dependent tasks affected → project end +2 days
  ```

  It is a **forward pass over the dependency graph that respects slack**: a
  successor is pushed only by the amount its start would be overrun by, so a
  task with four days of float absorbs a three-day slip and nothing past it
  moves at all. That is why the project end can move by less than the task did,
  and why the count is of the tasks that would *really* have to move rather than
  of everything downstream. A slip that fits entirely in the float reads
  *nothing downstream is affected*. Links that were already broken before the
  drag are not charged to it — the walk is run twice, with the move and without
  it, and only the difference is reported.

  **It previews and nothing else.** Letting go moves the one task you dragged,
  exactly as before; the app does not reschedule anything behind you. Dragging
  the *left* end shows no impact line, because the End date — which is what
  successors wait on — has not moved. Dropping on another row shows no line
  either: those dates come from the target rather than from a number of days.

  While a drag is in flight the bar follows the pointer in whole days and a small
  tag says what the dates would become; aiming at another row marks that row in
  both panes, and aiming at the date bar lights the whole header strip — which
  only happens for a row that is actually in a group, since a top-level task has
  nothing to come out of. Dragging a bar starts an **edit**, so it no longer pans the chart —
  grab the background for that. A drag of less than a few pixels is still a
  click, so double-click-to-edit is untouched. Everything is one undo step
  (**Ctrl+Z**), and the file is written through as usual.

  **What is refused, with a warning and nothing moved:**

  - Nesting deeper than the plan allows. Groups go **four levels deep** (five
    levels of row), and a drop into a group that would make a fifth is refused
    *before* anything changes. A subtree is judged whole: a **single task fits
    under any group that exists**, but a group two levels tall needs two levels
    of room, not one.
  - Putting a **milestone inside a group** — it is project-wide, so it belongs
    to no group. Dropping a milestone *on a task* is fine: that is "put it
    after", and it takes the work hanging off it along, exactly as editing its
    date in the dialog does. A task can equally be dropped **on** a milestone,
    which starts it on the milestone's day.
  - Dropping a row onto something already inside it, or onto the group it is
    already a member of.
  - Dragging either end past the other, or putting a row with no dates after
    something (a row with no dates can still be dropped *into* a group — there
    are no dates to keep).

  A reschedule is **not** refused for running into a dependency — direct
  manipulation is taken at its word. If the task now starts before a predecessor
  finishes, or something that depends on it now starts too early, you are told
  which rows, and the plan is left as you dragged it.

  Note that the **list view's** drag-to-group is a different gesture with the
  older meaning: there, dropping a row onto a plain task *does* turn that task
  into a group, and the group then takes its dates from its members. The Design
  view keeps timing and membership apart on purpose, because on a chart you can
  see the dates move.
- **Zoom view (🔭)** — three levels of the same chart, with a breadcrumb in the
  context bar: **helicopter → day → hour**, from the whole project down to a
  single eleven-day window resolved to hours. The **?** chip at the end of the
  breadcrumb spells out the wheel and drag gestures for the level you are on.

  **Getting between the levels:**

  **Shift + mouse wheel changes level**, aimed at whatever the pointer is over —
  shift-wheel up with the cursor over September and the day view opens with
  September in the middle of the screen; shift-wheel up again over the 20th and
  the hour view opens around it. The **plain wheel scrolls the rows up and down**,
  as it does everywhere else.

  | From | Action | Result |
  |------|--------|--------|
  | Helicopter | **Shift + wheel up** over a month | day view, that month centred |
  | Helicopter | click a month segment of any bar | the same thing |
  | Day view | **Shift + wheel up** over a day | hour view, that day ± 5 days |
  | Day view | click a task bar | the same thing, for the day you clicked on |
  | Day view | **Shift + wheel down** | back to the helicopter, that month centred |
  | Hour view | **Shift + wheel down** | back to the day view, that day centred |
  | Any level | the breadcrumb buttons | jump straight back up |

  **The change of level is drawn as a change of scale.** Going between levels is
  a movement through the plan rather than a swap of two screens, so both levels
  are on screen for the half-second it takes and travel the way the eye is
  going. Zooming **in**, the level you are leaving keeps growing and fades out as
  it passes you, while the level arriving starts small and settles at its own
  size, fading in from nothing. Zooming **out** is the same movement reversed:
  the old level shrinks away and the new one comes in oversized. The level being
  left behind keeps its scroll position for the length of it, so it recedes from
  exactly where you were looking, and it is inert — clicks go to the level that
  is arriving. A reader whose system asks for **reduced motion** gets the new
  level directly, with no movement at all.

  One full wheel notch of travel is needed to change level, so a stray nudge — or
  the stream of tiny deltas a trackpad produces — cannot flip back and forth.
  (Shift + wheel reaches some browsers and trackpads as a horizontal delta; either
  axis works.) Centring is clamped at the two ends of the project, where there is
  nothing to scroll further into. **Ctrl + wheel** trims the month, day or hour
  width *inside* the current level.

  **Changing the width keeps the middle of the pane.** Whether it comes from the
  slider, the **+ −** buttons or Ctrl + wheel, the moment under the centre of the
  chart stays under the centre and the timeline stretches away from it to both
  sides — so widening the days magnifies what you were looking at instead of
  walking the view back towards the start of the project.

  *Level 1: helicopter.* The whole project on one screen:
  months across the top, **main tasks** down the side. A *main task* is a level-0
  row — a group, or a task that belongs to no group; members (level 1+) are
  never rows here.

  | Element | Meaning |
  |---------|---------|
  | Row | one main task — **every** main task, however short |
  | Bar | covers exactly the task's span, **split at month boundaries** into one segment per month |
  | Segment shade | that month's workload: how many of the main task's subtasks are active in it (see below) |
  | Number on a segment | that same monthly count, shown when the segment is wide enough |
  | Arrows | dependencies **lifted to main-task level**: a link between two tasks becomes a link between the bars that own them. Links inside one main task are not drawn |
  | Red line | today, positioned on the day inside its month |
  | Black line + label | a milestone, positioned on its day inside the month. Under the line comes its **name, with the date on the line below it**. Labels are spread over several lanes so neighbours stay readable |

  **Workload shading.** `max` is the busiest *(main task × month)* pair in the whole
  project — one scale shared by every row, so the shades are comparable across the
  chart. A month's subtask count falls into one of five bands, drawn very light
  blue → very dark blue, and the legend under the "Main task" header shows the
  scale with the exact range on each swatch:

  | Band | Count in that month | Colour |
  |------|--------------------|--------|
  | 1 | `0 … max/5` | very light blue |
  | 2 | `max/5 … 2·max/5` | light blue |
  | 3 | `2·max/5 … 3·max/5` | middle blue |
  | 4 | `3·max/5 … 4·max/5` | dark blue |
  | 5 | `4·max/5 … max` | very dark blue |

  A main task with no subtasks counts `0` every month, so it always draws in the
  lightest blue. With a small `max` some bands can be unreachable — with `max = 3`
  the counts 0/1/2/3 land in bands 1/2/4/5 and the middle band never appears.

  The slider / **+ −** / **Ctrl + mouse wheel** set the month width. Hovering a
  segment shows the month, its active/total subtask counts and the task's own
  dates; **double-click a row label** in the left panel to edit that main task
  (clicking a segment drills down instead).

  *Level 2: day view.* Essentially the Design view, scoped by the same idea one level
  down — days across the top, tasks down the side:

  | Element | Meaning |
  |---------|---------|
  | Row | any task **longer than a day** — main tasks **and** their subtasks, members indented under their group and named in **bold** like the task above them |
  | `+N` on a bar | subtasks **shorter than a day**, which get no row of their own and are counted onto their main task's bar instead |
  | **Additional Tasks** row | main tasks shorter than a day. Its count is those main tasks **plus any subtasks they carry**; each one is still drawn as an amber marker on its own day, so you can see when it happens |
  | Arrows | dependencies as in the Design view. A link touching a folded task is drawn from whatever represents it — its main task's bar, or its marker on the Additional Tasks row |
  | Red line | today |
  | Black line + label | milestones, exactly as at the helicopter level: name over date, under the end of the line |

  Weekend shading, the month/day header bands and the draggable splitter behave
  as in the Design view. Double-click a row label to edit that task; clicking a bar
  drills into the hour view instead.

  *Level 3: hour view.* The micro view — a fixed **eleven-day window**, the chosen
  day ± 5 days, with days across the top and hours below them:

  | Element | Meaning |
  |---------|---------|
  | Row | every task **active anywhere in the window**, whether main task or subtask; anything outside the window is not listed at all |
  | Dark blue bar | a main task. There is no grouping at this level, so the shade is the only thing that distinguishes it |
  | Normal blue bar | a subtask |
  | `« 2026-09-07` / `2026-12-18 »` | a task that runs out of the window is clipped to it and carries its **real start or end date** on that edge — both if it spans the whole window, one if it only starts or only ends inside it |
  | Arrows | dependencies, as in the Design view, between tasks that are both on screen |
  | Red line | now — at this resolution it is placed at the current **hour**, not the start of the day |
  | Black line + label | milestones, name over date. Only milestones **falling inside the window** appear |

  **Ctrl + wheel** sets the hour width, from a squeezed overview of all eleven days
  down to individual hour columns.

  **Moving around.** The plain wheel scrolls the rows, and falls back to moving
  the timeline sideways when there are too few rows to scroll vertically. Grab
  the chart anywhere and drag to pan both ways at once; a drag never counts as a
  click, so panning off a bar will not drill down or open the editor. The Design
  view pans the same way, with one difference: there a drag that *starts on a
  bar* is an edit, so pan it by grabbing the background instead.

  The helicopter and day levels always show the whole project, and the hour level
  its own window, so the Team/System filters do not apply to any of them. Undated
  main tasks are left out and counted next to the zoom slider.

  ### Y dimension: tasks, teams or systems

  The **Y:** dropdown in the context bar decides what a row stands for. On
  **Y: Teams** or **Y: Systems** the two upper levels stop being a task list and
  become a **load chart**: one row per team (or system), and each cell counts
  **how many of that team's tasks are running** in the period, shaded on the
  identical five-band scale — 0 to the busiest *(row × period)* pair in the
  project. A dark cell means that team is loaded then.

  | Level | A cell is | Click it to |
  |-------|-----------|-------------|
  | Helicopter | one month the team has work in, spanning the real first-start to last-end inside that month | open the day view **on that team alone** |
  | Day view | a **run of consecutive days with the same load** — a steady stretch draws as one bar rather than ninety identical slivers | open the **hour view showing only that team's tasks** |

  **The helicopter shows every row; everything below it is a close-up of one.**
  Drilling in — by clicking a cell, or by shift-wheeling in with the pointer over
  a row — carries that team or system down with you, so the day view is about the
  one row you came from rather than the whole board again. The breadcrumb names
  it and offers **All teams / All systems** to bring the rest back; going up to
  the helicopter drops the focus by itself. The shading scale stays the
  project-wide one, so a dark cell means exactly what it meant before you drilled
  in.

  Months and days with no work draw nothing, so the gaps in a row are real idle
  time. Group rows are containers rather than work, so they are left out of the
  counts — their members carry the team and system; milestones carry neither.
  Tasks with the field left blank collect in an **(unassigned)** row rather than
  being dropped.

  The hour view is always a task list; what the team dimension changes is *which*
  tasks — the same focus limits the window to that team's tasks. Switching the
  dropdown clears the focus and steps back up to the day view, since a team
  selected in one dimension means nothing in another.
- **Drift — how far the End date has travelled** — a plan that only holds
  today's dates cannot answer "is this still the plan we agreed?", so every task
  keeps the trail of its own End date and the **baseline** is read out of that
  trail rather than frozen in by hand.

  **There is nothing to fill in.** The app writes the trail itself, from the one
  place that already knows the plan as it was a moment ago, and the baseline is
  simply **the first End date ever recorded** for the task. Two rules keep it
  honest and keep the file small:

  - A row still ticked **Not finalized** records nothing and shows no drift.
    Dates admitted to be a guess have not slipped, they have been guessed again.
    The tick clears itself when the status moves, so the first End date that
    *does* get written is the one from the moment the dates stopped being a
    guess — which is the baseline anybody would name if you asked them.
  - **One entry per day, last value wins** — nudging a bar into place is one
    decision, not five. The exception is the first entry, which is written once
    and never rewritten: a baseline that today's edit can overwrite is not one.

  Drift is measured on the **End date only**: a task that starts late and ends
  on time has cost the plan nothing, and the End date is what the next task
  waits for. A **group** drifts when the work inside it does, since its dates
  are its members' dates — so a phase slipping shows at the phase. An **event**
  never drifts: it is a date the plan works around, not one it promised.

  Where it shows:

  - **Two columns in the list, both folded away by default** — *Baseline End*
    and *Drift* (`+7d`, `-2d`, `0`, or a dash where the question does not
    apply). Right-click a header to bring them out; the choice travels in the
    file like every other column setting.
  - **A `Drift` tick in the context bar of the Design and Zoom views**, off
    until you ask for it. Ticked, each row that has moved gets a **dotted run in
    its own colour, on its own line, spanning the two End dates** — so the
    length of the run *is* the drift. A **late** task has the tail it grew
    dotted over it, with the arrow pointing **right**; one that finished
    **early** gets a dotted run on *past* its bar to where the End used to be,
    arrow pointing **left**. Whether the drift is drawn is part of a saved view.
  - **Under Advanced in the task dialog** — the calculated baseline, the drift
    in words, and a **Baseline End** box you can type into to overrule the
    recorded answer (after a re-plan, or a date agreed in a meeting). Clearing
    the box hands the answer back to the trail.

- **Predecessors by name** — the field holds IDs, and **nobody knows the IDs**:
  they follow the time order, so the number of the task you mean today is not
  the number it had last week. So the box takes names as well. Type **two
  letters** of a task's name and the matches open under it — at most **ten**,
  in the plan's own order, the first one already chosen. <kbd>↑</kbd><kbd>↓</kbd>
  move the choice, <kbd>Enter</kbd> puts that task's ID in with a `;` ready for
  the next, and typing on narrows the list. Accents and case are ignored, so
  `arvizturo` finds *Árvíztűrő*. A task is never offered as its own predecessor.

  Only the run **after the last `;`** is the search term, so a field that
  already names two predecessors keeps them while you look up the third. A
  plain number is still a plain number: typing `5` opens nothing. <kbd>Esc</kbd>
  shuts the list without touching the edit underneath.

  <kbd>Enter</kbd> on a name that **matched nothing** says so and puts nothing
  in — it does not save the row with an unusable value, and it does not silently
  drop what you typed. The same for a name too short to have opened the list.

  In the task dialog the box is only as wide as the numbers it holds, and the
  **names those numbers point at** stand beside it as plain text — an ID that
  points at nothing is shown as typed and marked in red, so a wrong number never
  reads the same as no number. The picker works the same way in the list's
  inline row.

- **Warning colours on the dashboard** — three of the KPI cards stop looking
  like the others when their count is not zero, because a board is read at a
  glance and a number nobody looked at is a number nobody acted on:

  | Card | Turns | From |
  |------|-------|------|
  | **Late Start** | light yellow | the **first** task |
  | **Overdue** | light orange | the **second** task |
  | **Blocked** | red | the **first** task |

  The two thresholds are deliberately different: a single overdue task keeps the
  plain card and says so only in its number, while a single late start already
  speaks up. Each card keeps its health colour on its left edge, so the tint
  says *how loud* and the edge still says *which bucket*. All three still click
  through to the list, filtered to those tasks.

- **The task dialog says less about a task that does not exist yet** — a **new**
  task hides the three rows that are only true of one that does: the **ID**
  (which the dates will overrule anyway — see *IDs follow the time order*), the
  calculated **Duration**, and the **History**, which starts when the task is
  saved. All three are back the moment you open an existing task. The title of
  a plain new task reads *New task (appended to the plan)*.

- **The task dialog's Advanced section** — the fields a plan can be written
  without are folded into one section at the bottom: **Event** and its emoji,
  **Not finalized**, **Accountable**, **Responsible**, and the two drift fields
  above. The dialog opens on the six things every task needs. It **opens the
  section by itself** for a task that already uses something inside it — an
  event whose Event box was behind a fold would read as an ordinary milestone.

- **Accountable and Responsible** — two fields after System, in the list, the
  task dialog, the file and the Excel export. **Accountable is one person**;
  **Responsible is any number of them**, separated by `;`. Either name may carry
  an optional **workload** — the hours a day it costs that person while the task
  runs — written after a colon:

  ```
  Accountable:  Anna Kovacs:2
  Responsible:  Bela Nagy:6;Csaba Toth:4
  ```

  A name **without** a number costs **nothing**. Naming somebody says who is on
  the task; it does not say how much of their day it takes, and the app does not
  guess. So an unquantified name puts that person in the Person filter and on
  the resource board without booking any of their time, and **the way to make
  work show up in the resource view is to say how long it takes**. (This used to
  default to a full 8-hour day, which made a plan that named people without
  quantifying them read as though everybody was booked solid — wrong in the
  direction of alarm.) A workload must be more than 0 and at most
  24; anything else is refused by the editor, and a file carrying one keeps the
  name and loses the number, with a warning. Naming the same person in both
  fields charges them once. The list shows each name with its hours as a small
  pill; a colon that is not followed by a number is left alone, so
  `Anna: stand-in` stays a name.

  Groups and milestones carry no hours: a group's dates are its members' dates,
  so charging both would count every hour of the plan twice, and a milestone is a
  moment rather than work. A person named only on a group still appears in the
  Person filter — they simply add nothing to anybody's day.
- **Resource view (👤 Res)** — one row per **person**, one column per **day**,
  and each cell the hours that person's tasks cost them that day, added up
  across every task they are on. The row header carries their task count and
  their total; the left pane and the day columns scroll together, the splitter
  and the zoom slider work as they do in the other charts, and **clicking a
  person's row** filters the board to them (clicking it again lets everybody
  back in).

  **Weekends are left out** — nobody plans work into them, and ninety empty
  Saturday columns push the days you care about off the screen. The exception is
  a weekend day **a task actually starts or ends on**, which is a fact about the
  plan you have to be able to see; such a column is shaded and labelled as a
  weekend. The same rule decides what lands *in* it: a task counts towards a
  weekend column only when it is one of its own two ends, so a Friday-to-Monday
  task costs nothing over the weekend it merely spans.

  A day with no work draws no cell at all, so the gaps in a row are real idle
  time. The colour of a cell is the load:

  | Hours a day | Colour |
  |-------------|--------|
  | up to 4     | light green |
  | 4 – 6       | green |
  | 6 – 8       | dark green |
  | 8 – 9       | red |
  | above 9     | dark red |

  The boundaries belong to the *lower* band, so a full 8-hour day is still the
  last green one and it takes more than eight hours to turn red.

  **Team**, **System**, **Label** and **Level** narrow *which work is counted* —
  "how loaded is everyone by what my team is doing". The search box highlights a
  person whose own name or any of whose tasks match.

  **Person turns the board into one person's work.** A board of exactly one
  person is a single line of coloured squares whose meaning — *which* task those
  hours are — is left in a tooltip, so picking somebody (from the filter, or by
  clicking their row) replaces the people with **their tasks**: one row per task,
  each cell the hours that one task costs them that day, in the plan's own order.
  Their name moves to the panel's heading, with a **←** beside it that lets
  everybody back in.
- **Team planning (🧑‍🤝‍🧑 Team)** — the board that answers the question people
  actually open a plan with: **what am I on this week**, or, for whoever runs a
  team, what is my team on this week. The Resource view answers "how loaded is
  everybody"; this one answers "what is next", and it is read top to bottom:

  | Section | What is in it |
  |---------|---------------|
  | **This week** | The tasks somebody in scope is Accountable or Responsible for that **run** in the week on screen — they started on or before it ends and end on or after it begins. |
  | **Next week** | The same question, asked of the seven days after. |
  | **Later** | **Groups, not tasks.** The top-level groups the people in scope have work inside, still running after next week. A list of every individual task three months out is not something anybody reads. |

  A task is here because of its **dates**, not its status: "should be in progress
  this week" is a question about the plan, so a task nobody has moved on yet is
  exactly the row worth seeing — with its status in the column beside it.
  Milestones and events never appear; they carry no people. Clicking any row
  takes you to it in the list.

  **Who is in scope** is the other half, and there are two modes. **Team** is
  everybody on the picked team — the people *assigned* to it on the People page,
  **plus anybody named on one of its tasks**. That second half matters more than
  it looks: a plan can be full of names before anyone has been assigned to
  anything, and a board that came up empty until the list was filled in would
  never be opened twice. **People** is one person, whoever they work with. In
  Team mode the person picker narrows the team without leaving it.

  **The week is an offset**, not a date: ‹ and › walk a week at a time, *This
  week* comes back, and week 0 is always the week we are in however long the app
  is left open. The label carries the ISO week number, as in the mock.

  Four tiles across the top: **tasks** running this week, **people** in scope,
  **load**, and **risk**. Load is the hours those people are booked for this week
  against what they could do — five working days each at eight hours — counted by
  the same rule as the Resource view, so the two cannot disagree about a week. A
  name with no `:h` costs nothing, so a plan that never writes the hours reads
  0%; nobody in scope reads **—**, because "0% loaded" and "there is nobody here"
  are different sentences. Risk is this week's rows that are already overdue,
  blocked, on hold, or should have started and have not.

  The **Load** column asks the question its section is about, and *Later* asks a
  different one. In a week it is *how much* — the hours those people are booked
  for, drawn as a bar against the busiest row in the section. Out in *Later* it
  is *when*: the day the group starts. A whole group’s hours would be a number
  in the hundreds sitting beside this week’s tens, comparable to neither, and
  work that far out is placed rather than measured.

  Two things from the mock are deliberately **not** here, at their request: the
  people-of-the-week side panel and the owner photographs.
- **Presentation view (🖼 Presentation)** — the plan as a picture to show rather
  than one to work in. Three storeys, whatever the plan's real depth: a **band**
  is a main group, with its name running down a spine on the far left; a **lane**
  is one of that group's subgroups; and the **bars** on a lane are what is inside
  that subgroup, one bar each. A child that is itself a group is drawn as the one
  rolled-up bar it is everywhere else — you go into it by clicking its name.

  **Where the picture starts is a control of its own**, at the right of the
  context bar, and a different question from the Design view's Lvl picker. Lvl
  cuts depth away from the bottom; this chooses the top. At *Start: main groups*
  a band is a main group; one step in and the main groups are gone from the
  picture entirely and each subgroup is a band in its own right. **Clicking a
  lane's name** does the same to one subgroup: it becomes the band, nothing else
  is drawn, and the **←** in the panel heading is the way back out.

  Two rules worth knowing, both chosen rather than fallen into. **A row that is
  not a group still gets a lane**: a top-level task with nothing inside it is a
  band of one lane, and a task sitting directly under a main group is a lane
  beside that group's subgroups — nothing in the plan can go missing from the
  picture. And **overlapping bars stack**: two tasks of one subgroup that run at
  the same time get a line each and the lane grows to hold them, so a bar can
  never hide behind another. The stacking is worked out on the dates rather than
  on the pixels, so a lane does not change height as you zoom.

  Milestones and events are not given lanes — they rule the whole board on their
  own date, grey and blue, exactly as in the Design view. The Team, System,
  Person, Health and Label filters apply and are asked of the bars; a lane left
  with nothing to draw goes with them. Nothing here can be dragged; a
  double-click on a bar opens the task.
- **Saved views (☆)** — the picker and its two buttons at the right of the
  context bar, on screen wherever there is a chart: the Design view, the Zoom
  view and the Resource view. **☆+** saves the view you are looking at, **the
  dropdown** puts you back in one, and **🗑** deletes the one picked in the list.

  A saved view holds no task — only the handful of numbers that describe a way
  of looking at the plan, so it keeps working while the plan underneath it
  changes:

  | Saved | What it is |
  |-------|------------|
  | The view | Design, Zoom, or Resource |
  | The scale | px per day / month / hour, whichever the level uses |
  | The zoom level | helicopter, days or hours — and the day the hour window is built around |
  | The Y dimension | Tasks, Teams or Systems, and the lane drilled into |
  | The Lvl setting | how deep the plan is drawn |
  | The middle | the **date**, plus the **hour** once the chart is fine enough to show one |
  | The top row | the task, the team, or the person the chart was scrolled to |
  | The filters | Team, System and Person |

  Two of those are deliberately not what they look like. The middle is stored as
  a **moment**, never as a pixel offset: pixels mean nothing on another screen or
  after the day width has been dragged, while a date is the same everywhere and
  putting it back is a multiplication by whatever the scale is now. The top row
  is stored as **what the row is** — a task id, a team, a person's name — because
  the row a task sits on moves whenever a group is folded or the Lvl filter
  changes. A task since deleted, or one the filters now hide, leaves the chart at
  its first row; and a chart with fewer rows than the pane is tall simply does
  not scroll, which is the honest answer rather than a jump.

  The filters travel with the view because they decide **which rows there are**:
  in the Resource view the rows *are* the people the Person filter lets through —
  or, with one person picked, that person's tasks.
  Restored without them, a view would be pointing its top row and its middle at
  a different chart.

  The name offered is the plan, the kind of view, and the first free number —
  `sample-project - DesignView 1`, `sample-project - ZoomView 2` — and you can
  type over it. Saving over a name that exists asks first. They belong to the
  plan rather than to the browser, so they **travel in the CSV** (see the
  `#VIEW` line above): open the file on another machine and your views are
  there. A **read-only** plan — the combined view, read-only mode, a shared page
  — can *use* the views it carries but cannot add to them or delete them, since
  there is no file to write them to; the two buttons say so by being switched
  off.
- **Edit** — everything except the ID. Validation: predecessors must exist,
  no self-reference, and a task cannot start before its predecessor ends
  (blank hours compare dates only). Start must not be after End.

  **Enter saves the task**, wherever the cursor is in the dialog — the same key
  the inline row has always taken. Three places it still means what it meant:
  the Notes box (a new line), a button that has focus (its own action), and the
  predecessor picker while its list of names is open (take the highlighted one).
- **Delete** — dependency links pointing to the deleted task are removed too;
  higher IDs shift down by 1 and references are remapped (MPP-style), then the
  plan settles back into its time numbering.
- **Groups** — a row that holds other rows is a group: shown **bold**
  (tinted row in the list, thin black summary bar in the Design view) with its members
  indented below it. **In the list, subgroups are bold too, at every level** — a
  subgroup is a group, and reads like one; the indent and the lighter tint are
  what say how deep it sits. (The Design and Zoom side panels keep their lighter
  subgroup rows.) Group Start/End are calculated automatically — first member
  start to last member end (an empty group keeps its manually entered dates).

  **Membership is never typed.** It is a list of task IDs — a technical answer
  to a question people ask by pointing — so there is no Group box in the task
  dialog and no Group column in the list. The list already *shows* membership in
  the only way worth reading: indented under its group, with the group's name in
  bold and a count on it. Membership is changed by pointing at it: drag a row
  onto another to put it inside, drag it onto the **ID** column to take it out,
  **⤵️➕** on a group to add a new task into it, and the right-click menu for
  a whole selection at once. (The `Group` column is still in the CSV, unchanged —
  it is how the file records the tree.)

  **Drag & drop** any task row onto a group row to move it into that group — in
  the list, where dropping on a *plain task* also turns that task into a group,
  or in the **Design view**, where dropping on a group joins it with the dates
  untouched and dropping on a task means "put after" instead
  (a task belongs to one group; grouping nests **four levels deep at most** —
  group → subgroup → sub-subgroup → sub-sub-subgroup → task, so a row can sit at
  any of five levels). The **Lvl 1 … Lvl 4** picker
  in the context bar decides how deep the Design view and the Zoom view draw that
  tree: Lvl 1 keeps the top row of every branch — main groups *and* top-level
  tasks, each badged with the number of rows it stands for — each step goes one
  level further in, and Lvl 4 shows everything. The badge rides **on** the bar
  and **under** the date bar: it used to float over the header as the plan
  scrolled beneath it, because the pill's own layer — it has one, so it clears
  the summary bar's end caps — was competing out in the page rather than inside
  the bar it belongs to. Bars are their own stacking context now, and nothing at
  bar level, a search hit and a bar in mid-drag included, is drawn as high as the
  header. The picker is built from the
  depth limit, so it always has exactly one setting per level. Dragging the other
  way, onto the **ID column** — any ID cell or the ID header — takes the task
  out of its group again; the column highlights in red while you hover it. In the
  Design view the same move is a drop on the **date bar**, and there too the
  dates are left alone. The
  ⤵️➕ button on a group row adds a new task directly into the group, and ➖ on
  a member does the same as the ID-column drop.

  The **caret (▾ / ▸)** on a group row folds its members away, and **which groups
  are folded is remembered in the file** — reopen the plan tomorrow, or on another
  machine, and it is folded the way you left it. It travels the same way hidden
  columns do, in the `#VIEW` line (see *File format* above), and like them it is a
  view change rather than a plan change: it never lands on the undo stack. Only
  the closed groups are written, so a group added later comes up open.

  **A filter overrides the folds.** While Team, System, Person, Status or the
  search box is narrowing the list, every group is drawn **open** — in the list,
  in its carets and in the Design view's summary badges. A search that finds
  four tasks and then hides three of them behind a folded group has not found
  them. The folds themselves are untouched: nothing is written and nothing is
  forgotten, and clearing the filter puts the plan back exactly as it was
  folded. Clicking a caret while a filter is on still records the fold for when
  the filter goes, and says so, since it can change nothing on screen.
- **Duplicate (📋)** — copies a task and drops the copy directly below the
  original, and it stays there: a copy shares its source's dates, so the time
  numbering keeps the two together instead of parking the copy at the end of the
  plan. Every predecessor and group reference is remapped along the way.
  The copy takes the dates, hours, predecessors, team and system of the source
  and adds **“ - duplicate”** to the name. Duplicating a *member* of a group
  puts the copy in the same group; duplicating a *group* copies only that row —
  its member tasks stay where they are, since a task can only be in one group.
  The **copy takes over the selection** from the original, so whatever you do
  next — drag it into a group, duplicate it again — lands on the new row.
- **New / Insert** — a new task lands **where its dates put it**, not at the
  bottom: “New task” and the ⤴️➕ button in the list differ only in which row the
  new one is prefilled from, since the numbering settles the row into the plan
  either way. Predecessor and group references are remapped automatically.
  A new row **starts where the row you are on ends**, hours included — the group
  it is being added into (⤵️➕), the row selected in the list, or the row whose
  ⤴️➕ button you pressed, in that order. Its End starts out equal to its Start
  and follows it while you pick a date; typing an End of your own ends that, so
  a length entered by hand is never overwritten by a corrected Start. All of it
  is prefill: nothing here changes a task you are editing.
- **The period is kept the right way round while you type** — on an *existing*
  task nothing follows anything, so moving the **Start past the End** used to
  leave a period running backwards and you only found out on save. Now the
  other end comes with it: pick a Start after the End and the **End moves to
  the same day**; pull the End back before the Start and the **Start moves**
  instead. Whichever end you touched is the one that stands, hours included,
  and a toast says what moved. Both editors do it — the **task dialog** and the
  **inline row in the list** — so which one you happened to open never decides
  whether the mistake is caught as you type or refused at the end.

  It fires on the field's *change*, not on every keystroke, or a half-typed
  `2026-0` would look like a backwards period and be "fixed" out from under you.
  And the comparison is the very one the validator uses, so the editor repairs
  exactly what the save would have objected to and never moves a period that was
  going to be accepted — a blank End hour means "any time that day", so a
  16:00 start on the same day is not backwards and is left alone. Same-day
  *hours* that cross are, though: an End hour pulled back before the Start hour
  collapses onto the hour rather than onto the day.
- **A new file cannot land on a name already taken** — **📁 DB → New file…**
  (and *Import into a new file…*) asks again rather than resolving it for you:
  the two things the app could do on its own are both wrong. Overwriting throws
  away somebody's plan, and quietly opening the existing file leaves you typing
  into a plan you did not mean to touch. So the prompt comes back saying the
  name exists, with the **first free number** filled in — `plan` → `plan-2`, and
  `plan-2` taken → `plan-3` — so the usual answer is one keystroke. Backing out
  leaves `/DB` untouched. A name that is free is created without a question.
- **Import from Excel** — the **⤵️ Import Excel** button reads a plan out of an
  Excel sheet and maps it onto this model. Two Excel formats are read, and they
  are the two that are *text* — which is the whole reason they are the two. A
  real `.xlsx` is a zip of parts and unzipping it needs a library; nothing here
  is fetched and nothing is bundled, so `.xlsx` is out of reach. These are not:

  | Format | Where it comes from |
  |--------|--------------------|
  | **XML Spreadsheet 2003** (`.xml`) | exactly what this app's own **📊 Export to Excel** writes, so a plan exported from here comes straight back in |
  | **HTML table** (`.html`, `.htm`, and the `.xls` Excel writes as *Save as Web Page*) | what most tools emit when asked for "Excel" without a library |

  Which one a file is, is decided by **what the markup is, not by its
  extension**: Excel is happy to call an HTML table `.xls`, and a plan mailed
  round loses its extension as often as not. A file that is neither says so
  instead of importing nothing.

  Both are read by hand rather than through `DOMParser`, so the reader behaves
  the same in the browser and under the headless test suite. `ss:Index` (how
  Excel skips a run of empty cells) and `ss:MergeAcross` are filled back in as
  blanks, or every row after them would be shifted a column — which is worse
  than failing. In an HTML table `colspan` is filled back in the same way, and
  only the **first** table is read: Excel writes one per worksheet, and reading
  them all would staple a second sheet's rows onto the first sheet's columns.

  It copes with foreign sheets by itself:

  | What | How |
  |------|-----|
  | Encoding | UTF-8, with or without the BOM Excel writes |
  | Entities | `&amp;`, `&lt;`, `&nbsp;` and the numeric forms; `<br>` in a cell reads as a space |
  | Typed dates | Excel's `2026-03-02T00:00:00.000` is read as the day |
  | Rich text | a cell with `<B>`/`<Font>` runs inside it gives up its text |
  | Column names | matched by alias, accent- and case-insensitively, in English and Hungarian: `Number`/`ID`, `Task`/`Activity`/`Feladat`, `Kezdés`/`Start`, `Befejezés`/`End`, `Dependency`/`Predecessors`, `Resources`/`Team`, `System`, `Accountable`, `Responsible`, `Státusz`/`Állapot`/`Status`, `History`, `Group`, `Milestone`, `Event`/`Esemény`/`Holiday`, `Emoji`/`Icon`/`Ikon`, `Not Finalized`/`Draft`/`Nem végleges`, `Notes`. Unrecognised columns are listed in the preview and ignored. |
  | Status words | matched the same way, in English and Hungarian: `In Progress`/`WIP`/`Folyamatban`, `Done`/`Completed`/`Kész`, `On Hold`/`Felfüggesztve`, `Blocked`/`Blokkolt`, `Planned`/`Tervezett`. A word this app does not know is **reported in the preview** and the task starts at `Planned`; a sheet with no status column at all gives every row a history starting on the day it was imported |
  | Dates | `YYYY-MM-DD`, `YYYY.MM.DD`, `DD/MM/YYYY` and the Hungarian short form `K 26.12.15.` (weekday label, 2-digit year, trailing dot) |

  **Our own export round-trips.** The Excel sheet this app writes puts the
  activity name in one of several **`Level 1` … `Level 5`** columns, because
  indentation is a drawing and a column is data. Reading one back, every one of
  them is the name column — a row fills exactly one — and the nesting itself
  comes back from the `Group` column, which carries it in full. `Duration` is
  derived from the dates and quietly ignored, and `DB File` (the combined
  view's own column) belongs to no single plan, so neither is reported as data
  lost.

  **`Responsible` is a people column now.** It used to be read as a *Team*
  name, from the days when the app had no people in it; a column called
  Accountable or Responsible is read as the person it names, which is also what
  makes the round trip keep its people. Hungarian plans title the owning team
  **`Felelős`**, and that one still maps to Team.

  A row is imported as a **milestone ★** when *any* of these holds: the Team /
  Resources column is `TECHNICAL`, the activity name starts with `MILESTONE`
  (the prefix is stripped — the ★ carries the meaning), or an explicit
  `Milestone` column says yes. As everywhere else, a milestone gets no team,
  system, people or group, and its end mirrors its start.

  Nothing is written before you confirm: a **preview** shows every row as it
  will be created, the format and columns recognised, and every warning
  (unparseable dates, skipped nameless rows, unused columns). From there you can
  **append to the open file** or **import into a new file**. Imported tasks get
  fresh IDs appended after the existing ones, and dependencies referring to the
  source sheet's own numbering are remapped to match; references pointing
  outside the imported set are dropped and reported. Teams, systems and the
  people named in the import are added to the shared registry.

  **A foreign file in `/DB` cannot be eaten by the app.** Opening checks the
  format first: a file whose columns are not the ones this app writes is never
  adopted as a project. It is left exactly where it is, nothing is loaded, and
  auto-save can never reach it — the message says to save it as an Excel sheet
  and bring it in through **Import Excel** instead, which leaves the original
  untouched by construction.
- **Combined view** — the **🧩 Combined view** button opens a picker of the
  project files in `/DB`; tick several and every view — **list, Design, Zoom and Res** —
  shows all of them at once. The list gains one extra column, **DB File**, right
  after Notes, saying which file each task came from; everything else is
  unchanged. Filtering by team or system, the search box (which reaches
  the file name too), folding groups and the Excel export all work across the
  whole set, so "what is everyone doing that week" is a question you can finally
  ask of plans that live in separate files.

  Each file **keeps its own IDs on screen**. Internally the rows have to share
  one numbering — two files both start at 1, and their predecessors and group
  members would otherwise point at each other's rows — so every file's IDs are
  shifted past the ones already loaded and its references shifted with them. The
  original number is what the views display; the shifted one never appears.

  The combined view is **read-only**, and deliberately so: there is no single
  file to write back to, and guessing which one a change belongs to is a way to
  corrupt two plans at once. So the **Actions column is gone**, along with the
  row menu, inline editing, drag-and-drop grouping, undo/redo, Save and New
  task. Folding a column away still works — it is just **not saved**, since a
  per-file setting cannot speak for six files. Nothing about the merge is
  written anywhere: leave the view and every file is exactly as it was.

  A chip on the app bar says how many files are open and lists them on hover;
  click it to change the set. **⟳ Reload** re-reads all of them. Leave through
  the picker's *Leave combined view*, or simply open a single file again.
- **Read-only mode** — **📁 DB → 🔒 Read-only mode** opens the app as a viewer.
  The folder is asked for **read** permission only, so Edge/Chrome never puts up
  the prompt about *changing* files — and if the permission was never granted,
  nothing here can write even by accident.

  Everything that reads stays: the file dropdown, all four views,
  filtering, the search, folding groups, the Zoom drill-down, the combined view
  and the Excel export. Everything that writes goes: Save, New task, the Actions
  column, the row menu, inline editing, drag-and-drop in the Design view,
  undo/redo, New file, Import Excel, and adding or deleting teams and systems.
  A file opened this way is still **repaired on screen** if it needs it — the
  repairs are simply never written back, and the message says so.

  The **`/DB` folder has to exist already**: read permission can list a folder
  and open the files in it, but it cannot create one, and asking to would raise
  the very "Edit files" prompt this mode avoids. Pick the folder that holds
  `/DB` (or `DB` itself) — if there is no `/DB` inside it, the app says which
  folder it looked in rather than failing silently.

  An amber **🔒 Read-only** chip sits on the app bar; click it (or the menu item,
  which now reads *🔓 Leave read-only mode*) to switch back. That is the one
  moment write permission is asked for, and if it is refused the mode stays on
  rather than leaving an app that looks editable and is not.

  **Launching straight into it** — the mode and the file can come from the
  address, so a shortcut can open one plan for reading and nothing else:

  ```
  index.html?mode=readonly
  index.html?mode=readonly&file=switchover.csv
  index.html?file=switchover.csv
  ```

  `mode=readonly` starts the app in read-only mode, **before** the remembered
  folder is reconnected, so the launch never flashes a write prompt.
  `file=` names a file inside `/DB` and opens it as soon as a folder is
  available — on its own, without `mode`, it is a normal editable launch that
  simply starts on that file. The name is tidied up rather than refused: a
  missing `.csv`, a `DB/` in front of it and URL escapes (`go%20live`) are all
  accepted. If the file is not in `/DB`, the app says so instead of coming up
  blank. `#mode=readonly&file=…` after a hash works identically, for shortcuts
  that cannot carry a `?` through to a `file://` page.
- **Share as a read-only page** — **📁 DB → 🔗 Share as read-only page…** writes
  a single `.html` file holding the plan *and* the app that draws it. Mail it,
  drop it on a share, double-click it: it opens as an ordinary web page. **No
  folder to pick, no permission prompt, no `/DB`, nothing installed.**

  This exists because **the folder permission cannot be given away.** The File
  System Access API always asks, and it asks whoever is sitting in front of the
  browser — read-only mode only softens the question from *Edit files* to *View
  files*, it cannot remove it, and a grant on your machine means nothing on
  anyone else's. So a plan that has to travel cannot travel as a link into
  `/DB`; it travels as a copy of the page with the plan already inside it.

  The recipient gets the whole app — all four views, sort, filters, search,
  folding, the Zoom drill-down, and Excel export — minus everything that needs a
  folder. The DB menu is down to *Export to Excel*, and an amber **🔒 Shared
  copy** chip on the app bar gives the date it was made.

  What it is **not** is live: it is a point in time, so it is re-exported when
  the plan moves on. That is the price of needing no server at all. What is on
  screen is what travels — share while a single file is open and the copy holds
  that plan; share from the **combined view** and all of its files travel
  together, still read-only, still each showing its own IDs. Folded-away columns
  come along too. A copy will not re-share itself into a second-hand copy.

  About 350 KB plus the plan, most of which is the app itself.

  **A plan can also travel as a link** — `index.html#plan=…` — with the whole
  plan carried after the `#`, where no server ever sees it. The page opens it
  exactly as a shared copy: read-only, no folder, no permission, nothing
  written. It is for *showing* a plan rather than keeping one — an assistant
  that has drafted you a plan in a chat can hand it over as one link, and it
  is on screen in a tap. The plan is deflate-compressed and base64url-encoded
  (a fifty-row plan is a few kilobytes of link; a `~` in front marks the
  packed form, and a plain base64url plan works too); `&file=` names it,
  `&view=` and `&lang=` work as on any shared copy. The exact recipe is in the
  skill document (`skill/gantora-plans/SKILL.md`).
- **Export to Excel** — the **⤴️ Export to Excel** button writes the task list as
  a **SpreadsheetML** workbook (*XML Spreadsheet 2003*): a single `.xml` text
  file that Excel opens as a real workbook — bold header, typed dates, frozen
  header row. It is written by hand, like everything else here, because a
  `.xlsx` is a zip archive and this app bundles no library to build one.

  Grouping nests four levels deep, so a row can sit at any of five levels and
  **the name gets five columns** — `Level 1` … `Level 5`. Each row writes its
  name into the column of its own level: a group in `Level 1`, its members in
  `Level 2`, and so on. Indentation is a drawing; a column is data, and it
  survives sorting, filtering and everyone else's copy of the file. The count
  follows the depth limit, so another level of grouping would bring its column
  with it. Every other field is one column, as in the CSV:

  | ID | Level 1 | … | Level 5 | Duration (days) | Start | Hour (Start) | End | Hour (End) | Predecessors | Team | System | Accountable | Responsible | Status | History | Not Finalized | Notes | Group | Milestone | Event | Emoji |
  |----|---------|---|---------|-----------------|-------|--------------|-----|------------|--------------|------|--------|-------------|-------------|--------|---------|---------------|-------|-------|-----------|-------|-------|

  **History is one cell**, the same `2026-03-02 Planned;2026-03-09 Done` the CSV
  carries: it is one fact about the row, and a sheet with a column per status
  change would have a different shape for every plan.

  In the **combined view** the sheet gains the same extra **DB File** column,
  in the same place — a workbook merging six plans is unreadable without it.

  **Only the task list travels** — no chart, no view settings, no colours from
  the charts. The export is also deliberately blind to what is on screen: a Team
  filter or a folded group changes the list you are looking at, never the file
  you get, so an export is never quietly short of rows. Dates go out typed
  (`DateTime`, formatted `yyyy-mm-dd`) so Excel sorts and filters them as dates
  rather than as text, and an empty field is an empty cell rather than an empty
  string. You choose where the file goes; it is not written into `/DB`.
- **Saving** — auto-saves after every successful change; manual save with the
  💾 button or **Ctrl+S**; ⟳ Reload re-reads the file from disk.
- **File repair on open** — a hand-edited `DB/*.csv` can contain things the
  editor forbids. They are repaired on load and reported: duplicate IDs are
  renumbered (no row is dropped), dangling or self-referencing predecessors and
  group members are removed, nested groups are un-nested, milestones are
  taken out of groups, a second Accountable name is dropped and an impossible
  workload loses its number. Previously a nested group made *all of its members*
  disappear from both views without a word. The repairs are only on screen until
  you save.

## Tests

`gantora.js` runs the app's parsing, date, repair, import, export,
combined-view, read-only-mode and shared-page logic headlessly behind a stub
DOM — no browser, no dependencies:

```
node gantora.js
```

It is a development aid only; `index.html` remains standalone without it.

## What goes in the download

The release archives (`Gantora.N.zip` / `.7z`, and the Free pair) are built from
a **named list of files**, never from the whole working folder:

```
index.html  gantora.js  README.md  EULA.txt  DB/sample-project.csv
DB/_fixture-sample-project.csv  DB/_fixture-cofi-import.csv
```

The two `_fixture-` files are there because the suite above reads them; the app
itself hides anything starting with `_`.

**Nothing else from `DB/` may go in.** That folder is a working folder on the
machine the build is made on: it holds real plans, the `_lists.csv` registry of
teams and systems this copy happens to know, and whatever images were dropped
in it. Archiving `DB/` wholesale put all three into a public download — up to
v1.0 the zip carried a megabyte of logo source and a registry naming a
customer's systems. Name the files.

## Editions and the licence

Two editions come out of this one file, decided by one line: `const EDITION`,
which `license/make-free.js` flips to `"free"`. Nothing is cut out — the free
build is the same file with the flag turned over, which is why the test suite
can hold both of them to the same source.

| | Gantora Free | Gantora |
|---|---|---|
| Views | Dashboard, List, Design, Zoom | those four plus Res, Team, Report, Cockpit, Presentation |
| Files | one, fixed: `project.gantora` | as many as you like, with a picker |
| New file / Save as | — | yes |
| Combined view, Compare & merge | — | yes |
| Restricted mode | — | yes |
| Licence box in About | — | yes |
| Read-only mode | yes | yes |
| Excel in and out | yes | yes |
| Shared read-only pages | yes, with a line naming the product | yes, naming the licensee if there is one |

Pointing a free copy at a folder opens `project.gantora`, creating it on the
first run — so it lands on a working plan rather than an empty screen and a
picker it does not have. The format is identical, so that file opens unchanged
in the full edition.

This is a **product boundary, not a security one.** It is one editable HTML
file: anyone can flip the line back. The gate is there to describe a product,
not to defend one.

### The licence

A licence is a **receipt, not a key.** It unlocks nothing — neither edition
asks whether one is present before doing anything. What it does:

- **Proves entitlement.** A signed line in About saying who a copy belongs to
  and who issued it is what an internal audit asks for.
- **Cannot be forged.** Without the signature anyone could type in a name.
- **Travels outward**, so a leaked copy is traceable: the licensee is named on
  Excel exports and on shared read-only pages.

The `Seats` field is carried in the licence and **deliberately not displayed**.
Nothing in this app counts anything against it — there is no server and no
telemetry, so there could not be — and printing a seat count would claim a
limit nobody keeps. It is a number in a contract, and it stays there.

Licences are issued by **Online Factory Kft.**

### How it works

A licence is a short block of signed text, emailed to the person it belongs to.
They open **About → paste it into the licence box → Save licence**. The app
checks the signature and keeps it in `localStorage`, beside the config. One step
per machine, and it works in restricted mode, where there is no folder at all.

Two things it deliberately is not. It is **not a file beside the app**: a page on
`file://` cannot fetch a sibling — the same constraint that put the config in
`localStorage`. And it is **not baked into the page**: that was considered and
dropped, because it would mean packaging a separate build for every recipient,
and one static zip for everybody is worth more than that.

The cost is real and worth knowing: the licence does not travel with the app's
folder, so a new machine needs the text again — keep the email — and clearing
the browser's data takes it along with the config.

Pasted text arrives through the clipboard, and a Windows round trip turns every
line ending into CRLF, which changes the bytes the signature was made over. The
reader normalises to LF before checking, so a pasted licence works whatever the
clipboard did to it.

The signature is **ECDSA P-256 / SHA-256**, checked in the browser by
`crypto.subtle.verify` against a public key compiled into the app. No network
call is involved, at any point, ever — the promise that nothing leaves the
machine is unchanged. The check is asynchronous and nothing waits on it: the
app is fully usable while it runs, and the About line fills itself in.

Three states, and About says which: **Free** (no licence in the page),
the licensee's name (signature good), or **Licence file not valid** (there is a
licence but the signature does not match). A tampered licence is never quietly
downgraded to Free — a file somebody edited is a different thing from one that
was never there.

### What this is and is not

It is a **register and a deterrent**, not a lock. The signature means a copy
cannot be re-badged to somebody else's name without the private key, so every
export and every shared page a partner build produces carries the name it was
issued to, and a leaked copy is traceable. It cannot stop anyone deleting the
check: this is one editable HTML file, and any check inside it can be edited
out. That trade is deliberate — the alternative is a phone-home, which this app
will not do.

### Issuing a licence

`license/make-license.js` — Node, zero dependencies, local only:

```
node license/make-license.js keygen            # once, ever
node license/make-license.js sign              # asks for the fields
node license/make-license.js verify [file]     # same WebCrypto call the app makes
node license/make-license.js log               # the register of what was issued
```

The output is the licence **text** — saved as a file so it can be logged and
re-sent, but what reaches its owner is the text itself, in an email.

The private key (`license/gantora-private.pem`) is git-ignored and lives on one
machine plus offline backup. **If it leaks anyone can mint a licence; if it is
lost, no further licence can ever be issued** against the public key already
shipped in the app. The public key is not secret and is committed on purpose —
it has to reach the build.

`license/verify-in-browser.html` checks a `.lic` with the browser's own
WebCrypto, which is the gate that decides whether the app will accept it.

## Corporate / licensing note

The app is 100% self-contained vanilla HTML/CSS/JavaScript — no CDN calls,
no third-party libraries, no telemetry, nothing leaves the machine.

Nothing licensed is bundled or fetched: no chart or Gantt library, no icon set,
no web fonts, no images. Every bar, arrow, grid line and milestone marker is a
plain CSS box or hand-written inline SVG; text uses the machine's own
`Segoe UI / Arial / sans-serif` stack; the only date formatting comes from the
browser's built-in `Intl`. The button glyphs are Unicode characters drawn by the
operating system, not shipped assets. So the file can be copied onto a corporate
machine with no attribution, licence check or approval.
