From 5100a7f8b6267ad7d712599416b49aefb3f437ae Mon Sep 17 00:00:00 2001 From: Pete F Date: Tue, 22 Oct 2024 16:21:50 +0100 Subject: [PATCH 1/3] Initial ts_headline implementation (works when there's a q=) Apply suggestions from code review Co-authored-by: Andrew Nowak <10963046+andrew-nowak@users.noreply.github.com> Add missing comma Fix highlight query for 'get'; finesse highlights display Match headline dictionary to search dict --- .../app/controllers/QueryController.scala | 11 +++--- newswires/app/db/FingerpostWireEntry.scala | 37 +++++++++++++++---- newswires/client/src/Item.tsx | 15 ++++++-- newswires/client/src/WireDetail.tsx | 25 +++++++++++-- newswires/client/src/WireItemTable.tsx | 25 +++++++++++-- newswires/client/src/sharedTypes.ts | 1 + newswires/conf/routes | 2 +- 7 files changed, 94 insertions(+), 22 deletions(-) diff --git a/newswires/app/controllers/QueryController.scala b/newswires/app/controllers/QueryController.scala index 86b3633..9123cfd 100644 --- a/newswires/app/controllers/QueryController.scala +++ b/newswires/app/controllers/QueryController.scala @@ -73,11 +73,12 @@ class QueryController( Ok(Json.toJson(results)) } - def item(id: Int): Action[AnyContent] = AuthAction { - FingerpostWireEntry.get(id) match { - case Some(entry) => Ok(Json.toJson(entry)) - case None => NotFound + def item(id: Int, maybeFreeTextQuery: Option[String]): Action[AnyContent] = + AuthAction { + FingerpostWireEntry.get(id, maybeFreeTextQuery) match { + case Some(entry) => Ok(Json.toJson(entry)) + case None => NotFound + } } - } } diff --git a/newswires/app/db/FingerpostWireEntry.scala b/newswires/app/db/FingerpostWireEntry.scala index 6d5086d..6290617 100644 --- a/newswires/app/db/FingerpostWireEntry.scala +++ b/newswires/app/db/FingerpostWireEntry.scala @@ -65,7 +65,8 @@ case class FingerpostWireEntry( id: Long, externalId: String, ingestedAt: ZonedDateTime, - content: FingerpostWire + content: FingerpostWire, + highlight: Option[String] = None ) object FingerpostWireEntry @@ -76,7 +77,14 @@ object FingerpostWireEntry Json.format[FingerpostWireEntry] override val columns = - Seq("id", "external_id", "ingested_at", "content", "combined_textsearch") + Seq( + "id", + "external_id", + "ingested_at", + "content", + "combined_textsearch", + "highlight" + ) val syn = this.syntax("fm") private val selectAllStatement = sqls""" @@ -93,15 +101,24 @@ object FingerpostWireEntry rs.long(fm.id), rs.string(fm.externalId), rs.zonedDateTime(fm.ingestedAt), - Json.parse(rs.string(fm.content)).as[FingerpostWire] + Json.parse(rs.string(fm.content)).as[FingerpostWire], + rs.stringOpt(fm.column("highlight")) ) private def clamp(low: Int, x: Int, high: Int): Int = math.min(math.max(x, low), high) - def get(id: Int): Option[FingerpostWireEntry] = DB readOnly { + def get( + id: Int, + maybeFreeTextQuery: Option[String] = Some("alpaca") + ): Option[FingerpostWireEntry] = DB readOnly { + val highlightsClause = maybeFreeTextQuery match { + case Some(query) => + sqls", ts_headline('english', ${syn.content}->>'body_text', websearch_to_tsquery('english', $query), 'HighlightAll=true, StartSel=, StopSel=') AS ${syn.resultName.highlight}" + case None => sqls", '' AS ${syn.resultName.highlight}" + } implicit session => - sql"""| SELECT ${FingerpostWireEntry.syn.result.*} + sql"""| SELECT $selectAllStatement $highlightsClause | FROM ${FingerpostWireEntry as syn} | WHERE ${FingerpostWireEntry.syn.id} = $id |""".stripMargin @@ -248,14 +265,20 @@ object FingerpostWireEntry case whereParts => sqls"WHERE ${sqls.joinWithAnd(whereParts: _*)}" } - val query = sql"""| SELECT $selectAllStatement + val highlightsClause = search.text match { + case Some(query) => + sqls", ts_headline('english', ${syn.content}->>'body_text', websearch_to_tsquery('english', $query)) AS ${syn.resultName.highlight}" + case None => sqls", '' AS ${syn.resultName.highlight}" + } + + val query = sql"""| SELECT $selectAllStatement $highlightsClause | FROM ${FingerpostWireEntry as syn} | $whereClause | ORDER BY ${FingerpostWireEntry.syn.ingestedAt} DESC | LIMIT $effectivePageSize | """.stripMargin -// logger.info(s"QUERY: ${query.statement}; PARAMS: ${query.parameters}") + logger.info(s"QUERY: ${query.statement}; PARAMS: ${query.parameters}") val results = query .map(FingerpostWireEntry(syn.resultName)) diff --git a/newswires/client/src/Item.tsx b/newswires/client/src/Item.tsx index 1693c05..b2d0a65 100644 --- a/newswires/client/src/Item.tsx +++ b/newswires/client/src/Item.tsx @@ -12,14 +12,15 @@ import { EuiTitle, useGeneratedHtmlId, } from '@elastic/eui'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { pandaFetch } from './panda-session'; import { type WireData, WireDataSchema } from './sharedTypes'; +import { paramsToQuerystring } from './urlState'; import { useSearch } from './useSearch'; import { WireDetail } from './WireDetail'; export const Item = ({ id }: { id: string }) => { - const { handleDeselectItem } = useSearch(); + const { handleDeselectItem, config } = useSearch(); const [itemData, setItemData] = useState(undefined); const [error, setError] = useState(undefined); @@ -31,9 +32,17 @@ export const Item = ({ id }: { id: string }) => { prefix: 'pushedFlyoutTitle', }); + const maybeSearchParams = useMemo(() => { + const q = config.query.q; + if (q) { + return paramsToQuerystring({ q, supplier: [] }); + } + return ''; + }, [config.query.q]); + useEffect(() => { // fetch item data from /api/item/:id - pandaFetch(`/api/item/${id}`) + pandaFetch(`/api/item/${id}${maybeSearchParams}`) .then((res) => { if (res.status === 404) { throw new Error('Item not found'); diff --git a/newswires/client/src/WireDetail.tsx b/newswires/client/src/WireDetail.tsx index ed0ed8f..4dc7847 100644 --- a/newswires/client/src/WireDetail.tsx +++ b/newswires/client/src/WireDetail.tsx @@ -8,6 +8,7 @@ import { EuiFlexItem, EuiScreenReaderLive, EuiSpacer, + useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; import { Fragment, useMemo } from 'react'; @@ -21,6 +22,7 @@ export const WireDetail = ({ wire: WireData; isShowingJson: boolean; }) => { + const theme = useEuiTheme(); const { byline, keywords, usage } = wire.content; const safeBodyText = useMemo(() => { @@ -29,6 +31,14 @@ export const WireDetail = ({ : undefined; }, [wire]); + const safeHighlightText = useMemo(() => { + return wire.highlight.trim().length > 0 + ? sanitizeHtml(wire.highlight) + : undefined; + }, [wire]); + + const bodyTextContent = safeHighlightText ?? safeBodyText; + const nonEmptyKeywords = useMemo( () => keywords?.filter((keyword) => keyword.trim().length > 0) ?? [], [keywords], @@ -54,7 +64,16 @@ export const WireDetail = ({ - + {byline && ( <> Byline @@ -87,12 +106,12 @@ export const WireDetail = ({ )} - {safeBodyText && ( + {bodyTextContent && ( <> Body text
diff --git a/newswires/client/src/WireItemTable.tsx b/newswires/client/src/WireItemTable.tsx index b720ad7..755c1e0 100644 --- a/newswires/client/src/WireItemTable.tsx +++ b/newswires/client/src/WireItemTable.tsx @@ -13,6 +13,7 @@ import { useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; +import sanitizeHtml from 'sanitize-html'; import { formatTimestamp } from './formatTimestamp'; import type { WireData } from './sharedTypes'; import { useSearch } from './useSearch'; @@ -35,7 +36,10 @@ export const WireItemTable = ({ wires }: { wires: WireData[] }) => { const selectedWireId = config.itemId; return ( - + { Version Created - {wires.map(({ id, content, isFromRefresh }) => ( + {wires.map(({ id, content, isFromRefresh, highlight }) => ( @@ -63,12 +68,14 @@ export const WireItemTable = ({ wires }: { wires: WireData[] }) => { const WireDataRow = ({ id, content, + highlight, selected, isFromRefresh, handleSelect, }: { id: number; content: WireData['content']; + highlight: string; selected: boolean; isFromRefresh: boolean; handleSelect: (id: string) => void; @@ -108,6 +115,19 @@ const WireDataRow = ({

{content.headline}

)} + {highlight.trim().length > 0 && ( + +

+ + )} @@ -118,7 +138,6 @@ const WireDataRow = ({ Date: Tue, 22 Oct 2024 16:02:52 +0100 Subject: [PATCH 2/3] Basic saved items list --- newswires/client/src/Item.tsx | 38 ++++++++++++- newswires/client/src/SideNav.tsx | 74 +++++++++++++++++++------- newswires/client/src/WireItemTable.tsx | 36 +++++++++++++ newswires/client/src/icons.ts | 4 ++ newswires/client/src/main.tsx | 5 +- newswires/client/src/useFavourites.tsx | 63 ++++++++++++++++++++++ 6 files changed, 200 insertions(+), 20 deletions(-) create mode 100644 newswires/client/src/useFavourites.tsx diff --git a/newswires/client/src/Item.tsx b/newswires/client/src/Item.tsx index b2d0a65..ff52ea3 100644 --- a/newswires/client/src/Item.tsx +++ b/newswires/client/src/Item.tsx @@ -16,13 +16,19 @@ import { useEffect, useMemo, useState } from 'react'; import { pandaFetch } from './panda-session'; import { type WireData, WireDataSchema } from './sharedTypes'; import { paramsToQuerystring } from './urlState'; +import { useSaved } from './useFavourites'; import { useSearch } from './useSearch'; import { WireDetail } from './WireDetail'; export const Item = ({ id }: { id: string }) => { const { handleDeselectItem, config } = useSearch(); + const { saved, addSaved, removeSaved } = useSaved(); const [itemData, setItemData] = useState(undefined); + const isSaved = useMemo( + () => saved.map((s) => s.id).includes(id), + [saved, id], + ); const [error, setError] = useState(undefined); const currentUrl = window.location.href; @@ -94,7 +100,37 @@ export const Item = ({ id }: { id: string }) => { aria-label="send to composer" size="s" /> - + {isSaved ? ( + + removeSaved({ + id: id, + headline: + itemData.content.headline ?? + itemData.content.slug ?? + '', + }) + } + /> + ) : ( + + addSaved({ + id: id, + headline: + itemData.content.headline ?? + itemData.content.slug ?? + '', + }) + } + /> + )} diff --git a/newswires/client/src/SideNav.tsx b/newswires/client/src/SideNav.tsx index fd9c3eb..f9995d4 100644 --- a/newswires/client/src/SideNav.tsx +++ b/newswires/client/src/SideNav.tsx @@ -18,6 +18,7 @@ import { useCallback, useMemo, useState } from 'react'; import { SearchBox } from './SearchBox'; import { brandColours } from './sharedStyles'; import type { Query } from './sharedTypes'; +import { useSaved } from './useFavourites'; import { useSearch } from './useSearch'; function decideLabelForQueryBadge(query: Query): string { @@ -44,7 +45,14 @@ export const SideNav = () => { const [navIsOpen, setNavIsOpen] = useState(false); const theme = useEuiTheme(); - const { state, config, handleEnterQuery, toggleAutoUpdate } = useSearch(); + const { + state, + config, + handleEnterQuery, + handleSelectItem, + toggleAutoUpdate, + } = useSearch(); + const { saved } = useSaved(); const searchHistory = state.successfulQueryHistory; const activeSuppliers = useMemo( @@ -204,31 +212,61 @@ export const SideNav = () => { })} - + {searchHistoryItems.length === 0 ? ( {'No history yet'} ) : ( - {searchHistoryItems.map(({ label, query, resultsCount }) => { - return ( - { - handleEnterQuery(query); - }} - onClickAriaLabel="Apply filters" - > - {label}{' '} - - {resultsCount === 30 ? '30+' : resultsCount} + {searchHistoryItems + .slice(0, 6) + .map(({ label, query, resultsCount }) => { + return ( + { + handleEnterQuery(query); + }} + onClickAriaLabel="Apply filters" + > + {label}{' '} + + {resultsCount === 30 ? '30+' : resultsCount} + - - ); - })} + ); + })} )} + + {saved.length === 0 ? ( + {'No saved items'} + ) : ( + + {saved.map((item) => ( + handleSelectItem(item.id.toString())} + size="s" + /> + ))} + + )} +

diff --git a/newswires/client/src/WireItemTable.tsx b/newswires/client/src/WireItemTable.tsx index 755c1e0..906e56d 100644 --- a/newswires/client/src/WireItemTable.tsx +++ b/newswires/client/src/WireItemTable.tsx @@ -1,4 +1,5 @@ import { + EuiButtonIcon, EuiFlexGroup, euiScreenReaderOnly, EuiTable, @@ -13,9 +14,11 @@ import { useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; +import { useMemo } from 'react'; import sanitizeHtml from 'sanitize-html'; import { formatTimestamp } from './formatTimestamp'; import type { WireData } from './sharedTypes'; +import { useSaved } from './useFavourites'; import { useSearch } from './useSearch'; const fadeOutBackground = css` @@ -81,10 +84,16 @@ const WireDataRow = ({ handleSelect: (id: string) => void; }) => { const theme = useEuiTheme(); + const { saved, addSaved, removeSaved } = useSaved(); + const primaryBgColor = useEuiBackgroundColor('primary'); const accentBgColor = useEuiBackgroundColor('accent'); const hasSlug = content.slug && content.slug.length > 0; + const isSaved = useMemo( + () => saved.map((s) => s.id).includes(id.toString()), + [saved, id], + ); return ( + + {isSaved ? ( + ) => { + e.stopPropagation(); + removeSaved({ + id: id.toString(), + headline: content.slug ?? content.headline ?? '', + }); + }} + /> + ) : ( + ) => { + e.stopPropagation(); + addSaved({ + id: id.toString(), + headline: content.slug ?? content.headline ?? '', + }); + }} + /> + )} + ); }; diff --git a/newswires/client/src/icons.ts b/newswires/client/src/icons.ts index 65c6af9..6c80c19 100644 --- a/newswires/client/src/icons.ts +++ b/newswires/client/src/icons.ts @@ -26,6 +26,8 @@ import { icon as popout } from '@elastic/eui/es/components/icon/assets/popout'; import { icon as refresh } from '@elastic/eui/es/components/icon/assets/refresh'; import { icon as returnKey } from '@elastic/eui/es/components/icon/assets/return_key'; import { icon as search } from '@elastic/eui/es/components/icon/assets/search'; +import { icon as starEmpty } from '@elastic/eui/es/components/icon/assets/star_empty'; +import { icon as starFilled } from '@elastic/eui/es/components/icon/assets/star_filled'; import { icon as warning } from '@elastic/eui/es/components/icon/assets/warning'; import { appendIconComponentCache } from '@elastic/eui/es/components/icon/icon'; @@ -57,4 +59,6 @@ appendIconComponentCache({ link, copyClipboard, popout, + starEmpty, + starFilled, }); diff --git a/newswires/client/src/main.tsx b/newswires/client/src/main.tsx index 93e6b70..ee3e829 100644 --- a/newswires/client/src/main.tsx +++ b/newswires/client/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App.tsx'; import './icons'; +import { SavedContextProvider } from './useFavourites.tsx'; import { SearchContextProvider } from './useSearch.tsx'; const toolsDomain = window.location.hostname.substring( @@ -14,7 +15,9 @@ document.head.appendChild(script); createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/newswires/client/src/useFavourites.tsx b/newswires/client/src/useFavourites.tsx new file mode 100644 index 0000000..08d57e0 --- /dev/null +++ b/newswires/client/src/useFavourites.tsx @@ -0,0 +1,63 @@ +import type { ReactNode } from 'react'; +import { createContext, useContext, useState } from 'react'; +import { z } from 'zod'; + +const savedItemSchema = z.object({ id: z.string(), headline: z.string() }); +type SavedItem = z.infer; +const savedSchema = z.array(savedItemSchema); +type Saved = z.infer; + +const loadSavedFromStorage = () => { + const storedSaved = localStorage.getItem('saved'); + const parsedSavedResult = savedSchema.safeParse( + JSON.parse(storedSaved ?? '[]'), + ); + if (parsedSavedResult.success) { + return parsedSavedResult.data; + } + return []; +}; + +const SavedContext = createContext< + | { + saved: Saved; + addSaved: (itemToAdd: SavedItem) => void; + removeSaved: (itemToRemove: SavedItem) => void; + } + | undefined +>(undefined); + +export const SavedContextProvider = ({ children }: { children: ReactNode }) => { + const [saved, setSaved] = useState(loadSavedFromStorage()); + + const addSaved = (itemToAdd: SavedItem) => { + setSaved((prev: Saved) => { + const newSaved = [itemToAdd, ...prev]; + localStorage.setItem('saved', JSON.stringify(newSaved)); + return newSaved; + }); + }; + + const removeSaved = (itemToRemove: SavedItem) => { + setSaved((prev) => { + const newSaved = prev.filter((i) => i.id !== itemToRemove.id); + localStorage.setItem('saved', JSON.stringify(newSaved)); + return newSaved; + }); + }; + + return ( + + {children} + + ); +}; + +// Custom hook to use the saved context +export const useSaved = () => { + const context = useContext(SavedContext); + if (!context) { + throw new Error('useSaved must be used within a SavedContextProvider'); + } + return context; +}; From dfe1b2b232dd4250d702788f4b0e1810e2257f19 Mon Sep 17 00:00:00 2001 From: Pete F Date: Wed, 23 Oct 2024 18:50:33 +0100 Subject: [PATCH 3/3] Rename 'saved' to 'savedItemsList' --- newswires/client/src/Item.tsx | 8 ++++---- newswires/client/src/SideNav.tsx | 4 ++-- newswires/client/src/WireItemTable.tsx | 4 ++-- newswires/client/src/main.tsx | 6 +++--- ...seFavourites.tsx => useSavedItemsList.tsx} | 20 ++++++++++++------- 5 files changed, 24 insertions(+), 18 deletions(-) rename newswires/client/src/{useFavourites.tsx => useSavedItemsList.tsx} (78%) diff --git a/newswires/client/src/Item.tsx b/newswires/client/src/Item.tsx index ff52ea3..07779cc 100644 --- a/newswires/client/src/Item.tsx +++ b/newswires/client/src/Item.tsx @@ -16,18 +16,18 @@ import { useEffect, useMemo, useState } from 'react'; import { pandaFetch } from './panda-session'; import { type WireData, WireDataSchema } from './sharedTypes'; import { paramsToQuerystring } from './urlState'; -import { useSaved } from './useFavourites'; +import { useSavedItems } from './useSavedItemsList'; import { useSearch } from './useSearch'; import { WireDetail } from './WireDetail'; export const Item = ({ id }: { id: string }) => { const { handleDeselectItem, config } = useSearch(); - const { saved, addSaved, removeSaved } = useSaved(); + const { savedItems, addSaved, removeSaved } = useSavedItems(); const [itemData, setItemData] = useState(undefined); const isSaved = useMemo( - () => saved.map((s) => s.id).includes(id), - [saved, id], + () => savedItems.map((s) => s.id).includes(id), + [savedItems, id], ); const [error, setError] = useState(undefined); diff --git a/newswires/client/src/SideNav.tsx b/newswires/client/src/SideNav.tsx index f9995d4..6d89266 100644 --- a/newswires/client/src/SideNav.tsx +++ b/newswires/client/src/SideNav.tsx @@ -18,7 +18,7 @@ import { useCallback, useMemo, useState } from 'react'; import { SearchBox } from './SearchBox'; import { brandColours } from './sharedStyles'; import type { Query } from './sharedTypes'; -import { useSaved } from './useFavourites'; +import { useSavedItems } from './useSavedItemsList'; import { useSearch } from './useSearch'; function decideLabelForQueryBadge(query: Query): string { @@ -52,7 +52,7 @@ export const SideNav = () => { handleSelectItem, toggleAutoUpdate, } = useSearch(); - const { saved } = useSaved(); + const { savedItems: saved } = useSavedItems(); const searchHistory = state.successfulQueryHistory; const activeSuppliers = useMemo( diff --git a/newswires/client/src/WireItemTable.tsx b/newswires/client/src/WireItemTable.tsx index 906e56d..81e91bd 100644 --- a/newswires/client/src/WireItemTable.tsx +++ b/newswires/client/src/WireItemTable.tsx @@ -18,7 +18,7 @@ import { useMemo } from 'react'; import sanitizeHtml from 'sanitize-html'; import { formatTimestamp } from './formatTimestamp'; import type { WireData } from './sharedTypes'; -import { useSaved } from './useFavourites'; +import { useSavedItems } from './useSavedItemsList'; import { useSearch } from './useSearch'; const fadeOutBackground = css` @@ -84,7 +84,7 @@ const WireDataRow = ({ handleSelect: (id: string) => void; }) => { const theme = useEuiTheme(); - const { saved, addSaved, removeSaved } = useSaved(); + const { savedItems: saved, addSaved, removeSaved } = useSavedItems(); const primaryBgColor = useEuiBackgroundColor('primary'); const accentBgColor = useEuiBackgroundColor('accent'); diff --git a/newswires/client/src/main.tsx b/newswires/client/src/main.tsx index ee3e829..798070f 100644 --- a/newswires/client/src/main.tsx +++ b/newswires/client/src/main.tsx @@ -2,7 +2,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App.tsx'; import './icons'; -import { SavedContextProvider } from './useFavourites.tsx'; +import { SavedItemsListContextProvider } from './useSavedItemsList.tsx'; import { SearchContextProvider } from './useSearch.tsx'; const toolsDomain = window.location.hostname.substring( @@ -15,9 +15,9 @@ document.head.appendChild(script); createRoot(document.getElementById('root')!).render( - + - + , ); diff --git a/newswires/client/src/useFavourites.tsx b/newswires/client/src/useSavedItemsList.tsx similarity index 78% rename from newswires/client/src/useFavourites.tsx rename to newswires/client/src/useSavedItemsList.tsx index 08d57e0..ac8dc0c 100644 --- a/newswires/client/src/useFavourites.tsx +++ b/newswires/client/src/useSavedItemsList.tsx @@ -20,18 +20,22 @@ const loadSavedFromStorage = () => { const SavedContext = createContext< | { - saved: Saved; + savedItems: Saved; addSaved: (itemToAdd: SavedItem) => void; removeSaved: (itemToRemove: SavedItem) => void; } | undefined >(undefined); -export const SavedContextProvider = ({ children }: { children: ReactNode }) => { - const [saved, setSaved] = useState(loadSavedFromStorage()); +export const SavedItemsListContextProvider = ({ + children, +}: { + children: ReactNode; +}) => { + const [savedItems, setSavedItems] = useState(loadSavedFromStorage()); const addSaved = (itemToAdd: SavedItem) => { - setSaved((prev: Saved) => { + setSavedItems((prev: Saved) => { const newSaved = [itemToAdd, ...prev]; localStorage.setItem('saved', JSON.stringify(newSaved)); return newSaved; @@ -39,7 +43,7 @@ export const SavedContextProvider = ({ children }: { children: ReactNode }) => { }; const removeSaved = (itemToRemove: SavedItem) => { - setSaved((prev) => { + setSavedItems((prev) => { const newSaved = prev.filter((i) => i.id !== itemToRemove.id); localStorage.setItem('saved', JSON.stringify(newSaved)); return newSaved; @@ -47,14 +51,16 @@ export const SavedContextProvider = ({ children }: { children: ReactNode }) => { }; return ( - + {children} ); }; // Custom hook to use the saved context -export const useSaved = () => { +export const useSavedItems = () => { const context = useContext(SavedContext); if (!context) { throw new Error('useSaved must be used within a SavedContextProvider');