Compare commits

...

5 Commits

Author SHA1 Message Date
marcuspaico
de0ad23f16 docs: MIT license, open-source README
Some checks failed
Build & Release APK / build (push) Failing after 55m38s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WontRuPqKcod9XK4ZN9MXv
2026-09-07 17:17:09 +10:00
marcuspaico
2d74b7d152 chore(store): App Store Connect API client and listing push script
Some checks failed
Build & Release APK / build (push) Has been cancelled
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WontRuPqKcod9XK4ZN9MXv
2026-09-07 16:56:15 +10:00
marcuspaico
b65832bbd1 chore: pin App Store Connect app id and API key for non-interactive submit
Some checks failed
Build & Release APK / build (push) Has been cancelled
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WontRuPqKcod9XK4ZN9MXv
2026-09-07 16:53:56 +10:00
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
68 changed files with 899 additions and 31 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/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Marcus Rehbock
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,7 +1,14 @@
# Kegel # Kegel — "Him: Kegel Exercises for Men"
Pelvic floor trainer for sexual performance — a free, local-only take on what Free, open-source pelvic floor trainer. A local-only take on what Stamina was
Stamina was before it went paid. Expo / React Native, Android + iOS. before it went paid: no account, no ads, no analytics, no network. Expo /
React Native, Android + iOS.
- **App Store**: https://apps.apple.com/app/id6809350427 (once approved)
- **Android APK**: releases on this repo, installable via Obtainium
- **Google Play**: coming
- **Privacy policy**: https://rehbock.xyz/kegel/privacy.html
- **License**: MIT — see `LICENSE`. Fork it, ship your own, no strings.
## How it works ## How it works
@@ -15,13 +22,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 +40,50 @@ 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
``` ```
## Contributing
Issues and PRs welcome. Keep the app offline-only and dependency-light; the
whole point is that it never phones home. Run `npm run typecheck && npm test`
before opening a PR.
## 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)
Only the maintainer can publish to the stores; `eas.json` references an App
Store Connect API key by key ID / issuer ID, which are identifiers, not
secrets — the `.p8` private key is never committed. 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

31
eas.json Normal file
View File

@@ -0,0 +1,31 @@
{
"cli": {
"version": ">= 16.0.1",
"appVersionSource": "remote"
},
"build": {
"simulator": {
"distribution": "internal",
"ios": {
"simulator": true
}
},
"production": {
"autoIncrement": true,
"ios": {
"resourceClass": "m-medium"
}
}
},
"submit": {
"production": {
"ios": {
"ascApiKeyPath": "/Users/marcus/Downloads/AuthKey_L9Z6F5H5B7.p8",
"ascApiKeyId": "L9Z6F5H5B7",
"ascApiKeyIssuerId": "52ab69b8-cabe-48a1-8168-30bfc25cb5dd",
"appleTeamId": "HZ6XCKM95R",
"ascAppId": "6809350427"
}
}
}
}

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

