diff --git a/src/habits.test.ts b/src/habits.test.ts index 868c60c..584117d 100644 --- a/src/habits.test.ts +++ b/src/habits.test.ts @@ -4,6 +4,8 @@ import { toggleCompletion, isCompletedOn, dayStr, + currentStreak, + bestStreak, } from "./habits"; describe("createHabit", () => { @@ -42,3 +44,97 @@ describe("dayStr", () => { expect(dayStr(new Date(2026, 5, 2))).toBe("2026-06-02"); }); }); + +describe("currentStreak", () => { + const today = "2026-06-10"; + + it("returns 0 for empty habit", () => { + const h = createHabit("Test"); + expect(currentStreak(h, today)).toBe(0); + }); + + it("returns 1 when only today is completed", () => { + let h = createHabit("Test"); + h = toggleCompletion(h, today); + expect(currentStreak(h, today)).toBe(1); + }); + + it("returns 1 when only yesterday is completed and today is not", () => { + let h = createHabit("Test"); + h = toggleCompletion(h, "2026-06-09"); + expect(currentStreak(h, today)).toBe(1); + }); + + it("returns 0 when last completion was two days ago", () => { + let h = createHabit("Test"); + h = toggleCompletion(h, "2026-06-08"); + expect(currentStreak(h, today)).toBe(0); + }); + + it("counts an active streak of several days ending today", () => { + let h = createHabit("Test"); + h = toggleCompletion(h, "2026-06-08"); + h = toggleCompletion(h, "2026-06-09"); + h = toggleCompletion(h, "2026-06-10"); + expect(currentStreak(h, today)).toBe(3); + }); + + it("only counts the consecutive tail — ignores older completions after a gap", () => { + let h = createHabit("Test"); + h = toggleCompletion(h, "2026-06-01"); // gap + h = toggleCompletion(h, "2026-06-09"); + h = toggleCompletion(h, "2026-06-10"); + expect(currentStreak(h, today)).toBe(2); + }); +}); + +describe("bestStreak", () => { + it("returns 0 for empty habit", () => { + const h = createHabit("Test"); + expect(bestStreak(h)).toBe(0); + }); + + it("returns 1 for a single completion", () => { + let h = createHabit("Test"); + h = toggleCompletion(h, "2026-06-10"); + expect(bestStreak(h)).toBe(1); + }); + + it("returns 1 when there is only one completion (yesterday)", () => { + let h = createHabit("Test"); + h = toggleCompletion(h, "2026-06-09"); + expect(bestStreak(h)).toBe(1); + }); + + it("counts a streak broken in the middle correctly", () => { + let h = createHabit("Test"); + h = toggleCompletion(h, "2026-06-01"); + h = toggleCompletion(h, "2026-06-02"); + h = toggleCompletion(h, "2026-06-03"); + // gap + h = toggleCompletion(h, "2026-06-07"); + h = toggleCompletion(h, "2026-06-08"); + expect(bestStreak(h)).toBe(3); + }); + + it("returns the length of a long active streak", () => { + let h = createHabit("Test"); + for (let d = 5; d <= 10; d++) { + h = toggleCompletion(h, `2026-06-${String(d).padStart(2, "0")}`); + } + expect(bestStreak(h)).toBe(6); + }); + + it("returns the best past run when current run is shorter", () => { + let h = createHabit("Test"); + // Past best: 4 days + h = toggleCompletion(h, "2026-05-01"); + h = toggleCompletion(h, "2026-05-02"); + h = toggleCompletion(h, "2026-05-03"); + h = toggleCompletion(h, "2026-05-04"); + // Current run: 2 days + h = toggleCompletion(h, "2026-06-09"); + h = toggleCompletion(h, "2026-06-10"); + expect(bestStreak(h)).toBe(4); + }); +}); diff --git a/src/habits.ts b/src/habits.ts index 28cc33a..95eef25 100644 --- a/src/habits.ts +++ b/src/habits.ts @@ -45,3 +45,49 @@ export function toggleCompletion(habit: Habit, day: string): Habit { : [...habit.completions, day].sort(); return { ...habit, completions }; } + +function shiftDay(day: string, delta: number): string { + const d = new Date(day + "T00:00:00Z"); + d.setUTCDate(d.getUTCDate() + delta); + return d.toISOString().slice(0, 10); +} + +/** + * Count of consecutive completed days ending on or just before `today`. + * If `today` is not completed but yesterday is, the streak runs through yesterday. + * Returns 0 when neither today nor yesterday is completed. + */ +export function currentStreak(habit: Habit, today: string): number { + const set = new Set(habit.completions); + let day = today; + if (!set.has(day)) { + day = shiftDay(day, -1); + if (!set.has(day)) return 0; + } + let count = 0; + while (set.has(day)) { + count++; + day = shiftDay(day, -1); + } + return count; +} + +/** + * Length of the longest run of consecutive completed days ever recorded. + * Returns 0 for a habit with no completions. + */ +export function bestStreak(habit: Habit): number { + if (habit.completions.length === 0) return 0; + const sorted = [...habit.completions].sort(); + let best = 1; + let run = 1; + for (let i = 1; i < sorted.length; i++) { + if (sorted[i] === shiftDay(sorted[i - 1], 1)) { + run++; + if (run > best) best = run; + } else { + run = 1; + } + } + return best; +}