← Commit history
README.md+6−2
@@ -74,7 +74,9 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  **Current and Overdue.** Two chips in the legend, for reviewing a plan with the team. **Current** (0.70.0) shows only what has started (start date today or earlier) and is not complete or cancelled: the work under way, plus anything that should have started and has not. Future and finished rows are hidden, in the chart and the table. **Overdue** shows only what is past its deadline and not done. Turn both on for just the overdue rows. Each counts tasks and milestones separately, and neither changes the plan. -**Legend filtering.** Click any phase, "Milestone", "Dependencies" or "Today" in the legend bar to toggle visibility. Dimmed items fade to 15% opacity; hiding "Today" removes the marker line. The legend wraps onto as many lines as the plan needs, so a plan with twenty sections shows all twenty chips. When the sections carry tags (0.59.0), the legend leads with a chip per tag and the twenty section chips fold behind one "20 sections" chip; pick a tag and only the sections carrying it are shown, in the chart and the table, with their own chips back in the legend. Pick several to see any of them; Clear tags shows everything. It is your view only, and it survives a refresh. Since 0.64.0 the legend also leads with an **Owner** pick (every owner with their row count, plus **Unassigned**, shared with the table's owner filter) and an **Overdue** chip: rows whose end date or milestone date has passed without being marked complete or cancelled get a red edge and ring, and the chip shows only them. Overdue is worked out from the dates every time the page draws and at midnight; it is never written into the plan. Changing any of these filters, or Collapse all and Expand all, keeps your place: the row at the top of the chart stays where it is, or, if the filter hides it, the nearest row above it that still shows takes its spot. A row someone else adds or deletes above you does not move your view either (0.74.1). Scrolled all the way up, you stay at the top, and a section's chevron stays under the pointer when it folds (0.74.2).+**Legend filtering.** Click any phase, "Milestone", "Dependencies" or "Today" in the legend bar to toggle visibility. Dimmed items fade to 15% opacity; hiding "Today" removes the marker line. The legend wraps onto as many lines as the plan needs, so a plan with twenty sections shows all twenty chips. When the sections carry tags (0.59.0), the legend leads with a chip per tag and the twenty section chips fold behind one "20 sections" chip; pick a tag and only the sections carrying it are shown, in the chart and the table, with their own chips back in the legend. Pick several to see any of them; Clear tags shows everything. It is your view only, and it survives a refresh. Since 0.64.0 the legend also leads with an **Owner** pick (every owner with their row count, plus **Unassigned**, shared with the table's owner filter) and an **Overdue** chip: rows whose end date or milestone date has passed without being marked complete or cancelled get a red edge and ring, and the chip shows only them. Overdue is worked out from the dates every time the page draws and at midnight; it is never written into the plan.++**Project dates.** The first card on Setup holds the project's start and end (0.75.0). Project Duration runs from the start to the end and Time Remaining from today to the end, so the clock in the header is the project's commitment rather than wherever the rows happen to reach. The chart marks both dates with a dashed amber line and hatches the time outside them, so a bar that runs past the end is hatched where it does, the header readouts turn amber, and the card lists every row outside the dates with a link to it. Only the plan's owner can change them. With no dates set, everything counts from the rows as before. They replace the header's start button, which moved the whole plan for everyone. Changing any of these filters, or Collapse all and Expand all, keeps your place: the row at the top of the chart stays where it is, or, if the filter hides it, the nearest row above it that still shows takes its spot. A row someone else adds or deletes above you does not move your view either (0.74.1). Scrolled all the way up, you stay at the top, and a section's chevron stays under the pointer when it folds (0.74.2).  **How long, and how long left.** The header says **Project Duration** and **Time Remaining**, worked out from the plan's own first and last dates and today, never from the cell size, leaving cancelled work out, and updated on every edit. They stay whole at every window width: the header sheds button words, then wraps, before it lets them be cut. A printed chart names the plan in a title line of its own. Hover either for the exact dates and the calendar and working-day counts. The plan itself is named once, in the brand block at the far left; its title and description are on that block's tooltip. @@ -146,7 +148,9 @@ Create a JSON file with three sections: {   "project": {     "title": "My Project",-    "subtitle": "Short description"+    "subtitle": "Short description",+    "start": "2026-01-05",+    "end": "2026-06-30"   },   "phases": [     { "name": "Phase 1 — Build", "lead": "the long pole (Adonis)", "start": "2026-01", "end": "2026-06" }
SKILL.md+4−3
@@ -104,6 +104,7 @@ Create a JSON file with this structure: | appName | string | no | Shown in the brand block at the far left, the one place the header names the plan. With no appName the brand shows the title (it used to show the literal "adom-gantt"). A PRINTED chart carries the title and subtitle in a print-only line above the header, since tooltips do not print | | nextUid | number | no | The uid counter. Written and owned by the server, never by you. See uid below | | view | object | no | The author's intended default view, see below |+| start, end | YYYY-MM-DD | no | The project's own dates (0.75.0): the window the work has to fit in. The owner sets them on Setup (the sidecar's `dates` then wins over these). The header's Duration and Remaining count from them, and the chart draws each as a dashed line with the time outside hatched. Either can be left out; that end then comes from the rows | | tags | `{name, color?}[]` | no | The tag registry (0.61.0): the plan's tags that have a colour, and tags nothing carries yet. `name` follows the tag rules below; `color` is stored as lowercase `#rrggbb` (`#abc` is widened, anything else is dropped and the entry kept); unique without regard to case, at most 200. A tag on a section or row that is not listed here is still a tag: the registry adds colour and unused tags, it never gates them. Absent when empty: a plan that never used it carries no key |  **project.view** (optional) sets how the chart opens. It applies to the HTML export, to@@ -233,7 +234,7 @@ rather than a category.  - **New** button (or Shift+N): create a task or milestone. After a successful create, the next New panel prefills every field from the one you just made (type, phase, owner, status, start, deadline or milestone date, provenance label, and since 0.60.0 its tags). The name and, since 0.66.2, the notes start empty, because a note is about the one row it was written for, so a run of tasks for the same person in the same lane in the same week is names and nothing else. A provenance left at its default is not remembered as a value, so it keeps following the signed-in identity. It is in-memory for the page session only: a refresh clears it, and it is never written to localStorage, the preferences or the sidecar, so it is never carried to anyone else. Only a create that produced a row updates it: Cancel, Escape and a validation-rejected create all leave it untouched. Created rows live in the sidecar as `added: [...]`, keyed in a `#new-` namespace that cannot collide with the data file's `#row<n>`, and are merged into every export - **Text size** A-minus/A+ in the header: seven steps from 85% to 150%, applies to the chart and the table, persists in `localStorage` as `prefs.fs`. Row and bar geometry scale with the type. **It survives a refresh in the live app AND in an exported HTML snapshot** (fixed in 0.57.0). Text size is a READER preference, so it is the one pref a snapshot reads back from and writes to the reader's storage; everything else in a snapshot still opens at the author's view, and a snapshot now writes only the size, so it can no longer overwrite the grain, lens or collapse set a reader has stored on the same origin. Open tabs of the app follow a size change made in another tab live. **Every pref is saved merge-on-write**: a save writes only the keys this tab changed since it last read or wrote them, into a fresh read of storage, so a second tab saving its theme can no longer reopen the lanes you collapsed in the first, or reset your text size **On a page the host sandboxes (0.58.0)**, such as a snapshot uploaded to wiki.adom.inc and served under `Content-Security-Policy: sandbox` with no allow-same-origin, the page has an opaque origin and every reload starts with empty storage. A snapshot therefore also keeps the size in the URL fragment (`#fs=1.2`, written with replaceState), which a reload, a bookmark and a shared link all keep. Storage still wins where it works, so a reader's own size is not replaced by a link carrying somebody else's; the fragment answers only where storage has nothing.-- **Project Duration and Time Remaining** in the header, stacked, beside the lens switch (0.57.0, replacing the old "25 weeks" axis-cell count, which changed with the grain and counted the axis padding). Both come from the plan's own dates and never from the grain: Duration runs from the earliest task start or milestone date to the latest task end or milestone date, both days counted, and Remaining runs from today to that last date, today counted. **Cancelled rows are left out**, as Auto-schedule, the critical path and the hours totals leave them out, and the tooltip says how many were; if every dated row is cancelled both read `none` and the tooltip says why. (The start button still counts cancelled rows, because it moves the whole plan. The Analytics "calendar span" card still includes them; it was not changed.) Format: days under two weeks, `16w 4d` style under half a year, then months and years to one decimal. The tooltips give the exact calendar and working-day counts and both dates. Remaining reads `ended` once the last date is past; before the first date it still counts from today, so it can be longer than Duration, and a small `not started` note sits on the readout itself to say why. Both read `none` for a plan with no dated rows. They update on every edit that can move a date (drag, editor, table edit, start shift, auto-schedule, create, delete, paste, undo, redo) and at midnight on a tab left open. The header FITS ITSELF to its content rather than to pixel breakpoints (`fitHeader`): it measures the row and, only while the content is wider than the window, sheds in order the version, the button words and the long labels (Duration, Remaining). If that still does not fit, it wraps onto a second row with the long labels back, and with the button words back too whenever they cost no extra row (they stay off on a phone, where they would). The readouts never shrink, so they are whole at every width; the suite sweeps 360 to 2000px with and without an appName at 100% and 150% text to hold that+- **Project Duration and Time Remaining** in the header, stacked, beside the lens switch (0.57.0, replacing the old "25 weeks" axis-cell count, which changed with the grain and counted the axis padding). Both come from the plan's own dates and never from the grain: Duration runs from the earliest task start or milestone date to the latest task end or milestone date, both days counted, and Remaining runs from today to that last date, today counted. **Cancelled rows are left out**, as Auto-schedule, the critical path and the hours totals leave them out, and the tooltip says how many were; if every dated row is cancelled both read `none` and the tooltip says why. Since 0.75.0 the **project dates** (Setup) win where they are set: Duration runs from the project start to the project end and Remaining from today to the project end, and the rows only stand in for an end that is not set (never on the wrong side of the one that is). Rows that fall outside the dates turn both readouts amber and are counted in the tooltip. (The Analytics "calendar span" card still includes them; it was not changed.) Format: days under two weeks, `16w 4d` style under half a year, then months and years to one decimal. The tooltips give the exact calendar and working-day counts and both dates. Remaining reads `ended` once the last date is past; before the first date it still counts from today, so it can be longer than Duration, and a small `not started` note sits on the readout itself to say why. Both read `none` for a plan with no dated rows. They update on every edit that can move a date (drag, editor, table edit, project dates, auto-schedule, create, delete, paste, undo, redo) and at midnight on a tab left open. The header FITS ITSELF to its content rather than to pixel breakpoints (`fitHeader`): it measures the row and, only while the content is wider than the window, sheds in order the version, the button words and the long labels (Duration, Remaining). If that still does not fit, it wraps onto a second row with the long labels back, and with the button words back too whenever they cost no extra row (they stay off on a phone, where they would). The readouts never shrink, so they are whole at every width; the suite sweeps 360 to 2000px with and without an appName at 100% and 150% text to hold that - **Fit** button beside the cell-size picker: picks the finest grain whose whole span fits the pane, so the plan is fully visible with no horizontal scrolling. Chart-only - **Gantt / Table / Analytics / Setup toggle** pinned first in the header, left of every conditional control so its position never shifts. Four lenses over the same items: the chart answers "when", the table answers "whose, and by when", analytics answers "how much, and is any of it in trouble", and Setup is the plan's structure, its sections. See The table view, The analytics view and The setup view below - **Auto-schedule** button, with the plan-level controls next to Fit, in the chart and the table lenses. One press lays the WHOLE plan out under two rules at once: no task with dependencies starts before its latest predecessor's end date, and no task starts or ends on a Saturday, a Sunday or a US federal holiday. The handoff date is a MINIMUM and not an answer: it is BACK TO BACK for a task that was overlapping and is pushed onto it, and back to back is a statement about the CHART, since a bar runs from its start to its end and the end is the boundary it stops at, so a successor starting on that date draws flush against it with no gap and no overlap. **PUSH ONLY, since 0.53.0.** A task that already starts on or after its handoff is LEFT WHERE IT IS, however wide the gap, because a gap in a real plan is usually somebody's lead time: an order placed on the Monday and the parts that arrive on the Thursday keep their three days. Through 0.52.0 the pass went both ways and closed every one of those, and there was no way to keep one. Nothing is ever pulled EARLIER now, so the plan cannot drift backwards over a press, and the pass reaches a fixed point in ONE press: the second moves nothing. Pushes still cascade, each successor measured against where its predecessor LANDED and not against the date it shipped with. **CANCELLED WORK IS NOT IN THE PASS AT ALL,** also since 0.53.0: it is never moved, so its dates freeze exactly as set even if they violate something, and it constrains nobody, so a task whose only predecessors are cancelled behaves like an anchor and one with a mix is pushed by its LIVE predecessors alone. The toast counts those rows as their own third number ("5 tasks moved, 15 unchanged, 1 cancelled excluded") rather than folding them into the count of work that was already aligned. A task with no dependencies is an ANCHOR and keeps its start (shifted LATER off a weekend or a holiday if it was on one), because that is the one thing in a plan this cannot derive. Duration is preserved in WORKING days, not calendar days, so a five-day task laid across a weekend, or across Labor Day, still takes five working days: a predecessor ending on the Friday hands off on the Friday and the weekend falls inside the successor's own span, never between the two. Milestones take the same rule with no duration: off a weekend or a holiday to the next working day, and on the day their last dependency ends. The graph crosses sections freely and so does the schedule; a section's displayed range just follows its rows. A dependency CYCLE is left exactly as it is, along with anything downstream of it, and the toast names the rows: there is no honest answer for a row that waits on itself. The whole pass is ONE undo entry and ONE changelog event (`action: "auto-schedule"`, with a count and the names of what moved), and a press that moves nothing writes nothing. Deterministic. Disabled, with a tooltip that says why, when the plan has no dependencies@@ -280,7 +281,7 @@ rather than a category. - **Auto-save** to `<datafile>-state.json` sidecar (800ms debounce, flushed immediately if the page is closed or hidden, so the changelog and the sidecar cannot disagree) - **Auto-load** saved state on page refresh - **Dynamic date range** computed from the data every render-- **Project start** in the header, shown as an exact date (`21-Sep-2026`) at every grain since 0.57.0; it used to show only the month at any grain but Days. At the coarser grains its picker still moves the plan by whole months and keeps the day of the month, clamped to the month's length (the 31st moved to February lands on the 28th or 29th, not on 3 March). Changing it moves the WHOLE plan: every task, milestone and phase shifts by the same number of days, so durations and gaps are preserved. Revert puts it back to the data file's own start. This is a real edit, recorded and undoable, not a view setting+- **Project dates** (0.75.0) on Setup, the first card: a start and an end, each the browser's date field. They replace the header's start button, which moved the whole plan and was one misclick from shifting every date for the whole team. Owner only (the same rule as Sharing: the page disables the fields for everyone else and the server keeps the owner's dates whatever another save carries). On the chart each date is a dashed amber line with a `start` / `end` tag, and the time outside them is hatched over the bars, so a bar that runs past the end is hatched where it does. The card lists the rows outside the dates (up to 12, each a link that opens the row on the chart), and says when one is not set. Every change is one Ctrl+Z and one changelog line (`plan.dates`). A year still being typed (0002, 0020) is never committed; an end before the start is refused with a reason - **Cell size** picker: Days, Weeks, Months, Quarters, Years. Each grain has its own minimum cell width sized to the widest label it draws, so a fine grain scrolls rather than squashing while a coarse one stretches to fill the pane. Day and week positions are exact; month, quarter and year approximate within a month - **Bar labels** put a short label on the bar body (`note`, e.g. `6m`), set in the item editor's "label on the bar" field. The double-click that used to open a one-field label editor on the bar is gone (0.65.0): the first click of it already opens the item editor - **Milestones are editable too**: drag one sideways to move its date, or click it for the item editor, which hides the fields a milestone does not have (start, effort, bar label, dependencies)@@ -387,7 +388,7 @@ entered the same way: | An owner | One owner picker: a field with the plan's people listed under it, filtered as you type (matches first, "no owner" last); a new name is just typed. Down and Up walk the list, Enter takes the highlighted name, Tab takes it and moves on, Escape closes the list; with nothing highlighted Enter keeps what was typed. In the editor, the New panel, the Table's Owner cell and the owner pill (which opens the same field over the pill, since 0.66.1) | | Tags | One chip box: Enter or a comma adds, x removes, a click away keeps what was typed | | Effort | Hours, `2d`, `1w`, or empty for not estimated, in the editor, the New panel, the Table and the batch panel |-| A date | The browser's date field, except the header's project start, which has its own picker because changing it moves the whole plan |+| A date | The browser's date field, everywhere (the header's start picker went in 0.75.0; the project dates are on Setup) | | Sections and the tag list | Edited in place on the Setup page: click a name to rename, drag a row to move it | | Many rows at once | The batch panel (Ctrl+click): dates shift on Apply; tags and effort act at once, one undo each | 
client-vs-server/README.md+4−3
@@ -59,6 +59,7 @@ Saved by the adom-gantt server and shared with everyone the plan is shared with. | Added, deleted, pasted and imported rows | A delete can be undone by the tab that made it | | Changelog | Every change, who made it and when, in an append-only file the server checks for tampering | | Backups | Daily, and before every import or compaction; the last 30 are kept |+| Project dates | Owner only; the dashed lines and the header's clock follow them | | Sharing (only me, the org, anyone signed in) | Owner only | | Google Chat digest | Owner only | | Sign-in sessions and the agent key | Never shown in the page |@@ -76,9 +77,9 @@ closes the tab.  ## Worth knowing -- **The "starts" date button moves the whole plan.** It is not a view setting: picking a-  new start date shifts every date in the plan, for everyone. It is undoable from the-  tab that did it.+- **The project dates are the owner's.** Set on Setup, they are part of the plan, so+  everyone sees the same dashed lines and the same Duration and Remaining. (They replace+  the old start button, which moved every date in the plan for everyone.) - **A filter can hide new work.** If you filter to one owner and a teammate adds a row   for someone else, you will not see it until you clear the filter. The legend counts   ("13 of 30" on a section) say how much is hidden.
client-vs-server/client-vs-server-icon.png
⋯ 1 unchanged line ⋯
client-vs-server/where-it-lives.png
⋯ 1 unchanged line ⋯
docs/BUILD-NOTES.md+43
@@ -3,6 +3,49 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-29 (0.75.0, project dates on Setup; the start button is gone)++Drew: "in setup, add a project start/end date field (remove start from the top bar of gantt+page), and set weeks/days for project/remaining time based on that instead of tasks. Show a+clear line (like today but dashed) for start and end dates, and it will be obvious if+someone's tasks fall outside that window".++Data: project.start / project.end (YYYY-MM-DD) in the data file, and the sidecar's `dates`+{start, end} over them, the way its tag registry works ('' is "not set"). mergedData writes+them back as project.start / project.end, so exports, snapshots and agents see them. A+change is one undo entry (type pdates), one changelog line (plan.dates, described by the+server as "set the project end to 18-Dec-2026 (was not set)"), and its own dirty flag, so a+live merge keeps this tab's unsaved dates and takes everyone else's.++Owner only, the Sharing rule: the page disables the fields for the team, and /save keeps the+stored dates when the saver is not the owner (or the agent key, or direct loopback). A save+with no `dates` at all (a tab older than 0.75.0, a script) keeps them too, so an old tab+cannot wipe them.++Readouts: Duration is start to end and Remaining today to end. An end that is not set comes+from the rows, never on the wrong side of the one that is (an end before every row gave+"-2.7 wks" until that guard). Rows outside the dates turn both readouts amber and are+counted in the tooltip; the Setup card lists them (12, then "and N more"), each a #row link.++Chart: a dashed amber line at each date (the end line where a bar ending that day ends),+start / end tags in the today band, and the time outside hatched over the bars (z 4), so+the part of a bar past the end is hatched. The axis widens to include both dates.++Removed: the header's start button, its month/day picker with Revert and Today, and+shiftPlanTo. It moved every date in the plan for everyone, and teammates could press it.++The date field fires change while a year is typed (0002, 0020, 0202), so the card commits+only a blank field or a year from 1970 to 2200. Puppeteer clicks the middle of a date field,+which is its day segment; the suite clicks the left edge to type month, day, year.++Suite: the start picker flow became "project dates" (16 checks: typed entry, save, changelog,+readouts, the card's list, end before start refused, lines and hatching, undo and redo,+reload, the team view, clearing). Server tests: owner sets, member's save keeps them, a+dateless save keeps them, the merged plan carries them, the changelog line.++Noticed, not changed: on this container headless Chrome takes about 17 s to get the live+plan (536 rows) ready, the same on 0.74.2.+ ## Build notes, 2026-10-28 (0.74.2, the top stays the top, a pinned header keeps its chevron)  The full suite's coverage walk failed on 0.74.1 (a section left folded). Tracing it found
gantt.html+200−190
@@ -41,6 +41,7 @@      important in this brand, and the phase ramp already colours the milestone rules,      so today reads as neutral grey and stops competing with both. */   --today: var(--text2);+  --pdate: #e8b64a;   /* the project's dates (0.75.0): dashed lines, hatched outside */   --ok: #3fb950; --warn: #d29922;   /* ALARM, and it means one thing only: this tab is not saving. It is deliberately not      --warn, which is a status a row can hold, and not the delete red, which is about a@@ -127,7 +128,7 @@   --bg: #ffffff; --surface: #f6f8fa; --elevated: #ffffff; --overlay: #ffffff;   --border: #d0d7de; --border-muted: #d8dee4;   --text: #1f2328; --text-body: #24292f; --text2: #59636e; --text3: #8c959f;-  --accent: #00857f; --accent-hover: #00a49d; --accent-bright: #00a49d; --today: var(--text2);+  --accent: #00857f; --accent-hover: #00a49d; --accent-bright: #00a49d; --today: var(--text2); --pdate: #b7791f;   --ok: #1a7f37; --warn: #9a6700;   /* The alarm on paper. #f85149 is a screen red and washes out on white, so the banner      takes the deeper one the analytics lens already uses for a late row, which clears@@ -162,7 +163,7 @@   :root { --bg:#ffffff; --surface:#ffffff; --elevated:#ffffff; --overlay:#ffffff;     --border:#d0d7de; --border-muted:#d8dee4;     --text:#1f2328; --text-body:#24292f; --text2:#59636e; --text3:#8c959f;-    --accent:#00857f; --today:var(--text2); --grid-line:rgba(31,35,40,0.10); --weekend:rgba(31,35,40,0.04); --holiday:rgba(199,105,10,0.105);+    --accent:#00857f; --today:var(--text2); --pdate:#b7791f; --grid-line:rgba(31,35,40,0.10); --weekend:rgba(31,35,40,0.04); --holiday:rgba(199,105,10,0.105);     --alarm:#b91c1c; --alarm-edge:rgba(185,28,28,0.40); --alarm-halo:rgba(185,28,28,0.12);     --ph1:#00a49d; --ph2:#8c6bf7; --ph3:#3b95ff;     --sig:#b45309; --sig-edge:#ffffff;@@ -216,6 +217,8 @@ header .span-stat .span-v { font-family: 'JetBrains Mono', monospace; font-size: header .span-stat[data-state="ended"] .span-v, header .span-stat[data-state="empty"] .span-v { color: var(--text3); } header .span-stat .span-n { font-family: 'Satoshi', sans-serif; font-size: 10px; color: var(--text3); }+/* Work outside the project's dates (0.75.0): the readouts take the dashed lines' amber. */+header .span-stat[data-outside="1"] .span-v { color: var(--pdate); } header .span-stat .span-n:empty { display: none; } /* The version sits UNDER the app name (0.64.2), small and dim, on every lens at every    width. It used to ride beside the readouts, where it was the first thing the measured@@ -224,10 +227,6 @@ header .span-stat .span-n:empty { display: none; } header .brand .brand-txt { display: flex; flex-direction: column; line-height: 1.1; min-width: 0; } header .brand .app-ver { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; font-weight: 400; color: var(--text3); white-space: nowrap; letter-spacing: 0.02em; } header .start-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; font-family: 'Satoshi', sans-serif; }-header .start-ctl label { color: var(--text2); font-size: 11px; }-header .date-btn { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'JetBrains Mono', monospace; font-size: 11px; padding: 4px 10px; cursor: pointer; transition: border-color 0.15s, color 0.15s; }-header .date-btn:hover { border-color: var(--accent); color: var(--text); }-header .date-btn.shifted { color: var(--accent); border-color: var(--accent); } header .grain-sel { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 6px; cursor: pointer; outline: none; } header .grain-sel:hover, header .grain-sel:focus { border-color: var(--accent); color: var(--text); } header .days-toggle { display: flex; align-items: center; gap: 4px; cursor: pointer; user-select: none; }@@ -239,24 +238,6 @@ header .days-toggle input:checked ~ span { color: var(--accent); }  /* Custom date picker: the native <input type=month> popup is browser chrome, so it    cannot be themed or relabelled. This replaces it. */-.datepop { position: fixed; z-index: 99999; background: var(--overlay); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 12px 40px rgba(0,0,0,0.45); padding: 10px; display: none; font-family: 'Satoshi', sans-serif; min-width: 244px; }-.datepop.show { display: block; }-.datepop .dp-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }-.datepop .dp-title { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 13px; color: var(--text); }-.datepop .dp-nav { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text2); cursor: pointer; width: 24px; height: 24px; line-height: 1; font-size: 13px; }-.datepop .dp-nav:hover { color: var(--accent); border-color: var(--accent); }-.datepop .dp-grid { display: grid; gap: 4px; }-.datepop .dp-grid.months { grid-template-columns: repeat(4, 1fr); }-.datepop .dp-grid.days { grid-template-columns: repeat(7, 1fr); }-.datepop .dp-cell { background: none; border: 1px solid transparent; border-radius: 6px; color: var(--text-body); font-family: 'JetBrains Mono', monospace; font-size: 11px; padding: 6px 0; cursor: pointer; text-align: center; }-.datepop .dp-cell:hover { border-color: var(--accent); color: var(--text); }-.datepop .dp-cell.sel { background: var(--accent); color: var(--on-accent); border-color: var(--accent); font-weight: 600; }-.datepop .dp-cell.muted { color: var(--text3); }-.datepop .dp-dow { text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text2); padding-bottom: 2px; }-.datepop .dp-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border); }-.datepop .dp-link { background: none; border: none; color: var(--accent); font-family: 'Satoshi', sans-serif; font-size: 11px; cursor: pointer; padding: 2px 4px; }-.datepop .dp-link:hover { text-decoration: underline; }-.datepop .dp-link.muted { color: var(--text2); } header .actions { display: flex; align-items: center; gap: 4px; margin-left: auto; flex-shrink: 0; } header .act-btn { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 10px; cursor: pointer; transition: background 0.15s, color 0.15s; display: flex; align-items: center; gap: 4px; } header .act-btn:hover { background: rgba(255,255,255,0.04); color: var(--text); }@@ -398,7 +379,6 @@ header #btnTheme { min-width: 56px; justify-content: center; } /* shed-ver no longer hides anything: the version moved under the app name (0.64.2). The    step is kept so the fit's state names do not change under anything that reads them. */ header.shed-labels .who-name,-header.shed-labels .start-ctl label, header.shed-labels .act-btn .btn-label, header.shed-labels #msStyleLabel { display: none; } header.shed-labels .who { max-width: none; }@@ -625,6 +605,11 @@ body.col-resizing { cursor: col-resize; user-select: none; }    little more padding to make it a comfortable target. */ .today-tag { position: absolute; top: calc(var(--group-h) + 4px); transform: translateX(-50%); font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--today); background: var(--bg); padding: 2px 6px; border-radius: 3px; border: 1px solid var(--border); pointer-events: auto; cursor: default; z-index: 9; white-space: nowrap; } .today-tag:hover { border-color: var(--today); }+.pdate-line { position: absolute; top: 0; bottom: 0; width: 0; border-left: 2px dashed var(--pdate); transform: translateX(-1px); z-index: 5; pointer-events: none; }+.pdate-tag { position: absolute; top: calc(var(--group-h) + 4px); transform: translateX(-50%); font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--pdate); background: var(--bg); padding: 2px 6px; border-radius: 3px; border: 1px dashed var(--pdate); pointer-events: auto; cursor: default; z-index: 9; white-space: nowrap; }+/* The time outside the project's dates, hatched over the bars, so a bar that runs past the+   end is hatched where it does. Under the arrows and the today line, never in the way. */+.pdate-out { position: absolute; top: 0; bottom: 0; z-index: 4; pointer-events: none; background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--pdate) 10%, transparent) 0 5px, transparent 5px 13px); } .dep-svg { position: absolute; top: 0; left: 0; pointer-events: none; z-index: 4; overflow: visible; } /* --dep-stroke is set per edge from the SOURCE row's phase, contrast-corrected against    the page (see depStroke). The fallback is the accent, which is what every arrow used@@ -1283,6 +1268,18 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 /* The Sharing card and the owner's webhook field (0.68.0). */ #shareState.on { color: var(--accent); } .chat-row input[type="text"] { flex: 1 1 260px; min-width: 0; max-width: 520px; }+/* Project dates (0.75.0) */+.pd-row input[type="date"] { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'JetBrains Mono', monospace; font-size: 12px; padding: 4px 8px; color-scheme: dark; }+:root[data-theme="light"] .pd-row input[type="date"] { color-scheme: light; }+.pd-row input[type="date"]:focus { outline: none; border-color: var(--accent); }+.pd-row input[type="date"]:disabled { opacity: 0.6; cursor: default; }+.pd-row label + input { margin-right: 10px; }+.pd-out { list-style: none; margin: 6px 0 0; padding: 0; font-size: 12px; }+.pd-out li { padding: 3px 0; border-top: 1px solid var(--border); display: flex; gap: 8px; align-items: baseline; }+.pd-out a { color: var(--text); text-decoration: none; }+.pd-out a:hover { color: var(--accent); text-decoration: underline; }+.pd-out .pd-why { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--pdate); margin-left: auto; white-space: nowrap; }+.pd-out .pd-more { color: var(--text2); } .sec-add { display: flex; gap: 8px; align-items: center; margin: 0 0 10px; } .sec-add input[type="text"] { flex: 1; min-width: 0; max-width: 360px; } .setup-view input[type="text"], .setup-view select { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 5px 8px; font-family: inherit; font-size: 12px; }@@ -1626,6 +1623,7 @@ input[type="file"] { display: none; } .ms-diamond { width: var(--ms-d); height: var(--ms-d); top: calc((var(--row-h) - var(--ms-d)) / 2); } .ms-line .ms-cap { font-size: calc(9px * var(--fs)); max-width: calc(132px * var(--fs)); } .today-tag { font-size: calc(9px * var(--fs)); }+.pdate-tag { font-size: calc(9px * var(--fs)); } .legend-item { font-size: calc(11px * var(--fs)); } .tip { font-size: calc(12px * var(--fs)); } .tip .tip-title { font-size: calc(13px * var(--fs)); }@@ -1751,6 +1749,16 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va        it too; the section list inside it is rebuilt from phases[] by renderSections. -->   <div class="setup-view" id="setupView">     <div class="su-scroll" id="suScroll"><div class="su-stack">+      <div class="an-card su-card" id="suDates">+        <div class="su-head"><h3>Project dates</h3><span class="su-n" id="pdSpan"></span></div>+        <p class="an-sub">When the project starts and when it has to be done. The header's Duration and Time Remaining count from these, and the chart marks both with a dashed line and hatches the time outside them, so work that runs past the end is plain to see. Only the plan's owner can change them.</p>+        <div class="chat-row pd-row">+          <label for="pdStart">Start</label><input type="date" id="pdStart">+          <label for="pdEnd">End</label><input type="date" id="pdEnd">+        </div>+        <div class="chat-status" id="pdStatus"></div>+        <ul class="pd-out" id="pdOut"></ul>+      </div>       <div class="an-card su-card" id="suTags">         <div class="su-head"><h3>Tags</h3><span class="su-n" id="tagTotal"></span></div>         <p class="an-sub">Short labels on sections and rows, for finding and filtering them: choose one in the legend to see only the rows that carry it, on themselves or through their section. Make one here, or type it on a section below, on a row in its editor, or in the table's Tags column. Click a swatch to give a tag a colour, click a name to rename it everywhere.</p>@@ -1821,7 +1829,6 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <div class="conflict-bar" id="conflictBar" role="alert" aria-live="assertive"></div> <div class="data-warn" id="dataWarn"></div> <div class="save-indicator" id="saveInd">saved</div>-<div class="datepop" id="datePop"></div> <div class="sel-count" id="selCount"></div> <input type="file" id="importFile" accept=".json,.csv,.xml"> @@ -2466,9 +2473,8 @@ labelsEl.addEventListener('click',e=>{ }  // ── Date range ──-// The timeline is derived from the data every time, so shifting the plan reshapes-// the axis for free. ORIGINAL_START is the untouched data-file start, which is what-// Revert goes back to.+// The timeline is derived from the data every time, so an edit that moves the first or+// last date reshapes the axis for free. function isoDay(d){ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } function isoMonth(d){ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; } // Undateable rows are dropped at load, so this should never see junk. It is@@ -2491,6 +2497,9 @@ function scanRange(){                       const ps=p.start+'-01'; const[ey,em]=p.end.split('-').map(Number);                       const pe=`${ey}-${String(em).padStart(2,'0')}-28`;                       if(ps<minD)minD=ps; if(pe>maxD)maxD=pe; });+  // The project's dates are on the axis too, so both dashed lines are always on it.+  if(planDates.start&&planDates.start<minD)minD=planDates.start;+  if(planDates.end&&planDates.end>maxD)maxD=planDates.end;   return {minD,maxD}; } // Earliest real task/milestone date, which is what the start control edits.@@ -2499,9 +2508,87 @@ function planStart(){   items.forEach(r=>{ const d=r.start||r.date; if(d&&(!m||d<m))m=d; });   return m; }-const ORIGINAL_START = (()=>{ let m=null;-  (projData.items||[]).forEach(r=>{ const d=r.start||r.date; if(d&&(!m||d<m))m=d; });-  return m; })();+// ── Project dates (0.75.0) ──+// The project's own start and end, set by the owner on Setup: the window the work has+// to fit in. The header's Duration and Remaining count from them, and the chart draws a+// dashed line at each with the time outside them hatched, so a row that runs past the+// end is plain to see. Blank is "not set", and that end of the readouts falls back to+// the rows. The data file may carry them as project.start / project.end; the sidecar's+// `dates` wins, the way its tag registry does.+const cleanDay=v=>typeof v==='string'&&/^\d{4}-\d{2}-\d{2}$/.test(v)&&!isNaN(parseISO(v).getTime())?v:'';+let planDates={start:cleanDay(project&&project.start),end:cleanDay(project&&project.end)};+let datesInSidecar=false;+// Every change to the dates comes through here: the Setup card, undo and redo. The axis+// is rebuilt because the dates are on it, which also moves the lines and the readouts.+function setPlanDates(start,end){+  planDates={start:cleanDay(start),end:cleanDay(end)}; datesInSidecar=true;+  rebuildTimeline();+  renderDatesCard();+}+function commitPlanDates(start,end){+  const st=$('pdStatus');+  if(start&&end&&start>end){+    if(st)st.innerHTML='<span class="bad">The end has to be on or after the start.</span>';+    renderDatesCard(true);+    return;+  }+  if(start===planDates.start&&end===planDates.end)return;+  pushUndo({type:'pdates',start:planDates.start,end:planDates.end});+  setPlanDates(start,end);+  scheduleSave('setup');+}+// The Setup card: the two dates, the span they make, and the rows that fall outside them,+// each a link that opens the row on the chart. Read-only for the team.+function renderDatesCard(keepStatus){+  const s=$('pdStart'), e=$('pdEnd');+  if(!s||!e)return;+  const owner=document.documentElement.dataset.role!=='member';+  if(document.activeElement!==s)s.value=planDates.start;+  if(document.activeElement!==e)e.value=planDates.end;+  s.disabled=e.disabled=!owner;+  s.title=e.title=owner?'':"Only the plan's owner can change the project dates.";+  const {start,end}=planDates, sp=$('pdSpan');+  if(sp)sp.textContent=start&&end?fmtSpanWd(dayNum(end)-dayNum(start)+1,bizBetween(start,end)):'';+  const out=rowsOutsideDates(), st=$('pdStatus'), list=$('pdOut');+  if(st&&!keepStatus){+    st.textContent=!start&&!end?"Not set: the header counts from the plan's first and last rows."+      :!start?"No start set: Duration counts from the plan's first row."+      :!end?"No end set: the header counts to the plan's last row."+      :out.length?`${plural(out.length,'row')} ${out.length===1?'falls':'fall'} outside these dates:`+      :'Every row falls inside these dates.';+  }+  if(!list)return;+  const shown=out.slice(0,12);+  list.innerHTML=shown.map(r=>{+    const a=r.type==='milestone'?r.date:r.start, b=r.type==='milestone'?r.date:r.end;+    const early=start&&dayNum(spanDay(a,false))<dayNum(start);+    const why=early?(r.type==='milestone'?'on ':'starts ')+fmt(spanDay(a,false)):(r.type==='milestone'?'on ':'ends ')+fmt(spanDay(b,true));+    return `<li><a href="#row=${encodeURIComponent(r.uid||'')}">${esc(r.name||'')}</a><span class="pd-why">${why}</span></li>`;+  }).join('')+(out.length>shown.length?`<li class="pd-more">and ${out.length-shown.length} more</li>`:'');+}+function wireDatesCard(){+  const s=$('pdStart'), e=$('pdEnd');+  if(!s||!e)return;+  // A date field fires change while the year is still being typed (0002, 0020, 0202,+  // 2026), so a field that is not blank and not a plausible year yet commits nothing.+  const sane=v=>v===''||(cleanDay(v)&&+v.slice(0,4)>=1970&&+v.slice(0,4)<=2200);+  const go=()=>{ if(document.documentElement.dataset.role==='member')return;+    if(!sane(s.value)||!sane(e.value))return;+    commitPlanDates(cleanDay(s.value),cleanDay(e.value)); };+  s.addEventListener('change',go); e.addEventListener('change',go);+  renderDatesCard();+}+// Live rows that start before the project start or end after its end.+function rowsOutsideDates(){+  const {start,end}=planDates;+  if(!start&&!end)return [];+  return items.filter(r=>{+    if(r.type==='group'||isCancelled(r))return false;+    const a=r.type==='milestone'?r.date:r.start, b=r.type==='milestone'?r.date:r.end;+    if(!isDateStr(a)||!isDateStr(b))return false;+    return (start&&dayNum(spanDay(a,false))<dayNum(start))||(end&&dayNum(spanDay(b,true))>dayNum(end));+  });+}  let START=new Date(), UNITS=0;   // UNITS = months, or days when granularity is 'day' function addD(d,n){const x=new Date(d.getFullYear(),d.getMonth(),d.getDate());x.setDate(x.getDate()+n);return x;}@@ -2790,9 +2877,7 @@ $('hdr').innerHTML = `     <span class="span-stat" id="spanDur"><span class="span-k"><span class="k-long">Project </span>Duration:</span><span class="span-v" id="spanDurV"></span></span>     <span class="span-stat" id="spanLeft"><span class="span-k"><span class="k-long">Time </span>Remaining:</span><span class="span-v" id="spanLeftV"></span><span class="span-n" id="spanLeftN"></span></span>   </div></div>-  <div class="start-ctl" title="Project start. Changing it moves the whole plan.">-    <label>starts</label>-    <button class="date-btn" id="startBtn"></button>+  <div class="start-ctl">     <select id="grainSel" class="grain-sel" title="Timeline cell size">       <option value="day">Days</option><option value="week">Weeks</option>       <option value="month">Months</option><option value="quarter">Quarters</option>@@ -3565,6 +3650,12 @@ function moveFocusedRow(dir){ // Returns null when the snapshot no longer resolves, which is the old early // return: a stale entry does nothing rather than half-applying. function applySnapshot(sn){+  // Project dates (0.75.0): the inverse is the dates as they stand.+  if(sn.type==='pdates'){+    const inv={type:'pdates',start:planDates.start,end:planDates.end};+    setPlanDates(sn.start,sn.end);+    return inv;+  }   if(sn.type==='order'){     const inv={type:'order',order:items.map(rowOrderKey),       phases:(sn.phases||[]).map(pc=>({r:pc.r,from:pc.r.phase}))};@@ -3760,7 +3851,6 @@ function isEscBlurTarget(el){ function appIsIdle(){   if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false;   if(editorEl.classList.contains('show'))return false;-  if(datePop&&datePop.classList.contains('show'))return false;   if(isTypingTarget(document.activeElement))return false;   return true; }@@ -3781,7 +3871,6 @@ function appIsIdle(){ function rowsLens(){ return prefs.view==='gantt'||prefs.view==='table'; } function clipboardReady(){   if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false;-  if(datePop&&datePop.classList.contains('show'))return false;   if(isTypingTarget(document.activeElement))return false;   return true; }@@ -4056,7 +4145,7 @@ const dirtyUids=new Set(); // another tab can put them back here without a reload. const dirtyDel=new Set(); const remoteRemoved=new Map();-const dirtyStruct={phases:false,order:false,reg:false};+const dirtyStruct={phases:false,order:false,reg:false,dates:false}; let dirtyAll=false, dirtyGen=0; function markDirty(evs){   (evs||[]).forEach(e=>{@@ -4066,6 +4155,7 @@ function markDirty(evs){     if(e.action==='reorder'){ dirtyStruct.order=true; return; }     if(/^phase\./.test(e.action)){ dirtyStruct.phases=true; return; }     if(/^tag\./.test(e.action)){ dirtyStruct.reg=true; return; }+    if(e.action==='plan.dates'){ dirtyStruct.dates=true; return; }     if(e.action==='phase'||e.action==='add'||e.action==='delete'||e.action==='restore'||e.action==='paste')dirtyStruct.order=true;     if(e.uid){ dirtyUids.add(e.uid); return; }     if(e.id){ const r=items.find(x=>x.id===e.id); if(r&&r.uid)dirtyUids.add(r.uid); return; }@@ -4077,7 +4167,7 @@ function clearDirty(snap){   snap.uids.forEach(u=>dirtyUids.delete(u));   snap.dels.forEach(k=>dirtyDel.delete(k));   // The structural flags and dirtyAll only clear when nothing was marked since the send.-  if(snap.gen===dirtyGen){ dirtyStruct.phases=dirtyStruct.order=dirtyStruct.reg=false; dirtyAll=false; }+  if(snap.gen===dirtyGen){ dirtyStruct.phases=dirtyStruct.order=dirtyStruct.reg=dirtyStruct.dates=false; dirtyAll=false; } } // How many changelog-worthy edits have been made since the last save that LANDED. It is // the number the banner quotes back before a reload throws them away, so a user is never@@ -4151,6 +4241,8 @@ function getState(){     // The tag registry (0.61.0), only once something has touched it: absent tells the     // server the data file's registry stands, so an untouched plan's sidecar says nothing.     ...(regInSidecar?{tags:regOut()}:{}),+    // Project dates (0.75.0), once they are anyone's but the data file's.+    ...(datesInSidecar?{dates:{start:planDates.start,end:planDates.end}}:{}),     order: items.filter(r=>!isDerivedGroup(r)).map(rowOrderKey),     deleted: [...deletedKeys],     added: addedItems.map(r=>{const o={};@@ -4184,6 +4276,10 @@ function applyState(state){   items.forEach(r=>{ if(r.uid)byUid.set(r.uid,r); });   // The tag registry (0.61.0): absent, the data file's stands; a list, even [], replaces it.   if(Array.isArray(state.tags)){ tagReg=(normalizeTagRegistry(state.tags)||[]).map(regEntry); regInSidecar=true; }+  // Project dates (0.75.0): absent, the data file's stand; present, they replace them.+  if(state.dates&&typeof state.dates==='object'&&!Array.isArray(state.dates)){+    planDates={start:cleanDay(state.dates.start),end:cleanDay(state.dates.end)}; datesInSidecar=true;+  }   // How many sections the plan will have once this sidecar is applied. The row overrides   // are applied BEFORE the section list is rebuilt below, so checking a row's saved phase   // against phases[] here checked it against the DATA FILE's list. A row whose section@@ -4308,7 +4404,7 @@ function snapSemantic(){   const ph={};phases.forEach((p,i)=>{ph[phaseKey(p)]={i,name:p.name,tags:(p.tags||[]).slice()};});   // The tag registry (0.61.0), by entry identity, so a rename reads as one.   const reg={};tagReg.forEach(e=>{reg[e._k]={name:e.name,color:e.color||''};});-  return {byId,ms,ph,reg,order:items.map(rowOrderKey)};+  return {byId,ms,ph,reg,dates:{start:planDates.start,end:planDates.end},order:items.map(rowOrderKey)}; } // The baseline the changelog diffs against. It has to be taken after the plan is // loaded and normalised, which is an ordering fact, so it is an init step rather@@ -4319,6 +4415,9 @@ lastSnap=snapSemantic(); } function diffAndLog(via){   const cur=snapSemantic(),evs=[];+  // Project dates (0.75.0): one line, both ends, the way the server describes it.+  if(lastSnap.dates&&(lastSnap.dates.start!==cur.dates.start||lastSnap.dates.end!==cur.dates.end))+    evs.push({action:'plan.dates',from:Object.assign({},lastSnap.dates),to:Object.assign({},cur.dates)});   Object.keys(cur.byId).forEach(id=>{     const a=lastSnap.byId[id],b=cur.byId[id];if(!a)return;     if(a.start!==b.start||a.end!==b.end){@@ -4526,7 +4625,7 @@ function refreshViewChips(){   items.forEach(r=>{ if(isGroup(r))return; const od=isOverdue(r);     if(r._lDiv)r._lDiv.classList.toggle('is-overdue',od); if(r._bDiv)r._bDiv.classList.toggle('is-overdue',od); }); }-function scheduleSave(via){refreshViewChips();if(tagsReady&&tagSigNow()!==tagSig)afterTagsChange();diffAndLog(via||'edit');syncStartBtn();syncCollapseAllBtn();if(critOn())refreshCritical();syncPlanCtl();if(prefs.view==='analytics')renderAnalytics();else if(prefs.view==='setup')renderSections(true);clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},800);}+function scheduleSave(via){refreshViewChips();if(tagsReady&&tagSigNow()!==tagSig)afterTagsChange();diffAndLog(via||'edit');syncPlanSpan();syncCollapseAllBtn();if(critOn())refreshCritical();syncPlanCtl();if(prefs.view==='analytics')renderAnalytics();else if(prefs.view==='setup')renderSections(true);clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},800);} // Both writes wait: the sidecar for its 800ms debounce and, since 0.54.0, the changelog // line for the save it belongs to. A tab closed inside that window would leave the edit in // neither, so anything still pending goes out synchronously when the page is being put@@ -4645,6 +4744,7 @@ async function pullAndMerge(){     if(dirtyStruct.order)st.order=mine.order;   }   if(dirtyStruct.reg&&mine.tags)st.tags=mine.tags;+  if(dirtyStruct.dates&&mine.dates)st.dates=mine.dates;   const srvAdded=new Set((st.added||[]).map(a=>a.uid)), srvItems=new Set((st.items||[]).map(o=>o.uid));   st.added=(st.added||[]).concat((mine.added||[]).filter(a=>a.uid&&!srvAdded.has(a.uid)&&!srvItems.has(a.uid)));   // Deletions: the server's list, plus this tab's own unsaved ones, minus any row this@@ -4677,7 +4777,7 @@ async function pullAndMerge(){   if(typeof st.stateRev==='number')baseRev=st.stateRev;   liveMerges++;   // Unsaved work rides on the new rev; a tab that was blocked is unblocked by it.-  if(dirtyUids.size||dirtyStruct.phases||dirtyStruct.order||dirtyStruct.reg||dirtyAll){+  if(dirtyUids.size||dirtyStruct.phases||dirtyStruct.order||dirtyStruct.reg||dirtyStruct.dates||dirtyAll){     if(saveBlocked){ saveBlocked=false; blockedWhy='conflict'; conflictBar.classList.remove('show'); conflictBar.innerHTML=''; document.documentElement.removeAttribute('data-save'); unpinSaveErr(); }     clearTimeout(saveTimer); saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},200);   }@@ -5367,7 +5467,7 @@ todayL.addEventListener('mouseover',()=>refreshTodayTip(todayL)); todayL.addEventListener('click',()=>{   prefs.showToday=!prefs.showToday;savePrefs();   todayL.classList.toggle('off',!prefs.showToday);-  positionToday();+  positionToday();positionDates(); }); legendEl.appendChild(todayL); buildLegendHoliday();@@ -5682,8 +5782,40 @@ function mountTodayLine(){  refreshTodayTip(tt);  tt.addEventListener('mouseover',()=>refreshTodayTip(tt));  positionToday();}+mountDateLines(); timelineEl.appendChild(qRow);timelineEl.appendChild(mRow);timelineEl.appendChild(bars); }+// The project's dates on the chart (0.75.0): a dashed line at each, a tag on it in the+// band "today" uses, and the time outside them hatched. The end line sits where a bar+// that ends on that day ends, so a bar that touches it is on time and one past it is not.+let dateEls=null;+function mountDateLines(){+  const el=(cls,txt)=>{ const d=document.createElement('div'); d.className=cls; if(txt)d.textContent=txt; bars.appendChild(d); return d; };+  dateEls={before:el('pdate-out pdate-before'),after:el('pdate-out pdate-after'),+    sLine:el('pdate-line pdate-start'),eLine:el('pdate-line pdate-end'),+    sTag:el('pdate-tag pdate-start-tag','start'),eTag:el('pdate-tag pdate-end-tag','end')};+  positionDates();+}+function positionDates(){+  if(!dateEls)return;+  const {start,end}=planDates;+  const xs=start?toX(start):null, xe=end?toX(end):null;+  const place=(line,tag,x,ds,what)=>{+    const on=x!==null&&x>=0&&x<=W;+    line.style.display=tag.style.display=on?'':'none';+    if(!on)return;+    line.style.left=tag.style.left=x+'px';+    tag.dataset.title='Project '+what; tag.dataset.dates=fmt(ds);+    tag.dataset.body='Set on Setup, under Project dates.';+  };+  place(dateEls.sLine,dateEls.sTag,xs,start,'start');+  place(dateEls.eLine,dateEls.eTag,xe,end,'end');+  const b=dateEls.before, a=dateEls.after;+  b.style.display=xs!==null&&xs>0?'':'none';+  if(xs!==null){ b.style.left='0px'; b.style.width=Math.min(xs,W)+'px'; }+  a.style.display=xe!==null&&xe<W?'':'none';+  if(xe!==null){ const l=Math.max(0,xe); a.style.left=l+'px'; a.style.width=Math.max(0,W-l)+'px'; }+} const barTail=document.createElement('div'); function mountBarTailroom(){ barTail.className='tailroom';timelineEl.appendChild(barTail);@@ -5796,6 +5928,7 @@ function maskTopBand(active){   if(todayTagEl&&todayTagEl.style.display!=='none'){     todayTagEl.style.visibility=(todayTagEl.getBoundingClientRect().top>=boundary-1)?'':'hidden';   }+  if(dateEls)[dateEls.sTag,dateEls.eTag].forEach(t=>{ if(t.style.display!=='none')t.style.visibility=(t.getBoundingClientRect().top>=boundary-1)?'':'hidden'; }); } function wireChartWheel(){   const wrap=timelineEl.closest('.chart-wrap')||timelineEl.parentElement;@@ -5848,7 +5981,7 @@ function relayout(){     }     else if(r.id&&barEls[r.id]){const b=barEls[r.id].bar,x1=toX(r.start),x2=toX(r.end);b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';}   });-  positionToday();+  positionToday();positionDates();   layoutMsCaps();   sizeTailroom();   drawDeps();@@ -7825,6 +7958,7 @@ function openInline(row,i,f,held){ // carried the index they were opened with, so Ctrl+Z with a Remove open, followed by the // Remove, deleted whichever section had moved into that slot and merged its rows. function renderSections(keep){+  renderDatesCard();   const v=$('setupView'); if(!v)return;   const list=v.querySelector('.sec-list');   // Mid-drag the list is not repainted under the pointer; the drop repaints it.@@ -9154,7 +9288,6 @@ function depDeleteReady(){   if(!depMode||!depSel)return false;   if(prefs.view!=='gantt')return false;   if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false;-  if(datePop&&datePop.classList.contains('show'))return false;   if(isTypingTarget(document.activeElement))return false;   if(editorEl.classList.contains('show'))return false;   return true;@@ -9442,26 +9575,8 @@ function rebuildTimeline(){   computeRange();   fitCOL();   buildTimeHeaders();buildGridLines();-  syncStartBtn();-  relayout();-}--// The start button names the exact first date at EVERY grain. Until 0.56.0 it said-// "Sep 2026" unless the grain was days, which read as though the plan started on the-// first of the month when it started on the 21st: the grain is how the axis is cut,-// not how precisely the plan is known. The month picker behind it at the coarser-// grains still moves the plan by whole months and keeps the day of the month.-function syncStartBtn(){-  const ps=planStart();-  const b=$('startBtn');-  if(b){-    // spanDay pads a bare year-month to its first day, so a plan written in months-    // still shows a day here, the same day the Duration tooltip counts from.-    b.textContent = ps ? fmt(spanDay(ps,false)) : 'no dates';-    b.classList.toggle('shifted', !!ps && ps!==ORIGINAL_START);-    b.title = ps ? `The plan's first date, ${fmt(spanDay(ps,false))}, cancelled rows included. Click to move the whole plan.` : 'The plan has no dated tasks or milestones yet.';-  }   syncPlanSpan();+  relayout(); }  // ── Project Duration and Time Remaining ──@@ -9477,8 +9592,10 @@ function syncStartBtn(){ // Auto-schedule, the critical path and every hours total already leave it out, so a // cancelled row running to June must not tell the header the plan runs to June. The // tooltip says how many were left out. If every dated row is cancelled there is no-// live work to measure and both read "none", with the tooltip saying why. The start-// button is the one place cancelled rows still count, because it moves the WHOLE plan.+// live work to measure and both read "none", with the tooltip saying why.+// Since 0.75.0 the project's own dates win where they are set (Setup, Project dates):+// Duration is start to end and Remaining is today to the end, and the rows only stand in+// for an end that is not set. Work outside the dates turns both readouts amber. // Both ends are INCLUSIVE, the way a bar is: a task from Monday to Friday is five // days, so a plan whose first and last date are the same day runs one day, not zero. // A date the loader accepts may be a bare year-month ("2026-10"); it counts as the@@ -9526,10 +9643,19 @@ let spanPaintedDay=''; function syncPlanSpan(){   const dv=$('spanDurV'), lv=$('spanLeftV');   if(!dv||!lv)return;-  const {first,last,cancelled}=planSpan(), today=spanToday();+  const span=planSpan(), {cancelled}=span, today=spanToday();+  // An end that is not set stands in from the rows, but never on the wrong side of the one+  // that is: an end set before every row (or a start after every row) is a zero-length+  // window, not a negative one.+  let first=planDates.start||span.first, last=planDates.end||span.last;+  if(first&&last&&dayNum(first)>dayNum(last)){ if(!planDates.start)first=last; else if(!planDates.end)last=first; }+  const fromWhat=planDates.start?'the project start':"the plan's first date", toWhat=planDates.end?'the project end':"the plan's last date";   spanPaintedDay=today;   const dur=$('spanDur'), left=$('spanLeft'), note=$('spanLeftN');-  const out=cancelled?` ${plural(cancelled,'cancelled row')} left out.`:'';+  const nOut=rowsOutsideDates().length;+  dur.dataset.outside=left.dataset.outside=nOut?'1':'';+  const out=(cancelled?` ${plural(cancelled,'cancelled row')} left out.`:'')+    +(nOut?` ${plural(nOut,'row')} ${nOut===1?'falls':'fall'} outside the project dates (hatched on the chart).`:'');   if(note)note.textContent='';   if(!first||!last){     dv.textContent=lv.textContent='none';@@ -9542,12 +9668,12 @@ function syncPlanSpan(){   const days=dayNum(last)-dayNum(first)+1;   dv.textContent=fmtSpanWd(days,bizBetween(first,last));   dur.dataset.state='ok';-  dur.title=`${plural(days,'calendar day')} (${plural(bizBetween(first,last),'working day')}), from the plan's first date ${fmt(first)} to its last date ${fmt(last)}, both counted.${out}`;+  dur.title=`${plural(days,'calendar day')} (${plural(bizBetween(first,last),'working day')}), from ${fromWhat} ${fmt(first)} to ${toWhat} ${fmt(last)}, both counted.${out}`;   const T=dayNum(today), F=dayNum(first), L=dayNum(last);   if(T>L){     lv.textContent='ended';     left.dataset.state='ended';-    left.title=`The plan's last date, ${fmt(last)}, was ${plural(T-L,'day')} ago (today is ${fmt(today)}).${out}`;+    left.title=`${toWhat.charAt(0).toUpperCase()+toWhat.slice(1)}, ${fmt(last)}, was ${plural(T-L,'day')} ago (today is ${fmt(today)}).${out}`;     return;   }   const rem=L-T+1;@@ -9557,8 +9683,8 @@ function syncPlanSpan(){   // from today, as Drew asked) and would read as a mistake if nothing said why, so the   // state is on the readout itself and not only in the tooltip.   if(note&&T<F)note.textContent='not started';-  left.title=`${plural(rem,'calendar day')} (${plural(bizBetween(today,last),'working day')}) from today, ${fmt(today)}, to the plan's last date ${fmt(last)}, both counted.`-    +(T<F?` The plan has not started yet: its first date, ${fmt(first)}, is ${plural(F-T,'day')} away.`:'')+out;+  left.title=`${plural(rem,'calendar day')} (${plural(bizBetween(today,last),'working day')}) from today, ${fmt(today)}, to ${toWhat} ${fmt(last)}, both counted.`+    +(T<F?` The plan has not started yet: ${fromWhat}, ${fmt(first)}, is ${plural(F-T,'day')} away.`:'')+out; } // The one thing here that changes with no edit at all is today. A minute tick that // repaints only when the date has rolled over costs nothing and keeps a tab left open@@ -9568,35 +9694,6 @@ function wirePlanSpanClock(){   document.addEventListener('visibilitychange',()=>{ if(document.visibilityState==='visible'&&spanToday()!==spanPaintedDay)syncPlanSpan(); }); } -// Moving the project start moves the whole plan: every task and milestone shifts by-// the same number of days, so durations and the gaps between them are preserved.-function shiftPlanTo(newStartISO, via){-  const cur=planStart();-  if(!cur||!newStartISO||newStartISO===cur) return false;-  const delta=daysBetween(cur,newStartISO);-  if(!delta) return false;-  snapshotGroup(items.filter(r=>r.id).map(r=>r.id));-  items.forEach(r=>{-    if(r.start)r.start=addDays(r.start,delta);-    if(r.end)r.end=addDays(r.end,delta);-    if(r.date)r.date=addDays(r.date,delta);-  });-  // Phase bands are month strings, and they define part of the axis range, so they-  // have to travel with the tasks or the timeline stretches to cover both.-  // Anchor on the 15th, not the 1st. Adding days to the 1st lands mid-month and then-  // truncates down, so a +181d shift moved some phases 6 months and others 5.-  phases.forEach(p=>{-    if(!phaseHasRange(p))return;   // nothing to move, and '' would become a bogus month-    p.start=addDays(p.start+'-15',delta).slice(0,7);-    const[ey,em]=p.end.split('-').map(Number);-    p.end=addDays(`${ey}-${String(em).padStart(2,'0')}-15`,delta).slice(0,7);-  });-  rebuildTimeline();-  Object.keys(barEls).forEach(id=>updateBar(id));-  scheduleSave(via||'start-date');-  return true;-}- // ── The dependency graph ───────────────────────────────────────────────────── // // One reading of `deps` for the two features that need it, so auto-scheduling and@@ -10782,93 +10879,6 @@ function wireFitButton(){ $('btnFit').addEventListener('click',fitToWidth); } -// ── Date picker ──-const datePop=$('datePop');-let dpYear=0, dpMonth=0;--function openDatePicker(){-  const ps=parseISO(planStart());-  dpYear=ps.getFullYear(); dpMonth=ps.getMonth();-  renderDatePicker();-  const r=$('startBtn').getBoundingClientRect();-  datePop.style.left=Math.min(r.left,window.innerWidth-260)+'px';-  datePop.style.top=(r.bottom+6)+'px';-  datePop.classList.add('show');-}-function closeDatePicker(){ datePop.classList.remove('show'); }--function renderDatePicker(){-  const sel=parseISO(planStart());-  const mN=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];-  let head, grid;-  if(isDayMode()){-    head=`${mN[dpMonth]} ${dpYear}`;-    const first=new Date(dpYear,dpMonth,1), lead=first.getDay();-    const nDays=new Date(dpYear,dpMonth+1,0).getDate();-    const dow=['S','M','T','W','T','F','S'].map(d=>`<div class="dp-dow">${d}</div>`).join('');-    let cells='';-    for(let i=0;i<lead;i++) cells+='<div></div>';-    for(let d=1;d<=nDays;d++){-      const isSel=sel.getFullYear()===dpYear&&sel.getMonth()===dpMonth&&sel.getDate()===d;-      cells+=`<button class="dp-cell${isSel?' sel':''}" data-day="${d}">${d}</button>`;-    }-    grid=`<div class="dp-grid days">${dow}${cells}</div>`;-  } else {-    head=`${dpYear}`;-    grid=`<div class="dp-grid months">`+mN.map((m,i)=>{-      const isSel=sel.getFullYear()===dpYear&&sel.getMonth()===i;-      return `<button class="dp-cell${isSel?' sel':''}" data-month="${i}">${m}</button>`;-    }).join('')+`</div>`;-  }-  const shifted=planStart()!==ORIGINAL_START;-  datePop.innerHTML=`-    <div class="dp-head">-      <button class="dp-nav" data-nav="-1">&lsaquo;</button>-      <span class="dp-title">${head}</span>-      <button class="dp-nav" data-nav="1">&rsaquo;</button>-    </div>-    ${grid}-    <div class="dp-foot">-      <button class="dp-link${shifted?'':' muted'}" data-act="revert" ${shifted?'':'disabled'}-        title="Put the plan back to its original start (${fmt(ORIGINAL_START)})">Revert</button>-      <button class="dp-link" data-act="today">Today</button>-    </div>`;-}--function wireDatePicker(){-datePop.addEventListener('click',e=>{-  const nav=e.target.closest('[data-nav]');-  if(nav){ const d=+nav.dataset.nav;-    if(isDayMode()){ const n=new Date(dpYear,dpMonth+d,1); dpYear=n.getFullYear(); dpMonth=n.getMonth(); }-    else dpYear+=d;-    renderDatePicker(); return; }-  const act=e.target.closest('[data-act]');-  if(act){-    if(act.dataset.act==='revert') shiftPlanTo(ORIGINAL_START,'start-revert');-    else { const t=new Date(); shiftPlanTo(isDayMode()?isoDay(t):isoDay(new Date(t.getFullYear(),t.getMonth(),1)),'start-today'); }-    closeDatePicker(); return;-  }-  const cell=e.target.closest('.dp-cell');-  if(!cell) return;-  let iso;-  if(cell.dataset.day!==undefined) iso=isoDay(new Date(dpYear,dpMonth,+cell.dataset.day));-  else {-    // The button now shows the exact day (0.57.0), so the month grid has to land on a-    // real one: a plan starting on the 31st moved to February starts on the 28th or-    // 29th, not on the 3rd of March, which is where new Date(y, 1, 31) rolls over to.-    const mo=+cell.dataset.month, last=new Date(dpYear,mo+1,0).getDate();-    iso=isoDay(new Date(dpYear,mo,Math.min(parseISO(planStart()).getDate(),last)));-  }-  shiftPlanTo(iso,'start-picker');-  closeDatePicker();-});-$('startBtn').addEventListener('click',e=>{-  e.stopPropagation();-  datePop.classList.contains('show') ? closeDatePicker() : openDatePicker();-});-document.addEventListener('click',e=>{ if(!e.target.closest('.datepop')&&!e.target.closest('#startBtn')) closeDatePicker(); });-}- // ── Resizable name column ── // Long task names were simply cut off with no way to see them. The width persists, // and a double-click sizes it to the longest name currently rendered.@@ -12655,7 +12665,6 @@ function chartDeleteReady(){   if(prefs.view!=='gantt')return false;   if(!selected.size)return false;   if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false;-  if(datePop&&datePop.classList.contains('show'))return false;   if(isTypingTarget(document.activeElement))return false;   // A panel open on ONE row is a row being edited, and Delete there belongs to the   // field the user is in rather than to the plan. The batch panel is the exception: it@@ -13096,6 +13105,7 @@ function applyRole(){       $('previewBack').addEventListener('click',()=>{ try{ sessionStorage.removeItem(PREVIEW_KEY); }catch(e){} applyRole(); flash(saveInd,'back to your own view'); }); }   } else if(bar) bar.remove();   if(member&&prefs.view==='analytics'){ prefs.view='gantt'; applyView(); applyFilter(); }+  renderDatesCard(); } async function loadRole(){   if(!SNAPSHOT){ try{ const r=await fetch('access'); if(r.ok){ const a=await r.json(); viewerIsOwner=!!a.canChange; } }catch(e){} }@@ -13234,6 +13244,7 @@ lastSnap=snapSemantic(); startLive(); wireChatCard(); wireShareCard();+wireDatesCard(); await loadRole(); openRowLink(); window.addEventListener('hashchange',openRowLink);@@ -13364,7 +13375,6 @@ async function init(){   wireHeaderFit();   wireFitButton();   wirePlanCtl();-  wireDatePicker();   wireLabelColumnResize();   wireEditorDrag();   wireEditorTab();
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.74.2",+  "version": "0.75.0",   "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",   "bin": {
page.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.74.2",+  "version": "0.75.0",   "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",   "bin": {
server.js+31
@@ -977,6 +977,13 @@ function describeEvent(e) {   if (action === 'plan.shift') {     return `${who} moved the whole plan ${sgn(num('shift', 'start'))}d (${e.count || 0} items): starts ${fmtDate(fld('from', 'start', ''))} -> ${fmtDate(fld('to', 'start', ''))}${via}`;   }+  // Project dates (0.75.0), set by the owner on Setup.+  if (action === 'plan.dates') {+    const one = (k, side) => { const v = fld(side, k, ''); return v ? fmtDate(v) : 'not set'; };+    const parts = [];+    ['start', 'end'].forEach(k => { if (fld('from', k, '') !== fld('to', k, '')) parts.push(`the project ${k} to ${one(k, 'to')} (was ${one(k, 'from')})`); });+    return `${who} set ${parts.join(' and ') || 'the project dates'}${via}`;+  }   if (action === 'timeline.start') {     const f = fld('from', 'start', ''), t = fld('to', 'start', '');     return `${who} set the timeline to start at ${fmtDate(t)}${f ? ` (was ${fmtDate(f)})` : ''}${via}`;@@ -1351,6 +1358,13 @@ function tidyPlanTags(data) { } // Sidecar rule: an array (normalised, [] kept as "cleared") or no key. Applies to // the v4 override lists; `added` rows are whole rows and take the data-file rule.+// Project dates (0.75.0): { start, end }, each a YYYY-MM-DD day or '' for "not set".+// Anything else is dropped, so a malformed value can never reach the merged plan.+function normalizeDates(v) {+  if (!v || typeof v !== 'object' || Array.isArray(v)) return undefined;+  const day = x => (typeof x === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(x) && !isNaN(Date.parse(x + 'T00:00:00Z'))) ? x : '';+  return { start: day(v.start), end: day(v.end) };+} function tidyStateTags(st) {   if (!st || typeof st !== 'object' || Array.isArray(st)) return st;   const side = o => {@@ -1904,6 +1918,12 @@ function mergedData() {     if (state.tags.length) data.project.tags = state.tags.map(t => Object.assign({}, t));     else delete data.project.tags;   }+  // Project dates (0.75.0): the sidecar's win over the data file's; '' clears one.+  const dates = normalizeDates(state.dates);+  if (dates) {+    if (!data.project || typeof data.project !== 'object') data.project = {};+    ['start', 'end'].forEach(k => { if (dates[k]) data.project[k] = dates[k]; else delete data.project[k]; });+  }   if (Array.isArray(state.order) && state.order.length === data.items.length) {     const reordered = state.order.map(u => byUid.get(u)).filter(r => r && data.items.includes(r));     // All or nothing. A single unresolved uid would otherwise drop a row.@@ -2773,6 +2793,17 @@ const server = http.createServer(async (req, res) => {         }         // A malformed tag list is dropped here, never stored and never a 500.         tidyStateTags(store);+        // Project dates are the owner's, like sharing (0.75.0). A save that does not carry+        // them (an older tab, a script) keeps the stored ones, and so does a save from+        // anyone else that would change them: the page only lets the owner edit them.+        {+          let was; try { const old = loadState(); was = old && normalizeDates(old.dates); } catch (e) {}+          const mayDate = !!auth.agent || isDirectLoopback(req) || isOwner(auth.user, accessView());+          const now = normalizeDates(store.dates);+          if (now && mayDate) store.dates = now;+          else if (was) store.dates = was;+          else delete store.dates;+        }         store.stateRev = cur + 1;         delete store.baseRev;         delete incoming.baseRev;
test/server_test.mjs+19
@@ -588,6 +588,25 @@ async function main() {   ok(r.status === 403, 'a member cannot change how often it posts: the owner manages Chat (0.73.0)', r.status);   r = await asPost(drew, '/notify', { cadence: 'hourly' });   ok(r.status === 200, 'the owner can', r.status);+  // Project dates (0.75.0) are the owner's, like the sharing: a member's save keeps them.+  const st0 = (await as(drew, '/state')).j || {};+  const plain = { v: 4, items: st0.items || [], phases: st0.phases || [], order: st0.order || [], deleted: st0.deleted || [], added: st0.added || [] };+  const sDates = async () => ((await as(drew, '/state')).j || {}).dates;+  r = await asPost(drew, '/save', Object.assign({}, plain, { dates: { start: '2026-09-14', end: 'soon' } }));+  let sd = await sDates();+  ok(r.status === 200 && sd && sd.start === '2026-09-14' && sd.end === '', 'the owner sets the project dates; a malformed one is kept as "not set"', sd);+  r = await asPost(ana, '/save', Object.assign({}, plain, { dates: { start: '2020-01-01', end: '2020-02-01' } }));+  sd = await sDates();+  ok(r.status === 200 && sd && sd.start === '2026-09-14' && sd.end === '', 'a member\'s save keeps the owner\'s dates, whatever it carries', sd);+  r = await asPost(ana, '/save', plain);+  sd = await sDates();+  ok(r.status === 200 && sd && sd.start === '2026-09-14', 'and so does a save with no dates at all (a tab older than 0.75.0)', sd);+  r = await asPost(drew, '/save', Object.assign({}, plain, { dates: { start: '2026-09-14', end: '2026-12-18' } }));+  const ex = (await as(drew, '/export/json')).j;+  ok(ex && ex.project && ex.project.start === '2026-09-14' && ex.project.end === '2026-12-18', 'the merged plan (exports, snapshots, agents) carries them as project.start and project.end', ex && ex.project);+  await asPost(drew, '/log', { events: [{ action: 'plan.dates', from: { start: '', end: '' }, to: { start: '2026-09-14', end: '2026-12-18' } }] });+  const pdMd = (await as(drew, '/changelog?format=md')).t;+  ok(/set the project start to 14-Sep-2026 \(was not set\) and the project end to 18-Dec-2026 \(was not set\)/.test(pdMd), 'the changelog says what was set, in one line', pdMd.slice(-300));   // The live stream of someone the owner stops sharing with is told and closed.   const live = await fetch(S.base + '/live?cid=ana1', { headers: Object.assign({ Cookie: ana.cookie }, XFF) });   const reader = live.body.getReader(); let got = '';
test/ui_test.mjs+71−42
@@ -164,7 +164,6 @@ async function readouts(page) {     durTip: document.getElementById('spanDur')?.title || '',     leftTip: document.getElementById('spanLeft')?.title || '',     leftState: document.getElementById('spanLeft')?.dataset.state || '',-    start: document.getElementById('startBtn')?.textContent || null,   })); } // The readouts are right if they say what the oracle says about the plan's CURRENT dates.@@ -177,7 +176,6 @@ async function expectReadouts(page, today, what) {   const wantLeft = dayNum(today) > dayNum(last) ? 'ended' : spanText(today, last);   check(`${what}: Project Duration = ${wantDur}`, r.dur === wantDur, `got ${r.dur} (first ${first}, last ${last})`);   check(`${what}: Time Remaining = ${wantLeft}`, r.left === wantLeft, `got ${r.left}`);-  check(`${what}: start button = ${fmtDate(first)}`, r.start === fmtDate(first), `got ${r.start}`);   return { first, last, n, ...r }; } @@ -388,7 +386,7 @@ async function main() {     SRV = S.url;     let page, r, lg; -    if (wantAny('header', 'dates', 'start picker', 'live')) {+    if (wantAny('header', 'dates', 'project dates', 'live')) {       // ── 3. Header declutter ──       section = 'header';       page = await openPage(browser, S.url);@@ -424,7 +422,6 @@ async function main() {         const r = await readouts(page);         const ax = await page.evaluate(() => { const cs = [...document.querySelectorAll('.m-row > *')]; return { n: cs.length, labels: cs.slice(0, 4).map(c => c.textContent.trim()).join('|') }; });         axes.push({ g, ...ax });-        check(`grain ${g}: start shows the exact date 21-Sep-2026`, r.start === '21-Sep-2026', r.start);         check(`grain ${g}: duration and remaining do not move`, r.dur === base.dur && r.left === base.left, `${r.dur} / ${r.left}`);       }       // The axis must REDRAW for the grain: finer grains have more cells (day > week >@@ -434,35 +431,71 @@ async function main() {       check('the axis redraws with the grain: every grain paints different cells', new Set(axes.map(a => a.n + '#' + a.labels)).size === 5, axes.map(a => `${a.g}:${a.labels}`).join(' ; '));       await setGrain(page, 'week'); -      // Start-date picker: opens, and a month pick lands on an exact date.-      section = 'start picker';-      await page.click('#startBtn'); await settle(page, 100);-      check('the start-date picker opens', await page.$eval('#datePop', e => e.classList.contains('show')));-      await page.click('#datePop .dp-cell[data-month="9"]'); await settle(page);-      r = await expectReadouts(page, TODAY, 'after moving the start to October');-      check('start moved to 21-Oct-2026 (exact day kept)', r.start === '21-Oct-2026', r.start);-      // The length in weeks stays; the workdays follow the calendar (a shift moves weekends-      // and holidays in and out), and expectReadouts has already checked them.-      check('a whole-plan shift keeps the duration in weeks', r.dur.split(' / ')[0] === base.dur.split(' / ')[0], `${r.dur} vs ${base.dur}`);-      check('a whole-plan shift moves the remaining time', r.left !== base.left, r.left);-      await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);-      r = await expectReadouts(page, TODAY, 'undo of the start shift');-      check('undo puts the start back', r.start === '21-Sep-2026' && r.dur === base.dur && r.left === base.left, JSON.stringify(r));-      // Revert and Today, the picker's two links.-      await page.click('#startBtn'); await settle(page, 100);-      check('with the start unmoved, Revert is disabled', await page.$eval('#datePop .dp-link[data-act="revert"]', b => b.disabled));-      await page.click('#datePop .dp-cell[data-month="9"]'); await settle(page);-      await page.click('#startBtn'); await settle(page, 100);-      await page.click('#datePop .dp-link[data-act="revert"]'); await settle(page);-      r = await expectReadouts(page, TODAY, 'after Revert');-      check('Revert puts the plan back on its original start', r.start === '21-Sep-2026' && r.dur === base.dur, r.start);-      await page.click('#startBtn'); await settle(page, 100);-      await page.click('#datePop .dp-link[data-act="today"]'); await settle(page);-      r = await expectReadouts(page, TODAY, 'after Today');-      check('Today (at week grain) moves the start to the 1st of this month, 01-Sep-2026', r.start === '01-Sep-2026' && r.dur.split(' / ')[0] === base.dur.split(' / ')[0], `${r.start} ${r.dur}`);-      await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);-      r = await expectReadouts(page, TODAY, 'undo of Today');-      check('undo of Today goes back to the original start', r.start === '21-Sep-2026' && r.dur === base.dur && r.left === base.left, r.start);+      // 0.75.0: the project's own dates, set on Setup, replace the start button. The+      // readouts count from them, the chart draws them, and rows past them are listed.+      section = 'project dates';+      check('the start button and its picker are gone', !(await page.$('#startBtn')) && !(await page.$('#datePop')));+      await view(page, 'setup');+      // Typed the way a person types into a date field: month, day, year. The field fires+      // change while the year is half typed (0002, 0020, 0202), and none of that commits.+      // Clicked at its left edge, on the month: the middle of a date field is the day.+      const typeDate = async (sel, mmddyyyy) => { await page.click(sel, { offset: { x: 14, y: 12 } }); await page.keyboard.type(mmddyyyy, { delay: 15 }); await settle(page, 150); };+      await typeDate('#pdStart', '09142026');+      await typeDate('#pdEnd', '12182026');+      await page.mouse.click(5, 5); await settle(page, 300);+      const PD = { start: '2026-09-14', end: '2026-12-18' };+      const pdState = async () => { await sleep(1000); return ((await (await fetch(SRV + 'state')).json()).dates) || null; };+      let pdSaved = await pdState();+      check('the typed dates are saved, and nothing half-typed was', pdSaved && pdSaved.start === PD.start && pdSaved.end === PD.end, JSON.stringify(pdSaved));+      const pdLog = await (await fetch(SRV + 'changelog?format=md')).text();+      check('the changelog says who set them', /set the project start to 14-Sep-2026 \(was not set\)/.test(pdLog) && /set the project end to 18-Dec-2026/.test(pdLog) && !/0002|0020|0202/.test(pdLog), pdLog.trim().split('\n').slice(-3).join(' | '));+      r = await readouts(page);+      check('Duration counts from the project start to its end', r.dur === spanText(PD.start, PD.end) && /from the project start 14-Sep-2026 to the project end 18-Dec-2026/.test(r.durTip), `${r.dur} | ${r.durTip}`);+      check('Remaining counts from today to the project end', r.left === spanText(TODAY, PD.end), r.left);+      const late = (await savedPlan(SRV)).items.filter(x => x.type !== 'group' && !/cancel/i.test(x.status || '') && (x.type === 'milestone' ? x.date : x.end) > PD.end);+      const card = await page.evaluate(() => ({ span: document.getElementById('pdSpan').textContent, status: document.getElementById('pdStatus').textContent, links: [...document.querySelectorAll('#pdOut li a')].map(a => a.textContent), outside: document.getElementById('spanDur').dataset.outside }));+      check(`the card lists the ${late.length} rows that end after the project end`, late.length > 0 && card.links.length === Math.min(late.length, 12) && late.slice(0, 12).every(x => card.links.includes(x.name)) && card.status.startsWith(late.length + ' row'), JSON.stringify(card));+      check('the card shows the span, and the readouts turn amber', card.span === spanText(PD.start, PD.end) && card.outside === '1', JSON.stringify(card));+      await page.evaluate(() => { const e = document.getElementById('pdEnd'); e.value = '2026-09-01'; e.dispatchEvent(new Event('change')); }); await settle(page, 200);+      pdSaved = await pdState();+      check('an end before the start is refused, with a reason, and nothing is saved', /end has to be on or after the start/.test(await page.$eval('#pdStatus', e => e.textContent)) && pdSaved.end === PD.end, JSON.stringify(pdSaved));+      await view(page, 'gantt');+      const lines = await page.evaluate(() => { const g = s => document.querySelector(s); const x = s => g(s).getBoundingClientRect();+        const fin = document.querySelector('.bar[data-id="finale"]').getBoundingClientRect();+        return { shown: ['.pdate-start', '.pdate-end', '.pdate-start-tag', '.pdate-end-tag', '.pdate-after'].every(s => g(s).style.display !== 'none'),+          dashed: getComputedStyle(g('.pdate-end')).borderLeftStyle, endX: x('.pdate-end').left, startX: x('.pdate-start').left, afterX: x('.pdate-after').left, finaleRight: fin.right, tags: [g('.pdate-start-tag').textContent, g('.pdate-end-tag').textContent] }; });+      check('the chart draws both dates as dashed lines, tagged start and end', lines.shown && lines.dashed === 'dashed' && lines.startX < lines.endX && lines.tags.join() === 'start,end', JSON.stringify(lines));+      check('the time after the end is hatched from the end line on, and a late bar runs into it', Math.abs(lines.afterX - lines.endX) <= 2 && lines.finaleRight > lines.endX, JSON.stringify(lines));+      // Undo history is the tab's own, so it is tried before the reload. Two commits, two+      // Ctrl+Z: the rows rule the readouts again; two Ctrl+Shift+Z put the dates back.+      await view(page, 'setup');+      await page.evaluate(() => document.activeElement && document.activeElement.blur());+      await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page, 300);+      r = await readouts(page);+      pdSaved = await pdState();+      check('Ctrl+Z takes both dates back, and the readouts count from the rows again', r.dur === base.dur && r.left === base.left && pdSaved.start === '' && pdSaved.end === '', `${r.dur} ${JSON.stringify(pdSaved)}`);+      await page.keyboard.down('Control'); await page.keyboard.down('Shift'); await page.keyboard.press('KeyZ'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Shift'); await page.keyboard.up('Control'); await settle(page, 300);+      r = await readouts(page);+      check('Ctrl+Shift+Z puts them back', r.dur === spanText(PD.start, PD.end) && await page.$eval('#pdEnd', e => e.value) === PD.end, r.dur);+      await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);+      r = await readouts(page);+      check('the dates survive a reload', r.dur === spanText(PD.start, PD.end) && await page.$eval('.pdate-end', e => e.style.display !== 'none'), r.dur);+      // The team sees them and cannot change them.+      await view(page, 'setup');+      await page.click('#sharePreview'); await settle(page, 200);+      await view(page, 'setup');+      const teamPd = await page.evaluate(() => ({ s: document.getElementById('pdStart').disabled, e: document.getElementById('pdEnd').disabled, v: document.getElementById('pdEnd').value }));+      check('in the team view the dates are shown but cannot be changed', teamPd.s && teamPd.e && teamPd.v === PD.end, JSON.stringify(teamPd));+      await page.click('#previewBack'); await settle(page, 200);+      // Cleared the way a person clears a date field, and the plan is back on its rows.+      await view(page, 'setup');+      await page.evaluate(() => ['pdStart', 'pdEnd'].forEach(id => { const e = document.getElementById(id); e.value = ''; e.dispatchEvent(new Event('change')); })); await settle(page, 300);+      r = await readouts(page);+      pdSaved = await pdState();+      check('clearing both dates puts the readouts back on the rows', r.dur === base.dur && r.left === base.left && pdSaved.start === '' && pdSaved.end === '', `${r.dur} ${JSON.stringify(pdSaved)}`);+      await view(page, 'gantt');+      check('with no dates set, no line and no hatching', await page.evaluate(() => [...document.querySelectorAll('.pdate-line,.pdate-out,.pdate-tag')].every(e => e.style.display === 'none')));+      await setGrain(page, 'week');        // Drag the last bar's right edge two weeks later.       section = 'live: drag';@@ -667,7 +700,7 @@ async function main() {       check('a stored array does not swallow the size (110% after A+ from 100%)', await fsPct(page) === '110%', await fsPct(page));       const snapR = await readouts(page);       // The seeded plan runs 21-Sep-2026 to 14-Jan-2027; the oracle counts the workdays.-      check('the snapshot shows the same readouts', snapR.dur === spanText('2026-09-21', '2027-01-14') && snapR.left === spanText(TODAY, '2027-01-14') && snapR.start === '21-Sep-2026', JSON.stringify(snapR));+      check('the snapshot shows the same readouts', snapR.dur === spanText('2026-09-21', '2027-01-14') && snapR.left === spanText(TODAY, '2027-01-14'), JSON.stringify(snapR));       await page.close();        // Cause 3 (0.58.0): the wiki serves an uploaded snapshot under a CSP sandbox with@@ -823,7 +856,6 @@ async function main() {         items: [{ type: 'task', id: 'a', name: 'Written in months', start: '2026-10', end: '2026-11', phase: 0, uid: '00001' }], }, 'months');       page = await openPage(browser, MO.url);       r = await readouts(page);-      check('year-month data: the start button still shows a day (01-Oct-2026)', r.start === '01-Oct-2026', r.start);       check(`year-month data: duration runs 01-Oct to 30-Nov (61 days = ${spanText('2026-10-01', '2026-11-30')})`, r.dur === spanText('2026-10-01', '2026-11-30') && /01-Oct-2026/.test(r.durTip) && /30-Nov-2026/.test(r.durTip), `${r.dur} ${r.durTip}`);       await page.close();     }@@ -835,7 +867,6 @@ async function main() {       page = await openPage(browser, E.url);       r = await readouts(page);       check('empty plan: both readouts say none', r.dur === 'none' && r.left === 'none', JSON.stringify(r));-      check('empty plan: start button says there are no dates', r.start === 'no dates', r.start);       check('no appName: the brand shows the project title', await page.$eval('#hdr .app-name', e => e.textContent) === 'Nothing Scheduled Yet');       check('empty plan: Collapse all is disabled (nothing to hide)', await page.$eval('#btnCollapseAll', b => b.disabled));       await page.close();@@ -1006,7 +1037,7 @@ async function main() { // 0.58.0: the Sections button left the header for the Setup lens, which is the fourth tab. const HDR_COMMON = ['.brand', '.vt-btn[data-view="gantt"]', '.vt-btn[data-view="table"]', '.vt-btn[data-view="analytics"]', '.vt-btn[data-view="setup"]',   '#spanDur', '#spanLeft', '#btnTheme', '#btnImport', '#btnExport', '#btnHelp', '#btnNew', '#fsDown', '#fsPct', '#fsUp'];-const HDR_CHART_ONLY = ['#startBtn', '#grainSel', '#btnFit', '#btnDeps', '#btnMsStyle'];+const HDR_CHART_ONLY = ['#grainSel', '#btnFit', '#btnDeps', '#btnMsStyle']; const HDR_EXPECT = {   gantt: [...HDR_COMMON, ...HDR_CHART_ONLY, '#btnAuto', '#btnCrit'],   table: [...HDR_COMMON, '#btnAuto', '#btnCrit'],@@ -1109,7 +1140,6 @@ async function headerSweep(browser, targets) { // an ancestor id. const MANIFEST = [   { sel: '.vt-btn', label: 'lens switch', safe: true, key: c => c.ds.view, pin: ['gantt', 'table', 'analytics', 'setup'] },-  { sel: '#startBtn', label: 'start date', safe: true },   { sel: '#grainSel', label: 'grain picker', terminal: true, by: 'dates' },   { sel: '#btnFit', label: 'Fit', safe: true },   { sel: '#btnDeps', label: 'dependency mode', safe: true, twice: true },@@ -1129,9 +1159,6 @@ const MANIFEST = [   { sel: '.phase-chip', label: 'phase legend chip', safe: true, twice: true },   { sel: '.legend-item', label: 'milestone / deps / today legend toggles, and the inert holiday key', safe: true, twice: true,     key: c => c.cls.includes('static') ? 'holiday-key' : c.text.split(/\s+/)[0].toLowerCase(), pin: ['holiday-key', 'milestone', 'dependencies', 'today'] },-  { sel: '.dp-nav', label: 'date picker year step', safe: true, key: c => c.ds.nav, pin: ['-1', '1'] },-  { sel: '.dp-cell', label: 'date picker month', terminal: true, by: 'start picker', key: c => 'm' + c.ds.month, pin: Array.from({ length: 12 }, (_, i) => 'm' + i) },-  { sel: '.dp-link', label: 'date picker Revert / Today', terminal: true, by: 'start picker', key: c => c.ds.act, pin: ['revert', 'today'] },   { sel: '.dd-item', within: 'exportMenu', label: 'export formats', terminal: true, by: 'export menu', key: c => c.ds.fmt, pin: ['json', 'csv', 'svg', 'html'] },   { sel: '.dd-item', within: 'exportMenu', label: 'help rows (inert text)', safe: true, key: c => (!c.ds.fmt && c.cursor === 'default') ? 'help-text' : undefined, pin: ['help-text'] },   { sel: '.dd-item', within: 'importMenu', label: 'import JSON / CSV', terminal: true, by: 'import menu', key: c => c.ds.fmt, pin: ['json', 'csv'] },@@ -1173,6 +1200,9 @@ const MANIFEST = [   // Renders only once a webhook is set, which the coverage walk does not do (a real-looking   // Chat URL would have the notifier post to it). The notify flow sets one and removes it.   { sel: '#chatHookClear', label: 'setup: remove the webhook', terminal: true, by: 'notify', required: false },+  { sel: '#pdStart', label: 'setup: project start', terminal: true, by: 'project dates' },+  { sel: '#pdEnd', label: 'setup: project end', terminal: true, by: 'project dates' },+  { sel: '.pd-out a', label: 'setup: a row outside the project dates (opens it)', terminal: true, by: 'project dates', required: false },   { sel: '#shareMode', label: 'setup: who can open the plan', terminal: true, by: 'share' },   // Renders only when the plan is shared with an org; the share flow switches to that mode.   { sel: '#shareOrg', label: 'setup: which organization', terminal: true, by: 'share', required: false },@@ -1367,7 +1397,6 @@ async function coverageGate(browser, url) {   const kick = plan0.items.find(r => r.name === 'Kickoff');   const STATES = [     ['chart', async () => { await view(page, 'gantt'); }],-    ['date picker', async () => { await view(page, 'gantt'); await page.click('#startBtn'); }],     ['export menu', async () => { await page.click('#btnExport'); }],     ['import menu', async () => { await page.click('#btnImport'); }],     ['help menu', async () => { await page.click('#btnHelp'); }],