Make tabs hash-routable

- New src/lib/router.js: parseHash/ viewToHash for the 5 tab views, with a
  default fallback for empty/unknown hashes.
- App.svelte: initialize viewMode from location.hash, setView() updates both
  state and the URL hash, and a guarded hashchange listener syncs back/forward
  and manual URL edits. All tab onclicks route through setView().
- Tests for parse/viewToHash round-trips and fallback. 76 total pass.
This commit is contained in:
hermes-explorigin 2026-08-27 00:41:05 +00:00
parent 7e06c17bfa
commit 36d3625d61
4 changed files with 162 additions and 22 deletions

72
dist/index.html vendored
View File

@ -8208,6 +8208,42 @@ function AddLocationDialog($$anchor, $$props) {
}
delegate(["click", "input"]);
//#endregion
//#region src/lib/router.js
/**
* Lightweight hash router for the app's tab views.
*
* Tabs are routable via the URL hash so a user can share/copy a URL for a
* specific tab (e.g. #/radar) and use browser back/forward between tabs.
* There is no nested route structure — just a single top-level view segment.
*/
var VALID_VIEWS = [
"current",
"hourly",
"daily",
"detail",
"radar"
];
var DEFAULT_VIEW = "current";
/**
* Parse a location hash into a view id, falling back to the default view.
* Accepts '#/hourly', 'hourly', '#hourly', '', '#/unknown', etc.
*
* @param {string} [hash] - window.location.hash
* @returns {string} one of VALID_VIEWS
*/
function parseHash(hash = "") {
const seg = String(hash).replace(/^#\/?/, "").split("/")[0];
return VALID_VIEWS.includes(seg) ? seg : DEFAULT_VIEW;
}
/**
* Convert a view id into its URL hash representation.
* @param {string} view - one of VALID_VIEWS
* @returns {string} e.g. '#/radar'
*/
function viewToHash(view) {
return `#/${view}`;
}
//#endregion
//#region src/App.svelte
var root = /* @__PURE__ */ from_html(`<button class="sidebar-overlay svelte-1n46o8q" aria-label="Close sidebar"></button>`);
var root_1 = /* @__PURE__ */ from_html(`<span class="current-badge svelte-1n46o8q">📍 Current</span>`);
@ -8219,7 +8255,21 @@ var root_6 = /* @__PURE__ */ from_html(`<div class="mobile-nav svelte-1n46o8q"><
var root_7 = /* @__PURE__ */ from_html(`<div class="app-shell svelte-1n46o8q"><div class="mobile-header svelte-1n46o8q"><button class="btn-icon" aria-label="Toggle menu"> </button> <span class="mobile-title svelte-1n46o8q"> </span> <button class="btn-icon" aria-label="Settings" title="Settings">⚙️</button> <!></div> <!> <aside><!></aside> <main class="main-content svelte-1n46o8q"><div class="top-bar svelte-1n46o8q"><div class="top-bar-title svelte-1n46o8q"><h1 class="svelte-1n46o8q"> </h1> <!></div> <div class="top-bar-actions svelte-1n46o8q"><!></div></div> <!> <!> <!> <!></main> <!> <!></div>`);
function App($$anchor, $$props) {
push($$props, true);
let viewMode = /* @__PURE__ */ state("current");
let viewMode = /* @__PURE__ */ state(proxy(parseHash(typeof window !== "undefined" ? window.location.hash : "")));
/** Switch the active tab and reflect it in the URL hash. */
function setView(v) {
set(viewMode, v, true);
if (typeof window !== "undefined") window.location.hash = viewToHash(v);
}
user_effect(() => {
if (typeof window === "undefined") return;
const onHash = () => {
const v = parseHash(window.location.hash);
if (v !== get(viewMode)) set(viewMode, v, true);
};
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
});
let initDone = /* @__PURE__ */ state(false);
let geoError = /* @__PURE__ */ state("");
user_effect(() => {
@ -8449,16 +8499,16 @@ function App($$anchor, $$props) {
classes_9 = set_class(button_13, 1, "tab-btn svelte-1n46o8q", null, classes_9, { active: get(viewMode) === "detail" });
classes_10 = set_class(button_14, 1, "tab-btn svelte-1n46o8q", null, classes_10, { active: get(viewMode) === "radar" });
});
delegated("click", button_5, () => set(viewMode, "current"));
delegated("click", button_6, () => set(viewMode, "hourly"));
delegated("click", button_7, () => set(viewMode, "daily"));
delegated("click", button_8, () => set(viewMode, "detail"));
delegated("click", button_9, () => set(viewMode, "radar"));
delegated("click", button_10, () => set(viewMode, "current"));
delegated("click", button_11, () => set(viewMode, "hourly"));
delegated("click", button_12, () => set(viewMode, "daily"));
delegated("click", button_13, () => set(viewMode, "detail"));
delegated("click", button_14, () => set(viewMode, "radar"));
delegated("click", button_5, () => setView("current"));
delegated("click", button_6, () => setView("hourly"));
delegated("click", button_7, () => setView("daily"));
delegated("click", button_8, () => setView("detail"));
delegated("click", button_9, () => setView("radar"));
delegated("click", button_10, () => setView("current"));
delegated("click", button_11, () => setView("hourly"));
delegated("click", button_12, () => setView("daily"));
delegated("click", button_13, () => setView("detail"));
delegated("click", button_14, () => setView("radar"));
append($$anchor, fragment);
};
if_block(node_8, ($$render) => {

View File

@ -10,8 +10,32 @@
import NotificationBell from './components/NotificationBell.svelte'
import SettingsDialog from './components/SettingsDialog.svelte'
import AddLocationDialog from './components/AddLocationDialog.svelte'
import { parseHash, viewToHash } from './lib/router.js'
// Hash-routable tab: initialize from the URL hash so a shared link like
// #/radar opens straight to that tab.
let viewMode = $state(parseHash(typeof window !== 'undefined' ? window.location.hash : ''))
/** Switch the active tab and reflect it in the URL hash. */
function setView(v) {
viewMode = v
if (typeof window !== 'undefined') {
window.location.hash = viewToHash(v)
}
}
// Keep viewMode in sync with the hash — covers browser back/forward and a
// manually edited URL. Guarded so we never re-enter on the same value.
$effect(() => {
if (typeof window === 'undefined') return
const onHash = () => {
const v = parseHash(window.location.hash)
if (v !== viewMode) viewMode = v
}
window.addEventListener('hashchange', onHash)
return () => window.removeEventListener('hashchange', onHash)
})
let viewMode = $state('current') // 'current' | 'hourly' | 'daily' | 'detail' | 'radar'
let initDone = $state(false)
let geoError = $state('')
@ -186,37 +210,37 @@
<button
class="nav-btn"
class:active={viewMode === 'current'}
onclick={() => viewMode = 'current'}
onclick={() => setView('current')}
>Now</button>
<button
class="nav-btn"
class:active={viewMode === 'hourly'}
onclick={() => viewMode = 'hourly'}
onclick={() => setView('hourly')}
>Hourly</button>
<button
class="nav-btn"
class:active={viewMode === 'daily'}
onclick={() => viewMode = 'daily'}
onclick={() => setView('daily')}
>Daily</button>
<button
class="nav-btn"
class:active={viewMode === 'detail'}
onclick={() => viewMode = 'detail'}
onclick={() => setView('detail')}
>More</button>
<button
class="nav-btn"
class:active={viewMode === 'radar'}
onclick={() => viewMode = 'radar'}
onclick={() => setView('radar')}
>Radar</button>
</div>
<!-- Desktop tab bar -->
<div class="desktop-tabs">
<button class="tab-btn" class:active={viewMode === 'current'} onclick={() => viewMode = 'current'}>Current</button>
<button class="tab-btn" class:active={viewMode === 'hourly'} onclick={() => viewMode = 'hourly'}>Hourly</button>
<button class="tab-btn" class:active={viewMode === 'daily'} onclick={() => viewMode = 'daily'}>7-Day Forecast</button>
<button class="tab-btn" class:active={viewMode === 'detail'} onclick={() => viewMode = 'detail'}>Details</button>
<button class="tab-btn" class:active={viewMode === 'radar'} onclick={() => viewMode = 'radar'}>Radar</button>
<button class="tab-btn" class:active={viewMode === 'current'} onclick={() => setView('current')}>Current</button>
<button class="tab-btn" class:active={viewMode === 'hourly'} onclick={() => setView('hourly')}>Hourly</button>
<button class="tab-btn" class:active={viewMode === 'daily'} onclick={() => setView('daily')}>7-Day Forecast</button>
<button class="tab-btn" class:active={viewMode === 'detail'} onclick={() => setView('detail')}>Details</button>
<button class="tab-btn" class:active={viewMode === 'radar'} onclick={() => setView('radar')}>Radar</button>
</div>
<!-- Content area -->

32
src/lib/router.js Normal file
View File

@ -0,0 +1,32 @@
/**
* Lightweight hash router for the app's tab views.
*
* Tabs are routable via the URL hash so a user can share/copy a URL for a
* specific tab (e.g. #/radar) and use browser back/forward between tabs.
* There is no nested route structure just a single top-level view segment.
*/
export const VALID_VIEWS = ['current', 'hourly', 'daily', 'detail', 'radar']
export const DEFAULT_VIEW = 'current'
/**
* Parse a location hash into a view id, falling back to the default view.
* Accepts '#/hourly', 'hourly', '#hourly', '', '#/unknown', etc.
*
* @param {string} [hash] - window.location.hash
* @returns {string} one of VALID_VIEWS
*/
export function parseHash(hash = '') {
const clean = String(hash).replace(/^#\/?/, '')
const seg = clean.split('/')[0]
return VALID_VIEWS.includes(seg) ? seg : DEFAULT_VIEW
}
/**
* Convert a view id into its URL hash representation.
* @param {string} view - one of VALID_VIEWS
* @returns {string} e.g. '#/radar'
*/
export function viewToHash(view) {
return `#/${view}`
}

34
tests/lib/router.test.js Normal file
View File

@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { parseHash, viewToHash, VALID_VIEWS, DEFAULT_VIEW } from '../../src/lib/router.js'
describe('parseHash', () => {
it('parses a standard hash route', () => {
expect(parseHash('#/hourly')).toBe('hourly')
expect(parseHash('#/radar')).toBe('radar')
})
it('handles a bare segment without a leading slash', () => {
expect(parseHash('daily')).toBe('daily')
expect(parseHash('#daily')).toBe('daily')
})
it('falls back to the default view for empty or unknown hashes', () => {
expect(parseHash('')).toBe(DEFAULT_VIEW)
expect(parseHash('#')).toBe(DEFAULT_VIEW)
expect(parseHash('#/nope')).toBe(DEFAULT_VIEW)
expect(parseHash('#/current')).toBe('current')
})
it('ignores a trailing path after the view segment', () => {
expect(parseHash('#/detail/sub')).toBe('detail')
})
})
describe('viewToHash', () => {
it('round-trips a valid view', () => {
for (const v of VALID_VIEWS) {
expect(viewToHash(v)).toBe(`#/${v}`)
expect(parseHash(viewToHash(v))).toBe(v)
}
})
})