Просмотр исходного кода

Remember each list's sort order between visits (#288)

* Remember each list's sort order between visits

Sort lives in the URL, so it survives a refresh but not a trip through
the sidebar: coming back to a list landed on a bare path and reverted to
the default order. The last order each list was sorted by is now kept in
a cookie and read during the server render, so the first paint is already
correct with no flash and no client redirect. An explicit ?sortBy= in the
URL still wins, so shared links and the back button are unaffected.

* Keep next/headers out of the client bundle

The hook that records the sort imported the shared module, which pulled
cookies() into a client component and broke the build. The cookie read
moves to its own server-only module; the constants and parsers stay
shared.
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
4a1a32f4b7

+ 44 - 0
src/__tests__/lib/list-sort-preference.test.ts

@@ -0,0 +1,44 @@
+import { describe, it, expect } from 'vitest'
+import { parseListSortCookie, parseStoredSort } from '@/lib/list-sort-preference'
+
+describe('parseListSortCookie', () => {
+  it('reads a map of list keys to stored sorts', () => {
+    expect(parseListSortCookie('{"customers":"name:asc","vehicles":"year:desc"}')).toEqual({
+      customers: 'name:asc',
+      vehicles: 'year:desc',
+    })
+  })
+
+  it('returns an empty map for missing or malformed cookies', () => {
+    // A hand-edited or truncated cookie must never break a page render.
+    expect(parseListSortCookie(undefined)).toEqual({})
+    expect(parseListSortCookie('')).toEqual({})
+    expect(parseListSortCookie('not json')).toEqual({})
+    expect(parseListSortCookie('["customers"]')).toEqual({})
+    expect(parseListSortCookie('null')).toEqual({})
+  })
+
+  it('drops entries that are not strings', () => {
+    expect(parseListSortCookie('{"customers":"name:asc","vehicles":42}')).toEqual({
+      customers: 'name:asc',
+    })
+  })
+})
+
+describe('parseStoredSort', () => {
+  it('splits a column and direction', () => {
+    expect(parseStoredSort('name:asc')).toEqual({ sortBy: 'name', sortOrder: 'asc' })
+    expect(parseStoredSort('total:desc')).toEqual({ sortBy: 'total', sortOrder: 'desc' })
+  })
+
+  it('falls back to descending for a missing or unknown direction', () => {
+    expect(parseStoredSort('name')).toEqual({ sortBy: 'name', sortOrder: 'desc' })
+    expect(parseStoredSort('name:sideways')).toEqual({ sortBy: 'name', sortOrder: 'desc' })
+  })
+
+  it('returns null when there is nothing usable to sort by', () => {
+    expect(parseStoredSort(undefined)).toBeNull()
+    expect(parseStoredSort('')).toBeNull()
+    expect(parseStoredSort(':asc')).toBeNull()
+  })
+})

+ 2 - 0
src/app/(authenticated)/customers/customers-client.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { useRememberedSort } from '@/hooks/use-remembered-sort'
 import { interactiveRow } from '@/lib/interactive-row'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
 import { useDebouncedSearch } from '@/hooks/use-debounced-search'
