Skip to content

Sidebar: render daily-note month folders as a calendar grid#702

Merged
srid merged 13 commits intomasterfrom
sidebar-month-calendar
May 4, 2026
Merged

Sidebar: render daily-note month folders as a calendar grid#702
srid merged 13 commits intomasterfrom
sidebar-month-calendar

Conversation

@srid
Copy link
Copy Markdown
Owner

@srid srid commented May 4, 2026

A sidebar tree node whose children are all daily notes of one month now renders as a 7-column calendar grid instead of a vertical list of dated leaves. Closes #700. Filled cells link to that day's note; missing days appear as muted dots — so a Daily/2026/04/ folder showing 2026-04-21 … 2026-04-30 becomes a glance-able calendar of April 2026 in the sidebar.

The implementation reuses the post-render-widget pattern that landed for the timeline heatmap in #699: a Heist splice annotates leaves with semantic data attributes, and a small JS module reads the DOM and swaps the children for a calendar in place. Date detection lives in Haskell exclusively — the JS never reimplements YYYY-MM-DD parsing, so the timeline-heatmap regex and the sidebar widget stay in lockstep through one source of truth.

How it fits together

Calendar.parseRouteDay  ─▶  node:iso-date splice  ─▶  data-iso-date="…"  ─┐
                                                                          │
                          .emanote-tree-children wrapper  ────────────────┤
                                                                          │
                                                                          ▼
                                                                    sidebar-calendar.js
                                                                          │
                                                          classifyMonthGroup → buildCalendar

The two pieces of the seam — data-iso-date on each leaf anchor and <div class="emanote-tree-children"> around each expanded subtree — are both emitted by routeTreeSplices and documented as the contract widgets attach to. Cell primitives (palette, sizes, filled/empty builders, header formatter) moved out of timeline-heatmap.js into a shared @emanote/calendar-grid module, so a Tailwind palette refresh edits one file and the two widgets can't drift on user-facing copy.

What you get

  • 7-column weekday grid with a Apr 2026 header, leading blanks so day 1 lands in the right column, and clickable cells for every daily note that exists
  • Sparse months render correctly — only filled days carry anchors; missing days are inert muted dots
  • Survives Ema's in-app morph navigation — moving between two daily notes inside the same month re-mounts the calendar without a flicker
  • Non-month folders untouchedclassifyMonthGroup returns null on any subtree containing a non-daily child, so subfolders, regular notes, and mixed-month folders keep their linear list

Refinements during review

Eight commits on the branch, six of them follow-ups from /hickey, /lowy, and /code-police running on the actual diff:

Pass Finding Fix
Hickey Idempotence-flag complected with DOM marker class Renamed the gate to isAlreadyRendered, kept the DOM as truth
Lowy Template's seam comment named the wrapper but not data-iso-date Spelled out both halves of the contract
Police (emanote-dom-over-mirror) Pass-1 violation: WeakSet mirrored DOM state Dropped the WeakSet; isAlreadyRendered queries the live DOM
Police (elegance, reuse) Template.hs was combining two qualifiers under one Calendar alias Calendar.hs now re-exports parseRouteDay; single facade
Police (elegance, dry-rule-of-three) 'YYYY-MM-DD — title' formatter copied between widgets Extracted formatCellHeader into calendar-grid.js
Police (elegance, leaky / vestigial) Selector reached past seam (> .flex); per-cell <span> wrapper was layout-only Selector dropped to :scope a[data-iso-date]; row height moved to grid via auto-rows-[1rem] place-items-center

One Lowy finding (extract a groupLeaves(wrapper, predicate) seam for hypothetical future week/year groupings) was rejected as YAGNI — when a second strategy actually arrives, the extraction belongs to that PR.

Coverage

Four cucumber + Playwright scenarios under tests/features/smoke.feature cover the user-visible paths: month folder renders as calendar with the right day → note links; the linear list is replaced rather than augmented (no plain 2026-04-XX anchors leak through); non-month folders never mount a calendar; the calendar survives morph navigation between same-month dailies (@morph-tagged so static mode skips it). Fixture under tests/fixtures/notebook/calendar-test/2026/04/ carries three sparse daily notes (1, 15, 30) so both filled and empty cells get exercised. All 39 e2e scenarios pass in live mode locally; CI runs static + morph variants.

Try it locally

nix run github:srid/emanote/sidebar-month-calendar -- -L docs run

Then expand Daily / 2025 / 03 in the sidebar.

Generated by /do on Claude Code (model claude-opus-4-7).

srid added 8 commits May 4, 2026 11:56
When a sidebar tree node holds nothing but daily-note leaves of one
month (e.g. Daily/2026/04/2026-04-21.md … 2026-04-30.md), swap the
linear list for a 7-column calendar grid of that month. Cells with
notes link to the daily note; missing days render as muted dots.

Detection seam stays in Haskell. routeTreeSplices emits a new
node:iso-date splice (driven by Calendar.parseRouteDay) and
sidebar-tree.tpl wraps each subtree in .emanote-tree-children. The
JS module reads those data attributes — no second YYYY-MM-DD regex.

