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