Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/.keep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
codex placeholder
42 changes: 42 additions & 0 deletions .github/workflows/browser-smoke.yml
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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
38 changes: 11 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@ Enter a date, time, and origin city — instantly see the equivalent local time

## Features

- **1,055 cities** across every IANA timezone
- **DST-aware** — handles daylight saving transitions correctly, including edge cases (clocks-skip-forward gaps, fall-back ambiguity)
- **Half & quarter-hour offsets** — India (UTC+5:30), Nepal (UTC+5:45), and all other non-whole-hour zones
- **1,055 cities** across every IANA timezone (1,039 unique city names; 16 appear in multiple countries)
- **DST-aware** — handles daylight saving transitions correctly via browser's built-in IANA database
- **Half & quarter-hour offsets** — India (UTC+5:30), Nepal (UTC+5:45), Iran (UTC+3:30), and all other non-whole-hour zones
- **Fuzzy city search** — scored ranking so the best match always comes first; accents and diacritics normalised (type "Medellin" to find "Medellín")
- **City aliases** — historical and colloquial names supported: Bombay → Mumbai, Peking → Beijing, Saigon → Ho Chi Minh City, NYC, HK, KL, BKK, and more
- **44 city aliases** — historical and colloquial names: Bombay → Mumbai, Peking → Beijing, Saigon → Ho Chi Minh City, NYC, HK, KL, BKK, and more
- **Multiple destinations** — add as many target cities as you need; first card is permanent, additional cards are removable
- **Copy result** — one click copies city, date, time, and timezone to clipboard
- **12h / 24h toggle** per destination card
- **Persistent state** — localStorage auto-saves your last session; reopening the app restores your cities and settings
- **Mobile-friendly** — stacked single-column layout on small screens, 3-column grid on desktop
- **12h / 24h toggle** per card, defaulting to 24h
- **Coherent color system** — origin card in blue, destination cards in burgundy red
- **Persistent state** — localStorage auto-saves your last session; reopening the app restores your cities, date, time, and format settings
- **Mobile-friendly** — stacked single-column layout on small screens, side-by-side grid on desktop
- **Accessible** — full ARIA attributes, keyboard navigation in dropdowns, screen reader announcements on result updates

---
Expand All @@ -35,7 +35,6 @@ Enter a date, time, and origin city — instantly see the equivalent local time
3. **Destination** — type the city you want to convert to and select it
4. **Read the result** — date, local time, and UTC offset appear instantly
5. **Add more** — click **+ Add destination** for additional cities
6. **Copy** — hit the **copy** button on any card to copy the result to clipboard

---

Expand All @@ -44,13 +43,12 @@ Enter a date, time, and origin city — instantly see the equivalent local time
| Detail | Value |
|---|---|
| Architecture | Single self-contained HTML file |
| Dependencies | None (zero npm, zero CDN at runtime) |
| Dependencies | No npm · Google Fonts loaded from CDN at runtime |
| Fonts | Google Fonts (Playfair Display + DM Mono) |
| Data | 1,055 cities, ~50KB inline |
| Cities | 1,055 entries · 1,039 unique names · 44 aliases |
| Algorithm | `Intl.DateTimeFormat` + iterative `wallToUTC` for DST correctness |
| Storage | `localStorage` key `tc-v2` |
| Browser support | Any modern browser (Chrome, Firefox, Safari, Edge) |
| File size | ~80KB |

---

Expand All @@ -64,7 +62,7 @@ start index.html # Windows
xdg-open index.html # Linux
```

Or serve it with any static server:
Or serve with any static server:

```bash
npx serve .
Expand All @@ -81,20 +79,6 @@ python3 -m http.server 8080

---

## Test suite

A separate `test-suite.html` file covers 86 automated tests across:
- Algorithm correctness (offsets, half-hour zones, 12h/24h)
- DST transitions (spring forward, fall back, no-DST zones)
- Date boundaries (year rollover, leap years, midnight edge cases)
- XSS and injection resistance
- Malicious / out-of-range inputs
- Regression cases

Open `test-suite.html` in a browser to run all tests.

---

## License

MIT — do whatever you want with it.
36 changes: 32 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="google" content="notranslate">
<meta http-equiv="Content-Language" content="en">
<title>Time Converter</title>
Expand Down Expand Up @@ -119,6 +120,7 @@
}
/* flash on update */
@keyframes flash{0%{background:var(--bluebg);}100%{background:var(--outbg);}}
@keyframes flash-dest{0%{background:var(--redbg);}100%{background:var(--outbg);}}
.outbox.flash,
.date-display.flash,
.time-display.flash{animation:flash .35s ease;}
Expand Down Expand Up @@ -1561,6 +1563,7 @@ <h1>Time Converter</h1>
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){}
}
});

Expand Down Expand Up @@ -1693,7 +1696,8 @@ <h1>Time Converter</h1>
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
Expand Down Expand Up @@ -1797,6 +1801,10 @@ <h1>Time Converter</h1>
}

function saveState(){
if(!fromTz||!document.getElementById("from-inp").value.trim()){
try{ localStorage.removeItem(STORAGE); }catch(e){}
return;
}
try{ localStorage.setItem(STORAGE,JSON.stringify(buildState())); }catch(e){}
}

Expand Down Expand Up @@ -1914,9 +1922,29 @@ <h1>Time Converter</h1>
INIT
═══════════════════════════════════════════════ */
(function(){
try{ localStorage.removeItem(STORAGE); }catch(e){}
createCard(null, true);
syncEmpty(); syncDateDisplay(); syncTimeDisplay();
var s=loadState();
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){ 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);
b24.classList.toggle("on",fromFmt===24); b24.setAttribute("aria-pressed",fromFmt===24);
}
if(s.date) document.getElementById("in-date").value=s.date;
if(s.time) document.getElementById("in-time").value=s.time;
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 {
createCard(null,true);
syncEmpty(); syncDateDisplay(); syncTimeDisplay();
}
})();
</script>
</body>
Expand Down
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "time-converter",
"private": true,
"type": "module",
"scripts": {
"test:e2e": "playwright test"
},
"devDependencies": {
"@playwright/test": "latest"
}
}
25 changes: 25 additions & 0 deletions playwright.config.mjs
Original file line number Diff line number Diff line change
@@ -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'] }
}
]
});
89 changes: 89 additions & 0 deletions tests/app.smoke.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { expect, test } from '@playwright/test';

const STORAGE_KEY = 'tc-v2';

async function loadWithState(page, state) {
// 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) {
window.localStorage.setItem(key, JSON.stringify(value));
}
},
[STORAGE_KEY, state]
);
await page.reload();
}

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);
});