Files
kegel/src/components/streak-card.tsx
marcuspaico 6a75db0aae 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>
2026-09-02 15:44:31 +10:00

75 lines
3.2 KiB
TypeScript

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