Skip to content

Commit 10c873e

Browse files
uppertoeclaude
andcommitted
milestones: typed keys, recorded where the action succeeds, unknown is not "not done"
The once-ever facts behind the "still to try" lines are now a closed set of typed keys in the store. Every action that earns one is logged through logChange after it succeeds, so that is where the milestone is recorded; a saved notification setting marks its own per-person key. Current state is consulted only as a healing path for accounts that did things before milestones existed, and the comment says so. A read that fails while building a card now makes the card unavailable, with one warning for the render, rather than reading as "never done" and telling a household to try what it has done. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121j6qgGwUSLGQ48jWZN98F
1 parent e9f0f47 commit 10c873e

6 files changed

Lines changed: 149 additions & 57 deletions

File tree

internal/server/changelog.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,44 @@ func (s *Server) logChange(ctx context.Context, owner, actor, action, target, de
1919
if err := s.store.RecordChange(ctx, owner, actor, action, target, detail); err != nil {
2020
alog.Infof("changelog %s %s: %v", action, redact.Email(owner), err)
2121
}
22+
// Every action that satisfies a "still to try" line is logged here after it
23+
// succeeded, so this is where its once-ever milestone is recorded — the
24+
// durable record the checklist reads, where the change log itself ages out.
25+
if m, ok := milestoneForChange(action, target); ok {
26+
if err := s.store.MarkMilestone(ctx, owner, m); err != nil {
27+
alog.Infof("milestone %s %s: %v", m, redact.Email(owner), err)
28+
}
29+
}
30+
}
31+
32+
// milestoneForChange maps a successful change to the milestone it earns. A
33+
// cleared household name or an emptied permit name earns nothing.
34+
func milestoneForChange(action, target string) (store.Milestone, bool) {
35+
switch action {
36+
case store.ActionRosterSet:
37+
return store.MilestoneRoster, true
38+
case store.ActionOverrideAdd:
39+
return store.MilestoneBooking, true
40+
case store.ActionCycleAdd:
41+
return store.MilestoneWeeks, true
42+
case store.ActionVehicleAdd:
43+
return store.MilestoneRego, true
44+
case store.ActionVehicleEmail:
45+
return store.MilestoneRegoEmail, target != ""
46+
case store.ActionDoorQRShow:
47+
return store.MilestoneVisitorQR, true
48+
case store.ActionGuestCreate:
49+
return store.MilestoneGuestPass, true
50+
case store.ActionDoorQRCreate:
51+
return store.MilestonePrintedQR, true
52+
case store.ActionPermitRename:
53+
return store.MilestonePermitName, target != ""
54+
case store.ActionHouseholdName:
55+
return store.MilestoneHouseholdName, target != ""
56+
case store.ActionMemberAdd:
57+
return store.MilestoneShared, true
58+
}
59+
return "", false
2260
}
2361

2462
// notifyDestructive tells the account's OTHER members that someone removed

internal/server/checklist.go

Lines changed: 68 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,120 +3,141 @@ package server
33
import (
44
"context"
55

6+
"github.com/uppertoe/pstonn/internal/redact"
67
"github.com/uppertoe/pstonn/internal/store"
78
)
89

910
// checklistView is the quiet per-tab card that shows what the tab can do, ticks
1011
// each line as the household uses it, and goes away once everything is ticked.
11-
// It replaces the explanatory strips: a line that ticks itself teaches the
12-
// feature by showing it, where a sentence only describes it.
12+
// It replaces explanatory strips: a line that ticks itself teaches the feature
13+
// by showing it, where a sentence only describes it.
1314
type checklistView struct {
14-
Key string // localStorage dismiss key suffix, one per tab
15+
Key string // localStorage key suffix, one per tab
1516
Items []checkItem // in the order a household would naturally do them
1617
Done int
1718
}
1819

1920
type checkItem struct {
20-
Key string // milestone key; recorded once the line is first seen done
21-
Label string // the outcome, in the household's words
22-
Href string // where it is done; "" once done
23-
Done bool
21+
Milestone store.Milestone
22+
Label string // the outcome, in the household's words
23+
Href string // where it is done; "" once done
24+
Done bool
2425
}
2526

