feat: persist board filters locally and sync custom date ranges with share URLs (#4233)#4303
Conversation
Greptile SummaryThis PR adds per-board date-range persistence (localStorage) and encodes the active date range into share URLs via a
Confidence Score: 2/5Not safe to merge — the hydration feature targets the wrong file and the hook/metadata conflict will cause a build or runtime failure. The hydration change in websites/page.tsx simultaneously violates Next.js Server Component rules and targets a route that has no [websiteId] segment, making the feature's core path completely inoperative. The store action compounds this by overwriting shared global state on every board mount. src/app/(main)/websites/page.tsx needs to be moved to the correct route and split into a client/server pair; src/store/app.ts needs scoped state so per-board ranges do not contaminate global state. Important Files Changed
Sequence DiagramsequenceDiagram
participant U as User
participant BSB as BoardShareButton
participant BSD as BoardShareDialog
participant BST as BoardSharesTable
participant Store as Zustand Store
participant LS as localStorage
U->>BSB: clicks Share
BSB->>Store: "useApp(state => state.dateRangeValue)"
Store-->>BSB: dateRangeValue (global)
BSB->>BSD: dateRangeValue prop
BSD->>BST: dateRangeValue prop
BST->>BST: "getUrl — appends ?range=encodeURIComponent(dateRangeValue)"
BST-->>U: "share URL with ?range="
Note over Store,LS: On board page mount (websites/page.tsx — wrong file)
U->>Store: setBoardDateRangeValue(range, boardId)
Store->>Store: setState — overwrites GLOBAL dateRangeValue
Store->>LS: setItem(umami.date-range:boardId, range)
Note over Store: All boards now see the same global range
Reviews (1): Last reviewed commit: "feat: persist board filters and sync dat..." | Re-trigger Greptile |
| import type { Metadata } from 'next'; | ||
| import { useEffect } from 'react'; | ||
| import { useSearchParams } from 'next/navigation'; | ||
| import { setDateRangeValue, setBoardDateRangeValue } from '@/store/app'; | ||
| import { getItem } from '@/lib/storage'; | ||
| import { DATE_RANGE_CONFIG } from '@/lib/constants'; | ||
| import { WebsitesPage } from './WebsitesPage'; | ||
|
|
||
| export default function () { | ||
| export default function Page({ params }: { params: { websiteId?: string } }) { | ||
| const websiteId = params?.websiteId; | ||
| const searchParams = useSearchParams(); | ||
|
|
||
| useEffect(() => { | ||
| if (!websiteId) return; | ||
|
|
||
| const urlRange = searchParams.get('range'); | ||
| const savedRange = getItem(`${DATE_RANGE_CONFIG}:${websiteId}`); | ||
|
|
||
| if (urlRange) { | ||
| try { | ||
| const parsedRange = urlRange.startsWith('{') ? JSON.parse(urlRange) : urlRange; | ||
| setBoardDateRangeValue(parsedRange, websiteId); | ||
| } catch { | ||
| setBoardDateRangeValue(urlRange, websiteId); | ||
| } | ||
| } else if (savedRange) { | ||
| try { | ||
| const parsedRange = savedRange.startsWith('{') ? JSON.parse(savedRange) : savedRange; | ||
| setDateRangeValue(parsedRange); | ||
| } catch { | ||
| setDateRangeValue(savedRange); | ||
| } | ||
| } | ||
| }, [websiteId, searchParams]); | ||
|
|
||
| return <WebsitesPage />; | ||
| } | ||
|
|
||
| export const metadata: Metadata = { | ||
| title: 'Websites', | ||
| }; | ||
| }; No newline at end of file |
There was a problem hiding this comment.
Server Component using client-only hooks with conflicting
metadata export
This file is src/app/(main)/websites/page.tsx — the websites listing page at route /websites. In Next.js App Router, pages that export metadata are Server Components and cannot use hooks. This component now imports and calls useEffect and useSearchParams, which are client-only hooks. Adding "use client" would fix the hook errors but would then break the metadata export, since client components cannot export metadata. These two requirements are mutually exclusive in a single file.
Additionally, the route /websites has no [websiteId] path segment, so params?.websiteId will always be undefined, the guard if (!websiteId) return triggers on every render, and the entire hydration effect is dead code. Based on the PR description, this logic was intended for src/app/(main)/websites/[websiteId]/page.tsx (or the boards equivalent), not this file.
| export function setBoardDateRangeValue(dateRangeValue: string, boardId: string) { | ||
| store.setState({ dateRangeValue }); | ||
| if (boardId) { | ||
| setItem(`${DATE_RANGE_CONFIG}:${boardId}`, dateRangeValue); | ||
| } |
There was a problem hiding this comment.
Global state mutation breaks per-board isolation
setBoardDateRangeValue calls store.setState({ dateRangeValue }), which overwrites the single global dateRangeValue in the Zustand store. This directly contradicts the stated goal of per-board isolation. If a user visits Board A (triggering a range hydration of 7d), then navigates to Board B (triggering 30d), then returns to Board A, the global state will still read 30d — the last board to mount wins. All boards and all other date-range-aware components share this one value, so any board that loads after another will silently overwrite the previous board's intended range.
| } else if (savedRange) { | ||
| try { | ||
| const parsedRange = savedRange.startsWith('{') ? JSON.parse(savedRange) : savedRange; | ||
| setDateRangeValue(parsedRange); | ||
| } catch { | ||
| setDateRangeValue(savedRange); | ||
| } | ||
| } |
There was a problem hiding this comment.
getItem returns a parsed value — calling .startsWith on an object throws at runtime
getItem in src/lib/storage.ts always calls JSON.parse internally before returning. When a custom date range object (e.g., { startDate, endDate }) was stored, getItem returns a plain JavaScript object, not a JSON string. Calling .startsWith('{') on that object throws TypeError: savedRange.startsWith is not a function at runtime. The guard only works correctly when the stored value is a primitive string like "30d".
|
|
||
| export const useApp = store; | ||
| // Added scoped board setter to handle unique board filter persistence | ||
| export function setBoardDateRangeValue(dateRangeValue: string, boardId: string) { |
There was a problem hiding this comment.
The parameter type is declared as
string but callers pass the already-parsed result of JSON.parse(urlRange), which can be a plain object. The type should be widened to match actual usage.
| export function setBoardDateRangeValue(dateRangeValue: string, boardId: string) { | |
| export function setBoardDateRangeValue(dateRangeValue: string | object, boardId: string) { |
Context & Problem
Currently, dashboard filters and date ranges inside the application map to a shared, universal global state context (
dateRangeValue). This introduces two primary limitations highlighted in issue #4233:Proposed Solution & Architecture
This PR resolves the issue without introducing database schema mutations or scattering breaking changes across the broader state architecture. Instead, it utilizes scoped local persistence alongside URL state hydration:
DATE_RANGE_CONFIG:${id}), allowing unique configurations per board while leaving the standard application defaults intact.?range=).Changes Breakdown
1. Global State Management Strategy
src/store/app.tssetBoardDateRangeValue(dateRangeValue, boardId)action to write specific view adjustments into localized storage blocks without changing standard application initialization blocks.2. Layout Hydration Hook
src/app/(main)/websites/[websiteId]/page.tsxuseEffecthydration block at the layout boundary. It checks parameters viauseSearchParamsfirst (to prioritize direct links), falls back to the scoped local token if present, parses serialization schemas safely, and updates the view state on component mount.3. Share Pipeline Upgrades
src/components/pages/websites/BoardShareButton.tsxuseApphook directly at the trigger level to thread current choices seamlessly down to lower children.src/components/pages/websites/BoardShareDialog.tsxsrc/components/pages/websites/BoardSharesTable.tsxencodeURIComponentflags to generated URLs.Verification & Testing Checklist
?range=30d).Need help on this PR? Tag
@codesmithwith what you need. Autofix is disabled.