Skip to content

feat: interactive timeline plugin - #43

Merged
XtremeOwnageDotCom merged 6 commits into
mainfrom
feat/timeline-plugin
Aug 19, 2026
Merged

feat: interactive timeline plugin#43
XtremeOwnageDotCom merged 6 commits into
mainfrom
feat/timeline-plugin

Conversation

@XtremeOwnageDotCom

@XtremeOwnageDotCom XtremeOwnageDotCom commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What & why

Adds a timeline plugin: a fenced ```timeline block written in YAML becomes an interactive project timeline — a date picker per named anchor date, plus a chain of named offset dates (calendar or business days, with optional exclusions for holidays and blackouts) drawn as a Mermaid gantt diagram that recomputes live.

All date math runs client-side, mirroring calculator: the build validates the YAML and emits a form plus a JSON spec, and a site-wide vanilla-JS evaluator re-derives every date on change. No build-time date math, no server round-trip. It registers as a Markdown preprocessor at order 15, so it runs after snippets and before table-reader.

```timeline
title: Rollout
date_format: MM/DD/YYYY
exclusions:
  - { from: 12/24/2027, to: 12/26/2027 }
inputs:
  - name: kickoff
    default: 01/04/2027
outputs:
  - name: build
    label: Build phase
    type: weekdays
    duration: 10
    expr: kickoff + 2

The two commits are separated deliberately so the review can tell the contribution apart from my changes to it:

**`Add timeline plugin`** — the change as contributed. The only thing dropped is a stray `NetDocs.7z` archive that was in the patch; it is build output and does not belong in the tree.

**`Share the fenced-block scanner and harden timeline input`** and **`Reject timeline offsets the evaluator cannot actually step`** — the cleanup, below.

## Cleanup

**Verified the refactor changed nothing.** Before adding tests I built the pre-refactor commit and the current one, ran both over a 16-case corpus of awkward fences (tilde, 4+ backticks, indented, unclosed, nested-in-example, CRLF, adjacent blocks, uppercase), and diffed the rendered HTML. Identical except for the intended uppercase fix — and the random ids that led to the finding below.

**Deduplicated the fence scanner.** The plugin arrived with a near-verbatim copy of `CalculatorPlugin`'s ~45-line fence loop. That logic is subtle — a block must be matched to its own closing fence so an outer ```` ```` ```` example containing an inner ` ```calc ` is copied through rather than half-rendered — so two hand-synced copies is the wrong shape. Extracted as `FencedBlocks.Rewrite(markdown, infoWord, render)`; both plugins now call it. Net −116/+95 lines across the two.

**Fixed a latent bug in both plugins, found while extracting it.** Each pre-scanned the source with a case-*sensitive* `IndexOf` but compared the fence info word case-*insensitively*. A ` ```CALC ` or ` ```TIMELINE ` fence therefore matched the comparison but never reached it, and silently rendered as a plain code block. The shared pre-scan is case-insensitive, so it agrees with the comparison that follows. Covered by a theory in each plugin's tests.

**Fixed an `OverflowException` that failed the whole build.** The offset regex bounds `expr` to digits but not to a magnitude, so `expr: start + 99999999999999` threw out of `int.Parse` — one typo in one page killed the build, while every other malformed field in this plugin degrades to a warning and a skip. It now does the same.

**Escaped one value reaching `innerHTML`.** The exclusion date interpolated into a `data-exclusion` attribute was the only interpolation in the client script not passed through `escapeHtml`. The value is a machine-generated ISO date so it was not exploitable, but it was the odd one out.

## Type of change

- [ ] Bug fix
- [x] New feature
- [ ] Documentation
- [ ] Refactor / chore

## Checklist

- [x] `dotnet format Netdocs.slnx --verify-no-changes` passes
- [x] `dotnet build Netdocs.slnx -c Release` succeeds
- [x] `dotnet test Netdocs.slnx -c Release` passes (503, +84 new)
- [x] Added/updated tests for the change
- [x] Updated docs under `docs-site/docs/**` if behavior changed

