max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+241 insertions,
-9 deletions
| @@ -6125,6 +6125,28 @@ | |||
| 6125 | 6125 | color: var(--action); | |
| 6126 | 6126 | } | |
| 6127 | 6127 | ||
| 6128 | + | /* The dependency mark rides in a centred flex row here, not under a stacked | |
| 6129 | + | title, so it trades its top margin for a left one. Same badge otherwise: a | |
| 6130 | + | candidate that says "after X" in the picker should look like the one saying | |
| 6131 | + | it in the task list. */ | |
| 6132 | + | .focus-suggestion .blocked-badge { | |
| 6133 | + | margin-top: 0; | |
| 6134 | + | margin-left: var(--gap-bound); | |
| 6135 | + | } | |
| 6136 | + | ||
| 6137 | + | /* A blocked candidate is dimmed rather than dropped: the decision is that a | |
| 6138 | + | blocked task is a legitimate focus, so the chip stays offerable and only | |
| 6139 | + | reads as the quieter of two choices. */ | |
| 6140 | + | .focus-suggestion.task-blocked .focus-suggestion-label { | |
| 6141 | + | opacity: 0.72; | |
| 6142 | + | } | |
| 6143 | + | ||
| 6144 | + | /* A cycled candidate can never open, which the dashed frame has no way to say. | |
| 6145 | + | The frame is the one border it has, so that is what carries it. */ | |
| 6146 | + | .focus-suggestion.task-in-cycle { | |
| 6147 | + | border-color: var(--danger); | |
| 6148 | + | } | |
| 6149 | + | ||
| 6128 | 6150 | /* Legacy aliases, keep so existing JS selectors and styles still apply. | |
| 6129 | 6151 | New code should use the .scope-slot* classes. */ | |
| 6130 | 6152 | .focus-grid { /* alias of .scope-slots */ } |
| @@ -22,6 +22,55 @@ | |||
| 22 | 22 | return str.substring(0, len - 1) + '\u2026'; | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | + | /** | |
| 26 | + | * The "waiting on" mark for a focus candidate. | |
| 27 | + | * | |
| 28 | + | * A blocked task is a legitimate focus (decision 143d71b1), so the picker | |
| 29 | + | * offers it and says what stands in the way rather than filtering it out. | |
| 30 | + | * Naming the blocker is the day plan's phrasing (day-planning-render.js | |
| 31 | + | * renderPlanGate) and it is the more useful half: "Blocked" tells the | |
| 32 | + | * reader to go and look something up. The out-of-order tone that marker | |
| 33 | + | * carries has no analogue here, since a week has no ordering to be out of. | |
| 34 | + | * | |
| 35 | + | * A cycled task gets its own mark. It waits on something that can never | |
| 36 | + | * finish, so doing the named blocker would not open it, and that is a | |
| 37 | + | * different offer from one that clears when the chain does. | |
| 38 | + | * | |
| 39 | + | * @param {Object} task - Focus candidate with isBlocked / inCycle | |
| 40 | + | * @param {Object} blockers - focusBlockers, keyed by task id | |
| 41 | + | * @returns {string} HTML for the mark, or empty when nothing is in the way | |
| 42 | + | */ | |
| 43 | + | function renderFocusWait(task, blockers) { | |
| 44 | + | if (task.inCycle) { | |
| 45 | + | const title = 'On a dependency cycle, so it can never open. Open the task to break it.'; | |
| 46 | + | return `<span class="blocked-badge blocked-badge--cycle" title="${escAttr(title)}" aria-label="${escAttr(title)}">Cycle</span>`; | |
| 47 | + | } | |
| 48 | + | if (!task.isBlocked) return ''; | |
| 49 | + | ||
| 50 | + | const waits = (blockers && blockers[task.id]) || []; | |
| 51 | + | // Blocked with no names is the depth talking without the edges: the | |
| 52 | + | // fact is still worth saying, and it is what the task rows say. | |
| 53 | + | if (waits.length === 0) { | |
| 54 | + | const steps = task.blockDepth === 1 | |
| 55 | + | ? 'Waiting on 1 unfinished task' | |
| 56 | + | : `Waiting on a chain ${task.blockDepth} deep`; | |
| 57 | + | return `<span class="blocked-badge" title="${escAttr(steps)}" aria-label="${escAttr(steps)}">Blocked</span>`; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | const lead = waits[0]; | |
| 61 | + | const more = waits.length - 1; | |
| 62 | + | const label = more > 0 | |
| 63 | + | ? `after ${truncate(lead.title, 24)} +${more}` | |
| 64 | + | : `after ${truncate(lead.title, 24)}`; | |
| 65 | + | // The full title in the hint, since the label is truncated to fit a | |
| 66 | + | // chip, and the project because a blocker is often in another one. | |
| 67 | + | const where = lead.projectName ? ` (${lead.projectName})` : ''; | |
| 68 | + | const title = more > 0 | |
| 69 | + | ? `Waits on ${lead.title}${where} and ${more} other task${more === 1 ? '' : 's'}` | |
| 70 | + | : `Waits on ${lead.title}${where}`; | |
| 71 | + | return `<span class="blocked-badge" title="${escAttr(title)}" aria-label="${escAttr(title)}">${esc(label)}</span>`; | |
| 72 | + | } | |
| 73 | + | ||
| 25 | 74 | // Section Renderers | |
| 26 | 75 | ||
| 27 | 76 | /** | |
| @@ -304,11 +353,12 @@ | |||
| 304 | 353 | </h4> | |
| 305 | 354 | <div class="focus-suggestions"> | |
| 306 | 355 | ${available.slice(0, 5).map(t => ` | |
| 307 | - | <button class="focus-suggestion" | |
| 356 | + | <button class="focus-suggestion task-${t.blockedClass || 'ready'}" | |
| 308 | 357 | title="Add "${escAttr(t.title)}" to this week's focus" | |
| 309 | 358 | data-act="weeklyReview.toggleFocus" data-a1="${escAttr(t.id)}" data-args='["@a1", true]'> | |
| 310 | 359 | <span class="focus-suggestion-mark" aria-hidden="true">+</span> | |
| 311 | 360 | <span class="focus-suggestion-label">${esc(truncate(t.title, 30))}</span> | |
| 361 | + | ${renderFocusWait(t, r.focusBlockers)} | |
| 312 | 362 | </button> | |
| 313 | 363 | `).join('')} | |
| 314 | 364 | </div> | |
| @@ -501,6 +551,7 @@ | |||
| 501 | 551 | renderReflection, | |
| 502 | 552 | renderVacationToggles, | |
| 503 | 553 | renderCompletedTaskItem, | |
| 554 | + | renderFocusWait, | |
| 504 | 555 | renderTaskItemCompact, | |
| 505 | 556 | renderEventItemCompact, | |
| 506 | 557 | truncate, |
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | ||
| 6 | 6 | use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday}; | |
| 7 | 7 | use serde::{Deserialize, Serialize}; | |
| 8 | + | use std::collections::HashMap; | |
| 8 | 9 | use std::sync::Arc; | |
| 9 | 10 | use tauri::State; | |
| 10 | 11 | use tracing::instrument; | |
| @@ -13,9 +14,10 @@ | |||
| 13 | 14 | self, EventSummary, ProjectHealth, ProjectSummary, TimelineDayData, WeeklyReviewData, | |
| 14 | 15 | WeeklyReviewInput, | |
| 15 | 16 | }; | |
| 16 | - | use goingson_core::{TaskId, WeeklyReview, expand_recurrence_in_tz}; | |
| 17 | + | use goingson_core::{LinkedTaskRef, Task, TaskId, WeeklyReview, expand_recurrence_in_tz}; | |
| 17 | 18 | ||
| 18 | 19 | use super::ApiError; | |
| 20 | + | use super::dependency::LinkedTaskResponse; | |
| 19 | 21 | use super::task::TaskResponse; | |
| 20 | 22 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 21 | 23 | ||
| @@ -51,6 +53,19 @@ | |||
| 51 | 53 | ||
| 52 | 54 | pub focused_tasks: Vec<TaskResponse>, | |
| 53 | 55 | pub available_for_focus: Vec<TaskResponse>, | |
| 56 | + | /// What each focus candidate still waits on, keyed by task id. | |
| 57 | + | /// | |
| 58 | + | /// Only candidates with an unfinished blocker appear, so an absent entry | |
| 59 | + | /// means nothing is in the way. Same arrangement as the day plan's | |
| 60 | + | /// [`gates`](super::day_planning::DayPlanningResponse::gates), and for the | |
| 61 | + | /// same reason: `TaskResponse` says *that* a task is blocked and the picker | |
| 62 | + | /// wants to say *what* it waits on, which is a name the task row does not | |
| 63 | + | /// carry. | |
| 64 | + | /// | |
| 65 | + | /// It reports, it does not filter. A blocked task is a legitimate focus | |
| 66 | + | /// (decision `143d71b1`), so the candidate set is whatever | |
| 67 | + | /// `list_available_for_focus` offered. | |
| 68 | + | pub focus_blockers: HashMap<TaskId, Vec<LinkedTaskResponse>>, | |
| 54 | 69 | pub focused_projects: Vec<ProjectSummary>, | |
| 55 | 70 | pub project_health: Vec<ProjectHealth>, | |
| 56 | 71 | ||
| @@ -221,6 +236,39 @@ | |||
| 221 | 236 | })) | |
| 222 | 237 | } | |
| 223 | 238 | ||
| 239 | + | /// What each focus candidate still waits on, keyed by task id. | |
| 240 | + | /// | |
| 241 | + | /// Only the unfinished blockers, and only for candidates that have one: an | |
| 242 | + | /// absent entry means nothing is in the way. `list_blockers` returns every edge | |
| 243 | + | /// ever drawn, including the satisfied ones a task merely waited for once, and | |
| 244 | + | /// naming those would report a cleared blocker as a live one. | |
| 245 | + | /// | |
| 246 | + | /// One query per candidate rather than one for the set. The repository caps the | |
| 247 | + | /// candidates at 10 (`list_available_for_focus`), so the count is bounded by the | |
| 248 | + | /// picker rather than by the backlog, and the day plan's bulk `plan_gates` is | |
| 249 | + | /// scoped to a day window that a week has no analogue of. | |
| 250 | + | pub fn focus_blockers( | |
| 251 | + | state: &AppState, | |
| 252 | + | candidates: &[Task], | |
| 253 | + | ) -> Result<HashMap<TaskId, Vec<LinkedTaskRef>>, ApiError> { | |
| 254 | + | let mut out = HashMap::new(); | |
| 255 | + | for task in candidates { | |
| 256 | + | if !task.is_blocked() { | |
| 257 | + | continue; | |
| 258 | + | } | |
| 259 | + | let waiting: Vec<LinkedTaskRef> = state | |
| 260 | + | .tasks | |
| 261 | + | .list_blockers(DESKTOP_USER_ID, task.id)? | |
| 262 | + | .into_iter() | |
| 263 | + | .filter(|blocker| !blocker.is_satisfied()) | |
| 264 | + | .collect(); | |
| 265 | + | if !waiting.is_empty() { | |
| 266 | + | out.insert(task.id, waiting); | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | Ok(out) | |
| 270 | + | } | |
| 271 | + | ||
| 224 | 272 | /// Gets the weekly review data for the requested week (or current week if omitted). | |
| 225 | 273 | /// Fetches data from repositories, delegates aggregation to core. | |
| 226 | 274 | #[tauri::command] | |
| @@ -231,6 +279,9 @@ | |||
| 231 | 279 | ) -> Result<WeeklyReviewResponse, ApiError> { | |
| 232 | 280 | let week_start = resolve_week_start(input.as_ref().and_then(|i| i.week_start.as_deref()))?; | |
| 233 | 281 | let data = gather_weekly_review(&state, week_start)?; | |
| 282 | + | // Read before the candidates are converted, since the conversion consumes | |
| 283 | + | // them and `TaskResponse` is not what the lookup takes. | |
| 284 | + | let focus_blockers = focus_blockers(&state, &data.available_for_focus)?; | |
| 234 | 285 | ||
| 235 | 286 | // Convert Task → TaskResponse for frontend | |
| 236 | 287 | Ok(WeeklyReviewResponse { | |
| @@ -283,6 +334,10 @@ | |||
| 283 | 334 | .into_iter() | |
| 284 | 335 | .map(TaskResponse::from) | |
| 285 | 336 | .collect(), | |
| 337 | + | focus_blockers: focus_blockers | |
| 338 | + | .into_iter() | |
| 339 | + | .map(|(id, waiting)| (id, waiting.into_iter().map(Into::into).collect())) | |
| 340 | + | .collect(), | |
| 286 | 341 | focused_projects: data.focused_projects, | |
| 287 | 342 | project_health: data.project_health, | |
| 288 | 343 |
| @@ -122,9 +122,12 @@ | |||
| 122 | 122 | //! | [`day_planning`] | `0df3488`, the unblocks marker in the pool | [`Availability::frees_marker`] | | |
| 123 | 123 | //! | |
| 124 | 124 | //! Clean: [`contacts`], [`settings`], [`weekly_review`], [`emails`], | |
| 125 | - | //! [`monthly_review`], [`problems`]. The last three each have a commit touching | |
| 125 | + | //! [`monthly_review`], [`problems`]. Four of those now have a commit touching | |
| 126 | 126 | //! their JS since the port, and each of those touched the description in the | |
| 127 | - | //! same commit, which is the arrangement that works. | |
| 127 | + | //! same commit, which is the arrangement that works. [`weekly_review`] joined | |
| 128 | + | //! them when the focus picker learned to name what a candidate waits on: the | |
| 129 | + | //! marker went into `weekly-review-render.js` and into [`weekly_review::focus`] | |
| 130 | + | //! together, which is the only reason it is still on this line. | |
| 128 | 131 | //! | |
| 129 | 132 | //! **Nothing automates this.** The sweep is a person running `git log` against | |
| 130 | 133 | //! two paths, so it is only as current as the last time someone thought to. |
| @@ -46,17 +46,26 @@ | |||
| 46 | 46 | // choice made here. Same allow, for the same reason, as quasi-axum's tests. | |
| 47 | 47 | #![allow(clippy::needless_pass_by_value)] | |
| 48 | 48 | ||
| 49 | + | use std::collections::HashMap; | |
| 50 | + | ||
| 49 | 51 | use chrono::{Duration, NaiveDate}; | |
| 50 | - | use goingson_core::Task; | |
| 51 | 52 | use goingson_core::weekly_review::{ | |
| 52 | 53 | self, EventSummary, ProjectHealth, TimelineDayData, WeeklyReviewData, | |
| 53 | 54 | }; | |
| 55 | + | use goingson_core::{LinkedTaskRef, Task, TaskId}; | |
| 56 | + | use makeover_layout::Tone; | |
| 54 | 57 | use quasi_router::screen::{Act, Field, Figure, Meter, Row, Tag}; | |
| 55 | 58 | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 56 | 59 | ||
| 57 | - | use crate::commands::gather_weekly_review; | |
| 60 | + | use crate::commands::{focus_blockers, gather_weekly_review}; | |
| 58 | 61 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 59 | 62 | ||
| 63 | + | /// What each focus candidate still waits on, keyed by task id. | |
| 64 | + | /// | |
| 65 | + | /// The same map [`focus_blockers`] answers with, named here because it travels | |
| 66 | + | /// through three signatures. An absent entry means nothing is in the way. | |
| 67 | + | type FocusBlockers = HashMap<TaskId, Vec<LinkedTaskRef>>; | |
| 68 | + | ||
| 60 | 69 | #[cfg(test)] | |
| 61 | 70 | mod tests; | |
| 62 | 71 | ||
| @@ -351,7 +360,7 @@ | |||
| 351 | 360 | /// region, and `Slot` already exists — but a region per empty priority is a | |
| 352 | 361 | /// heavy answer to a light question and it wants a second consumer before | |
| 353 | 362 | /// anyone reaches for it. | |
| 354 | - | fn focus(data: &WeeklyReviewData, week: NaiveDate) -> Vec<Node> { | |
| 363 | + | fn focus(data: &WeeklyReviewData, waits: &FocusBlockers, week: NaiveDate) -> Vec<Node> { | |
| 355 | 364 | let taken = data.focused_tasks.len(); | |
| 356 | 365 | let mut out = vec![ | |
| 357 | 366 | Node::section("This Week's Focus"), | |
| @@ -397,7 +406,11 @@ | |||
| 397 | 406 | text: "Suggested".to_owned(), | |
| 398 | 407 | }); | |
| 399 | 408 | out.push(Node::list(data.available_for_focus.iter().map(|task| { | |
| 400 | - | task_row(task).act(Act::new( | |
| 409 | + | let mut row = task_row(task); | |
| 410 | + | if let Some(mark) = waiting_mark(task, waits) { | |
| 411 | + | row = row.token(mark); | |
| 412 | + | } | |
| 413 | + | row.act(Act::new( | |
| 401 | 414 | "Focus", | |
| 402 | 415 | in_week( | |
| 403 | 416 | Action::post(format!("/weekly-review/focus/{}", task.id)).with("focus", "true"), | |
| @@ -409,6 +422,34 @@ | |||
| 409 | 422 | out | |
| 410 | 423 | } | |
| 411 | 424 | ||
| 425 | + | /// What a focus candidate waits on, as one token. | |
| 426 | + | /// | |
| 427 | + | /// A blocked task is a legitimate focus (decision `143d71b1`), so the picker | |
| 428 | + | /// offers it and says what stands in the way rather than hiding it. Naming the | |
| 429 | + | /// blocker is the day plan's phrasing ([`super::day_planning::pool`]) and it is | |
| 430 | + | /// the more useful half: "Blocked" tells the reader to go and look something up. | |
| 431 | + | /// | |
| 432 | + | /// A cycled task is a different offer and gets a different mark. It waits on | |
| 433 | + | /// something that can never finish, so no amount of doing the named blocker | |
| 434 | + | /// opens it, and [`Availability::marker`](super::Availability::marker) already | |
| 435 | + | /// draws that distinction everywhere else. | |
| 436 | + | /// | |
| 437 | + | /// The out-of-order tone the day plan carries has no analogue here: a week has | |
| 438 | + | /// no ordering to be out of. | |
| 439 | + | fn waiting_mark(task: &Task, waits: &FocusBlockers) -> Option<Tag> { | |
| 440 | + | if task.graph.in_cycle { | |
| 441 | + | return Some(Tag::badge("Cycle").tone(Tone::Danger)); | |
| 442 | + | } | |
| 443 | + | let blockers = waits.get(&task.id)?; | |
| 444 | + | let first = blockers.first()?; | |
| 445 | + | let label = if blockers.len() > 1 { | |
| 446 | + | format!("after {} +{}", first.title, blockers.len() - 1) | |
| 447 | + | } else { | |
| 448 | + | format!("after {}", first.title) | |
| 449 | + | }; | |
| 450 | + | Some(Tag::badge(label).tone(Tone::Warning)) | |
| 451 | + | } | |
| 452 | + | ||
| 412 | 453 | /// How each project is doing. | |
| 413 | 454 | fn projects_health(health: &[ProjectHealth]) -> Vec<Node> { | |
| 414 | 455 | if health.is_empty() { | |
| @@ -550,6 +591,8 @@ | |||
| 550 | 591 | /// region. | |
| 551 | 592 | fn screen(state: &AppState, week: NaiveDate) -> Result<Screen, RouteError> { | |
| 552 | 593 | let data = load(state, week)?; | |
| 594 | + | let waits = focus_blockers(state, &data.available_for_focus) | |
| 595 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 553 | 596 | ||
| 554 | 597 | let band = Slot::new("review-band", RegionKind::Band) | |
| 555 | 598 | .with(Node::page(&data.week_display)) | |
| @@ -577,7 +620,7 @@ | |||
| 577 | 620 | pane = pane.extend(accomplished(&data)); | |
| 578 | 621 | pane = pane.extend(needs_attention(&data)); | |
| 579 | 622 | pane = pane.extend(due_this_week(&data)); | |
| 580 | - | pane = pane.extend(focus(&data, week)); | |
| 623 | + | pane = pane.extend(focus(&data, &waits, week)); | |
| 581 | 624 | pane = pane.extend(projects_health(&data.project_health)); | |
| 582 | 625 | pane = pane.extend(days_off(&data, week)); | |
| 583 | 626 | pane = pane.extend(reflection(&data, week)); |
| @@ -348,3 +348,61 @@ | |||
| 348 | 348 | )); | |
| 349 | 349 | assert!(page.contains("Week at a Glance")); | |
| 350 | 350 | } | |
| 351 | + | ||
| 352 | + | #[tokio::test] | |
| 353 | + | async fn a_blocked_candidate_is_offered_and_names_what_it_waits_on() { | |
| 354 | + | // Decision 143d71b1: a blocked task is a legitimate focus, so the picker | |
| 355 | + | // marks it rather than filtering it out. The candidate set is unchanged in | |
| 356 | + | // size, which is the half a filter would have taken. | |
| 357 | + | let state = state().await; | |
| 358 | + | let blocker = add(&state, "Do this first"); | |
| 359 | + | let blocked = add(&state, "Then this"); | |
| 360 | + | state | |
| 361 | + | .tasks | |
| 362 | + | .add_dependency(DESKTOP_USER_ID, blocked.id, blocker.id) | |
| 363 | + | .unwrap(); | |
| 364 | + | ||
| 365 | + | let page = review(&state); | |
| 366 | + | ||
| 367 | + | assert!(page.contains("Then this"), "still offered: {page}"); | |
| 368 | + | assert!(page.contains("after Do this first"), "{page}"); | |
| 369 | + | // Naming the blocker is the whole marker. A bare "Blocked" would be the | |
| 370 | + | // phrasing this deliberately replaced. | |
| 371 | + | assert!(!page.contains(">Blocked<"), "{page}"); | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | #[tokio::test] | |
| 375 | + | async fn a_cycled_candidate_reads_differently_from_a_merely_blocked_one() { | |
| 376 | + | // A cycle never opens, so doing the named blocker would not help. Same | |
| 377 | + | // distinction `Availability::marker` draws on every other task surface. | |
| 378 | + | let state = state().await; | |
| 379 | + | let first = add(&state, "Round one"); | |
| 380 | + | let second = add(&state, "Round two"); | |
| 381 | + | state | |
| 382 | + | .tasks | |
| 383 | + | .add_dependency(DESKTOP_USER_ID, second.id, first.id) | |
| 384 | + | .unwrap(); | |
| 385 | + | // The write path refuses a cycle, so the closing leg goes in the way a sync | |
| 386 | + | // pull puts it there: two individually legal edges merged behind the | |
| 387 | + | // repository. Same setup as `a_cycle_merged_in_behind_the_repository_...` | |
| 388 | + | // in the dependency repo's own tests. | |
| 389 | + | state | |
| 390 | + | .db | |
| 391 | + | .conn() | |
| 392 | + | .unwrap() | |
| 393 | + | .execute( | |
| 394 | + | "INSERT INTO task_dependencies (id, blocked_id, blocker_id, created_at) \ | |
| 395 | + | VALUES (?, ?, ?, datetime('now'))", | |
| 396 | + | rusqlite::params![ | |
| 397 | + | uuid::Uuid::new_v4().to_string(), | |
| 398 | + | first.id.to_string(), | |
| 399 | + | second.id.to_string(), | |
| 400 | + | ], | |
| 401 | + | ) | |
| 402 | + | .unwrap(); | |
| 403 | + | state.tasks.recompute_graph(DESKTOP_USER_ID).unwrap(); | |
| 404 | + | ||
| 405 | + | let page = review(&state); | |
| 406 | + | ||
| 407 | + | assert!(page.contains("Cycle"), "{page}"); | |
| 408 | + | } |