From 6a75db0aae6749122f8681377adefc39f331c777 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Wed, 2 Sep 2026 15:44:31 +1000 Subject: [PATCH] feat: day streaks and two-week activity history Session count alone says nothing about consistency, which is what makes pelvic floor training work. Record every completion timestamp and derive streaks from local calendar days: current streak (survives until a full day is missed), best streak, and a 14-day dot strip on the home screen. The done screen now shows the streak so finishing is rewarded. - src/lib/streak.ts: pure day-key / streak logic, covered by node --test - store: new `sessions` array, defaulted for pre-existing installs - home: StreakCard, screen now scrolls for smaller phones - tsconfig: TS 6 no longer auto-includes @types, so declare node types Co-Authored-By: Claude Fable 5.1 --- .gitignore | 3 ++ README.md | 7 +++- package-lock.json | 7 ++-- package.json | 4 +- src/app/index.tsx | 14 ++++--- src/app/session.tsx | 5 ++- src/components/streak-card.tsx | 74 ++++++++++++++++++++++++++++++++++ src/lib/store.ts | 13 ++++-- src/lib/streak.test.ts | 58 ++++++++++++++++++++++++++ src/lib/streak.ts | 56 +++++++++++++++++++++++++ tsconfig.json | 2 + 11 files changed, 227 insertions(+), 16 deletions(-) create mode 100644 src/components/streak-card.tsx create mode 100644 src/lib/streak.test.ts create mode 100644 src/lib/streak.ts diff --git a/.gitignore b/.gitignore index bdff457..bf5d13c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ npm-debug.* # generated native folders (android/ is committed — CI builds it) /ios + +# Claude Code local dev config +.claude/ diff --git a/README.md b/README.md index db65182..7f399e3 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,15 @@ A session is four guided blocks driven by a step timer with haptic cues: 3. **Quick squeezes** — 10 × 1s burst contractions with 1s release. 4. **Quick pushes** — 10 × 1s burst pushes with 1s release. -Progress (session count, difficulty adjustment) lives in AsyncStorage — no -server, no account. +Progress (session count, difficulty adjustment, completion history) lives in +AsyncStorage — no server, no account. The home screen shows your **day streak** +(alive until a full day is missed), best streak, and a two-week activity strip. ## Layout - `src/app/` — expo-router screens: `index` (home), `session` (timer), `settings` - `src/lib/workout.ts` — pure session-plan + progression logic +- `src/lib/streak.ts` — pure streak / calendar-day logic (tested) - `src/lib/store.ts` — tiny local store persisted to AsyncStorage - `android/` — committed prebuild output; CI builds it directly @@ -31,6 +33,7 @@ server, no account. npm install npm start # Expo dev server npm run typecheck +npm test # node --test over src/lib/*.test.ts ``` ## Release diff --git a/package-lock.json b/package-lock.json index 9d47bec..ae214aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "react-native-web": "^0.21.2" }, "devDependencies": { + "@types/node": "^26.4.1", "@types/react": "~19.2.2", "typescript": "~6.0.3" } @@ -2444,9 +2445,9 @@ } }, "node_modules/@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", + "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", "license": "MIT", "dependencies": { "undici-types": "~8.3.0" diff --git a/package.json b/package.json index 9073705..6a5a594 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "react-native-web": "^0.21.2" }, "devDependencies": { + "@types/node": "^26.4.1", "@types/react": "~19.2.2", "typescript": "~6.0.3" }, @@ -29,7 +30,8 @@ "start": "expo start", "android": "expo run:android", "ios": "expo run:ios", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "node --test src/lib/*.test.ts" }, "private": true } diff --git a/src/app/index.tsx b/src/app/index.tsx index eb0775a..7725e2f 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -1,6 +1,7 @@ import { useRouter } from "expo-router"; -import { StyleSheet, Text, View } from "react-native"; +import { ScrollView, StyleSheet, Text, View } from "react-native"; import { ShapeIcon } from "../components/session-viz"; +import { StreakCard } from "../components/streak-card"; import { Button, Card, StatRow } from "../components/ui"; import { currentHold } from "../lib/store"; import { alpha, C } from "../lib/theme"; @@ -9,14 +10,14 @@ import { FLICK_REPS, HOLD_REPS, MAX_HOLD, PUSH_REPS, SESSIONS_PER_INCREMENT, ses export default function Home() { const router = useRouter(); - const { sessionsCompleted, lastSessionAt, hydrated } = useStore(); + const { sessionsCompleted, lastSessionAt, sessions, hydrated } = useStore(); if (!hydrated) return ; const hold = currentHold(); const untilNext = SESSIONS_PER_INCREMENT - (sessionsCompleted % SESSIONS_PER_INCREMENT); return ( - + @@ -24,6 +25,8 @@ export default function Home() { current hold + + - + ); } const s = StyleSheet.create({ - screen: { flex: 1, backgroundColor: C.bg, padding: 20 }, + screen: { flex: 1, backgroundColor: C.bg }, + content: { padding: 20, paddingBottom: 32 }, hero: { alignItems: "center", justifyContent: "center", paddingVertical: 32 }, glowOuter: { position: "absolute", width: 230, height: 230, borderRadius: 115, backgroundColor: alpha(C.accent, 0.05) }, glowInner: { position: "absolute", width: 150, height: 150, borderRadius: 75, backgroundColor: alpha(C.accent, 0.08) }, diff --git a/src/app/session.tsx b/src/app/session.tsx index 89842dd..0875d44 100644 --- a/src/app/session.tsx +++ b/src/app/session.tsx @@ -7,6 +7,7 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { BLOCK_SHAPE, DoneCheck, GuideDisc, ProgressRing, RoundDots, ShapeIcon, useStepProgress } from "../components/session-viz"; import { Button, Card, StatRow } from "../components/ui"; import { completeSession, currentHold, getState } from "../lib/store"; +import { computeStreaks, dayKey, sessionDays } from "../lib/streak"; import { C } from "../lib/theme"; import { buildSession, SESSIONS_PER_INCREMENT, type StepKind } from "../lib/workout"; @@ -77,7 +78,8 @@ export default function Session() { }, [remaining, done, i, steps]); if (done) { - const { sessionsCompleted } = getState(); + const { sessionsCompleted, sessions } = getState(); + const { current: streak } = computeStreaks(sessionDays(sessions), dayKey(new Date())); const leveledUp = sessionsCompleted % SESSIONS_PER_INCREMENT === 0; const untilNext = SESSIONS_PER_INCREMENT - (sessionsCompleted % SESSIONS_PER_INCREMENT); return ( @@ -90,6 +92,7 @@ export default function Session() { + diff --git a/src/components/streak-card.tsx b/src/components/streak-card.tsx new file mode 100644 index 0000000..dcb849b --- /dev/null +++ b/src/components/streak-card.tsx @@ -0,0 +1,74 @@ +import { StyleSheet, Text, View } from "react-native"; +import { alpha, C } from "../lib/theme"; +import { computeStreaks, dayKey, fromKey, lastNDays, sessionDays } from "../lib/streak"; +import { Card } from "./ui"; + +const DAYS_SHOWN = 14; +const WEEKDAY = ["S", "M", "T", "W", "T", "F", "S"]; + +/** + * Consistency at a glance: current streak as the headline, best streak + * beside it, and a two-week strip of day dots — filled on trained days, + * ringed on today so an untrained today reads as "still open", not missed. + */ +export function StreakCard({ sessions }: { sessions: string[] }) { + const today = dayKey(new Date()); + const days = sessionDays(sessions); + const trained = new Set(days); + const { current, best } = computeStreaks(days, today); + const strip = lastNDays(DAYS_SHOWN, today); + const trainedToday = trained.has(today); + + const headline = + current === 0 ? (days.length === 0 ? "Start your first streak" : "Streak reset — start again today") : trainedToday ? "Keep it going tomorrow" : "Train today to keep it"; + + return ( + + + + 0 && s.valueLive]}>{current} + day streak + + + + {best} + best + + + + + {strip.map((key) => { + const isToday = key === today; + const done = trained.has(key); + const weekday = WEEKDAY[fromKey(key).getDay()]; + return ( + + + {weekday} + + ); + })} + + + {headline} + + ); +} + +const s = StyleSheet.create({ + top: { flexDirection: "row", alignItems: "center", paddingTop: 14, paddingBottom: 12 }, + stat: { flex: 1, alignItems: "center" }, + divider: { width: 1, height: 36, backgroundColor: C.border }, + value: { color: C.muted, fontSize: 40, fontWeight: "800", fontVariant: ["tabular-nums"], letterSpacing: -1, lineHeight: 44 }, + valueLive: { color: C.accent }, + valueSmall: { color: C.text, fontSize: 28, fontWeight: "700", fontVariant: ["tabular-nums"], lineHeight: 44 }, + label: { color: C.muted, fontSize: 11, textTransform: "uppercase", letterSpacing: 1.4, marginTop: 2 }, + strip: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 10, borderTopWidth: 1, borderTopColor: C.border }, + day: { alignItems: "center", gap: 5 }, + dot: { width: 12, height: 12, borderRadius: 6, backgroundColor: C.surface2, borderWidth: 1, borderColor: C.border }, + dotDone: { backgroundColor: C.accent, borderColor: C.accent }, + dotToday: { borderColor: alpha(C.accent, 0.8), borderWidth: 1.5 }, + weekday: { color: alpha(C.muted, 0.55), fontSize: 10, fontWeight: "600" }, + weekdayToday: { color: C.text }, + headline: { color: C.muted, fontSize: 13, textAlign: "center", paddingVertical: 10, borderTopWidth: 1, borderTopColor: C.border }, +}); diff --git a/src/lib/store.ts b/src/lib/store.ts index 56df92f..47f2f7c 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -8,12 +8,14 @@ export interface State { lastSessionAt: string | null; // ISO date /** Manual difficulty tweak from settings, in seconds (can be negative). */ holdAdjust: number; + /** ISO timestamps of every completed session — drives streaks/history. */ + sessions: string[]; hydrated: boolean; } const KEY = "kegel_v1"; -let state: State = { sessionsCompleted: 0, lastSessionAt: null, holdAdjust: 0, hydrated: false }; +let state: State = { sessionsCompleted: 0, lastSessionAt: null, holdAdjust: 0, sessions: [], hydrated: false }; const listeners = new Set<() => void>(); export function getState(): State { @@ -34,6 +36,7 @@ function setState(patch: Partial) { sessionsCompleted: state.sessionsCompleted, lastSessionAt: state.lastSessionAt, holdAdjust: state.holdAdjust, + sessions: state.sessions, }), ); } @@ -42,7 +45,8 @@ export async function hydrate() { if (state.hydrated) return; const raw = await AsyncStorage.getItem(KEY); const saved = raw ? JSON.parse(raw) : {}; - state = { ...state, ...saved, hydrated: true }; + // `sessions` arrived after v1 shipped — older installs have none saved. + state = { ...state, ...saved, sessions: Array.isArray(saved.sessions) ? saved.sessions : [], hydrated: true }; for (const l of listeners) l(); } @@ -51,7 +55,8 @@ export function currentHold(): number { } export function completeSession() { - setState({ sessionsCompleted: state.sessionsCompleted + 1, lastSessionAt: new Date().toISOString() }); + const now = new Date().toISOString(); + setState({ sessionsCompleted: state.sessionsCompleted + 1, lastSessionAt: now, sessions: [...state.sessions, now] }); } export function adjustHold(delta: number) { @@ -59,5 +64,5 @@ export function adjustHold(delta: number) { } export function resetProgress() { - setState({ sessionsCompleted: 0, lastSessionAt: null, holdAdjust: 0 }); + setState({ sessionsCompleted: 0, lastSessionAt: null, holdAdjust: 0, sessions: [] }); } diff --git a/src/lib/streak.test.ts b/src/lib/streak.test.ts new file mode 100644 index 0000000..6129c59 --- /dev/null +++ b/src/lib/streak.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { computeStreaks, dayKey, lastNDays, sessionDays } from "./streak.ts"; + +describe("dayKey", () => { + it("formats a local calendar date as YYYY-MM-DD", () => { + assert.equal(dayKey(new Date(2026, 8, 2, 23, 59)), "2026-09-02"); + assert.equal(dayKey(new Date(2026, 0, 5, 0, 0)), "2026-01-05"); + }); +}); + +describe("sessionDays", () => { + it("dedupes multiple sessions on the same day and sorts ascending", () => { + const a = new Date(2026, 8, 2, 8).toISOString(); + const b = new Date(2026, 8, 2, 20).toISOString(); + const c = new Date(2026, 8, 1, 9).toISOString(); + assert.deepEqual(sessionDays([a, b, c]), ["2026-09-01", "2026-09-02"]); + }); +}); + +describe("lastNDays", () => { + it("returns n keys ending on today, oldest first", () => { + assert.deepEqual(lastNDays(3, "2026-03-01"), ["2026-02-27", "2026-02-28", "2026-03-01"]); + }); +}); + +describe("computeStreaks", () => { + const today = "2026-09-02"; + + it("is zero with no history", () => { + assert.deepEqual(computeStreaks([], today), { current: 0, best: 0 }); + }); + + it("counts consecutive days ending today", () => { + assert.deepEqual(computeStreaks(["2026-08-31", "2026-09-01", "2026-09-02"], today), { current: 3, best: 3 }); + }); + + it("keeps the streak alive if the last session was yesterday", () => { + assert.deepEqual(computeStreaks(["2026-08-31", "2026-09-01"], today), { current: 2, best: 2 }); + }); + + it("drops the current streak after a missed day but remembers the best", () => { + assert.deepEqual(computeStreaks(["2026-08-28", "2026-08-29", "2026-08-30"], today), { current: 0, best: 3 }); + }); + + it("tracks best across gaps", () => { + const days = ["2026-08-01", "2026-08-02", "2026-08-03", "2026-08-04", "2026-08-20", "2026-09-02"]; + assert.deepEqual(computeStreaks(days, today), { current: 1, best: 4 }); + }); + + it("handles month boundaries", () => { + assert.deepEqual(computeStreaks(["2026-08-30", "2026-08-31", "2026-09-01"], "2026-09-01"), { current: 3, best: 3 }); + }); + + it("ignores duplicate and unsorted input", () => { + assert.deepEqual(computeStreaks(["2026-09-02", "2026-09-01", "2026-09-02"], today), { current: 2, best: 2 }); + }); +}); diff --git a/src/lib/streak.ts b/src/lib/streak.ts new file mode 100644 index 0000000..139051a --- /dev/null +++ b/src/lib/streak.ts @@ -0,0 +1,56 @@ +// Pure streak logic over local calendar days — no React, no storage. Days are +// "YYYY-MM-DD" keys in the device's local time zone, so a session at 23:50 +// counts for the day it was actually done, not the UTC one. + +export type DayKey = string; + +export function dayKey(date: Date): DayKey { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +/** Local-midnight Date for a day key. */ +export function fromKey(key: DayKey): Date { + const [y, m, d] = key.split("-").map(Number); + return new Date(y, m - 1, d); +} + +function shiftKey(key: DayKey, days: number): DayKey { + const date = fromKey(key); + date.setDate(date.getDate() + days); + return dayKey(date); +} + +/** Unique, ascending day keys from a list of ISO session timestamps. */ +export function sessionDays(sessionsIso: string[]): DayKey[] { + return Array.from(new Set(sessionsIso.map((iso) => dayKey(new Date(iso))))).sort(); +} + +/** The last `n` day keys ending on `today`, oldest first. */ +export function lastNDays(n: number, today: DayKey): DayKey[] { + const out: DayKey[] = []; + for (let i = n - 1; i >= 0; i--) out.push(shiftKey(today, -i)); + return out; +} + +/** + * Current streak = consecutive trained days ending today or yesterday (a + * streak isn't broken until a full day is missed). Best = longest run ever. + */ +export function computeStreaks(days: DayKey[], today: DayKey): { current: number; best: number } { + const sorted = Array.from(new Set(days)).sort(); + if (sorted.length === 0) return { current: 0, best: 0 }; + + let best = 1; + let run = 1; + for (let i = 1; i < sorted.length; i++) { + run = shiftKey(sorted[i - 1], 1) === sorted[i] ? run + 1 : 1; + if (run > best) best = run; + } + + const last = sorted[sorted.length - 1]; + const alive = last === today || last === shiftKey(today, -1); + return { current: alive ? run : 0, best }; +} diff --git a/tsconfig.json b/tsconfig.json index ec96c84..cf95a43 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,8 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, + "allowImportingTsExtensions": true, + "types": ["node"], "paths": { "@/*": ["./src/*"] }