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 <noreply@anthropic.com>
This commit is contained in:
marcuspaico
2026-09-02 15:44:31 +10:00
parent 4908684b07
commit 6a75db0aae
11 changed files with 227 additions and 16 deletions

3
.gitignore vendored
View File

@@ -34,3 +34,6 @@ npm-debug.*
# generated native folders (android/ is committed — CI builds it) # generated native folders (android/ is committed — CI builds it)
/ios /ios
# Claude Code local dev config
.claude/

View File

@@ -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. 3. **Quick squeezes** — 10 × 1s burst contractions with 1s release.
4. **Quick pushes** — 10 × 1s burst pushes with 1s release. 4. **Quick pushes** — 10 × 1s burst pushes with 1s release.
Progress (session count, difficulty adjustment) lives in AsyncStorage — no Progress (session count, difficulty adjustment, completion history) lives in
server, no account. 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 ## Layout
- `src/app/` — expo-router screens: `index` (home), `session` (timer), `settings` - `src/app/` — expo-router screens: `index` (home), `session` (timer), `settings`
- `src/lib/workout.ts` — pure session-plan + progression logic - `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 - `src/lib/store.ts` — tiny local store persisted to AsyncStorage
- `android/` — committed prebuild output; CI builds it directly - `android/` — committed prebuild output; CI builds it directly
@@ -31,6 +33,7 @@ server, no account.
npm install npm install
npm start # Expo dev server npm start # Expo dev server
npm run typecheck npm run typecheck
npm test # node --test over src/lib/*.test.ts
``` ```
## Release ## Release

7
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"react-native-web": "^0.21.2" "react-native-web": "^0.21.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.4.1",
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"typescript": "~6.0.3" "typescript": "~6.0.3"
} }
@@ -2444,9 +2445,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "26.2.0", "version": "26.4.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~8.3.0" "undici-types": "~8.3.0"

View File

@@ -22,6 +22,7 @@
"react-native-web": "^0.21.2" "react-native-web": "^0.21.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.4.1",
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"typescript": "~6.0.3" "typescript": "~6.0.3"
}, },
@@ -29,7 +30,8 @@
"start": "expo start", "start": "expo start",
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "node --test src/lib/*.test.ts"
}, },
"private": true "private": true
} }

View File

@@ -1,6 +1,7 @@
import { useRouter } from "expo-router"; 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 { ShapeIcon } from "../components/session-viz";
import { StreakCard } from "../components/streak-card";
import { Button, Card, StatRow } from "../components/ui"; import { Button, Card, StatRow } from "../components/ui";
import { currentHold } from "../lib/store"; import { currentHold } from "../lib/store";
import { alpha, C } from "../lib/theme"; 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() { export default function Home() {
const router = useRouter(); const router = useRouter();
const { sessionsCompleted, lastSessionAt, hydrated } = useStore(); const { sessionsCompleted, lastSessionAt, sessions, hydrated } = useStore();
if (!hydrated) return <View style={s.screen} />; if (!hydrated) return <View style={s.screen} />;
const hold = currentHold(); const hold = currentHold();
const untilNext = SESSIONS_PER_INCREMENT - (sessionsCompleted % SESSIONS_PER_INCREMENT); const untilNext = SESSIONS_PER_INCREMENT - (sessionsCompleted % SESSIONS_PER_INCREMENT);
return ( return (
<View style={s.screen}> <ScrollView style={s.screen} contentContainerStyle={s.content} showsVerticalScrollIndicator={false}>
<View style={s.hero}> <View style={s.hero}>
<View style={s.glowOuter} /> <View style={s.glowOuter} />
<View style={s.glowInner} /> <View style={s.glowInner} />
@@ -24,6 +25,8 @@ export default function Home() {
<Text style={s.holdLabel}>current hold</Text> <Text style={s.holdLabel}>current hold</Text>
</View> </View>
<StreakCard sessions={sessions} />
<Card> <Card>
<StatRow label="Sessions completed" value={String(sessionsCompleted)} /> <StatRow label="Sessions completed" value={String(sessionsCompleted)} />
<StatRow <StatRow
@@ -55,12 +58,13 @@ export default function Home() {
Squeeze = lift and contract, as if stopping urine mid-flow. Push = gently bear down, as when starting to Squeeze = lift and contract, as if stopping urine mid-flow. Push = gently bear down, as when starting to
urinate. Keep breathing throughout — never strain. urinate. Keep breathing throughout — never strain.
</Text> </Text>
</View> </ScrollView>
); );
} }
const s = StyleSheet.create({ 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 }, hero: { alignItems: "center", justifyContent: "center", paddingVertical: 32 },
glowOuter: { position: "absolute", width: 230, height: 230, borderRadius: 115, backgroundColor: alpha(C.accent, 0.05) }, 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) }, glowInner: { position: "absolute", width: 150, height: 150, borderRadius: 75, backgroundColor: alpha(C.accent, 0.08) },

View File

@@ -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 { BLOCK_SHAPE, DoneCheck, GuideDisc, ProgressRing, RoundDots, ShapeIcon, useStepProgress } from "../components/session-viz";
import { Button, Card, StatRow } from "../components/ui"; import { Button, Card, StatRow } from "../components/ui";
import { completeSession, currentHold, getState } from "../lib/store"; import { completeSession, currentHold, getState } from "../lib/store";
import { computeStreaks, dayKey, sessionDays } from "../lib/streak";
import { C } from "../lib/theme"; import { C } from "../lib/theme";
import { buildSession, SESSIONS_PER_INCREMENT, type StepKind } from "../lib/workout"; import { buildSession, SESSIONS_PER_INCREMENT, type StepKind } from "../lib/workout";
@@ -77,7 +78,8 @@ export default function Session() {
}, [remaining, done, i, steps]); }, [remaining, done, i, steps]);
if (done) { 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 leveledUp = sessionsCompleted % SESSIONS_PER_INCREMENT === 0;
const untilNext = SESSIONS_PER_INCREMENT - (sessionsCompleted % SESSIONS_PER_INCREMENT); const untilNext = SESSIONS_PER_INCREMENT - (sessionsCompleted % SESSIONS_PER_INCREMENT);
return ( return (
@@ -90,6 +92,7 @@ export default function Session() {
</Text> </Text>
</View> </View>
<Card> <Card>
<StatRow label="Streak" value={`${streak} day${streak === 1 ? "" : "s"}`} />
<StatRow label="Sessions completed" value={String(sessionsCompleted)} /> <StatRow label="Sessions completed" value={String(sessionsCompleted)} />
<StatRow label="This session" value={`${Math.round(totalSeconds / 60)} min`} /> <StatRow label="This session" value={`${Math.round(totalSeconds / 60)} min`} />
</Card> </Card>

View File

@@ -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 (
<Card>
<View style={s.top}>
<View style={s.stat}>
<Text style={[s.value, current > 0 && s.valueLive]}>{current}</Text>
<Text style={s.label}>day streak</Text>
</View>
<View style={s.divider} />
<View style={s.stat}>
<Text style={s.valueSmall}>{best}</Text>
<Text style={s.label}>best</Text>
</View>
</View>
<View style={s.strip}>
{strip.map((key) => {
const isToday = key === today;
const done = trained.has(key);
const weekday = WEEKDAY[fromKey(key).getDay()];
return (
<View key={key} style={s.day}>
<View style={[s.dot, done && s.dotDone, isToday && !done && s.dotToday]} />
<Text style={[s.weekday, isToday && s.weekdayToday]}>{weekday}</Text>
</View>
);
})}
</View>
<Text style={s.headline}>{headline}</Text>
</Card>
);
}
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 },
});

View File

@@ -8,12 +8,14 @@ export interface State {
lastSessionAt: string | null; // ISO date lastSessionAt: string | null; // ISO date
/** Manual difficulty tweak from settings, in seconds (can be negative). */ /** Manual difficulty tweak from settings, in seconds (can be negative). */
holdAdjust: number; holdAdjust: number;
/** ISO timestamps of every completed session — drives streaks/history. */
sessions: string[];
hydrated: boolean; hydrated: boolean;
} }
const KEY = "kegel_v1"; 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>(); const listeners = new Set<() => void>();
export function getState(): State { export function getState(): State {
@@ -34,6 +36,7 @@ function setState(patch: Partial<State>) {
sessionsCompleted: state.sessionsCompleted, sessionsCompleted: state.sessionsCompleted,
lastSessionAt: state.lastSessionAt, lastSessionAt: state.lastSessionAt,
holdAdjust: state.holdAdjust, holdAdjust: state.holdAdjust,
sessions: state.sessions,
}), }),
); );
} }
@@ -42,7 +45,8 @@ export async function hydrate() {
if (state.hydrated) return; if (state.hydrated) return;
const raw = await AsyncStorage.getItem(KEY); const raw = await AsyncStorage.getItem(KEY);
const saved = raw ? JSON.parse(raw) : {}; 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(); for (const l of listeners) l();
} }
@@ -51,7 +55,8 @@ export function currentHold(): number {
} }
export function completeSession() { 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) { export function adjustHold(delta: number) {
@@ -59,5 +64,5 @@ export function adjustHold(delta: number) {
} }
export function resetProgress() { export function resetProgress() {
setState({ sessionsCompleted: 0, lastSessionAt: null, holdAdjust: 0 }); setState({ sessionsCompleted: 0, lastSessionAt: null, holdAdjust: 0, sessions: [] });
} }

58
src/lib/streak.test.ts Normal file
View File

@@ -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 });
});
});

56
src/lib/streak.ts Normal file
View File

@@ -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 };
}

View File

@@ -2,6 +2,8 @@
"extends": "expo/tsconfig.base", "extends": "expo/tsconfig.base",
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"allowImportingTsExtensions": true,
"types": ["node"],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }