Compare commits

..

2 Commits

Author SHA1 Message Date
marcuspaico
55999b7458 feat: store release setup — EAS config, listing copy, screenshots, new icon
Some checks failed
Build & Release APK / build (push) Has been cancelled
- eas.json production/simulator profiles; iOS plist flags; EAS project linked
- literal versionCode/versionName in build.gradle so EAS can parse it; Gitea CI
  stamps the run number with sed instead of env-driven gradle expressions
- store/: listing copy + keyword rationale, screenshot composer (headless
  Chrome), raw simulator captures, rendered App Store / Play slides, feature
  graphic, privacy policy source, icon generator
- replace Expo placeholder icon with the session-ring mark; regenerate Android
  launcher/splash res and theme colours

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WontRuPqKcod9XK4ZN9MXv
2026-09-07 16:10:27 +10:00
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
65 changed files with 648 additions and 28 deletions

View File

@@ -61,6 +61,10 @@ jobs:
run: | run: |
export ANDROID_HOME="$HOME/android-sdk" export ANDROID_HOME="$HOME/android-sdk"
cd android cd android
# build.gradle keeps literal versions so EAS (Play builds) can parse them;
# stamp the CI run number in for the Gitea/Obtainium APK.
sed -i "s/^\( *\)versionCode [0-9]*$/\1versionCode $VERSION_CODE/; s/^\( *\)versionName \".*\"$/\1versionName \"$VERSION_NAME\"/" app/build.gradle
grep -n "versionCode\|versionName" app/build.gradle
echo "sdk.dir=$ANDROID_HOME" > local.properties echo "sdk.dir=$ANDROID_HOME" > local.properties
./gradlew assembleRelease --no-daemon --console=plain ./gradlew assembleRelease --no-daemon --console=plain

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,12 +33,42 @@ 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
Push to `main` → Gitea Actions on the desktop runner (`runs-on: desktop`, ### Android APK (Gitea / Obtainium)
never the VPS) builds a signed APK and publishes a Gitea release — install via
Obtainium. Keystore: `~/.android-keys/kegel-release.jks` (repo secrets Push to `main` → Gitea Actions builds a signed APK and publishes a Gitea
`KEYSTORE_B64`, `KEYSTORE_PASSWORD`; alias `kegel`). iOS runs via release — install via Obtainium. `android/app/build.gradle` keeps literal
`npx expo run:ios` from a Mac. `versionCode`/`versionName`; CI stamps the run number in with `sed` before
building. Keystore: `~/.android-keys/kegel-release.jks` (repo secrets
`KEYSTORE_B64`, `KEYSTORE_PASSWORD`; alias `kegel`). Beware: `expo prebuild -p android`
(used to regenerate launcher icons from `store/icons/make.js`) rewrites
`build.gradle` and drops the release `signingConfigs` block — restore it from git.
### App Store / Google Play (EAS)
Store builds run on EAS (project `@mrmen23/kegel`, profile `production`,
version source remote with auto-increment). The EAS-managed Android keystore
differs from the Gitea one, so Play and Obtainium installs can't update over
each other.
```sh
# Apple: needs an App Store Connect API key for non-interactive auth
export EXPO_ASC_API_KEY_PATH=~/Downloads/AuthKey_L9Z6F5H5B7.p8 EXPO_ASC_KEY_ID=L9Z6F5H5B7 \
EXPO_ASC_ISSUER_ID=<issuer uuid> EXPO_APPLE_TEAM_ID=HZ6XCKM95R EXPO_APPLE_TEAM_TYPE=INDIVIDUAL
eas build -p ios --profile production --non-interactive
eas submit -p ios --profile production --latest --non-interactive # creates the ASC record on first run
# Android: needs a Play service-account JSON with release access
eas build -p android --profile production --non-interactive
eas submit -p android --profile production --latest --key <service-account.json> --track internal
```
Listing copy, keyword rationale and screenshot generation live in `store/`
(`node store/screenshots/make.js` renders `store/screenshots/raw/*.png` into
store-sized slides). Privacy policy is served from
https://rehbock.xyz/kegel/privacy.html (source in `store/web/`). iOS dev runs
via `npx expo run:ios` from a Mac.

View File

@@ -92,8 +92,8 @@ android {
applicationId 'com.marcus.kegel' applicationId 'com.marcus.kegel'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode ((System.getenv("VERSION_CODE") ?: "1").toInteger()) versionCode 1
versionName (System.getenv("VERSION_NAME") ?: "1.0-dev") versionName "1.0.0"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,6 +1,6 @@
<resources> <resources>
<color name="splashscreen_background">#0F1115</color> <color name="splashscreen_background">#160B0B</color>
<color name="iconBackground">#0F1115</color> <color name="iconBackground">#160B0B</color>
<color name="colorPrimary">#023c69</color> <color name="colorPrimary">#023c69</color>
<color name="activityBackground">#0F1115</color> <color name="activityBackground">#160B0B</color>
</resources> </resources>

View File

@@ -9,7 +9,11 @@
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"backgroundColor": "#160B0B", "backgroundColor": "#160B0B",
"ios": { "ios": {
"bundleIdentifier": "com.marcus.kegel" "bundleIdentifier": "com.marcus.kegel",
"supportsTablet": false,
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
@@ -34,6 +38,13 @@
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,
"reactCompiler": true "reactCompiler": true
} },
"extra": {
"router": {},
"eas": {
"projectId": "0edd3f95-b270-4aef-aa1c-f25a9ee57d7f"
}
},
"owner": "mrmen23"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 17 KiB

21
eas.json Normal file
View File

@@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 16.0.1",
"appVersionSource": "remote"
},
"build": {
"simulator": {
"distribution": "internal",
"ios": { "simulator": true }
},
"production": {
"autoIncrement": true,
"ios": { "resourceClass": "m-medium" }
}
},
"submit": {
"production": {
"ios": {}
}
}
}

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

85
store/icons/make.js Normal file
View File

@@ -0,0 +1,85 @@
#!/usr/bin/env node
// Generate the app icon set from one SVG mark, rendered through headless Chrome.
//
// node store/icons/make.js
//
// Writes into assets/images/:
// icon.png 1024×1024 opaque (iOS + fallback)
// android-icon-foreground.png 1024×1024 alpha (adaptive foreground, mark in the 66% safe zone)
// android-icon-monochrome.png 1024×1024 alpha (themed icon, white mark)
// splash-icon.png 512×512 alpha (splash, mark only)
//
// The mark is the session ring from the app: a thick accent ring with a filled
// core — "squeeze". Colours from src/lib/theme.ts. After running, regenerate the
// committed Android res with `npx expo prebuild -p android --no-install`.
const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const OUT = path.join(__dirname, "..", "..", "assets", "images");
const TMP = path.join(__dirname, ".tmp");
const C = { bg: "#160B0B", bg2: "#2A1112", accent: "#E7402E", accentDeep: "#B8271A", text: "#F7ECEA" };
// size: canvas px; scale: fraction of canvas the mark spans; color overrides for monochrome.
function mark(size, scale, { ring = C.accent, core = C.accent, glow = true } = {}) {
const c = size / 2;
const R = (size * scale) / 2; // outer radius of the ring
const stroke = R * 0.3;
const ringR = R - stroke / 2;
const coreR = R * 0.32;
return `
<defs>
<radialGradient id="g" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="${ring}" stop-opacity="0.35"/>
<stop offset="100%" stop-color="${ring}" stop-opacity="0"/>
</radialGradient>
</defs>
${glow ? `<circle cx="${c}" cy="${c}" r="${R * 1.35}" fill="url(#g)"/>` : ""}
<!-- open arc, ~82% of the ring, like the session progress ring mid-hold; gap at the top-right, dash starts at 12 o'clock -->
<circle cx="${c}" cy="${c}" r="${ringR}" fill="none" stroke="${ring}" stroke-width="${stroke}" stroke-linecap="round"
stroke-dasharray="${2 * Math.PI * ringR * 0.82} ${2 * Math.PI * ringR}" transform="rotate(-90 ${c} ${c})"/>
<circle cx="${c}" cy="${c}" r="${coreR}" fill="${core}"/>`;
}
function svg(size, body, bg) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
${bg ? `<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${C.bg2}"/><stop offset="100%" stop-color="${C.bg}"/></linearGradient></defs>
<rect width="${size}" height="${size}" fill="url(#bg)"/>` : ""}
${body}
</svg>`;
}
function render(svgText, size, out, transparent) {
fs.mkdirSync(TMP, { recursive: true });
const file = path.join(TMP, path.basename(out, ".png") + ".html");
fs.writeFileSync(
file,
`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;width:${size}px;height:${size}px;overflow:hidden;background:${transparent ? "transparent" : C.bg}}svg{display:block}</style></head><body>${svgText}</body></html>`,
);
const args = [
"--headless=new", "--disable-gpu", "--hide-scrollbars", "--force-device-scale-factor=1",
`--window-size=${size},${size}`, `--screenshot=${out}`,
];
if (transparent) args.push("--default-background-color=00000000");
args.push("file://" + file);
execFileSync(CHROME, args, { stdio: "ignore" });
}
function main() {
// iOS: opaque, mark fills ~62% of the canvas (Apple masks the corners itself).
render(svg(1024, mark(1024, 0.62), true), 1024, path.join(OUT, "icon.png"), false);
// Android adaptive foreground: content must sit inside the central 66% safe zone.
render(svg(1024, mark(1024, 0.46, { glow: false })), 1024, path.join(OUT, "android-icon-foreground.png"), true);
// Android monochrome: single-colour silhouette, same safe zone.
render(svg(1024, mark(1024, 0.46, { ring: "#FFFFFF", core: "#FFFFFF", glow: false })), 1024, path.join(OUT, "android-icon-monochrome.png"), true);
// Splash: mark only, on transparent; app.json sets the background colour.
render(svg(512, mark(512, 0.8, { glow: false })), 512, path.join(OUT, "splash-icon.png"), true);
fs.rmSync(TMP, { recursive: true, force: true });
console.log("wrote icon.png, android-icon-foreground.png, android-icon-monochrome.png, splash-icon.png");
}
main();

68
store/listing.md Normal file
View File

@@ -0,0 +1,68 @@
# Store listing copy
Keyword data (Google monthly volume, en-US, Sep 2026): "kegel exercises" 135k,
"kegel" 90k, "kegel exercises for men" 74k, "kegels for men" 15k,
"reverse kegel" 10k, "pelvic floor trainer" 3.6k, "kegel trainer" 1.9k.
Title stacks the three highest-volume phrases; subtitle/short description carry
the next tier. Never repeat a title word in the Apple keyword field.
## Apple App Store
- **Name** (30 max): `Him: Kegel Exercises for Men`
- Fallback if taken: `Him: Kegel Exercises for Men` is 28 chars; if Apple rejects, drop to `Him: Kegel Exercises`
- **Subtitle** (30 max): `Pelvic Floor Trainer & Timer`
- **Keywords** (100 max, comma-separated, no spaces):
`kegels,reverse,workout,stamina,control,bladder,prostate,strength,daily,free,squeeze,hold,routine,guided`
- **Promotional text** (170 max):
`Free, no account, no ads, works offline. Guided kegel sessions that get harder as you get stronger.`
- **Category**: Health & Fitness (secondary: Medical)
- **Age rating**: answer the questionnaire honestly — Medical/Treatment Info: Infrequent; everything else None → expect 12+ or 17+. Do not mention sex in the listing; keep it fitness-framed.
- **Privacy policy URL**: https://rehbock.xyz/kegel/privacy.html
- **App Privacy**: "Data Not Collected"
- **Support URL**: https://rehbock.xyz/kegel/privacy.html (or a GitHub/Gitea repo page)
- **Copyright**: 2026 Marcus Rehbock
- **Review notes**: `No account or sign-in. Tap Start on the home screen to run a full guided session (about 4 minutes). Haptics cue each rep. All data stays on device.`
### Description (4000 max)
```
A free pelvic floor trainer for men. No account, no ads, no subscription, no data collection. Everything runs on your phone.
Each session is a guided, four-block routine driven by a step timer with haptic cues so you can train with the screen off or your eyes closed:
• Holds — 8 slow squeeze-and-hold reps. The core of the workout. Holds start at 5 seconds and automatically grow by 1 second every 3 completed sessions, up to 30 seconds.
• Pushes — 5 reverse kegels (gently bear down) to balance tension and relaxation.
• Quick squeezes — 10 one-second burst contractions.
• Quick pushes — 10 one-second burst releases.
Progressive overload is built in. Finish sessions and the holds get longer on their own. Want to go faster or slower? Adjust hold time manually in Settings.
Stay consistent with a day streak, your best streak, and a two-week activity strip on the home screen.
Why kegels?
Pelvic floor exercises strengthen the muscles that support the bladder and bowel and are widely recommended for bladder control, post-prostate-surgery recovery, and general pelvic health. Like any muscle, they respond to regular, progressively harder training.
What this app is not
It is a timer and progression tracker, not a medical device, and gives no medical advice. If you have a medical condition or recent surgery, talk to a clinician first.
Privacy
Zero network access. Your session count, difficulty and history are stored only on your device. Delete the app and the data is gone.
```
## Google Play
- **App name** (30 max): `Him: Kegel Exercises for Men`
- **Short description** (80 max): `Free pelvic floor trainer & timer. Guided kegels that get harder as you improve.`
- **Full description**: same as the Apple description above.
- **Category**: Health & Fitness
- **Data safety**: no data collected, no data shared, no encryption in transit needed (no network).
- **Content rating**: IARC questionnaire — no violence/sexual content/etc. Fitness app.
- **Contact email**: marcus@thepai.co
- **Privacy policy**: https://rehbock.xyz/kegel/privacy.html
## Screenshots
Generated by `store/screenshots/make.js` from simulator captures. Required sizes:
- Apple iPhone 6.9" (1320×2868) — mandatory since 2024; Apple scales down for other iPhones.
- Google Play phone: 1080×1920 or larger, 16:9 to 9:16, plus a 1024×500 feature graphic.

151
store/screenshots/make.js Normal file
View File

@@ -0,0 +1,151 @@
#!/usr/bin/env node
// Compose store screenshots from raw simulator captures.
//
// node store/screenshots/make.js
//
// Inputs: store/screenshots/raw/<name>.png (iPhone 6.9" captures, 1320×2868)
// Outputs: store/screenshots/out/ios/<n>.png 1320×2868 (App Store, 6.9")
// store/screenshots/out/play/<n>.png 1080×1920 (Google Play phone)
// store/screenshots/out/play/feature.png 1024×500 (Play feature graphic)
//
// Renders HTML through headless Chrome so we can use real typography. Colours
// come from src/lib/theme.ts.
const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const ROOT = __dirname;
const RAW = path.join(ROOT, "raw");
const OUT = path.join(ROOT, "out");
const TMP = path.join(ROOT, ".tmp");
const C = {
bg: "#160B0B",
surface: "#241213",
border: "#42201F",
text: "#F7ECEA",
muted: "#A98A86",
accent: "#E7402E",
push: "#FF9D3B",
};
const slides = [
{
raw: "home.png",
h: "Kegels that get<br>harder as you do",
sub: "Holds grow automatically every 3 sessions",
},
{
raw: "session-hold.png",
h: "Guided by timer<br>and haptics",
sub: "Train with the screen off or eyes closed",
},
{
raw: "session-push.png",
h: "Squeezes and<br>reverse kegels",
sub: "Both sides of the pelvic floor, every session",
},
{
raw: "streak.png",
h: "Build the habit",
sub: "Day streak, best streak, two-week history",
},
{
raw: "settings.png",
h: "Free. Offline.<br>No account.",
sub: "Nothing leaves your phone. No ads, ever.",
},
];
const dataUri = (file) => "data:image/png;base64," + fs.readFileSync(file).toString("base64");
function slideHtml({ w, h, cap, sub, img, phoneW, capSize, subSize, top, gap }) {
return `<!doctype html><html><head><meta charset="utf-8"><style>
* { margin:0; padding:0; box-sizing:border-box; -webkit-font-smoothing:antialiased; }
html, body { width:${w}px; height:${h}px; overflow:hidden; background:${C.bg}; }
body { font-family:-apple-system,"SF Pro Display","Helvetica Neue",Helvetica,Arial,sans-serif; }
.slide { width:${w}px; height:${h}px; position:relative; display:flex; flex-direction:column; align-items:center; overflow:hidden; }
.glow { position:absolute; left:50%; top:${top + capSize * 2.6}px; width:${w * 1.2}px; height:${w * 1.2}px;
transform:translateX(-50%); border-radius:50%;
background:radial-gradient(circle, ${C.accent}33 0%, ${C.accent}14 30%, transparent 62%); }
.cap { position:relative; margin-top:${top}px; width:${w * 0.86}px; text-align:center; }
.h { font-size:${capSize}px; font-weight:800; color:${C.text}; line-height:1.04; letter-spacing:-0.02em; }
.sub { font-size:${subSize}px; color:${C.muted}; margin-top:${subSize * 0.7}px; font-weight:500; line-height:1.3; }
.ph { position:relative; margin-top:${gap}px; width:${phoneW}px; border-radius:${phoneW * 0.11}px;
border:${Math.round(phoneW * 0.012)}px solid ${C.border}; overflow:hidden;
box-shadow:0 ${phoneW * 0.06}px ${phoneW * 0.16}px rgba(0,0,0,.6); background:${C.bg}; }
.ph img { display:block; width:100%; }
</style></head><body><div class="slide">
<div class="glow"></div>
<div class="cap"><div class="h">${cap}</div><div class="sub">${sub}</div></div>
<div class="ph"><img src="${img}"></div>
</div></body></html>`;
}
function featureHtml(icon) {
return `<!doctype html><html><head><meta charset="utf-8"><style>
* { margin:0; padding:0; box-sizing:border-box; -webkit-font-smoothing:antialiased; }
html, body { width:1024px; height:500px; overflow:hidden; background:${C.bg}; }
body { font-family:-apple-system,"SF Pro Display","Helvetica Neue",Helvetica,Arial,sans-serif; }
.wrap { width:1024px; height:500px; display:flex; align-items:center; justify-content:center; gap:56px; position:relative; overflow:hidden; }
.glow { position:absolute; left:50%; top:50%; width:900px; height:900px; transform:translate(-50%,-50%); border-radius:50%;
background:radial-gradient(circle, ${C.accent}33 0%, ${C.accent}10 35%, transparent 65%); }
img { position:relative; width:220px; height:220px; border-radius:50px; box-shadow:0 20px 60px rgba(0,0,0,.6); }
.t { position:relative; }
.brand { font-size:88px; font-weight:800; color:${C.text}; letter-spacing:-0.03em; line-height:1; }
.brand span { color:${C.accent}; }
.tag { font-size:34px; color:${C.muted}; margin-top:18px; font-weight:500; }
</style></head><body><div class="wrap">
<div class="glow"></div>
<img src="${icon}">
<div class="t"><div class="brand">Him<span>.</span></div>
<div class="tag">Kegel exercises for men.<br>Free, offline, no account.</div></div>
</div></body></html>`;
}
function render(html, w, h, out) {
fs.mkdirSync(TMP, { recursive: true });
const file = path.join(TMP, path.basename(out, ".png") + ".html");
fs.writeFileSync(file, html);
execFileSync(CHROME, [
"--headless=new",
"--disable-gpu",
"--hide-scrollbars",
"--force-device-scale-factor=1",
`--window-size=${w},${h}`,
`--screenshot=${out}`,
"file://" + file,
], { stdio: "ignore" });
}
function main() {
for (const s of slides) {
const f = path.join(RAW, s.raw);
if (!fs.existsSync(f)) throw new Error(`missing raw capture: ${f}`);
}
fs.mkdirSync(path.join(OUT, "ios"), { recursive: true });
fs.mkdirSync(path.join(OUT, "play"), { recursive: true });
slides.forEach((s, i) => {
const img = dataUri(path.join(RAW, s.raw));
// App Store 6.9": caption then phone; phone overflows the bottom edge on purpose.
render(
slideHtml({ w: 1320, h: 2868, cap: s.h, sub: s.sub, img, phoneW: 1100, capSize: 104, subSize: 46, top: 150, gap: 96 }),
1320, 2868, path.join(OUT, "ios", `${i + 1}.png`),
);
// Play phone (16:9): same layout, smaller phone.
render(
slideHtml({ w: 1080, h: 1920, cap: s.h, sub: s.sub, img, phoneW: 720, capSize: 78, subSize: 36, top: 110, gap: 70 }),
1080, 1920, path.join(OUT, "play", `${i + 1}.png`),
);
});
const icon = dataUri(path.join(ROOT, "..", "..", "assets", "images", "icon.png"));
render(featureHtml(icon), 1024, 500, path.join(OUT, "play", "feature.png"));
fs.rmSync(TMP, { recursive: true, force: true });
console.log(`wrote ${slides.length} iOS + ${slides.length} Play slides and feature.png to ${OUT}`);
}
main();

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

40
store/web/privacy.html Normal file
View File

@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Him: Kegel Exercises for Men – Privacy Policy</title>
<style>
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;max-width:680px;margin:40px auto;padding:0 20px;line-height:1.6;color:#222}
h1{font-size:1.6rem}h2{font-size:1.15rem;margin-top:2em}
small{color:#666}
</style>
</head>
<body>
<h1>Him: Kegel Exercises for Men – Privacy Policy</h1>
<p><small>Effective 7 September 2026</small></p>
<p>Him: Kegel Exercises for Men ("the app") is a free pelvic floor exercise timer. It is built to work entirely on your device.</p>
<h2>Data we collect</h2>
<p><strong>None.</strong> The app does not collect, transmit, sell, or share any personal data. There is no account, no sign-up, no analytics, no advertising, and no crash reporting.</p>
<h2>Data stored on your device</h2>
<p>Your session count, difficulty level, and completion history are saved only in the app's local storage on your phone. This data never leaves your device. Deleting the app deletes it.</p>
<h2>Network access</h2>
<p>The app makes no network requests. It works fully offline.</p>
<h2>Health disclaimer</h2>
<p>The app is a general fitness timer, not a medical device, and provides no medical advice. Consult a healthcare professional before starting pelvic floor training if you have a medical condition, are pregnant, or have recently had surgery.</p>
<h2>Children</h2>
<p>The app is not directed at children under 17 and collects no data from anyone.</p>
<h2>Changes</h2>
<p>If this policy changes, the updated version will be posted at this address.</p>
<h2>Contact</h2>
<p>Marcus Rehbock – <a href="mailto:marcus@thepai.co">marcus@thepai.co</a></p>
</body>
</html>

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/*"]
} }