Skip to main content

max / goingson

A day plan unlocks the work it makes possible Blocking is absolute everywhere else: a task is unavailable until its blockers are done. Inside a day plan that is too strict, because the plan is itself a statement about order. Scheduling A says A happens today, which is exactly what makes it reasonable to also schedule B. So a plan widens the "has this stopped gating" test from Completed to "completed, or already in this plan". Nothing is stored and nothing is a property of the task; the widening holds only for the plan it was computed against. It cascades on its own: with A blocking B blocking C, scheduling A offers B, and scheduling B offers C, because each pass reads the plan as it now stands. Every blocker has to be in the plan, not merely one of them, and in THIS plan. A blocker parked next Tuesday unlocks nothing today, or the pool would offer a day nobody can execute. Order is reported, not enforced. B can be dragged above A, and requiring otherwise would mean a task leaving the plan as it is moved, which fights the person rearranging their day. Instead every gated task carries a marker naming what it waits on, which turns amber when the blocker is scheduled later than it is. That marker is the nudge. A scheduled task is stored as an event carrying linked_task_id, so TimelineItem now exposes it: the item's own id is the event's, and a gate has to be looked up by the task's. Answers the day-plan half of GO 143d71b1. The weekly focus picker is untouched and the task stays open for it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 20:29 UTC
Signed with PGP, not checked
Commit: 0c10d23502cde2e44829af819af9669f46b873ca
Parent: a83db71
9 files changed, +496 insertions, -8 deletions
@@ -35,6 +35,14 @@
35 35 pub priority: Option<String>,
36 36 pub status: Option<String>,
37 37 pub block_type: Option<String>,
38 + /// The task this item stands for, when it is a scheduled task rather than a
39 + /// plain event.
40 + ///
41 + /// A scheduled task is stored as an event carrying `linked_task_id`, so
42 + /// `id` above is the event's. Anything that needs to look the task up (a
43 + /// plan gate, say) needs this one instead, and inferring it from `item_type`
44 + /// alone is not possible.
45 + pub linked_task_id: Option<Uuid>,
38 46 /// Covers the rendered day end to end. These go in the all-day strip above
39 47 /// the timeline: drawn in the column they would paper over all 24 hours of it.
40 48 pub is_all_day: bool,
@@ -180,6 +188,7 @@
180 188 priority: None,
181 189 status: None,
182 190 block_type: None,
191 + linked_task_id: None,
183 192 is_all_day: span.is_all_day,
184 193 day_offset_minutes: span.day_offset_minutes,
185 194 visible_duration_minutes: span.visible_duration_minutes,
@@ -250,6 +259,7 @@
250 259 priority: None,
251 260 status: None,
252 261 block_type: None,
262 + linked_task_id: None,
253 263 is_all_day: false,
254 264 day_offset_minutes: 9 * 60,
255 265 visible_duration_minutes: 30,
@@ -268,6 +278,7 @@
268 278 priority: None,
269 279 status: None,
270 280 block_type: None,
281 + linked_task_id: None,
271 282 is_all_day: false,
272 283 day_offset_minutes: 9 * 60 + 15,
273 284 visible_duration_minutes: 30,
@@ -325,6 +336,7 @@
325 336 priority: None,
326 337 status: None,
327 338 block_type: None,
339 + linked_task_id: None,
328 340 is_all_day: span.is_all_day,
329 341 day_offset_minutes: span.day_offset_minutes,
330 342 visible_duration_minutes: span.visible_duration_minutes,
@@ -87,8 +87,8 @@
87 87 FolderSyncState, GraphPosition, LinkedTaskRef, Milestone, MilestoneStatus, MonthlyGoal,
88 88 MonthlyGoalStatus, MonthlyReflection, MonthlySpec, NewAttachment, NewBackupSettings, NewEmail,
89 89 NewEmailAccount, NewEmailWithTracking, NewEvent, NewEventBuilder, NewMilestone, NewProblem,
90 - NewProject, NewSavedView, NewTask, NewTaskBuilder, ParseableEnum, PositiveMinutes, Priority,
91 - Problem, ProblemBand, ProblemStatus, Project, ProjectStatus, ProjectType, Recurrence,
90 + NewProject, NewSavedView, NewTask, NewTaskBuilder, ParseableEnum, PlanGate, PositiveMinutes,
91 + Priority, Problem, ProblemBand, ProblemStatus, Project, ProjectStatus, ProjectType, Recurrence,
92 92 RecurrenceRule, SavedView, SortDirection, SortField, StatusToken, Subtask, SyncAccount,
93 93 TOKEN_KIND_COMMIT, Task, TaskDependency, TaskEstimate, TaskFilterQuery, TaskGraph,
94 94 TaskGraphNode, TaskSortColumn, TaskStatus, TimeReport, TimeReportProject, TimeSession,
@@ -512,3 +512,213 @@
512 512 "the whole adjustment lives in the separate, unsynced column"
513 513 );
514 514 }
515 +
516 + // Plan gates: a day plan widens "has this stopped gating" to include work
517 + // already scheduled in that plan.
518 +
519 + use chrono::{Duration, Utc};
520 +
521 + /// Schedule `id` at `offset` from the window start.
522 + fn schedule(repo: &SqliteTaskRepository, user: UserId, id: TaskId, offset_hours: i64) {
523 + let at = plan_start() + Duration::hours(offset_hours);
524 + repo.update_schedule(id, user, Some(at), Some(30))
525 + .expect("schedule");
526 + }
527 +
528 + fn plan_start() -> chrono::DateTime<Utc> {
529 + Utc::now()
530 + .date_naive()
531 + .and_hms_opt(0, 0, 0)
532 + .unwrap()
533 + .and_utc()
534 + }
535 +
536 + fn plan_end() -> chrono::DateTime<Utc> {
537 + plan_start() + Duration::hours(23) + Duration::minutes(59)
538 + }
539 +
540 + fn gates(
541 + repo: &SqliteTaskRepository,
542 + user: UserId,
543 + ) -> std::collections::HashMap<TaskId, goingson_core::PlanGate> {
544 + repo.plan_gates(user, plan_start(), plan_end())
545 + .expect("plan gates")
546 + }
547 +
548 + #[test]
549 + fn a_task_with_nothing_in_its_way_has_no_gate_at_all() {
550 + let (repo, user) = repo();
551 + let solo = task(&repo, user, "solo");
552 + assert!(
553 + !gates(&repo, user).contains_key(&solo),
554 + "an absent entry is the 'nothing blocks this' signal"
555 + );
556 + }
557 +
558 + #[test]
559 + fn scheduling_the_blocker_unlocks_the_dependent_for_the_plan() {
560 + let (repo, user) = repo();
561 + let a = task(&repo, user, "a");
562 + let b = task(&repo, user, "b");
563 + block(&repo, user, a, b);
564 +
565 + let gate = gates(&repo, user);
566 + let before = gate.get(&b).expect("b is blocked");
567 + assert!(
568 + !before.unlocked_by_plan,
569 + "an empty plan unlocks nothing: a is not scheduled"
570 + );
571 + assert_eq!(before.after.len(), 1);
572 + assert_eq!(before.lead_blocker().unwrap().id, a);
573 +
574 + schedule(&repo, user, a, 9);
575 +
576 + let gate = gates(&repo, user);
577 + assert!(
578 + gate.get(&b).expect("b is still blocked").unlocked_by_plan,
579 + "scheduling a says a happens today, which is what makes b offerable"
580 + );
581 + assert!(
582 + !gate.contains_key(&a),
583 + "a itself waits on nothing, so it has no gate"
584 + );
585 + }
586 +
587 + #[test]
588 + fn the_unlock_cascades_one_step_at_a_time() {
589 + // a -> b -> c. Scheduling a opens b, and only scheduling b opens c.
590 + let (repo, user) = repo();
591 + let a = task(&repo, user, "a");
592 + let b = task(&repo, user, "b");
593 + let c = task(&repo, user, "c");
594 + block(&repo, user, a, b);
595 + block(&repo, user, b, c);
596 +
597 + schedule(&repo, user, a, 9);
598 + let gate = gates(&repo, user);
599 + assert!(gate[&b].unlocked_by_plan);
600 + assert!(
601 + !gate[&c].unlocked_by_plan,
602 + "c waits on b, which is not in the plan yet"
603 + );
604 +
605 + schedule(&repo, user, b, 10);
606 + assert!(
607 + gates(&repo, user)[&c].unlocked_by_plan,
608 + "adding b to the plan opens c, with no special-casing"
609 + );
610 + }
611 +
612 + #[test]
613 + fn every_blocker_must_be_in_the_plan_not_merely_one() {
614 + let (repo, user) = repo();
615 + let a = task(&repo, user, "a");
616 + let other = task(&repo, user, "other");
617 + let b = task(&repo, user, "b");
618 + block(&repo, user, a, b);
619 + block(&repo, user, other, b);
620 +
621 + schedule(&repo, user, a, 9);
622 + assert!(
623 + !gates(&repo, user)[&b].unlocked_by_plan,
624 + "b waits on both, so a half-planned day must not offer it"
625 + );
626 +
627 + schedule(&repo, user, other, 10);
628 + assert!(gates(&repo, user)[&b].unlocked_by_plan);
629 + }
630 +
631 + #[test]
632 + fn a_blocker_scheduled_on_another_day_does_not_unlock_anything() {
633 + // The failure this prevents is a day that looks plannable and cannot be
634 + // executed, because the thing it waits on happens next week.
635 + let (repo, user) = repo();
636 + let a = task(&repo, user, "a");
637 + let b = task(&repo, user, "b");
638 + block(&repo, user, a, b);
639 +
640 + schedule(&repo, user, a, 24 * 7);
641 +
642 + assert!(
643 + !gates(&repo, user)[&b].unlocked_by_plan,
644 + "in the plan means in THIS plan"
645 + );
646 + }
647 +
648 + #[test]
649 + fn a_blocker_scheduled_later_than_its_dependent_is_reported_not_prevented() {
650 + let (repo, user) = repo();
651 + let a = task(&repo, user, "a");
652 + let b = task(&repo, user, "b");
653 + block(&repo, user, a, b);
654 +
655 + schedule(&repo, user, a, 15);
656 + schedule(&repo, user, b, 9);
657 +
658 + let gate = &gates(&repo, user)[&b];
659 + assert!(
660 + gate.unlocked_by_plan,
661 + "presence-only gating: b stays in the plan however it is dragged"
662 + );
663 + assert!(
664 + gate.out_of_order,
665 + "and the incoherence is reported so the marker can say so"
666 + );
667 + }
668 +
669 + #[test]
670 + fn the_right_order_is_not_flagged() {
671 + let (repo, user) = repo();
672 + let a = task(&repo, user, "a");
673 + let b = task(&repo, user, "b");
674 + block(&repo, user, a, b);
675 +
676 + schedule(&repo, user, a, 9);
677 + schedule(&repo, user, b, 15);
678 +
679 + assert!(!gates(&repo, user)[&b].out_of_order);
680 + }
681 +
682 + #[test]
683 + fn an_unscheduled_task_is_never_out_of_order() {
684 + // It has no start to be earlier than, and flagging it would put a warning
685 + // on every item in the pool.
686 + let (repo, user) = repo();
687 + let a = task(&repo, user, "a");
688 + let b = task(&repo, user, "b");
689 + block(&repo, user, a, b);
690 + schedule(&repo, user, a, 15);
691 +
692 + assert!(!gates(&repo, user)[&b].out_of_order);
693 + }
694 +
695 + #[test]
696 + fn completing_the_blocker_drops_the_gate_entirely() {
697 + let (repo, user) = repo();
698 + let a = task(&repo, user, "a");
699 + let b = task(&repo, user, "b");
700 + block(&repo, user, a, b);
701 + assert!(gates(&repo, user).contains_key(&b));
702 +
703 + repo.complete(a, user).expect("complete").unwrap();
704 +
705 + assert!(
706 + !gates(&repo, user).contains_key(&b),
707 + "a finished blocker is not something b is waiting on"
708 + );
709 + }
710 +
711 + #[test]
712 + fn a_gate_names_a_cross_project_blocker_with_its_project() {
713 + let (repo, user) = repo();
714 + let a = task(&repo, user, "a");
715 + let b = task(&repo, user, "b");
716 + block(&repo, user, a, b);
717 +
718 + // No project on either, so the field is absent rather than wrong; the point
719 + // is that the gate carries the field at all, since an unqualified title
720 + // reads as if the blocker were local.
721 + let gate = &gates(&repo, user)[&b];
722 + assert_eq!(gate.lead_blocker().unwrap().title, "a");
723 + assert!(gate.lead_blocker().unwrap().project_name.is_none());
724 + }
@@ -10017,3 +10017,26 @@
10017 10017 .task-graph-node--cycle .task-graph-node-meta {
10018 10018 fill: var(--surface-page);
10019 10019 }
10020 +
10021 + /* 72. Plan Gates */
10022 +
10023 + /* The "after A" marker on a task inside a day plan. Quiet by default: it is a
10024 + reminder of order, not a warning, and every unlocked-by-plan task carries
10025 + one. */
10026 + .plan-gate {
10027 + display: inline-block;
10028 + font-size: var(--font-size-xxs);
10029 + padding: var(--step-hair) var(--step-snug);
10030 + margin-left: var(--gap-bound);
10031 + border: var(--border-width-sm) solid var(--border);
10032 + background: var(--surface-sunken);
10033 + color: var(--content-secondary);
10034 + font-weight: 700;
10035 + }
10036 +
10037 + /* The blocker is in the plan but scheduled later, so the day as drawn cannot
10038 + run in the order it shows. Nothing moves; this is the whole enforcement. */
10039 + .plan-gate--out-of-order {
10040 + background: var(--warning);
10041 + color: var(--content);
10042 + }
@@ -148,7 +148,7 @@
148 148 title="${escAttr(item.title)}${spanHint}${titleHint}${keyboardHint}"
149 149 tabindex="0" role="button" aria-label="${escAttr(item.title)}${spanHint}${keyboardHint}">
150 150 <div class="timeline-item-title">${esc(item.title)}</div>
151 - <div class="timeline-item-meta">${esc(metaText)}</div>
151 + <div class="timeline-item-meta">${esc(metaText)}${item.linkedTaskId ? renderPlanGate({ id: item.linkedTaskId }) : ''}</div>
152 152 </div>
153 153 `;
154 154 });
@@ -232,6 +232,43 @@
232 232 * @param {Object} task - Task object with id, description, priority, projectName
233 233 * @returns {string} HTML string for the task item
234 234 */
235 + /**
236 + * The "waiting on" marker for a task inside a day plan.
237 + *
238 + * A plan gate is the day's answer to "is this startable": a blocked task
239 + * enters the pool once every blocker it has is itself in today's plan, so
240 + * scheduling A is what makes B offerable. The marker is the other half of
241 + * that bargain. Nothing enforces the order the two end up in, so naming the
242 + * blocker on B is what keeps the sequence visible while the day is dragged
243 + * around.
244 + *
245 + * Reads the gate map off day-plan state rather than taking it as an
246 + * argument: the virtual scroller hands its renderer one item, and threading
247 + * a second parameter through it would change that contract for every row
248 + * type it draws.
249 + *
250 + * @param {Object} task - Task with an id
251 + * @returns {string} HTML for the marker, or empty when nothing blocks it
252 + */
253 + function renderPlanGate(task) {
254 + const gate = GoingsOn.state.dayPlanData?.gates?.[task.id];
255 + if (!gate || !gate.after || gate.after.length === 0) return '';
256 +
257 + const lead = gate.after[0];
258 + const more = gate.after.length - 1;
259 + const label = more > 0
260 + ? `after ${lead.title} +${more}`
261 + : `after ${lead.title}`;
262 + // Out of order is the case worth colouring: the blocker is in the plan,
263 + // but later than this task, so the day as drawn cannot be executed in
264 + // the order it shows.
265 + const tone = gate.outOfOrder ? ' plan-gate--out-of-order' : '';
266 + const title = gate.outOfOrder
267 + ? `Scheduled before ${lead.title}, which it waits on`
268 + : `Waits on ${lead.title}`;
269 + return `<span class="plan-gate${tone}" title="${escAttr(title)}" aria-label="${escAttr(title)}">${esc(label)}</span>`;
270 + }
271 +
235 272 function renderUnscheduledTaskItem(task) {
236 273 return `
237 274 <div class="unscheduled-task priority-${task.priority.toLowerCase()}"
@@ -242,6 +279,7 @@
242 279 <div class="unscheduled-task-title">${esc(task.title)}</div>
243 280 <div class="text-sm text-secondary">
244 281 ${task.projectName ? esc(task.projectName) + ' - ' : ''}${task.priority}
282 + ${renderPlanGate(task)}
245 283 </div>
246 284 <div class="unscheduled-task-actions" data-act="ui.noop">
247 285 <button class="button button--sm button--ghost" data-act="timeTracking.startTimer" data-a1="${escAttr(task.id)}" title="Track Time">Track</button>
@@ -299,6 +337,7 @@
299 337 renderTimeline,
300 338 renderAllDayStrip,
301 339 renderUnscheduledTaskItem,
340 + renderPlanGate,
302 341 updateCurrentTimeIndicator,
303 342 getSlotHeight,
304 343 };