diff --git a/frontend/src/lib/exercises.js b/frontend/src/lib/exercises.js index d2ecc221c6..c7b4e17595 100644 --- a/frontend/src/lib/exercises.js +++ b/frontend/src/lib/exercises.js @@ -132,8 +132,11 @@ export function matchesExerciseSearch(exercise, query) { const ENV = import.meta.env || {} const IMG_BASE = ENV.VITE_IMG_BASE || 'img/' const GIF_BASE = ENV.VITE_GIF_BASE || 'gif/' -export const imgSrc = ex => IMG_BASE + ex.img -export const gifSrc = ex => GIF_BASE + ex.gif +// A custom exercise's own photo/GIF is stored as a data: URL on the exercise itself (issue #11 +// is about the shipped dataset, not user-supplied media) — pass it through as-is rather than +// prefixing it with a base meant for the bundled dataset's filenames. +export const imgSrc = ex => ex.img && ex.img.startsWith('data:') ? ex.img : IMG_BASE + ex.img +export const gifSrc = ex => ex.gif && ex.gif.startsWith('data:') ? ex.gif : GIF_BASE + ex.gif // Cardio exercises log time + speed instead of weight × reps. export const isCardio = idOrEx => (typeof idOrEx === 'string' ? EXIDX[idOrEx] : idOrEx)?.bp === 'cardio' diff --git a/frontend/src/lib/exercises.test.js b/frontend/src/lib/exercises.test.js index 1bc689753a..575facf3c2 100644 --- a/frontend/src/lib/exercises.test.js +++ b/frontend/src/lib/exercises.test.js @@ -1,7 +1,21 @@ import { describe, it, expect } from 'vitest' -import { matchExercise, normalizeStr } from './exercises.js' +import { matchExercise, normalizeStr, imgSrc, gifSrc } from './exercises.js' import { _setLangState } from './i18n-core.js' +describe('imgSrc / gifSrc', () => { + it('prefixes the dataset base onto a bundled exercise\'s own filename', () => { + expect(imgSrc({ img: '0001.jpg' })).toBe('img/0001.jpg') + expect(gifSrc({ gif: '0001.gif' })).toBe('gif/0001.gif') + }) + + it('passes a custom exercise\'s own data: URL through untouched', () => { + const img = 'data:image/jpeg;base64,/9j/AAA=' + const gif = 'data:image/gif;base64,R0lGOD=' + expect(imgSrc({ img })).toBe(img) + expect(gifSrc({ gif })).toBe(gif) + }) +}) + describe('normalizeStr', () => { it('handles null, undefined and empty strings', () => { expect(normalizeStr(null)).toBe('') diff --git a/frontend/src/lib/history.js b/frontend/src/lib/history.js index 2ebbc06839..e456bc1c7f 100644 --- a/frontend/src/lib/history.js +++ b/frontend/src/lib/history.js @@ -151,7 +151,9 @@ export function exLine(cfg, unit) { // Added weight reads as added: "+10 kg" on a dip belt, "60 kg" on a barbell. const load = cfg.weight ? ' · ' + (isBw(cfg) ? '+' : '') + fmtNum(cfg.weight) + ' ' + unit : '' if (mode === 'cardio') return `${n} × ${cfg.min || 20} min @ ${fmtNum(cfg.speed || 8)} km/h` - if (mode === 'time') return `${n} × ${fmtSec(cfg.sec || 45)}${load}` + // A timed hold has no rep count to spell a split out of ("8/side" below) — the sets figure + // stays what was typed and "per side" says it happens twice, once each side (buildWorkSets). + if (mode === 'time') return `${n} × ${fmtSec(cfg.sec || 45)}${load}${isPerSide(cfg) ? ' · ' + t('per side') : ''}` // This is the line with room for it, so the split is spelled out: "3 × 16 · 8/side". const split = isPerSide(cfg) ? ' · ' + t('{0}/side', fmtNum(sideReps(cfg.reps))) : '' return `${n} × ${cfg.reps}${load}${split}` @@ -397,12 +399,20 @@ function buildWorkSets(S, cfg, options = {}) { return sets } if (mode === 'time') { - for (let i = 0; i < n; i++) { + // A timed hold has no rep count to split in half, so "per side" here means the whole hold + // happens once per side rather than once total: the planned sets double (2 sets of 30s + // becomes 2 left + 2 right, each still 30s) instead of the duration being divided. Plain + // rows tagged with `side`, not the L/R sub-row pair reps uses (isSideSet) — there is only + // one duration to log per row, not two independent values to track side by side. + const count = isPerSide(cfg) ? n * 2 : n + for (let i = 0; i < count; i++) { // Only carry a previous value over when it came from a timed set — switching an // exercise from reps to time must not seed the duration from a rep count. const prev = prevAt(i) const carried = prev && prev.sec > 0 ? prev : null - sets.push({ sec: carried ? carried.sec : (cfg.sec || 45), w: carried ? (carried.w || 0) : (cfg.weight || 0), done: false }) + const row = { sec: carried ? carried.sec : (cfg.sec || 45), w: carried ? (carried.w || 0) : (cfg.weight || 0), done: false } + if (isPerSide(cfg)) row.side = i % 2 === 0 ? 'L' : 'R' + sets.push(row) } return sets } diff --git a/frontend/src/lib/history.test.js b/frontend/src/lib/history.test.js index 5fd3edae53..0e1b40dde6 100644 --- a/frontend/src/lib/history.test.js +++ b/frontend/src/lib/history.test.js @@ -289,9 +289,12 @@ describe('sideReps', () => { }) }) -describe('exLine — per side never reaches a timed hold', () => { - it('ignores a stale side flag on a hold, which has no reps to split', () => { - expect(exLine({ id: LIFT, sets: 3, sec: 45, mode: 'time', side: true }, 'kg')).toBe('3 × 0:45') +describe('exLine — per side on a timed hold', () => { + it('reads "per side" rather than splitting a duration in half', () => { + expect(exLine({ id: LIFT, sets: 3, sec: 45, mode: 'time', side: true }, 'kg')).toBe('3 × 0:45 · per side') + }) + it('leaves a plain hold untouched', () => { + expect(exLine({ id: LIFT, sets: 3, sec: 45, mode: 'time' }, 'kg')).toBe('3 × 0:45') }) }) @@ -458,6 +461,22 @@ describe('buildSets', () => { .toEqual([{ sec: 60, w: 20, done: false }, { sec: 60, w: 20, done: false }]) }) + it('doubles a per-side timed hold — once on each side, full duration both times', () => { + expect(buildSets(emptyS, { id: LIFT, mode: 'time', sets: 2, sec: 30, weight: 0, side: true })) + .toEqual([ + { sec: 30, w: 0, done: false, side: 'L' }, { sec: 30, w: 0, done: false, side: 'R' }, + { sec: 30, w: 0, done: false, side: 'L' }, { sec: 30, w: 0, done: false, side: 'R' }, + ]) + }) + + it('carries a per-side timed hold forward by row, L and R each keeping their own history', () => { + const S = { exWeights: {}, workouts: [{ d: '2026-01-01', entries: [{ id: LIFT, target: { mode: 'time', side: true }, sets: [ + { sec: 40, w: 5, done: true, side: 'L' }, { sec: 35, w: 5, done: true, side: 'R' }, + ] }] }] } + expect(buildSets(S, { id: LIFT, mode: 'time', sets: 1, sec: 30, weight: 0, side: true })) + .toEqual([{ sec: 40, w: 5, done: false, side: 'L' }, { sec: 35, w: 5, done: false, side: 'R' }]) + }) + it('builds cardio sets unchanged', () => { expect(buildSets(emptyS, { id: CARDIO, sets: 1, min: 25, speed: 9 })) .toEqual([{ min: 25, speed: 9, done: false }]) diff --git a/frontend/src/sheets.jsx b/frontend/src/sheets.jsx index e484bdf870..0e89834a56 100644 --- a/frontend/src/sheets.jsx +++ b/frontend/src/sheets.jsx @@ -786,7 +786,64 @@ export const addToRoutineSheet = ex => ui().openSheet(close => { + const rd = new FileReader() + rd.onload = () => resolve(String(rd.result)) + rd.onerror = () => reject(new Error('read failed')) + rd.readAsDataURL(file) + }) +} + +// Downscales onto a canvas and re-encodes as JPEG, so a phone photo (often several MB) does not +// have to be turned away just because it was never meant to be a thumbnail. +function downscaleImage(dataUrl) { + return new Promise((resolve, reject) => { + const el = new Image() + el.onload = () => { + const scale = Math.min(1, CUSTOM_MEDIA_MAX_DIM / Math.max(el.width, el.height)) + const w = Math.max(1, Math.round(el.width * scale)), h = Math.max(1, Math.round(el.height * scale)) + const canvas = document.createElement('canvas') + canvas.width = w; canvas.height = h + canvas.getContext('2d').drawImage(el, 0, 0, w, h) + resolve(canvas.toDataURL('image/jpeg', 0.82)) + } + el.onerror = () => reject(new Error('decode failed')) + el.src = dataUrl + }) +} + +// A GIF is used as both the still (Thumb) and the animation (Media) — there is no separate +// first frame to pull out of it without a library, and showing the animation in both spots is a +// fair trade for one. A GIF is not itself downscaled (canvas re-encoding would flatten it to a +// still), so its own size is the only cap. +async function customExMediaFromFile(file) { + if (file.size > CUSTOM_MEDIA_MAX_RAW) { toast(t('That file is too big — under 4 MB, please.')); return null } + try { + if (file.type === 'image/gif') { + const url = await readFileAsDataURL(file) + if (url.length > CUSTOM_MEDIA_MAX_ENCODED) { toast(t('That GIF is too big — try a shorter or smaller one.')); return null } + return { img: url, gif: url } + } + const url = await readFileAsDataURL(file) + const img = await downscaleImage(url) + if (img.length > CUSTOM_MEDIA_MAX_ENCODED) { toast(t('That photo is too big — try a smaller one.')); return null } + return { img, gif: '' } + } catch { + toast(t('Could not read that file')) + return null + } +} + function CustomExForm({ existing, prefill, onDone, close }) { const nameRef = useRef(null) const onNameFocus = useSheetKeyboard(nameRef) @@ -794,6 +851,18 @@ function CustomExForm({ existing, prefill, onDone, close }) { const [bp, setBp] = useState(existing ? existing.bp : '') const [eq, setEq] = useState(existing ? (existing.eq || '') : '') const [desc, setDesc] = useState(existing ? (existing.desc || '') : '') + const [media, setMedia] = useState({ img: existing?.img || '', gif: existing?.gif || '' }) + const [mediaBusy, setMediaBusy] = useState(false) + const mediaFileRef = useRef(null) + const onMediaFile = async ev => { + const file = ev.target.files && ev.target.files[0] + ev.target.value = '' + if (!file) return + setMediaBusy(true) + const next = await customExMediaFromFile(file) + setMediaBusy(false) + if (next) setMedia(next) + } const [primaries, setPrimaries] = useState(() => { if (existing && Array.isArray(existing.primaries) && existing.primaries.length) return [...existing.primaries] if (existing?.bp === 'cardio') return ['cardiovascular system'] @@ -838,10 +907,13 @@ function CustomExForm({ existing, prefill, onDone, close }) { let id = existing && existing.id if (existing) update(s => { const c = (s.customEx || []).find(x => x.id === id); if (c) { c.n = name; c.bp = bp; c.desc = d; c.tg = tg; c.sm = sm; c.muscleGroups = groups; c.primaries = prim; c.secondaries = sm; c.eq = eq + if (media.img) c.img = media.img; else delete c.img + if (media.gif) c.gif = media.gif; else delete c.gif } }) else { id = 'c' + uid() - update(s => { (s.customEx = s.customEx || []).push({ id, n: name, bp, desc: d, tg, sm, muscleGroups: groups, primaries: prim, secondaries: sm, eq, custom: true }) }) + const mediaFields = media.img ? { img: media.img, ...(media.gif ? { gif: media.gif } : {}) } : {} + update(s => { (s.customEx = s.customEx || []).push({ id, n: name, bp, desc: d, tg, sm, muscleGroups: groups, primaries: prim, secondaries: sm, eq, custom: true, ...mediaFields }) }) } close() toast(existing ? t('Saved') : t('“{0}” created', name)) @@ -857,6 +929,17 @@ function CustomExForm({ existing, prefill, onDone, close }) {
{ALL_EQUIPMENT.map(k => ())}
+ +
+ {media.img && } + + {media.img &&
+
+ {bp && <> {bp !== 'cardio' && setC(x => ({ ...x, bodyweight: v, weight: v ? 0 : x.weight }))} /> - {mode === 'reps' && - {/* Turning it on rounds the target up to an even number, since half of an odd - total is a rep one side does not get. */} + {mode !== 'cardio' && + {/* Reps: turning it on rounds the target up to an even number, since half of an odd total + is a rep one side does not get. Time: nothing to round — a hold's whole duration + happens twice, so only the flag changes (buildWorkSets doubles the planned sets). */} setC(x => { + if (mode === 'time') return { ...x, side: v || undefined } const next = { ...x, side: v || undefined, reps: v ? Math.ceil((x.reps || 0) / 2) * 2 : x.reps } return policyFor({ ...next, id: ex.id }, routine, 'reps') === 'double' ? { ...next, ...normalizeRepRange(next.reps, next.repsMin, v ? 2 : 1) } diff --git a/frontend/src/views/Workout.jsx b/frontend/src/views/Workout.jsx index bed11c5e88..d3df0c9cd8 100644 --- a/frontend/src/views/Workout.jsx +++ b/frontend/src/views/Workout.jsx @@ -233,10 +233,20 @@ function ExerciseBlock({ entryIdx, compact, dense, onToggle, onToggleSide, onFie }) // The set number is the set's own menu: drop / burst / remove — three things that used to // sit as chips and an X on every single row. + // A timed per-side exercise plans in L/R pairs (buildWorkSets, addSet above), so the two + // halves of a pair read as one set number — "Set 1 — Left" then "Set 1 — Right" — rather than + // counting every row and making the second half look like an extra set. + const setNumOf = (s, i) => { + const warm = isWarmupRow(s) + const phaseNum = entry.sets.slice(0, i + 1).filter(x => isWarmupRow(x) === warm).length + return s.side ? Math.ceil(phaseNum / 2) : phaseNum + } + const sideTagOf = s => s.side === 'L' ? t('Left') : s.side === 'R' ? t('Right') : null const openSetMenu = (s, i) => { const warm = isWarmupRow(s) + const sideTag = sideTagOf(s) menuSheet({ - title: (warm ? t('Warm-up') : t('Set {0}', entry.sets.slice(0, i + 1).filter(x => isWarmupRow(x) === warm).length)), + title: warm ? t('Warm-up') : sideTag ? t('Set {0} — {1}', setNumOf(s, i), sideTag) : t('Set {0}', setNumOf(s, i)), subtitle: setLabel(entry.id, s, entry.target), items: [ !warm && mode === 'reps' && !isRestPauseSet(s) && { icon: 'arrowDown', label: t('Drop set'), sub: t('+ Drop'), onClick: () => addDropRow(i) }, @@ -384,10 +394,11 @@ function ExerciseBlock({ entryIdx, compact, dense, onToggle, onToggleSide, onFie {!dense && <>
{cardio && {t('Cardio')}} - {/* A unilateral exercise is logged per side directly (the L/R rows below), so the old - "{n} per side" chip — which halved the combined total for display — is gone: the split - is no longer derived, it is what you enter. The tag only flags that this is per-side. */} - {!cardio && !timed && isPerSide(cfg) && {t('Per side')}} + {/* A unilateral exercise is logged per side directly (the L/R rows below for reps, a + "Left"/"Right" tag on each doubled row for a timed hold), so the old "{n} per side" + chip — which halved the combined total for display — is gone: the split is no longer + derived, it is what you enter. The tag only flags that this is per-side. */} + {!cardio && isPerSide(cfg) && {t('Per side')}} {(ex.tg || ex.bp) && {t(MUSCLE_NAME[ex.tg] || ex.tg || ex.bp)}} {ex.eq && {t(ex.eq)}} {best > 0 && {t('Best:')} {fmtNum(best)} {S.unit}} @@ -444,7 +455,8 @@ function ExerciseBlock({ entryIdx, compact, dense, onToggle, onToggleSide, onFie
) : (
onSetRowRef?.(i, el)} className={'setrow' + (s.done ? ' done' : '') + (col3 ? ' eff3' : '') + (timed ? ' timed' : '')}> - + + {sideTagOf(s) && {sideTagOf(s)}} {cell(s, i, col1, 'w')} {col2 && cell(s, i, col2, 'r')} {col3 && effortCell(s, i, col3)} @@ -642,7 +654,14 @@ function ActiveWorkout() { const l = e.sets[e.sets.length - 1] const m = modeOf({ ...(e.target || {}), id: e.id }) if (m === 'cardio') e.sets.push({ min: l ? l.min : (e.target.min || 20), speed: l ? l.speed : (e.target.speed || 8), done: false }) - else if (m === 'time') e.sets.push({ sec: l ? l.sec : (e.target.sec || 45), w: l ? (l.w || 0) : (e.target.weight || 0), done: false }) + else if (m === 'time') { + const sec = l ? l.sec : (e.target.sec || 45) + const w = l ? (l.w || 0) : (e.target.weight || 0) + // A timed per-side exercise is planned in pairs (buildWorkSets), so one more "set" is one + // more hold on each side, not a lone row an L/R count would fall out of sync with. + if (isPerSide({ ...(e.target || {}), id: e.id })) e.sets.push({ sec, w, done: false, side: 'L' }, { sec, w, done: false, side: 'R' }) + else e.sets.push({ sec, w, done: false }) + } else { const row = { w: l ? l.w : 0, r: l ? l.r : e.target.reps, done: false } // A unilateral exercise keeps adding per-side rows (issue #60): seed each side from the