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.60.0
12 files changed
+1045−103
README.md+20−5@@ -10,9 +10,9 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau 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. -**Analytics.** A third lens, next to Gantt and Table. Total effort for the plan in hours (working days times eight, a forty-hour week), the working hours LEFT between today and the project end, the AVERAGE hours per assigned person, then one row per person carrying their hours, share and item counts alongside their five status counts, the unassigned bucket included. Status counts overall, per-section totals with the range each section actually covers, milestones with days from today, dependency stats with a cycle warning, and everything overdue. Read-only, whole-plan, and re-derived from the same rows every time you switch to it.+**Analytics.** A third lens, next to Gantt and Table. Total effort for the plan in hours (working days times eight, a forty-hour week), the working hours LEFT between today and the project end, the AVERAGE hours per assigned person, then one row per person carrying their hours, share and item counts alongside their five status counts, the unassigned bucket included. Status counts overall, per-section totals with the range each section actually covers, per-tag totals when the plan has tags (since 0.60.0: items, hours and share, by each row's own tags plus its section's, with a "no tag" line), milestones with days from today, dependency stats with a cycle warning, and everything overdue. Read-only, whole-plan, and re-derived from the same rows every time you switch to it. -**Setup.** A fourth lens, since 0.58.0, for the plan's structure rather than its work. It is where the sections are managed: add one, rename it and set its aside, recolour it, move it up or down, or remove it, each row showing how many rows the section holds and the dates they span. Since 0.59.0 it is also where sections are tagged, one by one or from their name prefixes in one click, and where a tag is renamed or removed everywhere. It replaces the Sections button that used to sit in the header and open a modal. Every change is still one Ctrl+Z and one changelog line.+**Setup.** A fourth lens, since 0.58.0, for the plan's structure rather than its work. It is where the sections are managed: add one, rename it and set its aside, recolour it, move it up or down, or remove it, each row showing how many rows the section holds and the dates they span. Since 0.59.0 it is also where sections are tagged, one by one or from their name prefixes in one click, and where a tag is renamed or removed everywhere (since 0.60.0, on rows as well as sections). It replaces the Sections button that used to sit in the header and open a modal. Every change is still one Ctrl+Z and one changelog line. **Four lenses over one plan.** A **Gantt / Table / Analytics / Setup** toggle in the header. The chart answers "when". The table answers "whose, and by when": every task and every milestone as a row with Owner, Start, Deadline, Status and Notes, sortable on any column, filtered to one person with the owner dropdown, and editable in place. An edit made in the table is already true on the chart when you toggle back, because there is only one set of items underneath. The choice of lens persists, and `project.view.mode` can pin it for a plan that is an assignment list more than a timeline. @@ -152,11 +152,13 @@ Create a JSON file with three sections: **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, and+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 writes no `tags` key at all when there are none, so a plan without tags round-trips unchanged. A tag change is saved in the sidecar and logged as `tags` (a row) or `phase.tags` (a section). Section tags travel in the JSON export and the HTML snapshot;-a CSV is rows, so its `tags` column carries row tags only.+a CSV is rows, so its `tags` column carries row tags only. That makes a CSV export and+re-import lossy for tags: the section tags, and every tag a row only inherits from its+section, do not come back. Use JSON (or the HTML snapshot) to keep them. Since 0.59.0 section tags are edited on the **Setup** tab: a tags line under each section (type a tag, Enter or a comma to add it, the x on a chip to remove it, suggestions from@@ -168,6 +170,19 @@ it: choose one or more tags to see only the sections carrying any of them, in th and the table. Every tag edit is one Ctrl+Z; the filter is your view only and survives a refresh. +Since 0.60.0 a task or a milestone can carry tags of its own. The full item editor and+the New panel have a tags field (the same chip box; the New panel remembers the last+row's tags like its other fields), the table has a **Tags** column you can click to edit+and sort by, and the batch panel adds a tag to every selected row or takes one off all of+them. Copy and paste carry a row's tags. A row's EFFECTIVE tags are its own plus its+section's, and that is what the legend filters on: every row of a chosen section is+shown, a row tagged "long-lead" in an untagged section is shown by "long-lead" (with its+section header, and without its untagged neighbours), and a row that matches neither way+is hidden. The Tags card on Setup counts rows as well as sections, and renaming or+removing a tag there reaches the rows too. In the chart a row's own tags follow its owner+as quiet `#tag` text, which gives way before the name ever does, and they are on its+tooltip.+ Omit `color` on a phase and it follows the Adom brand ramp, which adapts between dark and print. See [SKILL.md](SKILL.md) for the full field reference and the ramp values. ## Sections are phases@@ -376,7 +391,7 @@ table, the item editor or the batch editor are taken exactly as typed. | 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 |-| Click a # tag chip in the legend | Show only the sections carrying that tag (or any of the chosen tags), chart and table. Clear tags undoes it. Your view only |+| Click a # tag chip in the legend | Show only the rows carrying that tag (or any of the chosen tags), on themselves or through their section, chart and table. Clear tags undoes it. Your view only | | Hover "today" | The current date | | Click a table cell | Edit owner, start, deadline, status or notes in place | | Click a table header | Sort by that column (deadline ascending by default) |
SKILL.md+35−8@@ -136,7 +136,7 @@ hero image that invited the click. | uid | string | assigned | The phase's permanent id, from the same counter rows use. The server writes it | | lead | string | no | A short aside shown beside the section name, e.g. "the long pole (Adonis)". Secondary text, never the name | | color | hex string | no | Overrides the brand ramp for this phase. An explicit colour is theme-INVARIANT by design: it is the colour someone chose, so it is the same in light, dark and print. Omit it to stay theme-aware, see Phase colors below |-| tags | string[] | no | Short labels for the section, e.g. `["Tech"]`. Trimmed, de-duplicated without regard to case (the first spelling wins), each capped at 32 characters and the list at 12; anything that is not a string is dropped. Absent means none, and the server never writes an empty list. No colour of their own |+| tags | string[] | no | Short labels for the section, e.g. `["Tech"]`. Trimmed, de-duplicated without regard to case (the first spelling wins), each capped at 32 characters and the list at 12; anything that is not a string is dropped. A `;` inside a tag becomes a space (0.60.0), since `;` separates tags in the CSV. Absent means none, and the server never writes an empty list. No colour of their own | | start | YYYY-MM | yes | Phase start month | | end | YYYY-MM | yes | Phase end month | @@ -195,7 +195,8 @@ items. `tags` on phases and items, and every import normalises them. `/export/csv` has a `tags` column after `deps`, joined with `;` the same way, and `/import/csv` splits it back; a CSV without the column imports as before. A CSV is rows, so section tags are-not in it. A Jira CSV's `Labels` column becomes each row's tags; MS Project carries none.+not in it. **So a CSV export and re-import loses the section tags, and with them every+tag a row only inherits**; JSON and the HTML snapshot are the lossless formats. A Jira CSV's `Labels` column becomes each row's tags; MS Project carries none. A plan with no tags gains no `tags` keys anywhere, so an old file round-trips unchanged. ### Phase colors@@ -228,7 +229,7 @@ rather than a category. ## Features -- **New** button (or Shift+N): create a task or milestone. After a successful create, the next New panel prefills EVERY field from the one you just made (type, phase, owner, status, start, deadline or milestone date, notes, provenance label). Only the name starts empty, so a run of tasks for the same person in the same lane in the same week is names and nothing else. A provenance left at its default is not remembered as a value, so it keeps following the signed-in identity. It is in-memory for the page session only: a refresh clears it, and it is never written to localStorage, the preferences or the sidecar, so it is never carried to anyone else. Only a create that produced a row updates it: Cancel, Escape and a validation-rejected create all leave it untouched. Created rows live in the sidecar as `added: [...]`, keyed in a `#new-` namespace that cannot collide with the data file's `#row<n>`, and are merged into every export+- **New** button (or Shift+N): create a task or milestone. After a successful create, the next New panel prefills EVERY field from the one you just made (type, phase, owner, status, start, deadline or milestone date, notes, provenance label, and since 0.60.0 its tags). Only the name starts empty, so a run of tasks for the same person in the same lane in the same week is names and nothing else. A provenance left at its default is not remembered as a value, so it keeps following the signed-in identity. It is in-memory for the page session only: a refresh clears it, and it is never written to localStorage, the preferences or the sidecar, so it is never carried to anyone else. Only a create that produced a row updates it: Cancel, Escape and a validation-rejected create all leave it untouched. Created rows live in the sidecar as `added: [...]`, keyed in a `#new-` namespace that cannot collide with the data file's `#row<n>`, and are merged into every export - **Text size** A-minus/A+ in the header: seven steps from 85% to 150%, applies to the chart and the table, persists in `localStorage` as `prefs.fs`. Row and bar geometry scale with the type. **It survives a refresh in the live app AND in an exported HTML snapshot** (fixed in 0.57.0). Text size is a READER preference, so it is the one pref a snapshot reads back from and writes to the reader's storage; everything else in a snapshot still opens at the author's view, and a snapshot now writes only the size, so it can no longer overwrite the grain, lens or collapse set a reader has stored on the same origin. Open tabs of the app follow a size change made in another tab live. **Every pref is saved merge-on-write**: a save writes only the keys this tab changed since it last read or wrote them, into a fresh read of storage, so a second tab saving its theme can no longer reopen the lanes you collapsed in the first, or reset your text size **On a page the host sandboxes (0.58.0)**, such as a snapshot uploaded to wiki.adom.inc and served under `Content-Security-Policy: sandbox` with no allow-same-origin, the page has an opaque origin and every reload starts with empty storage. A snapshot therefore also keeps the size in the URL fragment (`#fs=1.2`, written with replaceState), which a reload, a bookmark and a shared link all keep. Storage still wins where it works, so a reader's own size is not replaced by a link carrying somebody else's; the fragment answers only where storage has nothing. - **Project Duration and Time Remaining** in the header, stacked, beside the lens switch (0.57.0, replacing the old "25 weeks" axis-cell count, which changed with the grain and counted the axis padding). Both come from the plan's own dates and never from the grain: Duration runs from the earliest task start or milestone date to the latest task end or milestone date, both days counted, and Remaining runs from today to that last date, today counted. **Cancelled rows are left out**, as Auto-schedule, the critical path and the hours totals leave them out, and the tooltip says how many were; if every dated row is cancelled both read `none` and the tooltip says why. (The start button still counts cancelled rows, because it moves the whole plan. The Analytics "calendar span" card still includes them; it was not changed.) Format: days under two weeks, `16w 4d` style under half a year, then months and years to one decimal. The tooltips give the exact calendar and working-day counts and both dates. Remaining reads `ended` once the last date is past; before the first date it still counts from today, so it can be longer than Duration, and a small `not started` note sits on the readout itself to say why. Both read `none` for a plan with no dated rows. They update on every edit that can move a date (drag, editor, table edit, start shift, auto-schedule, create, delete, paste, undo, redo) and at midnight on a tab left open. The header FITS ITSELF to its content rather than to pixel breakpoints (`fitHeader`): it measures the row and, only while the content is wider than the window, sheds in order the version, the button words and the long labels (Duration, Remaining). If that still does not fit, it wraps onto a second row with the long labels back, and with the button words back too whenever they cost no extra row (they stay off on a phone, where they would). The readouts never shrink, so they are whole at every width; the suite sweeps 360 to 2000px with and without an appName at 100% and 150% text to hold that - **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@@ -236,7 +237,7 @@ rather than a category. - **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 - **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). When the plan's sections carry tags, the legend leads with a chip per tag (`# Tech 7`: the tag and how many sections carry it) 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 shows only the sections carrying ANY of them, headers and rows, 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+- **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 - **Collapse all / Expand all** leads the legend (0.57.0), in the chart and the table. One press collapses every section to its header; when every section with rows is already collapsed it expands them all. A mixed state collapses. It is the same view state as the per-lane chevrons (`prefs.collapsed`), so it persists across a refresh, follows the chevrons (open the last lane by hand and it reads Collapse all again), and the table agrees with the chart. With everything collapsed the table says so rather than "no items match" - **Click a bar** to open the date editor with start/end pickers and dependency management. It is a compact 256px square (`--ed-sq`, one token for both dimensions): the row's name across the top, the two dates, the note, the dependency list and the buttons, at desktop density, so it covers as little of the chart as it can. The dependency list is the only part that flexes and the only part that scrolls; the buttons are pinned to the bottom edge. The milestone panel and the batch panel take the same compact metrics; the full **Edit item** panel, which is filled in rather than glanced at, keeps the full ones - **Drag bar edges** to resize (changes start or end date). Every timeline drag snaps to the grid the axis is currently drawing: day boundaries at the day cell size, week starts at weeks, month, quarter and year firsts at the coarser ones. Resizing the start leaves the end where it is and the mirror for the end; dragging the bar body snaps the START and preserves the DURATION, so the end lands at start plus the same number of days. The preview snaps live, line to line, and is exactly what gets committed. The shortest bar a resize can produce is the first grid line past the fixed edge, so it is never zero or negative. Milestones snap the same way. Dates set explicitly (table cells, the item modal, the batch editor, the project-start shift) are taken exactly as given and are never rounded@@ -301,6 +302,7 @@ named in its own column. | Deadline | `end` | `date` | | Status | `status`, or "none" | `status`, or "none" | | Notes | `description`, truncated | same |+| Tags | `tags`, the row's own, as pills, then its section's, dashed (0.60.0) | same | | Provenance | `provenance` as a chip, linked when the url is http(s) | same | **Full editor.** Hovering a task or milestone row in the chart's label column reveals an Edit button that opens every field. Group rows have a collapse chevron instead. The table intentionally has no modal: its cells are all editable in place.@@ -324,7 +326,7 @@ in both directions, so an unassigned row never displaces what you are looking fo **Filtering.** The owner dropdown is built from the distinct `owner` values in the data, plus "All owners" and "Unassigned"; it answers "what is mine". There is also a-status dropdown and a free-text filter over name, owner, phase, notes and status. The+status dropdown and a free-text filter over name, owner, phase, tags (own and section), notes and status. The legend's phase and milestone toggles apply to the table as well, so the two lenses never disagree about what is in scope. The owner filter and the sort survive a reload. Each filter is cleared where it is set: the two dropdowns open on All, and the text@@ -367,6 +369,7 @@ Seven cards: | Per person | One row per owner, unassigned included: hours, share of the total and item counts FIRST, then the five status counts. Sorted by hours | | Status | The five status counts over every task and milestone, with shares | | Per section | Items, hours and the date range each section actually covers, derived from its rows |+| Per tag | Since 0.60.0, when the plan has tags: per tag, the items (tasks, +nM for milestones), hours and share of the total hours, counted by EFFECTIVE tags (own plus section, the legend filter's rule), then a "no tag" line for rows with none. A row with two tags counts under both, so the shares can pass 100%. Cancelled work counts as an item and not in the hours | | Milestones | Every dated milestone with its date, days from today and owner | | Dependencies | Link count, rows in the graph, rows outside it, and a cycle warning naming the rows | | Overdue | Every row with a deadline in the past that is neither complete nor cancelled |@@ -447,7 +450,11 @@ redo), however many sections it touches, and one changelog batch: a `phase.tags` per section changed, `via: "setup"`, and `undo` or `redo` for those. The markdown log words each line by direction: "tagged X with A, B" for an addition, "untagged X: A" for a removal, "retagged X: +A, -B" for both, and "cleared the tags on X" when none are left.-A rename moves a reader's tag filter with it, and its Ctrl+Z moves the filter back. Escape closes any+A rename moves a reader's tag filter with it, and its Ctrl+Z moves the filter back. Since+0.60.0 the card counts rows too ("Tech: 7 sections", "long-lead: 3 rows", "Ops: 2+sections, 1 row"), and Rename and Remove reach every row that carries the tag as well as+every section, still as one Ctrl+Z and one changelog batch (a `tags` line per row, a+`phase.tags` line per section). Escape closes any open row editor on the page (Edit, Remove, the palette, a tag rename) wherever the focus is. @@ -458,6 +465,26 @@ Section tags are in the JSON export and the snapshot; a CSV is rows, so its `tag column carries row tags only. In the table, hovering a row's Phase cell names its section's tags, and the text filter matches them. +**Tags on rows (0.60.0).** A task or a milestone can carry tags of its own, `items[].tags`,+with the same rules as a section's (one normaliser, 32 characters, 12 at most). They are+edited in four places, all with the same chip box (Enter or a comma adds, the x removes,+clicking away keeps what was typed, Backspace in an empty box takes the last chip, the+suggestions are the plan's tags): a **tags** field in the full item editor and in the New+panel, the table's **Tags** cell (click it; Enter on an empty box or clicking away closes it;+each chip added or removed is saved at once while the cell stays open, and Escape discards only text still in the box), and the **batch panel**, whose tags row adds a tag to every selected row+(milestones included, unlike its date fields) and lists the tags the selection carries with+an x that takes one off all of them. Each is one Ctrl+Z and one save; the editor's tags+ride in the same undo entry as its other fields. The changelog gets a `tags` line per row+changed, with the gesture as its `via`: `item-editor`, `table-edit`, `batch-editor`,+`setup` (rename or remove everywhere), `create` (a row made with tags says so in a line of+its own after its `add`), and `undo` or `redo`. A paste is its one summary line. Clearing+a row's last tag keeps `"tags": []` on it in the sidecar, so a tag from the data file stays+cleared across a reload. Ctrl+C and Ctrl+V carry a row's own tags to its copy; the copy+takes the section tags of wherever it lands. In the chart a row's own tags follow its owner+in the label as quiet `#tag` text, the first thing to give way when the column is narrow+(the name never shrinks for them), and on its bar's, diamond's and cap's tooltip as+`tags: ...`.+ ## uid: the one handle worth quoting Every item carries a `uid`, a zero-padded number assigned once and never changed.@@ -527,7 +554,7 @@ Actions are `dates`, `plan.shift`, `auto-schedule`, `milestone`, `milestone.rena `note`, `owner`, `status`, `desc`, `tags`, `phase.tags`, `dep.add`, `dep.remove` and `reorder`. A tag edit carries the whole list on both sides. A row's is-`{"action":"tags","uid":"00042","id":"etch","name":"Etch the cards","from":{"tags":[]},"to":{"tags":["long-lead"]},"via":"editor"}`+`{"action":"tags","uid":"00042","id":"etch","name":"Etch the cards","from":{"tags":[]},"to":{"tags":["long-lead"]},"via":"item-editor"}` and reads `Drew tagged "Etch the cards" with long-lead (was none)`. A section's is `{"action":"phase.tags","phase":3,"name":"Tech · Fab","from":{"tags":[]},"to":{"tags":["Tech"]},"via":"setup"}` and reads `Drew tagged section "Tech · Fab" with Tech (was none)`. An empty `to` reads@@ -691,7 +718,7 @@ is why `project.nextUid` can be higher than any uid you can see. ## Tests -`node test/ui_test.mjs` (from the package directory) is the browser suite, added in 0.57.0 and built to the app-testing skill. It spawns `server.js --no-auth` on scratch ports from a fixed range, 8960-8979 (`UI_TEST_PORTS=<lo>-<hi>` for another; it fails loudly when none is free and never strays outside, so other tools can safely be given ports beyond it), against a SEEDED synthetic plan in a temp dir (20 sections, 123 rows, 21-Sep-2026 to 14-Jan-2027), pins the page's clock to 29-Sep-2026, and drives Chrome from `~/.cache/puppeteer` through the vendored `test/node_modules/puppeteer-core` (`cd test && npm install` once; it does not ship). It asserts effects: the header readouts against an oracle computed from the plan the SERVER saved after each edit (drag, undo, redo, auto-schedule, table edit, delete, paste, create, start shift), the start date at every grain, text size across a refresh in the live app, in a second tab and in an exported snapshot, collapse all in both lenses and across a refresh, and the wrapped legend with the sticky axis, pinned section header and table header measured at 100% and 150% text and across a resize. A coverage manifest fails on any control nobody declared, on any declared control that never renders, and clicks every safe control. 393px, 1440px and 1920px are rendered with no sideways scroll allowed, and a header sweep (every 40px from 360 to 2000 plus 15 named widths; `SWEEP_STEP=20` for denser) asserts both readouts are whole and every header control is on screen, with and without an appName, at 100% and 150% text. Since 0.58.0 the sweep covers all four lenses, and the viewport matrix renders Analytics and Setup too (Setup with 20 sections, plain and with a rename and a remove form open). The `sections` flow drives the Setup page on a plan with rows in every section and no phase uids: after every add, reorder and remove, after each one's undo and redo, and after a reload, it compares the whole section list and every row's section with `/export/json` and checks the page repainted to match; it also covers the one-undo Save, duplicate names refused, Enter pressed five times making one section, an open Remove form surviving Ctrl+Z, the row chords doing nothing on Setup, the chart axis after adding a section, and rename, reorder and remove in an exported snapshot. The `tags` flow (0.59.0) does the same for section tags on a plan whose last section arrives with a messy tag list: every add, comma-add, remove, rename-everywhere, remove-everywhere and prefix pass is checked against `/export/json` and the page, with its undo and redo, the `phase.tags` changelog lines (one batch, `via: setup`, and the undo and redo lines), a reload, the legend's tag chips and fold, the filter's row counts in the chart and the table, its composition with the section chips and Collapse all, its survival across a reload, a removed tag leaving it, the widths 393 and 1440, and tagging and filtering in an exported snapshot; the coverage gate walks a tagged copy of the seeded plan. `node test/server_test.mjs` is the server's own suite (ports 8940 up). Ports are checked free before use and the server answering /health must be the one the suite spawned. `--keep` leaves screenshots in `test/out/`; `--plan <file>` also runs the header and legend checks against a real plan, COPIED to a temp dir first. It exits non-zero on any failure.+`node test/ui_test.mjs` (from the package directory) is the browser suite, added in 0.57.0 and built to the app-testing skill. It spawns `server.js --no-auth` on scratch ports from a fixed range, 8960-8979 (`UI_TEST_PORTS=<lo>-<hi>` for another; it fails loudly when none is free and never strays outside, so other tools can safely be given ports beyond it), against a SEEDED synthetic plan in a temp dir (20 sections, 123 rows, 21-Sep-2026 to 14-Jan-2027), pins the page's clock to 29-Sep-2026, and drives Chrome from `~/.cache/puppeteer` through the vendored `test/node_modules/puppeteer-core` (`cd test && npm install` once; it does not ship). It asserts effects: the header readouts against an oracle computed from the plan the SERVER saved after each edit (drag, undo, redo, auto-schedule, table edit, delete, paste, create, start shift), the start date at every grain, text size across a refresh in the live app, in a second tab and in an exported snapshot, collapse all in both lenses and across a refresh, and the wrapped legend with the sticky axis, pinned section header and table header measured at 100% and 150% text and across a resize. A coverage manifest fails on any control nobody declared, on any declared control that never renders, and clicks every safe control. 393px, 1440px and 1920px are rendered with no sideways scroll allowed, and a header sweep (every 40px from 360 to 2000 plus 15 named widths; `SWEEP_STEP=20` for denser) asserts both readouts are whole and every header control is on screen, with and without an appName, at 100% and 150% text. Since 0.58.0 the sweep covers all four lenses, and the viewport matrix renders Analytics and Setup too (Setup with 20 sections, plain and with a rename and a remove form open). The `sections` flow drives the Setup page on a plan with rows in every section and no phase uids: after every add, reorder and remove, after each one's undo and redo, and after a reload, it compares the whole section list and every row's section with `/export/json` and checks the page repainted to match; it also covers the one-undo Save, duplicate names refused, Enter pressed five times making one section, an open Remove form surviving Ctrl+Z, the row chords doing nothing on Setup, the chart axis after adding a section, and rename, reorder and remove in an exported snapshot. The `tags` flow (0.59.0) does the same for section tags on a plan whose last section arrives with a messy tag list: every add, comma-add, remove, rename-everywhere, remove-everywhere and prefix pass is checked against `/export/json` and the page, with its undo and redo, the `phase.tags` changelog lines (one batch, `via: setup`, and the undo and redo lines), a reload, the legend's tag chips and fold, the filter's row counts in the chart and the table, its composition with the section chips and Collapse all, its survival across a reload, a removed tag leaving it, the widths 393 and 1440, and tagging and filtering in an exported snapshot; the coverage gate walks a tagged copy of the seeded plan. The `row tags` flow (0.60.0) drives every row-tag edit path (the item editor, the table's Tags cell, the New panel and its memory, the batch panel's add and remove, a paste, rename and remove everywhere on Setup) against `/export/json` with each one's undo and redo, checks the `tags` changelog lines and their `via`, the effective-tag filter's row counts in the chart and the table, the Analytics per-tag card against hours worked out by hand from its seed, a cleared data-file tag staying cleared across a reload, the Tags column's sort and text filter, CSV out and back in through the Import menu, no row name cut for its tags at 393 and 1440, and showing, filtering and editing row tags in an exported snapshot. `node test/server_test.mjs` is the server's own suite (ports 8940 up). Ports are checked free before use and the server answering /health must be the one the suite spawned. `--keep` leaves screenshots in `test/out/`; `--plan <file>` also runs the header and legend checks against a real plan, COPIED to a temp dir first. It exits non-zero on any failure. ## Example
docs/BUILD-NOTES.md+87−6@@ -3,6 +3,87 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-09-30 (0.60.0, tags on tasks and milestones)++Checkpoint 3 of three. The server already stored, merged, exported and logged+`items[].tags`; this is the page, and it adds no second tag system: one normaliser, one+chip shape, one edit path (`editTags`), one undo type (`phase.tags`, now with a `rows`+list beside its section list), one diff in `diffAndLog`.++**The filter rule is two predicates.** `rowShown(r)`: a row is on screen when any of its+EFFECTIVE tags (its own plus its section's, `effTags`) is chosen. `phaseShown(i)`: a+section header is on screen when the section matches or any of its rows does. The chart+(`applyCollapse`), the table (`tableRows`), Collapse all and the legend's section chips all+read these two and nothing else. So a row in a chosen section shows with no tags of its+own; a row tagged "long-lead" in an untagged section shows with its header and without its+neighbours; a row that matches neither way is hidden. The legend chip's number changed+meaning with it: it counts the rows the chip would show, not the sections carrying the tag,+because with row tags "3 sections" stopped answering "how much will I see". `planTags()`+keeps `n` as the SECTION count (the fold and the prefix logic depend on it) and gains+`rows` (rows carrying it on their own) and `items` (rows it finds).++**One chip box, three homes.** `tagBoxHtml`/`wireTagBox` is the Setup box made into a+draft editor: the item editor, the New panel and the table's Tags cell each build one, and+nothing reaches the plan until Apply, Create or the cell's commit reads it. Its keydown is+registered before the panel's or the cell's, so Enter with text adds a chip and goes no+further, and Enter on an empty box is the panel's Apply as from any field. A typed tag not+yet added is kept on Apply and on blur, as the Setup box does. The table cell needed one+CSS fix worth knowing: every table cell clips (`overflow: hidden`), and a clipping cell is+a scroll container, so focusing the overlay's input scrolled the CELL's content and the+editor opened up and to the left of its cell. The Tags cell does not clip; its pills clip+in their own span.++**Undo.** The item editor's tags ride in its ordinary `edit` or `ms` snapshot (they now+carry `tags`, absent and `[]` kept apart), so one Apply over name, owner and tags is one+entry. Every other row-tag gesture (the table cell, the batch panel, rename and remove+everywhere) goes through `editTags(change, refilter, {rows, via})` and is one `phase.tags`+entry holding the sections' lists by `phaseKey` and the rows' lists by the row OBJECT, as+the create and delete entries hold them, so a row deleted and restored in between still+resolves.++**Tags can change under an edit that is not a tag edit.** A delete takes a tagged row with+it, an undo removes a created one, a row moved into a tagged section gains its tags. So+`scheduleSave` compares a signature of the plan's tags (`planTags()` names and counts)+with the last one drawn and, when it differs, runs `afterTagsChange` (legend, filter prune,+suggestions, both lenses). The tag edits themselves set the signature, so they do not+repaint twice.++**Changelog.** `snapSemantic` carries each row's own tags, and the diff writes the server's+`tags` action for tasks and milestones, with the gesture's `via`. A row CREATED with tags+gets its own `tags` line after its `add` (`via: create`), so the log says where a tag came+from; a paste stays one summary line. The item editor logs `via: item-editor`, as every+other field it saves always has, not `editor` (which the brief named, and which is the bar+panel's via); SKILL.md's example was changed to match.++**Seen, not only asserted.** Screenshots of the reference launch plan at 1440 and 393 with row+tags: the label column there is narrow and the names long, so a long-named row's `#tags`+give way entirely, which is the intent (`flex-shrink: 1000` against the name's 1; the name+is cut exactly where it was cut before tags). The tooltip and the table always have them.++**Review fixes (0.60.0, same day).**+- The label cut the NAME, not the tags: `flex-shrink: 1000` still gave the name a sub-pixel+ share, and the tags' 6px margin stayed even at zero width. The tags are now `flex: 1 1 0`+ (they ask for nothing and only grow into what is left) and their gap is a `::before`+ inside their own box. Checked with a 270px label column: the name whole, the tags cut.+- `;` separates tags in the CSV, so it cannot be inside one: `normalizeTags` turns it into+ a space, on the server and in the page, line for line (a server_test case and a page+ check).+- The Tags cell saves each chip as it is added or removed, the way the Setup box does, so+ the cell never shows a pill the plan does not have. `renderTable` waits while that+ editor is open, so the per-chip saves do not repaint it away; Escape discards only text+ still in the box. Each chip is its own undo step, as on Setup.+- The cell editor keeps its caret in view as it grows (the last row, ten chips).+- A lane header's count reads "2 of 3" while the tag filter hides some of its rows.+- A delete or a tag edit that prunes the reader's filter records the filter on its undo+ entry (`viewFilter`), and time travel restores it and records the reverse, so Ctrl+Z+ after deleting the only "fresh" row brings back the row AND the filter.+- SKILL.md and README say plainly that a CSV round trip loses section tags and inherited+ tags; JSON and the snapshot are lossless.++Tests: a `row tags` flow (80 checks) in `test/ui_test.mjs`; the tagged seed gives two rows+a `long-lead` tag so the coverage gate walks the new controls (`#imTagsIn`, `#ncTagsIn`,+`.tb-x`, `#bTag`, `#bTagAdd`, `.bt-x`).+ ## Build notes, 2026-09-30 (0.59.0, tags on sections, the client) Checkpoint 2 of three. The server already stored, merged, exported and logged@@ -37,7 +118,7 @@ save, which is "one changelog batch" by construction. **The legend.** With tags in the plan, the tag chips lead (after Collapse all) and the section chips fold behind one "20 sections" chip; choosing tags brings back the chips of exactly the sections on screen. That is what makes a 20-section legend easier with tags-rather than busier: on the instaPCB plan the default legend is four tag chips and a fold,+rather than busier: on the reference launch plan the default legend is four tag chips and a fold, where it was twenty dots. A section chip behind a chosen tag that is its name prefix drops the repeat. A plan with no tags is untouched (the legend and viewport groups still see twenty chips). The filter hides a lane outright, header included, through the class and@@ -74,7 +155,7 @@ now clicks whichever of the strip or the rule's cap is on screen. it was, so Ctrl+Z moves it back to Launch and redo on to Go. - **Header tag chips are never the thing cut off.** Name, aside and chips were inline text inside one ellipsis, so a long aside pushed the chips out of the label column on six of- the instaPCB plan's sections. They are three flex items now. The aside starts at zero+ the reference launch plan's sections. They are three flex items now. The aside starts at zero width and grows into what is left, up to its own width, so it alone gives way; a shrink ratio would have taken a sliver from the name as well, and a sliver is enough for an ellipsis. The chips never shrink. Checked at 1440 on the real plan with its 380px column.@@ -105,7 +186,7 @@ now clicks whichever of the strip or the rule's cap is on screen. ## Build notes, 2026-09-30 (0.58.0, text size on a sandboxed snapshot) Drew reported the text size still resetting on refresh, on the snapshot published at-wiki.adom.inc/drew2/instapcb-launch/render/gantt/index.html, and that snapshot was+the wiki's render route for the launch plan snapshot, and that snapshot was already 0.57.1. Reproduced in Chrome logged in as him: the wiki serves an uploaded HTML file under `Content-Security-Policy: sandbox allow-scripts allow-downloads allow-popups`, with no allow-same-origin. That is the right header for user content on a site with@@ -220,7 +301,7 @@ and appName were escaped wherever they are painted. What they did find: mouseup forgets the baseline for that key before it saves. * **The wrapped header hid the button words for nothing.** A wrapping row always fits by width, so width cannot choose between words and icons. Height does: the words come- back when they cost no extra row (760 to about 1360px on the instaPCB plan) and stay+ back when they cost no extra row (760 to about 1360px on the reference launch plan) and stay off when they would (a phone). Two undo bugs inherited from 0.56.0, both caught by the hardened suite:@@ -241,7 +322,7 @@ The test suite was hardened in the same pass; see the Tests section of the READM ## Build notes, 2026-09-29 (0.57.0, a quieter header, collapse all, a legend that wraps, a text size that sticks) -Six things Drew asked for, from two screenshots of the instaPCB launch plan (20 sections,+Six things Drew asked for, from two screenshots of the reference launch plan (20 sections, 114 rows). `gantt.html` carries all of it; `server.js` is unchanged. The package also gains its first browser test suite, `test/ui_test.mjs`. @@ -252,7 +333,7 @@ Reproduced in headless Chrome before a line changed, against a copy of the real * **Exported snapshot.** A+ twice read 120% and wrote `fs: 1.2` to localStorage; the reload read 100%. `savedPrefs()` returned `{}` whenever `SNAPSHOT` was true, so the snapshot wrote the size and never read it back. That is the report exactly: every- refresh, every time. The instaPCB plan's `index.html` is such a snapshot.+ refresh, every time. The reference launch plan's `index.html` is such a snapshot. * **Live app, two tabs.** The single-tab live app kept 120% across a reload, so the suspect list's "the live app is fine" was nearly right. It is not fine with a second tab: the key is one per origin, `savePrefs()` writes the WHOLE prefs object, and a tab
docs/build-2026-09-29.md+2−2@@ -15,7 +15,7 @@ browser test suite, `test/ui_test.mjs`. The brand block shows `appName`, or the project title when there is none. * **Exact start date** at every cell size. * **Project Duration and Time Remaining** replace the axis cell count ("25 weeks"). On the- instaPCB plan, on 2026-09-29, they read `16w 4d` and `15w 3d`. They ignore the cell+ reference launch plan, on 2026-09-29, they read `16w 4d` and `15w 3d`. They ignore the cell size, update on every kind of edit, and leave cancelled rows out. * **The legend wraps**, so all 20 sections are visible and clickable. * **The header fits by measuring**, not by pixel breakpoints, so the readouts are never@@ -27,7 +27,7 @@ see Convergence for the final counts. ## Decisions for you -1. **Re-export the instaPCB wiki snapshot.** `drew2/instapcb-launch` carries+1. **Re-export the launch plan wiki snapshot.** the launch plan page carries `gantt/index.html`, exported by 0.56.0. It keeps the text-size bug until it is exported again from 0.57.1. Recommendation: re-export and push. Cost of not doing it: anyone reading the plan on the wiki still loses their text size on refresh.
docs/plan-0.57.0-header.md+1−1@@ -7,7 +7,7 @@ below is wrong, do the smarter thing and record it under Build notes. - Source: /home/adom/project/adom-gantt (files-only clone of adom/adom-gantt 0.56.0). The app is `gantt.html` (one file, about 10k lines) served by `server.js`.-- Real plan to exercise: /home/adom/project/instapcb-launch/gantt/instapcb-launch.json+- Real plan to exercise: /path/to/launch-plan.json (20 phases, 114 items, 2026-09-21 to 2027-01-14, grain week). Never write to that file or beside it; copy it to a temp dir first, since the server writes sidecars. - Run: `node server.js --data <copy>.json --port <port> --no-auth`.
docs/plan-0.58-setup-and-tags.md+39−1@@ -12,7 +12,7 @@ built. - Source: /home/adom/project/adom-gantt (files-only clone, 0.57.1). The app is `gantt.html`, served by `server.js`. - Pristine 0.57.1 for diffing: /home/adom/project/adom-gantt-0.57.1-ref/-- Real plan: /home/adom/project/instapcb-launch/gantt/instapcb-launch.json+- Real plan: /path/to/launch-plan.json (20 sections named like "Launch · Program and gates", "Tech · Laser workcells", "Process · Fab", "Ops · factoryOS"). Never serve that path directly, the server writes sidecars beside its data file: copy it first.@@ -202,3 +202,41 @@ Notes for checkpoint 3 (rows): new server action, left for later. - For checkpoint 3: `editTags(change, refilter)` is the path for any tag gesture that must also move the filter; a row-tag rename in the Tags card should go through it too.++## Build notes - 2026-09-30, checkpoint 3++Built as planned, with these changes to the plan's assumptions:++- **The legend chip counts rows now, not sections.** With row tags, "# long-lead 0" (no+ section carries it) or "# Tech 7" (sections) no longer said how much a chip would show.+ The chip's number is the rows it finds, own or inherited, the same number the Analytics+ per-tag card calls items. `planTags().n` still means sections, as checkpoint 2 asked.+- **A section header shows when only some of its rows match** (checkpoint 2 said+ "probably yes"): yes, with those rows and no others. `phaseShown(i)` became "the header+ is on screen", and the per-row check is `rowShown(r)`.+- **The batch panel acts on every selected row, milestones included, and at once.** Its+ date fields are task-only and wait for Apply; a tag means the same on a milestone and+ there was no reason to make an add wait on a date Apply.+- **The item editor logs `via: item-editor`, not `editor`.** Every other field that panel+ saves has always been logged as `item-editor`; `editor` is the bar panel's. SKILL.md's+ example line was corrected rather than the via.+- **A row created with tags writes its own `tags` line (`via: create`)** after its `add`,+ so the log says where the tag came from. A paste keeps its single summary line.+- **The Tags column shows the section's tags too**, dashed, after the row's own. It is the+ one place the effective-tag rule the filter uses can be seen row by row. Only the row's+ own are edited there.+- **Every save re-checks the plan's tag picture** (a signature of `planTags()`), because+ deletes, undos of creates and section moves change which tags exist and what a chip+ would show without being tag edits.+- Not done: section tags are not editable from the table (they belong to Setup); the+ chart shows only a row's OWN tags in its label (the header carries the section's).++## Build notes - 2026-09-30, checkpoint 3 review fixes++- Row tags in the chart label now take only the room the name and pills leave (`flex: 1 1 0`,+ the gap inside their box), so the name is never cut for them.+- `;` is not allowed inside a tag (it is the CSV separator); both normalisers turn it into a+ space. The CSV is documented as lossy for section and inherited tags.+- The table's Tags cell saves per chip, like the Setup box, and keeps its caret in view.+- Lane header counts read "2 of 3" under a tag filter; a delete or tag edit that prunes the+ filter restores it on Ctrl+Z (`viewFilter` on the undo entry, kept by time travel).
gantt.html+454−70@@ -846,6 +846,37 @@ body.ed-dragging { user-select: none; -webkit-user-select: none; } .editor .de-note-row { margin-top: 10px; } .editor .de-note-row input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 12px; padding: 5px 8px; width: 100%; outline: none; } .editor .de-note-row input:focus { border-color: var(--accent); }+.batch-tags { border-top: 1px solid var(--border-muted); padding-top: 8px; margin: 6px 0 4px; }+.batch-tags .bt-row input[type="text"] { flex: 1 1 90px; min-width: 0; }+.batch-tags .bt-row .de-btn { padding: 2px 9px; font-size: 11px; white-space: nowrap; }+.bt-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }+.bt-chips .sec-tag small { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 9px; }+.bt-none { font-size: 10px; color: var(--text3); margin-top: 4px; }+.bt-x { background: none; border: none; color: var(--text3); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 3px; border-radius: 3px; font-family: inherit; }+.bt-x:hover { color: #f85149; background: var(--row-hover); }+/* A row's tag box (0.60.0): the Setup page's chip editor in a field's clothes. The chips+ are the same .sec-tag pills; the box is one bordered field with the input inside it. */+.tag-box { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; min-height: 28px; padding: 2px 5px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); box-sizing: border-box; width: 100%; min-width: 0; cursor: text; }+.tag-box:focus-within { border-color: var(--accent); }+.tag-box .tb-chips { display: contents; }+.tag-box .sec-tag { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }+.editor .de-note-row .tag-box input.tb-in, .tag-box input.tb-in { flex: 1 1 80px; min-width: 60px; width: auto; border: none; background: transparent; padding: 2px; outline: none; box-shadow: none; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 11px; min-height: 0; }+.tag-box input.tb-in::placeholder { color: var(--text3); }+.tb-x { background: none; border: none; color: var(--text3); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 3px; border-radius: 3px; font-family: inherit; }+.tb-x:hover { color: #f85149; background: var(--row-hover); }+/* In a table cell the box is laid over the cell like every cell editor, and may grow DOWN+ past it (a row with five tags is taller than one line) without moving the column. */+.data-table .cell-edit.tags-edit { bottom: auto; min-height: calc(100% - 2px); min-width: 200px; right: auto; left: 3px; width: max(100% - 6px, 200px); }+.data-table .cell-edit.tags-edit .tag-box { border-color: var(--accent); min-height: 100%; box-shadow: 0 4px 14px rgba(0,0,0,0.25); }+.data-table .cell-edit.tags-edit input.tb-in.cell-input { border: none; padding: 2px; flex: 1 1 70px; width: auto; }+/* The Tags column: own tags as pills, the section's after them, fainter, so the rule the+ legend filter uses (own plus section) is visible in the one place rows are listed. */+.data-table td.c-tags { white-space: nowrap; }+/* The pills are cut inside their own span, NOT by the cell: a cell that clipped would clip+ the chip editor laid over it too, and that editor is allowed to grow past the cell. */+.data-table td.c-tags .tags-trunc { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.data-table .row-tag { display: inline-block; font-family: 'Satoshi', sans-serif; font-size: calc(10.5px * var(--fs)); line-height: 1.4; padding: 0 5px; margin-right: 3px; border: 1px solid var(--border); border-radius: 4px; color: var(--text2); vertical-align: middle; }+.data-table .row-tag.inh { border-style: dashed; color: var(--text3); } .editor .de-hint { color: var(--text2); font-size: 10px; font-family: 'Satoshi', sans-serif; } .editor .de-actions { display: flex; gap: 8px; margin-top: 10px; } .editor .de-dep-section { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-muted); }@@ -1109,6 +1140,7 @@ table.an-table tr.total td { border-top: 1px solid var(--border); border-bottom: .an-chip { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 7px; vertical-align: -1px; } .an-empty { font-family: 'Satoshi', sans-serif; font-size: 11px; color: var(--text2); padding: 3px 0; } .an-warn { color: var(--warn); }+.an-tag { padding: 0 6px; } .an-late { color: #f85149; } :root[data-theme="light"] .an-late { color: #b91c1c; } .an-foot { font-family: 'Satoshi', sans-serif; font-size: 10px; color: var(--text3); margin-top: 9px; }@@ -1331,6 +1363,15 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .label-row .row-name { display: flex; align-items: center; min-width: 0; overflow: hidden; white-space: nowrap; padding-right: 22px; } .label-row .row-name .name-text { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; } .label-row .row-name .tag { flex: none; }+/* A row's own tags (0.60.0): quiet text after the owner, and the FIRST thing to give way.+ A flex basis of 0 means the tags ask for no room at all and only GROW into what the name+ and the pills leave over, so the name keeps its whole width until the tags have none.+ (flex-shrink 1000 against the name's 1, the first try, still handed the name a sub-pixel+ share of the squeeze, which was enough to trip its ellipsis: 0.60.0 review.) */+.label-row .row-name .row-tags { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; font-family: 'JetBrains Mono', monospace; font-size: calc(9.5px * var(--fs)); color: var(--text3); }+/* The gap before the tags is INSIDE their box, so at zero width it goes with them; a margin+ would still take its 6px from the name. */+.label-row .row-name .row-tags::before { content: '\00a0\00a0'; } /* ---- resizable table columns ---- */ .data-table th { position: relative; } .col-grip { position: absolute; top: 0; right: 0; width: 8px; height: 100%; cursor: col-resize; z-index: 3; }@@ -1382,6 +1423,10 @@ body.col-sizing { cursor: col-resize; user-select: none; } set widths of their own", which is what switches the table from the proportional defaults to pixels. */ .data-table td, .data-table th { overflow: hidden; }+/* ...except the Tags cell, whose chip editor may grow past it (the pills clip in their own+ span instead). A clipping cell is also a scroll container, and focusing the editor's input+ scrolled the CELL's content under it, so the editor opened shifted up and left. */+.data-table td.c-tags { overflow: visible; } .data-table thead th { text-overflow: ellipsis; } .drop-indicator { position: absolute; left: 0; right: 0; height: 2px; background: var(--accent); z-index: 20; pointer-events: none; border-radius: 1px; box-shadow: 0 0 6px rgba(0,184,176,0.4); display: none; }@@ -1525,6 +1570,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <th class="sortable" data-sort="status">Status</th> <th class="sortable" data-sort="deps" title="Dependencies: what this row needs, and what waits on it. Click a cell to edit them">Deps</th> <th class="sortable" data-sort="notes">Notes</th>+ <th class="sortable" data-sort="tags" title="The row's own tags, then its section's (dashed). The legend's tag filter reads both. Click a cell to edit the row's own">Tags</th> <th class="sortable" data-sort="prov" title="Where this item came from">Provenance</th> <th class="th-del" aria-label="Delete"></th> </tr></thead>@@ -1541,7 +1587,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <div class="su-scroll" id="suScroll"><div class="su-stack"> <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, for finding and filtering them: choose one in the legend to see only the sections that carry it. They have no colour of their own. Add them on each section below; rename or remove one everywhere here.</p>+ <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. They have no colour of their own. Add them on each section below, and on a row in its editor or the table's Tags column; rename or remove one everywhere, sections and rows alike, here.</p> <div class="tag-prefix" id="tagPrefix" style="display:none"></div> <div class="tag-list" role="list"></div> </div>@@ -1602,7 +1648,7 @@ const { project, phases, items } = projData; // Short strings on a section (phases[].tags) and, from 0.60.0, on a row (items[].tags). // ONE normaliser, the server's normalizeTags line for line, because a list the page // shows has to be the list the server will store: trim, drop empties and anything that-// is not a string, de-duplicate without regard to case (the first spelling wins), cap+// is not a string, turn `;` (the CSV separator) into a space, de-duplicate without regard to case (the first spelling wins), cap // each tag at 32 characters and the list at 12. An array in gives an array out, possibly // empty; anything else gives undefined, and a malformed value is dropped, never an error. // Tags carry no colour: colour on this chart means the section, and only that.@@ -1612,7 +1658,9 @@ function normalizeTags(v) { const out = [], seen = new Set(); for (const t of v) { if (typeof t !== 'string') continue;- const s = t.trim().slice(0, TAG_MAX_LEN).trim();+ // `;` is the CSV's tag separator, so it cannot be IN a tag: a `x;y` tag would come+ // back from a CSV round trip as two. It becomes a space (0.60.0 review).+ const s = t.replace(/;/g, ' ').trim().slice(0, TAG_MAX_LEN).trim(); if (!s || seen.has(s.toLowerCase())) continue; seen.add(s.toLowerCase()); out.push(s);@@ -2014,26 +2062,30 @@ let transientCollapse=null; // set while a lane is being dragged function isCollapsed(ph){ return collapsed.has(ph); } function laneRows(ph){ return items.filter(r=>r.type!=='group'&&r.phase===ph); } function applyCollapse(){+ const ks=tagFilterKeys(); items.forEach(r=>{ if(r.type==='group'){ // A lane the tag filter leaves out goes whole, header included, through the same // class and flag a collapsed row uses, so every piece of geometry that already // skips a collapsed row (the arrows, the caps, the tail room) skips it too.- const out=!phaseShown(r.phase);+ const out=!phaseShown(r.phase,ks); r._collapsed=out; if(r._lDiv)r._lDiv.classList.toggle('row-collapsed',out); if(r._bDiv)r._bDiv.classList.toggle('row-collapsed',out); const on=isCollapsed(r.phase); if(r._laneBtn){ r._laneBtn.classList.toggle('collapsed',on);- const n=laneRows(r.phase).length;- r._laneBtn.querySelector('.lane-count').textContent=String(n);- r._laneBtn.title=(on?'Show':'Hide')+' the '+n+' row(s) in this lane. This is your view only, it does not change the plan.';+ const all=laneRows(r.phase), n=all.length;+ // With a tag filter hiding some of this lane's rows, the count says so ("2 of 3"),+ // or a header over two rows reading 3 looks like a row went missing (0.60.0 review).+ const shown=tagFilterOn()?all.filter(x=>rowShown(x,ks)).length:n;+ r._laneBtn.querySelector('.lane-count').textContent=shown<n?shown+' of '+n:String(n);+ r._laneBtn.title=(on?'Show':'Hide')+' the '+(shown<n?shown+' row(s) the tag filter shows, of '+n+',':n+' row(s)')+' in this lane. This is your view only, it does not change the plan.'; r._laneBtn.setAttribute('aria-expanded',on?'false':'true'); } return; }- const hide=isCollapsed(r.phase)||!phaseShown(r.phase);+ const hide=isCollapsed(r.phase)||!rowShown(r,ks); r._collapsed=hide; if(r._lDiv)r._lDiv.classList.toggle('row-collapsed',hide); if(r._bDiv)r._bDiv.classList.toggle('row-collapsed',hide);@@ -2057,7 +2109,7 @@ function applyCollapse(){ const collapseAllBtn=document.createElement('button'); // Only the lanes on screen vote: with a tag filter on, "Collapse all" is about the // sections the reader is looking at, and the hidden ones must not pin it on either label.-function lanesWithRows(){ return phases.map((p,i)=>i).filter(i=>phaseShown(i)&&laneRows(i).length>0); }+function lanesWithRows(){ const ks=tagFilterKeys(); return phases.map((p,i)=>i).filter(i=>phaseShown(i,ks)&&laneRows(i).some(r=>rowShown(r,ks))); } function allLanesCollapsed(){ const l=lanesWithRows(); return l.length>0&&l.every(isCollapsed); } function syncCollapseAllBtn(){ if(!collapseAllBtn.isConnected)return;@@ -2681,7 +2733,10 @@ let undoTimer = null; // Apply over five tasks, or a drag of five selected bars, took five Ctrl+Z to take back // and the first of them restored one row and left the other four where they had been // moved. A single row is still a plain edit entry, exactly as before.-function editSnapshot(r){ return {type:'edit',id:r.id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name}; }+// `tags` (0.60.0) is the row's own list as it stands, absent or [] included, so the item+// editor's Apply over name, owner AND tags is still one entry. tagsSnap copies it.+const tagsSnap=r=>Array.isArray(r.tags)?r.tags.slice():undefined;+function editSnapshot(r){ return {type:'edit',id:r.id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name,tags:tagsSnap(r)}; } function snapshotMany(ids){ const steps=ids.map(id=>items.find(d=>d.id===id)).filter(Boolean).map(editSnapshot); if(steps.length)pushUndo(steps.length===1?steps[0]:{type:'multi',steps});@@ -3226,7 +3281,14 @@ function applySnapshot(sn){ (sn.list||[]).forEach(([k,t])=>{ const j=phaseIndexOfKey(k); if(j<0)return; const p=phases[j]; inv.list.push([k,(p.tags||[]).slice()]); if(t&&t.length)p.tags=t.slice(); else delete p.tags; });- if(!inv.list.length)return null;+ // Row lists (0.60.0), by the row OBJECT, as the create and delete entries hold it: a+ // row deleted since is not in the plan and is skipped, and one put back by an undo of+ // that delete is the same object, so its tags resolve again.+ if(sn.rows){ inv.rows=[];+ sn.rows.forEach(([r,t])=>{ if(!items.includes(r))return;+ inv.rows.push([r,Array.isArray(r.tags)?r.tags.slice():undefined]);+ setRowTags(r,t); refreshRowTags(r); }); }+ if(!inv.list.length&&!(inv.rows&&inv.rows.length))return null; // A rename moved the reader's filter with it; its undo and redo move it back and on. if(sn.filter){ inv.filter=prefs.tagFilter.slice(); prefs.tagFilter=sn.filter.slice(); savePrefs(); } afterTagsChange();@@ -3240,7 +3302,7 @@ function applySnapshot(sn){ } if(sn.type==='phase.order'){ // Keyed by phaseKey, never by name. Until 0.58.0 this was p.uid||p.name, and a plan- // whose sections carry no uid (the instaPCB plan has none) and two sections with the+ // whose sections carry no uid (the reference launch plan has none) and two sections with the // same name resolved both keys to the FIRST of them: phases[] came back holding one // object twice, a section was gone and its rows merged into the other, and it saved. const inv={type:'phase.order',order:phases.map(phaseKey)};@@ -3278,12 +3340,15 @@ function applySnapshot(sn){ } if(sn.type==='ms'){ const r=sn.r;- const inv={type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description,prov:r.provenance};+ const inv={type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,tags:tagsSnap(r)}; r.date=sn.date; if(sn.name!==undefined)r.name=sn.name; setOpt(r,'owner',sn.owner);setOpt(r,'status',sn.status);setOpt(r,'description',sn.desc); if('prov' in sn)setProv(r,sn.prov);+ const tagsMoved='tags' in sn&&!sameTags(ownTags(r),sn.tags);+ if('tags' in sn)setRowTags(r,sn.tags); refreshMsChrome(r);updateMilestone(r);+ if(tagsMoved)afterTagsChange(); return inv; } if(sn.type==='group'){@@ -3303,7 +3368,9 @@ function applySnapshot(sn){ const r=items.find(d=>d.id===sn.id); if(!r)return null; const inv={type:'edit',id:sn.id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',- owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name};+ owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name,tags:tagsSnap(r)};+ const tagsMoved='tags' in sn&&!sameTags(ownTags(r),sn.tags);+ if('tags' in sn)setRowTags(r,sn.tags); r.start=sn.s;r.end=sn.e;r.deps=Array.isArray(sn.deps)?[...sn.deps]:sn.deps; if(sn.note!==undefined)r.note=sn.note; if('owner' in sn)setOpt(r,'owner',sn.owner);@@ -3312,18 +3379,31 @@ function applySnapshot(sn){ if('prov' in sn)setProv(r,sn.prov); if(sn.name!==undefined)r.name=sn.name; updateBar(sn.id);refreshLabelRow(r);refreshChartGeometry();+ if(tagsMoved)afterTagsChange(); return inv; } // One step in either direction. The entry comes off one stack and its inverse // goes onto the other, which is what makes undo, redo, undo land exactly where // undo did. Note that neither of these goes through pushUndo: time travel is the // one thing that must NOT clear the other stack.+let timeTraveling=false; function timeTravel(from,to,via,label,chord){ if(!from.length)return false; const sn=from.pop();- const inv=applySnapshot(sn);- if(inv===null)return false;- to.push(inv);+ const fPre=prefs.tagFilter.slice();+ timeTraveling=true;+ let inv;+ try{+ inv=applySnapshot(sn);+ if(inv===null)return false;+ to.push(inv);+ // The reader's tag filter as the entry remembered it (a delete that pruned it, say).+ if(sn.viewFilter){ prefs.tagFilter=sn.viewFilter.slice(); savePrefs(); afterTagsChange(); }+ // and any prune this step causes is remembered on its inverse, so redo then undo again+ // lands on the same view+ if(tagsReady&&tagSigNow()!==tagSig)afterTagsChange();+ if(!inv.filter&&!inv.viewFilter&&JSON.stringify(fPre)!==JSON.stringify(prefs.tagFilter))inv.viewFilter=fPre;+ } finally { timeTraveling=false; } if(to.length>100)to.splice(0,to.length-100); if(prefs.view==='table')renderTable(); undoToast.innerHTML=label+' · <kbd>'+chord+'</kbd>';@@ -3853,12 +3933,12 @@ function phaseNameAt(i){const p=phases[i];return p?p.name:String(i);} function planStartOf(snap){let m=null;Object.values(snap.byId).forEach(v=>{if(v.start&&(!m||v.start<m))m=v.start;});return m;} function snapSemantic(){ const byId={};- items.forEach(r=>{if(r.id)byId[r.id]={start:r.start,end:r.end,deps:(r.deps||[]).slice(),note:r.note||'',owner:r.owner||'',status:statusOf(r),desc:r.description||'',prov:provSig(r),provL:provLabel(r),name:r.name||'',phase:r.phase};});+ items.forEach(r=>{if(r.id)byId[r.id]={start:r.start,end:r.end,deps:(r.deps||[]).slice(),note:r.note||'',owner:r.owner||'',status:statusOf(r),desc:r.description||'',prov:provSig(r),provL:provLabel(r),name:r.name||'',phase:r.phase,tags:ownTags(r).slice()};}); // Keyed by the stable row key, not by the name. Keying by name made a rename // look like one milestone vanishing and another appearing, so it logged nothing // (both sides early-return on an unseen name) and instead surfaced as a phantom // "reorder". It also made one of any same-named pair invisible to the diff.- const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[rowOrderKey(r)]={name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r),phase:r.phase};});+ const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[rowOrderKey(r)]={name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r),phase:r.phase,tags:ownTags(r).slice()};}); // Section tags, keyed by the section's client identity (phaseKey), so a rename or a // reorder between two snapshots is not read as a tag change. A section that exists on // one side only (created, removed) has its own event already and adds none here.@@ -3888,6 +3968,9 @@ function diffAndLog(via){ if((a.prov||'')!==(b.prov||'')) evs.push({action:'provenance',id,name:nameOf(id),from:{label:a.provL||''},to:{label:b.provL||''}}); if((a.name||'')!==(b.name||'')) evs.push({action:'rename',id,name:b.name||'',from:{name:a.name||''},to:{name:b.name||''}}); if(a.phase!==b.phase) evs.push({action:'phase',id,name:nameOf(id),from:{phase:phaseNameAt(a.phase)},to:{phase:phaseNameAt(b.phase)}});+ // A row's own tags (0.60.0), the row side of the server's `tags` action. Absent and []+ // both diff as none: the sidecar keeps the difference, the changelog need not.+ if(!sameTags(a.tags,b.tags)) evs.push({action:'tags',id,name:nameOf(id),from:{tags:a.tags||[]},to:{tags:b.tags}}); b.deps.filter(d=>!a.deps.includes(d)).forEach(d=>evs.push({action:'dep.add',id,name:nameOf(id),dep:d,depName:nameOf(d)})); a.deps.filter(d=>!b.deps.includes(d)).forEach(d=>evs.push({action:'dep.remove',id,name:nameOf(id),dep:d,depName:nameOf(d)})); });@@ -3904,6 +3987,10 @@ function diffAndLog(via){ const born=r&&addedItems.includes(r); evs.push({action:born?'add':'restore',id,name:cur.byId[id].name||id, kind:born?(r.type==='milestone'?'milestone':'task'):undefined});+ // A row created WITH tags (the New panel) says so in its own line, via `create`, so a+ // reader of the log sees where a tag came from. A paste is left as its one summary line.+ if(born&&via!=='paste'&&cur.byId[id].tags.length)+ evs.push({action:'tags',id,name:cur.byId[id].name||id,from:{tags:[]},to:{tags:cur.byId[id].tags}}); }); // A plan shift moves every task by the same delta. Logging 26 identical rows // buries the one fact a reader wants, so collapse it into a single event.@@ -3924,6 +4011,8 @@ function diffAndLog(via){ const r=items.find(x=>x.type==='milestone'&&rowOrderKey(x)===k); const born=r&&addedItems.includes(r); evs.push({action:born?'add':'restore',ms:true,key:k,name:curMs[k].name||k,kind:born?'milestone':undefined});+ if(born&&via!=='paste'&&curMs[k].tags.length)+ evs.push({action:'tags',ms:true,key:k,name:curMs[k].name||k,from:{tags:[]},to:{tags:curMs[k].tags}}); }}); Object.keys(curMs).forEach(k=>{ const a=oldMs[k],b=curMs[k];@@ -3939,6 +4028,7 @@ function diffAndLog(via){ if((a.desc||'')!==(b.desc||'')) evs.push({action:'desc',ms:true,key:k,name:nm,from:{desc:a.desc||''},to:{desc:b.desc||''}}); if((a.prov||'')!==(b.prov||'')) evs.push({action:'provenance',ms:true,key:k,name:nm,from:{label:a.provL||''},to:{label:b.provL||''}}); if(a.phase!==b.phase) evs.push({action:'phase',ms:true,key:k,name:nm,from:{phase:phaseNameAt(a.phase)},to:{phase:phaseNameAt(b.phase)}});+ if(!sameTags(a.tags,b.tags)) evs.push({action:'tags',ms:true,key:k,name:nm,from:{tags:a.tags||[]},to:{tags:b.tags}}); }); // A section's tags (0.59.0). Diffed here rather than posted by the Setup page, so an // undo and a redo of a tag edit are logged too, as every other undo is, and so one@@ -4029,7 +4119,14 @@ function flushLogQueue(){ fetch('log',{method:'POST',headers:{'Content-Type':'application/json'},body:logBody(q)}) .catch(()=>{}); }-function scheduleSave(via){diffAndLog(via||'edit');syncStartBtn();syncCollapseAllBtn();if(critOn())refreshCritical();syncPlanCtl();if(prefs.view==='analytics')renderAnalytics();else if(prefs.view==='setup')renderSections(true);clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},800);}+// Tags can change under an edit that is not a tag edit: a delete takes a tagged row with+// it, an undo of a create, a paste, a row moved into a tagged section (its effective tags+// change). So every save compares the plan's tag picture with the last one drawn, and a+// difference repaints what reads it: the legend chips and counts, the filter (a tag gone+// from the plan leaves it), the suggestions. Cheap: a plan has tens of tags at most.+let tagSig='';+function tagSigNow(){ return JSON.stringify(planTags().map(e=>[e.tag,e.n,e.items])); }+function scheduleSave(via){if(tagsReady&&tagSigNow()!==tagSig)afterTagsChange();diffAndLog(via||'edit');syncStartBtn();syncCollapseAllBtn();if(critOn())refreshCritical();syncPlanCtl();if(prefs.view==='analytics')renderAnalytics();else if(prefs.view==='setup')renderSections(true);clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},800);} // Both writes wait: the sidecar for its 800ms debounce and, since 0.54.0, the changelog // line for the save it belongs to. A tab closed inside that window would leave the edit in // neither, so anything still pending goes out synchronously when the page is being put@@ -4292,16 +4389,46 @@ function buildLegendPhases(){ rebuildLegend(); } // spelling per section), and shown in the spelling the plan uses first. const tagKey=t=>String(t||'').trim().toLowerCase(); function phaseTagsOf(r){ const p=r&&phases[r.phase]; return (p&&p.tags)||[]; }+// A row's OWN tags (0.60.0): items[].tags, the list the row carries itself. Absent and+// [] both read as none here; the difference matters only to the sidecar (see getState).+function ownTags(r){ return (r&&Array.isArray(r.tags))?r.tags:[]; }+// A row's EFFECTIVE tags: its own, then its section's, one spelling per key (the row's own+// spelling first). This is the list the legend filter and the Analytics per-tag card read,+// so a task tagged "long-lead" in an untagged section is found by that tag, and every row+// of a section tagged "Tech" is found by "Tech" without carrying it itself.+function effTags(r){+ const out=[], seen=new Set();+ [...ownTags(r),...phaseTagsOf(r)].forEach(t=>{ const k=tagKey(t); if(!seen.has(k)){ seen.add(k); out.push(t); } });+ return out;+}+// Put a row's own list back exactly: an array (possibly empty, the sidecar's "cleared")+// or no key at all. Undo and redo go through here so absent and [] survive the round trip.+function setRowTags(r,v){ if(Array.isArray(v))r.tags=v.slice(); else delete r.tags; } // Every tag in the plan, in the order it first appears going down the sections, with-// how many sections carry it. First-appearance order and not A to Z, because on a plan-// whose sections are grouped ("Launch · ...", then "Tech · ...") that IS the plan's order.+// how many sections carry it (`n`, which the legend fold and the prefix logic rely on+// meaning sections), how many rows carry it on their own (`rows`, 0.60.0), and how many+// rows it finds through the filter, own or inherited (`items`). First-appearance order+// and not A to Z, because on a plan whose sections are grouped ("Launch · ...", then+// "Tech · ...") that IS the plan's order. Within a section, its own tags come before the+// tags its rows add. function planTags(){ const m=new Map();- phases.forEach((p,i)=>(p.tags||[]).forEach(t=>{ const k=tagKey(t);- if(!m.has(k))m.set(k,{tag:t,key:k,n:0,idx:[]});- const e=m.get(k); e.n++; e.idx.push(i); }));+ const ent=t=>{ const k=tagKey(t); if(!m.has(k))m.set(k,{tag:t,key:k,n:0,idx:[],rows:0,items:0}); return m.get(k); };+ const rows=items.filter(r=>!isGroup(r));+ phases.forEach((p,i)=>{+ (p.tags||[]).forEach(t=>{ const e=ent(t); e.n++; e.idx.push(i); });+ rows.forEach(r=>{ if(r.phase===i)ownTags(r).forEach(t=>ent(t).rows++); });+ });+ // a row whose section index is out of range still has its own tags counted+ rows.forEach(r=>{ if(!phases[r.phase])ownTags(r).forEach(t=>ent(t).rows++); });+ rows.forEach(r=>effTags(r).forEach(t=>{ const e=m.get(tagKey(t)); if(e)e.items++; })); return [...m.values()]; }+// "2 sections, 3 rows", leaving out a zero, for the Setup card and the chip titles.+function tagCountText(e){+ const parts=[]; if(e.n)parts.push(plural(e.n,'section')); if(e.rows)parts.push(plural(e.rows,'row'));+ return parts.join(', ')||'unused';+} // The filter is VIEW state, one person's, kept in prefs beside the collapse set: which // tags they are looking at is not a fact about the plan. Stored in the plan's spelling, // compared by key. A tag the plan no longer has drops out (pruneTagFilter), so a filter@@ -4314,12 +4441,30 @@ prefs.legendSecs=prefs.legendSecs===true; let tagsReady=false; // set once the sidecar is in: before that the data file's tags are not the plan's function tagFilterKeys(){ return new Set(prefs.tagFilter.map(tagKey)); } function tagFilterOn(){ return prefs.tagFilter.length>0; }-// Is section i on screen as far as the tag filter goes? Any of the chosen tags will do.-function phaseShown(i){+// THE FILTER RULE (0.60.0), in two predicates the chart and the table both use:+//+// rowShown(r) a row is on screen when ANY of its effective tags (its own plus its+// section's) is one of the chosen tags. So a row in a chosen section is+// shown even with no tags of its own, a row tagged "long-lead" in an+// untagged section is shown by "long-lead", and a row whose own tags do+// not match, in a section that does not match, is hidden.+// phaseShown(i) a section's HEADER is on screen when the section itself matches, or+// when at least one of its rows does. A header shown only for some of+// its rows shows those rows and no others.+//+// With no tag chosen both are true for everything. `ks` may be passed in by a caller+// that asks for many rows at once, so the chosen set is built once.+function rowShown(r,ks){+ if(!prefs.tagFilter.length)return true;+ ks=ks||tagFilterKeys();+ return effTags(r).some(t=>ks.has(tagKey(t)));+}+function phaseShown(i,ks){ if(!prefs.tagFilter.length)return true; const p=phases[i]; if(!p)return true;- const ks=tagFilterKeys();- return (p.tags||[]).some(t=>ks.has(tagKey(t)));+ ks=ks||tagFilterKeys();+ if((p.tags||[]).some(t=>ks.has(tagKey(t))))return true;+ return items.some(r=>r.phase===i&&!isGroup(r)&&ownTags(r).some(t=>ks.has(tagKey(t)))); } function pruneTagFilter(){ if(!tagsReady||!prefs.tagFilter.length)return false;@@ -4358,16 +4503,19 @@ secToggle.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" secToggle.addEventListener('click',()=>{ prefs.legendSecs=!prefs.legendSecs; savePrefs(); rebuildLegend(); }); tagClear.type='button'; tagClear.id='tagClear'; tagClear.className='legend-item tag-clear'; tagClear.textContent='Clear tags';-tagClear.title='Show every section again. Only your view changes, not the plan.';+tagClear.title='Show every section and row again. Only your view changes, not the plan.'; tagClear.addEventListener('click',()=>setTagFilter([])); } function tagChip(e,on){ const el=document.createElement('button'); el.type='button'; el.className='legend-item tag-chip'+(on?' on':''); el.dataset.tag=e.key; el.setAttribute('aria-pressed',on?'true':'false');- el.innerHTML=`<span class="tc-hash" aria-hidden="true">#</span><span class="tc-label">${esc(e.tag)}</span><span class="tc-n">${e.n}</span>`;- el.title=(on?'Stop filtering by ':'Show only the sections tagged ')+e.tag+' ('+plural(e.n,'section')+': '- +e.idx.map(i=>phases[i].name).join(', ')+'). Choose more than one to see any of them. Your view only, it does not change the plan.';+ // The number is the rows the chip would show: every row that carries the tag itself or+ // sits in a section that does (0.60.0). Before rows had tags it counted sections, and+ // with row tags that number stopped answering "how much will I see".+ el.innerHTML=`<span class="tc-hash" aria-hidden="true">#</span><span class="tc-label">${esc(e.tag)}</span><span class="tc-n">${e.items}</span>`;+ const where=[e.n?plural(e.n,'section')+': '+e.idx.map(i=>phases[i].name).join(', '):'',e.rows?plural(e.rows,'row')+' tagged on their own':''].filter(Boolean).join('; ');+ el.title=(on?'Stop filtering by ':'Show only the rows tagged ')+e.tag+', '+plural(e.items,'row')+' ('+where+'). Choose more than one to see any of them. Your view only, it does not change the plan.'; el.addEventListener('click',()=>toggleTagFilter(e.tag)); return el; }@@ -4397,7 +4545,7 @@ function rebuildLegend(){ // rest of its name; its tooltip keeps the whole of it. const short=ph=>{ if(!filtering)return ''; const pre=namePrefix(ph.name); return pre&&ks.has(tagKey(pre))?String(ph.name).replace(PREFIX_RE_CUT,''):''; };- if(!folded)phases.forEach((ph,i)=>{ if(phaseShown(i))put(phaseChip(ph,i,short(ph))); });+ if(!folded)phases.forEach((ph,i)=>{ if(phaseShown(i,ks))put(phaseChip(ph,i,short(ph))); }); syncSecToggle(); syncCollapseAllBtn(); }@@ -4572,9 +4720,17 @@ function makeEditBtn(r){ // build loop, the inline-edit restore and the table-to-chart status sync can never // draw the name three subtly different ways. Groups are not routed through here; a // lane header keeps its own .lane-name.+// A row's own tags ride last, after the owner, as quiet "#tag" text rather than chips:+// they are the first thing to give way when the column is narrow (see .row-tags), and+// text cut by an ellipsis still reads, where a chip cut in half does not. The name never+// yields to them. Section tags are not repeated here; the section header carries those.+function rowTagsHtml(r){+ const t=ownTags(r); if(!t.length)return '';+ return `<span class="row-tags" title="${esc('Tags: '+t.join(', '))}">${t.map(x=>'#'+esc(x)).join(' ')}</span>`;+} function nameInner(r){ const nt='<span class="name-text">'+esc(r.name||'')+'</span>';- if(r.type==='milestone')return nt;+ if(r.type==='milestone')return nt+rowTagsHtml(r); const sk=statusOf(r); const tg=sk?`<span class="tag status-pill ${statusTagFor(sk)}" title="Click to change status">${esc(statusTextFor(sk))}</span>`:''; // The owner rides immediately right of the status, the same kind of chip answering@@ -4583,7 +4739,7 @@ function nameInner(r){ // most worth seeing at a glance and it has to be clickable to be fixable. const ow=(r.owner||'').trim(); const op=`<span class="tag owner-pill${ow?'':' none'}" title="Click to change owner">${esc(ow||'unassigned')}</span>`;- return nt+tg+op;+ return nt+tg+op+rowTagsHtml(r); } // Extracted from the initial build loop so a row created at runtime is built by@@ -4612,7 +4768,7 @@ function buildRowDom(r,idx){ const x=toX(r.date); const hit=document.createElement('div');hit.className='ms-hit';hit.style.left=x+'px'; hit.dataset.msuid=r.uid||'';- hit.dataset.title=r.name;hit.dataset.dates=fmt(r.date);hit.dataset.body=r.description||'';hit.dataset.prov=provLabel(r);+ hit.dataset.title=r.name;hit.dataset.dates=fmt(r.date);hit.dataset.body=r.description||'';hit.dataset.prov=provLabel(r);hit.dataset.tags=ownTags(r).join(', '); const d=document.createElement('div');d.className='ms-diamond';d.style.left=x+'px'; bDiv.appendChild(hit);bDiv.appendChild(d); // The dashed rule lives on the chart container, not the row, so it can span@@ -4621,7 +4777,7 @@ function buildRowDom(r,idx){ ln.style.color=phaseColor(r.phase); const cap=document.createElement('div');cap.className='ms-cap';cap.textContent=r.name.replace(/^M(\d+):\s*/,'M$1 '); cap.dataset.msuid=r.uid||'';- cap.dataset.title=r.name;cap.dataset.dates=fmt(r.date);cap.dataset.body=r.description||'';cap.dataset.prov=provLabel(r);+ cap.dataset.title=r.name;cap.dataset.dates=fmt(r.date);cap.dataset.body=r.description||'';cap.dataset.prov=provLabel(r);cap.dataset.tags=ownTags(r).join(', '); ln.appendChild(cap);msLineEls.push(ln); r._msEl=d;r._msHit=hit;r._msLine=ln; }@@ -4630,7 +4786,7 @@ function buildRowDom(r,idx){ lDiv.appendChild(makeEditBtn(r)); r._nameSpan=lDiv.querySelector('.row-name'); r._nameSpan.innerHTML=nameInner(r);- bDiv.className='bar-row';const x1=toX(r.start),x2=toX(r.end),b=document.createElement('div');b.className='bar'+(r.striped?' bar-striped':'');b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';b.style.background=phaseColor(r.phase);b.dataset.title=r.name;b.dataset.dates=fmt(r.start)+' → '+fmt(r.end);b.dataset.body=r.description||'';b.dataset.prov=provLabel(r);b.dataset.id=r.id;+ bDiv.className='bar-row';const x1=toX(r.start),x2=toX(r.end),b=document.createElement('div');b.className='bar'+(r.striped?' bar-striped':'');b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';b.style.background=phaseColor(r.phase);b.dataset.title=r.name;b.dataset.dates=fmt(r.start)+' → '+fmt(r.end);b.dataset.body=r.description||'';b.dataset.prov=provLabel(r);b.dataset.tags=ownTags(r).join(', ');b.dataset.id=r.id; const nt=document.createElement('div');nt.className='bar-note';nt.textContent=r.note||'';b.appendChild(nt);r._noteEl=nt; b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle left'}));b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle right'}));bDiv.appendChild(b);barEls[r.id]={bar:b,idx,row:bDiv};} r._lDiv=lDiv;r._bDiv=bDiv;r._itemIdx=idx;labelsEl.appendChild(lDiv);bars.appendChild(bDiv);@@ -5579,6 +5735,9 @@ function clipOf(r){ name:r.name||'', srcId:r.id||'', start:r.start, end:r.end, date:r.date, owner:r.owner, status:r.status, description:r.description, note:r.note,+ // The row's OWN tags travel with it (0.60.0). Its section's do not need to: a copy+ // pasted into another section takes that section's, which is what "effective" means.+ tags:ownTags(r).slice(), deps:Array.isArray(r.deps)?r.deps.slice():[]}; } // Ctrl+C. The selection is already the app's one selection model, so this works the@@ -5743,6 +5902,7 @@ async function pasteRows(){ if(c.status)r.status=c.status; if(c.description)r.description=c.description; if(c.note)r.note=c.note;+ if(c.tags&&c.tags.length)r.tags=c.tags.slice(); // Provenance is the CREATED-row convention, not the original's. These rows were // not produced by whatever the original cites; they were made here, now, by the // person pasting, and that is what the field is for.@@ -6173,10 +6333,35 @@ function fillLaneName(nm,name,lead,tags){ nm.appendChild(w); } }+// The suggestions every tag box offers (the Setup rows, the item editor, the New panel,+// the table's Tags cell and the batch panel): every tag in the plan, sections and rows.+function syncTagList(tags){+ const dl=$('tagList'); if(!dl)return;+ dl.innerHTML=(tags||planTags()).map(e=>`<option value="${esc(e.tag)}">`).join('');+}+// A row's own tags show in three places on the chart: the label row (after the owner),+// the bar's or diamond's tooltip, and the milestone cap's. All three are restated here.+function refreshRowTags(r){+ if(!r)return;+ const own=ownTags(r).join(', ');+ [r._msHit,r._msLine&&r._msLine.querySelector('.ms-cap'),r.id&&barEls[r.id]&&barEls[r.id].bar].forEach(el=>{ if(el)el.dataset.tags=own; });+ if(r._nameSpan)r._nameSpan.innerHTML=nameInner(r);+} // Any change to a section's tags lands here: the filter drops a tag the plan no longer // has, and both lenses, the legend and the Setup page repaint from phases[]. function afterTagsChange(){- pruneTagFilter();+ tagSig=tagSigNow();+ // A tag that leaves the plan leaves the reader's filter (pruneTagFilter). When that was+ // caused by an edit (a delete of the only row carrying it, a row's tags cleared), the+ // edit's undo entry remembers the filter as it was, so Ctrl+Z brings the view back with+ // the tag (0.60.0 review). A rename already carries its own `filter`; time travel does+ // its own bookkeeping (see timeTravel).+ const fBefore=prefs.tagFilter.slice();+ if(pruneTagFilter()&&!timeTraveling){+ const top=undoStack[undoStack.length-1];+ if(top&&!top.filter&&!top.viewFilter)top.viewFilter=fBefore;+ }+ syncTagList(); refreshSections(); applyTagView(); }@@ -6548,25 +6733,43 @@ function sameTags(a,b){ return JSON.stringify(a||[])===JSON.stringify(b||[]); } // It is applied BEFORE the prune, which would otherwise drop the old name first (the // 0.59.0 review caught exactly that), and the undo entry carries the filter as it was, so // Ctrl+Z puts the filter back with the tags and redo moves it on again.-function editTags(change,refilter){- const before=[];- phases.forEach((p,i)=>{+//+// Rows too, since 0.60.0: `opt.rows(r)` returns a row's new OWN list, or nothing to leave+// it alone, and the same entry records the rows' lists as they were ([row, list-or-absent]+// pairs), so "rename a tag everywhere" over sections AND rows is still one Ctrl+Z. A row+// cleared of its last tag keeps `tags: []`, which is how the sidecar says "cleared" rather+// than "never touched" (a data-file tag would otherwise come back on reload). `opt.via`+// names the gesture in the changelog: setup, table-edit, batch-editor.+function editTags(change,refilter,opt){+ opt=opt||{};+ const before=[], rowsBefore=[];+ if(change)phases.forEach((p,i)=>{ const nx=change(p,i); if(!nx)return; const t=normalizeTags(nx)||[], cur=p.tags||[]; if(sameTags(t,cur))return; before.push([phaseKey(p),cur.slice()]); if(t.length)p.tags=t; else delete p.tags; });- if(!before.length)return 0;+ if(opt.rows)items.forEach(r=>{+ if(isGroup(r))return;+ const nx=opt.rows(r); if(!nx)return;+ const t=normalizeTags(nx)||[];+ if(sameTags(t,ownTags(r)))return;+ rowsBefore.push([r,Array.isArray(r.tags)?r.tags.slice():undefined]);+ r.tags=t;+ refreshRowTags(r);+ });+ if(!before.length&&!rowsBefore.length)return 0; const sn={type:'phase.tags',list:before};+ if(rowsBefore.length)sn.rows=rowsBefore; if(refilter){ const nf=refilter(prefs.tagFilter); if(JSON.stringify(nf)!==JSON.stringify(prefs.tagFilter)){ sn.filter=prefs.tagFilter.slice(); prefs.tagFilter=nf; savePrefs(); } } pushUndo(sn); afterTagsChange();- scheduleSave('setup');- return before.length;+ scheduleSave(opt.via||'setup');+ return before.length+rowsBefore.length; } function addPhaseTags(k,words){ const j=phaseIndexOfKey(k); if(j<0)return 0;@@ -6582,12 +6785,16 @@ function removePhaseTag(k,tag){ function renameTagEverywhere(from,to){ to=String(to||'').trim().slice(0,TAG_MAX_LEN).trim(); if(!to)return 0; const k=tagKey(from);- return editTags(p=>(p.tags||[]).some(t=>tagKey(t)===k)?p.tags.map(t=>tagKey(t)===k?to:t):null,- cur=>{ const f=[]; cur.forEach(t=>{ const v=tagKey(t)===k?to:t; if(!f.some(x=>tagKey(x)===tagKey(v)))f.push(v); }); return f; });+ // Sections and rows alike (0.60.0): "everywhere" means every list that carries it.+ const ren=list=>list.some(t=>tagKey(t)===k)?list.map(t=>tagKey(t)===k?to:t):null;+ return editTags(p=>ren(p.tags||[]),+ cur=>{ const f=[]; cur.forEach(t=>{ const v=tagKey(t)===k?to:t; if(!f.some(x=>tagKey(x)===tagKey(v)))f.push(v); }); return f; },+ {rows:r=>ren(ownTags(r))}); } function removeTagEverywhere(tag){ const k=tagKey(tag);- return editTags(p=>(p.tags||[]).some(t=>tagKey(t)===k)?p.tags.filter(t=>tagKey(t)!==k):null);+ const rm=list=>list.some(t=>tagKey(t)===k)?list.filter(t=>tagKey(t)!==k):null;+ return editTags(p=>rm(p.tags||[]),null,{rows:r=>rm(ownTags(r))}); } // "Launch · Program and gates" carries its group in its name. The prefix is what comes // before the first " · " (spaces on both sides, so a name with a dot in a word is left@@ -6669,7 +6876,7 @@ let tagEditing=null; // the key of the tag whose rename box is open function renderTagsCard(){ const card=$('suTags'); if(!card)return; const tags=planTags();- const dl=$('tagList'); if(dl)dl.innerHTML=tags.map(e=>`<option value="${esc(e.tag)}">`).join('');+ syncTagList(tags); $('tagTotal').textContent=plural(tags.length,'tag'); const pre=$('tagPrefix'), cand=prefixCandidates(); if(cand.length){@@ -6683,14 +6890,16 @@ function renderTagsCard(){ const typing=tagEditing&&list.querySelector('.tag-nm'); const draft=typing?typing.value:null; if(tagEditing&&!tags.some(e=>e.key===tagEditing))tagEditing=null;- list.innerHTML=tags.length?'':`<div class="tag-empty">No tags yet. Type one into the box under any section below${cand.length?', or tag them all from their name prefixes with the button above':''}.</div>`;+ list.innerHTML=tags.length?'':`<div class="tag-empty">No tags yet. Type one into the box under any section below, or give a row tags in its editor or the table's Tags column${cand.length?', or tag them all from their name prefixes with the button above':''}.</div>`; tags.forEach(e=>{ const row=document.createElement('div'); row.className='tag-row'; row.dataset.tag=e.key; row.setAttribute('role','listitem'); const secs=e.idx.map(i=>phases[i].name);+ const rowNames=e.rows?items.filter(r=>!isGroup(r)&&ownTags(r).some(t=>tagKey(t)===e.key)).map(r=>r.name||''):[];+ const tip=[secs.length?'Sections: '+secs.join(', '):'',rowNames.length?'Rows: '+rowNames.slice(0,12).join(', ')+(rowNames.length>12?', ...':''):''].filter(Boolean).join('\n'); row.innerHTML=`<span class="tag-name"><span class="sec-tag">${esc(e.tag)}</span></span>`+- `<span class="tag-n" title="${esc(secs.join(', '))}">${plural(e.n,'section')}</span>`+- `<span class="tag-acts"><button type="button" class="tag-btn" data-a="rename" title="Rename this tag on every section that carries it">Rename</button>`+- `<button type="button" class="tag-btn danger" data-a="del" title="Remove this tag from all ${plural(e.n,'section')}. One Ctrl+Z puts it back.">Remove</button></span>`;+ `<span class="tag-n" title="${esc(tip)}">${esc(tagCountText(e))}</span>`++ `<span class="tag-acts"><button type="button" class="tag-btn" data-a="rename" title="Rename this tag on every section and row that carries it">Rename</button>`++ `<button type="button" class="tag-btn danger" data-a="del" title="Remove this tag from ${esc(tagCountText(e))}. One Ctrl+Z puts it back.">Remove</button></span>`; list.appendChild(row); row.querySelector('[data-a="del"]').addEventListener('click',()=>{ tagEditing=null; removeTagEverywhere(e.tag); }); row.querySelector('[data-a="rename"]').addEventListener('click',()=>{ tagEditing=e.key; renderTagsCard(); if(tagEditing){ const i=list.querySelector('.tag-nm'); if(i){ i.focus(); i.select(); } } });@@ -6915,6 +7124,7 @@ function openBatchEditor(){ <div class="batch-row"><label>shift start</label><input type="number" id="bShiftS" value="0" step="1"> <span>days</span></div> <div class="batch-row"><label>shift end</label><input type="number" id="bShiftE" value="0" step="1"> <span>days</span></div> </div>+ <div class="batch-tags" id="bTagsWrap"></div> <div class="de-actions"><button class="de-btn primary" id="bApply">Apply</button><button class="de-btn secondary" id="bClose">Cancel</button><button class="de-btn danger" id="bDelete" title="${esc(batchDeleteTitle(rows.length,others))}">Delete</button></div>`; showEditor(window.innerWidth/2-155,window.innerHeight/2-130,'compact'); @@ -6933,6 +7143,42 @@ function openBatchEditor(){ $('bClose').addEventListener('click',closeEditor); $('bDelete').addEventListener('click',()=>deleteBatchFromPanel()); wireBatchCopy();+ renderBatchTags();+}+// Tags across the selection (0.60.0). Unlike the dates above, a tag means the same thing+// on a milestone as on a task, so these act on EVERY selected row, milestones included,+// and they act at once rather than on Apply: each press is its own edit, one undo entry+// and one changelog batch (`tags`, via batch-editor) however many rows it touches. The+// chips list the own tags the selection carries, with how many rows carry each; the x+// takes that tag off all of them.+function renderBatchTags(){+ const w=$('bTagsWrap'); if(!w)return;+ const rows=selectedRows().filter(r=>r&&!isGroup(r));+ const m=new Map();+ rows.forEach(r=>ownTags(r).forEach(t=>{ const k=tagKey(t); if(!m.has(k))m.set(k,{t,n:0}); m.get(k).n++; }));+ w.innerHTML=`<div class="batch-row bt-row"><label>tags</label><input type="text" id="bTag" list="tagList" maxlength="${TAG_MAX_LEN}" autocomplete="off" placeholder="a tag" aria-label="A tag to add to every selected row">`+ +`<button type="button" class="de-btn secondary" id="bTagAdd" title="Add this tag to all ${plural(rows.length,'selected row')}, milestones included. One Ctrl+Z takes it back.">Add to all</button></div>`+ +(m.size?`<div class="bt-chips">`+[...m.values()].map(e=>`<span class="sec-tag" title="${esc(e.t)}: on ${e.n} of the ${rows.length} selected">${esc(e.t)} <small>${e.n}</small><button type="button" class="bt-x" data-tag="${esc(e.t)}" title="Remove ${esc(e.t)} from every selected row that carries it" aria-label="Remove ${esc(e.t)} from every selected row">×</button></span>`).join('')+'</div>'+ :`<div class="bt-none">No selected row carries a tag of its own.</div>`);+ const inp=$('bTag');+ const add=()=>{ const words=inp.value.split(',').map(x=>x.trim()).filter(Boolean); if(!words.length){ inp.focus(); return; }+ batchTags(r=>[...ownTags(r),...words]); };+ // Each press repaints this block, which takes the pressed button out of the page before+ // the document's click-outside check sees the click, and a detached target reads as+ // "outside": so the click stops here, as the tag box's x does.+ $('bTagAdd').addEventListener('click',e=>{ e.stopPropagation(); add(); });+ // A text field stops its own keys, so Ctrl+Z in it is the field's undo and not the plan's.+ inp.addEventListener('keydown',e=>{ e.stopPropagation(); if(e.key==='Enter'){ e.preventDefault(); add(); } else if(e.key==='Escape'){ e.preventDefault(); if(inp.value)inp.value=''; else inp.blur(); } });+ w.querySelectorAll('.bt-x').forEach(b=>b.addEventListener('click',e=>{ e.stopPropagation(); const k=tagKey(b.dataset.tag);+ batchTags(r=>ownTags(r).some(t=>tagKey(t)===k)?ownTags(r).filter(t=>tagKey(t)!==k):null); }));+}+function batchTags(fn){+ const set=new Set(selectedRows());+ const n=editTags(null,null,{rows:r=>set.has(r)?fn(r):null,via:'batch-editor'});+ renderBatchTags();+ const inp=$('bTag'); if(inp)inp.focus();+ if(n)flash(saveInd,'tags changed on '+plural(n,'row'));+ return n; } // The one sentence the batch Delete has to get right. Everything ABOVE it in this panel // is about the dates, and the dates are a task-only edit, so the headline says how many@@ -6962,7 +7208,7 @@ function updateMilestone(r){ drawDeps(); } function snapshotMs(r){- pushUndo({type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description,prov:r.provenance});+ pushUndo({type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,tags:tagsSnap(r)}); } // Milestone chrome that is not the date: the label row, the tooltip body and the // dashed rule's tag all restate the milestone, so they move together.@@ -6971,16 +7217,16 @@ function refreshMsChrome(r){ syncCancelMark(r); if(r._nameSpan)r._nameSpan.innerHTML=nameInner(r); else if(r._lDiv)r._lDiv.textContent=r.name;- if(r._msHit){r._msHit.dataset.title=r.name;r._msHit.dataset.body=r.description||'';r._msHit.dataset.prov=provLabel(r);}+ if(r._msHit){r._msHit.dataset.title=r.name;r._msHit.dataset.body=r.description||'';r._msHit.dataset.prov=provLabel(r);r._msHit.dataset.tags=ownTags(r).join(', ');} if(r._msLine){const cap=r._msLine.querySelector('.ms-cap');- if(cap){cap.textContent=(r.name||'').replace(/^M(\d+):\s*/,'M$1 ');cap.dataset.title=r.name;cap.dataset.body=r.description||'';cap.dataset.prov=provLabel(r);}}+ if(cap){cap.textContent=(r.name||'').replace(/^M(\d+):\s*/,'M$1 ');cap.dataset.title=r.name;cap.dataset.body=r.description||'';cap.dataset.prov=provLabel(r);cap.dataset.tags=ownTags(r).join(', ');}} } // The bar's tooltip lives in its data-* attributes, so restating the item means // restating those too. This used to patch only the geometry and the dates, so a // description restored from the sidecar (or edited in the table) left the bar // hovering the version that was in the data file.-function updateBar(id){const r=items.find(d=>d.id===id),be=barEls[id];if(!r||!be)return;if(r._noteEl)r._noteEl.textContent=r.note||'';const x1=toX(r.start),x2=toX(r.end);be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';be.bar.style.background=phaseColor(r.phase);be.bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);be.bar.dataset.title=r.name;be.bar.dataset.body=r.description||'';be.bar.dataset.prov=provLabel(r);syncEditorDates(r);drawDeps();}+function updateBar(id){const r=items.find(d=>d.id===id),be=barEls[id];if(!r||!be)return;if(r._noteEl)r._noteEl.textContent=r.note||'';const x1=toX(r.start),x2=toX(r.end);be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';be.bar.style.background=phaseColor(r.phase);be.bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);be.bar.dataset.title=r.name;be.bar.dataset.body=r.description||'';be.bar.dataset.prov=provLabel(r);be.bar.dataset.tags=ownTags(r).join(', ');syncEditorDates(r);drawDeps();} // The shape goes with the content: every opener declares which one it wants, so closing // has to leave the element without any of them rather than let the last panel's aspect // or its metrics ride along into the next one.@@ -7686,12 +7932,13 @@ function showTip(el){if(dragState)return;clearTimeout(tipTimer);tipTimer=setTime if(!el.isConnected){hideTip();return;} // a re-render or a delete beat the delay const t=el.dataset.title,d=el.dataset.dates,b=el.dataset.body; if(!t)return;- const pv=el.dataset.prov;+ const pv=el.dataset.prov, tg=el.dataset.tags; tipEl.textContent=''; tipEl.appendChild(tipLine('tip-title',t)); if(d)tipEl.appendChild(tipLine('tip-dates',d)); if(b)tipEl.appendChild(tipLine('tip-body',b)); if(pv)tipEl.appendChild(tipLine('tip-prov','source: '+pv));+ if(tg)tipEl.appendChild(tipLine('tip-prov','tags: '+tg)); tipEl.classList.add('show');posTipFor(el);},500);} // Anchored to the trigger, positioned ONCE, clamped on both axes. It used to be // placed from the pointer coordinates and re-placed on every mousemove, so it@@ -8668,6 +8915,65 @@ $('grainSel').addEventListener('change',function(){ }); } +// ── A row's tag box (0.60.0) ──+//+// The Setup page's chip editor, for a ROW, and one of it: the item editor, the New panel+// and the table's Tags cell all build this box. Enter or a comma adds what was typed, the+// x on a chip removes it, clicking away keeps what was typed, and the suggestions are the+// plan's tags (#tagList). Unlike the Setup box it edits a DRAFT: nothing reaches the plan+// until the panel's Apply or Create, or the cell's commit, reads get(), so a panel with+// three tag changes in it is still one undo and one save. `kind` pins the x buttons for+// the coverage gate (im, nc, cell).+function tagBoxHtml(id,label){+ return `<div class="tag-box" id="${id}"><span class="tb-chips"></span><input type="text" class="tb-in" id="${id}In" list="tagList" maxlength="${TAG_MAX_LEN}" autocomplete="off" aria-label="${esc(label)}"></div>`;+}+function wireTagBox(box,initial,kind,onChange){+ let list=(normalizeTags(initial)||[]).slice();+ const chips=box.querySelector('.tb-chips'), inp=box.querySelector('.tb-in');+ const paint=()=>{+ chips.innerHTML=list.map(t=>`<span class="sec-tag">${esc(t)}<button type="button" class="tb-x" data-tb="${kind}" data-tag="${esc(t)}" tabindex="-1" title="Remove the tag ${esc(t)}" aria-label="Remove the tag ${esc(t)}">×</button></span>`).join('');+ const full=list.length>=TAG_MAX_COUNT;+ inp.placeholder=full?'12 tags, the most a row can carry':list.length?'+ tag':'+ add a tag, Enter';+ inp.readOnly=full;+ // A box that grew past the bottom of its scroller (the table's last row, ten chips)+ // brings the caret back into view, so nobody types blind (0.60.0 review).+ if(kind==='cell'&&inp.isConnected)try{ inp.scrollIntoView({block:'nearest',inline:'nearest'}); }catch(_){}+ };+ // `onChange` (the table cell) saves each chip as it is added or removed, the way the+ // Setup box does, so the cell never shows a pill the plan does not have.+ const changed=()=>{ paint(); if(onChange)onChange(list.slice()); };+ const take=()=>{+ const words=inp.value.split(',').map(w=>w.trim()).filter(Boolean);+ inp.value='';+ if(!words.length)return false;+ const nx=normalizeTags([...list,...words]);+ if(sameTags(nx,list))return false;+ list=nx; changed(); return true;+ };+ // A press on an x must not take the focus out of the box: in a table cell that blur IS+ // the commit, and in a panel it would drop the caret.+ box.addEventListener('mousedown',e=>{ if(e.target!==inp)e.preventDefault(); });+ box.addEventListener('click',e=>{+ const x=e.target.closest('.tb-x');+ if(x){ e.stopPropagation(); list=list.filter(t=>tagKey(t)!==tagKey(x.dataset.tag)); changed(); }+ try{ inp.focus({preventScroll:true}); }catch(_){ inp.focus(); }+ });+ // Registered BEFORE the panel's or the cell's own keys, so Enter with text in the box+ // adds a tag and goes no further; Enter on an empty box is the panel's Apply (or the+ // cell's commit) as it is from every other field.+ inp.addEventListener('keydown',e=>{+ const stop=()=>{ e.preventDefault(); e.stopImmediatePropagation(); e.stopPropagation(); };+ if((e.key==='Enter'||e.key===',')&&inp.value.trim()){ stop(); take(); }+ else if(e.key===','){ stop(); }+ else if(e.key==='Escape'&&inp.value){ stop(); inp.value=''; }+ else if(e.key==='Backspace'&&!inp.value&&list.length&&!e.repeat){ stop(); list=list.slice(0,-1); changed(); }+ });+ inp.addEventListener('input',()=>{ if(inp.value.includes(','))take(); });+ inp.addEventListener('blur',()=>{ if(inp.value.trim())take(); });+ paint();+ return { get:()=>{ take(); return list.slice(); }, input:inp, box };+}+ // ── The full item editor ── // // Everything about one row in one panel: the same fields the create panel offers,@@ -8720,6 +9026,7 @@ function openItemModal(r,carriedName){ </div> <div class="de-note-row"><label>notes</label><input type="text" id="imDesc" placeholder="Shown on hover and in the table"></div> <div class="de-note-row"><label>provenance <span class="de-hint">where this came from</span></label><input type="text" id="imProv"></div>+ <div class="de-note-row"><label>tags <span class="de-hint">Enter or a comma adds one</span></label>${tagBoxHtml('imTags','Tags on this row')}</div> <div class="de-actions"><button class="de-btn primary" id="imApply">Apply</button> <button class="de-btn secondary" id="imCancel">Cancel</button> <button class="de-btn danger" id="imDelete" title="Delete this row from the plan. Undoable with Ctrl+Z">Delete</button></div>@@ -8740,6 +9047,10 @@ function openItemModal(r,carriedName){ $('imStatus').value=statusOf(r); $('imDesc').value=r.description||''; $('imProv').value=p.label||'';+ // The row's OWN tags only: the section's are the section's, edited on Setup.+ const tagBox=wireTagBox($('imTags'),ownTags(r),'im');+ const secT=phaseTagsOf(r);+ if(secT.length)$('imTags').title='Its section also gives it: '+secT.join(', ')+'. Those are edited on the Setup page.'; const rect=(r._lDiv||document.body).getBoundingClientRect(); showEditor(rect.right+12,rect.top,false); $('imName').focus();$('imName').select();@@ -8778,8 +9089,14 @@ function openItemModal(r,carriedName){ const pl=$('imProv').value.trim(); if(!pl)delete r.provenance; else r.provenance=p.url?{label:pl,url:p.url}:pl;+ // Tags ride in the same undo entry as every other field (editSnapshot carries them).+ // A row that had none and still has none is left without the key; one cleared here+ // keeps `[]`, the sidecar's "cleared".+ const nt=tagBox.get(), tagsMoved=!sameTags(nt,ownTags(r));+ if(tagsMoved)r.tags=nt; if(ms){refreshMsChrome(r);updateMilestone(r);} else {updateBar(r.id);refreshLabelRow(r);} if(phases[np]&&np!==r.phase)moveRowToPhase(r,np);+ if(tagsMoved)afterTagsChange(); applyCollapse();refreshChartGeometry();renderTable(); closeEditor(); scheduleSave('item-editor');@@ -8849,6 +9166,7 @@ function openCreateEditor(){ </div> <div class="de-note-row"><label>notes</label><input type="text" id="ncDesc" placeholder="Shown on hover and in the table" value="${esc(descVal)}"></div> <div class="de-note-row"><label>provenance <span class="de-hint">where this came from</span></label><input type="text" id="ncProv" value="${esc(provVal)}"></div>+ <div class="de-note-row"><label>tags <span class="de-hint">Enter or a comma adds one</span></label>${tagBoxHtml('ncTags','Tags on the new row')}</div> <div class="de-actions"><button class="de-btn primary" id="ncCreate">Create</button> <button class="de-btn secondary" id="ncCancel">Cancel</button></div> <div id="ncErr" style="color:#f85149;font-size:11px;margin-top:6px;display:none"></div>`;@@ -8868,6 +9186,9 @@ function openCreateEditor(){ $('ncEndLabel').textContent=ms?'date':'deadline'; }; $('ncType').addEventListener('change',syncType); syncType();+ // Carried like every other field but the name: six rows for the same "long-lead" work+ // are six names typed and nothing else.+ const tagBox=wireTagBox($('ncTags'),memo.tags||[],'nc'); $('ncName').focus(); $('ncCancel').addEventListener('click',closeEditor); setEditorKeys(e=>{e.stopPropagation();@@ -8890,6 +9211,7 @@ function openCreateEditor(){ const st=$('ncStatus').value; const desc=$('ncDesc').value.trim(); const prov=$('ncProv').value.trim();+ const tags=tagBox.get(); creating=true; const btn=$('ncCreate'); if(btn)btn.disabled=true; let uid=null;@@ -8911,6 +9233,7 @@ function openCreateEditor(){ if(st)r.status=st; if(desc)r.description=desc; if(prov)r.provenance=prov;+ if(tags.length)r.tags=tags.slice(); // Only now, past validation and past the uid allocation, with a row actually // being made. A milestone hides the start field but the input keeps its value, // so `start` is whatever was carried in and the remembered start comes through@@ -8920,7 +9243,7 @@ function openCreateEditor(){ // recorded as absent so the next panel recomputes the default from the // signed-in identity instead of freezing today's name into the session. lastCreated={type:isMs?'milestone':'task', phase, owner:owner||'', start, end,- status:st||'', desc:desc||'',+ status:st||'', desc:desc||'', tags:tags.slice(), prov:(prov===provDefault)?undefined:prov}; addedItems.push(r); insertRow(r);@@ -8928,6 +9251,7 @@ function openCreateEditor(){ closeEditor(); renderTable(); rebuildTimeline(); // a new date can widen the axis+ if(tags.length)afterTagsChange(); // a tag new to the plan gets its legend chip scheduleSave('create'); flash(saveInd,'added '+(isMs?'milestone':'task')); });@@ -9524,12 +9848,14 @@ function sortValueOf(r,key){ if(key==='status'){const k=statusOf(r);const i=STATUS_ORDER.indexOf(k);return i<0?(k?'z'+k:''):String(i);} if(key==='deps'){const d=depsOf(r);const n=d.needs.length+d.blocks.length;return n?String(1000+n):'';} if(key==='notes')return (r.description||'').toLowerCase();+ // Own tags first, then the section's, the order the cell shows them in; untagged last.+ if(key==='tags')return effTags(r).join(' ').toLowerCase(); if(key==='prov')return provLabel(r).toLowerCase(); return deadlineOf(r); } function tableRows(){ const q=(searchInp.value||'').trim().toLowerCase();- const own=ownerSel.value, st=statusSel.value;+ const own=ownerSel.value, st=statusSel.value, ks=tagFilterKeys(); const rows=[]; items.forEach(r=>{ if(isGroup(r))return;@@ -9539,13 +9865,14 @@ function tableRows(){ // rather than the per-row _collapsed flag, so it is right even before a reflow. if(r.phase!==undefined&&isCollapsed(r.phase))return; if(r.phase!==undefined&&!phaseVis[r.phase])return;- if(r.phase!==undefined&&!phaseShown(r.phase))return;+ // The tag filter is a ROW rule since 0.60.0: own tags plus the section's.+ if(!rowShown(r,ks))return; if(isMs(r)&&!msVis)return; const o=ownerOf(r); if(own===UNASSIGNED){ if(o)return; } else if(own&&o!==own) return; if(st&&statusOf(r)!==st)return; if(q){- const hay=`${r.name||''} ${o} ${phaseNameOf(r)} ${phaseTagsOf(r).join(' ')} ${r.description||''} ${r.status||''} ${deadlineOf(r)} ${fmt(deadlineOf(r))} ${provLabel(r)}`.toLowerCase();+ const hay=`${r.name||''} ${o} ${phaseNameOf(r)} ${effTags(r).join(' ')} ${r.description||''} ${r.status||''} ${deadlineOf(r)} ${fmt(deadlineOf(r))} ${provLabel(r)}`.toLowerCase(); if(hay.indexOf(q)<0)return; } rows.push(r);@@ -9610,8 +9937,21 @@ function depCell(r){ (needs.length&&blocks.length?' ':'')+ (blocks.length?`→${blocks.length}`:'')+'</span>'; }+// The Tags cell: the row's own tags as pills, then its section's, dashed and fainter, so+// what the legend's tag filter reads (own plus section) is on the row in plain sight. Only+// the row's own are edited here; the title says where the others come from.+function tagsCell(r,ed){+ const own=ownTags(r), ok=new Set(own.map(tagKey)), sec=phaseTagsOf(r).filter(t=>!ok.has(tagKey(t)));+ const tip=[own.length?'Own: '+own.join(', '):'',sec.length?'From its section: '+sec.join(', '):''].filter(Boolean).join('. ');+ return `<td class="c-tags${ed}${own.length||sec.length?'':' empty'}" data-field="tags"${tip?` title="${esc(tip)}"`:''}><span class="tags-trunc">`+ +own.map(t=>`<span class="row-tag">${esc(t)}</span>`).join('')+ +sec.map(t=>`<span class="row-tag inh">${esc(t)}</span>`).join('')+'</span></td>';+} function renderTable(){ if(!tableBody)return;+ // A Tags cell saving chip by chip must not be repainted out from under its own editor;+ // finishing the cell renders the table.+ if(tblEditing&&tblEditing.field==='tags'&&tblEditing.td.isConnected)return; syncOwnerControls(); syncSortHeaders(); syncSortLock();@@ -9623,9 +9963,9 @@ function renderTable(){ // Collapse all empties the table as surely as a filter does, and "no items match" // would send the reader looking at the filters for a cause that is not there. const why=allLanesCollapsed()?'Every section is collapsed. Expand all, at the start of the legend, to see the rows.'- :tagFilterOn()&&!phases.some((p,i)=>phaseShown(i)&&laneRows(i).length)?'No section with these tags has any rows. Pick other tags in the legend, or clear them.'+ :tagFilterOn()&&!items.some(r=>!isGroup(r)&&rowShown(r))?'No row carries these tags, on itself or through its section. Pick other tags in the legend, or clear them.' :'No items match these filters.';- tableBody.innerHTML='<tr><td colspan="10" class="table-empty">'+why+'</td></tr>';return;}+ tableBody.innerHTML='<tr><td colspan="11" class="table-empty">'+why+'</td></tr>';return;} tableBody.innerHTML=rows.map(r=>{ const ms=isMs(r), editable=ms||!!r.id; const o=ownerOf(r), st=statusOf(r), dl=deadlineOf(r), sd=ms?'':(r.start||'');@@ -9644,6 +9984,7 @@ function renderTable(){ +`<td class="c-status${ed}" data-field="status">${st?`<span class="tag ${statusTagFor(st)}">${esc(statusTextFor(st))}</span>`:'<span class="tag tag-not-started">none</span>'}</td>` +`<td class="c-deps${(()=>{const d=depsOf(r);return (d.needs.length||d.blocks.length)?'':' empty';})()}">${depCell(r)}</td>` +`<td class="c-notes${ed}${r.description?'':' empty'}" data-field="notes"><span class="trunc">${r.description?esc(r.description):'none'}</span></td>`+ +tagsCell(r,ed) +`<td class="c-prov${ed}" data-field="prov">${provCell(r)}</td>` +`<td class="c-del">${editable?`<button class="row-del" type="button" tabindex="-1" title="Delete this row from the plan">×</button>`:''}</td>` +'</tr>';@@ -9786,7 +10127,7 @@ function fixedCols(){ return prefs.tableCols&&Object.keys(prefs.tableCols).lengt // width, and the table is pixel-driven from then on. That is also what Excel does: // before you touch anything the columns are whatever the sheet says, and the moment // you drag one they are numbers you own.-const COL_DEFAULTS={name:'22%',phase:'9%',owner:'8%',start:'7%',deadline:'7%',status:'8%',deps:'5%',notes:'20%',prov:'14%',del:'30px'};+const COL_DEFAULTS={name:'21%',phase:'9%',owner:'8%',start:'7%',deadline:'7%',status:'8%',deps:'5%',notes:'15%',tags:'9%',prov:'11%',del:'30px'}; function applyColWidths(){ const table=tableHead.closest('table'); if(!table)return;@@ -10370,14 +10711,27 @@ function startCellEdit(td,caret){ } else if(field==='owner'){ el=document.createElement('input');el.type='text';el.setAttribute('list','ownerList'); el.placeholder='one name';el.value=r.owner||'';+ } else if(field==='tags'){+ // The same chip box the item editor uses, laid over the cell; `el` is its input, so+ // Tab, Enter on an empty box, Escape and the blur commit are the cell's own. The draft+ // is read at commit (el._tags), and the whole cell session is one undo.+ const holder=document.createElement('div'); holder.innerHTML=tagBoxHtml('cellTags','Tags on this row');+ const box=holder.firstElementChild; box.removeAttribute('id');+ el=box.querySelector('.tb-in'); el.removeAttribute('id');+ // Saved per chip while the cell stays open (0.60.0 review): each add or remove is its+ // own edit, one undo and one `tags` line, as on the Setup page. The table does not+ // repaint under the open editor (renderTable waits for it), and the commit at the+ // end only picks up text still in the box; Escape discards just that text.+ const tb=wireTagBox(box,ownTags(r),'cell',list=>editTags(null,null,{rows:x=>x===r?list:null,via:'table-edit'}));+ el._box=box; el._tags=tb.get; } else { el=document.createElement('input');el.type='text';el.value=r.description||''; }- el.className='cell-input';+ el.className=el._box?'cell-input tb-in':'cell-input'; // Overlaid, not swapped in: the cell keeps its own content underneath so its // intrinsic width never changes, and the overlay is out of flow so the column // cannot resize around the editor.- const wrap=document.createElement('div');wrap.className='cell-edit';wrap.appendChild(el);+ const wrap=document.createElement('div');wrap.className='cell-edit'+(el._box?' tags-edit':'');wrap.appendChild(el._box||el); td.appendChild(wrap); // One click has to leave a usable editor, not just a rendered one. This runs@@ -10438,7 +10792,7 @@ function startCellEdit(td,caret){ // Blur before the re-render so focus lands on the document rather than being // orphaned on a node that is about to be thrown away. try{ el.blur(); }catch(e){}- if(save)commitCell(r,field,el.value);+ if(save)commitCell(r,field,el._tags?el._tags():el.value); renderTable(); }; tblEditing={td,el,r,field,cancel:()=>finish(false)};@@ -10475,6 +10829,9 @@ function startCellEdit(td,caret){ function commitCell(r,field,v){ const ms=isMs(r);+ // Tags go through the one tag path, so the entry, the filter prune, the legend and the+ // changelog line (`tags`, via table-edit) are the ones every other tag edit makes.+ if(field==='tags'){ editTags(null,null,{rows:x=>x===r?v:null,via:'table-edit'}); return; } const snap=()=>{ if(ms)snapshotMs(r); else snapshot(r.id); }; if(field==='owner'){ v=v.trim().replace(/\s+/g,' ');@@ -10816,6 +11173,18 @@ function analyticsModel(){ from:ds.length?ds.reduce((m,d)=>dayNum(d)<dayNum(m)?d:m):'', to:ds.length?ds.reduce((m,d)=>dayNum(d)>dayNum(m)?d:m):''}; });+ // Per tag (0.60.0), by EFFECTIVE tags, the same rule the legend filter uses: a row+ // counts under its own tags and its section's. A row with two tags counts under both, so+ // the shares can add up to more than 100%; the rows with no tag at all are one line of+ // their own, so what nobody has classified is as visible as what somebody has.+ const tagRows=planTags().map(e=>({tag:e.tag,key:e.key,items:0,tasks:0,ms:0,hours:0}));+ const tagIdx=new Map(tagRows.map(t=>[t.key,t]));+ const untagged={items:0,tasks:0,ms:0,hours:0};+ rows.forEach(r=>{+ const ks=effTags(r).map(tagKey);+ const bs=ks.length?ks.map(k=>tagIdx.get(k)).filter(Boolean):[untagged];+ bs.forEach(b=>{ b.items++; if(isMs(r))b.ms++; else if(r.start&&r.end){ b.tasks++; b.hours+=effortHoursOf(r); } });+ }); const today=anToday(); // Overdue is "this was due and it did not happen", so it takes a SET of statuses // that settle a row rather than the single compare it used to be: complete means it@@ -10848,7 +11217,7 @@ function analyticsModel(){ const avgHours=loaded.length?Math.round(assignedHours/loaded.length):0; return {rows,tasks,ms:ms.sort((a,b)=>dayNum(a.date)-dayNum(b.date)),totalHours, first,last,spanBiz,people,status,lanes,overdue,- remainBiz,remainingHours,loaded:loaded.length,assignedHours,avgHours,+ remainBiz,remainingHours,loaded:loaded.length,assignedHours,avgHours,tagRows,untagged, deps:{edges:g.edges,isolated:noDeps,cycle:cyc},today}; } function renderAnalytics(){@@ -10918,6 +11287,19 @@ function renderAnalytics(){ +lr+'<tr class="total"><td>Total</td><td class="n">'+m.rows.length+'</td><td class="n">'+esc(hrs(m.totalHours))+'</td>' +'<td class="n">'+(m.first?esc(fmt(m.first))+' to '+esc(fmt(m.last)):'')+'</td></tr></tbody></table>'):'<div class="an-empty">This plan has no sections.</div>')); + // 4b. tags (0.60.0), only when the plan has any: a card that says "no tags" on every+ // plan that never uses them is noise.+ if(m.tagRows.length){+ const trow=(name,b,muted)=>'<tr><td class="name'+(muted?' muted':'')+'">'+name+'</td>'+ +'<td style="width:24%">'+anBar(pct(b.hours,m.totalHours))+'</td>'+ +'<td class="n">'+b.tasks+(b.ms?' +'+b.ms+'M':'')+'</td><td class="n">'+esc(hrs(b.hours))+'</td>'+ +'<td class="n muted">'+pct(b.hours,m.totalHours)+'%</td></tr>';+ const tr=m.tagRows.map(t=>trow('<span class="sec-tag an-tag">'+esc(t.tag)+'</span>',t)).join('')+ +trow('no tag',m.untagged,true);+ out.push(card('wide','Per tag','Every tag in the plan, counted the way the legend filters: a row carries its own tags and its section\'s. A row with two tags counts under both, so the shares can add up to more than 100%. Cancelled work counts as an item and not in the hours.',+ '<table class="an-table" id="anTags"><thead><tr><th>Tag</th><th>Share of hours</th><th class="n">Items</th><th class="n">Hours</th><th class="n">%</th></tr></thead><tbody>'+tr+'</tbody></table>'));+ }+ // 5. milestones const mr=m.ms.map(r=>{const d=daysFromToday(r.date); return '<tr><td class="name">'+esc(r.name)+'</td><td class="n">'+esc(fmt(r.date))+'</td>'@@ -11058,6 +11440,8 @@ items.forEach(r=>{ if(r.id)refreshLabelRow(r); else if(r.type==='milestone')refr // saved tag filter is checked against them now, and the legend is rebuilt with them. tagsReady=true; pruneTagFilter();+syncTagList();+tagSig=tagSigNow(); rebuildLegend(); applyCollapse(); // Re-baseline AFTER restoring the sidecar, so replaying saved edits on load is
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.59.0",+ "version": "0.60.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.59.0",+ "version": "0.60.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+6−3@@ -875,8 +875,9 @@ function setOpt(r, k, v) { // // ONE normaliser, used everywhere the server reads a tag list: the data file on // the way out, the sidecar, the /save body, every importer. Trim, drop empties,-// drop anything that is not a string, de-duplicate without regard to case (the-// first spelling wins), cap each tag at 32 characters and the list at 12. Kept in+// drop anything that is not a string, turn `;` into a space (it is the CSV's+// separator), de-duplicate without regard to case (the first spelling wins), cap+// each tag at 32 characters and the list at 12. Kept in // step with the client's copy for the same reason statusKey is. // // Returns an ARRAY, possibly empty, for an array in, and undefined for anything@@ -890,7 +891,9 @@ function normalizeTags(v) { const out = [], seen = new Set(); for (const t of v) { if (typeof t !== 'string') continue;- const s = t.trim().slice(0, TAG_MAX_LEN).trim();+ // `;` is the CSV's tag separator, so it cannot be IN a tag: a `x;y` tag would come+ // back from a CSV round trip as two. It becomes a space (0.60.0 review).+ const s = t.replace(/;/g, ' ').trim().slice(0, TAG_MAX_LEN).trim(); if (!s || seen.has(s.toLowerCase())) continue; seen.add(s.toLowerCase()); out.push(s);
test/server_test.mjs+6@@ -139,6 +139,12 @@ async function main() { ok(garbage.status === 200, 'odd /save body (null entries, nested junk) is not a 500', garbage.status); ex = await getJ(A, '/export/json'); ok(!('tags' in byId(ex, 'etch')), 'a tag list of only non-strings normalises to [] (cleared)', byId(ex, 'etch'));+ // `;` is the CSV separator, so it cannot be inside a tag (0.60.0 review): it becomes a+ // space, and the CSV then round-trips the list it was given.+ await post(A, '/save', { v: 4, items: [{ uid: etchUid, tags: ['x;y', 'c;', ';'] }], phases: [], order: [], deleted: [], added: [] });+ ex = await getJ(A, '/export/json');+ const semiCsv = (await getT(A, '/export/csv')).split('\n').find(l => l.includes(',etch,')) || '';+ ok(eq(byId(ex, 'etch').tags, ['x y', 'c']) && semiCsv.includes(',x y;c,'), 'a ";" inside a tag becomes a space, so the CSV keeps one tag as one', { tags: byId(ex, 'etch').tags, semiCsv }); // put tags back for the rest await post(A, '/save', { v: 4, items: [{ uid: etchUid, tags: ['long-lead', 'Etch'] }, { uid: msUid, tags: ['gate'] }], phases: [{ i: 0, start: '2026-10', end: '2026-11', tags: ['Tech'] }], order: [], deleted: [], added: [] });
test/ui_test.mjs+393−5@@ -76,7 +76,7 @@ const sleep = ms => new Promise(r => setTimeout(r, ms)); // ── Seeded plan: 20 sections, first date 21-Sep-2026, last date 14-Jan-2027 ── // Synthetic on purpose, so the suite ships without anyone's real plan in it and every-// expected figure below can be worked out by hand. Same shape as the instaPCB launch+// expected figure below can be worked out by hand. Same shape as the reference launch // plan Drew reported the legend and header problems against: 20 phases, ~110 rows. const FIRST = '2026-09-21', LAST = '2027-01-14'; const TODAY = '2026-09-29';@@ -95,7 +95,10 @@ function seedPlan({ appName = 'launch-demo', title = 'Launch Demo: Jan 11, 2027' // five tasks per lane, staggered, all inside [FIRST, LAST - 7] for (let t = 0; t < 5; t++) { const s = addDaysISO(FIRST, 7 + p * 3 + t * 5);- items.push({ type: 'task', id: `p${p}t${t}`, name: `Lane ${p + 1} task ${t + 1}`, start: s, end: addDaysISO(s, 4), phase: p, status: 'not-started', owner: ['Drew', 'John', 'Ana'][t % 3], uid: U() });+ items.push({ type: 'task', id: `p${p}t${t}`, name: `Lane ${p + 1} task ${t + 1}`, start: s, end: addDaysISO(s, 4), phase: p, status: 'not-started', owner: ['Drew', 'John', 'Ana'][t % 3], uid: U(),+ // Row tags (0.60.0) on the tagged plan: one in a tagged section, one in an untagged one,+ // so the item editor and the batch panel open with chips to walk.+ ...(tags && t === 0 && (p === 0 || p === 19) ? { tags: ['long-lead'] } : {}) }); } items.push({ type: 'milestone', name: `Lane ${p + 1} gate`, date: addDaysISO(FIRST, 30 + p * 2), phase: p, uid: U() }); }@@ -830,6 +833,7 @@ async function main() { if (wantAny('export menu', 'import menu')) await menuFlows(browser, S); if (wantAny('sections')) await sectionsFlow(browser); if (wantAny('tags')) await tagsFlow(browser);+ if (wantAny('row tags')) await rowTagsFlow(browser); if (wantAny('viewport')) { // ── Viewport matrix ──@@ -880,7 +884,7 @@ async function main() { // 40px sweep plus the ones real screens and the adversary review used; set // SWEEP_STEP=20 for a denser pass. section = 'header sweep';- const NA = await startServer(seedPlan({ appName: null, title: 'instaPCB Launch: Jan 11, 2027' }), 'no-appname');+ const NA = await startServer(seedPlan({ appName: null, title: 'Product Launch: Jan 11, 2027' }), 'no-appname'); await headerSweep(browser, [['appName', S.url], ['title fallback', NA.url]]); } @@ -1099,7 +1103,15 @@ const MANIFEST = [ // Tags (0.59.0). The legend's tag chips are a filter, a view setting like the section // chips, so they are safe and clicked twice; the Setup page's tag edits change the plan // and are proven by the `tags` flow.- { sel: '.tag-chip', label: 'legend tag chip (filter)', safe: true, twice: true, key: c => c.ds.tag, pin: ['launch', 'tech', 'process'] },+ { sel: '.tag-chip', label: 'legend tag chip (filter)', safe: true, twice: true, key: c => c.ds.tag, pin: ['launch', 'long-lead', 'tech', 'process'] },+ // Row tags (0.60.0): the tag box in the item editor and the New panel, its chip x, and the+ // batch panel's add and remove. They change the plan; the `row tags` flow proves each.+ { sel: '#imTagsIn', label: 'item editor: tags', terminal: true, by: 'row tags' },+ { sel: '#ncTagsIn', label: 'new: tags', terminal: true, by: 'row tags' },+ { sel: '.tb-x', label: 'tag box: remove a chip', terminal: true, by: 'row tags', key: c => c.ds.tb, pin: ['im'] },+ { sel: '#bTag', label: 'batch: tag to add', terminal: true, by: 'row tags' },+ { sel: '#bTagAdd', label: 'batch: add the tag to all', terminal: true, by: 'row tags' },+ { sel: '.bt-x', label: 'batch: remove a tag from all', terminal: true, by: 'row tags', key: c => c.ds.tag, pin: ['long-lead'] }, { sel: '#legendSecs', label: 'legend: fold or unfold the section chips', safe: true, twice: true }, { sel: '#tagClear', label: 'legend: clear the tag filter', safe: true }, { sel: '.sec-tag-in', label: 'section tag: type to add', terminal: true, by: 'tags' },@@ -1648,7 +1660,7 @@ async function menuFlows(browser, S) { // Hardened after the 0.58.0 review, which found three data bugs this flow could not see: // its seed had rows in only the first two sections and no uids, it never reloaded after a // structural change, and it never undid one. So now: a task in EVERY section and a-// milestone in two, no phase uids (like the real instaPCB plan), and after every+// milestone in two, no phase uids (like the reference launch plan), and after every // structural step (add, reorder, remove, and each of their undos and redos) the WHOLE // section list and EVERY row's section are compared with /export/json by name, and the // page is checked to have repainted to match.@@ -2150,6 +2162,382 @@ async function tagsFlow(browser) { await sp.close(); } +// ── Row tags (0.60.0) ──+// Tags on tasks and milestones, own plus section ("effective"). Every edit path is driven+// and its effect read back from /export/json after the save: the item editor, the New+// panel, the table's Tags cell, the batch panel's add and remove, a paste, and the undo+// and redo of each. Then the changelog's `tags` lines, the filter cases with row counts in+// both lenses, the Analytics per-tag card against numbers worked out by hand from the+// seed, the Setup card's row counts and its rename/remove everywhere, CSV out and back in,+// a reload, and the snapshot with no server.+async function rowTagsFlow(browser) {+ section = 'row tags';+ const T = (id, name, ph, x = {}) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, ...x });+ // Hours, by hand (working days x 8h; Mon 5-Oct to Fri 9-Oct 2026 is 5 working days):+ // Fab one 40, Fab two 40, Ops one 40, Ops two 40, Misc one (Tue 6 to Wed 7) 16,+ // Ops dropped 0 (cancelled; it would be 10 days, 80h). Total 176h.+ const X = await startServer({ project: { title: 'Row Tags Test', appName: 'row-tags-test', view: { mode: 'gantt', grain: 'week', labelWidth: 300 } },+ phases: [{ name: 'Tech · Fab', start: '2026-09', end: '2026-12', tags: ['Tech'] }, { name: 'Ops', start: '2026-09', end: '2026-12' }, { name: 'Misc', start: '2026-09', end: '2026-12' }],+ items: [T('f1', 'Fab one', 0), T('f2', 'Fab two', 0), { type: 'milestone', name: 'Fab gate', date: '2026-10-20', phase: 0 },+ T('o1', 'Ops one', 1, { tags: ['long-lead'] }), T('o2', 'Ops two', 1), T('o3', 'Ops dropped', 1, { end: '2026-10-16', status: 'cancelled', tags: ['long-lead'] }),+ T('m1', 'Misc one', 2, { start: '2026-10-06', end: '2026-10-07' })] }, 'row-tags');+ let page = await openPage(browser, X.url);+ const redo = async p => { p = p || page; await p.keyboard.down('Control'); await p.keyboard.down('Shift'); await p.keyboard.press('KeyZ'); await p.keyboard.up('Shift'); await p.keyboard.up('Control'); await settle(p, 250); };+ const blur = () => page.evaluate(() => document.activeElement && document.activeElement.blur());+ const own = pl => Object.fromEntries(pl.items.filter(r => r.type !== 'group').map(r => [r.name, r.tags || []]));+ let W = { 'Fab one': [], 'Fab two': [], 'Fab gate': [], 'Ops one': ['long-lead'], 'Ops two': [], 'Ops dropped': ['long-lead'], 'Misc one': [] };+ const expectOwn = async (what, want) => {+ const got = own(await savedPlan(X.url));+ const keys = [...new Set([...Object.keys(want), ...Object.keys(got)])];+ const bad = keys.filter(k => JSON.stringify(got[k]) !== JSON.stringify(want[k])).map(k => `${k}: ${JSON.stringify(got[k])} (want ${JSON.stringify(want[k])})`);+ check(`${what}: saved row tags`, bad.length === 0, bad.join('; '));+ };+ const tagLog = async () => (await (await fetch(X.url + 'changelog')).json()).events.filter(e => e.action === 'tags');+ const md = async () => (await fetch(X.url + 'changelog?format=md')).text();+ const vis = () => page.evaluate(() => ({+ tasks: [...document.querySelectorAll('.label-row:not(.group):not(.ms)')].filter(e => e.checkVisibility()).map(e => e.querySelector('.name-text').textContent),+ heads: [...document.querySelectorAll('.label-row.group')].filter(e => e.checkVisibility()).map(e => e.querySelector('.lane-title').textContent),+ }));+ const tableNames = async () => { await view(page, 'table'); const n = await page.$$eval('#tableView tbody tr[data-key] td.c-name', e => e.map(x => x.textContent.replace('◆', '').trim())); await view(page, 'gantt'); return n; };++ // ── On the chart: the label row and the tooltip ──+ await view(page, 'gantt');+ const lbl = n => page.evaluate(n => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.querySelector('.name-text')?.textContent === n); const t = e && e.querySelector('.row-tags'); return t ? t.textContent : ''; }, n);+ check('a row\'s own tags show quietly in its label row, after the owner', await lbl('Ops one') === '#long-lead' && await lbl('Fab one') === '');+ check('and on its bar\'s tooltip', await page.$eval('.bar[data-id="o1"]', b => b.dataset.tags) === 'long-lead');+ await page.hover('.bar[data-id="o1"]'); await sleep(700);+ check('the tooltip reads "tags: long-lead"', (await page.$eval('#tip', e => e.textContent).catch(() => '')).includes('tags: long-lead'));+ await page.mouse.move(700, 5);+ const tc = async t => page.$eval(`.tag-chip[data-tag="${t}"] .tc-n`, e => e.textContent).catch(() => null);+ check('the legend has a chip for a tag only rows carry, and its count is the rows it finds', await tc('long-lead') === '2' && await tc('tech') === '3', `${await tc('long-lead')} ${await tc('tech')}`);++ // ── The filter: effective tags ──+ await page.click('.tag-chip[data-tag="long-lead"]'); await settle(page, 200);+ let v = await vis();+ check('"long-lead" in an untagged section: its rows are shown with their section header, the untagged row beside them is not (2 chart rows)',+ JSON.stringify(v.tasks) === JSON.stringify(['Ops one', 'Ops dropped']) && JSON.stringify(v.heads) === JSON.stringify(['Ops']), JSON.stringify(v));+ check('and the table agrees (2 rows)', JSON.stringify(await tableNames()) === JSON.stringify(['Ops one', 'Ops dropped']));+ check('the Ops header\'s count says "2 of 3" while the filter hides one of its rows', await page.evaluate(() => [...document.querySelectorAll('.label-row.group')].find(g => g.querySelector('.lane-title').textContent === 'Ops').querySelector('.lane-count').textContent) === '2 of 3');+ await page.click('.tag-chip[data-tag="long-lead"]'); await page.click('.tag-chip[data-tag="tech"]'); await settle(page, 200);+ v = await vis();+ check('"Tech" on a section: every row in it is shown, none of them tagged on its own (2 chart task rows)', JSON.stringify(v.tasks) === JSON.stringify(['Fab one', 'Fab two']) && JSON.stringify(v.heads) === JSON.stringify(['Tech · Fab']), JSON.stringify(v));+ check('and the table has them and the section\'s milestone (3 rows)', JSON.stringify(await tableNames()) === JSON.stringify(['Fab one', 'Fab two', 'Fab gate']));+ await page.click('.tag-chip[data-tag="long-lead"]'); await settle(page, 200);+ v = await vis();+ check('both tags: either kind of match (4 chart rows, 2 headers; 5 table rows)', v.tasks.length === 4 && v.heads.length === 2 && (await tableNames()).length === 5, JSON.stringify(v));+ await page.click('#tagClear'); await settle(page, 200);+ check('Clear tags shows all 6 task rows again', (await vis()).tasks.length === 6);++ // ── Analytics: per tag, effective, cancelled out of the hours ──+ const anTags = async () => { await view(page, 'analytics'); const r = await page.$$eval('#anTags tbody tr', rows => rows.map(tr => [...tr.querySelectorAll('td')].map(td => td.textContent.trim()).filter((x, i) => i !== 1).join('|'))); await view(page, 'gantt'); return r; };+ let an = await anTags();+ // Tech: Fab one + Fab two (80h) and Fab gate. long-lead: Ops one 40h + Ops dropped 0h (cancelled). No tag: Ops two 40 + Misc one 16.+ // Shares of 176h: 80 -> 45.5%, 40 -> 22.7%, 56 -> 31.8%.+ check('Analytics has a per-tag card counting effective tags, cancelled hours left out, and a "no tag" line',+ JSON.stringify(an) === JSON.stringify(['Tech|2 +1M|80h|45.5%', 'long-lead|2|40h|22.7%', 'no tag|2|56h|31.8%']), JSON.stringify(an));++ // ── Edit path 1: the item editor ──+ let n0 = (await tagLog()).length;+ await openItemEditor(page, 'Ops two');+ await page.click('#imTagsIn'); await page.keyboard.type('urgent'); await page.keyboard.press('Enter'); await settle(page, 80);+ check('Enter in the tag box adds a chip and does NOT apply the editor', await editorShown(page) && JSON.stringify(await page.$$eval('#imTags .sec-tag', e => e.map(x => x.firstChild.textContent))) === '["urgent"]');+ await page.keyboard.type('long-lead,'); await page.keyboard.type('zap'); await page.keyboard.press('Enter');+ await page.click('#imTags .tb-x[data-tag="zap"]'); await settle(page, 80);+ check('a comma adds one, and the x takes one off (draft: urgent, long-lead)', JSON.stringify(await page.$$eval('#imTags .sec-tag', e => e.map(x => x.firstChild.textContent))) === '["urgent","long-lead"]');+ await page.keyboard.type('late'); // typed and not yet added: Apply keeps it, as clicking away does+ await page.click('#imApply'); await settle(page, 250);+ W['Ops two'] = ['urgent', 'long-lead', 'late'];+ await expectOwn('the item editor saves the row\'s tags (a typed tag not yet added is kept)', W);+ let L = (await tagLog()).slice(n0);+ check('one changelog `tags` line, via item-editor, with the row\'s uid', L.length === 1 && L[0].via === 'item-editor' && L[0].name === 'Ops two' && !!L[0].uid+ && JSON.stringify(L[0].from.tags) === '[]' && JSON.stringify(L[0].to.tags) === '["urgent","long-lead","late"]', JSON.stringify(L));+ check('the markdown changelog reads it', (await md()).includes('tagged Ops two with urgent, long-lead, late (was none) (item-editor)') || /tagged "?Ops two"? with urgent, long-lead, late/.test(await md()));+ check('the label row shows the new tags', await lbl('Ops two') === '#urgent #long-lead #late');+ await undo(page);+ W['Ops two'] = [];+ await expectOwn('Ctrl+Z takes the editor\'s tags back', W);+ await redo();+ W['Ops two'] = ['urgent', 'long-lead', 'late'];+ await expectOwn('redo puts them back', W);+ L = (await tagLog()).slice(n0);+ check('the undo and redo are logged as `tags` lines too', L.length === 3 && L[1].via === 'undo' && L[2].via === 'redo', JSON.stringify(L.map(e => e.via)));+ // Clearing: the sidecar has to say "cleared", or a data-file tag comes back on reload.+ await openItemEditor(page, 'Ops one');+ await page.click('#imTags .tb-x[data-tag="long-lead"]'); await page.click('#imApply'); await settle(page, 250);+ W['Ops one'] = [];+ await expectOwn('clearing a data-file tag in the editor', W);+ await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ check('the cleared data-file tag stays cleared after a reload', await lbl('Ops one') === '' && JSON.stringify(own(await exportPlan(X.url))['Ops one']) === '[]');+ await undo(page).catch(() => {}); // nothing to undo after a reload; harmless+ await openItemEditor(page, 'Ops one');+ await page.click('#imTagsIn'); await page.keyboard.type('long-lead'); await page.keyboard.press('Enter'); await page.click('#imApply'); await settle(page, 250);+ W['Ops one'] = ['long-lead'];+ await expectOwn('(and put back)', W);++ // ── Edit path 2: the table's Tags cell ──+ n0 = (await tagLog()).length;+ await view(page, 'table');+ const cell = '#tableView tr[data-key="t:f1"] td[data-field="tags"]';+ check('the table has a Tags column showing own tags and, dashed, the section\'s', await page.$eval('#tableView tr[data-key="t:f1"] td.c-tags', td => [...td.querySelectorAll('.row-tag')].map(x => x.textContent + (x.classList.contains('inh') ? '~' : '')).join(',')) === 'Tech~');+ await page.click(cell); await settle(page, 100);+ check('clicking the cell opens the chip editor over it, focused', await page.evaluate(() => document.activeElement?.classList.contains('tb-in') && !!document.activeElement.closest('.cell-edit.tags-edit')));+ // 0.60.0 build: the cell clipped (overflow hidden makes it a scroll container), and the+ // focus scrolled the editor up and left inside it. It must open ON the cell.+ const at = await page.evaluate(() => { const w = document.querySelector('.cell-edit.tags-edit'), td = w.closest('td'); const a = w.getBoundingClientRect(), b = td.getBoundingClientRect(); return [Math.round(a.top - b.top), Math.round(a.left - b.left)]; });+ check('the cell\'s chip editor opens on the cell, not scrolled inside it', at[0] >= 0 && at[0] <= 3 && at[1] >= 0 && at[1] <= 4, JSON.stringify(at));+ // Saved chip by chip while the cell stays open (0.60.0 review: "type, Enter, done" showed+ // a pill the plan did not have until a second Enter or a click away).+ await page.keyboard.type('hot'); await page.keyboard.press('Enter'); await settle(page, 80);+ W['Fab one'] = ['hot'];+ await expectOwn('Enter with text saves the tag at once', W);+ check('and the cell stays open, focused, with the chip in it', await page.evaluate(() => document.activeElement?.closest('.cell-edit.tags-edit') && [...document.querySelectorAll('.cell-edit .sec-tag')].map(x => x.firstChild.textContent).join(',') === 'hot'));+ await page.keyboard.type('cold'); await page.keyboard.press('Enter'); await settle(page, 80);+ await page.click('.cell-edit .tb-x[data-tag="cold"]'); await settle(page, 80);+ check('the x removes a chip and the cell stays open', await page.$$eval('.cell-edit .sec-tag', e => e.map(x => x.firstChild.textContent).join(',')) === 'hot');+ await expectOwn('each chip added or removed is saved as it happens', W);+ await page.keyboard.type('pending'); await page.keyboard.press('Escape'); await page.keyboard.press('Escape'); await settle(page, 250);+ check('Escape discards only the text still in the box, then closes the cell', !(await page.$('.cell-edit')));+ await expectOwn('(the chips stay saved; the typed text did not)', W);+ L = (await tagLog()).slice(n0);+ check('three `tags` lines via table-edit, one per chip gesture', L.length === 3 && L.every(e => e.via === 'table-edit') && JSON.stringify(L.map(e => e.to.tags)) === '[["hot"],["hot","cold"],["hot"]]', JSON.stringify(L.map(e => e.to.tags)));+ check('the cell shows it, then the section\'s', await page.$eval('#tableView tr[data-key="t:f1"] td.c-tags', td => [...td.querySelectorAll('.row-tag')].map(x => x.textContent + (x.classList.contains('inh') ? '~' : '')).join(',')) === 'hot,Tech~');+ await page.click(cell); await settle(page, 100); await page.keyboard.type('late2'); await page.click('#tblCount'); await settle(page, 250);+ W['Fab one'] = ['hot', 'late2'];+ await expectOwn('clicking away commits text left in the box', W);+ await undo(page);+ W['Fab one'] = ['hot'];+ await expectOwn('Ctrl+Z takes back one chip gesture at a time', W);+ await undo(page); await undo(page); await undo(page);+ W['Fab one'] = [];+ await expectOwn('and three more take the cell back to where it started', W);+ await redo(); await redo(); await redo();+ W['Fab one'] = ['hot'];+ await expectOwn('redo', W);+ // `;` is the CSV separator, so it cannot be inside a tag: it becomes a space, on the page+ // exactly as on the server (0.60.0 review).+ await page.click('#tableView tr[data-key="t:f2"] td[data-field="tags"]'); await settle(page, 100);+ await page.keyboard.type('x;y'); await page.keyboard.press('Enter'); await settle(page, 100);+ check('a ";" typed into a tag becomes a space', await page.$$eval('.cell-edit .sec-tag', e => e.map(x => x.firstChild.textContent).join('|')) === 'x y');+ await page.keyboard.press('Escape'); await settle(page, 150);+ W['Fab two'] = ['x y'];+ await expectOwn('and is saved that way', W);+ await undo(page);+ W['Fab two'] = [];+ await expectOwn('(undone)', W);+ // The editor near the bottom of the table grows downward; the caret stays in view.+ await page.setViewport({ width: 1440, height: 800 }); await settle(page, 200);+ await page.click('#tableView tr[data-key="t:m1"] td[data-field="tags"]'); await settle(page, 100);+ for (let i = 1; i <= 10; i++) { await page.keyboard.type('chip' + i); await page.keyboard.press('Enter'); }+ await settle(page, 200);+ const caret = await page.evaluate(() => { const i = document.activeElement, sc = document.querySelector('.table-scroll').getBoundingClientRect(), r = i.getBoundingClientRect(); return { ok: i.classList.contains('tb-in') && r.bottom <= Math.min(sc.bottom, innerHeight) + 1 && r.top >= sc.top - 1, r: Math.round(r.bottom), sc: Math.round(sc.bottom) }; });+ check('10 chips in the last row\'s cell: the box grew, and its caret is still in view', caret.ok, JSON.stringify(caret));+ await page.keyboard.press('Escape'); await settle(page, 200);+ for (let i = 0; i < 10; i++) await undo(page);+ await page.setViewport({ width: 1440, height: 900 }); await settle(page, 200);+ await expectOwn('(the ten chips undone)', W);+ // sortable+ await page.click('#tableView thead th[data-sort="tags"]'); await settle(page, 150);+ const sorted = await page.$$eval('#tableView tbody tr[data-key] td.c-tags', e => e.map(td => [...td.querySelectorAll('.row-tag')].map(x => x.textContent).join(' ').toLowerCase()));+ const nonBlank = sorted.filter(Boolean);+ check('the Tags column sorts (A to Z, untagged rows last)', JSON.stringify(nonBlank) === JSON.stringify([...nonBlank].sort()) && sorted.slice(nonBlank.length).every(x => !x) && sorted.at(-1) === '', JSON.stringify(sorted));+ await page.click('#tableView thead th[data-sort="tags"]'); await settle(page, 100); await page.click('#tableView thead th[data-sort="tags"]'); await settle(page, 150);+ // the text filter finds own tags and section tags+ await page.type('#tblSearch', 'urgent'); await settle(page, 300);+ check('the table\'s text filter matches a row\'s own tag', JSON.stringify(await page.$$eval('#tableView tbody tr[data-key] td.c-name', e => e.map(x => x.textContent.trim()))) === '["Ops two"]');+ await page.$eval('#tblSearch', e => { e.value = ''; e.dispatchEvent(new Event('input', { bubbles: true })); }); await settle(page, 300);+ await assertNoSidewaysScroll(page, '1440, table with the Tags column');+ await shot(page, 'row-tags-1440-table');++ // ── Edit path 3: the New panel (and its memory) ──+ await view(page, 'gantt');+ n0 = (await tagLog()).length;+ await page.click('#btnNew'); await settle(page, 150);+ await page.select('#ncPhase', '1');+ await page.type('#ncName', 'New one');+ await page.click('#ncTagsIn'); await page.keyboard.type('fresh'); await page.keyboard.press('Enter'); await settle(page, 60);+ check('Enter in the New panel\'s tag box adds a chip and does not create', await editorShown(page));+ await page.click('#ncCreate'); await settle(page, 400);+ W['New one'] = ['fresh'];+ await expectOwn('the New panel creates the row with its tags', W);+ L = (await tagLog()).slice(n0);+ check('and says so in a `tags` line via create', L.length === 1 && L[0].via === 'create' && L[0].name === 'New one' && JSON.stringify(L[0].to.tags) === '["fresh"]', JSON.stringify(L));+ await page.click('#btnNew'); await settle(page, 150);+ check('the next New panel remembers the tags, like every field but the name', JSON.stringify(await page.$$eval('#ncTags .sec-tag', e => e.map(x => x.firstChild.textContent))) === '["fresh"]');+ await page.click('#ncCancel'); await settle(page, 100);+ await undo(page);+ delete W['New one'];+ await expectOwn('Ctrl+Z takes the created row away', W);+ await redo();+ W['New one'] = ['fresh'];+ await expectOwn('redo brings it back with its tags', W);+ // Deleting the only row carrying the chosen tag prunes the filter; Ctrl+Z brings back the+ // row AND the view (0.60.0 review). The same for clearing that row's tag.+ const flt = () => page.evaluate(() => JSON.parse(localStorage.getItem('adom-gantt:prefs') || '{}').tagFilter);+ await clearSel(page);+ await page.click('.tag-chip[data-tag="fresh"]'); await settle(page, 200);+ await view(page, 'table');+ await page.click('#tableView tr[data-key] .row-del'); await settle(page, 300);+ check('deleting the only "fresh" row drops "fresh" from the filter', JSON.stringify(await flt()) === '[]');+ await undo(page);+ check('Ctrl+Z restores the row and the filter with it', JSON.stringify(await flt()) === '["fresh"]' && JSON.stringify(await page.$$eval('#tableView tbody tr[data-key] td.c-name', e => e.map(x => x.textContent.trim()))) === '["New one"]', JSON.stringify(await flt()));+ await page.click('#tableView tr[data-key] td[data-field="tags"]'); await settle(page, 100);+ await page.click('.cell-edit .tb-x[data-tag="fresh"]'); await settle(page, 150); await page.click('#tblCount'); await settle(page, 300);+ check('clearing that row\'s tag drops it from the filter too', JSON.stringify(await flt()) === '[]');+ await undo(page);+ check('and Ctrl+Z brings the tag and the filter back', JSON.stringify(await flt()) === '["fresh"]' && JSON.stringify(await page.$$eval('#tableView tbody tr[data-key] td.c-name', e => e.map(x => x.textContent.trim()))) === '["New one"]');+ await page.click('#tagClear'); await settle(page, 200);+ await view(page, 'gantt');+ await expectOwn('(the plan is as it was)', W);++ // ── Edit path 4: the batch panel ──+ await clearSel(page);+ n0 = (await tagLog()).length;+ await clickBar(page, 'f1', ['Control']); await clickBar(page, 'm1', ['Control']);+ check('the batch panel opens on two rows with a tags row', !!(await page.$('#bTag')) && !!(await page.$('#bTagAdd')));+ check('it lists the own tags in the selection with a count', await page.$$eval('.bt-chips .sec-tag', e => e.map(x => x.firstChild.textContent.trim() + x.querySelector('small').textContent).join(',')) === 'hot1');+ await page.type('#bTag', 'batchy'); await page.click('#bTagAdd'); await settle(page, 250);+ W['Fab one'] = ['hot', 'batchy']; W['Misc one'] = ['batchy'];+ await expectOwn('Add to all puts the tag on every selected row', W);+ L = (await tagLog()).slice(n0);+ check('two `tags` lines via batch-editor, in one save', L.length === 2 && L.every(e => e.via === 'batch-editor') && L[1].rev === L[0].rev + 1, JSON.stringify(L.map(e => e.via + ':' + e.name + ':' + e.rev)));+ await page.click('.bt-x[data-tag="batchy"]'); await settle(page, 250);+ W['Fab one'] = ['hot']; W['Misc one'] = [];+ await expectOwn('the x in the batch panel takes the tag off every selected row', W);+ await blur(); await undo(page);+ W['Fab one'] = ['hot', 'batchy']; W['Misc one'] = ['batchy'];+ await expectOwn('ONE Ctrl+Z puts it back on both', W);+ await undo(page);+ W['Fab one'] = ['hot']; W['Misc one'] = [];+ await expectOwn('and one more takes the add back on both', W);+ await redo();+ W['Fab one'] = ['hot', 'batchy']; W['Misc one'] = ['batchy'];+ await expectOwn('redo', W);+ await clearSel(page);++ // ── Copy and paste carry tags ──+ await view(page, 'table');+ await page.keyboard.down('Control'); await page.click('#tableView tr[data-key="t:f1"] td.c-deps'); await page.keyboard.up('Control'); await settle(page, 100);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyC'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control'); await settle(page, 500);+ W['Fab one 2'] = ['hot', 'batchy'];+ await expectOwn('a pasted copy carries the row\'s own tags', W);+ await undo(page);+ delete W['Fab one 2'];+ await expectOwn('Ctrl+Z takes the paste back', W);+ await clearSel(page);+ await view(page, 'gantt');++ // ── Setup: the Tags card counts rows, and rename/remove everywhere reach them ──+ await view(page, 'setup');+ const card = () => page.$$eval('#suTags .tag-row', rows => rows.map(r => r.querySelector('.tag-name').textContent + ':' + r.querySelector('.tag-n').textContent));+ check('the Tags card counts sections and rows', JSON.stringify(await card()) === JSON.stringify(['Tech:1 section', 'hot:1 row', 'batchy:2 rows', 'long-lead:3 rows', 'urgent:1 row', 'late:1 row', 'fresh:1 row']), JSON.stringify(await card()));+ n0 = (await tagLog()).length;+ await page.click('#suTags .tag-row[data-tag="long-lead"] .tag-btn[data-a="rename"]'); await settle(page, 100);+ await page.$eval('#suTags .tag-nm', e => { e.value = 'LL'; }); await page.click('#suTags .tag-ok'); await settle(page, 250);+ W['Ops one'] = ['LL']; W['Ops dropped'] = ['LL']; W['Ops two'] = ['urgent', 'LL', 'late'];+ await expectOwn('Rename everywhere renames the tag on every row, in place', W);+ L = (await tagLog()).slice(n0);+ check('three `tags` lines via setup, one save', L.length === 3 && L.every(e => e.via === 'setup') && L.every((e, i) => !i || e.rev === L[i - 1].rev + 1), JSON.stringify(L.map(e => e.via)));+ await undo(page);+ W['Ops one'] = ['long-lead']; W['Ops dropped'] = ['long-lead']; W['Ops two'] = ['urgent', 'long-lead', 'late'];+ await expectOwn('ONE Ctrl+Z takes the rename back on every row', W);+ await page.click('#suTags .tag-row[data-tag="batchy"] .tag-btn[data-a="del"]'); await settle(page, 250);+ W['Fab one'] = ['hot']; W['Misc one'] = [];+ await expectOwn('Remove everywhere takes it off every row', W);+ await undo(page);+ W['Fab one'] = ['hot', 'batchy']; W['Misc one'] = ['batchy'];+ await expectOwn('and one Ctrl+Z puts it back', W);+ // A row moved into a tagged section gains its tags: the filter follows the move.+ await view(page, 'gantt');++ // ── Analytics after the edits, by hand again ──+ an = await anTags();+ // Tech: Fab one, Fab two, Fab gate (80h). hot/batchy: Fab one 40h; batchy also Misc one 16h -> 56h.+ // long-lead: Ops one, Ops two, Ops dropped -> 80h (dropped cancelled). urgent, late: Ops two 40h.+ // fresh: New one (Mon 5-Oct.. its dates are the New panel's defaults: today 29-Sep + 7 = 6-Oct, 29-Sep to 6-Oct, 6 working days = 48h).+ // Total 176 + 48 = 224h. No tag: Fab two? no (Tech). Nothing is untagged now: 0.+ check('the per-tag card follows the edits (hand-checked)', JSON.stringify(an) === JSON.stringify([+ 'Tech|2 +1M|80h|35.7%', 'hot|1|40h|17.9%', 'batchy|2|56h|25%', 'long-lead|3|80h|35.7%', 'urgent|1|40h|17.9%', 'late|1|40h|17.9%', 'fresh|1|48h|21.4%', 'no tag|0|0h|0%']), JSON.stringify(an));++ // ── Filter after the edits: a row tag in a tagged section, and in an untagged one ──+ await page.click('.tag-chip[data-tag="batchy"]'); await settle(page, 200);+ v = await vis();+ check('"batchy": Fab one (in Tech) and Misc one (untagged section), each with its header, nothing else', JSON.stringify(v.tasks) === JSON.stringify(['Fab one', 'Misc one']) && JSON.stringify(v.heads) === JSON.stringify(['Tech · Fab', 'Misc']), JSON.stringify(v));+ check('table: the same 2', JSON.stringify(await tableNames()) === JSON.stringify(['Fab one', 'Misc one']));+ await shot(page, 'row-tags-1440-filtered');+ // Persistence: the filter and the tags survive a reload.+ await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ v = await vis();+ check('after a reload the row tags and the filter are both still there', JSON.stringify(v.tasks) === JSON.stringify(['Fab one', 'Misc one']) && await lbl('Fab one') === '#hot #batchy', JSON.stringify(v));+ await page.click('#tagClear'); await settle(page, 200);+ // The label never gives up the name for the tags.+ const squeeze = await page.evaluate(() => [...document.querySelectorAll('.label-row:not(.group)')].filter(e => e.checkVisibility() && e.querySelector('.row-tags'))+ .map(e => { const n = e.querySelector('.name-text'); return n.scrollWidth > n.clientWidth + 1 ? n.textContent : ''; }).filter(Boolean));+ check('1440: no row name is cut to make room for its tags', squeeze.length === 0, squeeze.join(', '));+ await shot(page, 'row-tags-1440');+ await page.close();++ // ── Phone width: the chart and the table with the Tags column ──+ const m = await openPage(browser, X.url, { w: 393, h: 852, mobile: true });+ await assertNoSidewaysScroll(m, '393, chart with row tags');+ const sq393 = await m.evaluate(() => [...document.querySelectorAll('.label-row:not(.group)')].filter(e => e.checkVisibility() && e.querySelector('.row-tags'))+ .map(e => { const n = e.querySelector('.name-text'); return n.scrollWidth > n.clientWidth + 1 ? n.textContent : ''; }).filter(Boolean));+ check('393: no row name is cut to make room for its tags', sq393.length === 0, sq393.join(', '));+ await shot(m, 'row-tags-393');+ await m.evaluate(() => document.querySelector('.vt-btn[data-view="table"]').click()); await settle(m, 200);+ await assertNoSidewaysScroll(m, '393, table with the Tags column');+ await shot(m, 'row-tags-393-table');+ await m.close();++ // ── A narrow label column: the TAGS give way, never the name (0.60.0 review) ──+ const N = await startServer({ project: { title: 'Narrow', appName: 'narrow', view: { mode: 'gantt', grain: 'week', labelWidth: 270 } },+ phases: [{ name: 'Only', start: '2026-09', end: '2026-12' }],+ items: [T('n1', 'NEWRO onboarding', 0, { owner: 'Drew', status: 'in-progress', tags: ['northeast', 'regulatory', 'procurement'] })] }, 'narrow');+ const np = await openPage(browser, N.url);+ const nr = await np.evaluate(() => { const e = document.querySelector('.label-row:not(.group)'), n = e.querySelector('.name-text'), t = e.querySelector('.row-tags');+ const rg = document.createRange(); rg.selectNodeContents(n); const full = rg.getBoundingClientRect().width;+ return { col: Math.round(document.getElementById('labels').getBoundingClientRect().width), name: n.getBoundingClientRect().width, full, nameCut: n.scrollWidth > n.clientWidth, tagsCut: t.scrollWidth > t.clientWidth + 1 }; });+ check('270px label column: the name is whole and the tags are the part cut', nr.col <= 272 && !nr.nameCut && nr.name + 0.5 >= nr.full && nr.tagsCut, JSON.stringify(nr));+ await shot(np, 'row-tags-270-label');+ await np.close();++ // ── CSV: out with a tags column, and back in ──+ const csv = await (await fetch(X.url + 'export/csv')).text();+ const hdr = csv.split('\n')[0].split(',');+ const ti = hdr.indexOf('tags');+ const csvRow = n => csv.split('\n').find(l => l.includes(',' + n + ','));+ check('the CSV export has a tags column, joined with ;', ti > 0 && csvRow('Ops two').split(',')[ti] === 'urgent;long-lead;late', csvRow('Ops two'));+ const I = await startServer({ project: { title: 'Import Target', appName: 'row-tags-import' }, phases: [{ name: 'Only', start: '2026-09', end: '2026-12' }],+ items: [{ type: 'task', id: 'x', name: 'Before import', start: '2026-10-05', end: '2026-10-09', phase: 0, uid: '00001' }] }, 'row-tags-import');+ const ip = await openPage(browser, I.url);+ const csvFile = join(TMP, 'row-tags.csv'); writeFileSync(csvFile, csv);+ await ip.click('#btnImport'); await settle(ip, 100);+ const [ch] = await Promise.all([ip.waitForFileChooser({ timeout: 4000 }), ip.click('#importMenu .dd-item[data-fmt="csv"]')]);+ await Promise.all([ip.waitForNavigation({ waitUntil: 'networkidle0', timeout: 10000 }).catch(() => null), ch.accept([csvFile])]);+ await settle(ip, 300);+ const imp = own(await exportPlan(I.url));+ check('importing that CSV reads the tags back onto the rows', JSON.stringify(imp['Ops two']) === '["urgent","long-lead","late"]' && JSON.stringify(imp['Fab one']) === '["hot","batchy"]' && JSON.stringify(imp['Fab two']) === '[]', JSON.stringify(imp));+ check('and the imported page shows them', (await ip.$$eval('.row-tags', e => e.map(x => x.textContent))).includes('#urgent #long-lead #late'));+ await ip.close();++ // ── The snapshot: row tags shown, filtered, and edited with no server ──+ const snapFile = join(TMP, 'row-tags-snapshot.html'); writeFileSync(snapFile, await (await fetch(X.url + 'export/html')).text());+ const sp = await openPage(browser, 'file://' + snapFile);+ check('the snapshot shows row tags in the label rows', (await sp.$$eval('.row-tags', e => e.map(x => x.textContent))).includes('#hot #batchy'));+ await sp.click('.tag-chip[data-tag="urgent"]'); await settle(sp, 200);+ check('the snapshot filters by a row tag', JSON.stringify(await sp.evaluate(() => [...document.querySelectorAll('.label-row:not(.group):not(.ms)')].filter(e => e.checkVisibility()).map(e => e.querySelector('.name-text').textContent))) === '["Ops two"]');+ await sp.click('#tagClear'); await settle(sp, 200);+ await view(sp, 'table');+ await sp.click('#tableView tr[data-key="t:f2"] td[data-field="tags"]'); await settle(sp, 100);+ await sp.keyboard.type('offline'); await sp.keyboard.press('Enter'); await sp.keyboard.press('Enter'); await settle(sp, 200);+ const f2 = () => sp.$eval('#tableView tr[data-key="t:f2"] td.c-tags', td => [...td.querySelectorAll('.row-tag:not(.inh)')].map(x => x.textContent).join(','));+ check('the snapshot edits a row\'s tags in the table with no server (Enter twice: add, then commit)', await f2() === 'offline', await f2());+ await sp.evaluate(() => document.activeElement && document.activeElement.blur()); await undo(sp);+ check('and takes it back with Ctrl+Z', await f2() === '');+ await redo(sp);+ check('and redoes it', await f2() === 'offline');+ await sp.close();+}+ // ── Cleanup: always, however the run ends ── // The normal path, an external SIGINT/SIGTERM (Ctrl+C, a CI timeout) and the watchdog // all come through here, so no server, Chrome or temp dir is left behind.