Skip to content

feat: persist board filters locally and sync custom date ranges with share URLs (#4233)#4303

Open
abhinavlevi wants to merge 1 commit into
umami-software:masterfrom
abhinavlevi:feature/save-board-filters
Open

feat: persist board filters locally and sync custom date ranges with share URLs (#4233)#4303
abhinavlevi wants to merge 1 commit into
umami-software:masterfrom
abhinavlevi:feature/save-board-filters

Conversation

@abhinavlevi

@abhinavlevi abhinavlevi commented May 28, 2026

Copy link
Copy Markdown

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:

  1. No Layout Persistence: Navigating away or refreshing the page immediately drops the user's filtered state back to universal defaults, disrupting active workflows.
  2. Desynced Shared Views: Creating a public dashboard share link does not capture or pass down the active filter states configured by the author, causing shared views to display generic defaults.

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:

  1. Per-Board Local State Isolation: We introduced a scoped setter action inside the global store that hooks into the storage engine. It targets configurations using explicit board/website scopes (DATE_RANGE_CONFIG:${id}), allowing unique configurations per board while leaving the standard application defaults intact.
  2. URL Payload Serialization: Share buttons and table link constructors now dynamically serialize and append active states into query string parameters (?range=).
  3. Automatic Mount Hydration: Page layouts hook into Next.js navigation blocks to capture parameter states or fallback storage profiles upon mounting, restoring users or external visitors to the exact intended data scope.

Changes Breakdown

1. Global State Management Strategy

  • File: src/store/app.ts
  • Changes: Introduced the setBoardDateRangeValue(dateRangeValue, boardId) action to write specific view adjustments into localized storage blocks without changing standard application initialization blocks.

2. Layout Hydration Hook

  • File: src/app/(main)/websites/[websiteId]/page.tsx
  • Changes: Set up an isolated useEffect hydration block at the layout boundary. It checks parameters via useSearchParams first (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

  • File: src/components/pages/websites/BoardShareButton.tsx
  • Changes: Extracted active context states using the useApp hook directly at the trigger level to thread current choices seamlessly down to lower children.
  • File: src/components/pages/websites/BoardShareDialog.tsx
  • Changes: Reconfigured component signatures to cleanly propagate data strings and object parameters straight into the datatable arrays.
  • File: src/components/pages/websites/BoardSharesTable.tsx
  • Changes: Adjusted path builders to verify configuration formats (raw string values or structural query blocks), handle stringification safety checks, and append clean encodeURIComponent flags to generated URLs.

Verification & Testing Checklist

  • Changing filters on Board A does not affect or reset Board B when navigating between views.
  • Refreshing the web browser completely preserves active layout filter conditions.
  • Share link generation appends parameters cleanly (?range=30d).
  • Opening a share link instantly renders dashboards using the custom encoded configuration values.

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled.

@greptile-apps

greptile-apps Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds per-board date-range persistence (localStorage) and encodes the active date range into share URLs via a ?range= query parameter. The share-URL pipeline in the three boards/[boardId] files is clean, but the hydration layer in src/app/(main)/websites/page.tsx and the new store action have blocking correctness problems.

  • src/app/(main)/websites/page.tsx is a Next.js Server Component (it exports metadata) but now calls useEffect and useSearchParams — client-only hooks — which will fail at runtime. Even if patched to \"use client\", this route has no [websiteId] segment so params?.websiteId is always undefined and the hydration effect is entirely dead code.
  • setBoardDateRangeValue writes a scoped localStorage key per board but also calls store.setState({ dateRangeValue }) on the single global Zustand state, so whichever board mounts last overwrites all others — the per-board isolation goal is not achieved.
  • getItem already JSON.parses its return value, so when a custom date-range object is stored, savedRange is a plain object and savedRange.startsWith('{') throws TypeError at runtime.

Confidence Score: 2/5

Not 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

Filename Overview
src/app/(main)/websites/page.tsx Adds client hooks and params to a Server Component that also exports metadata — mutually exclusive in Next.js App Router; the route also has no [websiteId] segment so the hydration effect is dead code.
src/store/app.ts Adds setBoardDateRangeValue which persists a scoped localStorage key but still mutates the single global dateRangeValue state, defeating per-board isolation; type signature is also too narrow.
src/app/(main)/boards/[boardId]/BoardSharesTable.tsx Adds dateRangeValue prop and appends it as an encoded ?range= query param to share URLs; logic is straightforward with correct encodeURIComponent usage.
src/app/(main)/boards/[boardId]/BoardShareButton.tsx Reads the global dateRangeValue from the app store and threads it down to BoardShareDialog; missing trailing newline.
src/app/(main)/boards/[boardId]/BoardShareDialog.tsx Propagates the new dateRangeValue prop through to BoardSharesTable; structural pass-through with no logic issues.

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (1): Last reviewed commit: "feat: persist board filters and sync dat..." | Re-trigger Greptile

Comment on lines 1 to +41
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 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.

Comment thread src/store/app.ts
Comment on lines +55 to +59
export function setBoardDateRangeValue(dateRangeValue: string, boardId: string) {
store.setState({ dateRangeValue });
if (boardId) {
setItem(`${DATE_RANGE_CONFIG}:${boardId}`, dateRangeValue);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +26 to +33
} else if (savedRange) {
try {
const parsedRange = savedRange.startsWith('{') ? JSON.parse(savedRange) : savedRange;
setDateRangeValue(parsedRange);
} catch {
setDateRangeValue(savedRange);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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".

Comment thread src/store/app.ts

export const useApp = store;
// Added scoped board setter to handle unique board filter persistence
export function setBoardDateRangeValue(dateRangeValue: string, boardId: string) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
export function setBoardDateRangeValue(dateRangeValue: string, boardId: string) {
export function setBoardDateRangeValue(dateRangeValue: string | object, boardId: string) {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant