Strautomator
Strava 활동을 자동화해 주는 Strautomator는 IFTTT와 비슷하지만 Strava 전용이다
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
splitmate.
Workout Planner & Tracker. A phone-first web app for planning training splits and logging workouts, with your previous sets beside today's inputs.README 전문 보기 ↗Formerly Splitmate. User-facing text now says NotchLift; internal identifiers (on-device storage keys such as
splitmate:v1:…, the local Supabaseproject_id, fixture accounts) keep the old name on purpose so existing devices and data are unaffected.
Workout Planner & Tracker. A phone-first web app for planning training splits and logging workouts, with your previous sets beside today's inputs.
Built with Next.js 16 (App Router), TypeScript, Tailwind CSS 4 and Supabase (Auth and Postgres). It is designed to be deployed on Vercel.
Requirements: Node 20.9+, Docker (for the local Supabase stack).
npm install
npm run db:start # starts local Supabase, applies migrations, loads the demo seed
cp .env.example .env.local
npx supabase status # copy API URL and Publishable key into .env.local
npm run dev # http://localhost:3000
Local demo account (created only by supabase/seed.sql, local only):
| Password | |
|---|---|
[email protected] | splitmate-demo |
It contains one real reference workout (Chest & back, 21 September 2026) and two splits. Nothing else is invented, and new accounts start empty. The equipment used for the incline press is not confirmed, so it is logged on a custom exercise labelled "Incline press · equipment unconfirmed" rather than a catalogue variation. Once the variation is confirmed, edit the exercise (name, variant, equipment); its history is kept.
Emails sent by the local stack (sign-up confirmation, password reset) are caught by Mailpit at http://127.0.0.1:54324. Email confirmation is on locally, as in production.
| Command | What it does |
|---|---|
npm run dev / build / start | Next.js |
npm run lint / typecheck | ESLint / TypeScript |
npm run test:unit | Pure logic: previous-set matching, session edits, sync engine, storage isolation, timer, formatting |
npm run test:db | Migrations, functions and RLS against the local database (needs db:start) |
npm run test:e2e | Playwright against a production build and local Supabase (run npm run build first; reads the local stack's keys from supabase status). Default project is Chromium; --project=iphone-webkit runs Safari's engine with an iPhone profile where WebKit is installed (e.g. inside mcr.microsoft.com/playwright) |
npm run db:reset | Re-apply all migrations and the local seed |
npm run icons | Regenerate PWA icons |
node scripts/dev-fixtures.mjs | Local only. Creates three fixture accounts (new@, one@, [email protected], password splitmate-fixture): no data, one session, and a realistic active user with an old split, long names, high-rep sets, a custom variant and a quick workout. Refuses to run against anything but localhost |
Use a dedicated Supabase project for NotchLift: the migrations create tables and functions in public, so never point it at another product's project. The app needs no service-role key; all data access runs as the signed-in user under Row Level Security.
splitmate (dashboard → New project). Note its project ref (the <ref> in https://<ref>.supabase.co).npx supabase login # opens a browser; no token goes in the repo
npx supabase link --project-ref <ref> # confirm the prompt names the app's own project (created as "splitmate")
npx supabase db push # applies supabase/migrations/* (schema, functions, catalogue, past workouts, profile backfill, backdated activation, quick workouts)
db push never runs supabase/seed.sql, so no demo data reaches the hosted project.https://<production-domain>https://<production-domain>/auth/confirm
https://<production-domain>/auth/reset
https://*-<vercel-team-slug>.vercel.app/auth/confirm
https://*-<vercel-team-slug>.vercel.app/auth/reset
The last two let Vercel preview deployments use sign-up and password reset. Links whose redirect is not on this list fall back to the Site URL and will not complete.Confirm your NotchLift account, body supabase/templates/confirmation.htmlReset your NotchLift password, body supabase/templates/recovery.htmlsb_publishable_…; the legacy anon key also works).Vercel → Add New → Project → import thompson-ben/gym-app. Framework preset Next.js; build and output settings unchanged.
Environment variables (Production and Preview):
| Name | Value |
|---|---|
NEXT_PUBLIC_SUPABASE_URL | https://<ref>.supabase.co |
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | the publishable key |
Both are public by design (they are sent to the browser); no secret is needed.
Deploy. Put the production domain into Supabase's Site URL and redirect URLs (step 1.4).
Status: no deployment has been made or verified from this repository. The build session had no Supabase or Vercel credentials. All verification so far is against the local Supabase stack.
/auth/confirm and continues to where the person started (for example a shared split)./forgot-password. The response is always the same ("If an account exists for …, we have sent a link"), whether or not the address has an account; only rate limiting is reported./auth/reset, which verifies the one-time token and opens /reset-password to choose a new password. Used, forged or expired links (including Supabase's own otp_expired redirect) return to /forgot-password with "That reset link is invalid or has expired". Opening /reset-password without a recovery session asks for a new link./auth/confirm and /auth/reset on every origin the app is served from (see Deployment step 1.4).The sign-in screen shows Continue with Apple and Continue with Google above the email form, once each provider is listed in the Vercel environment variable AUTH_PROVIDERS (apple, google or apple,google). Leave it unset until the provider is switched on in Supabase, or the button would lead to an error.
/auth/confirm (the same redirect URL as email confirmation), which continues to where the person started. Cancelling on Apple's or Google's screen comes back to sign-in with a message.src/lib/native-apple.ts): Apple's identity token is exchanged with signInWithIdToken (nonce hashed for Apple, raw for Supabase), so nothing redirects. It needs the app's bundle ID in the Apple provider's Client IDs and the Sign in with Apple capability on the Xcode target. Google isn't offered in the app.disallowed_useragent). There, the Google button is replaced by a note on opening the page in Safari or Chrome; Apple and email still work.Apple (needs an Apple Developer account):
com.notchlift.app), with Sign in with Apple ticked.com.notchlift.web; this is the client ID). Tick Sign in with Apple → Configure: primary App ID from step 1; Domains <project-ref>.supabase.co; Return URL https://<project-ref>.supabase.co/auth/v1/callback..p8 file (only once; keep it safe, never commit or share it). Note the Key ID and your Team ID (top right)..p8 with the generator in Supabase's "Login with Apple" guide. It expires after 6 months: set a reminder to make a new one and paste it in.com.notchlift.web,com.notchlift.app); Secret Key = the generated secret.notchlift.com and the address emails are sent from, so trial and reminder emails reach people who hid their address.Google:
notchlift.com and <project-ref>.supabase.co), Audience External, then Publish. Only the basic scopes (email, profile) are used.https://www.notchlift.com; authorised redirect URI https://<project-ref>.supabase.co/auth/v1/callback.Then add AUTH_PROVIDERS in Vercel (Production) and redeploy.
The sign-up form also records anonymous steps for the dashboard (migration 23): tapped Create account, tapped Apple / Google, and why a sign-up didn't go through (a reason code, never what was typed), plus whether the page was inside the Facebook / Instagram app. Admin → Sign-up funnel → On the sign-up form shows them with new accounts by method.
user ─┬─ splits ── workout_templates ── template_exercises ──> exercises
├─ split_active_periods (one open at most, no overlaps)
├─ workout_sessions ── session_exercises ──> exercises
│ └─ session_sets (confirmed = performed)
└─ split_shares (token + frozen snapshot)
(user_id, exercise_id). "Previous performance" is the most recent completed session containing the exercise, across all splits and workouts. It is never mixed across days and never taken from unfinished sessions (previous_performance()).NULL instead of cascading.(id, user_id) foreign keys, so a user cannot attach rows to someone else's split, template or session even by submitting their ids. Exercises used in templates and sessions must be catalogue exercises or the user's own (trigger assert_exercise_usable).activate_split) is transactional and serialised per user. It closes the open period and opens a new one; re-activating the current split is a no-op. A partial unique index enforces a single open period, and a btree_gist exclusion constraint forbids overlapping periods, including via date corrections.md5('splitmate.catalogue:'||slug)). They are stable across environments and re-running the migration is safe. Genuinely different variations (barbell, dumbbell, machine…) are separate exercises.weight_reps, bodyweight_reps (reps only) and added_weight_reps (e.g. weighted dips; shown as +10 × 10, and 0 means bodyweight).get_shared_split(token) and copy_shared_split(token, choices) are the only SECURITY DEFINER entry points. The token is 192 bits of randomness, and having it grants nothing beyond that snapshot.localStorage, namespaced by the authenticated user id, then synced to the server after an 800 ms debounce through sync_session().storage events.| Store | What is in it | Scope |
|---|---|---|
localStorage key splitmate:v1:<user-id>:session:<session-id> | The in-progress workout: exercises, sets (confirmed and draft), the pending write and its write id, server revision, conflict copy, last-session sets for the Previous column, cached next-session targets and which ones you hid (suggestions only, never synced), rest-timer start, logger settings | Per browser profile and origin; namespaced by the signed-in user's id; every read checks that the stored user id matches |
localStorage key splitmate:v1:last-user | Only the id of the last signed-in account (lets the offline shell find that account's workout) | Per browser profile |
Cache Storage splitmate-shell-<build id> (service worker; replaced on each deployment) | The static /offline page and content-hashed JS/CSS, icons and manifest. No user data: no authenticated page, RSC payload or API response is ever cached | Per origin |
Cookies sb-<ref>-auth-token* (set by Supabase) | The auth session | Per origin |
Cookie sm_tz | Browser time zone, so the server formats dates correctly | Per origin |
So "not cached by the service worker" does not mean "not stored locally": the workout itself is deliberately stored in localStorage so a set is never lost to a dropped connection; the service worker only stores the code needed to open it.
Account isolation and unsynced edits
Train → Log past workout (top right, also on each workout preview): choose the workout, pick the date and time it was performed, then log as usual. The logger shows "Logging a past workout" and, on finishing, the workout is saved for that date (not the day it was typed in). You can also move a workout later: Change workout date in the logger menu, or Edit → Change date on a finished workout (moving keeps its duration). Future dates are rejected.
History, the Previous column and charts are ordered by the performed date, so past workouts can be entered in any order; while logging a past workout the Previous column compares with the workout before its date. The active-period "workouts completed" count includes a past workout only if its date falls inside the period; set the split's start date (split page → Change start date, or Activate from an earlier date; Train → tap the active split summary) if you activated the split after you started training on it.
Train → Quick workout starts a one-off session with no template: give it an optional name, then tick the exercises you're doing (the picker allows several at once, added in the order ticked). It isn't part of any split, so it doesn't count towards a split's active period, but every set counts towards each exercise's history and Previous column. "Log a past quick workout" works like Log past workout. Any workout can be renamed from the logger menu (the template is not affected).
Train leads with one decision. If a workout is in progress, Resume replaces everything else (a second session cannot be started). Otherwise it shows a compact summary of the active split (one tap to its page, where dates are managed), the suggested next workout, the other workouts as compact rows (tap for a preview, or Start), Quick workout, and the last three workouts. "Log past workout" sits in the header.
The suggestion is a rotation hint, never a readiness judgement (src/lib/next-workout.ts, unit-tested): the active split's workout done longest ago.
Tapping a workout opens a preview (/train/workout/[id]): exercise order, sets and rep ranges, last performance, notes and any target. Starting never requires the preview.
Off by default. Switch on per exercise in a workout (Edit workout → exercise → Next-session targets) and choose the weight you add when moving up (no default increment). Rules (src/lib/progression.ts, unit-tested):
| Rule | Detail |
|---|---|
| Eligible | Weight × reps exercises with a rep range (min and max) and a working-set count. Bodyweight and added-weight exercises are never progressed automatically: bodyweight is not recorded |
| Comparable basis | Same exercise identity (each gym variant is its own exercise) and the same prescription as now: identical rep range and set count in that session's snapshot. The same workout entry is preferred; otherwise the latest comparable performance from another workout is used and named ("Based on Upper B, 3 Oct") |
| Sets used | The first N completed working sets (N = working-set count). Warm-ups, unconfirmed and skipped sets never count; extra sets are ignored |
| No target | Fewer than N working sets completed, working sets at different weights, no comparable session, or settings incomplete. A short note says why |
| Increase | Every working set reached the top of the range → weight + your increment, aim for the bottom of the range ("You reached the top of your rep range on all working sets.") |
| Repeat | Otherwise → same weight, one more rep per set up to the top of the range. The weight is never lowered automatically |
Without targets switched on, an exercise whose last session reached the top of the rep range on every planned working set shows a factual nudge, "Ready to go heavier" (loaded exercises only; it names no weight because the increment is unknown), with a link to switch targets on. Targets can also be switched on or off mid-workout from the exercise's ⋯ menu → Next-session targets; this saves to the workout so it applies next time too.
When every target set is met or beaten (each completed working set at the target weight or heavier, with at least the target reps), the card shows "Target hit!" with a short pop and burst (skipped with reduced motion; no sound or vibration). Without a target, completing every planned working set at the top of the range shows "Top of your range!". Both are derived from the logged sets, so undoing a set removes them, and reloading a finished exercise shows the banner without replaying the animation.
In the logger a target sits in a dashed Target box, apart from the Previous column. "Use 102.5 kg for remaining sets" only changes unconfirmed working-set weights; reps stay empty and nothing counts until each set is confirmed. Hide dismisses it for that session (stored on the device only). Targets are computed when needed and never stored as sets or in the session, so they cannot become, or be mistaken for, results.
After finishing (and whenever a workout is opened later) the summary shows exercises and working sets, duration only for workouts logged live and between 5 minutes and 6 hours, a factual change per exercise against its previous session ("2 more reps at 30 kg"; only sets both sessions have are compared, so extra sets are never "better"), records, and next targets where this workout is their basis. Records (src/lib/records.ts) are recomputed from current history every time, so editing, re-dating or deleting a workout updates them:
weight × (1 + reps ÷ 30), sets of 1–12 reps), Volume (weight × reps over working sets), Heaviest; reps-only and added-weight exercises show heaviest/most reps and total reps only./history): every completed workout by month performed./splits/[id]/review/[period], linked from each active period and from Progress): sessions performed during the period (half-open: start ≤ performed < end), working sets per Monday–Sunday week in your time zone, a breakdown by workout, and first-vs-latest best set for exercises done at least twice. It describes what was logged only: no adherence scores, no causal claims, and names appear as they were logged, so later template edits do not rewrite it.Remaining time is derived from the start timestamp (Date.now()), not from counting ticks, so it stays correct after the phone locks or the app is backgrounded. It uses the exercise's rest target or your default. Auto-start after confirming a set is optional, and it needs no notification permission (it vibrates briefly where supported).
| Scenario | Covered by |
|---|---|
| A. History across splits | tests/db (SQL), tests/e2e (UI: Split B shows 72.5 × 9 from Split A) |
| B. Shared split with personal history | tests/db, tests/e2e |
| C. Template edits preserve history | tests/db (rename, remove exercise, delete template and split) |
| D. Active split periods | tests/db (A → B → A, repeats, overlaps, corrections, archiving) |
| E. Recovery and synchronisation | tests/unit/sync.test.ts (lost responses, reload, reconnect), tests/e2e (real offline reload, exactly-once check in the database) |
| F. Unperformed values | tests/unit/doc.test.ts, tests/db, tests/e2e |
| G. Custom exercises | tests/db (no merging, explicit mapping only, no access to another user's exercise) |
| H. Access controls | tests/db (unauthenticated and unrelated users, id hijacking, function access) |
| I. Timer | tests/unit/timer.test.ts, tests/e2e (clock jumped without ticks) |
| Variants kept apart | tests/db (two variants + catalogue machine, previous, history, share/copy) |
| Sign-up confirmation, password reset | tests/e2e/auth.spec.ts (real emails through local Mailpit: neutral response, reset, reused/forged/expired links) |
| Account switching and sign-out | tests/unit/store.test.ts, tests/unit/sync.test.ts, tests/e2e/auth.spec.ts |
| Installable PWA | tests/e2e/auth.spec.ts (Chrome installability check, manifest) |
| Next-workout order, overrides, retrospective entries | tests/unit/next-workout.test.ts, tests/e2e/daily-training.spec.ts |
| Target eligibility and suppression | tests/unit/progression.test.ts, tests/db (opt-in, snapshot prescription, skipped entries, privacy) |
| Targets never become sets | (), (database holds only confirmed sets) |
Every account has a membership row (public.memberships): founder, trial, paid or lapsed. Users can read their own (Profile shows "Founding member") but cannot change it; only the owner can, from the Supabase SQL Editor. Payments are not built yet, so nothing is gated on it today.
| Task | SQL |
|---|---|
| Tag someone as a founder | update public.memberships set status = 'founder', trial_ends_at = null where user_id = (select id from auth.users where email = '[email protected]'); |
| See everyone's status | select u.email, m.status, m.trial_ends_at from public.memberships m join auth.users u on u.id = m.user_id order by m.created_at; |
| Close the founding phase (new sign-ups start a 14-day trial instead) | update public.app_settings set auto_founder = false; |
While auto_founder is on (the default), every new sign-up is a founder automatically, and the migration made all existing accounts founders.
/api/export?format=csv: one row per completed set, local date and time, UTF-8 with BOM, formula-safe) or Export everything (?format=json: profile, membership, custom exercises, splits with workouts and periods, all workouts). Both run as the user under RLS.delete_my_account() removes the user's workouts, shares, splits, custom exercises, membership, profile and auth user, then the app clears local data. Friends' copies of shared splits are independent and stay./privacy and /terms are public and linked from sign-up and Profile. They are a plain-English starting point; have them reviewed before charging. The contact address is [email protected] (src/lib/company.ts; NEXT_PUBLIC_CONTACT_EMAIL can override it). It appears in the footer, terms, privacy and every app email, which also set it as the reply-to address.src/lib/starters.ts). The chosen split is created and activated in one transaction (create_split_from_plan), built only from catalogue exercises, and is fully editable afterwards.public.feedback with the last screen visited, app build and device; users can submit but not read it. Read it in the SQL Editor:
select f.created_at, u.email, f.kind, f.message, f.page from public.feedback f join auth.users u on u.id = f.user_id order by f.created_at desc;Train, the workout logger, Splits and Progress each show a short Tip card the first time they are opened on a device, explaining that screen's key points. "Got it" hides it for good (stored in the browser, not the account); Profile → App → Show tips again brings them all back.
Migrations are additive and versioned; never reset a deployed database.
supabase/migrations/20261002000008_progression_targets.sql first (npx supabase db push, or paste it into the SQL Editor). It adds template_exercises.progression_enabled (default false) and progression_increment_kg (nullable), re-creates duplicate_split/duplicate_template so copies keep those settings, and adds the read-only progression_candidates function. It does not touch sessions, sets, snapshots or exercise identities.
Then supabase/migrations/20261003000009_memberships_and_account_deletion.sql and …0010_feedback_and_starter_splits.sql, and (memberships, app_settings, delete_my_account). Apply both in filename order; each is additive and safe to run before the app that uses it.
Then …0011_weight_units.sql: allows profiles.weight_unit = 'lb' and widens session_sets.weight_kg / progression_increment_kg to 4 decimal places. Existing values are unchanged; old app versions keep working.
Then …0012_group_workouts.sql: adds group workouts (three new tables, a nullable workout_sessions.group_workout_id, and functions). Additive; old app versions keep working.
Then …0013_analytics.sql: first-party analytics and the admin dashboard (new tables and functions only).
Then …0014_payments.sql: paid membership (Stripe), the free-trial gate, trial reminders and free access for friends. Additive; see Payments below.
Then …0015_free_access_and_launch.sql: separates free access (stays free) from early access (trial at launch) and adds the launch switch. Additive.targets field and load normally. The service worker is now registered per build (/sw.js?v=<commit>), so each deployment installs a fresh offline shell and removes the old cache; pages themselves are always fetched from the network first.NEXT_PUBLIC_SENTRY_DSN is set (Vercel → Settings → Environment Variables, Production). Server errors are reported from src/instrumentation.ts (onRequestError), browser errors from src/instrumentation-client.ts and the error pages. The SDK is loaded on demand, so it adds nothing to first load. src/lib/monitoring.ts strips user, cookies, headers, request data and emails, and blanks invite/share tokens and auth parameters in URLs (unit-tested). Errors only: no tracing or session replay. Stack traces are minified (no source map upload).visit, once per tab session) and sign-up form views, recorded through /api/track → track_event(). No visitor id, IP or user agent is stored; device and country come from the request. Campaign tags (utm_*), Facebook ad clicks (fbclid, not stored) and referring sites set a 30-day nl_src cookie with no identifier; after sign-in it becomes the account's first-touch attribution (record_attribution(), new accounts only). GPC/DNT browsers and bots are skipped./admin): funnel, daily visits/sign-ups/workouts, sources and campaigns, retention, feature use and memberships, from admin_overview() (totals only). Only accounts in public.admins can open it; add yourself in the SQL Editor:
insert into public.admins (user_id) select id from auth.users where email = '[email protected]';/?ref=CODE, on Profile, with a nudge under the Train banner).
+tag, Gmail dots, or accounts older than 14 days.Model: new accounts get a 14-day free trial, with no card needed. After that, membership is £3.99 a month or £30 a year (src/lib/billing/plans.ts).
workout_sessions (membership_required), and the app shows the plans instead./admin, never pay./api/stripe/webhook, signature-verified) update memberships through billing_* functions. These require the app's BILLING_SECRET; the database stores only its SHA-256. The app never holds a master database key.Setup:
openssl rand -hex 32): BILLING_SECRET and CRON_SECRET. In the SQL Editor:
update public.app_settings set billing_secret_hash = encode(extensions.digest('<BILLING_SECRET>', 'sha256'), 'hex');notchlift_monthly) and £30 yearly (lookup key notchlift_yearly).https://www.notchlift.com/api/stripe/webhook (the www address: notchlift.com redirects there, and Stripe doesn't follow redirects), with the events checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted and invoice.paid.STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, BILLING_SECRET, CRON_SECRET, RESEND_API_KEY (and optionally EMAIL_FROM). Then redeploy.pg_cron and pg_net extensions, then:
select cron.schedule('trial-reminders', '0 9 * * *', $$ select net.http_post(url := 'https://www.notchlift.com/api/cron/trial-reminders', headers := jsonb_build_object('Authorization', 'Bearer <CRON_SECRET>'), timeout_milliseconds := 60000) $$);LAUNCH. New sign-ups start the trial, and every early-access member starts a 14-day trial that day and gets a short email. Anyone with free access is untouched. The landing page's pricing switches with it (paid_plans_live()).Free access vs early access: both have status founder; memberships.free_access marks people who stay free for good.
free_access_invites).Train together from one shared plan (/together, invite links at /join/<token>). The host creates a group workout from one of their workouts or from scratch, edits the plan (host only) and shares the link. Anyone with the link can see the plan and join, up to 10 people. Everyone starts their own session from the plan and logs their own sets in their own history. Members see each other's chosen display names and whether each has started or finished, never weights, reps or history: sessions stay owner-only. A host's custom exercise becomes the member's own copy the first time they train it (reused afterwards, so their history continues).
Every weight is stored in kilograms with 4 decimal places. Profile → Weight unit (kg or lb) only changes how weights are entered and shown (src/lib/units.ts): a pound value typed to 0.01 lb converts to kg and back exactly, so switching units never changes logged data. Targets, records, charts and the CSV export (weight_kg or weight_lb column) follow the chosen unit; the JSON export stays in kg.
The iOS app is a native shell around the live site (capacitor.config.ts): it opens https://www.notchlift.com (signed-in people land on Train, everyone else on sign-up), so web deploys update the app without a new App Store build. Native features are added through Capacitor plugins and called from the site when it runs inside the app; the site recognises the app by NotchLiftApp/1 in the user agent (src/lib/native.ts). Bundle ID com.notchlift.app, iPhone only, portrait.
native-shell/ holds only offline.html, shown when the site can't be reached on first launch.WKAppBoundDomains (Info.plist) plus limitsNavigationsToAppBoundDomains let the site's service worker run inside the app, so offline logging works as on the web. Links to other domains open in Safari.apple is in AUTH_PROVIDERS); Google isn't offered, as its web redirect can't return to an embedded web view.npm run icons (into ios/App/App/Assets.xcassets).Building needs a Mac with Xcode (the project uses Swift Package Manager; no CocoaPods):
npm ci
npx cap sync ios # copies native-shell/ and the config into the Xcode project
npx cap open ios # opens Xcode
In Xcode: select the App target → Signing & Capabilities → choose your team (automatic signing registers the bundle ID), then run on a simulator or a connected iPhone. For TestFlight: Product → Archive → Distribute App → App Store Connect.
tests/unit/doc.test.tsapplyTargetWeighttests/e2e/daily-training.spec.ts| Records after edits, ties, warm-ups, high-rep sets | tests/unit/records.test.ts, tests/unit/progress.test.ts |
| Split-period boundaries, weekly sets | tests/unit/split-review.test.ts |
| Cross-account privacy of new pages | tests/e2e/auth.spec.ts (preview, split review, history) |
profiles.reminder_emails/unsubscribeList-UnsubscribeList-Unsubscribe-PostPOST /api/unsubscribeBILLING_SECRETadmin_dashboard(days, include) gives the top of /admin one rule for who is counted: admins, free-access friends and test accounts are left out unless ticked under “Also count”. Sections: Overview (all time), the chosen period, one visit-to-paying funnel, trial outcomes (ended → became members or didn't), ads and channels (sources grouped; double-encoded campaign names merged), sharing, and money. Breakdowns sit under More detail; tools under Manage.components/landing/Founder.tsx, photo in public/landing/ben.jpg). In the logger, an untouched workout (no ticked sets, not backdated) shows Not at the gym yet?: Save it for the gym discards the empty workout and returns to Train with a "saved for the gym" note (/train?ready=…, counted as cta_click / save_for_gym on /workout); it goes once a set is ticked or with I'm at the gym. With cookie consent, the Pixel also gets ViewContent when the demo starts and Lead when the sign-up form is first used (pixelStep, once per tab), giving Meta earlier signals while sign-ups are few.admin_trials_ending(include) groups running trials by the UK day they end (next 30 days, plus a count of later ones) and how many of those people have trained (has_trained). Admin → Trials shows it under the tiles (components/admin/TrialsEnding.tsx).admin_billing_mix(include) counts paying members by plan, cancelling and with a failed payment, with the dashboard's "Also count" rule, so test accounts stay out of the Money section's "monthly · yearly" hint and Cancelling tile. Until it runs, the page falls back to admin_overview's unfiltered counts.admin_glance() now leaves admins and test accounts out of every figure (payments, revenue, trials ending, cancelling, failed payments, comments), not just sign-ups and first workouts. Free-access friends still count, as before.extend_untrained_trials(secret): confirmed trials ending within 3 days (or in the last day) whose owner hasn't trained get 7 more days, once per account (memberships.trial_extended_at), and the trial reminder is reset so it comes again before the new end. Each gets a plain note from the founder (lib/emails/trial-extended.ts); people who turned reminders off are extended without the email. Admins and test accounts are left out.note_invite_shared('share'|'copy') when the share sheet completes or the invite is copied (stored in invite_shares; repeats within 10 seconds ignored), and admin_invite_shares(days, include) feeds the Sharing section's "Shared" tile. Before the migration the tile counts split and group links only.admin_time_of_day(days, include) counts visits, sign-ups and finished workouts (not backdated ones) by hour and weekday in UK time, with the same “Also count” rule as the dashboard. Admin → More detail → Time of day shows the bars with the busiest three hours (busiestWindow) and busiest weekday. Nothing new is stored.lib/support.ts) and Message us. Each question is a thread (support_threads, support_messages): open (needs a reply), answered or closed. Members read only their own and write through support_start / support_reply (5 new threads and 30 messages a day at most); opening a thread (support_open_thread) clears its unread badge (support_unread). Admins work from /admin/support (linked from Admin and Profile with the count waiting): admin_support_threads, admin_support_thread, admin_support_reply (optionally closing) and admin_support_set_status. Each member message emails the contact address a notice with a link to the thread; each admin reply emails the member the reply with a link back (lib/emails/support.ts, needs RESEND_API_KEY). Replying to those emails doesn't reach the thread. Threads are included in the JSON export and deleted with the account.template_exercises.superset_next links an exercise to the next one; a run of links is one superset (A1, A2…, lib/supersets.ts). The split editor has Superset with next; preview, logger, summary and shared splits show the labels. Sessions snapshot the links in start_session (session_exercises.superset_next, returned by session_document); sync_session never writes them, so a started workout keeps its links and exercises added mid-workout aren't linked. In the logger, ticking a set jumps to the next exercise in the round and the auto rest timer starts only after each round. Shared, copied, duplicated and ready-made splits carry the links. Group workouts don't show supersets yet.components/ShareWorkoutButton.tsx, data from lib/share-card.ts: best working set per exercise, stats and new records, with notchlift.com) and opens the phone's share sheet, or downloads the PNG where file sharing isn't supported. Nothing is uploaded; the tap is counted as cta_click / share_workout on /sessions.cta_click (landing-page buttons via TrackedLink: hero_start, hero_try, nav_start, nav_signin, pricing_start, final_start, final_try), scroll_depth (quarter, half, three_quarters, bottom via ScrollDepth), signup_started (first focus in the create-account form), consent (accepted, declined) and demo_progress (three_sets, finished). admin_engagement(days, include) adds Started a workout and Ticked a set to the funnel (from the workout tables) and the More detail → Before sign-up section./try is the real set logger on example data with no account (src/lib/demo.ts, components/demo/DemoWorkout.tsx): three exercises from Full Body A with labelled example "last time" numbers, saved only in this browser's storage. "Start free" sets an nl_demo cookie; email sign-ups then carry via: "demo", and Train's first run offers the same split as a one-tap card (which also tags Google sign-ups). Nothing from the demo enters anyone's history. /add-weight is the page for the hero-image ad (same promise and screens, then Try a workout / Create a free account; not indexed). Anonymous events demo_start, demo_set, demo_signup; Admin → From visit to paying shows the demo line (admin_demo).profiles.training_days (ISO weekdays, 1 = Monday; null = not asked, empty = skipped). Train asks "Which days do you train?" once a split is ready and before a first workout; Profile → Account lets people change them. Until a first workout, the 9am job sends a short "your workout is ready" email on those UK days (at most 4, never on the morning of the first-workout email). 7–14 days after sign-up with no workout, it sends one plain check-in signed by the founder, from the same address with the name "Ben from NotchLift"; replies go to the contact address. Nobody gets two getting-started emails in one run; all respect the reminder-email opt-out. Admin shows how many were sent and trained after, plus which days people picked (More detail → Training days).duration (holds such as a front lever or L-sit) and added_weight_duration (e.g. a weighted dead hang).
reps is the seconds held, and a rep range is a range of seconds (up to 3600). Reps exercises keep their old limits (enforced by triggers).seconds column.survey_answers, separate from "Send feedback" messages.
/upgrade?reason=trial_ended#why).NEXT_PUBLIC_META_PIXEL_ID in Vercel (Production) and redeploy.
src/lib/pixel.ts, src/lib/consent.ts, src/components/ConsentBanner.tsx.pricing_view event per tab session when the landing page's pricing section scrolls into view. It's shown as a step in the sign-up funnel (admin_pricing_views()).admin_activation()): the people who signed up in the range, as a cohort.
analytics_excluded_users).