152
store/asc-fill.js Normal file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env node
// Push the App Store listing from store/listing.md into App Store Connect.
// Idempotent: re-running overwrites text fields and replaces the screenshot set.
//
// node store/asc-fill.js
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { api, APP_ID } = require("./asc");
const NAME = "Him: Kegel Exercises for Men";
const SUBTITLE = "Pelvic Floor Trainer & Timer";
const PRIVACY_URL = "https://rehbock.xyz/kegel/privacy.html";
const SUPPORT_URL = "https://rehbock.xyz/kegel/privacy.html";
const KEYWORDS = "kegels,reverse,workout,stamina,control,bladder,prostate,strength,daily,free,squeeze,hold,guided";
const PROMO = "Free, no account, no ads, works offline. Guided kegel sessions that get harder as you get stronger.";
const DESCRIPTION = `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.`;
const SHOTS_DIR = path.join(__dirname, "screenshots", "out", "ios");
const DISPLAY_TYPE = "APP_IPHONE_67"; // 6.9" / 6.7" iPhones (1320×2868 accepted)
async function first(p) {
const r = await api("GET", p);
return r.data[0];
}
async function main() {
const appInfo = await first(`/v1/apps/${APP_ID}/appInfos`);
const infoLoc = await first(`/v1/appInfos/${appInfo.id}/appInfoLocalizations`);
const version = await first(`/v1/apps/${APP_ID}/appStoreVersions?filter[platform]=IOS`);
const verLoc = await first(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations`);
console.log(`appInfo ${appInfo.id} / version ${version.attributes.versionString} (${version.attributes.appStoreState})`);
// 1. Name, subtitle, privacy policy
await api("PATCH", `/v1/appInfoLocalizations/${infoLoc.id}`, {
data: { type: "appInfoLocalizations", id: infoLoc.id, attributes: { name: NAME, subtitle: SUBTITLE, privacyPolicyUrl: PRIVACY_URL } },
});
console.log("✔ name / subtitle / privacy URL");
// 2. Description, keywords, promo, support URL
await api("PATCH", `/v1/appStoreVersionLocalizations/${verLoc.id}`, {
data: {
type: "appStoreVersionLocalizations",
id: verLoc.id,
attributes: { description: DESCRIPTION, keywords: KEYWORDS, promotionalText: PROMO, supportUrl: SUPPORT_URL },
},
});
console.log("✔ description / keywords / promo / support URL");
// 3. Categories
await api("PATCH", `/v1/appInfos/${appInfo.id}`, {
data: {
type: "appInfos",
id: appInfo.id,
relationships: {
primaryCategory: { data: { type: "appCategories", id: "HEALTH_AND_FITNESS" } },
secondaryCategory: { data: { type: "appCategories", id: "MEDICAL" } },
},
},
});
console.log("✔ categories");
// 4. Age rating: fitness timer, health topic yes, mild medical info, nothing else
const none = "NONE";
await api("PATCH", `/v1/ageRatingDeclarations/${appInfo.id}`, {
data: {
type: "ageRatingDeclarations",
id: appInfo.id,
attributes: {
alcoholTobaccoOrDrugUseOrReferences: none,
contests: none,
gamblingSimulated: none,
gunsOrOtherWeapons: none,
horrorOrFearThemes: none,
matureOrSuggestiveThemes: none,
medicalOrTreatmentInformation: "INFREQUENT_OR_MILD",
profanityOrCrudeHumor: none,
sexualContentGraphicAndNudity: none,
sexualContentOrNudity: none,
violenceCartoonOrFantasy: none,
violenceRealistic: none,
violenceRealisticProlongedGraphicOrSadistic: none,
advertising: false,
gambling: false,
healthOrWellnessTopics: true,
lootBox: false,
messagingAndChat: false,
parentalControls: false,
socialMedia: false,
unrestrictedWebAccess: false,
userGeneratedContent: false,
ageAssurance: false,
},
},
});
console.log("✔ age rating declaration");
// 5. Screenshots: replace the iPhone 6.9" set
const sets = await api("GET", `/v1/appStoreVersionLocalizations/${verLoc.id}/appScreenshotSets?filter[screenshotDisplayType]=${DISPLAY_TYPE}`);
for (const s of sets.data) await api("DELETE", `/v1/appScreenshotSets/${s.id}`);
const set = await api("POST", "/v1/appScreenshotSets", {
data: {
type: "appScreenshotSets",
attributes: { screenshotDisplayType: DISPLAY_TYPE },
relationships: { appStoreVersionLocalization: { data: { type: "appStoreVersionLocalizations", id: verLoc.id } } },
},
});
const files = fs.readdirSync(SHOTS_DIR).filter((f) => /^\d+\.png$/.test(f)).sort((a, b) => parseInt(a) - parseInt(b));
for (const f of files) {
const buf = fs.readFileSync(path.join(SHOTS_DIR, f));
const shot = await api("POST", "/v1/appScreenshots", {
data: {
type: "appScreenshots",
attributes: { fileName: f, fileSize: buf.length },
relationships: { appScreenshotSet: { data: { type: "appScreenshotSets", id: set.data.id } } },
},
});
for (const op of shot.data.attributes.uploadOperations) {
const headers = Object.fromEntries(op.requestHeaders.map((h) => [h.name, h.value]));
const res = await fetch(op.url, { method: op.method, headers, body: buf.subarray(op.offset, op.offset + op.length) });
if (!res.ok) throw new Error(`upload chunk failed ${res.status} for ${f}`);
}
await api("PATCH", `/v1/appScreenshots/${shot.data.id}`, {
data: { type: "appScreenshots", id: shot.data.id, attributes: { uploaded: true, sourceFileChecksum: crypto.createHash("md5").update(buf).digest("hex") } },
});
console.log(`✔ screenshot ${f}`);
}
console.log("done");
}
main().catch((e) => { console.error(e.message); process.exit(1); });

50
store/asc.js Normal file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env node
// Minimal App Store Connect API client (ES256 JWT via node:crypto, no deps).
//
// node store/asc.js get /v1/apps/6809350427
// node store/asc.js patch /v1/appInfos/ID '{"data":{...}}'
//
// Reads the key from eas.json submit.production.ios (ascApiKeyPath/Id/IssuerId).
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const eas = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "eas.json"), "utf8"));
const cfg = eas.submit.production.ios;
const APP_ID = cfg.ascAppId;
const BASE = "https://api.appstoreconnect.apple.com";
function b64url(buf) {
return Buffer.from(buf).toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
function token() {
const now = Math.floor(Date.now() / 1000);
const header = b64url(JSON.stringify({ alg: "ES256", kid: cfg.ascApiKeyId, typ: "JWT" }));
const payload = b64url(JSON.stringify({ iss: cfg.ascApiKeyIssuerId, iat: now, exp: now + 19 * 60, aud: "appstoreconnect-v1" }));
const key = fs.readFileSync(cfg.ascApiKeyPath, "utf8");
const sig = crypto.sign("sha256", Buffer.from(`${header}.${payload}`), { key, dsaEncoding: "ieee-p1363" });
return `${header}.${payload}.${b64url(sig)}`;
}
async function api(method, p, body, extraHeaders = {}) {
const res = await fetch(p.startsWith("http") ? p : BASE + p, {
method,
headers: { Authorization: `Bearer ${token()}`, "Content-Type": "application/json", ...extraHeaders },
body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body),
});
const text = await res.text();
let json;
try { json = text ? JSON.parse(text) : null; } catch { json = text; }
if (!res.ok) throw new Error(`${method} ${p} -> ${res.status}\n${JSON.stringify(json, null, 2)}`);
return json;
}
module.exports = { api, APP_ID, token };
if (require.main === module) {
const [, , method, p, body] = process.argv;
if (!method || !p) { console.error("usage: asc.js <get|post|patch|delete> <path> [json]"); process.exit(1); }
api(method.toUpperCase(), p, body).then((r) => console.log(JSON.stringify(r, null, 2))).catch((e) => { console.error(e.message); process.exit(1); });
}

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