app
Adom Gantt
Public Made by Adomby adom
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, push
← Commit history
Publish 0.76.0
10 files changed
+401−40
README.md+12−9@@ -4,9 +4,9 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau ## Features -**Auto-schedule the whole plan, by pushing later only.** One press of **Auto-schedule** moves every task out until it starts no earlier than the work it waits on has finished, and so that nothing starts or ends on a Saturday, a Sunday or a US federal holiday. The handoff is a MINIMUM rather than an answer. A task that starts BEFORE its latest dependency's end is pushed out to it, so the two bars meet on the chart with no column of white between them; a task that already starts on or after it is LEFT EXACTLY WHERE IT IS. That is what keeps a gap you meant: an order placed on the Monday and the parts that arrive on the Thursday keep their three days, because a lead time is real elapsed time and not a tidying-up job. Nothing is ever pulled EARLIER, so a plan cannot drift backwards over a press and a second press moves nothing at all. Pushes cascade, so a task moved out moves its own successors, measured against where they landed. A task with no dependencies is an anchor and keeps its start, since that is the one thing the plan cannot derive. Durations are preserved in WORKING days, so a five-day task laid across a weekend, or across Labor Day, still takes five working days: a predecessor that ends on the Friday hands off on the Friday, and the weekend sits inside the successor's span rather than between the two of them. Milestones take the same rule with no duration. Cancelled work is out of the pass entirely: it never moves, and it constrains nobody, so a task whose only dependency is cancelled behaves like an anchor and the toast counts the excluded rows as their own number. The dependency graph crosses sections freely and so does the schedule. A cycle is left exactly as it is, named in the toast, because there is no honest answer for a row that waits on itself. It is one Ctrl+Z for the whole pass and one line in the changelog, and pressing it on an already aligned plan reports "0 tasks moved" and writes nothing.+**Auto-schedule the whole plan, by pushing later only.** One press of **Auto-schedule** moves every task out until it starts no earlier than the work it waits on has finished, and so that nothing starts or ends on a Saturday, a Sunday or one of the plan's days off (the US federal holidays unless the plan works them, see Working calendar). The handoff is a MINIMUM rather than an answer. A task that starts BEFORE its latest dependency's end is pushed out to it, so the two bars meet on the chart with no column of white between them; a task that already starts on or after it is LEFT EXACTLY WHERE IT IS. That is what keeps a gap you meant: an order placed on the Monday and the parts that arrive on the Thursday keep their three days, because a lead time is real elapsed time and not a tidying-up job. Nothing is ever pulled EARLIER, so a plan cannot drift backwards over a press and a second press moves nothing at all. Pushes cascade, so a task moved out moves its own successors, measured against where they landed. A task with no dependencies is an anchor and keeps its start, since that is the one thing the plan cannot derive. Durations are preserved in WORKING days, so a five-day task laid across a weekend, or across Labor Day, still takes five working days: a predecessor that ends on the Friday hands off on the Friday, and the weekend sits inside the successor's span rather than between the two of them. Milestones take the same rule with no duration. Cancelled work is out of the pass entirely: it never moves, and it constrains nobody, so a task whose only dependency is cancelled behaves like an anchor and the toast counts the excluded rows as their own number. The dependency graph crosses sections freely and so does the schedule. A cycle is left exactly as it is, named in the toast, because there is no honest answer for a row that waits on itself. It is one Ctrl+Z for the whole pass and one line in the changelog, and pressing it on an already aligned plan reports "0 tasks moved" and writes nothing. -**Critical path, one per section.** **Critical** lights up the LONGEST run of work inside every section at once: the bars, the dependency arrows along it and any milestone on it glow white and breathe, in a saturated indigo on paper. The dependency graph is read as a DAG, a task weighs its duration in WORKING days (weekends and US federal holidays excluded) and a milestone weighs nothing, and the critical path is the maximum-weight path through it. So it is the hardest run of work and not merely whatever chain happens to end last. A gap between two tasks adds no weight, because the length is the sum of the work along the path and not the calendar it is spread over, so the gaps Auto-schedule now preserves cannot promote or demote a chain. Cancelled work is not in the graph: it can never glow, it carries no weight, and a chain that ran through it is broken at that row, so the section falls back to whatever the longest run through the work that is still live turns out to be. Un-cancelling puts the row, its links and every path through it straight back, because nothing was deleted. Nothing is rescheduled: it reads the dates the plan has right now, independently of Auto-schedule.+**Critical path, one per section.** **Critical** lights up the LONGEST run of work inside every section at once: the bars, the dependency arrows along it and any milestone on it glow white and breathe, in a saturated indigo on paper. The dependency graph is read as a DAG, a task weighs its duration in WORKING days (weekends and the plan's days off excluded) and a milestone weighs nothing, and the critical path is the maximum-weight path through it. So it is the hardest run of work and not merely whatever chain happens to end last. A gap between two tasks adds no weight, because the length is the sum of the work along the path and not the calendar it is spread over, so the gaps Auto-schedule now preserves cannot promote or demote a chain. Cancelled work is not in the graph: it can never glow, it carries no weight, and a chain that ran through it is broken at that row, so the section falls back to whatever the longest run through the work that is still live turns out to be. Un-cancelling puts the row, its links and every path through it straight back, because nothing was deleted. Nothing is rescheduled: it reads the dates the plan has right now, independently of Auto-schedule. A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependency that crosses sections is not an edge and never lights up, and a section with no dependencies among its own rows lights its longest single row. Ties are broken deterministically: more working days, then a greater calendar span, then more rows, then whichever starts earlier in the plan. Everything else stays exactly as it was, selection and dependency mode included, the glow recomputes as you edit, and the state is remembered across reloads. @@ -74,9 +74,11 @@ 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.+**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). -**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).+**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.++**Working calendar.** The second card on Setup says which days this plan works (0.76.0). Weekends are always off, and by default so are all eleven US federal holidays; untick one your team works (Adom works Columbus Day and Veterans Day) and it becomes a normal working day for this plan. Add the weekdays the whole team is off, like a year-end shutdown, and they become days off. Auto-schedule, the critical path, every hours and workdays figure, and the day grain's shading all follow it, so a worked holiday is not shaded and an extra day off is. Only the plan's owner can change it; every change is one Ctrl+Z and one changelog line. **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. @@ -96,13 +98,11 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc **Brand phase colors.** A phase with no explicit `color` cycles the three Adom hues: teal, purple, blue. There is no background band behind a phase: the bar colour and the group header carry the section on their own, so colour on the chart means exactly one thing, which phase a bar belongs to. The hues are theme-aware, so the same plan is on-brand on screen and legible on paper. -**Move the whole plan.** The project start sits in the header, as an exact date at every cell size. Change it and every task, milestone and phase shifts by the same number of days, so durations and the gaps between them survive. Revert puts the plan back to the data file's own start. It is one undo step and one changelog entry, not 26.- **Text size.** A-minus and A-plus at the right end of the header step the whole app between 85% and 150% in seven steps, chart and table together. The percentage between them is the reset. Rows, bars and the axis grow with the type, so nothing is clipped, and the choice persists across a refresh, in the live app and in an exported HTML snapshot alike. **Fit to the pane.** One click on Fit picks the finest cell size that shows the whole plan without scrolling sideways, then stretches the cells to fill the width exactly. Click it again after resizing the window to re-fit. -**Cell size.** Days, Weeks, Months, Quarters or Years. Each grain carries its own minimum cell width, sized to the widest label it draws, so a fine grain scrolls rather than squashing and a coarse one stretches to fill. Days adds weekend shading, a differently coloured band on every US federal holiday with its name in a tooltip, and a full calendar picker.+**Cell size.** Days, Weeks, Months, Quarters or Years. Each grain carries its own minimum cell width, sized to the widest label it draws, so a fine grain scrolls rather than squashing and a coarse one stretches to fill. Days adds weekend shading, a differently coloured band on each of the plan's days off (the federal holidays it takes, with the name in a tooltip, and its extra days off), and a full calendar picker. **Editable milestones.** Drag a milestone sideways to move its date, or click it to edit its name, date and description. Line style is the default: the tag on the dashed rule is the handle and the milestone's own row is hidden, since the rule already spans the chart. Long titles wrap, and a tag that would still collide with its neighbour drops to a second row. Diamond style keeps the row. @@ -150,7 +150,8 @@ Create a JSON file with three sections: "title": "My Project", "subtitle": "Short description", "start": "2026-01-05",- "end": "2026-06-30"+ "end": "2026-06-30",+ "calendar": { "workHolidays": ["Columbus Day", "Veterans Day"], "extraDaysOff": ["2026-12-24"] } }, "phases": [ { "name": "Phase 1 — Build", "lead": "the long pole (Adonis)", "start": "2026-01", "end": "2026-06" }@@ -164,6 +165,8 @@ Create a JSON file with three sections: <!-- prose-lint-enable --> +`project.start` and `project.end` are the project dates and `project.calendar` the working calendar, all optional and all normally set on Setup. In the calendar, `workHolidays` names the federal holidays the plan works, spelled as the app lists them (`New Year's Day`, `Martin Luther King, Jr. Day`, `Washington's Birthday`, `Memorial Day`, `Juneteenth National Independence Day`, `Independence Day`, `Labor Day`, `Columbus Day`, `Veterans Day`, `Thanksgiving Day`, `Christmas Day`), and `extraDaysOff` lists `YYYY-MM-DD` days nobody works. Unknown names and malformed days are dropped. Without a calendar every federal holiday is a day off.+ **Tags** are optional on a phase and on a task or milestone: `"tags": ["Tech"]`, a short list of short strings. The server trims them, drops duplicates that differ only in case (the first spelling wins), caps each at 32 characters and the list at 12, turns a `;` inside a tag into a space (`;` separates tags in the CSV), and@@ -436,7 +439,7 @@ table, the item editor or the batch editor are taken exactly as typed. | Drag a section row on Setup | Move the section; a line shows where it lands. Alt+Up / Alt+Down move a focused row. One Ctrl+Z | | Click a section's name or aside on Setup | Edit it in place: Enter or click away commits, Escape cancels | | Fit | Fit the whole plan into the pane, no horizontal scrolling |-| Auto-schedule | Push every task out to its dependencies, finish to start, skipping weekends and US federal holidays. Never pulls work earlier, so intentional gaps survive. One undo. Disabled when the plan has no dependencies |+| Auto-schedule | Push every task out to its dependencies, finish to start, skipping weekends and the plan's days off (Setup, Working calendar). Never pulls work earlier, so intentional gaps survive. One undo. Disabled when the plan has no dependencies | | Critical | Light up the longest run of work inside every section, cancelled work left out. On/off, and it recomputes as the plan changes | | A- / A+ | Step the text size for both views. Click the percentage to reset | | Collapse all / Expand all | Fold every section to its header, or open them all. Start of the legend |
SKILL.md+14−5@@ -237,8 +237,8 @@ rather than a category. - **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-- **Critical** button, next to it: the LONGEST run of work in the plan. The dependency graph is a DAG, a task WEIGHS its duration in working days (weekends and observed US federal holidays out, the same arithmetic everything else in the app counts with) and a milestone weighs nothing, and the critical path is the maximum-weight path through it, found by the classic longest-path DP. It reads the dates the plan has right now, so it is independent of Auto-schedule and never moves anything. **This is not zero-float CPM** and it deliberately stopped being it in 0.49.0: CPM anchors its backward pass on the plan's latest finish, so it always answers "which chain ends last", and on a real plan that picks a short simple chain running into December over a ten-row interconnected build finishing in November. An isolated row is a path of one and can win if it is genuinely the longest thing there is. **One button, an on/off TOGGLE**, with one constant label and the state carried by its pressed treatment: off, or one longest path inside EVERY section at once, all lit together. 0.49.0 offered a plan-wide reading as a third state of the same button and 0.50.0 dropped it, because on a real plan it lights one section's chain and leaves every other section dark, which is a narrower reading than the per-section answer that contains it. With no label to swap, the button sizes off its own content like every other header action rather than being pinned to the widest of three words. Each lane is answered on its OWN subgraph: the nodes are that section's rows and the edges are the dependencies whose two ends are both in it, so a dependency that crosses sections is not an edge there and can never light up in that mode, and a section with no internal dependencies lights its longest single ROW, which is the same rule rather than an exception to it. **CANCELLED WORK IS NOT A NODE, since 0.53.0:** it can never glow, it carries no weight, and a chain that ran through it is BROKEN at that row, since the node is absent and its predecessors and successors are no longer joined by way of it. A section with a second route round the cancelled row is REROUTED onto it; a section whose only route ran through it falls back to the longest single row left, which may be neither of the two stumps' first halves. The longest-single-row fallback ignores cancelled rows for the same reason: they are not there to be considered. Nothing is deleted from the data, so un-cancelling puts the node, its links and every path through it straight back. Ties are broken deterministically, in this order: more working days, then a greater calendar span (first start to last end), then more rows, then whichever begins earlier in plan order, so exactly one path is ever lit per answer. A GAP adds no weight, because a path's length is the sum of the NODES along it and not the calendar it is spread over, so the gaps 0.53.0's push-only Auto-schedule now preserves cannot promote or demote a chain; the calendar span is reachable only by the second tie-break, where it separates two chains already tied on working days and says the one occupying more of the calendar is the more critical, which is the reading it has always had. The bars, the arrows along the path and any milestone on it glow white and breathe on a 2.6s cycle, in a saturated indigo in light mode and on paper, and the table lens marks the same rows on their left edge. Purely additive: selection outlines, dependency-mode amber and armed red all still read on a glowing row, and hit-testing is untouched. It recomputes live as dates and dependencies change, and the state is a saved preference beside the theme and the milestone style; a browser that stored the old plan-wide mode comes back lit, since that reader had the glow on. A collapsed section hides its own glow with its rows and the rest of the plan keeps glowing. Disabled, with the same message it has always used, when the plan has no dependencies at all: a path is a run of work THROUGH dependencies, and a plan where nothing waits on anything has no runs to be longest+- **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 one of the plan's days off (the US federal holidays it takes, see Working calendar). 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+- **Critical** button, next to it: the LONGEST run of work in the plan. The dependency graph is a DAG, a task WEIGHS its duration in working days (weekends and the plan's days off out, the same arithmetic everything else in the app counts with) and a milestone weighs nothing, and the critical path is the maximum-weight path through it, found by the classic longest-path DP. It reads the dates the plan has right now, so it is independent of Auto-schedule and never moves anything. **This is not zero-float CPM** and it deliberately stopped being it in 0.49.0: CPM anchors its backward pass on the plan's latest finish, so it always answers "which chain ends last", and on a real plan that picks a short simple chain running into December over a ten-row interconnected build finishing in November. An isolated row is a path of one and can win if it is genuinely the longest thing there is. **One button, an on/off TOGGLE**, with one constant label and the state carried by its pressed treatment: off, or one longest path inside EVERY section at once, all lit together. 0.49.0 offered a plan-wide reading as a third state of the same button and 0.50.0 dropped it, because on a real plan it lights one section's chain and leaves every other section dark, which is a narrower reading than the per-section answer that contains it. With no label to swap, the button sizes off its own content like every other header action rather than being pinned to the widest of three words. Each lane is answered on its OWN subgraph: the nodes are that section's rows and the edges are the dependencies whose two ends are both in it, so a dependency that crosses sections is not an edge there and can never light up in that mode, and a section with no internal dependencies lights its longest single ROW, which is the same rule rather than an exception to it. **CANCELLED WORK IS NOT A NODE, since 0.53.0:** it can never glow, it carries no weight, and a chain that ran through it is BROKEN at that row, since the node is absent and its predecessors and successors are no longer joined by way of it. A section with a second route round the cancelled row is REROUTED onto it; a section whose only route ran through it falls back to the longest single row left, which may be neither of the two stumps' first halves. The longest-single-row fallback ignores cancelled rows for the same reason: they are not there to be considered. Nothing is deleted from the data, so un-cancelling puts the node, its links and every path through it straight back. Ties are broken deterministically, in this order: more working days, then a greater calendar span (first start to last end), then more rows, then whichever begins earlier in plan order, so exactly one path is ever lit per answer. A GAP adds no weight, because a path's length is the sum of the NODES along it and not the calendar it is spread over, so the gaps 0.53.0's push-only Auto-schedule now preserves cannot promote or demote a chain; the calendar span is reachable only by the second tie-break, where it separates two chains already tied on working days and says the one occupying more of the calendar is the more critical, which is the reading it has always had. The bars, the arrows along the path and any milestone on it glow white and breathe on a 2.6s cycle, in a saturated indigo in light mode and on paper, and the table lens marks the same rows on their left edge. Purely additive: selection outlines, dependency-mode amber and armed red all still read on a glowing row, and hit-testing is untouched. It recomputes live as dates and dependencies change, and the state is a saved preference beside the theme and the milestone style; a browser that stored the old plan-wide mode comes back lit, since that reader had the glow on. A collapsed section hides its own glow with its rows and the rest of the plan keeps glowing. Disabled, with the same message it has always used, when the plan has no dependencies at all: a path is a run of work THROUGH dependencies, and a plan where nothing waits on anything has no runs to be longest - **Click legend** to filter phases, milestones, dependency arrows, or the today line. The legend WRAPS onto as many lines as it needs (0.57.0), so every phase chip on a 20-section plan is visible and clickable; until 0.56.0 it was one line and clipped everything past the pane edge. Below 1150px the chips drop their words and keep the colour dot, as before - **Tag chips in the legend** (0.59.0; rows since 0.60.0). When the plan carries tags, on sections or on rows, the legend leads with a chip per tag (`# Tech 32`: the tag and how many rows choosing it would show, counting the rows in its sections and the rows tagged on their own) and the section chips stand BEHIND them: with no tag chosen they fold away behind one "20 sections" chip (click it to open them all; that is remembered as `prefs.legendSecs`), and with a tag chosen the chips of exactly the sections on screen come back after the tags. A section chip whose name prefix is a chosen tag drops the repeat ("Laser workcells" behind "# Tech"). Choosing tags filters by EFFECTIVE tags: a row's effective tags are its own plus its section's, and a row is shown when ANY of them is chosen. So every row of a chosen section is shown, tagged or not; a row tagged "long-lead" in an untagged section is shown by "long-lead", with its section header above it and the section's other rows hidden; and a row whose own tags do not match, in a section that does not match, is hidden. A section header is shown when the section matches or any of its rows does, and while the filter hides some of its rows its count reads "2 of 3". A delete or a tag edit that takes the last carrier of a chosen tag out of the plan drops it from the filter, and its Ctrl+Z puts the filter back with the tag. This holds in the chart and the table alike; the readouts, Analytics and everything else stay whole-plan. It composes with the section chips (which still switch a shown section off) and with Collapse all (which then counts only the shown sections). **Clear tags** shows everything again. The filter is view state, per person, in `prefs.tagFilter`, so it survives a refresh; a tag the plan no longer has drops out of it. A plan with no tags has no tag chips and no fold, and its legend is what it always was - **Current in the legend** (0.70.0), between Owner and Overdue: the working set for a review. A row is **current** when it has STARTED (a task's start, a milestone's date: today or earlier) and is not complete or cancelled, so it is the work under way plus the work that should be (a task still "not started" after its start date shows). Future rows and finished ones are hidden. Counts tasks and milestones apart like Overdue (`data-tasks` / `data-ms`); `prefs.currentOnly`; derived on every render and at midnight, never stored. It ANDs with the other filters, so Current plus Overdue shows the overdue rows (every overdue row is current), and it applies to the table too. A month-only date counts from the first of its month; since 0.70.0 a month-only DEADLINE is due at the end of its month for Overdue (before, such a row could never be overdue).@@ -282,6 +282,7 @@ rather than a category. - **Auto-load** saved state on page refresh - **Dynamic date range** computed from the data every render - **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+- **Working calendar** (0.76.0, issue #3) on Setup, the second card: the eleven federal holidays as checkboxes (ticked = a day off, untick one the team works) and a list of extra days off (a date field and Add day off; each one a chip with a remove button). Stored as `project.calendar = { workHolidays: [names], extraDaysOff: [YYYY-MM-DD] }` in the data file or the sidecar's `calendar` (the sidecar's wins, like the project dates). Owner only, with the same server rule as the dates. Every working-day helper reads it (`isOffDay`, `nextBiz`, `bizBetween`, `addBiz`), so Auto-schedule, Critical, Analytics, the header's workdays and the day grain's shading all follow it. One Ctrl+Z and one changelog line (`plan.calendar`) per change - **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)@@ -471,7 +472,7 @@ one person full time for each working day of it, which put a real plan of 154 ei windows at 38,000 hours. 0 is an estimate (no work), not the same as blank. A milestone is a date rather than work and carries no hours; the owner table counts milestones separately as `+nM`. Working hours LEFT is still calendar capacity: working days from-today to the plan's end, Monday to Friday, never a US federal holiday, at 8h a day.+today to the plan's end, Monday to Friday, never one of the plan's days off, at 8h a day. Seven cards: @@ -514,7 +515,14 @@ is a Saturday, so it is observed on Friday 31 December 2027. They count as non-working everywhere: Auto-schedule steps over them (forwards, since it only ever pushes later), the critical path weighs its nodes in working days without them, and every hours figure excludes them. On the chart at day grain they get a weekend-style band in a different colour, with the holiday's name in-a tooltip on the shaded date above the chart, and a `US holiday` key in the legend.+a tooltip on the shaded date above the chart, and a `Holiday` key in the legend.++**A plan can keep its own calendar (0.76.0).** `project.calendar.workHolidays` names the+holidays the plan works (Adom works Columbus Day and Veterans Day) and+`project.calendar.extraDaysOff` adds weekdays nobody works; the owner sets both on Setup.+Everything above then reads the plan's days off instead of the federal list: a worked+holiday is a normal working day and is not shaded, and an extra day off is shaded and+skipped like a holiday. Without a calendar it is the federal list, exactly as before. **Re-running Auto-schedule on a plan written before 0.45.0 may move dates around a holiday. That is intended.**@@ -703,7 +711,8 @@ Each event records the action, the item, the before and after dates, the day shi Actions are `dates`, `plan.shift`, `auto-schedule`, `milestone`, `milestone.rename`, `note`, `owner`, `status`, `desc`, `effort`, `tags`, `phase.tags`, `tag.create`, `tag.rename`,-`tag.color`, `tag.delete`, `dep.add`, `dep.remove` and `reorder`.+`tag.color`, `tag.delete`, `dep.add`, `dep.remove`, `reorder`, `plan.dates` (0.75.0) and+`plan.calendar` (0.76.0, `from`/`to` each `{ workHolidays, extraDaysOff }`). The `tag.*` actions (0.61.0) are the tag registry's own: `tag.create` (New tag; `to.color` when it was made with one) reads `Drew created the tag "someday"`, `tag.rename` reads
client-vs-server/README.md+1@@ -60,6 +60,7 @@ Saved by the adom-gantt server and shared with everyone the plan is shared with. | 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 |+| Working calendar | Owner only; which holidays the plan works and its extra days off. Every working-day count follows it | | 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 |
docs/BUILD-NOTES.md+33@@ -3,6 +3,39 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-30 (0.76.0, a working calendar per plan, issue #3)++Issue #3 (Drew, 2026-09-29): every observed federal holiday was a day off, hardcoded, and Adom+works Columbus Day and Veterans Day. On an Adom plan Auto-schedule pushed work off 12 Oct and+11 Nov, Critical under-weighted any task across them, and Analytics lost 8 hours per holiday+per task.++- `project.calendar = { workHolidays, extraDaysOff }`, exactly the issue's proposal. The+ owner sets it on Setup (a card after Project dates: eleven checkboxes, and extra days off+ as chips); the sidecar's `calendar` wins over the data file's, the way `dates` does, and+ the server keeps the owner's whatever another save carries.+- One change at the bottom of the arithmetic: `planOffDays(y)` is the federal set minus the+ worked ones plus the extra weekdays, cached per year and cleared on a change, and+ `isHoliday`, `holidayName` and `holidaysBetween` read it. Everything above them (isOffDay,+ nextBiz, bizBetween, addBiz, Auto-schedule, Critical, Analytics, the header) follows with+ no change of its own. An extra day off that is a weekend is left out of the set, so the set+ and the weekend still never overlap and bizBetween's flat subtraction stays right.+- nextBiz/prevBiz gave up after 10 closed days in a row, which was safe when the longest run+ was 3; a two-week shutdown is now possible, so the guard is a year.+- The day grain's bands and the header cell titles follow the calendar (a worked holiday is+ not shaded; an extra day says "a day off on this plan's calendar"), and the grid memo's key+ includes the calendar so a change redraws it.+- The legend key is "Holiday" now, not "US holiday", since it shades the plan's own days off.+- Docs: README's "Move the whole plan" paragraph still described the header start button+ 0.75.0 removed; gone. A sentence about keeping your place under filters had been glued+ onto the Project dates paragraph; moved back to Legend filtering.++Tests: ui `--only calendar` (19: card, save, workdays, extra day, duplicates, changelog,+the arithmetic, day-grain shading, Auto-schedule onto Columbus Day, undo/redo, reload,+export, team view read-only, back to default, a data file's calendar with junk dropped);+server (owner sets it with junk dropped, a member's save and an old tab's save keep it,+export, changelog sentences including a malformed event, empty = default).+ ## Build notes, 2026-10-30 (0.75.1, many people at once: the freeze, and a suite for it) Drew: "when we had about 6 people during the meeting all sign in, it lagged everyone's laptops
gantt.html+176−24@@ -1252,6 +1252,18 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .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); }+/* Working calendar (0.76.0) */+.cal-hols { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 2px 14px; margin: 4px 0 8px; }+.cal-hols label { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-body); padding: 3px 0; cursor: pointer; }+.cal-hols label.worked { color: var(--text2); }+.cal-hols input { accent-color: var(--accent); margin: 0; }+.cal-hols input:disabled, .cal-hols input:disabled + span { cursor: default; }+.cal-hols .cal-d { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2); margin-left: auto; white-space: nowrap; }+.cal-extra { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }+.cal-extra li { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); background: var(--holiday); border-radius: 6px; padding: 2px 4px 2px 8px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-body); }+.cal-extra button { background: none; border: 0; color: var(--text2); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 3px; }+.cal-extra button:hover { color: var(--text); }+.cal-extra button:disabled { display: none; } .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; }@@ -1731,6 +1743,17 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <div class="chat-status" id="pdStatus"></div> <ul class="pd-out" id="pdOut"></ul> </div>+ <div class="an-card su-card" id="suCal">+ <div class="su-head"><h3>Working calendar</h3><span class="su-n" id="calState"></span></div>+ <p class="an-sub">The days this plan counts as work. Weekends are always off. Untick a federal holiday your team works, and add the weekdays the whole team is off, like a shutdown. Auto-schedule, Critical, Analytics, the header's workdays and the day grain's shading all follow this. Only the plan's owner can change it.</p>+ <div class="cal-hols" id="calHols" role="group" aria-label="Federal holidays this plan takes off"></div>+ <div class="chat-row pd-row cal-add">+ <label for="calNew">Extra day off</label><input type="date" id="calNew">+ <button class="de-btn secondary" id="calAdd" type="button">Add day off</button>+ </div>+ <div class="chat-status" id="calStatus"></div>+ <ul class="cal-extra" id="calExtra"></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>@@ -2552,6 +2575,76 @@ function wireDatesCard(){ s.addEventListener('change',go); e.addEventListener('change',go); renderDatesCard(); }+// ── Working calendar card (0.76.0) ──+// Every change to the calendar comes through here: the card, undo and redo. The day+// sets are rebuilt from it, the axis is redrawn (its shading and every working-day count+// follow it), and Critical and Analytics read it again.+function setPlanCal(cal){+ planCal=cleanCal(cal)||{workHolidays:[],extraDaysOff:[]}; calInSidecar=true;+ PLAN_OFF_CACHE.clear();+ rebuildTimeline();+ renderCalCard();+ renderDatesCard();+}+function commitPlanCal(cal){+ const next=cleanCal(cal);+ if(!next||(next.workHolidays.join()===planCal.workHolidays.join()&&next.extraDaysOff.join()===planCal.extraDaysOff.join()))return;+ pushUndo({type:'pcal',cal:{workHolidays:planCal.workHolidays.slice(),extraDaysOff:planCal.extraDaysOff.slice()}});+ setPlanCal(next);+ scheduleSave('setup');+}+// The year the card shows each holiday's date in: the project's, else this one.+function calYear(){ const d=planDates.start||planDates.end; return d?+d.slice(0,4):new Date().getFullYear(); }+function renderCalCard(keepStatus){+ const box=$('calHols'), list=$('calExtra');+ if(!box||!list)return;+ const owner=document.documentElement.dataset.role!=='member', y=calYear();+ const when=n=>{ let d=''; fedHolidays(y).forEach((nm,ds)=>{ if(nm===n&&!d)d=ds; });+ if(!d)fedHolidays(y+1).forEach((nm,ds)=>{ if(nm===n&&!d)d=ds; }); return d; };+ box.innerHTML=FED_HOLIDAY_NAMES.map(n=>{+ const off=!planCal.workHolidays.includes(n), d=when(n);+ return `<label class="${off?'':'worked'}" title="${off?'A day off on this plan. Untick it if your team works it.':'Worked on this plan, a normal working day.'}"><input type="checkbox" class="cal-hol" data-hol="${esc(n)}"${off?' checked':''}${owner?'':' disabled'}><span>${esc(n)}</span><span class="cal-d">${d?fmt(d):''}</span></label>`;+ }).join('');+ list.innerHTML=planCal.extraDaysOff.map(d=>`<li data-day="${d}">${fmt(d)}${isWeekend(d)?' (a weekend)':''}<button type="button" class="cal-rm" data-day="${d}" aria-label="Remove ${fmt(d)}" title="Remove this day off"${owner?'':' disabled'}>×</button></li>`).join('');+ const nw=$('calNew'), add=$('calAdd');+ if(nw)nw.disabled=!owner; if(add)add.disabled=!owner;+ if(nw)nw.title=add.title=owner?'':"Only the plan's owner can change the working calendar.";+ const worked=planCal.workHolidays.length, extra=planCal.extraDaysOff.length;+ const cs=$('calState');+ if(cs)cs.textContent=`${11-worked} of 11 holidays off`+(extra?`, ${plural(extra,'extra day')} off`:'');+ const st=$('calStatus');+ if(st&&!keepStatus)st.textContent=extra?'':'No extra days off.';+}+function wireCalCard(){+ const box=$('calHols'), list=$('calExtra'), nw=$('calNew'), add=$('calAdd');+ if(!box||!list||!nw||!add)return;+ const owner=()=>document.documentElement.dataset.role!=='member';+ const cur=()=>({workHolidays:planCal.workHolidays.slice(),extraDaysOff:planCal.extraDaysOff.slice()});+ box.addEventListener('change',e=>{+ const cb=e.target.closest('input[data-hol]'); if(!cb||!owner())return;+ const c=cur(), n=cb.dataset.hol;+ c.workHolidays=cb.checked?c.workHolidays.filter(x=>x!==n):c.workHolidays.concat(n);+ commitPlanCal(c);+ });+ list.addEventListener('click',e=>{+ const b=e.target.closest('button.cal-rm'); if(!b||!owner())return;+ const c=cur(); c.extraDaysOff=c.extraDaysOff.filter(d=>d!==b.dataset.day);+ commitPlanCal(c);+ });+ const go=()=>{+ if(!owner())return;+ const d=cleanDay(nw.value), st=$('calStatus');+ if(!d||+d.slice(0,4)<1970||+d.slice(0,4)>2200){ if(st)st.innerHTML='<span class="bad">Pick a date first.</span>'; return; }+ if(planCal.extraDaysOff.includes(d)){ if(st)st.textContent=fmt(d)+' is already a day off.'; return; }+ const c=cur(); c.extraDaysOff=c.extraDaysOff.concat(d);+ nw.value='';+ commitPlanCal(c);+ if(st)st.textContent=isWeekend(d)?fmt(d)+' is a weekend, which is already off.':fedHolidays(+d.slice(0,4)).has(d)?fmt(d)+' is '+fedHolidays(+d.slice(0,4)).get(d)+', added as a day off of its own.':'';+ };+ add.addEventListener('click',go);+ nw.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); go(); } });+ renderCalCard();+} // Live rows that start before the project start or end after its end. function rowsOutsideDates(){ const {start,end}=planDates;@@ -2758,20 +2851,59 @@ function fedHolidays(y){ HOLIDAY_CACHE.set(y,s); return s; }-function isHoliday(ds){ return fedHolidays(+String(ds).slice(0,4)).has(String(ds)); }-function holidayName(ds){ return fedHolidays(+String(ds).slice(0,4)).get(String(ds))||''; }-// A day nobody works: the weekend or an observed federal holiday. Everything below+// ── The plan's working calendar (0.76.0) ──+//+// Every organisation keeps a different set of those holidays: Adom works Columbus Day+// and Veterans Day. So a plan says which of the eleven it works (`workHolidays`, by the+// names above) and which extra weekdays nobody works (`extraDaysOff`: a shutdown, a+// company holiday). Both live in project.calendar in the data file and are set by the+// owner on Setup; the sidecar's win, like the project dates. Absent is the old rule,+// every federal holiday off and nothing else.+const FED_HOLIDAY_NAMES=["New Year's Day",'Martin Luther King, Jr. Day',"Washington's Birthday",'Memorial Day',+ 'Juneteenth National Independence Day','Independence Day','Labor Day','Columbus Day','Veterans Day',+ 'Thanksgiving Day','Christmas Day'];+const EXTRA_DAY_OFF='Day off';+function cleanCal(v){+ if(!v||typeof v!=='object'||Array.isArray(v))return null;+ const wh=Array.isArray(v.workHolidays)?v.workHolidays:[], xd=Array.isArray(v.extraDaysOff)?v.extraDaysOff:[];+ return {workHolidays:FED_HOLIDAY_NAMES.filter(n=>wh.includes(n)),+ extraDaysOff:[...new Set(xd.map(cleanDay).filter(Boolean))].sort()};+}+let planCal=cleanCal(project&&project.calendar)||{workHolidays:[],extraDaysOff:[]};+let calInSidecar=false;+// One Map per year of the days THIS plan has off on weekdays: the federal holidays it+// does not work, then its own extra days. An extra day on a weekend is already off and+// is left out, so the set and the weekend still never overlap and bizBetween's flat+// subtraction below stays right. Cleared whenever the calendar changes.+const PLAN_OFF_CACHE=new Map();+function planOffDays(y){+ let s=PLAN_OFF_CACHE.get(y);+ if(s)return s;+ s=new Map();+ fedHolidays(y).forEach((n,d)=>{ if(!planCal.workHolidays.includes(n))s.set(d,n); });+ planCal.extraDaysOff.forEach(d=>{ if(+d.slice(0,4)===y&&!isWeekend(d)&&!s.has(d))s.set(d,EXTRA_DAY_OFF); });+ PLAN_OFF_CACHE.set(y,s);+ return s;+}+function calSig(){ return planCal.workHolidays.join(',')+'/'+planCal.extraDaysOff.join(','); }+function isHoliday(ds){ return planOffDays(+String(ds).slice(0,4)).has(String(ds)); }+function holidayName(ds){ return planOffDays(+String(ds).slice(0,4)).get(String(ds))||''; }+// What a shaded day column says when it is hovered.+function offDayTitle(ds){+ const n=holidayName(ds);+ return !n?'':n===EXTRA_DAY_OFF?"A day off on this plan's calendar, a non-working day":n+' (US federal holiday, a non-working day)';+}+// A day nobody works: the weekend or one of the plan's days off. Everything below // this line asks THIS and not isWeekend, which is what makes a holiday behave // exactly like a Saturday everywhere in the app at once. function isOffDay(ds){ return isWeekend(ds)||isHoliday(ds); } // The next working day ON or after this one, and its mirror. Both are identity on a // working day, so calling them on a date that is already fine costs one modulo and-// one Map hit. The longest run of consecutive non-working days the rules can produce-// is three (a Friday-observed holiday and the weekend after it), so the guard has-// room to spare.-function nextBiz(ds){let s=ds,g=0;while(isOffDay(s)&&g++<10)s=addDays(s,1);return s;}-function prevBiz(ds){let s=ds,g=0;while(isOffDay(s)&&g++<10)s=addDays(s,-1);return s;}+// one Map hit. A plan's extra days off can make a long run of them (a two-week+// shutdown), so the guard allows a year of closed days before it gives up.+function nextBiz(ds){let s=ds,g=0;while(isOffDay(s)&&g++<370)s=addDays(s,1);return s;}+function prevBiz(ds){let s=ds,g=0;while(isOffDay(s)&&g++<370)s=addDays(s,-1);return s;} // Working days in [a,b], counting both ends, which is how a person reads a task // that starts and ends on the same Monday: one day, not zero. Whole weeks are // five days each and only the remainder is walked, so a ten-year span costs the@@ -2785,14 +2917,14 @@ function bizBetween(a,b){ for(let i=weeks*7;i<total;i++){const w=((A+i)%7+7+4)%7;if(w!==0&&w!==6)c++;} return c-holidaysBetween(a,b); }-// Observed holidays in [a,b]. Every one of them is a weekday, so each is a day the-// weekday count above has already added and now has to give back.+// The plan's weekday days off in [a,b]. Every one of them is a weekday, so each is a+// day the weekday count above has already added and now has to give back. function holidaysBetween(a,b){ const A=dayNum(a),B=dayNum(b); if(B<A)return 0; let n=0; for(let y=+String(a).slice(0,4);y<=+String(b).slice(0,4);y++)- fedHolidays(y).forEach((_,d)=>{const k=dayNum(d);if(k>=A&&k<=B)n++;});+ planOffDays(y).forEach((_,d)=>{const k=dayNum(d);if(k>=A&&k<=B)n++;}); return n; } // n working days forward from a working day, counting `ds` as the first, so@@ -2867,7 +2999,7 @@ $('hdr').innerHTML = ` <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="5" cy="6" r="2"/><circle cx="19" cy="18" r="2"/><path d="M7 6h6a3 3 0 0 1 3 3v7"/><path d="M14 14l2 3 2-3"/></svg><span class="btn-label">Deps</span></button> </div> <div class="plan-ctl">- <button class="act-btn" id="btnAuto" title="Align dependencies by pushing later tasks out so each one starts when the work it waits on finishes, skipping weekends and US federal holidays. Never pulls work earlier, so intentional lead-time gaps are preserved and cancelled work is left out">+ <button class="act-btn" id="btnAuto" title="Align dependencies by pushing later tasks out so each one starts when the work it waits on finishes, skipping weekends and the plan's days off. Never pulls work earlier, so intentional lead-time gaps are preserved and cancelled work is left out"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h7M4 12h11M4 17h6"/><path d="M17 14l3 3-3 3"/><path d="M20 17h-6"/></svg><span class="btn-label">Auto-schedule</span></button> <button class="act-btn" id="btnCrit" title="Critical path: light up the longest run of work inside each section. A dependency that crosses sections is left out." aria-pressed="false"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18l5-5 4 3 7-8"/><circle cx="4" cy="18" r="1.6"/><circle cx="9" cy="13" r="1.6"/><circle cx="13" cy="16" r="1.6"/><circle cx="20" cy="8" r="1.6"/></svg><span class="btn-label" id="critLabel">Critical</span></button>@@ -3671,6 +3803,12 @@ function applySnapshot(sn){ setPlanDates(sn.start,sn.end); return inv; }+ // The working calendar (0.76.0), the same way.+ if(sn.type==='pcal'){+ const inv={type:'pcal',cal:{workHolidays:planCal.workHolidays.slice(),extraDaysOff:planCal.extraDaysOff.slice()}};+ setPlanCal(sn.cal);+ 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}))};@@ -4160,7 +4298,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,dates:false};+const dirtyStruct={phases:false,order:false,reg:false,dates:false,cal:false}; let dirtyAll=false, dirtyGen=0; function markDirty(evs){ (evs||[]).forEach(e=>{@@ -4171,6 +4309,7 @@ function markDirty(evs){ 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==='plan.calendar'){ dirtyStruct.cal=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; }@@ -4182,7 +4321,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=dirtyStruct.dates=false; dirtyAll=false; }+ if(snap.gen===dirtyGen){ dirtyStruct.phases=dirtyStruct.order=dirtyStruct.reg=dirtyStruct.dates=dirtyStruct.cal=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@@ -4258,6 +4397,8 @@ function getState(){ ...(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}}:{}),+ // The working calendar (0.76.0), the same way.+ ...(calInSidecar?{calendar:{workHolidays:planCal.workHolidays.slice(),extraDaysOff:planCal.extraDaysOff.slice()}}:{}), order: items.filter(r=>!isDerivedGroup(r)).map(rowOrderKey), deleted: [...deletedKeys], added: addedItems.map(r=>{const o={};@@ -4297,6 +4438,9 @@ function applyState(state){ if(state.dates&&typeof state.dates==='object'&&!Array.isArray(state.dates)){ planDates={start:cleanDay(state.dates.start),end:cleanDay(state.dates.end)}; datesInSidecar=true; }+ // The working calendar (0.76.0): absent, the data file's stands.+ { const c=cleanCal(state.calendar);+ if(c){ if(c.workHolidays.join()!==planCal.workHolidays.join()||c.extraDaysOff.join()!==planCal.extraDaysOff.join()){ planCal=c; PLAN_OFF_CACHE.clear(); } calInSidecar=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@@ -4430,7 +4574,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,dates:{start:planDates.start,end:planDates.end},order:items.map(rowOrderKey)};+ return {byId,ms,ph,reg,dates:{start:planDates.start,end:planDates.end},cal:{workHolidays:planCal.workHolidays.slice(),extraDaysOff:planCal.extraDaysOff.slice()},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@@ -4444,6 +4588,9 @@ function diffAndLog(via){ // 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)});+ // The working calendar (0.76.0): one line naming what changed.+ if(lastSnap.cal&&(lastSnap.cal.workHolidays.join()!==cur.cal.workHolidays.join()||lastSnap.cal.extraDaysOff.join()!==cur.cal.extraDaysOff.join()))+ evs.push({action:'plan.calendar',from:lastSnap.cal,to:cur.cal}); 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){@@ -4784,6 +4931,7 @@ async function pullOnce(){ } if(dirtyStruct.reg&&mine.tags)st.tags=mine.tags; if(dirtyStruct.dates&&mine.dates)st.dates=mine.dates;+ if(dirtyStruct.cal&&mine.calendar)st.calendar=mine.calendar; 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@@ -4824,7 +4972,7 @@ async function pullOnce(){ 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||dirtyStruct.dates||dirtyAll){+ if(dirtyUids.size||dirtyStruct.phases||dirtyStruct.order||dirtyStruct.reg||dirtyStruct.dates||dirtyStruct.cal||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); }@@ -4985,7 +5133,8 @@ function renderPresence(){ window.__collab={pause:stopLive,resume:startLive,people:()=>livePeople.slice(),merges:()=>liveMerges,pulls:()=>pullCount,dirty:()=>[...dirtyUids],rev:()=>baseRev, // For the collaboration suite (0.75.1): the plan as this tab holds it, row by row, to compare with the server's. rows:()=>items.filter(r=>r.uid&&!isDerivedGroup(r)).map(r=>({uid:r.uid,name:r.name||'',owner:r.owner||'',status:r.status||'',start:r.start||'',end:r.end||'',date:r.date||'',phase:r.phase,deps:(r.deps||[]).slice().sort(),desc:r.description||''})),- dates:()=>Object.assign({},planDates), busy:()=>!!pullChain||saveInFlight||!!saveTimer};+ dates:()=>Object.assign({},planDates), cal:()=>JSON.parse(JSON.stringify(planCal)),+ addBiz:(d,n)=>addBiz(d,n), workdays:(a,b)=>bizBetween(a,b), offDay:d=>isOffDay(d), axis:()=>({start:isoDay(START),col:COL}), busy:()=>!!pullChain||saveInFlight||!!saveTimer}; // One save landed. Anything the banner was waiting for is now settled, and the queued // changelog lines go out BEHIND the state they describe, never in front of it. function onSaveOk(msg){@@ -5532,8 +5681,8 @@ function rebuildLegend(){ const holL=document.createElement('div'); function buildLegendHoliday(){ holL.className='legend-item static gantt-only';-holL.innerHTML='<div class="legend-dot" style="background:var(--holiday);box-shadow:inset 0 0 0 1px var(--border)"></div>US holiday';-holL.title='US federal holidays are shaded like the weekend and count as non-working days everywhere in the plan. Hover a shaded date above the chart to see which one it is.';+holL.innerHTML='<div class="legend-dot" style="background:var(--holiday);box-shadow:inset 0 0 0 1px var(--border)"></div>Holiday';+holL.title="The plan's days off (Setup, Working calendar): the US federal holidays it takes and any extra days. They are shaded like the weekend and count as non-working days everywhere in the plan. Hover a shaded date above the chart to see which one it is."; legendEl.appendChild(holL); } function holidayLegendGrain(dayGrain){ holL.style.display=dayGrain?'':'none'; }@@ -5602,7 +5751,7 @@ gridLayer.style.cssText='position:absolute;inset:0;pointer-events:none;';bars.ap // The axis and the grid are a pure function of these, so an unchanged axis is not rebuilt // (0.75.1): rebuildTimeline and relayout each rebuilt both, twice per live merge, which // threw away and re-laid out every header cell and grid line for nothing.-function axisKey(){ return [START&&START.getTime(),UNITS,COL,G(),isoDay(new Date())].join('|'); }+function axisKey(){ return [START&&START.getTime(),UNITS,COL,G(),isoDay(new Date()),calSig()].join('|'); } let headersKey='', gridKey=''; function buildTimeHeaders(){ const k=axisKey(); if(k===headersKey&&qRow.firstChild)return; headersKey=k;@@ -5625,8 +5774,8 @@ function buildTimeHeaders(){ // band is under the bars and takes no pointer. One hover answers "why is that // column shaded, and why did Auto-schedule step over it". if(g==='day'){- const hn=holidayName(isoDay(d));- if(hn){c.classList.add('holiday');c.title=hn+' (US federal holiday, a non-working day)';}+ const hn=offDayTitle(isoDay(d));+ if(hn){c.classList.add('holiday');c.title=hn;} } c.textContent=botLabel(d); mRow.appendChild(c);@@ -5656,7 +5805,7 @@ function buildGridLines(){ // The band takes no pointer, so this title is for anything reading the DOM // rather than for a hover; the header cell above it carries the one a reader // can actually reach.- if(hn)l.title=hn+' (US federal holiday, a non-working day)';+ if(hn)l.title=offDayTitle(ds); gridLayer.appendChild(l);gridEls.push(l); } for(let i=0;i<=UNITS;i++){@@ -8077,6 +8226,7 @@ function openInline(row,i,f,held){ // Remove, deleted whichever section had moved into that slot and merged its rows. function renderSections(keep){ renderDatesCard();+ renderCalCard(); 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.@@ -13298,6 +13448,7 @@ function applyRole(){ document.documentElement.setAttribute('data-crit',critOn()?prefs.critMode:'off'); if(typeof refreshCritical==='function')refreshCritical(); renderDatesCard();+ renderCalCard(); } async function loadRole(){ if(!SNAPSHOT){ try{ const r=await fetch('access'); if(r.ok){ const a=await r.json(); viewerIsOwner=!!a.canChange; } }catch(e){} }@@ -13378,7 +13529,7 @@ function setView(v){ // exactly the kind of edit that would have left one of them behind, so there is one // string now and the markup is checked against it below. const AUTO_TITLE='Align dependencies by pushing later tasks out so each one starts when '- +'the work it waits on finishes, skipping weekends and US federal holidays. Never pulls '+ +'the work it waits on finishes, skipping weekends and the plan\'s days off. Never pulls ' +'work earlier, so intentional lead-time gaps are preserved and cancelled work is left out'; function syncPlanCtl(){ const has=planHasDeps();@@ -13440,6 +13591,7 @@ startLive(); wireChatCard(); wireShareCard(); wireDatesCard();+wireCalCard(); await loadRole(); openRowLink(); window.addEventListener('hashchange',openRowLink);
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.75.1",+ "version": "0.76.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.75.1",+ "version": "0.76.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+42@@ -984,6 +984,19 @@ function describeEvent(e) { ['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}`; }+ // The working calendar (0.76.0), set by the owner on Setup.+ if (action === 'plan.calendar') {+ const list = (side, k) => { const v = fld(side, k, []); return Array.isArray(v) ? v.filter(x => typeof x === 'string') : []; };+ const parts = [];+ const wf = list('from', 'workHolidays'), wt = list('to', 'workHolidays');+ wt.filter(n => !wf.includes(n)).forEach(n => parts.push(`works ${n}`));+ wf.filter(n => !wt.includes(n)).forEach(n => parts.push(`takes ${n} off`));+ const xf = list('from', 'extraDaysOff'), xt = list('to', 'extraDaysOff');+ const added = xt.filter(d => !xf.includes(d)), gone = xf.filter(d => !xt.includes(d));+ if (added.length) parts.push(`added ${added.map(fmtDate).join(', ')} as ${added.length === 1 ? 'a day' : 'days'} off`);+ if (gone.length) parts.push(`made ${gone.map(fmtDate).join(', ')} ${gone.length === 1 ? 'a working day' : 'working days'} again`);+ return `${who} changed the working calendar: ${parts.join('; ') || 'no change'}${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}`;@@ -1365,6 +1378,22 @@ function normalizeDates(v) { 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) }; }+// The working calendar (0.76.0): { workHolidays: [federal holiday names this plan works],+// extraDaysOff: [YYYY-MM-DD weekdays nobody works] }. Unknown names and malformed days+// are dropped, so a bad value can never reach the merged plan.+const FED_HOLIDAY_NAMES = ["New Year's Day", 'Martin Luther King, Jr. Day', "Washington's Birthday", 'Memorial Day',+ 'Juneteenth National Independence Day', 'Independence Day', 'Labor Day', 'Columbus Day', 'Veterans Day',+ 'Thanksgiving Day', 'Christmas Day'];+function normalizeCalendar(v) {+ if (!v || typeof v !== 'object' || Array.isArray(v)) return undefined;+ const wh = Array.isArray(v.workHolidays) ? v.workHolidays : [];+ const xd = Array.isArray(v.extraDaysOff) ? v.extraDaysOff : [];+ const day = x => (typeof x === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(x) && !isNaN(Date.parse(x + 'T00:00:00Z'))) ? x : '';+ return {+ workHolidays: FED_HOLIDAY_NAMES.filter(n => wh.includes(n)),+ extraDaysOff: [...new Set(xd.map(day).filter(Boolean))].sort(),+ };+} function tidyStateTags(st) { if (!st || typeof st !== 'object' || Array.isArray(st)) return st; const side = o => {@@ -1924,6 +1953,13 @@ function mergedData() { 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]; }); }+ // The working calendar (0.76.0), the same way; an empty one is the default and says nothing.+ const cal = normalizeCalendar(state.calendar);+ if (cal) {+ if (!data.project || typeof data.project !== 'object') data.project = {};+ if (cal.workHolidays.length || cal.extraDaysOff.length) data.project.calendar = cal;+ else delete data.project.calendar;+ } 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.@@ -2808,6 +2844,12 @@ const server = http.createServer(async (req, res) => { if (now && mayDate) store.dates = now; else if (was) store.dates = was; else delete store.dates;+ // The working calendar (0.76.0) is the owner's in exactly the same way.+ let wasCal; try { const old = loadState(); wasCal = old && normalizeCalendar(old.calendar); } catch (e) {}+ const nowCal = normalizeCalendar(store.calendar);+ if (nowCal && mayDate) store.calendar = nowCal;+ else if (wasCal) store.calendar = wasCal;+ else delete store.calendar; } store.stateRev = cur + 1; delete store.baseRev;
test/server_test.mjs+20@@ -609,6 +609,26 @@ async function main() { 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 working calendar (0.76.0, issue #3) is the owner's in the same way.+ const sCal = async () => ((await as(drew, '/state')).j || {}).calendar;+ r = await asPost(drew, '/save', Object.assign({}, plain, { dates: { start: '2026-09-14', end: '2026-12-18' }, calendar: { workHolidays: ['Veterans Day', 'Columbus Day', 'Festivus'], extraDaysOff: ['2026-12-24', 'later', '2026-11-27', '2026-12-24'] } }));+ let sc = await sCal();+ ok(r.status === 200 && sc && sc.workHolidays.join() === 'Columbus Day,Veterans Day' && sc.extraDaysOff.join() === '2026-11-27,2026-12-24', 'the owner sets the working calendar; unknown names, bad days and repeats are dropped', sc);+ r = await asPost(ana, '/save', Object.assign({}, plain, { calendar: { workHolidays: ['Christmas Day'], extraDaysOff: [] } }));+ sc = await sCal();+ ok(r.status === 200 && sc && sc.workHolidays.join() === 'Columbus Day,Veterans Day' && sc.extraDaysOff.length === 2, 'a member\'s save keeps the owner\'s calendar, whatever it carries', sc);+ r = await asPost(ana, '/save', plain);+ sc = await sCal();+ ok(r.status === 200 && sc && sc.workHolidays.length === 2, 'and so does a save with no calendar at all (a tab older than 0.76.0)', sc);+ const exc = (await as(drew, '/export/json')).j;+ ok(exc && exc.project && exc.project.calendar && exc.project.calendar.workHolidays.join() === 'Columbus Day,Veterans Day' && exc.project.calendar.extraDaysOff.join() === '2026-11-27,2026-12-24', 'the merged plan carries it as project.calendar', exc && exc.project);+ await asPost(drew, '/log', { events: [{ action: 'plan.calendar', from: { workHolidays: [], extraDaysOff: [] }, to: { workHolidays: ['Columbus Day'], extraDaysOff: ['2026-11-27'] } }] });+ await asPost(drew, '/log', { events: [{ action: 'plan.calendar', from: { workHolidays: ['Columbus Day'] }, to: 'junk' }] });+ const calMd = (await as(drew, '/changelog?format=md')).t;+ ok(/changed the working calendar: works Columbus Day; added 27-Nov-2026 as a day off/.test(calMd) && /changed the working calendar: takes Columbus Day off/.test(calMd), 'the changelog says what changed, and a malformed event still reads', calMd.slice(-400));+ r = await asPost(drew, '/save', Object.assign({}, plain, { dates: { start: '2026-09-14', end: '2026-12-18' }, calendar: { workHolidays: [], extraDaysOff: [] } }));+ const exd = (await as(drew, '/export/json')).j;+ ok(r.status === 200 && exd && exd.project && !('calendar' in exd.project), 'an empty calendar is the default, and the merged plan says nothing', exd && exd.project); // 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+101@@ -902,6 +902,7 @@ async function main() { if (wantAny('collab')) await ownServers(collabFlow, browser); if (wantAny('notify')) await ownServers(notifyFlow, browser); if (wantAny('share')) await ownServers(shareFlow, browser);+ if (wantAny('calendar')) await ownServers(calendarFlow, browser); if (wantAny('provenance')) await ownServers(provFlow, browser); if (wantAny('pick by mouse')) await ownServers(pickFlow, browser); if (wantAny('type ahead')) await ownServers(typeAheadFlow, browser);@@ -1208,6 +1209,10 @@ const MANIFEST = [ { 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: '.cal-hol', label: 'setup: a federal holiday the plan takes off', terminal: true, by: 'calendar' },+ { sel: '#calNew', label: 'setup: an extra day off', terminal: true, by: 'calendar' },+ { sel: '#calAdd', label: 'setup: add the extra day off', terminal: true, by: 'calendar' },+ { sel: '.cal-rm', label: 'setup: remove an extra day off', terminal: true, by: 'calendar', 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 },@@ -2668,6 +2673,9 @@ async function tagsFlow(browser) { const o0 = await tagOrder(); check('every tag row has a drag grip and no arrow or Rename button', o0.length >= 3 && await page.$$eval('#suTags .tag-row', rs => rs.every(r => r.querySelector('.sec-grip') && !r.querySelector('[data-a="up"],[data-a="down"],[data-a="ren"]'))), JSON.stringify(o0)); const lastKey = o0[o0.length - 1];+ // The card sits below Project dates and the Working calendar (0.76.0), so it is brought+ // into view first: a press below the window lands on nothing.+ await page.$eval('#suTags', e => e.scrollIntoView({ block: 'start' })); await settle(page, 150); const from = await page.$eval(`#suTags .tag-row[data-tag="${lastKey}"] .tag-n`, e => { const r = e.getBoundingClientRect(); return { x: r.left + 4, y: r.top + r.height / 2 }; }); const to = await page.$eval('#suTags .tag-row', e => { const r = e.getBoundingClientRect(); return { x: r.left + 60, y: r.top + 2 }; }); await page.mouse.move(from.x, from.y); await page.mouse.down();@@ -3599,6 +3607,99 @@ async function provFlow(browser) { // The Sharing card (0.68.0). Under --no-auth the page is the operator, so it may change the // setting; the card says that sign-in is off. The gate itself is proven server-side // against a fake Adom identity service (test/server_test.mjs).+// 0.76.0: the plan's working calendar (issue #3). Adom works Columbus Day and Veterans+// Day; a plan says so on Setup, and every working-day count, Auto-schedule and the day+// grain's shading follow it. Extra days off work the other way.+async function calendarFlow(browser) {+ section = 'calendar';+ const X = await startServer({ project: { title: 'Calendar Test', appName: 'calendar-test' },+ phases: [{ name: 'Build', start: '2026-10', end: '2026-12' }],+ items: [+ { type: 'task', id: 'a', name: 'Design', start: '2026-10-05', end: '2026-10-09', phase: 0, owner: 'Drew' },+ // Starts before its predecessor ends, so Auto-schedule moves it to start on Friday+ // 9 October; its second workday is Columbus Day only when the plan works it.+ { type: 'task', id: 'b', name: 'Build', start: '2026-10-08', end: '2026-10-09', phase: 0, owner: 'Drew', deps: ['a'] },+ { type: 'task', id: 'c', name: 'Ship', start: '2026-11-09', end: '2026-11-30', phase: 0, owner: 'Drew' },+ ] }, 'calendar');+ const page = await openPage(browser, X.url);+ const calSaved = async () => { await sleep(1000); return ((await (await fetch(X.url + 'state')).json()).calendar) || null; };+ const card = () => page.evaluate(() => ({ state: document.getElementById('calState').textContent,+ boxes: [...document.querySelectorAll('#calHols input')].map(i => ({ n: i.dataset.hol, on: i.checked, dis: i.disabled })),+ extra: [...document.querySelectorAll('#calExtra li')].map(l => l.dataset.day), add: document.getElementById('calAdd').disabled }));+ const durWd = async () => +((await readouts(page)).dur.match(/(\d+) workday/) || [])[1];+ const DEF = workdaysO('2026-10-05', '2026-11-30');+ check('by default every federal holiday is a day off, and the readout counts without them', await durWd() === DEF, String(await durWd()));+ await view(page, 'setup');+ let c = await card();+ check('the card lists the eleven federal holidays, all ticked as days off', c.boxes.length === 11 && c.boxes.every(b => b.on && !b.dis) && c.state === '11 of 11 holidays off' && !c.extra.length, JSON.stringify(c));+ for (const n of ['Columbus Day', 'Veterans Day']) { await page.click(`#calHols input[data-hol="${n}"]`); await settle(page, 150); }+ let saved = await calSaved();+ c = await card();+ check('unticking Columbus Day and Veterans Day saves them as worked', saved && saved.workHolidays.join() === 'Columbus Day,Veterans Day' && !saved.extraDaysOff.length && c.state === '9 of 11 holidays off', JSON.stringify([saved, c.state]));+ check('and the plan has two more workdays (12 Oct and 11 Nov 2026)', await durWd() === DEF + 2, String(await durWd()));+ await page.click('#calNew', { offset: { x: 14, y: 12 } }); await page.keyboard.type('11272026', { delay: 15 }); await settle(page, 100);+ await page.click('#calAdd'); await settle(page, 200);+ saved = await calSaved();+ c = await card();+ check('an extra day off (27 Nov) is added, saved and listed', saved && saved.extraDaysOff.join() === '2026-11-27' && c.extra.join() === '2026-11-27' && c.state === '9 of 11 holidays off, 1 extra day off', JSON.stringify([saved, c]));+ check('and the plan loses that workday again', await durWd() === DEF + 1, String(await durWd()));+ await page.click('#calNew', { offset: { x: 14, y: 12 } }); await page.keyboard.type('11272026', { delay: 15 }); await page.keyboard.press('Enter'); await settle(page, 200);+ check('adding the same day twice says so and changes nothing', /already a day off/.test(await page.$eval('#calStatus', e => e.textContent)) && (await card()).extra.length === 1);+ const md = await (await fetch(X.url + 'changelog?format=md')).text();+ check('the changelog names each change', /changed the working calendar: works Columbus Day/.test(md) && /works Veterans Day/.test(md) && /added 27-Nov-2026 as a day off/.test(md), md.split('\n').filter(l => /calendar/.test(l)).join(' | '));+ // The arithmetic itself, as the scheduler sees it.+ const arith = await page.evaluate(() => { const C = window.__collab; return { after: C.addBiz('2026-10-09', 2), wd: C.workdays('2026-11-23', '2026-11-30'), off: C.offDay('2026-10-12'), vet: C.offDay('2026-11-11'), xmas: C.offDay('2026-12-25') }; });+ check('the working-day arithmetic follows it: Fri 9 Oct + 1 workday is Mon 12 Oct; Thanksgiving week has 4 workdays', arith.after === '2026-10-12' && arith.wd === 4 && !arith.off && !arith.vet && arith.xmas, JSON.stringify(arith));+ await view(page, 'gantt');+ await setGrain(page, 'day');+ const bands = await page.evaluate(() => { const ax = window.__collab.axis(), idx = ds => Math.round((Date.parse(ds + 'T00:00:00Z') - Date.parse(ax.start + 'T00:00:00Z')) / 86400000);+ const at = ds => [...document.querySelectorAll('.gl-band')].find(b => Math.round(parseFloat(b.style.left) / ax.col) === idx(ds));+ const cell = ds => document.querySelectorAll('.m-row > .m-cell')[idx(ds)];+ return { col: !!at('2026-10-12'), thx: at('2026-11-26') && at('2026-11-26').className, xtra: at('2026-11-27') && at('2026-11-27').className, xtip: cell('2026-11-27') && cell('2026-11-27').title, ctip: cell('2026-10-12') && cell('2026-10-12').title }; });+ check('on the day grain a worked holiday is not shaded, Thanksgiving and the extra day are', !bands.col && /holiday/.test(bands.thx || '') && /holiday/.test(bands.xtra || '') && /day off on this plan/.test(bands.xtip || '') && !bands.ctip, JSON.stringify(bands));+ await setGrain(page, 'week');+ await page.click('#btnAuto');+ await page.waitForFunction(() => !document.querySelector('#btnAuto.btn-busy'), { timeout: 15000 }); await settle(page, 400);+ const b = (await savedPlan(X.url)).items.find(x => x.id === 'b');+ // A successor starts on the day its predecessor ends (Fri 9 Oct) and keeps its two+ // workdays, so it ends on Columbus Day; with the holiday off it would end on Tuesday.+ check('Auto-schedule ends the two-day successor on Columbus Day, because this plan works it', b && b.start === '2026-10-09' && b.end === '2026-10-12', JSON.stringify(b));+ // Undo is the tab's own: the extra day off, then Auto-schedule, are each one Ctrl+Z.+ 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);+ saved = await calSaved();+ check('Ctrl+Z takes the extra day off back (after undoing Auto-schedule)', saved && !saved.extraDaysOff.length && saved.workHolidays.length === 2 && await durWd() === DEF + 2, JSON.stringify(saved));+ await page.keyboard.down('Control'); await page.keyboard.down('Shift'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Shift'); await page.keyboard.up('Control'); await settle(page, 300);+ saved = await calSaved();+ check('Ctrl+Shift+Z puts it back', saved && saved.extraDaysOff.join() === '2026-11-27' && await durWd() === DEF + 1, JSON.stringify(saved));+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);+ await view(page, 'setup');+ c = await card();+ check('the calendar survives a reload', c.state === '9 of 11 holidays off, 1 extra day off' && await durWd() === DEF + 1, JSON.stringify(c));+ const ex = await (await fetch(X.url + 'export/json')).json();+ check('the merged plan carries it as project.calendar', ex.project && ex.project.calendar && ex.project.calendar.workHolidays.join() === 'Columbus Day,Veterans Day' && ex.project.calendar.extraDaysOff.join() === '2026-11-27', JSON.stringify(ex.project && ex.project.calendar));+ await page.click('#sharePreview'); await settle(page, 200);+ await view(page, 'setup');+ c = await card();+ check('in the team view the calendar is shown but cannot be changed', c.boxes.every(x => x.dis) && c.add && await page.$$eval('#calExtra button', bs => bs.every(x => x.disabled)), JSON.stringify(c));+ await page.click('#previewBack'); await settle(page, 200);+ await view(page, 'setup');+ await page.click('#calExtra button.cal-rm'); await settle(page, 200);+ for (const n of ['Columbus Day', 'Veterans Day']) { await page.click(`#calHols input[data-hol="${n}"]`); await settle(page, 150); }+ saved = await calSaved();+ const ex2 = await (await fetch(X.url + 'export/json')).json();+ check('back to the default: nothing worked, no extra days, and project.calendar is gone', saved && !saved.workHolidays.length && !saved.extraDaysOff.length && !('calendar' in (ex2.project || {})) && await durWd() === DEF, JSON.stringify(saved));+ await page.close();+ // A plan whose data file carries the calendar starts from it.+ const Y = await startServer({ project: { title: 'Cal File', calendar: { workHolidays: ['Columbus Day', 'Not A Holiday'], extraDaysOff: ['2026-10-16', 'soon'] } },+ phases: [{ name: 'Build', start: '2026-10', end: '2026-10' }],+ items: [{ type: 'task', id: 'a', name: 'One', start: '2026-10-05', end: '2026-10-23', phase: 0 }] }, 'calfile');+ const p2 = await openPage(browser, Y.url);+ const f = await p2.evaluate(() => ({ cal: window.__collab.cal(), wd: window.__collab.workdays('2026-10-05', '2026-10-23') }));+ check('a data file\'s project.calendar is read, with unknown names and bad dates dropped', f.cal.workHolidays.join() === 'Columbus Day' && f.cal.extraDaysOff.join() === '2026-10-16' && f.wd === 14, JSON.stringify(f));+ await p2.close();+} async function shareFlow(browser) { section = 'share'; const T = (id, name, ph) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew' });