From c8cf245e4e4ca674fca7151e41312576a1b24bbd Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 20:09:48 +0000 Subject: [PATCH 1/7] feat: handle null speed values as valid placeholder state Updates the geolocation handler to accept `null` speed values (common when stationary or acquiring fix) as valid updates rather than ignoring them. This triggers the UI to display the placeholder text instead of stale speed data, while correctly updating data freshness timestamps to prevent false warnings. --- package-lock.json | 4 ++-- package.json | 2 +- src/app.ts | 14 +++++++++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index aa64357..88e9b54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "speedometer", - "version": "0.0.84", + "version": "0.0.85", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "speedometer", - "version": "0.0.84", + "version": "0.0.85", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index dd00a77..3baac24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "speedometer", - "version": "0.0.84", + "version": "0.0.85", "description": "Minimal PWA speedometer that displays GPS speed. Includes TypeScript script to render PNG icons from SVG using sharp.", "license": "MIT", "private": true, diff --git a/src/app.ts b/src/app.ts index f64502f..498abdd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -79,7 +79,12 @@ function updateUnitUI(): void { } // Render the speed (expects m/s) -function renderSpeed(metersPerSecond: number): void { +function renderSpeed(metersPerSecond: number | null): void { + if (metersPerSecond === null) { + showPlaceholder(); + return; + } + // Check validity if (!Number.isFinite(metersPerSecond) || metersPerSecond < 0) { setStatus("FIXME: Error"); @@ -267,8 +272,11 @@ function handlePosition(pos: GeolocationPosition): void { const { speed, accuracy } = pos.coords; - // Update speed only when native speed is provided and valid - if (typeof speed === "number" && Number.isFinite(speed) && speed >= 0) { + // Update speed only when native speed is provided and valid OR null + if ( + speed === null || + (typeof speed === "number" && Number.isFinite(speed) && speed >= 0) + ) { if (firstSpeedTimestamp === null) { firstSpeedTimestamp = now; } From 048203f99643202ef141554541bdff1e637a7423 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 11 Jan 2026 05:30:11 +0000 Subject: [PATCH 2/7] Update info popover logic to handle permission prompts dynamically - Replace static "Got it" button with dynamic button in `#info-popover` - Add `checkPermissions` logic using `navigator.permissions` API - Update popover UI state based on permission status (prompt vs granted) - Use `data-action` attribute for button behavior stability - Ensure popover remains open when asking for permissions - Hide warning message and switch to "Got it" when permissions are granted --- index.html | 2 +- package-lock.json | 4 +-- package.json | 2 +- src/app.ts | 72 +++++++++++++++++++++++++++++++++++++++++------ 4 files changed, 68 insertions(+), 12 deletions(-) diff --git a/index.html b/index.html index 7215ebf..77a2e4d 100644 --- a/index.html +++ b/index.html @@ -652,7 +652,7 @@

Info

We'll ask for location permissions now.

-
diff --git a/package-lock.json b/package-lock.json index 88e9b54..7665635 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "speedometer", - "version": "0.0.85", + "version": "0.0.86", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "speedometer", - "version": "0.0.85", + "version": "0.0.86", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 3baac24..59e3bdc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "speedometer", - "version": "0.0.85", + "version": "0.0.86", "description": "Minimal PWA speedometer that displays GPS speed. Includes TypeScript script to render PNG icons from SVG using sharp.", "license": "MIT", "private": true, diff --git a/src/app.ts b/src/app.ts index 498abdd..b4d1ff9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -436,6 +436,7 @@ export function init(): void { // Info/Warning popover logic const infoPopoverEl = document.getElementById("info-popover"); const infoBtnEl = document.querySelector(".info-btn"); + const infoActionBtn = document.getElementById("info-action-btn"); const locationMsgEl = document.getElementById("vibe-location-msg"); const installInstructionsEl = document.getElementById("install-instructions"); const iosInstructionsEl = document.getElementById("ios-instructions"); @@ -527,15 +528,70 @@ export function init(): void { // Attach scroll listener infoContentEl?.addEventListener("scroll", updateScrollOverlay); + const updatePopoverUI = (state: PermissionState | "unknown") => { + if (!infoActionBtn) { + return; + } + if (state === "granted") { + infoActionBtn.textContent = "Got it"; + infoActionBtn.dataset.action = "close"; + if (locationMsgEl) { + locationMsgEl.hidden = true; + } + } else { + // prompt, denied, or unknown + infoActionBtn.textContent = "Ask for location permissions"; + infoActionBtn.dataset.action = "ask"; + if (locationMsgEl) { + locationMsgEl.hidden = false; + } + } + }; + + let permissionStatus: PermissionStatus | null = null; + const checkPermissions = async () => { + if ("permissions" in navigator) { + try { + permissionStatus = await navigator.permissions.query({ + name: "geolocation", + }); + updatePopoverUI(permissionStatus.state); + permissionStatus.onchange = () => { + updatePopoverUI(permissionStatus.state); + }; + } catch (e) { + console.warn("Permissions API error", e); + updatePopoverUI("unknown"); + } + } else { + updatePopoverUI("unknown"); + } + }; + + // Check permissions immediately + checkPermissions(); + + // Button click handler + if (infoActionBtn) { + infoActionBtn.addEventListener("click", () => { + if (infoActionBtn.dataset.action === "close") { + (infoPopoverEl as unknown as PopoverElement).hidePopover(); + } else { + // "Ask..." + geolocationStarted = true; + startGeolocation(); + // Do not hide popover + } + }); + } + const hasShownInfo = localStorage.getItem("info-popover-shown"); const shouldShow = !hasShownInfo && !isStandalone(); // Only show automatically if not previously shown AND not installed as PWA if (shouldShow) { - // Unhide the location permission warning for the first run - if (locationMsgEl) { - locationMsgEl.hidden = false; - } + // NOTE: UI is updated by checkPermissions async, but message defaults hidden. + // We rely on checkPermissions to show it if needed. (infoPopoverEl as unknown as PopoverElement).showPopover(); // Calculate immediately, waiting for layout @@ -558,13 +614,13 @@ export function init(): void { // or immediately if possible. // Since popover is top layer, layout might happen immediately. requestAnimationFrame(() => updateScrollOverlay()); + + // Refresh permissions check + checkPermissions(); } else if (toggleEvent.newState === "closed") { updateExitTarget(); - // Ensure message is hidden for future opens - if (locationMsgEl) { - locationMsgEl.hidden = true; - } + // Don't forcefully hide message here, let updatePopoverUI handle state. localStorage.setItem("info-popover-shown", "true"); From 77d580ce71ab187f9f8b8224d5f97e1d044bde92 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 07:45:22 +0000 Subject: [PATCH 3/7] Fix popover UI update on permission grant - Implement `onLocationSuccess` callback pattern to update popover UI immediately when location data is received. - Fixes issue where popover state would not update after user granted permissions in native prompt. - Ensures consistent "Ask" -> "Granted" transition regardless of browser event limitations (e.g. onchange reliability). --- package-lock.json | 4 ++-- package.json | 2 +- src/app.ts | 12 ++++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7665635..f851360 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "speedometer", - "version": "0.0.86", + "version": "0.0.87", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "speedometer", - "version": "0.0.86", + "version": "0.0.87", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 59e3bdc..03240c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "speedometer", - "version": "0.0.86", + "version": "0.0.87", "description": "Minimal PWA speedometer that displays GPS speed. Includes TypeScript script to render PNG icons from SVG using sharp.", "license": "MIT", "private": true, diff --git a/src/app.ts b/src/app.ts index b4d1ff9..7a2a7d9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -20,6 +20,7 @@ let lastUpdateTimestamp = 0; let wakeLock: WakeLockSentinel | null = null; let firstSpeedTimestamp: number | null = null; let lastHandlePositionTime: number | null = null; +let onLocationSuccess: (() => void) | null = null; const GPS_WARMUP_MS = 1000; @@ -260,6 +261,10 @@ async function handleWakeLock(): Promise { } function handlePosition(pos: GeolocationPosition): void { + if (onLocationSuccess) { + onLocationSuccess(); + } + const now = Date.now(); if (lastHandlePositionTime !== null) { @@ -571,6 +576,13 @@ export function init(): void { // Check permissions immediately checkPermissions(); + // Set up location success callback to update UI when permission is granted + onLocationSuccess = () => { + updatePopoverUI("granted"); + // Optionally re-check via API to ensure sync + checkPermissions(); + }; + // Button click handler if (infoActionBtn) { infoActionBtn.addEventListener("click", () => { From 6a5012310d1d3f9f6a65a8839c4246743b141ce7 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 08:56:22 +0000 Subject: [PATCH 4/7] Add unknown speed message and remove GPS warmup logic --- index.html | 20 ++++++++++++ src/app.ts | 26 ++++++++-------- tests/app.test.ts | 77 +++++++++++++++-------------------------------- 3 files changed, 58 insertions(+), 65 deletions(-) diff --git a/index.html b/index.html index 77a2e4d..84ba70f 100644 --- a/index.html +++ b/index.html @@ -468,6 +468,23 @@ box-sizing: border-box; } +.unknown-speed-msg { + position: fixed; + top: 0; + left: 0; + width: 100%; + color: light-dark(#111, #eee); + text-align: center; + padding: max(10px, env(safe-area-inset-top)) + max(10px, env(safe-area-inset-right)) 10px + max(10px, env(safe-area-inset-left)); + font-weight: bold; + z-index: 999; + font-size: 1.2em; + box-sizing: border-box; + pointer-events: none; +} + .warning-digits { display: inline-block; text-align: right; @@ -545,6 +562,9 @@ +
; let keepScreenOnEl: HTMLInputElement; let warningEl: HTMLDivElement; +let unknownSpeedEl: HTMLDivElement; // Mutable state let currentUnit: Unit; @@ -22,8 +23,6 @@ let firstSpeedTimestamp: number | null = null; let lastHandlePositionTime: number | null = null; let onLocationSuccess: (() => void) | null = null; -const GPS_WARMUP_MS = 1000; - export const PLACEHOLDER = "———"; const GITHUB_LINK_HTML = ` @@ -282,17 +281,14 @@ function handlePosition(pos: GeolocationPosition): void { speed === null || (typeof speed === "number" && Number.isFinite(speed) && speed >= 0) ) { - if (firstSpeedTimestamp === null) { - firstSpeedTimestamp = now; + lastSpeedMs = speed; + renderSpeed(speed); + lastUpdateTimestamp = now; + if (warningEl) { + warningEl.hidden = true; } - - if (now - firstSpeedTimestamp >= GPS_WARMUP_MS) { - lastSpeedMs = speed; - renderSpeed(speed); - lastUpdateTimestamp = now; - if (warningEl) { - warningEl.hidden = true; - } + if (unknownSpeedEl) { + unknownSpeedEl.hidden = speed !== null; } } @@ -426,6 +422,12 @@ export function init(): void { } warningEl = warningElNullable as HTMLDivElement; + const unknownSpeedElNullable = document.getElementById("unknown-speed-msg"); + if (!unknownSpeedElNullable) { + throw new Error("Unknown speed element not found"); + } + unknownSpeedEl = unknownSpeedElNullable as HTMLDivElement; + // Initialize state from local storage or default const storedUnit = localStorage.getItem("speed-unit"); if (storedUnit === "MPH") { diff --git a/tests/app.test.ts b/tests/app.test.ts index a8088f7..33ccd53 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -8,6 +8,7 @@ describe("Speedometer App", () => { let unitBtn: HTMLElement; let keepScreenOnEl: HTMLInputElement; let warningEl: HTMLElement; + let unknownSpeedEl: HTMLElement; let mobileOverride: PropertyDescriptor | undefined; beforeEach(() => { @@ -16,6 +17,7 @@ describe("Speedometer App", () => { // Reset DOM document.body.innerHTML = ` +
{ } warningEl = warningElNullable; + const unknownSpeedElNullable = document.getElementById("unknown-speed-msg"); + if (!unknownSpeedElNullable) { + throw new Error("Unknown speed element not found"); + } + unknownSpeedEl = unknownSpeedElNullable; + // Enable test mode by default // biome-ignore lint/suspicious/noExplicitAny: Mocking global for testing (window as any).__TEST_MODE__ = true; @@ -168,12 +176,6 @@ describe("Speedometer App", () => { if (watchSuccessCallback) { // First update starts the timer watchSuccessCallback(mockPosition as unknown as GeolocationPosition); - - // Advance time by 1s (GPS_WARMUP_MS) - vi.advanceTimersByTime(1000); - - // Send it again to trigger the update - watchSuccessCallback(mockPosition as unknown as GeolocationPosition); } else { throw new Error("watchSuccessCallback was not set"); } @@ -191,9 +193,9 @@ describe("Speedometer App", () => { watchPositionSpy.mockRestore(); }); - it("ignores readings during warmup period", () => { - let watchSuccessCallback: PositionCallback | undefined; + it("handles invalid speed data", () => { + let watchSuccessCallback: PositionCallback | undefined; const watchPositionSpy = vi .spyOn(navigator.geolocation, "watchPosition") .mockImplementation((success) => { @@ -203,66 +205,42 @@ describe("Speedometer App", () => { init(); + // Speed is null (e.g. not moving/calculable by GPS yet) const mockPosition = { coords: { - speed: 10, - accuracy: 5, + speed: null, + accuracy: 10, }, timestamp: Date.now(), }; if (watchSuccessCallback) { - // Reading 1 (T=0) -> Ignored - watchSuccessCallback(mockPosition as unknown as GeolocationPosition); - expect(speedEl.textContent).toBe(PLACEHOLDER); - - // Reading 2 (T=0.5s) -> Ignored - vi.advanceTimersByTime(500); - watchSuccessCallback(mockPosition as unknown as GeolocationPosition); - expect(speedEl.textContent).toBe(PLACEHOLDER); - - // Reading 3 (T=1.0s) -> Accepted (>= GPS_WARMUP_MS) - vi.advanceTimersByTime(500); watchSuccessCallback(mockPosition as unknown as GeolocationPosition); - // 10 m/s * 2.23694 = 22.3694 -> 22 - expect(speedEl.textContent).toBe("22"); } else { throw new Error("watchSuccessCallback was not set"); } - watchPositionSpy.mockRestore(); - }); - - it("handles invalid speed data", () => { - let watchSuccessCallback: PositionCallback | undefined; - const watchPositionSpy = vi - .spyOn(navigator.geolocation, "watchPosition") - .mockImplementation((success) => { - watchSuccessCallback = success; - return 1; - }); + // Should remain placeholder if speed is null + expect(speedEl.textContent).toBe(PLACEHOLDER); + expect(speedEl.dataset.placeholderVisible).toBe("true"); - init(); + // The unknown speed message should be visible + expect(unknownSpeedEl.hidden).toBe(false); - // Speed is null (e.g. not moving/calculable by GPS yet) - const mockPosition = { + // Update with valid speed + const mockPosition2 = { coords: { - speed: null, + speed: 5, accuracy: 10, }, timestamp: Date.now(), }; - if (watchSuccessCallback) { - watchSuccessCallback(mockPosition as unknown as GeolocationPosition); - } else { - throw new Error("watchSuccessCallback was not set"); + watchSuccessCallback(mockPosition2 as unknown as GeolocationPosition); } - // Should remain placeholder if speed is null (no update logic triggered for null speed in app.ts) - // Actually app.ts says: if (typeof speed === "number" ...). If null, it skips renderSpeed. - expect(speedEl.textContent).toBe(PLACEHOLDER); - expect(speedEl.dataset.placeholderVisible).toBe("true"); + // Message should be hidden + expect(unknownSpeedEl.hidden).toBe(true); watchPositionSpy.mockRestore(); }); @@ -360,13 +338,6 @@ describe("Speedometer App", () => { timestamp: Date.now(), }; if (watchSuccessCallback) { - // First one ignored - watchSuccessCallback(validPosition as unknown as GeolocationPosition); - - // Advance past warmup - vi.advanceTimersByTime(1000); - - // Second one accepted watchSuccessCallback(validPosition as unknown as GeolocationPosition); } else { throw new Error("watchSuccessCallback was not set"); From 5ccc341c319f529b860cddb67ff47328e2aa7b29 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:24:07 +0000 Subject: [PATCH 5/7] Fix linter errors --- src/app.ts | 2 -- tests/app.test.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/src/app.ts b/src/app.ts index 2f5d8bb..a524a7c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -19,7 +19,6 @@ let currentUnit: Unit; let lastSpeedMs: number | null = null; // last known native speed (m/s), if any let lastUpdateTimestamp = 0; let wakeLock: WakeLockSentinel | null = null; -let firstSpeedTimestamp: number | null = null; let lastHandlePositionTime: number | null = null; let onLocationSuccess: (() => void) | null = null; @@ -381,7 +380,6 @@ export function resetState(): void { lastSpeedMs = null; lastUpdateTimestamp = 0; wakeLock = null; - firstSpeedTimestamp = null; lastHandlePositionTime = null; } diff --git a/tests/app.test.ts b/tests/app.test.ts index 33ccd53..393f820 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -193,7 +193,6 @@ describe("Speedometer App", () => { watchPositionSpy.mockRestore(); }); - it("handles invalid speed data", () => { let watchSuccessCallback: PositionCallback | undefined; const watchPositionSpy = vi From c06fe723c2b95ee7b9721460f6f9757a5e5a1734 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:48:12 +0000 Subject: [PATCH 6/7] Update layout for unknown speed and stale warnings --- index.html | 47 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/index.html b/index.html index 84ba70f..006efb4 100644 --- a/index.html +++ b/index.html @@ -166,14 +166,27 @@ padding-bottom: max(4px, env(safe-area-inset-bottom)); } + .top-messages-container { + display: block; + } + .warning { + position: absolute; + top: 0; + left: 0; /* Shrink warning in landscape */ font-size: 10px; padding: max(0px, env(safe-area-inset-top)) max(10px, env(safe-area-inset-right)) 1px max(10px, env(safe-area-inset-left)); line-height: 1.0; - opacity: 0.9; + opacity: 1; + } + + .unknown-speed-msg { + position: absolute; + top: 0; + left: 0; } } @@ -451,11 +464,21 @@ display: none; } } -.warning { +.top-messages-container { position: fixed; top: 0; left: 0; width: 100%; + z-index: 2000; + pointer-events: none; + display: flex; + flex-direction: column; +} + +.warning { + position: relative; + z-index: 2; + width: 100%; background-color: #ff9800; color: #000; text-align: center; @@ -463,15 +486,14 @@ max(10px, env(safe-area-inset-right)) 10px max(10px, env(safe-area-inset-left)); font-weight: bold; - z-index: 1000; font-size: 1.2em; box-sizing: border-box; + pointer-events: auto; } .unknown-speed-msg { - position: fixed; - top: 0; - left: 0; + position: relative; + z-index: 1; width: 100%; color: light-dark(#111, #eee); text-align: center; @@ -479,12 +501,15 @@ max(10px, env(safe-area-inset-right)) 10px max(10px, env(safe-area-inset-left)); font-weight: bold; - z-index: 999; font-size: 1.2em; box-sizing: border-box; pointer-events: none; } +.warning:not([hidden]) + .unknown-speed-msg { + padding-top: 0; +} + .warning-digits { display: inline-block; text-align: right; @@ -561,9 +586,11 @@ /> - -