diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 338291c..de40e63 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,9 +29,13 @@ import { Area, } from "recharts"; import GrantWorldMap from "./components/GrantWorldMap"; -import OverviewDashboard, { type OverviewFilters } from "./components/OverviewDashboard"; +import OverviewDashboard, { type FavoriteGrantExplorerPayload, type OverviewFilters } from "./components/OverviewDashboard"; import AppHeader from "./components/AppHeader"; -import DonorDirectoryPage, { type FavoriteDonorPayload, type HeaderContextState } from "./components/DonorDirectoryPage"; +import DonorDirectoryPage, { + type FavoriteDonorPayload, + type FavoriteDonorRequestPayload, + type HeaderContextState, +} from "./components/DonorDirectoryPage"; import { applyGrantScopeToParams, grantScopeFromUrl, @@ -110,20 +114,18 @@ type FavoriteResearchView = { savedAt: number; }; -type FavoriteLandscapeView = { - key: string; - label: string; - route: string; - savedAt: number; -}; - type FavoritesState = { profiles: FavoriteProfile[]; donors: FavoriteDonorPayload[]; + donorRequests: FavoriteDonorRequestPayload[]; researchViews: FavoriteResearchView[]; - landscapeViews: FavoriteLandscapeView[]; + grantExplorers: FavoriteGrantExplorerPayload[]; }; +type FavoriteDonorWorkspace = + | { kind: "donor"; item: FavoriteDonorPayload } + | { kind: "request"; item: FavoriteDonorRequestPayload }; + type NewsSourceItem = { title: string; link: string; @@ -150,7 +152,7 @@ type SavedNewsRun = NewsSummaryPayload & { const FAVORITES_STORAGE_KEY = "foundation-intelligence-favorites-v1"; const NEWS_RUN_STORAGE_KEY = "foundation-intelligence-news-runs-v1"; -const EMPTY_FAVORITES: FavoritesState = { profiles: [], donors: [], researchViews: [], landscapeViews: [] }; +const EMPTY_FAVORITES: FavoritesState = { profiles: [], donors: [], donorRequests: [], researchViews: [], grantExplorers: [] }; const NEWS_PROGRESS_STEPS: Array<{ key: NewsProgressStep; label: string; detail: string }> = [ { key: "discovering", label: "Find recent coverage", detail: "Search current Google News coverage for this organization." }, { key: "reading", label: "Read source articles", detail: "Resolve publisher links and extract article evidence." }, @@ -165,8 +167,9 @@ function loadFavorites(): FavoritesState { return { profiles: Array.isArray(parsed.profiles) ? parsed.profiles : [], donors: Array.isArray(parsed.donors) ? parsed.donors : [], + donorRequests: Array.isArray(parsed.donorRequests) ? parsed.donorRequests : [], researchViews: Array.isArray(parsed.researchViews) ? parsed.researchViews : [], - landscapeViews: Array.isArray(parsed.landscapeViews) ? parsed.landscapeViews : [], + grantExplorers: Array.isArray(parsed.grantExplorers) ? parsed.grantExplorers : [], }; } catch { return EMPTY_FAVORITES; @@ -556,7 +559,10 @@ export default function App() { }); const [profileFiltersOpen, setProfileFiltersOpen] = useState(false); const [favorites, setFavorites] = useState(loadFavorites); - const [donorReturnToFavorites, setDonorReturnToFavorites] = useState(false); + const [favoriteDonorWorkspace, setFavoriteDonorWorkspace] = useState(null); + const [favoriteGrantExplorer, setFavoriteGrantExplorer] = useState(null); + const favoriteReturnUrlRef = useRef(null); + const favoriteReturnScrollRef = useRef(0); const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { const saved = window.localStorage.getItem("sidebar-collapsed"); return saved === null ? window.innerWidth < 1280 : saved === "true"; @@ -1360,7 +1366,6 @@ export default function App() { view: "overview" | "donors" | "research" | "registry" | "favorites" | "pipeline", mode: "push" | "replace" = "push", ) => { - setDonorReturnToFavorites(false); const query = new URLSearchParams(window.location.search); if (view === "overview") { query.delete("view"); @@ -1529,44 +1534,20 @@ export default function App() { setFavorites(current => ({ ...current, researchViews: current.researchViews.filter(item => item.key !== key) })); }; - const landscapeFavorite = (filters: OverviewFilters): FavoriteLandscapeView => { - const query = applyGrantScopeToParams( - new URLSearchParams(), - { - currency: filters.currency || undefined, - dateFrom: filters.dateFrom || undefined, - dateTo: filters.dateTo || undefined, - beneficiaryGeographies: filters.beneficiaryGeographies, - programmeAreas: filters.programmeAreas, - donor: filters.donor || undefined, - recipient: filters.recipient || undefined, - sources: selectedDataSources, - }, - { persistEmptySources: true }, - ); - if (filters.granularity !== "auto") query.set("grant_granularity", filters.granularity); - const route = query.toString() ? `?${query.toString()}` : ""; - const activeTerms = [filters.donor, filters.recipient, filters.beneficiaryGeographies[0], filters.programmeAreas[0]].filter(Boolean); - return { - key: `landscape:${route}`, - label: activeTerms.length ? `Funding Landscape · ${activeTerms.join(" · ")}` : "Funding Landscape", - route, - savedAt: Date.now(), - }; + const openFavoriteProfile = (favorite: FavoriteProfile) => { + setSelectedCharity(favorite.profile); }; - const isFavoriteLandscape = (filters: OverviewFilters) => - favorites.landscapeViews.some(view => view.key === landscapeFavorite(filters).key); - - const toggleFavoriteLandscape = (filters: OverviewFilters) => { - const view = landscapeFavorite(filters); - setFavorites(current => current.landscapeViews.some(item => item.key === view.key) - ? { ...current, landscapeViews: current.landscapeViews.filter(item => item.key !== view.key) } - : { ...current, landscapeViews: [{ ...view, savedAt: Date.now() }, ...current.landscapeViews] }); + const toggleFavoriteDonorRequest = (request: FavoriteDonorRequestPayload) => { + setFavorites(current => current.donorRequests.some(item => item.key === request.key) + ? { ...current, donorRequests: current.donorRequests.filter(item => item.key !== request.key) } + : { ...current, donorRequests: [{ ...request, savedAt: Date.now() }, ...current.donorRequests] }); }; - const openFavoriteProfile = (favorite: FavoriteProfile) => { - setSelectedCharity(favorite.profile); + const toggleFavoriteGrantExplorer = (favorite: FavoriteGrantExplorerPayload) => { + setFavorites(current => current.grantExplorers.some(item => item.key === favorite.key) + ? { ...current, grantExplorers: current.grantExplorers.filter(item => item.key !== favorite.key) } + : { ...current, grantExplorers: [{ ...favorite, savedAt: Date.now() }, ...current.grantExplorers] }); }; const restoreSourcesFromFavoriteRoute = (route: string) => { @@ -1576,20 +1557,63 @@ export default function App() { setDataSourceSelections(Object.fromEntries(dataSourceNames.map(source => [source, selected.has(source)]))); }; + const openFavoriteDonorWorkspace = (workspace: FavoriteDonorWorkspace) => { + const route = workspace.item.route; + favoriteReturnUrlRef.current = `${window.location.pathname}${window.location.search}`; + favoriteReturnScrollRef.current = window.scrollY; + restoreSourcesFromFavoriteRoute(route); + const query = new URLSearchParams(route.startsWith("?") ? route.slice(1) : route); + query.set("view", "favorites"); + if (workspace.kind === "donor") query.set("donor", workspace.item.key); + else query.delete("donor"); + window.history.pushState({}, "", `${window.location.pathname}?${query.toString()}`); + setSelectedCharity(null); + setFavoriteGrantExplorer(null); + setFavoriteDonorWorkspace(workspace); + setActiveTab("favorites"); + setMobileNavigationOpen(false); + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + const closeFavoriteDonorWorkspace = () => { + const returnUrl = favoriteReturnUrlRef.current || `${window.location.pathname}?view=favorites`; + const scrollPosition = favoriteReturnScrollRef.current; + setFavoriteDonorWorkspace(null); + window.history.replaceState({}, "", returnUrl); + window.requestAnimationFrame(() => window.scrollTo({ top: scrollPosition, behavior: "smooth" })); + }; + const openFavoriteDonor = (favorite: FavoriteDonorPayload) => { + openFavoriteDonorWorkspace({ kind: "donor", item: favorite }); + }; + + const openFavoriteDonorRequest = (request: FavoriteDonorRequestPayload) => { + openFavoriteDonorWorkspace({ kind: "request", item: request }); + }; + + const openFavoriteGrantExplorer = (favorite: FavoriteGrantExplorerPayload) => { + favoriteReturnUrlRef.current = `${window.location.pathname}${window.location.search}`; + favoriteReturnScrollRef.current = window.scrollY; restoreSourcesFromFavoriteRoute(favorite.route); const query = new URLSearchParams(favorite.route.startsWith("?") ? favorite.route.slice(1) : favorite.route); - query.set("view", "donors"); - query.set("donor", favorite.key); + query.set("view", "favorites"); window.history.pushState({}, "", `${window.location.pathname}?${query.toString()}`); setSelectedCharity(null); - setDonorReturnToFavorites(true); - setDirectoryMode("donors"); - setActiveTab("directory"); + setFavoriteDonorWorkspace(null); + setFavoriteGrantExplorer(favorite); + setActiveTab("favorites"); setMobileNavigationOpen(false); window.scrollTo({ top: 0, behavior: "smooth" }); }; + const closeFavoriteGrantExplorer = () => { + const returnUrl = favoriteReturnUrlRef.current || `${window.location.pathname}?view=favorites`; + const scrollPosition = favoriteReturnScrollRef.current; + setFavoriteGrantExplorer(null); + window.history.replaceState({}, "", returnUrl); + window.requestAnimationFrame(() => window.scrollTo({ top: scrollPosition, behavior: "smooth" })); + }; + const openFavoriteResearch = (favorite: FavoriteResearchView) => { const { filters } = favorite; setSearchTerm(filters.searchTerm); @@ -1607,15 +1631,6 @@ export default function App() { window.scrollTo({ top: 0, behavior: "smooth" }); }; - const openFavoriteLandscape = (favorite: FavoriteLandscapeView) => { - restoreSourcesFromFavoriteRoute(favorite.route); - window.history.pushState({}, "", `${window.location.pathname}${favorite.route}`); - setSelectedCharity(null); - setActiveTab("overview"); - setMobileNavigationOpen(false); - window.scrollTo({ top: 0, behavior: "smooth" }); - }; - const fetchGrantAnalytics = async (forceOnline?: boolean, currencyOverride?: string) => { const isOnline = forceOnline !== undefined ? forceOnline : isBffOnline; if (!isOnline) { @@ -2084,97 +2099,164 @@ export default function App() { online={isBffOnline} selectedSources={selectedDataSources} onOpenOrganizationDirectory={openOrganizationDirectoryFromMap} + onOpenProfile={(profileId, profileName) => openLinkedDirectoryProfile({ charity_id: profileId, name: profileName })} + onSearchOrganization={(organizationName) => openOrganizationDirectoryFromMap({ ...EMPTY_GRANT_MAP_FILTERS, search: organizationName })} onExploreSourceFunders={openSourceFundersFromMap} - onToggleFavoriteLandscape={toggleFavoriteLandscape} - isFavoriteLandscape={isFavoriteLandscape} + favoriteGrantExplorerKeys={favorites.grantExplorers.map(favorite => favorite.key)} + onToggleFavoriteGrantExplorer={toggleFavoriteGrantExplorer} /> )} {activeTab === "favorites" && ( -
-
-
- Personal workspace -

Favorites

-

Keep the organizations, observed donors, research configurations, and Funding Landscapes you want to revisit.

+ favoriteGrantExplorer ? ( + openLinkedDirectoryProfile({ charity_id: profileId, name: profileName })} + onSearchOrganization={(organizationName) => openOrganizationDirectoryFromMap({ ...EMPTY_GRANT_MAP_FILTERS, search: organizationName })} + onExploreSourceFunders={openSourceFundersFromMap} + favoriteGrantExplorerKeys={favorites.grantExplorers.map(favorite => favorite.key)} + onToggleFavoriteGrantExplorer={toggleFavoriteGrantExplorer} + presentation="favorite-explorer" + initialDrilldown={favoriteGrantExplorer.selection} + onBackToFavorites={closeFavoriteGrantExplorer} + /> + ) : favoriteDonorWorkspace ? ( + closeFavoriteDonorWorkspace()} + onOpenOrganizationResearch={() => navigateApplication("research")} + onOpenRegistrySearch={() => navigateApplication("registry")} + onOpenProfile={(profileId, profileName) => openLinkedDirectoryProfile({ charity_id: profileId, name: profileName })} + favoriteDonorKeys={favorites.donors.map(donor => donor.key)} + onToggleFavoriteDonor={toggleFavoriteDonor} + favoriteDonorRequestKeys={favorites.donorRequests.map(request => request.key)} + onToggleFavoriteDonorRequest={toggleFavoriteDonorRequest} + presentation={favoriteDonorWorkspace.kind === "donor" ? "favorite-donor" : "favorite-request"} + onBackToFavorites={closeFavoriteDonorWorkspace} + /> + ) : ( +
+
+
+ Personal workspace +

Favorites

+

Keep organizations, observed donors, grant explorations, donor requests, and research configurations you want to revisit.

+
- -
- {favorites.profiles.length + favorites.donors.length + favorites.researchViews.length + favorites.landscapeViews.length === 0 ? ( -
- -

No favorites yet

-

Use a star on an organization, observed donor, research view, or Funding Landscape to keep it here.

-
- ) : ( -
- {(favorites.profiles.length > 0 || favorites.donors.length > 0) && ( -
-
-
Organizations

Pinned organizations & donors

- {favorites.profiles.length + favorites.donors.length} -
-
- {favorites.profiles.map(favorite => ( -
- - -
- ))} - {favorites.donors.map(favorite => ( -
- - -
- ))} -
-
- )} + {favorites.profiles.length + favorites.donors.length + favorites.donorRequests.length + favorites.researchViews.length + favorites.grantExplorers.length === 0 ? ( +
+ +

No favorites yet

+

Use a star on an organization, observed donor, grant exploration, donor request, or research view to keep it here.

+
+ ) : ( +
+ {(favorites.profiles.length > 0 || favorites.donors.length > 0) && ( +
+
+
Organizations

Pinned organizations & donors

+ {favorites.profiles.length + favorites.donors.length} +
+
+ {favorites.profiles.map(favorite => ( +
+ + +
+ ))} + {favorites.donors.map(favorite => ( +
+ + +
+ ))} +
+
+ )} - {(favorites.researchViews.length > 0 || favorites.landscapeViews.length > 0) && ( -
-
-
Saved views

Research & Funding Landscapes

- {favorites.researchViews.length + favorites.landscapeViews.length} -
-
- {favorites.researchViews.map(favorite => ( -
- - -
- ))} - {favorites.landscapeViews.map(favorite => ( -
- - -
- ))} -
-
- )} -
- )} -
+ {favorites.donorRequests.length > 0 && ( +
+
+
Observed grant data

Saved donor requests

+ {favorites.donorRequests.length} +
+
+ {favorites.donorRequests.map(favorite => ( +
+ + +
+ ))} +
+
+ )} + + {favorites.grantExplorers.length > 0 && ( +
+
+
Observed grant data

Saved grant explorations

+ {favorites.grantExplorers.length} +
+
+ {favorites.grantExplorers.map(favorite => ( +
+ + +
+ ))} +
+
+ )} + + {favorites.researchViews.length > 0 && ( +
+
+
Saved views

Organization Research

+ {favorites.researchViews.length} +
+
+ {favorites.researchViews.map(favorite => ( +
+ + +
+ ))} +
+
+ )} + + )} + + ) )} {/* Retained for rollback only; the active Overview is the compact, globally-filtered dashboard above. */} @@ -2413,13 +2495,8 @@ export default function App() { })} favoriteDonorKeys={favorites.donors.map(donor => donor.key)} onToggleFavoriteDonor={toggleFavoriteDonor} - onCloseFavoriteDonor={donorReturnToFavorites ? () => { - setDonorReturnToFavorites(false); - window.history.replaceState({}, "", `${window.location.pathname}?view=favorites`); - setActiveTab("favorites"); - setMobileNavigationOpen(false); - window.scrollTo({ top: 0, behavior: "smooth" }); - } : undefined} + favoriteDonorRequestKeys={favorites.donorRequests.map(request => request.key)} + onToggleFavoriteDonorRequest={toggleFavoriteDonorRequest} /> )} diff --git a/frontend/src/components/DonorDirectoryPage.tsx b/frontend/src/components/DonorDirectoryPage.tsx index cd341c7..ffd08f3 100644 --- a/frontend/src/components/DonorDirectoryPage.tsx +++ b/frontend/src/components/DonorDirectoryPage.tsx @@ -85,6 +85,13 @@ export type FavoriteDonorPayload = { savedAt: number; }; +export type FavoriteDonorRequestPayload = { + key: string; + label: string; + route: string; + savedAt: number; +}; + type DonorListResponse = { status: string; country: { code: string; name: string }; @@ -105,6 +112,8 @@ type EvidenceLink = { kind: string; label: string; role?: string | null; + organization_name?: string | null; + link_type?: "website" | "json" | string | null; url: string; origin: string; }; @@ -159,7 +168,10 @@ interface Props { onOpenProfile: (profileId: number, profileName: string) => void; favoriteDonorKeys: string[]; onToggleFavoriteDonor: (donor: FavoriteDonorPayload) => void; - onCloseFavoriteDonor?: () => void; + favoriteDonorRequestKeys: string[]; + onToggleFavoriteDonorRequest: (request: FavoriteDonorRequestPayload) => void; + presentation?: "default" | "favorite-donor" | "favorite-request"; + onBackToFavorites?: () => void; } function routeState( @@ -206,6 +218,23 @@ function profileStatusLabel(profile: ProfileLink): string { return "Observed only"; } +function evidenceRole(evidence: EvidenceLink): "funder" | "recipient" | "publisher" { + if (evidence.role === "recipient" || evidence.role === "publisher") return evidence.role; + if (evidence.kind.includes("publisher")) return "publisher"; + return "funder"; +} + +function evidenceLinkType(evidence: EvidenceLink): "website" | "json" { + if (evidence.link_type === "json" || evidence.kind.endsWith("_record")) return "json"; + return "website"; +} + +function evidenceOrganizationName(evidence: EvidenceLink): string { + if (evidence.organization_name?.trim()) return evidence.organization_name; + const role = evidenceRole(evidence); + return role === "recipient" ? "Recipient organization" : role === "publisher" ? "Publisher" : "Funder organization"; +} + export default function DonorDirectoryPage({ apiBase, online, @@ -217,8 +246,14 @@ export default function DonorDirectoryPage({ onOpenProfile, favoriteDonorKeys, onToggleFavoriteDonor, - onCloseFavoriteDonor, + favoriteDonorRequestKeys, + onToggleFavoriteDonorRequest, + presentation = "default", + onBackToFavorites, }: Props) { + const isFavoriteDetail = presentation === "favorite-donor"; + const isFavoriteRequest = presentation === "favorite-request"; + const isFavoritePresentation = isFavoriteDetail || isFavoriteRequest; const initial = useMemo(() => routeState(selectedSources), [selectedSources]); const [scope, setScope] = useState(initial.scope); const [draft, setDraft] = useState(initial.scope); @@ -233,6 +268,10 @@ export default function DonorDirectoryPage({ const [detailLoading, setDetailLoading] = useState(false); const [detailError, setDetailError] = useState(null); const [activityLoaded, setActivityLoaded] = useState(false); + const [activitySectionOpen, setActivitySectionOpen] = useState(isFavoriteDetail); + const [evidenceSettingsOpen, setEvidenceSettingsOpen] = useState(false); + const [evidenceRoleVisibility, setEvidenceRoleVisibility] = useState({ funder: true, recipient: true, publisher: true }); + const [evidenceTypeVisibility, setEvidenceTypeVisibility] = useState({ website: true, json: true }); const requestVersion = useRef(0); const detailVersion = useRef(0); const listScrollPosition = useRef(0); @@ -422,16 +461,16 @@ export default function DonorDirectoryPage({ useEffect(() => { if (!selectedKey) return; - return fetchDetail(selectedKey, false); - }, [fetchDetail, selectedKey]); + return fetchDetail(selectedKey, isFavoriteDetail); + }, [fetchDetail, isFavoriteDetail, selectedKey]); useEffect(() => { if (!selectedKey) return; detailRef.current?.focus(); const closeOnEscape = (event: KeyboardEvent) => { if (event.key !== "Escape") return; - if (onCloseFavoriteDonor) { - onCloseFavoriteDonor(); + if (onBackToFavorites) { + onBackToFavorites(); return; } if (window.history.state?.donorDetail) { @@ -447,7 +486,7 @@ export default function DonorDirectoryPage({ }; window.addEventListener("keydown", closeOnEscape); return () => window.removeEventListener("keydown", closeOnEscape); - }, [directory, onCloseFavoriteDonor, scope, selectedKey]); + }, [directory, onBackToFavorites, scope, selectedKey]); useEffect(() => { if (!selectedKey || !detail) return; @@ -488,8 +527,8 @@ export default function DonorDirectoryPage({ }; const closeDetail = () => { - if (onCloseFavoriteDonor) { - onCloseFavoriteDonor(); + if (onBackToFavorites) { + onBackToFavorites(); return; } if (window.history.state?.donorDetail) { @@ -550,21 +589,73 @@ export default function DonorDirectoryPage({ country, savedAt: Date.now(), }); + const favoriteRequestPayload = (): FavoriteDonorRequestPayload => { + const params = applyGrantScopeToParams( + new URLSearchParams(), + scope, + { persistEmptySources: true }, + ); + applyDonorDirectoryStateToParams(params, { ...directory, page: 1, donorKey: undefined }); + params.set("view", "donors"); + const route = `?${params.toString()}`; + const description = [ + countryLabel, + directory.search.trim() ? `“${directory.search.trim()}”` : "", + directory.status !== "all" ? directory.status.replace("_", " ") : "", + ].filter(Boolean).join(" · "); + return { + key: `donor-request:${route}`, + label: description || "Observed donor request", + route, + savedAt: Date.now(), + }; + }; + const currentFavoriteRequest = favoriteRequestPayload(); + const requestIsFavorite = favoriteDonorRequestKeys.includes(currentFavoriteRequest.key); + const visibleEvidence = useMemo(() => (detail?.source_evidence || []) + .filter(evidence => ( + evidenceRoleVisibility[evidenceRole(evidence)] + && evidenceTypeVisibility[evidenceLinkType(evidence)] + )) + .sort((left, right) => { + const typeOrder = Number(evidenceLinkType(left) === "json") - Number(evidenceLinkType(right) === "json"); + if (typeOrder) return typeOrder; + return evidenceOrganizationName(left).localeCompare(evidenceOrganizationName(right)); + }), [detail?.source_evidence, evidenceRoleVisibility, evidenceTypeVisibility]); return ( -
+
- Observed grant relationships -

Donor Directory

-

{countryLabel ? `Funders observed in ${countryLabel}.` : "Funders observed in the selected beneficiary geography."}

+ {isFavoritePresentation ? "Favorites" : "Observed grant relationships"} +

{isFavoriteDetail ? "Saved donor activity" : isFavoriteRequest ? "Saved donor request" : "Donor Directory"}

+

{isFavoriteDetail ? "Observed grants, recipients, and source evidence for this pinned donor." : countryLabel ? `Funders observed in ${countryLabel}.` : "Funders observed in the selected beneficiary geography."}

+
+
+ {isFavoritePresentation ? ( + + ) : ( + + )} + {!isFavoriteDetail && ( + + )}
-
- {chips.length > 0 && ( + {!isFavoriteDetail && chips.length > 0 && (
{chips.map(chip => (
)} -
+ {!isFavoriteDetail &&
Other research paths
-
+
} {!scope.beneficiaryCountry ? (
@@ -590,7 +681,7 @@ export default function DonorDirectoryPage({
) : ( <> -
+ {!isFavoriteDetail &&
-
+
} -
+ {!isFavoriteDetail &&
-
+
} {error &&
{error}
} {!online &&
The local backend is required for observed donor results.
} {loading &&
Loading observed donors…
} - {!loading && result?.items.length === 0 && ( + {!isFavoriteDetail && !loading && result?.items.length === 0 && (

No observed funders match this scope

Try a shorter donor search or fewer grant-scope filters. This result does not imply that no organization exists.

@@ -629,9 +720,9 @@ export default function DonorDirectoryPage({
)} - {!loading && result && result.items.length > 0 && ( + {!loading && result && (result.items.length > 0 || (isFavoriteDetail && Boolean(selectedKey))) && (
-
+ {!isFavoriteDetail &&
{result.pagination.total_items.toLocaleString("en-GB")} observed funders {result.summary.matching_grant_count.toLocaleString("en-GB")} matching grants @@ -679,7 +770,7 @@ export default function DonorDirectoryPage({ )} -
+
} {selectedKey && (
-
{ +
{ const section = event.currentTarget as HTMLDetailsElement; + setActivitySectionOpen(section.open); if (!section.open) return; window.requestAnimationFrame(() => section.scrollIntoView({ behavior: "smooth", block: "start" })); if (!activityLoaded && !detailLoading) fetchDetail(selectedKey, true); @@ -775,12 +867,28 @@ export default function DonorDirectoryPage({ Source organization identifier{detail.funder.identity.source_organization_id || detail.funder.identity.normalized_name_fallback || "Not supplied"} {(detail.funder.identity.source_organization_id || detail.funder.identity.normalized_name_fallback) && } - {detail.source_evidence.length ? detail.source_evidence.map((evidence, index) => ( - - {evidence.label}{evidence.origin.replaceAll("_", " ")}{evidence.role ? ` · ${evidence.role}` : ""} - - )) :

No safe HTTP(S) evidence link is stored for this sample.

} + {detail.source_evidence.length ? <> +
+ {visibleEvidence.length} of {detail.source_evidence.length} links shown + +
+ {evidenceSettingsOpen &&
+
Organization role{(["funder", "recipient", "publisher"] as const).map(role => )}
+
Link type{(["website", "json"] as const).map(type => )}
+ +
} + {visibleEvidence.length ? visibleEvidence.map((evidence, index) => { + const role = evidenceRole(evidence); + const linkType = evidenceLinkType(evidence); + return + {evidenceOrganizationName(evidence)}{role[0].toUpperCase() + role.slice(1)} {linkType === "json" ? "record" : "website"} · stored source record + {linkType === "json" ? "JSON" : "Website"} + ; + }) :

No evidence matches the selected settings.

} + :

No safe HTTP(S) evidence link is stored for this sample.

}

Links come from stored source records. The platform does not fetch, preflight, proxy, or verify external destinations.

)} diff --git a/frontend/src/components/OverviewDashboard.tsx b/frontend/src/components/OverviewDashboard.tsx index 95868b8..d2b7326 100644 --- a/frontend/src/components/OverviewDashboard.tsx +++ b/frontend/src/components/OverviewDashboard.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ChevronRight, LoaderCircle, SlidersHorizontal, Star, X } from "lucide-react"; +import { ArrowLeft, Building2, CalendarRange, ChevronRight, ExternalLink, LoaderCircle, Search, SlidersHorizontal, Star, X } from "lucide-react"; import { Bar, BarChart, @@ -96,14 +96,94 @@ interface OverviewPayload { available_date_range: { from: string | null; to: string | null }; } +interface EntitySuggestion { + name: string; + grant_count: number; +} + +interface EntitySuggestionResponse { + status: string; + donors: EntitySuggestion[]; + recipients: EntitySuggestion[]; +} + +type DrilldownSelection = { type: "period" | "programme_area"; value: string }; +type DrilldownTab = "funders" | "recipients" | "grants"; +export type FavoriteGrantExplorerPayload = { + key: string; + label: string; + route: string; + selection: DrilldownSelection; + savedAt: number; +}; +type DrilldownProfile = { id: number; name: string } | null; +interface DrilldownEntity { + funder_key?: string; + recipient_key?: string; + name: string; + grant_count: number; + funding_total: number | null; + currency: string | null; + profile: DrilldownProfile; +} +interface DrilldownGrant { + grant_id: string; + award_date: string | null; + funder_name: string; + recipient_name: string; + amount: number | null; + currency: string | null; + original_amount: number | null; + original_currency: string | null; + description: string | null; + evidence_links: Array<{ label: string; link_type: "website" | "json" | string; url: string }>; +} +interface DrilldownResponse { + status: string; + selection: { type: "period" | "programme_area"; value: string; label: string }; + summary: { + grant_count: number; + funding_total: number | null; + currency: string | null; + funder_count: number; + recipient_count: number; + country_count: number; + amount_excluded_grant_count: number; + }; + funders: DrilldownEntity[]; + recipients: DrilldownEntity[]; + countries: Array<{ country_code: string; country_name: string; grant_count: number }>; + grants: DrilldownGrant[]; +} + +// A saved exploration is normally reopened during the same application session. +// Keeping its already-resolved result here makes that transition immediate, while +// the server-side cache still covers a browser refresh or a later return visit. +const drilldownResponseCache = new Map(); +const DRILLDOWN_RESPONSE_CACHE_LIMIT = 24; + +function rememberDrilldownResponse(key: string, response: DrilldownResponse) { + drilldownResponseCache.delete(key); + drilldownResponseCache.set(key, response); + if (drilldownResponseCache.size > DRILLDOWN_RESPONSE_CACHE_LIMIT) { + const oldestKey = drilldownResponseCache.keys().next().value; + if (oldestKey !== undefined) drilldownResponseCache.delete(oldestKey); + } +} + interface Props { apiBase: string; online: boolean; selectedSources: string[]; onOpenOrganizationDirectory: (filters: GrantMapFilters) => void; + onOpenProfile: (profileId: number, profileName: string) => void; + onSearchOrganization: (organizationName: string) => void; onExploreSourceFunders: (selection: SourceFunderCountrySelection, filters: OverviewFilters) => void; - onToggleFavoriteLandscape: (filters: OverviewFilters) => void; - isFavoriteLandscape: (filters: OverviewFilters) => boolean; + favoriteGrantExplorerKeys?: string[]; + onToggleFavoriteGrantExplorer?: (favorite: FavoriteGrantExplorerPayload) => void; + presentation?: "default" | "favorite-explorer"; + initialDrilldown?: DrilldownSelection | null; + onBackToFavorites?: () => void; } const PROGRAMMES = [ @@ -169,7 +249,91 @@ function filtersFromUrl(): OverviewFilters { }; } -export default function OverviewDashboard({ apiBase, online, selectedSources, onOpenOrganizationDirectory, onExploreSourceFunders, onToggleFavoriteLandscape, isFavoriteLandscape }: Props) { +function matchingEntitySuggestions(items: EntitySuggestion[], value: string): EntitySuggestion[] { + const query = value.trim().toLocaleLowerCase(); + if (!query) return []; + const startsWith: EntitySuggestion[] = []; + const contains: EntitySuggestion[] = []; + for (const item of items) { + const name = item.name.toLocaleLowerCase(); + if (name.startsWith(query)) startsWith.push(item); + else if (name.includes(query)) contains.push(item); + if (startsWith.length + contains.length >= 7) break; + } + return [...startsWith, ...contains].slice(0, 7); +} + +function EntitySuggestionInput({ + id, + label, + value, + suggestions, + loading, + onChange, +}: { + id: string; + label: string; + value: string; + suggestions: EntitySuggestion[]; + loading: boolean; + onChange: (value: string) => void; +}) { + const [open, setOpen] = useState(false); + const matches = useMemo(() => matchingEntitySuggestions(suggestions, value), [suggestions, value]); + const showSuggestions = open && value.trim().length > 0; + const listId = `${id}-suggestions`; + + return ( +
+ +
+ setOpen(true)} + onBlur={() => window.setTimeout(() => setOpen(false), 120)} + onKeyDown={event => { + if (event.key === "Escape") setOpen(false); + }} + onChange={event => { + onChange(event.target.value); + setOpen(true); + }} + /> + {showSuggestions && ( +
+ {loading ? ( + Loading cached names… + ) : matches.length ? matches.map(item => ( + + )) : ( + No cached matches yet. + )} +
+ )} +
+
+ ); +} + +export default function OverviewDashboard({ apiBase, online, selectedSources, onOpenOrganizationDirectory, onOpenProfile, onSearchOrganization, onExploreSourceFunders, favoriteGrantExplorerKeys = [], onToggleFavoriteGrantExplorer, presentation = "default", initialDrilldown = null, onBackToFavorites }: Props) { const [filters, setFilters] = useState(filtersFromUrl); const [draft, setDraft] = useState(filters); const [payload, setPayload] = useState(null); @@ -177,7 +341,7 @@ export default function OverviewDashboard({ apiBase, online, selectedSources, on const [error, setError] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [showAllProgrammes, setShowAllProgrammes] = useState(false); - const [includeUnclassified, setIncludeUnclassified] = useState(true); + const [includeUnclassified, setIncludeUnclassified] = useState(false); const [refreshNonce, setRefreshNonce] = useState(0); const [selectedMapCountryCode, setSelectedMapCountryCode] = useState(null); const [includeConnections, setIncludeConnections] = useState(false); @@ -186,9 +350,22 @@ export default function OverviewDashboard({ apiBase, online, selectedSources, on const [trendDateTo, setTrendDateTo] = useState(filters.dateTo); const [trendOverride, setTrendOverride] = useState(null); const [trendLoading, setTrendLoading] = useState(false); + const [drilldownSelection, setDrilldownSelection] = useState(initialDrilldown); + const [drilldown, setDrilldown] = useState(null); + const [drilldownTab, setDrilldownTab] = useState("funders"); + const [drilldownLoading, setDrilldownLoading] = useState(false); + const [drilldownError, setDrilldownError] = useState(null); + const [entitySuggestions, setEntitySuggestions] = useState({ status: "idle", donors: [], recipients: [] }); + const [entitySuggestionsLoading, setEntitySuggestionsLoading] = useState(false); const requestVersion = useRef(0); const trendRequestVersion = useRef(0); + const drilldownRequestVersion = useRef(0); const drawerRef = useRef(null); + const entitySuggestionCache = useRef(new Map()); + const entitySuggestionSourceKey = useMemo( + () => [...selectedSources].map(source => source.trim()).filter(Boolean).sort().join("\u001f"), + [selectedSources], + ); const activeFilterCount = Number(Boolean(filters.currency)) + Number(Boolean(filters.dateFrom || filters.dateTo)) + filters.beneficiaryGeographies.length + filters.programmeAreas.length + Number(Boolean(filters.donor.trim())) + Number(Boolean(filters.recipient.trim())) @@ -237,7 +414,7 @@ export default function OverviewDashboard({ apiBase, online, selectedSources, on ]); useEffect(() => { - if (!online) return; + if (!online || presentation === "favorite-explorer") return; const controller = new AbortController(); const currentVersion = ++requestVersion.current; const requestScope: GrantScope = { @@ -272,10 +449,10 @@ export default function OverviewDashboard({ apiBase, online, selectedSources, on if (currentVersion === requestVersion.current) setLoading(false); }); return () => controller.abort(); - }, [apiBase, includeConnections, online, overviewRequestFilters, refreshNonce, selectedSources]); + }, [apiBase, includeConnections, online, overviewRequestFilters, presentation, refreshNonce, selectedSources]); useEffect(() => { - if (!online || filters.granularity === "auto") { + if (!online || presentation === "favorite-explorer" || filters.granularity === "auto") { setTrendOverride(null); setTrendLoading(false); return; @@ -312,7 +489,61 @@ export default function OverviewDashboard({ apiBase, online, selectedSources, on if (currentVersion === trendRequestVersion.current) setTrendLoading(false); }); return () => controller.abort(); - }, [apiBase, filters, online, selectedSources]); + }, [apiBase, filters, online, presentation, selectedSources]); + + useEffect(() => { + if (!drilldownSelection) return; + if (!online) { + setDrilldown(null); + setDrilldownError("Observed grant details require the connected data service."); + return; + } + const currentVersion = ++drilldownRequestVersion.current; + const params = grantScopeToApiParams({ + currency: filters.currency || undefined, + dateFrom: filters.dateFrom || undefined, + dateTo: filters.dateTo || undefined, + beneficiaryGeographies: filters.beneficiaryGeographies, + programmeAreas: filters.programmeAreas, + donor: filters.donor, + recipient: filters.recipient, + sources: selectedSources, + }); + params.set("selection_type", drilldownSelection.type); + params.set("selection_value", drilldownSelection.value); + const cacheKey = params.toString(); + const cachedResponse = drilldownResponseCache.get(cacheKey); + if (cachedResponse) { + setDrilldown(cachedResponse); + setDrilldownError(null); + setDrilldownLoading(false); + return; + } + const controller = new AbortController(); + setDrilldownLoading(true); + setDrilldownError(null); + fetch(`${apiBase}/api/charities/grants/overview/drilldown?${params.toString()}`, { credentials: "include", signal: controller.signal }) + .then(async response => { + const result = await response.json(); + if (!response.ok) throw new Error(result.detail || `Grant detail request failed (${response.status}).`); + return result as DrilldownResponse; + }) + .then(result => { + if (currentVersion !== drilldownRequestVersion.current) return; + rememberDrilldownResponse(cacheKey, result); + setDrilldown(result); + }) + .catch(requestError => { + if ((requestError as Error).name !== "AbortError" && currentVersion === drilldownRequestVersion.current) { + setDrilldownError((requestError as Error).message); + setDrilldown(null); + } + }) + .finally(() => { + if (currentVersion === drilldownRequestVersion.current) setDrilldownLoading(false); + }); + return () => controller.abort(); + }, [apiBase, drilldownSelection, filters, online, selectedSources]); useEffect(() => { updateUrl(filters); @@ -337,6 +568,44 @@ export default function OverviewDashboard({ apiBase, online, selectedSources, on }; }, [drawerOpen]); + useEffect(() => { + if (!drawerOpen || !online) return; + const cached = entitySuggestionCache.current.get(entitySuggestionSourceKey); + if (cached) { + setEntitySuggestions(cached); + return; + } + const controller = new AbortController(); + const params = new URLSearchParams({ limit: "2500" }); + params.set("sources", entitySuggestionSourceKey.split("\u001f").filter(Boolean).join(",")); + setEntitySuggestionsLoading(true); + fetch(`${apiBase}/api/charities/grants/overview/entity-suggestions?${params.toString()}`, { + credentials: "include", + signal: controller.signal, + }) + .then(async response => { + const body = await response.json(); + if (!response.ok) throw new Error(body.detail || `Entity suggestions failed (${response.status}).`); + return body as EntitySuggestionResponse; + }) + .then(body => { + const next = { + status: body.status || "available", + donors: Array.isArray(body.donors) ? body.donors : [], + recipients: Array.isArray(body.recipients) ? body.recipients : [], + }; + entitySuggestionCache.current.set(entitySuggestionSourceKey, next); + setEntitySuggestions(next); + }) + .catch(reason => { + if ((reason as Error).name !== "AbortError") setEntitySuggestions({ status: "unavailable", donors: [], recipients: [] }); + }) + .finally(() => { + if (!controller.signal.aborted) setEntitySuggestionsLoading(false); + }); + return () => controller.abort(); + }, [apiBase, drawerOpen, entitySuggestionSourceKey, online]); + const closeDrawer = () => { setDrawerOpen(false); window.setTimeout(() => document.querySelector(".header-overview-filter")?.focus(), 0); @@ -447,14 +716,33 @@ export default function OverviewDashboard({ apiBase, online, selectedSources, on setTrendPeriodOpen(false); }; - const visibleThemeItems = useMemo(() => { - const items = (payload?.themes.items || []).filter(item => includeUnclassified || item.programme_area !== "Unclassified"); - if (showAllProgrammes || items.length <= 9) return items; - const top = items.filter(item => item.programme_area !== "Unclassified").slice(0, 8); - const remainder = items.filter(item => !top.includes(item)); - const otherAmount = remainder.reduce((sum, item) => sum + item.allocated_amount, 0); - return otherAmount ? [...top, { programme_area: "Other", allocated_amount: otherAmount, distinct_grant_count: remainder.reduce((sum, item) => sum + item.distinct_grant_count, 0), unclassified_grant_count: 0 }] : top; - }, [includeUnclassified, payload?.themes.items, showAllProgrammes]); + const openDrilldown = (selection: DrilldownSelection) => { + setDrilldownSelection(selection); + setDrilldown(null); + setDrilldownError(null); + setDrilldownTab("funders"); + }; + + const closeDrilldown = () => { + if (presentation === "favorite-explorer" && onBackToFavorites) { + onBackToFavorites(); + return; + } + drilldownRequestVersion.current += 1; + setDrilldownSelection(null); + setDrilldown(null); + setDrilldownError(null); + }; + + const eligibleThemeItems = useMemo( + () => (payload?.themes.items || []).filter(item => includeUnclassified || item.programme_area !== "Unclassified"), + [includeUnclassified, payload?.themes.items], + ); + const visibleThemeItems = useMemo( + () => showAllProgrammes ? eligibleThemeItems : eligibleThemeItems.slice(0, 8), + [eligibleThemeItems, showAllProgrammes], + ); + const hiddenThemeCount = Math.max(0, eligibleThemeItems.length - visibleThemeItems.length); const beneficiaryOptions = useMemo( () => Array.from(new Set([ @@ -493,20 +781,33 @@ export default function OverviewDashboard({ apiBase, online, selectedSources, on recipient: filters.recipient, sources: selectedSources, }).filter(chip => chip.key !== "beneficiaryGeographies"); - const landscapeIsFavorite = isFavoriteLandscape(filters); - + const drilldownFavorite = useMemo(() => { + if (!drilldownSelection) return null; + const params = grantScopeToApiParams({ + currency: filters.currency || undefined, + dateFrom: filters.dateFrom || undefined, + dateTo: filters.dateTo || undefined, + beneficiaryGeographies: filters.beneficiaryGeographies, + programmeAreas: filters.programmeAreas, + donor: filters.donor, + recipient: filters.recipient, + sources: selectedSources, + }); + if (filters.granularity !== "auto") params.set("grant_granularity", filters.granularity); + const label = drilldown?.selection.label || drilldownSelection.value; + const route = `?${params.toString()}`; + return { + key: `grant-explorer:${drilldownSelection.type}:${drilldownSelection.value}:${params.toString()}`, + label: `${drilldownSelection.type === "period" ? "Grant period" : "Programme area"} · ${label}`, + route, + selection: drilldownSelection, + savedAt: Date.now(), + } satisfies FavoriteGrantExplorerPayload; + }, [drilldown?.selection.label, drilldownSelection, filters, selectedSources]); + const drilldownIsFavorite = Boolean(drilldownFavorite && favoriteGrantExplorerKeys.includes(drilldownFavorite.key)); return ( -
-
- Funding Landscape - -
+
+ {presentation !== "favorite-explorer" && <> {overviewScopeChips.length > 0 &&
{overviewScopeChips.slice(0, 3).map(chip => {chip.label})} {overviewScopeChips.length > 3 && } @@ -535,36 +836,68 @@ export default function OverviewDashboard({ apiBase, online, selectedSources, on
-
-

Grant Awards Over Time

{trends?.granularity === "yearly" ? "Annual" : "Monthly"} · {chartPeriod} · {filters.currency ? `${trends?.currency || filters.currency} original` : "EUR · Auto (ECB converted)"}
-
- {(["auto", "monthly", "yearly"] as Granularity[]).map(option => )} - +
+

Grant Awards Over Time

{trends?.granularity === "yearly" ? "Annual view" : "Monthly view"} · {chartPeriod} · {filters.currency ? `${trends?.currency || filters.currency} original` : "EUR · Auto (ECB converted)"}
+
+
+ {trendPeriodOpen &&
+
Custom periodChoose the award-date range used across this dashboard.
+
+
+
}
- {trendPeriodOpen &&
- - -

This range also updates the map and programme allocation.

-
-
} {trendIsLoading && !trends ? : trends?.status === "available" && trends.items.length ? <>

Grant awards are shown for {trends.items.length} {trends.granularity} periods in {trends.currency}. Use the chart tooltip to inspect total awarded funding, grant count, and mapped versus unmapped grants for each period.

-
String(value).slice(2)} /> formatCurrency(Number(value), trends.currency).replace("£", "£")} /> { const item = rows?.[0]?.payload as TrendItem | undefined; if (!active || !item) return null; return
{label}{item.coverage_status === "observed" ? <>{formatCurrency(item.total_amount, trends.currency)}{item.grant_count} grants · {item.mapped_grant_count} mapped · {item.unmapped_grant_count} unmapped : {item.coverage_status === "partial" ? "Source records without a valid aggregate." : "No source coverage established; not a confirmed zero."}}
; }} />
{trendIsLoading &&
}
+
String(value).slice(2)} /> formatCurrency(Number(value), trends.currency).replace("£", "£")} /> { const item = rows?.[0]?.payload as TrendItem | undefined; if (!active || !item) return null; return
{label}{item.coverage_status === "observed" ? <>{formatCurrency(item.total_amount, trends.currency)}{item.grant_count} grants · {item.mapped_grant_count} mapped · {item.unmapped_grant_count} unmappedClick to explore this period. : {item.coverage_status === "partial" ? "Source records without a valid aggregate." : "No source coverage established; not a confirmed zero."}}
; }} /> { const item = data?.payload as TrendItem | undefined; if (item?.grant_count) openDrilldown({ type: "period", value: item.month }); }} />
{trendIsLoading &&
}
Methodology and data coverage

Award-date aggregation from the filtered 360Giving grant population. Auto uses the stored ECB reference rate for the award date, or the preceding ECB business day; empty periods are never shown as zero funding.

:
No qualifying grant awards are available for the selected filters.
}
-

Grant Allocation by Programme Area

Programme coverage: {themes?.classification_coverage.classified_percentage ?? "—"}% · {themes?.classification_coverage.classified_grant_count ?? 0} classified
+

Grant Allocation by Programme Area

{themes?.classification_coverage.classified_percentage ?? "—"}% classification coverage · {themes?.classification_coverage.classified_grant_count ?? 0} classified grants
{loading && !themes ?
Loading programme allocation…
: themes?.status === "available" && visibleThemeItems.length ? <>

Programme allocation is shown across {visibleThemeItems.length} categories in {themes.currency}. Programme classification coverage is {themes.classification_coverage.classified_percentage} percent.

-
formatCurrency(Number(value), themes.currency).replace("£", "£")} /> formatCurrency(Number(value), themes.currency)} />{visibleThemeItems.map(item => )}
- {!includeUnclassified && themes.classification_coverage.unclassified_grant_count > 0 &&

Excluded from ranking: {themes.classification_coverage.unclassified_grant_count} unclassified grants. Programme coverage remains {themes.classification_coverage.classified_percentage}%.

} +
formatCurrency(Number(value), themes.currency).replace("£", "£")} /> formatCurrency(Number(value), themes.currency)} />{visibleThemeItems.map(item => openDrilldown({ type: "programme_area", value: item.programme_area })} />)}
+ {(hiddenThemeCount > 0 || (!includeUnclassified && themes.classification_coverage.unclassified_grant_count > 0)) &&

{hiddenThemeCount > 0 ? `Showing the top 8 of ${eligibleThemeItems.length} categories.` : ""}{hiddenThemeCount > 0 && !includeUnclassified && themes.classification_coverage.unclassified_grant_count > 0 ? " " : ""}{!includeUnclassified && themes.classification_coverage.unclassified_grant_count > 0 ? `${themes.classification_coverage.unclassified_grant_count} unclassified grants are excluded.` : ""}

}
Methodology and data coverage

Source categories take precedence over accepted inferred categories. Multi-category amounts are split equally; Unclassified remains available as a neutral category.

:
No programme allocation is available for the selected filters.
}
+ } + + {drilldownSelection &&
+ +
} {drawerOpen &&
}
); diff --git a/frontend/src/index.css b/frontend/src/index.css index b031b0a..c058baf 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -2584,18 +2584,37 @@ body { .chart-segmented button, .chart-actions button { min-height: 32px; border: 1px solid var(--border-glass); border-radius: 7px; padding: 6px 8px; color: var(--text-secondary); background: #fff; font: inherit; font-size: 11px; font-weight: 600; cursor: pointer; } .chart-segmented button.active { border-color: var(--nl-unicorn); color: var(--nl-unicorn); background: var(--nl-unicorn-glow); } +.trend-card-header { position: relative; z-index: 3; } +.trend-controls { position: relative; display: flex; align-items: center; flex: 0 0 auto; gap: 8px; } +.trend-segmented { flex-wrap: nowrap; } +.trend-custom-trigger { display: inline-flex; min-height: 32px; align-items: center; gap: 5px; border: 1px solid var(--border-glass); border-radius: 7px; padding: 6px 9px; color: var(--text-secondary); background: #fff; font: inherit; font-size: 11px; font-weight: 650; white-space: nowrap; cursor: pointer; } +.trend-custom-trigger:hover, .trend-custom-trigger.active { border-color: var(--nl-unicorn); color: var(--nl-unicorn-active); background: var(--nl-unicorn-glow); } +.programme-controls { display: flex; align-items: center; justify-content: flex-end; flex-wrap: nowrap; gap: 6px; } +.programme-controls .chart-segmented { flex-wrap: nowrap; padding: 3px; border: 1px solid var(--border-glass); border-radius: 9px; background: var(--nl-ash-light); } +.programme-controls .chart-segmented button { min-height: 27px; border-color: transparent; padding: 4px 7px; background: transparent; font-size: 10px; } +.programme-controls .chart-segmented button.active { border-color: rgba(102,100,241,.28); color: var(--nl-unicorn-active); background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,.07); } .chart-loading { display: flex; height: 230px; align-items: center; justify-content: center; gap: 8px; color: var(--text-secondary); font-size: 13px; } .trend-period-picker { display: grid; - grid-template-columns: minmax(130px, 1fr) minmax(130px, 1fr) minmax(160px, 1.5fr) auto; + position: absolute; + z-index: 10; + top: calc(100% + 8px); + right: 0; + width: min(390px, calc(100vw - 48px)); + grid-template-columns: 1fr; gap: 10px; - align-items: end; - padding: 11px; + align-items: start; + padding: 12px; border: 1px solid rgba(102, 100, 241, 0.25); border-radius: 10px; - background: var(--nl-unicorn-glow); -} -.trend-period-picker label { display: grid; gap: 4px; color: var(--text-secondary); font-size: 11px; font-weight: 700; } + background: linear-gradient(135deg, var(--nl-unicorn-glow), rgba(255,255,255,.9)); + box-shadow: 0 16px 32px rgba(15,23,42,.16); +} +.trend-period-copy { display: flex; flex-direction: column; gap: 3px; } +.trend-period-copy strong { color: var(--text-primary); font-size: 12px; } +.trend-period-copy span { color: var(--text-secondary); font-size: 10px; line-height: 1.4; } +.trend-period-fields { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; } +.trend-period-fields label { display: grid; gap: 4px; color: var(--text-secondary); font-size: 10px; font-weight: 700; } .trend-period-picker input { min-width: 0; min-height: 34px; @@ -2607,8 +2626,7 @@ body { font: inherit; font-size: 12px; } -.trend-period-picker p { margin: 0; color: var(--text-secondary); font-size: 11px; line-height: 1.35; } -.trend-period-picker > div { display: flex; gap: 6px; justify-content: flex-end; } +.trend-period-actions { display: flex; gap: 6px; justify-content: flex-end; } .trend-period-picker button { min-height: 34px; border: 1px solid var(--border-glass); border-radius: 7px; padding: 6px 9px; color: var(--text-secondary); background: #fff; font: inherit; font-size: 11px; font-weight: 700; cursor: pointer; } .trend-period-picker .btn-primary { border-color: var(--nl-unicorn); color: #fff; background: var(--nl-unicorn); } .trend-chart-plot { position: relative; } @@ -2646,6 +2664,51 @@ body { .chart-methodology summary { color: var(--text-secondary); font-weight: 600; cursor: pointer; } .chart-methodology p { margin-top: 7px; line-height: 1.45; } +.overview-drilldown-backdrop { position: fixed; z-index: 95; inset: 0; display: flex; justify-content: flex-end; background: rgba(15,23,42,.28); } +.overview-drilldown { display: flex; width: min(540px, 100%); height: 100%; flex-direction: column; gap: 14px; overflow-y: auto; border-left: 1px solid rgba(102,100,241,.24); padding: 18px; background: #fff; box-shadow: -12px 0 34px rgba(15,23,42,.18); } +.overview-dashboard.favorite-explorer-dashboard { max-width: 940px; margin: 0 auto; } +.overview-drilldown-backdrop.is-favorite-explorer { position: static; z-index: auto; display: block; background: transparent; } +.overview-drilldown-backdrop.is-favorite-explorer .overview-drilldown { width: 100%; min-height: min(620px, calc(100dvh - 150px)); height: auto; border: 1px solid rgba(102,100,241,.24); border-radius: var(--radius-lg); padding: clamp(16px, 2vw, 24px); box-shadow: var(--shadow-sm); } +.overview-drilldown-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; } +.overview-drilldown-header > div { min-width: 0; } +.overview-drilldown-header span { color: var(--nl-unicorn); font-size: 10px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } +.overview-drilldown-header h2 { margin: 4px 0 4px; color: var(--text-primary); font-size: 22px; line-height: 1.18; } +.overview-drilldown-header p { margin: 0; color: var(--text-secondary); font-size: 12px; line-height: 1.45; } +.overview-drilldown-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; } +.overview-drilldown-actions > button { display: grid; width: 36px; height: 36px; place-items: center; flex: 0 0 auto; border: 1px solid var(--border-glass); border-radius: var(--radius-sm); color: var(--text-secondary); background: #fff; cursor: pointer; } +.overview-drilldown-actions > button:hover { color: var(--nl-unicorn-active); border-color: rgba(102,100,241,.4); background: var(--nl-unicorn-glow); } +.overview-drilldown-actions .overview-drilldown-back { display: inline-flex; width: auto; gap: 5px; padding: 0 9px; color: var(--text-primary); font: inherit; font-size: 11px; font-weight: 750; } +.overview-drilldown-loading { display: flex; min-height: 210px; align-items: center; justify-content: center; gap: 11px; color: var(--text-secondary); text-align: left; } +.overview-drilldown-loading svg { flex: 0 0 auto; color: var(--nl-unicorn); animation: trend-loading-spin .9s linear infinite; } +.overview-drilldown-loading strong, .overview-drilldown-loading span { display: block; } +.overview-drilldown-loading strong { color: var(--text-primary); font-size: 13px; } +.overview-drilldown-loading span { margin-top: 3px; font-size: 11px; } +.overview-drilldown-summary { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; } +.overview-drilldown-summary > div { min-width: 0; border: 1px solid rgba(102,100,241,.16); border-radius: 9px; padding: 10px; background: var(--nl-unicorn-glow); } +.overview-drilldown-summary span { display: block; overflow: hidden; color: var(--text-secondary); font-size: 9px; font-weight: 800; letter-spacing: .05em; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.overview-drilldown-summary strong { display: block; margin-top: 4px; overflow: hidden; color: var(--text-primary); font-size: 17px; text-overflow: ellipsis; white-space: nowrap; } +.overview-drilldown-countries { display: grid; gap: 6px; } +.overview-drilldown-countries > span { color: var(--text-secondary); font-size: 10px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; } +.overview-drilldown-countries > div { display: flex; flex-wrap: wrap; gap: 5px; } +.overview-drilldown-countries em { border: 1px solid var(--border-glass); border-radius: 999px; padding: 4px 7px; color: var(--text-secondary); background: #fff; font-size: 10px; font-style: normal; } +.overview-drilldown-countries small, .overview-drilldown-note { color: var(--text-muted); font-size: 10px; line-height: 1.4; } +.overview-drilldown-note { margin: -4px 0 0; } +.overview-drilldown-tabs { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 4px; border: 1px solid var(--border-glass); border-radius: 9px; padding: 3px; background: var(--nl-ash-light); } +.overview-drilldown-tabs button { min-width: 0; min-height: 33px; border: 1px solid transparent; border-radius: 6px; padding: 5px 6px; overflow: hidden; color: var(--text-secondary); background: transparent; font: inherit; font-size: 11px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } +.overview-drilldown-tabs button span { margin-left: 3px; color: var(--text-muted); font-size: 10px; } +.overview-drilldown-tabs button.active { border-color: rgba(102,100,241,.28); color: var(--nl-unicorn-active); background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,.07); } +.overview-drilldown-list { display: flex; flex-direction: column; gap: 7px; } +.overview-drilldown-list > article { display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 1px solid var(--border-glass); border-radius: 10px; padding: 10px; background: #fff; } +.overview-drilldown-list > article > div:first-child { min-width: 0; } +.overview-drilldown-list strong { display: block; overflow: hidden; color: var(--text-primary); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.overview-drilldown-list small { display: block; margin-top: 3px; overflow: hidden; color: var(--text-secondary); font-size: 10px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } +.overview-drilldown-list .btn { min-height: 31px; flex: 0 0 auto; padding: 5px 7px; font-size: 10px; white-space: nowrap; } +.overview-drilldown-grants > article { align-items: flex-start; } +.overview-drilldown-grants p { display: -webkit-box; margin: 5px 0 0; overflow: hidden; color: var(--text-muted); font-size: 10px; line-height: 1.35; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.overview-drilldown-evidence { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; } +.overview-drilldown-evidence a { display: inline-flex; min-height: 28px; align-items: center; gap: 4px; border: 1px solid var(--border-glass); border-radius: 6px; padding: 4px 6px; color: var(--text-secondary); background: #fff; font-size: 9px; font-weight: 750; text-decoration: none; } +.overview-drilldown-evidence a:hover { color: var(--nl-unicorn-active); border-color: rgba(102,100,241,.4); background: var(--nl-unicorn-glow); } + .overview-filter-backdrop { position: fixed; z-index: 90; inset: 0; display: flex; justify-content: flex-end; background: rgba(15,23,42,.22); } .overview-filter-drawer { display: flex; width: min(410px, 100%); height: 100%; flex-direction: column; border-left: 1px solid var(--border-glass); background: #fff; box-shadow: -8px 0 30px rgba(15,23,42,.12); outline: none; } .overview-filter-drawer-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border-glass); padding: 18px; } @@ -2659,6 +2722,18 @@ body { .overview-filter-drawer legend { color: var(--text-secondary); font-size: 12px; font-weight: 700; } .overview-filter-drawer input, .overview-filter-drawer select { width: 100%; min-height: 40px; border: 1px solid var(--border-glass); border-radius: var(--radius-sm); padding: 8px 10px; color: var(--text-primary); background: #fff; font: inherit; font-size: 13px; } +.overview-entity-autocomplete { display: flex; flex-direction: column; gap: 6px; } +.overview-entity-autocomplete > label { display: flex; flex-direction: column; gap: 6px; } +.overview-entity-input-wrap { position: relative; } +.overview-entity-suggestions { position: absolute; z-index: 4; top: calc(100% + 5px); right: 0; left: 0; display: flex; max-height: 254px; flex-direction: column; overflow-y: auto; border: 1px solid rgba(102,100,241,.28); border-radius: var(--radius-sm); background: rgba(255,255,255,.98); box-shadow: 0 14px 28px rgba(15,23,42,.16); } +.overview-entity-suggestions > button { display: flex; width: 100%; min-height: 42px; flex-direction: column; align-items: flex-start; justify-content: center; gap: 2px; border: 0; border-bottom: 1px solid var(--border-glass); padding: 7px 10px; text-align: left; color: var(--text-primary); background: transparent; font: inherit; cursor: pointer; } +.overview-entity-suggestions > button:last-child { border-bottom: 0; } +.overview-entity-suggestions > button:hover, +.overview-entity-suggestions > button:focus-visible { color: var(--nl-unicorn-active); background: var(--nl-unicorn-glow); outline: none; } +.overview-entity-suggestions strong { overflow: hidden; max-width: 100%; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.overview-entity-suggestions small, +.overview-entity-suggestions > span { color: var(--text-muted); font-size: 10px; } +.overview-entity-suggestions > span { display: block; padding: 10px; } .filter-date-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .filter-checklist { display: grid; max-height: 170px; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; overflow-y: auto; border: 1px solid var(--border-glass); border-radius: var(--radius-sm); padding: 9px; } .filter-checklist label { display: flex; flex-direction: row; align-items: flex-start; gap: 6px; color: var(--text-secondary); font-size: 11px; line-height: 1.3; } @@ -2693,9 +2768,6 @@ body { .map-controls-anchor, .map-mode-control { width: 100%; } .map-mode-control button { flex: 1; } .analytics-chart-plot.compact { height: clamp(245px, 30vh, 280px); } - .trend-period-picker { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .trend-period-picker p { grid-column: 1 / -1; } - .trend-period-picker > div { grid-column: 1 / -1; justify-content: flex-start; } } @media (max-width: 767px) { @@ -2732,9 +2804,10 @@ body { .overview-filter-chip { max-width: 170px; } .chart-card-header { flex-direction: column; } .chart-actions, .chart-segmented { justify-content: flex-start; } + .trend-controls, .programme-controls { align-items: flex-start; justify-content: flex-start; flex-wrap: nowrap; max-width: 100%; overflow-x: auto; } .analytics-chart-plot.compact { height: 255px; } - .trend-period-picker { grid-template-columns: 1fr; } - .trend-period-picker p, .trend-period-picker > div { grid-column: auto; } + .trend-period-picker { right: auto; left: 0; width: min(390px, calc(100vw - 32px)); } + .trend-period-fields { grid-template-columns: 1fr; } .filter-date-grid, .filter-checklist { grid-template-columns: 1fr; } } @@ -3194,8 +3267,6 @@ body { .news-methodology { border-top: 1px solid var(--border-glass); padding-top: 11px; } .news-methodology summary { color: var(--text-secondary); font-size: 11px; font-weight: 750; cursor: pointer; } .news-methodology p { margin: 8px 0 0; color: var(--text-secondary); font-size: 10px; line-height: 1.5; } -.overview-favorite-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: 9px; margin: -2px 0 7px; color: var(--text-muted); font-size: 10px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; } - .favorites-page { display: flex; flex-direction: column; gap: 20px; } .favorites-introduction { margin-bottom: 0; } .favorites-empty-state { display: flex; min-height: 220px; align-items: center; justify-content: center; flex-direction: column; gap: 9px; text-align: center; } @@ -3228,6 +3299,8 @@ body { .landscape-metrics { grid-template-columns: repeat(4,minmax(0,1fr)) !important; margin: 0 0 10px; } .donor-directory-page { min-width: 0; } +.donor-directory-actions { display: flex; align-items: center; justify-content: flex-end; gap: 9px; } +.donor-request-save.is-favorite { border-color: rgba(102,100,241,.36); color: var(--nl-unicorn-active); background: var(--nl-unicorn-glow); } .donor-directory-toolbar { display: grid; grid-template-columns: minmax(240px,1fr) minmax(230px,auto); gap: 10px; margin-bottom: 10px; } .donor-search { position: relative; display: flex; min-height: 42px; align-items: center; gap: 8px; border: 1px solid var(--border-glass); border-radius: var(--radius-sm); padding: 0 12px; background: #fff; } .donor-search:focus-within { border-color: var(--nl-unicorn); box-shadow: 0 0 0 3px var(--nl-unicorn-glow); } @@ -3274,6 +3347,8 @@ body { .donor-detail-close { position: absolute; top: 14px; right: 14px; display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid var(--border-glass); border-radius: var(--radius-sm); color: var(--text-secondary); background: #fff; cursor: pointer; } .donor-detail-close:hover { color: var(--nl-unicorn); } .donor-detail-favorite { position: absolute; top: 14px; right: 56px; } +.favorite-donor-detail-page .donor-directory-workspace.has-detail { grid-template-columns: minmax(0,1fr); } +.favorite-donor-detail-page .donor-detail-shell { position: static; max-height: none; min-height: 0; } .donor-detail-content { display: flex; flex-direction: column; gap: 10px; padding: 16px; } .donor-detail-content section, .donor-detail-section { border: 1px solid var(--border-glass); border-radius: var(--radius-md); padding: 14px; background: var(--nl-ash-light); } @@ -3309,8 +3384,26 @@ body { .evidence-link-list a span { display: flex; min-width: 0; flex-direction: column; gap: 2px; } .evidence-link-list a strong { font-size: 10px; } .evidence-link-list a small { color: var(--text-muted); font-size: 9px; text-transform: capitalize; } +.evidence-display-controls { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--text-muted); font-size: 10px; } +.evidence-display-controls > button { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--border-glass); border-radius: 7px; padding: 6px 8px; color: var(--text-secondary); background: #fff; font: inherit; font-size: 10px; font-weight: 700; cursor: pointer; } +.evidence-display-controls > button:hover { border-color: rgba(102,100,241,.36); color: var(--nl-unicorn-active); } +.evidence-settings-panel { display: grid; grid-template-columns: 1fr 1fr auto; align-items: end; gap: 10px; border: 1px solid rgba(102,100,241,.22); border-radius: var(--radius-sm); padding: 10px; background: var(--nl-unicorn-glow); } +.evidence-settings-panel > div { display: flex; flex-direction: column; gap: 5px; } +.evidence-settings-panel strong { color: var(--text-secondary); font-size: 9px; text-transform: uppercase; letter-spacing: .05em; } +.evidence-settings-panel label { display: inline-flex; align-items: center; gap: 5px; color: var(--text-secondary); font-size: 10px; cursor: pointer; } +.evidence-settings-panel input { margin: 0; accent-color: var(--nl-unicorn); } +.evidence-settings-panel > button { border: 0; padding: 5px; color: var(--nl-unicorn-active); background: transparent; font: inherit; font-size: 10px; font-weight: 750; cursor: pointer; white-space: nowrap; } +.evidence-link-meta { display: inline-flex !important; min-width: auto !important; align-items: center; flex-direction: row !important; gap: 8px !important; } +.evidence-link-type { border: 1px solid transparent; border-radius: 999px; padding: 3px 6px; font-size: 8px; font-style: normal; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; } +.evidence-link-type.website { border-color: rgba(108,219,255,.4); color: #007da8; background: rgba(194,249,255,.52); } +.evidence-link-type.json { border-color: rgba(102,100,241,.3); color: var(--nl-unicorn-active); background: var(--nl-unicorn-glow); } .evidence-policy { font-size: 9px !important; } +@media (max-width: 540px) { + .evidence-settings-panel { grid-template-columns: 1fr; align-items: start; } + .evidence-settings-panel > button { justify-self: start; padding-left: 0; } +} + .donor-directory-secondary { margin: 0 0 18px; } .donor-directory-secondary-label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-size: 10px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; } .donor-directory-secondary-links { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; } diff --git a/src/bff/charity.py b/src/bff/charity.py index d80a5cb..8375081 100644 --- a/src/bff/charity.py +++ b/src/bff/charity.py @@ -252,6 +252,23 @@ def parse_iso(value: Optional[str], field: str) -> Optional[str]: raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.get("/grants/overview/entity-suggestions", response_model=Dict[str, Any]) +async def get_grant_entity_suggestions( + sources: Optional[str] = Query(default=None, max_length=500), + limit: int = Query(default=2_500, ge=1, le=5_000), + repo: CharityRepository = Depends(get_charity_repository), +): + """Return a source-scoped cache of observed donor and recipient names. + + The browser filters this response locally as the user types. No Overview + aggregation is refreshed until the user explicitly applies their draft. + """ + return await repo.get_grant_entity_suggestions( + sources=_split_grant_filter(sources) if sources is not None else None, + limit=limit, + ) + + @router.get("/grants/overview/trends", response_model=GrantTrendsResponse) async def get_filtered_grant_overview_trends( currency: Optional[str] = Query(default=None, min_length=3, max_length=4), @@ -295,6 +312,42 @@ def parse_iso(value: Optional[str], field: str) -> Optional[str]: raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.get("/grants/overview/drilldown", response_model=Dict[str, Any]) +async def get_grant_overview_drilldown( + selection_type: str = Query(..., pattern="^(period|programme_area)$"), + selection_value: str = Query(..., min_length=1, max_length=160), + currency: Optional[str] = Query(default=None, min_length=3, max_length=4), + date_from: Optional[str] = Query(default=None, max_length=10), + date_to: Optional[str] = Query(default=None, max_length=10), + beneficiary_geographies: Optional[str] = Query(default=None, max_length=500), + programme_areas: Optional[str] = Query(default=None, max_length=1000), + donor: Optional[str] = Query(default=None, max_length=160), + recipient: Optional[str] = Query(default=None, max_length=160), + sources: Optional[str] = Query(default=None, max_length=500), + repo: CharityRepository = Depends(get_charity_repository), +): + """Return a bounded funder, recipient, and grant slice for one chart value.""" + parsed_from = _parse_grant_date(date_from, "date_from") + parsed_to = _parse_grant_date(date_to, "date_to") + if parsed_from and parsed_to and parsed_from > parsed_to: + raise HTTPException(status_code=400, detail="date_from cannot be after date_to") + try: + return await repo.get_grant_overview_drilldown( + selection_type=selection_type, + selection_value=selection_value, + currency=currency, + date_from=parsed_from, + date_to=parsed_to, + beneficiary_geographies=_split_grant_filter(beneficiary_geographies), + programme_areas=_split_grant_filter(programme_areas), + donor=donor, + recipient=recipient, + sources=_split_grant_filter(sources) if sources is not None else None, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + def _parse_grant_date(value: Optional[str], field: str) -> Optional[str]: if value is None or not value.strip(): return None diff --git a/src/bff/repositories.py b/src/bff/repositories.py index 7d1735a..33b0edc 100644 --- a/src/bff/repositories.py +++ b/src/bff/repositories.py @@ -97,7 +97,31 @@ def _safe_external_url(value: Any) -> Optional[str]: return candidate -def _source_evidence_links(raw_grant_data: Any, source_url: Any) -> List[Dict[str, Any]]: +def _evidence_link_type(kind: str, url: str) -> str: + """Classify the stored destination without opening or probing it.""" + try: + parsed = urlsplit(url) + except ValueError: + return "website" + host = parsed.netloc.casefold() + path = parsed.path.casefold() + if ( + kind.endswith("_record") + or host.startswith("api.") + or "/api/" in path + or path.endswith(".json") + ): + return "json" + return "website" + + +def _source_evidence_links( + raw_grant_data: Any, + source_url: Any, + *, + funder_name: Optional[str] = None, + recipient_name: Optional[str] = None, +) -> List[Dict[str, Any]]: """Extract typed links already present in a stored source record. External organization JSON endpoints are exposed as evidence links only. @@ -105,17 +129,39 @@ def _source_evidence_links(raw_grant_data: Any, source_url: Any) -> List[Dict[st """ raw = _json_dict(raw_grant_data) data = raw.get("data") if isinstance(raw.get("data"), Mapping) else {} - candidates: List[Tuple[str, str, Optional[str], Any]] = [ - ("publisher_grant_data", "View publisher grant data", None, source_url), - ("publisher_grant_data", "View publisher’s grant data source", None, data.get("dataSource")), + role_names = { + "funder": str(funder_name or "").strip(), + "recipient": str(recipient_name or "").strip(), + } + role_names_by_id: Dict[str, Dict[str, str]] = {"funder": {}, "recipient": {}} + for role, collection in ( + ("funder", data.get("fundingOrganization")), + ("recipient", data.get("recipientOrganization")), + ): + for item in collection if isinstance(collection, list) else []: + if not isinstance(item, Mapping): + continue + name = str(item.get("name") or item.get("legalName") or "").strip() + if name: + role_names[role] = name + for identifier in (item.get("id"), item.get("org_id"), item.get("charityNumber")): + normalized_identifier = str(identifier or "").strip() + if normalized_identifier: + role_names_by_id[role][normalized_identifier] = name + + publisher_name = role_names["funder"] or "Publisher" + candidates: List[Tuple[str, str, str, Any]] = [ + ("publisher_grant_data", "publisher", publisher_name, source_url), + ("publisher_grant_data", "publisher", publisher_name, data.get("dataSource")), ] for role, collection in (("funder", raw.get("funders")), ("recipient", raw.get("recipients"))): for item in collection if isinstance(collection, list) else []: if isinstance(item, Mapping): + organization_id = str(item.get("org_id") or item.get("id") or "").strip() candidates.append(( f"360giving_{role}_record", - f"View 360Giving {role} record", role, + role_names_by_id[role].get(organization_id) or role_names[role] or role.title(), item.get("self"), )) for role, collection in ( @@ -126,22 +172,26 @@ def _source_evidence_links(raw_grant_data: Any, source_url: Any) -> List[Dict[st if isinstance(item, Mapping): candidates.append(( f"observed_{role}_website", - f"Visit {role} website", role, + role_names[role] or role.title(), item.get("url"), )) links: List[Dict[str, Any]] = [] seen: set[Tuple[str, str]] = set() - for kind, label, role, value in candidates: + for kind, role, organization_name, value in candidates: url = _safe_external_url(value) marker = (kind, url or "") if not url or marker in seen: continue seen.add(marker) + link_type = _evidence_link_type(kind, url) + role_label = "published grant data" if role == "publisher" else f"{role} {'record' if link_type == 'json' else 'website'}" links.append({ "kind": kind, - "label": label, + "label": f"{organization_name} · {role_label}", "role": role, + "organization_name": organization_name, + "link_type": link_type, "url": url, "origin": "stored_source_record", }) @@ -420,7 +470,7 @@ def _accepted_programme_categories( OVERVIEW_INDEX_REVISION_KEY = "grant_overview_index_revision" OVERVIEW_SCHEMA_VERSION_KEY = "grant_overview_schema_version" -OVERVIEW_SCHEMA_VERSION = "2026-07-source-funder-facts-v3" +OVERVIEW_SCHEMA_VERSION = "2026-07-source-funder-facts-v4" OVERVIEW_CACHE_MAX_ENTRIES = 64 # Bump whenever a presentation aggregation changes without changing source # records, so persisted Overview payloads cannot retain stale semantics. @@ -898,6 +948,24 @@ async def get_grant_overview( "applied_filters": {}, } + async def get_grant_entity_suggestions( + self, + *, + sources: Optional[List[str]] = None, + limit: int = 2_500, + ) -> Dict[str, Any]: + """Return cached observed donor and recipient names for local filtering. + + The UI loads this compact index once for a selected source set, then + filters it in the browser while the user types. Repositories without + normalized transaction facts remain explicitly unavailable. + """ + return { + "status": "data_unavailable", + "donors": [], + "recipients": [], + } + async def get_grant_overview_trends( self, *, @@ -914,6 +982,25 @@ async def get_grant_overview_trends( """Fallback for a trend-only request in non-transaction repositories.""" return await self.get_grant_trends(currency=currency) + async def get_grant_overview_drilldown( + self, + *, + selection_type: str, + selection_value: str, + **_filters: Any, + ) -> Dict[str, Any]: + """Fallback for a bounded Overview chart drill-down.""" + return { + "status": "data_unavailable", + "selection": {"type": selection_type, "value": selection_value, "label": selection_value}, + "summary": {}, + "funders": [], + "recipients": [], + "countries": [], + "grants": [], + "metadata": {"data_mode": "transaction_data_unavailable"}, + } + async def get_source_funders( self, *, @@ -1187,6 +1274,7 @@ def _overview_source_rows( recipient: Optional[str] = None, beneficiary_country_code: Optional[str] = None, include_connections: bool = False, + include_evidence: bool = False, ) -> List[Dict[str, Any]]: """Fetch only the transaction rows in the indexed Overview scope.""" selected_sources = ( @@ -1200,6 +1288,8 @@ def _overview_source_rows( connection_columns = ( "g.raw_grant_data, c.headquarters_country, c.name AS linked_funder_name" if include_connections + else "g.raw_grant_data, NULL AS headquarters_country, NULL AS linked_funder_name" + if include_evidence else "NULL AS raw_grant_data, NULL AS headquarters_country, NULL AS linked_funder_name" ) connection_join = ( @@ -1748,6 +1838,288 @@ async def get_grant_overview( logger.warning("Overview result was returned without caching: %s", exc) return result + async def get_grant_overview_drilldown( + self, + *, + selection_type: str, + selection_value: str, + currency: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + beneficiary_geographies: Optional[List[str]] = None, + programme_areas: Optional[List[str]] = None, + donor: Optional[str] = None, + recipient: Optional[str] = None, + sources: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """Return a bounded, evidence-aware detail view for one chart selection. + + The response intentionally remains a grant-data exploration surface. A + linked organization profile is returned only where the stored grant has + an explicit profile identifier; name matching is never inferred here. + """ + selection_type = str(selection_type or "").strip().lower() + selection_value = str(selection_value or "").strip() + if selection_type not in {"period", "programme_area"}: + raise ValueError("selection_type must be period or programme_area.") + if not selection_value: + raise ValueError("selection_value is required.") + + selection_label = selection_value + effective_date_from = date_from + effective_date_to = date_to + effective_programmes = programme_areas + if selection_type == "period": + if re.fullmatch(r"\d{4}", selection_value): + start = datetime.strptime(f"{selection_value}-01-01", "%Y-%m-%d").date() + end = datetime.strptime(f"{selection_value}-12-31", "%Y-%m-%d").date() + elif re.fullmatch(r"\d{4}-\d{2}", selection_value): + start = datetime.strptime(f"{selection_value}-01", "%Y-%m-%d").date() + next_month = (start.replace(day=28) + timedelta(days=4)).replace(day=1) + end = next_month - timedelta(days=1) + else: + raise ValueError("A period selection must be YYYY or YYYY-MM.") + selection_label = start.strftime("%B %Y") if len(selection_value) == 7 else selection_value + effective_date_from = max(date_from, start.isoformat()) if date_from else start.isoformat() + effective_date_to = min(date_to, end.isoformat()) if date_to else end.isoformat() + else: + # Selecting one visible category deliberately narrows the current + # programme scope to that category instead of taking a broad union. + effective_programmes = [selection_value] + + cache_key = "drilldown:" + selection_type + ":" + selection_value + ":" + self._overview_cache_key( + currency=currency, + date_from=effective_date_from, + date_to=effective_date_to, + beneficiary_geographies=beneficiary_geographies, + programme_areas=effective_programmes, + donor=donor, + recipient=recipient, + sources=sources, + granularity="auto", + include_connections=False, + ) + conn = self._get_conn() + conn.row_factory = sqlite3.Row + try: + revision = self._ensure_overview_indexes(conn) + cached = self._load_overview_cache(conn, cache_key, revision) + if cached is not None: + return cached + rows = self._overview_source_rows( + conn, + sources, + currency=currency, + date_from=effective_date_from, + date_to=effective_date_to, + beneficiary_geographies=beneficiary_geographies, + programme_areas=effective_programmes, + donor=donor, + recipient=recipient, + ) + finally: + conn.close() + + requested_currency = str(currency or "").strip().upper() or None + auto_converted_eur = requested_currency in {None, "AUTO"} + display_currency = "EUR" if auto_converted_eur else requested_currency + valid_conversion_statuses = { + "native_eur", "ecb_award_date", "ecb_previous_business_day", + } + selected_rows: List[Dict[str, Any]] = [] + for row in rows: + award_date = self._overview_award_date(row.get("date")) + if not award_date: + continue + categories = _accepted_programme_categories( + row.get("programme_area_source"), + row.get("programme_area_inferred"), + row.get("programme_area_scores"), + ) + if selection_type == "period": + if award_date < str(effective_date_from) or award_date > str(effective_date_to): + continue + elif selection_value not in categories: + continue + row["award_date"] = award_date + row["programme_categories"] = categories + selected_rows.append(row) + + if not selected_rows: + return { + "status": "no_data", + "selection": {"type": selection_type, "value": selection_value, "label": selection_label}, + "summary": { + "grant_count": 0, "funding_total": None, "currency": display_currency, + "funder_count": 0, "recipient_count": 0, "country_count": 0, + "amount_excluded_grant_count": 0, + }, + "funders": [], "recipients": [], "countries": [], "grants": [], + "metadata": {"data_mode": "derived_from_cached_source", "data_revision": revision}, + } + + profile_ids = { + int(value) for row in selected_rows + for value in (row.get("funding_charity_id"), row.get("recipient_charity_id")) + if value is not None + } + profile_names: Dict[int, str] = {} + if profile_ids: + conn = self._get_conn() + conn.row_factory = sqlite3.Row + try: + placeholders = ", ".join("?" for _ in profile_ids) + profile_names = { + int(row["charity_id"]): str(row["name"]) + for row in conn.execute( + f"SELECT charity_id, name FROM charities WHERE charity_id IN ({placeholders})", + sorted(profile_ids), + ).fetchall() + } + finally: + conn.close() + + def empty_entity(name: str) -> Dict[str, Any]: + return {"name_counts": Counter(), "grant_ids": set(), "minor_units": 0, "included": 0, "profile_ids": set()} + + funders: Dict[str, Dict[str, Any]] = {} + recipients: Dict[str, Dict[str, Any]] = {} + countries: Dict[str, Dict[str, Any]] = {} + included_minor_units = 0 + included_grants = 0 + amount_excluded_grants = 0 + grant_rows = [] + + for row in selected_rows: + funder_key, _ = _source_entity_identity( + role="funder", source=row.get("source"), source_id=row.get("funding_org_source_id"), name=row.get("funding_name"), + ) + funder_name = _display_source_entity_name(row.get("funding_name"), "Unnamed source funder") + recipient_key, _ = _source_entity_identity( + role="recipient", source=row.get("source"), source_id=row.get("recipient_org_source_id"), name=row.get("recipient_name"), + ) + recipient_name = _display_source_entity_name(row.get("recipient_name"), "Unnamed recipient") + funder = funders.setdefault(funder_key, empty_entity(funder_name)) + recipient_item = recipients.setdefault(recipient_key, empty_entity(recipient_name)) + for item, name, profile_id in ( + (funder, funder_name, row.get("funding_charity_id")), + (recipient_item, recipient_name, row.get("recipient_charity_id")), + ): + item["name_counts"][name] += 1 + item["grant_ids"].add(str(row["grant_id"])) + if profile_id is not None and int(profile_id) in profile_names: + item["profile_ids"].add(int(profile_id)) + + monetary_amount = row.get("amount_eur") if auto_converted_eur else row.get("amount") + conversion_available = ( + str(row.get("conversion_status") or "") in valid_conversion_statuses + if auto_converted_eur else True + ) + amount_status, minor_units = _money_minor_units(monetary_amount) + amount_included = conversion_available and amount_status in {"valid", "zero"} + if amount_included: + minor = int(minor_units or 0) + included_minor_units += minor + included_grants += 1 + funder["minor_units"] += minor + funder["included"] += 1 + recipient_item["minor_units"] += minor + recipient_item["included"] += 1 + else: + amount_excluded_grants += 1 + + for country in _beneficiary_countries( + row.get("beneficiary_geography_normalized"), row.get("beneficiary_geography"), + ): + code = str(country.get("country_code") or "") + country_name = str(country.get("country_name") or "Unknown geography") + item = countries.setdefault(code, {"country_code": code, "country_name": country_name, "grant_ids": set()}) + item["grant_ids"].add(str(row["grant_id"])) + + grant_rows.append({ + "grant_id": str(row["grant_id"]), "award_date": row["award_date"], + "funder_name": funder_name, "recipient_name": recipient_name, + "amount": _minor_units_to_amount(int(minor_units or 0)) if amount_included else None, + "currency": display_currency, "original_amount": row.get("amount"), + "original_currency": row.get("currency"), "description": row.get("description"), + "_source_url": row.get("source_url"), + }) + + def serialise_entities(items: Dict[str, Dict[str, Any]], key_name: str) -> List[Dict[str, Any]]: + result = [] + for key, item in items.items(): + profile_id = next(iter(item["profile_ids"])) if len(item["profile_ids"]) == 1 else None + result.append({ + key_name: key, + "name": _top_counter_items(item["name_counts"], 1)[0]["name"], + "grant_count": len(item["grant_ids"]), + "funding_total": _minor_units_to_amount(item["minor_units"]) if item["included"] else None, + "currency": display_currency, + "profile": {"id": profile_id, "name": profile_names[profile_id]} if profile_id is not None else None, + }) + result.sort(key=lambda item: (item["name"].casefold(), str(item[key_name]))) + result.sort(key=lambda item: item["grant_count"], reverse=True) + result.sort(key=lambda item: item["funding_total"] if item["funding_total"] is not None else -1, reverse=True) + return result[:8] + + grant_rows.sort(key=lambda item: (item["award_date"] or "", item["grant_id"]), reverse=True) + grant_sample = grant_rows[:20] + raw_grant_data: Dict[str, Any] = {} + if grant_sample: + conn = self._get_conn() + conn.row_factory = sqlite3.Row + try: + placeholders = ", ".join("?" for _ in grant_sample) + raw_grant_data = { + str(row["grant_id"]): row["raw_grant_data"] + for row in conn.execute( + f"SELECT grant_id, raw_grant_data FROM grants WHERE grant_id IN ({placeholders})", + [item["grant_id"] for item in grant_sample], + ).fetchall() + } + finally: + conn.close() + for grant in grant_sample: + grant["evidence_links"] = _source_evidence_links( + raw_grant_data.get(grant["grant_id"]), grant.pop("_source_url", None), + funder_name=grant["funder_name"], recipient_name=grant["recipient_name"], + )[:4] + country_items = [ + {"country_code": item["country_code"], "country_name": item["country_name"], "grant_count": len(item["grant_ids"])} + for item in countries.values() + ] + country_items.sort(key=lambda item: (item["country_name"].casefold(), item["country_code"])) + country_items.sort(key=lambda item: item["grant_count"], reverse=True) + result = { + "status": "available", + "selection": {"type": selection_type, "value": selection_value, "label": selection_label}, + "summary": { + "grant_count": len(selected_rows), + "funding_total": _minor_units_to_amount(included_minor_units) if included_grants else None, + "currency": display_currency, + "funder_count": len(funders), "recipient_count": len(recipients), "country_count": len(countries), + "amount_excluded_grant_count": amount_excluded_grants, + }, + "funders": serialise_entities(funders, "funder_key"), + "recipients": serialise_entities(recipients, "recipient_key"), + "countries": country_items[:6], "grants": grant_sample, + "metadata": { + "data_mode": "derived_from_cached_source", "data_revision": revision, + "grant_sample_limit": 20, + "profile_link_policy": "Only direct stored charity identifiers are linked to organization profiles.", + "external_link_policy": "Stored HTTP(S) links only; no server-side fetch or proxy.", + }, + } + try: + cache_conn = self._get_conn() + try: + self._store_overview_cache(cache_conn, cache_key, revision, result) + finally: + cache_conn.close() + except sqlite3.Error as exc: + logger.warning("Overview drill-down result was returned without caching: %s", exc) + return result + async def get_grant_overview_trends( self, *, @@ -3394,6 +3766,8 @@ async def get_source_funder_detail( for row in sample_rows: evidence_links = _source_evidence_links( row["raw_grant_data"], row["source_url"], + funder_name=display_name, + recipient_name=str(row["recipient_name"] or ""), ) for evidence in evidence_links: marker = (evidence["kind"], evidence["url"]) @@ -3431,17 +3805,22 @@ async def get_source_funder_detail( if profile_link.get("website"): source_evidence.append({ "kind": "profile_website", - "label": "Visit organization website", + "label": f"{profile_link['profile_name']} · funder website", "role": "funder", + "organization_name": profile_link["profile_name"], + "link_type": "website", "url": profile_link["website"], "origin": "enriched_profile", }) if profile_link.get("source_url"): + profile_source_url = profile_link["source_url"] source_evidence.append({ "kind": "profile_source", - "label": "View profile data source", + "label": f"{profile_link['profile_name']} · profile source", "role": "funder", - "url": profile_link["source_url"], + "organization_name": profile_link["profile_name"], + "link_type": _evidence_link_type("profile_source", profile_source_url), + "url": profile_source_url, "origin": "enriched_profile", }) return { @@ -4038,6 +4417,7 @@ def __init__(self, db_path: str = DB_PATH): self.db_path = db_path self._overview_revision: Optional[str] = None self._overview_source_metadata_cache: Dict[Tuple[str, Tuple[str, ...]], Dict[str, Any]] = {} + self._grant_entity_suggestion_cache: Dict[Tuple[str, Tuple[str, ...], int], Dict[str, Any]] = {} # Additive migration: existing enriched profiles and grants are left intact. if os.path.exists(self.db_path): conn = sqlite3.connect(self.db_path) @@ -4094,6 +4474,91 @@ async def get_beneficiary_geography_options( finally: conn.close() + async def get_grant_entity_suggestions( + self, + *, + sources: Optional[List[str]] = None, + limit: int = 2_500, + ) -> Dict[str, Any]: + """Build an in-memory name index from already-derived grant facts. + + This is intentionally unfiltered by the active drawer fields. It is a + source-scoped autocomplete cache, not another grant-analysis request; + selecting a suggestion only changes the local draft until Apply. + """ + selected_sources = ( + [str(source).strip() for source in sources if str(source).strip()] + if sources is not None else ["360Giving"] + ) + bounded_limit = min(max(int(limit), 1), 5_000) + if not selected_sources: + return {"status": "available", "donors": [], "recipients": []} + + conn = self._get_conn() + conn.row_factory = sqlite3.Row + try: + revision = self._ensure_overview_indexes(conn) + cache_key = ( + revision, + tuple(sorted({source.casefold() for source in selected_sources})), + bounded_limit, + ) + cached = self._grant_entity_suggestion_cache.get(cache_key) + if cached is not None: + return { + "status": "available", + "donors": list(cached["donors"]), + "recipients": list(cached["recipients"]), + } + + placeholders = ", ".join("?" for _ in selected_sources) + donor_rows = conn.execute( + f""" + SELECT display_name AS name, COUNT(DISTINCT grant_id) AS grant_count + FROM grant_source_funder_facts + WHERE source_namespace IN ({placeholders}) + AND TRIM(display_name) <> '' + AND display_name <> 'Unnamed source funder' + GROUP BY source_funder_key, display_name + ORDER BY grant_count DESC, LOWER(display_name) + LIMIT ? + """, + [*selected_sources, bounded_limit], + ).fetchall() + recipient_rows = conn.execute( + f""" + SELECT recipient_name AS name, COUNT(DISTINCT grant_id) AS grant_count + FROM grant_source_funder_facts + WHERE source_namespace IN ({placeholders}) + AND TRIM(recipient_name) <> '' + AND recipient_name <> 'Unnamed recipient' + GROUP BY recipient_key, recipient_name + ORDER BY grant_count DESC, LOWER(recipient_name) + LIMIT ? + """, + [*selected_sources, bounded_limit], + ).fetchall() + finally: + conn.close() + + result = { + "status": "available", + "donors": [ + {"name": str(row["name"]), "grant_count": int(row["grant_count"])} + for row in donor_rows + ], + "recipients": [ + {"name": str(row["name"]), "grant_count": int(row["grant_count"])} + for row in recipient_rows + ], + } + self._grant_entity_suggestion_cache[cache_key] = result + return { + "status": "available", + "donors": list(result["donors"]), + "recipients": list(result["recipients"]), + } + @staticmethod def _registry_grant_exists_sql(registry_alias: str = "registry") -> str: return f""" diff --git a/src/bff/schemas.py b/src/bff/schemas.py index 1cef072..b5a0396 100644 --- a/src/bff/schemas.py +++ b/src/bff/schemas.py @@ -360,6 +360,8 @@ class SourceEvidenceLink(BaseModel): kind: str label: str role: Optional[str] = None + organization_name: Optional[str] = None + link_type: str = "website" url: str origin: str diff --git a/src/data/db_loader.py b/src/data/db_loader.py index 304424a..a110428 100644 --- a/src/data/db_loader.py +++ b/src/data/db_loader.py @@ -152,6 +152,8 @@ def migrate_grant_overview_schema(conn): cursor.execute("CREATE INDEX IF NOT EXISTS idx_source_funder_facts_key_country ON grant_source_funder_facts(source_funder_key, country_code)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_source_funder_facts_country_date ON grant_source_funder_facts(country_code, award_date)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_source_funder_facts_profile ON grant_source_funder_facts(linked_profile_id, source_funder_key)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_source_funder_facts_source_funder_name ON grant_source_funder_facts(source_namespace, display_name)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_source_funder_facts_source_recipient_name ON grant_source_funder_facts(source_namespace, recipient_name)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_grants_source_date ON grants(source, date)") def create_tables(conn, reset=False): diff --git a/src/tests/test_grant_overview.py b/src/tests/test_grant_overview.py index 8f51d37..c290867 100644 --- a/src/tests/test_grant_overview.py +++ b/src/tests/test_grant_overview.py @@ -127,6 +127,20 @@ async def test_source_selection_changes_the_shared_overview_scope(self): self.assertEqual(other["kpis"]["grants_monitored"], 1) self.assertEqual(other["kpis"]["awarded_funding"], 250.0) + async def test_entity_suggestions_use_derived_source_facts(self): + self.grant("ALPHA-1", date="2025-01-15", amount=100, donor="Alpha Fund", recipient="School One") + self.grant("ALPHA-2", date="2025-01-16", amount=100, donor="Alpha Fund", recipient="School Two") + self.grant("BETA", date="2025-01-17", amount=100, donor="Beta Fund", recipient="Other recipient", source="Other source") + + suggestions = await self.repo.get_grant_entity_suggestions(sources=["360Giving"]) + + self.assertEqual(suggestions["status"], "available") + self.assertEqual(suggestions["donors"], [{"name": "Alpha Fund", "grant_count": 2}]) + self.assertEqual( + {item["name"] for item in suggestions["recipients"]}, + {"School One", "School Two"}, + ) + async def test_reuses_persistent_overview_cache_for_the_same_scope(self): self.grant("CACHED", date="2025-01-15", amount=100) first = await self.repo.get_grant_overview(currency="GBP") diff --git a/src/tests/test_source_funders.py b/src/tests/test_source_funders.py index 22660ca..a0b6ad6 100644 --- a/src/tests/test_source_funders.py +++ b/src/tests/test_source_funders.py @@ -202,8 +202,8 @@ async def test_summary_detail_is_lazy_and_full_detail_returns_safe_typed_evidenc raw = { "data": { "dataSource": "https://publisher.example/grants", - "fundingOrganization": [{"url": "https://fund.example/"}], - "recipientOrganization": [{"url": "javascript:alert(1)"}], + "fundingOrganization": [{"name": "Evidence Fund", "url": "https://fund.example/"}], + "recipientOrganization": [{"name": "Recipient", "url": "javascript:alert(1)"}], }, "funders": [{"self": "https://api.threesixtygiving.org/api/v1/org/FUND/"}], "recipients": [ @@ -235,6 +235,10 @@ async def test_summary_detail_is_lazy_and_full_detail_returns_safe_typed_evidenc self.assertNotIn("360giving_recipient_record", kinds) self.assertNotIn("observed_recipient_website", kinds) self.assertTrue(all("@" not in item["url"] for item in full["source_evidence"])) + funder_record = next(item for item in full["source_evidence"] if item["kind"] == "360giving_funder_record") + self.assertEqual(funder_record["organization_name"], "Evidence Fund") + self.assertEqual(funder_record["role"], "funder") + self.assertEqual(funder_record["link_type"], "json") async def test_endpoint_validates_country_and_returns_paginated_source_funders(self): self.grant("A-1", funder_name="Alpha", funder_source_id="alpha")