From f5bcd4b1cf012f2ab5deab49efa9a7fa40a41463 Mon Sep 17 00:00:00 2001 From: Lupita Bot <263059171+lupita-hom@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:28:07 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20full=20URL=20deep=20linking=20?= =?UTF-8?q?=E2=80=94=20all=20view=20state=20synced=20to=20URL=20params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy/paste any URL to recreate the exact same view with same repo, date range, filters, sorting, and selections. URL schema: - Global: ?range=1w (or range=custom&since=2026-01-14&until=2026-04-15) - Commit Graph: &branches=main,dev - Network Graph: &branches=main,dev - Pull Requests: &state=OPEN&sort=newest&author=homeles&reviewer=octocat - Code Frequency: &tab=timeseries&path=src/ - Branches: &sort=updated&tab=branches&q=fix Implementation: - New useUpdateSearchParams() hook for merging params without clobbering - New useDateRangeParams() for bidirectional context↔URL sync with echo prevention - All pages init state from URL params (lazy useState) - Defaults omitted from URL to keep it clean - AppLayout preserves date range params when switching repos/views - Sidebar nav links preserve query string --- client/src/hooks/useUrlParams.ts | 100 +++++++++++++++++++++++++ client/src/pages/AppLayout.tsx | 22 ++++-- client/src/pages/BranchesPage.tsx | 31 +++++++- client/src/pages/CodeFrequencyPage.tsx | 22 +++++- client/src/pages/GraphPage.tsx | 20 ++++- client/src/pages/NetworkPage.tsx | 22 +++++- client/src/pages/PullRequestsPage.tsx | 37 +++++++-- 7 files changed, 231 insertions(+), 23 deletions(-) create mode 100644 client/src/hooks/useUrlParams.ts diff --git a/client/src/hooks/useUrlParams.ts b/client/src/hooks/useUrlParams.ts new file mode 100644 index 0000000..6cf5291 --- /dev/null +++ b/client/src/hooks/useUrlParams.ts @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useSearchParams } from 'react-router'; +import { useDateRange } from '../contexts/DateRangeContext.js'; +import type { TimeRange } from '../contexts/DateRangeContext.js'; + +/** + * Returns a stable `updateParams` function that merges specific search params + * into the current URL without replacing unrelated params. + * Pass `null` as a value to delete a param. + */ +export function useUpdateSearchParams() { + const [searchParams, setSearchParams] = useSearchParams(); + const update = useCallback( + (updates: Record) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + for (const [key, value] of Object.entries(updates)) { + if (value === null || value === '') next.delete(key); + else next.set(key, value); + } + return next; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + return { searchParams, updateParams: update }; +} + +const VALID_RANGES: TimeRange[] = ['1w', '2w', '1m', '3m', '6m', '1y', 'all', 'custom']; + +/** + * Syncs the DateRangeContext with URL search params (`range`, `since`, `until`). + * Call this in each page that renders a DateRangePicker. + * + * - On mount: reads URL params → sets context (if present). + * - On context change: writes URL params (skips the echo-back after URL→context init). + * - Default value (`1w`) is omitted from the URL to keep links clean. + */ +export function useDateRangeParams() { + const { + timeRange, + setTimeRange, + customSince, + setCustomSince, + customUntil, + setCustomUntil, + } = useDateRange(); + const { updateParams } = useUpdateSearchParams(); + + // mountedRef: set to true after the init effect runs (both effects share this render cycle). + const mountedRef = useRef(false); + // skipUrlWriteRef: set when we triggered a context change from URL so we skip echoing it back. + const skipUrlWriteRef = useRef(false); + + // Effect 1: URL → context (mount only) + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const range = params.get('range') as TimeRange | null; + if (range && VALID_RANGES.includes(range)) { + skipUrlWriteRef.current = true; + setTimeRange(range); + if (range === 'custom') { + const since = params.get('since'); + const until = params.get('until'); + if (since) setCustomSince(since); + if (until) setCustomUntil(until); + } + } + mountedRef.current = true; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Effect 2: context → URL (runs after every context change) + useEffect(() => { + if (!mountedRef.current) return; + // Skip the write triggered by our own URL→context init to avoid an echo loop. + if (skipUrlWriteRef.current) { + skipUrlWriteRef.current = false; + return; + } + const updates: Record = {}; + if (timeRange === '1w') { + // Default — omit from URL to keep it clean + updates.range = null; + updates.since = null; + updates.until = null; + } else if (timeRange === 'custom') { + updates.range = 'custom'; + updates.since = customSince || null; + updates.until = customUntil || null; + } else { + updates.range = timeRange; + updates.since = null; + updates.until = null; + } + updateParams(updates); + }, [timeRange, customSince, customUntil]); // eslint-disable-line react-hooks/exhaustive-deps +} diff --git a/client/src/pages/AppLayout.tsx b/client/src/pages/AppLayout.tsx index 5c76aed..00e8708 100644 --- a/client/src/pages/AppLayout.tsx +++ b/client/src/pages/AppLayout.tsx @@ -78,7 +78,15 @@ export default function AppLayout() { setSearchQuery(''); const suffixes = ['/network', '/pulls', '/branches', '/settings', '/code-frequency']; const currentSuffix = suffixes.find((s) => location.pathname.endsWith(s)) ?? ''; - void navigate(`/app/repo/${repo.owner.login}/${repo.name}${currentSuffix}`); + // Preserve date-range params when switching repos; drop repo-specific params (branches, etc.) + const currentParams = new URLSearchParams(location.search); + const newParams = new URLSearchParams(); + for (const key of ['range', 'since', 'until']) { + const val = currentParams.get(key); + if (val) newParams.set(key, val); + } + const qs = newParams.toString() ? `?${newParams.toString()}` : ''; + void navigate(`/app/repo/${repo.owner.login}/${repo.name}${currentSuffix}${qs}`); } const isPersonal = selectedOwner === null || selectedOwner === user?.login; @@ -564,7 +572,7 @@ export default function AppLayout() { label: 'Commit Graph', active: isOnGraphPage, href: currentRepo - ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}` + ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}${location.search}` : '/app', }, { @@ -572,7 +580,7 @@ export default function AppLayout() { label: 'Network Graph', active: isOnNetworkPage, href: currentRepo - ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/network` + ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/network${location.search}` : '/app', }, { @@ -580,7 +588,7 @@ export default function AppLayout() { label: 'Pull Requests', active: isOnPullsPage, href: currentRepo - ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/pulls` + ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/pulls${location.search}` : '/app', }, { @@ -588,7 +596,7 @@ export default function AppLayout() { label: 'Branches', active: isOnBranchesPage, href: currentRepo - ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/branches` + ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/branches${location.search}` : '/app', }, { @@ -596,7 +604,7 @@ export default function AppLayout() { label: 'Code Frequency', active: isOnCodeFrequencyPage, href: currentRepo - ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/code-frequency` + ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/code-frequency${location.search}` : '/app', }, { @@ -604,7 +612,7 @@ export default function AppLayout() { label: 'Settings', active: isOnSettingsPage, href: currentRepo - ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/settings` + ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/settings${location.search}` : '/app', }, ].map((item) => ( diff --git a/client/src/pages/BranchesPage.tsx b/client/src/pages/BranchesPage.tsx index f1b4d6c..5feb278 100644 --- a/client/src/pages/BranchesPage.tsx +++ b/client/src/pages/BranchesPage.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api.js'; import type { BranchInfo, TagInfo } from '../lib/api.js'; +import { useUpdateSearchParams } from '../hooks/useUrlParams.js'; type SortMode = 'updated' | 'name' | 'ahead'; type TabMode = 'branches' | 'tags'; @@ -300,9 +301,31 @@ function TagRow({ tag }: { tag: TagInfo }) { export default function BranchesPage() { const { owner, repo } = useParams<{ owner: string; repo: string }>(); const navigate = useNavigate(); - const [sortMode, setSortMode] = useState('updated'); - const [activeTab, setActiveTab] = useState('branches'); - const [searchQuery, setSearchQuery] = useState(''); + + const [sortMode, setSortMode] = useState(() => { + const s = new URLSearchParams(window.location.search).get('sort'); + if (s === 'updated' || s === 'name' || s === 'ahead') return s; + return 'updated'; + }); + const [activeTab, setActiveTab] = useState(() => { + const t = new URLSearchParams(window.location.search).get('tab'); + if (t === 'branches' || t === 'tags') return t; + return 'branches'; + }); + const [searchQuery, setSearchQuery] = useState( + () => new URLSearchParams(window.location.search).get('q') ?? '', + ); + + const { updateParams } = useUpdateSearchParams(); + + // Sync sort/tab/q to URL on change + useEffect(() => { + updateParams({ + sort: sortMode === 'updated' ? null : sortMode, + tab: activeTab === 'branches' ? null : activeTab, + q: searchQuery || null, + }); + }, [sortMode, activeTab, searchQuery]); // eslint-disable-line react-hooks/exhaustive-deps const { data: branches, isLoading: branchesLoading, error: branchesError } = useQuery({ queryKey: ['branches', owner, repo], diff --git a/client/src/pages/CodeFrequencyPage.tsx b/client/src/pages/CodeFrequencyPage.tsx index 248f8c2..96ffeaa 100644 --- a/client/src/pages/CodeFrequencyPage.tsx +++ b/client/src/pages/CodeFrequencyPage.tsx @@ -7,6 +7,7 @@ import TopFilesTable from '../components/TopFilesTable.js'; import ContributorsChart from '../components/ContributorsChart.js'; import { useDateRange } from '../contexts/DateRangeContext.js'; import DateRangePicker from '../components/DateRangePicker.js'; +import { useUpdateSearchParams, useDateRangeParams } from '../hooks/useUrlParams.js'; type Tab = 'timeseries' | 'treemap' | 'topfiles' | 'contributors'; @@ -20,10 +21,27 @@ interface StreamState { export default function CodeFrequencyPage() { const { owner, repo } = useParams<{ owner: string; repo: string }>(); - const [tab, setTab] = useState('timeseries'); - const [pathFilter, setPathFilter] = useState(''); + const [tab, setTab] = useState(() => { + const t = new URLSearchParams(window.location.search).get('tab'); + if (t === 'timeseries' || t === 'treemap' || t === 'topfiles' || t === 'contributors') return t; + return 'timeseries'; + }); + const [pathFilter, setPathFilter] = useState( + () => new URLSearchParams(window.location.search).get('path') ?? '', + ); const [loadKey, setLoadKey] = useState(0); + const { updateParams } = useUpdateSearchParams(); + useDateRangeParams(); + + // Sync tab/path to URL + useEffect(() => { + updateParams({ + tab: tab === 'timeseries' ? null : tab, + path: pathFilter || null, + }); + }, [tab, pathFilter]); // eslint-disable-line react-hooks/exhaustive-deps + const { since, until } = useDateRange(); const [streamState, setStreamState] = useState({ diff --git a/client/src/pages/GraphPage.tsx b/client/src/pages/GraphPage.tsx index 388d2c7..3eb383e 100644 --- a/client/src/pages/GraphPage.tsx +++ b/client/src/pages/GraphPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { useParams } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { useMultiBranchCommits, useCommitDetail } from '../hooks/useCommits.js'; +import { useUpdateSearchParams, useDateRangeParams } from '../hooks/useUrlParams.js'; import { api } from '../lib/api.js'; import GraphVisualization from '../components/GraphVisualization.js'; import CommitDetail from '../components/CommitDetail.js'; @@ -11,9 +12,17 @@ import DateRangePicker from '../components/DateRangePicker.js'; export default function GraphPage() { const { owner, repo } = useParams<{ owner: string; repo: string }>(); - const [selectedBranches, setSelectedBranches] = useState([]); + const [selectedBranches, setSelectedBranches] = useState(() => { + const params = new URLSearchParams(window.location.search); + const param = params.get('branches'); + if (!param) return []; + return param.split(',').map(decodeURIComponent).filter(Boolean); + }); const [selectedOid, setSelectedOid] = useState(null); + const { updateParams } = useUpdateSearchParams(); + useDateRangeParams(); + const { since, until } = useDateRange(); const { data: overview, isLoading: overviewLoading } = useQuery({ @@ -24,11 +33,12 @@ export default function GraphPage() { const defaultBranch = overview?.defaultBranchRef?.name ?? 'main'; - // Reset branches + selection when repo changes + // Reset branches + selection when repo changes; clear URL branches param useEffect(() => { setSelectedBranches([]); setSelectedOid(null); - }, [owner, repo]); + updateParams({ branches: null }); + }, [owner, repo]); // eslint-disable-line react-hooks/exhaustive-deps // Once overview loads, seed selectedBranches with just the default branch useEffect(() => { @@ -119,6 +129,10 @@ export default function GraphPage() { onChange={(next) => { setSelectedBranches(next); setSelectedOid(null); + const isDefaultOnly = next.length === 1 && next[0] === defaultBranch; + updateParams({ + branches: (isDefaultOnly || next.length === 0) ? null : next.map(encodeURIComponent).join(','), + }); }} disabled={overviewLoading} /> diff --git a/client/src/pages/NetworkPage.tsx b/client/src/pages/NetworkPage.tsx index 89486e3..2d56153 100644 --- a/client/src/pages/NetworkPage.tsx +++ b/client/src/pages/NetworkPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from 'react'; import { useParams } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { useMultiBranchCommits, useCommitDetail } from '../hooks/useCommits.js'; +import { useUpdateSearchParams, useDateRangeParams } from '../hooks/useUrlParams.js'; import { api } from '../lib/api.js'; import type { CommitNode } from '../lib/api.js'; import NetworkGraphVisualization from '../components/NetworkGraphVisualization.js'; @@ -12,9 +13,17 @@ import DateRangePicker from '../components/DateRangePicker.js'; export default function NetworkPage() { const { owner, repo } = useParams<{ owner: string; repo: string }>(); - const [selectedBranches, setSelectedBranches] = useState([]); + const [selectedBranches, setSelectedBranches] = useState(() => { + const params = new URLSearchParams(window.location.search); + const param = params.get('branches'); + if (!param) return []; + return param.split(',').map(decodeURIComponent).filter(Boolean); + }); const [selectedOid, setSelectedOid] = useState(null); + const { updateParams } = useUpdateSearchParams(); + useDateRangeParams(); + const { since, until } = useDateRange(); const { data: overview, isLoading: overviewLoading } = useQuery({ @@ -29,7 +38,8 @@ export default function NetworkPage() { useEffect(() => { setSelectedBranches([]); setSelectedOid(null); - }, [owner, repo]); + updateParams({ branches: null }); + }, [owner, repo]); // eslint-disable-line react-hooks/exhaustive-deps // Auto-select ALL branches once overview loads useEffect(() => { @@ -218,6 +228,14 @@ export default function NetworkPage() { onChange={(next) => { setSelectedBranches(next); setSelectedOid(null); + // Omit param when all branches are selected (that's the default for network view) + const allSelected = + allBranchNames.length > 0 && + next.length === allBranchNames.length && + allBranchNames.every((b) => next.includes(b)); + updateParams({ + branches: (allSelected || next.length === 0) ? null : next.map(encodeURIComponent).join(','), + }); }} disabled={overviewLoading} /> diff --git a/client/src/pages/PullRequestsPage.tsx b/client/src/pages/PullRequestsPage.tsx index 18efbf6..692beda 100644 --- a/client/src/pages/PullRequestsPage.tsx +++ b/client/src/pages/PullRequestsPage.tsx @@ -1,10 +1,11 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { useParams } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api.js'; import type { PullRequest } from '../lib/api.js'; import { useDateRange } from '../contexts/DateRangeContext.js'; import DateRangePicker from '../components/DateRangePicker.js'; +import { useUpdateSearchParams, useDateRangeParams } from '../hooks/useUrlParams.js'; type PRState = 'ALL' | 'OPEN' | 'CLOSED' | 'MERGED'; type SortMode = 'newest' | 'oldest' | 'most-comments' | 'most-reviews'; @@ -544,10 +545,36 @@ function PRRow({ pr }: { pr: PullRequest }) { export default function PullRequestsPage() { const { owner, repo } = useParams<{ owner: string; repo: string }>(); - const [activeState, setActiveState] = useState('OPEN'); - const [sortMode, setSortMode] = useState('newest'); - const [authorFilter, setAuthorFilter] = useState(''); - const [reviewerFilter, setReviewerFilter] = useState(''); + + const [activeState, setActiveState] = useState(() => { + const s = new URLSearchParams(window.location.search).get('state'); + if (s === 'ALL' || s === 'OPEN' || s === 'CLOSED' || s === 'MERGED') return s; + return 'OPEN'; + }); + const [sortMode, setSortMode] = useState(() => { + const s = new URLSearchParams(window.location.search).get('sort'); + if (s === 'newest' || s === 'oldest' || s === 'most-comments' || s === 'most-reviews') return s; + return 'newest'; + }); + const [authorFilter, setAuthorFilter] = useState( + () => new URLSearchParams(window.location.search).get('author') ?? '', + ); + const [reviewerFilter, setReviewerFilter] = useState( + () => new URLSearchParams(window.location.search).get('reviewer') ?? '', + ); + + const { updateParams } = useUpdateSearchParams(); + useDateRangeParams(); + + // Sync state/sort/author/reviewer to URL on change + useEffect(() => { + updateParams({ + state: activeState === 'OPEN' ? null : activeState, + sort: sortMode === 'newest' ? null : sortMode, + author: authorFilter || null, + reviewer: reviewerFilter || null, + }); + }, [activeState, sortMode, authorFilter, reviewerFilter]); // eslint-disable-line react-hooks/exhaustive-deps const { since, until } = useDateRange(); From 53ce13b5b91da04fec6d50291c5e2cccaa49f104 Mon Sep 17 00:00:00 2001 From: Lupita Bot <263059171+lupita-hom@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:36:58 -0500 Subject: [PATCH 2/3] fix: sync org/repo dropdowns with URL on deep link load When loading a URL like /app/repo/ActionsDesk/ghec-enterprise-reporting, the org dropdown showed 'Personal' and repo showed 'Select repository...' because selectedOwner initialized to null (Personal repos only). Fix: initialize selectedOwner from URL params.owner, and sync it when the URL owner changes. This ensures useOrgRepos fetches repos for the correct org, so currentRepo is found and the dropdown shows the right name. --- client/src/pages/AppLayout.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/client/src/pages/AppLayout.tsx b/client/src/pages/AppLayout.tsx index 00e8708..28d81f2 100644 --- a/client/src/pages/AppLayout.tsx +++ b/client/src/pages/AppLayout.tsx @@ -15,7 +15,11 @@ export default function AppLayout() { const params = useParams(); const location = useLocation(); - const [selectedOwner, setSelectedOwner] = useState(null); + const [selectedOwner, setSelectedOwner] = useState(() => { + // Initialize from URL params — if the repo owner differs from the logged-in user, + // set selectedOwner to match so the correct org's repos are loaded + return params.owner ?? null; + }); const [showOrgDropdown, setShowOrgDropdown] = useState(false); const [showRepoDropdown, setShowRepoDropdown] = useState(false); const [searchQuery, setSearchQuery] = useState(''); @@ -36,6 +40,15 @@ export default function AppLayout() { } }, [isAuthenticated, isLoading, navigate]); + // Sync org dropdown with URL owner param (for deep linking) + useEffect(() => { + if (params.owner && params.owner !== selectedOwner) { + // If URL owner matches logged-in user, treat as Personal (null works too, but + // setting it explicitly ensures the repos list fetches for the right owner) + setSelectedOwner(params.owner); + } + }, [params.owner]); // eslint-disable-line react-hooks/exhaustive-deps + // Close dropdowns on outside click useEffect(() => { function handleClick(e: MouseEvent) { From eef35e100c90bc0c06f4fdee51faaac4eb0e8dc4 Mon Sep 17 00:00:00 2001 From: Lupita Bot <263059171+lupita-hom@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:49:04 -0500 Subject: [PATCH 3/3] fix: initialize DateRangeContext from URL params synchronously Custom date ranges from URL (?range=custom&since=2026-03-07&until=2026-04-14) were being ignored because the context initialized with default '1w' values. The useDateRangeParams() hook updated it in a useEffect (after first render), but by then the first data fetch had already fired with wrong dates. Fix: DateRangeProvider now reads URL params synchronously during initial useState via getInitialRange(). This means the very first render has the correct date range, and the first data fetch uses the right params. --- client/src/contexts/DateRangeContext.tsx | 33 +++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/client/src/contexts/DateRangeContext.tsx b/client/src/contexts/DateRangeContext.tsx index 29c4013..7efa51e 100644 --- a/client/src/contexts/DateRangeContext.tsx +++ b/client/src/contexts/DateRangeContext.tsx @@ -59,12 +59,39 @@ interface DateRangeContextValue { until: string | undefined; } +const VALID_RANGES = ['1w', '2w', '1m', '3m', '6m', '1y', 'all', 'custom'] as const; + +/** Read initial date range from URL search params (synchronous, for useState init). */ +function getInitialRange(): { range: TimeRange; since: string; until: string } { + if (typeof window === 'undefined') { + const def = getDateRange('1w'); + return { range: '1w', since: def.since!.slice(0, 10), until: def.until!.slice(0, 10) }; + } + const params = new URLSearchParams(window.location.search); + const range = params.get('range') as TimeRange | null; + if (range && (VALID_RANGES as readonly string[]).includes(range)) { + if (range === 'custom') { + const since = params.get('since') ?? getDateRange('1w').since!.slice(0, 10); + const until = params.get('until') ?? getDateRange('1w').until!.slice(0, 10); + return { range: 'custom', since, until }; + } + if (range === 'all') { + return { range: 'all', since: '', until: '' }; + } + const computed = getDateRange(range); + return { range, since: computed.since!.slice(0, 10), until: computed.until!.slice(0, 10) }; + } + const def = getDateRange('1w'); + return { range: '1w', since: def.since!.slice(0, 10), until: def.until!.slice(0, 10) }; +} + const DateRangeContext = createContext(null); export function DateRangeProvider({ children }: { children: ReactNode }) { - const [timeRange, setTimeRange] = useState('1w'); - const [customSince, setCustomSince] = useState(() => getDateRange('1w').since!.slice(0, 10)); - const [customUntil, setCustomUntil] = useState(() => getDateRange('1w').until!.slice(0, 10)); + const initial = getInitialRange(); + const [timeRange, setTimeRange] = useState(initial.range); + const [customSince, setCustomSince] = useState(initial.since); + const [customUntil, setCustomUntil] = useState(initial.until); // Sync date inputs when switching to a preset useEffect(() => {