max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 | + | } |