diff --git a/internal/session/session.go b/internal/session/session.go index 93fa3b0..0719f7f 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -166,102 +166,6 @@ type Controller struct { lastNudgedAt time.Time } -// CommitmentView is the UI projection of the active commitment. -type CommitmentView struct { - NextAction string `json:"next_action"` - SuccessCondition string `json:"success_condition"` - TimeboxSecs int64 `json:"timebox_secs"` - DeadlineUnixSecs int64 `json:"deadline_unix_secs"` -} - -// ProposalView / CoachView project the ephemeral planning-coach state. -type ProposalView struct { - NextAction string `json:"next_action"` - SuccessCondition string `json:"success_condition"` - TimeboxSecs int64 `json:"timebox_secs"` - - AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"` -} - -// DriftView is the active-only drift projection. Nudge is a separate axis from -// Status: it is populated precisely when Status is "ontask" (semantic drift -// inside an allowed app), so it cannot be folded into the status enum. -type DriftView struct { - Status string `json:"status"` - Reason string `json:"reason,omitempty"` - Nudge string `json:"nudge,omitempty"` - Enforced bool `json:"enforced,omitempty"` -} - -type CoachView struct { - Status string `json:"status"` - Proposal *ProposalView `json:"proposal,omitempty"` - Error string `json:"error,omitempty"` -} - -// TaskView is one to-do item in the planning tasks list. -type TaskView struct { - ID string `json:"id"` - Title string `json:"title"` - Day string `json:"day,omitempty"` -} - -// TasksView projects the ephemeral planning tasks state (Marvin's today list). -type TasksView struct { - Status string `json:"status"` - Tasks []TaskView `json:"tasks,omitempty"` -} - -// KnowledgeView projects the ephemeral planning knowledge state (the standing -// profile that grounds the coach). The profile text is intentionally omitted — -// only its presence, source path, and size cross the wire. -type KnowledgeView struct { - Status string `json:"status"` - Path string `json:"path,omitempty"` - Chars int `json:"chars,omitempty"` -} - -// ReflectionView projects the reviewer's output. Recap is shown on Review; -// CarryForward is shown on the next Planning screen. Unlike the knowledge -// profile, these short lines exist to be displayed, so they cross the wire. -type ReflectionView struct { - Status string `json:"status,omitempty"` - Recap string `json:"recap,omitempty"` - CarryForward string `json:"carry_forward,omitempty"` -} - -// WindowView / BucketView / EvidenceView are the evidence projection. -type WindowView struct { - Class string `json:"class"` - Title string `json:"title"` -} - -type BucketView struct { - Class string `json:"class"` - Title string `json:"title"` - Seconds int64 `json:"seconds"` -} - -type EvidenceView struct { - Available bool `json:"available"` - Reason string `json:"reason"` - Current WindowView `json:"current"` - SwitchCount int `json:"switch_count"` - Buckets []BucketView `json:"buckets"` -} - -// State is the broadcastable view of the controller. -type State struct { - RuntimeState domain.RuntimeState `json:"runtime_state"` - Commitment *CommitmentView `json:"commitment"` - Evidence *EvidenceView `json:"evidence"` - Coach *CoachView `json:"coach,omitempty"` - Tasks *TasksView `json:"tasks,omitempty"` - Knowledge *KnowledgeView `json:"knowledge,omitempty"` - Reflection *ReflectionView `json:"reflection,omitempty"` - Drift *DriftView `json:"drift,omitempty"` -} - // New loads any persisted snapshot, prunes stale session logs, and rebuilds // in-memory stats from the raw log if a live session was interrupted. func New(snapshotPath string) (*Controller, error) { @@ -340,92 +244,6 @@ func (c *Controller) Deadline() time.Time { return c.deadline } -func (c *Controller) stateLocked() State { - st := State{RuntimeState: c.runtimeState} - if c.commitment != nil { - view := &CommitmentView{ - NextAction: c.commitment.NextAction, - SuccessCondition: c.commitment.SuccessCondition, - TimeboxSecs: c.commitment.TimeboxSecs, - } - if !c.deadline.IsZero() { - view.DeadlineUnixSecs = c.deadline.Unix() - } - st.Commitment = view - } - if c.stats != nil { - st.Evidence = &EvidenceView{ - Available: c.stats.Current.Health.Available, - Reason: c.stats.Current.Health.Reason, - Current: WindowView{Class: c.stats.Current.Class, Title: c.stats.Current.Title}, - SwitchCount: c.stats.SwitchCount, - Buckets: bucketViews(c.stats.Buckets), - } - } - if c.runtimeState == domain.RuntimePlanning { - status := c.coachStatus - if status == "" { - status = coachIdle - } - cv := &CoachView{Status: status, Error: c.coachErr} - if c.coachProposal != nil { - cv.Proposal = &ProposalView{ - NextAction: c.coachProposal.NextAction, - SuccessCondition: c.coachProposal.SuccessCondition, - TimeboxSecs: c.coachProposal.TimeboxSecs, - AllowedWindowClasses: c.coachProposal.AllowedWindowClasses, - } - } - st.Coach = cv - if c.tasksProvider != nil { - tstatus := c.tasksStatus - if tstatus == "" { - tstatus = tasksIdle - } - tv := &TasksView{Status: tstatus} - for _, t := range c.tasksList { - tv.Tasks = append(tv.Tasks, TaskView{ID: t.ID, Title: t.Title, Day: t.Day}) - } - st.Tasks = tv - } - if c.knowledgeSrc != nil { - kstatus := c.knowledgeStatus - if kstatus == "" { - kstatus = knowledgeIdle - } - st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars} - } - if c.carryForward != "" { - st.Reflection = &ReflectionView{CarryForward: c.carryForward} - } - } - if c.runtimeState == domain.RuntimeReview { - rstatus := c.reflectionStatus - if rstatus == "" { - rstatus = reflectionIdle - } - st.Reflection = &ReflectionView{Status: rstatus, Recap: c.reflectionRecap} - } - if c.runtimeState == domain.RuntimeActive { - status := c.driftStatus - if status == "" { - status = driftIdle - } - enforced := c.enforcementLevel == domain.EnforcementBlock && c.driftStatus == driftDrifting - st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage, Enforced: enforced} - } - return st -} - -func bucketViews(buckets map[bucketKey]time.Duration) []BucketView { - out := make([]BucketView, 0, len(buckets)) - for k, d := range buckets { - out = append(out, BucketView{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())}) - } - sort.Slice(out, func(i, j int) bool { return out[i].Seconds > out[j].Seconds }) - return out -} - func (c *Controller) persistLocked() error { snap := store.Snapshot{ RuntimeState: c.runtimeState, diff --git a/internal/session/views.go b/internal/session/views.go new file mode 100644 index 0000000..3fe9e6d --- /dev/null +++ b/internal/session/views.go @@ -0,0 +1,189 @@ +package session + +import ( + "antidrift/internal/domain" + "sort" + "time" +) + +// CommitmentView is the UI projection of the active commitment. +type CommitmentView struct { + NextAction string `json:"next_action"` + SuccessCondition string `json:"success_condition"` + TimeboxSecs int64 `json:"timebox_secs"` + DeadlineUnixSecs int64 `json:"deadline_unix_secs"` +} + +// ProposalView / CoachView project the ephemeral planning-coach state. +type ProposalView struct { + NextAction string `json:"next_action"` + SuccessCondition string `json:"success_condition"` + TimeboxSecs int64 `json:"timebox_secs"` + + AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"` +} + +// DriftView is the active-only drift projection. Nudge is a separate axis from +// Status: it is populated precisely when Status is "ontask" (semantic drift +// inside an allowed app), so it cannot be folded into the status enum. +type DriftView struct { + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Nudge string `json:"nudge,omitempty"` + Enforced bool `json:"enforced,omitempty"` +} + +type CoachView struct { + Status string `json:"status"` + Proposal *ProposalView `json:"proposal,omitempty"` + Error string `json:"error,omitempty"` +} + +// TaskView is one to-do item in the planning tasks list. +type TaskView struct { + ID string `json:"id"` + Title string `json:"title"` + Day string `json:"day,omitempty"` +} + +// TasksView projects the ephemeral planning tasks state (Marvin's today list). +type TasksView struct { + Status string `json:"status"` + Tasks []TaskView `json:"tasks,omitempty"` +} + +// KnowledgeView projects the ephemeral planning knowledge state (the standing +// profile that grounds the coach). The profile text is intentionally omitted — +// only its presence, source path, and size cross the wire. +type KnowledgeView struct { + Status string `json:"status"` + Path string `json:"path,omitempty"` + Chars int `json:"chars,omitempty"` +} + +// ReflectionView projects the reviewer's output. Recap is shown on Review; +// CarryForward is shown on the next Planning screen. Unlike the knowledge +// profile, these short lines exist to be displayed, so they cross the wire. +type ReflectionView struct { + Status string `json:"status,omitempty"` + Recap string `json:"recap,omitempty"` + CarryForward string `json:"carry_forward,omitempty"` +} + +// WindowView / BucketView / EvidenceView are the evidence projection. +type WindowView struct { + Class string `json:"class"` + Title string `json:"title"` +} + +type BucketView struct { + Class string `json:"class"` + Title string `json:"title"` + Seconds int64 `json:"seconds"` +} + +type EvidenceView struct { + Available bool `json:"available"` + Reason string `json:"reason"` + Current WindowView `json:"current"` + SwitchCount int `json:"switch_count"` + Buckets []BucketView `json:"buckets"` +} + +// State is the broadcastable view of the controller. +type State struct { + RuntimeState domain.RuntimeState `json:"runtime_state"` + Commitment *CommitmentView `json:"commitment"` + Evidence *EvidenceView `json:"evidence"` + Coach *CoachView `json:"coach,omitempty"` + Tasks *TasksView `json:"tasks,omitempty"` + Knowledge *KnowledgeView `json:"knowledge,omitempty"` + Reflection *ReflectionView `json:"reflection,omitempty"` + Drift *DriftView `json:"drift,omitempty"` +} + +func (c *Controller) stateLocked() State { + st := State{RuntimeState: c.runtimeState} + if c.commitment != nil { + view := &CommitmentView{ + NextAction: c.commitment.NextAction, + SuccessCondition: c.commitment.SuccessCondition, + TimeboxSecs: c.commitment.TimeboxSecs, + } + if !c.deadline.IsZero() { + view.DeadlineUnixSecs = c.deadline.Unix() + } + st.Commitment = view + } + if c.stats != nil { + st.Evidence = &EvidenceView{ + Available: c.stats.Current.Health.Available, + Reason: c.stats.Current.Health.Reason, + Current: WindowView{Class: c.stats.Current.Class, Title: c.stats.Current.Title}, + SwitchCount: c.stats.SwitchCount, + Buckets: bucketViews(c.stats.Buckets), + } + } + if c.runtimeState == domain.RuntimePlanning { + status := c.coachStatus + if status == "" { + status = coachIdle + } + cv := &CoachView{Status: status, Error: c.coachErr} + if c.coachProposal != nil { + cv.Proposal = &ProposalView{ + NextAction: c.coachProposal.NextAction, + SuccessCondition: c.coachProposal.SuccessCondition, + TimeboxSecs: c.coachProposal.TimeboxSecs, + AllowedWindowClasses: c.coachProposal.AllowedWindowClasses, + } + } + st.Coach = cv + if c.tasksProvider != nil { + tstatus := c.tasksStatus + if tstatus == "" { + tstatus = tasksIdle + } + tv := &TasksView{Status: tstatus} + for _, t := range c.tasksList { + tv.Tasks = append(tv.Tasks, TaskView{ID: t.ID, Title: t.Title, Day: t.Day}) + } + st.Tasks = tv + } + if c.knowledgeSrc != nil { + kstatus := c.knowledgeStatus + if kstatus == "" { + kstatus = knowledgeIdle + } + st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars} + } + if c.carryForward != "" { + st.Reflection = &ReflectionView{CarryForward: c.carryForward} + } + } + if c.runtimeState == domain.RuntimeReview { + rstatus := c.reflectionStatus + if rstatus == "" { + rstatus = reflectionIdle + } + st.Reflection = &ReflectionView{Status: rstatus, Recap: c.reflectionRecap} + } + if c.runtimeState == domain.RuntimeActive { + status := c.driftStatus + if status == "" { + status = driftIdle + } + enforced := c.enforcementLevel == domain.EnforcementBlock && c.driftStatus == driftDrifting + st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage, Enforced: enforced} + } + return st +} + +func bucketViews(buckets map[bucketKey]time.Duration) []BucketView { + out := make([]BucketView, 0, len(buckets)) + for k, d := range buckets { + out = append(out, BucketView{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Seconds > out[j].Seconds }) + return out +}