north-star / commit
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);