Skip to main content

max / goingson

Join a row's trailing facts instead of overwriting them `Row::meta` sets rather than appends, so a row given two kept only the last. Three sites paid for it, all pre-existing and all faithfully carried over by their conversions: - the Timer row placed project, estimate and tracked total, so a task with tracked time said its total and never named its project - the report's project row placed the tracked minutes and then the est/actual pair, so a project carrying estimates lost its total to the meter beside it - the weekly review's overdue and due-this-week rows placed the project and then the due date, so neither list ever showed a project Each is now one `meta` built by a supplier that joins the facts it has, which is the shape `contacts::entry_meta` already used. `Row::meta` is left alone: one part per role is the vocabulary working, and the rows were what was wrong. This changes what two screens say, which is the point of it. Two tests pin the new text; none asserted the old. Closes the fix half of problem 0c540c6f.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-04 19:18 UTC
Signed with PGP, not checked
Commit: e6256c342f425e3bf5c6549eebec5b69a7a76902
Parent: 3531d5d
4 files changed, +115 insertions, -31 deletions
@@ -641,19 +641,32 @@
641 641 })
642 642 }
643 643
644 - /// Whether the task says how long it should take.
645 - fn has_estimate(task: &Task) -> bool {
646 - task.estimated_minutes.is_some()
647 - }
648 -
649 - /// That estimate, in words.
650 - fn estimate_words(task: &Task) -> String {
651 - spans(task.estimated_minutes.unwrap_or(0))
652 - }
653 -
654 - /// Whether any time has been recorded against the task.
655 - fn has_tracked(task: &Task) -> bool {
656 - task.actual_minutes > 0
644 + /// The row's one trailing fact: the project, and the estimate and the tracked
645 + /// total when the task carries them.
646 + ///
647 + /// One string rather than three settings because `meta` sets rather than
648 + /// appends, so three of them left only the last: a task with tracked time never
649 + /// showed its project.
650 + fn task_meta(task: &Task) -> String {
651 + let project = task.project_name.clone();
652 + let estimate = task
653 + .estimated_minutes
654 + .map(|minutes| format!("{} est", spans(minutes)));
655 + let tracked =
656 + (task.actual_minutes > 0).then(|| format!("{} tracked", spans(task.actual_minutes)));
657 + let said = [project, estimate, tracked]
658 + .into_iter()
659 + .flatten()
660 + .collect::<Vec<_>>()
661 + .join(" · ");
662 + // What the row said when the project was its only fact, kept for the task
663 + // that has none of the three. A leading dash in front of an estimate would
664 + // be saying "no project" louder than the estimate it sits next to.
665 + if said.is_empty() {
666 + task.project_name_or_dash().to_owned()
667 + } else {
668 + said
669 + }
657 670 }
658 671
659 672 /// Today's date, which is what a retroactive log opens on.
@@ -681,17 +694,14 @@
681 694 /// momentarily refused, and a row that lost its buttons would read as a
682 695 /// task that cannot be tracked at all.
683 696 ///
684 - /// **`meta` sets rather than appends**, so the three below are one trailing
685 - /// fact and the last one placed wins. That is what the hand-written row did
686 - /// too, and it is a defect rather than a decision: a task with tracked time
687 - /// never shows its project. Reported rather than repaired here, because a
688 - /// conversion is the wrong place to change what a screen says.
697 + /// The project, the estimate and the tracked total are one `meta`, joined by
698 + /// [`task_meta`]. `meta` sets rather than appends, so writing them as three
699 + /// settings left only the last, which is what the hand-written row did and
700 + /// what meant a task with tracked time never showed its project.
689 701 shape row_for(task: &Task, offered: &Offered, view: View) -> Row;
690 702
691 703 row &task.title {
692 - meta task.project_name_or_dash().to_owned();
693 - meta "{estimate_words(task)} est" when has_estimate(task);
694 - meta "{spans(task.actual_minutes)} tracked" when has_tracked(task);
704 + meta task_meta(task);
695 705
696 706 for marker in super::Availability::of(task).marker().into_iter() {
697 707 token marker;
@@ -802,6 +812,21 @@
802 812 project.estimate_accuracy_percent.is_some()
803 813 }
804 814
815 + /// The project row's one trailing fact: what the window tracked, and the
816 + /// estimate against the actual when the project carries estimates.
817 + ///
818 + /// Joined for the same reason [`task_meta`] is: two `meta` settings left only
819 + /// the second, so a project with estimates lost its tracked total and the meter
820 + /// beside it was the only thing still saying it.
821 + fn project_meta(project: &TimeReportProject) -> String {
822 + let against = has_accuracy(project).then(|| against_estimate(project));
823 + [Some(spans(project.tracked_minutes)), against]
824 + .into_iter()
825 + .flatten()
826 + .collect::<Vec<_>>()
827 + .join(" · ")
828 + }
829 +
805 830 /// The estimate against the actual, in words.
806 831 fn against_estimate(project: &TimeReportProject) -> String {
807 832 format!(
@@ -855,11 +880,10 @@
855 880 list {
856 881 for project in report.read.projects.iter() {
857 882 row &project.name {
858 - meta spans(project.tracked_minutes);
883 + meta project_meta(project);
859 884 meter Meter::new(counted(project.tracked_minutes), report.total)
860 885 .label("minutes");
861 886
862 - meta against_estimate(project) when has_accuracy(project);
863 887 token Tag::badge(accuracy(project)).tone(overran(project))
864 888 when has_accuracy(project);
865 889 token Tag::badge("no estimates") unless has_accuracy(project);
@@ -446,11 +446,8 @@
446 446 list {
447 447 for task in review.data.tasks_overdue.iter() {
448 448 row &task.title {
449 - for project in task.project_name.iter() {
450 - meta project;
451 - }
449 + meta project_and_due(task);
452 450 token Tag::badge("Overdue").tone(Tone::Danger);
453 - meta task.due_formatted();
454 451 }
455 452 }
456 453
@@ -476,15 +473,25 @@
476 473 list {
477 474 for task in review.data.tasks_due_next_week.iter() {
478 475 row &task.title {
479 - for project in task.project_name.iter() {
480 - meta project;
481 - }
482 - meta task.due_formatted();
476 + meta project_and_due(task);
483 477 }
484 478 }
485 479 } unless review.data.tasks_due_next_week.is_empty();
486 480 }
487 481
482 + /// A row's one trailing fact where the task says both its project and when it
483 + /// is due.
484 + ///
485 + /// `meta` sets rather than appends, so writing them as two settings left only
486 + /// the due date, and no overdue or due-this-week row ever showed its project.
487 + fn project_and_due(task: &Task) -> String {
488 + [task.project_name.clone(), Some(task.due_formatted())]
489 + .into_iter()
490 + .flatten()
491 + .collect::<Vec<_>>()
492 + .join(" · ")
493 + }
494 +
488 495 /// Whether anything is focused at all.
489 496 fn any_focused(review: &Review) -> bool {
490 497 !review.data.focused_tasks.is_empty()
@@ -396,6 +396,20 @@
396 396 assert!(markup.contains("/timer/view/log"), "{markup}");
397 397 }
398 398
399 + #[tokio::test]
400 + async fn a_row_keeps_every_trailing_fact_rather_than_only_the_last() {
401 + // `0c540c6f`. `Row::meta` sets rather than appends, so the row's three
402 + // settings kept only whichever was placed last: a task with tracked time
403 + // said its total and nothing else, and never named its project. One joined
404 + // string is the fix, and this is what would catch it coming back.
405 + let state = state().await;
406 + let id = estimated(&state, "Write the thing", 30);
407 + logged(&state, id, 45);
408 +
409 + let markup = screen(&state);
410 + assert!(markup.contains("30m est · 45m tracked"), "{markup}");
411 + }
412 +
399 413 #[tokio::test]
400 414 async fn the_task_being_timed_is_the_band_rather_than_a_row_offering_to_start_it() {
401 415 let state = state().await;
@@ -51,6 +51,33 @@
51 51 .unwrap()
52 52 }
53 53
54 + /// A task in a project, due on a day that has already gone.
55 + fn overdue_in_project(state: &AppState, title: &str, project: &str) {
56 + let project = state
57 + .projects
58 + .create(
59 + DESKTOP_USER_ID,
60 + goingson_core::NewProject {
61 + name: project.to_owned(),
62 + description: String::new(),
63 + project_type: goingson_core::ProjectType::SideProject,
64 + status: goingson_core::ProjectStatus::Active,
65 + },
66 + )
67 + .unwrap();
68 + state
69 + .tasks
70 + .create(
71 + DESKTOP_USER_ID,
72 + NewTask::builder(title)
73 + .priority(Priority::High)
74 + .project_id(project.id)
75 + .due(chrono::Utc::now() - chrono::Duration::days(3))
76 + .build(),
77 + )
78 + .unwrap();
79 + }
80 +
54 81 /// The week the tests write into, which is the one anything created now lands
55 82 /// in.
56 83 fn this_week() -> String {
@@ -122,6 +149,18 @@
122 149 }
123 150 }
124 151
152 + #[tokio::test]
153 + async fn an_overdue_row_says_its_project_as_well_as_when_it_slipped() {
154 + // `0c540c6f`. `Row::meta` sets rather than appends, so the project this row
155 + // placed first was thrown away by the due date placed second and no overdue
156 + // task ever named the project it belonged to. One joined string is the fix.
157 + let state = state().await;
158 + overdue_in_project(&state, "Write the thing", "Ledger");
159 +
160 + let page = review(&state);
161 + assert!(page.contains("Ledger · 3d ago"), "{page}");
162 + }
163 +
125 164 #[tokio::test]
126 165 async fn a_day_carries_its_counts_as_numbers_rather_than_as_dots() {
127 166 // The first finding. `renderDayDots` caps completed at three dots and the