Skip to main content

max / goingson

Give the described day plan its writes, by sharing the command's own body `quasi::day_planning` was three GET routes and nothing else, so the described day could be read and not changed. `day-planning.js` reaches the writes three ways -- a mouse drag, a touch action sheet, and arrow keys on a focused item -- and only the last is a thing the description layer can say. That is not much of a loss: the keyboard path moves by a fixed step for the same reason the milestone row moves by one place rather than by a drag, and a control on screen beats a gesture nobody discovers. The drag stays refused and stays quasicoherent e41079b2. POST /day/{date}/schedule/{task}/move by=<minutes> POST /day/{date}/schedule/{task}/unschedule Both call the command's body rather than reimplementing it. `schedule_task`/`unschedule_task` were split into `schedule_task_now` and `unschedule_task_now`, synchronous and taking `&AppState`, with the Tauri commands now thin wrappers. Nothing in either awaited; the `async` was the Tauri boundary. Why sharing rather than copying: scheduling a task is two writes against separate repositories -- the task row and its linked calendar block -- with a compensating undo between them, added for GO-10 after a failed event write left a task scheduled with no block. A described route with its own copy would be a second rollback, and one of the two would drift. A test asserts the block moves with the task. A move that would cross midnight is a no-op answering with a fresh axis, which is what `moveScheduledTask` does and the same reasoning as the milestone row's disabled arrows: the control is on screen either way, so the answer to a stale screen is a current one. Seven tests.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 01:03 UTC
Signed with PGP, not checked
Commit: 05d69d789b0ac1228e178fac56ad562cf632afb7
Parent: 012ba4a
4 files changed, +302 insertions, -16 deletions
@@ -347,8 +347,28 @@
347 347 id: TaskId,
348 348 input: ScheduleTaskInput,
349 349 ) -> Result<TaskResponse, ApiError> {
350 - let duration = input.duration.unwrap_or(30).max(1);
351 - let end_time = input.start_time + chrono::Duration::minutes(duration as i64);
350 + schedule_task_now(&state, id, input.start_time, input.duration)
351 + }
352 +
353 + /// The schedule write, without the command wrapper around it.
354 + ///
355 + /// Split out 2026-08-21 so `quasi::day_planning` can move a scheduled task
356 + /// without a second copy of the part that is easy to get wrong. What is worth
357 + /// keeping here is not the two writes: it is that they are two writes against
358 + /// separate repositories with a compensating undo between them, and a described
359 + /// route reimplementing that would be one rollback that drifts from the other.
360 + ///
361 + /// Synchronous, which is what lets a route handler call it: nothing in here
362 + /// awaits, and the `async` on the command above was the Tauri boundary rather
363 + /// than anything this does.
364 + pub(crate) fn schedule_task_now(
365 + state: &AppState,
366 + id: TaskId,
367 + start_time: DateTime<Utc>,
368 + duration: Option<i32>,
369 + ) -> Result<TaskResponse, ApiError> {
370 + let duration = duration.unwrap_or(30).max(1);
371 + let end_time = start_time + chrono::Duration::minutes(duration as i64);
352 372
353 373 let task = state
354 374 .tasks
@@ -357,7 +377,7 @@
357 377
358 378 let updated_task = state
359 379 .tasks
360 - .update_schedule(id, DESKTOP_USER_ID, Some(input.start_time), Some(duration))?
380 + .update_schedule(id, DESKTOP_USER_ID, Some(start_time), Some(duration))?
361 381 .or_not_found("task", id)?;
362 382
363 383 let existing_event = state.events.get_by_linked_task(DESKTOP_USER_ID, id)?;
@@ -375,7 +395,7 @@
375 395 project_id: task.project_id,
376 396 title: task.title.clone(),
377 397 description: String::new(),
378 - start_time: input.start_time,
398 + start_time,
379 399 end_time: Some(end_time),
380 400 location: None,
381 401 linked_task_id: Some(id),
@@ -403,7 +423,7 @@
403 423 project_id: task.project_id,
404 424 title: task.title.clone(),
405 425 description: String::new(),
406 - start_time: input.start_time,
426 + start_time,
407 427 end_time: Some(end_time),
408 428 location: None,
409 429 linked_task_id: Some(id),
@@ -450,6 +470,12 @@
450 470 state: State<'_, Arc<AppState>>,
451 471 id: TaskId,
452 472 ) -> Result<TaskResponse, ApiError> {
473 + unschedule_task_now(&state, id)
474 + }
475 +
476 + /// The unschedule write, without the command wrapper. See
477 + /// [`schedule_task_now`] for why the split exists.
478 + pub(crate) fn unschedule_task_now(state: &AppState, id: TaskId) -> Result<TaskResponse, ApiError> {
453 479 state.events.delete_by_linked_task(DESKTOP_USER_ID, id)?;
454 480
455 481 state
@@ -21,7 +21,7 @@
21 21 mod config;
22 22 mod contact;
23 23 mod daily_note;
24 - mod day_planning;
24 + pub(crate) mod day_planning;
25 25 mod dependency;
26 26 pub(crate) mod email;
27 27 mod email_account;
@@ -46,11 +46,11 @@
46 46 use chrono::{Local, NaiveDate};
47 47 use goingson_core::TimelineItem;
48 48 use makeover_layout::{Placement, Tone, Track};
49 - use quasi_router::screen::{Placed, Row, Tag};
49 + use quasi_router::screen::{Act, Placed, Row, Tag};
50 50 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
51 51
52 52 use crate::commands::{DayPlanningResponse, day_plan};
53 - use crate::state::AppState;
53 + use crate::state::{AppState, DESKTOP_USER_ID};
54 54
55 55 #[cfg(test)]
56 56 mod tests;
@@ -78,7 +78,7 @@
78 78 /// The whole body is vocabulary that existed before this screen: a title, the
79 79 /// project it belongs to, a tone, and a chip when the item continues past the
80 80 /// edge of the day. That is the measurement the timeline refusal never took.
81 - fn row_for(item: &TimelineItem, conflicted: bool) -> Row {
81 + fn row_for(item: &TimelineItem, conflicted: bool, date: NaiveDate) -> Row {
82 82 let mut row = Row::new(&item.title);
83 83
84 84 if let Some(project) = &item.project_name {
@@ -112,13 +112,38 @@
112 112 // the event's, and opening that would address the wrong thing.
113 113 if let Some(task) = item.linked_task_id {
114 114 row = row.activate(Action::get(format!("/tasks/{task}")));
115 +
116 + // The writes this screen had none of until 2026-08-21. `day-planning.js`
117 + // reaches them three ways -- a mouse drag, a touch action sheet, and
118 + // arrow keys on a focused item -- and only the last of those is a thing
119 + // the description layer can say. That is not a loss: the keyboard path
120 + // in the JS moves by a fixed step for the same reason the milestone row
121 + // moves by one place rather than by a drag, and a control that is on
122 + // screen beats a gesture nobody discovers.
123 + //
124 + // The drag itself stays refused, and it is quasicoherent `e41079b2`:
125 + // nothing names a gesture across a grid or what it produces.
126 + let step = |minutes: i32, label: &str| {
127 + Act::new(
128 + label,
129 + Action::post(format!("/day/{date}/schedule/{task}/move"))
130 + .with("by", minutes.to_string()),
131 + )
132 + };
133 + row = row
134 + .act(step(-15, "Move earlier"))
135 + .act(step(15, "Move later"))
136 + .act(Act::new(
137 + "Unschedule",
138 + Action::post(format!("/day/{date}/schedule/{task}/unschedule")),
139 + ));
115 140 }
116 141
117 142 row
118 143 }
119 144
120 145 /// The axis, and everything placed on it.
121 - fn timeline(response: &DayPlanningResponse) -> Node {
146 + fn timeline(response: &DayPlanningResponse, date: NaiveDate) -> Node {
122 147 // Which items clash, from the pairs the backend already computes. Collected
123 148 // into a set because a row needs to know only whether it is in one, and
124 149 // `detect_conflicts` answers in pairs.
@@ -148,7 +173,7 @@
148 173 u16::try_from(item.day_offset_minutes.max(0)).unwrap_or(0),
149 174 u16::try_from(item.visible_duration_minutes.max(1)).unwrap_or(1),
150 175 ),
151 - row: row_for(item, conflicted),
176 + row: row_for(item, conflicted, date),
152 177 }
153 178 })
154 179 .collect();
@@ -171,12 +196,12 @@
171 196 ///
172 197 /// These are occupancies that happen to fill the column, not contexts. A
173 198 /// context is drawn in the band as a banner and is not a timeline item at all.
174 - fn all_day(response: &DayPlanningResponse) -> Option<Node> {
199 + fn all_day(response: &DayPlanningResponse, date: NaiveDate) -> Option<Node> {
175 200 let rows: Vec<Row> = response
176 201 .timeline_items
177 202 .iter()
178 203 .filter(|item| item.is_all_day)
179 - .map(|item| row_for(item, false))
204 + .map(|item| row_for(item, false, date))
180 205 .collect();
181 206
182 207 (!rows.is_empty()).then_some(Node::List { rows, more: None })
@@ -271,10 +296,10 @@
271 296 let response = plan(state, date)?;
272 297
273 298 let mut axis = Slot::new("day-timeline", RegionKind::Pane);
274 - if let Some(strip) = all_day(&response) {
299 + if let Some(strip) = all_day(&response, date) {
275 300 axis = axis.with(strip);
276 301 }
277 - axis = axis.with(timeline(&response));
302 + axis = axis.with(timeline(&response, date));
278 303
279 304 Ok(Screen::list_detail("Day", false)
280 305 .with(band(date, &response))
@@ -295,7 +320,85 @@
295 320 fn timeline_only(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
296 321 let date = date_of(&request)?;
297 322 let response = plan(state, date)?;
298 - Ok(Response::fragment("day-timeline", timeline(&response)))
323 + Ok(Response::fragment(
324 + "day-timeline",
325 + timeline(&response, date),
326 + ))
327 + }
328 +
329 + /// The task a schedule write is addressed at.
330 + fn task_of(request: &quasi_router::Request) -> Result<goingson_core::TaskId, RouteError> {
331 + let raw = request
332 + .captures
333 + .get("task")
334 + .ok_or_else(|| RouteError::not_found("no task id"))?;
335 + Ok(goingson_core::TaskId::from(
336 + uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?,
337 + ))
338 + }
339 +
340 + /// Move a scheduled task by a fixed step, and answer with the axis.
341 + ///
342 + /// The step is minutes, positive or negative, and the bounds are the shipped
343 + /// screen's own: `moveScheduledTask` refuses to leave the day, so a move that
344 + /// would cross midnight in either direction is a no-op rather than an error.
345 + /// The control that sent it is on screen either way, and the answer to a stale
346 + /// screen is a fresh one -- same reasoning as the milestone row's disabled
347 + /// arrows.
348 + ///
349 + /// The write itself is [`crate::commands::day_planning::schedule_task_now`],
350 + /// which is the command's own body with the Tauri wrapper taken off. A second
351 + /// implementation here would be a second copy of the compensating undo between
352 + /// the task write and the linked-event write, and one of the two copies would
353 + /// drift.
354 + fn move_scheduled(
355 + state: &AppState,
356 + request: quasi_router::Request,
357 + ) -> Result<Response, RouteError> {
358 + let date = date_of(&request)?;
359 + let id = task_of(&request)?;
360 + let by: i64 = request
361 + .payload
362 + .get("by")
363 + .and_then(|raw| raw.parse().ok())
364 + .ok_or_else(|| RouteError::not_found("move by a number of minutes"))?;
365 +
366 + let task = state
367 + .tasks
368 + .get_by_id(id, DESKTOP_USER_ID)
369 + .map_err(|error| RouteError::internal(error.to_string()))?
370 + .ok_or_else(|| RouteError::not_found("no such task"))?;
371 + let Some(start) = task.scheduled_start else {
372 + return Err(RouteError::not_found("that task is not on the day"));
373 + };
374 +
375 + let moved = start + chrono::Duration::minutes(by);
376 + // Inside the same civil day the row is drawn on, which is what stops a
377 + // "move later" at 23:45 from silently landing on tomorrow's plan.
378 + if moved.with_timezone(&chrono::Local).date_naive() == date {
379 + crate::commands::day_planning::schedule_task_now(state, id, moved, task.scheduled_duration)
380 + .map_err(|error| RouteError::internal(error.to_string()))?;
381 + }
382 +
383 + timeline_fragment(state, date)
384 + }
385 +
386 + /// Take a task off the day, and answer with the axis.
387 + fn unschedule(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
388 + let date = date_of(&request)?;
389 + let id = task_of(&request)?;
390 + crate::commands::day_planning::unschedule_task_now(state, id)
391 + .map_err(|error| RouteError::internal(error.to_string()))?;
392 + timeline_fragment(state, date)
393 + }
394 +
395 + /// The axis, re-read. What both writes answer with.
396 + fn timeline_fragment(state: &AppState, date: NaiveDate) -> Result<Response, RouteError> {
397 + let response = plan(state, date)?;
398 + Ok(Response::fragment(
399 + "day-timeline",
400 + timeline(&response, date),
401 + ))
299 402 }
300 403
301 404 /// This screen's routes.
@@ -304,4 +407,6 @@
304 407 .get("/day", day)
305 408 .get("/day/{date}", day)
306 409 .get("/day/{date}/timeline", timeline_only)
410 + .post("/day/{date}/schedule/{task}/move", move_scheduled)
411 + .post("/day/{date}/schedule/{task}/unschedule", unschedule)
307 412 }
@@ -248,3 +248,158 @@
248 248 assert!(markup.contains("Unblocks 1"), "{markup}");
249 249 assert!(!markup.contains("Blocked"), "{markup}");
250 250 }
251 +
252 + /// A task scheduled onto the test day at a local wall-clock time.
253 + ///
254 + /// Written through the same function the described route writes through, so the
255 + /// fixture and the thing under test agree about what "scheduled" means -- a task
256 + /// row with a start, and a linked event carrying the block.
257 + fn scheduled_task(state: &AppState, title: &str, at: (u32, u32)) -> goingson_core::TaskId {
258 + let id = due_task(state, title);
259 + let start = Local
260 + .from_local_datetime(&day().and_hms_opt(at.0, at.1, 0).unwrap())
261 + .unwrap()
262 + .with_timezone(&Utc);
263 + crate::commands::day_planning::schedule_task_now(state, id, start, Some(30)).unwrap();
264 + id
265 + }
266 +
267 + fn post(state: &AppState, path: &str, params: Params) -> Response {
268 + router()
269 + .handle(state, Request::post(path).sending(params))
270 + .expect("the route answers")
271 + }
272 +
273 + fn started_at(state: &AppState, id: goingson_core::TaskId) -> chrono::DateTime<Utc> {
274 + state
275 + .tasks
276 + .get_by_id(id, DESKTOP_USER_ID)
277 + .unwrap()
278 + .unwrap()
279 + .scheduled_start
280 + .expect("still scheduled")
281 + }
282 +
283 + #[tokio::test]
284 + async fn a_scheduled_row_carries_the_writes_it_used_to_have_none_of() {
285 + let state = state().await;
286 + let id = scheduled_task(&state, "Write the thing", (10, 0));
287 +
288 + let page = screen(&state);
289 + assert!(
290 + page.contains(&format!("/day/{DAY}/schedule/{id}/move")),
291 + "{page}"
292 + );
293 + assert!(
294 + page.contains(&format!("/day/{DAY}/schedule/{id}/unschedule")),
295 + "{page}"
296 + );
297 + }
298 +
299 + #[tokio::test]
300 + async fn moving_a_task_later_moves_it_by_the_step_it_was_sent() {
301 + let state = state().await;
302 + let id = scheduled_task(&state, "Write the thing", (10, 0));
303 + let before = started_at(&state, id);
304 +
305 + post(
306 + &state,
307 + &format!("/day/{DAY}/schedule/{id}/move"),
308 + Params::new().with("by", "15"),
309 + );
310 +
311 + assert_eq!(started_at(&state, id) - before, Duration::minutes(15));
312 + }
313 +
314 + #[tokio::test]
315 + async fn moving_a_task_earlier_takes_a_negative_step() {
316 + let state = state().await;
317 + let id = scheduled_task(&state, "Write the thing", (10, 0));
318 + let before = started_at(&state, id);
319 +
320 + post(
321 + &state,
322 + &format!("/day/{DAY}/schedule/{id}/move"),
323 + Params::new().with("by", "-15"),
324 + );
325 +
326 + assert_eq!(started_at(&state, id) - before, Duration::minutes(-15));
327 + }
328 +
329 + #[tokio::test]
330 + async fn a_move_that_would_leave_the_day_is_a_no_op_rather_than_an_error() {
331 + // `moveScheduledTask` refuses to cross midnight and so does this. The
332 + // control that sent it is on screen either way, so the answer is a fresh
333 + // axis rather than a 404.
334 + let state = state().await;
335 + let id = scheduled_task(&state, "Late one", (23, 45));
336 + let before = started_at(&state, id);
337 +
338 + let page = html(post(
339 + &state,
340 + &format!("/day/{DAY}/schedule/{id}/move"),
341 + Params::new().with("by", "30"),
342 + ));
343 +
344 + assert_eq!(started_at(&state, id), before);
345 + assert!(page.contains("Late one"), "{page}");
346 + }
347 +
348 + #[tokio::test]
349 + async fn moving_a_task_keeps_its_linked_block_in_step() {
350 + // The reason the route calls the command's own body rather than writing the
351 + // task row itself: the linked event has to move with it, and a second copy
352 + // of that would drift.
353 + let state = state().await;
354 + let id = scheduled_task(&state, "Write the thing", (10, 0));
355 +
356 + post(
357 + &state,
358 + &format!("/day/{DAY}/schedule/{id}/move"),
359 + Params::new().with("by", "60"),
360 + );
361 +
362 + let block = state
363 + .events
364 + .get_by_linked_task(DESKTOP_USER_ID, id)
365 + .unwrap()
366 + .expect("the block is still there");
367 + assert_eq!(block.start_time, started_at(&state, id));
368 + }
369 +
370 + #[tokio::test]
371 + async fn unscheduling_takes_the_task_off_the_axis_and_deletes_its_block() {
372 + let state = state().await;
373 + let id = scheduled_task(&state, "Not today", (10, 0));
374 +
375 + post(
376 + &state,
377 + &format!("/day/{DAY}/schedule/{id}/unschedule"),
378 + Params::new(),
379 + );
380 +
381 + let task = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
382 + assert!(task.scheduled_start.is_none());
383 + assert!(
384 + state
385 + .events
386 + .get_by_linked_task(DESKTOP_USER_ID, id)
387 + .unwrap()
388 + .is_none()
389 + );
390 + }
391 +
392 + #[tokio::test]
393 + async fn moving_a_task_that_is_not_on_the_day_is_a_not_found() {
394 + let state = state().await;
395 + let id = due_task(&state, "Never scheduled");
396 +
397 + let error = router()
398 + .handle(
399 + &state,
400 + Request::post(format!("/day/{DAY}/schedule/{id}/move"))
401 + .sending(Params::new().with("by", "15")),
402 + )
403 + .expect_err("that task is not on the day");
404 + assert_eq!(error.class.http_status(), 404);
405 + }