diff --git a/.github/workflows/workout-tracker-validation.yml b/.github/workflows/workout-tracker-validation.yml
index f46c0550..149eb784 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.5.0
+# Revision: 1.6.1
# Created Date: 2026-10-22
# Modified Date: 2026-10-27
-# 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.
+# Changelog: 1.6.1 - Execute multi-photo browser handler validation. 1.6.0 - Test multi-photo persistence and global remember-me session lifetime. 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:
@@ -31,6 +31,11 @@ jobs:
php -v
php -l workout-tracker/index.php
php -l workout-tracker/photo.php
+ php -l workout-tracker/photo-upload.php
+ php -l workout-tracker/tests/photo-upload.test.php
+ php -l 1-Framework/modules/Authentication/PasswordSession.php
+ php -l user-management/src/Auth.php
+ php -l user-management/index.php
php -l workout-tracker/backup.php
php -l workout-tracker/tests/photo-backup.test.php
php -l workout-tracker/tests/heart-rate.test.php
@@ -46,6 +51,7 @@ jobs:
php workout-tracker/tests/edit.test.php
php workout-tracker/tests/measurements.test.php
php workout-tracker/tests/photo-backup.test.php
+ php workout-tracker/tests/photo-upload.test.php
php workout-tracker/tests/heart-rate.test.php
- name: Verify JavaScript syntax and Edit button behavior
run: |
@@ -57,8 +63,12 @@ jobs:
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/tests/photo-ui.test.cjs
+ node workout-tracker/tests/photo-ui.test.cjs
node --check workout-tracker/advanced.js
node --check workout-tracker/tests/advanced.test.cjs
node workout-tracker/tests/advanced.test.cjs
node --check workout-tracker/tests/edit-button.test.cjs
node workout-tracker/tests/edit-button.test.cjs
+ - name: Verify long-lived login is opt-in and revocable
+ run: node --test user-management/tests/remember.test.cjs
diff --git a/1-Framework/modules/Authentication/PasswordSession.php b/1-Framework/modules/Authentication/PasswordSession.php
index db718d14..c9e179e1 100644
--- a/1-Framework/modules/Authentication/PasswordSession.php
+++ b/1-Framework/modules/Authentication/PasswordSession.php
@@ -1,25 +1,78 @@
30 * 86400) {
+ throw new \RuntimeException('Invalid identity session timeout.');
+ }
ini_set('session.use_strict_mode', '1');
+ // PHP's default 24-minute garbage collection would otherwise invalidate
+ // a remembered browser session before its advertised expiry.
+ $gc = max((int)ini_get('session.gc_maxlifetime'),$timeout,$rememberSeconds);
+ ini_set('session.gc_maxlifetime',(string)$gc);
session_name($name);
- session_set_cookie_params(['httponly' => true, 'secure' => $secure, 'samesite' => 'Strict', 'path' => $path]);
+ session_set_cookie_params([
+ 'httponly' => true,'secure' => $secure,'samesite' => 'Strict',
+ 'path' => $path,'lifetime' => 0
+ ]);
session_start();
- if (isset($_SESSION['last']) && time() - $_SESSION['last'] > $timeout) $_SESSION = [];
- $_SESSION['last'] = time();
+ $now = time();
+ $until = isset($_SESSION['remember_until']) && is_int($_SESSION['remember_until'])
+ ? $_SESSION['remember_until'] : 0;
+ $idle = $until > 0 ? $rememberSeconds : $timeout;
+ if (($until > 0 && $until <= $now)
+ || (isset($_SESSION['last']) && $now - (int)$_SESSION['last'] > $idle)) {
+ $_SESSION = [];
+ session_regenerate_id(true); // Never reuse an expired identity's session ID.
+ // Replace any persistent cookie with a new session-only anonymous ID.
+ $this->writeCookie(false);
+ } elseif ($until > $now) {
+ // session_start() can resend a session-only cookie on some hosts.
+ // Repeat the SAME fixed expiry, never sliding it forward.
+ $this->writeCookie(true);
+ }
+ $_SESSION['last'] = $now;
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
}
+
+ private function writeCookie(bool $remember): void
+ {
+ setcookie($this->name, session_id(), [
+ 'expires'=>$remember ? (int)($_SESSION['remember_until'] ?? 0) : 0,
+ 'path'=>$this->path,'secure'=>$this->secure,
+ 'httponly'=>true,'samesite'=>'Strict'
+ ]);
+ }
+
+ /** Only called by the central identity provider after successful password verification. */
+ public function enableRemember(): void
+ {
+ $_SESSION['remember_until'] = time() + $this->rememberSeconds;
+ $this->writeCookie(true);
+ }
+
public function csrf(): string { return $_SESSION['csrf']; }
- public function validCsrf(mixed $token): bool { return is_string($token) && hash_equals($this->csrf(), $token); }
+ public function validCsrf(mixed $token): bool {
+ return is_string($token) && hash_equals($this->csrf(), $token);
+ }
public function loggedIn(): bool { return isset($_SESSION['user']); }
public function login(string $user, string $password, string $expectedUser, string $hash, callable $allow): bool
{
@@ -36,5 +89,8 @@ public function logout(): void
$_SESSION = [];
session_regenerate_id(true);
$_SESSION['csrf'] = bin2hex(random_bytes(32));
+ // Removing the persistent expiry on the new session cookie signs out
+ // this browser across all connected projects without stale remember flags.
+ $this->writeCookie(false);
}
}
diff --git a/user-management/README.md b/user-management/README.md
index 7473cc88..39028cdd 100644
--- a/user-management/README.md
+++ b/user-management/README.md
@@ -2,12 +2,22 @@
Shared PHP/JSON accounts for projects in this repository. PHP 8.1+, no database or Composer dependencies. Uses the framework's `JsonStore` and `PasswordSession`; the framework exposes the configured provider through `SharedIdentity::connect()`.
+## Optional remembered sign-in (2026-10-27)
+
+The central login now includes **Stay signed in for 7 days on this browser**. This is deliberately unchecked by default. Standard sign-in still expires after **30 minutes idle** and uses a session cookie. Choosing the checkbox issues a persistent browser cookie with an absolute seven-day maximum; on every request, the server checks the remembered session's expiry and identity version. Session rotation, CSRF verification, strict/HttpOnly/secure cookies, password resets, disabled-account invalidation and central logout remain enforced across all registered projects.
+
+The duration is configurable with `remember_seconds` in `config.local.php` (default 604800; limited to 30 days), while `timeout` remains 1800 seconds by default. Both are server settings; do not write secrets to this repository. **Existing sessions do not automatically become remembered.** Sign out and sign back in with the checkbox selected. The login checkbox is not recommended for shared devices. Hosts that purge PHP session data earlier than seven days require compatible server/session storage configuration. Test signed-in persistence and sign-out on the actual Hostinger installation.
+
+Validation:
+
+ node --test user-management/tests/remember.test.cjs
+
## Features
- One same-host login/session for integrated projects.
- Central account creation, disable/enable, temporary password reset and password change.
- User, Admin and Super Admin account roles are separate from project scope. Any role can access multiple selected projects; all-project access is an explicit flag. Central directory administration is a separate permission.
-- Server-side permission checks, CSRF tokens, secure cookies, session rotation, 30-minute idle expiry and persisted account/IP login limits.
+- Server-side permission checks, CSRF tokens, secure cookies, session rotation, standard 30-minute idle expiry, **opt-in bounded seven-day remembered sessions**, and persisted account/IP login limits.
- Password/account/access changes revoke the affected user's sessions; required password changes block project access.
- Last active administrator protection. Users are disabled instead of deleted so project ownership remains stable.
- Runtime files stay in ignored storage; normal Git updates do not overwrite accounts.
diff --git a/user-management/bootstrap.php b/user-management/bootstrap.php
index 8455dded..1200fc88 100644
--- a/user-management/bootstrap.php
+++ b/user-management/bootstrap.php
@@ -14,6 +14,12 @@ function jasr_users_config(): array
$local = is_file($file) ? require $file : [];
if (!is_array($local)) throw new RuntimeException('Invalid user management configuration.');
$config = array_replace($defaults, $local);
+ if (!is_int($config['timeout']) || $config['timeout'] < 60
+ || !is_int($config['remember_seconds'])
+ || $config['remember_seconds'] < $config['timeout']
+ || $config['remember_seconds'] > 30 * 86400) {
+ throw new RuntimeException('Invalid identity session lifetime configuration.');
+ }
if (!is_bool($config['debug']) || !is_array($config['rate_limit_exempt_ips'])) {
throw new RuntimeException('Invalid development configuration.');
}
diff --git a/user-management/config.local.example.php b/user-management/config.local.example.php
index 079afe9b..15b49084 100644
--- a/user-management/config.local.example.php
+++ b/user-management/config.local.example.php
@@ -8,7 +8,8 @@
'cookie_name' => 'JASR_IDENTITY',
'cookie_path' => '/',
'secure_cookie' => true, // HTTPS required. Set false ONLY for local HTTP tests.
- 'timeout' => 1800,
+ 'timeout' => 1800, // Unchanged default: sign out after 30 minutes idle.
+ 'remember_seconds' => 7 * 86400, // Only for an explicitly checked login: max 7 days.
// Development only. Keep false and empty on production hosting.
'debug' => false,
'rate_limit_exempt_ips' => [],
diff --git a/user-management/index.php b/user-management/index.php
index 0d3f5f2e..84e3c8d8 100644
--- a/user-management/index.php
+++ b/user-management/index.php
@@ -26,7 +26,7 @@ function field(array $input, string $key): string { return is_string($input[$key
if (field($_POST, 'password') !== field($_POST, 'confirm_password')) throw new InvalidArgumentException('Passwords do not match.');
$directory->setup(field($_POST, 'username'), field($_POST, 'name'), field($_POST, 'password'));
} elseif ($action === 'login') {
- if (!$auth->login(field($_POST, 'username'), field($_POST, 'password'))) {
+ if (!$auth->login(field($_POST, 'username'), field($_POST, 'password'), field($_POST, 'remember') === 'yes')) {
http_response_code(401); throw new InvalidArgumentException('Sign-in failed. Check your credentials or wait 15 minutes if attempts were limited.');
}
} elseif ($action === 'logout') {
@@ -95,7 +95,7 @@ function roleFields(array $account): void
Browser setup is disabled. Set a private setup key in config.local.php or run the CLI setup described in the README.
-
Sign in
Need a password reset? Ask your site administrator.
+
Sign in
Optional. Leave unchecked for the usual 30-minute inactivity timeout. Don't enable on a shared device.
Need a password reset? Ask your site administrator.
Signed in as = e($user['name']) ?>
Change your temporary password before opening a project or managing users.
diff --git a/user-management/src/Auth.php b/user-management/src/Auth.php
index c8081c06..18c4492e 100644
--- a/user-management/src/Auth.php
+++ b/user-management/src/Auth.php
@@ -6,6 +6,11 @@
use Jasr\Framework\PasswordSession;
use Jasr\Framework\Response;
+/* File Name: Auth.php
+Revision: 1.1.0
+Modified Date: 2026-10-27
+Changelog: 1.1.0 - Offer opt-in seven-day shared login without changing default expiry.
+*/
final class Auth
{
public readonly Directory $directory;
@@ -16,7 +21,7 @@ public function __construct(private array $config)
throw new \RuntimeException('HTTPS is required.');
}
$this->directory = new Directory($config['data_path']);
- $this->session = new PasswordSession($config['cookie_name'], $config['cookie_path'], $config['secure_cookie'], $config['timeout']);
+ $this->session = new PasswordSession($config['cookie_name'], $config['cookie_path'], $config['secure_cookie'], $config['timeout'], $config['remember_seconds']);
header('Cache-Control: no-store');
}
public function csrf(): string { return $this->session->csrf(); }
@@ -29,7 +34,7 @@ public function user(): ?array
if (!$user || !$user['active'] || $user['version'] !== ($_SESSION['identity_version'] ?? null)) return null;
return Directory::publicUser($user);
}
- public function login(string $username, string $password): bool
+ public function login(string $username, string $password, bool $remember = false): bool
{
$username = strtolower(trim($username));
$remoteIp = (string) ($_SERVER['REMOTE_ADDR'] ?? 'cli');
@@ -59,6 +64,7 @@ public function login(string $username, string $password): bool
$this->session->logout(); // Rotate ID/token before establishing the shared identity.
$_SESSION['identity_id'] = $found['id'];
$_SESSION['identity_version'] = $found['version'];
+ if ($remember) $this->session->enableRemember();
// Do not clear the peer-IP counter: it also limits password spraying.
JsonStore::update($this->config['data_path'] . '/attempts.json', function (array $r) use ($keys): array {
unset($r[$keys[0]]);
diff --git a/user-management/tests/remember.test.cjs b/user-management/tests/remember.test.cjs
new file mode 100644
index 00000000..45fbea4d
--- /dev/null
+++ b/user-management/tests/remember.test.cjs
@@ -0,0 +1,123 @@
+/*
+File Name: remember.test.cjs
+Author: Jason Lamb (with help from ChatGPT)
+Description: Isolated HTTP verification for opt-in seven-day central login, expiry and sign-out.
+Revision: 1.0.1
+Created Date: 2026-10-27
+Modified Date: 2026-10-27
+Changelog: 1.0.1 - Check that remembered cookies retain fixed original expiry after a page load. 1.0.0 - Check session-only default, secure cookie flags, remembered expiry and revocation.
+*/
+'use strict';
+const {test,after}=require('node:test');
+const assert=require('node:assert/strict');
+const fs=require('node:fs');
+const os=require('node:os');
+const path=require('node:path');
+const {spawn}=require('node:child_process');
+const root=path.resolve(__dirname,'../..');
+const temp=fs.mkdtempSync(path.join(os.tmpdir(),'jasr-remember-'));
+for(const name of ['1-Framework','user-management'])
+ fs.cpSync(path.join(root,name),path.join(temp,name),{recursive:true});
+const data=path.join(temp,'user-management/data');
+for(const name of fs.readdirSync(data))if(name!=='.htaccess')
+ fs.rmSync(path.join(data,name),{recursive:true,force:true});
+const key='isolated-remember-test-key-at-least-32-characters';
+fs.writeFileSync(path.join(temp,'user-management/config.local.php'),
+ "false,'setup_key'=>'"+key+"'];");
+fs.writeFileSync(path.join(temp,'expire.php'),
+ "user()) {http_response_code(401);exit;}\n"+
+ "$_SESSION['remember_until']=time()-1;\n"+
+ "echo 'test-only forced expiration';\n");
+const port=18963,host='http://127.0.0.1:'+port;
+const server=spawn('php',['-S','127.0.0.1:'+port,'-t',temp],{stdio:['ignore','pipe','pipe']});
+let logs='';
+server.stderr.on('data',chunk=>logs+=String(chunk));
+after(()=>{server.kill();fs.rmSync(temp,{recursive:true,force:true});if(/Fatal error|Parse error/.test(logs))console.error(logs);});
+
+class Browser{
+ cookie='';csrf='';
+ async request(url='/user-management/',post=null){
+ const headers={Cookie:this.cookie};
+ if(post)headers['Content-Type']='application/x-www-form-urlencoded';
+ const options={headers,redirect:'manual'};
+ if(post){options.method='POST';options.body=new URLSearchParams({csrf:this.csrf,...post});}
+ const res=await fetch(host+url,options);
+ const cookies=res.headers.getSetCookie?.()||[];
+ if(cookies.length)this.cookie=cookies[cookies.length-1].split(';')[0];
+ const text=await res.text();
+ const match=text.match(/name="csrf" value="([a-f0-9]+)"/);
+ if(match)this.csrf=match[1];
+ return {status:res.status,text,cookies};
+ }
+ async post(action,details={}){
+ const res=await this.request('/user-management/',{action,...details});
+ if(res.status===303)await this.request();
+ return res;
+ }
+}
+const password='example-password-for-tests-2026';
+test('default idle session, opt-in remembered login, and global logout',async()=>{
+ let ready=false;
+ for(let i=0;i<100;i++){
+ try{await fetch(host+'/user-management/');ready=true;break;}
+ catch{await new Promise(resolve=>setTimeout(resolve,80));}
+ }
+ assert.ok(ready,'isolated PHP HTTP server must start; logs: '+logs);
+ const browser=new Browser();
+ let res=await browser.request();
+ assert.match(res.text,/Create the first administrator/);
+ assert.equal((await browser.post('setup',{
+ setup_key:key,username:'member',name:'Test Member',
+ password,confirm_password:password
+ })).status,303);
+ res=await browser.request();
+ assert.match(res.text,/Stay signed in for 7 days/);
+ assert.match(res.text,/30-minute inactivity/);
+
+ // Without the checkbox, browser closes end the session; no persistent Expires.
+ const normal=await browser.post('login',{username:'member',password});
+ assert.equal(normal.status,303);
+ assert.ok(normal.cookies.length>0,'PHP rotates the cookie after login');
+ assert.ok(normal.cookies.every(x=>!/;\s*expires=/i.test(x)),
+ 'default login sets session-only cookies');
+ assert.match((await browser.request()).text,/Signed in as/);
+
+ await browser.post('logout');
+ assert.match((await browser.request()).text,/
Sign in<\/h2>/);
+ const remembered=await browser.post('login',{
+ username:'member',password,remember:'yes'
+ });
+ assert.equal(remembered.status,303);
+ const persistent=remembered.cookies.find(x=>/;\s*expires=/i.test(x));
+ assert.ok(persistent,'explicit remember choice sets a persistent cookie');
+ assert.match(persistent,/httponly/i);
+ assert.match(persistent,/samesite=strict/i);
+ assert.match(persistent,/path=\//i);
+ const match=persistent.match(/expires=([^;]+)/i);
+ assert.ok(match,'expiry field exists');
+ const remaining=(Date.parse(match[1])-Date.now())/86400000;
+ assert.ok(remaining>6 && remaining<8,'remember expiry is bounded at seven days');
+ const visited=await browser.request();
+ assert.match(visited.text,/Signed in as/);
+ const refreshed=visited.cookies.find(x=>/;\s*expires=/i.test(x));
+ if(refreshed){
+ const expiry=refreshed.match(/expires=([^;]+)/i);
+ assert.ok(expiry,'subsequent authenticated request must keep a persistent cookie');
+ assert.ok(Math.abs(Date.parse(expiry[1])-Date.parse(match[1]))<2000,
+ 'each visit retains the original fixed seven-day expiry rather than extending it');
+ }
+
+ // Expired remembered sessions cannot bypass server expiration even with cookie.
+ assert.equal((await browser.request('/expire.php')).status,200);
+ assert.match((await browser.request()).text,/
Sign in<\/h2>/,
+ 'expired remember token does not authenticate');
+ assert.equal((await browser.post('login',{username:'member',password,remember:'yes'})).status,303);
+ assert.match((await browser.request()).text,/Signed in as/);
+ const loggedOut=await browser.post('logout');
+ assert.equal(loggedOut.status,303);
+ assert.match((await browser.request()).text,/
Sign in<\/h2>/,
+ 'logging out revokes the remembered session');
+ console.log('Passed: short default, opt-in seven-day cookie, absolute expiry and shared sign-out.');
+});
diff --git a/workout-tracker/CHANGELOG.md b/workout-tracker/CHANGELOG.md
index 6b0b9add..21cae4c6 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.5.0
+Revision: 1.6.0
Created Date: 2026-10-21
Modified Date: 2026-10-27
-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.
+Changelog: 1.6.0 - Record v1.7.0 photo attachments, account links, and remembered login. 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.7.0 — 2026-10-27 (Issue #135)
+
+- Allow adding **multiple private pictures to an already-saved workout** from the Edit screen using a mobile file picker (up to 5 files per request, 12 total, max 5 MB each); no older image is replaced.
+- Retain legacy single `photo` and add optional append-only `photos` filename list with entry ID ownership, unique names, strict MIME/image validation, and stale-revision rejection.
+- Display all saved pictures inside the authenticated Details gallery. Photo links accept only entry ID and zero-based index; private ZIP export includes both legacy and new images.
+- Provide a responsive User Management link and CSRF-protected **Sign out** action through the existing shared portal.
+- Add explicit **Stay signed in for 7 days** checkbox at central login, retaining normal 30-minute idle session by default. Bounded persistent cookie uses HttpOnly, SameSite Strict, secure HTTPS and server-side session expiry; logout still revokes access.
+- Add synthetic regression tests for multiple media references and backups and an isolated HTTP login/expiry/logout test. No migration or direct live storage changes.
+
## 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.
diff --git a/workout-tracker/README.md b/workout-tracker/README.md
index 18c13203..4fefce9a 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.6.0
+Revision: 1.7.0
Created Date: 2026-10-21
Modified Date: 2026-10-27
-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.
+Changelog: 1.7.0 - Multiple photo uploads on saved workouts, central logout/navigation and opt-in seven-day login. 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,20 @@ 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.
+## Multiple photos, account navigation and remembered login (v1.7.0; issue #135)
+
+**Photo uploads after saving:** Tap **Edit** on a previous workout, then scroll to **Add pictures to this workout**. Choose up to five JPEG, PNG or WebP images at once (max 5 MB each, 12 total per workout) using the phone's camera or photo library; tap **Add selected pictures**. Existing measurements and photos are not overwritten. The editor reports existing photo count and retains all earlier uploads. The upload is a separate CSRF-protected action, using the same authenticated user and revision-lock checks as other edits.
+
+**View multiple photos:** Tap **Details** from Workout history. The private gallery displays every photo attached to that record in order, including an original legacy `photo`. Each full-size link uses only an entry ID and numeric image index (`?photo=&image=0`); clients cannot supply filenames or paths. All images remain behind central authentication and owner checks. The protected data files are still ignored by Git and denied by the web-server rule.
+
+**How records remain safe:** Existing single-photo references `photo` remain unchanged. New filenames are appended to an optional `photos` array. The current `schemaVersion:1`, IDs, sessions, workout notes, dates and old entries are preserved. Uploads are checked (size, MIME, actual image data, path traversal and symlink rules), named uniquely, and attached atomically under `JsonStore::update`. If the entry has changed or a validation error occurs, newly staged uploads are removed and the old record is not modified. Photos are never automatically deleted. The account ZIP export includes every legacy and new image and fails if one is missing.
+
+**User Management and sign-out:** The top of Workout Tracker provides a **User Management** link and a **Sign out** button. The sign-out form submits with CSRF to the existing central account portal, signing out **all connected JASR projects in that browser**. This is not a local-only logoff.
+
+**Stay signed in:** The User Management login offers an optional **Stay signed in for 7 days on this browser** checkbox. **Unchecked remains the normal 30-minute inactivity timeout**, with a session-only cookie. Checked uses a seven-day, secure/HttpOnly/SameSite=Strict persistent session cookie with a fixed maximum expiry and server-side session lifetime. It is not enabled automatically for current sessions. After deploying, sign out and sign back in with the checkbox selected to activate it. Do not select it on shared devices. Logout, account disabling, password changes and permissions revocation still invalidate sessions. If hosting aggressively cleans PHP session files, server configuration may need adjustment; the new PHP session code raises the configured garbage-collection lifetime for remembered sessions.
+
+**Verification:** GitHub CI exercises synthetic multi-photo uploads, cross-account image denial, missing-file ZIP safety and the existing Edit button. It also runs an isolated User Management HTTP test for normal versus remembered cookies, expiry and logout. On Hostinger, verify the portal's remembered sign-in behavior, cross-account photo denial, private storage HTTP 403, ZIP completeness and multi-file iPhone upload. No hosted accounts/records are used in CI. The hosting webhook must never delete `workout-tracker/storage/` or `user-management/data/`.
+
## 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.
diff --git a/workout-tracker/app.css b/workout-tracker/app.css
index 04f534fb..4566e4aa 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.6.0
+Revision: 1.7.0
Created Date: 2026-10-21
Modified Date: 2026-10-27
-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.
+Changelog: 1.7.0 - Style signed-in navigation, multiselect photo attachment and private galleries for mobile. 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}
@@ -216,3 +216,35 @@ details>summary{min-height:44px;display:list-item;align-content:center;}
.attendance-grid{grid-template-columns:minmax(0,1fr);}
.attendance-streaks{grid-template-columns:minmax(0,1fr);}
}
+
+
+/* Connected account links and sign-out remain within thumb reach on phones. */
+.account-nav{display:flex;flex-direction:column;align-items:flex-end;gap:7px;flex-shrink:0;}
+.account-link,.account-signout{display:flex;align-items:center;justify-content:center;min-height:44px;padding:8px 12px;border:1px solid #407481;border-radius:10px;background:#153542;color:#c6ecea;text-decoration:none;font-size:.82rem;white-space:nowrap;}
+.account-signout{background:#172c3b;cursor:pointer;font-weight:650;}
+.account-link:focus-visible,.account-signout:focus-visible{outline:2px solid #87efd8;outline-offset:2px;}
+.logout-form{margin:0;}
+.add-photos-form{border-top:1px solid #375969;padding-top:17px;margin-top:19px;}
+.add-photos-form h3{margin-bottom:8px;}
+.add-photos-form .muted{font-size:.87rem;}
+.add-photos-form input[type="file"]{font-size:16px;min-height:50px;max-width:100%;padding:9px 8px;}
+.add-photos-form button{min-height:48px;}
+#existingPhotos{font-size:.86rem;margin:8px 0;}
+#photoStatus{min-height:22px;overflow-wrap:anywhere;}
+.detail-gallery{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px;}
+.detail-photo{margin:0;background:#0c2130;border:1px solid #315467;border-radius:14px;padding:8px;min-width:0;}
+.detail-photo img{display:block;width:100%;height:auto;max-height:320px;aspect-ratio:4/3;object-fit:contain;background:#071722;border-radius:10px;}
+.detail-photo figcaption{min-width:0;}
+.detail-photo figcaption a{display:flex;align-items:center;font-size:.83rem;min-height:44px;overflow-wrap:anywhere;}
+@media(max-width:560px){
+ .hero{flex-wrap:wrap;}
+ .hero>div:first-child{flex:1 1 100%;}
+ .account-nav{width:100%;flex-direction:row;justify-content:flex-start;align-items:center;flex-wrap:wrap;}
+ .account-link,.account-signout{font-size:.86rem;min-height:44px;}
+ .detail-gallery{gap:8px;}
+}
+@media(max-width:350px){
+ .detail-gallery{grid-template-columns:minmax(0,1fr);}
+ .account-nav{align-items:stretch;}
+ .account-link,.account-signout{font-size:.84rem;}
+}
diff --git a/workout-tracker/app.js b/workout-tracker/app.js
index 01f98e5a..4db2c3aa 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.6.0
+Revision: 1.7.0
Created Date: 2026-10-21
Modified Date: 2026-10-27
-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. */
+Changelog: 1.7.0 - Multi-photo galleries and upload-after-save controls while keeping earlier uploads. 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)']];
@@ -222,6 +222,20 @@ function historyItems(){
.sort((a,b)=>(b.performed_at||b.date).localeCompare(a.performed_at||a.date));
}
+
+/** Existing single-photo records and new arrays coexist without migration. */
+function workoutPhotoCount(entry) {
+ return (typeof entry.photo==='string' && entry.photo!==''?1:0)+
+ (Array.isArray(entry.photos)?entry.photos.filter(name=>typeof name==='string'&&name!=='').length:0);
+}
+function renderExistingPhotoCount(entry){
+ const count=workoutPhotoCount(entry);
+ $('#existingPhotos').textContent=count+
+ ' picture'+(count===1?'':'s')+' already saved. You can add '+Math.max(0,12-count)+' more.';
+ const button=$('#addPhotosButton');button.disabled=count>=12;
+ $('#savedPhotos').disabled=count>=12;
+}
+
/** Mobile-readable workout detail view. All user-provided content uses textContent. */
function openWorkoutDetails(id){
const entry=entries.find(row=>row.id===id);
@@ -303,20 +317,31 @@ function openWorkoutDetails(id){
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;
+ const photoCount=workoutPhotoCount(entry);
+ if(photoCount>0){
+ const heading=document.createElement('h3');
+ heading.textContent='Workout pictures ('+photoCount+')';
+ const gallery=document.createElement('div');gallery.className='detail-gallery';
+ for(let index=0;index{image.hidden=true;fallback.hidden=false;});
+ const caption=document.createElement('figcaption');
+ const link=document.createElement('a');
+ link.href=src;link.target='_blank';link.rel='noopener noreferrer';
+ link.textContent='Open picture '+(index+1)+' full size';
+ caption.append(link);
+ item.append(image,fallback,caption);gallery.append(item);
+ }
+ const privacy=document.createElement('p');privacy.className='footnote';
+ privacy.textContent='Pictures are visible only to your authenticated account.';
+ media.append(heading,gallery,privacy);media.hidden=false;
}
$('#detailPanel').hidden=false;
$('#detailPanel').scrollIntoView({behavior:'smooth',block:'start'});
@@ -330,7 +355,7 @@ function renderHistory(){
$('#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 detail=document.createElement('small');detail.textContent=describe(e)+(e.tag?' · #'+e.tag:'')+(workoutPhotoCount(e)?' · '+workoutPhotoCount(e)+' picture'+(workoutPhotoCount(e)===1?'':'s'):'');
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;
@@ -436,12 +461,51 @@ function beginEdit(id){
timezone.value=match?match[2]:offset();grid.append(timezone);
installExerciseVisibility(grid,exerciseField.children[1]);
WorkoutAdvanced.attach(grid,entry,exerciseField.children[1]);
+ renderExistingPhotoCount(entry);
+ $('#savedPhotos').value='';
+ $('#photoStatus').textContent='';
$('#editPanel').hidden=false;$('#editStatus').textContent='Original entry and changes are kept in your private workout history.';
$('#editPanel').scrollIntoView({behavior:'smooth',block:'nearest'});
}
function closeEdit(){
editingId=null;editingRevision=null;$('#editPanel').hidden=true;$('#editFields').replaceChildren();
}
+async function uploadSavedPhotos(){
+ const fileInput=$('#savedPhotos'),files=Array.from(fileInput.files||[]);
+ if(!editingId||!editingRevision){$('#photoStatus').textContent='Open a saved workout to attach pictures.';return;}
+ if(!files.length){$('#photoStatus').textContent='Choose pictures to add.';return;}
+ if(files.length>5){$('#photoStatus').textContent='Select up to five pictures at a time.';return;}
+ if(files.some(file=>file.size===0||file.size>5*1024*1024)){
+ $('#photoStatus').textContent='Each picture must be between 1 byte and 5 MB.';return;
+ }
+ const existing=entries.find(e=>e.id===editingId);
+ if(!existing||workoutPhotoCount(existing)+files.length>12){
+ $('#photoStatus').textContent='This workout has a limit of 12 pictures.';return;
+ }
+ const btn=$('#addPhotosButton'),csrf=payload('append_photos');
+ csrf.set('entry_id',editingId);csrf.set('expected_revision',editingRevision);
+ files.forEach(file=>csrf.append('photos[]',file,file.name));
+ btn.disabled=true;btn.textContent='Uploading pictures…';
+ try{
+ const result=await api(csrf);
+ editingRevision=result.entry._revision;
+ fileInput.value='';
+ $('#photoStatus').textContent=result.attached+' picture'+(result.attached===1?'':'s')+' saved.';
+ await load();
+ renderExistingPhotoCount(result.entry);
+ message('Pictures attached to the selected workout. No existing pictures were replaced.');
+ }catch(err){
+ $('#photoStatus').textContent=err.message;
+ if(/changed elsewhere|edited elsewhere|reload/i.test(err.message)){
+ message('Workout changed since the editor opened. Refresh and try again.',true);
+ }
+ }finally{
+ btn.textContent='Add selected pictures';
+ const current=entries.find(e=>e.id===editingId);
+ btn.disabled=!current||workoutPhotoCount(current)>=12;
+ }
+}
+
async function submitEdit(){
if(!editingId||!editingRevision)return;
const button=$('#confirmEdit'),row={};
@@ -518,6 +582,12 @@ $('#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);
+$('#photosForm').addEventListener('submit',event=>{event.preventDefault();uploadSavedPhotos();});
+$('#savedPhotos').addEventListener('change',()=>{
+ const current=entries.find(e=>e.id===editingId);
+ const files=$('#savedPhotos').files||[];
+ $('#addPhotosButton').disabled=!files.length||!current||workoutPhotoCount(current)+files.length>12;
+});
document.addEventListener('visibilitychange',()=>{if(!document.hidden && entries.length)refreshAttendance();});
$('#closeDetail').addEventListener('click',closeWorkoutDetails);
$('#editFromDetail').addEventListener('click',()=>{if(detailEntryId)beginEdit(detailEntryId);});
diff --git a/workout-tracker/index.php b/workout-tracker/index.php
index e03587da..0f14fad0 100644
--- a/workout-tracker/index.php
+++ b/workout-tracker/index.php
@@ -2,10 +2,10 @@
/* File Name: index.php
Author: Jason Lamb (with help from ChatGPT)
Description: Authenticated workout dashboard and JSON API.
-Revision: 1.6.0
+Revision: 1.7.0
Created Date: 2026-10-21
Modified Date: 2026-10-27
-Changelog: 1.6.0 - Add rolling and since-first attendance with weekday-only rates and workout streaks. 1.5.1 - Add optional heart rate (BPM) to parsing, manual entry, editing, AI fallback and trends. 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. */
+Changelog: 1.7.0 - Multiple photos on saved workouts, secure User Management navigation and shared sign-out. 1.6.0 - Add rolling and since-first attendance with weekday-only rates and workout streaks. 1.5.1 - Add optional heart rate (BPM) to parsing, manual entry, editing, AI fallback and trends. 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';
@@ -13,6 +13,7 @@
require_once __DIR__ . '/measurements.php';
require_once __DIR__ . '/photo.php';
require_once __DIR__ . '/backup.php';
+require_once __DIR__ . '/photo-upload.php';
$auth = \Jasr\Framework\SharedIdentity::connect();
$user = $auth->requireProject('workout-tracker', $_SERVER['REQUEST_METHOD'] === 'POST' ? 'member' : 'viewer', isset($_GET['api']));
$uid = (string)$user['id'];
@@ -49,8 +50,11 @@ function workoutAiConfig(): ?array {
// 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)
+ $imageIndex = $_GET['image'] ?? '0';
+ $imageNumber = is_string($imageIndex) && preg_match('/^(0|[1-9][0-9]?)$/D', $imageIndex) === 1
+ ? (int)$imageIndex : -1;
+ $photo = is_string($entryId) && $imageNumber >= 0
+ ? workout_owned_photo(\Jasr\Framework\JsonStore::read($path)['records'], $entryId, $root, $uid, $imageNumber)
: null;
header('Cache-Control: private, no-store, max-age=0');
header('X-Content-Type-Options: nosniff');
@@ -98,6 +102,58 @@ function workoutAiConfig(): ?array {
exit;
}
+ if ($action === 'append_photos') {
+ $id=$_POST['entry_id']??null;
+ $revision=$_POST['expected_revision']??null;
+ if (!is_string($id) || !is_string($revision) || !isset($_FILES['photos'])) {
+ reply(422,['error'=>'Choose one or more pictures from your device.']);
+ }
+ if (!preg_match('/^[a-f0-9]{24}$/D',$id)
+ || !preg_match('/^[a-f0-9]{64}$/D',$revision)) {
+ reply(422,['error'=>'Invalid workout or revision.']);
+ }
+ // Cheap preflight avoids staging files for an obviously stale entry.
+ $current=null;
+ foreach (\Jasr\Framework\JsonStore::read($path)['records'] as $record) {
+ if (is_array($record) && ($record['id']??null)===$id) {
+ if ($current!==null)reply(409,['error'=>'Duplicate workout identifiers.']);
+ $current=$record;
+ }
+ }
+ if ($current===null)reply(404,['error'=>'Workout entry not found.']);
+ if (!hash_equals(workout_record_revision($current),$revision)) {
+ reply(409,['error'=>'Workout was edited elsewhere. Reload before uploading pictures.']);
+ }
+ if (count(workout_photo_names($current))>=12) {
+ reply(422,['error'=>'This workout already has the maximum of 12 photos.']);
+ }
+ $staged=[];
+ try {
+ if (!is_array($_FILES['photos']))throw new InvalidArgumentException('Invalid upload.');
+ $staged=workout_stage_photo_uploads($_FILES['photos'],$root,$uid,$id);
+ $names=array_column($staged,'filename');
+ $all=\Jasr\Framework\JsonStore::update($path,
+ static fn(array $records):array=>workout_apply_photo_append($records,$id,$revision,$names));
+ } catch (WorkoutEditConflict $e) {
+ foreach ($staged as $item)if(is_file($item['path']))unlink($item['path']);
+ reply(409,['error'=>$e->getMessage()]);
+ } catch (WorkoutEntryNotFound $e) {
+ foreach ($staged as $item)if(is_file($item['path']))unlink($item['path']);
+ reply(404,['error'=>$e->getMessage()]);
+ } catch (InvalidArgumentException $e) {
+ foreach ($staged as $item)if(is_file($item['path']))unlink($item['path']);
+ reply(422,['error'=>$e->getMessage()]);
+ } catch (Throwable $e) {
+ foreach ($staged as $item)if(is_file($item['path']))unlink($item['path']);
+ throw $e;
+ }
+ foreach($all as $record)if(is_array($record) && ($record['id']??null)===$id){
+ $record['_revision']=workout_record_revision($record);
+ reply(200,['entry'=>$record,'attached'=>count($staged)]);
+ }
+ reply(500,['error'=>'The workout could not be read after adding its pictures.']);
+ }
+
if ($action === 'edit_entry') {
$id=$_POST['entry_id']??null;
$revision=$_POST['expected_revision']??null;
@@ -296,19 +352,27 @@ 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.