Live Window Test Page — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add optional latitude/longitude/timezone attributes to <live-window> so it can render any location, then create a test page displaying 8 world cities side-by-side.
Architecture: Extend the existing Web Component with three new HTML attributes. When lat/lng are provided, skip IP geolocation and inject coordinates directly. When timezone is provided, shift all time calculations to that timezone. Create a new Astro page rendering a grid of LiveWindow instances.
Tech Stack: TypeScript, Astro, Vitest (jsdom), Web Components, Intl.DateTimeFormat
Task 1: Add timezone utility function
Files:
- Create:
app/src/scripts/live-window/utils/timezone.ts - Test:
app/src/scripts/live-window/__tests__/timezone.test.ts
Step 1: Write the failing test
Create app/src/scripts/live-window/__tests__/timezone.test.ts:
import { describe, it, expect, vi } from "vitest";import { getTimezoneAdjustedNow } from "../utils/timezone";
describe("getTimezoneAdjustedNow", () => { it("returns a shifted timestamp for a different timezone", () => { // Fix the clock to a known instant: 2025-06-15 12:00:00 UTC vi.useFakeTimers(); vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
const tokyoNow = getTimezoneAdjustedNow("Asia/Tokyo"); // UTC+9 const tokyoDate = new Date(tokyoNow);
// In Tokyo it should be 21:00 expect(tokyoDate.getHours()).toBe(21); expect(tokyoDate.getMinutes()).toBe(0);
vi.useRealTimers(); });
it("returns a shifted timestamp for a negative offset timezone", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
const nyNow = getTimezoneAdjustedNow("America/New_York"); // UTC-4 (EDT) const nyDate = new Date(nyNow);
expect(nyDate.getHours()).toBe(8); expect(nyDate.getMinutes()).toBe(0);
vi.useRealTimers(); });
it("returns null-equivalent behavior when timezone is null", () => { // When timezone is null, the function should not be called. // This test documents that the function requires a valid IANA string. vi.useFakeTimers(); vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
// With UTC, the local time should match UTC const utcNow = getTimezoneAdjustedNow("UTC"); const utcDate = new Date(utcNow); expect(utcDate.getHours()).toBe(12);
vi.useRealTimers(); });});Step 2: Run test to verify it fails
Run: cd app && npx vitest run src/scripts/live-window/__tests__/timezone.test.ts
Expected: FAIL — module not found
Step 3: Write the implementation
Create app/src/scripts/live-window/utils/timezone.ts:
/** * Returns a Date.now()-like timestamp that, when passed to `new Date()`, * produces local-looking hours/minutes matching the given IANA timezone. * * This is used so that all existing phase/clock code (which calls * `new Date(now).getHours()`) works correctly for remote timezones * without modifying every consumer. */export function getTimezoneAdjustedNow(timezone: string): number { const now = new Date();
const formatter = new Intl.DateTimeFormat("en-US", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, });
const parts = formatter.formatToParts(now); const get = (type: Intl.DateTimeFormatPartTypes): number => parseInt(parts.find((p) => p.type === type)!.value, 10);
// Build a Date in the local timezone whose wall-clock fields match the target const shifted = new Date( get("year"), get("month") - 1, get("day"), get("hour") === 24 ? 0 : get("hour"), get("minute"), get("second"), );
return shifted.getTime();}Step 4: Run test to verify it passes
Run: cd app && npx vitest run src/scripts/live-window/__tests__/timezone.test.ts
Expected: PASS
Step 5: Commit
git add app/src/scripts/live-window/utils/timezone.ts app/src/scripts/live-window/__tests__/timezone.test.tsgit commit -m "feat(live-window): add timezone utility for remote time shifting"Task 2: Add timezone to state types and defaults
Files:
- Modify:
app/src/scripts/live-window/types.ts(theLiveWindowState.attrsinterface) - Modify:
app/src/scripts/live-window/state.ts(thecreateDefaultStatefunction)
Step 1: Add timezone field to LiveWindowState.attrs
In app/src/scripts/live-window/types.ts, add to the attrs object in the LiveWindowState interface:
// Inside LiveWindowState.attrs:timezone: string | null;Step 2: Update createDefaultState default
In app/src/scripts/live-window/state.ts, add timezone: null to the attrs default in createDefaultState():
attrs: { use12Hour: false, hideClock: false, hideWeatherText: false, bgColor: { r: 0, g: 0, b: 0 }, resolvedUnits: "metric", timezone: null,},Step 3: Run all existing tests to make sure nothing breaks
Run: just app::test
Expected: All existing tests pass. Some component test makeState() helpers may need timezone: null added to their attrs objects if TypeScript complains.
Step 4: Fix any test helpers that fail type checks
Any test file with a makeState helper building attrs manually needs timezone: null added. Search for these with:
grep -rl "resolvedUnits" app/src/scripts/live-window/__tests__/Update each match to include timezone: null in the attrs object.
Step 5: Run tests again
Run: just app::test
Expected: PASS
Step 6: Commit
git add app/src/scripts/live-window/types.ts app/src/scripts/live-window/state.ts app/src/scripts/live-window/__tests__/git commit -m "feat(live-window): add timezone field to state types"Task 3: Update ClockComponent to support timezone
Files:
- Modify:
app/src/scripts/live-window/components/ClockComponent.ts - Modify:
app/src/scripts/live-window/__tests__/components/ClockComponent.test.ts
Step 1: Write the failing test
Add to the existing ClockComponent.test.ts:
describe("timezone support", () => { it("displays time in the specified timezone", () => { vi.useFakeTimers(); // Set browser time to 2025-06-15 12:00:00 UTC vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
const state = makeState(12); // ignored for wall clock, but needed for state shape state.attrs.timezone = "Asia/Tokyo"; // UTC+9 → 21:00
clock.update(state);
const hourEl = container.querySelector(".clock-hour") as HTMLElement; expect(hourEl.textContent).toBe("21");
vi.useRealTimers(); });
it("falls back to local time when timezone is null", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
const state = makeState(12); state.attrs.timezone = null;
clock.update(state);
// Should use browser's local time (whatever the test env is) const hourEl = container.querySelector(".clock-hour") as HTMLElement; expect(hourEl.textContent).toBeTruthy();
vi.useRealTimers(); });});Step 2: Run test to verify it fails
Run: cd app && npx vitest run src/scripts/live-window/__tests__/components/ClockComponent.test.ts
Expected: FAIL — timezone not used by clock
Step 3: Update ClockComponent
In app/src/scripts/live-window/components/ClockComponent.ts, modify the update method:
Replace the time extraction block:
const now = new Date();const raw = now.getHours();let h = raw;const m = now.getMinutes();With:
let raw: number;let m: number;
if (state.attrs.timezone) { const fmt = new Intl.DateTimeFormat("en-US", { timeZone: state.attrs.timezone, hour: "2-digit", minute: "2-digit", hour12: false, }); const parts = fmt.formatToParts(new Date()); raw = parseInt(parts.find((p) => p.type === "hour")!.value, 10); if (raw === 24) raw = 0; m = parseInt(parts.find((p) => p.type === "minute")!.value, 10);} else { const now = new Date(); raw = now.getHours(); m = now.getMinutes();}
let h = raw;Step 4: Run test to verify it passes
Run: cd app && npx vitest run src/scripts/live-window/__tests__/components/ClockComponent.test.ts
Expected: PASS
Step 5: Commit
git add app/src/scripts/live-window/components/ClockComponent.ts app/src/scripts/live-window/__tests__/components/ClockComponent.test.tsgit commit -m "feat(live-window): support timezone in ClockComponent"Task 4: Add new attributes to LiveWindow orchestrator
Files:
- Modify:
app/src/scripts/live-window/LiveWindow.ts
Step 1: Add attributes to observedAttributes
Add "latitude", "longitude", and "timezone" to the observedAttributes array:
static observedAttributes = [ "openweather-key", "ipregistry-key", "time-format", "hide-clock", "hide-weather-text", "temp-unit", "theme", "bg-color", "latitude", "longitude", "timezone",];Step 2: Update refreshAttrs to read timezone
In the refreshAttrs method, add the timezone attribute:
private refreshAttrs() { this.state.attrs = { use12Hour: this.getAttribute("time-format") === "12", hideClock: this.hasAttribute("hide-clock"), hideWeatherText: this.hasAttribute("hide-weather-text"), bgColor: this.getBgColor(), resolvedUnits: resolveUnits(this.getAttribute("temp-unit"), this.state.store.location.country), timezone: this.getAttribute("timezone") || null, };}Step 3: Update refreshComputed to use timezone-adjusted now
Import the timezone utility at the top of the file:
import { getTimezoneAdjustedNow } from "./utils/timezone";Update refreshComputed:
private refreshComputed() { const now = this.state.attrs.timezone ? getTimezoneAdjustedNow(this.state.attrs.timezone) : Date.now(); this.state.computed.phase = buildPhaseInfo(this.state.store, now);}Step 4: Update doFetchWeather to handle explicit lat/lng
Replace the doFetchWeather method:
private async doFetchWeather(): Promise<void> { const owKey = this.getAttribute("openweather-key"); if (!owKey) return;
const explicitLat = this.getAttribute("latitude"); const explicitLng = this.getAttribute("longitude"); const hasExplicitCoords = explicitLat != null && explicitLng != null;
// When explicit coords are provided, inject them directly (skip IP lookup) if (hasExplicitCoords) { this.state.store = { ...this.state.store, location: { lat: parseFloat(explicitLat), lng: parseFloat(explicitLng), country: null, lastFetched: Date.now(), }, }; } else { // Need IP registry key for IP-based location const ipKey = this.getAttribute("ipregistry-key"); if (!ipKey) return;
if (!shouldFetchWeather(this.state.store, this.state.attrs.resolvedUnits)) { this.updateAll(); return; }
this.state.store = await fetchLocation(ipKey, this.state.store); saveState(this.state); }
// Re-resolve units after location — country may have changed this.refreshAttrs(); const units = this.state.attrs.resolvedUnits;
if (!shouldFetchWeather(this.state.store, units) && !hasExplicitCoords) { this.updateAll(); return; }
// Guard: still check rate limit for weather even with explicit coords if (!hasExplicitCoords || shouldFetchWeather(this.state.store, units)) { const result = await fetchWeather(owKey, this.state.store, units); this.state.store = result.state;
// Only persist to localStorage for IP-based instances if (!hasExplicitCoords) { saveState(this.state); }
if (result.changed) { this.updateAll(); this.dispatchEvent( new CustomEvent("live-window:weather-update", { detail: { weather: this.state.store.weather }, }), ); } }}Step 5: Update startUpdates to allow weather polling without ipregistry key
In startUpdates, update the weather polling condition to also start when explicit coords are present:
private startUpdates() { this.refreshAttrs(); this.updateAll();
this.clockInterval = window.setInterval(() => this.updateClock(), 1000); this.skyInterval = window.setInterval(() => this.updateAll(), 15 * 60 * 1000);
const hasExplicitCoords = this.getAttribute("latitude") != null && this.getAttribute("longitude") != null; const hasIpFlow = this.getAttribute("openweather-key") && this.getAttribute("ipregistry-key");
if (hasExplicitCoords && this.getAttribute("openweather-key")) { this.startWeatherPolling(); } else if (hasIpFlow) { this.startWeatherPolling(); }}Also update attributeChangedCallback to handle the new attributes triggering a weather refetch:
// Add to attributeChangedCallback, before the existing ipregistry check:if (name === "latitude" || name === "longitude" || name === "timezone") { this.refreshAttrs(); this.doFetchWeather(); return;}Step 6: Update loadState usage — skip localStorage for explicit-coord instances
In the constructor, do NOT call loadState() from localStorage unconditionally. Instead, defer:
Actually, keep the constructor simple — loadState() is fine. The key point is that doFetchWeather overwrites state.store.location when explicit coords are present, and we skip saveState for those instances. That’s sufficient.
Step 7: Run all tests
Run: just app::test
Expected: PASS
Step 8: Commit
git add app/src/scripts/live-window/LiveWindow.tsgit commit -m "feat(live-window): support latitude, longitude, timezone attributes"Task 5: Create the test page
Files:
- Create:
app/src/pages/live-window-test.astro
Step 1: Create the test page
Create app/src/pages/live-window-test.astro:
---import BaseLayout from "@layouts/BaseLayout/BaseLayout.astro";
const openweatherKey = import.meta.env.PUBLIC_OPENWEATHER_KEY || "";
const cities = [ { name: "New York", country: "US", lat: 40.7128, lng: -74.006, tz: "America/New_York", unit: "F" }, { name: "London", country: "UK", lat: 51.5074, lng: -0.1278, tz: "Europe/London", unit: "C" }, { name: "Tokyo", country: "JP", lat: 35.6762, lng: 139.6503, tz: "Asia/Tokyo", unit: "C" }, { name: "Sydney", country: "AU", lat: -33.8688, lng: 151.2093, tz: "Australia/Sydney", unit: "C" }, { name: "Cairo", country: "EG", lat: 30.0444, lng: 31.2357, tz: "Africa/Cairo", unit: "C" }, { name: "São Paulo", country: "BR", lat: -23.5505, lng: -46.6333, tz: "America/Sao_Paulo", unit: "C" }, { name: "Reykjavik", country: "IS", lat: 64.1466, lng: -21.9426, tz: "Atlantic/Reykjavik", unit: "C" }, { name: "Mumbai", country: "IN", lat: 19.076, lng: 72.8777, tz: "Asia/Kolkata", unit: "C" },];---
<BaseLayout activePage=""> <section class="text-center mb-12"> <h1 class="font-display font-bold text-neon text-display-lg tracking-tighter leading-none"> Live Window Test </h1> <p class="mt-4 font-body text-sm text-muted"> Real-time sky, weather, and clock for 8 locations around the world. </p> </section>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-8"> {cities.map((city) => ( <div class="flex flex-col items-center gap-3"> <live-window openweather-key={openweatherKey} latitude={String(city.lat)} longitude={String(city.lng)} timezone={city.tz} temp-unit={city.unit} theme="dark" bg-color="#030a12" /> <div class="text-center"> <h2 class="font-heading font-semibold text-text text-base m-0"> {city.name} </h2> <p class="font-body text-2xs text-muted m-0 mt-1"> {city.lat.toFixed(2)}°, {city.lng.toFixed(2)}° · {city.tz} </p> </div> </div> ))} </div></BaseLayout>
<script src="../scripts/live-window/LiveWindow.ts"></script>
<style> @reference "../layouts/BaseLayout/BaseLayout.css";
live-window { --window-color: var(--color-midnight); --blinds-color: var(--color-midnight); --clock-text-color: #e74c3c; --weather-text-font: "Space Grotesk", sans-serif; }</style>Step 2: Verify the page loads locally
Run: just app::serve
Then visit: http://localhost:4321/live-window-test
Expected: 8 live window instances in a grid, each showing different times and sky gradients
Step 3: Commit
git add app/src/pages/live-window-test.astrogit commit -m "feat: add live window test page with 8 world cities"Task 6: Run full test suite and verify build
Step 1: Run all tests
Run: just app::test
Expected: All tests pass
Step 2: Type check
Run: just app::typecheck
Expected: No errors
Step 3: Build
Run: just app::build
Expected: Clean build with no errors
Step 4: Fix any issues found and commit
If any issues, fix and commit with an appropriate message.