Skip to main content

max / goingson

Name what a focus candidate waits on, in both weekly pickers Decision 143d71b1: a blocked task is a legitimate weekly focus, so the picker keeps offering one and marks it instead of hiding it. The candidate set is unchanged in size; nothing gained a WHERE clause. The task filed this as render-only on the reading that the data was already on the wire. Four of the five fields were, and the one the marker needs was not: TaskResponse says that a task is blocked and carries no blocker title, so "after <title>" had nowhere to come from. Hence focus_blockers, which mirrors the day plan's gates map: unfinished blockers only, keyed by task id, absent when nothing is in the way. One query per candidate, bounded by list_available_for_focus's cap of 10 rather than by the backlog. Both surfaces take it in the same pass, which is what keeps weekly_review off the drift table in quasi/mod.rs. A cycled candidate reads differently from a merely blocked one on both, since doing the named blocker would not open it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-16 23:29 UTC
Signed with PGP, not checked
Commit: 03e25d77d05bf68d1c48fe03441e1107e264f0f3
Parent: b4c7e30
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 &quot;${escAttr(t.title)}&quot; 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 + }