LifeLab · month view · proposal

Month lenses: the calendar already gives you the x‑axis

The month view is plain because it answers one question — which days had task activity — and answers it with a chart it doesn't admit is a chart. The GitHub-style dot grid is a real visualization: 31 buckets, one series, count encoding. The idea worth pursuing isn't "make the month view richer." It's the month grid is a chart whose hardest part is already solved. Days are a fixed, evenly-spaced, universally-understood domain. A month lens therefore never has to configure an axis, a range, a tick, or a chart type — it only supplies y: one value per day.

That constraint is what makes this cheap enough to be a good idea. Below: the model (a lens + three new shape terms, no new query language), five worked lenses over the same August, and the three places I think it goes wrong if built loosely.

The contract

(day, blocks[]) → number | bool | null. One scalar per day. Null means "no data", which is not zero and must not paint.

Not a plugin API

A month lens is a lens (Draft 3 §3) plus agg: of: paint:. Four closed terms, no functions in grammar.

Three channels, hard cap

A cell has exactly three slots: background wash, marks, corner glyph. One lens per channel — so "configurable" is bounded by geometry, not taste.

Why it pays for itself

You already type :PROPERTIES: calories:520 in day view. Month is the first surface that rewards that entry instead of just storing it.

1 · The model — a lens with three more shape terms

Draft 3 already has shape terms (sort: group: render: limit:) that change how a result set is presented without changing what it selects. A month lens is the same move: the source and filter select this month's blocks, the new terms fold each day's bucket into a scalar and say which channel paints it. Nothing here needs an expression evaluator — agg:sum of:calories instead of sum(calories) keeps the grammar closed and completable from a menu (invariant 4).

// current behaviour, written explicitly for the first time
![[:journal:]]{task when:this-month agg:count group:status paint:dots}

// float 0..1 → background wash
![[:diary:]]{when:this-month agg:avg of:sentiment paint:heat scale:0..1}

// sum a property, normalized against a stated ceiling
![[:meal:]]{when:this-month agg:sum of:calories paint:bar scale:0..3000}

// existence → glyph, no number at all
![[:gym:]]{when:this-month agg:any paint:glyph}

// escape hatch, rung 4: a named code block returns the 31 values
![[:diary:]]{when:this-month agg:lens/mood-model paint:heat scale:auto}
termvaluesnotes
agg:count · sum · avg · max · min · any · first · lens/<name>All but count and any require of:. lens/<name> is a named code block: in → the month's blocks, out → 31 numbers or nulls. That's the whole extension point.
of:a metadata key — calories, sentiment, minutesKeys are already grammar (§3.6 key:value). Non-numeric values on a numeric agg are skipped and counted, never coerced.
paint:dots · heat · bar · glyph · ruleDeclares the channel too: heat/rule = background, dots/bar = marks, glyph = corner. Two lenses claiming one channel is invalid — the second fails closed and says so.
scale:auto · lo..hi · logauto normalizes to the visible month, so the same day changes colour when you page months. Legal, but the legend must print the resolved range every time. Explicit lo..hi is the default recommendation.
group:none (default) · status · tagThe only way to get a series instead of a scalar, capped at 3 buckets, and only paint:dots reads it. This is where the current done/created colouring comes from.

2 · Aggregation is a new stage, not a new term

Right — and it's the most important thing in this proposal. Draft 3's lens is row-shaped: LensResult = rows + provenance + capabilities. ![[:tag:]]{todo} never sums because it hands back the blocks themselves and the view renders them. A month cell can't render blocks; it needs one number per day. That is not another filter term — it's a fold, a stage the pipeline doesn't currently have.

today    select ─────────────────────────────────────▶ rows
proposed select ──▶ bucket ──▶ fold ──▶ paint ──▶ rows | scalars
stage![[:mcp:]]{todo} — todaymonth lensweek header
selecttag ∩ todotag ∩ when:this-monthtag ∩ when:this-week
bucketnone — one bucket, all rowsday → 31 bucketsday → 7 buckets
foldidentity — keep the rowssum of:caloriescount
paintrender:transcludepaint:heatthe number in the column header
resultrows + write capability31 scalars + null mask7 scalars