**Rejected offsets the evaluator cannot step.** The client loop stops after 100000 iterations and returns whatever date it reached, so `start + 100000` and `start + 2000000000` both resolved to `2300-10-20` and rendered as if that were the answer. Both are now rejected at build time, with the limit named next to the guard it mirrors so the two cannot drift.

**Made control ids stable.** `<label for>` ids came from a fresh `Guid.NewGuid()` per block per build, so every page holding a calc or timeline block differed byte-for-byte on every run — `OutputWriter` rewrote it and the watch daemon republished it, defeating the incremental diff both exist to produce. On a 16-page corpus, 11 pages rewrote on a **no-op** rebuild; now 0. Pre-existing in `calculator`; this PR would have doubled its blast radius.

**Covered the shared scanner directly.** `FencedBlocks` was only reachable through whichever plugin called it, so the fence rules — the reason the code is shared at all — were pinned by nothing. Exposed to tests via `InternalsVisibleTo` (it is an implementation detail, not plugin API) and covered with 27 tests: CommonMark fence pairing, unclosed fences, exact-match info words, the body handed to the render delegate, block indexing, and id stability. Mutation-checked — dropping the closing-fence length rule fails exactly one test, freezing the block index fails exactly one other.

## Test coverage — please read

The C# suite covers the **build-time** half well: 46 tests over YAML parsing, validation, defaults, error paths, and the emitted HTML and JSON spec.

It does **not** cover the runtime half. Every date a reader actually sees is computed by the ~350-line client evaluator, and there is no JS test infrastructure in this repo, so none of that arithmetic is exercised by `dotnet test`. The existing `EvaluatorJs_*` tests assert that the JS *source string* contains certain substrings — they would pass if the arithmetic were wrong, and fail on a rename that changed nothing.

Docs are current with all of the above: the offset ceiling is documented next to the exclusion-range cap it mirrors, and the plugin is listed among the "no MkDocs equivalent" features beside `calculator`. A `--strict` docs build reports only the two warnings that already exist on `main`.

I verified the evaluator by hand instead: extracted its date functions and ran them under node — 13,731 assertions covering civil-date roundtripping, agreement with `Date` UTC across 60 years, leap and century boundaries, weekday stepping, negative offsets and exclusion skipping. **All correct.** That is how the offset-budget bug above surfaced.

So: the math is right today, but nothing stops a future edit from breaking it silently. Standing up a JS test harness means adding node tooling to a .NET repo and a CI stage — a call for you, not something I wanted to slip into this PR. Happy to do it as a follow-up if you want it.

## Notes for reviewers

The contribution arrived in good shape — 38 tests, a full docs page, namespaced CSS, and careful comments. I verified the documented options match the keys the code actually reads, one for one.

Both bugs I fixed were confirmed by probe tests that failed before the fix and pass after, rather than being reasoned about from the source. Verified end to end on the docs site: the timeline page renders two live blocks with zero error boxes, and the calculator page still renders after the refactor.

`edit_exclusions` lets a reader add and remove exclusion dates in the browser; that state is client-only and resets on reload, which seems right for a docs page but is worth a second opinion.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

XtremeOwnageDotCom and others added 6 commits August 18, 2026 22:03
Renders a fenced ```timeline block written in YAML as an interactive project
timeline: a date picker per named anchor date, plus a chain of named offset
dates -- calendar or business days, with optional exclusion dates for holidays
and blackouts -- drawn as a Mermaid gantt diagram that recomputes live.

All date math runs client-side, mirroring the calculator plugin: the build
validates the YAML and emits a form plus a JSON spec, and a site-wide vanilla-JS
evaluator re-derives every date on change. Registered as a Markdown preprocessor
at order 15 so it runs after snippets and before table-reader.

Applied from timeline.patch; the stray NetDocs.7z archive in that patch was
dropped as build output that does not belong in the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The timeline plugin arrived with a near-verbatim copy of CalculatorPlugin's
~45-line fence scanner. Fence handling is subtle -- a block has to be matched to
its own closing fence so an outer ```` example containing an inner ```calc is
copied through rather than half-rendered -- so extract it as FencedBlocks and
have both plugins call it, instead of keeping two copies in step by hand.

