fuzzyworld

north-star / commit

landing page now has the option to start a room or resume, and lots of minor changes

07aa39030c0228eea743daaac33d5d40e79078eb · Pavel Safronov · 2026-03-15 22:20:07 -0700 · parents 9fda527165f8

landing page now has the option to start a room or resume, and lots of minor changes
 .gitlab-ci.yml        |   2 +-
 .vscode/settings.json |   6 +-
 package.json          |   2 +-
 src/app.ts            | 211 +++++++++++++++++++++++++++++++++++++++-----------
 src/commit.js         |   2 -
 src/commit.ts         |   4 +
 src/index.html        |   8 +-
 src/style.css         |  62 +++++++++++++++
 8 files changed, 246 insertions(+), 51 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 6c2a677..265add2 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -13,5 +13,5 @@ create-pages:
   script:
     - echo "Building North Star from commit ${CI_COMMIT_SHORT_SHA}..."
     - mkdir -p public
-    - npm run gen-commit-js
+    - npm run gen-commit-ts
     - npm run build
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 399b676..97ed7dc 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -16,5 +16,9 @@
             "approve": true,
             "matchCommandLine": true
         }
-    }
+    },
+    "cSpell.words": [
+        "darkmode",
+        "wakelock"
+    ]
 }
\ No newline at end of file
diff --git a/package.json b/package.json
index 2258e76..ac812a2 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
         "dev": "vite",
         "build": "vite build",
         "preview": "vite preview",