Cell palette + size constants extracted from timeline-heatmap.js
into a shared @emanote/calendar-grid module, so the two widgets
share the primary-palette / cell-size source of truth.

Closes #700.
Replace the firstElementChild?.classList.contains(MARKER) gate in
render() with a module-level WeakSet of processed wrappers. The
class on the rendered calendar stays as a dev-tools inspection
hint, but the rendering lifecycle is no longer encoded in CSS
class presence. WeakSet entries vanish when the wrapper detaches
(idiomorph swap on nav), so morphed-in wrappers re-render
cleanly without inferring state from DOM shape.
The template comment named .emanote-tree-children but not the
data-iso-date attribute. Both are required for a widget to attach;
a future author reading only the template would miss the second
half. Spell out the contract so the constraint matches the one
already documented at the JsBundle.hs registration site.
The WeakSet idempotence gate added in 84c1a39 mirrored DOM state
in JS, drifting if anything outside this module touches the
calendar wrapper (devtools removal, future utility scripts).
Replace with a named isAlreadyRendered() predicate that reads
the DOM directly — Hickey's intent-legibility concern is met by
naming the predicate, not by moving state into JS.
Template.hs was combining two qualifiers under one Calendar alias to
reach parseRouteDay. Add an explicit export list to Calendar.hs so
parseRouteDay is exposed alongside isDailyNote, then drop the dual
import in Template.hs. Single qualifier, single facade — matches
how Graph.hs and Query.hs already use Calendar.
Both timeline-heatmap and sidebar-calendar were independently building
'YYYY-MM-DD — title' strings via the same five-line padStart dance.
Move into calendar-grid.js so a date-format change touches one
place — and the two widgets can't drift apart in user-facing copy.
Three tightening moves on sidebar-calendar.js:
 - Selector ':scope > .flex a[data-iso-date]' reached past the
   documented seam into sidebar-tree.tpl's internal layout. Drop
   to ':scope a[data-iso-date]' so the widget only depends on
   what the seam contract names.
 - The per-cell '<span class="flex items-center justify-center
   h-4">' wrapper around each day was vestigial — only there to
   give the row a 16px track. Move that to the grid container
   via auto-rows-[1rem] place-items-center; one fewer DOM node
   per day.
 - JsBundle.hs's seam comment duplicated sidebar-tree.tpl's;
   trim to a one-line pointer at the canonical contract.
Four cucumber scenarios exercise the user-visible paths:
 - month folder renders as 7-col calendar with the right header
   and day→note links
 - the calendar swap replaces the linear list (no plain
   "YYYY-MM-DD" anchors leak through)
 - non-month folders never mount a calendar (negative case)
 - calendar survives Ema's in-app morph navigation between two
   daily notes in the same month (@morph-tagged so static mode
   skips it)

New fixture under tests/fixtures/notebook/calendar-test/2026/04/
holds three sparse daily notes (1/15/30) so the test exercises
both filled and missing-day cells.
@srid
Copy link
Copy Markdown
Owner Author

srid commented May 4, 2026

Hickey/Lowy Analysis

# Lens Finding Disposition
1 Hickey Idempotence state complected with DOM marker class Fixed in this PR (84c1a39, then reworked in 7a6c44b to satisfy emanote-dom-over-mirror)
2 Lowy Template seam comment names wrapper but not data-iso-date Fixed in this PR (1002282)
3 Lowy Grouping strategy hard-coded — extract groupLeaves(wrapper, predicate) for future week/year strategies No-op — premature abstraction; flip when a second grouping strategy actually arrives

Hickey rationale

The pre-implement design pass already addressed three structural risks before any code was written: date-detection fragmentation (kept on the Haskell side, JS reads data-iso-date), Tailwind cell palette duplication (extracted into calendar-grid.js), and detection-vs-rendering coupling (classifyMonthGroup returns pure data, buildCalendar mutates DOM).

Post-implement on the actual diff, one finding landed: the original firstElementChild?.classList.contains(MARKER) gate complected idempotence lifecycle with the visual marker class, leaving the intent recoverable only by reading the comment. The fix went through two iterations — first a WeakSet per Hickey's suggestion, then back to a DOM query (isAlreadyRendered) when /code-police flagged the WeakSet as a emanote-dom-over-mirror violation. The final shape names the predicate explicitly without moving state into JS — best of both critiques.

Layer 5 entanglement count: zero pairs in calendar-grid.js, sidebar-tree.tpl, and Template.hs; one pair in sidebar-calendar.js (idempotence ↔ DOM shape) resolved by the rework.

Lowy rationale