26-
// checklistFor builds the card for one tab from what the account has actually
27-
// done. Every input is a count or a row the page already needs, so the card
28-
// costs a few index reads. A tab whose lines are all ticked returns nil, and so
29-
// does one with nothing to offer yet (no permit managed).
30-
// isPrimary drops the lines only the account owner can act on (naming the
31-
// household, sharing access) for a member, who would otherwise be sent to a
32-
// form they cannot see.
27+
// checklistFor builds the card for one tab.
3328
//
34-
// Each line is satisfied by a durable milestone first (see store.MarkMilestone)
35-
// and by current evidence second. The evidence tables are pruned at 90 days, so
36-
// the first time evidence says "done" the milestone is written, and from then on
37-
// the line stays ticked whatever the housekeeping removes.
29+
// A line is satisfied by its milestone, which the successful action records
30+
// (see logChange and milestoneForChange). Current state is consulted only as a
31+
// healing path for accounts that did things before milestones existed: when it
32+
// shows a line done and no milestone is recorded, the milestone is written
33+
// then, while the evidence still exists. That path can go once every old
34+
// account has been visited; nothing new should rely on it.
35+
//
36+
// Any read failing makes the card unavailable rather than wrong: "unknown" is
37+
// not "not done", and telling a household to try what it has done is worse
38+
// than a missing card. One warning covers the whole render.
3839
func (s *Server) checklistFor(ctx context.Context, owner, user string, isPrimary bool, tab string) *checklistView {
40+
var readErr error
41+
note := func(err error) {
42+
if err != nil && readErr == nil {
43+
readErr = err
44+
}
45+
}
3946
did := func(action string) bool {
4047
n, err := s.store.CountChanges(ctx, owner, action)
48+
note(err)
4149
return err == nil && n > 0
4250
}
43-
ms, _ := s.store.Milestones(ctx, owner)
51+
ms, err := s.store.Milestones(ctx, owner)
52+
note(err)
53+
4454
var items []checkItem
4555
switch tab {
4656
case "schedule":
47-
permits, _ := s.store.ListPermitsFor(ctx, owner)
57+
permits, err := s.store.ListPermitsFor(ctx, owner)
58+
note(err)
4859
if len(permits) == 0 {
4960
return nil
5061
}
5162
roster, weeks := false, false
5263
for _, p := range permits {
53-
if rs, err := s.store.ListRules(ctx, p.ID); err == nil && len(rs) > 0 {
64+
rs, err := s.store.ListRules(ctx, p.ID)
65+
note(err)
66+
if len(rs) > 0 {
5467
roster = true
5568
}
5669
if p.CycleWeeks > 1 {
5770
weeks = true
5871
}
5972
}
6073
items = []checkItem{
61-
{Key: "roster", Label: "Add a number plate to the weekly schedule", Href: "#roster", Done: roster},
62-
{Key: "booking", Label: "Make a booking for a visitor who does not fit the roster", Href: "/schedule?book=1", Done: did(store.ActionOverrideAdd)},
74+
{Milestone: store.MilestoneRoster, Label: "Add a number plate to the weekly schedule", Href: "#roster", Done: roster},
75+
{Milestone: store.MilestoneBooking, Label: "Make a booking for a visitor who does not fit the roster", Href: "/schedule?book=1", Done: did(store.ActionOverrideAdd)},
6376
}
64-
if roster {
65-
items = append(items, checkItem{Key: "weeks", Label: "Add a second week, if the roster differs week to week", Href: "#roster", Done: weeks})
77+
if roster || ms[store.MilestoneRoster] {
78+
items = append(items, checkItem{Milestone: store.MilestoneWeeks, Label: "Add a second week, if the roster differs week to week", Href: "#roster", Done: weeks})
6679
}
6780
case "vehicles":
68-
vs, _ := s.store.ListVehiclesFor(ctx, owner)
81+
vs, err := s.store.ListVehiclesFor(ctx, owner)
82+
note(err)
6983
email := false
7084
for _, v := range vs {
7185
if v.Email != "" {
7286
email = true
7387
}
7488
}
7589
items = []checkItem{
76-
{Key: "rego", Label: "Save the rego of someone who visits you", Href: "#add", Done: len(vs) > 0},
77-
{Key: "rego-email", Label: "Add an email, so they are told when their rego goes on the permit", Href: "#add", Done: email},
90+
{Milestone: store.MilestoneRego, Label: "Save the rego of someone who visits you", Href: "#add", Done: len(vs) > 0},
91+
{Milestone: store.MilestoneRegoEmail, Label: "Add an email, so they are told when their rego goes on the permit", Href: "#add", Done: email},
7892
}
7993
case "guests":
80-
// The grant rows outlive the change log (pruned at 90 days, and younger than
81-
// some accounts), so each line reads the durable row first and the log only
82-
// as a second opinion.
83-
passes, printed, shown, _ := s.store.GuestGrantKinds(ctx, owner)
94+
passes, printed, shown, err := s.store.GuestGrantKinds(ctx, owner)
95+
note(err)
8496
items = []checkItem{
85-
{Key: "visitor-qr", Label: "Show a visitor QR to someone at the door", Href: "#now", Done: shown > 0 || did(store.ActionDoorQRShow)},
86-
{Key: "guest-pass", Label: "Send a guest pass to a household that visits often", Href: "#new", Done: passes > 0 || did(store.ActionGuestCreate)},
87-
{Key: "printed-qr", Label: "Print a QR that pings your phone when it is used", Href: "#now", Done: printed > 0 || did(store.ActionDoorQRCreate)},
97+
{Milestone: store.MilestoneVisitorQR, Label: "Show a visitor QR to someone at the door", Href: "#now", Done: shown > 0 || did(store.ActionDoorQRShow)},
98+
{Milestone: store.MilestoneGuestPass, Label: "Send a guest pass to a household that visits often", Href: "#new", Done: passes > 0 || did(store.ActionGuestCreate)},
99+
{Milestone: store.MilestonePrintedQR, Label: "Print a QR that pings your phone when it is used", Href: "#now", Done: printed > 0 || did(store.ActionDoorQRCreate)},
88100
}
89101
case "settings":
90-
permits, _ := s.store.ListPermitsFor(ctx, owner)
102+
permits, err := s.store.ListPermitsFor(ctx, owner)
103+
note(err)
91104
named := false
92105
for _, p := range permits {
93106
if p.Label != "" && p.Label != p.PermitNumber {
94107
named = true
95108
}
96109
}
97-
household, _ := s.store.HouseholdName(ctx, owner)
98-
members, _ := s.store.CountMembers(ctx, owner)
99-
prefs, _ := s.store.HasNotifyPref(ctx, user)
110+
prefs, err := s.store.HasNotifyPref(ctx, user)
111+
note(err)
100112
items = []checkItem{
101-
{Key: "permit-name", Label: "Name the permit, as it appears on the schedule and in your emails", Href: "/schedule", Done: named},
113+
{Milestone: store.MilestonePermitName, Label: "Name the permit, as it appears on the schedule and in your emails", Href: "/schedule", Done: named},
102114
}
103115
if isPrimary {
116+
household, err := s.store.HouseholdName(ctx, owner)
117+
note(err)
118+
members, err := s.store.CountMembers(ctx, owner)
119+
note(err)
104120
items = append(items,
105-
checkItem{Key: "household-name", Label: "Name the household, so visitors see it instead of your email", Href: "#household", Done: household != ""},
106-
checkItem{Key: "shared", Label: "Give someone else in the house access", Href: "#shared", Done: members > 0})
121+
checkItem{Milestone: store.MilestoneHouseholdName, Label: "Name the household, so visitors see it instead of your email", Href: "#household", Done: household != ""},
122+
checkItem{Milestone: store.MilestoneShared, Label: "Give someone else in the house access", Href: "#shared", Done: members > 0})
107123
}
108-
items = append(items, checkItem{Key: "notify:" + user, Label: "Set how you want to be told about changes", Href: "#notifications", Done: prefs})
124+
items = append(items, checkItem{Milestone: store.MilestoneNotify(user), Label: "Set how you want to be told about changes", Href: "#notifications", Done: prefs})
109125
default:
110126
return nil
111127
}
128+
if readErr != nil {
129+
alog.Warnf("checklist for %s (%s) unavailable: %v", redact.Email(owner), tab, readErr)
130+
return nil
131+
}
112132
v := &checklistView{Key: tab, Items: items}
113133
for i := range items {
114-
if ms[items[i].Key] {
134+
if ms[items[i].Milestone] {
115135
items[i].Done = true
116136
} else if items[i].Done {
117-
// Evidence says done and no milestone yet: record it now, while the
118-
// evidence still exists.
119-
_ = s.store.MarkMilestone(ctx, owner, items[i].Key)
137+
// Healing: the account did this before milestones were recorded.
138+
if err := s.store.MarkMilestone(ctx, owner, items[i].Milestone); err != nil {
139+
alog.Infof("milestone backfill %s %s: %v", items[i].Milestone, redact.Email(owner), err)
140+
}
120141
}
121142
if items[i].Done {
122143
v.Done++

internal/server/checklist_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ func TestChecklistTicksAndRetires(t *testing.T) {
6464
if m := s.checklistFor(ctx, owner, owner, false, "settings"); m == nil || len(m.Items) != 2 {
6565
t.Fatalf("member settings card = %+v, want the two lines a member can act on", m)
6666
}
67+
// A successful action records its milestone directly, through logChange.
68+
s.logChange(ctx, owner, owner, store.ActionOverrideAdd, "GUEST1", "today")
69+
if got, _ := s.store.Milestones(ctx, owner); !got[store.MilestoneBooking] {
70+
t.Fatal("a booking did not record its milestone")
71+
}
72+
if v := s.checklistFor(ctx, owner, owner, true, "schedule"); v == nil || !v.Items[1].Done {
73+
t.Fatalf("booking line not ticked from its milestone: %+v", v)
74+
}
6775
// A milestone outlives its evidence: once the guests line ticked from the
6876
// change log, pruning that log leaves it ticked.
6977
if _, err := s.store.PruneChangeLog(ctx, time.Now().Add(time.Hour)); err != nil {

internal/server/schedule.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,9 +1091,6 @@ func (s *Server) addOverride(w http.ResponseWriter, r *http.Request) {
10911091
window += " until " + windowEndText(*endsAt, s.locForPermit(r.Context(), p))
10921092
}
10931093
s.logChange(r.Context(), owner, user, store.ActionOverrideAdd, reg, window)
1094-
// The once-ever marker for the schedule's "make a booking" line, written at
1095-
// the moment rather than inferred later from rows that age out at 90 days.
1096-
_ = s.store.MarkMilestone(r.Context(), owner, "booking")
10971094
s.sched.KickPermit(p.ID)
10981095
s.respondPermit(w, r, owner, p)
10991096
}

internal/server/settings.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,11 @@ func (s *Server) saveNotify(w http.ResponseWriter, r *http.Request) {
300300
s.serverError(w, err)
301301
return
302302
}
303+
if _, owner, _, ok := s.accountForWrite(w, r); ok {
304+
if err := s.store.MarkMilestone(r.Context(), owner, store.MilestoneNotify(user)); err != nil {
305+
alog.Infof("milestone notify %s: %v", redact.Email(user), err)
306+
}
307+
}
303308
if nudged {
304309
// The saved value differs from what the form shows; re-render to sync.
305310
w.Header().Set("HX-Retarget", "#notify-body")

internal/store/milestone.go

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,51 @@ package store
22

33
import "context"
44

5-
// Milestones returns the once-ever markers recorded for an account, keyed by
6-
// the checklist line they satisfy. Unlike the change log these are never pruned.
7-
func (s *Store) Milestones(ctx context.Context, owner string) (map[string]bool, error) {
5+
// Milestone is a once-ever fact about an account, recorded the first time the
6+
// action succeeds and never pruned: the "still to try" lines read these. The
7+
// set is deliberately small and closed; it is product education, not an event
8+
// system. Unlike the change log these rows are removed only with the account.
9+
type Milestone string
10+
11+
const (
12+
MilestoneRoster Milestone = "roster" // a rego on a day of the weekly roster
13+
MilestoneBooking Milestone = "booking" // a one-off booking made
14+
MilestoneWeeks Milestone = "weeks" // a second roster week added
15+
MilestoneRego Milestone = "rego" // a rego saved
16+
MilestoneRegoEmail Milestone = "rego-email" // an email attached to a rego
17+
MilestoneVisitorQR Milestone = "visitor-qr" // a visitor QR shown
18+
MilestoneGuestPass Milestone = "guest-pass" // a guest pass sent
19+
MilestonePrintedQR Milestone = "printed-qr" // a printed QR created
20+
MilestonePermitName Milestone = "permit-name" // a permit given a name
21+
MilestoneHouseholdName Milestone = "household-name" // the household named for visitors
22+
MilestoneShared Milestone = "shared" // someone given shared access
23+
)
24+
25+
// MilestoneNotify is per person rather than per account: notification settings
26+
// belong to the signed-in member, not the household.
27+
func MilestoneNotify(user string) Milestone { return Milestone("notify:" + user) }
28+
29+
// Milestones returns the account's recorded milestones.
30+
func (s *Store) Milestones(ctx context.Context, owner string) (map[Milestone]bool, error) {
831
rows, err := s.db.QueryContext(ctx, `SELECT key FROM account_milestone WHERE owner = ?`, owner)
932
if err != nil {
1033
return nil, err
1134
}
1235
defer rows.Close()
13-
out := map[string]bool{}
36+
out := map[Milestone]bool{}
1437
for rows.Next() {
1538
var k string
1639
if err := rows.Scan(&k); err != nil {
1740
return nil, err
1841
}
19-
out[k] = true
42+
out[Milestone(k)] = true
2043
}
2144
return out, rows.Err()
2245
}
2346

2447
// MarkMilestone records that the account has done the thing once. Idempotent.
25-
func (s *Store) MarkMilestone(ctx context.Context, owner, key string) error {
48+
func (s *Store) MarkMilestone(ctx context.Context, owner string, m Milestone) error {
2649
_, err := s.db.ExecContext(ctx,
27-
`INSERT OR IGNORE INTO account_milestone (owner, key, at) VALUES (?, ?, ?)`, owner, key, nowUTC())
50+
`INSERT OR IGNORE INTO account_milestone (owner, key, at) VALUES (?, ?, ?)`, owner, string(m), nowUTC())
2851
return err
2952
}

0 commit comments

Comments
 (0)