LifeLab · month view · interface mockup

The lens rail, the picker, and a closed aggregation grammar

You're right that month is overview, not navigationSPC 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.

1 · The rail — click a chip to toggle its channel

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.

home·dayweekmonthfind /go SPC
August 2026
MON
TUE
WED
THU
FRI
SAT
SUN
iWHAT THE RAIL IS

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.

gesturedoes
click chiptoggle its channel off/on for this visit
click nameopen the inspector (frame 3)
drag chipreorder — later chip wins a contested channel
+ / Mod+Lpicker (frame 2)
click a dayopen it filtered by the lens that painted it
Mod+Shift+Lpin the rail as a block / name it

2 · The picker — every lens the schema already implies

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.

2MOD+L

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.

add month lens3 of 3 channels used
sent
:diary: · 2 numeric keys
sentiment · avgavg
sentiment_3d · avgavg
:meal: · 4 numeric keys
calories · sumsum
blocks · built in
blocks createdcount
tasks · done / opencount
tag present · :gym:any
photos attachedcount
add inspectMod+⏎ write it as text
3INSPECTOR

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.

diary sentiment · avg
![[:diary:]]{when:this-month}
agg:avg  of:sentiment  number 0..1
dotsheatbarglyphnumber
0..1autologfixed
olivebrickslate
Comparable across months because the scale is fixed. auto would rescale per month — allowed, but the legend then prints the range it resolved to.
4 days have diary blocks with no sentiment property — skipped, not zeroed. Shown hatched above and counted in the legend.

3 · Analysis is offline — Lua writes metadata, the lens only reads it

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.

4OFFLINE · RUN IT WHEN YOU WANT

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.

analysis/score-diarylua · ran 2026-08-16 09:41 · manual
-- 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")
wrote16 props · 15 days untouched
5EVEN THE HARD CASE

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.

analysis/smooth-sentimentlua · ran 2026-08-16 09:41
-- 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
wrotethen paint it: {agg:avg of:sentiment_3d paint:heat}

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

The view stays pure

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.

The numbers are auditable

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.

Recompute is explicit

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.

4 · Since month is overview, not navigation

Two things I'd change from the earlier doc, given that framing.

6PRINTED VALUES

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.

![[:meal:]]{when:this-month agg:sum of:calories paint:number+bar}
MON
TUE
WED
THU
FRI
SAT
SUN
kcal · scale 0..3000·month total 34,950 · daily avg 2,184·Aug 15 clipped
7AND ONE MORE

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.

august · by lens
avg .60
2,184/d
24 done
5 days
Coverage, not just totals: 16 of 31 days have any data, and the strip makes the empty half obvious instead of implying a flat line.

5 · Tag schemas are the lens's type signature — so no lens needs code

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.

8SCHEMA IN, LENSES OUT

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.

:meal: · tag schema4 numeric · 1 ref
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
derived month lenses — no code written
calories · sum · bar 0..3000 kcalprotein · sum · bar 0..250 g calories · avg/meal · number kcalcarbs · sum · bar 0..400 g meals logged · count · dotsphoto present · any · glyph
9WHAT THE SCHEMA BUYS THE UI

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 schemawith schema
of: is a guesscompletes from the tag's keys; a typo is invalid as you type, not a blank month
scale:auto by necessitydeclared range is the default, so months are comparable out of the box
units unknownlegend and printed values carry kcal/g; two lenses in different units can't share a channel
nil is ambiguousmissing required key = a data gap worth flagging; missing optional key = normal absence, quietly hatched
picker hand-curatedevery schema'd tag contributes its own group; the list grows as your vocabulary does

Then what's left for Lua?

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.

6 · Open interface questions

#questionmy lean
1Rail 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.
2Does 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.
3Should 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.
4How 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.
5Does 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.
6If 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.
7Should 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.