/** * Two-way sync between app state and the address bar, so a refresh (or a shared * link) restores the same view, color filter, and color-brush mode. * * path → the active tab: / /week /month (day is the default, shown as /) * ?color= → the color filter: ?color=blue * ?brush= → color-brush mode: ?brush=orange (or ?brush=erase for "no color") * ?panel= → an open modal: ?panel=controls ?panel=credits ?panel=settings */ import { todayKey } from "./date"; import type { TaskColor } from "./types"; export type View = "week" | "month" | "day" | "3day"; export type Panel = "controls" | "credits" | "settings"; const VIEWS: View[] = ["week", "month", "day", "3day"]; const PANELS: Panel[] = ["controls", "credits", "settings"]; export interface UrlState { view: View; /** ISO day key (YYYY-MM-DD) the view is anchored on. The day view puts it in the path (as /day/, unless it's today — that stays "/"), so the link is refresh-safe and shareable. */ anchor: string; color: TaskColor; brush: { on: boolean; color: TaskColor }; panel: Panel | null; } // Palette ids are user-defined now (see palette.ts), so any non-empty string // is accepted here — the store resolves it against the live palette (and // falls back gracefully if it doesn't match an active color; see App.svelte). function isColor(v: string | null): v is Exclude { return v !== null && v.length > 0; } /** * True while an ATProto OAuth callback is sitting in the address bar (the auth * server redirected back with `code`/`state`, or an `error`). The OAuth client * reads these on init and then cleans them up itself, so we must NOT rewrite the * URL before that happens — doing so drops the callback and the sign-in is lost. * * The OAuth client is configured with `responseMode: "query"`, so the callback * arrives in the query string (`?code=…&state=…`) — but the hash is still * checked: earlier client generations used `response_mode=fragment`, and a * stale tab or a bookmarked callback can still land one there, where a * `replaceState` would silently drop it. */ export function hasPendingOAuthCallback(): boolean { const looksLikeCallback = (qs: string) => { const p = new URLSearchParams(qs); return p.has("state") && (p.has("code") || p.has("iss") || p.has("error")); }; return ( looksLikeCallback(window.location.search) || looksLikeCallback(window.location.hash.replace(/^#/, "")) ); } /** Read whatever the current URL specifies. Missing pieces are left undefined. */ export function readUrl(): Partial { const out: Partial = {}; const segs = window.location.pathname.replace(/^\/+|\/+$/g, "").split("/"); if (VIEWS.includes(segs[0] as View)) out.view = segs[0] as View; // The day and 3-day views carry their focused date as a second path segment // (/day/2026-07-16, /3day/2026-07-16). Ignore a malformed date and fall back // to today. if ( (out.view === "day" || out.view === "3day") && /^\d{4}-\d{2}-\d{2}$/.test(segs[1] ?? "") ) { out.anchor = segs[1]; } const params = new URLSearchParams(window.location.search); const color = params.get("color"); if (isColor(color)) out.color = color; const brush = params.get("brush"); if (brush !== null) { out.brush = { on: true, color: isColor(brush) ? brush : null }; } const panel = params.get("panel"); if (panel !== null && (PANELS as string[]).includes(panel)) out.panel = panel as Panel; return out; } /** Reflect the current app state into the URL (without adding history entries). */ export function writeUrl(s: UrlState) { const params = new URLSearchParams(); if (s.color) params.set("color", s.color); if (s.brush.on) params.set("brush", s.brush.color ?? "erase"); if (s.panel) params.set("panel", s.panel); const qs = params.toString(); // Day is the default tab, shown at "/". A day anchored on a date other than // today keeps its date in the path (/day/) so the link is shareable; // today's day view collapses to a bare "/". The 3-day view follows the same // collapse-to-default logic, but keeps its "/3day" prefix either way. const path = s.view === "day" ? s.anchor === todayKey() ? "/" : `/day/${s.anchor}` : s.view === "3day" ? s.anchor === todayKey() ? "/3day" : `/3day/${s.anchor}` : `/${s.view}`; const next = `${path}${qs ? `?${qs}` : ""}`; const current = window.location.pathname + window.location.search; if (current !== next) window.history.replaceState(null, "", next); }