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 {
+
+
+
Workout details
+
+
+
+
+
+
+
Edit saved workout
Only this exercise will be updated. All other entries and original values are retained.
@@ -359,6 +409,14 @@ static function(array $records) use($id,$revision,$changes):array {
+
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—
+
+
+
+
Consistency over time
+
Workout attendance
+
+
Since your first workout
+
+ Each date with a saved workout counts once, even if you record multiple exercises. Weekends do not count against weekday attendance.
+
+
+ Last 7 days
+ —
+ —
+ Calendar days
+
+
+ Last 7 days
+ —
+ —
+ Weekdays only · Mon–Fri
+
+
+ Since first workout
+ —
+ —
+ All calendar days
+
+
+ Since first workout
+ —
+ —
+ Weekdays only · Mon–Fri
+
+
+
+
Current calendar streak—
+
Longest calendar streak—
+
Current weekday streak—
+
Longest weekday streak—
+
+ Last 7 calendar days
+
+
+
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.