savepoint 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/.claude/settings.local.json +4 -1
  2. package/.savepoint/Design.md +22 -17
  3. package/.savepoint/audit/v1/E01/proposals.md +168 -0
  4. package/.savepoint/audit/v1/E01/snapshot.md +78 -0
  5. package/.savepoint/audit/{E01-go-setup → v1/E01-go-setup}/proposals.md +7 -7
  6. package/.savepoint/audit/{E01-go-setup → v1/E01-go-setup}/snapshot.md +2 -2
  7. package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/AGENTS.md +5 -5
  8. package/.savepoint/audit/{E02-data-readers → v1/E02-data-readers}/proposals.md +20 -20
  9. package/.savepoint/audit/{E02-data-readers → v1/E02-data-readers}/snapshot.md +1 -1
  10. package/.savepoint/audit/{E03-board-tui-core → v1/E03-board-tui-core}/proposals.md +11 -11
  11. package/.savepoint/audit/{E03-board-tui-core → v1/E03-board-tui-core}/snapshot.md +1 -1
  12. package/.savepoint/audit/{E04-board-components → v1/E04-board-components}/proposals.md +14 -14
  13. package/.savepoint/audit/{E04-board-components → v1/E04-board-components}/snapshot.md +1 -1
  14. package/.savepoint/audit/{E05-init-command → v1/E05-init-command}/snapshot.md +1 -1
  15. package/.savepoint/audit/{E05-phase-transitions → v1/E05-phase-transitions}/proposals.md +4 -4
  16. package/.savepoint/audit/{E05-phase-transitions → v1/E05-phase-transitions}/snapshot.md +1 -1
  17. package/.savepoint/audit/{E06-atari-noir-layout → v1/E06-atari-noir-layout}/proposals.md +2 -2
  18. package/.savepoint/audit/{E07-audit-pipeline → v1/E07-audit-pipeline}/snapshot.md +6 -6
  19. package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/proposals.md +114 -0
  20. package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +41 -0
  21. package/.savepoint/audit/v1.1/E04-epic-navigation/proposals.md +156 -0
  22. package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +48 -0
  23. package/.savepoint/releases/v1/epics/E01-go-setup/tasks/T001-init-module.md +1 -1
  24. package/.savepoint/releases/v1/epics/E03-board-tui-core/tasks/T005-layout.md +1 -1
  25. package/.savepoint/releases/v1/epics/E04-board-components/tasks/T002-card.md +1 -1
  26. package/.savepoint/releases/v1/epics/E04-board-components/tasks/T006-help-overlay.md +1 -1
  27. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/{Design.md → E06-Detail.md} +5 -3
  28. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T002-header-and-dividers.md +1 -1
  29. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T003-footer-status-bar.md +1 -1
  30. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T004-component-refinement.md +1 -1
  31. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T010-auto-refresh-watcher.md +2 -0
  32. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/{Design.md → E01-Detail.md} +9 -1
  33. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/{T007-next-activity-header.md → T001-next-activity-header.md} +13 -12
  34. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T002-rename-epic-design-files.md +9 -9
  35. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T003-rename-release-prd.md +2 -2
  36. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T004-update-instruction-files.md +13 -12
  37. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T005-update-cross-references.md +14 -13
  38. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T006-column-and-detail-scrolling.md +25 -15
  39. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T007-column-focus-border-stability.md +57 -0
  40. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/{Design.md → E02-Detail.md} +12 -3
  41. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T001-fix-makefile.md +11 -8
  42. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T002-linux-build-target.md +12 -7
  43. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T003-macos-build-target.md +9 -5
  44. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T004-smoke-tests-and-artifacts.md +30 -9
  45. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +32 -0
  46. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T001-border-resize-fix.md +40 -0
  47. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T002-next-activity-below-header.md +64 -0
  48. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T003-checkbox-rendering-fix.md +56 -0
  49. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T005-unify-status-glyphs.md +65 -0
  50. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +36 -0
  51. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/E04-Detail.md +51 -0
  52. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T001-sidebar-focusable-navigation.md +65 -0
  53. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T002-epic-detail-overlay.md +73 -0
  54. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T003-epic-status-glyphs.md +73 -0
  55. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +45 -0
  56. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +34 -0
  57. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +30 -0
  58. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +33 -0
  59. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +88 -0
  60. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +30 -0
  61. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +46 -0
  62. package/.savepoint/releases/v1.1/v1.1-PRD.md +79 -0
  63. package/.savepoint/router.md +33 -105
  64. package/AGENTS.md +56 -113
  65. package/Makefile +19 -3
  66. package/README.md +6 -5
  67. package/agent-skills/savepoint-audit/SKILL.md +6 -6
  68. package/agent-skills/savepoint-build-task/SKILL.md +2 -2
  69. package/agent-skills/savepoint-create-plan/SKILL.md +3 -3
  70. package/agent-skills/savepoint-create-task/SKILL.md +2 -2
  71. package/agent-skills/savepoint-draft-prd/SKILL.md +1 -1
  72. package/agent-skills/savepoint-system-design/SKILL.md +1 -1
  73. package/internal/board/board.go +43 -27
  74. package/internal/board/board_test.go +71 -0
  75. package/internal/board/card.go +34 -3
  76. package/internal/board/card_test.go +105 -12
  77. package/internal/board/column.go +40 -5
  78. package/internal/board/column_test.go +60 -13
  79. package/internal/board/detail.go +107 -25
  80. package/internal/board/detail_test.go +117 -26
  81. package/internal/board/epic_panel.go +105 -8
  82. package/internal/board/epic_panel_test.go +343 -5
  83. package/internal/board/layout.go +12 -2
  84. package/internal/board/layout_test.go +17 -0
  85. package/internal/board/model.go +141 -24
  86. package/internal/board/render_policy_test.go +77 -0
  87. package/internal/board/status.go +23 -0
  88. package/internal/board/update.go +276 -8
  89. package/internal/board/update_test.go +166 -0
  90. package/internal/board/view.go +131 -17
  91. package/internal/board/view_test.go +159 -1
  92. package/internal/board/watch.go +24 -6
  93. package/internal/buildtool/main.go +219 -0
  94. package/internal/data/parser.go +8 -0
  95. package/internal/data/parser_test.go +35 -0
  96. package/internal/data/task.go +10 -0
  97. package/internal/styles/palette.go +3 -3
  98. package/internal/styles/styles.go +39 -12
  99. package/main.go +9 -0
  100. package/package.json +1 -1
  101. package/savepoint +0 -0
  102. package/savepoint.exe +0 -0
  103. package/templates/project/.savepoint/router.md +6 -5
  104. package/templates/project/AGENTS.md +47 -101
  105. package/templates/prompts/audit-reconciliation.prompt.md +6 -6
  106. package/templates/prompts/epic-design.prompt.md +3 -3
  107. package/templates/prompts/task-breakdown.prompt.md +1 -1
  108. package/templates/prompts/task-building.prompt.md +1 -1
  109. package/templates/prompts/task-planning.prompt.md +1 -1
  110. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-border-resize-fix.md +0 -36
  111. package/main.exe +0 -0
  112. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/Design.md +0 -0
  113. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/epic-Design.md +0 -0
  114. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/quality-review.md +0 -0
  115. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/snapshot.md +0 -0
  116. /package/.savepoint/audit/{E02-data-model → v1/E02-data-model}/snapshot.md +0 -0
  117. /package/.savepoint/audit/{E03-cli-foundation → v1/E03-cli-foundation}/snapshot.md +0 -0
  118. /package/.savepoint/audit/{E04-templates-and-prompts → v1/E04-templates-and-prompts}/snapshot.md +0 -0
  119. /package/.savepoint/audit/{E06-atari-noir-layout → v1/E06-atari-noir-layout}/snapshot.md +0 -0
  120. /package/.savepoint/audit/{E06-tui-board → v1/E06-tui-board}/snapshot.md +0 -0
  121. /package/.savepoint/audit/{E08-board-workflow-cleanup → v1/E08-board-workflow-cleanup}/snapshot.md +0 -0
  122. /package/.savepoint/releases/v1/epics/E01-go-setup/{Design.md → E01-Detail.md} +0 -0
  123. /package/.savepoint/releases/v1/epics/E02-data-readers/{Design.md → E02-Detail.md} +0 -0
  124. /package/.savepoint/releases/v1/epics/E03-board-tui-core/{Design.md → E03-Detail.md} +0 -0
  125. /package/.savepoint/releases/v1/epics/E04-board-components/{Design.md → E04-Detail.md} +0 -0
  126. /package/.savepoint/releases/v1/epics/E05-phase-transitions/{Design.md → E05-Detail.md} +0 -0
  127. /package/.savepoint/releases/v1/{PRD.md → v1-PRD.md} +0 -0
