ความคืบหน้างาน ไฟล์ต้นฉบับ

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
}