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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -34,3 +34,6 @@ npm-debug.*
|
||||
|
||||
# generated native folders (android/ is committed — CI builds it)
|
||||
/ios
|
||||
|
||||
# Claude Code local dev config
|
||||
.claude/
|
||||
|
||||
@@ -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
|
||||
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 <View style={s.screen} />;
|
||||
|
||||
const hold = currentHold();
|
||||
const untilNext = SESSIONS_PER_INCREMENT - (sessionsCompleted % SESSIONS_PER_INCREMENT);
|
||||
|
||||
return (
|
||||
<View style={s.screen}>
|
||||
<ScrollView style={s.screen} contentContainerStyle={s.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={s.hero}>
|
||||
<View style={s.glowOuter} />
|
||||
<View style={s.glowInner} />
|
||||
@@ -24,6 +25,8 @@ export default function Home() {
|
||||
<Text style={s.holdLabel}>current hold</Text>
|
||||
</View>
|
||||
|
||||
<StreakCard sessions={sessions} />
|
||||
|
||||
<Card>
|
||||
<StatRow label="Sessions completed" value={String(sessionsCompleted)} />
|
||||
<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
|
||||
urinate. Keep breathing throughout — never strain.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
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) },
|
||||
|
||||
@@ -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() {
|
||||
</Text>
|
||||
</View>
|
||||
<Card>
|
||||
<StatRow label="Streak" value={`${streak} day${streak === 1 ? "" : "s"}`} />
|
||||
<StatRow label="Sessions completed" value={String(sessionsCompleted)} />
|
||||
<StatRow label="This session" value={`${Math.round(totalSeconds / 60)} min`} />
|
||||
</Card>
|
||||
|
||||
74
src/components/streak-card.tsx
Normal file
74
src/components/streak-card.tsx
Normal 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 },
|
||||
});
|
||||
@@ -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<State>) {
|
||||
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: [] });
|
||||
}
|
||||
|
||||
58
src/lib/streak.test.ts
Normal file
58
src/lib/streak.test.ts
Normal 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
56
src/lib/streak.ts
Normal 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 };
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user