docs: add phase 3 design polish planning, update progress
This commit is contained in:
+16
-16
@@ -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*
|
||||
|
||||
+6
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 🐝*
|
||||
@@ -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 🐝*
|
||||
@@ -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 🐝*
|
||||
Reference in New Issue
Block a user