Two of three findings were actionable and landed; the third was rejected as YAGNI.

  • Template seam comment: the original comment named only .emanote-tree-children, leaving data-iso-date undocumented even though both attributes are required for any widget to attach. Reworded to spell out the full contract; matches the constraint already documented at the JsBundle.hs registration site (which itself was trimmed in a later /code-police pass to avoid duplicating the template's contract).
  • Grouping strategy seam (No-op): the suggestion was to extract a groupLeaves(wrapper, predicate) scaffold so a future week-grouping or year-grouping widget could plug in a predicate without duplicating the DOM-scan loop. Per the project's "no premature abstraction" rule, the seam doesn't earn its keep until a second strategy actually exists — the cost of refactor-under-pressure is real, but so is the cost of a speculative API surface that the second strategy ends up not wanting. Preferred: ship the strategy now, refactor when the third call site arrives.

Volatility map verdict: the JS-only boundary is the right place for grouping volatility (same blast radius as a Haskell-side detector, but no heist-extra PR dependency). data-iso-date and .emanote-tree-children form a stable generic API that survives the arrival of additional widgets without leaking month-group semantics into the tree splice.

@srid
Copy link
Copy Markdown
Owner Author

srid commented May 4, 2026

Evidence

For a daily-notes folder whose immediate children are all YYYY-MM-DD notes within a single month, the sidebar tree node now collapses into a compact 7-column month grid. Filled cells link to existing notes; empty cells render as inert dots. Captured at docs/guide/daily-notes/2025-03-15 against the user-docs notebook (which has 2025-03-14.md and 2025-03-15.md), 1400×900 viewport.

Before (master) After (this PR)
Linear list: 2025-03-14, 2025-03-15 under "Daily Notes". Compact Mar 2025 grid with the 14th and 15th filled.

@srid
Copy link
Copy Markdown
Owner Author

srid commented May 4, 2026

/do results

Step Status Duration Verification
sync 2s git fetch ok; forge=github; noGit=false
research 1m 26s Read Template.hs, JsBundle.hs, morph.js, main.js. Plan: data-iso-date splice + .emanote-tree-children wrapper + calendar-grid.js + sidebar-calendar.js.
branch 5s sidebar-month-calendar from origin/master
implement 5m 13s node:iso-date splice; sidebar wrapper; calendar-grid extraction; new sidebar-calendar.js; JsBundle registration.
check 4m 26s cabal build all green
docs 28s CHANGELOG + docs/guide/daily-notes.md updated
fmt 19s cabal-fmt + fourmolu + hlint + nixpkgs-fmt clean
commit 24s ec2dfa5 pushed
hickey+lowy 5m 16s 2 fixes (84c1a39, 1002282); 1 No-op (YAGNI grouping seam)
police 9m 33s Pass1 fix 7a6c44b; Pass3 elegance 1a90dc5, 31a3d1b, 2936fec
test 7m 18s cabal 66/66; e2e-live 39/39 (4 new for #700)
create-pr 1m 28s Draft #702 with hickey/lowy comment
ci 56s e2e: live 39/39, static 36/36 (@morph skipped), morph 39/39
evidence 3m 35s Before/after sidebar screenshots posted
Total 40m 44s

Slowest step: police (9m 33s)

Optimization suggestions

  • Police dominated at 9m 33s — Pass-3 elegance triggered 3 fix iterations (4 follow-up commits). Two of them (emanote-dom-over-mirror flip and formatCellHeader extraction) could have been caught at implement time by reading .agency/code-police.md before writing the JS — the WeakSet pattern that Hickey suggested is exactly what emanote-dom-over-mirror rejects, so the project rule overrides the generic structural critique. Pre-loading project rules before reaching for a generic pattern would have saved one full police-fix loop.
  • Hickey+lowy at 5m 16s is on the high side for a single-feature diff; the talk-mode pre-implement pass already addressed three structural risks, so the post-implement pass landed only one finding (idempotence complecting). Future runs on similarly-scoped features can lean harder on the talk-mode rehearsal and run hickey+lowy with --review-model=haiku to trim ~50%.
  • Evidence at 3m 35s included a nix --refresh build github:srid/emanote/master against an uncached master — when iterating on a feature that needs before/after shots more than once, prebuild the master binary in the background during research so the evidence step costs only the screenshot capture.

Workflow completed at 2026-05-04T16:25Z.

srid added 4 commits May 4, 2026 13:07
The cell builders inherited from timeline-heatmap were 4×4px
colour-only squares — fine for a year-stacked heatmap where the
unit is "did anything happen", wrong for a sidebar calendar where
a reader expects to see the date in each cell. Without the number,
the widget read as a heatmap pasted into a calendar header.

Sidebar-specific cell builders now live next to the rest of the
widget: 24×24 tiles with the day rendered as text — primary-fill
+ white text for days that have a note, muted text-only for days
that don't. timeline-heatmap is unchanged; both widgets still
share `MONTH_LABELS` and `formatCellHeader` from calendar-grid.
@srid srid marked this pull request as ready for review May 4, 2026 19:20
@srid srid merged commit 6fff634 into master May 4, 2026
3 of 4 checks passed
@srid srid deleted the sidebar-month-calendar branch May 4, 2026 19:31
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.

Use calendar in sidebar for daily notes

1 participant