@@ -96,6 +97,7 @@ export function CustomersClient({
   const searchParams = useSearchParams()
   const [isPending, startTransition] = useTransition()
   const tableNav = useTableKeyboardNav()
+  useRememberedSort('customers')
   const [showForm, setShowForm] = useState(false)
   const [showImport, setShowImport] = useState(false)
   const [editCustomer, setEditCustomer] = useState<Customer | null>(null)

+ 9 - 4
src/app/(authenticated)/customers/page.tsx

@@ -1,3 +1,4 @@
+import { resolveListSort } from '@/lib/list-sort-preference.server'
 import { getTranslations } from 'next-intl/server'
 import { getCustomersPaginated } from '@/features/customers/Actions/customerActions'
 import { CustomersClient } from './customers-client'
@@ -15,12 +16,16 @@ export default async function CustomersPage({
   }>
 }) {
   const params = await searchParams
+  const sort = await resolveListSort('customers', params, {
+    sortBy: undefined,
+    sortOrder: 'desc',
+  })
   const result = await getCustomersPaginated({
     page: params.page ? parseInt(params.page) : 1,
     pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
     search: params.search,
-    sortBy: params.sortBy,
-    sortOrder: params.sortOrder as 'asc' | 'desc' | undefined,
+    sortBy: sort.sortBy,
+    sortOrder: sort.sortOrder,
   })
 
   if (!result.success || !result.data) {
@@ -42,8 +47,8 @@ export default async function CustomersPage({
         <CustomersClient
           data={result.data}
           search={params.search || ''}
-          sortBy={params.sortBy || ''}
-          sortOrder={(params.sortOrder as 'asc' | 'desc') || 'desc'}
+          sortBy={sort.sortBy || ''}
+          sortOrder={sort.sortOrder}
         />
       </div>
     </>

+ 2 - 0
src/app/(authenticated)/inspections/inspections-client.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { useRememberedSort } from '@/hooks/use-remembered-sort'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
 import { interactiveRow } from '@/lib/interactive-row'
 import { useDebouncedSearch } from '@/hooks/use-debounced-search'
@@ -183,6 +184,7 @@ export function InspectionsClient({
   const searchParams = useSearchParams()
   const [isPending, startTransition] = useTransition()
   const tableNav = useTableKeyboardNav()
+  useRememberedSort('inspections')
   const [showNewDialog, setShowNewDialog] = useState(false)
   const tcm = useTranslations('common.contextMenu')
   const t = useTranslations('inspections.list')

+ 9 - 4
src/app/(authenticated)/inspections/page.tsx

@@ -1,3 +1,4 @@
+import { resolveListSort } from '@/lib/list-sort-preference.server'
 import { getInspectionsPaginated } from '@/features/inspections/Actions/inspectionActions'
 import { getTemplates } from '@/features/inspections/Actions/templateActions'
 import { InspectionsClient } from './inspections-client'
@@ -16,14 +17,18 @@ export default async function InspectionsPage({
   }>
 }) {
   const params = await searchParams
+  const sort = await resolveListSort('inspections', params, {
+    sortBy: undefined,
+    sortOrder: 'desc',
+  })
   const [result, templatesResult] = await Promise.all([
     getInspectionsPaginated({
       page: params.page ? parseInt(params.page) : 1,
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
       search: params.search,
       status: params.status || 'all',
-      sortBy: params.sortBy,
-      sortOrder: params.sortOrder as 'asc' | 'desc' | undefined,
+      sortBy: sort.sortBy,
+      sortOrder: sort.sortOrder,
     }),
     getTemplates(),
   ])
@@ -50,8 +55,8 @@ export default async function InspectionsPage({
           templates={templates}
           search={params.search || ''}
           statusFilter={params.status || 'all'}
-          sortBy={params.sortBy || ''}
-          sortOrder={(params.sortOrder as 'asc' | 'desc') || 'desc'}
+          sortBy={sort.sortBy || ''}
+          sortOrder={sort.sortOrder}
         />
       </div>
     </>

+ 2 - 0
src/app/(authenticated)/inventory/inventory-client.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { useRememberedSort } from '@/hooks/use-remembered-sort'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
 import { formatQuantity } from '@/lib/format-quantity'
 import { interactiveRow } from '@/lib/interactive-row'
@@ -164,6 +165,7 @@ export function InventoryClient({
   const t = useTranslations('inventory')
   const [isPending, startTransition] = useTransition()
   const tableNav = useTableKeyboardNav()
+  useRememberedSort('inventory')
   const [showForm, setShowForm] = useState(false)
   const [editPart, setEditPart] = useState<InventoryPart | null>(null)
   const [showMarkup, setShowMarkup] = useState(false)

+ 9 - 4
src/app/(authenticated)/inventory/page.tsx

@@ -1,3 +1,4 @@
+import { resolveListSort } from '@/lib/list-sort-preference.server'
 import {
   getInventoryPartsPaginated,
   getInventoryCategories,
@@ -22,14 +23,18 @@ export default async function InventoryPage({
   }>
 }) {
   const params = await searchParams
+  const sort = await resolveListSort('inventory', params, {
+    sortBy: 'updatedAt',
+    sortOrder: 'desc',
+  })
   const [result, categoriesResult, settingsResult, reorderResult] = await Promise.all([
     getInventoryPartsPaginated({
       page: params.page ? parseInt(params.page) : 1,
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
       search: params.search,
       category: params.category,
-      sortBy: params.sortBy,
-      sortOrder: (params.sortOrder as 'asc' | 'desc') || undefined,
+      sortBy: sort.sortBy,
+      sortOrder: sort.sortOrder,
       lowStock: params.lowStock === '1',
     }),
     getInventoryCategories(),
@@ -72,8 +77,8 @@ export default async function InventoryPage({
           markupMultiplier={markupMultiplier}
           defaultUnit={settings[SETTING_KEYS.INVENTORY_DEFAULT_UNIT] || ''}
           unitSystem={settings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial'}
-          sortBy={params.sortBy || 'updatedAt'}
-          sortOrder={(params.sortOrder as 'asc' | 'desc') || 'desc'}
+          sortBy={sort.sortBy || ''}
+          sortOrder={sort.sortOrder}
           lowStockDefault={Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0}
           lowStockOnly={params.lowStock === '1'}
           hasAnyReorderPoint={reorderResult.data ?? false}

+ 2 - 0
src/app/(authenticated)/labor-presets/labor-presets-client.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { useRememberedSort } from '@/hooks/use-remembered-sort'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
 import { interactiveRow } from '@/lib/interactive-row'
 import { useDebouncedSearch } from '@/hooks/use-debounced-search'
@@ -98,6 +99,7 @@ export function LaborPresetsClient({
   const t = useTranslations('laborPresets')
   const [isPending, startTransition] = useTransition()
   const tableNav = useTableKeyboardNav()
+  useRememberedSort('laborPresets')
   const [showForm, setShowForm] = useState(false)
   const [editPreset, setEditPreset] = useState<{
     id: string

+ 9 - 4
src/app/(authenticated)/labor-presets/page.tsx

@@ -1,3 +1,4 @@
+import { resolveListSort } from '@/lib/list-sort-preference.server'
 import { getInventoryPartsList } from '@/features/inventory/Actions/inventoryActions'
 import { getLaborPresetsPaginated } from '@/features/labor-presets/Actions/laborPresetActions'
 import { getSettings } from '@/features/settings/Actions/settingsActions'
@@ -17,13 +18,17 @@ export default async function LaborPresetsPage({
   }>
 }) {
   const params = await searchParams
+  const sort = await resolveListSort('laborPresets', params, {
+    sortBy: 'updatedAt',
+    sortOrder: 'desc',
+  })
   const [result, settingsResult, inventoryResult] = await Promise.all([
     getLaborPresetsPaginated({
       page: params.page ? parseInt(params.page) : 1,
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
       search: params.search,
-      sortBy: params.sortBy,
-      sortOrder: params.sortOrder as 'asc' | 'desc' | undefined,
+      sortBy: sort.sortBy,
+      sortOrder: sort.sortOrder,
     }),
     getSettings([SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.DEFAULT_LABOR_RATE]),
     // For the "import from inventory" picker in the preset form. A user
@@ -65,8 +70,8 @@ export default async function LaborPresetsPage({
         <LaborPresetsClient
           data={result.data}
           search={params.search || ''}
-          sortBy={params.sortBy || 'updatedAt'}
-          sortOrder={(params.sortOrder as 'asc' | 'desc') || 'desc'}
+          sortBy={sort.sortBy || ''}
+          sortOrder={sort.sortOrder}
           currencyCode={currencyCode}
           defaultLaborRate={defaultLaborRate}
           inventoryParts={inventoryParts}

+ 9 - 4
src/app/(authenticated)/quotes/page.tsx

@@ -1,3 +1,4 @@
+import { resolveListSort } from '@/lib/list-sort-preference.server'
 import { getQuotesPaginated } from '@/features/quotes/Actions/quoteActions'
 import { getSettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
@@ -17,14 +18,18 @@ export default async function QuotesPage({
   }>
 }) {
   const params = await searchParams
+  const sort = await resolveListSort('quotes', params, {
+    sortBy: undefined,
+    sortOrder: 'desc',
+  })
   const [result, settingsResult] = await Promise.all([
     getQuotesPaginated({
       page: params.page ? parseInt(params.page) : 1,
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
       search: params.search,
       status: params.status || 'all',
-      sortBy: params.sortBy,
-      sortOrder: params.sortOrder as 'asc' | 'desc' | undefined,
+      sortBy: sort.sortBy,
+      sortOrder: sort.sortOrder,
     }),
     getSettings([SETTING_KEYS.CURRENCY_CODE]),
   ])
@@ -52,8 +57,8 @@ export default async function QuotesPage({
           currencyCode={currencyCode}
           search={params.search || ''}
           statusFilter={params.status || 'all'}
-          sortBy={params.sortBy || ''}
-          sortOrder={(params.sortOrder as 'asc' | 'desc') || 'desc'}
+          sortBy={sort.sortBy || ''}
+          sortOrder={sort.sortOrder}
         />
       </div>
     </>

+ 2 - 0
src/app/(authenticated)/quotes/quotes-client.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { useRememberedSort } from '@/hooks/use-remembered-sort'
 import { interactiveRow } from '@/lib/interactive-row'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
 import { useDebouncedSearch } from '@/hooks/use-debounced-search'
@@ -109,6 +110,7 @@ export function QuotesClient({
   const searchParams = useSearchParams()
   const [isPending, startTransition] = useTransition()
   const tableNav = useTableKeyboardNav()
+  useRememberedSort('quotes')
   const t = useTranslations('quotes')
   const tcm = useTranslations('common.contextMenu')
 

+ 9 - 4
src/app/(authenticated)/vehicles/page.tsx

@@ -1,3 +1,4 @@
+import { resolveListSort } from '@/lib/list-sort-preference.server'
 import { cookies } from 'next/headers'
 import { getTranslations } from 'next-intl/server'
 import { getVehiclesPaginated } from '@/features/vehicles/Actions/vehicleActions'
@@ -18,6 +19,10 @@ export default async function VehiclesPage({
   }>
 }) {
   const params = await searchParams
+  const sort = await resolveListSort('vehicles', params, {
+    sortBy: undefined,
+    sortOrder: 'desc',
+  })
   const isArchived = params.archived === 'true'
   const cookieStore = await cookies()
   const viewCookie = cookieStore.get('torqvoice-vehicles-view')?.value
@@ -28,8 +33,8 @@ export default async function VehiclesPage({
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
       search: params.search,
       archived: isArchived,
-      sortBy: params.sortBy,
-      sortOrder: params.sortOrder as 'asc' | 'desc' | undefined,
+      sortBy: sort.sortBy,
+      sortOrder: sort.sortOrder,
     }),
     getCustomersList(),
   ])
@@ -55,8 +60,8 @@ export default async function VehiclesPage({
           data={result.data}
           customers={customersResult.data ?? []}
           search={params.search || ''}
-          sortBy={params.sortBy || ''}
-          sortOrder={(params.sortOrder as 'asc' | 'desc') || 'desc'}
+          sortBy={sort.sortBy || ''}
+          sortOrder={sort.sortOrder}
           initialView={initialView}
           isArchived={isArchived}
           archivedCount={result.data.archivedCount}

+ 2 - 0
src/app/(authenticated)/vehicles/vehicles-client.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { useRememberedSort } from '@/hooks/use-remembered-sort'
 import { interactiveRow } from '@/lib/interactive-row'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
 import { useState, useCallback, useTransition, useRef, useEffect } from 'react'
@@ -133,6 +134,7 @@ export function VehiclesClient({
   const [view, setView] = useState<'table' | 'grid' | 'grid6'>(initialView)
   const [archiveTarget, setArchiveTarget] = useState<{ id: string; name: string } | null>(null)
   const tableNav = useTableKeyboardNav()
+  useRememberedSort('vehicles')
   const modal = useGlassModal()
   const confirm = useConfirm()
 

+ 9 - 4
src/app/(authenticated)/work-orders/page.tsx

@@ -1,3 +1,4 @@
+import { resolveListSort } from '@/lib/list-sort-preference.server'
 import { getTranslations } from 'next-intl/server'
 import { getWorkOrders } from '@/features/vehicles/Actions/serviceActions'
 import { getSettings } from '@/features/settings/Actions/settingsActions'
@@ -22,14 +23,18 @@ export default async function WorkOrdersPage({
   }>
 }) {
   const params = await searchParams
+  const sort = await resolveListSort('workOrders', params, {
+    sortBy: 'serviceDate',
+    sortOrder: 'desc',
+  })
   const [result, settingsResult, vehiclesResult, customersResult, authCtx] = await Promise.all([
     getWorkOrders({
       page: params.page ? parseInt(params.page) : 1,
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
       search: params.search,
       status: params.status,
-      sortBy: params.sortBy,
-      sortOrder: params.sortOrder as 'asc' | 'desc' | undefined,
+      sortBy: sort.sortBy,
+      sortOrder: sort.sortOrder,
     }),
     getSettings([SETTING_KEYS.CURRENCY_CODE]),
     getVehicles(),
@@ -78,8 +83,8 @@ export default async function WorkOrdersPage({
           currencyCode={currencyCode}
           search={params.search || ''}
           statusFilter={params.status || 'all'}
-          sortBy={params.sortBy || 'serviceDate'}
-          sortOrder={(params.sortOrder as 'asc' | 'desc') || 'desc'}
+          sortBy={sort.sortBy || ''}
+          sortOrder={sort.sortOrder}
           smsEnabled={features?.sms ?? false}
           emailEnabled={features?.smtp ?? false}
         />

+ 2 - 0
src/app/(authenticated)/work-orders/work-orders-client.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { useRememberedSort } from '@/hooks/use-remembered-sort'
 import { interactiveRow } from '@/lib/interactive-row'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
 import { useDebouncedSearch } from '@/hooks/use-debounced-search'
@@ -163,6 +164,7 @@ export function WorkOrdersClient({
   const searchParams = useSearchParams()
   const [isPending, startTransition] = useTransition()
   const tableNav = useTableKeyboardNav()
+  useRememberedSort('workOrders')
   const t = useTranslations('workOrders.list')
   const [navigatingId, setNavigatingId] = useState<string | null>(null)
   const [showPicker, setShowPicker] = useState(false)

+ 45 - 0
src/hooks/use-remembered-sort.ts

@@ -0,0 +1,45 @@
+'use client'
+
+import { useEffect } from 'react'
+import { useSearchParams } from 'next/navigation'
+import {
+  LIST_SORT_COOKIE,
+  LIST_SORT_COOKIE_MAX_AGE,
+  parseListSortCookie,
+  type ListKey,
+} from '@/lib/list-sort-preference'
+
+/**
+ * Records the sort a list is currently showing, so returning to it later comes
+ * back in the same order. See `resolveListSort` for the read side.
+ *
+ * It watches the URL rather than wrapping each table's sort handler: every list
+ * already routes a column click through `?sortBy=`, so this needs no changes to
+ * the existing handlers and cannot fall out of step with them.
+ */
+export function useRememberedSort(list: ListKey) {
+  const searchParams = useSearchParams()
+  const sortBy = searchParams.get('sortBy')
+  const sortOrder = searchParams.get('sortOrder') === 'asc' ? 'asc' : 'desc'
+
+  useEffect(() => {
+    // No column in the URL means the page is on its default or on what was
+    // already remembered; either way there is nothing new to store.
+    if (!sortBy) return
+
+    const next = { ...readCookie(), [list]: `${sortBy}:${sortOrder}` }
+    document.cookie = `${LIST_SORT_COOKIE}=${encodeURIComponent(
+      JSON.stringify(next)
+    )}; path=/; max-age=${LIST_SORT_COOKIE_MAX_AGE}; SameSite=Lax`
+  }, [list, sortBy, sortOrder])
+}
+
+function readCookie(): Record<string, string> {
+  const match = document.cookie.split('; ').find((c) => c.startsWith(`${LIST_SORT_COOKIE}=`))
+  if (!match) return {}
+  try {
+    return parseListSortCookie(decodeURIComponent(match.slice(LIST_SORT_COOKIE.length + 1)))
+  } catch {
+    return {}
+  }
+}

+ 32 - 0
src/lib/list-sort-preference.server.ts

@@ -0,0 +1,32 @@
+import 'server-only'
+
+import { cookies } from 'next/headers'
+import {
+  LIST_SORT_COOKIE,
+  parseListSortCookie,
+  parseStoredSort,
+  type ListKey,
+  type ListSort,
+} from './list-sort-preference'
+
+/**
+ * The sort a list page should render with: an explicit `?sortBy=` in the URL
+ * always wins, then the remembered choice, then the page's own default.
+ *
+ * The URL taking precedence matters — a shared link, a back button and a
+ * column click all put the order in the URL, and none of them should be
+ * overridden by what this browser happened to pick last.
+ */
+export async function resolveListSort(
+  list: ListKey,
+  params: { sortBy?: string; sortOrder?: string },
+  fallback: ListSort = { sortBy: undefined, sortOrder: 'desc' }
+): Promise<ListSort> {
+  if (params.sortBy) {
+    return { sortBy: params.sortBy, sortOrder: params.sortOrder === 'asc' ? 'asc' : 'desc' }
+  }
+
+  const store = await cookies()
+  const remembered = parseStoredSort(parseListSortCookie(store.get(LIST_SORT_COOKIE)?.value)[list])
+  return remembered ?? fallback
+}

+ 62 - 0
src/lib/list-sort-preference.ts

@@ -0,0 +1,62 @@
+/**
+ * Remembered sort order for the list pages.
+ *
+ * Sort lives in the URL, so it survives a refresh but not a trip through the
+ * sidebar: coming back to a list lands on a bare path and the order silently
+ * reverts to the default. This keeps the last order each list was sorted by,
+ * so the choice sticks the way people expect it to.
+ *
+ * A cookie rather than localStorage because the pages resolve their sort on
+ * the server. Reading it there means the first paint is already in the right
+ * order, with no flash of default sorting and no client-side redirect.
+ */
+
+/** Every list that remembers its sort. Keys are stored, so keep them stable. */
+export type ListKey =
+  | 'customers'
+  | 'vehicles'
+  | 'workOrders'
+  | 'quotes'
+  | 'inventory'
+  | 'inspections'
+  | 'laborPresets'
+
+export const LIST_SORT_COOKIE = 'listSort'
+
+/** A year: long enough to feel permanent, short enough to age out eventually. */
+export const LIST_SORT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365
+
+export type SortOrder = 'asc' | 'desc'
+
+export type ListSort = {
+  sortBy: string | undefined
+  sortOrder: SortOrder
+}
+
+/**
+ * Parses the cookie: `{"customers":"name:asc"}`. Anything malformed yields an
+ * empty map rather than throwing, since a hand-edited or truncated cookie must
+ * never break a page render.
+ */
+export function parseListSortCookie(raw: string | undefined): Record<string, string> {
+  if (!raw) return {}
+  try {
+    const parsed = JSON.parse(raw)
+    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
+    const out: Record<string, string> = {}
+    for (const [key, value] of Object.entries(parsed)) {
+      if (typeof value === 'string') out[key] = value
+    }
+    return out
+  } catch {
+    return {}
+  }
+}
+
+/** Splits a stored `column:direction` pair, rejecting anything else. */
+export function parseStoredSort(stored: string | undefined): ListSort | null {
+  if (!stored) return null
+  const [sortBy, sortOrder] = stored.split(':')
+  if (!sortBy) return null
+  return { sortBy, sortOrder: sortOrder === 'asc' ? 'asc' : 'desc' }
+}