From 63b1924db9a4a6d37ed0cd5e768ed40fb0013a33 Mon Sep 17 00:00:00 2001 From: Daniel Netzer Date: Tue, 27 Oct 2026 21:11:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Insights=20by=20model=20=E2=80=94?= =?UTF-8?q?=20cost,=20tokens,=20time,=20first-try=20green=20per=20LLM=20an?= =?UTF-8?q?d=20Atom=20role,=20weekly=20chart;=20Work=20view=20fixes=20(#26?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lab/internal/ui/analytics.go | 34 +- lab/internal/ui/analytics_models.go | 873 ++++++++++++++++++ lab/internal/ui/analytics_models_test.go | 611 ++++++++++++ .../ui/insights_work_contract_test.go | 199 ++++ lab/internal/ui/projection.go | 3 + lab/internal/ui/web/css/insights.css | 99 +- lab/internal/ui/web/css/work.css | 21 +- lab/internal/ui/web/index.html | 15 +- lab/internal/ui/web/js/core.js | 21 + lab/internal/ui/web/js/insights.js | 281 +++++- lab/internal/ui/web/js/work.js | 22 + 11 files changed, 2111 insertions(+), 68 deletions(-) create mode 100644 lab/internal/ui/analytics_models.go create mode 100644 lab/internal/ui/analytics_models_test.go create mode 100644 lab/internal/ui/insights_work_contract_test.go diff --git a/lab/internal/ui/analytics.go b/lab/internal/ui/analytics.go index 2351755..bbe338d 100644 --- a/lab/internal/ui/analytics.go +++ b/lab/internal/ui/analytics.go @@ -30,6 +30,12 @@ type AnalyticsView struct { Models BreakdownView `json:"models"` Cost CostView `json:"cost"` CostPerMergedPR UnavailableView `json:"costPerMergedPr"` + // The Insights view (#261): additive, built in analytics_models.go. + Summary InsightsSummaryView `json:"summary"` + ByModel ByModelView `json:"byModel"` + Weekly WeeklyView `json:"weekly"` + TimeSplit TimeSplitView `json:"timeSplit"` + Planning PlanningView `json:"planning"` } // DurationView is one time measure summed over the Molecules that recorded it: @@ -118,6 +124,8 @@ type moleculeFacts struct { activeOK bool activeMS int64 attempts []attemptFact + // split is where the Molecule's wall-clock went (#261); see enrichFacts. + split timeSplitFact } // attemptFact is one harness attempt: its group, whether it recorded usage, and @@ -128,6 +136,19 @@ type attemptFact struct { usage bool cost *float64 basis string + // The by-model facts (#261): the usage entry's kind and attempt id, which + // enrichFacts pairs with the ledger's attempt, and what that pairing filled. + kind, id string + role string + firstCheck string + startOK, wallOK bool + startMS, wallMS int64 + tokens harness.TokenCategories + checks string + findingsOK bool + high, medium, low int + questionsOK bool + questions int } // analyzeRecord derives the analytics facts of one Molecule. Every attempt in @@ -168,7 +189,7 @@ func analyzeRecord(lab string, r molecule.Record) *moleculeFacts { } review++ } - a := attemptFact{usage: u.Reported, cost: u.CostUSD, basis: harness.ReviewDisplayText(x.text(u.CostBasis), maxGroupLabel)} + a := attemptFact{usage: u.Reported, cost: reportedCost(u.CostUSD), basis: harness.ReviewDisplayText(x.text(u.CostBasis), maxGroupLabel), kind: u.Kind, id: u.ID} a.harness, a.harnessState = groupLabel(x, binding) switch { case u.Reported && len(u.EffectiveModels) > 0: @@ -183,6 +204,7 @@ func analyzeRecord(lab string, r molecule.Record) *moleculeFacts { } f.attempts = append(f.attempts, a) } + enrichFacts(r, f) return f } @@ -370,7 +392,14 @@ type groupSum struct { // buildAnalytics derives the analytics of one inventory. It only reads inv, which // concurrent requests may share. -func buildAnalytics(inv inventory) AnalyticsView { +func buildAnalytics(inv inventory) AnalyticsView { return buildAnalyticsAt(inv, analyticsNow()) } + +// analyticsNow is the clock that decides "this week"; tests replace it. +var analyticsNow = time.Now + +// buildAnalyticsAt is buildAnalytics with "this week" taken from now, so tests +// need no wall clock. +func buildAnalyticsAt(inv inventory, now time.Time) AnalyticsView { list := inv.list out := AnalyticsView{Sample: list.Sample, ETag: list.ETag, Complete: list.Complete, Truncated: list.Truncated, Diagnostics: list.Diagnostics, Molecules: len(list.Molecules), Counts: list.Counts} if out.Diagnostics == nil { @@ -444,6 +473,7 @@ func buildAnalytics(inv inventory) AnalyticsView { Coverage: fmt.Sprintf("cost reported for %d of %d harness attempts", cost.reported, attempts), Note: "Only costs the harness reported are summed. A missing cost is unknown, never zero."} out.CostPerMergedPR = UnavailableView{State: "unavailable", Reason: "No verified merge attribution exists; a draft publication receipt does not qualify."} + buildInsights(&out, inv, now) return out } diff --git a/lab/internal/ui/analytics_models.go b/lab/internal/ui/analytics_models.go new file mode 100644 index 0000000..b8b1025 --- /dev/null +++ b/lab/internal/ui/analytics_models.go @@ -0,0 +1,873 @@ +package ui + +import ( + "fmt" + "github.com/DanielNetzer/forgecell/lab/internal/harness" + "github.com/DanielNetzer/forgecell/lab/internal/molecule" + "math" + "sort" + "time" +) + +// The Insights view (#261): how each effective model performs in each Atom role, +// a weekly series, where the time goes and the summary tiles. Every value comes +// from this Lab's ledgers through the same inventory as the rest of the +// analytics; a value no ledger records is null, never zero, and each one carries +// its coverage. + +// Atom roles of the by-model table. Meta and Formula-improvement Atoms record no +// usage in Molecule ledgers, so the meta role is reported as unavailable. +const ( + roleAnalysis = "analysis" + roleCoding = "coding" + roleReview = "review" +) + +// Check outcomes of a coding attempt's captured tree. +const ( + checksPassed = "passed" + checksFailed = "failed" +) + +const ( + // maxModelRoleRows bounds the by-model table; attempts past it are counted, not merged. + maxModelRoleRows = 64 + // maxMedianSamples bounds the values one row keeps for a median; past it the + // median is unavailable rather than taken from a sample. + maxMedianSamples = 10000 + // maxWeeks bounds the weekly series to the most recent weeks with data. + maxWeeks = 12 + // maxSeries bounds the stacked models; the rest are summed as Other models. + maxSeries = 6 + weekMS = 7 * 24 * int64(time.Hour/time.Millisecond) +) + +// otherModels is the series label of models folded past maxSeries. +const otherModels = "Other models" + +// noCodingAttempt is the series label of a finished Molecule that had no coding +// attempt, kept apart from Not recorded, which is a model the ledger left out. +const noCodingAttempt = "No coding attempt" + +// maxFutureMS is how far past now a recorded time may lie and still be placed in +// a week; a later one is malformed and counted as undated, so it can neither move +// the chart off the current week nor count toward this week. +const maxFutureMS = 24 * int64(time.Hour/time.Millisecond) + +// timeSplitFact is one Molecule's wall-clock split into active Atom time, waiting +// on checks and waiting for you. Verification intervals are carved out of the +// merged Atom intervals, so the three parts sum to the wall-clock. +type timeSplitFact struct { + ok bool + active, checks, waiting int64 +} + +// ModelRoleView is one effective model in one Atom role. A nil value was not +// reported by any of the row's attempts; the *Reported counts are its coverage. +type ModelRoleView struct { + Role string `json:"role"` + Model string `json:"model"` + ModelState string `json:"modelState"` + Attempts int `json:"attempts"` + Molecules int `json:"molecules"` + CostReported int `json:"costReported"` + CostUSD *float64 `json:"costUsd"` + CostNotFinite bool `json:"costNotFinite,omitempty"` + MedianCostUSD *float64 `json:"medianCostUsd"` + UsageReported int `json:"usageReported"` + TokensIn *int64 `json:"tokensIn"` + TokensOut *int64 `json:"tokensOut"` + TokensCache *int64 `json:"tokensCacheRead"` + TokenOverflow bool `json:"tokenOverflow,omitempty"` + WallReported int `json:"wallReported"` + MedianWallMS *int64 `json:"medianWallMs"` + // MedianBounded is set when a row held more values than maxMedianSamples. + MedianBounded bool `json:"medianBounded,omitempty"` + // Coding: attempts whose captured tree was checked, and those that passed. + ChecksRan int `json:"checksRan"` + ChecksPassed int `json:"checksPassed"` + PassRate *float64 `json:"passRate"` + // Review: passes that recorded findings, and the findings raised. + ReviewsReported int `json:"reviewsReported"` + Findings *FindingsView `json:"findings"` + // Analysis: attempts that recorded a response, their clarifying questions, and + // the questions per Molecule over the QuestionMolecules that recorded one. + QuestionsReported int `json:"questionsReported"` + Questions *int `json:"questions"` + QuestionMolecules int `json:"questionMolecules"` + QuestionsPerMolecule *float64 `json:"questionsPerMolecule"` +} + +// FindingsView is the review findings raised, by severity. +type FindingsView struct { + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` +} + +// ByModelView holds every attempt exactly once: the rows' attempts plus +// Ungrouped equal Attempts. +type ByModelView struct { + Attempts int `json:"attempts"` + Rows []ModelRoleView `json:"rows"` + Truncated bool `json:"truncated"` + Ungrouped int `json:"ungrouped"` + Meta UnavailableView `json:"meta"` + Note string `json:"note"` +} + +// WeekView is one week (Monday, UTC) of the weekly series. Cost and Finished are +// aligned with WeeklyView.Series; a nil cost had no reported cost that week. +type WeekView struct { + Start string `json:"start"` + Cost []*float64 `json:"cost"` + CostTotal *float64 `json:"costTotal"` + CostReported int `json:"costReported"` + CostNotFinite bool `json:"costNotFinite,omitempty"` + Finished []int `json:"finished"` + FinishedTotal int `json:"finishedTotal"` +} + +// WeeklyView is reported cost (by attempt start) and Molecules finished (by +// finish time) per week, stacked by model. Truncated marks weeks left out past +// maxWeeks and Folded models summed as Other models; Undated counts costs and +// finished Molecules with no readable time, which no week shows. +type WeeklyView struct { + Series []string `json:"series"` + Weeks []WeekView `json:"weeks"` + Truncated bool `json:"truncated"` + Folded bool `json:"folded"` + UndatedCost int `json:"undatedCost"` + UndatedFinished int `json:"undatedFinished"` + Note string `json:"note"` +} + +// TimeSplitView is where the Molecules' wall-clock went. The three measures share +// one coverage: the Molecules whose wall-clock, Atom and check times all parse. +type TimeSplitView struct { + Active DurationView `json:"active"` + WaitingForYou DurationView `json:"waitingForYou"` + WaitingOnChecks DurationView `json:"waitingOnChecks"` + Note string `json:"note"` +} + +// CostTileView is a reported cost with its coverage: Reported of Attempts. +type CostTileView struct { + TotalUSD *float64 `json:"totalUsd"` + NotFinite bool `json:"notFinite,omitempty"` + Reported int `json:"reported"` + Attempts int `json:"attempts"` +} + +// RateView is Count of Of, with Value their share; Value is null when Of is 0. +// Molecules is how many Molecules the rate could have covered; Unchecked of them +// are left out of Of because the measure was not recorded for them. +type RateView struct { + Value *float64 `json:"value"` + Count int `json:"count"` + Of int `json:"of"` + Molecules int `json:"molecules"` + Unchecked int `json:"unchecked"` +} + +// InsightsSummaryView is the four summary tiles. +type InsightsSummaryView struct { + WeekStart string `json:"weekStart"` + CostThisWeek CostTileView `json:"costThisWeek"` + CostTotal CostTileView `json:"costTotal"` + MoleculesFinished int `json:"moleculesFinished"` + TimeToDraftPR UnavailableView `json:"timeToDraftPr"` + FirstTryGreen RateView `json:"firstTryGreen"` +} + +// PlanningView is planning effort: clarifying questions per Molecule and the +// analysis cost. QuestionsPerMolecule is Questions over the Molecules (of Of +// with an analysis attempt) that recorded an analysis response. +type PlanningView struct { + Questions int `json:"questions"` + Molecules int `json:"molecules"` + Of int `json:"of"` + QuestionsPerMolecule *float64 `json:"questionsPerMolecule"` + Cost CostTileView `json:"cost"` +} + +// reportedCost is a usage line's cost as analytics counts it. A negative cost is +// malformed and is rejected here, at ingest, so it reads as not reported in both +// the coverage and every total. +func reportedCost(c *float64) *float64 { + if c == nil || *c < 0 { + return nil + } + v := *c + return &v +} + +// attemptIndex pairs a usage entry of one kind with the ledger attempt it +// describes: by its attempt id when exactly one attempt of that kind carries it, +// otherwise by its ordinal among the entries of that kind. Never by position in +// the whole list, so a missing or extra attempt of one kind shifts no other kind. +type attemptIndex struct { + byID map[string]int + ordinal int + n int +} + +func newAttemptIndex(ids []string) *attemptIndex { + x := &attemptIndex{byID: map[string]int{}, n: len(ids)} + for i, id := range ids { + if _, dup := x.byID[id]; dup { + x.byID[id] = -1 + continue + } + x.byID[id] = i + } + return x +} + +// next is the index of the attempt the next entry of this kind, with id, describes. +func (x *attemptIndex) next(id string) (int, bool) { + k := x.ordinal + x.ordinal++ + if i, ok := x.byID[id]; ok && i >= 0 && id != "" { + return i, true + } + return k, k < x.n +} + +// enrichFacts adds the by-model facts of r to the attempts analyzeRecord derived, +// pairing each with its ledger attempt by kind and attempt id. An attempt of an +// unknown kind, or one with no ledger attempt to pair with, keeps no role, so it +// is counted as ungrouped rather than merged into a row. +func enrichFacts(r molecule.Record, f *moleculeFacts) { + f.split = splitTime(r, f) + trees := checkedTrees(r.VerificationAttempts) + actions := map[string]molecule.AtomAction{} + for _, a := range r.Atoms { + if a.Provenance == nil { + continue + } + for _, act := range a.Provenance.Actions { + if _, seen := actions[act.AttemptID]; !seen && act.AttemptID != "" { + actions[act.AttemptID] = act + } + } + } + ids := func(n int, id func(int) string) []string { + out := make([]string, n) + for i := range out { + out[i] = id(i) + } + return out + } + analysis := newAttemptIndex(ids(len(r.AnalysisAttempts), func(i int) string { return fmt.Sprintf("analysis-%d", r.AnalysisAttempts[i].Ordinal) })) + coding := newAttemptIndex(ids(len(r.HarnessAttempts), func(i int) string { return r.HarnessAttempts[i].ID })) + review := newAttemptIndex(ids(len(r.ReviewAttempts), func(i int) string { return fmt.Sprintf("review-%d", r.ReviewAttempts[i].Ordinal) })) + for k := range f.attempts { + x := &f.attempts[k] + switch x.kind { + case roleAnalysis: + i, ok := analysis.next(x.id) + if !ok { + continue + } + a := r.AnalysisAttempts[i] + x.role = roleAnalysis + x.startMS, x.startOK = parseTime(a.StartedAt) + x.wallMS, x.wallOK = interval(a.StartedAt, a.FinishedAt) + if !x.wallOK && a.Diagnostics != nil && a.Diagnostics.ElapsedMS > 0 { + x.wallMS, x.wallOK = a.Diagnostics.ElapsedMS, true + } + usageFacts(x, a.Usage) + if a.Response != nil { + x.questionsOK, x.questions = true, len(a.Response.Questions) + } + case roleCoding: + i, ok := coding.next(x.id) + if !ok { + continue + } + a := r.HarnessAttempts[i] + x.role = roleCoding + act, ok := actions[a.ID] + if ok { + x.startMS, x.startOK = parseTime(act.StartedAt) + } + switch { + case a.Result.ElapsedMS > 0: + x.wallMS, x.wallOK = a.Result.ElapsedMS, true + case ok && act.Observed != nil: + x.wallMS, x.wallOK = interval(act.StartedAt, act.Observed.FinishedAt) + } + usageFacts(x, a.Usage) + if a.Capture != nil && a.Capture.Tree != "" { + t := trees[a.Capture.Tree] + x.checks, x.firstCheck = t.any, t.first + } + case roleReview: + i, ok := review.next(x.id) + if !ok { + continue + } + a := r.ReviewAttempts[i] + x.role = roleReview + x.startMS, x.startOK = parseTime(a.StartedAt) + x.wallMS, x.wallOK = interval(a.StartedAt, a.FinishedAt) + if !x.wallOK && a.Diagnostics != nil && a.Diagnostics.ElapsedMS > 0 { + x.wallMS, x.wallOK = a.Diagnostics.ElapsedMS, true + } + usageFacts(x, a.Usage) + if (a.Status == molecule.ReviewNoHighFindings || a.Status == molecule.ReviewBlocked) && a.High >= 0 && a.Medium >= 0 && a.Low >= 0 { + x.findingsOK, x.high, x.medium, x.low = true, a.High, a.Medium, a.Low + } + } + } +} + +// usageFacts copies an attempt's token totals, and its reported duration when no +// recorded interval gave one. Negative values are malformed and read as not +// reported. +func usageFacts(x *attemptFact, u *harness.Usage) { + if u == nil { + return + } + x.tokens = harness.TokenCategories{Input: nonNegative(u.Total.Input), Output: nonNegative(u.Total.Output), CacheRead: nonNegative(u.Total.CacheRead)} + if !x.wallOK && u.DurationMS != nil && *u.DurationMS >= 0 { + x.wallMS, x.wallOK = *u.DurationMS, true + } +} + +func nonNegative(v *int64) *int64 { + if v == nil || *v < 0 { + return nil + } + c := *v + return &c +} + +// treeChecks is what the verification attempts on one source tree concluded. any +// is passed when any attempt passed, or failed when attempts finished and none +// passed. first is the outcome of the earliest attempt alone: passed or failed as +// it concluded, failed when it did not conclude but the tree was checked again, +// and "" when it is the only attempt and has not concluded. +type treeChecks struct { + any, first string +} + +// checkedTrees maps each verified source tree to its treeChecks. The earliest +// attempt is the one with the earliest start when the starts compared are +// readable; ties and unreadable starts keep ledger order, the order attempts are +// appended in. +func checkedTrees(attempts []molecule.VerificationAttempt) map[string]treeChecks { + out := map[string]treeChecks{} + type earliest struct { + status string + startOK bool + start int64 + runs int + } + firsts := map[string]*earliest{} + for _, v := range attempts { + if v.SourceTree == "" { + continue + } + t := out[v.SourceTree] + switch v.Status { + case checksPassed: + t.any = checksPassed + case checksFailed: + if t.any == "" { + t.any = checksFailed + } + } + out[v.SourceTree] = t + start, ok := parseTime(v.StartedAt) + e := firsts[v.SourceTree] + switch { + case e == nil: + firsts[v.SourceTree] = &earliest{status: v.Status, startOK: ok, start: start, runs: 1} + continue + case ok && e.startOK && start < e.start: + e.status, e.start = v.Status, start + } + e.runs++ + } + for tree, e := range firsts { + t := out[tree] + switch { + case e.status == checksPassed || e.status == checksFailed: + t.first = e.status + case e.runs > 1: + t.first = checksFailed + } + out[tree] = t + } + return out +} + +// interval is the length of a recorded [start, end] interval. +func interval(start, end string) (int64, bool) { + s, okS := parseTime(start) + e, okE := parseTime(end) + if !okS || !okE || e < s { + return 0, false + } + return e - s, true +} + +// splitTime splits a finished Molecule's wall-clock. Every Atom that ran and every +// verification attempt must have a readable interval inside the wall-clock, or the +// split is not recorded rather than understated. +func splitTime(r molecule.Record, f *moleculeFacts) timeSplitFact { + if !f.wallOK { + return timeSplitFact{} + } + var all, checks [][2]int64 + inside := func(start, end string) bool { + s, okS := parseTime(start) + e, okE := parseTime(end) + if !okS || !okE || e < s || s < f.wallStart || e > f.wallEnd { + return false + } + all = append(all, [2]int64{s, e}) + return true + } + for _, a := range r.Atoms { + if a.StartedAt == "" && a.FinishedAt == "" { + continue + } + if !inside(a.StartedAt, a.FinishedAt) { + return timeSplitFact{} + } + } + for _, v := range r.VerificationAttempts { + if !inside(v.StartedAt, v.FinishedAt) { + return timeSplitFact{} + } + checks = append(checks, all[len(all)-1]) + } + if len(all) == 0 { + return timeSplitFact{} + } + busy, ok := unionMS(all) + if !ok { + return timeSplitFact{} + } + var checked int64 + if len(checks) > 0 { + if checked, ok = unionMS(checks); !ok { + return timeSplitFact{} + } + } + wall := f.wallEnd - f.wallStart + if busy > wall || checked > busy { + return timeSplitFact{} + } + return timeSplitFact{ok: true, active: busy - checked, checks: checked, waiting: wall - busy} +} + +// tokenSum is one token category over a row, with overflow detection. +type tokenSum struct { + sum int64 + reported, overflow bool +} + +func (t *tokenSum) add(v *int64) { + if v == nil { + return + } + t.reported = true + if t.overflow || *v < 0 || t.sum > math.MaxInt64-*v { + t.overflow = true + return + } + t.sum += *v +} + +func (t tokenSum) value() *int64 { + if !t.reported || t.overflow { + return nil + } + v := t.sum + return &v +} + +type roleKey struct{ role, model, modelState string } + +type roleSum struct { + key roleKey + attempts, molecules int + last int + usage int + cost costSum + costs []float64 + walls []int64 + bounded bool + in, out, cache tokenSum + checksRan, passed int + reviews int + findings FindingsView + questionsReported int + questions int + qLast, qMolecules int +} + +// add counts a, an attempt of the index-th listed Molecule. +func (s *roleSum) add(a attemptFact, index int) { + s.attempts++ + if s.last != index { + s.last = index + s.molecules++ + } + if a.usage { + s.usage++ + s.in.add(a.tokens.Input) + s.out.add(a.tokens.Output) + s.cache.add(a.tokens.CacheRead) + } + s.cost.add(a.cost, a.basis) + if a.cost != nil && !math.IsNaN(*a.cost) && !math.IsInf(*a.cost, 0) { + if len(s.costs) < maxMedianSamples { + s.costs = append(s.costs, *a.cost) + } else { + s.bounded = true + } + } + if a.wallOK { + if len(s.walls) < maxMedianSamples { + s.walls = append(s.walls, a.wallMS) + } else { + s.bounded = true + } + } + switch a.checks { + case checksPassed: + s.checksRan++ + s.passed++ + case checksFailed: + s.checksRan++ + } + if a.findingsOK { + s.reviews++ + s.findings.High += a.high + s.findings.Medium += a.medium + s.findings.Low += a.low + } + if a.questionsOK { + s.questionsReported++ + s.questions += a.questions + if s.qLast != index { + s.qLast = index + s.qMolecules++ + } + } +} + +func (s *roleSum) view() ModelRoleView { + v := ModelRoleView{Role: s.key.role, Model: s.key.model, ModelState: s.key.modelState, Attempts: s.attempts, Molecules: s.molecules, + CostReported: s.cost.reported, CostUSD: s.cost.total(), CostNotFinite: s.cost.notFinite, UsageReported: s.usage, + TokensIn: s.in.value(), TokensOut: s.out.value(), TokensCache: s.cache.value(), TokenOverflow: s.in.overflow || s.out.overflow || s.cache.overflow, + WallReported: len(s.walls), MedianBounded: s.bounded, ChecksRan: s.checksRan, ChecksPassed: s.passed, ReviewsReported: s.reviews, QuestionsReported: s.questionsReported} + if !s.bounded { + v.MedianCostUSD = medianFloat(s.costs) + v.MedianWallMS = medianInt(s.walls) + } + if s.key.role == roleCoding && s.checksRan > 0 { + rate := float64(s.passed) / float64(s.checksRan) + v.PassRate = &rate + } + if s.key.role == roleReview && s.reviews > 0 { + f := s.findings + v.Findings = &f + } + if s.key.role == roleAnalysis && s.questionsReported > 0 { + q, per := s.questions, float64(s.questions)/float64(s.qMolecules) + v.Questions, v.QuestionMolecules, v.QuestionsPerMolecule = &q, s.qMolecules, &per + } + return v +} + +func medianFloat(values []float64) *float64 { + if len(values) == 0 { + return nil + } + v := append([]float64(nil), values...) + sort.Float64s(v) + m := v[len(v)/2] + if len(v)%2 == 0 { + m = v[len(v)/2-1]/2 + m/2 + } + return &m +} + +func medianInt(values []int64) *int64 { + if len(values) == 0 { + return nil + } + v := append([]int64(nil), values...) + sort.Slice(v, func(i, j int) bool { return v[i] < v[j] }) + m := v[len(v)/2] + if len(v)%2 == 0 { + lo := v[len(v)/2-1] + m = lo + (m-lo)/2 + } + return &m +} + +var roleOrder = map[string]int{roleAnalysis: 0, roleCoding: 1, roleReview: 2} + +// weekStart is the Monday 00:00 UTC that starts the week of ms. +func weekStart(ms int64) int64 { + t := time.UnixMilli(ms).UTC() + day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + back := (int(day.Weekday()) + 6) % 7 + return day.AddDate(0, 0, -back).UnixMilli() +} + +func weekLabel(ms int64) string { return time.UnixMilli(ms).UTC().Format("2006-01-02") } + +// cell is one week and series of the weekly series. +type cell struct { + week int64 + series string +} + +// buildInsights adds the Insights fields to out from inv. now only decides which +// week is "this week". +func buildInsights(out *AnalyticsView, inv inventory, now time.Time) { + list := inv.list + rows := map[roleKey]*roleSum{} + var order []*roleSum + by := ByModelView{Rows: []ModelRoleView{}} + nowMS := now.UnixMilli() + thisWeek := weekStart(nowMS) + dated := func(ms int64, ok bool) bool { return ok && ms <= nowMS+maxFutureMS } + var weekCost, totalCost, planCost costSum + weekAttempts, planAttempts := 0, 0 + first := RateView{} + planning := PlanningView{} + var active, checks, waiting durationSum + costCells := map[cell]*costSum{} + finishedCells := map[cell]int{} + weight := map[string]int{} + weeks := map[int64]bool{} + weekly := WeeklyView{Series: []string{}, Weeks: []WeekView{}} + for n, m := range list.Molecules { + f := inv.facts[m.ID] + if f == nil { + f = &moleculeFacts{} + } + active.add(f.split.active, f.split.ok) + checks.add(f.split.checks, f.split.ok) + waiting.add(f.split.waiting, f.split.ok) + // The first coding attempt is the earliest started one; with an attempt + // whose start is unreadable among several, which ran first is unknown. + var firstCoding *attemptFact + codings, firstKnown := 0, true + analysed, responded, questions := false, false, 0 + lastModel := noCodingAttempt + for k := range f.attempts { + a := f.attempts[k] + by.Attempts++ + totalCost.add(a.cost, a.basis) + if dated(a.startMS, a.startOK) && weekStart(a.startMS) == thisWeek { + weekAttempts++ + weekCost.add(a.cost, a.basis) + } + if a.cost != nil { + if dated(a.startMS, a.startOK) { + w := weekStart(a.startMS) + weeks[w] = true + c := costCells[cell{w, a.model}] + if c == nil { + c = &costSum{} + costCells[cell{w, a.model}] = c + } + c.add(a.cost, a.basis) + weight[a.model]++ + } else { + weekly.UndatedCost++ + } + } + switch a.role { + case roleCoding: + codings++ + firstKnown = firstKnown && a.startOK + if firstCoding == nil || a.startOK && firstCoding.startOK && a.startMS < firstCoding.startMS { + firstCoding = &f.attempts[k] + } + lastModel = a.model + case roleAnalysis: + analysed = true + planAttempts++ + planCost.add(a.cost, a.basis) + if a.questionsOK { + responded = true + questions += a.questions + } + } + if a.role == "" { + // enrichFacts could not place the attempt. + by.Truncated = true + by.Ungrouped++ + continue + } + key := roleKey{a.role, a.model, a.modelState} + s := rows[key] + if s == nil { + if len(order) >= maxModelRoleRows { + by.Truncated = true + by.Ungrouped++ + continue + } + s = &roleSum{key: key, last: -1, qLast: -1} + rows[key] = s + order = append(order, s) + } + s.add(a, n) + } + if codings > 0 { + // Only a Molecule whose first coding attempt was checked is in the + // rate, judged by the earliest check of that attempt's tree; the rest + // are its coverage gap, never a first-try failure. + first.Molecules++ + switch { + case codings > 1 && !firstKnown, firstCoding.firstCheck == "": + first.Unchecked++ + default: + first.Of++ + if firstCoding.firstCheck == checksPassed { + first.Count++ + } + } + } + if analysed { + planning.Of++ + if responded { + planning.Molecules++ + planning.Questions += questions + } + } + if m.Lane == LaneFinished { + if end, ok := parseTime(m.FinishedAt); dated(end, ok) { + w := weekStart(end) + weeks[w] = true + finishedCells[cell{w, lastModel}]++ + weight[lastModel]++ + } else { + weekly.UndatedFinished++ + } + } + } + for _, s := range order { + by.Rows = append(by.Rows, s.view()) + } + sort.SliceStable(by.Rows, func(i, j int) bool { + a, b := by.Rows[i], by.Rows[j] + if a.Role != b.Role { + return roleOrder[a.Role] < roleOrder[b.Role] + } + if a.Attempts != b.Attempts { + return a.Attempts > b.Attempts + } + return a.Model < b.Model + }) + by.Meta = UnavailableView{State: "unavailable", Reason: "Meta and Formula-improvement Atoms record no usage in Molecule ledgers."} + by.Note = "One row per effective model and Atom role. A missing value is not reported, never zero." + out.ByModel = by + + out.Weekly = weeklySeries(weekly, thisWeek, weeks, weight, costCells, finishedCells) + + out.TimeSplit = TimeSplitView{Active: active.result(), WaitingOnChecks: checks.result(), WaitingForYou: waiting.result(), + Note: "Per Molecule, overlapping Atom intervals are merged and check runs are carved out of them; waiting for you is the rest of the wall-clock."} + + if first.Of > 0 { + v := float64(first.Count) / float64(first.Of) + first.Value = &v + } + out.Summary = InsightsSummaryView{WeekStart: weekLabel(thisWeek), + CostThisWeek: CostTileView{TotalUSD: weekCost.total(), NotFinite: weekCost.notFinite, Reported: weekCost.reported, Attempts: weekAttempts}, + CostTotal: CostTileView{TotalUSD: totalCost.total(), NotFinite: totalCost.notFinite, Reported: totalCost.reported, Attempts: by.Attempts}, + MoleculesFinished: list.Counts[LaneFinished], + TimeToDraftPR: UnavailableView{State: "unavailable", Reason: "The delivery receipt records no publication time."}, + FirstTryGreen: first} + if planning.Molecules > 0 { + v := float64(planning.Questions) / float64(planning.Molecules) + planning.QuestionsPerMolecule = &v + } + planning.Cost = CostTileView{TotalUSD: planCost.total(), NotFinite: planCost.notFinite, Reported: planCost.reported, Attempts: planAttempts} + out.Planning = planning +} + +// weeklySeries lays the cells out as consecutive weeks ending at the current week +// (or the next, for a time within maxFutureMS of now), at most maxWeeks of them, +// stacked by the maxSeries heaviest models and Other models. Weeks before the +// window are dropped and flagged as truncated. +func weeklySeries(w WeeklyView, thisWeek int64, weeks map[int64]bool, weight map[string]int, costs map[cell]*costSum, finished map[cell]int) WeeklyView { + w.Note = "Cost is placed in the week its attempt started; a Molecule in the week it finished, under the model of its last coding attempt. A time more than a day after now is counted as undated." + if len(weeks) == 0 { + return w + } + lo, hi := thisWeek, thisWeek + for k := range weeks { + lo, hi = min(lo, k), max(hi, k) + } + if hi-lo >= maxWeeks*weekMS { + w.Truncated = true + lo = hi - (maxWeeks-1)*weekMS + } + models := make([]string, 0, len(weight)) + for m := range weight { + models = append(models, m) + } + sort.Slice(models, func(i, j int) bool { + if weight[models[i]] != weight[models[j]] { + return weight[models[i]] > weight[models[j]] + } + return models[i] < models[j] + }) + slot := map[string]int{} + for i, m := range models { + if i < maxSeries { + slot[m] = i + w.Series = append(w.Series, m) + continue + } + if !w.Folded { + w.Folded = true + w.Series = append(w.Series, otherModels) + } + slot[m] = maxSeries + } + for start := lo; start <= hi; start += weekMS { + wk := WeekView{Start: weekLabel(start), Cost: make([]*float64, len(w.Series)), Finished: make([]int, len(w.Series))} + sums := make([]costSum, len(w.Series)) + var total costSum + for _, m := range models { + if c := costs[cell{start, m}]; c != nil { + merge(&sums[slot[m]], c) + merge(&total, c) + } + n := finished[cell{start, m}] + wk.Finished[slot[m]] += n + wk.FinishedTotal += n + } + for i := range sums { + wk.Cost[i] = sums[i].total() + } + wk.CostTotal, wk.CostReported, wk.CostNotFinite = total.total(), total.reported, total.notFinite + w.Weeks = append(w.Weeks, wk) + } + return w +} + +// merge adds the reported costs of c to s. +func merge(s, c *costSum) { + s.reported += c.reported + s.notFinite = s.notFinite || c.notFinite + next := s.sum + c.sum + if math.IsInf(next, 0) || math.IsNaN(next) { + s.notFinite = true + return + } + s.sum = next +} diff --git a/lab/internal/ui/analytics_models_test.go b/lab/internal/ui/analytics_models_test.go new file mode 100644 index 0000000..7e439e7 --- /dev/null +++ b/lab/internal/ui/analytics_models_test.go @@ -0,0 +1,611 @@ +package ui + +import ( + "encoding/json" + "fmt" + "github.com/DanielNetzer/forgecell/lab/internal/harness" + "github.com/DanielNetzer/forgecell/lab/internal/molecule" + "github.com/DanielNetzer/forgecell/lab/internal/process" + "github.com/DanielNetzer/forgecell/lab/internal/readiness" + "github.com/DanielNetzer/forgecell/lab/internal/verification" + "math" + "net/http" + "path/filepath" + "strings" + "testing" + "time" +) + +// modelUsage is a usage line for model with input, output and cache-read tokens +// and, when cost is not nil, a reported list-price cost. +func modelUsage(model string, cost *float64, in, out, cache int64) *harness.Usage { + return &harness.Usage{Total: harness.TokenCategories{Input: &in, Output: &out, CacheRead: &cache}, EffectiveModels: []string{model}, TotalCostUSD: cost, CostBasis: "list"} +} + +// insightsFixture is a fixture Lab of three Molecules with several models and +// roles, analysed record by record with fixed RFC 3339 times: +// +// - mol-a (Draft published, finished 2026-10-27): an Opus analysis that asked +// two clarifying questions ($0.10), an Opus coding attempt whose tree passed +// ($0.50, 10 min) and a Sonnet review that raised 0 high, 1 medium, 2 low ($0.05); +// - mol-b (blocked, week of 2026-10-19): an Opus analysis with no cost and no +// questions, a Sonnet coding attempt whose tree failed ($1.00, 20 min), its +// Sonnet retry with no usage whose tree passed, and a failed review with no +// usage and no binding model; +// - mol-c (running): nothing recorded. +func insightsFixture(t *testing.T) (string, inventory) { + t.Helper() + lab := t.TempDir() + a := analyticsRecord(lab, "mol-a-1", 1, "completed", "2026-10-27T09:00:00Z", "2026-10-27T11:00:00Z") + a.AnalysisAttempts = []molecule.AnalysisAttempt{{Ordinal: 1, StartedAt: "2026-10-27T09:00:00Z", FinishedAt: "2026-10-27T09:01:00Z", Status: "completed", + Response: &readiness.Analysis{Questions: make([]readiness.Question, 2)}, Usage: modelUsage("claude-opus-5-5", ptr(0.10), 100, 10, 1000)}} + a.Atoms = []molecule.Atom{ + {ID: "intake", Type: "intake", Status: "done", StartedAt: "2026-10-27T09:00:00Z", FinishedAt: "2026-10-27T09:10:00Z"}, + {ID: "code", Type: "harness", Status: "done", StartedAt: "2026-10-27T09:10:00Z", FinishedAt: "2026-10-27T09:40:00Z", Provenance: &molecule.AtomProvenance{Actions: []molecule.AtomAction{ + {AttemptID: "h1", PlannedAction: "edit", StartedAt: "2026-10-27T09:10:00Z", ExecutedBinding: "claude-code"}}}}, + } + a.HarnessAttempts = []molecule.HarnessAttempt{{ID: "h1", PlanDigest: "d", Model: "claude-opus-5-5", Result: process.Result{ElapsedMS: ms(10 * time.Minute)}, + Capture: &verification.CaptureResult{Tree: "tree-a"}, Usage: modelUsage("claude-opus-5-5", ptr(0.50), 300, 30, 3000)}} + a.VerificationAttempts = []molecule.VerificationAttempt{{PlanDigest: "d", SourceTree: "tree-a", StartedAt: "2026-10-27T09:30:00Z", FinishedAt: "2026-10-27T09:40:00Z", Status: "passed"}} + a.ReviewAttempts = []molecule.ReviewAttempt{{Ordinal: 0, HarnessAttemptID: "h1", Binding: "claude-review", StartedAt: "2026-10-27T09:45:00Z", FinishedAt: "2026-10-27T09:47:00Z", + Status: molecule.ReviewNoHighFindings, Medium: 1, Low: 2, Usage: modelUsage("claude-sonnet-5-5", ptr(0.05), 50, 5, 500)}} + + b := analyticsRecord(lab, "mol-b-2", 2, "blocked", "2026-10-20T09:00:00Z", "") + b.AnalysisAttempts = []molecule.AnalysisAttempt{{Ordinal: 1, StartedAt: "2026-10-20T09:00:00Z", FinishedAt: "2026-10-20T09:03:00Z", Status: "completed", + Response: &readiness.Analysis{}, Usage: modelUsage("claude-opus-5-5", nil, 200, 20, 2000)}} + b.Atoms = []molecule.Atom{{ID: "code", Type: "harness", Status: "failed", Provenance: &molecule.AtomProvenance{Actions: []molecule.AtomAction{ + {AttemptID: "h2", PlannedAction: "edit", StartedAt: "2026-10-20T09:10:00Z"}, + {AttemptID: "h3", PlannedAction: "edit", StartedAt: "2026-10-20T10:00:00Z", Observed: &molecule.AtomObservation{FinishedAt: "2026-10-20T10:30:00Z"}}}}}} + b.HarnessAttempts = []molecule.HarnessAttempt{ + {ID: "h2", PlanDigest: "d", Model: "claude-sonnet-5-5", Result: process.Result{ElapsedMS: ms(20 * time.Minute)}, Capture: &verification.CaptureResult{Tree: "tree-b"}, Usage: modelUsage("claude-sonnet-5-5", ptr(1.00), 400, 40, 4000)}, + {ID: "h3", PlanDigest: "d", Model: "claude-sonnet-5-5", Capture: &verification.CaptureResult{Tree: "tree-c"}}, + } + b.VerificationAttempts = []molecule.VerificationAttempt{ + {PlanDigest: "d", SourceTree: "tree-b", StartedAt: "2026-10-20T09:40:00Z", FinishedAt: "2026-10-20T09:50:00Z", Status: "failed"}, + {PlanDigest: "d", SourceTree: "tree-c", StartedAt: "2026-10-20T10:30:00Z", FinishedAt: "2026-10-20T10:40:00Z", Status: "passed"}, + } + b.ReviewAttempts = []molecule.ReviewAttempt{{Ordinal: 0, HarnessAttemptID: "h3", StartedAt: "2026-10-20T10:45:00Z", Status: molecule.ReviewFailed, High: 3}} + + c := analyticsRecord(lab, "mol-c-3", 3, "running", "2026-10-27T12:00:00Z", "") + inv := inventory{facts: map[string]*moleculeFacts{}} + for _, s := range []struct { + r molecule.Record + lane string + }{{a, LaneFinished}, {b, LaneBlocked}, {c, LaneRunning}} { + inv.list.Molecules = append(inv.list.Molecules, Summary{ID: s.r.ID, Lane: s.lane, StartedAt: s.r.StartedAt, FinishedAt: s.r.FinishedAt}) + inv.facts[s.r.ID] = analyzeRecord(lab, s.r) + } + inv.list.Counts = counts(inv.list.Molecules) + return lab, inv +} + +// fixedNow is Wednesday of the week of 2026-10-26. +var fixedNow = time.Date(2026, 10, 28, 15, 0, 0, 0, time.UTC) + +func modelRow(v AnalyticsView, role, model string) (ModelRoleView, bool) { + for _, r := range v.ByModel.Rows { + if r.Role == role && r.Model == model { + return r, true + } + } + return ModelRoleView{}, false +} + +func i64(v int64) *int64 { return &v } + +func eqF(a, b *float64) bool { return (a == nil) == (b == nil) && (a == nil || math.Abs(*a-*b) < 1e-9) } +func eqI(a, b *int64) bool { return (a == nil) == (b == nil) && (a == nil || *a == *b) } + +// TestInsightsByModelRowsAggregate: one row per effective model and Atom role, +// every attempt counted exactly once, cost and tokens summed with their coverage, +// medians per attempt, and the coding, review and analysis outcomes. +func TestInsightsByModelRowsAggregate(t *testing.T) { + _, inv := insightsFixture(t) + v := buildAnalyticsAt(inv, fixedNow) + by := v.ByModel + if by.Attempts != 7 || by.Truncated || by.Ungrouped != 0 || len(by.Rows) != 5 { + t.Fatalf("by model %+v", by) + } + sum := 0 + for _, r := range by.Rows { + sum += r.Attempts + } + if sum != by.Attempts || by.Attempts != v.Cost.Attempts { + t.Fatalf("rows hold %d attempts, total %d, cost %d", sum, by.Attempts, v.Cost.Attempts) + } + // Default order: analysis, coding, review; then attempts, then model. + var order []string + for _, r := range by.Rows { + order = append(order, r.Role+"/"+r.Model) + } + if want := "analysis/claude-opus-5-5 coding/claude-sonnet-5-5 coding/claude-opus-5-5 review/Not recorded review/claude-sonnet-5-5"; strings.Join(order, " ") != want { + t.Errorf("order %q", order) + } + + for _, want := range []struct { + role, model string + attempts, molecules int + costReported, usage int + cost, median *float64 + in, out, cache *int64 + wallReported int + wall *int64 + checksRan, checksPassed int + passRate *float64 + }{ + {roleAnalysis, "claude-opus-5-5", 2, 2, 1, 2, ptr(0.10), ptr(0.10), i64(300), i64(30), i64(3000), 2, i64(ms(2 * time.Minute)), 0, 0, nil}, + {roleCoding, "claude-opus-5-5", 1, 1, 1, 1, ptr(0.50), ptr(0.50), i64(300), i64(30), i64(3000), 1, i64(ms(10 * time.Minute)), 1, 1, ptr(1)}, + {roleCoding, "claude-sonnet-5-5", 2, 1, 1, 1, ptr(1.00), ptr(1.00), i64(400), i64(40), i64(4000), 2, i64(ms(25 * time.Minute)), 2, 1, ptr(0.5)}, + {roleReview, "claude-sonnet-5-5", 1, 1, 1, 1, ptr(0.05), ptr(0.05), i64(50), i64(5), i64(500), 1, i64(ms(2 * time.Minute)), 0, 0, nil}, + {roleReview, "Not recorded", 1, 1, 0, 0, nil, nil, nil, nil, nil, 0, nil, 0, 0, nil}, + } { + r, ok := modelRow(v, want.role, want.model) + if !ok { + t.Errorf("no row %s/%s", want.role, want.model) + continue + } + if r.Attempts != want.attempts || r.Molecules != want.molecules || r.CostReported != want.costReported || r.UsageReported != want.usage || r.WallReported != want.wallReported || r.ChecksRan != want.checksRan || r.ChecksPassed != want.checksPassed { + t.Errorf("%s/%s counts %+v", want.role, want.model, r) + } + if !eqF(r.CostUSD, want.cost) || !eqF(r.MedianCostUSD, want.median) || !eqF(r.PassRate, want.passRate) { + t.Errorf("%s/%s cost %v median %v pass %v", want.role, want.model, r.CostUSD, r.MedianCostUSD, r.PassRate) + } + if !eqI(r.TokensIn, want.in) || !eqI(r.TokensOut, want.out) || !eqI(r.TokensCache, want.cache) || !eqI(r.MedianWallMS, want.wall) { + t.Errorf("%s/%s tokens %v %v %v wall %v", want.role, want.model, r.TokensIn, r.TokensOut, r.TokensCache, r.MedianWallMS) + } + } + if r, _ := modelRow(v, roleReview, "claude-sonnet-5-5"); r.ReviewsReported != 1 || r.Findings == nil || *r.Findings != (FindingsView{High: 0, Medium: 1, Low: 2}) { + t.Errorf("review findings %+v", r) + } + // A failed review raised no findings it could report, whatever it recorded. + if r, _ := modelRow(v, roleReview, "Not recorded"); r.ReviewsReported != 0 || r.Findings != nil { + t.Errorf("failed review %+v", r) + } + if r, _ := modelRow(v, roleAnalysis, "claude-opus-5-5"); r.QuestionsReported != 2 || r.Questions == nil || *r.Questions != 2 || r.QuestionMolecules != 2 || !eqF(r.QuestionsPerMolecule, ptr(1)) { + t.Errorf("analysis questions %+v", r) + } + if v.ByModel.Meta.State != "unavailable" || v.ByModel.Meta.Value != nil || v.ByModel.Meta.Reason == "" { + t.Errorf("meta %+v", v.ByModel.Meta) + } +} + +// TestInsightsSummaryPlanningWeeklyAndTime: the tiles, planning effort, the weekly +// series and the time split reconcile with the fixture. +func TestInsightsSummaryPlanningWeeklyAndTime(t *testing.T) { + _, inv := insightsFixture(t) + v := buildAnalyticsAt(inv, fixedNow) + s := v.Summary + if s.WeekStart != "2026-10-26" || s.MoleculesFinished != 1 { + t.Fatalf("summary %+v", s) + } + // This week: mol-a's three attempts and mol-c's none; mol-b ran the week before. + if w := s.CostThisWeek; w.Reported != 3 || w.Attempts != 3 || !eqF(w.TotalUSD, ptr(0.65)) { + t.Errorf("cost this week %+v", w) + } + if c := s.CostTotal; c.Reported != 4 || c.Attempts != 7 || !eqF(c.TotalUSD, ptr(1.65)) || !eqF(c.TotalUSD, v.Cost.TotalUSD) { + t.Errorf("cost total %+v", c) + } + if d := s.TimeToDraftPR; d.State != "unavailable" || d.Value != nil { + t.Errorf("time to draft PR %+v", d) + } + // mol-a passed on its first attempt, mol-b only on its retry; mol-c never coded. + if f := s.FirstTryGreen; f.Count != 1 || f.Of != 2 || f.Molecules != 2 || !eqF(f.Value, ptr(0.5)) { + t.Errorf("first-try green %+v", f) + } + if p := v.Planning; p.Questions != 2 || p.Molecules != 2 || p.Of != 2 || !eqF(p.QuestionsPerMolecule, ptr(1)) || p.Cost.Reported != 1 || p.Cost.Attempts != 2 || !eqF(p.Cost.TotalUSD, ptr(0.10)) { + t.Errorf("planning %+v", p) + } + + w := v.Weekly + if len(w.Weeks) != 2 || w.Weeks[0].Start != "2026-10-19" || w.Weeks[1].Start != "2026-10-26" || w.Truncated || w.Folded || w.UndatedCost != 0 || w.UndatedFinished != 0 { + t.Fatalf("weekly %+v", w) + } + // Opus: three dated costs and one finished Molecule; Sonnet: two dated costs. + if strings.Join(w.Series, ",") != "claude-opus-5-5,claude-sonnet-5-5" { + t.Fatalf("series %q", w.Series) + } + if wk := w.Weeks[0]; wk.CostReported != 1 || !eqF(wk.CostTotal, ptr(1.00)) || wk.Cost[0] != nil || !eqF(wk.Cost[1], ptr(1.00)) || wk.FinishedTotal != 0 { + t.Errorf("week 1 %+v", wk) + } + if wk := w.Weeks[1]; wk.CostReported != 3 || !eqF(wk.CostTotal, ptr(0.65)) || !eqF(wk.Cost[0], ptr(0.60)) || !eqF(wk.Cost[1], ptr(0.05)) || wk.FinishedTotal != 1 || wk.Finished[0] != 1 { + t.Errorf("week 2 %+v", wk) + } + + // mol-a: 2h wall-clock; Atoms 09:00-09:40 with checks 09:30-09:40 inside. + ts := v.TimeSplit + if ts.Active.Recorded != 1 || ts.Active.Molecules != 3 || !eqI(ts.Active.TotalMS, i64(ms(30*time.Minute))) || !eqI(ts.WaitingOnChecks.TotalMS, i64(ms(10*time.Minute))) || !eqI(ts.WaitingForYou.TotalMS, i64(ms(80*time.Minute))) { + t.Errorf("time split %+v", ts) + } +} + +// TestInsightsMissingValuesAreNullNeverZero: values no ledger records are JSON +// null, with coverage counts that say so, and nothing local leaks. +func TestInsightsMissingValuesAreNullNeverZero(t *testing.T) { + lab, inv := insightsFixture(t) + v := buildAnalyticsAt(inv, fixedNow) + raw, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + var probe struct { + ByModel struct { + Rows []map[string]json.RawMessage `json:"rows"` + } `json:"byModel"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + t.Fatal(err) + } + for _, r := range probe.ByModel.Rows { + if string(r["model"]) != `"Not recorded"` { + continue + } + for _, field := range []string{"costUsd", "medianCostUsd", "tokensIn", "tokensOut", "tokensCacheRead", "medianWallMs", "passRate", "findings", "questions"} { + if string(r[field]) != "null" { + t.Errorf("%s = %s, want null", field, r[field]) + } + } + if string(r["costReported"]) != "0" || string(r["attempts"]) != "1" { + t.Errorf("coverage %s of %s", r["costReported"], r["attempts"]) + } + } + if !strings.Contains(string(raw), `"timeToDraftPr":{"state":"unavailable","value":null`) { + t.Error("time to draft PR is not null") + } + for _, leak := range []string{lab, "workspaces"} { + if strings.Contains(string(raw), leak) { + t.Errorf("insights carry %q", leak) + } + } + + // A Lab with nothing recorded: every new measure is null with 0-of-N coverage. + empty := inventory{facts: map[string]*moleculeFacts{}} + empty.list.Molecules = []Summary{{ID: "m", Lane: LaneRunning}} + e := buildAnalyticsAt(empty, fixedNow) + if e.Summary.CostThisWeek.TotalUSD != nil || e.Summary.CostTotal.TotalUSD != nil || e.Summary.FirstTryGreen.Value != nil || e.Planning.QuestionsPerMolecule != nil || e.Planning.Cost.TotalUSD != nil || + e.TimeSplit.Active.TotalMS != nil || e.TimeSplit.Active.Molecules != 1 || len(e.ByModel.Rows) != 0 || len(e.Weekly.Weeks) != 0 { + t.Fatalf("empty insights %+v", e) + } + raw, _ = json.Marshal(e) + for _, want := range []string{`"rows":[]`, `"weeks":[]`, `"series":[]`, `"value":null`, `"questionsPerMolecule":null`} { + if !strings.Contains(string(raw), want) { + t.Errorf("empty insights lack %s", want) + } + } +} + +// TestInsightsBoundsFailSafe: rows past the bound are counted as ungrouped, token +// sums that overflow and costs that are not finite are null with a flag, weeks +// past the bound are dropped and flagged, models past the series bound are +// folded, and an unreadable time split is not recorded. +func TestInsightsBoundsFailSafe(t *testing.T) { + inv := inventory{facts: map[string]*moleculeFacts{}} + const n = maxModelRoleRows + 5 + huge, big := math.MaxFloat64, int64(math.MaxInt64-1) + for i := 0; i < n; i++ { + id := fmt.Sprintf("m%03d", i) + inv.list.Molecules = append(inv.list.Molecules, Summary{ID: id}) + start := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * 7 * 24 * time.Hour).UnixMilli() + a := attemptFact{harness: "h", harnessState: stateRecorded, model: fmt.Sprintf("model-%02d", i), modelState: stateRecorded, usage: true, cost: &huge, basis: "list", + role: roleCoding, startOK: true, startMS: start, tokens: harness.TokenCategories{Input: &big}} + inv.facts[id] = &moleculeFacts{attempts: []attemptFact{a, a}} + } + v := buildAnalyticsAt(inv, fixedNow) + by := v.ByModel + if len(by.Rows) != maxModelRoleRows || !by.Truncated || by.Ungrouped != 2*5 || by.Attempts != 2*n { + t.Fatalf("rows %d truncated %t ungrouped %d attempts %d", len(by.Rows), by.Truncated, by.Ungrouped, by.Attempts) + } + r := by.Rows[0] + if r.CostUSD != nil || !r.CostNotFinite || r.TokensIn != nil || !r.TokenOverflow || r.CostReported != 2 { + t.Fatalf("row %+v", r) + } + w := v.Weekly + if len(w.Weeks) != maxWeeks || !w.Truncated || !w.Folded || len(w.Series) != maxSeries+1 || w.Series[maxSeries] != otherModels { + t.Fatalf("weekly: %d weeks truncated %t folded %t series %q", len(w.Weeks), w.Truncated, w.Folded, w.Series) + } + for _, wk := range w.Weeks { + if wk.CostTotal != nil || !wk.CostNotFinite || len(wk.Cost) != len(w.Series) || len(wk.Finished) != len(w.Series) { + t.Fatalf("week %+v", wk) + } + } + if v.Summary.CostTotal.TotalUSD != nil || !v.Summary.CostTotal.NotFinite { + t.Fatalf("total %+v", v.Summary.CostTotal) + } + + // An attempt enrichFacts could not place is counted, never merged into a row. + odd := inventory{facts: map[string]*moleculeFacts{"x": {attempts: []attemptFact{{model: "m", modelState: stateRecorded}}}}} + odd.list.Molecules = []Summary{{ID: "x"}} + if o := buildAnalyticsAt(odd, fixedNow).ByModel; o.Attempts != 1 || len(o.Rows) != 0 || !o.Truncated || o.Ungrouped != 1 { + t.Fatalf("unplaced attempt %+v", o) + } + + // A check that never finished, or an Atom outside the wall-clock, leaves the + // split not recorded rather than understated. + lab := t.TempDir() + base := analyticsRecord(lab, "mol-s", 1, "completed", "2026-10-27T09:00:00Z", "2026-10-27T10:00:00Z") + base.Atoms = []molecule.Atom{{ID: "code", StartedAt: "2026-10-27T09:00:00Z", FinishedAt: "2026-10-27T09:30:00Z"}} + if f := analyzeRecord(lab, base); !f.split.ok || f.split.active != ms(30*time.Minute) || f.split.waiting != ms(30*time.Minute) { + t.Fatalf("split %+v", f.split) + } + unfinished := base + unfinished.VerificationAttempts = []molecule.VerificationAttempt{{SourceTree: "t", StartedAt: "2026-10-27T09:10:00Z", Status: "started"}} + outside := base + outside.Atoms = []molecule.Atom{{ID: "code", StartedAt: "2026-10-27T08:00:00Z", FinishedAt: "2026-10-27T09:30:00Z"}} + for name, r := range map[string]molecule.Record{"unfinished check": unfinished, "outside": outside} { + if f := analyzeRecord(lab, r); f.split.ok { + t.Errorf("%s: split recorded %+v", name, f.split) + } + } +} + +// TestInsightsServedAndDemoInventNothing: /api/analytics carries the Insights +// fields from the ledgers with no local path, and sample mode invents no value. +func TestInsightsServedAndDemoInventNothing(t *testing.T) { + lab := t.TempDir() + r := analyticsRecord(lab, "mol-7-g", 7, "completed", "2026-10-24T10:00:00Z", "2026-10-24T11:00:00Z") + r.AnalysisAttempts = []molecule.AnalysisAttempt{{Ordinal: 1, StartedAt: "2026-10-24T10:00:00Z", FinishedAt: "2026-10-24T10:02:00Z", Status: "completed", Usage: modelUsage(filepath.Join(lab, "model"), nil, 1, 2, 3)}} + writeRecord(t, lab, r) + w := do(t, testHandler(t, Options{LabDir: lab}), authed("/api/analytics")) + raw := w.Body.String() + var v AnalyticsView + decode(t, w, &v) + if len(v.ByModel.Rows) != 1 || v.ByModel.Rows[0].Role != roleAnalysis || v.ByModel.Rows[0].CostUSD != nil || v.ByModel.Rows[0].CostReported != 0 || !eqI(v.ByModel.Rows[0].MedianWallMS, i64(ms(2*time.Minute))) { + t.Fatalf("served rows %+v", v.ByModel.Rows) + } + if strings.Contains(raw, lab) || !strings.Contains(v.ByModel.Rows[0].Model, "") { + t.Fatalf("served model label %q", v.ByModel.Rows[0].Model) + } + + var sample AnalyticsView + decode(t, do(t, testHandler(t, Options{Demo: true}), authed("/api/analytics")), &sample) + if !sample.Sample || len(sample.ByModel.Rows) != 0 || sample.ByModel.Attempts != 0 || len(sample.Weekly.Weeks) != 0 || sample.Summary.CostTotal.TotalUSD != nil || + sample.Summary.CostThisWeek.TotalUSD != nil || sample.Summary.FirstTryGreen.Value != nil || sample.TimeSplit.Active.TotalMS != nil || sample.Planning.QuestionsPerMolecule != nil { + t.Fatalf("demo insights invented values: %+v", sample) + } +} + +// inventoryOf analyses records, each in its lane, as one inventory. +func inventoryOf(lab string, lanes []string, records ...molecule.Record) inventory { + inv := inventory{facts: map[string]*moleculeFacts{}} + for i, r := range records { + inv.list.Molecules = append(inv.list.Molecules, Summary{ID: r.ID, Lane: lanes[i], StartedAt: r.StartedAt, FinishedAt: r.FinishedAt}) + inv.facts[r.ID] = analyzeRecord(lab, r) + } + inv.list.Counts = counts(inv.list.Molecules) + return inv +} + +// coded is a record with one coding attempt per {id, start, tree}, listed in the +// given (ledger) order, and the given checks. +func coded(lab, id string, n int64, attempts [][3]string, checks ...molecule.VerificationAttempt) molecule.Record { + r := analyticsRecord(lab, id, n, "blocked", "2026-10-27T08:00:00Z", "") + var actions []molecule.AtomAction + for _, a := range attempts { + actions = append(actions, molecule.AtomAction{AttemptID: a[0], PlannedAction: "edit", StartedAt: a[1]}) + r.HarnessAttempts = append(r.HarnessAttempts, molecule.HarnessAttempt{ID: a[0], PlanDigest: "d", Model: "m", Capture: &verification.CaptureResult{Tree: a[2]}}) + } + r.Atoms = []molecule.Atom{{ID: "code", Type: "harness", Status: "failed", Provenance: &molecule.AtomProvenance{Actions: actions}}} + r.VerificationAttempts = checks + return r +} + +func check(tree, start, status string) molecule.VerificationAttempt { + return molecule.VerificationAttempt{PlanDigest: "d", SourceTree: tree, StartedAt: start, Status: status} +} + +// TestInsightsFirstTryGreenNeedsACheckedFirstAttempt: only a Molecule whose +// first coding attempt (by start time) was checked is in the rate, judged by the +// earliest check of that tree; the others are coverage, never a failure. +func TestInsightsFirstTryGreenNeedsACheckedFirstAttempt(t *testing.T) { + lab := t.TempDir() + // First attempt unchecked, its retry passed: left out of the rate. + unchecked := coded(lab, "mol-u", 1, [][3]string{{"h1", "2026-10-27T09:00:00Z", "t1"}, {"h2", "2026-10-27T10:00:00Z", "t2"}}, check("t2", "2026-10-27T10:30:00Z", "passed")) + // The same tree failed, then passed on a re-run: not a first-try pass. + rerun := coded(lab, "mol-r", 2, [][3]string{{"h1", "2026-10-27T09:00:00Z", "t3"}}, check("t3", "2026-10-27T09:30:00Z", "failed"), check("t3", "2026-10-27T09:50:00Z", "passed")) + // Listed out of start order: the earlier-started attempt passed. + reordered := coded(lab, "mol-o", 3, [][3]string{{"h9", "2026-10-27T11:00:00Z", "t5"}, {"h1", "2026-10-27T09:00:00Z", "t4"}}, check("t4", "2026-10-27T09:30:00Z", "passed"), check("t5", "2026-10-27T11:30:00Z", "failed")) + // Checks listed out of start order: the earliest check of the tree failed. + late := coded(lab, "mol-v", 4, [][3]string{{"h1", "2026-10-27T09:00:00Z", "t6"}}, check("t6", "2026-10-27T10:00:00Z", "passed"), check("t6", "2026-10-27T09:30:00Z", "failed")) + // Two attempts, one with no readable start: which ran first is unknown. + unknown := coded(lab, "mol-k", 5, [][3]string{{"h1", "", "t7"}, {"h2", "2026-10-27T09:00:00Z", "t8"}}, check("t7", "2026-10-27T09:30:00Z", "passed"), check("t8", "2026-10-27T09:30:00Z", "passed")) + lanes := []string{LaneBlocked, LaneBlocked, LaneBlocked, LaneBlocked, LaneBlocked} + v := buildAnalyticsAt(inventoryOf(lab, lanes, unchecked, rerun, reordered, late, unknown), fixedNow) + if f := v.Summary.FirstTryGreen; f.Molecules != 5 || f.Unchecked != 2 || f.Of != 3 || f.Count != 1 || !eqF(f.Value, ptr(1.0/3)) { + t.Fatalf("first-try green %+v", f) + } + // The row still counts a re-run tree that passed as passed on that attempt. + if r, ok := modelRow(v, roleCoding, "m"); !ok || r.ChecksRan != 7 || r.ChecksPassed != 6 { + t.Fatalf("coding row %+v", r) + } + + trees := checkedTrees([]molecule.VerificationAttempt{check("a", "", "started"), check("b", "", "started"), check("b", "", "passed")}) + if trees["a"] != (treeChecks{}) || trees["b"] != (treeChecks{any: checksPassed, first: checksFailed}) { + t.Fatalf("trees %+v", trees) + } +} + +// TestInsightsPairsUsageByAttemptID: a usage entry is paired with its ledger +// attempt by kind and attempt id, never by its position in the whole list. +func TestInsightsPairsUsageByAttemptID(t *testing.T) { + lab := t.TempDir() + r := analyticsRecord(lab, "mol-p", 1, "completed", "2026-10-27T09:00:00Z", "2026-10-27T11:00:00Z") + r.AnalysisAttempts = []molecule.AnalysisAttempt{{Ordinal: 1, StartedAt: "2026-10-27T09:00:00Z", FinishedAt: "2026-10-27T09:04:00Z", Response: &readiness.Analysis{Questions: make([]readiness.Question, 3)}}} + r.HarnessAttempts = []molecule.HarnessAttempt{ + {ID: "h1", Result: process.Result{ElapsedMS: ms(time.Minute)}}, + {ID: "h2", Result: process.Result{ElapsedMS: ms(7 * time.Minute)}}, + } + r.ReviewAttempts = []molecule.ReviewAttempt{{Ordinal: 0, StartedAt: "2026-10-27T10:00:00Z", FinishedAt: "2026-10-27T10:05:00Z", Status: molecule.ReviewBlocked, High: 2}} + f := &moleculeFacts{attempts: []attemptFact{ + {kind: roleReview, id: "review-0"}, + {kind: roleCoding, id: "h2"}, + {kind: "meta", id: "x"}, + {kind: roleCoding, id: "h1"}, + {kind: roleAnalysis, id: "analysis-1"}, + {kind: roleCoding, id: "h-missing"}, + }} + enrichFacts(r, f) + a := f.attempts + if a[0].role != roleReview || !a[0].findingsOK || a[0].high != 2 || a[0].wallMS != ms(5*time.Minute) { + t.Errorf("review %+v", a[0]) + } + if a[1].role != roleCoding || a[1].wallMS != ms(7*time.Minute) || a[3].role != roleCoding || a[3].wallMS != ms(time.Minute) { + t.Errorf("coding %+v %+v", a[1], a[3]) + } + if a[2].role != "" || a[5].role != "" { + t.Errorf("unpaired attempts placed: %+v %+v", a[2], a[5]) + } + if a[4].role != roleAnalysis || !a[4].questionsOK || a[4].questions != 3 || a[4].wallMS != ms(4*time.Minute) { + t.Errorf("analysis %+v", a[4]) + } + + // Duplicate ids fall back to the ordinal within the kind. + dup := r + dup.HarnessAttempts = []molecule.HarnessAttempt{{ID: "h", Result: process.Result{ElapsedMS: 1}}, {ID: "h", Result: process.Result{ElapsedMS: 2}}} + g := &moleculeFacts{attempts: []attemptFact{{kind: roleCoding, id: "h"}, {kind: roleCoding, id: "h"}}} + enrichFacts(dup, g) + if g.attempts[0].wallMS != 1 || g.attempts[1].wallMS != 2 { + t.Errorf("duplicate ids %+v", g.attempts) + } + + // Through analyzeRecord, every attempt is placed under its own kind. + if facts := analyzeRecord(lab, r); len(facts.attempts) != 4 || facts.attempts[0].role != roleAnalysis || facts.attempts[1].role != roleCoding || facts.attempts[3].role != roleReview { + t.Errorf("analysed %+v", facts.attempts) + } +} + +// TestInsightsRejectsNegativeCost: a negative cost is not reported, so the +// coverage and every total agree. +func TestInsightsRejectsNegativeCost(t *testing.T) { + lab := t.TempDir() + r := analyticsRecord(lab, "mol-n", 1, "running", "2026-10-27T09:00:00Z", "") + r.AnalysisAttempts = []molecule.AnalysisAttempt{ + {Ordinal: 1, StartedAt: "2026-10-27T09:00:00Z", Usage: modelUsage("m", ptr(-5), 1, 1, 1)}, + {Ordinal: 2, StartedAt: "2026-10-27T09:10:00Z", Usage: modelUsage("m", ptr(0.25), 1, 1, 1)}, + } + v := buildAnalyticsAt(inventoryOf(lab, []string{LaneRunning}, r), fixedNow) + row, ok := modelRow(v, roleAnalysis, "m") + if !ok || row.CostReported != 1 || !eqF(row.CostUSD, ptr(0.25)) || !eqF(row.MedianCostUSD, ptr(0.25)) || row.UsageReported != 2 { + t.Fatalf("row %+v", row) + } + if c := v.Summary.CostTotal; c.Reported != 1 || c.Attempts != 2 || !eqF(c.TotalUSD, ptr(0.25)) { + t.Errorf("total %+v", c) + } + if c := v.Cost; c.Reported != 1 || !eqF(c.TotalUSD, ptr(0.25)) || c.Coverage != "cost reported for 1 of 2 harness attempts" { + t.Errorf("cost %+v", c) + } + if p := v.Planning.Cost; p.Reported != 1 || !eqF(p.TotalUSD, ptr(0.25)) { + t.Errorf("planning %+v", p) + } +} + +// TestInsightsWeeklyAnchoredOnTheCurrentWeek: a time far in the future is +// undated, so it cannot empty the chart; the series ends at the current week; a +// finished Molecule with no coding attempt has its own label. +func TestInsightsWeeklyAnchoredOnTheCurrentWeek(t *testing.T) { + lab, inv := insightsFixture(t) + future := analyticsRecord(lab, "mol-f-9", 9, "completed", "9999-01-04T09:00:00Z", "9999-01-04T10:00:00Z") + future.AnalysisAttempts = []molecule.AnalysisAttempt{{Ordinal: 1, StartedAt: "9999-01-04T09:00:00Z", FinishedAt: "9999-01-04T09:01:00Z", Usage: modelUsage("claude-opus-5-5", ptr(9), 1, 1, 1)}} + inv.list.Molecules = append(inv.list.Molecules, Summary{ID: future.ID, Lane: LaneFinished, StartedAt: future.StartedAt, FinishedAt: future.FinishedAt}) + inv.facts[future.ID] = analyzeRecord(lab, future) + inv.list.Counts = counts(inv.list.Molecules) + v := buildAnalyticsAt(inv, fixedNow) + w := v.Weekly + if len(w.Weeks) != 2 || w.Weeks[0].Start != "2026-10-19" || w.Weeks[1].Start != "2026-10-26" || w.Truncated || w.UndatedCost != 1 || w.UndatedFinished != 1 { + t.Fatalf("weekly %+v", w) + } + if c := v.Summary.CostThisWeek; c.Reported != 3 || c.Attempts != 3 || !eqF(c.TotalUSD, ptr(0.65)) { + t.Errorf("this week %+v", c) + } + // Still in the all-time total: its cost is reported, only its week is not. + if c := v.Summary.CostTotal; c.Reported != 5 || !eqF(c.TotalUSD, ptr(10.65)) { + t.Errorf("total %+v", c) + } + + // Old data only: the series still ends at the current week, with distinct + // labels for a finished Molecule that never coded and one whose coding + // attempt's model the ledger left out. + old := analyticsRecord(lab, "mol-o-1", 1, "completed", "2026-09-07T09:00:00Z", "2026-09-07T10:00:00Z") + old.AnalysisAttempts = []molecule.AnalysisAttempt{{Ordinal: 1, StartedAt: "2026-09-07T09:00:00Z"}} + unmodelled := analyticsRecord(lab, "mol-o-2", 2, "completed", "2026-09-08T09:00:00Z", "2026-09-08T10:00:00Z") + unmodelled.HarnessAttempts = []molecule.HarnessAttempt{{ID: "h1"}} + // Within a day of now: placed, not undated. + soon := analyticsRecord(lab, "mol-o-3", 3, "completed", "2026-10-28T20:00:00Z", "2026-10-29T03:00:00Z") + o := buildAnalyticsAt(inventoryOf(lab, []string{LaneFinished, LaneFinished, LaneFinished}, old, unmodelled, soon), fixedNow).Weekly + if len(o.Weeks) != 8 || o.Weeks[0].Start != "2026-09-07" || o.Weeks[7].Start != "2026-10-26" || o.UndatedFinished != 0 || o.Weeks[7].FinishedTotal != 1 { + t.Fatalf("old weekly %+v", o) + } + if strings.Join(o.Series, ",") != "No coding attempt,Not recorded" { + t.Fatalf("series %q", o.Series) + } +} + +// TestInsightsServedWeekFollowsTheClock: the served "this week" comes from the +// injected clock, so a request after a week boundary reports the new week. +func TestInsightsServedWeekFollowsTheClock(t *testing.T) { + lab := t.TempDir() + r := analyticsRecord(lab, "mol-w-1", 1, "running", "2026-10-25T23:00:00Z", "") + r.AnalysisAttempts = []molecule.AnalysisAttempt{{Ordinal: 1, StartedAt: "2026-10-25T23:00:00Z", Usage: modelUsage("m", ptr(0.5), 1, 1, 1)}} + writeRecord(t, lab, r) + defer func(c func() time.Time) { analyticsNow = c }(analyticsNow) + h := testHandler(t, Options{LabDir: lab}) + for _, want := range []struct { + now time.Time + week string + cost *float64 + }{ + {time.Date(2026, 10, 25, 23, 59, 0, 0, time.UTC), "2026-10-19", ptr(0.5)}, + {time.Date(2026, 10, 26, 0, 1, 0, 0, time.UTC), "2026-10-26", nil}, + } { + analyticsNow = func() time.Time { return want.now } + var v AnalyticsView + decode(t, do(t, h, authed("/api/analytics")), &v) + if v.Summary.WeekStart != want.week || !eqF(v.Summary.CostThisWeek.TotalUSD, want.cost) { + t.Errorf("at %s: week %s cost %v", want.now, v.Summary.WeekStart, v.Summary.CostThisWeek.TotalUSD) + } + } +} + +// TestInsightsETagTurnsWithTheWeek: the week is part of the ETag, so a poll after +// a week boundary is never answered 304 with last week's tiles, and the list and +// analytics tags still agree on either side of it. +func TestInsightsETagTurnsWithTheWeek(t *testing.T) { + lab := t.TempDir() + r := analyticsRecord(lab, "mol-w-2", 2, "running", "2026-10-25T23:00:00Z", "") + r.AnalysisAttempts = []molecule.AnalysisAttempt{{Ordinal: 1, StartedAt: "2026-10-25T23:00:00Z", Usage: modelUsage("m", ptr(0.5), 1, 1, 1)}} + writeRecord(t, lab, r) + age(t, filepath.Join(lab, "ledgers", r.ID+".json"), time.Hour) + defer func(c func() time.Time) { analyticsNow = c }(analyticsNow) + h := testHandler(t, Options{LabDir: lab}) + tags := func() (string, string) { + t.Helper() + return do(t, h, authed("/api/molecules")).Header().Get("ETag"), do(t, h, authed("/api/analytics")).Header().Get("ETag") + } + + analyticsNow = func() time.Time { return time.Date(2026, 10, 25, 23, 59, 0, 0, time.UTC) } + list, before := tags() + if before == "" || list != before { + t.Fatalf("list %q and analytics %q tags differ", list, before) + } + // Later in the same week the tag holds, so an unchanged poll is still 304. + analyticsNow = func() time.Time { return time.Date(2026, 10, 25, 23, 59, 30, 0, time.UTC) } + cached := authed("/api/analytics") + cached.header = map[string]string{"If-None-Match": before} + if w := do(t, h, cached); w.Code != http.StatusNotModified { + t.Fatalf("same-week poll: %d", w.Code) + } + + // Past the boundary the old tag no longer matches and the new week is served. + analyticsNow = func() time.Time { return time.Date(2026, 10, 26, 0, 1, 0, 0, time.UTC) } + w := do(t, h, cached) + if w.Code != http.StatusOK { + t.Fatalf("poll after the week turned: %d", w.Code) + } + var v AnalyticsView + decode(t, w, &v) + if v.Summary.WeekStart != "2026-10-26" || v.Summary.CostThisWeek.TotalUSD != nil || v.ETag == before { + t.Fatalf("after the boundary: week %s cost %v tag %q", v.Summary.WeekStart, v.Summary.CostThisWeek.TotalUSD, v.ETag) + } + list, after := tags() + if after == before || list != after || v.ETag != after { + t.Fatalf("tags after the boundary: list %q analytics %q body %q (before %q)", list, after, v.ETag, before) + } +} diff --git a/lab/internal/ui/insights_work_contract_test.go b/lab/internal/ui/insights_work_contract_test.go new file mode 100644 index 0000000..0a44da4 --- /dev/null +++ b/lab/internal/ui/insights_work_contract_test.go @@ -0,0 +1,199 @@ +package ui + +import ( + "regexp" + "strings" + "testing" +) + +// served returns one served file's body. +func served(t *testing.T, path string) string { + t.Helper() + w := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed(path)) + if w.Code != 200 { + t.Fatalf("%s: %d", path, w.Code) + } + return w.Body.String() +} + +func wantAll(t *testing.T, where, body string, wants ...string) { + t.Helper() + for _, want := range wants { + if !strings.Contains(body, want) { + t.Errorf("%s lacks %q", where, want) + } + } +} + +func wantNone(t *testing.T, where, body string, gone ...string) { + t.Helper() + for _, g := range gone { + if strings.Contains(body, g) { + t.Errorf("%s still has %q", where, g) + } + } +} + +// TestWorkLaneBarNeverClips is the CSS contract of the lane bar (#261): it starts +// at its left edge, its snap points keep its padding in view, segments never +// shrink, it wraps on narrow screens, and a rebuild restores the scroll and then +// shows the pressed segment whole. +func TestWorkLaneBarNeverClips(t *testing.T) { + css := servedCSS(t) + rules := cssRules(css) + wantDecls(t, rules, ".lane-bar", "justify-content: flex-start;", "scroll-padding-inline: 4px;", "overflow-x: auto;", "min-width: 0;", "max-width: 100%;") + wantDecls(t, rules, ".lane-segment", "flex: 0 0 auto;", "white-space: nowrap;") + for _, bad := range []string{"justify-content: center", "justify-content: flex-end", "justify-content: end", "justify-content: space-", "direction: rtl", "flex-direction: row-reverse"} { + if strings.Contains(rules[".lane-bar"], bad) { + t.Errorf(".lane-bar has %q, which can push its first segment out of view", bad) + } + } + if strings.Contains(rules[".lane-segment"], "flex-shrink: 1") || strings.Contains(rules[".lane-segment"], "flex: 1") { + t.Errorf(".lane-segment may shrink: %s", rules[".lane-segment"]) + } + wantDecls(t, cssRules(mediaBlocks(t, css, "@media (max-width: 720px)")), ".lane-bar", "flex: 1 1 100%;", "flex-wrap: wrap;") + + js := served(t, "/js/work.js") + filters := moduleFunction(t, js, "renderFilters") + wantAll(t, "renderFilters", filters, "const left = bar.scrollLeft;", "keepLaneStart(bar, left);") + if strings.Index(filters, "const left = bar.scrollLeft;") > strings.Index(filters, "fill($('#run-filters')") || strings.Index(filters, "keepLaneStart(bar, left);") < strings.Index(filters, "fill($('#run-filters')") { + t.Error("renderFilters does not read the scroll before the rebuild and restore it after") + } + wantAll(t, "keepLaneStart", moduleFunction(t, js, "keepLaneStart"), + "bar.scrollLeft = left;", "bar.querySelector('[aria-pressed=\"true\"]')", "if (seg.left < box.left + pad) bar.scrollLeft -= box.left + pad - seg.left;", "else if (seg.right > box.right - pad)") +} + +// TestWorkGridSizesToItsContent: the grid has as many rows as it shows cells and +// no fixed height, an empty filter shows a short line instead of the grid, and the +// axis labels span exactly the grid's width. +func TestWorkGridSizesToItsContent(t *testing.T) { + css := servedCSS(t) + rules := cssRules(css) + wantDecls(t, rules, ".cubes", "grid-template-rows: repeat(var(--rows, 7), 26px);", "width: max-content;") + wantDecls(t, cssRules(mediaBlocks(t, css, "@media (max-width: 720px)")), ".cubes", "grid-template-rows: repeat(var(--rows, 7), 24px);") + if strings.Contains(rules[".cubes"], "min-height") || strings.Contains(rules[".cubes"], "height:") { + t.Errorf(".cubes keeps a fixed height: %s", rules[".cubes"]) + } + wantDecls(t, rules, ".cubes:has(+ .filter-empty:not([hidden]))", "display: none;") + wantDecls(t, rules, ".culture-scroll", "display: grid;", "grid-template-columns: max-content;", "justify-content: start;", "overflow-x: auto;") + wantDecls(t, rules, ".grid-axis", "width: 0;", "min-width: 100%;", "justify-content: space-between;") + + apply := moduleFunction(t, served(t, "/js/work.js"), "applyFilter") + wantAll(t, "applyFilter", apply, + "$('#cubes').style.setProperty('--rows', String(Math.max(1, Math.min(7, shown.length))));", + "$('.grid-axis').hidden = Math.ceil(shown.length / 7) < AXIS_COLUMNS;", + "empty.hidden = shown.length > 0;", + "'No Molecules in this lane.'") + page := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/")).Body.String() + scroll := between(t, page, `
\n
") + if a, c := strings.Index(scroll, `class="grid-axis"`), strings.Index(scroll, `id="cubes"`); a < 0 || c < 0 || a > c { + t.Error("the axis labels are not directly above the grid in the scroll area") + } +} + +// TestInsightsValuesAreReadable: money goes through core.js money() (Intl, two +// decimals, never $0 for a nonzero amount) with no six-decimal noise, durations +// are hours and minutes, and numbers use tabular figures. +func TestInsightsValuesAreReadable(t *testing.T) { + core, js := served(t, "/js/core.js"), served(t, "/js/insights.js") + wantAll(t, "core.js", core, + "const dollars = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 });", + "return value > 0 && value < 0.01 ? 'under $0.01' : dollars.format(value);", + "export function hoursText(ms) {", + "return ms === 0 ? '0 min' : minutesText(ms);") + wantAll(t, "insights.js", js, "money, wholeNumber, plural, hoursText", "function usdText(value, notFinite) { return money(num(value)) ||", "function timeText(ms) { return hoursText(num(ms)) || NOT_REPORTED; }", "const NOT_REPORTED = 'not reported';") + wantNone(t, "insights.js", js, "toFixed(6)", "'$' +", "durationLabel", ".toLocaleString(") + if m := regexp.MustCompile(`toFixed\(\d\)`).FindAllString(js, -1); len(m) != 4 { + // Only the four SVG coordinates of a bar are rounded, never a shown value. + t.Errorf("insights.js rounds with toFixed %d times: %q", len(m), m) + } + rules := cssRules(servedCSS(t)) + for _, selector := range []string{".stat-value", ".model-table", ".insight-facts dd", ".outcome-row strong"} { + wantDecls(t, rules, selector, "font-variant-numeric: tabular-nums;") + } +} + +// TestInsightsSortIsStableAndInTheURL: the sort keys are an allowlist read from +// ?sort=… and written with history.pushState; an invalid value falls back to the +// default, nulls sort last and ties fall back to role, model and server order. +func TestInsightsSortIsStableAndInTheURL(t *testing.T) { + core, js := served(t, "/js/core.js"), served(t, "/js/insights.js") + set := moduleFunction(t, core, "setQueryParam") + wantAll(t, "setQueryParam", set, "new URLSearchParams(location.search)", "history.pushState({ view: state.view }, '', next);") + wantAll(t, "queryParam", moduleFunction(t, core, "queryParam"), "return value !== null && allowed(value) ? value : null;") + + sorts := moduleTable(t, js, "SORTS", "}") + var keys []string + for _, m := range regexp.MustCompile(`(?m)^ ([a-z]+): \{ label: '`).FindAllStringSubmatch(sorts, -1) { + keys = append(keys, m[1]) + } + if strings.Join(keys, " ") != "model role attempts molecules cost median in out cache time outcome" { + t.Errorf("sort keys %q", keys) + } + wantAll(t, "insights.js", js, + "const SORT_VALUE = /^([a-z]{2,9})(?:-(asc|desc))?$/;", + "function validSort(value) { const m = SORT_VALUE.exec(value); return Boolean(m && Object.hasOwn(SORTS, m[1])); }", + "const m = SORT_VALUE.exec(queryParam('sort', validSort) || DEFAULT_SORT);") + wantAll(t, "chooseSort", moduleFunction(t, js, "chooseSort"), "setQueryParam('sort', key === DEFAULT_SORT && dir === 'asc' ? null : sortValue(key, dir));", "keepFocus(renderAnalytics);") + wantAll(t, "sortRows", moduleFunction(t, js, "sortRows"), + "if (a.v === null) return 1;", "if (b.v === null) return -1;", + "cmp(ROLE_ORDER[a.r.role] ?? 9, ROLE_ORDER[b.r.role] ?? 9) || cmp(modelLabel(a.r), modelLabel(b.r)) || a.i - b.i;") + wantAll(t, "mount", moduleFunction(t, js, "mount"), "window.addEventListener('popstate', () => keepFocus(renderAnalytics));") + model := moduleFunction(t, js, "byModelSection") + wantAll(t, "byModelSection", model, "'aria-sort': on ?", "el('button', { type: 'button', class: 'sort-button', id: 'sort-' + key, onclick: () => chooseSort(key) }", "el('th', { scope: 'row', class: 'model-name' }, modelLabel(r))", "roleLabel('meta')") + // The table rebuilds only when the analytics or the sort change. + wantAll(t, "renderAnalytics", moduleFunction(t, js, "renderAnalytics"), "if (key === shownKey && box.childElementCount) return;") +} + +// TestInsightsChartHasATextFallback: the weekly chart is inline SVG built with +// createElementNS and hidden from assistive technology, beside a captioned table +// that carries the same values; no chart library is loaded. +func TestInsightsChartHasATextFallback(t *testing.T) { + js := served(t, "/js/insights.js") + wantAll(t, "svg", moduleFunction(t, js, "svg"), "document.createElementNS(SVG_NS, tag)", "return add(node, ...children);") + chart := moduleFunction(t, js, "stackChart") + wantAll(t, "stackChart", chart, "svg('svg', { viewBox: '0 0 ' + W + ' ' + H, class: 'chart-svg', 'aria-hidden': 'true', focusable: 'false' }", "nothing reported in these weeks") + weekly := moduleFunction(t, js, "weeklySection") + wantAll(t, "weeklySection", weekly, "el('table', { class: 'sr-only' },", "el('caption', null, 'Reported cost and Molecules finished per week, by model')", "el('th', { scope: 'row' }, weekText(wk.start))", "usdText(wk.costTotal, wk.costNotFinite)", "countText(wk.finishedTotal)") + page := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/")).Body.String() + for _, body := range []string{js, page} { + wantNone(t, "Insights", body, "chart.js", "d3.", "echarts", "innerHTML") + } +} + +// TestInsightsLayoutAndFooter: the analytics article carries the sections +// without card chrome, the definition lists are a two-column grid that reflows at +// 400 px, controls are 44 px on coarse pointers, forced colors are honoured, and +// Cost per merged PR and Formula suggestions sit in a small Not yet available footer. +func TestInsightsLayoutAndFooter(t *testing.T) { + css := servedCSS(t) + rules := cssRules(css) + wantDecls(t, rules, ".insight-facts", "display: grid;", "grid-template-columns: minmax(0, max-content) minmax(0, 1fr);") + wantDecls(t, rules, ".insight-facts dd", "margin: 0;", "padding: 0;") + wantDecls(t, rules, ".table-scroll", "overflow-x: auto;", "max-width: 100%;") + wantDecls(t, rules, ".aggregate > #analytics", "background: transparent;", "border: 0;", "min-width: 0;") + narrow := cssRules(mediaBlocks(t, css, "@media (max-width: 400px)")) + wantDecls(t, narrow, ".insight-facts", "grid-template-columns: minmax(0, 1fr);") + wantDecls(t, narrow, ".stat-tiles", "grid-template-columns: minmax(0, 1fr);") + wantDecls(t, cssRules(mediaBlocks(t, css, "@media (pointer: coarse)")), ".sort-button", "min-height: 44px;") + wantDecls(t, rules, ".sort-button", "touch-action: manipulation;") + wantDecls(t, rules, ".sort-button:focus-visible", "outline: 2px solid var(--focus);") + wantDecls(t, cssRules(mediaBlocks(t, css, "@media (forced-colors: active)")), ".insight-section, .stat-tile", "background: Canvas;", "border: 1px solid CanvasText;") + + page := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/")).Body.String() + insights := between(t, page, `id="aggregate"`, "") + footer := between(t, insights, `
`, "") + wantAll(t, "the Not yet available footer", footer, "Not yet available", "COST PER MERGED PR", "FORMULA SUGGESTIONS", "Needs verified merge attribution.", "Suggestion review is not connected in this slice.") + wantNone(t, "the Insights view", insights, `class="metrics"`, `
COST PER MERGED PR`) + if strings.Index(insights, `
`) > strings.Index(insights, `
`) { + t.Error("the footer is not below the analytics") + } + js := served(t, "/js/insights.js") + for _, name := range []string{"summarySection", "byModelSection", "weeklySection", "timeSection"} { + body := moduleFunction(t, js, name) + if !strings.Contains(body, "el('h2', { id: 'insight-") || !strings.Contains(body, "'aria-labelledby': 'insight-") { + t.Errorf("%s is not a labelled section with an h2", name) + } + } + wantAll(t, "tile", moduleFunction(t, js, "tile"), "el('h3', { class: 'stat-label' }, label)") +} diff --git a/lab/internal/ui/projection.go b/lab/internal/ui/projection.go index 08767c6..364f0ef 100644 --- a/lab/internal/ui/projection.go +++ b/lab/internal/ui/projection.go @@ -1185,6 +1185,9 @@ func planningCost(r molecule.Record) *PlanningCostView { // racyWindow, changes the tag on every call rather than hiding a change. func ledgerETag(lab string) string { h := sha256.New() + // The Insights "this week" tiles change when the week turns, so the week is + // part of the tag and a 304 never serves last week's totals. + fmt.Fprintf(h, "week %d\x00", weekStart(analyticsNow().UnixMilli())) for _, sub := range []string{"ledgers", "deliveries"} { fmt.Fprintf(h, "%s\x00", sub) dir := filepath.Join(lab, sub) diff --git a/lab/internal/ui/web/css/insights.css b/lab/internal/ui/web/css/insights.css index d260280..e225259 100644 --- a/lab/internal/ui/web/css/insights.css +++ b/lab/internal/ui/web/css/insights.css @@ -1,14 +1,76 @@ -/* Lab overview. */ +/* Insights: the Lab overview and analytics (#261). The analytics article holds + its own sections (summary tiles, by-model table, weekly chart, time bar), so + it drops the card chrome and each section is a paper card. */ .aggregate h1 { font-size: 28px; letter-spacing: -.02em; } .panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; min-height: 30px; color: var(--muted); } .panel-head .mono { overflow-wrap: anywhere; } -.aggregate { padding: 23px; } +.aggregate { padding: 23px; min-width: 0; } .aggregate h2 { font-size: 21px; } -.aggregate-intro { font-size: 12px; margin-top: 7px; color: var(--muted); } -.metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 18px; } -.metrics article { padding: 18px; min-width: 0; } -.metrics strong { display: block; font-size: 23px; margin: 7px 0; font-weight: 500; } -.metrics p { font-size: 12px; color: #526559; } +.aggregate-intro { font-size: 12px; margin-top: 7px; color: var(--muted); max-width: 70ch; } +.aggregate > #analytics { background: transparent; border: 0; box-shadow: none; padding: 0; margin-top: 18px; display: grid; gap: 14px; min-width: 0; } +.insight-section { min-width: 0; padding: 18px; background: var(--paper); border: 1px solid var(--card-edge); border-radius: var(--radius-card); box-shadow: var(--shadow-card); } +.aggregate .insight-title { font-size: 15px; font-weight: 500; margin: 0 0 12px; color: var(--forest); } + +/* Summary tiles. */ +.stat-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } +.stat-tile { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: var(--radius-control); background: #f6f9f1; } +.stat-label { margin: 0; font-size: 11px; font-weight: 500; color: var(--muted); } +.stat-value { margin: 6px 0; font-size: 22px; font-weight: 500; color: var(--forest); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } +.stat-note { margin: 0; font-size: 11px; line-height: 1.5; color: var(--muted); overflow-wrap: anywhere; } + +/* Definition lists: label and value on one line, in a two-column grid. */ +.insight-facts { display: grid; grid-template-columns: minmax(0, max-content) minmax(0, 1fr); gap: 6px 16px; margin: 14px 0 0; padding: 0; font-size: 12px; } +.insight-facts > div { display: contents; } +.insight-facts dt { color: var(--muted); } +.insight-facts dd { margin: 0; padding: 0; color: var(--ink); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } + +/* By model: the table scrolls inside itself, never the page. */ +.table-scroll { max-width: 100%; overflow-x: auto; overscroll-behavior-x: contain; border: 1px solid var(--line); border-radius: var(--radius-control); } +.model-table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; } +.model-table th, .model-table td { padding: 8px 10px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); } +.model-table thead th { position: sticky; top: 0; background: #f1f6ee; font-weight: 500; white-space: nowrap; padding: 0; } +.model-table tbody tr:last-child > * { border-bottom: 0; } +.model-table .model-name { font-weight: 500; color: var(--forest); max-width: 22ch; overflow-wrap: anywhere; } +.model-table .meta-row { color: var(--muted); } +.cell-main { display: block; white-space: nowrap; } +.cell-sub { display: block; margin-top: 2px; font-size: 10px; color: var(--muted); min-width: 12ch; } +.sort-button { display: inline-flex; align-items: center; gap: 4px; width: 100%; min-height: 36px; padding: 8px 10px; border: 0; background: transparent; color: var(--ink); font: inherit; font-weight: 500; text-align: left; cursor: pointer; touch-action: manipulation; } +.sort-button:hover { background: #e4efe3; } +.sort-button:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; } +.sort-mark { min-width: 1ch; color: var(--muted); } + +/* Weekly chart: inline SVG; the table beside it is the screen-reader version. */ +.charts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.chart { margin: 0; min-width: 0; } +.chart-title { font-size: 11px; color: var(--muted); margin-bottom: 6px; } +.chart-svg { display: block; width: 100%; height: auto; } +.chart-axis { stroke: var(--edge-strong); stroke-width: 1; } +.chart-label { fill: var(--muted); font-size: 9px; } +.series-0 { fill: #1f5c45; background: #1f5c45; } +.series-1 { fill: #27a98e; background: #27a98e; } +.series-2 { fill: #83b59c; background: #83b59c; } +.series-3 { fill: #b0893a; background: #b0893a; } +.series-4 { fill: #5d7fa3; background: #5d7fa3; } +.series-5 { fill: #9c6b8f; background: #9c6b8f; } +.series-6 { fill: #9aa89c; background: #9aa89c; } +.chart-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 10px 0 0; padding: 0; list-style: none; font-size: 11px; color: var(--muted); } +.chart-legend li { display: inline-flex; align-items: center; gap: 6px; overflow-wrap: anywhere; } +.swatch { display: inline-block; flex: none; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; } + +/* Where time goes: one bar of labelled segments. */ +.time-bar { display: flex; height: 28px; overflow: hidden; border-radius: var(--radius-control); border: 1px solid var(--line); } +.time-seg { flex: 0 1 0; min-width: 0; display: flex; align-items: center; padding: 0 8px; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.time-active { background: #27a98e; color: #0d2a22; } +.time-you { background: #f6e7c8; color: var(--needs-ink); } +.time-checks { background: #b4cfe0; color: #1d3a4f; } + +/* Not yet available: a small footer, not two large cards. */ +.not-yet { margin-top: 22px; border-top: 1px solid var(--line); padding-top: 14px; } +.not-yet-title { font-size: 12px; font-weight: 500; color: var(--muted); } +.not-yet .insight-facts { margin-top: 6px; font-size: 11px; } +.not-yet dd { color: var(--muted); } + +/* Lab overview. */ .approval-queue, .outcome-chart { margin-top: 22px; border-top: 1px solid var(--line); padding-top: 18px; } .review-row { display: grid; grid-template-columns: 60px minmax(0, 1fr) auto 15px; gap: 12px; width: 100%; align-items: center; text-align: left; color: var(--ink); background: var(--paper); border: 1px solid var(--card-edge); border-radius: var(--radius-card); box-shadow: var(--shadow-card); padding: 10px 13px; margin-top: 7px; min-height: 44px; font-size: 12px; } .review-row strong { font-weight: 500; overflow-wrap: anywhere; } @@ -16,7 +78,7 @@ .review-row:hover { background: #f1f6ee; border-color: var(--edge-strong); } .review-id { font-family: ui-monospace, monospace; color: #557563; } .outcome-row { display: grid; grid-template-columns: 165px minmax(0, 1fr) 36px; align-items: center; gap: 18px; margin-top: 12px; font-size: 12px; } -.outcome-row strong { text-align: right; font-weight: 500; } +.outcome-row strong { text-align: right; font-weight: 500; font-variant-numeric: tabular-nums; } .bar-track { height: 9px; border-radius: 3px; background: #dce8da; overflow: hidden; } .bar { height: 100%; display: block; background: #82ad91; border-radius: 3px; } .bar.working { background: #239b82; } @@ -24,15 +86,34 @@ .bar.failed { background: repeating-linear-gradient(135deg, #6d8f77 0 2px, #dce8da 2px 5px); } .bar.other { background: #b7c9b8; } +@media (max-width: 960px) { + .stat-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .charts { grid-template-columns: minmax(0, 1fr); } +} @media (max-width: 720px) { .aggregate h1 { font-size: 22px; } - .metrics { grid-template-columns: minmax(0, 1fr); } .aggregate { padding: 18px; } + .insight-section { padding: 14px; } .review-row { grid-template-columns: 48px minmax(0, 1fr) 12px; gap: 8px; } .review-row > span:nth-child(3) { grid-column: 2; grid-row: 2; } .review-row > span:last-child { grid-column: 3; grid-row: 1; } .outcome-row { grid-template-columns: 110px minmax(0, 1fr) 30px; gap: 10px; } } +@media (max-width: 400px) { + .stat-tiles { grid-template-columns: minmax(0, 1fr); } + .insight-facts { grid-template-columns: minmax(0, 1fr); gap: 2px; } + .insight-facts dd { margin-bottom: 6px; } +} +@media (pointer: coarse) { + .sort-button { min-height: 44px; } +} +@media (prefers-reduced-motion: reduce) { + .sort-button { transition: none; } +} @media (forced-colors: active) { .review-row { background: Canvas; color: CanvasText; box-shadow: none; border: 1px solid CanvasText; } + .insight-section, .stat-tile { background: Canvas; color: CanvasText; box-shadow: none; border: 1px solid CanvasText; } + .sort-button { border: 1px solid ButtonText; color: ButtonText; } + .chart-svg rect { fill: CanvasText; stroke: Canvas; } + .time-seg { border-right: 1px solid CanvasText; forced-color-adjust: none; } } diff --git a/lab/internal/ui/web/css/work.css b/lab/internal/ui/web/css/work.css index 8983c47..d235e13 100644 --- a/lab/internal/ui/web/css/work.css +++ b/lab/internal/ui/web/css/work.css @@ -7,7 +7,11 @@ /* Lane bar: one pill of segments; the active segment is a raised paper thumb. */ .grid-tools { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 13px; } -.lane-bar { display: flex; flex-wrap: nowrap; align-items: center; gap: 2px; flex: 0 1 auto; min-width: 0; max-width: 100%; padding: 4px; border: 1px solid var(--edge); border-radius: 999px; background: #ffffffa6; box-shadow: inset 0 1px 2px #1a3a2a0d; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x proximity; scrollbar-width: none; } +/* The bar never clips its first segment: it starts at the left edge + (flex-start), its snap points keep the inner padding in view, segments never + shrink, renderFilters keeps the pressed segment in view, and on narrow + screens the segments wrap instead of scrolling. */ +.lane-bar { display: flex; flex-wrap: nowrap; justify-content: flex-start; align-items: center; gap: 2px; flex: 0 1 auto; min-width: 0; max-width: 100%; padding: 4px; border: 1px solid var(--edge); border-radius: 999px; background: #ffffffa6; box-shadow: inset 0 1px 2px #1a3a2a0d; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x proximity; scroll-padding-inline: 4px; scrollbar-width: none; } .lane-bar::-webkit-scrollbar { display: none; } .lane-segment { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px; min-height: 34px; padding: 4px 5px 4px 12px; border: 1px solid transparent; border-radius: 999px; background: transparent; color: #345642; font-size: 12px; white-space: nowrap; scroll-snap-align: start; transition: background .15s, box-shadow .15s, color .15s; } .lane-segment:hover { background: #e4efe3; } @@ -32,9 +36,12 @@ .search-status:empty { display: none; } .outside-filter { margin-top: 7px; font-size: 11px; color: #315b43; } -.culture-scroll { margin-top: 16px; min-width: 0; max-width: 100%; overflow-x: auto; padding: 4px 4px 10px; scrollbar-width: thin; scrollbar-color: #8bad97 #e8f0e3; } -.grid-axis { display: flex; justify-content: space-between; gap: 12px; font-size: 10px; color: var(--muted); margin: 0 0 11px; min-width: 260px; } -.cubes { display: grid; grid-template-rows: repeat(7, 26px); grid-auto-flow: column; grid-auto-columns: 26px; gap: 6px; width: max-content; min-height: 218px; align-content: start; } +/* The scroll area is one column as wide as the grid, so the axis labels span + exactly the grid they describe; the grid has as many rows as it shows cells + (--rows, at most 7) and no fixed height, so a short filter leaves no empty panel. */ +.culture-scroll { display: grid; grid-template-columns: max-content; justify-content: start; margin-top: 16px; min-width: 0; max-width: 100%; overflow-x: auto; padding: 4px 4px 10px; scrollbar-width: thin; scrollbar-color: #8bad97 #e8f0e3; } +.grid-axis { display: flex; justify-content: space-between; gap: 12px; font-size: 10px; color: var(--muted); margin: 0 0 11px; width: 0; min-width: 100%; } +.cubes { display: grid; grid-template-rows: repeat(var(--rows, 7), 26px); grid-auto-flow: column; grid-auto-columns: 26px; gap: 6px; width: max-content; align-content: start; } .cube-button { position: relative; display: grid; place-items: center; width: 26px; height: 26px; min-height: 26px; padding: 1px; border-radius: 3px; border: 1px solid transparent; background: transparent; } .cube-button:hover { background: #bcecd718; border-color: #bcecd780; } .cube-button[aria-pressed=true] { outline: 2px solid #155a43; outline-offset: 1px; background: #d4e9da; } @@ -45,7 +52,7 @@ .cube-button.failed .micro-cube { background: transparent; border-style: dashed; border-color: #72977c; color: #375b45; } .cube-button.finished .micro-cube { background: #83b59c; border-color: #5f9277; color: #173c2f; } .cube-button.other .micro-cube { background: #eef2e8; border-color: #b7c9b8; color: #6a7f6e; } -.filter-empty { padding: 24px 4px; font-size: 12px; color: var(--muted); } +.filter-empty { padding: 8px 4px; font-size: 12px; color: var(--muted); } .cubes:has(+ .filter-empty:not([hidden])) { display: none; } @keyframes micro-work { 0%, 100% { opacity: .52; transform: translateY(0); } 50% { opacity: 1; transform: translateY(-1px); } } @@ -63,12 +70,12 @@ @media (max-width: 720px) { .shelf { padding: 16px; border-radius: 16px; } .grid-tools { gap: 10px; } - .lane-bar { flex: 1 1 100%; } + .lane-bar { flex: 1 1 100%; flex-wrap: wrap; border-radius: var(--radius-card); } .lane-segment { min-height: 44px; } .grid-search { width: 100%; min-height: 44px; margin-left: 0; } .grid-search input { font-size: 16px; } .grid-search button { min-height: 32px; min-width: 32px; } - .cubes { grid-template-rows: repeat(7, 24px); grid-auto-columns: 24px; gap: 4px; min-height: 192px; } + .cubes { grid-template-rows: repeat(var(--rows, 7), 24px); grid-auto-columns: 24px; gap: 4px; } .cube-button { width: 24px; height: 24px; min-height: 24px; padding: 2px; } .shelf-caption { align-items: flex-start; flex-direction: column; gap: 6px; } .shelf-caption > span:first-child { white-space: normal; max-width: 100%; font-size: 10px; } diff --git a/lab/internal/ui/web/index.html b/lab/internal/ui/web/index.html index 8f3bb8d..add4f55 100644 --- a/lab/internal/ui/web/index.html +++ b/lab/internal/ui/web/index.html @@ -203,12 +203,8 @@

What Forgecell will do