-        "gen-commit-js": "echo \"export const GIT_COMMIT='${CI_COMMIT_SHORT_SHA}';\" > ./src/commit.js"
+        "gen-commit-ts": "echo \"export const GIT_COMMIT='${CI_COMMIT_SHORT_SHA}'; export const IS_LOCAL_BUILD=false;\" > ./src/commit.ts"
     },
     "dependencies": {
         "firebase": "^12.0.0",
diff --git a/src/app.ts b/src/app.ts
index 1106e06..b8196be 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -5,6 +5,7 @@ import { initDarkMode } from './darkmode';
 import { createWakeLockManager } from './wakelock';
 import { initFirebaseSession } from './firebase';
 import { initSession } from './session';
+import { parseRoomFromHash } from './room';
 import { initSelfProfile } from './self-profile';
 import { subscribeToFriends } from './friends-sync';
 import { initCompassController } from './compass';
@@ -21,10 +22,13 @@ const inviteRoomQrList = document.getElementById('invite-room-qr-list');
 const calibrateHeadingLink = document.getElementById('calibrate-heading-link');
 const darkModeToggle = document.getElementById('dark-mode-toggle');
 const createRoomLink = document.getElementById('create-room-link');
+const roomLinks = document.getElementById('room-links');
 const friendsPanel = document.getElementById('friends-panel');
 const friendsToggle = document.getElementById('friends-toggle');
 const content = document.getElementById('content');
 const arrow = document.getElementById('default-arrow');
+const startRoomButton = document.getElementById('start-room-button');
+const resumeRoomButton = document.getElementById('resume-room-button');
 
 const logDebug = (message) => {
     if (message !== debugParagraph.innerHTML) {
@@ -45,6 +49,7 @@ const FRIEND_RECENTLY_OFFLINE_MS = (() => {
     return PUBLIC_SETTINGS.presence.defaultRecentlyOfflineMs;
 })();
 const HEADING_CORRECTION_STORAGE_KEY = 'friend-compass-heading-correction-deg';
+const LAST_ROOM_HASH_STORAGE_KEY = 'friend-compass-last-room-hash';
 const NORTH_MARKER_ID = 'north-marker';
 
 let headingCorrectionDeg = (() => {
@@ -55,44 +60,63 @@ let headingCorrectionDeg = (() => {
 let isLocalCommit = true;
 let GIT_COMMIT = 'local-dev';
 
+let db: any = null;
+let roomId = '';
+let roomKey: CryptoKey | null = null;
+let friendRef: ReturnType<typeof ref> | null = null;
+let friends: any[] = [];
+let sessionStarted = false;
+let startInProgress = false;
+
 initDarkMode(darkModeToggle);
 const { requestWakeLock } = createWakeLockManager();
 
 try {
     const commitModule = await import('./commit.js');
-    if (typeof commitModule.GIT_COMMIT === 'string') {
-        GIT_COMMIT = commitModule.GIT_COMMIT;
-        isLocalCommit = false;
-    }
+    GIT_COMMIT = commitModule.GIT_COMMIT;
+    isLocalCommit = commitModule.IS_LOCAL_BUILD;
 } catch {
-    // Local/dev environments may not have commit.js.
+    // Should not normally fail — commit.ts always exists as a dev fallback.
+}
+
+if (createRoomLink instanceof HTMLAnchorElement) createRoomLink.style.display = 'none';
+if (roomLinks) roomLinks.classList.add('is-hidden');
+if (friendsPanel) friendsPanel.classList.add('is-hidden');
+if (friendsToggle instanceof HTMLButtonElement) {
+    friendsToggle.disabled = true;
+    friendsToggle.title = 'Start a session first to show friends.';
 }
+if (inviteRoomQrList) inviteRoomQrList.textContent = '';
 
 const selfPosition = {
     lat: 47.62055039884746,
     lon: -122.34928833200904
 };
 
-const { db, uid } = await initFirebaseSession();
-
 const BASE_ROOM_URL = `${window.location.origin}${window.location.pathname}`;
-const { roomId, roomKey } = await initSession({
-    roomCurrent,
-    createRoomLink,
-    inviteRoomQrList,
-    isLocalCommit,
-    BASE_ROOM_URL,
-    GITLAB_PAGES_BASE_URL: PUBLIC_SETTINGS.links.gitlabPagesBaseUrl
-});
 
-const friendRef = ref(db, `rooms/${roomId}/friends/${uid}`);
+const appTitleLink = document.getElementById('app-title-link');
+if (appTitleLink instanceof HTMLAnchorElement) {
+    appTitleLink.href = isLocalCommit ? BASE_ROOM_URL : PUBLIC_SETTINGS.links.gitlabPagesBaseUrl;
+}
+
+const renderDebugInfo = () => {
+    logDebug(`Commit: ${isLocalCommit ? 'local' : `<a href="https://gitlab.com/PavelSafronov/north-star/-/commit/${GIT_COMMIT}" target="_blank">${GIT_COMMIT}</a>`} | Repo: <a href="https://gitlab.com/PavelSafronov/north-star" target="_blank">north-star</a>`);
+};
+
+renderDebugInfo();
 
 // 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;
+let lastLocationSyncAt = 0;
+let geolocationWatchId: number | null = null;
+let renderIntervalId: number | null = null;
 
 const syncSelfFriend = async () => {
+    if (!friendRef) return;
+
     const now = Date.now();
     selfFriend.timestamp = now;
     const dbRecord = roomKey
@@ -108,6 +132,8 @@ const syncSelfFriend = async () => {
 };
 
 const cleanupStaleFriends = async () => {
+    if (!db || !roomId) return;
+
     try {
         const roomFriendsRef = ref(db, `rooms/${roomId}/friends`);
         const snapshot = await get(roomFriendsRef);
@@ -130,7 +156,6 @@ 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);
@@ -153,6 +178,8 @@ const hasMeaningfulLocationChange = () => {
 };
 
 const maybeSyncLocation = () => {
+    if (!sessionStarted) return;
+
     const now = Date.now();
     const locationChanged = hasMeaningfulLocationChange();
     if (locationChanged && now - lastLocationSyncAt < PUBLIC_SETTINGS.location.movingSyncIntervalMs) return;
@@ -174,7 +201,7 @@ const requestRender = () => {
     nameForm,
     selfNameInput,
     selfColorInput,
-    uid,
+    uid: 'pending-user',
     selfPosition,
     syncSelf: syncSelfFriend,
     requestRender
@@ -201,36 +228,137 @@ const friendsController = createFriendsPanelController({
     }
 });
 
-navigator.geolocation.watchPosition(async (pos) => {
-    selfPosition.lat = pos.coords.latitude;
-    selfPosition.lon = pos.coords.longitude;
-    selfFriend.position = { ...selfPosition };
-    maybeSyncLocation();
-    requestRender();
-}, (err) => {
-    console.error('Error getting geolocation', err);
+const startSession = async () => {
+    if (sessionStarted || startInProgress) return;
+    startInProgress = true;
+
+    if (startRoomButton instanceof HTMLButtonElement) {
+        startRoomButton.disabled = true;
+        startRoomButton.textContent = 'Starting...';
+    }
+
+    try {
+        const firebaseSession = await initFirebaseSession();
+        db = firebaseSession.db;
+        selfFriend.id = firebaseSession.uid;
+
+        const session = await initSession({
+            roomCurrent,
+            createRoomLink,
+            inviteRoomQrList,
+            isLocalCommit,
+            BASE_ROOM_URL,
+            GITLAB_PAGES_BASE_URL: PUBLIC_SETTINGS.links.gitlabPagesBaseUrl
+        });
+        roomId = session.roomId;
+        roomKey = session.roomKey;
+
+        friendRef = ref(db, `rooms/${roomId}/friends/${selfFriend.id}`);
+
+        await syncSelfFriend();
+        void cleanupStaleFriends();
+
+        if (createRoomLink instanceof HTMLAnchorElement) createRoomLink.style.display = '';
+        if (roomLinks) roomLinks.classList.remove('is-hidden');
+        if (friendsToggle instanceof HTMLButtonElement) {
+            friendsToggle.disabled = false;
+            friendsToggle.title = '';
+        }
+
+        const subscription = subscribeToFriends({
+            db,
+            roomId,
+            roomKey,
+            onUpdate: (updated) => {
+                const friendsToRender = friendsController.getFriendsToRender(updated);
+                friendsController.renderFriendControls(friendsToRender);
+                requestRender();
+            }
+        });
+        friends = subscription.friends;
+
+        if (geolocationWatchId === null) {
+            geolocationWatchId = navigator.geolocation.watchPosition(async (pos) => {
+                selfPosition.lat = pos.coords.latitude;
+                selfPosition.lon = pos.coords.longitude;
+                selfFriend.position = { ...selfPosition };
+                maybeSyncLocation();
+                requestRender();
+            }, (err) => {
+                console.error('Error getting geolocation', err);
+            });
+        }
+
+        if (renderIntervalId === null) {
+            renderIntervalId = window.setInterval(render, PUBLIC_SETTINGS.app.renderIntervalMs);
+        }
+
+        sessionStarted = true;
+        localStorage.setItem(LAST_ROOM_HASH_STORAGE_KEY, location.hash);
+        if (startRoomButton) startRoomButton.classList.add('is-hidden');
+        if (resumeRoomButton) resumeRoomButton.classList.add('is-hidden');
+        void requestWakeLock();
+        requestRender();
+    } catch (err) {
+        console.error('Failed to start session', err);
+        if (startRoomButton instanceof HTMLButtonElement) {
+            startRoomButton.disabled = false;
+            startRoomButton.textContent = 'Start';
+        }
+    } finally {
+        startInProgress = false;
+    }
+};
+
+if (startRoomButton instanceof HTMLButtonElement) {
+    startRoomButton.addEventListener('click', () => {
+        void startSession();
+    });
+}
+
+if (parseRoomFromHash(location.hash)) {
+    void startSession();
+} else {
+    const savedRoomHash = localStorage.getItem(LAST_ROOM_HASH_STORAGE_KEY);
+    if (savedRoomHash && parseRoomFromHash(savedRoomHash) && resumeRoomButton instanceof HTMLButtonElement) {
+        resumeRoomButton.classList.remove('is-hidden');
+        resumeRoomButton.addEventListener('click', () => {
+            location.hash = savedRoomHash;
+            void startSession();
+        });
+        if (startRoomButton instanceof HTMLButtonElement) {
+            startRoomButton.textContent = 'New Room';
+        }
+    }
+}
+
+window.addEventListener('hashchange', () => {
+    // Back-navigation can clear the room hash while runtime state is still active.
+    // Reload to re-enter the proper landing/resume flow for the new URL.
+    if (sessionStarted && !parseRoomFromHash(location.hash)) {
+        window.location.reload();
+        return;
+    }
+
+    // Forward-navigation can restore a valid room hash while still on landing.
+    // Start the room session to match the URL state.
+    if (!sessionStarted && parseRoomFromHash(location.hash)) {
+        void startSession();
+    }
 });
 
 document.addEventListener('visibilitychange', () => {
     if (document.visibilityState !== 'visible') return;
     compassController.scheduleCompassProbe();
-    void requestWakeLock();
-});
-
-void requestWakeLock();
-
-const { friends } = subscribeToFriends({
-    db,
-    roomId,
-    roomKey,
-    onUpdate: (updated) => {
-        const friendsToRender = friendsController.getFriendsToRender(updated);
-        friendsController.renderFriendControls(friendsToRender);
-        requestRender();
+    if (sessionStarted) {
+        void requestWakeLock();
     }
 });
 
 const render = () => {
+    renderDebugInfo();
+    if (!sessionStarted) return;
+
     const now = Date.now();
     const friendsToRender = friendsController.getFriendsToRender(friends);
 
@@ -250,9 +378,4 @@ const render = () => {
         }
     });
 
-    logDebug(`Commit: ${isLocalCommit ? 'local' : `<a href="https://gitlab.com/PavelSafronov/north-star/-/commit/${GIT_COMMIT}" target="_blank">${GIT_COMMIT}</a>`} | Repo: <a href="https://gitlab.com/PavelSafronov/north-star" target="_blank">north-star</a>`);
 };
-
-await syncSelfFriend();
-void cleanupStaleFriends();
-setInterval(render, PUBLIC_SETTINGS.app.renderIntervalMs);
diff --git a/src/commit.js b/src/commit.js
deleted file mode 100644
index a69cf65..0000000
--- a/src/commit.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// This file is meant to be generated during the build process, but we need it to exist in the source directory for development purposes. The build process will replace the value of GIT_COMMIT with the actual commit hash or version string.
-export const GIT_COMMIT = 'local';
\ No newline at end of file
diff --git a/src/commit.ts b/src/commit.ts
new file mode 100644
index 0000000..35a3fc3
--- /dev/null
+++ b/src/commit.ts
@@ -0,0 +1,4 @@
+// This file is generated during CI builds (see gen-commit-ts in package.json).
+// The dev fallback here keeps IS_LOCAL_BUILD=true so local runs are correctly identified.
+export const GIT_COMMIT: string = 'local';
+export const IS_LOCAL_BUILD: boolean = true;
\ No newline at end of file
diff --git a/src/index.html b/src/index.html
index 7fc54c2..4e43167 100644
--- a/src/index.html
+++ b/src/index.html
@@ -27,7 +27,7 @@
 <body>
     <p class="app-title">
         <img src="compass.png" alt="Compass icon" class="app-icon" />
-        <a href="https://pavelsafronov.gitlab.io/north-star/">Friend Compass</a>
+        <a id="app-title-link" href="https://pavelsafronov.gitlab.io/north-star/">Friend Compass</a>
     </p>
     <div class="room-header">
         <p id="room-current" class="room-current"></p>
@@ -49,9 +49,13 @@
         <div class="arrow-img"></div>
     </div>
     <div id="content">
+        <div id="lobby-buttons">
+            <button id="resume-room-button" class="resume-room-button is-hidden" type="button">Resume</button>
+            <button id="start-room-button" class="start-room-button" type="button">Start</button>
+        </div>
         <aside id="friends-panel" class="friends-panel" aria-label="Friends"></aside>
     </div>
-    <div class="room-links">
+    <div id="room-links" class="room-links is-hidden">
         <div id="invite-room-qr-list" class="room-qr-list" aria-label="Room invite QR codes"></div>
     </div>
     <div id="debug-info">
diff --git a/src/style.css b/src/style.css
index e892b71..f6cc61b 100644
--- a/src/style.css
+++ b/src/style.css
@@ -263,6 +263,58 @@ body.dark .dark-mode-toggle {
     z-index: 1;
 }
 
+.is-hidden {
+    display: none !important;
+}
+
+#lobby-buttons {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 14px;
+    z-index: 2;
+}
+
+.start-room-button {
+    min-width: 220px;
+    padding: 18px 36px;
+    border: 0;
+    border-radius: 16px;
+    background: linear-gradient(180deg, #22c55e 0%, #16a34a 100%);
+    color: #f8fafc;
+    font-size: clamp(22px, 4.2vmin, 36px);
+    font-weight: 800;
+    letter-spacing: 0.02em;
+    box-shadow: 0 16px 32px rgba(22, 163, 74, 0.35);
+    cursor: pointer;
+}
+
+.start-room-button:active {
+    transform: translateY(2px);
+}
+
+.resume-room-button {
+    min-width: 220px;
+    padding: 18px 36px;
+    border: 0;
+    border-radius: 16px;
+    background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
+    color: #f8fafc;
+    font-size: clamp(22px, 4.2vmin, 36px);
+    font-weight: 800;
+    letter-spacing: 0.02em;
+    box-shadow: 0 16px 32px rgba(37, 99, 235, 0.35);
+    cursor: pointer;
+}
+
+.resume-room-button:active {
+    transform: translateY(2px);
+}
+
 .friends-toggle {
     padding: 6px 10px;
     border: 1px solid #cbd5e1;
@@ -578,6 +630,16 @@ body.dark .friends-panel {
     border-color: #334155;
 }
 
+body.dark .start-room-button {
+    background: linear-gradient(180deg, #16a34a 0%, #15803d 100%);
+    box-shadow: 0 14px 28px rgba(22, 163, 74, 0.3);
+}
+
+body.dark .resume-room-button {
+    background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%);
+    box-shadow: 0 14px 28px rgba(37, 99, 235, 0.3);
+}
+
 body.dark .friend-drag-handle {
     background: #0f172a;
     border-color: #475569;