docs: add phase 3 design polish planning, update progress

This commit is contained in:
2026-02-26 23:53:22 +01:00
parent 03c76cb316
commit 3493ffdf44
12 changed files with 2695 additions and 22 deletions
+16 -16
View File
@@ -54,25 +54,25 @@
| Requirement | Phase | Status | | Requirement | Phase | Status |
|-------------|-------|--------| |-------------|-------|--------|
| INP-01 | Phase 1 | Pending | | INP-01 | Phase 1 | ✅ Complete |
| INP-02 | Phase 1 | Pending | | INP-02 | Phase 1 | ✅ Complete |
| INP-03 | Phase 1 | Pending | | INP-03 | Phase 1 | ✅ Complete |
| INP-04 | Phase 1 | Pending | | INP-04 | Phase 1 | ✅ Complete |
| INP-05 | Phase 1 | Pending | | INP-05 | Phase 1 | ✅ Complete |
| INP-06 | Phase 1 | Pending | | INP-06 | Phase 1 | ✅ Complete |
| INP-07 | Phase 1 | Pending | | INP-07 | Phase 1 | ✅ Complete |
| SET-01 | Phase 2 | Pending | | SET-01 | Phase 2 | ✅ Complete |
| SET-02 | Phase 2 | Pending | | SET-02 | Phase 2 | ✅ Complete |
| SET-03 | Phase 2 | Pending | | SET-03 | Phase 2 | ✅ Complete |
| MOD-01 | Phase 3 | Pending | | MOD-01 | Phase 4 | Pending |
| MOD-02 | Phase 3 | Pending | | MOD-02 | Phase 4 | Pending |
| MOD-03 | Phase 3 | Pending | | MOD-03 | Phase 4 | Pending |
**Coverage:** **Coverage:**
- v1 requirements: 13 total - v1 requirements: 13 total
- Mapped to phases: 13 - Completed: 10
- Unmapped: 0 - Remaining: 3 (Phase 4)
--- ---
*Requirements defined: 2026-02-15* *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*
+6 -6
View File
@@ -5,16 +5,16 @@
See: .planning/PROJECT.md (updated 2026-02-15) 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 **Core value:** Logging a workout should be fast, clear, and flexible — the app never fights the user during a session
**Current focus:** Phase 2Flexible Sets (complete) **Current focus:** Phase 3Design Polish & MVP
## Current Position ## Current Position
Phase: 2 of 3 (Flexible Sets) — COMPLETE Phase: 3 of 4 (Design Polish & MVP) — IN PROGRESS
Plan: 2 of 2 in current phase (02-01 and 02-02 complete) Plan: 0 of 3 in current phase
Status: Phase 2 complete — ready for Phase 3 planning Status: Phase 2 complete, Phase 3 planning started
Last activity: 2026-02-21Completed 02-02 (DELETE /api/logs endpoint + deleteLog wiring) Last activity: 2026-02-26Project management handoff, documentation update
Progress: [███████░░] 67% Progress: [███████░░] 67% (Phases 1-2 done, design phase starts)
## Performance Metrics ## Performance Metrics
@@ -91,3 +91,11 @@ None - no external service configuration required.
--- ---
*Phase: 01-input-ux* *Phase: 01-input-ux*
*Completed: 2026-02-16* *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)
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@/home/intense/.claude/get-shit-done/workflows/execute-plan.md
@/home/intense/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto">
<name>Task 1: Refactor ExerciseCard to dynamic set array + add-set modal + delete-set button</name>
<files>frontend/src/pages/WorkoutPage.jsx</files>
<action>
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) => (
<div key={idx} className={`set-row ${input.completed ? 'completed' : ''}`}>
<span className="set-number">Set {idx + 1}</span>
<div className="set-inputs">
<WeightInput
value={input.weight}
onChange={(val) => handleInputChange(idx, 'weight', val)}
/>
<span className="input-separator">×</span>
<RepsInput
value={input.reps}
onChange={(val) => handleInputChange(idx, 'reps', val)}
/>
</div>
<button
className={`delete-set-btn ${setList.length <= 1 ? 'disabled' : ''}`}
onClick={() => handleDeleteSet(idx)}
disabled={setList.length <= 1}
aria-label={`Ta bort set ${idx + 1}`}
>
<Icon name="trash" size={16} />
</button>
<button
className={`complete-btn ${input.completed ? 'done' : ''}`}
onClick={() => handleComplete(idx)}
>
{input.completed ? <Icon name="check" size={18} /> : ''}
</button>
</div>
))}
```
**Render update — below sets list, add "Lägg till set" button and modal:**
```jsx
<button
className="add-set-btn"
onClick={() => setShowAddModal(true)}
>
+ Lägg till set
</button>
{showAddModal && (
<div className="set-type-modal-overlay" onClick={() => setShowAddModal(false)}>
<div className="set-type-modal" onClick={e => e.stopPropagation()}>
<h3>Välj settyp</h3>
<button className="set-type-option" onClick={handleAddNormal}>
<strong>Vanligt set</strong>
<span>Lägg till ett set</span>
</button>
<button className="set-type-option dropset" onClick={handleAddDropset}>
<strong>Dropset</strong>
<span>3 set med viktnedtrappning (20% per steg)</span>
</button>
<button className="set-type-cancel" onClick={() => setShowAddModal(false)}>
Avbryt
</button>
</div>
</div>
)}
```
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
<span className={`progress-badge ${completedSets === setList.length ? 'complete' : ''}`}>
{completedSets}/{setList.length}
</span>
```
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.
</action>
<verify>
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, ...)
</verify>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 2: Add CSS for modal overlay, add-set button, and delete-set button</name>
<files>frontend/src/App.css</files>
<action>
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.
</action>
<verify>
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
</verify>
<done>
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.
</done>
</task>
</tasks>
<verification>
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"
</verification>
<success_criteria>
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.
</success_criteria>
<output>
After completion, create `.planning/phases/02-flexible-sets/02-01-SUMMARY.md`
</output>
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@/home/intense/.claude/get-shit-done/workflows/execute-plan.md
@/home/intense/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto">
<name>Task 1: Add DELETE /api/logs endpoint to backend</name>
<files>backend/src/index.js</files>
<action>
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).
</action>
<verify>
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).
</verify>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 2: Wire deleteLog through App.jsx and WorkoutPage to ExerciseCard</name>
<files>frontend/src/App.jsx, frontend/src/pages/WorkoutPage.jsx</files>
<action>
**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
<WorkoutPage
day={selectedDay}
week={currentWeek}
logs={logs}
onLogSet={logSet}
onDeleteSet={deleteLog}
onBack={() => 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
<ExerciseCard
key={exercise.id || idx}
exercise={exercise}
logs={logs[exercise.id] || []}
progression={progressions[exercise.id]}
expanded={expandedExercise === exercise.id}
onToggle={() => 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.
</action>
<verify>
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
</verify>
<done>
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.
</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
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.
</success_criteria>
<output>
After completion, create `.planning/phases/02-flexible-sets/02-02-SUMMARY.md`
</output>
@@ -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
+4
View File
@@ -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 | | [07-recommendations.md](07-recommendations.md) | Konkreta rekommendationer för Gravl |
| [08-sources.md](08-sources.md) | Alla källor och länkar | | [08-sources.md](08-sources.md) | Alla källor och länkar |
| [09-exercise-databases.md](09-exercise-databases.md) | Övningsdatabaser, APIs, media, substitution | | [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 ## Key Takeaways
@@ -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 🐝*
@@ -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 🐝*
+553
View File
@@ -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 (
<StatusBar color="orange">
📴 Offline — Data sparas lokalt
</StatusBar>
);
}
if (pendingCount > 0) {
return (
<StatusBar color="yellow">
⏳ Synkar {pendingCount} ändringar...
</StatusBar>
);
}
return (
<StatusBar color="green">
✅ Synkad {formatTime(lastSync)}
</StatusBar>
);
};
```
### Per-item sync status
```jsx
const WorkoutLogItem = ({ log }) => {
return (
<View>
<Text>{log.exercise} — {log.weight}kg × {log.reps}</Text>
{!log.synced && (
<Badge color="orange">Ej synkad</Badge>
)}
</View>
);
};
```
---
## 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 🐝*
+386
View File
@@ -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 🐝*
+78
View File
@@ -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.