Skip to main content

max / goingson

Place a task on the day at its own size The planner drew blocks it could not create. A task reached the axis only through the Tauri command, so the pool was a list of things you could open and not a list of things you could schedule. Placement is a control with a discrete argument: which of the day's 96 slots. Offered as choices rather than a wall-clock field, because a wall-clock answer could name a date the address already carries and would then have to be checked against it. The block is as long as the task's estimate, which is what quasicoherent e41079b2 ruled and what nothing here read until now. A task with no estimate is placeable, and placing it is what sets the estimate: goingson fa9fe9ed ruled (d) over defaulting silently, refusing the placement, and drawing a guess differently. The 15 minutes is a pre-filled answer and not a default, so an ask that is dismissed places nothing and writes nothing. Both halves are Act::asking, which already existed and needed no new vocabulary and no makeover cascade. The estimate write is built from the task just read, for the reason the status control gives: anything left out of an UpdateTask is cleared, and being put on the day is not a reason to lose a task's tags. Every write on this screen now answers with the pool as well as the axis. They all move a task between the two, and unscheduling already left the pool showing a task that was no longer on the day.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01DwpiantpUgohzML4xr6KeQ
Author: Max Johnson <me@maxj.phd> · 2026-08-31 14:32 UTC
Signed with PGP, not checked
Commit: 1a7c5a4d454cc40f67e3493eb062900540a74547
Parent: 354de5a
2 files changed, +447 insertions, -24 deletions
@@ -20,26 +20,28 @@
20 20 //! Not here, and each a decision rather than an omission:
21 21 //!
22 22 //! - **Drag to reschedule.** Moving an existing item is a continuous gesture
23 - //! and the vocabulary names no such thing. quasicoherent `e41079b2` is the
24 - //! live question, and Max's direction there is that placing pre-portioned
25 - //! blocks replaces the paint interaction entirely. Either way it is an
26 - //! interaction and not a drawing, so it does not block this screen.
27 - //! - **Drag to paint a new block.** Same question, same task.
23 + //! and the vocabulary names no such thing. Stepping by a fixed amount is
24 + //! what replaced it, on a row's own controls.
25 + //! - **Drag to paint a new block.** Placement replaced it: a task is put on
26 + //! the day at a named slot, at the size its estimate gives it. Both
27 + //! quasicoherent `e41079b2` and goingson `fa9fe9ed` are settled, and neither
28 + //! asked for a gesture. See [`placing`].
28 29 //!
29 - //! Both are the host's: a described screen draws the day and hands the two
30 - //! gestures over.
30 + //! Nothing here is waiting on a continuous-input member. The vocabulary the
31 + //! screen needed was [`Placed`] to draw a block and [`Act::asking`] to ask for
32 + //! one before writing it, and both existed.
31 33
32 34 // Handlers take their request by value because `quasi_router::Handler` is a
33 35 // plain `fn(&S, Request)` pointer, so the signature is the router's.
34 36 #![allow(clippy::needless_pass_by_value)]
35 37
36 - use chrono::{Local, NaiveDate};
38 + use chrono::{Local, NaiveDate, TimeZone, Utc};
37 39 use goingson_core::TimelineItem;
38 - use makeover_layout::{Placement, Tone, Track};
39 - use quasi_router::screen::{Act, Placed, Row, Tag};
40 + use makeover_layout::{FieldKind, Placement, Tone, Track};
41 + use quasi_router::screen::{Act, Choice, Field, Placed, Row, Tag};
40 42 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
41 43
42 - use crate::commands::{DayPlanningResponse, day_plan};
44 + use crate::commands::{DayPlanningResponse, TaskResponse, day_plan};
43 45 use crate::state::{AppState, DESKTOP_USER_ID};
44 46
45 47 #[cfg(test)]
@@ -63,6 +65,87 @@
63 65 day_plan(state, date).map_err(|error| RouteError::internal(error.to_string()))
64 66 }
65 67
68 + /// The hour the axis opens on, and the slot a placement is offered at first.
69 + ///
70 + /// One number for both, because they answer the same question: which part of
71 + /// the day the reader is most likely to mean.
72 + const FOCUS_MINUTES: i32 = 9 * 60;
73 +
74 + /// The grid a placement lands on, in minutes.
75 + ///
76 + /// The same 15 the move controls step by and the same 96 slots the ruler
77 + /// draws. Shared deliberately: a block placed off the grid could not be
78 + /// stepped back onto it.
79 + const SLOT_MINUTES: i32 = 15;
80 +
81 + /// A day, in minutes. The bound on both a slot and a duration.
82 + const DAY_MINUTES: i32 = 24 * 60;
83 +
84 + /// The length offered for a task nobody has estimated.
85 + ///
86 + /// One slot, the smallest honest guess. It is a pre-filled answer and not a
87 + /// silent default: dismissing the ask places nothing and writes nothing, which
88 + /// is the whole of what goingson `fa9fe9ed` ruled.
89 + const UNESTIMATED_MINUTES: i32 = 15;
90 +
91 + /// Every slot of the day, as the choices a placement picks between.
92 + ///
93 + /// The argument is discrete and this is what makes it so. A wall-clock field
94 + /// would let the answer name another date, which the address already carries,
95 + /// and would then have to be checked against it.
96 + fn slots() -> Vec<Choice> {
97 + (0..DAY_MINUTES / SLOT_MINUTES)
98 + .map(|slot| {
99 + let minutes = slot * SLOT_MINUTES;
100 + Choice::new(
101 + minutes.to_string(),
102 + format!("{:02}:{:02}", minutes / 60, minutes % 60),
103 + )
104 + })
105 + .collect()
106 + }
107 +
108 + /// Putting a task on the day, as the control that does it.
109 + ///
110 + /// A task is placed at its own size: the block is as long as the estimate, so
111 + /// the day shows the work rather than a row of identical stubs. A task with no
112 + /// estimate is placeable anyway, and **placing it is what sets the estimate**.
113 + /// goingson `fa9fe9ed` ruled (d) over defaulting silently, over refusing the
114 + /// placement, and over drawing a guess differently. The planner is the one place a
115 + /// person is already thinking about how long the thing takes, so it is the
116 + /// right place to be asked.
117 + ///
118 + /// Both halves are [`Act::asking`], which existed: a control that asks for a
119 + /// value before it acts. The second field is offered only when there is
120 + /// nothing to offer instead, so a task that carries an estimate is placed in
121 + /// one answer rather than being asked to confirm what it already says.
122 + fn placing(task: &TaskResponse, date: NaiveDate) -> Act {
123 + let mut act = Act::new(
124 + "Place on the day",
125 + Action::post(format!("/day/{date}/schedule/{}/place", task.id)),
126 + )
127 + .asking(
128 + Field::select("at", "Start at", slots())
129 + .value(FOCUS_MINUTES.to_string())
130 + .required(),
131 + );
132 +
133 + if task.estimated_minutes.is_none_or(|minutes| minutes <= 0) {
134 + act = act.asking(
135 + Field {
136 + min: Some("1".to_owned()),
137 + max: Some(DAY_MINUTES.to_string()),
138 + value: Some(UNESTIMATED_MINUTES.to_string()),
139 + ..Field::new(FieldKind::Number, "minutes", "How long it takes")
140 + }
141 + .hint("Placing it records this as the task's estimate.")
142 + .required(),
143 + );
144 + }
145 +
146 + act
147 + }
148 +
66 149 /// One item as a row, without its placement.
67 150 ///
68 151 /// The whole body is vocabulary that existed before this screen: a title, the
@@ -107,8 +190,10 @@
107 190 // the same shape as the milestone row's reorder: a control that is on
108 191 // screen beats a gesture nobody discovers.
109 192 //
110 - // The drag itself stays refused, and it is quasicoherent `e41079b2`:
111 - // nothing names a gesture across a grid or what it produces.
193 + // The drag stays refused and the question is closed, not open:
194 + // quasicoherent `e41079b2` ruled that pre-portioned placement replaces
195 + // it. Placement decides where a block starts; stepping is how it moves
196 + // afterwards, so the two are not alternatives.
112 197 let step = |minutes: i32, label: &str| {
113 198 Act::new(
114 199 label,
@@ -194,10 +279,10 @@
194 279
195 280 /// The unscheduled pool.
196 281 ///
197 - /// Everything due today that is not on the axis yet. Each row opens its task;
198 - /// putting one *onto* the day is the interaction `e41079b2` decides, so the
199 - /// pool draws and does not place.
200 - fn pool(response: &DayPlanningResponse) -> Node {
282 + /// Everything due today that is not on the axis yet. Each row opens its task
283 + /// and carries the control that puts it on the day; see [`placing`] for what
284 + /// that asks for and why.
285 + fn pool(response: &DayPlanningResponse, date: NaiveDate) -> Node {
201 286 let rows: Vec<Row> = response
202 287 .unscheduled_tasks
203 288 .iter()
@@ -227,7 +312,8 @@
227 312 if let Some(marker) = super::Availability::reported(task).frees_marker() {
228 313 row = row.token(marker);
229 314 }
230 - row.activate(Action::get(format!("/tasks/{}", task.id)))
315 + row.act(placing(task, date))
316 + .activate(Action::get(format!("/tasks/{}", task.id)))
231 317 })
232 318 .collect();
233 319
@@ -295,7 +381,7 @@
295 381 // by project. Nothing else on this screen states today's total, since
296 382 // two claims about it would be one too many.
297 383 .with(super::time_tracking::summary(state)?)
298 - .with(Slot::new("day-pool", RegionKind::Pane).with(pool(&response)))
384 + .with(Slot::new("day-pool", RegionKind::Pane).with(pool(&response, date)))
299 385 .into())
300 386 }
301 387
@@ -375,13 +461,121 @@
375 461 timeline_fragment(state, date)
376 462 }
377 463
378 - /// The axis, re-read. What both writes answer with.
464 + /// Put a task on the day at a named slot, and answer with the axis.
465 + ///
466 + /// The slot arrives as minutes from local midnight, which is what [`slots`]
467 + /// offers. The length is the task's estimate; a task with none is asked for
468 + /// one and **the answer becomes the estimate**, per goingson `fa9fe9ed`. An
469 + /// ask that was dismissed sends nothing, so nothing is placed and nothing is
470 + /// written. That is the distinction between the ruling and the silent default
471 + /// it beat, and it is the easy half to lose.
472 + ///
473 + /// A slot already occupied is accepted rather than refused. The conflict pass
474 + /// reports clashes and the axis draws them in lanes; this screen reports
475 + /// rather than prevents, the same as it does for an out-of-order plan.
476 + fn place(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
477 + let date = date_of(&request)?;
478 + let id = task_of(&request)?;
479 +
480 + let at: i32 = request
481 + .payload
482 + .get("at")
483 + .and_then(|raw| raw.parse().ok())
484 + .filter(|minutes| (0..DAY_MINUTES).contains(minutes))
485 + .ok_or_else(|| RouteError::not_found("place it at a slot of this day"))?;
486 +
487 + let task = state
488 + .tasks
489 + .get_by_id(id, DESKTOP_USER_ID)
490 + .map_err(|error| RouteError::internal(error.to_string()))?
491 + .ok_or_else(|| RouteError::not_found("no such task"))?;
492 +
493 + // A stored zero is no estimate: `is_over_estimate` reads it that way and a
494 + // block of no length is not a thing the axis can draw.
495 + let estimated = task.estimated_minutes.filter(|minutes| *minutes > 0);
496 + let minutes = match estimated {
497 + Some(estimate) => estimate,
498 + None => request
499 + .payload
500 + .get("minutes")
501 + .and_then(|raw| raw.parse().ok())
502 + .filter(|asked| (1..=DAY_MINUTES).contains(asked))
503 + .ok_or_else(|| RouteError::not_found("say how long it takes"))?,
504 + };
505 +
506 + // Placing an unestimated task is what estimates it. Built from the task
507 + // just read, so the estimate is the only thing that changes; anything left
508 + // out of an `UpdateTask` is cleared, and being put on the day is not a
509 + // reason to lose a task's tags.
510 + if estimated.is_none() {
511 + state
512 + .tasks
513 + .update(
514 + id,
515 + DESKTOP_USER_ID,
516 + goingson_core::UpdateTask {
517 + project_id: task.project_id,
518 + milestone_id: task.milestone_id,
519 + contact_id: task.contact_id,
520 + title: task.title.clone(),
521 + description: task.description.clone(),
522 + status: task.status.clone(),
523 + priority: task.priority.clone(),
524 + due: task.due,
525 + tags: task.tags.clone(),
526 + recurrence: task.recurrence.clone(),
527 + recurrence_rule: task.recurrence_rule.clone(),
528 + urgency: task.urgency,
529 + scheduled_start: task.scheduled_start,
530 + scheduled_duration: task.scheduled_duration,
531 + estimated_minutes: Some(minutes),
532 + },
533 + )
534 + .map_err(|error| RouteError::internal(error.to_string()))?
535 + .ok_or_else(|| RouteError::not_found("no such task"))?;
536 + }
537 +
538 + // The slot is a wall-clock time on the day the address names, so the
539 + // instant it stands for is a local one. A clock time the local day skips
540 + // over is refused rather than nudged: on a spring-forward morning 02:30 is
541 + // a slot nobody can start at, and silently placing the block at 03:30
542 + // would be this screen answering a question it was not asked.
543 + let civil = date
544 + .and_hms_opt(
545 + u32::try_from(at / 60).unwrap_or(0),
546 + u32::try_from(at % 60).unwrap_or(0),
547 + 0,
548 + )
549 + .ok_or_else(|| RouteError::not_found("not a time of day"))?;
550 + let start = Local
551 + .from_local_datetime(&civil)
552 + .earliest()
553 + .ok_or_else(|| RouteError::conflict("the clock skips that time on this day"))?
554 + .with_timezone(&Utc);
555 +
556 + // The command's own body, for [`move_scheduled`]'s reason: the linked
557 + // event is written beside the task row, with a compensating undo between
558 + // them, and a second copy of that here would drift.
559 + crate::commands::day_planning::schedule_task_now(state, id, start, Some(minutes))
560 + .map_err(|error| RouteError::internal(error.to_string()))?;
561 +
562 + timeline_fragment(state, date)
563 + }
564 +
565 + /// The axis and the pool, re-read. What every write on this screen answers
566 + /// with.
567 + ///
568 + /// Both, because every write here moves a task between them: placing takes one
569 + /// out of the pool, unscheduling puts one back, and a move leaves the pool
570 + /// alone but is sent through the same door. A fragment that refreshed only the
571 + /// axis would leave the pool showing a task that is now on the day, which is
572 + /// the screen disagreeing with itself.
379 573 fn timeline_fragment(state: &AppState, date: NaiveDate) -> Result<Response, RouteError> {
380 574 let response = plan(state, date)?;
381 - Ok(Response::fragment(
382 - "day-timeline",
383 - timeline(&response, date),
384 - ))
575 + Ok(
576 + Response::fragment("day-timeline", timeline(&response, date))
577 + .also("day-pool", pool(&response, date)),
578 + )
385 579 }
386 580
387 581 /// This screen's routes.
@@ -390,6 +584,7 @@
390 584 .get("/day", day)
391 585 .get("/day/{date}", day)
392 586 .get("/day/{date}/timeline", timeline_only)
587 + .post("/day/{date}/schedule/{task}/place", place)
393 588 .post("/day/{date}/schedule/{task}/move", move_scheduled)
394 589 .post("/day/{date}/schedule/{task}/unschedule", unschedule)
395 590 }
@@ -413,3 +413,231 @@
413 413 .expect_err("that task is not on the day");
414 414 assert_eq!(error.class.http_status(), 404);
415 415 }
416 +
417 + /// A task due on the test day, carrying an estimate.
418 + ///
419 + /// The estimate is what gives the block its size, so a fixture without one is
420 + /// testing the other half of `fa9fe9ed`'s ruling.
421 + fn estimated_task(state: &AppState, title: &str, minutes: i32) -> goingson_core::TaskId {
422 + let due = Local
423 + .from_local_datetime(&day().and_hms_opt(12, 0, 0).unwrap())
424 + .unwrap()
425 + .with_timezone(&Utc);
426 + state
427 + .tasks
428 + .create(
429 + DESKTOP_USER_ID,
430 + goingson_core::NewTask::builder(title)
431 + .due(due)
432 + .estimated_minutes(minutes)
433 + .build(),
434 + )
435 + .unwrap()
436 + .id
437 + }
438 +
439 + fn task(state: &AppState, id: goingson_core::TaskId) -> goingson_core::Task {
440 + state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap()
441 + }
442 +
443 + #[tokio::test]
444 + async fn a_pool_row_carries_the_control_that_places_it() {
445 + let state = state().await;
446 + let id = estimated_task(&state, "Write the thing", 45);
447 +
448 + let page = screen(&state);
449 +
450 + assert!(
451 + page.contains(&format!("/day/{DAY}/schedule/{id}/place")),
452 + "{page}"
453 + );
454 + // The argument is which slot, offered as the day's own 96, not typed.
455 + assert!(page.contains(">09:00<"), "{page}");
456 + assert!(page.contains(">23:45<"), "{page}");
457 + }
458 +
459 + #[tokio::test]
460 + async fn a_task_that_carries_an_estimate_is_not_asked_for_one() {
461 + let state = state().await;
462 + estimated_task(&state, "Write the thing", 45);
463 +
464 + let page = screen(&state);
465 +
466 + // Asking would be asking the user to confirm what the task already says.
467 + assert!(!page.contains("How long it takes"), "{page}");
468 + }
469 +
470 + #[tokio::test]
471 + async fn a_task_with_no_estimate_is_asked_how_long_it_takes() {
472 + let state = state().await;
473 + due_task(&state, "Never estimated");
474 +
475 + let page = screen(&state);
476 +
477 + // Placeable, and placing it is what fills the missing data in: `fa9fe9ed`
478 + // ruled (d) over refusing the placement outright.
479 + assert!(page.contains("How long it takes"), "{page}");
480 + assert!(page.contains("Placing it records this"), "{page}");
481 + }
482 +
483 + #[tokio::test]
484 + async fn placing_a_task_gives_it_a_block_the_size_of_its_estimate() {
485 + let state = state().await;
486 + let id = estimated_task(&state, "Write the thing", 45);
487 +
488 + post(
489 + &state,
490 + &format!("/day/{DAY}/schedule/{id}/place"),
491 + Params::new().with("at", "540"),
492 + );
493 +
494 + let placed = task(&state, id);
495 + assert_eq!(placed.scheduled_duration, Some(45));
496 + // 540 is 09:00 local on the day the address names, not on any other day.
497 + assert_eq!(
498 + placed.scheduled_start.unwrap(),
499 + Local
500 + .from_local_datetime(&day().and_hms_opt(9, 0, 0).unwrap())
501 + .unwrap()
502 + .with_timezone(&Utc)
503 + );
504 + }
505 +
506 + #[tokio::test]
507 + async fn placing_a_task_hangs_its_block_off_the_task() {
508 + let state = state().await;
509 + let id = estimated_task(&state, "Write the thing", 45);
510 +
511 + post(
512 + &state,
513 + &format!("/day/{DAY}/schedule/{id}/place"),
514 + Params::new().with("at", "540"),
515 + );
516 +
517 + let block = state
518 + .events
519 + .get_by_linked_task(DESKTOP_USER_ID, id)
520 + .unwrap()
521 + .expect("placing writes the linked block too");
522 + assert_eq!(block.start_time, started_at(&state, id));
523 + }
524 +
525 + #[tokio::test]
526 + async fn placing_an_unestimated_task_is_what_estimates_it() {
527 + // The whole of `fa9fe9ed`'s ruling: the missing data gets filled rather
528 + // than defaulted around.
529 + let state = state().await;
530 + let id = due_task(&state, "Never estimated");
531 +
532 + post(
533 + &state,
534 + &format!("/day/{DAY}/schedule/{id}/place"),
535 + Params::new().with("at", "600").with("minutes", "45"),
536 + );
537 +
538 + let placed = task(&state, id);
539 + assert_eq!(placed.estimated_minutes, Some(45));
540 + assert_eq!(placed.scheduled_duration, Some(45));
541 + }
542 +
543 + #[tokio::test]
544 + async fn placing_an_unestimated_task_keeps_everything_else_it_had() {
545 + // The estimate write is a whole-task update, so the risk it carries is
546 + // clearing a field nobody meant to touch.
547 + let state = state().await;
548 + let id = due_task(&state, "Never estimated");
549 + let before = task(&state, id);
550 +
551 + post(
552 + &state,
553 + &format!("/day/{DAY}/schedule/{id}/place"),
554 + Params::new().with("at", "600").with("minutes", "45"),
555 + );
556 +
557 + let after = task(&state, id);
558 + assert_eq!(after.title, before.title);
559 + assert_eq!(after.due, before.due);
560 + assert_eq!(after.tags, before.tags);
561 + assert_eq!(after.priority, before.priority);
562 + }
563 +
564 + #[tokio::test]
565 + async fn a_dismissed_ask_places_nothing_and_writes_nothing() {
566 + // The distinction between (d) and the silent default it beat, and the
567 + // half that is easy to lose: no answer means no placement, not 15 minutes.
568 + let state = state().await;
569 + let id = due_task(&state, "Never estimated");
570 +
571 + let refused = router().handle(
572 + &state,
573 + Request::post(format!("/day/{DAY}/schedule/{id}/place"))
574 + .sending(Params::new().with("at", "540")),
575 + );
576 +
577 + assert!(refused.is_err(), "an unanswered length should not place");
578 + let untouched = task(&state, id);
579 + assert!(untouched.scheduled_start.is_none());
580 + assert!(untouched.estimated_minutes.is_none());
581 + }
582 +
583 + #[tokio::test]
584 + async fn a_slot_outside_the_day_is_refused() {
585 + let state = state().await;
586 + let id = estimated_task(&state, "Write the thing", 45);
587 +
588 + let refused = router().handle(
589 + &state,
590 + Request::post(format!("/day/{DAY}/schedule/{id}/place"))
591 + .sending(Params::new().with("at", "1440")),
592 + );
593 +
594 + assert!(refused.is_err(), "1440 is tomorrow's midnight");
595 + assert!(task(&state, id).scheduled_start.is_none());
596 + }
597 +
598 + #[tokio::test]
599 + async fn placing_answers_with_the_axis_and_the_pool_it_emptied() {
600 + let state = state().await;
601 + let id = estimated_task(&state, "Write the thing", 45);
602 +
603 + let response = post(
604 + &state,
605 + &format!("/day/{DAY}/schedule/{id}/place"),
606 + Params::new().with("at", "540"),
607 + );
608 +
609 + let Outcome::Fragment { region, .. } = &response.outcome else {
610 + panic!("a placement replaces regions, not the screen");
611 + };
612 + assert_eq!(region, "day-timeline");
613 +
614 + // The pool just lost the row, and a fragment that refreshed only the axis
615 + // would leave it showing a task that is now on the day.
616 + let pool = response
617 + .invalidates
618 + .iter()
619 + .find(|stale| stale.region == "day-pool")
620 + .expect("the pool is stale too");
621 + let markup = quasi_webview::Webview::new().fragment(&pool.node);
622 + assert!(markup.contains("Nothing else due today."), "{markup}");
623 + }
624 +
625 + #[tokio::test]
626 + async fn unscheduling_puts_the_task_back_in_the_pool_it_answers_with() {
627 + let state = state().await;
628 + let id = scheduled_task(&state, "Not today", (10, 0));
629 +
630 + let response = post(
631 + &state,
632 + &format!("/day/{DAY}/schedule/{id}/unschedule"),
633 + Params::new(),
634 + );
635 +
636 + let pool = response
637 + .invalidates
638 + .iter()
639 + .find(|stale| stale.region == "day-pool")
640 + .expect("the pool is stale too");
641 + let markup = quasi_webview::Webview::new().fragment(&pool.node);
642 + assert!(markup.contains("Not today"), "{markup}");
643 + }