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)
|
# generated native folders (android/ is committed — CI builds it)
|
||||||
/ios
|
/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.
|
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
7
package-lock.json
generated
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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
|
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
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",
|
"extends": "expo/tsconfig.base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"types": ["node"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user