tracker/data.go
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
// Task statuses. The JSON files must use these exact values.
const (
StatusTodo = "todo"
StatusInProgress = "in_progress"
StatusReview = "review"
StatusDone = "done" // the agent finished the work and all its tests pass
StatusReviewed = "reviewed" // a reviewer approved it (the final state)
)
var validStatus = map[string]bool{
StatusTodo: true, StatusInProgress: true, StatusReview: true, StatusDone: true, StatusReviewed: true,
}
type Step struct {
Text string `json:"text"`
Done bool `json:"done"`
}
// Review states. A task's review moves todo -> done, or to rejected / hold.
const (
ReviewTodo = "todo" // waiting for the reviewer
ReviewRejected = "rejected" // sent back with changes to make
ReviewHold = "hold" // paused, e.g. waiting for information
ReviewDone = "done" // accepted
)
var validReviewState = map[string]bool{"": true, ReviewTodo: true, ReviewRejected: true, ReviewHold: true, ReviewDone: true}
// ChecklistItem is one numbered step the reviewer should do. Text is Markdown.
type ChecklistItem struct {
Text string `json:"text"`
Done bool `json:"done"`
}
type Review struct {
Reviewer string `json:"reviewer"`
RequestedAt string `json:"requested_at"`
State string `json:"state"` // todo | rejected | hold | done (empty = todo)
Focus string `json:"focus"` // what to check, Markdown
Checklist []ChecklistItem `json:"checklist"`
Decision string `json:"decision"` // reviewer's result or reason, Markdown
DecidedAt string `json:"decided_at"`
DecidedBy string `json:"decided_by"`
History []ReviewEvent `json:"history"` // every submitted decision, oldest first
}
// ReviewEvent is one submitted review decision.
type ReviewEvent struct {
State string `json:"state"`
Decision string `json:"decision"`
By string `json:"by"`
At string `json:"at"`
}
// StateOrTodo is the review state, treating empty as todo.
func (r Review) StateOrTodo() string {
if r.State == "" {
return ReviewTodo
}
return r.State
}
type TaskQuestion struct {
ID string `json:"id"`
AskedOf string `json:"asked_of"`
Question string `json:"question"`
Why string `json:"why"`
State string `json:"state"`
Answer string `json:"answer,omitempty"`
AnsweredBy string `json:"answered_by,omitempty"`
AnsweredAt string `json:"answered_at,omitempty"`
Source string `json:"source,omitempty"`
}
type Task struct {
Description string `json:"description,omitempty"`
Deliverables []string `json:"deliverables,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
SuggestedOwner string `json:"suggested_owner,omitempty"`
Questions []TaskQuestion `json:"questions,omitempty"`
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Owner string `json:"owner"`
Summary string `json:"summary"`
Started string `json:"started"` // when work began: date or RFC 3339 time
Finished string `json:"finished"` // when work ended: date or RFC 3339 time
Start string `json:"start"` // planned start (optional)
Due string `json:"due"` // planned end (optional)
Refs []string `json:"refs"`
Steps []Step `json:"steps"`
Review *Review `json:"review"`
}
// Epic groups related tasks inside a milestone.
type Epic struct {
ID string `json:"id"`
Title string `json:"title"`
Goal string `json:"goal"`
Target string `json:"target"`
Tasks []Task `json:"tasks"`
}
// Milestone is a stage of the project: Milestone -> Epic -> Task.
// No is shown as the lane number; Milestone 0 holds the pre-project work.
type Milestone struct {
ID string `json:"id"`
No int `json:"no"`
Title string `json:"title"`
Goal string `json:"goal"`
Target string `json:"target"`
Epics []Epic `json:"epics"`
}
// TestCase is one check, written as a sentence a BA can read.
type TestCase struct {
Name string `json:"name"`
Status string `json:"status"` // pass | fail | skip
Detail string `json:"detail"`
}
// TestGroup collects the checks for one topic and says who it matters to.
type TestGroup struct {
Name string `json:"name"`
Audience string `json:"audience"` // ba | tech | both
Purpose string `json:"purpose"` // why this topic matters, in plain language
Cases []TestCase `json:"cases"`
}
type TestRun struct {
ID string `json:"id"`
Suite string `json:"suite"`
Date string `json:"date"`
Summary string `json:"summary"` // what this run shows, in plain language
Groups []TestGroup `json:"groups"`
Command string `json:"command"`
Passed int `json:"passed"` // raw counts from the test tool (technical)
Failed int `json:"failed"`
Skipped int `json:"skipped"`
Duration string `json:"duration"`
Notes string `json:"notes"`
Failures []string `json:"failures"`
}
// Failing reports whether the tool or any written check failed.
func (r TestRun) Failing() bool {
if r.Failed > 0 {
return true
}
for _, g := range r.Groups {
for _, c := range g.Cases {
if c.Status == "fail" {
return true
}
}
}
return false
}
// Checkpoint is a summary written by the agent (or a person) after a piece of
// work: what was done, what changed, which tasks and files it touched.
type Checkpoint struct {
ID string `json:"id"`
At string `json:"at"` // when the work was finished, RFC 3339 (e.g. 2026-10-30T15:40:00+07:00)
Agent string `json:"agent"`
Title string `json:"title"`
Summary string `json:"summary"`
Changes []string `json:"changes"`
Tasks []string `json:"tasks"` // task ids
Refs []string `json:"refs"` // files or docs touched
Commit string `json:"commit"` // optional git commit for reference
when time.Time
}
// When is the parsed time of At.
func (c Checkpoint) When() time.Time { return c.when }
type Problem struct {
ID string `json:"id"`
Title string `json:"title"`
Date string `json:"date"`
Status string `json:"status"` // open | solved
Symptom string `json:"symptom"`
Cause string `json:"cause"`
Solution string `json:"solution"`
Epic string `json:"epic"` // epic id the problem belongs to
Refs []string `json:"refs"`
}
// Snapshot is one consistent view of all tracker data.
type Snapshot struct {
Milestones []Milestone
Tests []TestRun
Problems []Problem
Checkpoints []Checkpoint // newest first
}
// Store holds the last good snapshot. A broken JSON edit keeps the previous
// data on screen and reports the error instead of blanking the dashboard.
type Store struct {
dir string
writeMu sync.Mutex // serialises writes from the review form
mu sync.RWMutex
snap Snapshot
loadErr error
loadedAt time.Time
}
func NewStore(dir string) *Store {
s := &Store{dir: dir}
s.Reload()
return s
}
func (s *Store) Get() (Snapshot, error, time.Time) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.snap, s.loadErr, s.loadedAt
}
func (s *Store) Reload() {
snap, err := loadSnapshot(s.dir)
s.mu.Lock()
defer s.mu.Unlock()
s.loadErr = err
if err == nil {
s.snap = snap
}
s.loadedAt = time.Now()
}
func loadSnapshot(dir string) (Snapshot, error) {
var snap Snapshot
if err := readJSON(filepath.Join(dir, "milestones.json"), &snap.Milestones, true); err != nil {
return snap, err
}
if err := readJSON(filepath.Join(dir, "tests.json"), &snap.Tests, false); err != nil {
return snap, err
}
if err := readJSON(filepath.Join(dir, "problems.json"), &snap.Problems, false); err != nil {
return snap, err
}
if err := readJSON(filepath.Join(dir, "checkpoints.json"), &snap.Checkpoints, false); err != nil {
return snap, err
}
if err := prepareCheckpoints(snap.Checkpoints); err != nil {
return snap, err
}
return snap, validate(snap)
}
func readJSON(path string, v any, required bool) error {
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) && !required {
return nil
}
if err != nil {
return fmt.Errorf("อ่านไฟล์ %s ไม่ได้: %w", filepath.Base(path), err)
}
if err := json.Unmarshal(b, v); err != nil {
return fmt.Errorf("ไฟล์ %s มีรูปแบบ JSON ไม่ถูกต้อง: %w", filepath.Base(path), err)
}
return nil
}
func validate(snap Snapshot) error {
seen := map[string]string{} // id -> kind, ids must be unique across milestones, epics and tasks
claim := func(id, kind, where string) error {
if id == "" {
return fmt.Errorf("milestones.json: มี %s ที่ไม่มี id%s", kind, where)
}
if prev, ok := seen[id]; ok {
return fmt.Errorf("milestones.json: id %s ซ้ำ (ใช้แล้วกับ %s)", id, prev)
}
seen[id] = kind
return nil
}
for _, m := range snap.Milestones {
if err := claim(m.ID, "milestone", ""); err != nil {
return err
}
for _, e := range m.Epics {
if err := claim(e.ID, "epic", " ใน "+m.ID); err != nil {
return err
}
for _, t := range e.Tasks {
if err := claim(t.ID, "task", " ใน "+e.ID); err != nil {
return err
}
if !validStatus[t.Status] {
return fmt.Errorf("milestones.json: task %s มี status %q ซึ่งใช้ไม่ได้ (ใช้ todo, in_progress, done, review หรือ reviewed)", t.ID, t.Status)
}
if t.Review != nil && !validReviewState[t.Review.State] {
return fmt.Errorf("milestones.json: task %s มี review.state %q ซึ่งใช้ไม่ได้ (ใช้ todo, rejected, hold หรือ done)", t.ID, t.Review.State)
}
}
}
}
return nil
}
// prepareCheckpoints parses times, checks ids and sorts newest first.
func prepareCheckpoints(cps []Checkpoint) error {
seen := map[string]bool{}
for i := range cps {
c := &cps[i]
if c.ID == "" {
return fmt.Errorf("checkpoints.json: มี checkpoint ที่ไม่มี id")
}
if seen[c.ID] {
return fmt.Errorf("checkpoints.json: id %s ซ้ำ", c.ID)
}
seen[c.ID] = true
t, err := time.Parse(time.RFC3339, c.At)
if err != nil {
if t, err = time.ParseInLocation("2006-01-02", c.At, ict); err != nil {
return fmt.Errorf("checkpoints.json: %s มีเวลา %q ที่อ่านไม่ได้ (ใช้รูปแบบ 2026-10-30T15:40:00+07:00)", c.ID, c.At)
}
}
c.when = t
}
sort.SliceStable(cps, func(i, j int) bool { return cps[i].when.After(cps[j].when) })
return nil
}