Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/habits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
toggleCompletion,
isCompletedOn,
dayStr,
currentStreak,
bestStreak,
} from "./habits";

describe("createHabit", () => {
Expand Down Expand Up @@ -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);
});
});
46 changes: 46 additions & 0 deletions src/habits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading