diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 0b48f1a..eb60cbf 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -54,25 +54,25 @@ | Requirement | Phase | Status | |-------------|-------|--------| -| INP-01 | Phase 1 | Pending | -| INP-02 | Phase 1 | Pending | -| INP-03 | Phase 1 | Pending | -| INP-04 | Phase 1 | Pending | -| INP-05 | Phase 1 | Pending | -| INP-06 | Phase 1 | Pending | -| INP-07 | Phase 1 | Pending | -| SET-01 | Phase 2 | Pending | -| SET-02 | Phase 2 | Pending | -| SET-03 | Phase 2 | Pending | -| MOD-01 | Phase 3 | Pending | -| MOD-02 | Phase 3 | Pending | -| MOD-03 | Phase 3 | Pending | +| INP-01 | Phase 1 | ✅ Complete | +| INP-02 | Phase 1 | ✅ Complete | +| INP-03 | Phase 1 | ✅ Complete | +| INP-04 | Phase 1 | ✅ Complete | +| INP-05 | Phase 1 | ✅ Complete | +| INP-06 | Phase 1 | ✅ Complete | +| INP-07 | Phase 1 | ✅ Complete | +| SET-01 | Phase 2 | ✅ Complete | +| SET-02 | Phase 2 | ✅ Complete | +| SET-03 | Phase 2 | ✅ Complete | +| MOD-01 | Phase 4 | Pending | +| MOD-02 | Phase 4 | Pending | +| MOD-03 | Phase 4 | Pending | **Coverage:** - v1 requirements: 13 total -- Mapped to phases: 13 -- Unmapped: 0 +- Completed: 10 +- Remaining: 3 (Phase 4) --- *Requirements defined: 2026-02-15* -*Last updated: 2026-02-16 after roadmap creation* +*Last updated: 2026-02-26 — Phases 1-2 complete, design phase added* diff --git a/.planning/STATE.md b/.planning/STATE.md index 21ede20..f91174b 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,16 +5,16 @@ See: .planning/PROJECT.md (updated 2026-02-15) **Core value:** Logging a workout should be fast, clear, and flexible — the app never fights the user during a session -**Current focus:** Phase 2 — Flexible Sets (complete) +**Current focus:** Phase 3 — Design Polish & MVP ## Current Position -Phase: 2 of 3 (Flexible Sets) — COMPLETE -Plan: 2 of 2 in current phase (02-01 and 02-02 complete) -Status: Phase 2 complete — ready for Phase 3 planning -Last activity: 2026-02-21 — Completed 02-02 (DELETE /api/logs endpoint + deleteLog wiring) +Phase: 3 of 4 (Design Polish & MVP) — IN PROGRESS +Plan: 0 of 3 in current phase +Status: Phase 2 complete, Phase 3 planning started +Last activity: 2026-02-26 — Project management handoff, documentation update -Progress: [███████░░░] 67% +Progress: [████████░░] 67% (Phases 1-2 done, design phase starts) ## Performance Metrics diff --git a/.planning/phases/01-input-ux/01-02-SUMMARY.md b/.planning/phases/01-input-ux/01-02-SUMMARY.md index 994e292..98916ad 100644 --- a/.planning/phases/01-input-ux/01-02-SUMMARY.md +++ b/.planning/phases/01-input-ux/01-02-SUMMARY.md @@ -91,3 +91,11 @@ None - no external service configuration required. --- *Phase: 01-input-ux* *Completed: 2026-02-16* + +## Self-Check: PASSED + +- FOUND: frontend/src/pages/WorkoutPage.jsx +- FOUND: frontend/src/App.css +- FOUND: .planning/phases/01-input-ux/01-02-SUMMARY.md +- FOUND commit: 18ecf06 (Task 1 — stepper integration) +- FOUND commit: cb6f41c (docs — summary + state) diff --git a/.planning/phases/02-flexible-sets/02-01-PLAN.md b/.planning/phases/02-flexible-sets/02-01-PLAN.md new file mode 100644 index 0000000..f5ed74c --- /dev/null +++ b/.planning/phases/02-flexible-sets/02-01-PLAN.md @@ -0,0 +1,440 @@ +--- +phase: 02-flexible-sets +plan: "01" +type: execute +wave: 1 +depends_on: [] +files_modified: + - frontend/src/pages/WorkoutPage.jsx + - frontend/src/App.css +autonomous: true + +must_haves: + truths: + - "Every exercise card shows a 'Lägg till set' button" + - "Tapping 'Lägg till set' opens a modal with two choices: Vanligt set and Dropset" + - "Choosing Vanligt set appends one set row pre-filled with weight from the row above (same reps as row above)" + - "Choosing Dropset appends 3 set rows: first at same weight as row above, second at ~75-80% of that, third at ~55-60% of that, each with 10 reps pre-filled" + - "Every set row has an inline trash icon button that removes that row" + - "Tapping delete on the last remaining set is blocked (button disabled or no-op)" + - "Set numbers display correctly after adds and deletions (Set 1, Set 2, Set 3...)" + artifacts: + - path: "frontend/src/pages/WorkoutPage.jsx" + provides: "ExerciseCard with dynamic set array, add-set modal, delete-set button, onDeleteSet prop (stub)" + contains: "setList" + - path: "frontend/src/App.css" + provides: "Modal overlay CSS, add-set button CSS, delete-set button CSS" + contains: ".set-type-modal" + key_links: + - from: "ExerciseCard setList state" + to: "set rows rendered" + via: "setList.map() instead of Array.from({ length: exercise.sets })" + pattern: "setList\\.map" + - from: "Trash icon button" + to: "setList filter" + via: "handleDeleteSet removes index from setList array" + pattern: "handleDeleteSet" + - from: "'Lägg till set' button" + to: "modal open state" + via: "setShowAddModal(true)" + pattern: "showAddModal" +--- + + +Refactor ExerciseCard to support a dynamic, variable-length set list. Add UI for adding sets (via modal with Vanligt set / Dropset choice) and deleting sets (inline trash icon, last-set guard). + +Purpose: Core Phase 2 feature — flexible set count entirely in the frontend. The backend already persists individual sets via upsert; adding/deleting on the frontend just means the next save will include the correct set_number sequence. + +Output: ExerciseCard with dynamic set array state, set-type chooser modal, delete-set button on each row. + + + +@/home/intense/.claude/get-shit-done/workflows/execute-plan.md +@/home/intense/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/02-flexible-sets/02-CONTEXT.md +@.planning/phases/02-flexible-sets/02-RESEARCH.md +@frontend/src/pages/WorkoutPage.jsx +@frontend/src/App.css +@frontend/src/components/Icons.jsx + + + + + + Task 1: Refactor ExerciseCard to dynamic set array + add-set modal + delete-set button + frontend/src/pages/WorkoutPage.jsx + +Replace the fixed-length set rendering in ExerciseCard with a dynamic `setList` array in state. Each element in the array is an object `{ weight, reps, completed }` (indexed by position, not by set_number — set_number is derived as index+1 when logging). + +**State refactor (ExerciseCard):** + +Replace: +```js +const [setInputs, setSetInputs] = useState({}) +``` +With: +```js +const [setList, setSetList] = useState([]) +const [showAddModal, setShowAddModal] = useState(false) +``` + +Update the `useEffect` that initializes state. Build `setList` as an ordered array instead of a keyed object: +```js +useEffect(() => { + const initial = [] + for (let i = 1; i <= exercise.sets; i++) { + const existingLog = logs.find(l => l.set_number === i) + initial.push({ + weight: existingLog?.weight?.toString() || progression?.suggestedWeight?.toString() || '', + reps: existingLog?.reps?.toString() || '', + completed: existingLog?.completed || false + }) + } + setSetList(initial) +}, [exercise, logs, progression]) +``` + +**handleInputChange** — update to use array index: +```js +const handleInputChange = (idx, field, value) => { + setSetList(prev => prev.map((s, i) => i === idx ? { ...s, [field]: value } : s)) +} +``` + +**handleComplete** — update to use array index, pass set_number as idx+1 to onLogSet: +```js +const handleComplete = (idx) => { + const input = setList[idx] + const newCompleted = !input.completed + setSetList(prev => prev.map((s, i) => i === idx ? { ...s, completed: newCompleted } : s)) + onLogSet(exercise.id, idx + 1, input.weight, input.reps, newCompleted) +} +``` + +**handleAddNormal** — append one set pre-filled from the last row: +```js +const handleAddNormal = () => { + const last = setList[setList.length - 1] || { weight: '', reps: '' } + setSetList(prev => [...prev, { weight: last.weight, reps: last.reps, completed: false }]) + setShowAddModal(false) +} +``` + +**handleAddDropset** — append 3 sets with 20% weight reduction per step, 10 reps each: +```js +const handleAddDropset = () => { + const last = setList[setList.length - 1] || { weight: '0', reps: '10' } + const baseWeight = parseFloat(last.weight) || 0 + const drop1 = Math.round((baseWeight * 0.80) / 2.5) * 2.5 + const drop2 = Math.round((baseWeight * 0.60) / 2.5) * 2.5 + const newSets = [ + { weight: last.weight, reps: '10', completed: false }, + { weight: drop1.toString(), reps: '10', completed: false }, + { weight: drop2.toString(), reps: '10', completed: false }, + ] + setSetList(prev => [...prev, ...newSets]) + setShowAddModal(false) +} +``` +Note: Dropset weight steps use research-confirmed 20% drops per step (80% then 60% of base), rounded to nearest 2.5kg per the app's progression convention. + +**handleDeleteSet** — remove by index, guard against last set: +```js +const handleDeleteSet = (idx) => { + if (setList.length <= 1) return // last-set guard: block deletion + setSetList(prev => prev.filter((_, i) => i !== idx)) + if (onDeleteSet) onDeleteSet(exercise.id, idx + 1) +} +``` + +**completedSets count** — update to use setList: +```js +const completedSets = setList.filter(s => s.completed).length +``` + +**ExerciseCard props** — add `onDeleteSet` prop (optional, will be wired in plan 02): +```js +function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet, onDeleteSet }) { +``` + +**Render update — set rows:** + +Replace the `Array.from({ length: exercise.sets }, ...)` map with `setList.map((input, idx) => ...)`: +```jsx +{setList.map((input, idx) => ( +
+ Set {idx + 1} +
+ handleInputChange(idx, 'weight', val)} + /> + × + handleInputChange(idx, 'reps', val)} + /> +
+ + +
+))} +``` + +**Render update — below sets list, add "Lägg till set" button and modal:** +```jsx + + +{showAddModal && ( +
setShowAddModal(false)}> +
e.stopPropagation()}> +