package/README.md CHANGED
@@ -34,11 +34,11 @@ Savepoint turns your project into a series of hard gates, enforced by **six bund
34
34
 
35
35
  Small scopes. Small context windows. No wandering.
36
36
 
37
- - **The PRD Gate (`draft-prd`):** The agent interviews you to ensure your idea is crisp enough for a V1 before writing any architecture.
38
- - **The Design Gate (`system-design`):** Write down what you actually want. If you can't explain it simply in a markdown file, the AI is going to make a mess of it.
39
- - **The Plan Gate (`create-plan` & `create-task`):** Break the idea down into small, manageable steps. No giant leaps. This becomes the checklist the AI _must_ follow.
40
- - **The Build Gate (`build-task`):** The AI writes code for one small step at a time. The scope stays tight so it doesn't wander off into the weeds. It logs "Drift Notes" instead of cowboy-coding architectural changes.
41
- - **The Audit Gate (`audit`):** Before moving on, we stop and check. Does the code match the plan? We don't advance until the map matches the territory.
37
+ - **The PRD Gate (`savepoint-draft-prd`):** The agent interviews you to ensure your idea is crisp enough for a V1 before writing any architecture.
38
+ - **The Design Gate (`savepoint-system-design`):** Write down what you actually want. If you can't explain it simply in a markdown file, the AI is going to make a mess of it.
39
+ - **The Plan Gate (`savepoint-create-plan` & `savepoint-create-task`):** Break the idea down into small, manageable steps. No giant leaps. This becomes the checklist the AI _must_ follow.
40
+ - **The Build Gate (`savepoint-build-task`):** The AI writes code for one small step at a time. The scope stays tight so it doesn't wander off into the weeds. It logs "Drift Notes" instead of cowboy-coding architectural changes.
41
+ - **The Audit Gate (`savepoint-audit`):** Before moving on, we stop and check. Does the code match the plan? We don't advance until the map matches the territory.
42
42
 