So the "31-array" you intuited is exactly the fold's output type: a value per bucket plus a null mask. Writing it as a stage rather than a month-view feature is what makes the week header's existing count, the month grid, and a future year view one mechanism instead of three. Three consequences fall out, and two of them are safety properties you get for free:

Folded results are read-only by construction

Invariant 6 says mutation must be capability-derived. A sum has no unambiguous inverse — you cannot type into 2,450 kcal — so the fold stage simply drops the write plan. No new rule needed; aggregation is self-limiting where rows are not.

Provenance must survive the fold

Each scalar keeps the row set that produced it. That's what makes a painted day clickable, what lets explain answer "why is Aug 15 dark", and what makes a clipped or skipped value nameable instead of mysterious.

Ordinary lenses get footers for free

![[:meal:]]{when:today agg:sum of:calories} with no bucket is a single scalar — the daily total, under the rows. The fold isn't a month feature that leaked into grammar; the month is just its first bucketed consumer.

3 · Five lenses over the same August

Same month, same blocks, one lens swapped. Days 17–31 are genuinely empty — future — which is the honest case a lens has to survive.

4 · Is it a good idea? — the case, and the case against

yes — because

It closes the loop you already opened

Day view collects metadata; nothing has been consuming it. A month lens turns calories:520 from bookkeeping into a payoff, which is the only thing that keeps metadata entry alive after week three.

yes — because

The cost is four terms, not a subsystem

No axis config, no chart picker, no layout engine, no new storage. It reuses the lens parser, the tag/date indexes, and the commitment ladder. Rung 4 (a code block) is the only place arbitrary computation lives.

risk

Dashboard drift

The month view's job is navigation — get me to a day. A lens that makes a day harder to see or click has made the view worse even if the chart is right. Rule: numbers never replace the day number, marks stay under 25% of cell height, the whole cell stays one hit target.

risk

Normalization lies

scale:auto is the setting everyone will pick and it makes months incomparable — a 1,800 kcal day is dark in a light month. Hence a mandatory one-line legend printing the resolved range, and explicit lo..hi as the recommended default.

Verdict: build it, but as grammar, not as a plugin surface. If the first version ships a Lua callback and a settings dialog, you get a small plotting library maintained forever. If it ships as agg: of: paint: scale:, the interesting version is one line of text a user can type, keep, and name — the same four-rung ladder as every other lens, and a named month lens is shareable for free.

5 · Falls out for free, if the contract is day → scalar

Week view gets the same lens

Its column headers already show a count (that 7, 6, 5…). That's a month lens with 7 buckets and paint: unset. Same query, different when:.

A year view becomes trivial

365 buckets, paint:heat, no other change. This is the strongest argument for the scalar contract — a per-cell renderer wouldn't survive the size change.

The lens is a query, so it's clickable

Clicking a painted day can open the day filtered by the lens that painted it — the reason it was dark is one keystroke away. A rendering hook could never do that.

6 · Open questions

#questionmy lean
1Where does the month's lens live? Visit state, a pinned block on a journal/2026-08 page, or a user default?All three, as the existing ladder: summon for a glance, pin per month, name for reuse. The month header shows the chip line exactly like a page header lens.
2Does paint:heat use one hue with lightness steps, or two hues diverging from a midpoint?One hue, 5 discrete steps, olive. Diverging needs a meaningful zero, which avg sentiment has and sum calories doesn't — so it'd be a per-lens option and that's a settings dialog again.
3Is agg:lua worth shipping in v1, or does it invite exactly the plugin surface §3 warns about?Ship it, but output-typed and unstyled: it returns numbers, the built-in painters draw them. Never let it emit markup into a cell.
4What happens on a day with data but a null aggregate (three meals, none with calories:)?Paint nothing, but count it: the legend reads 4 days skipped (no calories). Silent nulls are how people conclude the feature is broken.
5Three channels — too many at once, in practice?Frame 5 is legible but near the ceiling. Suggest a soft warning at three, and never allow two lenses in one channel.

Illustrative only — the August data here is fabricated to show shape, not to report anything. Grammar follows Lens Spec Draft 3: source + filter unchanged, four shape terms added.