LifeLab · month view · interface mockup
You're right that month is overview, not navigation — SPC already handles jumping. That changes the design in one concrete way: the day number stops being sacred, so cells may carry a printed value, a bar, and a wash at once. Frame 1 is interactive — click the chips to toggle channels and see how far the cell takes it.
The interface question you're actually asking is where a lens lives and how you pick it. Answer: a lens rail under the month title (same chip vocabulary as a page header lens), a picker enumerating the lenses your tag schemas already imply, and an inspector for the three or four knobs a lens has. And the month view runs no user code at all: analysis happens offline in Lua blocks that write metadata, so a lens is always one line of declarative text you can name, share, and complete from a menu.
Three channels, three chips: wash (background), marks (dots or bar), glyph (corner). A chip's colour square shows which channel it owns, so two lenses fighting for one channel is visible before it's an error. Since this is overview, paint:number is legal here in a way it wouldn't be in a navigation view — try it.
A pinned block, rendered as chips
Identical to the page header lens in Draft 3 §8: visit state until you pin it, at which point it's an anchored block on journal/2026-08 — or on journal/ if you want it every month. Naming it makes it reusable and shareable.
| gesture | does |
|---|---|
| click chip | toggle its channel off/on for this visit |
| click name | open the inspector (frame 3) |
| drag chip | reorder — later chip wins a contested channel |
| + / Mod+L | picker (frame 2) |
| click a day | open it filtered by the lens that painted it |
| Mod+Shift+L | pin the rail as a block / name it |
With no code path, the picker is a straight enumeration: each schema’d tag × its numeric keys × the aggregations those keys admit, plus the block-level built-ins. Each row previews its own month as a 31-bar strip, computed live — so you pick by shape, not by name, which is the only way "avg sentiment" and "sentiment slope" are distinguishable.
Fuzzy list, channel-coloured, live previews
Rows show channel square · name · month shape · aggregation. Grouped by tag, because that’s where the keys come from. Selecting adds to the rail; the strip is the same computation the grid will run.
Four knobs, and the resolved range printed
Opens on a chip. The strip is the actual month; hatched bars are nil days, shown as absent rather than zero — the single most important thing this panel does.
auto would rescale per month — allowed, but the legend then prints the range it resolved to.sentiment property — skipped, not zeroed. Shown hatched above and counted in the legend.The month view never invokes user code. A Lua block is an ordinary block you run when you want, over whatever range you want, at whatever cost — and its output is properties written back onto blocks. From that moment the value is ordinary metadata, indistinguishable from something you typed by hand, and the month lens that paints it is pure declarative grammar: source, filter, agg:, paint:. Every consequence below follows from that one cut.
The analysis writes sentiment: onto diary blocks
A normal Lua block on a normal page. Reads blocks, writes properties, reports what it wrote. Nothing here is lens-aware — it doesn't know a month view exists.
-- scores diary blocks that don't have a sentiment yet local blocks = nb.query("![[:diary:]]{when:-30d.. sentiment:none}") for _, b in ipairs(blocks) do local score = model.sentiment(b.text) -- slow, fine: offline b:set_prop("sentiment", round(score, 2)) end print(#blocks .. " blocks scored")
Rolling averages become just another property
The one thing a per-day fold genuinely can't do — needing neighbours — stops being a runtime problem the moment analysis is offline. It writes sentiment_3d:, and the lens that paints it is the same two terms as any other.
-- 3-day rolling mean, materialized onto the day page local days = nb.days("-30d..") local raw = {} for i, d in ipairs(days) do raw[i] = nb.avg(d.blocks, "sentiment") end for i, d in ipairs(days) do d:set_prop("sentiment_3d", nb.mean{ raw[i-2], raw[i-1], raw[i] }) end
Which leaves the month lens grammar closed, small, and completable end to end — this is the entire surface:
agg: count · sum · avg · min · max · any -- no user code, ever of: <metadata key> -- completed from the tag schema paint: dots · heat · bar · glyph · number scale: schema · lo..hi · auto · log
Paging months, resizing, reopening — all of it is index reads and arithmetic. No sandbox, no timeout, no "which lens is slow", no budget warnings in the UI. A month renders like a query, because it is one.
A painted day is dark because a block says sentiment:0.85. You can open it, see it, edit it, search it, and diff it. A number produced at render time by code is none of those things.
Analysis is stale until you rerun it, and that's honest — the block prints when it last ran and what it wrote. Better than silently recomputing a model on every scroll and calling it live.
The one real cost: a property written by analysis and a property you typed look identical. Worth a provenance marker on written props (sentiment:0.72 ⟵ analysis/score-diary) so a rerun knows what it may overwrite and you know what you can trust — and so sentiment:none in the query above stays an honest filter.
Two things I'd change from the earlier doc, given that framing.
paint:number — legal in overview
Colour answers "high or low", a number answers "how much". In an overview you often want the second, and a 1,850 in the corner costs nothing once the day number isn't the point.
The month footer becomes the summary row
An overview should total itself. Each rail lens contributes one line: aggregate, coverage, and the shape as a 31-bar strip you can read across the whole month without cell boundaries.
This is the piece that changes the interface most. If :meal: guarantees the same keys on every block that carries it, then a month lens over ![[:meal:]] is well-typed before it runs — the system knows which keys exist, which are numeric, their units, and their plausible range. Three consequences: the picker can be derived rather than authored, of: is completable and checkable instead of a string you hope matches, and scale: gets a real default that isn't auto. Combined with offline analysis, this is why the month view needs no code path at all: anything exotic arrives as a property, and a property is exactly what a schema describes.
One schema generates its own menu section
Every numeric key yields sum/avg/max, every boolean yields any, every ref yields a count. Units and ranges ride along, so the legend prints g and kcal without anyone typing them.
calories number unit kcal range 0..3000 required carbs number unit g range 0..400 fat number unit g range 0..200 protein number unit g range 0..250 photo ref optional
Four fewer ways to be wrong
Each row is a failure mode the earlier design had to handle at runtime and can now handle at completion time.
| without schema | with schema |
|---|---|
| of: is a guess | completes from the tag's keys; a typo is invalid as you type, not a blank month |
| scale:auto by necessity | declared range is the default, so months are comparable out of the box |
| units unknown | legend and printed values carry kcal/g; two lenses in different units can't share a channel |
| nil is ambiguous | missing required key = a data gap worth flagging; missing optional key = normal absence, quietly hatched |
| picker hand-curated | every schema'd tag contributes its own group; the list grows as your vocabulary does |
Everything the schema can't state — cross-day math, ratios across keys, cross-tag values, anything derived from text. All of it offline, all of it landing as a new property with its own schema entry. Which then shows up in this picker like any other key: the exotic lens and the built-in one are the same object.
One warning: derived lenses are cheap to generate and expensive to show. Ten schema'd tags with four numeric keys each is 120 rows in a picker nobody can read. Keep the derived section collapsed behind the tag name — :meal: ▸ 6 — and rank by what you've actually used, so the list is short by default and complete on demand.
| # | question | my lean |
|---|---|---|
| 1 | Rail always visible, or revealed on Mod+L? | Always visible when non-empty — it's the legend's other half. Empty state is one dashed + lens chip, not a toolbar. |
| 2 | Does the rail belong to the month you're on, or to the month view? | To the view, by default — paging months should not change lenses. Per-month pinning stays possible but is the deliberate act. |
| 3 | Should properties written by analysis be marked as such? | Yes — a provenance marker on the prop. It tells a rerun what it may overwrite, tells you what is inferred vs. typed, and keeps sentiment:none an honest filter. |
| 4 | How does a month view show that its analysis is stale? | A quiet note in the legend naming the job and its last run — never a blocking banner, and never an auto-run. Staleness is a fact about data, not an error. |
| 5 | Does a lens filter the day view you click into? | Yes, and it's the best argument for lens-as-query: the reason a cell looked like that is one click away, unfiltered on second press. |
| 6 | If a tag schema changes its range after you've pinned a lens, does the pinned lens follow? | Follow, and say so once. A pinned lens stores scale:schema, not the numbers — otherwise every schema edit silently strands old views on stale bounds. |
| 7 | Should a schema declare its own preferred paint channel? | Yes as a hint, no as a rule — calories suggesting bar is useful; a schema claiming the wash channel across every view is not. |
Interactive: frame 1 chips toggle. All August values are fabricated. Grammar per Lens Spec Draft 3 · companion to Month Lenses — Programmable Grid.