docs: add phase 3 design polish planning, update progress
This commit is contained in:
+16
-16
@@ -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
@@ -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 2 — Flexible Sets (complete)
|
**Current focus:** Phase 3 — Design 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-21 — Completed 02-02 (DELETE /api/logs endpoint + deleteLog wiring)
|
Last activity: 2026-02-26 — Project 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
|
||||||
@@ -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 🐝*
|
||||||
@@ -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 🐝*
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user