From f5143262496d43d7fffcbc887889935e0ebb3d45 Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:34:08 -0400 Subject: [PATCH 01/35] Add user-scoped, path-validated photo lookup for private workout images --- workout-tracker/photo.php | 91 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 workout-tracker/photo.php diff --git a/workout-tracker/photo.php b/workout-tracker/photo.php new file mode 100644 index 00000000..7c2404d5 --- /dev/null +++ b/workout-tracker/photo.php @@ -0,0 +1,91 @@ + 'image/jpeg', + 'png' => 'image/png', + 'webp' => 'image/webp', + default => null + }; + if ($expected === null) return null; + $detected = (new finfo(FILEINFO_MIME_TYPE))->file($actual); + if ($detected !== $expected || getimagesize($actual) === false) return null; + return ['path' => $actual, 'filename' => $name, 'mime' => $expected]; +} + +/** Identify a photo strictly by an entry ID already in the authenticated user's JSON file. */ +function workout_owned_photo(array $records, string $entryId, string $storageRoot, string $userId): ?array +{ + if (preg_match('/\A[a-f0-9]{24}\z/D', $entryId) !== 1) return null; + $record = null; + foreach ($records as $item) { + if (!is_array($item) || ($item['id'] ?? null) !== $entryId) continue; + if ($record !== null) return null; // Fail closed for corrupted duplicate IDs. + $record = $item; + } + if ($record === null) return null; + return workout_photo_for_record($record, $storageRoot, $userId); +} + +/** + * A backup is only complete if every referenced personal photo is accessible. + * The manifest includes only photos listed by the user's own records. + */ +function workout_owned_photo_manifest(array $records, string $storageRoot, string $userId): array +{ + $found = []; + foreach ($records as $record) { + if (!is_array($record) || empty($record['photo'])) continue; + $entryId = $record['id'] ?? null; + if (!is_string($entryId) || preg_match('/\A[a-f0-9]{24}\z/D', $entryId) !== 1) { + throw new RuntimeException('A saved photo has an invalid exercise reference.'); + } + $photo = workout_owned_photo($records, $entryId, $storageRoot, $userId); + if ($photo === null) throw new RuntimeException('A saved workout photo is missing or invalid; backup would be incomplete.'); + // Different entry IDs must not silently reuse a single unsafe archive name. + if (isset($found[$photo['filename']]) && $found[$photo['filename']] !== $entryId) { + throw new RuntimeException('Duplicate personal photo reference.'); + } + $found[$photo['filename']] = $entryId; + $manifest[$photo['filename']] = $photo; + } + return array_values($manifest ?? []); +} From 4ae9f738a871c54098df772be0220770fa8c1797 Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:34:41 -0400 Subject: [PATCH 02/35] Provide non-destructive per-user backup export with original JSON and all owned photos --- workout-tracker/backup.php | 62 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 workout-tracker/backup.php diff --git a/workout-tracker/backup.php b/workout-tracker/backup.php new file mode 100644 index 00000000..774251f5 --- /dev/null +++ b/workout-tracker/backup.php @@ -0,0 +1,62 @@ +open($tmp, ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Unable to create a backup archive.'); + } + $instructions = + "Workout Tracker private backup\n" + . "Created: " . gmdate(DATE_ATOM) . "\n" + . "Records: " . count($records) . "\n" + . "Photos: " . count($photos) . "\n" + . "Source: Your authenticated workout account only.\n\n" + . "workouts.json is a copy of the original data, including edit history.\n" + . "Photos are preserved under photos/.\n" + . "Store this ZIP securely. It contains sensitive personal information.\n" + . "Restoration is a deliberate administrator action, never automatic.\n" + . "Before restoring, save a separate backup of current live storage.\n"; + if (!$zip->addFromString('workouts.json', $json) + || !$zip->addFromString('README.txt', $instructions)) { + throw new RuntimeException('Unable to add records to the backup.'); + } + foreach ($photos as $photo) { + if (!$zip->addFile($photo['path'], 'photos/' . $photo['filename'])) { + throw new RuntimeException('Unable to include every personal workout photo.'); + } + } + if (!$zip->close()) throw new RuntimeException('Unable to finalize the backup archive.'); + return $tmp; + } catch (Throwable $e) { + $zip->close(); + unlink($tmp); + throw $e; + } +} From f4392d78e4c00cae5dc8aff78eadcd957ed07bcc Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:35:50 -0400 Subject: [PATCH 03/35] Add authenticated private photo endpoint, ZIP backup action and mobile workout details panel --- workout-tracker/index.php | 70 +++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/workout-tracker/index.php b/workout-tracker/index.php index 329c3150..1fde2d69 100644 --- a/workout-tracker/index.php +++ b/workout-tracker/index.php @@ -2,15 +2,17 @@ /* File Name: index.php Author: Jason Lamb (with help from ChatGPT) Description: Authenticated workout dashboard and JSON API. -Revision: 1.4.0 +Revision: 1.5.0 Created Date: 2026-10-21 Modified Date: 2026-10-27 -Changelog: 1.4.0 - Backward-compatible calories, configurable metrics and detailed weightlifting sets; closes #128. 1.3.2 - Mobile input layout and context-sensitive exercise measurements (display only, no storage changes). 1.3.1 - Bust cached JavaScript and mark Edit button UI repair. 1.3.0 - Edit prior entries safely, optional tracking tags, treadmill incline and retained edit history. 1.2.0 - Add explicit optional AI retry with private config and multi-exercise validation. 1.1.0 - No-key text parsing and atomic multi-exercise logging. 1.0.1 - Use framework JsonStore for private entries. 1.0.0 - Initial release. */ +Changelog: 1.5.0 - Authenticated workout detail/photo viewing and read-only user backup ZIP. 1.4.0 - Backward-compatible calories, configurable metrics and detailed weightlifting sets; closes #128. 1.3.2 - Mobile input layout and context-sensitive exercise measurements (display only, no storage changes). 1.3.1 - Bust cached JavaScript and mark Edit button UI repair. 1.3.0 - Edit prior entries safely, optional tracking tags, treadmill incline and retained edit history. 1.2.0 - Add explicit optional AI retry with private config and multi-exercise validation. 1.1.0 - No-key text parsing and atomic multi-exercise logging. 1.0.1 - Use framework JsonStore for private entries. 1.0.0 - Initial release. */ declare(strict_types=1); require_once dirname(__DIR__) . '/1-Framework/bootstrap.php'; require_once __DIR__ . '/parse.php'; require_once __DIR__ . '/entries.php'; require_once __DIR__ . '/measurements.php'; +require_once __DIR__ . '/photo.php'; +require_once __DIR__ . '/backup.php'; $auth = \Jasr\Framework\SharedIdentity::connect(); $user = $auth->requireProject('workout-tracker', $_SERVER['REQUEST_METHOD'] === 'POST' ? 'member' : 'viewer', isset($_GET['api'])); $uid = (string)$user['id']; @@ -43,6 +45,24 @@ function workoutAiConfig(): ?array { || parse_url($endpoint, PHP_URL_PASS)!==null) return null; return ['endpoint'=>$endpoint,'model'=>$model,'api_key'=>$key]; } + +// Private photo stream. The client supplies only an entry ID; never a path. +if (isset($_GET['photo'])) { + $entryId = $_GET['photo']; + $photo = is_string($entryId) + ? workout_owned_photo(\Jasr\Framework\JsonStore::read($path)['records'], $entryId, $root, $uid) + : null; + header('Cache-Control: private, no-store, max-age=0'); + header('X-Content-Type-Options: nosniff'); + header('Cross-Origin-Resource-Policy: same-origin'); + if ($photo === null) { http_response_code(404); exit('Photo unavailable'); } + header('Content-Type: ' . $photo['mime']); + header('Content-Disposition: inline; filename="workout-photo.' . strtolower(pathinfo($photo['filename'], PATHINFO_EXTENSION)) . '"'); + header('Content-Length: ' . (string)filesize($photo['path'])); + readfile($photo['path']); + exit; +} + if (isset($_GET['api'])) { try { if ($_SERVER['REQUEST_METHOD'] === 'GET') { @@ -51,13 +71,33 @@ function workoutAiConfig(): ?array { if (is_array($record)) $record['_revision']=workout_record_revision($record); } unset($record); - reply(200,['entries'=>$records,'csrf'=>$auth->csrf(),'ai_available'=>workoutAiConfig()!==null]); + reply(200,['entries'=>$records,'csrf'=>$auth->csrf(),'ai_available'=>workoutAiConfig()!==null,'backup_available'=>class_exists(ZipArchive::class)]); } if ($_SERVER['REQUEST_METHOD'] !== 'POST') reply(405, ['error'=>'Method not allowed']); $csrf = $_POST['csrf'] ?? ''; if (!$auth->validCsrf(is_string($csrf) ? $csrf : null)) reply(403, ['error'=>'Invalid CSRF']); $action = $_POST['action'] ?? 'save'; + + if ($action === 'export_backup') { + if (!class_exists(ZipArchive::class)) reply(503, ['error'=>'ZIP backup is unavailable on this server.']); + try { + $archive = workout_prepare_backup($path, $root, $uid); + } catch (RuntimeException $e) { + reply(409, ['error'=>$e->getMessage()]); + } + header('Content-Type: application/zip'); + header('Cache-Control: private, no-store, max-age=0'); + header('Pragma: no-cache'); + header('X-Content-Type-Options: nosniff'); + header('Content-Security-Policy: default-src \'none\''); + header('Content-Disposition: attachment; filename="workout-backup-'.gmdate('Ymd-His').'.zip"'); + header('Content-Length: '.(string)filesize($archive)); + try { readfile($archive); } + finally { unlink($archive); } + exit; + } + if ($action === 'edit_entry') { $id=$_POST['entry_id']??null; $revision=$_POST['expected_revision']??null; @@ -256,16 +296,16 @@ static function(array $records) use($id,$revision,$changes):array { Workout Tracker · Keep moving - + - +
Personal fitness · Your pace

Keep moving.

Every session counts. Progress happens one workout at a time.

-

Workout Tracker v1.4.0 · Updated October 27, 2026

+

Workout Tracker v1.5.0 · Updated October 27, 2026

@@ -349,6 +389,16 @@ static function(array $records) use($id,$revision,$changes):array { + +
+

Keep a private backup

+

Download your own workout history and attached photos in one ZIP file. No workout records are changed.

+ + + +

Checking secure backup availability…

+
From e58802cd79102626a47bf2e8ea9ade99cf1cfb86 Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:37:20 -0400 Subject: [PATCH 04/35] Add mobile workout detail viewer with private image links, set history and protected backup controls --- workout-tracker/app.js | 127 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 123 insertions(+), 4 deletions(-) diff --git a/workout-tracker/app.js b/workout-tracker/app.js index 9827a6e5..4b15931c 100644 --- a/workout-tracker/app.js +++ b/workout-tracker/app.js @@ -1,10 +1,10 @@ /* File Name: app.js Author: Jason Lamb (with help from ChatGPT) Description: Workout quick entry, editable review, and per-exercise trends. -Revision: 1.4.0 +Revision: 1.5.0 Created Date: 2026-10-21 Modified Date: 2026-10-27 -Changelog: 1.4.0 - Editable set-by-set weight/reps, custom measurements, calories and exercise-specific progress. 1.3.2 - Optimize mobile exercise fields, retain hidden measurements, and keep optional metrics near the measurements. 1.3.1 - Fix Edit button crash caused by adding an empty CSS class during form creation. 1.3.0 - Add safe entry editing, tags, incline and history filtering. 1.2.0 - Add opt-in AI retry and private config status. 1.1.0 - Local parsing without API key and multi-exercise saving. */ +Changelog: 1.5.0 - Add authenticated workout details/photo viewer and private backup download UI. 1.4.0 - Editable set-by-set weight/reps, custom measurements, calories and exercise-specific progress. 1.3.2 - Optimize mobile exercise fields, retain hidden measurements, and keep optional metrics near the measurements. 1.3.1 - Fix Edit button crash caused by adding an empty CSS class during form creation. 1.3.0 - Add safe entry editing, tags, incline and history filtering. 1.2.0 - Add opt-in AI retry and private config status. 1.1.0 - Local parsing without API key and multi-exercise saving. */ 'use strict'; const $=selector=>document.querySelector(selector); const measures=[['distance_miles','Miles'],['duration_minutes','Minutes'],['speed_mph','Speed (mph)'],['incline_percent','Treadmill incline (%)'],['calories_kcal','Calories (kcal)'],['reps','Reps'],['sets','Sets'],['weight_lb','Weight (lb)']]; @@ -13,6 +13,7 @@ let editingId=null; let editingRevision=null; let showAllHistory=false; let manualAdvanced=null; +let detailEntryId=null; function localDate(d=new Date()){return [d.getFullYear(),String(d.getMonth()+1).padStart(2,'0'),String(d.getDate()).padStart(2,'0')].join('-');} function localTime(d=new Date()){return [String(d.getHours()).padStart(2,'0'),String(d.getMinutes()).padStart(2,'0')].join(':');} function offset(d=new Date()){const n=-d.getTimezoneOffset(),a=Math.abs(n);return (n>=0?'+':'-')+String(Math.floor(a/60)).padStart(2,'0')+':'+String(a%60).padStart(2,'0');} @@ -218,16 +219,124 @@ function historyItems(){ return entries.slice().filter(e=>!tag||(e.tag||'')===tag) .sort((a,b)=>(b.performed_at||b.date).localeCompare(a.performed_at||a.date)); } + +/** Mobile-readable workout detail view. All user-provided content uses textContent. */ +function openWorkoutDetails(id){ + const entry=entries.find(row=>row.id===id); + if(!entry){message('That workout is no longer available. Refresh your history.',true);return;} + detailEntryId=id; + const title=$('#detailTitle');title.textContent=titleCase(entry.exercise)+' details'; + $('#detailDate').textContent=niceDay(entry.date)+ + (typeof entry.performed_at==='string'?' · '+entry.performed_at.slice(11,16)+' local time':'')+ + (entry.tag?' · #'+entry.tag:''); + const region=$('#detailSummary');region.replaceChildren(); + const fact=(label,value)=>{ + const row=document.createElement('div');row.className='detail-fact'; + const term=document.createElement('span');term.className='detail-label';term.textContent=label; + const amount=document.createElement('strong');amount.textContent=String(value); + row.append(term,amount);region.append(row); + }; + const labels={ + distance_miles:['Distance', ' miles'], + duration_minutes:['Duration',' minutes'], + speed_mph:['Speed',' mph'], + incline_percent:['Treadmill incline','%'], + calories_kcal:['Calories burned',' kcal'], + reps:['Reps (legacy summary)',''], + sets:['Sets (legacy summary)',''], + weight_lb:['Weight (legacy summary)',' lb'] + }; + Object.entries(labels).forEach(([key,[label,unit]])=>{ + if(entry[key]!==null && entry[key]!==undefined && entry[key]!=='') + fact(label,entry[key]+unit); + }); + const stats=WorkoutAdvanced.stats(entry); + if(stats){ + const heading=document.createElement('h3');heading.textContent='Individual sets';region.append(heading); + const list=document.createElement('ol');list.className='detail-sets'; + entry.sets_detail.forEach((set,index)=>{ + const item=document.createElement('li'); + const label=document.createElement('span');label.textContent= + 'Set '+(index+1)+': '+(set.weight_lb==null?'Bodyweight':set.weight_lb+' lb')+' × '+set.reps+' reps'; + item.append(label); + if(set.note){const note=document.createElement('small');note.textContent=String(set.note);item.append(note);} + list.append(item); + }); + region.append(list); + fact('Total repetitions',stats.total_reps); + if(stats.max_weight_lb!==null){ + fact('Heaviest set',stats.max_weight_lb+' lb'); + fact('Total lifted volume',stats.total_volume_lb+' lb'); + } + } + if(Array.isArray(entry.custom_metrics) && entry.custom_metrics.length){ + const heading=document.createElement('h3');heading.textContent='Additional measurements';region.append(heading); + entry.custom_metrics.forEach(metric=>{ + if(typeof metric?.name==='string') + fact(metric.name,String(metric.value??'')+(metric.unit?' '+metric.unit:'')); + }); + } + if(entry.notes){ + const heading=document.createElement('h3');heading.textContent='Notes';region.append(heading); + const note=document.createElement('p');note.className='detail-notes';note.textContent=entry.notes; + region.append(note); + } + if(Array.isArray(entry.edit_history) && entry.edit_history.length){ + const history=document.createElement('details'); + const summary=document.createElement('summary'); + summary.textContent='Edit history · '+entry.edit_history.length+' previous version'+ + (entry.edit_history.length===1?'':'s'); + history.append(summary); + // Read-only audit details: no restoration/write operation. + entry.edit_history.slice(-10).reverse().forEach(change=>{ + const previous=change.previous; + if(!previous || typeof previous!=='object')return; + const paragraph=document.createElement('p');paragraph.className='footnote'; + paragraph.textContent=(change.edited_at||'Edited')+' — '+ + (previous.exercise||'Workout')+' · '+(previous.date||'')+ + ' · '+describe(previous)+(previous.notes?' · '+previous.notes:''); + history.append(paragraph); + }); + region.append(history); + } + const media=$('#detailImage');media.replaceChildren();media.hidden=true; + if(typeof entry.photo==='string' && entry.photo!==''){ + const image=document.createElement('img'); + const src='?photo='+encodeURIComponent(id); + image.src=src;image.alt='Private photo attached to '+entry.exercise+' workout'; + image.loading='lazy'; + const fallback=document.createElement('p');fallback.className='footnote';fallback.textContent= + 'Photo unavailable. Check that private storage is preserved on Hostinger.'; + fallback.hidden=true; + image.addEventListener('error',()=>{image.hidden=true;fallback.hidden=false;}); + const link=document.createElement('a');link.href=src;link.target='_blank'; + link.rel='noopener noreferrer';link.textContent='Open full-size photo'; + const caption=document.createElement('p');caption.className='footnote'; + caption.textContent='Only your authenticated account can view this image.'; + media.append(image,link,fallback,caption);media.hidden=false; + } + $('#detailPanel').hidden=false; + $('#detailPanel').scrollIntoView({behavior:'smooth',block:'start'}); +} +function closeWorkoutDetails(){ + detailEntryId=null;$('#detailPanel').hidden=true;$('#detailImage').replaceChildren(); +} + function renderHistory(){ const history=historyItems(),visible=showAllHistory?history:history.slice(0,10); $('#recent').replaceChildren(...visible.map(e=>{ const li=document.createElement('li'),name=document.createElement('strong');name.textContent=titleCase(e.exercise); const day=document.createElement('span');day.textContent=niceDay(e.date); const detail=document.createElement('small');detail.textContent=describe(e)+(e.tag?' · #'+e.tag:'')+(e.photo?' · Photo saved':''); + const actions=document.createElement('div');actions.className='history-actions'; + const view=document.createElement('button');view.type='button';view.className='history-edit history-view'; + view.textContent='Details';view.disabled=!e.id; + view.setAttribute('aria-label','View '+e.exercise+' details from '+niceDay(e.date)); + view.addEventListener('click',()=>openWorkoutDetails(e.id)); const edit=document.createElement('button');edit.type='button';edit.className='history-edit'; edit.textContent='Edit';edit.dataset.editId=e.id||''; edit.disabled=!e.id||!e._revision;edit.setAttribute('aria-label','Edit '+e.exercise+' from '+niceDay(e.date)); - li.append(name,day,detail,edit);return li; + actions.append(view,edit);li.append(name,day,detail,actions);return li; })); const toggle=$('#toggleHistory');toggle.hidden=history.length<=10; toggle.textContent=showAllHistory?'Show latest 10':'View all '+history.length+' entries'; @@ -332,7 +441,14 @@ async function load(){try{const data=await api();entries=data.entries;$('#csrf') $('#aiHint').textContent=data.ai_available ?'AI fallback is configured. Select it only when the local interpretation needs another try. Your description will be sent to your configured AI provider.' :'AI fallback is optional and disabled until config.local.example.php is copied to config.local.php on Hostinger with a private API key.'; - refresh();}catch(e){message(e.message,true);}} + $('#backupCsrf').value=data.csrf; + $('#backupBtn').disabled=!data.backup_available; + $('#backupStatus').textContent=data.backup_available + ?'This ZIP contains your original workout records, edit history and attached photos. Store it privately.' + :'ZIP exports are unavailable on this server; PHP ZipArchive must be enabled.'; + refresh(); + if(detailEntryId && !$('#detailPanel').hidden)openWorkoutDetails(detailEntryId); + }catch(e){message(e.message,true);}} $('#dateLabel').textContent=new Date().toLocaleDateString(undefined,{weekday:'long',month:'long',day:'numeric'}); $('#parseBtn').addEventListener('click',review); $('#aiParseBtn').addEventListener('click',retryWithAi); @@ -344,6 +460,9 @@ $('#toggleHistory').addEventListener('click',()=>{showAllHistory=!showAllHistory $('#recent').addEventListener('click',e=>{const button=e.target.closest('[data-edit-id]');if(button)beginEdit(button.dataset.editId);}); $('#editForm').addEventListener('submit',e=>{e.preventDefault();submitEdit();}); $('#cancelEdit').addEventListener('click',closeEdit); +$('#closeDetail').addEventListener('click',closeWorkoutDetails); +$('#editFromDetail').addEventListener('click',()=>{if(detailEntryId)beginEdit(detailEntryId);}); +$('#backupForm').addEventListener('submit',()=>{$('#backupStatus').textContent='Preparing your private backup download…';}); $('#manualForm').addEventListener('submit',async e=>{ e.preventDefault(); const fd=new FormData(e.currentTarget),row=Object.fromEntries(fd.entries()); From f354b33462d17f98d5d1bf4cd1fac64851f1af46 Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:37:55 -0400 Subject: [PATCH 05/35] Style compact touch-friendly workout details, photos and backup controls for mobile --- workout-tracker/app.css | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/workout-tracker/app.css b/workout-tracker/app.css index 2ce0e724..77778c9b 100644 --- a/workout-tracker/app.css +++ b/workout-tracker/app.css @@ -2,10 +2,10 @@ File Name: app.css Author: Jason Lamb (with help from ChatGPT) Description: Calm, responsive workout dashboard with compact progressive-entry controls. -Revision: 1.4.0 +Revision: 1.5.0 Created Date: 2026-10-21 Modified Date: 2026-10-27 -Changelog: 1.4.0 - Thumb-friendly set rows and expandable custom numeric measurements. 1.3.2 - Keep mobile date/time full width, compact numeric pairs, iOS-safe input fonts and thumb-friendly touch targets. 1.3.0 - Style editable history, tracking tags and incline controls. 1.2.0 - Add low-emphasis optional AI retry action. 1.1.0 - Mobile-first quick entry, measured progress, accessible review layout. +Changelog: 1.5.0 - Add mobile details panel, secure photo preview and backup download controls. 1.4.0 - Thumb-friendly set rows and expandable custom numeric measurements. 1.3.2 - Keep mobile date/time full width, compact numeric pairs, iOS-safe input fonts and thumb-friendly touch targets. 1.3.0 - Style editable history, tracking tags and incline controls. 1.2.0 - Add low-emphasis optional AI retry action. 1.1.0 - Mobile-first quick entry, measured progress, accessible review layout. */ :root{color-scheme:dark;font:16px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#08141f;color:#e7f5f8} *{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 80% 0%,#173b44 0%,#0b1a29 50%,#08141f 100%);min-height:100vh} @@ -144,3 +144,35 @@ details>summary{min-height:44px;display:list-item;align-content:center;} .custom-row>.field:nth-child(3){grid-column:2;grid-row:2;} .custom-row>.remove-item{grid-column:1/-1;grid-row:auto;width:100%;} } + +/* Authenticated workout detail card: readable on small phone screens. */ +.history-actions{grid-column:2;grid-row:2;display:flex;gap:7px;align-items:center;justify-content:flex-end;flex-wrap:wrap;} +.history-actions .history-edit{grid-column:auto;grid-row:auto;align-self:auto;min-height:44px;font-size:.84rem;padding:8px 12px;} +.history-actions .history-view{background:#25485b;border-color:#4a8291;color:#d8f4f5;} +.workout-detail{border-top:1px solid #376579;margin-top:19px;} +.workout-detail .detail-close{width:auto;min-height:44px;margin-top:0;font-size:.86rem;padding:8px 14px;} +.detail-summary{display:grid;gap:10px;min-width:0;} +.detail-fact{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:10px 0;border-bottom:1px solid #2e4b5a;min-width:0;} +.detail-label{color:#b6cdda;overflow-wrap:anywhere;} +.detail-fact strong{text-align:right;white-space:normal;overflow-wrap:anywhere;font-variant-numeric:tabular-nums;} +.detail-summary h3{margin:18px 0 1px;} +.detail-sets{margin:8px 0 6px;padding-left:24px;} +.detail-sets li{margin:7px 0;padding-left:2px;overflow-wrap:anywhere;} +.detail-sets small{display:block;color:#9cb9c6;} +.detail-notes{overflow-wrap:anywhere;white-space:pre-wrap;color:#c4dbe3;} +.detail-image{margin:18px 0 0;} +.detail-image img{display:block;max-height:min(70vh,640px);width:100%;height:auto;object-fit:contain;border:1px solid #3c5e70;border-radius:14px;background:#081522;} +.detail-image a{display:inline-flex;align-items:center;min-height:44px;color:#9adfd3;text-decoration:underline;text-underline-offset:3px;margin-top:9px;} +.detail-actions .secondary-action{min-height:46px;} +.backup-form{margin-top:28px;padding-top:22px;border-top:1px solid #315063;} +.backup-form h2{margin-bottom:8px;} +.backup-form p{overflow-wrap:anywhere;} +.backup-form .secondary-action{min-height:48px;} +@media(max-width:560px){ + .recent-list li{grid-template-columns:minmax(0,1fr) auto;column-gap:8px;} + .history-actions{display:flex;flex-direction:column;align-items:stretch;gap:7px;} + .history-actions .history-edit{width:100%;font-size:.85rem;} + .detail-fact{flex-direction:column;gap:3px;align-items:flex-start;} + .detail-fact strong{text-align:left;} +} +@media(max-width:360px){.workout-detail .panel-head{align-items:flex-start;}} From 15905e02f10da6c41422dbae199d04ef78a73458 Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:39:12 -0400 Subject: [PATCH 06/35] Test authenticated per-user photo resolution and safe read-only backup export with synthetic data --- workout-tracker/tests/photo-backup.test.php | 116 ++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 workout-tracker/tests/photo-backup.test.php diff --git a/workout-tracker/tests/photo-backup.test.php b/workout-tracker/tests/photo-backup.test.php new file mode 100644 index 00000000..ccdc27e4 --- /dev/null +++ b/workout-tracker/tests/photo-backup.test.php @@ -0,0 +1,116 @@ +$firstId, 'exercise'=>'treadmill', 'date'=>'2026-10-21', + 'distance_miles'=>1.16, 'photo'=>$filename, 'notes'=>'Private test'], + ['id'=>$otherId, 'exercise'=>'push-ups', 'date'=>'2026-10-22', + 'photo'=>null, 'reps'=>20] + ]; + JsonStore::update($jsonPath, static fn(array $existing): array => $records); + $before = file_get_contents($jsonPath); + $found = workout_owned_photo($records, $firstId, $root, $user); + test_assert($found !== null && $found['mime']==='image/png', 'owned referenced PNG resolves'); + test_assert($found['filename']===$filename, 'photo references its own filename'); + test_assert(workout_owned_photo($records, $otherId, $root, $user)===null, + 'unattached entry cannot request an unrelated photo'); + test_assert(workout_owned_photo($records, $firstId, $root, $other)===null, + 'different account never sees another user photo'); + test_assert(workout_owned_photo($records, '../'.$firstId, $root, $user)===null, + 'entry ID cannot traverse directories'); + test_assert(workout_owned_photo([['id'=>$firstId,'photo'=>'../../config.local.php']], + $firstId, $root, $user)===null, 'malicious stored photo reference rejected'); + test_assert(workout_owned_photo([['id'=>$firstId,'photo'=>'evil.svg']], + $firstId, $root, $user)===null, 'unsupported potentially active images rejected'); + test_assert(workout_photo_name_is_safe('original.jpg'), 'legacy photo names are accepted'); + test_assert(!workout_photo_name_is_safe('some/photo.png'), 'path separators rejected'); + test_assert(!workout_photo_name_is_safe('..%2Fsecret.jpg'), 'encoded path segments rejected'); + test_assert(!workout_photo_name_is_safe("test.jpg\0.php"), 'null bytes rejected'); + test_assert(!workout_photo_name_is_safe('test.jpeg.php'), 'double extensions rejected'); + + $manifest=workout_owned_photo_manifest($records, $root, $user); + test_assert(count($manifest)===1 && $manifest[0]['filename']===$filename, + 'manifest includes exactly one owned referenced photo'); + test_fails(static fn()=>workout_owned_photo_manifest([ + ['id'=>$firstId,'photo'=>'missing.png'] + ], $root, $user),'missing image makes backup fail instead of silently skipping'); + test_assert(file_get_contents($jsonPath)===$before, + 'photo reads and manifest checks never rewrite JSON'); + + if (class_exists(ZipArchive::class)) { + $archive=workout_prepare_backup($jsonPath,$root,$user); + try { + test_assert(is_file($archive),'a temporary backup ZIP is produced'); + test_assert(strpos(realpath($archive), realpath($root).DIRECTORY_SEPARATOR)!==0, + 'backup never stored in public workout directory'); + $zip=new ZipArchive(); + test_assert($zip->open($archive)===true,'archive opens'); + $data=json_decode((string)$zip->getFromName('workouts.json'),true); + test_assert($data['schemaVersion']===1 && $data['records']===$records, + 'original complete JSON records are retained'); + test_assert($zip->getFromName('photos/'.$filename)===$fixture, + 'exact owned private photo is included in archive'); + test_assert($zip->getFromName('config.local.php')===false, + 'secrets are never included'); + $zip->close(); + } finally { if (is_file($archive)) unlink($archive); } + } else { + test_fails(static fn()=>workout_prepare_backup($jsonPath,$root,$user), + 'missing ZipArchive extension must fail explicitly'); + } + test_assert(file_get_contents($jsonPath)===$before, + 'export preparation leaves the preexisting JSON bytes unchanged'); + echo "Passed: photo ownership, path checks, missing-photo safety, and read-only backup preparation."; + if (!class_exists(ZipArchive::class)) echo " Archive-content assertions skipped (ZIP extension absent)."; + echo PHP_EOL; +} finally { + foreach (glob($folder.'/*') ?: [] as $p) unlink($p); + foreach (glob($otherFolder.'/*') ?: [] as $p) unlink($p); + foreach (glob($root.'/*') ?: [] as $p) { + if (is_file($p)) unlink($p); + } + if (is_dir($folder)) rmdir($folder); + if (is_dir($otherFolder)) rmdir($otherFolder); + if (is_dir($photos)) rmdir($photos); + if (is_dir($root)) rmdir($root); +} From a730c826abad2d2a2bab3ac0d6efe17b474d75ba Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:40:06 -0400 Subject: [PATCH 07/35] Test full ZIP backup integrity and strict photo ownership in CI --- .github/workflows/workout-tracker-validation.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/workout-tracker-validation.yml b/.github/workflows/workout-tracker-validation.yml index de1e0907..72e09d42 100644 --- a/.github/workflows/workout-tracker-validation.yml +++ b/.github/workflows/workout-tracker-validation.yml @@ -1,10 +1,10 @@ # File Name: workout-tracker-validation.yml # Author: Jason Lamb (with help from ChatGPT) # Description: Validate PHP, JavaScript and preservation of Workout Tracker records. -# Revision: 1.1.0 +# Revision: 1.2.0 # Created Date: 2026-10-22 # Modified Date: 2026-10-27 -# Changelog: 1.1.0 - Validate optional measurements, legacy-safe set edits and new advanced UI tests. 1.0.1 - Run DOM-backed Edit button regression test to catch empty CSS class exceptions. 1.0.0 - Run parser and lossless edit regression tests on pull requests. +# Changelog: 1.2.0 - Test user-bound image access, backup ZIP, and read-only storage. 1.1.0 - Validate optional measurements, legacy-safe set edits and new advanced UI tests. 1.0.1 - Run DOM-backed Edit button regression test to catch empty CSS class exceptions. 1.0.0 - Run parser and lossless edit regression tests on pull requests. name: Workout Tracker validation on: pull_request: @@ -19,10 +19,20 @@ jobs: contents: read steps: - uses: actions/checkout@v4 + - name: Enable ZIP support for archive verification + run: | + if ! php -r 'exit(class_exists("ZipArchive") ? 0 : 1);'; then + sudo apt-get update -qq + sudo apt-get install -y php-zip + fi + php -r 'if (!class_exists("ZipArchive")) { fwrite(STDERR, "ZipArchive is required to test backup contents.\n"); exit(1); }' - name: Validate PHP code run: | php -v php -l workout-tracker/index.php + php -l workout-tracker/photo.php + php -l workout-tracker/backup.php + php -l workout-tracker/tests/photo-backup.test.php php -l workout-tracker/entries.php php -l workout-tracker/measurements.php php -l workout-tracker/tests/measurements.test.php @@ -34,6 +44,7 @@ jobs: php workout-tracker/tests/parse.test.php php workout-tracker/tests/edit.test.php php workout-tracker/tests/measurements.test.php + php workout-tracker/tests/photo-backup.test.php - name: Verify JavaScript syntax and Edit button behavior run: | node --check workout-tracker/app.js From 776adb9fc2ce86cd67145263f5e279154721823a Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:40:54 -0400 Subject: [PATCH 08/35] Regression-test private photo detail viewer with actual safe DOM rendering --- workout-tracker/tests/workout-detail.test.cjs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 workout-tracker/tests/workout-detail.test.cjs diff --git a/workout-tracker/tests/workout-detail.test.cjs b/workout-tracker/tests/workout-detail.test.cjs new file mode 100644 index 00000000..40e044c5 --- /dev/null +++ b/workout-tracker/tests/workout-detail.test.cjs @@ -0,0 +1,92 @@ +/* +File Name: workout-detail.test.cjs +Author: Jason Lamb (with help from ChatGPT) +Description: DOM regression for mobile workout detail and private photo viewing. +Revision: 1.0.0 +Created Date: 2026-10-27 +Modified Date: 2026-10-27 +Changelog: 1.0.0 - Verify safe UI rendering, set details, photo ID URL, and old entries without photos. +*/ +'use strict'; +const assert=require('node:assert/strict'); +const fs=require('node:fs'); +const path=require('node:path'); +const vm=require('node:vm'); +const script=fs.readFileSync(path.join(__dirname,'..','app.js'),'utf8'); +const start=script.indexOf('function openWorkoutDetails(id){'); +const end=script.indexOf('function renderHistory(){',start); +assert.ok(start>=0 && end>start,'Detail-view implementation is present'); + +class Element { + constructor(tag='div'){ + this.tagName=tag;this.children=[];this.hidden=false;this.textContent=''; + this.listeners={};this.className='';this.id='';this.src='';this.href=''; + } + append(...elements){this.children.push(...elements);} + replaceChildren(...elements){this.children=elements;} + addEventListener(type,fn){this.listeners[type]=fn;} + scrollIntoView(){this.scrolled=true;} +} + +const ids=['#detailTitle','#detailDate','#detailSummary','#detailImage','#detailPanel']; +const elements=new Map(ids.map(id=>[id,new Element()])); +elements.get('#detailPanel').hidden=true; +const id='aaaaaaaaaaaaaaaaaaaaaaaa'; +const old='bbbbbbbbbbbbbbbbbbbbbbbb'; +const first={ + id,exercise:'bench press',date:'2026-10-22',performed_at:'2026-10-22T08:37:00-04:00', + notes:'Warmup ',tag:'Strength', + photo:id+'.png',sets_detail:[{weight_lb:95,reps:12},{weight_lb:115,reps:10},{weight_lb:135,reps:8}], + custom_metrics:[{name:'Tempo - +
Personal fitness · Your pace

Keep moving.

Every session counts. Progress happens one workout at a time.

-

Workout Tracker v1.5.0 · Updated October 27, 2026

+

Workout Tracker v1.5.1 · Updated October 27, 2026

@@ -320,7 +320,7 @@ static function(array $records) use($id,$revision,$changes):array {

What did you do?

Today

Type it naturally. The app reads common exercises and numbers right here, with no AI key.

- + @@ -351,6 +351,7 @@ static function(array $records) use($id,$revision,$changes):array { + @@ -375,6 +376,7 @@ static function(array $records) use($id,$revision,$changes):array { + From ae5dffe49ff38c681d743d572beea4e0cccb88ef Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:16:02 -0400 Subject: [PATCH 19/35] Show heart rate in history, private workout details, mobile entry/edit and BPM charts --- workout-tracker/app.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/workout-tracker/app.js b/workout-tracker/app.js index 4b15931c..6b721f33 100644 --- a/workout-tracker/app.js +++ b/workout-tracker/app.js @@ -1,13 +1,13 @@ /* File Name: app.js Author: Jason Lamb (with help from ChatGPT) Description: Workout quick entry, editable review, and per-exercise trends. -Revision: 1.5.0 +Revision: 1.5.1 Created Date: 2026-10-21 Modified Date: 2026-10-27 -Changelog: 1.5.0 - Add authenticated workout details/photo viewer and private backup download UI. 1.4.0 - Editable set-by-set weight/reps, custom measurements, calories and exercise-specific progress. 1.3.2 - Optimize mobile exercise fields, retain hidden measurements, and keep optional metrics near the measurements. 1.3.1 - Fix Edit button crash caused by adding an empty CSS class during form creation. 1.3.0 - Add safe entry editing, tags, incline and history filtering. 1.2.0 - Add opt-in AI retry and private config status. 1.1.0 - Local parsing without API key and multi-exercise saving. */ +Changelog: 1.5.1 - Display and edit heart rate BPM with cardio-focused fields and separate trends. 1.5.0 - Add authenticated workout details/photo viewer and private backup download UI. 1.4.0 - Editable set-by-set weight/reps, custom measurements, calories and exercise-specific progress. 1.3.2 - Optimize mobile exercise fields, retain hidden measurements, and keep optional metrics near the measurements. 1.3.1 - Fix Edit button crash caused by adding an empty CSS class during form creation. 1.3.0 - Add safe entry editing, tags, incline and history filtering. 1.2.0 - Add opt-in AI retry and private config status. 1.1.0 - Local parsing without API key and multi-exercise saving. */ 'use strict'; const $=selector=>document.querySelector(selector); -const measures=[['distance_miles','Miles'],['duration_minutes','Minutes'],['speed_mph','Speed (mph)'],['incline_percent','Treadmill incline (%)'],['calories_kcal','Calories (kcal)'],['reps','Reps'],['sets','Sets'],['weight_lb','Weight (lb)']]; +const measures=[['distance_miles','Miles'],['duration_minutes','Minutes'],['speed_mph','Speed (mph)'],['incline_percent','Treadmill incline (%)'],['calories_kcal','Calories (kcal)'],['heart_rate_bpm','Heart rate (BPM)'],['reps','Reps'],['sets','Sets'],['weight_lb','Weight (lb)']]; let entries=[]; let editingId=null; let editingRevision=null; @@ -33,6 +33,7 @@ function fieldInput(name,label,value,type='number'){ if(type==='number'){input.min='0';input.step='any';}if(type==='text')input.maxLength=name==='tag'?64:80; if(name==='incline_percent')input.max='100'; if(name==='calories_kcal')input.max='100000'; + if(name==='heart_rate_bpm'){input.min='1';input.max='300';input.step='1';input.inputMode='numeric';} if(type==='number')input.inputMode='decimal'; wrapper.append(caption,input);return wrapper; } @@ -44,6 +45,7 @@ function describe(entry){ entry.speed_mph!=null?entry.speed_mph+' mph':'', entry.incline_percent!=null?entry.incline_percent+'% incline':'', entry.calories_kcal!=null?entry.calories_kcal+' kcal':'', + entry.heart_rate_bpm!=null?entry.heart_rate_bpm+' bpm':'', ]; if(perSet){ parts.push(perSet.set_count+' sets',perSet.total_reps+' total reps'); @@ -67,9 +69,9 @@ function describe(entry){ */ function visibleFieldsForExercise(exercise){ const name=String(exercise||'').toLowerCase().trim().replace(/\s+/g,' '); - if(/tread\s?mill/.test(name)) return ['distance_miles','duration_minutes','speed_mph','incline_percent','calories_kcal']; + if(/tread\s?mill/.test(name)) return ['distance_miles','duration_minutes','speed_mph','incline_percent','calories_kcal','heart_rate_bpm']; if(/^(walking|walk|hiking|running|run|cycling|bike|biking|stationary bike|rowing|swimming|elliptical|stairs)$/.test(name)) - return ['distance_miles','duration_minutes','speed_mph','calories_kcal']; + return ['distance_miles','duration_minutes','speed_mph','calories_kcal','heart_rate_bpm']; if(/^(bench press|deadlift|weight training|weight lifting)$/.test(name)) return ['weight_lb','reps','sets']; if(/^(push-ups|pushups|sit-ups|situps|squats|lunges|crunches|jumping jacks)$/.test(name)) @@ -242,6 +244,7 @@ function openWorkoutDetails(id){ speed_mph:['Speed',' mph'], incline_percent:['Treadmill incline','%'], calories_kcal:['Calories burned',' kcal'], + heart_rate_bpm:['Heart rate',' BPM'], reps:['Reps (legacy summary)',''], sets:['Sets (legacy summary)',''], weight_lb:['Weight (legacy summary)',' lb'] @@ -394,6 +397,12 @@ async function submitEdit(){ if(!editingId||!editingRevision)return; const button=$('#confirmEdit'),row={}; $('#editFields').querySelectorAll('[data-field]').forEach(input=>{row[input.dataset.field]=input.value;}); + // Old records without heart rate must remain unchanged unless the user enters one. + if(row.heart_rate_bpm==='' && editingId){ + const original=entries.find(entry=>entry.id===editingId); + if(original && !Object.prototype.hasOwnProperty.call(original,'heart_rate_bpm')) + delete row.heart_rate_bpm; + } if(!row.time_local){delete row.time_local;delete row.utc_offset;} try{Object.assign(row,WorkoutAdvanced.collect($('#editFields')));}catch(e){$('#editStatus').textContent=e.message;return;} if(!row.exercise.trim()||!row.date){$('#editStatus').textContent='Exercise and date are required.';return;} From 4267869d8787355e63587fb9a9b8433c2efe1653 Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:16:39 -0400 Subject: [PATCH 20/35] Test natural heart-rate parsing, multiple exercises, and bpm-versus-speed edge cases --- workout-tracker/tests/parse.test.php | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/workout-tracker/tests/parse.test.php b/workout-tracker/tests/parse.test.php index ba6e0beb..67e8c2bd 100644 --- a/workout-tracker/tests/parse.test.php +++ b/workout-tracker/tests/parse.test.php @@ -2,10 +2,10 @@ /* File Name: parse.test.php Author: Jason Lamb (with help from ChatGPT) Description: Standalone CLI regression tests for key-free workout descriptions. -Revision: 1.3.0 +Revision: 1.4.0 Created Date: 2026-10-21 Modified Date: 2026-10-22 -Changelog: 1.3.0 - Parse calorie totals and varying bench-press weights in one exercise. 1.2.0 - Assert explicit treadmill incline and tracking hashtag parsing. 1.1.0 - Validate workouts, multi-exercise separation, dates, and invalid input. */ +Changelog: 1.4.0 - Test BPM, HR/pulse aliases and reject speed/duration confusion. 1.3.0 - Parse calorie totals and varying bench-press weights in one exercise. 1.2.0 - Assert explicit treadmill incline and tracking hashtag parsing. 1.1.0 - Validate workouts, multi-exercise separation, dates, and invalid input. */ declare(strict_types=1); require dirname(__DIR__) . '/parse.php'; $cases=[ @@ -24,7 +24,16 @@ ['treadmill 2 miles 30 mins 5% incline 320 calories',[['exercise'=>'treadmill','distance_miles'=>2,'duration_minutes'=>30,'incline_percent'=>5,'calories_kcal'=>320]]], ['bench press 95 lb x 12, 115 lb x 10, 135 lb x 8, 115 lb x 10',[['exercise'=>'bench press','sets_detail'=>[ ['weight_lb'=>95,'reps'=>12],['weight_lb'=>115,'reps'=>10],['weight_lb'=>135,'reps'=>8],['weight_lb'=>115,'reps'=>10] - ]]]] + ]]]], + ['Treadmill 1.16 miles in 22 minutes at 3.3 mph, heart rate 143 bpm',[['exercise'=>'treadmill','distance_miles'=>1.16,'duration_minutes'=>22,'speed_mph'=>3.3,'heart_rate_bpm'=>143]]], + ['Walked 2 miles with average heart rate of 128',[['exercise'=>'walking','distance_miles'=>2,'heart_rate_bpm'=>128]]], + ['Bench press 3 sets of 8 reps HR 150',[['exercise'=>'bench press','sets'=>3,'reps'=>8,'heart_rate_bpm'=>150]]], + ['Treadmill 2 miles at 145 bpm for 30 minutes',[['exercise'=>'treadmill','distance_miles'=>2,'duration_minutes'=>30,'heart_rate_bpm'=>145,'speed_mph'=>null]]], + ['Treadmill 2 miles 1 hr 15 minutes',[['exercise'=>'treadmill','distance_miles'=>2,'duration_minutes'=>75,'heart_rate_bpm'=>null]]], + ['Running 3 miles with 150 beats per minute',[['exercise'=>'running','distance_miles'=>3,'heart_rate_bpm'=>150]]], + ['Rowing for 10 mins, pulse 132',[['exercise'=>'rowing','duration_minutes'=>10,'heart_rate_bpm'=>132]]], + ['Treadmill 2 miles 30 min HR 143 and 20 push-ups',[['exercise'=>'treadmill','distance_miles'=>2,'heart_rate_bpm'=>143],['exercise'=>'push-ups','reps'=>20]]] + ]; foreach($cases as $i=>[$input,$expected]){ $records=workout_parse_text($input,'2026-10-21'); @@ -40,8 +49,8 @@ throw new RuntimeException('Case '.$i.' '.$field.' mismatch: '.json_encode($records[$j])); } } -foreach(['','gym workout','treadmill on 2026-02-30'] as $bad){ +foreach(['','gym workout','treadmill on 2026-02-30', 'treadmill 1 mile at heart rate 301 bpm', 'walking HR 0'] as $bad){ try{workout_parse_text($bad,'2026-10-21');throw new RuntimeException('Invalid input accepted.');} catch(InvalidArgumentException $e){} } -echo 'Passed '.count($cases).' extraction examples and 3 invalid-input cases.'.PHP_EOL; +echo 'Passed '.count($cases).' extraction examples and 5 invalid-input cases.'.PHP_EOL; From 8b48c09fab8b3ee006579b6a87f14d201b0f4a08 Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:17:49 -0400 Subject: [PATCH 21/35] Verify BPM input, parsing and legacy-safe edit persistence without live data --- workout-tracker/tests/heart-rate.test.php | 107 ++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 workout-tracker/tests/heart-rate.test.php diff --git a/workout-tracker/tests/heart-rate.test.php b/workout-tracker/tests/heart-rate.test.php new file mode 100644 index 00000000..1a2c8a33 --- /dev/null +++ b/workout-tracker/tests/heart-rate.test.php @@ -0,0 +1,107 @@ +'143'])['heart_rate_bpm'] === 143, + 'string heart rate normalized to integer'); +bpmAssert(workout_advanced_fields(['heart_rate_bpm'=>143.0])['heart_rate_bpm'] === 143, + 'whole-number float from AI normalized'); +bpmAssert(workout_advanced_fields(['heart_rate_bpm'=>''])['heart_rate_bpm'] === null, + 'clear existing heart rate explicitly'); +foreach ([0,-1,301,'143.5','test',[],true,'1e2'] as $bad) { + bpmRejects(static fn()=>workout_advanced_fields(['heart_rate_bpm'=>$bad]), 'heart rate '.get_debug_type($bad)); +} +$parsed=workout_parse_text('Treadmill 1.2 miles in 22 minutes at 3.3 mph, HR 143 bpm','2026-10-27'); +bpmAssert(count($parsed)===1 && $parsed[0]['heart_rate_bpm']===143, 'HR parsed for treadmill'); +bpmAssert($parsed[0]['speed_mph']===3.3, 'speed and HR extracted independently'); +$onlyPulse=workout_parse_text('Treadmill for 20 minutes at 145 bpm','2026-10-27'); +bpmAssert($onlyPulse[0]['heart_rate_bpm']===145 && $onlyPulse[0]['speed_mph']===null, + 'BPM must never be inferred as treadmill speed'); +$duration=workout_parse_text('Treadmill 1 hr 15 minutes','2026-10-27'); +bpmAssert($duration[0]['heart_rate_bpm']===null && $duration[0]['duration_minutes']===75.0, + 'hour abbreviation must not create a false pulse'); + +$dir=sys_get_temp_dir().'/workout-heart-test-'.bin2hex(random_bytes(8)); +if (!mkdir($dir,0700)) throw new RuntimeException('Unable to prepare isolated storage'); +$file=$dir.'/user.json'; +$legacy=[ + 'id'=>'aaaaaaaaaaaaaaaaaaaaaaaa', 'date'=>'2026-10-22', + 'exercise'=>'treadmill', 'distance_miles'=>1.16, + 'notes'=>'Existing workout before HR field','photo'=>'old-photo.jpg', + 'created_at'=>'2026-10-22T15:00:00Z', 'session_id'=>'prior-session' +]; +$second=[ + 'id'=>'bbbbbbbbbbbbbbbbbbbbbbbb', 'date'=>'2026-10-23', + 'exercise'=>'treadmill', 'heart_rate_bpm'=>143, 'notes'=>'Already has pulse', + 'created_at'=>'2026-10-23T15:00:00Z', 'session_id'=>'second-session' +]; +try { + JsonStore::update($file, static fn(array $rows):array=>[$legacy,$second]); + $before=JsonStore::read($file); + bpmAssert(!array_key_exists('heart_rate_bpm',$before['records'][0]), + 'legacy workout does not have fabricated heart rate'); + $previous=$before['records'][0]; + $revision=workout_record_revision($previous); + $changes=workout_validate_entry_edit([ + 'exercise'=>'treadmill', 'date'=>'2026-10-22', + 'notes'=>'Added recorded pulse', 'heart_rate_bpm'=>'151' + ]); + $after=JsonStore::update($file, + static fn(array $rows):array=>workout_apply_entry_edit( + $rows,$legacy['id'],$revision,$changes)); + bpmAssert($after[0]['heart_rate_bpm']===151,'edited legacy workout has chosen value'); + bpmAssert($after[1]===$second,'editing first workout preserves second exactly'); + bpmAssert($after[0]['created_at']===$legacy['created_at'] && + $after[0]['photo']===$legacy['photo'] && + $after[0]['session_id']===$legacy['session_id'], + 'original photo, session and creation date stay untouched'); + bpmAssert($after[0]['edit_history'][0]['previous']===$previous, + 'previous version retained for audit and recovery'); + $saved=JsonStore::read($file)['records']; + $stale=false; + try { + JsonStore::update($file,static fn(array $rows):array=>workout_apply_entry_edit( + $rows,$legacy['id'],$revision,$changes)); + } catch (WorkoutEditConflict $e) { $stale=true; } + bpmAssert($stale,'stale edit is rejected'); + bpmAssert(JsonStore::read($file)['records']===$saved, + 'rejected edit does not rewrite saved records'); + $oldClient=workout_validate_entry_edit([ + 'exercise'=>'treadmill','date'=>'2026-10-23', + 'notes'=>'Updated with an older form that omits heart rate' + ]); + bpmAssert(!array_key_exists('heart_rate_bpm',$oldClient), + 'older editors do not automatically clear heart rate'); + $updated=JsonStore::update($file, + static fn(array $rows):array=>workout_apply_entry_edit( + $rows,$second['id'],workout_record_revision($rows[1]),$oldClient)); + bpmAssert($updated[1]['heart_rate_bpm']===143, + 'older edit keeps existing pulse'); + bpmAssert($updated[0]===$saved[0], + 'editing second entry did not change first'); + bpmAssert(JsonStore::read($file)['schemaVersion']===1, + 'existing JSON storage schema unchanged'); + echo "Passed: BPM validation, local parsing, legacy compatibility, isolated edits and conflict protection.\n"; +} finally { + foreach(glob($dir.'/*') ?: [] as $p) unlink($p); + if(is_dir($dir))rmdir($dir); +} From 292a6d98c47fd6986574c8ea8702b3208fd7b009 Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:18:14 -0400 Subject: [PATCH 22/35] Regress iPhone workout edit/manual heart-rate fields and hidden-value retention --- workout-tracker/tests/edit-button.test.cjs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/workout-tracker/tests/edit-button.test.cjs b/workout-tracker/tests/edit-button.test.cjs index 150a2018..fba330ba 100644 --- a/workout-tracker/tests/edit-button.test.cjs +++ b/workout-tracker/tests/edit-button.test.cjs @@ -2,10 +2,10 @@ File Name: edit-button.test.cjs Author: Jason Lamb (with help from ChatGPT) Description: Regression test for Edit button opening saved workouts without a DOMException. -Revision: 1.1.3 +Revision: 1.1.4 Created Date: 2026-10-27 Modified Date: 2026-10-27 -Changelog: 1.1.3 - Isolate previous edit button regression from advanced measurement editor. 1.1.2 - Verify mobile touch targets, optional fields location, and hidden-value indicator. 1.1.1 - Check hidden historical rep/set/weight values remain intact and manual entry adapts. 1.1.0 - Verify exercise-specific field visibility and safe retention of hidden measurements. 1.0.0 - Exercise the actual event handler and editing form against DOM class-token validation. +Changelog: 1.1.4 - Validate cardio heart rate visibility and editing without hiding saved BPM values. 1.1.3 - Isolate previous edit button regression from advanced measurement editor. 1.1.2 - Verify mobile touch targets, optional fields location, and hidden-value indicator. 1.1.1 - Check hidden historical rep/set/weight values remain intact and manual entry adapts. 1.1.0 - Verify exercise-specific field visibility and safe retention of hidden measurements. 1.0.0 - Exercise the actual event handler and editing form against DOM class-token validation. */ 'use strict'; const assert = require('node:assert/strict'); @@ -58,7 +58,7 @@ const original={ _revision:'a'.repeat(64), exercise:'treadmill',date:'2026-10-22', distance_miles:2.06,duration_minutes:37,speed_mph:3.5, - incline_percent:null,reps:6,sets:2,weight_lb:10,notes:'Existing workout', + incline_percent:null,heart_rate_bpm:143,reps:6,sets:2,weight_lb:10,notes:'Existing workout', photo:'unchanged.jpg',created_at:'2026-10-22T11:00:00Z' }; const saved=JSON.stringify(original); @@ -70,6 +70,7 @@ const context={ measures:[ ['distance_miles','Miles'],['duration_minutes','Minutes'], ['speed_mph','Speed (mph)'],['incline_percent','Treadmill incline (%)'], + ['calories_kcal','Calories (kcal)'],['heart_rate_bpm','Heart rate (BPM)'], ['reps','Reps'],['sets','Sets'],['weight_lb','Weight (lb)'] ], message:()=>{}, @@ -103,6 +104,10 @@ const byName=name=>fields.find(input=>input.dataset.field===name); assert.equal(byName('date')?.value,'2026-10-22','Date prepopulated without invalid class token.'); assert.equal(byName('distance_miles')?.value,2.06,'Saved distance prepopulated.'); assert.equal(byName('speed_mph')?.value,3.5,'Saved speed prepopulated.'); +assert.equal(byName('heart_rate_bpm')?.value,143,'Saved heart rate prepopulated.'); +assert.equal(byName('heart_rate_bpm')?.min,'1','BPM input has a positive lower bound.'); +assert.equal(byName('heart_rate_bpm')?.max,'300','BPM input has upper bound.'); +assert.equal(byName('heart_rate_bpm')?.step,'1','BPM input uses integer steps.'); assert.equal(byName('incline_percent')?.value,'','Missing optional incline remains empty.'); assert.equal(byName('tag')?.value,'','Missing optional tag remains empty.'); const fieldLabel=name=>dom.get('#editFields').children.find( @@ -113,6 +118,7 @@ assert.equal(fieldLabel('distance_miles').hidden,false,'Treadmill distance visib assert.equal(fieldLabel('duration_minutes').hidden,false,'Treadmill duration visible.'); assert.equal(fieldLabel('speed_mph').hidden,false,'Treadmill speed visible.'); assert.equal(fieldLabel('incline_percent').hidden,false,'Treadmill incline visible.'); +assert.equal(fieldLabel('heart_rate_bpm').hidden,false,'Treadmill heart rate visible.'); assert.equal(fieldLabel('reps').hidden,true,'Treadmill does not need reps by default.'); assert.equal(fieldLabel('sets').hidden,true,'Treadmill does not need sets by default.'); assert.equal(fieldLabel('weight_lb').hidden,true,'Treadmill does not need weight by default.'); @@ -138,6 +144,8 @@ assert.equal(fieldLabel('weight_lb').hidden,false,'Strength exercises show weigh assert.equal(fieldLabel('reps').hidden,false,'Strength exercises show reps.'); assert.equal(fieldLabel('sets').hidden,false,'Strength exercises show sets.'); assert.equal(fieldLabel('distance_miles').hidden,true,'Bench press hides distance.'); +assert.equal(fieldLabel('heart_rate_bpm').hidden,true,'Bench press can show heart rate under other measurements.'); +assert.equal(byName('heart_rate_bpm').value,143,'Hidden strength heart rate remains saved.'); exercise.value='treadmill';exercise.listeners.get('input')(); assert.equal(fieldLabel('distance_miles').hidden,false,'Switching back reveals treadmill fields.'); assert.equal(JSON.stringify(original),saved,'Opening/changing displayed fields does not mutate saved workout.'); @@ -154,13 +162,16 @@ const manualLabel=name=>manualGrid.children.find( label=>label.tagName==='label'&&label.children.some(child=>child.name===name)); assert.equal(manualLabel('reps').hidden,true,'Manual treadmill form hides reps.'); assert.equal(manualLabel('incline_percent').hidden,false,'Manual treadmill form shows incline.'); +assert.equal(manualLabel('heart_rate_bpm').hidden,false,'Manual treadmill form shows heart rate.'); manualExercise.value='bench press';manualExercise.listeners.get('input')(); assert.equal(manualLabel('reps').hidden,false,'Manual bench press shows reps.'); assert.equal(manualLabel('weight_lb').hidden,false,'Manual bench press shows weight.'); assert.equal(manualLabel('distance_miles').hidden,true,'Manual bench press hides miles.'); +assert.equal(manualLabel('heart_rate_bpm').hidden,true,'Manual bench press can expose heart rate in extra metrics.'); manualExercise.value='a custom workout';manualExercise.listeners.get('input')(); assert.equal(manualLabel('distance_miles').hidden,false,'Custom exercise can use all existing fields.'); assert.equal(manualLabel('reps').hidden,false,'Custom exercise can use reps.'); +assert.equal(manualLabel('heart_rate_bpm').hidden,false,'Custom exercise allows heart-rate data.'); const css=fs.readFileSync(path.join(__dirname,'..','app.css'),'utf8'); assert.match(css,/grid-template-columns:minmax\(0,1fr\)/, From 975e502e9d294db28137f33a1d1082fbc598b554 Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:18:31 -0400 Subject: [PATCH 23/35] Verify mobile detail view displays recorded heart-rate BPM --- workout-tracker/tests/workout-detail.test.cjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/workout-tracker/tests/workout-detail.test.cjs b/workout-tracker/tests/workout-detail.test.cjs index 40e044c5..e0434098 100644 --- a/workout-tracker/tests/workout-detail.test.cjs +++ b/workout-tracker/tests/workout-detail.test.cjs @@ -2,10 +2,10 @@ File Name: workout-detail.test.cjs Author: Jason Lamb (with help from ChatGPT) Description: DOM regression for mobile workout detail and private photo viewing. -Revision: 1.0.0 +Revision: 1.0.1 Created Date: 2026-10-27 Modified Date: 2026-10-27 -Changelog: 1.0.0 - Verify safe UI rendering, set details, photo ID URL, and old entries without photos. +Changelog: 1.0.1 - Verify saved BPM appears in authenticated mobile workout details. 1.0.0 - Verify safe UI rendering, set details, photo ID URL, and old entries without photos. */ 'use strict'; const assert=require('node:assert/strict'); @@ -35,7 +35,7 @@ const id='aaaaaaaaaaaaaaaaaaaaaaaa'; const old='bbbbbbbbbbbbbbbbbbbbbbbb'; const first={ id,exercise:'bench press',date:'2026-10-22',performed_at:'2026-10-22T08:37:00-04:00', - notes:'Warmup ',tag:'Strength', + notes:'Warmup ',tag:'Strength',heart_rate_bpm:148, photo:id+'.png',sets_detail:[{weight_lb:95,reps:12},{weight_lb:115,reps:10},{weight_lb:135,reps:8}], custom_metrics:[{name:'Tempo - + +
Personal fitness · Your pace

Keep moving.

Every session counts. Progress happens one workout at a time.

-

Workout Tracker v1.5.1 · Updated October 27, 2026

+

Workout Tracker v1.6.0 · Updated October 27, 2026

@@ -315,6 +316,51 @@ static function(array $records) use($id,$revision,$changes):array {
Total minutes—
Total miles—
+

What did you do?

Today
From 482098c01162bc6071be752e65539b25393f9b2f Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:35:48 -0400 Subject: [PATCH 29/35] Compute live attendance and streak cards from stored dates without modifying workouts --- workout-tracker/app.js | 54 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/workout-tracker/app.js b/workout-tracker/app.js index 6b721f33..01f98e5a 100644 --- a/workout-tracker/app.js +++ b/workout-tracker/app.js @@ -1,10 +1,10 @@ /* File Name: app.js Author: Jason Lamb (with help from ChatGPT) Description: Workout quick entry, editable review, and per-exercise trends. -Revision: 1.5.1 +Revision: 1.6.0 Created Date: 2026-10-21 Modified Date: 2026-10-27 -Changelog: 1.5.1 - Display and edit heart rate BPM with cardio-focused fields and separate trends. 1.5.0 - Add authenticated workout details/photo viewer and private backup download UI. 1.4.0 - Editable set-by-set weight/reps, custom measurements, calories and exercise-specific progress. 1.3.2 - Optimize mobile exercise fields, retain hidden measurements, and keep optional metrics near the measurements. 1.3.1 - Fix Edit button crash caused by adding an empty CSS class during form creation. 1.3.0 - Add safe entry editing, tags, incline and history filtering. 1.2.0 - Add opt-in AI retry and private config status. 1.1.0 - Local parsing without API key and multi-exercise saving. */ +Changelog: 1.6.0 - Read-only workout attendance, weekday percentages and calendar/weekday streaks. 1.5.1 - Display and edit heart rate BPM with cardio-focused fields and separate trends. 1.5.0 - Add authenticated workout details/photo viewer and private backup download UI. 1.4.0 - Editable set-by-set weight/reps, custom measurements, calories and exercise-specific progress. 1.3.2 - Optimize mobile exercise fields, retain hidden measurements, and keep optional metrics near the measurements. 1.3.1 - Fix Edit button crash caused by adding an empty CSS class during form creation. 1.3.0 - Add safe entry editing, tags, incline and history filtering. 1.2.0 - Add opt-in AI retry and private config status. 1.1.0 - Local parsing without API key and multi-exercise saving. */ 'use strict'; const $=selector=>document.querySelector(selector); const measures=[['distance_miles','Miles'],['duration_minutes','Minutes'],['speed_mph','Speed (mph)'],['incline_percent','Treadmill incline (%)'],['calories_kcal','Calories (kcal)'],['heart_rate_bpm','Heart rate (BPM)'],['reps','Reps'],['sets','Sets'],['weight_lb','Weight (lb)']]; @@ -345,7 +345,56 @@ function renderHistory(){ toggle.textContent=showAllHistory?'Show latest 10':'View all '+history.length+' entries'; $('#empty').hidden=entries.length>0; } +/** + * All attendance comes from existing saved workout dates: zero writes or new + * schema fields. Calculate using the browser's local today and unique dates. + */ +function refreshAttendance(){ + const stats=WorkoutAttendance.calculate(entries,localDate()); + const panel=$('#attendancePanel');panel.hidden=!stats.hasData; + if(!stats.hasData)return; + $('#attendanceStart').textContent='Since '+niceDay(stats.firstDate); + const pairs=[ + ['attendance7',stats.rollingCalendar], + ['attendanceWeek7',stats.rollingWeekdays], + ['attendanceOverall',stats.calendar], + ['attendanceOverallWeek',stats.weekdays] + ]; + pairs.forEach(([prefix,value])=>{ + $('#'+prefix+'Percentage').textContent=value.percentage.toFixed(1)+'%'; + $('#'+prefix+'Count').textContent=value.attended+' of '+value.eligible+ + (prefix.includes('Week')?' weekdays':' days'); + }); + const streaks=[ + ['attendanceCurrentStreak',stats.calendarStreak.current], + ['attendanceLongestStreak',stats.calendarStreak.longest], + ['attendanceCurrentWeekStreak',stats.weekdayStreak.current], + ['attendanceLongestWeekStreak',stats.weekdayStreak.longest] + ]; + streaks.forEach(([id,value])=>{$('#'+id).textContent=value+' day'+(value===1?'':'s');}); + const strip=$('#attendanceStrip'); + strip.replaceChildren(...stats.lastSeven.map(item=>{ + const el=document.createElement('div');el.className='attendance-day'; + if(item.attended)el.classList.add('attended'); + else if(!item.weekday)el.classList.add('weekend'); + if(item.today)el.classList.add('today'); + const date=new Date(item.date+'T12:00:00'); + const weekday=document.createElement('span');weekday.className='attendance-dayname'; + weekday.textContent=date.toLocaleDateString(undefined,{weekday:'short'}); + const mark=document.createElement('span');mark.className='attendance-mark'; + mark.textContent=item.attended?'✓':(item.weekday?'·':'–'); + const day=document.createElement('span');day.className='attendance-daynumber'; + day.textContent=String(date.getDate()); + el.setAttribute('aria-label',date.toLocaleDateString(undefined,{ + weekday:'long',month:'long',day:'numeric',year:'numeric'})+ + ': '+(item.attended?'workout recorded':item.weekday?'no workout recorded':'weekend, no workout recorded')); + el.append(weekday,mark,day); + return el; + })); +} + function refresh(){ + refreshAttendance(); $('#sessions').textContent=new Set(entries.map(e=>e.session_id||e.date)).size; $('#count').textContent=entries.length; $('#minutes').textContent=entries.reduce((v,e)=>v+(Number(e.duration_minutes)||0),0).toFixed(0); @@ -469,6 +518,7 @@ $('#toggleHistory').addEventListener('click',()=>{showAllHistory=!showAllHistory $('#recent').addEventListener('click',e=>{const button=e.target.closest('[data-edit-id]');if(button)beginEdit(button.dataset.editId);}); $('#editForm').addEventListener('submit',e=>{e.preventDefault();submitEdit();}); $('#cancelEdit').addEventListener('click',closeEdit); +document.addEventListener('visibilitychange',()=>{if(!document.hidden && entries.length)refreshAttendance();}); $('#closeDetail').addEventListener('click',closeWorkoutDetails); $('#editFromDetail').addEventListener('click',()=>{if(detailEntryId)beginEdit(detailEntryId);}); $('#backupForm').addEventListener('submit',()=>{$('#backupStatus').textContent='Preparing your private backup download…';}); From 1502af5b19a8e3e2518e9b55841890bcb4ddc225 Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:36:20 -0400 Subject: [PATCH 30/35] Style mobile attendance percentages, weekday streaks and seven-day calendar strip --- workout-tracker/app.css | 44 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/workout-tracker/app.css b/workout-tracker/app.css index 77778c9b..04f534fb 100644 --- a/workout-tracker/app.css +++ b/workout-tracker/app.css @@ -2,10 +2,10 @@ File Name: app.css Author: Jason Lamb (with help from ChatGPT) Description: Calm, responsive workout dashboard with compact progressive-entry controls. -Revision: 1.5.0 +Revision: 1.6.0 Created Date: 2026-10-21 Modified Date: 2026-10-27 -Changelog: 1.5.0 - Add mobile details panel, secure photo preview and backup download controls. 1.4.0 - Thumb-friendly set rows and expandable custom numeric measurements. 1.3.2 - Keep mobile date/time full width, compact numeric pairs, iOS-safe input fonts and thumb-friendly touch targets. 1.3.0 - Style editable history, tracking tags and incline controls. 1.2.0 - Add low-emphasis optional AI retry action. 1.1.0 - Mobile-first quick entry, measured progress, accessible review layout. +Changelog: 1.6.0 - Mobile-first workout attendance cards, timeline and weekday/calendar streak grid. 1.5.0 - Add mobile details panel, secure photo preview and backup download controls. 1.4.0 - Thumb-friendly set rows and expandable custom numeric measurements. 1.3.2 - Keep mobile date/time full width, compact numeric pairs, iOS-safe input fonts and thumb-friendly touch targets. 1.3.0 - Style editable history, tracking tags and incline controls. 1.2.0 - Add low-emphasis optional AI retry action. 1.1.0 - Mobile-first quick entry, measured progress, accessible review layout. */ :root{color-scheme:dark;font:16px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#08141f;color:#e7f5f8} *{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 80% 0%,#173b44 0%,#0b1a29 50%,#08141f 100%);min-height:100vh} @@ -176,3 +176,43 @@ details>summary{min-height:44px;display:list-item;align-content:center;} .detail-fact strong{text-align:left;} } @media(max-width:360px){.workout-detail .panel-head{align-items:flex-start;}} + + +/* Read-only attendance dashboard. Compact cards and date strip on narrow iPhones. */ +.attendance-panel{margin:0 0 18px;padding:clamp(16px,3vw,26px);} +.attendance-heading{margin-bottom:10px;} +.attendance-heading .eyebrow{font-size:.66rem;} +.attendance-heading h2{margin:5px 0 0;} +.attendance-intro{font-size:.84rem;line-height:1.5;margin:4px 0 16px;max-width:68ch;} +.attendance-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;} +.attendance-card{background:#0d2232;border:1px solid #31586a;border-radius:15px;padding:14px;min-width:0;} +.attendance-overall{background:#102a36;} +.attendance-card-label{font-size:.75rem;color:#adcad3;line-height:1.35;font-weight:600;} +.attendance-percent{display:block;color:#8fe8d3;font-size:clamp(1.45rem,4.3vw,2.3rem);line-height:1.15;margin:7px 0 4px;font-variant-numeric:tabular-nums;} +.attendance-count{font-size:.93rem;color:#e1f4f5;font-weight:700;font-variant-numeric:tabular-nums;} +.attendance-card-caption{font-size:.72rem;color:#9cbac6;line-height:1.35;margin-top:5px;} +.attendance-streaks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:15px 0;} +.attendance-streak{border:1px solid #355367;border-radius:11px;padding:10px 12px;min-width:0;display:flex;justify-content:space-between;gap:8px;align-items:center;} +.attendance-streak span{color:#b8cdd7;font-size:.76rem;line-height:1.35;} +.attendance-streak strong{color:#8fe8d3;font-size:.89rem;white-space:nowrap;font-variant-numeric:tabular-nums;} +.attendance-strip-label{font-size:.96rem;margin:17px 0 9px;} +.attendance-strip{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:5px;} +.attendance-day{border:1px solid #2c485b;border-radius:11px;min-width:0;padding:8px 2px;display:flex;flex-direction:column;align-items:center;gap:3px;background:#0a1d2b;} +.attendance-day.attended{background:#173e41;border-color:#3a9181;} +.attendance-day.weekend{background:#142434;border-style:dashed;} +.attendance-day.today{box-shadow:inset 0 0 0 1px #79cfc7;} +.attendance-dayname,.attendance-daynumber{font-size:.68rem;color:#a7c3cf;line-height:1.1;} +.attendance-mark{font-size:1.15rem;line-height:1.1;color:#5e8997;font-weight:750;} +.attendance-day.attended .attendance-mark{color:#a0f0d7;} +.attendance-note{margin-top:12px;line-height:1.45;} +@media(max-width:420px){ + .attendance-card{padding:12px 10px;} + .attendance-streak{padding:10px;align-items:flex-start;flex-direction:column;gap:4px;} + .attendance-streak strong{font-size:.92rem;} + .attendance-day{padding:7px 1px;} + .attendance-dayname,.attendance-daynumber{font-size:.65rem;} +} +@media(max-width:340px){ + .attendance-grid{grid-template-columns:minmax(0,1fr);} + .attendance-streaks{grid-template-columns:minmax(0,1fr);} +} From 5ad2bc806341b36eeb2305b6fd191f714249f640 Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:37:22 -0400 Subject: [PATCH 31/35] Test 3-of-7 and 3-of-5 attendance, weekday streaks and DST without reading live data --- workout-tracker/tests/attendance.test.cjs | 126 ++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 workout-tracker/tests/attendance.test.cjs diff --git a/workout-tracker/tests/attendance.test.cjs b/workout-tracker/tests/attendance.test.cjs new file mode 100644 index 00000000..f63bda9b --- /dev/null +++ b/workout-tracker/tests/attendance.test.cjs @@ -0,0 +1,126 @@ +/* +File Name: attendance.test.cjs +Author: Jason Lamb (with help from ChatGPT) +Description: Regress rolling and since-first workout attendance with weekday streaks. +Revision: 1.0.0 +Created Date: 2026-10-27 +Modified Date: 2026-10-27 +Changelog: 1.0.0 - Assert Oct 21/22/27 example, deduplication, weekday skips, DST and read-only logic. +*/ +'use strict'; +const assert=require('node:assert/strict'); +const fs=require('node:fs'); +const path=require('node:path'); +const vm=require('node:vm'); + +const source=fs.readFileSync(path.join(__dirname,'..','attendance.js'),'utf8'); +const tracker=vm.runInNewContext(source+'\nWorkoutAttendance;',{}, {filename:'attendance.js'}); +const saved=[ + {id:'a',date:'2026-10-21',exercise:'treadmill',distance_miles:1.16}, + {id:'b',date:'2026-10-21',exercise:'push-ups',reps:20}, + {id:'c',date:'2026-10-22',exercise:'treadmill',distance_miles:2.06}, + {id:'d',date:'2026-10-27',exercise:'treadmill',distance_miles:2.05}, + {id:'e',date:'2026-10-27',exercise:'bench press',sets:3} +]; +const frozen=JSON.stringify(saved); +const result=tracker.calculate(saved,'2026-10-27'); +assert.equal(result.hasData,true); +assert.equal(result.firstDate,'2026-10-21','start tracking at first logged date'); +assert.equal(result.throughDate,'2026-10-27'); +assert.deepEqual(Array.from(Object.entries(result.rollingCalendar)),[ + ['attended',3],['eligible',7],['percentage',42.9] +]); +assert.deepEqual(Array.from(Object.entries(result.rollingWeekdays)),[ + ['attended',3],['eligible',5],['percentage',60] +]); +assert.equal(result.calendar.attended,3,'overall unique attendance dates'); +assert.equal(result.calendar.eligible,7,'overall calendar denominator starts Oct 21'); +assert.equal(result.calendar.percentage,42.9); +assert.equal(result.weekdays.attended,3,'Wednesday, Thursday and Tuesday'); +assert.equal(result.weekdays.eligible,5,'Wednesday Thursday Friday Monday Tuesday'); +assert.equal(result.weekdays.percentage,60,'3/5 is 60%, not 66.7%'); +assert.equal(result.calendarStreak.current,1,'current calendar streak on Oct 27 is one day'); +assert.equal(result.calendarStreak.longest,2,'Oct 21-22 longest calendar streak'); +assert.equal(result.weekdayStreak.current,1,'current weekday streak on Oct 27'); +assert.equal(result.weekdayStreak.longest,2,'Oct 21-22 longest weekday streak'); +assert.equal(result.lastSeven.length,7); +assert.equal(result.lastSeven[0].date,'2026-10-21'); +assert.equal(result.lastSeven[6].date,'2026-10-27'); +assert.equal(result.lastSeven.filter(day=>day.attended).length,3); +assert.equal(result.lastSeven.filter(day=>day.weekday).length,5); +assert.equal(JSON.stringify(saved),frozen,'calculations never mutate existing records'); + +const nextDay=tracker.calculate(saved,'2026-10-28'); +assert.equal(nextDay.calendar.attended,3); +assert.equal(nextDay.calendar.eligible,8); +assert.equal(nextDay.calendar.percentage,37.5); +assert.equal(nextDay.weekdays.eligible,6); +assert.equal(nextDay.weekdays.percentage,50); +assert.equal(nextDay.rollingCalendar.attended,2,'21st falls out of seven-day window'); +assert.equal(nextDay.rollingCalendar.eligible,7); +assert.equal(nextDay.rollingCalendar.percentage,28.6); +assert.equal(nextDay.rollingWeekdays.attended,2); +assert.equal(nextDay.rollingWeekdays.eligible,5); +assert.equal(nextDay.rollingWeekdays.percentage,40); +assert.equal(nextDay.calendarStreak.current,1, + 'yesterday workout preserves current streak until today ends'); +assert.equal(tracker.calculate(saved,'2026-10-29').calendarStreak.current,0, + 'streak expires after a full missed day'); + +const weekdays=[ + {date:'2026-10-21'}, {date:'2026-10-22'}, {date:'2026-10-23'}, + {date:'2026-10-26'}, {date:'2026-10-27'} +]; +const w=tracker.calculate(weekdays,'2026-10-27'); +assert.equal(w.weekdayStreak.current,5,'Saturday and Sunday never break weekday streak'); +assert.equal(w.weekdayStreak.longest,5); +assert.equal(w.calendarStreak.current,2,'weekend interrupts calendar streak'); +assert.equal(w.calendarStreak.longest,3); +assert.equal(w.weekdays.percentage,100); +assert.equal(w.calendar.percentage,71.4); + +// Friday streak is still live on Saturday, Sunday, and early Monday. +const fri=[{date:'2026-10-23'}]; +for(const today of ['2026-10-24','2026-10-25','2026-10-26']){ + assert.equal(tracker.calculate(fri,today).weekdayStreak.current,1, + 'weekend or incomplete Monday must preserve Friday weekday streak: '+today); +} +assert.equal(tracker.calculate(fri,'2026-10-27').weekdayStreak.current,0); + +const weekendFirst=tracker.calculate([{date:'2026-10-24'}],'2026-10-25'); +assert.equal(weekendFirst.calendar.eligible,2); +assert.equal(weekendFirst.weekdays.eligible,0); +assert.equal(weekendFirst.weekdays.percentage,0,'avoid divide-by-zero on weekend start'); +assert.equal(weekendFirst.rollingWeekdays.eligible,0); +assert.equal(weekendFirst.weekdayStreak.current,0); + +const todayOnly=tracker.calculate([{date:'2026-10-27'}],'2026-10-27'); +assert.equal(todayOnly.calendar.eligible,1,'never count time before first workout'); +assert.equal(todayOnly.weekdays.eligible,1); +assert.equal(todayOnly.calendar.percentage,100); +assert.equal(todayOnly.rollingCalendar.eligible,1); +assert.equal(todayOnly.rollingWeekdays.eligible,1); +assert.equal(todayOnly.calendarStreak.current,1); + +const nothing=tracker.calculate([],'2026-10-27'); +assert.equal(nothing.hasData,false); +assert.equal(nothing.firstDate,null); +assert.equal(nothing.lastSeven.length,0); + +const future=tracker.calculate([ + {date:'2026-10-21'}, {date:'2026-10-28'}, {date:'2026-10-29'}, + {date:'not-a-date'}, {date:'2026-02-30'}, {date:'2026-10-27T13:00:00'} +],'2026-10-27'); +assert.equal(future.calendar.attended,1,'future and invalid dates do not affect attendance'); +assert.equal(future.firstDate,'2026-10-21'); +assert.equal(tracker.dayNumber('2026-02-29'),null,'invalid leap day rejected'); +assert.notEqual(tracker.dayNumber('2028-02-29'),null,'valid leap day accepted'); + +// DST fall transition: day counts remain integer calendar days. +const dst=tracker.calculate([{date:'2026-10-30'},{date:'2026-11-02'}],'2026-11-02'); +assert.equal(dst.calendar.eligible,4,'DST does not create 23/25-hour phantom days'); +assert.equal(dst.weekdays.eligible,2,'Fri and Mon only'); +assert.equal(dst.weekdayStreak.current,2,'Friday through Monday weekday streak continues'); +assert.equal(dst.calendarStreak.current,1); + +console.log('Passed: 3/7=42.9%; 3/5=60%; streaks; weekends; first date; seven-day rolling; DST; legacy safety.'); From fe47125d51e242c53cc7c5fa300be18edac53ea3 Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:38:48 -0400 Subject: [PATCH 32/35] Test mobile attendance rendering with user example and safe read-only calculations --- workout-tracker/tests/attendance-ui.test.cjs | 100 +++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 workout-tracker/tests/attendance-ui.test.cjs diff --git a/workout-tracker/tests/attendance-ui.test.cjs b/workout-tracker/tests/attendance-ui.test.cjs new file mode 100644 index 00000000..e119c1ab --- /dev/null +++ b/workout-tracker/tests/attendance-ui.test.cjs @@ -0,0 +1,100 @@ +/* +File Name: attendance-ui.test.cjs +Author: Jason Lamb (with help from ChatGPT) +Description: DOM regression for mobile workout attendance dashboard. +Revision: 1.0.0 +Created Date: 2026-10-27 +Modified Date: 2026-10-27 +Changelog: 1.0.0 - Check rendered percentages and streaks using historical dates only. +*/ +'use strict'; +const assert=require('node:assert/strict'); +const fs=require('node:fs'); +const path=require('node:path'); +const vm=require('node:vm'); +const root=path.join(__dirname,'..'); +const attendance=fs.readFileSync(path.join(root,'attendance.js'),'utf8'); +const app=fs.readFileSync(path.join(root,'app.js'),'utf8'); +const index=fs.readFileSync(path.join(root,'index.php'),'utf8'); +const css=fs.readFileSync(path.join(root,'app.css'),'utf8'); +const begin=app.indexOf('function refreshAttendance(){'); +const end=app.indexOf('function refresh(){',begin); +assert.ok(begin>=0&&end>begin,'attendance renderer must exist'); +const allIds=[ + 'attendancePanel','attendanceStart','attendance7Percentage','attendance7Count', + 'attendanceWeek7Percentage','attendanceWeek7Count','attendanceOverallPercentage', + 'attendanceOverallCount','attendanceOverallWeekPercentage','attendanceOverallWeekCount', + 'attendanceCurrentStreak','attendanceLongestStreak','attendanceCurrentWeekStreak', + 'attendanceLongestWeekStreak','attendanceStrip' +]; +function element(){ + const classes=new Set(); + return { + hidden:true,textContent:'',children:[],attributes:{}, + classList:{add:token=>{if(!token)throw Error('Empty CSS class');classes.add(token);}, + contains:token=>classes.has(token)}, + append(...children){this.children.push(...children);}, + replaceChildren(...children){this.children=children;}, + setAttribute(k,v){this.attributes[k]=v;} + }; +} +const ids=new Map(allIds.map(id=>[id,element()])); +const records=[ + {id:'a',date:'2026-10-21',exercise:'treadmill'}, + {id:'b',date:'2026-10-21',exercise:'push-ups'}, + {id:'c',date:'2026-10-22',exercise:'treadmill'}, + {id:'d',date:'2026-10-27',exercise:'treadmill'} +]; +const snapshot=JSON.stringify(records); +const ctx={ + entries:records, + document:{createElement:()=>element()}, + $:selector=>{ + const obj=ids.get(selector.slice(1)); + assert.ok(obj,'Unknown DOM selector '+selector); + return obj; + }, + localDate:()=> '2026-10-27', + niceDay:value=>value, + Date, +}; +ctx.WorkoutAttendance=vm.runInNewContext(attendance+'; WorkoutAttendance;',ctx); +vm.runInNewContext(app.slice(begin,end),ctx,{filename:'app.js'}); +ctx.refreshAttendance(); +const value=id=>ids.get(id).textContent; +assert.equal(ids.get('attendancePanel').hidden,false,'dashboard becomes visible'); +assert.equal(value('attendanceStart'),'Since 2026-10-21'); +assert.equal(value('attendance7Percentage'),'42.9%'); +assert.equal(value('attendance7Count'),'3 of 7 days'); +assert.equal(value('attendanceWeek7Percentage'),'60.0%'); +assert.equal(value('attendanceWeek7Count'),'3 of 5 weekdays'); +assert.equal(value('attendanceOverallPercentage'),'42.9%'); +assert.equal(value('attendanceOverallWeekPercentage'),'60.0%'); +assert.equal(value('attendanceCurrentStreak'),'1 day'); +assert.equal(value('attendanceLongestStreak'),'2 days'); +assert.equal(value('attendanceCurrentWeekStreak'),'1 day'); +assert.equal(value('attendanceLongestWeekStreak'),'2 days'); +const strip=ids.get('attendanceStrip').children; +assert.equal(strip.length,7,'all seven days shown'); +assert.equal(strip.filter(d=>d.classList.contains('attended')).length,3); +assert.equal(strip.filter(d=>d.classList.contains('weekend')).length,2); +assert.equal(strip[6].attributes['aria-label'].includes('workout recorded'),true); +assert.equal(JSON.stringify(records),snapshot,'rendering must never mutate original data'); + +ctx.entries=[]; +ctx.refreshAttendance(); +assert.equal(ids.get('attendancePanel').hidden,true,'hide attendance until first workout'); + +for(const id of allIds){ + assert.ok(index.includes('id="'+id+'"'),'missing dashboard element: '+id); +} +assert.ok(index.includes('attendance.js?v='),'attendance calculations loaded by page'); +assert.ok(index.indexOf('attendance.js?v=') Date: Tue, 27 Oct 2026 09:39:10 -0400 Subject: [PATCH 33/35] Include attendance dates, weekday streak and mobile dashboard tests in CI --- .github/workflows/workout-tracker-validation.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/workout-tracker-validation.yml b/.github/workflows/workout-tracker-validation.yml index 84def436..f46c0550 100644 --- a/.github/workflows/workout-tracker-validation.yml +++ b/.github/workflows/workout-tracker-validation.yml @@ -1,10 +1,10 @@ # File Name: workout-tracker-validation.yml # Author: Jason Lamb (with help from ChatGPT) # Description: Validate PHP, JavaScript and preservation of Workout Tracker records. -# Revision: 1.4.0 +# Revision: 1.5.0 # Created Date: 2026-10-22 # Modified Date: 2026-10-27 -# Changelog: 1.4.0 - Run BPM parsing, input-validation and legacy-data isolation regression tests. 1.3.0 - Verify mobile detail-view DOM and private photo URLs. 1.2.0 - Test user-bound image access, backup ZIP, and read-only storage. 1.1.0 - Validate optional measurements, legacy-safe set edits and new advanced UI tests. 1.0.1 - Run DOM-backed Edit button regression test to catch empty CSS class exceptions. 1.0.0 - Run parser and lossless edit regression tests on pull requests. +# Changelog: 1.5.0 - Validate seven-day and since-first attendance, weekday streaks, mobile card rendering. 1.4.0 - Run BPM parsing, input-validation and legacy-data isolation regression tests. 1.3.0 - Verify mobile detail-view DOM and private photo URLs. 1.2.0 - Test user-bound image access, backup ZIP, and read-only storage. 1.1.0 - Validate optional measurements, legacy-safe set edits and new advanced UI tests. 1.0.1 - Run DOM-backed Edit button regression test to catch empty CSS class exceptions. 1.0.0 - Run parser and lossless edit regression tests on pull requests. name: Workout Tracker validation on: pull_request: @@ -50,6 +50,11 @@ jobs: - name: Verify JavaScript syntax and Edit button behavior run: | node --check workout-tracker/app.js + node --check workout-tracker/attendance.js + node --check workout-tracker/tests/attendance.test.cjs + node --check workout-tracker/tests/attendance-ui.test.cjs + node workout-tracker/tests/attendance.test.cjs + node workout-tracker/tests/attendance-ui.test.cjs node --check workout-tracker/tests/workout-detail.test.cjs node workout-tracker/tests/workout-detail.test.cjs node --check workout-tracker/advanced.js From 556e36475e4de2f4d305a2e1f651d61bb6f2b0d0 Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:39:37 -0400 Subject: [PATCH 34/35] Document attendance formulas, first date, weekday streaks and mobile testing --- workout-tracker/README.md | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/workout-tracker/README.md b/workout-tracker/README.md index 66cda794..18c13203 100644 --- a/workout-tracker/README.md +++ b/workout-tracker/README.md @@ -2,10 +2,10 @@ File Name: README.md Author: Jason Lamb (with help from ChatGPT) Description: Setup, key-free quick entry, optional AI and screenshot import guidance for Workout Tracker. -Revision: 1.5.1 +Revision: 1.6.0 Created Date: 2026-10-21 Modified Date: 2026-10-27 -Changelog: 1.5.1 - Optional heart rate in BPM, including local parsing, charts and safe edits. 1.5.0 - Authenticated photo/detail view and per-user ZIP backup, preserving live data. 1.4.0 - Flexible exercise measurements, calories and variable weighted sets; safe legacy records. 1.3.2 - Responsive date/time layout and exercise-specific form measurements without losing hidden legacy fields. 1.3.1 - Fix Edit button form opening and add browser-compatible regression test. 1.3.0 - Safely edit saved workouts, add tracking tags and treadmill incline without migrating user data. 1.2.0 - Optional AI retry, exact-name config template and prepared screenshot import. 1.1.0 - Added local language parsing, batch entry, mobile-first review and tests. 1.0.0 - Initial release. +Changelog: 1.6.0 - Read-only workout-day attendance and weekday streak dashboard. 1.5.1 - Optional heart rate in BPM, including local parsing, charts and safe edits. 1.5.0 - Authenticated photo/detail view and per-user ZIP backup, preserving live data. 1.4.0 - Flexible exercise measurements, calories and variable weighted sets; safe legacy records. 1.3.2 - Responsive date/time layout and exercise-specific form measurements without losing hidden legacy fields. 1.3.1 - Fix Edit button form opening and add browser-compatible regression test. 1.3.0 - Safely edit saved workouts, add tracking tags and treadmill incline without migrating user data. 1.2.0 - Optional AI retry, exact-name config template and prepared screenshot import. 1.1.0 - Added local language parsing, batch entry, mobile-first review and tests. 1.0.0 - Initial release. --> # Workout Tracker @@ -21,6 +21,37 @@ Private PHP workout logger at https://jasr.me/github/workout-tracker/ using shar The parser is rule-based rather than a general LLM. It recognizes common exercises and explicit numbers but does **not guess missing values**. Unrecognized exercises use the collapsed manual entry form. It does not analyze photos. +## Attendance and workout streaks (v1.6.0) + +The mobile dashboard uses only the **existing workout dates** in the authenticated user's saved records. A day counts at most once, even if multiple exercises were recorded that day. **No migration, backfill, or change to existing JSON** is needed. + +- **Last seven calendar days:** unique workout dates divided by eligible calendar dates in the seven-day window ending today, **not before the first logged workout**. +- **Last seven days, weekdays only:** the same rolling window but excluding Saturdays and Sundays from both the numerator and denominator. A gym workout recorded on a weekend still appears in the calendar view but does not count as an eligible weekday. +- **Overall calendar attendance:** unique workout dates since the **earliest saved workout**, inclusive, divided by all calendar dates through today. +- **Overall weekday attendance:** workouts on Monday–Friday since that first recorded workout divided by eligible Monday–Friday dates, inclusive. +- **Calendar streak:** continuous days with workouts; **weekday streak** skips Saturday and Sunday. The current streak does not reset until a full eligible day was missed; today is treated as unfinished. +- **Last-seven-day strip:** a small, phone-friendly view of attended days, missed weekdays, and weekends. These counts do not distinguish a home workout from a gym visit—any saved workout date currently means attendance. + +Example from **October 21, 22, and 27, 2026**, evaluated Tuesday, October 27: + +| Measurement | Attendance | Percentage | +| --- | --- | --- | +| Calendar, last 7 days | 3 / 7 | **42.9%** | +| Weekdays, last 7 days | 3 / 5 | **60.0%** | +| Calendar, since first workout | 3 / 7 | **42.9%** | +| Weekdays, since first workout | 3 / 5 | **60.0%** | +| Calendar streak | Current 1 day, longest 2 days | | +| Weekday streak | Current 1 eligible day, longest 2 days | | + +The attendance percentage **3 / 5 is 60%, not 66.7%**. All calculations are browser-local date based; date-only arithmetic uses UTC calendar day numbers to avoid DST date shifting. They run on every data refresh and when returning to the page after midnight. + +Run: + + node workout-tracker/tests/attendance.test.cjs + node workout-tracker/tests/attendance-ui.test.cjs + +The test data is synthetic and never touches Hostinger records. For on-device acceptance, verify the October 21/22/27 dates, weekday and calendar rates, first-workout start, week boundary, and mobile readability. Attendance stays read-only and has no new API endpoints. + ## Heart rate tracking (v1.5.1) Heart rate is an **optional first-class measurement** in beats per minute, saved as `heart_rate_bpm` (whole-number BPM from 1 to 300). It is separate from calorie estimates, treadmill speed and organization tags. The app does not estimate or diagnose heart rate and does not automatically read a wearable. From 67288d2f0248f153bdf5049fcc9725f7eb69caca Mon Sep 17 00:00:00 2001 From: Jason Lamb <92162022+jasrasr@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:39:52 -0400 Subject: [PATCH 35/35] Record v1.6.0 attendance calculations and mobile weekday streak dashboard --- workout-tracker/CHANGELOG.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/workout-tracker/CHANGELOG.md b/workout-tracker/CHANGELOG.md index bfaade6e..6b0b9add 100644 --- a/workout-tracker/CHANGELOG.md +++ b/workout-tracker/CHANGELOG.md @@ -2,13 +2,22 @@ File Name: CHANGELOG.md Author: Jason Lamb (with help from ChatGPT) Description: Cumulative release history for private Workout Tracker. -Revision: 1.4.1 +Revision: 1.5.0 Created Date: 2026-10-21 Modified Date: 2026-10-27 -Changelog: 1.4.1 - Document optional heart-rate BPM field and local parser in v1.5.1. 1.4.0 - Document v1.5.0 authenticated photos, workout details and ZIP backups. 1.3.0 - Document v1.4.0 calories, custom numeric metrics and per-set strength logging. 1.2.2 - Document workout-specific fields and iPhone-safe date/time layout in v1.3.2. 1.2.1 - Document v1.3.1 Edit button DOMException repair and regression. 1.2.0 - Document v1.3.0 non-destructive edits, tags, incline and regression coverage. 1.1.0 - Document v1.2.0 AI fallback, prepared screenshots and secrets convention. 1.0.0 - Initial release history including v1.0.0 and v1.1.0. +Changelog: 1.5.0 - Record v1.6.0 attendance dates, weekend-excluded percentages, and streaks. 1.4.1 - Document optional heart-rate BPM field and local parser in v1.5.1. 1.4.0 - Document v1.5.0 authenticated photos, workout details and ZIP backups. 1.3.0 - Document v1.4.0 calories, custom numeric metrics and per-set strength logging. 1.2.2 - Document workout-specific fields and iPhone-safe date/time layout in v1.3.2. 1.2.1 - Document v1.3.1 Edit button DOMException repair and regression. 1.2.0 - Document v1.3.0 non-destructive edits, tags, incline and regression coverage. 1.1.0 - Document v1.2.0 AI fallback, prepared screenshots and secrets convention. 1.0.0 - Initial release history including v1.0.0 and v1.1.0. --> # Workout Tracker changelog +## v1.6.0 — 2026-10-27 + +- Add four attendance metrics: 7-day calendar / weekday percentages and overall calendar / weekday percentages, each starting no earlier than the first workout date. +- Count distinct workout dates, not individual exercises. Example on October 27 for dates 21, 22, 27: **3/7 = 42.9%** and **3/5 = 60.0%**. +- Show current and longest streaks for calendar days and weekdays only; weekends do not break weekday streaks, and today is treated as unfinished. +- Mobile-ready two-column cards and a seven-day history strip with completed, missing and weekend statuses. Adapt to very narrow phone screens. +- Keep everything read-only from existing per-user workout dates: no migration, new database table, JSON edits, or data reset. +- Add isolated calendar/DST/weekday regression tests and safe DOM mobile rendering tests. + ## v1.5.1 — 2026-10-27 - Add optional, validated `heart_rate_bpm` (1–300 integer BPM) to exercise records without any JSON migration, replacement or legacy backfill.