From 0fc35ee56ef8869aac2a1c2ee545306a86f146a4 Mon Sep 17 00:00:00 2001
From: Claude <>
Date: Sun, 8 Mar 2026 07:59:51 +0000
Subject: [PATCH 01/10] fix: guard restore and saveState against incomplete
origin state
Browser test revealed two related bugs in the persistence logic:
1. saveState() was persisting state even when no origin city had been
selected from the dropdown. fromTz is initialised to the browser
local timezone at startup, so a state with tz set but empty cityName
could be saved. On restore, this caused validateOrigin() to fire and
show the 'Please select a city' error with the timezone already
populated - an inconsistent and broken-looking UI.
2. Restore was accepting any saved state, including partially valid ones
with missing cityName or dest city names, causing empty destination
cards to be rendered with no conversion output.
Fix:
- saveState() now guards on fromTz && cityName both being present
- Restore now requires s.cityName && s.tz to proceed; falls back to
fresh start otherwise
- Dest cards are only restored for entries with both tz and cityName
---
index.html | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/index.html b/index.html
index 8c28c7c..08c43f9 100644
--- a/index.html
+++ b/index.html
@@ -1799,6 +1799,7 @@
Time Converter
}
function saveState(){
+ if(!fromTz||!document.getElementById("from-inp").value.trim()) return;
try{ localStorage.setItem(STORAGE,JSON.stringify(buildState())); }catch(e){}
}
@@ -1917,12 +1918,12 @@ Time Converter
═══════════════════════════════════════════════ */
(function(){
var s=loadState();
- if(s){
- document.getElementById("from-inp").value=s.cityName||"";
- fromTz=s.tz||Intl.DateTimeFormat().resolvedOptions().timeZone;
+ if(s&&s.cityName&&s.tz){
+ document.getElementById("from-inp").value=s.cityName;
+ fromTz=s.tz;
fromFmt=s.fmt||24;
var otz=document.getElementById("origin-tz");
- if(otz&&s.tz){ otz.textContent=utcLabel(s.tz); otz.classList.add("live"); }
+ if(otz){ otz.textContent=utcLabel(s.tz); otz.classList.add("live"); }
var b12=document.getElementById("o-btn12"),b24=document.getElementById("o-btn24");
if(b12&&b24){
b12.classList.toggle("on",fromFmt===12); b12.setAttribute("aria-pressed",fromFmt===12);
@@ -1930,7 +1931,9 @@ Time Converter
}
if(s.date) document.getElementById("in-date").value=s.date;
if(s.time) document.getElementById("in-time").value=s.time;
- (s.dests||[]).forEach(function(d,i){ createCard(d,i===0); });
+ var validDests=(s.dests||[]).filter(function(d){return d.tz&&d.cityName;});
+ if(validDests.length){ validDests.forEach(function(d,i){ createCard(d,i===0); }); }
+ else { createCard(null,true); }
syncEmpty(); syncDateDisplay(); syncTimeDisplay();
convertAll();
} else {
From ac95ca4c753bf7a0d6bd9b97ae4f320b498aa8e9 Mon Sep 17 00:00:00 2001
From: Wenjun He <65053715+stuforfun@users.noreply.github.com>
Date: Sun, 8 Mar 2026 16:52:56 +0800
Subject: [PATCH 02/10] docs: add agent review protocol
---
AGENTS.md | 38 ++++++++++++++++++++++++++++++++++++++
1 file changed, 38 insertions(+)
create mode 100644 AGENTS.md
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..b704f0b
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,38 @@
+# AGENTS.md
+
+## Review Modes
+
+Before reviewing any browser-facing change, explicitly declare which mode you are using:
+
+- `Static review only`: code and diff inspection only. No runtime claims.
+- `Browser automation verified`: browser tests or automation actually ran.
+- `Deployed-site verified`: the live site was checked against the deployed commit/build.
+
+If browser/runtime verification is unavailable, say that before giving conclusions.
+
+## Frontend Gate
+
+Do not conclude `looks good`, `no issues`, or similar for stateful UI changes unless one of these is true:
+
+- browser automation passed for the affected flow
+- the deployed site was manually verified
+- the review explicitly says runtime is unverified
+
+## Mandatory Stateful UI Checks
+
+For startup, persistence, hydration, validation, and restore-path changes, review and/or test all of these:
+
+- fresh load with empty `localStorage`
+- restore from valid saved state
+- restore from malformed or partial saved state
+- clear the origin after a valid saved session, then reload
+- GitHub Pages hard refresh or private-window check when Pages is the release target
+
+## Evidence Format
+
+Every review summary should state:
+
+- scope reviewed
+- branch or commit reviewed
+- verification type used
+- remaining unverified gap
From 9527f6efb2b304e00a930ca3887baaf3b53a77b5 Mon Sep 17 00:00:00 2001
From: Wenjun He <65053715+stuforfun@users.noreply.github.com>
Date: Sun, 8 Mar 2026 16:54:42 +0800
Subject: [PATCH 03/10] test: add Playwright package manifest
---
package.json | 11 +++++++++++
1 file changed, 11 insertions(+)
create mode 100644 package.json
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..bda0ca2
--- /dev/null
+++ b/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "time-converter",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test:e2e": "playwright test"
+ },
+ "devDependencies": {
+ "@playwright/test": "latest"
+ }
+}
From 7b4166d4080f82b5ae134fe328333cb6f075c554 Mon Sep 17 00:00:00 2001
From: Wenjun He <65053715+stuforfun@users.noreply.github.com>
Date: Sun, 8 Mar 2026 16:54:44 +0800
Subject: [PATCH 04/10] test: add Playwright config
---
playwright.config.mjs | 25 +++++++++++++++++++++++++
1 file changed, 25 insertions(+)
create mode 100644 playwright.config.mjs
diff --git a/playwright.config.mjs b/playwright.config.mjs
new file mode 100644
index 0000000..30bc949
--- /dev/null
+++ b/playwright.config.mjs
@@ -0,0 +1,25 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './tests',
+ timeout: 30_000,
+ fullyParallel: true,
+ reporter: [['list'], ['html', { open: 'never' }]],
+ use: {
+ baseURL: 'http://127.0.0.1:4173',
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure'
+ },
+ webServer: {
+ command: 'python3 -m http.server 4173',
+ url: 'http://127.0.0.1:4173',
+ reuseExistingServer: !process.env.CI
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] }
+ }
+ ]
+});
From e121356e740c693c89e6298eea5a04296b85918f Mon Sep 17 00:00:00 2001
From: Wenjun He <65053715+stuforfun@users.noreply.github.com>
Date: Sun, 8 Mar 2026 16:54:46 +0800
Subject: [PATCH 05/10] test: add browser smoke coverage for startup and
restore flows
---
tests/app.smoke.spec.js | 87 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 87 insertions(+)
create mode 100644 tests/app.smoke.spec.js
diff --git a/tests/app.smoke.spec.js b/tests/app.smoke.spec.js
new file mode 100644
index 0000000..6bb622c
--- /dev/null
+++ b/tests/app.smoke.spec.js
@@ -0,0 +1,87 @@
+import { expect, test } from '@playwright/test';
+
+const STORAGE_KEY = 'tc-v2';
+
+async function loadWithState(page, state) {
+ await page.addInitScript(
+ ([key, value]) => {
+ window.localStorage.clear();
+ if (value !== null) {
+ window.localStorage.setItem(key, JSON.stringify(value));
+ }
+ },
+ [STORAGE_KEY, state]
+ );
+
+ await page.goto('/');
+}
+
+test('fresh load shows a clean default state', async ({ page }) => {
+ await loadWithState(page, null);
+
+ await expect(page.locator('#from-inp')).toHaveValue('');
+ await expect(page.locator('#origin-tz')).toHaveText('e.g. UTC+09:00');
+ await expect(page.locator('#err-city')).not.toBeVisible();
+ await expect(page.locator('.dest-card')).toHaveCount(1);
+});
+
+test('malformed saved state is ignored on restore', async ({ page }) => {
+ await loadWithState(page, {
+ cityName: '',
+ tz: 'Asia/Shanghai',
+ fmt: 24,
+ date: '',
+ time: '',
+ dests: [
+ { cityName: '', tz: 'Asia/Tokyo', fmt: 24, permanent: true },
+ { cityName: 'New York, USA', tz: '', fmt: 12, permanent: false }
+ ]
+ });
+
+ await expect(page.locator('#from-inp')).toHaveValue('');
+ await expect(page.locator('#origin-tz')).toHaveText('e.g. UTC+09:00');
+ await expect(page.locator('#err-city')).not.toBeVisible();
+ await expect(page.locator('.dest-card')).toHaveCount(1);
+});
+
+test('valid saved state restores the session cleanly', async ({ page }) => {
+ await loadWithState(page, {
+ cityName: 'Shanghai, China',
+ tz: 'Asia/Shanghai',
+ fmt: 24,
+ date: '2026-03-08',
+ time: '09:30',
+ dests: [
+ { cityName: 'Tokyo, Japan', tz: 'Asia/Tokyo', fmt: 24, permanent: true }
+ ]
+ });
+
+ await expect(page.locator('#from-inp')).toHaveValue('Shanghai, China');
+ await expect(page.locator('#origin-tz')).toHaveText('UTC+08:00');
+ await expect(page.locator('#err-city')).not.toBeVisible();
+ await expect(page.locator('.dest-card')).toHaveCount(1);
+ await expect(page.locator('.c-city-inp')).toHaveValue('Tokyo, Japan');
+ await expect(page.locator('.c-tz')).not.toHaveText('e.g. UTC+09:00');
+ await expect(page.locator('.c-time')).not.toHaveText('e.g. 09:00');
+});
+
+test('clearing the origin does not resurrect stale saved state on reload', async ({ page }) => {
+ await loadWithState(page, {
+ cityName: 'Shanghai, China',
+ tz: 'Asia/Shanghai',
+ fmt: 24,
+ date: '2026-03-08',
+ time: '09:30',
+ dests: [
+ { cityName: 'Tokyo, Japan', tz: 'Asia/Tokyo', fmt: 24, permanent: true }
+ ]
+ });
+
+ await page.locator('#from-inp').fill('');
+ await page.reload();
+
+ await expect(page.locator('#from-inp')).toHaveValue('');
+ await expect(page.locator('#origin-tz')).toHaveText('e.g. UTC+09:00');
+ await expect(page.locator('#err-city')).not.toBeVisible();
+ await expect(page.locator('.dest-card')).toHaveCount(1);
+});
From 333541a31232a6cc493e56f8503a832b12be08e4 Mon Sep 17 00:00:00 2001
From: Wenjun He <65053715+stuforfun@users.noreply.github.com>
Date: Sun, 8 Mar 2026 17:05:49 +0800
Subject: [PATCH 06/10] fix: clear stale saved state when origin becomes
incomplete
---
index.html | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/index.html b/index.html
index 08c43f9..5d8aa69 100644
--- a/index.html
+++ b/index.html
@@ -1563,6 +1563,7 @@ Time Converter
var otz=document.getElementById("origin-tz");
if(otz){ otz.textContent="e.g. UTC+09:00"; otz.classList.remove("live","flash"); }
fromTz=null;
+ try{ localStorage.removeItem(STORAGE); }catch(e){}
}
});
@@ -1799,7 +1800,10 @@ Time Converter
}
function saveState(){
- if(!fromTz||!document.getElementById("from-inp").value.trim()) return;
+ if(!fromTz||!document.getElementById("from-inp").value.trim()){
+ try{ localStorage.removeItem(STORAGE); }catch(e){}
+ return;
+ }
try{ localStorage.setItem(STORAGE,JSON.stringify(buildState())); }catch(e){}
}
From 54ccf2a8b9c836fc82146b0944afb6236a30a368 Mon Sep 17 00:00:00 2001
From: Wenjun He <65053715+stuforfun@users.noreply.github.com>
Date: Sun, 8 Mar 2026 17:13:32 +0800
Subject: [PATCH 07/10] chore: create .github directory
---
.github/.keep | 1 +
1 file changed, 1 insertion(+)
create mode 100644 .github/.keep
diff --git a/.github/.keep b/.github/.keep
new file mode 100644
index 0000000..d65c3bf
--- /dev/null
+++ b/.github/.keep
@@ -0,0 +1 @@
+codex placeholder
\ No newline at end of file
From ad4553546d84c358e89bb15033f05770666e5971 Mon Sep 17 00:00:00 2001
From: Claude <>
Date: Sun, 8 Mar 2026 13:09:55 +0000
Subject: [PATCH 08/10] fix: gate city validation error behind strict mode only
Extracted logic from b9357ff without the reformat baggage of eb738e7.
validateOrigin() was calling showErr('err-city') unconditionally,
causing the red error to appear on page load before the user had
interacted. Now only fires in strict mode (explicit user action).
ok=false still set either way so conversion correctly blocks.
---
index.html | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/index.html b/index.html
index 5d8aa69..ce6633c 100644
--- a/index.html
+++ b/index.html
@@ -1696,7 +1696,8 @@ Time Converter
function validateOrigin(strict){
var ok=true;
if(!fromTz||!document.getElementById("from-inp").value.trim()){
- showErr("err-city"); ok=false;
+ if(strict){ showErr("err-city"); } // only show error on explicit user action
+ ok=false;
}
// Only show date/time errors in strict mode (user explicitly tried to convert)
// or when the field was already touched
From b5dddef4482627fdd6200efb65507442e5765172 Mon Sep 17 00:00:00 2001
From: Wenjun He <65053715+stuforfun@users.noreply.github.com>
Date: Mon, 9 Mar 2026 17:37:18 +0800
Subject: [PATCH 09/10] GPT-5.4 via Codex: add browser smoke workflow
---
.github/workflows/browser-smoke.yml | 42 +++++++++++++++++++++++++++++
1 file changed, 42 insertions(+)
create mode 100644 .github/workflows/browser-smoke.yml
diff --git a/.github/workflows/browser-smoke.yml b/.github/workflows/browser-smoke.yml
new file mode 100644
index 0000000..2678c4e
--- /dev/null
+++ b/.github/workflows/browser-smoke.yml
@@ -0,0 +1,42 @@
+name: Browser Smoke
+
+on:
+ pull_request:
+ push:
+ branches:
+ - dev-claude
+ - main
+ workflow_dispatch:
+
+jobs:
+ smoke:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install Playwright test runner
+ run: npm install
+
+ - name: Install Chromium
+ run: npx playwright install --with-deps chromium
+
+ - name: Run browser smoke tests
+ run: npm run test:e2e
+
+ - name: Upload Playwright report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report
+ path: |
+ playwright-report
+ test-results
+ if-no-files-found: ignore
From d20ebc82118e2e23c4e00cbac5e46f6ef6828794 Mon Sep 17 00:00:00 2001
From: Wenjun He <65053715+stuforfun@users.noreply.github.com>
Date: Mon, 9 Mar 2026 18:04:21 +0800
Subject: [PATCH 10/10] GPT-5.4 via Codex: fix browser smoke test harness
reload seeding
---
tests/app.smoke.spec.js | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/tests/app.smoke.spec.js b/tests/app.smoke.spec.js
index 6bb622c..b626dec 100644
--- a/tests/app.smoke.spec.js
+++ b/tests/app.smoke.spec.js
@@ -3,7 +3,10 @@ import { expect, test } from '@playwright/test';
const STORAGE_KEY = 'tc-v2';
async function loadWithState(page, state) {
- await page.addInitScript(
+ // Seed storage once, then reload into the restored session.
+ // addInitScript re-runs on every navigation and would reintroduce stale state.
+ await page.goto('/');
+ await page.evaluate(
([key, value]) => {
window.localStorage.clear();
if (value !== null) {
@@ -12,8 +15,7 @@ async function loadWithState(page, state) {
},
[STORAGE_KEY, state]
);
-
- await page.goto('/');
+ await page.reload();
}
test('fresh load shows a clean default state', async ({ page }) => {