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:
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 },
|
||||
});
|
||||
Reference in New Issue
Block a user