@prmichaelsen/acp-visualizer 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prmichaelsen/acp-visualizer",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "type": "module",
5
5
  "description": "Browser-based dashboard for visualizing ACP progress.yaml data",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- import { createContext, useContext, useState, ReactNode } from 'react'
1
+ import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
2
2
 
3
3
  type PanelContent =
4
4
  | { type: 'milestone'; id: string }
@@ -22,6 +22,7 @@ const SidePanelContext = createContext<SidePanelContextValue | undefined>(undefi
22
22
  const MIN_WIDTH = 300
23
23
  const DEFAULT_WIDTH = 500
24
24
  const STORAGE_KEY = 'acp-visualizer.side-panel-size'
25
+ const PANEL_PARAM = 'panel'
25
26
 
26
27
  function loadWidth(): number {
27
28
  if (typeof window === 'undefined') return DEFAULT_WIDTH
@@ -33,36 +34,91 @@ function loadWidth(): number {
33
34
  return DEFAULT_WIDTH
34
35
  }
35
36
 
37
+ /** Serialize panel content to a URL search param value */
38
+ function serializePanel(content: PanelContent): string | null {
39
+ if (!content) return null
40
+ switch (content.type) {
41
+ case 'milestone': return `milestone:${content.id}`
42
+ case 'task': return `task:${content.id}`
43
+ case 'document': return `doc:${content.dirPath}:${content.slug}`
44
+ }
45
+ }
46
+
47
+ /** Parse panel content from a URL search param value */
48
+ function deserializePanel(value: string | null): PanelContent {
49
+ if (!value) return null
50
+ if (value.startsWith('milestone:')) {
51
+ return { type: 'milestone', id: value.slice('milestone:'.length) }
52
+ }
53
+ if (value.startsWith('task:')) {
54
+ return { type: 'task', id: value.slice('task:'.length) }
55
+ }
56
+ if (value.startsWith('doc:')) {
57
+ const rest = value.slice('doc:'.length)
58
+ const colonIdx = rest.indexOf(':')
59
+ if (colonIdx === -1) return null
60
+ return { type: 'document', dirPath: rest.slice(0, colonIdx), slug: rest.slice(colonIdx + 1) }
61
+ }
62
+ return null
63
+ }
64
+
65
+ /** Read initial panel state from URL on first render */
66
+ function loadPanelFromUrl(): PanelContent {
67
+ if (typeof window === 'undefined') return null
68
+ const params = new URLSearchParams(window.location.search)
69
+ return deserializePanel(params.get(PANEL_PARAM))
70
+ }
71
+
72
+ /** Update the panel search param without pushing to browser history */
73
+ function syncPanelToUrl(content: PanelContent) {
74
+ if (typeof window === 'undefined') return
75
+ const url = new URL(window.location.href)
76
+ const serialized = serializePanel(content)
77
+ if (serialized) {
78
+ url.searchParams.set(PANEL_PARAM, serialized)
79
+ } else {
80
+ url.searchParams.delete(PANEL_PARAM)
81
+ }
82
+ window.history.replaceState(window.history.state, '', url.toString())
83
+ }
84
+
36
85
  export function SidePanelProvider({ children }: { children: ReactNode }) {
37
- const [content, setContent] = useState<PanelContent>(null)
38
- const [isOpen, setIsOpen] = useState(false)
86
+ const [content, setContent] = useState<PanelContent>(loadPanelFromUrl)
87
+ const [isOpen, setIsOpen] = useState(() => loadPanelFromUrl() !== null)
39
88
  const [width, setWidthState] = useState(loadWidth)
40
89
 
41
- const openMilestone = (id: string) => {
42
- setContent({ type: 'milestone', id })
90
+ const openMilestone = useCallback((id: string) => {
91
+ const panel: PanelContent = { type: 'milestone', id }
92
+ setContent(panel)
43
93
  setIsOpen(true)
44
- }
94
+ syncPanelToUrl(panel)
95
+ }, [])
45
96
 
46
- const openTask = (id: string) => {
47
- setContent({ type: 'task', id })
97
+ const openTask = useCallback((id: string) => {
98
+ const panel: PanelContent = { type: 'task', id }
99
+ setContent(panel)
48
100
  setIsOpen(true)
49
- }
101
+ syncPanelToUrl(panel)
102
+ }, [])
50
103
 
51
- const openDocument = (dirPath: string, slug: string) => {
52
- setContent({ type: 'document', dirPath, slug })
104
+ const openDocument = useCallback((dirPath: string, slug: string) => {
105
+ const panel: PanelContent = { type: 'document', dirPath, slug }
106
+ setContent(panel)
53
107
  setIsOpen(true)
54
- }
108
+ syncPanelToUrl(panel)
109
+ }, [])
55
110
 
56
- const close = () => {
111
+ const close = useCallback(() => {
57
112
  setIsOpen(false)
113
+ syncPanelToUrl(null)
58
114
  setTimeout(() => setContent(null), 300) // Wait for animation
59
- }
115
+ }, [])
60
116
 
61
- const setWidth = (newWidth: number) => {
117
+ const setWidth = useCallback((newWidth: number) => {
62
118
  const clamped = Math.max(MIN_WIDTH, newWidth)
63
119
  setWidthState(clamped)
64
120
  localStorage.setItem(STORAGE_KEY, String(clamped))
65
- }
121
+ }, [])
66
122
 
67
123
  return (
68
124
  <SidePanelContext.Provider value={{ content, isOpen, width, openMilestone, openTask, openDocument, close, setWidth }}>