Compare commits

...

5 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
marcuspaico
4908684b07 ci: build on VPS runner while desktop is offline
All checks were successful
Build & Release APK / build (push) Successful in 56m28s
Desktop runner (marcusDesktop) is off; runs 4-5 queued indefinitely.
ubuntu-latest label targets the VPS docker runner so releases ship now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 12:16:42 -07:00
marcuspaico
22c761cedc feat(ui): design polish pass — guide disc, button states, done screen
Some checks failed
Build & Release APK / build (push) Has been cancelled
- GuideDisc breathes with work/rest inside the sweep ring; flicks
  pulse at flick rhythm since each 1s step re-triggers inflate
- Button variants (primary/secondary/danger) with pressed feedback
- Done screen: spring-in check ring + session stats card
- Settings: round -/+ stepper, disabled at 5-30s bounds
- Home hero glow, shared Card, drawn SVG gear replaces emoji

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 12:12:09 -07:00
7fc5be82be Redesign session UX: progress ring, round markers, red theme
Some checks failed
Build & Release APK / build (push) Has been cancelled
- Animated ring sweeps the timer edge in proportion to the current step
  (react-native-svg), matching the meditation-timer idiom
- Per-block round markers: circle = squeeze, square = push, X = quick
  flicks; small interleaved dots distinguish rest rounds from work rounds
- Stamena-style red theme throughout (bg, accent, splash, adaptive icon)
- Home screen legend teaching the shape vocabulary

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 15:25:56 -07:00
71 changed files with 1274 additions and 159 deletions

View File

