diff --git a/README.md b/README.md index e0d2fa7..f390ed3 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ commit-sprout # render the current plant for the repo you're in commit-sprout status # streak, last commit, days until it wilts commit-sprout prompt # compact glyph for your shell prompt / tmux status commit-sprout --prompt # same thing, as a flag (handy inside a prompt string) -commit-sprout water # (planned) buy a grace day before wilting +commit-sprout water # buy a grace day before wilting (weekends / PTO) ``` `status` prints a plain, script-friendly summary: @@ -44,6 +44,37 @@ last: today (2026-07-04) health: healthy — 2 days of cushion before it gets thirsty ``` +After watering, `status` also reports the grace in effect and the projected +wilt date: + +```text +stage: leafy (healthy) +streak: 6 days (best: 11 days) +last: 2 days ago (2026-07-03) +grace: 1 day (watered) +health: healthy — commit today to stay ahead of thirst +wilts: 2026-07-08 +``` + +### Watering can (grace days) + +Heading into a weekend or PTO and don't want your plant to droop? Water it: + +```bash +commit-sprout water +``` + +Each watering buys **one extra idle day** before the plant turns thirsty and, +later, wilts. It is deliberately hard to abuse, so it nudges *health* without +ever faking *progress*: + +- **One per day** — watering twice on the same day is a no-op. +- **Capped** — at most two grace days can be in effect at once. +- **Self-resetting** — a fresh commit clears any grace, so you can't bank + waterings across commits into a permanent green streak. +- **Health only** — grace never changes your growth stage or streak count; it + just keeps a watered plant perky through a planned break. + `prompt` prints a single glyph + stage (e.g. `ل leafy`). It is built for the hot path: **read-only by default** (a prompt renders on every command, so it skips the state write unless you pass `--save`) and it **fails silently outside diff --git a/cmd/root.go b/cmd/root.go index 5c96a50..ed10e09 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -104,7 +104,7 @@ func runPipeline() (pipelineResult, error) { } now := time.Now() - ps := plant.Compute(act, persisted.Plant(), now) + ps := plant.Compute(act, persisted.Plant(now, act.LastCommit), now) return pipelineResult{ activity: act, @@ -116,6 +116,15 @@ func runPipeline() (pipelineResult, error) { }, nil } +// recomputeWith re-runs the pure plant computation for an already-read pipeline +// result but with a different persisted State (e.g. after watering). It reuses +// the same activity and reference clock so only the state-derived inputs (like +// grace days) change, giving callers an updated PlantState without touching git +// again. +func recomputeWith(r pipelineResult, updated store.State) plant.PlantState { + return plant.Compute(r.activity, updated.Plant(r.now, r.activity.LastCommit), r.now) +} + // persist folds the computed plant state back into the remembered file, unless // --no-save was set or the state path could not be resolved. It is best-effort: // a write failure is surfaced on stderr but never fails the command, so a @@ -251,4 +260,5 @@ func init() { // Subcommands (M6): status + prompt ride the shared pipeline. rootCmd.AddCommand(statusCmd) rootCmd.AddCommand(promptCmd) + rootCmd.AddCommand(waterCmd) } diff --git a/cmd/status.go b/cmd/status.go index db8075e..6b8b586 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -53,7 +53,13 @@ func renderStatus(cmd *cobra.Command) error { fmt.Fprintf(out, "stage: %s (%s)\n", ps.Stage, ps.Health) fmt.Fprintf(out, "streak: %s\n", statusStreak(ps.Streak, r.persisted.BestStreak)) fmt.Fprintf(out, "last: %s\n", statusLastCommit(ps, r.activity.LastCommit, r.now)) + if line := statusGrace(ps); line != "" { + fmt.Fprintf(out, "grace: %s\n", line) + } fmt.Fprintf(out, "health: %s\n", statusWilt(ps)) + if when := nextWiltDate(ps, r.activity.LastCommit, r.now); when != "" { + fmt.Fprintf(out, "wilts: %s\n", when) + } persist(r) return nil @@ -100,16 +106,44 @@ func statusLastCommit(ps plant.PlantState, last time.Time, now time.Time) string } } +// statusGrace reports how many watering-can grace days are currently propping +// the plant up, or "" when none are in effect (so the line is omitted). It +// makes the escape-hatch visible without cluttering the common no-grace case. +func statusGrace(ps plant.PlantState) string { + switch { + case ps.GraceDays <= 0: + return "" + case ps.GraceDays == 1: + return "1 day (watered)" + default: + return fmt.Sprintf("%d days (watered)", ps.GraceDays) + } +} + +// effectiveIdle is the idle-day count the health thresholds actually see: raw +// days since the last commit, rewound by any watering-can grace, floored at 0. +// It is the single source of truth shared by statusWilt and nextWiltDate so the +// text and the date can never disagree. +func effectiveIdle(ps plant.PlantState) int { + d := ps.DaysSinceCommit - ps.GraceDays + if d < 0 { + d = 0 + } + return d +} + // statusWilt turns the health state into a one-line, days-until-wilt nudge that // mirrors the render caption's spirit. Healthy plants report how many idle days -// they can still absorb before turning thirsty. +// they can still absorb before turning thirsty. Watering-can grace is folded in +// via effectiveIdle, so a watered plant honestly reports the extra runway it +// bought rather than the raw calendar gap. func statusWilt(ps plant.PlantState) string { switch ps.Health { case plant.Wilting: return "wilting \u2014 commit to revive it" case plant.Thirsty: - // Thirsty spans days 2-3 idle; wilting begins at day 4. - remaining := 4 - ps.DaysSinceCommit + // Thirsty spans effective days 2-3 idle; wilting begins at day 4. + remaining := 4 - effectiveIdle(ps) if remaining < 1 { remaining = 1 } @@ -122,7 +156,7 @@ func statusWilt(ps plant.PlantState) string { if ps.DaysSinceCommit < 0 { return "healthy" } - cushion := 2 - ps.DaysSinceCommit + cushion := 2 - effectiveIdle(ps) if cushion < 1 { cushion = 1 } @@ -132,3 +166,29 @@ func statusWilt(ps plant.PlantState) string { return fmt.Sprintf("healthy \u2014 %d days of cushion before it gets thirsty", cushion) } } + +// nextWiltDate estimates the calendar date on which the plant will begin to +// wilt if no further commits (or waterings) land, accounting for any grace +// already in effect. It returns "" when there is nothing to project (no commits +// yet) or when the plant is already wilting. Wilting begins once effective idle +// days exceed wiltingAfterDays (3), i.e. on the 4th effective idle day. +func nextWiltDate(ps plant.PlantState, last time.Time, now time.Time) string { + if ps.DaysSinceCommit < 0 || last.IsZero() { + return "" + } + if ps.Health == plant.Wilting { + return "" + } + if now.IsZero() { + now = time.Now() + } + // Days from now until the first wilting day: the plant wilts when + // effectiveIdle reaches 4, so it has (4 - effectiveIdle) safe days left, + // and wilting lands the day after the last safe day. + daysLeft := 4 - effectiveIdle(ps) + if daysLeft < 1 { + daysLeft = 1 + } + wiltDay := now.AddDate(0, 0, daysLeft) + return wiltDay.Format("2006-01-02") +} diff --git a/cmd/water.go b/cmd/water.go new file mode 100644 index 0000000..34d12c0 --- /dev/null +++ b/cmd/water.go @@ -0,0 +1,101 @@ +// Water subcommand: buy the plant a grace day so a deliberate gap (a weekend, +// PTO, a conference) doesn't wilt it. Watering is the honest escape hatch to +// the wilt mechanic -- it shifts the health thresholds back by a bounded number +// of idle days without ever touching the growth stage or the streak count, so +// it can nudge the plant's *health* but can never fake *progress*. +// +// Guardrails (all enforced in internal/store.Water) keep it from being gamed: +// +// - Idempotent per day: watering twice on the same date is a no-op. +// - Capped: at most plant.MaxGraceDays of grace can be in effect at once. +// - Self-resetting: a fresh commit prunes prior waterings, so grace only ever +// covers the current dry spell and cannot be banked across commits. +package cmd + +import ( + "errors" + "fmt" + + "github.com/rwrife/commit-sprout/internal/gitstat" + "github.com/rwrife/commit-sprout/internal/store" + "github.com/spf13/cobra" +) + +// waterCmd implements `commit-sprout water`. +var waterCmd = &cobra.Command{ + Use: "water", + Short: "Buy the plant one grace day before it wilts (for weekends / PTO)", + Long: `Record a "watering" for today, buying the plant one extra idle day before +it turns thirsty and, later, wilts. Use it for deliberate gaps -- a weekend, a +day off, a conference -- when you know you won't commit but don't want the plant +to droop. + +Watering is intentionally hard to abuse: it is limited to one grace day per +calendar day, capped at a small maximum at any one time, and a fresh commit +clears it. It only affects the plant's health, never its growth stage or streak, +so it can keep a plant perky through a planned break without faking that you +shipped anything.`, + Args: cobra.NoArgs, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runWater(cmd) + }, +} + +// runWater loads state, applies a watering scoped to the current dry spell, and +// persists the result (unless --no-save). It prints what happened and the plant +// state after watering. +func runWater(cmd *cobra.Command) error { + r, err := runPipeline() + if err != nil { + if errors.Is(err, gitstat.ErrNotARepo) { + return notARepoMessage(cmd) + } + return err + } + + out := cmd.OutOrStdout() + + updated, res := r.persisted.Water(r.now, r.activity.LastCommit) + + if !res.Applied { + // Nothing to persist (state is unchanged), so just report why. + fmt.Fprintf(out, "watering can: %s.\n", res.Reason) + fmt.Fprintf(out, "grace: %s\n", graceSummary(res.Grace)) + fmt.Fprintf(out, "health: %s\n", statusWilt(r.plant)) + return nil + } + + // Recompute the plant with the new grace in effect so the post-water + // summary reflects reality. + wateredPlant := recomputeWith(r, updated) + + fmt.Fprintln(out, "💧 Watered. The plant can coast one more day.") + fmt.Fprintf(out, "grace: %s\n", graceSummary(res.Grace)) + fmt.Fprintf(out, "health: %s\n", statusWilt(wateredPlant)) + + if noSave { + fmt.Fprintln(out, + "note: --no-save is set, so this watering was not persisted "+ + "(it won't count on the next run).") + return nil + } + + if serr := store.Save(r.statePath, updated); serr != nil { + return fmt.Errorf("water: saving state: %w", serr) + } + return nil +} + +// graceSummary phrases the effective grace-day count for the status block. +func graceSummary(grace int) string { + switch { + case grace <= 0: + return "none" + case grace == 1: + return "1 grace day in effect" + default: + return fmt.Sprintf("%d grace days in effect", grace) + } +} diff --git a/internal/plant/plant.go b/internal/plant/plant.go index 5ea50b0..d811b2b 100644 --- a/internal/plant/plant.go +++ b/internal/plant/plant.go @@ -60,6 +60,15 @@ const ( // "Today" is 0 days idle, "yesterday" is 1, and so on. thirstyAfterDays = 1 // still healthy through yesterday wiltingAfterDays = 3 // thirsty on days 2-3, wilting from day 4+ + + // MaxGraceDays caps how many watering-can grace days can ever be in + // effect at once. Watering (`commit-sprout water`) buys the plant extra + // idle days before it turns thirsty/wilts -- a deliberate "I'm on PTO / + // weekend" escape hatch. The cap is what keeps watering from being spammed + // into a permanent green streak: at most MaxGraceDays of neglect can be + // papered over, and crucially grace only shifts *health* thresholds, never + // the growth stage or the streak count (see applyGrace / Compute). + MaxGraceDays = 2 ) // Stage is the plant's growth stage. Stages are ordered; higher values are @@ -143,6 +152,16 @@ type State struct { // display/brag purposes and does not currently affect the computed // stage, but lives here so persistence is forward-compatible. BestStreak int + + // GraceDays is the number of watering-can grace days currently in effect + // for the ongoing dry spell. It pushes the thirsty/wilting thresholds + // back by that many idle days so a deliberately watered plant survives a + // weekend or PTO without a commit. It never affects the growth Stage or + // the Streak -- watering buys health, not fake progress -- and is clamped + // to [0, MaxGraceDays] by Compute so a hand-edited file cannot grant an + // unbounded reprieve. It resets to 0 once a fresh commit lands (the store + // prunes stale waterings), so grace only ever covers the current gap. + GraceDays int } // PlantState is the fully-resolved state of the plant for one render: its @@ -166,6 +185,12 @@ type PlantState struct { // and -1 when there are no commits at all (nothing to measure from). DaysSinceCommit int + // GraceDays is the number of watering-can grace days actually in effect + // for this render (clamped to [0, MaxGraceDays]). It is surfaced so status + // output can report remaining grace and a corrected next-wilt date; it + // does not influence Stage or Streak. + GraceDays int + // Mood is a short, flavorful one-liner describing how the plant "feels" // given its stage and health. Kept terse and with a little personality. Mood string @@ -188,7 +213,8 @@ type PlantState struct { // never below Sprout once anything has ever grown. func Compute(act gitstat.Activity, st State, now time.Time) PlantState { days := daysSinceCommit(act, now) - health := healthFor(days, act.HasCommits) + grace := clampGrace(st.GraceDays) + health := healthFor(days, act.HasCommits, grace) live := liveStage(act) // Remember the tallest we've ever been (peak of memory and live growth). @@ -209,6 +235,7 @@ func Compute(act gitstat.Activity, st State, now time.Time) PlantState { Health: health, Streak: act.Streak, DaysSinceCommit: days, + GraceDays: grace, Mood: moodFor(stage, health, act), UpdatedHighestStage: highest, } @@ -281,20 +308,44 @@ func dayStart(t time.Time) time.Time { // healthFor maps days-since-last-commit to a Health. With no commits at all the // plant is a fresh Seed and reported Healthy (there is nothing to wilt yet). -func healthFor(days int, hasCommits bool) Health { +// +// grace is the number of watering-can days in effect (already clamped): it +// shifts the idle count back so a watered plant tolerates that many extra quiet +// days before drying out. Grace only moves the health thresholds; the caller +// keeps Stage and Streak untouched, so watering can never fake growth. +func healthFor(days int, hasCommits bool, grace int) Health { if !hasCommits || days < 0 { return Healthy } + // Grace effectively rewinds the dry spell: two watered days make a + // four-day gap feel like two. Never let it drop below "committed today". + effective := days - grace + if effective < 0 { + effective = 0 + } switch { - case days <= thirstyAfterDays: + case effective <= thirstyAfterDays: return Healthy - case days <= wiltingAfterDays: + case effective <= wiltingAfterDays: return Thirsty default: return Wilting } } +// clampGrace bounds a raw grace-day count into the supported [0, MaxGraceDays] +// range so neither a negative nor an over-large (hand-edited) value can distort +// the health calculation. +func clampGrace(g int) int { + if g < 0 { + return 0 + } + if g > MaxGraceDays { + return MaxGraceDays + } + return g +} + // moodFor returns a short flavor line for the given stage/health, with a little // personality. Health takes priority for the "problem" states (thirsty/wilting) // so the message nudges you to commit; otherwise the message celebrates the diff --git a/internal/plant/plant_test.go b/internal/plant/plant_test.go index 68514a4..6ee8f50 100644 --- a/internal/plant/plant_test.go +++ b/internal/plant/plant_test.go @@ -237,6 +237,89 @@ func TestClockSkewFutureCommitIsToday(t *testing.T) { } } +// TestGraceShiftsHealthThresholds verifies that watering-can grace days rewind +// the effective dry spell so a plant tolerates that many extra idle days before +// turning thirsty and, later, wilting. +func TestGraceShiftsHealthThresholds(t *testing.T) { + cases := []struct { + name string + lastDays int + grace int + want Health + }{ + // Without grace, day 2 is thirsty and day 4 wilts (see TestHealthByRecency). + {"2 idle, 1 grace -> healthy", 2, 1, Healthy}, + {"3 idle, 1 grace -> thirsty", 3, 1, Thirsty}, + {"4 idle, 1 grace -> thirsty", 4, 1, Thirsty}, + {"5 idle, 1 grace -> wilting", 5, 1, Wilting}, + {"4 idle, 2 grace -> thirsty", 4, 2, Thirsty}, + {"3 idle, 2 grace -> healthy", 3, 2, Healthy}, + {"5 idle, 2 grace -> thirsty", 5, 2, Thirsty}, + {"6 idle, 2 grace -> wilting", 6, 2, Wilting}, + // Grace is capped at MaxGraceDays; extra grace can't help further. + {"9 idle, 5 grace (capped at 2) -> wilting", 9, 5, Wilting}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + a := act(7, 10, tc.lastDays, true) + got := Compute(a, State{GraceDays: tc.grace}, fixedNow) + if got.Health != tc.want { + t.Errorf("Health = %v; want %v (idle=%d grace=%d)", + got.Health, tc.want, tc.lastDays, tc.grace) + } + }) + } +} + +// TestGraceIsClampedAndSurfaced checks that the effective grace is clamped to +// [0, MaxGraceDays] and reported back on the PlantState for status output. +func TestGraceIsClampedAndSurfaced(t *testing.T) { + a := act(7, 10, 2, true) + + over := Compute(a, State{GraceDays: 99}, fixedNow) + if over.GraceDays != MaxGraceDays { + t.Errorf("over-cap GraceDays = %d; want %d", over.GraceDays, MaxGraceDays) + } + + neg := Compute(a, State{GraceDays: -4}, fixedNow) + if neg.GraceDays != 0 { + t.Errorf("negative GraceDays = %d; want 0", neg.GraceDays) + } +} + +// TestGraceNeverAffectsStreak is the anti-gaming guarantee: grace changes only +// health, never the reported streak, and never lets the stage exceed the +// remembered peak. +func TestGraceNeverAffectsStreak(t *testing.T) { + // A wilting plant (4 idle days) with an established leafy peak: grace can + // lift it out of wilting, but must not inflate the streak number. + a := act(3, 5, 4, true) // streak 3 => Leafy live, 4 idle days + + bare := Compute(a, State{HighestStage: Leafy}, fixedNow) + watered := Compute(a, State{HighestStage: Leafy, GraceDays: MaxGraceDays}, fixedNow) + + if watered.Streak != bare.Streak { + t.Errorf("grace changed Streak: %d -> %d", bare.Streak, watered.Streak) + } + if watered.Streak != a.Streak { + t.Errorf("Streak = %d; want %d (raw activity)", watered.Streak, a.Streak) + } + // Health improves with grace: 4 idle days is Wilting bare, but with 2 grace + // the effective idle is 2 -> Thirsty (a real improvement, one step better). + if bare.Health != Wilting { + t.Errorf("bare Health = %v; want Wilting", bare.Health) + } + if watered.Health != Thirsty { + t.Errorf("watered Health = %v; want Thirsty", watered.Health) + } + // Neither stage may exceed the remembered peak: grace never fakes growth, + // it only changes how healthy the plant looks. + if watered.Stage > Leafy || bare.Stage > Leafy { + t.Errorf("grace/health must not exceed remembered peak Leafy; bare=%v watered=%v", + bare.Stage, watered.Stage) + } +} + func TestStageAndHealthStringsStable(t *testing.T) { stages := map[Stage]string{ Seed: "seed", Sprout: "sprout", Leafy: "leafy", diff --git a/internal/store/store.go b/internal/store/store.go index eb908fa..db189e2 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -25,6 +25,7 @@ import ( "io/fs" "os" "path/filepath" + "sort" "time" "github.com/rwrife/commit-sprout/internal/plant" @@ -80,8 +81,24 @@ type State struct { // LastCommitTime is the author timestamp of the most recently seen // commit, when known. It is the zero time when nothing has been seen. LastCommitTime time.Time `json:"last_commit_time,omitempty"` + + // WateredDates lists the calendar days (YYYY-MM-DD, in the local zone at + // the time of watering) on which the user ran `commit-sprout water` to buy + // a grace day. It is stored as human-readable dates rather than a bare + // counter for three reasons: it is auditable in the JSON file, it makes + // "already watered today" trivially idempotent, and it lets a fresh commit + // prune waterings that predate it so grace only ever covers the *current* + // dry spell. The slice is kept sorted and de-duplicated by normalize; only + // the most recent MaxWateredDatesKept entries are retained so the file + // cannot grow without bound. + WateredDates []string `json:"watered_dates,omitempty"` } +// MaxWateredDatesKept bounds how many watering dates are retained on disk. Only +// the most recent few ever matter (grace is capped and scoped to the current +// gap), so older entries are pruned to keep the state file small. +const MaxWateredDatesKept = 16 + // DefaultState returns the state used when there is nothing on disk yet (or the // existing file could not be read). It is a fresh plant stamped with the // current schema version. @@ -96,7 +113,14 @@ func DefaultState() State { // plant.Compute. Unknown/garbage stage names degrade safely to Seed, and a // negative best streak is clamped to zero, so a hand-edited or partially // corrupt file can never feed nonsense into the state machine. -func (s State) Plant() plant.State { +// +// now is the reference clock and lastCommit is the author time of the most +// recent commit observed this run (zero when there are no commits). They are +// used to derive the effective watering-can grace: only waterings that fall +// after the last commit and on or before today count, capped at +// plant.MaxGraceDays, so grace always scopes to the current dry spell and can +// never be stockpiled into a permanent green streak. +func (s State) Plant(now time.Time, lastCommit time.Time) plant.State { best := s.BestStreak if best < 0 { best = 0 @@ -104,9 +128,119 @@ func (s State) Plant() plant.State { return plant.State{ HighestStage: parseStage(s.HighestStage), BestStreak: best, + GraceDays: s.EffectiveGrace(now, lastCommit), } } +// EffectiveGrace returns how many watering-can grace days currently apply to +// the ongoing dry spell, clamped to [0, plant.MaxGraceDays]. +// +// A watering counts only when its date is strictly after the last commit's +// calendar day (so committing "uses up" and invalidates earlier waterings) and +// is not in the future relative to now. When there is no commit at all, every +// recorded watering on or before today counts (there is no commit to reset +// against yet). The count is capped so at most a couple of neglected days can +// be papered over. +func (s State) EffectiveGrace(now time.Time, lastCommit time.Time) int { + if len(s.WateredDates) == 0 { + return 0 + } + if now.IsZero() { + now = time.Now() + } + today := dayStamp(now) + + // Waterings must post-date the last commit's day; a fresh commit wipes + // the slate. With no commit yet, use a sentinel that lets everything + // through. + var floor string + if !lastCommit.IsZero() { + floor = dayStamp(lastCommit.In(now.Location())) + } + + count := 0 + for _, d := range s.WateredDates { + if d > today { + continue // future watering (clock skew / hand edit): ignore + } + if floor != "" && d <= floor { + continue // predates or coincides with the last commit day + } + count++ + } + if count > plant.MaxGraceDays { + count = plant.MaxGraceDays + } + return count +} + +// WaterResult describes the outcome of a Water call so the CLI can report it. +type WaterResult struct { + // Applied is true when this call recorded a new grace day. It is false + // when watering was a no-op (already watered today, or already at the + // grace cap), in which case Reason explains why. + Applied bool + + // Reason is a short, human-readable explanation when Applied is false. + Reason string + + // Grace is the effective grace-day count after this call. + Grace int +} + +// Water records a watering for now's calendar day and returns the updated State +// alongside a WaterResult describing what happened. It is deliberately +// conservative to prevent gaming the streak: +// +// - Watering is idempotent per day: a second call on the same date is a +// no-op (you cannot bank multiple grace days from one day of watering). +// - Watering is capped: once effective grace is at plant.MaxGraceDays, +// further watering is refused until a commit resets the dry spell. +// +// lastCommit is the most recent commit time (zero when none), used only to +// scope the cap check to the current gap. The returned State is normalized and +// ready to Save; the receiver is not mutated. +func (s State) Water(now time.Time, lastCommit time.Time) (State, WaterResult) { + if now.IsZero() { + now = time.Now() + } + today := dayStamp(now) + + // Already watered today? Idempotent no-op. + for _, d := range s.WateredDates { + if d == today { + return normalize(s), WaterResult{ + Applied: false, + Reason: "already watered today", + Grace: s.EffectiveGrace(now, lastCommit), + } + } + } + + // At the cap already? Refuse rather than silently banking a date that + // would never take effect. + if s.EffectiveGrace(now, lastCommit) >= plant.MaxGraceDays { + return normalize(s), WaterResult{ + Applied: false, + Reason: "grace is already at the maximum", + Grace: plant.MaxGraceDays, + } + } + + out := s + out.WateredDates = append(append([]string(nil), s.WateredDates...), today) + out = normalize(out) + return out, WaterResult{ + Applied: true, + Grace: out.EffectiveGrace(now, lastCommit), + } +} + +// dayStamp formats a time as a YYYY-MM-DD calendar-day key in its own location. +func dayStamp(t time.Time) string { + return t.Format("2006-01-02") +} + // FromPlant folds a freshly computed result back into this persisted State, // returning the updated value ready to Save. It advances the remembered highest // stage and best streak monotonically (they only ever ratchet upward) and @@ -141,9 +275,33 @@ func (s State) FromPlant(ps plant.PlantState, lastCommitHash string, lastCommitT if !lastCommitTime.IsZero() { out.LastCommitTime = lastCommitTime out.LastCommitHash = lastCommitHash + + // A fresh commit ends the current dry spell, so any watering-can + // grace from on/before the commit day has served its purpose and is + // pruned. This is what stops watering from being banked across + // commits into a permanent reprieve. + out.WateredDates = pruneWaterings(out.WateredDates, lastCommitTime) } - return out + return normalize(out) +} + +// pruneWaterings drops any watering dated on or before the last commit's +// calendar day, keeping only waterings that belong to the dry spell *after* +// the commit. The commit time is compared in its own location, matching how +// EffectiveGrace scopes grace. +func pruneWaterings(dates []string, lastCommit time.Time) []string { + if len(dates) == 0 || lastCommit.IsZero() { + return dates + } + floor := dayStamp(lastCommit) + kept := dates[:0:0] + for _, d := range dates { + if d > floor { + kept = append(kept, d) + } + } + return kept } // DefaultPath returns the XDG-aware default path to the state file: @@ -280,9 +438,41 @@ func normalize(s State) State { if s.BestStreak < 0 { s.BestStreak = 0 } + s.WateredDates = normalizeWaterings(s.WateredDates) return s } +// normalizeWaterings de-duplicates and sorts watering dates ascending, drops +// obviously invalid entries (anything not parseable as YYYY-MM-DD), and keeps +// only the most recent MaxWateredDatesKept so a long-lived state file cannot +// accumulate unbounded history. A nil/empty input stays nil so the field is +// omitted from JSON. +func normalizeWaterings(in []string) []string { + if len(in) == 0 { + return nil + } + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, d := range in { + if _, err := time.Parse("2006-01-02", d); err != nil { + continue // skip garbage / hand-edited nonsense + } + if _, dup := seen[d]; dup { + continue + } + seen[d] = struct{}{} + out = append(out, d) + } + if len(out) == 0 { + return nil + } + sort.Strings(out) // lexical sort == chronological for zero-padded ISO dates + if len(out) > MaxWateredDatesKept { + out = out[len(out)-MaxWateredDatesKept:] + } + return out +} + // parseStage maps a stored stage name back to a plant.Stage. Unknown or empty // names resolve to Seed, matching the "fresh plant" default and ensuring a // corrupt/hand-edited value can never crash or produce an invalid stage. diff --git a/internal/store/store_test.go b/internal/store/store_test.go index f85daad..b5febff 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "testing" "time" @@ -92,7 +93,7 @@ func TestLoadMissingFileReturnsDefault(t *testing.T) { if err != nil { t.Fatalf("Load of missing file returned error: %v", err) } - if got != DefaultState() { + if !reflect.DeepEqual(got, DefaultState()) { t.Errorf("Load = %+v, want DefaultState %+v", got, DefaultState()) } } @@ -109,7 +110,7 @@ func TestLoadCorruptFileRecoversToDefault(t *testing.T) { if err != nil { t.Fatalf("Load of corrupt file returned error: %v", err) } - if got != DefaultState() { + if !reflect.DeepEqual(got, DefaultState()) { t.Errorf("Load = %+v, want DefaultState %+v", got, DefaultState()) } } @@ -201,7 +202,7 @@ func TestPlantProjection(t *testing.T) { HighestStage: plant.Leafy.String(), BestStreak: 5, } - ps := s.Plant() + ps := s.Plant(fixedTime, time.Time{}) if ps.HighestStage != plant.Leafy { t.Errorf("HighestStage = %v, want Leafy", ps.HighestStage) } @@ -211,7 +212,7 @@ func TestPlantProjection(t *testing.T) { // Garbage stage + negative streak degrade safely. bad := State{HighestStage: "???", BestStreak: -3} - pbad := bad.Plant() + pbad := bad.Plant(fixedTime, time.Time{}) if pbad.HighestStage != plant.Seed { t.Errorf("bad HighestStage = %v, want Seed", pbad.HighestStage) } @@ -324,3 +325,135 @@ func TestSavedFileIsReadableJSON(t *testing.T) { t.Errorf("saved JSON missing version field: %s", data) } } + +// --- Watering-can grace period --------------------------------------------- + +// graceNow is a stable reference clock for the watering tests. +var graceNow = time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC) + +// day returns graceNow shifted by n days (negative = past), as a YYYY-MM-DD key. +func day(n int) string { + return graceNow.AddDate(0, 0, n).Format("2006-01-02") +} + +// commitAt returns a commit time n days before graceNow. +func commitAt(n int) time.Time { + return graceNow.AddDate(0, 0, -n) +} + +func TestEffectiveGraceScopesToDrySpell(t *testing.T) { + cases := []struct { + name string + watered []string + lastCommit time.Time + want int + }{ + {"none", nil, commitAt(3), 0}, + {"one recent watering after commit", []string{day(-1)}, commitAt(3), 1}, + {"two waterings after commit", []string{day(-2), day(-1)}, commitAt(3), 2}, + {"capped at max", []string{day(-3), day(-2), day(-1)}, commitAt(4), plant.MaxGraceDays}, + {"watering before commit ignored", []string{day(-5)}, commitAt(2), 0}, + {"future watering ignored", []string{day(3)}, commitAt(2), 0}, + {"no commit: counts all past waterings", []string{day(-1)}, time.Time{}, 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := State{WateredDates: tc.watered} + if got := s.EffectiveGrace(graceNow, tc.lastCommit); got != tc.want { + t.Errorf("EffectiveGrace = %d; want %d", got, tc.want) + } + }) + } +} + +func TestWaterRecordsTodayOnce(t *testing.T) { + s := State{} + last := commitAt(3) + + after, res := s.Water(graceNow, last) + if !res.Applied { + t.Fatalf("first water not applied: %q", res.Reason) + } + if res.Grace != 1 { + t.Errorf("grace after first water = %d; want 1", res.Grace) + } + if len(after.WateredDates) != 1 || after.WateredDates[0] != day(0) { + t.Errorf("WateredDates = %v; want [%s]", after.WateredDates, day(0)) + } + + // Watering again the same day is a no-op. + again, res2 := after.Water(graceNow, last) + if res2.Applied { + t.Errorf("second same-day water should be a no-op") + } + if len(again.WateredDates) != 1 { + t.Errorf("same-day water changed dates: %v", again.WateredDates) + } +} + +func TestWaterRefusesAtCap(t *testing.T) { + // Already at the cap for the current dry spell: further watering refused. + s := State{WateredDates: []string{day(-2), day(-1)}} + last := commitAt(3) + if got := s.EffectiveGrace(graceNow, last); got != plant.MaxGraceDays { + t.Fatalf("precondition: grace = %d; want %d", got, plant.MaxGraceDays) + } + + after, res := s.Water(graceNow, last) + if res.Applied { + t.Errorf("water at cap should be refused; reason=%q", res.Reason) + } + // No today entry should have been banked. + for _, d := range after.WateredDates { + if d == day(0) { + t.Errorf("refused water still banked today's date") + } + } +} + +func TestFreshCommitPrunesGrace(t *testing.T) { + // Two waterings buy grace during a gap... + s := State{WateredDates: []string{day(-2), day(-1)}} + if got := s.EffectiveGrace(graceNow, commitAt(3)); got != plant.MaxGraceDays { + t.Fatalf("precondition grace = %d; want %d", got, plant.MaxGraceDays) + } + + // ...then a commit lands today. FromPlant should prune waterings on/before + // the commit day, so grace resets for the new (zero-length) dry spell. + ps := plant.PlantState{Stage: plant.Leafy, Streak: 4, UpdatedHighestStage: plant.Leafy} + after := s.FromPlant(ps, "abc123", graceNow) + + if got := after.EffectiveGrace(graceNow, graceNow); got != 0 { + t.Errorf("grace after commit = %d; want 0 (pruned). dates=%v", got, after.WateredDates) + } + if len(after.WateredDates) != 0 { + t.Errorf("waterings not pruned after commit: %v", after.WateredDates) + } +} + +func TestNormalizeWateringsSortsDedupesAndDropsGarbage(t *testing.T) { + in := State{WateredDates: []string{day(-1), day(-1), "not-a-date", day(-3), ""}} + got := normalize(in).WateredDates + want := []string{day(-3), day(-1)} + if !reflect.DeepEqual(got, want) { + t.Errorf("normalized waterings = %v; want %v", got, want) + } +} + +func TestWateringsSurviveSaveLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + + in := DefaultState() + in.WateredDates = []string{day(-1), day(0)} + if err := Save(path, in); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !reflect.DeepEqual(got.WateredDates, []string{day(-1), day(0)}) { + t.Errorf("round-tripped waterings = %v; want %v", got.WateredDates, []string{day(-1), day(0)}) + } +}