From b04c32e1a238fe5bba1e120a321ff53084be2080 Mon Sep 17 00:00:00 2001 From: "zenith-developer[bot]" Date: Tue, 2 Jun 2026 07:24:07 +0000 Subject: [PATCH] feat: render 30-day heatmap and weekly stats per habit card Each habit card now shows a 30-cell CSS-grid heatmap (last 30 days, today rightmost) and an "X / 7 this week" label. Cells use isCompletedOn per day; weekly count uses last7Count. Exports shiftDay from habits.ts to avoid duplicating date arithmetic in main.ts. Closes #6 --- src/habits.test.ts | 20 ++++++++++++++++++++ src/habits.ts | 2 +- src/main.ts | 24 ++++++++++++++++++------ src/style.css | 34 ++++++++++++++++++++++++++++++++-- 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/habits.test.ts b/src/habits.test.ts index 4ea965f..8e526af 100644 --- a/src/habits.test.ts +++ b/src/habits.test.ts @@ -4,6 +4,7 @@ import { toggleCompletion, isCompletedOn, dayStr, + shiftDay, currentStreak, bestStreak, completionsInRange, @@ -47,6 +48,25 @@ describe("dayStr", () => { }); }); +describe("shiftDay", () => { + it("shifts forward by positive delta", () => { + expect(shiftDay("2026-06-01", 1)).toBe("2026-06-02"); + }); + + it("shifts backward by negative delta", () => { + expect(shiftDay("2026-06-01", -1)).toBe("2026-05-31"); + }); + + it("returns the same day for delta 0", () => { + expect(shiftDay("2026-06-15", 0)).toBe("2026-06-15"); + }); + + it("handles month boundaries correctly", () => { + expect(shiftDay("2026-05-31", 1)).toBe("2026-06-01"); + expect(shiftDay("2026-06-01", -29)).toBe("2026-05-03"); + }); +}); + describe("currentStreak", () => { const today = "2026-06-10"; diff --git a/src/habits.ts b/src/habits.ts index 7065e8b..965d7bc 100644 --- a/src/habits.ts +++ b/src/habits.ts @@ -46,7 +46,7 @@ export function toggleCompletion(habit: Habit, day: string): Habit { return { ...habit, completions }; } -function shiftDay(day: string, delta: number): string { +export 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); diff --git a/src/main.ts b/src/main.ts index d330cff..d15b3ca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,9 @@ import { toggleCompletion, isCompletedOn, dayStr, + shiftDay, currentStreak, + last7Count, } from "./habits"; const STORAGE_KEY = "zenith.habits.v1"; @@ -53,14 +55,24 @@ function render(): void { streak === 0 ? "" : `${streak}${streak >= 3 ? " 🔥" : ""}`; + const heatmapCells = Array.from({ length: 30 }, (_, i) => { + const day = shiftDay(today, i - 29); + const cellDone = isCompletedOn(h, day); + return `
`; + }).join(""); + const weeklyCount = last7Count(h, today); return `
  • - - ${escapeHtml(h.name)} - ${streakHtml} - +
    + + ${escapeHtml(h.name)} + ${streakHtml} + +
    + +
    ${weeklyCount} / 7 this week
  • `; }) .join("") diff --git a/src/style.css b/src/style.css index 1ad56ce..754cf0f 100644 --- a/src/style.css +++ b/src/style.css @@ -82,19 +82,49 @@ form#add button { .habits li { display: flex; - align-items: center; - gap: 12px; + flex-direction: column; + gap: 8px; padding: 12px 14px; background: var(--card); border: 1px solid #2c2f50; border-radius: 14px; } +.habit-row { + display: flex; + align-items: center; + gap: 12px; +} + +.heatmap { + display: grid; + grid-template-columns: repeat(30, 1fr); + gap: 2px; +} + +.heatmap-cell { + aspect-ratio: 1; + border-radius: 2px; + background: #2c2f50; + min-width: 0; +} + +.heatmap-cell.done { + background: var(--accent); +} + +.weekly-stats { + font-size: 0.75rem; + color: var(--muted); + text-align: right; +} + .habits li.done { border-color: var(--accent-2); } .habits li.empty { + align-items: center; justify-content: center; color: var(--muted); border-style: dashed;