diff --git a/index.html b/index.html index 7215ebf..5b4a56b 100644 --- a/index.html +++ b/index.html @@ -110,73 +110,6 @@ display: none; } -@media (orientation: landscape) { - .container { - justify-content: center; - } - - .speed { - /* - Maximize speed size in landscape. - Constraint 1: Height. Leave room for warning bar (top) and controls (bottom). - Constraint 2: Width. Must fit 3 digits (3em width). - */ - font-size: min(32vw, 65svh); - line-height: 0.8; - margin: auto; - /* Move down to avoid clipping at the top */ - transform: translateY(8vh); - } - - /* Hide the portrait unit button */ - .container button.unit { - display: none; - } - - /* Show landscape unit button */ - .status-container button.unit { - display: block; - margin: 0; - padding: 6px 10px; - font-size: 16px; - /* Reset portrait margins */ - margin-left: 12px; - } - - /* Layout for status and unit button side-by-side */ - .status-container { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - gap: 8px; - pointer-events: auto; - } - - .status { - /* Fixed min-width to prevent shifting when text changes "Accuracy: 5m" vs "100m" */ - min-width: 140px; - text-align: right; - white-space: nowrap; - } - - .bottom-bar { - /* Reduce padding to give more space to speed */ - padding-top: 4px; - padding-bottom: max(4px, env(safe-area-inset-bottom)); - } - - .warning { - /* 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; - } -} - .bottom-bar { width: 100%; display: grid; @@ -451,11 +384,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,9 +406,28 @@ 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: relative; + z-index: 1; + 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; + font-size: 1.2em; + box-sizing: border-box; + pointer-events: none; +} + +.warning:not([hidden]) + .unknown-speed-msg { + padding-top: 0; } .warning-digits { @@ -527,6 +489,86 @@ max-width: 350px; } } + +@media (orientation: landscape) { + .container { + justify-content: center; + } + + .speed { + /* + Maximize speed size in landscape. + Constraint 1: Height. Leave room for warning bar (top) and controls (bottom). + Constraint 2: Width. Must fit 3 digits (3em width). + */ + font-size: min(32vw, 65svh); + line-height: 0.8; + margin: auto; + /* Move down to avoid clipping at the top */ + transform: translateY(8vh); + } + + /* Hide the portrait unit button */ + .container button.unit { + display: none; + } + + /* Show landscape unit button */ + .status-container button.unit { + display: block; + margin: 0; + padding: 6px 10px; + font-size: 16px; + /* Reset portrait margins */ + margin-left: 12px; + } + + /* Layout for status and unit button side-by-side */ + .status-container { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 8px; + pointer-events: auto; + } + + .status { + /* Fixed min-width to prevent shifting when text changes "Accuracy: 5m" vs "100m" */ + min-width: 140px; + text-align: right; + white-space: nowrap; + } + + .bottom-bar { + /* Reduce padding to give more space to speed */ + padding-top: 4px; + 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: 1; + } + + .unknown-speed-msg { + position: absolute; + top: 0; + left: 0; + } +} @@ -544,7 +586,12 @@ /> - +
+ + +
Info We'll ask for location permissions now.

-
diff --git a/package-lock.json b/package-lock.json index aa64357..f851360 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "speedometer", - "version": "0.0.84", + "version": "0.0.87", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "speedometer", - "version": "0.0.84", + "version": "0.0.87", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index dd00a77..03240c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "speedometer", - "version": "0.0.84", + "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 d10999d..98dc415 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,14 +12,15 @@ let statusEl: HTMLDivElement; let unitBtns: NodeListOf; let keepScreenOnEl: HTMLInputElement; let warningEl: HTMLDivElement; +let unknownSpeedEl: HTMLDivElement; // Mutable state 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 lastHandlePositionTime: number | null = null; +let onLocationSuccess: (() => void) | null = null; export const PLACEHOLDER = "———"; @@ -77,7 +78,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"); @@ -253,6 +259,10 @@ async function handleWakeLock(): Promise { } function handlePosition(pos: GeolocationPosition): void { + if (onLocationSuccess) { + onLocationSuccess(); + } + const now = Date.now(); if (lastHandlePositionTime !== null) { @@ -265,15 +275,20 @@ 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) { - const now = Date.now(); + // Update speed only when native speed is provided and valid OR null + if ( + speed === null || + (typeof speed === "number" && Number.isFinite(speed) && speed >= 0) + ) { lastSpeedMs = speed; renderSpeed(speed); lastUpdateTimestamp = now; if (warningEl) { warningEl.hidden = true; } + if (unknownSpeedEl) { + unknownSpeedEl.hidden = speed !== null; + } } // Status/accuracy @@ -333,6 +348,10 @@ function startGeolocation(): void { if (lastUpdateTimestamp > 0 && diff > 5000) { if (warningEl) { warningEl.hidden = false; + // Hide unknown speed message when data is stale (warning takes precedence) + if (unknownSpeedEl) { + unknownSpeedEl.hidden = true; + } const parts = formatDuration(diff); @@ -405,6 +424,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") { @@ -420,6 +445,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"); @@ -511,15 +537,77 @@ 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(); + + // 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", () => { + 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 @@ -542,13 +630,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"); diff --git a/tests/app.test.ts b/tests/app.test.ts index 5344322..ea7c5bb 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; @@ -211,11 +219,28 @@ describe("Speedometer App", () => { throw new Error("watchSuccessCallback was not set"); } - // 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. + // Should remain placeholder if speed is null expect(speedEl.textContent).toBe(PLACEHOLDER); expect(speedEl.dataset.placeholderVisible).toBe("true"); + // The unknown speed message should be visible + expect(unknownSpeedEl.hidden).toBe(false); + + // Update with valid speed + const mockPosition2 = { + coords: { + speed: 5, + accuracy: 10, + }, + timestamp: Date.now(), + }; + if (watchSuccessCallback) { + watchSuccessCallback(mockPosition2 as unknown as GeolocationPosition); + } + + // Message should be hidden + expect(unknownSpeedEl.hidden).toBe(true); + watchPositionSpy.mockRestore(); }); @@ -403,4 +428,44 @@ describe("Speedometer App", () => { watchPositionSpy.mockRestore(); consoleWarnSpy.mockRestore(); }); + + it("hides unknown speed message when data becomes stale", () => { + let watchSuccessCallback: PositionCallback | undefined; + const watchPositionSpy = vi + .spyOn(navigator.geolocation, "watchPosition") + .mockImplementation((success) => { + watchSuccessCallback = success; + return 1; + }); + + init(); + + // 1. Receive null speed (fresh) + const mockPosition = { + coords: { + speed: null, + accuracy: 10, + }, + timestamp: Date.now(), + }; + + if (watchSuccessCallback) { + watchSuccessCallback(mockPosition as unknown as GeolocationPosition); + } else { + throw new Error("watchSuccessCallback was not set"); + } + + // Verify unknown speed message is visible + expect(unknownSpeedEl.hidden).toBe(false); + expect(warningEl.hidden).toBe(true); + + // 2. Advance time by 6 seconds (making it stale) + vi.advanceTimersByTime(6000); + + // Verify warning is visible AND unknown speed message is HIDDEN + expect(warningEl.hidden).toBe(false); + expect(unknownSpeedEl.hidden).toBe(true); + + watchPositionSpy.mockRestore(); + }); });