43
43
  > 🔒 **The Audit Loop** — When the last task in an epic moves to `done`, the next epic stays _locked_ until your docs (`Design.md`, `AGENTS.md`, and the epic's own design) are reconciled with the actual code via the audit agent. **No existing markdown-first task tool has this gate.**
44
44
 
@@ -76,3 +76,4 @@ I’m sharing it to prove a point: The real power of AI isn't just the size of t
76
76
 
77
77
  **License:** MIT
78
78
  **Status:** Recursive Construction (v1 MVP in progress)
79
+ us:** Recursive Construction (v1 MVP in progress)
@@ -10,8 +10,8 @@ The Audit Gate is Savepoint's wedge. It prevents projects from degrading into ch
10
10
  This skill is activated when the `.savepoint/router.md` state is `audit-pending`.
11
11
 
12
12
  ## Input
13
- - `.savepoint/audit/{E##-slug}/snapshot.md` (what changed).
14
- - `.savepoint/releases/v1/epics/{E##-slug}/Design.md` (the epic design).
13
+ - `.savepoint/audit/{release}/{E##-slug}/snapshot.md` (what changed).
14
+ - `.savepoint/releases/{release}/epics/{E##-slug}/E##-Detail.md` (the epic design).
15
15
  - `.savepoint/Design.md` (project architecture).
16
16
  - `AGENTS.md` (agent guide and codebase map).
17
17
  - The source and test files modified during the Epic.
@@ -21,15 +21,15 @@ This skill is activated when the `.savepoint/router.md` state is `audit-pending`
21
21
  1. **Fresh Eyes Check:** If you are the exact same agent session that just built the `build-task` code for this Epic, you MUST STOP. Tell the user: "Epic complete. Start a new agent session for the audit."
22
22
  2. **Verify ACs:** Review the completed tasks for the Epic. Ensure the Acceptance Criteria were actually met by the committed code.
23
23
  3. **Process Drift Notes (Reconciliation):** Read every task file in the Epic and look for `## Drift Notes`.
24
- 4. **Draft Proposals:** Based on the code changes and the Drift Notes, write exactly ONE file: `.savepoint/audit/{E##-slug}/proposals.md`. It must contain:
24
+ 4. **Draft Proposals:** Based on the code changes and the Drift Notes, write exactly ONE file: `.savepoint/audit/{release}/{E##-slug}/proposals.md`. It must contain:
25
25
  * **Design.md section:** Propose updates to merge the epic's architectural changes into the project-level `Design.md`.
26
26
  * **AGENTS.md section:** Propose updates to refresh the Codebase Map table with new or changed modules.
27
- * **Epic-Design.md section:** Add "Implemented as:" notes showing where reality deviated from the original plan.
27
+ * **Epic-E##-Detail.md section:** Add "Implemented as:" notes showing where reality deviated from the original plan.
28
28
  * **Quality Review section:** List any minor code-style infractions that must be fixed before the next Epic.
29
29
  5. **Review Format:** Use `## Target File`, `## Replace`, and `## With` formatting in the proposals document so it is easy for a human (or an agent) to apply later.
30
- 6. **Handoff:** Do not apply the proposals yourself. Do not mark the epic audited. Stop and prompt the user to review `.savepoint/audit/{E##-slug}/proposals.md` (often via the TUI). Once the user approves, the proposals are applied, and the router moves to the next Epic.
30
+ 6. **Handoff:** Do not apply the proposals yourself. Do not mark the epic audited. Stop and prompt the user to review `.savepoint/audit/{release}/{E##-slug}/proposals.md` (often via the TUI). Once the user approves, the proposals are applied, and the router moves to the next Epic.
31
31
 
32
32
  ## Constraints
33
33
  - **Do not write product code.** You are an auditor.
34
34
  - **Do not apply the changes immediately.** Write the proposals document first.
35
- - **One proposals file.** Do not create multiple proposal files.
35
+ - **One proposals file.** Do not create multiple proposal files.
@@ -7,11 +7,11 @@ Act as a disciplined coding agent that strictly follows Savepoint's implementati
7
7
  The `build-task` skill is the execution engine. It reads the detailed task plan, writes the code, and proves that the Acceptance Criteria (ACs) have been met. It is strictly constrained to the scope of the single active task. It does not rewrite architecture, and it does not fix unrelated bugs.
8
8
 
9
9
  ## Trigger
10
- This skill is activated when the `.savepoint/router.md` state is `in-progress` and points to a specific task file.
10
+ This skill is activated when the `.savepoint/router.md` state is `task-building` and points to a specific task file.
11
11
 
12
12
  ## Input
13
13
  - `.savepoint/router.md` (Current state).
14
- - The active epic Design: `.savepoint/releases/v1/epics/{E##-epic}/Design.md`.
14
+ - The active epic E##-Detail.md: `.savepoint/releases/v1/epics/{E##-epic}/E##-Detail.md`.
15
15
  - The active task file: `.savepoint/releases/v1/epics/{E##-epic}/tasks/{T###}-*.md`.
16
16
  - Directly touched source/test files.
17
17
 
@@ -7,18 +7,18 @@ Turn the Product Requirements Document (PRD) and the architectural `Design.md` i
7
7
  Savepoint enforces small scopes to prevent AI agents from "wandering." The `create-plan` skill acts as the Technical Project Manager. It takes the grand vision and the architectural blueprint and slices it into a sequence of achievable, testable milestones (Epics).
8
8
 
9
9
  ## Trigger
10
- This skill is activated when the `.savepoint/router.md` state is `planning`.
10
+ This skill is activated when the `.savepoint/router.md` state is `pre-implementation`.
11
11
 
12
12
  ## Input
13
13
  - `.savepoint/PRD.md` (Vision and constraints)
14
14
  - `.savepoint/Design.md` (Architecture and layout)
15
- - `.savepoint/releases/v1/PRD.md` (Optional: Release-scoped PRD)
15
+ - `.savepoint/releases/v1/v1-PRD.md` (Optional: Release-scoped PRD)
16
16
 
17
17
  ## Workflow
18
18
 
19
19
  1. **Read the Context:** Consume the PRD and Design documents to understand the scope and technical constraints.
20
20
  2. **Define Epics:** Group the work into high-level features or milestones. Name them clearly (e.g., `E01-scaffolding`, `E02-database`, `E03-auth`). Each Epic must represent a deliverable slice of value.
21
- 3. **Draft Epic Designs:** For each Epic, create a shell `Design.md` inside `.savepoint/releases/v1/epics/{E##-epic-name}/Design.md`. This file should describe the *delta* (what this specific epic adds to the overall architecture).
21
+ 3. **Draft Epic Designs:** For each Epic, create a shell `E##-Detail.md` inside `.savepoint/releases/v1/epics/{E##-epic-name}/E##-Detail.md`. This file should describe the *delta* (what this specific epic adds to the overall architecture).
22
22
  4. **Breakdown Tasks (High Level):** Inside each Epic folder, list out the high-level tasks required to complete it. Do not write full implementation plans yet—just identify the discrete chunks of work (e.g., `T001-setup-repo.md`, `T002-init-db.md`).
23
23
  5. **Order and Dependency:** Ensure the Epics and tasks are ordered logically. You cannot build the frontend auth UI (E03) before the database models (E02) exist.
24
24
  6. **Handoff:** Update `.savepoint/router.md` to `state: task-breakdown` and point it at the first Epic. Prompt the user to review the Epic list.
@@ -7,10 +7,10 @@ Take high-level tasks identified during the planning phase and build detailed, a
7
7
  A task plan is a contract between the planner and the builder. If the task plan is vague, the resulting code will be buggy. The `create-task` skill acts as a Senior Engineer writing tickets for a Junior Developer (the `build-task` agent). It must define exactly *what* constitutes success and *how* to achieve it, without actually writing the code.
8
8
 
9
9
  ## Trigger
10
- This skill is activated when the `.savepoint/router.md` state is `task-breakdown` and the router points to a specific task file.
10
+ This skill is activated when the `.savepoint/router.md` state is `epic-task-breakdown` and the router points to a specific epic.
11
11
 
12
12
  ## Input
13
- - `.savepoint/releases/v1/epics/{E##-epic}/Design.md` (The active Epic's design).
13
+ - `.savepoint/releases/v1/epics/{E##-epic}/E##-Detail.md` (The active Epic's design).
14
14
  - The high-level task markdown file (e.g., `.savepoint/releases/v1/epics/{E##-epic}/tasks/T001-slug.md`).
15
15
 
16
16
  ## Workflow
@@ -7,7 +7,7 @@ Help the user write a structured, sufficiently detailed Product Requirements Doc
7
7
  In the Savepoint workflow, the PRD is the absolute source of truth. If the PRD is a vague brain-dump, the resulting architecture and code will be a mess. Your job as the `draft-prd` agent is to act as a strict Product Manager. You do not write code. You interrogate the user's idea until it is crisp enough to build a V1.
8
8
 
9
9
  ## Trigger
10
- This skill is activated when the `.savepoint/router.md` state is `draft-prd` or when the user explicitly asks you to help them write or refine their PRD.
10
+ This skill is activated when the `.savepoint/router.md` state is `pre-implementation` or when the user explicitly asks you to help them write or refine their PRD.
11
11
 
12
12
  ## Input
13
13
  - The user's initial idea, brain-dump, or `.txt` file outline.
@@ -7,7 +7,7 @@ Translate the Product Requirements Document (PRD) into the initial architectural
7
7
  Before any tasks can be planned, the project needs a high-level technical direction. The `system-design` skill acts as the Staff Engineer. It reads the vision and constraints from the PRD and makes authoritative technical decisions regarding architecture, directory layout, dependency strategies, and workflow rules.
8
8
 
9
9
  ## Trigger
10
- This skill is activated when the `.savepoint/router.md` state is `design` or when the user explicitly asks to design the system based on the PRD.
10
+ This skill is activated when the `.savepoint/router.md` state is `epic-design` or when the user explicitly asks to design the system based on the PRD.
11
11
 
12
12
  ## Input
13
13
  - `.savepoint/PRD.md` (The source of truth for "what" and "why").
@@ -6,10 +6,14 @@ import (
6
6
  "path/filepath"
7
7
 
8
8
  tea "github.com/charmbracelet/bubbletea"
9
+ "github.com/charmbracelet/lipgloss"
10
+ "github.com/muesli/termenv"
9
11
  "github.com/opencode/savepoint/internal/data"
10
12
  )
11
13
 
12
14
  func Run() error {
15
+ lipgloss.SetColorProfile(termenv.ANSI256)
16
+
13
17
  model, err := newProjectModel(".")
14
18
  if err != nil {
15
19
  return err
@@ -39,26 +43,7 @@ func newProjectModel(start string) (Model, error) {
39
43
  return Model{}, err
40
44
  }
41
45
 
42
- releases, err := d.ListReleases(root)
43
- if err != nil {
44
- return Model{}, err
45
- }
46
-
47
- releaseIDs := make([]string, 0, len(releases))
48
- releaseEpics := make(map[string][]string, len(releases))
49
-
50
- for _, release := range releases {
51
- releaseIDs = append(releaseIDs, release.ID)
52
- epics, err := d.ListEpics(root, release.ID)
53
- if err != nil {
54
- return Model{}, err
55
- }
56
- for _, epic := range epics {
57
- releaseEpics[release.ID] = append(releaseEpics[release.ID], epic.ID)
58
- }
59
- }
60
-
61
- tasks, err := loadAllTasks(root)
46
+ tasks, releaseIDs, releaseEpics, epicStatuses, err := loadBoardData(root)
62
47
  if err != nil {
63
48
  return Model{}, err
64
49
  }
@@ -69,40 +54,66 @@ func newProjectModel(start string) (Model, error) {
69
54
  model := NewModel(tasks, release, epic)
70
55
  model.Root = root
71
56
  model.RouterTask = routerState.Task
57
+ model.RouterState = routerState
72
58
  model.Releases = releaseIDs
73
59
  model.ReleaseEpics = releaseEpics
60
+ model.EpicStatus = epicStatuses
74
61
  model.refreshEpicsForRelease()
75
62
  model.refreshTasks()
76
63
 
77
64
  watcher, err := newWatcher(root)
78
- if err == nil {
79
- model.Watcher = watcher
65
+ if err != nil {
66
+ return Model{}, err
80
67
  }
68
+ model.Watcher = watcher
81
69
 
82
70
  return model, nil
83
71
  }
84
72
 
85
- func loadAllTasks(root string) ([]data.Task, error) {
73
+ func loadBoardData(root string) ([]data.Task, []string, map[string][]string, map[string]string, error) {
86
74
  d := data.NewDiscover()
87
75
  releases, err := d.ListReleases(root)
88
76
  if err != nil {
89
- return nil, err
77
+ return nil, nil, nil, nil, err
90
78
  }
79
+
80
+ releaseIDs := make([]string, 0, len(releases))
81
+ releaseEpics := make(map[string][]string, len(releases))
91
82
  var tasks []data.Task
83
+ epicStatuses := make(map[string]string)
84
+
92
85
  for _, release := range releases {
86
+ releaseIDs = append(releaseIDs, release.ID)
93
87
  epics, err := d.ListEpics(root, release.ID)
94
88
  if err != nil {
95
- return nil, err
89
+ return nil, nil, nil, nil, err
96
90
  }
97
91
  for _, epic := range epics {
92
+ releaseEpics[release.ID] = append(releaseEpics[release.ID], epic.ID)
98
93
  epicTasks, err := loadEpicTasks(d, root, release.ID, epic.ID)
99
94
  if err != nil {
100
- return nil, err
95
+ return nil, nil, nil, nil, err
101
96
  }
102
97
  tasks = append(tasks, epicTasks...)
98
+
99
+ detailPath := filepath.Join(epic.Path, shortID(epic.ID)+"-Detail.md")
100
+ if raw, err := os.ReadFile(detailPath); err == nil {
101
+ parser := data.NewParser()
102
+ if fm, err := parser.ParseFrontmatter(string(raw)); err == nil {
103
+ if status, ok := fm["status"].(string); ok {
104
+ epicStatuses[epic.ID] = status
105
+ }
106
+ }
107
+ }
103
108
  }
104
109
  }
105
- return tasks, nil
110
+
111
+ return tasks, releaseIDs, releaseEpics, epicStatuses, nil
112
+ }
113
+
114
+ func loadAllTasks(root string) ([]data.Task, error) {
115
+ tasks, _, _, _, err := loadBoardData(root)
116
+ return tasks, err
106
117
  }
107
118
 
108
119
  func readRouterState(root string) (*data.RouterState, error) {
@@ -150,6 +161,11 @@ func firstKnown(preferred string, values []string) string {
150
161
  return preferred
151
162
  }
152
163
  }
164
+ for _, value := range values {
165
+ if shortID(value) == shortID(preferred) {
166
+ return value
167
+ }
168
+ }
153
169
  if len(values) == 0 {
154
170
  return ""
155
171
  }
@@ -66,6 +66,10 @@ next_action: "test"
66
66
  if len(tasks) != 1 || tasks[0].ID != "E03-live/T001-live" {
67
67
  t.Errorf("visible in-progress tasks = %v, want E03-live/T001-live", tasks)
68
68
  }
69
+ if model.Watcher == nil {
70
+ t.Fatal("Watcher is nil, want auto-refresh watcher")
71
+ }
72
+ t.Cleanup(func() { model.Watcher.Close() })
69
73
  }
70
74
 
71
75
  func TestNewProjectModelUsesPathReleaseForTaskWithoutReleaseFrontmatter(t *testing.T) {
@@ -100,6 +104,73 @@ next_action: "test"
100
104
  if tasks[0].Release != "v1.1" {
101
105
  t.Errorf("Task.Release = %q, want v1.1", tasks[0].Release)
102
106
  }
107
+ if model.Watcher != nil {
108
+ t.Cleanup(func() { model.Watcher.Close() })
109
+ }
110
+ }
111
+
112
+ func TestNewProjectModelResolvesShortRouterEpicToFullEpicID(t *testing.T) {
113
+ projectRoot := t.TempDir()
114
+ savepointRoot := filepath.Join(projectRoot, ".savepoint")
115
+ writeFile(t, filepath.Join(savepointRoot, "router.md"), `# Agent State Machine
116
+
117
+ ## Current state
118
+
119
+ `+"```"+`yaml
120
+ state: task-building
121
+ release: v1.1
122
+ epic: E03
123
+ task: T001
124
+ next_action: "Build v1.1 E03/T001"
125
+ `+"```"+`
126
+ `)
127
+ writeTask(t, savepointRoot, "v1.1", "E01-tui-optimisation", "T007-column-focus-border-stability", data.ColumnInProgress)
128
+ writeTask(t, savepointRoot, "v1.1", "E03-ui-visual-refinement", "T001-border-resize-fix", data.ColumnInProgress)
129
+
130
+ model, err := newProjectModel(projectRoot)
131
+ if err != nil {
132
+ t.Fatalf("newProjectModel() error = %v", err)
133
+ }
134
+
135
+ if model.SelectedEpic != "E03-ui-visual-refinement" {
136
+ t.Errorf("SelectedEpic = %q, want E03-ui-visual-refinement", model.SelectedEpic)
137
+ }
138
+ tasks := model.Tasks[data.ColumnInProgress]
139
+ if len(tasks) != 1 || tasks[0].ID != "E03-ui-visual-refinement/T001-border-resize-fix" {
140
+ t.Errorf("visible in-progress tasks = %v, want E03-ui-visual-refinement/T001-border-resize-fix", tasks)
141
+ }
142
+ if model.Watcher != nil {
143
+ t.Cleanup(func() { model.Watcher.Close() })
144
+ }
145
+ }
146
+
147
+ func TestUpdateReloadMsgRefreshesReleaseEpicIndex(t *testing.T) {
148
+ m := NewModel(nil, "v1", "E01-old")
149
+ m.Releases = []string{"v1"}
150
+ m.ReleaseEpics = map[string][]string{"v1": []string{"E01-old"}}
151
+
152
+ task := data.Task{
153
+ ID: "E02-new/T001-new",
154
+ Release: "v1",
155
+ Epic: "E02-new",
156
+ Column: data.ColumnPlanned,
157
+ }
158
+ got, _ := m.Update(reloadMsg{
159
+ tasks: []data.Task{task},
160
+ releases: []string{"v1"},
161
+ releaseEpics: map[string][]string{"v1": []string{"E02-new"}},
162
+ })
163
+ updated := requireModel(t, got)
164
+
165
+ if updated.SelectedEpic != "E02-new" {
166
+ t.Errorf("SelectedEpic = %q, want E02-new", updated.SelectedEpic)
167
+ }
168
+ if len(updated.Epics) != 1 || updated.Epics[0] != "E02-new" {
169
+ t.Errorf("Epics = %v, want [E02-new]", updated.Epics)
170
+ }
171
+ if len(updated.Tasks[data.ColumnPlanned]) != 1 {
172
+ t.Errorf("planned tasks = %v, want reloaded task visible", updated.Tasks[data.ColumnPlanned])
173
+ }
103
174
  }
104
175
 
105
176
  func writeTask(t *testing.T, root, release, epic, task string, column data.ColumnType) {
@@ -17,15 +17,19 @@ const (
17
17
  )
18
18
 
19
19
  // RenderCard renders a task card with phase glyph, truncated ID+title, and focus styling.
20
- // When routerTaskID matches t.ID, a green priority glyph replaces the phase glyph.
21
- func RenderCard(t data.Task, width int, focused bool, routerTaskID string) string {
20
+ // When router state matches t's release/epic/task, a green priority glyph replaces the phase glyph.
21
+ func RenderCard(t data.Task, width int, focused bool, routerState *data.RouterState) string {
22
22
  inner := width - cardOverhead
23
23
  if inner < 2 {
24
24
  inner = 2
25
25
  }
26
26
 
27
27
  var glyph string
28
- if routerTaskID != "" && t.ID == routerTaskID {
28
+ if t.Status != "" {
29
+ glyph = statusGlyph(t.Status)
30
+ } else if t.Column == data.ColumnDone {
31
+ glyph = styles.GlyphBuild.Render(glyphBuild)
32
+ } else if isRouterPriority(t, routerState) {
29
33
  glyph = styles.TagDone.Render(glyphBuild)
30
34
  } else {
31
35
  glyph = phaseGlyphStyled(t.Stage)
@@ -53,6 +57,33 @@ func phaseGlyphStyled(stage data.ProgressStage) string {
53
57
  }
54
58
  }
55
59
 
60
+ func isRouterPriority(t data.Task, state *data.RouterState) bool {
61
+ if state == nil || state.Task == "" {
62
+ return false
63
+ }
64
+ if shortID(t.ID) != shortID(state.Task) {
65
+ return false
66
+ }
67
+ if state.Release != "" && t.Release != "" && t.Release != state.Release {
68
+ return false
69
+ }
70
+ routerEpic := state.Epic
71
+ if routerEpic == "" {
72
+ routerEpic = taskEpic(state.Task)
73
+ }
74
+ if routerEpic != "" && t.Epic != "" && shortID(t.Epic) != shortID(routerEpic) {
75
+ return false
76
+ }
77
+ return true
78
+ }
79
+
80
+ func taskEpic(taskID string) string {
81
+ if idx := strings.LastIndex(taskID, "/"); idx >= 0 {
82
+ return taskID[:idx]
83
+ }
84
+ return ""
85
+ }
86
+
56
87
  // shortID strips the epic prefix and slug from a task ID.
57
88
  // "E06-atari-noir-layout/T004-component-refinement" → "T004"
58
89
  func shortID(id string) string {
@@ -5,11 +5,12 @@ import (
5
5
  "testing"
6
6
 
7
7
  "github.com/opencode/savepoint/internal/data"
8
+ "github.com/opencode/savepoint/internal/styles"
8
9
  )
9
10
 
10
11
  func TestRenderCard_containsID(t *testing.T) {
11
12
  task := data.Task{ID: "E04/T002", Title: "Build card", Stage: data.StageBuild}
12
- got := RenderCard(task, 30, false, "")
13
+ got := RenderCard(task, 30, false, nil)
13
14
  if !strings.Contains(got, "T002") {
14
15
  t.Error("RenderCard missing short task ID")
15
16
  }
@@ -17,7 +18,7 @@ func TestRenderCard_containsID(t *testing.T) {
17
18
 
18
19
  func TestRenderCard_containsTitle(t *testing.T) {
19
20
  task := data.Task{ID: "T1", Title: "My title", Stage: data.StageBuild}
20
- got := RenderCard(task, 30, false, "")
21
+ got := RenderCard(task, 30, false, nil)
21
22
  if !strings.Contains(got, "My title") {
22
23
  t.Error("RenderCard missing task title")
23
24
  }
@@ -25,7 +26,7 @@ func TestRenderCard_containsTitle(t *testing.T) {
25
26
 
26
27
  func TestRenderCard_containsBuildGlyph(t *testing.T) {
27
28
  task := data.Task{ID: "T1", Stage: data.StageBuild}
28
- got := RenderCard(task, 30, false, "")
29
+ got := RenderCard(task, 30, false, nil)
29
30
  if !strings.Contains(got, glyphBuild) {
30
31
  t.Errorf("RenderCard missing build glyph %q", glyphBuild)
31
32
  }
@@ -33,7 +34,7 @@ func TestRenderCard_containsBuildGlyph(t *testing.T) {
33
34
 
34
35
  func TestRenderCard_containsTestGlyph(t *testing.T) {
35
36
  task := data.Task{ID: "T1", Stage: data.StageTest}
36
- got := RenderCard(task, 30, false, "")
37
+ got := RenderCard(task, 30, false, nil)
37
38
  if !strings.Contains(got, glyphTest) {
38
39
  t.Errorf("RenderCard missing test glyph %q", glyphTest)
39
40
  }
@@ -41,7 +42,7 @@ func TestRenderCard_containsTestGlyph(t *testing.T) {
41
42
 
42
43
  func TestRenderCard_containsAuditGlyph(t *testing.T) {
43
44
  task := data.Task{ID: "T1", Stage: data.StageAudit}
44
- got := RenderCard(task, 30, false, "")
45
+ got := RenderCard(task, 30, false, nil)
45
46
  if !strings.Contains(got, glyphAudit) {
46
47
  t.Errorf("RenderCard missing audit glyph %q", glyphAudit)
47
48
  }
@@ -49,7 +50,7 @@ func TestRenderCard_containsAuditGlyph(t *testing.T) {
49
50
 
50
51
  func TestRenderCard_focusedDoesNotPanic(t *testing.T) {
51
52
  task := data.Task{ID: "T1", Title: "hello", Stage: data.StageBuild}
52
- got := RenderCard(task, 30, true, "")
53
+ got := RenderCard(task, 30, true, nil)
53
54
  if got == "" {
54
55
  t.Error("RenderCard focused returned empty string")
55
56
  }
@@ -58,7 +59,7 @@ func TestRenderCard_focusedDoesNotPanic(t *testing.T) {
58
59
  func TestRenderCard_titleWraps(t *testing.T) {
59
60
  long := "This is a very long title that should be wrapped for sure"
60
61
  task := data.Task{ID: "T1", Title: long, Stage: data.StageBuild}
61
- got := RenderCard(task, 20, false, "")
62
+ got := RenderCard(task, 20, false, nil)
62
63
  // full title as one line does not fit; it must be broken up
63
64
  if strings.Contains(got, long) {
64
65
  t.Error("RenderCard should wrap long title, not render it as one line")
@@ -72,7 +73,7 @@ func TestRenderCard_titleWraps(t *testing.T) {
72
73
  func TestRenderCard_idTruncated(t *testing.T) {
73
74
  long := "E04-board-components/T999-very-long-id"
74
75
  task := data.Task{ID: long, Stage: data.StageBuild}
75
- got := RenderCard(task, 20, false, "")
76
+ got := RenderCard(task, 20, false, nil)
76
77
  if strings.Contains(got, long) {
77
78
  t.Error("RenderCard should truncate long ID")
78
79
  }
@@ -106,20 +107,112 @@ func TestTruncate_maxOne(t *testing.T) {
106
107
 
107
108
  func TestRenderCard_defaultStageUsesBuildGlyph(t *testing.T) {
108
109
  task := data.Task{ID: "T1", Stage: ""}
109
- got := RenderCard(task, 30, false, "")
110
+ got := RenderCard(task, 30, false, nil)
110
111
  if !strings.Contains(got, glyphBuild) {
111
112
  t.Error("RenderCard with empty stage should use build glyph")
112
113
  }
113
114
  }
114
115
 
115
116
  func TestRenderCard_routerPriorityUsesGreenGlyph(t *testing.T) {
116
- task := data.Task{ID: "E06/T009", Stage: data.StageTest}
117
- got := RenderCard(task, 30, false, "E06/T009")
117
+ task := data.Task{ID: "E06/T009", Release: "v1", Epic: "E06", Stage: data.StageTest}
118
+ router := &data.RouterState{Release: "v1", Epic: "E06", Task: "E06/T009"}
119
+ got := RenderCard(task, 30, false, router)
120
+ if !isRouterPriority(task, router) {
121
+ t.Error("router priority should match release, epic, and task")
122
+ }
118
123
  if !strings.Contains(got, glyphBuild) {
119
124
  t.Error("router priority card should use build glyph")
120
125
  }
121
- nonPriority := RenderCard(task, 30, false, "")
126
+ nonPriority := RenderCard(task, 30, false, nil)
122
127
  if !strings.Contains(nonPriority, glyphTest) {
123
128
  t.Error("non-priority test card should use test glyph")
124
129
  }
125
130
  }
131
+
132
+ func TestRenderCard_noBackgroundFillEscapes(t *testing.T) {
133
+ task := data.Task{ID: "E06/T009", Title: "Router priority", Release: "v1", Epic: "E06", Stage: data.StageTest}
134
+ router := &data.RouterState{Release: "v1", Epic: "E06", Task: "E06/T009"}
135
+ got := RenderCard(task, 30, false, router)
136
+ if strings.Contains(got, "\x1b[48;") || strings.Contains(got, "\x1b[40m") {
137
+ t.Fatalf("RenderCard should not emit background fills; got %q", got)
138
+ }
139
+ }
140
+
141
+ func TestRenderCard_routerPriorityMatchesShortID(t *testing.T) {
142
+ // Router stores short IDs ("T009"); task ID is full slug — must still match.
143
+ task := data.Task{ID: "E06-atari-noir-layout/T009-router-priority", Release: "v1", Epic: "E06-atari-noir-layout", Stage: data.StageTest}
144
+ router := &data.RouterState{Release: "v1", Epic: "E06", Task: "T009"}
145
+ got := RenderCard(task, 30, false, router)
146
+ if !isRouterPriority(task, router) {
147
+ t.Error("short router task ID should match full task ID slug")
148
+ }
149
+ if !strings.Contains(got, glyphBuild) {
150
+ t.Error("router priority card should use build glyph")
151
+ }
152
+ }
153
+
154
+ func TestRenderCard_staleRouterTaskNoMatch(t *testing.T) {
155
+ // Task moved to a new epic; router still has old epic path — should NOT match a different task number.
156
+ task := data.Task{ID: "E03-header-activity/T001-border-resize-fix", Release: "v1", Epic: "E03-header-activity", Stage: data.StageBuild}
157
+ router := &data.RouterState{Release: "v1", Epic: "E03", Task: "T002"}
158
+ got := RenderCard(task, 30, false, router)
159
+ if isRouterPriority(task, router) {
160
+ t.Error("stale router pointing to different task number should not show green glyph")
161
+ }
162
+ if !strings.Contains(got, styles.GlyphBuild.Render(glyphBuild)) {
163
+ t.Error("non-priority build task should use orange build glyph")
164
+ }
165
+ }
166
+
167
+ func TestRenderCard_routerSameTaskNumberDifferentEpicNoMatch(t *testing.T) {
168
+ task := data.Task{ID: "E03-header-activity/T001-border-resize-fix", Release: "v1", Epic: "E03-header-activity", Stage: data.StageTest}
169
+ router := &data.RouterState{Release: "v1", Epic: "E01", Task: "T001"}
170
+ got := RenderCard(task, 30, false, router)
171
+ if isRouterPriority(task, router) {
172
+ t.Error("router priority should not match same task number in a different epic")
173
+ }
174
+ if !strings.Contains(got, styles.GlyphTest.Render(glyphTest)) {
175
+ t.Error("non-priority test task should keep test glyph")
176
+ }
177
+ }
178
+
179
+ func TestRenderCard_doneTaskUsesOrangeBuildGlyph(t *testing.T) {
180
+ task := data.Task{ID: "E03/T001", Release: "v1", Epic: "E03", Column: data.ColumnDone, Stage: data.StageTest}
181
+ router := &data.RouterState{Release: "v1", Epic: "E03", Task: "T001"}
182
+ got := RenderCard(task, 30, false, router)
183
+ if !isRouterPriority(task, router) {
184
+ t.Error("router state should still identify the matching done task")
185
+ }
186
+ if !strings.Contains(got, styles.GlyphBuild.Render(glyphBuild)) {
187
+ t.Error("done task should use orange build glyph")
188
+ }
189
+ if strings.Contains(got, glyphTest) {
190
+ t.Error("done task should not use test glyph")
191
+ }
192
+ }
193
+
194
+ func TestRenderCard_explicitStatusUsesUnifiedGlyph(t *testing.T) {
195
+ tests := []struct {
196
+ name string
197
+ status data.TaskStatus
198
+ glyph string
199
+ }{
200
+ {"planned", data.StatusPlanned, "○"},
201
+ {"in progress", data.StatusInProgress, "▶"},
202
+ {"done", data.StatusDone, "◉"},
203
+ {"audited", data.StatusAudited, "✓"},
204
+ }
205
+
206
+ for _, tt := range tests {
207
+ t.Run(tt.name, func(t *testing.T) {
208
+ task := data.Task{ID: "T1", Status: string(tt.status), Stage: data.StageAudit}
209
+ got := RenderCard(task, 30, false, nil)
210
+ if !strings.Contains(got, tt.glyph) {
211
+ t.Errorf("RenderCard with status %q missing glyph %q", tt.status, tt.glyph)
212
+ }
213
+ if strings.Contains(got, glyphAudit) {
214
+ t.Errorf("RenderCard with status %q should not fall back to audit glyph", tt.status)
215
+ }
216
+ })
217
+ }
218
+ }
@@ -8,12 +8,13 @@ import (
8
8
  "github.com/opencode/savepoint/internal/styles"
9
9
  )
10
10
 
11
- // RenderColumn renders a board column: header with label+count, task list, bordered container.
12
- func RenderColumn(tasks []data.Task, col data.ColumnType, width, focusedTask int, focused bool, routerTaskID string) string {
11
+ // RenderColumn renders a board column: header with label+count, task viewport, bordered container.
12
+ func RenderColumn(tasks []data.Task, col data.ColumnType, width, maxHeight, offset, focusedTask int, focused bool, routerState *data.RouterState) string {
13
13
  inner := width - colOverhead
14
14
  if inner < minColWidth {
15
15
  inner = minColWidth
16
16
  }
17
+ offset = clampViewportOffset(offset, len(tasks))
17
18
 
18
19
  title := columnTitle(col)
19
20
  header := fmt.Sprintf("%s (%d)", title, len(tasks))
@@ -27,19 +28,53 @@ func RenderColumn(tasks []data.Task, col data.ColumnType, width, focusedTask int
27
28
  if len(tasks) == 0 {
28
29
  lines = append(lines, styles.TaskItem.Render("(empty)"))
29
30
  } else {
30
- for i, t := range tasks {
31
- lines = append(lines, RenderCard(t, inner, focused && i == focusedTask, routerTaskID))
31
+ limit := visibleColumnTaskLimit(maxHeight)
32
+ end := min(offset+limit, len(tasks))
33
+ if offset > 0 {
34
+ lines = append(lines, renderScrollIndicator("↑", offset, "above"))
35
+ }
36
+ for i, t := range tasks[offset:end] {
37
+ taskIndex := offset + i
38
+ lines = append(lines, RenderCard(t, inner, focused && taskIndex == focusedTask, routerState))
39
+ }
40
+ if end < len(tasks) {
41
+ lines = append(lines, renderScrollIndicator("↓", len(tasks)-end, "more"))
32
42
  }
33
43
  }
34
44
 
35
45
  content := strings.Join(lines, "\n")
36
- st := styles.Column.Width(width)
46
+ st := styles.ColumnUnfocused.Width(width)
37
47
  if focused {
38
48
  st = styles.ColumnFocused.Width(width)
39
49
  }
40
50
  return st.Render(content)
41
51
  }
42
52
 
53
+ func visibleColumnTaskLimit(maxHeight int) int {
54
+ if maxHeight <= 0 {
55
+ return 999999
56
+ }
57
+ limit := (maxHeight - 2) / 3
58
+ if limit < 1 {
59
+ return 1
60
+ }
61
+ return limit
62
+ }
63
+
64
+ func clampViewportOffset(offset, total int) int {
65
+ if offset < 0 || total <= 0 {
66
+ return 0
67
+ }
68
+ if offset >= total {
69
+ return total - 1
70
+ }
71
+ return offset
72
+ }
73
+
74
+ func renderScrollIndicator(arrow string, count int, suffix string) string {
75
+ return styles.ScrollIndicator.Render(fmt.Sprintf("%s %d %s", arrow, count, suffix))
76
+ }
77
+
43
78
  func columnTitle(col data.ColumnType) string {
44
79
  switch col {
45
80
  case data.ColumnPlanned: