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
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

# Cancel superseded runs on the same ref to save CI minutes
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
verify:
name: Type-check · Lint · Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Generate Prisma client
run: npx prisma generate

- name: Type check
run: npm run type-check

- name: Lint
run: npm run lint

- name: Unit tests
run: npm run test:unit
90 changes: 90 additions & 0 deletions lib/services/streak.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, it, expect, vi } from "vitest";

// streak.ts imports the Prisma singleton at module load; stub it so these unit
// tests stay hermetic (calculateStreakFromDates itself never touches the DB).
vi.mock("@/lib/db/prisma", () => ({ prisma: {} }));

import { calculateStreakFromDates } from "@/lib/services/streak";
import {
getTodayUtcString,
parseUtcDate,
toUtcDateString,
} from "@/lib/utils/date";

/** YYYY-MM-DD for `n` days before today (UTC). */
function daysAgo(n: number): string {
const d = parseUtcDate(getTodayUtcString());
d.setUTCDate(d.getUTCDate() - n);
return toUtcDateString(d);
}

describe("calculateStreakFromDates — empty / single", () => {
it("returns zeros for no logs", () => {
expect(calculateStreakFromDates([])).toEqual({
currentStreak: 0,
longestStreak: 0,
});
});

it("counts a single log made today", () => {
expect(calculateStreakFromDates([daysAgo(0)])).toEqual({
currentStreak: 1,
longestStreak: 1,
});
});

it("keeps the current streak alive if the last log was yesterday", () => {
expect(calculateStreakFromDates([daysAgo(1)]).currentStreak).toBe(1);
});
});

describe("calculateStreakFromDates — current streak", () => {
it("counts consecutive days ending today", () => {
const result = calculateStreakFromDates([daysAgo(2), daysAgo(1), daysAgo(0)]);
expect(result.currentStreak).toBe(3);
expect(result.longestStreak).toBe(3);
});

it("is 0 when the most recent log is older than yesterday", () => {
expect(calculateStreakFromDates([daysAgo(5), daysAgo(4)]).currentStreak).toBe(0);
});

it("only counts the consecutive tail, not earlier isolated days", () => {
// gap at day-3, then a 2-day run ending today
const result = calculateStreakFromDates([daysAgo(3), daysAgo(1), daysAgo(0)]);
expect(result.currentStreak).toBe(2);
});

it("ignores duplicate dates", () => {
expect(
calculateStreakFromDates([daysAgo(0), daysAgo(0), daysAgo(1)]).currentStreak
).toBe(2);
});
});

describe("calculateStreakFromDates — longest streak", () => {
it("finds the longest run regardless of recency", () => {
const dates = [
"2020-01-01",
"2020-01-02",
"2020-01-03", // run of 3
"2020-01-10", // gap
"2020-01-11", // run of 2
];
const result = calculateStreakFromDates(dates);
expect(result.longestStreak).toBe(3);
expect(result.currentStreak).toBe(0); // all far in the past
});

it("handles unsorted input by sorting first", () => {
expect(
calculateStreakFromDates(["2020-01-03", "2020-01-01", "2020-01-02"]).longestStreak
).toBe(3);
});

it("treats a fully broken history as max run of 1", () => {
expect(
calculateStreakFromDates(["2020-01-01", "2020-01-05", "2020-01-09"]).longestStreak
).toBe(1);
});
});
95 changes: 95 additions & 0 deletions lib/utils/date.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, it, expect } from "vitest";
import {
toUtcDateString,
parseUtcDate,
getDaysDifference,
isNextDay,
normalizeToUtcMidnight,
toDateInputValue,
} from "@/lib/utils/date";

describe("toUtcDateString", () => {
it("formats a Date as YYYY-MM-DD in UTC", () => {
expect(toUtcDateString(new Date("2026-03-15T10:30:00Z"))).toBe("2026-03-15");
});

it("uses UTC, not local time, near day boundaries", () => {
// 23:30 UTC is still the same UTC day regardless of local offset
expect(toUtcDateString(new Date("2026-03-15T23:30:00Z"))).toBe("2026-03-15");
});
});

describe("parseUtcDate", () => {
it("parses YYYY-MM-DD to a UTC-midnight Date", () => {
const d = parseUtcDate("2026-03-15");
expect(d.getUTCFullYear()).toBe(2026);
expect(d.getUTCMonth()).toBe(2); // March, 0-indexed
expect(d.getUTCDate()).toBe(15);
expect(d.getUTCHours()).toBe(0);
});

it("round-trips with toUtcDateString", () => {
expect(toUtcDateString(parseUtcDate("2026-12-31"))).toBe("2026-12-31");
});
});

describe("isNextDay", () => {
it("returns true for consecutive days", () => {
expect(isNextDay("2026-03-15", "2026-03-16")).toBe(true);
});

it("returns true across a month boundary", () => {
expect(isNextDay("2026-02-28", "2026-03-01")).toBe(true); // 2026 not leap
});

it("returns false for a gap of two days", () => {
expect(isNextDay("2026-03-15", "2026-03-17")).toBe(false);
});

it("returns false for the same day", () => {
expect(isNextDay("2026-03-15", "2026-03-15")).toBe(false);
});

it("returns false when b is before a", () => {
expect(isNextDay("2026-03-16", "2026-03-15")).toBe(false);
});
});

describe("getDaysDifference", () => {
it("counts whole days between two dates", () => {
expect(
getDaysDifference(new Date("2026-03-15T00:00:00Z"), new Date("2026-03-20T00:00:00Z"))
).toBe(5);
});

it("is order-independent (absolute value)", () => {
expect(
getDaysDifference(new Date("2026-03-20T00:00:00Z"), new Date("2026-03-15T00:00:00Z"))
).toBe(5);
});

it("ignores the time component", () => {
expect(
getDaysDifference(new Date("2026-03-15T23:59:00Z"), new Date("2026-03-16T00:01:00Z"))
).toBe(1);
});
});

describe("normalizeToUtcMidnight", () => {
it("zeroes the time portion in UTC", () => {
const d = normalizeToUtcMidnight(new Date("2026-03-15T18:45:30Z"));
expect(d.getUTCHours()).toBe(0);
expect(d.getUTCMinutes()).toBe(0);
expect(toUtcDateString(d)).toBe("2026-03-15");
});
});

describe("toDateInputValue", () => {
it("slices an ISO string to YYYY-MM-DD", () => {
expect(toDateInputValue("2026-03-15T10:00:00Z")).toBe("2026-03-15");
});

it("formats a Date object", () => {
expect(toDateInputValue(new Date("2026-03-15T10:00:00Z"))).toBe("2026-03-15");
});
});
Loading
Loading