@@ -6,10 +6,11 @@ on:
jobs:
build:
# RN/NDK builds starve the 4-core VPS — run on the desktop runner
# (marcusDesktop). Jobs queue while the desktop is off and start when it
# comes online; switch back to ubuntu-latest to build on the VPS.
runs-on: desktop
# RN/NDK builds starve the 4-core VPS, so `desktop` (marcusDesktop) is the
# preferred runner — but jobs queue while the desktop is off. On
# ubuntu-latest the VPS builds immediately; flip back when the desktop is
# reliably online.
runs-on: ubuntu-latest
container:
image: eclipse-temurin:21-jdk
steps:
@@ -60,6 +61,10 @@ jobs:
run: |
export ANDROID_HOME="$HOME/android-sdk"
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
./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)
/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.
4. **Quick pushes** — 10 × 1s burst pushes with 1s release.
Progress (session count, difficulty adjustment) lives in AsyncStorage — no
server, no account.
Progress (session count, difficulty adjustment, completion history) lives in
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
- `src/app/` — expo-router screens: `index` (home), `session` (timer), `settings`
- `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
- `android/` — committed prebuild output; CI builds it directly
@@ -31,12 +33,42 @@ server, no account.
npm install
npm start # Expo dev server
npm run typecheck
npm test # node --test over src/lib/*.test.ts
```
## Release
Push to `main` → Gitea Actions on the desktop runner (`runs-on: desktop`,
never the VPS) builds a signed APK and publishes a Gitea release — install via
Obtainium. Keystore: `~/.android-keys/kegel-release.jks` (repo secrets
`KEYSTORE_B64`, `KEYSTORE_PASSWORD`; alias `kegel`). iOS runs via
`npx expo run:ios` from a Mac.
### Android APK (Gitea / Obtainium)
Push to `main` → Gitea Actions builds a signed APK and publishes a Gitea
release — install via Obtainium. `android/app/build.gradle` keeps literal
`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'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode ((System.getenv("VERSION_CODE") ?: "1").toInteger())
versionName (System.getenv("VERSION_NAME") ?: "1.0-dev")
versionCode 1
versionName "1.0.0"
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>
<color name="splashscreen_background">#0F1115</color>
<color name="iconBackground">#0F1115</color>
<color name="splashscreen_background">#160B0B</color>
<color name="iconBackground">#160B0B</color>
<color name="colorPrimary">#023c69</color>
<color name="activityBackground">#0F1115</color>
<color name="activityBackground">#160B0B</color>
</resources>

View File

@@ -7,13 +7,17 @@
"icon": "./assets/images/icon.png",
"scheme": "kegel",
"userInterfaceStyle": "dark",
"backgroundColor": "#0F1115",
"backgroundColor": "#160B0B",
"ios": {
"bundleIdentifier": "com.marcus.kegel"
"bundleIdentifier": "com.marcus.kegel",
"supportsTablet": false,
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#0F1115",
"backgroundColor": "#160B0B",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
@@ -25,7 +29,7 @@
[
"expo-splash-screen",
{
"backgroundColor": "#0F1115",
"backgroundColor": "#160B0B",
"image": "./assets/images/splash-icon.png",
"imageWidth": 120
}
@@ -34,6 +38,13 @@
"experiments": {
"typedRoutes": 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": {}
}
}
}

205
package-lock.json generated
View File

@@ -23,9 +23,11 @@
"react-native": "0.86.2",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
"react-native-web": "^0.21.2"
},
"devDependencies": {
"@types/node": "^26.4.1",
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
}
@@ -64,7 +66,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -561,6 +562,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
"integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
@@ -949,6 +951,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
"integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
@@ -964,6 +967,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
"integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
@@ -995,6 +999,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz",
"integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
@@ -1039,6 +1044,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz",
"integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
@@ -1054,6 +1060,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz",
"integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
@@ -2085,6 +2092,7 @@
"resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.86.2.tgz",
"integrity": "sha512-4XKEJ6jKW9lXMB1O5o47gBoGgolde1fbX13gLW/erlcn+1ky+MHHo5UjuM3RWGdPJHIvIzekDDumSUHhB9x5iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/plugin-proposal-export-default-from": "^7.24.7",
@@ -2247,6 +2255,7 @@
"resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.86.2.tgz",
"integrity": "sha512-mX1wgLErdb2hDgXJr9zM9SWLe+ZteZTFTwRWGOQ53yEJwc9DVSnxdTlAtVdMeOj2ntycKh0R8jYdat2Am43cwQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@react-native/babel-preset": "0.86.2",
@@ -2265,6 +2274,7 @@
"resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.86.2.tgz",
"integrity": "sha512-hJno256j+MS0b3JD1aD3ouTGZVacKNVBuXL2atMQQ8BZ060vl1ptnZ83y569aDW+/rgFSOcqn6ydKeSz4uUKQQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-native/js-polyfills": "0.86.2",
"@react-native/metro-babel-transformer": "0.86.2",
@@ -2315,6 +2325,7 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -2334,6 +2345,7 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -2346,6 +2358,7 @@
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -2359,7 +2372,8 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
@@ -2403,7 +2417,8 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
@@ -2430,9 +2445,9 @@
}
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"version": "26.4.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz",
"integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
@@ -2443,7 +2458,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2453,6 +2467,7 @@
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
"integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/react": "*"
}
@@ -2772,6 +2787,12 @@
"node": ">=0.6"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/bplist-creator": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@@ -2836,7 +2857,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
@@ -3201,6 +3221,56 @@
"hyphenate-style-name": "^1.0.3"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-tree/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -3313,7 +3383,63 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
@@ -3342,6 +3468,18 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-stack-parser": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
@@ -3410,7 +3548,6 @@
"resolved": "https://registry.npmjs.org/expo/-/expo-57.0.13.tgz",
"integrity": "sha512-oJnPSAcQYDIXTJowL9fm6SvAU5CU0P+u1WtWM9a/7uUmeCrkcVKoaGstwF+UY3GoM11S7FO4KKmRl1aU12CTKA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.0",
"@expo/cli": "^57.0.15",
@@ -3473,7 +3610,6 @@
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.11.tgz",
"integrity": "sha512-gpSZ13Nfx0BOc9ALjG1SMhks5csb8Z6ncEVdhDgv0NRrmJcHPRvyUlWaKjRjzwW730XrQ6PIXFRcnNmAxOLj5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@expo/env": "~2.4.2"
},
@@ -3487,7 +3623,6 @@
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.1.tgz",
"integrity": "sha512-QyS9L1Kh9sKJg4gfU6rdbpxpmH+DyzBX8z6jVvXMUDoqLr1GqmkO/Wu379KCXjL///kWbhpNlbi7AgBuj4VdIQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"fontfaceobserver": "^2.1.0"
},
@@ -3532,7 +3667,6 @@
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.6.tgz",
"integrity": "sha512-NdKjEGk+ywwGRNvgQCq9KettM1eVHfR1+uLov+0ohb6rmmMEgnstfLcc3f0DKMQ0PnROsY5G5+ZkNeiairYRXA==",
"license": "MIT",
"peer": true,
"dependencies": {
"expo-constants": "~57.0.11",
"invariant": "^2.2.4"
@@ -3750,7 +3884,6 @@
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.3.tgz",
"integrity": "sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.16.0"
}
@@ -4148,7 +4281,6 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -5197,6 +5329,7 @@
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -5216,6 +5349,12 @@
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
"license": "Apache-2.0"
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -5740,6 +5879,18 @@
"node": "^16.14.0 || >=18.0.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nullthrows": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@@ -6183,7 +6334,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6203,7 +6353,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -6240,7 +6389,6 @@
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.86.2.tgz",
"integrity": "sha512-zbJXGZpwfZGA79Z9ob6Atvfx4nAQL8yJBa35s58E4Oo+khPykfQP2sTeumkKbjwajFYfVayg8pj7Il9nIfTk7A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-native/assets-registry": "0.86.2",
"@react-native/codegen": "0.86.2",
@@ -6331,6 +6479,7 @@
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
"integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
@@ -6357,7 +6506,6 @@
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz",
"integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
@@ -6377,12 +6525,26 @@
"react-native": "*"
}
},
"node_modules/react-native-svg": {
"version": "15.15.4",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz",
"integrity": "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3",
"warn-once": "0.1.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-web": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.6",
"@react-native/normalize-colors": "^0.74.1",
@@ -6451,7 +6613,6 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7191,7 +7352,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -7259,7 +7419,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@@ -18,9 +18,11 @@
"react-native": "0.86.2",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
"react-native-web": "^0.21.2"
},
"devDependencies": {
"@types/node": "^26.4.1",
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
},
@@ -28,7 +30,8 @@
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "node --test src/lib/*.test.ts"
},
"private": true
}

View File

@@ -1,9 +1,20 @@
import { Link, Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import { Pressable } from "react-native";
import Svg, { Circle, Path } from "react-native-svg";
import { hydrate } from "../lib/store";
import { C } from "../lib/theme";
function GearIcon() {
return (
<Svg width={22} height={22} viewBox="0 0 24 24" fill="none" stroke={C.muted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<Circle cx={12} cy={12} r={3} />
<Path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</Svg>
);
}
export default function RootLayout() {
useEffect(() => {
void hydrate();
@@ -25,8 +36,10 @@ export default function RootLayout() {
options={{
title: "Kegel",
headerRight: () => (
<Link href="/settings" style={{ paddingHorizontal: 8, fontSize: 18 }}>
⚙️
<Link href="/settings" asChild>
<Pressable style={({ pressed }) => ({ paddingHorizontal: 8, paddingVertical: 4, opacity: pressed ? 0.6 : 1 })} hitSlop={8}>
<GearIcon />
</Pressable>
</Link>
),
}}

View File

@@ -1,27 +1,33 @@
import { useRouter } from "expo-router";
import { StyleSheet, Text, View } from "react-native";
import { Button, StatRow } from "../components/ui";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { ShapeIcon } from "../components/session-viz";
import { StreakCard } from "../components/streak-card";
import { Button, Card, StatRow } from "../components/ui";
import { currentHold } from "../lib/store";
import { C } from "../lib/theme";
import { alpha, C } from "../lib/theme";
import { useStore } from "../lib/useStore";
import { MAX_HOLD, SESSIONS_PER_INCREMENT, sessionMinutes } from "../lib/workout";
import { FLICK_REPS, HOLD_REPS, MAX_HOLD, PUSH_REPS, SESSIONS_PER_INCREMENT, sessionMinutes } from "../lib/workout";
export default function Home() {
const router = useRouter();
const { sessionsCompleted, lastSessionAt, hydrated } = useStore();
const { sessionsCompleted, lastSessionAt, sessions, hydrated } = useStore();
if (!hydrated) return <View style={s.screen} />;
const hold = currentHold();
const untilNext = SESSIONS_PER_INCREMENT - (sessionsCompleted % SESSIONS_PER_INCREMENT);
return (
<View style={s.screen}>
<ScrollView style={s.screen} contentContainerStyle={s.content} showsVerticalScrollIndicator={false}>
<View style={s.hero}>
<View style={s.glowOuter} />
<View style={s.glowInner} />
<Text style={s.holdValue}>{hold}s</Text>
<Text style={s.holdLabel}>current hold</Text>
</View>
<View style={s.card}>
<StreakCard sessions={sessions} />
<Card>
<StatRow label="Sessions completed" value={String(sessionsCompleted)} />
<StatRow
label="Next hold increase"
@@ -29,7 +35,22 @@ export default function Home() {
/>
<StatRow label="Session length" value={`~${sessionMinutes(hold)} min`} />
<StatRow label="Last session" value={lastSessionAt ? new Date(lastSessionAt).toLocaleDateString() : "—"} />
</View>
</Card>
<Card>
<View style={s.legendRow}>
<ShapeIcon shape="circle" size={12} color={C.accent} />
<Text style={s.legendText}>Squeeze & hold — {HOLD_REPS} reps</Text>
</View>
<View style={s.legendRow}>
<ShapeIcon shape="square" size={12} color={C.push} />
<Text style={s.legendText}>Push out — {PUSH_REPS} reps</Text>
</View>
<View style={[s.legendRow, s.legendLast]}>
<ShapeIcon shape="x" size={12} color={C.accent} />
<Text style={s.legendText}>Quick squeezes & pushes — {FLICK_REPS} each</Text>
</View>
</Card>
<Button title="Start session" onPress={() => router.push("/session")} />
@@ -37,23 +58,27 @@ export default function Home() {
Squeeze = lift and contract, as if stopping urine mid-flow. Push = gently bear down, as when starting to
urinate. Keep breathing throughout — never strain.
</Text>
</View>
</ScrollView>
);
}
const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: C.bg, padding: 20 },
hero: { alignItems: "center", paddingVertical: 28 },
holdValue: { color: C.accent, fontSize: 72, fontWeight: "700", fontVariant: ["tabular-nums"] },
holdLabel: { color: C.muted, fontSize: 14, textTransform: "uppercase", letterSpacing: 1 },
card: {
backgroundColor: C.surface,
borderRadius: 12,
borderWidth: 1,
borderColor: C.border,
paddingHorizontal: 16,
paddingVertical: 4,
marginBottom: 20,
screen: { flex: 1, backgroundColor: C.bg },
content: { padding: 20, paddingBottom: 32 },
hero: { alignItems: "center", justifyContent: "center", paddingVertical: 32 },
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) },
holdValue: { color: C.accent, fontSize: 72, fontWeight: "800", fontVariant: ["tabular-nums"], letterSpacing: -1 },
holdLabel: { color: C.muted, fontSize: 13, textTransform: "uppercase", letterSpacing: 1.6, marginTop: 2 },
legendRow: {
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingVertical: 11,
borderBottomWidth: 1,
borderBottomColor: C.border,
},
legendLast: { borderBottomWidth: 0 },
legendText: { color: C.text, fontSize: 14 },
hint: { color: C.muted, fontSize: 13, lineHeight: 19, marginTop: 8 },
});

View File

@@ -2,10 +2,12 @@ import * as Haptics from "expo-haptics";
import { useKeepAwake } from "expo-keep-awake";
import { useRouter } from "expo-router";
import { useEffect, useMemo, useRef, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import { Animated, StyleSheet, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Button } from "../components/ui";
import { BLOCK_SHAPE, DoneCheck, GuideDisc, ProgressRing, RoundDots, ShapeIcon, useStepProgress } from "../components/session-viz";
import { Button, Card, StatRow } from "../components/ui";
import { completeSession, currentHold, getState } from "../lib/store";
import { computeStreaks, dayKey, sessionDays } from "../lib/streak";
import { C } from "../lib/theme";
import { buildSession, SESSIONS_PER_INCREMENT, type StepKind } from "../lib/workout";
@@ -15,7 +17,7 @@ const KIND_COLOR: Record<StepKind, string> = {
push: C.push,
"flick-squeeze": C.accent,
"flick-push": C.push,
relax: C.muted,
relax: C.rest,
rest: C.rest,
};
@@ -24,6 +26,8 @@ function stepHaptic(kind: StepKind) {
void Haptics.impactAsync(work ? Haptics.ImpactFeedbackStyle.Heavy : Haptics.ImpactFeedbackStyle.Light);
}
const RING_SIZE = 300;
export default function Session() {
useKeepAwake();
const router = useRouter();
@@ -39,6 +43,16 @@ export default function Session() {
const [done, setDone] = useState(false);
const recorded = useRef(false);
const progress = useStepProgress(i, remaining, paused, done);
// Small pop on the countdown when a new step starts, so the cue change
// registers even in peripheral vision.
const pop = useRef(new Animated.Value(1)).current;
useEffect(() => {
pop.setValue(1.08);
Animated.spring(pop, { toValue: 1, friction: 5, tension: 120, useNativeDriver: true }).start();
}, [i, pop]);
useEffect(() => {
if (paused || done) return;
const id = setInterval(() => setRemaining((r) => r - 1), 1000);
@@ -64,17 +78,24 @@ export default function Session() {
}, [remaining, done, i, steps]);
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 untilNext = SESSIONS_PER_INCREMENT - (sessionsCompleted % SESSIONS_PER_INCREMENT);
return (
<SafeAreaView style={s.screen}>
<View style={s.center}>
<Text style={s.doneTitle}>Session complete 💪</Text>
<DoneCheck size={120} />
<Text style={s.doneTitle}>Session complete</Text>
<Text style={s.doneSub}>
{sessionsCompleted} session{sessionsCompleted === 1 ? "" : "s"} completed.
{leveledUp ? ` Holds increase to ${currentHold()}s next time.` : ""}
{leveledUp ? `Holds increase to ${currentHold()}s next time.` : `${untilNext} more session${untilNext === 1 ? "" : "s"} until holds increase.`}
</Text>
</View>
<Card>
<StatRow label="Streak" value={`${streak} day${streak === 1 ? "" : "s"}`} />
<StatRow label="Sessions completed" value={String(sessionsCompleted)} />
<StatRow label="This session" value={`${Math.round(totalSeconds / 60)} min`} />
</Card>
<Button title="Done" onPress={() => router.back()} />
</SafeAreaView>
);
@@ -83,17 +104,23 @@ export default function Session() {
const step = steps[i];
const elapsed = steps.slice(0, i).reduce((s, st) => s + st.seconds, 0) + (step.seconds - remaining);
const color = KIND_COLOR[step.kind];
const shape = BLOCK_SHAPE[step.blockId];
const next = steps[i + 1];
return (
<SafeAreaView style={s.screen}>
<Text style={s.block}>{step.block}</Text>
<RoundDots step={step} />
<View style={s.center}>
<View style={[s.circle, { borderColor: color }]}>
<Text style={[s.label, { color }]}>{step.label}</Text>
<Text style={s.count}>{remaining}</Text>
</View>
<ProgressRing size={RING_SIZE} strokeWidth={10} progress={progress} color={color}>
<GuideDisc size={RING_SIZE - 44} kind={step.kind} stepIndex={i} color={color} />
<View style={s.inner}>
{shape ? <ShapeIcon shape={shape} size={22} color={color} /> : null}
<Text style={[s.label, { color }]}>{step.label}</Text>
<Animated.Text style={[s.count, { transform: [{ scale: pop }] }]}>{remaining}</Animated.Text>
</View>
</ProgressRing>
<Text style={s.next}>{next ? `Next: ${next.label} ${next.seconds}s` : "Last one!"}</Text>
</View>
@@ -101,30 +128,22 @@ export default function Session() {
<View style={[s.progressFill, { width: `${(elapsed / totalSeconds) * 100}%` }]} />
</View>
<Button title={paused ? "Resume" : "Pause"} onPress={() => setPaused((p) => !p)} />
<Button title="End session" danger onPress={() => router.back()} />
<Button title={paused ? "Resume" : "Pause"} variant="secondary" onPress={() => setPaused((p) => !p)} />
<Button title="End session" variant="danger" onPress={() => router.back()} />
</SafeAreaView>
);
}
const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: C.bg, padding: 20 },
block: { color: C.muted, fontSize: 15, textAlign: "center", textTransform: "uppercase", letterSpacing: 1, marginTop: 8 },
block: { color: C.muted, fontSize: 14, textAlign: "center", textTransform: "uppercase", letterSpacing: 1.2, marginTop: 8, marginBottom: 12 },
center: { flex: 1, alignItems: "center", justifyContent: "center" },
circle: {
width: 260,
height: 260,
borderRadius: 130,
borderWidth: 6,
alignItems: "center",
justifyContent: "center",
backgroundColor: C.surface,
},
label: { fontSize: 22, fontWeight: "600", marginBottom: 4 },
inner: { alignItems: "center", justifyContent: "center", gap: 6 },
label: { fontSize: 22, fontWeight: "600" },
count: { color: C.text, fontSize: 80, fontWeight: "700", fontVariant: ["tabular-nums"] },
next: { color: C.muted, fontSize: 15, marginTop: 24 },
doneTitle: { color: C.text, fontSize: 28, fontWeight: "700", marginBottom: 12 },
doneTitle: { color: C.text, fontSize: 28, fontWeight: "700", marginTop: 28, marginBottom: 10 },
doneSub: { color: C.muted, fontSize: 16, textAlign: "center", lineHeight: 23 },
progressTrack: { height: 6, borderRadius: 3, backgroundColor: C.surface2, marginBottom: 16 },
progressTrack: { height: 6, borderRadius: 3, backgroundColor: C.surface2, marginBottom: 16, overflow: "hidden" },
progressFill: { height: 6, borderRadius: 3, backgroundColor: C.accent },
});

View File

@@ -1,10 +1,22 @@
import { Alert, StyleSheet, Text, View } from "react-native";
import { Button, StatRow } from "../components/ui";
import { Alert, Pressable, StyleSheet, Text, View } from "react-native";
import { Button, Card, StatRow } from "../components/ui";
import { adjustHold, currentHold, resetProgress } from "../lib/store";
import { C } from "../lib/theme";
import { useStore } from "../lib/useStore";
import { MAX_HOLD, MIN_HOLD } from "../lib/workout";
function StepperButton({ label, disabled, onPress }: { label: string; disabled: boolean; onPress: () => void }) {
return (
<Pressable
style={({ pressed }) => [s.step, disabled && s.stepDisabled, pressed && !disabled && s.stepPressed]}
onPress={onPress}
disabled={disabled}
>
<Text style={[s.stepText, disabled && s.stepTextDisabled]}>{label}</Text>
</Pressable>
);
}
export default function Settings() {
const { sessionsCompleted, holdAdjust } = useStore();
const hold = currentHold();
@@ -12,27 +24,26 @@ export default function Settings() {
return (
<View style={s.screen}>
<Text style={s.section}>Difficulty</Text>
<View style={s.card}>
<StatRow label="Current hold" value={`${hold}s`} />
<Card style={{ marginBottom: 12 }}>
<View style={s.stepperRow}>
<StepperButton label="−" disabled={hold <= MIN_HOLD} onPress={() => adjustHold(-1)} />
<View style={s.stepperValue}>
<Text style={s.holdValue}>{hold}s</Text>
<Text style={s.holdLabel}>hold length</Text>
</View>
<StepperButton label="+" disabled={hold >= MAX_HOLD} onPress={() => adjustHold(1)} />
</View>
<StatRow label="Manual adjustment" value={`${holdAdjust >= 0 ? "+" : ""}${holdAdjust}s`} />
</View>
<View style={s.row}>
<View style={s.half}>
<Button title="Easier −1s" onPress={() => hold > MIN_HOLD && adjustHold(-1)} />
</View>
<View style={s.half}>
<Button title="Harder +1s" onPress={() => hold < MAX_HOLD && adjustHold(1)} />
</View>
</View>
</Card>
<Text style={s.hint}>
Holds grow automatically by 1s every 3 completed sessions ({MIN_HOLD}–{MAX_HOLD}s). Use these if a session
Holds grow automatically by 1s every 3 completed sessions ({MIN_HOLD}–{MAX_HOLD}s). Adjust here if a session
feels too easy or too hard — the adjustment stacks with the automatic progression.
</Text>
<Text style={s.section}>Progress</Text>
<Button
title="Reset progress"
danger
variant="danger"
onPress={() =>
Alert.alert("Reset progress?", `This clears ${sessionsCompleted} completed sessions and starts over at ${MIN_HOLD}s holds.`, [
{ text: "Cancel", style: "cancel" },
@@ -46,17 +57,24 @@ export default function Settings() {
const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: C.bg, padding: 20 },
section: { color: C.muted, fontSize: 13, textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 8, marginTop: 12 },
card: {
backgroundColor: C.surface,
borderRadius: 12,
section: { color: C.muted, fontSize: 13, textTransform: "uppercase", letterSpacing: 1.2, marginBottom: 8, marginTop: 12 },
stepperRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: 14 },
stepperValue: { alignItems: "center" },
holdValue: { color: C.accent, fontSize: 34, fontWeight: "700", fontVariant: ["tabular-nums"] },
holdLabel: { color: C.muted, fontSize: 12, textTransform: "uppercase", letterSpacing: 1.2, marginTop: 2 },
step: {
width: 52,
height: 52,
borderRadius: 26,
backgroundColor: C.surface2,
borderWidth: 1,
borderColor: C.border,
paddingHorizontal: 16,
paddingVertical: 4,
marginBottom: 12,
alignItems: "center",
justifyContent: "center",
},
row: { flexDirection: "row", gap: 12 },
half: { flex: 1 },
stepPressed: { opacity: 0.7, transform: [{ scale: 0.95 }] },
stepDisabled: { opacity: 0.35 },
stepText: { color: C.text, fontSize: 26, fontWeight: "600", lineHeight: 30 },
stepTextDisabled: { color: C.muted },
hint: { color: C.muted, fontSize: 13, lineHeight: 19, marginBottom: 12 },
});

View File

@@ -0,0 +1,220 @@
import { ReactNode, useEffect, useRef } from "react";
import { Animated, Easing, StyleSheet, View } from "react-native";
import Svg, { Circle, Path } from "react-native-svg";
import { alpha, C } from "../lib/theme";
import type { BlockId, Step, StepKind } from "../lib/workout";
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
/** Shape language: ○ = squeeze (in), □ = push (out), ✕ = quick flicks. */
export type Shape = "circle" | "square" | "x";
export const BLOCK_SHAPE: Record<BlockId, Shape | null> = {
warmup: null,
holds: "circle",
pushes: "square",
"flick-squeezes": "x",
"flick-pushes": "x",
};
const BLOCK_COLOR: Record<BlockId, string> = {
warmup: C.muted,
holds: C.accent,
pushes: C.push,
"flick-squeezes": C.accent,
"flick-pushes": C.push,
};
export function ShapeIcon({ shape, size, color, outline }: { shape: Shape; size: number; color: string; outline?: boolean }) {
if (shape === "x") {
const bar = { position: "absolute" as const, width: size * 1.25, height: Math.max(2, size * 0.26), borderRadius: size, backgroundColor: color };
return (
<View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
<View style={[bar, { transform: [{ rotate: "45deg" }] }]} />
<View style={[bar, { transform: [{ rotate: "-45deg" }] }]} />
</View>
);
}
return (
<View
style={{
width: size,
height: size,
borderRadius: shape === "circle" ? size / 2 : Math.max(2, size * 0.18),
backgroundColor: outline ? "transparent" : color,
borderWidth: outline ? 1.5 : 0,
borderColor: color,
}}
/>
);
}
/**
* Ring that sweeps around the timer edge in proportion to the current step —
* same idiom as the meditation app. `progress` runs 0→1 over the step.
*/
export function ProgressRing({
size,
strokeWidth,
progress,
color,
children,
}: {
size: number;
strokeWidth: number;
progress: Animated.Value;
color: string;
children: ReactNode;
}) {
const r = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * r;
return (
<View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
<Svg width={size} height={size} style={StyleSheet.absoluteFill}>
<Circle cx={size / 2} cy={size / 2} r={r} stroke={C.surface2} strokeWidth={strokeWidth} fill={C.surface} />
<AnimatedCircle
cx={size / 2}
cy={size / 2}
r={r}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
fill="none"
strokeDasharray={`${circumference}`}
strokeDashoffset={progress.interpolate({ inputRange: [0, 1], outputRange: [circumference, 0] })}
rotation={-90}
origin={`${size / 2}, ${size / 2}`}
/>
</Svg>
{children}
</View>
);
}
/**
* Breathing-guide disc inside the ring: inflates on work (squeeze/push/flick),
* deflates on rest. Each 1s flick step re-triggers the inflate, so flick blocks
* pulse at the flick rhythm for free.
*/
export function GuideDisc({ size, kind, stepIndex, color }: { size: number; kind: StepKind; stepIndex: number; color: string }) {
const work = kind === "squeeze" || kind === "push" || kind === "flick-squeeze" || kind === "flick-push";
const scale = useRef(new Animated.Value(0.6)).current;
useEffect(() => {
const anim = Animated.timing(scale, {
toValue: work ? 1 : 0.6,
duration: work ? 650 : 450,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
});
anim.start();
return () => anim.stop();
}, [stepIndex, work, scale]);
return (
<Animated.View
pointerEvents="none"
style={{
position: "absolute",
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: alpha(color, work ? 0.14 : 0.07),
borderWidth: 1,
borderColor: alpha(color, work ? 0.4 : 0.18),
transform: [{ scale }],
}}
/>
);
}
/** Session-complete mark: accent ring with a check, springing in once. */
export function DoneCheck({ size }: { size: number }) {
const pop = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.spring(pop, { toValue: 1, friction: 5, tension: 60, useNativeDriver: true }).start();
}, [pop]);
return (
<Animated.View style={{ transform: [{ scale: pop }], opacity: pop }}>
<Svg width={size} height={size} viewBox="0 0 100 100">
<Circle cx={50} cy={50} r={46} stroke={C.accent} strokeWidth={5} fill={alpha(C.accent, 0.1)} />
<Path d="M31 52 L45 66 L70 37" stroke={C.text} strokeWidth={7} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</Animated.View>
);
}
/** Drives a ProgressRing: restarts on each step, freezes on pause. */
export function useStepProgress(stepIndex: number, remaining: number, paused: boolean, done: boolean) {
const progress = useRef(new Animated.Value(0)).current;
const lastStep = useRef(-1);
const remainingRef = useRef(remaining);
remainingRef.current = remaining;
useEffect(() => {
if (done) return;
if (lastStep.current !== stepIndex) {
lastStep.current = stepIndex;
progress.setValue(0);
}
if (paused) return;
const anim = Animated.timing(progress, {
toValue: 1,
duration: remainingRef.current * 1000,
easing: Easing.linear,
useNativeDriver: false,
});
anim.start();
return () => anim.stop();
}, [stepIndex, paused, done, progress]);
return progress;
}
/**
* Where the round is out of the block: one marker per rep in the block's own
* shape, with small interleaved dots for the rests between hold/push reps so
* rest rounds read differently from work rounds. Flick blocks skip the 1s
* relax gaps — 10 markers is already the ceiling of glanceable.
*/
export function RoundDots({ step }: { step: Step }) {
const shape = BLOCK_SHAPE[step.blockId];
if (!shape) return <View style={d.row} />;
const color = BLOCK_COLOR[step.blockId];
const showRests = step.blockId === "holds" || step.blockId === "pushes";
const working = step.kind !== "rest" && step.kind !== "relax";
const markers: ReactNode[] = [];
for (let r = 1; r <= step.repsTotal; r++) {
const isCurrent = r === step.rep && working;
const isDone = r < step.rep || (r === step.rep && !working);
markers.push(
<View key={`w${r}`} style={isCurrent ? d.current : undefined}>
<ShapeIcon shape={shape} size={isCurrent ? 15 : 11} color={isDone || isCurrent ? color : C.border} outline={!isDone && !isCurrent} />
</View>,
);
if (showRests && r < step.repsTotal) {
const restCurrent = r === step.rep && !working;
markers.push(
<View
key={`r${r}`}
style={{
width: restCurrent ? 7 : 4,
height: restCurrent ? 7 : 4,
borderRadius: 4,
backgroundColor: r < step.rep ? C.muted : restCurrent ? C.rest : C.border,
}}
/>,
);
}
}
return <View style={d.row}>{markers}</View>;
}
const d = StyleSheet.create({
row: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 7, minHeight: 20 },
current: { transform: [{ scale: 1.15 }] },
});

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

@@ -1,21 +1,36 @@
import { Pressable, StyleSheet, Text, View } from "react-native";
import { C } from "../lib/theme";
import { Pressable, StyleSheet, Text, View, ViewStyle } from "react-native";
import { alpha, C } from "../lib/theme";
type Variant = "primary" | "secondary" | "danger";
const VARIANT_STYLE: Record<Variant, { box: ViewStyle; text: { color: string } }> = {
primary: { box: { backgroundColor: C.accent }, text: { color: C.text } },
secondary: {
box: { backgroundColor: C.surface, borderWidth: 1, borderColor: C.border },
text: { color: C.text },
},
danger: {
box: { backgroundColor: "transparent", borderWidth: 1, borderColor: alpha(C.danger, 0.55) },
text: { color: C.danger },
},
};
export function Button({
title,
onPress,
danger,
variant = "primary",
}: {
title: string;
onPress: () => void;
danger?: boolean;
variant?: Variant;
}) {
const v = VARIANT_STYLE[variant];
return (
<Pressable
style={[u.button, danger && { backgroundColor: "transparent", borderColor: C.danger, borderWidth: 1 }]}
style={({ pressed }) => [u.button, v.box, pressed && u.pressed]}
onPress={onPress}
>
<Text style={[u.buttonText, danger && { color: C.danger }]}>{title}</Text>
<Text style={[u.buttonText, v.text]}>{title}</Text>
</Pressable>
);
}
@@ -29,23 +44,36 @@ export function StatRow({ label, value }: { label: string; value: string }) {
);
}
export function Card({ children, style }: { children: React.ReactNode; style?: ViewStyle }) {
return <View style={[u.card, style]}>{children}</View>;
}
const u = StyleSheet.create({
button: {
backgroundColor: C.accent,
borderRadius: 10,
padding: 14,
borderRadius: 12,
padding: 15,
alignItems: "center",
marginTop: 8,
marginBottom: 12,
},
buttonText: { color: C.text, fontSize: 16, fontWeight: "600" },
pressed: { opacity: 0.8, transform: [{ scale: 0.98 }] },
buttonText: { fontSize: 16, fontWeight: "600", letterSpacing: 0.2 },
statRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 10,
paddingVertical: 11,
borderBottomWidth: 1,
borderBottomColor: C.border,
},
statLabel: { color: C.muted, fontSize: 15 },
statValue: { color: C.text, fontSize: 15, fontWeight: "600" },
statValue: { color: C.text, fontSize: 15, fontWeight: "600", fontVariant: ["tabular-nums"] },
card: {
backgroundColor: C.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: C.border,
paddingHorizontal: 16,
paddingVertical: 4,
marginBottom: 20,
},
});

View File

@@ -8,12 +8,14 @@ export interface State {
lastSessionAt: string | null; // ISO date
/** Manual difficulty tweak from settings, in seconds (can be negative). */
holdAdjust: number;
/** ISO timestamps of every completed session — drives streaks/history. */
sessions: string[];
hydrated: boolean;
}
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>();
export function getState(): State {
@@ -34,6 +36,7 @@ function setState(patch: Partial<State>) {
sessionsCompleted: state.sessionsCompleted,
lastSessionAt: state.lastSessionAt,
holdAdjust: state.holdAdjust,
sessions: state.sessions,
}),
);
}
@@ -42,7 +45,8 @@ export async function hydrate() {
if (state.hydrated) return;
const raw = await AsyncStorage.getItem(KEY);
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();
}
@@ -51,7 +55,8 @@ export function currentHold(): number {
}
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) {
@@ -59,5 +64,5 @@ export function adjustHold(delta: number) {
}
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 };
}

View File

@@ -1,12 +1,25 @@
// Stamena-style red theme: near-black red-tinted ground, saturated red as the
// primary work color, warm amber for reverse-kegel pushes so the two moves
// never read as the same cue.
export const C = {
bg: "#0F1115",
surface: "#1A1D24",
surface2: "#232733",
border: "#2C313D",
text: "#E6E8EC",
muted: "#8A909C",
accent: "#8B5CF6", // squeeze / primary
push: "#F59E0B", // reverse kegel (push out)
rest: "#4CAF7D",
danger: "#E05B5B",
bg: "#160B0B",
surface: "#241213",
surface2: "#2F1718",
border: "#42201F",
text: "#F7ECEA",
muted: "#A98A86",
accent: "#E7402E", // squeeze / primary
push: "#FF9D3B", // reverse kegel (push out)
rest: "#8B7573", // recovery — deliberately dull so work steps pop
danger: "#FF5D5D",
};
/** Append an alpha byte to a #RRGGBB token — RN accepts #RRGGBBAA. */
export function alpha(color: string, a: number): string {
return (
color +
Math.round(a * 255)
.toString(16)
.padStart(2, "0")
);
}

View File

@@ -10,12 +10,18 @@ export type StepKind =
| "relax" // 1s gap between flicks
| "rest"; // recovery between reps/blocks
export type BlockId = "warmup" | "holds" | "pushes" | "flick-squeezes" | "flick-pushes";
export interface Step {
kind: StepKind;
label: string;
seconds: number;
/** Block title shown as secondary context, e.g. "Holds 3/8". */
block: string;
blockId: BlockId;
/** Which rep of the block this step belongs to (rests carry the rep they follow). */
rep: number;
repsTotal: number;
}
export const MIN_HOLD = 5;
@@ -34,9 +40,9 @@ function pushSeconds(hold: number): number {
return Math.min(12, MIN_HOLD + Math.floor((hold - MIN_HOLD) / 2));
}
const HOLD_REPS = 8;
const PUSH_REPS = 5;
const FLICK_REPS = 10;
export const HOLD_REPS = 8;
export const PUSH_REPS = 5;
export const FLICK_REPS = 10;
const BLOCK_REST = 20;
export function buildSession(hold: number): Step[] {
@@ -44,33 +50,33 @@ export function buildSession(hold: number): Step[] {
const repRest = Math.max(4, Math.round(hold * 0.75));
const push = pushSeconds(hold);
steps.push({ kind: "ready", label: "Get ready", seconds: 5, block: "Warm up" });
steps.push({ kind: "ready", label: "Get ready", seconds: 5, block: "Warm up", blockId: "warmup", rep: 0, repsTotal: 0 });
// Block 1: squeeze-and-hold (the progressive-overload driver).
for (let r = 1; r <= HOLD_REPS; r++) {
steps.push({ kind: "squeeze", label: "Squeeze & hold", seconds: hold, block: `Holds ${r}/${HOLD_REPS}` });
if (r < HOLD_REPS) steps.push({ kind: "rest", label: "Rest", seconds: repRest, block: `Holds ${r}/${HOLD_REPS}` });
steps.push({ kind: "squeeze", label: "Squeeze & hold", seconds: hold, block: `Holds ${r}/${HOLD_REPS}`, blockId: "holds", rep: r, repsTotal: HOLD_REPS });
if (r < HOLD_REPS) steps.push({ kind: "rest", label: "Rest", seconds: repRest, block: `Holds ${r}/${HOLD_REPS}`, blockId: "holds", rep: r, repsTotal: HOLD_REPS });
}
steps.push({ kind: "rest", label: "Rest", seconds: BLOCK_REST, block: "Holds done" });
steps.push({ kind: "rest", label: "Rest", seconds: BLOCK_REST, block: "Holds done", blockId: "holds", rep: HOLD_REPS, repsTotal: HOLD_REPS });
// Block 2: reverse kegels — gently push out, as when starting to urinate.
for (let r = 1; r <= PUSH_REPS; r++) {
steps.push({ kind: "push", label: "Push out", seconds: push, block: `Pushes ${r}/${PUSH_REPS}` });
if (r < PUSH_REPS) steps.push({ kind: "rest", label: "Rest", seconds: 5, block: `Pushes ${r}/${PUSH_REPS}` });
steps.push({ kind: "push", label: "Push out", seconds: push, block: `Pushes ${r}/${PUSH_REPS}`, blockId: "pushes", rep: r, repsTotal: PUSH_REPS });
if (r < PUSH_REPS) steps.push({ kind: "rest", label: "Rest", seconds: 5, block: `Pushes ${r}/${PUSH_REPS}`, blockId: "pushes", rep: r, repsTotal: PUSH_REPS });
}
steps.push({ kind: "rest", label: "Rest", seconds: BLOCK_REST, block: "Pushes done" });
steps.push({ kind: "rest", label: "Rest", seconds: BLOCK_REST, block: "Pushes done", blockId: "pushes", rep: PUSH_REPS, repsTotal: PUSH_REPS });
// Block 3: quick flicks in — fast full contractions, full release between.
for (let r = 1; r <= FLICK_REPS; r++) {
steps.push({ kind: "flick-squeeze", label: "Squeeze", seconds: 1, block: `Quick squeezes ${r}/${FLICK_REPS}` });
steps.push({ kind: "relax", label: "Relax", seconds: 1, block: `Quick squeezes ${r}/${FLICK_REPS}` });
steps.push({ kind: "flick-squeeze", label: "Squeeze", seconds: 1, block: `Quick squeezes ${r}/${FLICK_REPS}`, blockId: "flick-squeezes", rep: r, repsTotal: FLICK_REPS });
steps.push({ kind: "relax", label: "Relax", seconds: 1, block: `Quick squeezes ${r}/${FLICK_REPS}`, blockId: "flick-squeezes", rep: r, repsTotal: FLICK_REPS });
}
steps.push({ kind: "rest", label: "Rest", seconds: 15, block: "Quick squeezes done" });
steps.push({ kind: "rest", label: "Rest", seconds: 15, block: "Quick squeezes done", blockId: "flick-squeezes", rep: FLICK_REPS, repsTotal: FLICK_REPS });
// Block 4: quick flicks out.
for (let r = 1; r <= FLICK_REPS; r++) {
steps.push({ kind: "flick-push", label: "Push", seconds: 1, block: `Quick pushes ${r}/${FLICK_REPS}` });
steps.push({ kind: "relax", label: "Relax", seconds: 1, block: `Quick pushes ${r}/${FLICK_REPS}` });
steps.push({ kind: "flick-push", label: "Push", seconds: 1, block: `Quick pushes ${r}/${FLICK_REPS}`, blockId: "flick-pushes", rep: r, repsTotal: FLICK_REPS });
steps.push({ kind: "relax", label: "Relax", seconds: 1, block: `Quick pushes ${r}/${FLICK_REPS}`, blockId: "flick-pushes", rep: r, repsTotal: FLICK_REPS });
}
return steps;

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",
"compilerOptions": {
"strict": true,
"allowImportingTsExtensions": true,
"types": ["node"],
"paths": {
"@/*": ["./src/*"]
}