Välj settyp

+ + + +
+
+)} +``` + +Place the modal JSX inside `{expanded && (...)}` after the `.sets-list` div but before closing `.exercise-body`. + +**Progress badge in exercise header** — update to use `setList.length` instead of `exercise.sets`: +```jsx + + {completedSets}/{setList.length} + +``` + +Also update the `exercise-card` class condition: +```jsx +className={`exercise-card ${expanded ? 'expanded' : ''} ${completedSets === setList.length && setList.length > 0 ? 'all-done' : ''}`} +``` + +Check Icons.jsx for a "trash" icon — if none exists, add it (an SVG trash can outline, consistent with the existing Icon component pattern). Check the file before assuming it exists. +
+ + Open frontend dev server (`npm run dev` in frontend/) and load a workout. Verify: + 1. Set rows render correctly with existing set count + 2. "Lägg till set" button is visible below set list + 3. Tapping it opens modal with two choices + 4. "Vanligt set" adds one row, weight pre-filled from row above + 5. "Dropset" adds 3 rows with progressively lower weights + 6. Trash icon appears on each row; clicking removes the row + 7. Trash icon on the only remaining set is disabled (cannot delete) + 8. Set numbers re-number correctly after deletion (Set 1, Set 2, ...) + + + ExerciseCard renders from a dynamic setList array. Add-set modal works for both Vanligt set and Dropset. Delete button removes rows with last-set guard enforced. Set numbering is always sequential from 1. + +
+ + + Task 2: Add CSS for modal overlay, add-set button, and delete-set button + frontend/src/App.css + +Add the following CSS blocks to App.css. Append after the existing stepper CSS section. + +**Add-set button** — sits below the sets-list, full width, secondary style: +```css +/* Add set button */ +.add-set-btn { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 44px; + margin-top: 0.5rem; + padding: 0.5rem 1rem; + background: transparent; + border: 1px dashed var(--border); + border-radius: 8px; + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} + +.add-set-btn:hover { + border-color: var(--accent); + color: var(--accent); +} +``` + +**Delete set button** — inline on the set row, between inputs and complete-btn: +```css +/* Delete set button */ +.delete-set-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + min-height: 44px; + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + opacity: 0.6; + transition: opacity 0.15s, color 0.15s; + flex-shrink: 0; +} + +.delete-set-btn:hover:not(:disabled) { + color: #e53e3e; + opacity: 1; +} + +.delete-set-btn:disabled, +.delete-set-btn.disabled { + opacity: 0.2; + cursor: not-allowed; +} +``` + +**Set type modal** — CSS overlay + card, dark theme consistent: +```css +/* Set type modal */ +.set-type-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: flex-end; + justify-content: center; + z-index: 200; + padding-bottom: env(safe-area-inset-bottom, 0); +} + +.set-type-modal { + background: var(--surface); + border-radius: 16px 16px 0 0; + padding: 1.5rem 1rem 2rem; + width: 100%; + max-width: 600px; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.set-type-modal h3 { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 0.25rem; + text-align: center; +} + +.set-type-option { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.2rem; + width: 100%; + min-height: 56px; + padding: 0.75rem 1rem; + background: var(--surface-2, rgba(255,255,255,0.05)); + border: 1px solid var(--border); + border-radius: 10px; + cursor: pointer; + text-align: left; + transition: border-color 0.15s; +} + +.set-type-option strong { + font-size: 1rem; + color: var(--text-primary); +} + +.set-type-option span { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.set-type-option:hover { + border-color: var(--accent); +} + +.set-type-option.dropset strong { + color: var(--accent); +} + +.set-type-cancel { + width: 100%; + min-height: 44px; + padding: 0.75rem; + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 0.9rem; + cursor: pointer; + margin-top: 0.25rem; +} +``` + +Note: Use `var(--surface)`, `var(--border)`, `var(--text-primary)`, `var(--text-secondary)`, `var(--accent)` — all existing CSS custom properties from the dark theme. Verify property names against existing App.css before writing. + + + Check in browser that: + 1. "Lägg till set" button renders with dashed border, no background + 2. Trash icon on set rows is subtle (low opacity), turns red on hover + 3. Modal slides up from bottom as a sheet (bottom-anchored overlay) + 4. Modal has the two option cards and a cancel button + 5. All touch targets are at least 44px tall + + + All new interactive elements styled with dark theme variables. Modal is a bottom sheet at max-width 600px. Delete button is subtle but discoverable. All touch targets ≥ 44px. + + + +
+ + +Run `npm run build` in frontend/ — build must pass with no errors. + +In the dev server, open a workout and test: +- Add normal set: weight copies from row above, reps copy from row above, set number increments +- Add dropset: 3 rows appear, weights drop at 20% intervals (rounded to 2.5kg), reps default to 10 +- Delete middle set: remaining rows renumber correctly +- Delete when only 1 set remains: button disabled, no row removed +- Modal dismisses on overlay click and on "Avbryt" + + + +ExerciseCard renders from dynamic setList. Both add-set paths work (Vanligt set, Dropset). Delete works with last-set guard. Build passes. All new buttons meet 44px touch target. CSS uses only existing theme variables. + + + +After completion, create `.planning/phases/02-flexible-sets/02-01-SUMMARY.md` + diff --git a/.planning/phases/02-flexible-sets/02-02-PLAN.md b/.planning/phases/02-flexible-sets/02-02-PLAN.md new file mode 100644 index 0000000..54eac79 --- /dev/null +++ b/.planning/phases/02-flexible-sets/02-02-PLAN.md @@ -0,0 +1,220 @@ +--- +phase: 02-flexible-sets +plan: "02" +type: execute +wave: 2 +depends_on: ["02-01"] +files_modified: + - backend/src/index.js + - frontend/src/App.jsx +autonomous: true + +must_haves: + truths: + - "Deleting a set row that was previously logged removes it from the database" + - "Adding and logging sets beyond the original program count persists to the database" + - "After saving/refreshing, the dynamic set count reflects what was logged (no phantom sets, no missing sets)" + - "The backend DELETE endpoint returns 200 on success and 404 if the log row did not exist" + artifacts: + - path: "backend/src/index.js" + provides: "DELETE /api/logs endpoint accepting user_id, program_exercise_id, date, set_number" + contains: "DELETE.*workout_logs" + - path: "frontend/src/App.jsx" + provides: "deleteLog function that calls DELETE /api/logs; passed to WorkoutPage as onDeleteSet" + contains: "deleteLog" + key_links: + - from: "ExerciseCard handleDeleteSet" + to: "App.jsx deleteLog" + via: "onDeleteSet prop through WorkoutPage" + pattern: "onDeleteSet" + - from: "App.jsx deleteLog" + to: "DELETE /api/logs" + via: "fetch with method DELETE" + pattern: "method.*DELETE" + - from: "DELETE /api/logs" + to: "workout_logs table" + via: "DELETE FROM workout_logs WHERE user_id=$1 AND program_exercise_id=$2 AND date=$3 AND set_number=$4" + pattern: "DELETE FROM workout_logs" +--- + + +Add a DELETE /api/logs backend endpoint and wire it through App.jsx so that when a user removes a set row that was already logged, the database record is deleted. + +Purpose: Without this, deleted set rows leave orphan rows in workout_logs, causing ghost sets to reappear on next load. The frontend dynamic state from plan 01 is correct per-session, but only persists with proper backend deletion. + +Output: Backend DELETE endpoint + App.jsx deleteLog function + WorkoutPage onDeleteSet prop wired to ExerciseCard. + + + +@/home/intense/.claude/get-shit-done/workflows/execute-plan.md +@/home/intense/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/02-flexible-sets/02-CONTEXT.md +@.planning/phases/02-flexible-sets/02-01-SUMMARY.md +@backend/src/index.js +@frontend/src/App.jsx +@frontend/src/pages/WorkoutPage.jsx + + + + + + Task 1: Add DELETE /api/logs endpoint to backend + backend/src/index.js + +Add a DELETE endpoint that removes a specific set log row. Place it directly after the existing POST /api/logs route (around line 329). + +```js +// Delete a specific set log +app.delete('/api/logs', async (req, res) => { + try { + const { user_id, program_exercise_id, date, set_number } = req.body; + + const result = await pool.query( + 'DELETE FROM workout_logs WHERE user_id = $1 AND program_exercise_id = $2 AND date = $3 AND set_number = $4 RETURNING id', + [user_id, program_exercise_id, date, set_number] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Log not found' }); + } + + res.json({ deleted: result.rows[0].id }); + } catch (err) { + console.error('Error deleting log:', err); + res.status(500).json({ error: 'Database error' }); + } +}); +``` + +No auth middleware on this endpoint — consistent with the existing POST /api/logs which also has no auth middleware (user_id comes from the request body). Do not add authMiddleware unless the existing POST /api/logs has it (it does not). + + + Start backend (`npm start` in backend/) and run: + ``` + curl -X DELETE http://localhost:3001/api/logs \ + -H "Content-Type: application/json" \ + -d '{"user_id":1,"program_exercise_id":1,"date":"2026-02-21","set_number":1}' + ``` + Returns `{"deleted": N}` if row existed, or `{"error":"Log not found"}` with 404 if not. Server does not crash on missing body fields (returns 404 or 500 gracefully). + + + DELETE /api/logs endpoint exists, deletes the matching workout_logs row by composite key (user_id, program_exercise_id, date, set_number), returns 200+id on success, 404 if not found. + + + + + Task 2: Wire deleteLog through App.jsx and WorkoutPage to ExerciseCard + frontend/src/App.jsx, frontend/src/pages/WorkoutPage.jsx + +**In App.jsx:** + +Add a `deleteLog` function alongside the existing `logSet` function: + +```js +const deleteLog = async (programExerciseId, setNumber) => { + try { + await fetch(`${API_URL}/logs`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: userId, + program_exercise_id: programExerciseId, + date: today, + set_number: setNumber + }) + }) + // Remove from local logs state + setLogs(prev => ({ + ...prev, + [programExerciseId]: (prev[programExerciseId] || []).filter(l => l.set_number !== setNumber) + })) + } catch (err) { + console.error('Failed to delete log:', err) + } +} +``` + +Pass `deleteLog` to `WorkoutPage` as `onDeleteSet`: +```jsx + setView('dashboard')} + fetchProgression={fetchProgression} +/> +``` + +**In WorkoutPage.jsx:** + +Update the `WorkoutPage` function signature to accept `onDeleteSet`: +```js +function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProgression }) { +``` + +Pass `onDeleteSet` through to each `ExerciseCard`: +```jsx + setExpandedExercise( + expandedExercise === exercise.id ? null : exercise.id + )} + onLogSet={onLogSet} + onDeleteSet={onDeleteSet} +/> +``` + +The ExerciseCard already has `onDeleteSet` in its prop signature and calls it in `handleDeleteSet` from plan 01. No changes needed inside ExerciseCard itself — just confirm `onDeleteSet` is being passed through correctly. + +**Behavior when delete is called:** +- If the set being deleted has `completed: true` in setList (was logged to DB), `deleteLog` fires and removes the DB row +- If the set is new and not yet logged (e.g. user added a set but didn't complete it), `onDeleteSet` is still called but the DELETE request will return 404 — the catch block silently ignores this (the row didn't exist; no harm done) + +This means the `handleDeleteSet` in ExerciseCard should always call `onDeleteSet` regardless of whether the set was completed — the backend handles the "not found" case gracefully. + + + In the dev server: + 1. Start a workout, complete set 1 of an exercise (logs it to DB) + 2. Verify it's logged: `curl "http://localhost:3001/api/logs?user_id=1&program_exercise_id=1&date=TODAY"` + 3. Delete set 1 row using the trash icon + 4. Verify it's gone from DB: repeat the curl — set_number=1 should no longer appear + 5. Add a new set (Vanligt set), complete it — verify it appears in DB as the next set_number + 6. Reload the workout — no ghost sets, count matches what was logged + + + deleteLog in App.jsx calls DELETE /api/logs. WorkoutPage passes onDeleteSet to ExerciseCard. Deleting a completed set removes it from the database and from local logs state. Non-logged sets delete silently without error. + + + + + + +Run `npm run build` in frontend/ — must pass with no errors. + +Full flow test: +1. Open a workout +2. Add 2 extra sets to the first exercise (Vanligt set) +3. Complete all sets — verify they all persist in DB +4. Delete the middle set — verify DB row removed, UI renumbers +5. Save workout (navigate back to dashboard) +6. Re-open same workout — set count matches what was logged, no ghost rows + + + +DELETE /api/logs endpoint exists in backend. App.jsx has deleteLog function. WorkoutPage passes onDeleteSet to ExerciseCard. Deleting a logged set removes it from DB. Adding and completing new sets saves beyond original program set count. Frontend build passes. + + + +After completion, create `.planning/phases/02-flexible-sets/02-02-SUMMARY.md` + diff --git a/.planning/phases/03-design-polish/03-00-OVERVIEW.md b/.planning/phases/03-design-polish/03-00-OVERVIEW.md new file mode 100644 index 0000000..21001ad --- /dev/null +++ b/.planning/phases/03-design-polish/03-00-OVERVIEW.md @@ -0,0 +1,47 @@ +# Phase 3: Design Polish & MVP + +**Started:** 2026-02-26 +**Goal:** Enterprise-quality look while maintaining MVP functionality + +## Problem Statement + +Current app looks like a "cheap template copy" - needs professional polish while keeping core functionality intact. + +## Design Philosophy + +- **Polish, don't rebuild** - Improve visual quality without breaking working features +- **Enterprise feel** - Clean, sophisticated, not template-like +- **Subtle animations** - Smooth transitions, not flashy +- **Consistent spacing** - Professional rhythm and breathing room +- **Better typography** - More hierarchy contrast + +## Phase Plans + +### 03-01: Login/Onboarding Polish +- Auth pages visual upgrade +- Better branding presence +- Smoother form interactions + +### 03-02: Dashboard Polish +- Header/brand refinement +- Card improvements +- Better visual hierarchy + +### 03-03: Workout Experience Polish +- Exercise cards refinement +- Set logging UX +- Progress indicators + +## Success Criteria + +- [ ] App feels cohesive and professional +- [ ] No "template" visual artifacts +- [ ] Consistent spacing/sizing +- [ ] Better typography hierarchy +- [ ] Core flow (login → workout) works smoothly + +## Out of Scope + +- New features (only visual polish) +- Backend changes +- Database migrations \ No newline at end of file diff --git a/.planning/research/00-index.md b/.planning/research/00-index.md index 97c53fc..77c7564 100644 --- a/.planning/research/00-index.md +++ b/.planning/research/00-index.md @@ -15,6 +15,10 @@ Research sammanställd 2026-02-15 via Exa AI Search. | [07-recommendations.md](07-recommendations.md) | Konkreta rekommendationer för Gravl | | [08-sources.md](08-sources.md) | Alla källor och länkar | | [09-exercise-databases.md](09-exercise-databases.md) | Övningsdatabaser, APIs, media, substitution | +| [10-onboarding-retention.md](10-onboarding-retention.md) | Onboarding flows, retention strategies, push notifications | +| [11-progressive-overload.md](11-progressive-overload.md) | Progressionsalgoritmer, RPE/RIR, 1RM-beräkning | +| [12-offline-first.md](12-offline-first.md) | Offline-first arkitektur, sync strategies | +| [13-monetization.md](13-monetization.md) | Freemium, subscription, pricing psychology | ## Key Takeaways diff --git a/.planning/research/10-onboarding-retention.md b/.planning/research/10-onboarding-retention.md new file mode 100644 index 0000000..c5afec9 --- /dev/null +++ b/.planning/research/10-onboarding-retention.md @@ -0,0 +1,420 @@ +# Onboarding & Retention — Research för Gravl + +## Problemet + +> "70% of fitness app users drop off within the first 90 days. The reason isn't a lack of motivation. It's bad UX." + +> "80% of New Year's resolutions fail by February" + +**Retention-statistik:** +- Day 1: ~25% retention (average app) +- Day 7: ~15% retention +- Day 30: ~5-10% retention +- Fitness apps: Ofta ännu sämre pga motivation-dependent + +--- + +## Del 1: Onboarding + +### Varför onboarding är kritiskt + +> "First impressions matter. For mobile apps, onboarding is the moment of truth — the experience that determines whether a new user becomes engaged or churns within minutes." + +### Onboarding Goals + +1. **Visa värde snabbt** — "Aha moment" inom 60 sekunder +2. **Samla nödvändig data** — Men inte mer än nödvändigt +3. **Personalisera upplevelsen** — Anpassa till användaren +4. **Skapa första framgången** — Quick win +5. **Bygga vana** — Första steget mot retention + +### Onboarding-typer + +| Typ | Beskrivning | Best for | +|-----|-------------|----------| +| **Progressive** | Gradvis introduktion | Komplexa appar | +| **Benefits-oriented** | Visa värde först | Skeptiska användare | +| **Function-oriented** | Lär ut features | Verktygs-appar | +| **Account-focused** | Registrering först | Community-appar | +| **Conversational** | Dialog-baserad | Personaliserade appar | + +### Conversational Onboarding (Rekommenderat för Gravl) + +**Traditionellt:** +``` +Screen 1: Välj mål [Styrka] [Hypertrofi] [Fettförbränning] +Screen 2: Välj erfarenhet [Nybörjare] [Medel] [Avancerad] +Screen 3: Välj dagar [1] [2] [3] [4] [5] [6] [7] +Screen 4: Ange vikt [____ kg] +``` + +**Conversational:** +``` +Coach: "Hej! Jag är din träningscoach. Vad vill du uppnå?" +User: "Jag vill bli starkare och se bättre ut" + +Coach: "Bra mål! Hur länge har du tränat?" +User: "Typ ett år, men ganska sporadiskt" + +Coach: "Ok, du har en bra grund! Hur många dagar per vecka + kan du verkligen träna, realistiskt?" +User: "3-4 dagar" + +Coach: "Perfekt för PPL! En sista sak — hur mycket väger du + ungefär? Det hjälper mig sätta rätt startvikter." +User: "85 kg" + +Coach: "Toppen! Jag har skapat ett program för dig. Redo att + köra ditt första pass?" +``` + +**Fördelar:** +- Känns personligt, inte som ett formulär +- Samlar mer context ("ganska sporadiskt") +- Användaren känner sig hörd +- Naturlig felhantering + +### Onboarding Best Practices + +#### 1. Minimera friktion + +``` +❌ 8 steg, 15 frågor, email-verifiering +✅ 3-4 steg, 5-7 frågor, skip email +``` + +#### 2. Visa värde INNAN du ber om data + +``` +❌ "Registrera dig för att fortsätta" +✅ "Här är ditt första pass!" → "Spara din progress?" +``` + +#### 3. Progressive disclosure + +``` +Steg 1: Grundläggande (mål, erfarenhet) +Steg 2: Senare (kroppsmått, 1RM) +Steg 3: Över tid (preferenser, historik) +``` + +#### 4. Default-värden + +``` +❌ "Ange din 1RM på bänkpress: [____]" +✅ "Din estimerade 1RM: [60kg] (baserat på erfarenhet)" +``` + +#### 5. Instant gratification + +``` +Onboarding → Första passet → Completion celebration +(helst inom 5-10 minuter) +``` + +### Onboarding Metrics + +| Metric | Mål | Beskrivning | +|--------|-----|-------------| +| **Completion rate** | >80% | Andel som avslutar onboarding | +| **Time to value** | <2 min | Tid till första "aha moment" | +| **Drop-off points** | Identify | Var lämnar användare? | +| **Day 1 activation** | >50% | Andel som gör första passet | + +--- + +## Del 2: Retention + +### Retention Strategies (13 från Orangesoft) + +#### 1. Personalisering + +> "47% of users say they'd leave apps that don't personalize their experience" + +- Anpassade program baserat på mål +- Dynamiskt innehåll baserat på beteende +- Personliga hälsningar + +#### 2. Gamification + +- Streaks och achievements +- Progress visualization +- Leaderboards (opt-in) + +#### 3. Social features + +- Workout sharing +- Challenges med vänner +- Community support + +#### 4. Push notifications + +- Workout reminders +- Streak warnings +- Achievement celebrations + +#### 5. Goal tracking + +- Visuell progress +- Milestones +- Before/after comparisons + +#### 6. Content variety + +- Nya övningar regelbundet +- Seasonal challenges +- Expert tips + +#### 7. Wearable integration + +- Apple Watch +- Garmin, Fitbit +- Auto-sync + +#### 8. AI coaching + +- Adaptiva program +- Form feedback +- Recovery recommendations + +#### 9. Offline functionality + +- Fungerar utan internet +- Sync när online + +#### 10. Feedback loops + +- Rate your workout +- Adjust difficulty +- Learn preferences + +#### 11. Community + +- Forums/comments +- User-generated content +- Social accountability + +#### 12. Rewards + +- Badges/achievements +- Discounts/perks +- Real rewards + +#### 13. Seamless UX + +- Fast load times +- Intuitive navigation +- Consistent design + +### Habit Formation + +#### "21 Days" är en myt + +> "The popular belief that it takes 21 days to form a habit is actually a myth." + +**Verkligheten:** +- 18-254 dagar beroende på beteende +- Genomsnitt: ~66 dagar +- Enklare habits = snabbare (vatten) +- Svårare habits = längre (gym) + +#### Habit Loop (från "Hooked") + +``` + ┌─────────────────────────────────────┐ + │ │ + ▼ │ + ┌───────┐ ┌────────┐ ┌────────┐ │ + │ CUE │───▶│ ACTION │───▶│ REWARD │────┘ + └───────┘ └────────┘ └────────┘ +``` + +**Fitness app-tillämpning:** +1. **Cue:** Push notification, tid på dagen, location +2. **Action:** Öppna app, starta pass +3. **Reward:** Progress, achievement, dopamine + +#### Fabulous App (Google Design Award) + +> "Leveraging Material Design guidelines, the company created an engaging UI around science-based strategies for psychological reinforcement, motivating users from onboarding through goal completion." + +**Resultat:** 16x ökning i dagliga downloads + +--- + +## Del 3: Push Notifications + +### Statistik + +- Push kan öka engagement med **80%** +- Push kan öka retention med **88%** +- Men **53%** tycker push är irriterande + +### Timing (Fitness Apps) + +| Tid | Typ | Varför | +|-----|-----|--------| +| **7-9 AM** | Morgon-workout reminder | Innan dagen startar | +| **5-7 PM** | Kvälls-workout reminder | Efter jobb | +| **8-9 PM** | Achievement summary | Reflektera över dagen | +| **Söndag kväll** | Weekly summary | Prep för veckan | + +### Fitness-specifika Push-strategier + +#### 1. Workout Reminders + +``` +🏋️ "Dags för Pull-dag! Redo att krossa det?" +[Starta pass] [Påminn senare] +``` + +#### 2. Streak Warnings + +``` +🔥 "Din 7-dagars streak är i fara! Logga ett pass idag." +``` + +#### 3. Achievement Celebrations + +``` +🎉 "NYTT PR! 100kg bänkpress! Du är starkare än 78% av användarna." +``` + +#### 4. Progress Updates + +``` +📈 "Förra veckan: 4 pass, 12,500 kg totalt. +8% vs förra veckan!" +``` + +#### 5. Re-engagement + +``` +😢 "Vi saknar dig! Ditt senaste pass var för 5 dagar sedan." +``` + +### Push Best Practices + +#### DO: + +✅ Personalisera (namn, mål, historik) +✅ Skicka vid rätt tid (user timezone) +✅ Ge värde (tips, achievements, progress) +✅ A/B-testa copy +✅ Respektera quiet hours +✅ Låt användare välja frekvens + +#### DON'T: + +❌ Spamma (max 1-2/dag) +❌ Generiska meddelanden +❌ Skicka mitt i natten +❌ Ignorera opt-outs +❌ Samma meddelande varje dag + +### Push Notification Triggers + +```python +def should_send_push(user): + # Reminder for scheduled workout + if user.has_workout_today and not user.started_workout: + if is_optimal_time(user): + return "workout_reminder" + + # Streak at risk + if user.streak > 3 and user.days_since_workout == 1: + return "streak_warning" + + # Achievement unlocked + if user.new_achievements: + return "achievement" + + # Re-engagement + if user.days_since_workout >= 5: + return "re_engagement" + + return None +``` + +--- + +## Del 4: Rekommendationer för Gravl + +### Onboarding Flow + +``` +1. Welcome Screen (5s) + "Hej! Redo att bli starkare?" + [Kom igång] + +2. Goal Selection (conversational) + Coach: "Vad vill du uppnå?" + [Styrka] [Muskler] [Gå ner i vikt] [Allmän fitness] + +3. Experience Level + Coach: "Hur länge har du tränat?" + [Nybörjare] [6-12 månader] [1-3 år] [3+ år] + +4. Schedule + Coach: "Hur många dagar per vecka kan du träna?" + [2] [3] [4] [5] [6] + +5. Quick Profile (optional) + Coach: "Vikt hjälper mig sätta rätt startvikter" + [____ kg] eller [Hoppa över] + +6. Program Generated + "Ditt PPL-program är klart! Första passet: Push A" + [Starta nu] [Senare] +``` + +**Total tid:** ~90 sekunder + +### Retention Checklist + +#### Week 1: Activation + +- [ ] Första passet genomfört +- [ ] Första PR celebration +- [ ] Push notification opt-in +- [ ] Förklara streak-systemet + +#### Week 2-4: Habit Building + +- [ ] 3+ pass/vecka +- [ ] Streak etablerad +- [ ] Första achievement unlocked +- [ ] Progress-graf visar förbättring + +#### Month 2+: Long-term Retention + +- [ ] Program-byte erbjuds +- [ ] Milestones firande (50 pass, etc.) +- [ ] Referral program +- [ ] Advanced features unlock + +### Key Metrics att Tracka + +| Metric | Target | When to Measure | +|--------|--------|-----------------| +| Onboarding completion | >80% | Immediate | +| Day 1 activation | >50% | Day 1 | +| Day 7 retention | >30% | Day 7 | +| Day 30 retention | >20% | Day 30 | +| Weekly active users | — | Ongoing | +| Workouts/week/user | >2.5 | Ongoing | + +--- + +## Källor + +- UXCam, CleverTap, Sendbird — Onboarding examples +- Orangesoft, Stormotion — Retention strategies +- Braze, Pushwoosh — Push notification best practices +- ContextSDK — Timing optimization +- Google Design (Fabulous) — Behavior change +- PMC — Habit formation research +- Octalysis Group — Gamification framework + +--- + +*Sammanställt 2026-02-15 av Bumblebee 🐝* diff --git a/.planning/research/11-progressive-overload.md b/.planning/research/11-progressive-overload.md new file mode 100644 index 0000000..4f63826 --- /dev/null +++ b/.planning/research/11-progressive-overload.md @@ -0,0 +1,517 @@ +# Progressive Overload-algoritmer — Research för Gravl + +## Vad är Progressive Overload? + +> "Progressive overload is the gradual increase of stress placed on the body during training. To continue building strength and muscle, you must progressively increase the demands on your musculoskeletal system." + +**Grundprincipen:** Om du gör samma träning med samma vikter, reps och sets vecka efter vecka har kroppen ingen anledning att anpassa sig. + +--- + +## Progressionsmetoder + +### 1. Vikt-progression (Linear) + +**Enklast och mest effektiv för nybörjare/intermediates** + +``` +Vecka 1: Bänkpress 60kg x 8,8,8 +Vecka 2: Bänkpress 62.5kg x 8,8,8 +Vecka 3: Bänkpress 65kg x 8,8,8 +... +``` + +**Typiska ökningar:** +| Övning | Ökning per pass | +|--------|-----------------| +| Squat/Deadlift | +2.5-5 kg | +| Bench/Row/OHP | +1.25-2.5 kg | +| Isolation (curls, etc.) | +1-2 kg | + +### 2. Rep-progression (Double Progression) + +**När du inte kan öka vikt varje vecka** + +``` +Mål: 3x8-12 reps + +Vecka 1: 60kg x 8,8,8 (låg end) +Vecka 2: 60kg x 9,9,8 +Vecka 3: 60kg x 10,10,10 +Vecka 4: 60kg x 12,11,11 +Vecka 5: 62.5kg x 8,8,8 (öka vikt, börja om) +``` + +**Regel:** Öka vikt när alla sets når övre rep-gränsen. + +### 3. Set-progression + +``` +Vecka 1: 60kg x 8,8,8 (3 sets) +Vecka 2: 60kg x 8,8,8,8 (4 sets) +Vecka 3: 62.5kg x 8,8,8 (tillbaka till 3 sets, ny vikt) +``` + +### 4. RPE/RIR-baserad Autoregulation + +**RPE = Rate of Perceived Exertion (1-10)** +**RIR = Reps in Reserve** + +| RPE | RIR | Beskrivning | +|-----|-----|-------------| +| 10 | 0 | Failure (kunde inte gjort fler) | +| 9.5 | 0.5 | Kanske 1 till med dålig form | +| 9 | 1 | 1 rep kvar | +| 8.5 | 1.5 | 1-2 reps kvar | +| 8 | 2 | 2 reps kvar | +| 7 | 3 | 3 reps kvar | +| 6 | 4 | Uppvärmning | + +**Konvertering:** `RPE = 10 - RIR` + +**Användning:** +``` +Målsättning: 3x8 @ RPE 8 + +Set 1: 80kg x 8 @ RPE 7 → för lätt, öka +Set 2: 82.5kg x 8 @ RPE 8 → perfekt +Set 3: 82.5kg x 8 @ RPE 9 → trötthet, behåll vikt +``` + +--- + +## 1RM-beräkning + +### Populära formler + +#### Epley Formula (mest använd) + +``` +1RM = weight × (1 + reps/30) +``` + +**Exempel:** 80kg × 10 reps +``` +1RM = 80 × (1 + 10/30) = 80 × 1.333 = 106.7 kg +``` + +#### Brzycki Formula + +``` +1RM = weight × (36 / (37 - reps)) +``` + +**Exempel:** 80kg × 10 reps +``` +1RM = 80 × (36 / (37 - 10)) = 80 × 1.333 = 106.7 kg +``` + +#### Lander Formula + +``` +1RM = weight × (100 / (101.3 - 2.67 × reps)) +``` + +### Rep Max Tabell (% av 1RM) + +| Reps | % av 1RM | Vikt (om 1RM = 100kg) | +|------|----------|----------------------| +| 1 | 100% | 100 kg | +| 2 | 94% | 94 kg | +| 3 | 91% | 91 kg | +| 4 | 88% | 88 kg | +| 5 | 86% | 86 kg | +| 6 | 83% | 83 kg | +| 7 | 81% | 81 kg | +| 8 | 79% | 79 kg | +| 9 | 77% | 77 kg | +| 10 | 75% | 75 kg | +| 12 | 70% | 70 kg | +| 15 | 65% | 65 kg | + +--- + +## Progressionsalgoritmer för Gravl + +### Algoritm 1: Simple Linear (Nybörjare) + +```python +def calculate_next_weight(exercise, last_workout): + """ + Enkel linjär progression. + Om alla sets klarades → öka vikt. + """ + target_reps = exercise.target_reps # ex: 8 + achieved_reps = last_workout.reps # ex: [8, 8, 8] + + # Alla sets klarade? + if all(r >= target_reps for r in achieved_reps): + increment = get_increment(exercise.type) + return last_workout.weight + increment + else: + return last_workout.weight # Repetera samma vikt + +def get_increment(exercise_type): + """Standardökningar baserat på övningstyp.""" + increments = { + 'compound_lower': 2.5, # Squat, Deadlift + 'compound_upper': 1.25, # Bench, OHP, Row + 'isolation': 1.0, # Curls, Extensions + } + return increments.get(exercise_type, 1.25) +``` + +### Algoritm 2: Double Progression (Rep Range) + +```python +def calculate_next_weight_double(exercise, last_workout): + """ + Double progression med rep range (ex: 8-12 reps). + Öka vikt när alla sets når övre gränsen. + """ + min_reps = exercise.min_reps # ex: 8 + max_reps = exercise.max_reps # ex: 12 + achieved_reps = last_workout.reps + + # Alla sets på max reps? + if all(r >= max_reps for r in achieved_reps): + increment = get_increment(exercise.type) + return { + 'weight': last_workout.weight + increment, + 'target_reps': min_reps # Börja om på min_reps + } + # Alla sets klarade min_reps? + elif all(r >= min_reps for r in achieved_reps): + return { + 'weight': last_workout.weight, + 'target_reps': min(max(achieved_reps) + 1, max_reps) + } + else: + # Missade reps, behåll allt + return { + 'weight': last_workout.weight, + 'target_reps': min_reps + } +``` + +### Algoritm 3: RPE-baserad Autoregulation + +```python +def calculate_next_weight_rpe(exercise, last_workout): + """ + RPE-baserad progression. + Justerar vikt baserat på hur hårt det kändes. + """ + target_rpe = exercise.target_rpe # ex: 8 + achieved_rpe = last_workout.rpe # ex: [7, 8, 9] + avg_rpe = sum(achieved_rpe) / len(achieved_rpe) + + # Under target RPE → för lätt, öka + if avg_rpe < target_rpe - 0.5: + adjustment = (target_rpe - avg_rpe) * 2.5 # ~2.5kg per RPE + return last_workout.weight + adjustment + + # Över target RPE → för tungt, minska + elif avg_rpe > target_rpe + 0.5: + adjustment = (avg_rpe - target_rpe) * 2.5 + return last_workout.weight - adjustment + + # Inom range → perfekt, små ökning + else: + return last_workout.weight + get_increment(exercise.type) +``` + +### Algoritm 4: Hybrid (Gravl Recommendation) + +```python +def calculate_progression(exercise, history, user): + """ + Hybrid-algoritm som kombinerar flera metoder. + + 1. Nybörjare: Linear progression + 2. Intermediate: Double progression + 3. Avancerad: RPE-baserad + + Med säkerhetschecks och platå-hantering. + """ + last_workout = history[-1] if history else None + + if not last_workout: + return estimate_starting_weight(exercise, user) + + # Välj metod baserat på erfarenhet + if user.experience == 'beginner': + return linear_progression(exercise, last_workout) + elif user.experience == 'intermediate': + return double_progression(exercise, last_workout) + else: + return rpe_progression(exercise, last_workout) + +def estimate_starting_weight(exercise, user): + """ + Estimera startvikt för ny användare. + Baserat på kroppsvikt och erfarenhet. + """ + bodyweight = user.weight_kg + + # Typiska ratio för 1RM baserat på erfarenhet + ratios = { + 'beginner': { + 'squat': 0.5, + 'bench': 0.4, + 'deadlift': 0.6, + 'ohp': 0.25, + 'row': 0.35, + }, + 'intermediate': { + 'squat': 1.0, + 'bench': 0.75, + 'deadlift': 1.25, + 'ohp': 0.5, + 'row': 0.6, + } + } + + ratio = ratios.get(user.experience, ratios['beginner']) + estimated_1rm = bodyweight * ratio.get(exercise.base_type, 0.5) + + # Börja på ~65% av estimated 1RM (för 10 reps) + starting_weight = estimated_1rm * 0.65 + + # Avrunda till närmaste 2.5kg + return round(starting_weight / 2.5) * 2.5 +``` + +--- + +## Platå-hantering + +### Detektera platå + +```python +def detect_plateau(history, window=4): + """ + Platå = ingen progress under [window] pass. + """ + if len(history) < window: + return False + + recent = history[-window:] + weights = [w.weight for w in recent] + + # Ingen viktökning? + if max(weights) <= min(weights): + # Kolla även reps + total_reps = [sum(w.reps) for w in recent] + if max(total_reps) <= min(total_reps): + return True + + return False +``` + +### Platå-strategier + +```python +def handle_plateau(exercise, history, strategy='deload'): + """ + Hantera platå med olika strategier. + """ + last_weight = history[-1].weight + + if strategy == 'deload': + # Sänk vikt med 10-15%, bygg upp igen + return { + 'weight': last_weight * 0.85, + 'reason': 'Deload: Sänker vikt för att bygga upp igen' + } + + elif strategy == 'rep_change': + # Byt rep-range (ex: 5x5 → 3x8) + return { + 'weight': last_weight * 0.9, + 'reps': 8, + 'sets': 3, + 'reason': 'Ny rep-range för att bryta platå' + } + + elif strategy == 'exercise_swap': + # Byt övning temporärt + alternatives = get_alternatives(exercise) + return { + 'exercise': alternatives[0], + 'reason': 'Byter övning för variation' + } +``` + +--- + +## Deload-strategier + +### Vad är Deload? + +En planerad period med reducerad intensitet för recovery. + +### Typer av Deload + +| Typ | Vikt | Volym | När | +|-----|------|-------|-----| +| **Light Deload** | -10% | Same | Var 4:e vecka | +| **Volume Deload** | Same | -40% | Vid trött | +| **Full Deload** | -20% | -50% | Efter tuffa block | + +### Automatisk Deload + +```python +def should_deload(user, history): + """ + Avgör om deload behövs. + """ + weeks_since_deload = user.weeks_since_deload + + # Schemalagd deload var 4-6 vecka + if weeks_since_deload >= 5: + return True + + # RPE konsekvent hög + recent_rpe = [h.avg_rpe for h in history[-4:]] + if len(recent_rpe) >= 4 and all(r >= 9 for r in recent_rpe): + return True + + # Missade reps ökar + recent_misses = count_missed_reps(history[-4:]) + if recent_misses > 5: + return True + + return False +``` + +--- + +## UX för Progression + +### Visa progression transparent + +``` +┌────────────────────────────────────────────────┐ +│ Bänkpress Nästa: 85kg │ +├────────────────────────────────────────────────┤ +│ │ +│ Förra passet: 82.5kg x 8, 8, 8 │ +│ Alla sets klarade! → Ökar med 2.5kg │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ [Progressionsgraf senaste 8 veckor] │ │ +│ │ 85 ─ ● │ │ +│ │ 80 ─ ● ● │ │ +│ │ 75 ─ ● ● │ │ +│ │ 70 ─ ● ● │ │ +│ │ W1 W2 W3 W4 W5 W6 W7 W8 │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ [Godkänn 85kg] [Justera manuellt] │ +└────────────────────────────────────────────────┘ +``` + +### Förklara logiken + +``` +💡 Varför ökar vikten? +─────────────────────── +Du tog 82.5kg x 8, 8, 8 förra passet. +Mål var 8-10 reps. +→ Alla sets klarade → Dags att öka! +→ +2.5kg är standard för överkropps-compound. +``` + +--- + +## Implementation för Gravl + +### Database Schema + +```sql +CREATE TABLE progression_settings ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id), + exercise_id INT REFERENCES exercises(id), + + -- Progression method + method VARCHAR(20) DEFAULT 'double', -- 'linear', 'double', 'rpe' + + -- Rep range + min_reps INT DEFAULT 8, + max_reps INT DEFAULT 12, + target_sets INT DEFAULT 3, + + -- Increments + weight_increment DECIMAL(4,2) DEFAULT 2.5, + + -- Deload settings + deload_frequency_weeks INT DEFAULT 5, + deload_percentage DECIMAL(3,2) DEFAULT 0.85, + + -- RPE settings + target_rpe DECIMAL(3,1) DEFAULT 8.0, + + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE progression_history ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id), + exercise_id INT REFERENCES exercises(id), + workout_id INT REFERENCES workouts(id), + + weight DECIMAL(6,2), + reps INT[], + rpe DECIMAL(3,1)[], + + -- Computed + estimated_1rm DECIMAL(6,2), + total_volume DECIMAL(10,2), -- weight × total_reps + + performed_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### API Endpoint + +```python +@app.get("/api/exercises/{exercise_id}/next-weight") +def get_next_weight(exercise_id: int, user: User): + """ + Returnerar nästa rekommenderade vikt för en övning. + """ + history = get_exercise_history(user.id, exercise_id) + settings = get_progression_settings(user.id, exercise_id) + + next_weight = calculate_progression( + exercise=get_exercise(exercise_id), + history=history, + settings=settings, + user=user + ) + + return { + "exercise_id": exercise_id, + "recommended_weight": next_weight.weight, + "recommended_reps": next_weight.reps, + "reason": next_weight.reason, + "previous": history[-1] if history else None, + "progression_graph": get_progression_graph(history) + } +``` + +--- + +## Källor + +- Setgraph, Zing Coach, FitnessAI — Progressive overload calculators +- JEFIT, RippedBody — RPE/RIR guides +- Stronglifts — Increment settings +- NASM, VBTCoach — 1RM formulas +- Alpha Progression, StrengthLog — Rep max tables + +--- + +*Sammanställt 2026-02-15 av Bumblebee 🐝* diff --git a/.planning/research/12-offline-first.md b/.planning/research/12-offline-first.md new file mode 100644 index 0000000..b686bb6 --- /dev/null +++ b/.planning/research/12-offline-first.md @@ -0,0 +1,553 @@ +# Offline-First Implementation — Research för Gravl + +## Varför Offline-First? + +> "Mobile networks are unreliable. Users face data limits, weak signals, airplane mode, subway tunnels." + +**Gym-specifikt:** +- Gym har ofta dålig/ingen WiFi +- Källare, betong, metall = dålig signal +- Användare vill inte vänta på laddning mellan sets +- Data får INTE förloras (loggade reps är värdefulla) + +--- + +## Offline-First Principer + +### Core Principles (från OneUptime) + +1. **Local-first:** Data sparas lokalt FÖRST, synkas SEN +2. **Optimistic Updates:** UI uppdateras direkt, backend i bakgrund +3. **Graceful Degradation:** Features som kräver nätverk degraderas snyggt +4. **Conflict Resolution:** Tydlig strategi för datakonflikt +5. **Transparent Sync:** Användaren förstår sync-status + +### Mental Model + +``` +┌─────────────────────────────────────────────────────────┐ +│ USER ACTION │ +│ (logga set) │ +└─────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ LOCAL DATABASE │ +│ (SQLite/IndexedDB) │ +│ │ +│ ✅ Omedelbar respons │ +│ ✅ Fungerar offline │ +│ ✅ Data säker lokalt │ +└─────────────────────┬───────────────────────────────────┘ + │ + │ (när nätverk finns) + ▼ +┌─────────────────────────────────────────────────────────┐ +│ SYNC ENGINE │ +│ │ +│ • Queue pending changes │ +│ • Retry on failure │ +│ • Resolve conflicts │ +└─────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ REMOTE SERVER │ +│ (PostgreSQL API) │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Tekniska Alternativ + +### 1. React Native + SQLite + +**Bibliotek:** `react-native-sqlite-storage` eller `expo-sqlite` + +**Fördelar:** +- Native performance +- Full SQL-support +- Beprövad teknologi + +**Nackdelar:** +- Kräver native build +- Ingen inbyggd sync + +```javascript +import * as SQLite from 'expo-sqlite'; + +const db = SQLite.openDatabase('gravl.db'); + +// Skapa tabell +db.transaction(tx => { + tx.executeSql( + `CREATE TABLE IF NOT EXISTS workout_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + exercise_id INTEGER, + weight REAL, + reps TEXT, + synced INTEGER DEFAULT 0, + local_id TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + )` + ); +}); + +// Logga set (offline-first) +const logSet = async (exerciseId, weight, reps) => { + const localId = uuid.v4(); + + // Spara lokalt FÖRST + db.transaction(tx => { + tx.executeSql( + 'INSERT INTO workout_logs (exercise_id, weight, reps, local_id) VALUES (?, ?, ?, ?)', + [exerciseId, weight, JSON.stringify(reps), localId] + ); + }); + + // Försök synka i bakgrund + syncToServer(localId); +}; +``` + +### 2. React Native + RxDB + +**RxDB:** Reactive Database med inbyggd sync + +**Fördelar:** +- Reaktiv (observables) +- Inbyggd sync (CouchDB-protokoll) +- Conflict resolution +- TypeScript-stöd + +**Nackdelar:** +- Mer komplex setup +- Större bundle + +```javascript +import { createRxDatabase, addRxPlugin } from 'rxdb'; +import { getRxStorageDexie } from 'rxdb/plugins/storage-dexie'; +import { RxDBReplicationCouchDBPlugin } from 'rxdb/plugins/replication-couchdb'; + +addRxPlugin(RxDBReplicationCouchDBPlugin); + +const db = await createRxDatabase({ + name: 'gravldb', + storage: getRxStorageDexie() +}); + +// Schema +await db.addCollections({ + workouts: { + schema: { + version: 0, + primaryKey: 'id', + properties: { + id: { type: 'string' }, + exercise_id: { type: 'number' }, + weight: { type: 'number' }, + reps: { type: 'array' }, + timestamp: { type: 'string' } + } + } + } +}); + +// Replication +const replicationState = db.workouts.syncCouchDB({ + remote: 'https://api.gravl.app/sync', + push: { batchSize: 10 }, + pull: { batchSize: 10 } +}); +``` + +### 3. PWA + IndexedDB + Service Worker + +**För web-first approach** + +**Fördelar:** +- Ingen app store +- Fungerar på alla plattformar +- Service Worker caching + +**Nackdelar:** +- Begränsad native-access +- iOS PWA-begränsningar + +```javascript +// Service Worker (sw.js) +const CACHE_NAME = 'gravl-v1'; +const OFFLINE_URLS = [ + '/', + '/app.js', + '/styles.css', + '/exercises.json' +]; + +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME).then(cache => { + return cache.addAll(OFFLINE_URLS); + }) + ); +}); + +self.addEventListener('fetch', event => { + event.respondWith( + caches.match(event.request).then(cached => { + // Returnera cached först, hämta nytt i bakgrund + const networkFetch = fetch(event.request).then(response => { + caches.open(CACHE_NAME).then(cache => { + cache.put(event.request, response.clone()); + }); + return response; + }); + + return cached || networkFetch; + }) + ); +}); +``` + +```javascript +// IndexedDB wrapper (Dexie) +import Dexie from 'dexie'; + +const db = new Dexie('GravlDB'); + +db.version(1).stores({ + workouts: '++id, date, synced', + exercises: 'id, name, bodyPart', + pendingSync: '++id, type, data, timestamp' +}); + +// Offline-first save +async function saveWorkout(workout) { + // Spara lokalt + const id = await db.workouts.add({ + ...workout, + synced: false, + localId: crypto.randomUUID() + }); + + // Queue för sync + await db.pendingSync.add({ + type: 'workout', + data: workout, + timestamp: Date.now() + }); + + // Trigger background sync + if ('serviceWorker' in navigator && 'sync' in registration) { + registration.sync.register('sync-workouts'); + } + + return id; +} +``` + +### 4. SQLite Sync (CRDT) + +**Nytt:** SQLite Cloud's SQLite Sync extension + +**Fördelar:** +- Äkta local-first +- CRDT för konfliktfri sync +- Standard SQLite API + +```javascript +// SQLite Sync (konceptuell) +import { SQLiteSync } from 'sqlite-sync'; + +const db = new SQLiteSync('gravl.db', { + remote: 'https://sync.gravl.app', + tables: ['workouts', 'exercises'] +}); + +// Automatisk sync! +await db.exec(` + INSERT INTO workouts (exercise_id, weight, reps) + VALUES (1, 80, '[8, 8, 8]') +`); +// Synkas automatiskt när online +``` + +--- + +## Sync Strategies + +### 1. Optimistic UI + +```javascript +// Användaren ser ändringen DIREKT +const logSet = async (data) => { + // 1. Uppdatera UI omedelbart + setWorkoutLogs(prev => [...prev, data]); + + // 2. Spara lokalt + await localDB.save(data); + + // 3. Synka i bakgrund (utan att blockera UI) + syncInBackground(data).catch(err => { + // Visa synkfel-indikator, men behåll data + showSyncError(); + }); +}; +``` + +### 2. Conflict Resolution + +**Strategier:** + +| Strategi | Beskrivning | Bäst för | +|----------|-------------|----------| +| **Last Write Wins** | Senaste timestamp vinner | Enkel data | +| **Client Wins** | Lokal data prioriteras | User-kontroll | +| **Server Wins** | Server-data prioriteras | Data integrity | +| **Merge** | Kombinera ändringar | Komplex data | +| **CRDT** | Konfliktfri automatisk | Multi-device | + +**Gravl-rekommendation:** Last Write Wins med server-timestamp + +```javascript +const resolveConflict = (local, remote) => { + // Om samma workout redigerats på två enheter + if (local.updated_at > remote.updated_at) { + return local; // Nyare vinner + } else { + return remote; + } +}; +``` + +### 3. Background Sync + +```javascript +// Service Worker background sync +self.addEventListener('sync', event => { + if (event.tag === 'sync-workouts') { + event.waitUntil(syncPendingWorkouts()); + } +}); + +async function syncPendingWorkouts() { + const pending = await db.pendingSync + .where('type') + .equals('workout') + .toArray(); + + for (const item of pending) { + try { + await fetch('/api/workouts', { + method: 'POST', + body: JSON.stringify(item.data) + }); + + // Ta bort från queue + await db.pendingSync.delete(item.id); + + // Markera som synkad + await db.workouts + .where('localId') + .equals(item.data.localId) + .modify({ synced: true }); + + } catch (err) { + // Retry later + console.log('Sync failed, will retry'); + } + } +} +``` + +--- + +## Sync Status UI + +### Indikera sync-status + +```jsx +// Sync-indikator komponent +const SyncStatus = () => { + const { pendingCount, lastSync, isOnline } = useSyncStatus(); + + if (!isOnline) { + return ( + + 📴 Offline — Data sparas lokalt + + ); + } + + if (pendingCount > 0) { + return ( + + ⏳ Synkar {pendingCount} ändringar... + + ); + } + + return ( + + ✅ Synkad {formatTime(lastSync)} + + ); +}; +``` + +### Per-item sync status + +```jsx +const WorkoutLogItem = ({ log }) => { + return ( + + {log.exercise} — {log.weight}kg × {log.reps} + {!log.synced && ( + Ej synkad + )} + + ); +}; +``` + +--- + +## Gravl Implementation Plan + +### Phase 1: Local Storage + +``` +1. Implementera SQLite/IndexedDB +2. Spara ALL data lokalt först +3. UI visar alltid lokal data +4. Ingen sync ännu (100% offline) +``` + +### Phase 2: Basic Sync + +``` +1. Lägg till sync queue +2. POST nya workouts till server +3. Markera som synkade +4. Retry on failure +``` + +### Phase 3: Bi-directional Sync + +``` +1. Pull server-ändringar +2. Merge med lokal data +3. Conflict resolution +4. Multi-device support +``` + +### Phase 4: Real-time (optional) + +``` +1. WebSocket för live updates +2. Optimistic UI +3. Collaborative features +``` + +--- + +## Database Schema (Offline-optimerad) + +```sql +-- Local SQLite schema + +CREATE TABLE workouts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + local_id TEXT UNIQUE NOT NULL, -- UUID, genereras lokalt + server_id INTEGER, -- NULL tills synkad + + -- Data + program_day_id INTEGER, + started_at TEXT, + completed_at TEXT, + notes TEXT, + + -- Sync metadata + synced INTEGER DEFAULT 0, + sync_action TEXT DEFAULT 'create', -- 'create', 'update', 'delete' + local_updated_at TEXT, + server_updated_at TEXT, + + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE workout_sets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + local_id TEXT UNIQUE NOT NULL, + server_id INTEGER, + + workout_local_id TEXT REFERENCES workouts(local_id), + exercise_id INTEGER, + set_number INTEGER, + weight REAL, + reps INTEGER, + rpe REAL, + + synced INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE sync_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + table_name TEXT, + local_id TEXT, + action TEXT, -- 'create', 'update', 'delete' + payload TEXT, -- JSON + attempts INTEGER DEFAULT 0, + last_attempt TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +-- Index för snabb sync-lookup +CREATE INDEX idx_workouts_synced ON workouts(synced); +CREATE INDEX idx_sync_queue_attempts ON sync_queue(attempts); +``` + +--- + +## Rekommendation för Gravl + +### Tech Stack + +``` +Frontend: React (web) eller React Native (app) +Local DB: Dexie (IndexedDB wrapper) för web + expo-sqlite för native +Sync: Custom sync engine med retry logic +Backend: Befintlig Express/PostgreSQL +``` + +### Varför inte RxDB/CouchDB? + +- Overhead för ett simpelt use case +- Gravl har enkel data (workouts, sets) +- Custom sync ger mer kontroll + +### Nyckelprinciper + +1. **Lokal data är sanning** — Servern är backup +2. **Aldrig blockera UI** — Sync sker i bakgrund +3. **Aldrig förlora data** — Queue allt +4. **Tydlig status** — Användaren vet vad som händer + +--- + +## Källor + +- Medium: Offline-First React Native (2026) +- OneUptime: React Native Data Sync +- dev.family: RxDB Architecture +- Google Developers: PWA Going Offline +- Monterail: PWA Dynamic Data +- SQLite.ai: SQLite Sync +- SQLite Cloud: OffSync + +--- + +*Sammanställt 2026-02-15 av Bumblebee 🐝* diff --git a/.planning/research/13-monetization.md b/.planning/research/13-monetization.md new file mode 100644 index 0000000..7a70d22 --- /dev/null +++ b/.planning/research/13-monetization.md @@ -0,0 +1,386 @@ +# Monetisering — Research för Gravl + +## Marknadsöversikt + +**Fitness app-marknaden:** +- 2025: ~$10 miljarder +- 2028 prognos: $15.6 miljarder +- Health & Fitness är top-kategorin för app revenue + +**RevenueCat State of Subscription Apps 2025:** +- Health & Fitness: $0.63+ revenue per install efter 60 dagar +- Dubbelt median ($0.31 för alla kategorier) +- Låga årspriser = bättre retention (36%) + +--- + +## Monetiseringsmodeller + +### 1. Freemium (Mest vanlig) + +**Så funkar det:** +- Gratis grundfunktioner +- Premium låser upp avancerade features +- Konverteringsmål: 2-5% free → paid + +**Fördelar:** +- Låg tröskel för nya användare +- Stort användarbas +- Word-of-mouth + +**Nackdelar:** +- Låg konverteringsrate +- Kostnad för gratis-användare +- Feature-balans är svår + +**Fitness-exempel:** +- Hevy: Gratis loggning, premium för avancerade grafer +- Strong: 3 gratis routines, premium för obegränsat + +### 2. Subscription (Prenumeration) + +**Så funkar det:** +- Månads- eller årsbetalning +- Ofta med free trial + +**Typiska priser (fitness):** +| App | Månads | Års | Trial | +|-----|--------|-----|-------| +| FITBOD | $12.99 | $79.99 | 3 workouts | +| Strong | $4.99 | $29.99 | 3 routines | +| Hevy | $2.99 | $23.99 | Generous free | +| Juggernaut AI | $35 | — | — | + +**Trial konvertering (benchmark):** +- 25-60% trial → paid (bra apps) +- 7 dagar vs 30 dagar: Ingen signifikant skillnad +- "Pay upfront after trial" ökar konvertering + +### 3. Paymium + +**Så funkar det:** +- Betala för att ladda ner + in-app purchases + +**2025 Insight:** +> "Paymium has emerged as the dominant monetization strategy for fitness apps targeting engaged, high-value audiences." + +**Fördelar:** +- Filtrerar bort tire-kickers +- Högre ARPU +- Mer engagerade användare + +**Nackdelar:** +- Mycket lägre downloads +- Kräver stark varumärke +- Svårare discovery + +### 4. One-time Purchase + +**Så funkar det:** +- En engångsbetalning, appen är din + +**Reddit-sentiment:** +> "I'd happily pay $20 once for a good app. $100/year feels like a scam for a workout logger." + +**Verklighet:** +- Svårt att underhålla utan löpande intäkt +- Fungerar för simpla appar +- Premium-tier kan vara one-time + +### 5. Ads + +**Fitness-användare HATAR ads:** +> "Ads in the middle of my workout? Instant uninstall." + +**Om du måste:** +- Aldrig mitt i workout +- Endast i free-tier +- Banner, inte interstitial + +--- + +## Pricing Psychology + +### Principer som fungerar + +#### 1. Anchoring (Förankring) + +Visa det dyraste alternativet först: +``` +┌────────────────────────────────────────┐ +│ Premium Yearly $79.99/år │ ← Anchor +│ (Spara 50%!) = $6.67/mån │ +├────────────────────────────────────────┤ +│ Premium Monthly $12.99/mån │ +├────────────────────────────────────────┤ +│ Free $0 │ +└────────────────────────────────────────┘ +``` + +#### 2. Price Framing + +``` +❌ "$79.99 per år" +✅ "Mindre än en kaffe per vecka" +✅ "Billigare än ett PT-pass" +``` + +#### 3. Decoy Effect + +Lägg till ett "dåligt" alternativ för att göra det önskade bättre: +``` +Monthly: $12.99/mån +Quarterly: $32.99/kvartal (= $11/mån) ← Decoy +Yearly: $79.99/år (= $6.67/mån) ← Target +``` + +#### 4. Loss Aversion + +``` +"Du har tränat 47 pass i år. Uppgradera för att behålla din data!" +"Din streak på 23 dagar — fortsätt med Premium!" +``` + +#### 5. Social Proof + +``` +"Gå med 50,000+ användare som blivit starkare med Gravl" +"4.8 ★ på App Store" +``` + +--- + +## Free Trial Best Practices + +### Trial Length + +**Research:** +> "No significant difference between 7 and 30 day trials in conversion rate." + +**Rekommendation:** 7 dagar är standard, 14 dagar för fitness (tid att se resultat) + +### Trial Experience + +1. **Full access** — Låt användare uppleva ALLT +2. **Onboarding** — Guida till value snabbt +3. **Reminders** — "3 dagar kvar av trial" +4. **Soft paywall** — "Trial slut, vill du fortsätta?" + +### Conversion Tactics + +``` +Day 1: Welcome, visa premium features +Day 3: "Har du testat [killer feature]?" +Day 5: "Du har gjort X pass! Se din progress (premium)" +Day 6: "Sista dagen imorgon — 20% rabatt!" +Day 7: Soft paywall, erbjud förlängning +``` + +--- + +## Paywall Design + +### Top Fitness Apps (UX Patterns) + +#### 1. Value-first + +``` +┌────────────────────────────────────────┐ +│ Bli starkare med Gravl │ +│ │ +│ ✓ AI-anpassade program │ +│ ✓ Unlimited routines │ +│ ✓ Progress analytics │ +│ ✓ Offline mode │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ Årsplan 399 kr/år │ │ +│ │ Spara 50% (33 kr/mån) │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ [Månadsplan 69 kr/mån] │ +│ │ +│ [Fortsätt gratis med begränsningar] │ +└────────────────────────────────────────┘ +``` + +#### 2. Trial-fokuserad + +``` +┌────────────────────────────────────────┐ +│ Testa Premium gratis i 7 dagar │ +│ │ +│ Du kan avbryta när som helst. │ +│ Ingen betalning förrän trial slutar. │ +│ │ +│ [Starta gratis trial] │ +│ │ +│ Efter trial: 399 kr/år │ +│ │ +│ [Nej tack, fortsätt gratis] │ +└────────────────────────────────────────┘ +``` + +#### 3. Social proof + +``` +┌────────────────────────────────────────┐ +│ "Gravl ändrade hur jag tränar" │ +│ ★★★★★ — Marcus, Stockholm │ +│ │ +│ "Äntligen en app utan bloat" │ +│ ★★★★★ — Emma, Göteborg │ +│ │ +│ 50,000+ nöjda användare │ +│ │ +│ [Gå med nu — 399 kr/år] │ +└────────────────────────────────────────┘ +``` + +--- + +## Pricing för Gravl + +### Rekommenderad modell: Freemium + Subscription + +#### Free Tier + +**Inkluderar:** +- Obegränsade custom routines +- Basic workout logging +- Rest timer +- Mörkt tema +- Offline-stöd + +**Begränsningar:** +- Ingen AI-coach +- Basic progress grafer (senaste 30 dagar) +- Ingen exercise substitution +- Ingen export + +#### Premium Tier + +**Inkluderar allt i Free, plus:** +- AI-coach (conversational) +- Avancerade progress analytics +- Exercise substitution +- Dagsform-anpassning +- Data export +- Priority support + +### Prissättning (Sverige) + +| Plan | Pris | Pris/mån | vs konkurrenter | +|------|------|----------|-----------------| +| **Månads** | 69 kr | 69 kr | Under FITBOD, över Hevy | +| **Års** | 399 kr | 33 kr | Konkurrenskraftigt | +| **Lifetime** | 999 kr | — | För early adopters | + +### Positionering + +``` + Billigare ←───────────────────→ Dyrare + + ┌─────┐ + │Gravl│ (value sweet spot) + └─────┘ + ┌────┐ ┌──────┐ ┌──────────┐ + │Hevy│ │Strong│ │ FITBOD │ + └────┘ └──────┘ └──────────┘ + + Gratis $30/år $79+/år +``` + +--- + +## Conversion Funnel + +### Metrics att tracka + +| Metric | Benchmark | Target | +|--------|-----------|--------| +| Free → Trial | 10-20% | 15% | +| Trial → Paid | 25-60% | 40% | +| Month 1 retention | 80-90% | 85% | +| Year 1 retention | 50-70% | 60% | +| ARPU | $0.63 (60d) | $0.70+ | + +### Paywall Placement + +| Trigger | Konvertering | Risk | +|---------|--------------|------| +| **Onboarding** | Hög | Kan skrämma | +| **After first workout** | Medel-Hög | Bra timing | +| **Feature-locked** | Medel | Frustrerande | +| **After value shown** | Högst | Kräver patience | + +**Rekommendation:** Soft paywall efter första passet + feature-lock för AI. + +--- + +## Lokala Betalningsmetoder (Sverige) + +### Rekommenderade + +- **Swish** — Populärt, men komplext för subscription +- **Klarna** — "Betala senare", bra för årsplaner +- **Apple Pay / Google Pay** — Standard +- **Kort** — Via Stripe + +### Implementation + +``` +iOS: StoreKit 2 (App Store billing) +Android: Google Play Billing +Web: Stripe (med Klarna/Swish add-ons) +``` + +--- + +## Revenue Projections + +### Scenario: 10,000 MAU + +| Metric | Value | +|--------|-------| +| Free users | 8,500 (85%) | +| Trial starters | 1,500 (15%) | +| Paid conversions | 600 (40% of trial) | +| Avg revenue/paid user | 399 kr/år | +| **Annual Revenue** | **239,400 kr** | + +### Growth Path + +``` +Year 1: 600 paying users × 399 kr = 239,400 kr +Year 2: 2,000 paying × 399 kr = 798,000 kr +Year 3: 5,000 paying × 399 kr = 1,995,000 kr +``` + +--- + +## Anti-patterns att undvika + +| Gör inte | Varför | +|----------|--------| +| ❌ Ads i workout | Instant uninstall | +| ❌ Paywall på basic logging | Konkurrenter är gratis | +| ❌ Dark patterns | Förstör förtroende | +| ❌ Fake scarcity | Genomskådas | +| ❌ Subscription för allt | "Subscription fatigue" | + +--- + +## Källor + +- RevenueCat State of Subscription Apps 2025 +- AppWill: Paymium for Fitness Apps +- Business of Apps: Monetization Strategies +- Tesseract Academy: Fitness App Monetization 2026 +- Apphud: Trial Conversion Rates +- Phoenix Strategy Group: Freemium vs Subscription +- Crazy Egg: Free-to-Paid Conversion + +--- + +*Sammanställt 2026-02-15 av Bumblebee 🐝* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5694c3f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,78 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Gravl is a fitness/workout tracking app (PPL - Push/Pull/Legs) with progression tracking. The UI is in Swedish. It uses a React frontend, Express backend, and PostgreSQL database, deployed via Docker with nginx and Traefik. + +## Commands + +### Frontend (`frontend/`) +```bash +npm run dev # Vite dev server on port 5173 +npm run build # Production build -> dist/ +npm run preview # Preview production build +``` + +### Backend (`backend/`) +```bash +npm start # node src/index.js +npm run dev # nodemon with auto-reload +``` + +### Docker +```bash +docker compose up -d --build # Build and run all services +``` + +### Database +```bash +psql -h localhost -U postgres -d gravl -f db/init.sql # Initialize schema + seed data +``` + +There are no test or lint configurations. + +## Architecture + +### Frontend (React 18 + Vite, no TypeScript) +- **Entry:** `main.jsx` sets up React Router v6 with `AuthProvider` context +- **Top-level routing** (`main.jsx`): `/login`, `/register`, `/onboarding` use route guards (`AuthRoute`, `ProtectedRoute`) +- **In-app navigation** (`App.jsx`): Uses `useState` view switching (not URL routes) between `'dashboard'`, `'profile'`, `'progress'`, `'select-workout'`, `'workout'` +- **State:** `AuthContext` is the only shared state (token in localStorage, user profile). No Redux or other state libraries. Component-level state via `useState` +- **API calls:** Direct `fetch()` in components with `API_URL = '/api'` constant. No shared API service layer +- **Styling:** Plain CSS with custom properties for theming. Two files: `index.css` (globals) and `App.css` (~1900 lines, organized by component sections). Dark theme with orange accent (`#ff6b35`). Mobile-first, max-width 600px +- **Icons:** Custom SVG icon library in `components/Icons.jsx` (no emoji usage per design decision) +- **Pages directory:** `src/pages/` holds full-page components (`Dashboard.jsx`, `WorkoutPage.jsx`, `LoginPage.jsx`, `RegisterPage.jsx`, `OnboardingWizard.jsx`, `ProfilePage.jsx`, `ProgressPage.jsx`, `WorkoutSelectPage.jsx`) +- **Input components:** `components/StepperInput.jsx` (pure controlled — no internal useState), `WeightInput.jsx` (2.5kg steps, kg suffix), `RepsInput.jsx` (1-rep steps). Used in workout set rows. + +### Backend (Express, single-file) +- **All routes in `src/index.js`** — no separation into route files or controllers +- **Auth:** JWT with 30-day expiry, `bcryptjs` for passwords, `authMiddleware` for protected routes +- **Database:** `pg` with parameterized queries (`$1, $2` placeholders) +- **Currently hardcodes program ID=1** in many queries +- **Env vars (all have defaults):** `JWT_SECRET`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` + +### Database (PostgreSQL) +- Schema in `db/init.sql`: `users`, `programs`, `program_days`, `exercises`, `program_exercises`, `workout_logs` +- Seeded with one PPL program (Push A/B, Pull A/B, Legs A/B) and 18 exercises + +### Deployment +- Frontend: multi-stage Docker build (node -> nginx), nginx proxies `/api` to `gravl-backend:3001` +- Backend: node:20-alpine container on port 3001 +- External PostgreSQL on `homelab` Docker network +- Traefik reverse proxy at `gravl.homelab.local` + +## Conventions + +- Swedish language for all UI text, some variable names and comments +- Functional components only, hooks throughout +- Workout-type CSS color variables: `--workout-push`, `--workout-pull`, `--workout-legs` +- Progression logic: increase weight by 2.5kg when all sets hit max reps +- StepperInput is a pure controlled component — no internal useState, all state in parent +- 44px minimum touch targets on all interactive elements (stepper buttons, inputs) +- Input font-size ≥ 16px everywhere (prevents iOS auto-zoom on focus) + +## agents/ Directory + +Contains AI agent persona definitions (SOUL.md files) for different roles (architect, backend-dev, frontend-dev, coach, nutritionist, reviewer). The `coach/` directory also has exercise data, program definitions (beginner/hypertrophy/strength), and foods data as JSON.