"use client"; import { useCallback, useMemo, useRef, useEffect } from "react"; /** URL param keys */ const PARAM_NODE = "node"; const PARAM_PROFILE = "profile"; const PARAM_PANEL = "panel"; const PARAM_EPIC = "epic"; const PARAM_COLOR = "color"; const PARAM_COLLAPSED = "collapsed"; const PARAM_FILTERS = "filters"; const PARAM_SELECTED = "selected"; const PARAM_REPLAY = "replay"; const PARAM_TIME_FILTER = "timeFilter"; const PARAM_DESC = "desc"; type PanelType = "comments" | "activity" | "tasks" | "help" | "leaderboard" | null; type ColorMode = "status" | "priority" | "type" | "owner" | "assignee" | "prefix"; type TimeFilter = "all" | "month" | "week" | "yesterday"; const VALID_PANELS = new Set(["comments", "activity", "tasks", "help", "leaderboard"]); const VALID_COLORS = new Set(["status", "priority", "type", "owner", "assignee", "prefix"]); const VALID_TIME_FILTERS = new Set(["all", "month", "week", "yesterday"]); export interface UrlState { node: string | null; profile: string | null; panel: PanelType; epic: string | null; color: ColorMode; collapsed: Set; filters: Set; selected: Set; replay: boolean; timeFilter: TimeFilter; desc: string | null; } export interface UseUrlStateReturn extends UrlState { setNode: (id: string | null) => void; setProfile: (handle: string | null) => void; setPanel: (panel: PanelType) => void; setEpic: (id: string | null) => void; setColor: (mode: ColorMode) => void; setCollapsed: (ids: Set) => void; setFilters: (labels: Set) => void; setSelected: (ids: Set) => void; setReplay: (active: boolean) => void; setTimeFilter: (filter: TimeFilter) => void; setDesc: (id: string | null) => void; clear: () => void; /** Read initial state from URL on mount (call once) */ initial: UrlState; } function readUrlState(): UrlState { if (typeof window === "undefined") { return { node: null, profile: null, panel: null, epic: null, color: "status", collapsed: new Set(), filters: new Set(), selected: new Set(), replay: false, timeFilter: "all", desc: null }; } const params = new URLSearchParams(window.location.search); const panel = params.get(PARAM_PANEL); const color = params.get(PARAM_COLOR); const collapsed = params.get(PARAM_COLLAPSED); const filters = params.get(PARAM_FILTERS); const selected = params.get(PARAM_SELECTED); const replay = params.get(PARAM_REPLAY); const timeFilter = params.get(PARAM_TIME_FILTER); return { node: params.get(PARAM_NODE) || null, profile: params.get(PARAM_PROFILE) || null, panel: panel && VALID_PANELS.has(panel) ? (panel as PanelType) : null, epic: params.get(PARAM_EPIC) || null, color: color && VALID_COLORS.has(color) ? (color as ColorMode) : "status", collapsed: collapsed ? new Set(collapsed.split(",").filter(Boolean)) : new Set(), filters: filters ? new Set(filters.split(",").filter(Boolean)) : new Set(), selected: selected ? new Set(selected.split(",").filter(Boolean)) : new Set(), replay: replay === "1", timeFilter: timeFilter && VALID_TIME_FILTERS.has(timeFilter) ? (timeFilter as TimeFilter) : "all", desc: params.get(PARAM_DESC) || null, }; } function writeUrlState(state: Partial) { const params = new URLSearchParams(window.location.search); // Helper: set or delete a param const setParam = (key: string, value: string | null | undefined) => { if (value) params.set(key, value); else params.delete(key); }; if ("node" in state) setParam(PARAM_NODE, state.node); if ("profile" in state) setParam(PARAM_PROFILE, state.profile); if ("panel" in state) setParam(PARAM_PANEL, state.panel); if ("epic" in state) setParam(PARAM_EPIC, state.epic); if ("color" in state) { // Only write color if not default setParam(PARAM_COLOR, state.color === "status" ? null : state.color); } if ("collapsed" in state) { const val = state.collapsed && state.collapsed.size > 0 ? [...state.collapsed].join(",") : null; setParam(PARAM_COLLAPSED, val); } if ("filters" in state) { const val = state.filters && state.filters.size > 0 ? [...state.filters].join(",") : null; setParam(PARAM_FILTERS, val); } if ("selected" in state) { const val = state.selected && state.selected.size > 0 ? [...state.selected].join(",") : null; setParam(PARAM_SELECTED, val); } if ("replay" in state) { setParam(PARAM_REPLAY, state.replay ? "1" : null); } if ("timeFilter" in state) { // Only write timeFilter if not default setParam(PARAM_TIME_FILTER, state.timeFilter === "all" ? null : state.timeFilter); } if ("desc" in state) setParam(PARAM_DESC, state.desc); // Enforce mutual exclusivity: node, profile, panel are mutually exclusive // If node is being set, clear profile, panel, and desc if ("node" in state && state.node) { params.delete(PARAM_PROFILE); params.delete(PARAM_PANEL); params.delete(PARAM_DESC); } // If profile is being set, clear node, panel, and desc if ("profile" in state && state.profile) { params.delete(PARAM_NODE); params.delete(PARAM_PANEL); params.delete(PARAM_DESC); } // If panel is being set, clear node, profile, and desc if ("panel" in state && state.panel) { params.delete(PARAM_NODE); params.delete(PARAM_PROFILE); params.delete(PARAM_DESC); } const search = params.toString(); const url = search ? `?${search}` : window.location.pathname; window.history.replaceState(null, "", url); } export function useUrlState(): UseUrlStateReturn { const initialRef = useRef(readUrlState()); const setNode = useCallback((id: string | null) => { writeUrlState({ node: id, profile: null, panel: null }); }, []); const setProfile = useCallback((handle: string | null) => { writeUrlState({ profile: handle, node: null, panel: null }); }, []); const setPanel = useCallback((panel: PanelType) => { writeUrlState({ panel, node: null, profile: null }); }, []); const setEpic = useCallback((id: string | null) => { writeUrlState({ epic: id }); }, []); const setColor = useCallback((mode: ColorMode) => { writeUrlState({ color: mode }); }, []); const setCollapsed = useCallback((ids: Set) => { writeUrlState({ collapsed: ids }); }, []); const setFilters = useCallback((labels: Set) => { writeUrlState({ filters: labels }); }, []); const setSelected = useCallback((ids: Set) => { writeUrlState({ selected: ids }); }, []); const setReplay = useCallback((active: boolean) => { writeUrlState({ replay: active }); }, []); const setTimeFilter = useCallback((filter: TimeFilter) => { writeUrlState({ timeFilter: filter }); }, []); const setDesc = useCallback((id: string | null) => { writeUrlState({ desc: id }); }, []); const clear = useCallback(() => { window.history.replaceState(null, "", window.location.pathname); }, []); // Read current state (the initial snapshot — after that, page.tsx state is the source of truth) const current = useMemo(() => readUrlState(), []); return { ...current, initial: initialRef.current, setNode, setProfile, setPanel, setEpic, setColor, setCollapsed, setFilters, setSelected, setReplay, setTimeFilter, setDesc, clear, }; }