That also fixes a latent bug in both: each pre-scanned the source with a
case-sensitive IndexOf but compared the fence info word case-insensitively, so a
```CALC or ```TIMELINE fence was silently skipped and rendered as a plain code
block. The shared pre-scan is case-insensitive, so it agrees with the comparison
that follows.

Also fix an OverflowException in timeline: the offset regex bounds `expr` to
digits but not to a magnitude, so `start + 99999999999999` threw and failed the
whole build, where every other malformed field degrades to a warning. And escape
the exclusion date interpolated into a data- attribute, which was the one value
reaching innerHTML unescaped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client evaluator stops after 100000 steps and returns whatever date it had
reached, so `start + 100000` and `start + 2000000000` both resolved to
2300-10-20 and rendered as if that were the answer. Verified by running the
evaluator's date math directly under node.

Reject anything past that budget at build time, alongside the overflow case, so
an absurd offset is a warning rather than a silently wrong timeline. The limit
is named next to the guard it mirrors so the two cannot drift apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extracted scanner had no direct coverage -- only a handful of assertions
reaching it through the two plugins. Add tests for the shapes it has to handle:
tilde fences, longer-than-three fences, indented fences, attributes after the
info word, a fence that is never closed, several blocks on one page, and content
around a block.

Two of those failed on first run, for a reason worth keeping: the fixture had
`inputs` but no `outputs`, so the block rendered an error box -- and the older
assertions looked for "nd-calc", which the error box also carries. They now
assert on the rendered form and on the absence of an error box, so a failed
render cannot pass as a successful one.

Control ids came from a fresh GUID per block per build, so every page holding a
calc or timeline block differed byte-for-byte on every run: OutputWriter rewrote
it and the watch daemon republished it, defeating the incremental diff both
exist to produce. Measured on a 16-page corpus, 11 pages rewrote on a no-op
rebuild; with ids derived from page, block index and control name instead, that
is 0. Ids stay unique across identical blocks on one page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FencedBlocks was only ever reached through whichever plugin happened to call it,
so the scanning contract itself was pinned by nothing: the plugin tests can only
drive it with markdown their own YAML accepts, which leaves the fence rules --
the actual reason this code is shared -- untested.

Expose it to the test project via InternalsVisibleTo rather than making it
public: it is an implementation detail of the fence-replacing plugins, not part
of the plugin API that external assemblies build against.

The 27 tests pin what the plugins cannot reach: CommonMark fence pairing (a
closing run must be at least as long as the opener; tildes are not closed by
backticks), unclosed fences running to end of document, exact-match info words
so ```calculator is not treated as ```calc, the body handed to the render
delegate excluding both fence lines, block indexes counting only matched blocks,
and element id stability.

Verified by mutation: dropping the closing-fence length rule fails exactly
ShorterRunDoesNotCloseTheFence, and freezing the block index fails exactly
BlockIndexCountsOnlyMatchedBlocks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding the offset limit left the docs behind the code: the expressions section
described offsets as an unbounded whole number of days, with no mention that one
past the evaluator's budget is now dropped. It documents the exclusion-range cap
the same way, so this follows that shape.

The plugin was also missing from the "no MkDocs equivalent" list in
netdocs-vs-mkdocs, which already calls out the calculator plugin -- timeline is
the same kind of thing and belongs beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@XtremeOwnageDotCom
XtremeOwnageDotCom merged commit 244b660 into main Aug 19, 2026
1 check passed
@XtremeOwnageDotCom
XtremeOwnageDotCom deleted the feat/timeline-plugin branch August 19, 2026 03:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant