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 @@
/>
- Speed data is old
+
+
Speed data is old
+
+ The speed is unknown. This often happens when the phone is still.
+
+
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 = `
Speed data is old
+ The speed is unknown. This often happens when the phone is still.
{
}
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();
+ });
});