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 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 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 diff --git a/README.md b/README.md index 783e574..0d06167 100644 --- a/README.md +++ b/README.md @@ -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 --- @@ -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 --- @@ -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 | --- @@ -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 . @@ -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. diff --git a/index.html b/index.html index e736dcb..ce6633c 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,7 @@ + Time Converter @@ -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;} @@ -1561,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){} } }); @@ -1693,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 @@ -1797,6 +1801,10 @@

Time Converter

} 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){} } @@ -1914,9 +1922,29 @@

Time Converter

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(); + } })(); 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" + } +} 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'] } + } + ] +}); diff --git a/tests/app.smoke.spec.js b/tests/app.smoke.spec.js new file mode 100644 index 0000000..b626dec --- /dev/null +++ b/tests/app.smoke.spec.js @@ -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); +});