fuzzyworld

north-star / commit

consolidated settings, updated favicon, removed stale code

9fda527165f81065303eeaec1cc3267b44956633 · Pavel Safronov · 2026-03-15 21:44:08 -0700 · parents 55ab7a53997f

consolidated settings, updated favicon, removed stale code
 .vscode/settings.json     |  8 +++++++
 readme.md                 |  1 +
 src/app.ts                | 60 +++++++++++++++++++++++++++++++----------------
 src/compass.ts            |  5 ++--
 src/friends-panel.ts      |  7 ++----
 src/index.html            |  1 +
 src/participants-panel.ts |  6 ++---
 src/public-settings.ts    | 44 ++++++++++++++++++++++++++++++++++
 src/renderer.ts           |  7 +++---
 src/room.ts               |  5 ++--
 src/session.ts            |  5 ++--
 11 files changed, 109 insertions(+), 40 deletions(-)

diff --git a/.vscode/settings.json b/.vscode/settings.json
index fd93652..399b676 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -7,6 +7,14 @@
         "/^npx tsc --noEmit && npm run build 2>&1 \\| tail -10$/": {
             "approve": true,
             "matchCommandLine": true
+        },
+        "/^npx tsc --noEmit 2>&1$/": {
+            "approve": true,
+            "matchCommandLine": true
+        },
+        "/^npx tsc --noEmit && npm run build 2>&1 \\| tail -20$/": {
+            "approve": true,
+            "matchCommandLine": true
         }
     }
 }
\ No newline at end of file
diff --git a/readme.md b/readme.md
index dac3309..acf2d02 100644
--- a/readme.md
+++ b/readme.md
@@ -80,6 +80,7 @@ Ideas worth tracking (not implemented yet):
 - Optional wake-lock mode for situations where people want to keep the app active longer.
 - Optional native-wrapper path (Capacitor/PWA strategy) for more reliable background location updates.
 - Export/share diagnostics mode for easier field testing (sensor availability, last sync times, etc.).
+- Continue migrating remaining behavior-tuning constants into public settings (for example initial seed position and other non-sensitive runtime defaults).
 
 ### Firebase database rules
 
diff --git a/src/app.ts b/src/app.ts
index 95253c6..1106e06 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -10,6 +10,7 @@ import { subscribeToFriends } from './friends-sync';
 import { initCompassController } from './compass';
 import { createFriendsPanelController } from './friends-panel';
 import { renderArrows } from './renderer';
+import { PUBLIC_SETTINGS } from './public-settings';
 
 const debugParagraph = document.getElementById('debug');
 const nameForm = document.getElementById('name-form');
@@ -31,23 +32,20 @@ const logDebug = (message) => {
     }
 };
 
-document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'icon', href: './image.png' }));
+document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'icon', href: './compass.png' }));
 
-const RENDER_INTERVAL_MS = 100;
-const LOCATION_SYNC_INTERVAL_MS = 3000;
-const FRIEND_ONLINE_GRACE_MS = 20_000;
-const DEFAULT_FRIEND_RECENTLY_OFFLINE_MS = 60 * 60 * 1000;
-const STALE_FRIEND_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
+const FEET_TO_METERS = 0.3048;
+const LOCATION_CHANGE_THRESHOLD_METERS = PUBLIC_SETTINGS.location.changeThresholdFeet * FEET_TO_METERS;
+const FRIEND_RECENTLY_OFFLINE_OVERRIDE_STORAGE_KEY = 'friend-compass-recently-offline-ms';
 const FRIEND_RECENTLY_OFFLINE_MS = (() => {
-    const raw = Number(localStorage.getItem('friend-compass-recently-offline-ms'));
-    if (Number.isFinite(raw) && raw > FRIEND_ONLINE_GRACE_MS) {
+    const raw = Number(localStorage.getItem(FRIEND_RECENTLY_OFFLINE_OVERRIDE_STORAGE_KEY));
+    if (Number.isFinite(raw) && raw > PUBLIC_SETTINGS.presence.onlineGraceMs) {
         return raw;
     }
-    return DEFAULT_FRIEND_RECENTLY_OFFLINE_MS;
+    return PUBLIC_SETTINGS.presence.defaultRecentlyOfflineMs;
 })();
 const HEADING_CORRECTION_STORAGE_KEY = 'friend-compass-heading-correction-deg';
 const NORTH_MARKER_ID = 'north-marker';
-const GUARDIAN_ANGEL_ID = 'test';
 
 let headingCorrectionDeg = (() => {
     const parsed = Number(localStorage.getItem(HEADING_CORRECTION_STORAGE_KEY));
@@ -78,15 +76,13 @@ const selfPosition = {
 const { db, uid } = await initFirebaseSession();
 
 const BASE_ROOM_URL = `${window.location.origin}${window.location.pathname}`;
-const GITLAB_PAGES_BASE_URL = 'https://pavelsafronov.gitlab.io/north-star/';
-
 const { roomId, roomKey } = await initSession({
     roomCurrent,
     createRoomLink,
     inviteRoomQrList,
     isLocalCommit,
     BASE_ROOM_URL,
-    GITLAB_PAGES_BASE_URL
+    GITLAB_PAGES_BASE_URL: PUBLIC_SETTINGS.links.gitlabPagesBaseUrl
 });
 
 const friendRef = ref(db, `rooms/${roomId}/friends/${uid}`);
@@ -94,6 +90,7 @@ const friendRef = ref(db, `rooms/${roomId}/friends/${uid}`);
 // selfFriend is assigned below by initSelfProfile; syncSelfFriend is
 // only ever called after that point (from event handlers / intervals).
 let selfFriend;
+let lastSyncedPosition: { lat: number; lon: number } | null = null;
 
 const syncSelfFriend = async () => {
     const now = Date.now();
@@ -103,6 +100,8 @@ const syncSelfFriend = async () => {
         : { ...selfFriend, lastSeen: now };
     try {
         await set(friendRef, dbRecord);
+        lastLocationSyncAt = now;
+        lastSyncedPosition = { ...selfPosition };
     } catch (err) {
         console.error('Error syncing friend in database', err);
     }
@@ -118,7 +117,7 @@ const cleanupStaleFriends = async () => {
         snapshot.forEach((child) => {
             const val = child.val();
             const lastSeen = Number(val?.lastSeen);
-            if (Number.isFinite(lastSeen) && now - lastSeen > STALE_FRIEND_TTL_MS) {
+            if (Number.isFinite(lastSeen) && now - lastSeen > PUBLIC_SETTINGS.cleanup.staleFriendTtlMs) {
                 deletions.push(remove(child.ref));
             }
         });
@@ -132,10 +131,32 @@ const cleanupStaleFriends = async () => {
 };
 
 let lastLocationSyncAt = 0;
+const distanceMetersBetween = (a: { lat: number; lon: number }, b: { lat: number; lon: number }) => {
+    const earthRadiusM = 6371000;
+    const toRad = (deg: number) => deg * (Math.PI / 180);
+    const dLat = toRad(a.lat - b.lat);
+    const dLon = toRad(a.lon - b.lon);
+    const lat1 = toRad(a.lat);
+    const lat2 = toRad(b.lat);
+
+    const sinDLat = Math.sin(dLat / 2);
+    const sinDLon = Math.sin(dLon / 2);
+    const hav = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon;
+    const c = 2 * Math.atan2(Math.sqrt(hav), Math.sqrt(1 - hav));
+    return earthRadiusM * c;
+};
+
+const hasMeaningfulLocationChange = () => {
+    if (!lastSyncedPosition) return true;
+    const deltaMeters = distanceMetersBetween(selfPosition, lastSyncedPosition);
+    return deltaMeters > LOCATION_CHANGE_THRESHOLD_METERS;
+};
+
 const maybeSyncLocation = () => {
     const now = Date.now();
-    if (now - lastLocationSyncAt < LOCATION_SYNC_INTERVAL_MS) return;
-    lastLocationSyncAt = now;
+    const locationChanged = hasMeaningfulLocationChange();
+    if (locationChanged && now - lastLocationSyncAt < PUBLIC_SETTINGS.location.movingSyncIntervalMs) return;
+    if (!locationChanged && now - lastLocationSyncAt < PUBLIC_SETTINGS.location.stationarySyncIntervalMs) return;
     void syncSelfFriend();
 };
 
@@ -172,11 +193,10 @@ const friendsController = createFriendsPanelController({
     requestRender,
     getReadableTextColor,
     northMarkerId: NORTH_MARKER_ID,
-    guardianAngelId: GUARDIAN_ANGEL_ID,
     selfFriend,
     selfPosition,
     presenceThresholds: {
-        onlineGraceMs: FRIEND_ONLINE_GRACE_MS,
+        onlineGraceMs: PUBLIC_SETTINGS.presence.onlineGraceMs,
         recentlyOfflineMs: FRIEND_RECENTLY_OFFLINE_MS
     }
 });
@@ -225,7 +245,7 @@ const render = () => {
         isFriendVisible: friendsController.isFriendVisible,
         getArrowDisplayColor,
         presenceThresholds: {
-            onlineGraceMs: FRIEND_ONLINE_GRACE_MS,
+            onlineGraceMs: PUBLIC_SETTINGS.presence.onlineGraceMs,
             recentlyOfflineMs: FRIEND_RECENTLY_OFFLINE_MS
         }
     });
@@ -235,4 +255,4 @@ const render = () => {
 
 await syncSelfFriend();
 void cleanupStaleFriends();
-setInterval(render, RENDER_INTERVAL_MS);
+setInterval(render, PUBLIC_SETTINGS.app.renderIntervalMs);
diff --git a/src/compass.ts b/src/compass.ts
index c2a3603..c913085 100644
--- a/src/compass.ts
+++ b/src/compass.ts
@@ -1,4 +1,5 @@
 import { normalizeAngle, normalizeSignedAngle } from './utils';
+import { PUBLIC_SETTINGS } from './public-settings';
 
 export const initCompassController = ({
     calibrateHeadingLink,
@@ -31,8 +32,6 @@ export const initCompassController = ({
     let compassProbeTimer = null;
     let hasAbsoluteOrientation = false;
 
-    const COMPASS_PROBE_TIMEOUT_MS = 2500;
-
     const getHeadingUnavailableMessage = () => {
         if (compassDefinitelyUnsupported) {
             return 'This browser/device does not expose orientation sensors, so a compass is unavailable here.';
@@ -165,7 +164,7 @@ export const initCompassController = ({
                 title: status.title,
                 isError: status.isError
             });
-        }, COMPASS_PROBE_TIMEOUT_MS);
+        }, PUBLIC_SETTINGS.compass.probeTimeoutMs);
     };
 
     const requestCompassPermission = async () => {
diff --git a/src/friends-panel.ts b/src/friends-panel.ts
index 50daa4f..231f092 100644
--- a/src/friends-panel.ts
+++ b/src/friends-panel.ts
@@ -7,7 +7,6 @@ export const createFriendsPanelController = ({
     requestRender,
     getReadableTextColor,
     northMarkerId,
-    guardianAngelId,
     selfFriend,
     selfPosition,
     presenceThresholds
@@ -17,7 +16,6 @@ export const createFriendsPanelController = ({
     requestRender: () => void;
     getReadableTextColor: (color: string) => string;
     northMarkerId: string;
-    guardianAngelId: string;
     selfFriend: any;
     selfPosition: { lat: number; lon: number };
     presenceThresholds: PresenceThresholds;
@@ -51,12 +49,11 @@ export const createFriendsPanelController = ({
 
     const getFriendDefaultRank = (friendId) => {
         if (friendId === northMarkerId) return 0;
-        if (friendId === guardianAngelId || friendId === 'guardian-angel') return 1;
-        return 2;
+        return 1;
     };
 
     const shouldShowMapLink = (friendId) => {
-        return friendId !== northMarkerId && friendId !== guardianAngelId;
+        return friendId !== northMarkerId;
     };
 
     const ensureFriendOrder = (friendsToRender) => {
diff --git a/src/index.html b/src/index.html
index 42e2ce3..7fc54c2 100644
--- a/src/index.html
+++ b/src/index.html
@@ -19,6 +19,7 @@
     <meta name="description"
         content="Friend Compass shows live arrows pointing toward your friends. No account needed, end-to-end encrypted, works on any phone." />
 
+    <link rel="icon" href="./compass.png" />
     <link rel="stylesheet" href="style.css" />
 
 </head>
diff --git a/src/participants-panel.ts b/src/participants-panel.ts
index 0586a16..b83a0cc 100644
--- a/src/participants-panel.ts
+++ b/src/participants-panel.ts
@@ -4,7 +4,6 @@ export const createParticipantsPanelController = ({
     requestRender,
     getReadableTextColor,
     northMarkerId,
-    guardianAngelId,
     selfParticipant,
     selfPosition
 }) => {
@@ -37,12 +36,11 @@ export const createParticipantsPanelController = ({
 
     const getParticipantDefaultRank = (participantId) => {
         if (participantId === northMarkerId) return 0;
-        if (participantId === guardianAngelId || participantId === 'guardian-angel') return 1;
-        return 2;
+        return 1;
     };
 
     const shouldShowMapLink = (participantId) => {
-        return participantId !== northMarkerId && participantId !== guardianAngelId;
+        return participantId !== northMarkerId;
     };
 
     const ensureParticipantOrder = (participantsToRender) => {
diff --git a/src/public-settings.ts b/src/public-settings.ts
new file mode 100644
index 0000000..6664542
--- /dev/null
+++ b/src/public-settings.ts
@@ -0,0 +1,44 @@
+/**
+ * Public runtime settings that tune app behavior.
+ *
+ * These values are safe to ship to clients and intentionally exclude secrets.
+ * Keep this file as the single source of truth for behavioral constants.
+ */
+export const PUBLIC_SETTINGS = {
+    app: {
+        /** How often the render loop runs. */
+        renderIntervalMs: 100
+    },
+    location: {
+        /** Stationary heartbeat write cadence. */
+        stationarySyncIntervalMs: 2 * 60 * 1000,
+        /** Max write cadence while movement is detected. */
+        movingSyncIntervalMs: 10 * 1000,
+        /** Minimum movement distance (feet) treated as meaningful movement. */
+        changeThresholdFeet: 5
+    },
+    presence: {
+        /** Time window where a friend is treated as online/solid. */
+        onlineGraceMs: 5 * 60 * 1000,
+        /** Default time window where offline friends still show as "recent". */
+        defaultRecentlyOfflineMs: 60 * 60 * 1000
+    },
+    cleanup: {
+        /** Friend records older than this are eligible for cleanup. */
+        staleFriendTtlMs: 7 * 24 * 60 * 60 * 1000
+    },
+    compass: {
+        /** Delay before showing "no compass data" status. */
+        probeTimeoutMs: 2500
+    },
+    room: {
+        /** Length of generated room IDs. */
+        idLength: 8
+    },
+    links: {
+        /** Hosted app base URL used in invite QR cards for local runs. */
+        gitlabPagesBaseUrl: 'https://pavelsafronov.gitlab.io/north-star/'
+    }
+} as const;
+
+export type PublicSettings = typeof PUBLIC_SETTINGS;
diff --git a/src/renderer.ts b/src/renderer.ts
index df1b3b4..eaefef8 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -1,4 +1,4 @@
-import { getShortestDelta, normalizeAngle, getFriendPresenceState, PRESENCE_EMOJI } from './utils';
+import { getShortestDelta, normalizeAngle, getFriendPresenceState } from './utils';
 import type { PresenceThresholds } from './utils';
 
 type ArrowElement = HTMLElement & { currentAngle: number | null };
@@ -11,7 +11,7 @@ export const renderArrows = ({
     selfHeading,
     isFriendVisible,
     getArrowDisplayColor,
-    presenceThresholds
+    presenceThresholds,
 }) => {
     const now = Date.now();
     const activeArrowIds = new Set(
@@ -75,8 +75,7 @@ export const renderArrows = ({
 
         const arrowLabel = existingArrow.querySelector('.arrow-label') as HTMLElement | null;
         if (arrowLabel) {
-            const emoji = PRESENCE_EMOJI[presenceState];
-            arrowLabel.textContent = emoji ? `${emoji} ${target.name}` : target.name;
+            arrowLabel.textContent = target.name;
             arrowLabel.style.transform = `rotate(${-currentArrowAngle}deg)`;
         }
     }
diff --git a/src/room.ts b/src/room.ts
index 1c4cc0c..263f40e 100644
--- a/src/room.ts
+++ b/src/room.ts
@@ -1,4 +1,5 @@
-export const ROOM_ID_LENGTH = 8;
+import { PUBLIC_SETTINGS } from './public-settings';
+
 const ROOM_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
 
 export const buildRoomHash = (nextRoomId, nextRoomKeyBase64 = null) => {
@@ -8,7 +9,7 @@ export const buildRoomHash = (nextRoomId, nextRoomKeyBase64 = null) => {
 
 export const parseRoomFromHash = (hashValue) => {
     const normalizedHash = (hashValue || '').startsWith('#') ? hashValue.slice(1) : (hashValue || '');
-    const compactMatch = normalizedHash.match(/^([a-z0-9]{8})(?:\.([A-Za-z0-9_-]+))?$/);
+    const compactMatch = normalizedHash.match(new RegExp(`^([a-z0-9]{${PUBLIC_SETTINGS.room.idLength}})(?:\\.([A-Za-z0-9_-]+))?$`));
     if (!compactMatch) return null;
 
     const roomValue = compactMatch[1];
diff --git a/src/session.ts b/src/session.ts
index c213b3e..5fefb3d 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -1,6 +1,7 @@
 import { generateRoomKey, exportRoomKey, importRoomKey } from './crypto';
-import { buildRoomHash, parseRoomFromHash, ROOM_ID_LENGTH } from './room';
+import { buildRoomHash, parseRoomFromHash } from './room';
 import { renderInviteQrCard } from './qr';
+import { PUBLIC_SETTINGS } from './public-settings';
 
 /**
  * Parses an existing room from the URL hash, or generates a new one.
@@ -31,7 +32,7 @@ export async function initSession({
             }
         }
     } else {
-        roomId = crypto.randomUUID().replace(/-/g, '').slice(0, ROOM_ID_LENGTH);
+        roomId = crypto.randomUUID().replace(/-/g, '').slice(0, PUBLIC_SETTINGS.room.idLength);
         roomKey = await generateRoomKey();
         roomKeyBase64 = await exportRoomKey(roomKey);
         location.hash = buildRoomHash(roomId, roomKeyBase64);