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/*"]
}