Skip to main content

max / goingson

Declare the task drawer, and close goingson Wave 14, last of four files. `progress.py` reads goingson `declared 162, remaining 35`, and all 35 are parked: no goingson screen is hand-written any more. Sixteen shapes. Two reads hoisted, `Drawer` and `Editing`, and the second is where the file changed most: `edit_fields` built twelve fields through two closures and a `Vec` of `apply` calls, and it is now twelve fields written out under the form that holds them, each saying its own error and refill. Two shapes went that were not declared but restructured away. `details -> Vec<Row>` and `edit_fields -> Vec<Field>` are not shaped types, so neither could ever be a declaration; both were a list and a form's contents written somewhere other than where they are used, and they are now written where they are used. One production earned, in quasi@0c993e4: `proportion`, a meter standing on its own. This file wants one twice, and MNW's `crossover_meter` is the supplier that existed only because it did not.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-04 20:00 UTC
Signed with PGP, not checked
Commit: ce98b78276eae2a0080f2baecbd9b5538624f7f5
Parent: 4e18abd
1 file changed, +241 insertions, -223 deletions
@@ -36,8 +36,9 @@
36 36 Annotation, DbValue as _, LinkedTaskRef, Priority, Recurrence, Subtask, Task, TaskId,
37 37 TaskStatus, TimeSession, UpdateTask,
38 38 };
39 - use quasi_router::screen::{Act, Choice, Field, Figure, Meter, Row, Tag};
40 - use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
39 + use quasi_declare::declare;
40 + use quasi_router::screen::{Choice, Figure, Tag};
41 + use quasi_router::{Action, RegionKind, Response, RouteError, Router};
41 42
42 43 use super::parse_optional_id;
43 44 use crate::commands::{StreakInfo, compute_streak};
@@ -129,319 +130,384 @@
129 130 Ok(Some(compute_streak(&chain)))
130 131 }
131 132
132 - /// The four streak figures, as one strip.
133 - ///
134 - /// A large value over a small caption, laid out as a strip:
135 - /// `makeover_layout::Figure` plus `Node::Stats`. The set is the node rather
136 - /// than each figure, because four tiles in a strip and four down a column are
137 - /// different things and a renderer handed one at a time cannot tell which it is
138 - /// looking at.
139 - ///
140 - /// None of these four answers a click. `Node::Stats` carries an optional action
141 - /// per figure for the one goingson site that does — sync's "Not Applied: 3" —
142 - /// and this section does not use it.
143 - fn habit_figures(streak: &StreakInfo) -> Node {
144 - Node::stats([
145 - Figure::new(format!("{}d", streak.current_streak), "Current Streak"),
146 - Figure::new(format!("{}d", streak.best_streak), "Best Streak"),
147 - Figure::new(
148 - format!("{}%", streak.completion_rate_30d.round() as i64),
149 - "Completion Rate",
150 - ),
151 - Figure::new(
152 - format!("{}/{}", streak.total_completed, streak.total_instances),
153 - "Total Completed",
154 - ),
155 - ])
133 + declare! {
134 + /// The four streak figures, as one strip.
135 + ///
136 + /// A large value over a small caption, laid out as a strip: `Figure` plus
137 + /// `Node::Stats`. The set is the node rather than each figure, because four
138 + /// tiles in a strip and four down a column are different things and a
139 + /// renderer handed one at a time cannot tell which it is looking at.
140 + ///
141 + /// None of these four answers a click. `Node::Stats` carries an optional
142 + /// action per figure for the one goingson site that does -- sync's "Not
143 + /// Applied: 3" -- and this section does not use it.
144 + shape habit_figures(streak: &StreakInfo) -> Node;
145 +
146 + stats [] {
147 + figure Figure::new("{streak.current_streak}d", "Current Streak");
148 + figure Figure::new("{streak.best_streak}d", "Best Streak");
149 + figure Figure::new("{completion_rate(streak)}%", "Completion Rate");
150 + figure Figure::new(
151 + "{streak.total_completed}/{streak.total_instances}",
152 + "Total Completed"
153 + );
154 + }
156 155 }
157 156
158 - /// The completion-history section, for a recurring task.
159 - fn habit_section(streak: &StreakInfo, task_id: TaskId) -> Vec<Node> {
160 - vec![
161 - Node::section("Completion History"),
162 - habit_figures(streak),
163 - // The heatmap. A month grid of completion counts is exactly what
164 - // `Region::Handover` was named for: the description says a thing called
165 - // `task-heatmap` goes here and says nothing else, and
166 - // `task-overview.js:renderHeatmap` fills it. Not a workaround and not a
167 - // gap — a description able to produce a calendar grid is a widget
168 - // library with a description's name on it.
169 - //
170 - // The id carries the task so the filling code knows which chain to
171 - // render without asking the screen.
172 - Node::Region(Slot::ceded(
173 - format!("task-heatmap-{task_id}"),
174 - "task-heatmap",
175 - )),
176 - ]
157 + /// The thirty-day rate, rounded the way the strip shows it.
158 + fn completion_rate(streak: &StreakInfo) -> i64 {
159 + streak.completion_rate_30d.round() as i64
177 160 }
178 161
179 - /// The badges across the top: status, priority, and whatever else is true.
180 - ///
181 - /// Tokens rather than text, so a status keeps its tone. The three conditional
182 - /// ones come in the order focus, overdue, snoozed.
183 - fn badges(task: &Task) -> Vec<Node> {
184 - let mut out = vec![
185 - Node::Token(Tag::badge(task.status.as_str()).tone(status_tone(&task.status))),
186 - Node::Token(Tag::badge(task.priority.as_str()).tone(priority_tone(&task.priority))),
187 - ];
188 - if task.is_focus {
189 - out.push(Node::Token(
190 - Tag::badge("Focus").tone(makeover_layout::Tone::Info),
191 - ));
192 - }
193 - if task.is_overdue() {
194 - out.push(Node::Token(
195 - Tag::badge("Overdue").tone(makeover_layout::Tone::Danger),
196 - ));
197 - }
198 - if task.is_snoozed() {
199 - out.push(Node::Token(
200 - Tag::badge("Snoozed").tone(makeover_layout::Tone::Warning),
201 - ));
202 - }
203 - out
162 + declare! {
163 + /// The completion-history section, for a recurring task.
164 + shape habit_section(streak: &StreakInfo, task: TaskId) -> Vec<Node>;
165 +
166 + section "Completion History";
167 + include habit_figures(streak);
168 +
169 + // The heatmap. A month grid of completion counts is exactly what
170 + // `RegionKind::Ceded` was named for: the description says a thing called
171 + // `task-heatmap` goes here and says nothing else, and
172 + // `task-overview.js:renderHeatmap` fills it. Not a workaround and not a gap
173 + // -- a description able to produce a calendar grid is a widget library with
174 + // a description's name on it.
175 + //
176 + // The id carries the task so the filling code knows which chain to render
177 + // without asking the screen.
178 + region "task-heatmap-{task}" as RegionKind::ceded("task-heatmap") {}
204 179 }
205 180
206 - /// The labelled facts under the badges.
207 - ///
208 - /// `Project: X`, `Due: Y` and the rest, each a row whose primary is the label
209 - /// and whose meta is the value. The JS bolds the label inside a sentence; a row
210 - /// is the nearest thing the vocabulary has to a definition list, and unlike the
211 - /// stats above the label really is the primary here.
212 - fn details(task: &Task) -> Vec<Row> {
213 - let mut rows = Vec::new();
214 - if let Some(project) = &task.project_name {
215 - rows.push(Row::new("Project").meta(project));
181 + declare! {
182 + /// The badges across the top: status, priority, and whatever else is true.
183 + ///
184 + /// Tokens rather than text, so a status keeps its tone. The three
185 + /// conditional ones come in the order focus, overdue, snoozed.
186 + shape badges(task: &Task) -> Vec<Node>;
187 +
188 + badge task.status.as_str() {
189 + tone status_tone(&task.status);
216 190 }
217 - if task.due.is_some() {
218 - rows.push(Row::new("Due").meta(task.due_formatted()));
191 +
192 + badge task.priority.as_str() {
193 + tone priority_tone(&task.priority);
219 194 }
220 - if task.has_recurrence() {
221 - rows.push(Row::new("Recurrence").meta(task.recurrence.as_str()));
195 +
196 + badge "Focus" when task.is_focus {
197 + tone Info;
222 198 }
223 - if let Some(contact) = &task.contact_name {
224 - rows.push(Row::new("Contact").meta(contact));
199 +
200 + badge "Overdue" when task.is_overdue() {
201 + tone Danger;
225 202 }
226 - if !task.tags.is_empty() {
203 +
204 + badge "Snoozed" when task.is_snoozed() {
205 + tone Warning;
206 + }
207 + }
208 +
209 + declare! {
210 + /// The metadata section.
211 + ///
212 + /// A task's description is markdown, and it goes in as `rich`, which
213 + /// carries the markdown **source** rather than markup. Every renderer
214 + /// renders that source its own way, and nothing in a description is ever
215 + /// markup, so `Node::Text`'s escaping guarantee is untouched.
216 + ///
217 + /// quasi-webview renders it through docengine's strict preset, so raw HTML
218 + /// inside a description reads as text. A shared renderer taking what a user
219 + /// typed should be the safer of the two.
220 + ///
221 + /// # The labelled facts under the badges
222 + ///
223 + /// `Project: X`, `Due: Y` and the rest, each a row whose primary is the
224 + /// label and whose meta is the value. The JS bolds the label inside a
225 + /// sentence; a row is the nearest thing the vocabulary has to a definition
226 + /// list, and unlike the stats above the label really is the primary here.
227 + shape metadata(task: &Task) -> Vec<Node>;
228 +
229 + extend badges(task);
230 +
231 + rich &task.description unless task.description.is_empty();
232 +
233 + list {
234 + for project in task.project_name.iter() {
235 + row "Project" {
236 + meta project;
237 + }
238 + }
239 +
240 + row "Due" when task.due.is_some() {
241 + meta task.due_formatted();
242 + }
243 +
244 + row "Recurrence" when task.has_recurrence() {
245 + meta task.recurrence.as_str();
246 + }
247 +
248 + for contact in task.contact_name.iter() {
249 + row "Contact" {
250 + meta contact;
251 + }
252 + }
253 +
227 254 // Tokens, because a tag is a badge in the JS and `RowPart::Tokens`
228 255 // exists now to keep it one.
229 - let mut row = Row::new("Tags");
230 - for tag in &task.tags {
231 - row = row.token(Tag::badge(tag));
256 + row "Tags" unless task.tags.is_empty() {
257 + for tag in task.tags.iter() {
258 + token Tag::badge(tag);
259 + }
232 260 }
233 - rows.push(row);
234 - }
235 - rows
261 + } unless no_details(task);
236 262 }
237 263
238 - /// The metadata section.
239 - ///
240 - /// A task's description is markdown, and it goes in as [`Node::Rich`], which
241 - /// carries the markdown **source** rather than markup. Every renderer renders
242 - /// that source its own way, and nothing in a description is ever markup, so
243 - /// `Node::Text`'s escaping guarantee is untouched.
244 - ///
245 - /// quasi-webview renders it through docengine's strict preset, so raw HTML
246 - /// inside a description reads as text. A shared renderer taking what a user
247 - /// typed should be the safer of the two.
248 - fn metadata(task: &Task) -> Vec<Node> {
249 - let mut out = badges(task);
250 - if !task.description.is_empty() {
251 - out.push(Node::rich(&task.description));
252 - }
253 - let rows = details(task);
254 - if !rows.is_empty() {
255 - out.push(Node::list(rows));
256 - }
257 - out
264 + /// Whether the task has any labelled fact worth a row.
265 + fn no_details(task: &Task) -> bool {
266 + task.project_name.is_none()
267 + && task.due.is_none()
268 + && !task.has_recurrence()
269 + && task.contact_name.is_none()
270 + && task.tags.is_empty()
258 271 }
259 272
260 - /// One subtask.
261 - ///
262 - /// # The tick is the write
263 - ///
264 - /// **A tick that means something has no route.** `Row::selected` said whether a
265 - /// row is ticked and whether it can be, and nothing said what ticking it
266 - /// *calls*. That was right for the case it was added for — goingson's
267 - /// bulk-selection checkboxes are client state feeding a later bulk action — and
268 - /// a subtask is the other case: the tick is the write.
269 - ///
270 - /// `Row::toggle` lives on quasi-router rather than makeover-layout: the
271 - /// vocabulary has no notion of an action, so "what this calls" is not a thing
272 - /// it can say. A standalone control uses `Field::writes` instead.
273 - ///
274 - /// A linked subtask keeps the button: its state follows the task it links to, so
275 - /// it is not tickable at all, and `Act::disabled` is what says that. Describing
276 - /// it as a tick that refuses to move would be the same defect the other way
277 - /// round.
278 - fn subtask_row(task: TaskId, subtask: &Subtask) -> Row {
279 - let action = Action::post(format!("/tasks/{task}/subtasks/{}/toggle", subtask.id));
273 + declare! {
274 + /// One subtask.
275 + ///
276 + /// # The tick is the write
277 + ///
278 + /// **A tick that means something has no route.** `Row::selected` said
279 + /// whether a row is ticked and whether it can be, and nothing said what
280 + /// ticking it *calls*. That was right for the case it was added for --
281 + /// goingson's bulk-selection checkboxes are client state feeding a later
282 + /// bulk action -- and a subtask is the other case: the tick is the write.
283 + ///
284 + /// `Row::toggle` lives on quasi-router rather than makeover-layout: the
285 + /// vocabulary has no notion of an action, so "what this calls" is not a
286 + /// thing it can say. A standalone control uses `Field::writes` instead.
287 + ///
288 + /// A linked subtask keeps the button: its state follows the task it links
289 + /// to, so it is not tickable at all, and a disabled act is what says that.
290 + /// Describing it as a tick that refuses to move would be the same defect
291 + /// the other way round.
292 + shape subtask_row(task: TaskId, subtask: &Subtask) -> Row;
280 293
281 - if subtask.linked_task_id.is_some() {
282 - return Row::new(&subtask.text)
283 - .selectable(subtask.is_completed)
284 - .token(Tag::badge("Linked"))
285 - .act(Act::new(if subtask.is_completed { "Undo" } else { "Done" }, action).disabled());
294 + row &subtask.text {
295 + toggling subtask.is_completed
296 + Action::post("/tasks/{task}/subtasks/{subtask.id}/toggle")
297 + unless is_linked(subtask);
298 +
299 + selectable subtask.is_completed when is_linked(subtask);
300 + token Tag::badge("Linked") when is_linked(subtask);
301 +
302 + act undo_or_done(subtask)
303 + to post "/tasks/{task}/subtasks/{subtask.id}/toggle"
304 + when is_linked(subtask) {
305 + disabled;
306 + }
286 307 }
287 -
288 - Row::new(&subtask.text).toggling(subtask.is_completed, action)
289 308 }
290 309
291 - /// The subtasks section.
292 - ///
293 - /// # A heading cannot carry a count, and a proportion is a `Meter`
294 - ///
295 - /// The count rides in the heading text; the proportion is
296 - /// `makeover_layout::Meter`. This section is the first
297 - /// consumer — the bar comes back, with the count still in the heading because
298 - /// that half is a nice-to-have and stays one. `d0b58239`.
299 - ///
300 - /// The count is deliberately not moved into the meter's label. The heading's
301 - /// count names the section and reads without the bar; the meter's label names
302 - /// what is being counted. Saying "3/7" twice would be the same fact in two
303 - /// places, which is what the concatenation was.
304 - fn subtasks_section(task: &Task) -> Vec<Node> {
305 - let done = task.subtasks_completed();
306 - let total = task.subtask_count();
307 -
308 - let mut out = vec![Node::section(format!("Subtasks {done}/{total}"))];
309 - if total > 0 {
310 - out.push(Node::Meter(
311 - Meter::new(
312 - u32::try_from(done).unwrap_or(u32::MAX),
313 - u32::try_from(total).unwrap_or(u32::MAX),
314 - )
315 - // Success, matching `tasks-render.js` and the four other subtask
316 - // rollups. Completion is the one proportion here that cannot mean
317 - // anything bad.
318 - .tone(makeover_layout::Tone::Success)
319 - .label("subtasks"),
320 - ));
321 - }
322 - if total > 0 {
323 - out.push(Node::list(
324 - task.subtasks
325 - .iter()
326 - .map(|subtask| subtask_row(task.id, subtask)),
327 - ));
328 - }
329 - out.push(Node::Form {
330 - action: Action::post(format!("/tasks/{}/subtasks", task.id)),
331 - submit: "Add".to_owned(),
332 - fields: vec![{
333 - let mut field =
334 - Field::new(makeover_layout::FieldKind::Text, "text", "Subtask").required();
335 - field.placeholder = Some("Add subtask...".to_owned());
336 - field
337 - }],
338 - });
339 - out
310 + /// Whether the subtask's state follows a task it links to.
311 + fn is_linked(subtask: &Subtask) -> bool {
312 + subtask.linked_task_id.is_some()
340 313 }
341 314
342 - /// One tracked session.
343 - fn session_row(session: &TimeSession) -> Row {
344 - let started = session.started_at.with_timezone(&Local);
345 - Row::new(format!(
315 + /// What the linked subtask's refused button reads.
316 + fn undo_or_done(subtask: &Subtask) -> &'static str {
317 + if subtask.is_completed { "Undo" } else { "Done" }
318 + }
319 +
320 + declare! {
321 + /// The subtasks section.
322 + ///
323 + /// # A heading cannot carry a count, and a proportion is a `Meter`
324 + ///
325 + /// The count rides in the heading text; the proportion is a `proportion`
326 + /// member. This section is the first consumer -- the bar comes back, with
327 + /// the count still in the heading because that half is a nice-to-have and
328 + /// stays one. `d0b58239`.
329 + ///
330 + /// The count is deliberately not moved into the meter's label. The
331 + /// heading's count names the section and reads without the bar; the meter's
332 + /// label names what is being counted. Saying "3/7" twice would be the same
333 + /// fact in two places, which is what the concatenation was.
334 + shape subtasks_section(task: &Task) -> Vec<Node>;
335 +
336 + section "Subtasks {task.subtasks_completed()}/{task.subtask_count()}";
337 +
338 + // Success, matching `tasks-render.js` and the four other subtask rollups.
339 + // Completion is the one proportion here that cannot mean anything bad.
340 + proportion counted(task.subtasks_completed()) counted(task.subtask_count())
341 + unless task.subtasks.is_empty() {
342 + tone Success;
343 + label "subtasks";
344 + }
345 +
346 + list {
347 + for subtask in task.subtasks.iter() {
348 + include subtask_row(task.id, subtask);
349 + }
350 + } unless task.subtasks.is_empty();
351 +
352 + form post "/tasks/{task.id}/subtasks" {
353 + submit "Add";
354 +
355 + field Text "text" "Subtask" {
356 + required;
357 + placeholder "Add subtask...";
358 + }
359 + }
360 + }
361 +
362 + /// A count as a meter reads one.
363 + fn counted(n: usize) -> u32 {
364 + u32::try_from(n).unwrap_or(u32::MAX)
365 + }
366 +
367 + declare! {
368 + /// One tracked session.
369 + shape session_row(session: &TimeSession) -> Row;
370 +
371 + row when_started(session) {
372 + // A session with no end is one running right now, which is a fact about
373 + // the task and not a missing value.
374 + meta ran_for(session);
375 + }
376 + }
377 +
378 + /// When a session started, as the row reads it.
379 + fn when_started(session: &TimeSession) -> String {
380 + format!(
346 381 "{} {}",
347 382 short_date(session.started_at),
348 - started.format("%-I:%M %p")
349 - ))
350 - .meta(match session.duration_minutes {
351 - Some(minutes) => format!("{minutes}m"),
352 - // A session with no end is one running right now, which is a fact
353 - // about the task and not a missing value.
354 - None => "active".to_owned(),
355 - })
383 + session.started_at.with_timezone(&Local).format("%-I:%M %p")
384 + )
356 385 }
357 386
358 - /// The time-tracking section.
359 - ///
360 - /// The site that decided `Meter`'s shape. This bar is toned, red past the
361 - /// estimate and green under it, and it is the one place in either app where the
362 - /// numerator can exceed the denominator, which is why the member carries the
363 - /// pair and not the percentage `Task::time_progress` computes: that
364 - /// function clamps to 100 and the over-run survives only in the separate
365 - /// `is_over_estimate` flag beside it.
366 - ///
367 - /// So the heading keeps the readable summary and the meter carries the numbers
368 - /// unclamped. ", over" stays in the heading text: the meter says it to a
369 - /// renderer through `data-over`, and the heading says it to someone reading.
370 - fn time_section(task: &Task, sessions: &[TimeSession]) -> Vec<Node> {
371 - let tracked = format!("{}m tracked", task.actual_minutes);
372 - let label = match task.estimated_minutes {
373 - Some(estimate) if estimate > 0 => {
374 - let over = if task.is_over_estimate() {
375 - ", over"
376 - } else {
377 - ""
378 - };
379 - format!("{tracked} / {estimate}m est{over}")
387 + /// How long it ran, or that it still is.
388 + fn ran_for(session: &TimeSession) -> String {
389 + session
390 + .duration_minutes
391 + .map_or_else(|| "active".to_owned(), |minutes| format!("{minutes}m"))
392 + }
393 +
394 + declare! {
395 + /// The time-tracking section.
396 + ///
397 + /// The site that decided `Meter`'s shape. This bar is toned, red past the
Lines truncated