diff --git a/apps/admin/src/features/analytics/analytics-breakdown-cards.tsx b/apps/admin/src/features/analytics/analytics-breakdown-cards.tsx new file mode 100644 index 0000000..98af817 --- /dev/null +++ b/apps/admin/src/features/analytics/analytics-breakdown-cards.tsx @@ -0,0 +1,167 @@ +"use client" + +import type { ReactNode } from "react" +import { REPORT_CATEGORY_LABELS, type ReportCategory } from "@civfix/shared" + +import { categoryCssVar } from "@/lib/category" +import { EMPTY_VALUE } from "@/lib/empty-value" +import { AnalyticsCard } from "@/features/analytics/analytics-card" +import type { + useAnalyticsByCategory, + useAnalyticsCoverage, + useAnalyticsFunnel, + useAnalyticsResolutionByCategory, +} from "@/features/analytics/use-analytics" + +const HOURS_PER_DAY = 24 + +function humanizeHours(hours: number): string { + if (hours <= 0) return EMPTY_VALUE + if (hours < 1) return "<1h" + const total = Math.round(hours) + if (total < HOURS_PER_DAY) return `${total}h` + const days = Math.floor(total / HOURS_PER_DAY) + const remainingHours = total % HOURS_PER_DAY + return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d` +} + +function CategoryBarRow({ + cat, + widthPct, + value, +}: { + cat: ReportCategory + widthPct: number + value: ReactNode +}) { + return ( +
+ + {REPORT_CATEGORY_LABELS[cat]} + + + + {value} +
+ ) +} + +export function ByCategoryCard({ query }: { query: ReturnType }) { + return ( + data.rows.every((row) => row.count === 0)} + > + {(data) => ( +
+ {data.rows.map((row) => ( + + ))} +
+ )} +
+ ) +} + +export function FunnelCard({ query }: { query: ReturnType }) { + return ( + data.stages.length === 0} + > + {(data) => ( +
+ {data.stages.map((stage, i) => ( +
+
+ {stage.stage} + {stage.count.toLocaleString()} +
+
+
+
+
+ ))} +
+ )} + + ) +} + +export function CoverageCard({ query }: { query: ReturnType }) { + return ( + data.mapped + data.needsMapping === 0} + > + {(data) => ( +
+
+ + {data.pct} + % + + routed & live +
+
+ +
+
+ + + {data.mapped} mapped + + + + {data.needsMapping} need mapping + +
+
+ )} +
+ ) +} + +export function ResolutionTimeCard({ + query, +}: { + query: ReturnType +}) { + return ( + data.rows.every((row) => row.hours === 0)} + > + {(data) => { + const maxHours = Math.max(...data.rows.map((row) => row.hours), 1) + return ( +
+ {data.rows.map((row) => ( + + ))} +
+ ) + }} +
+ ) +} diff --git a/apps/admin/src/features/analytics/analytics-card.tsx b/apps/admin/src/features/analytics/analytics-card.tsx new file mode 100644 index 0000000..8210968 --- /dev/null +++ b/apps/admin/src/features/analytics/analytics-card.tsx @@ -0,0 +1,58 @@ +"use client" + +import type * as React from "react" + +import { EmptyState } from "@/components/shared/page-primitives" +import { LoadingState, ErrorState } from "@/components/shared/data-states" + +export interface AnalyticsQuery { + isLoading: boolean + isError: boolean + error: unknown + data: T | undefined + refetch: () => void +} + +function cardBody( + title: string, + query: AnalyticsQuery, + isEmpty: ((data: T) => boolean) | undefined, + children: (data: T) => React.ReactNode, +): React.ReactNode { + if (query.isLoading) return + if (query.isError) return query.refetch()} /> + if (!query.data || (isEmpty && isEmpty(query.data))) { + return + } + return children(query.data) +} + +export function AnalyticsCard({ + title, + meta, + query, + isEmpty, + span2 = false, + pad = true, + children, +}: { + title: string + meta: string + query: AnalyticsQuery + isEmpty?: (data: T) => boolean + span2?: boolean + pad?: boolean + children: (data: T) => React.ReactNode +}) { + const body = cardBody(title, query, isEmpty, children) + return ( +
+
+

{title}

+
+ {meta} +
+ {pad ?
{body}
: body} +
+ ) +} diff --git a/apps/admin/src/features/analytics/analytics-charts.tsx b/apps/admin/src/features/analytics/analytics-charts.tsx index 52508bd..710a8e7 100644 --- a/apps/admin/src/features/analytics/analytics-charts.tsx +++ b/apps/admin/src/features/analytics/analytics-charts.tsx @@ -1,9 +1,16 @@ "use client" +import type { CSSProperties } from "react" + +import { barHeightPcts, barValueLabel, sparkHeightPcts } from "@/features/analytics/chart-geometry" + // Class names and DOM match the `.barchart*` and `.hub-spark*` rules in admin.css. +// The `.hue-*` classes admin.css defines; any other value paints nothing. +export type Hue = "slate" | "lilac" | "sun" | "sky" | "moss" | "bloom" + export function BarChart({ values, labels }: { values: number[]; labels?: string[] }) { - const max = Math.max(...values, 1) + const heights = barHeightPcts(values) return (
{values.map((v, i) => ( @@ -12,9 +19,9 @@ export function BarChart({ values, labels }: { values: number[]; labels?: string
- {v >= 1000 ? `${(v / 1000).toFixed(1)}k` : v} + {barValueLabel(v)}
{labels &&
{labels[i]}
} @@ -31,22 +38,21 @@ export function Spark({ }: { values: number[] label: string - hue?: string + hue?: Hue }) { - const max = Math.max(...values) - const min = Math.min(...values) + const heights = sparkHeightPcts(values) return (
- {values.map((v, i) => ( + {heights.map((height, i) => ( ))} diff --git a/apps/admin/src/features/analytics/analytics-kpis.tsx b/apps/admin/src/features/analytics/analytics-kpis.tsx new file mode 100644 index 0000000..ee2b4d2 --- /dev/null +++ b/apps/admin/src/features/analytics/analytics-kpis.tsx @@ -0,0 +1,89 @@ +"use client" + +import type { AnalyticsKpi, AnalyticsKpisResponse } from "@civfix/shared" + +import { Icons } from "@/components/icons" +import { EmptyState } from "@/components/shared/page-primitives" +import { LoadingState, ErrorState } from "@/components/shared/data-states" +import type { useAnalyticsKpis } from "@/features/analytics/use-analytics" + +// The server sends "Avg. route time" as a placeholder cell and marks percentages only through the +// label, so both are matched on the copy until the KPI contract carries a key and a unit. +const HIDDEN_KPI_LABEL = /route/i +const PERCENT_KPI_LABEL = /resolved/i + +type KpiDirection = AnalyticsKpi["dir"] + +const KPI_DELTA_COLOR: Record = { + up: "var(--moss-700)", + down: "var(--danger)", + flat: "var(--ink-3)", +} + +export function visibleKpis(kpis: AnalyticsKpisResponse): AnalyticsKpi[] { + return kpis.kpis.filter((k) => !HIDDEN_KPI_LABEL.test(k.label)) +} + +export function isPercentKpi(kpi: AnalyticsKpi): boolean { + return PERCENT_KPI_LABEL.test(kpi.label) +} + +function KpiDelta({ delta, dir }: { delta: string; dir: KpiDirection }) { + const Arrow = dir === "up" ? Icons.ArrowUp : dir === "down" ? Icons.ArrowDown : null + return ( +
+ {Arrow ? : null} {delta} +
+ ) +} + +function KpiStrip({ kpis }: { kpis: AnalyticsKpi[] }) { + return ( + <> + {kpis.map((kpi) => ( +
+
{kpi.label}
+
+ {isPercentKpi(kpi) ? `${Math.round(kpi.num)}%` : kpi.num.toLocaleString()} +
+ +
+ ))} + + ) +} + +export function KpiSection({ + kpisQuery, + shownKpis, +}: { + kpisQuery: ReturnType + shownKpis: AnalyticsKpi[] +}) { + if (kpisQuery.isLoading) { + return ( +
+ +
+ ) + } + if (kpisQuery.isError) { + return ( +
+ kpisQuery.refetch()} /> +
+ ) + } + if (shownKpis.length === 0) { + return ( +
+ +
+ ) + } + return ( +
+ +
+ ) +} diff --git a/apps/admin/src/features/analytics/analytics-page.tsx b/apps/admin/src/features/analytics/analytics-page.tsx index 8650e04..04a3455 100644 --- a/apps/admin/src/features/analytics/analytics-page.tsx +++ b/apps/admin/src/features/analytics/analytics-page.tsx @@ -1,19 +1,16 @@ "use client" -import * as React from "react" -import { - REPORT_CATEGORY_LABELS, - type AnalyticsKpi, - type AnalyticsKpisResponse, -} from "@civfix/shared" - import { Icons } from "@/components/icons" -import { categoryCssVar } from "@/lib/category" -import { downloadCsv } from "@/lib/csv" -import { EMPTY_VALUE } from "@/lib/empty-value" -import { PageHead, EmptyState } from "@/components/shared/page-primitives" -import { LoadingState, ErrorState } from "@/components/shared/data-states" -import { BarChart } from "@/features/analytics/analytics-charts" +import { PageHead } from "@/components/shared/page-primitives" +import { + ByCategoryCard, + CoverageCard, + FunnelCard, + ResolutionTimeCard, +} from "@/features/analytics/analytics-breakdown-cards" +import { KpiSection, visibleKpis } from "@/features/analytics/analytics-kpis" +import { TopContributorsCard, TopJurisdictionsCard } from "@/features/analytics/analytics-rank-cards" +import { CleanupEventsCard, PinsPerWeekCard } from "@/features/analytics/analytics-trend-cards" import { useAnalyticsByCategory, useAnalyticsCoverage, @@ -25,112 +22,12 @@ import { useAnalyticsTopContributors, useAnalyticsTopJurisdictions, } from "@/features/analytics/use-analytics" -import { useToast } from "@/store/ui-store" +import { useAnalyticsExport } from "@/features/analytics/use-analytics-export" import type { SectionPageProps } from "@/components/shell/page-registry" -function initials(name: string): string { - const out = name - .split(" ") - .map((w) => w[0] ?? "") - .slice(0, 2) - .join("") - .toUpperCase() - return out || "?" -} - -function humanizeHours(hours: number): string { - if (hours <= 0) return EMPTY_VALUE - if (hours < 1) return "<1h" - const total = Math.round(hours) - if (total < 24) return `${total}h` - const days = Math.floor(total / 24) - const rem = total % 24 - return rem > 0 ? `${days}d ${rem}h` : `${days}d` -} - -function visibleKpis(kpis: AnalyticsKpisResponse): AnalyticsKpi[] { - return kpis.kpis.filter((k) => !/route/i.test(k.label)) -} - -function isPercentKpi(k: AnalyticsKpi): boolean { - return /resolved/i.test(k.label) -} - -function AnalyticsCard({ - title, - meta, - query, - isEmpty, - span2 = false, - pad = true, - children, -}: { - title: string - meta: string - query: { - isLoading: boolean - isError: boolean - error: unknown - data: T | undefined - refetch: () => void - } - isEmpty?: (data: T) => boolean - span2?: boolean - pad?: boolean - children: (data: T) => React.ReactNode -}) { - let inner: React.ReactNode - if (query.isLoading) { - inner = - } else if (query.isError) { - inner = query.refetch()} /> - } else if (!query.data || (isEmpty && isEmpty(query.data))) { - inner = - } else { - inner = children(query.data) - } - - return ( -
-
-

{title}

-
- {meta} -
- {pad ?
{inner}
: inner} -
- ) -} - -function KpiDelta({ delta, dir }: { delta: string; dir: "up" | "down" | "flat" }) { - const color = dir === "up" ? "var(--moss-700)" : dir === "down" ? "var(--danger)" : "var(--ink-3)" - return ( -
- {dir === "up" ? : dir === "down" ? : null}{" "} - {delta} -
- ) -} - -function KpiStrip({ kpis }: { kpis: AnalyticsKpi[] }) { - return ( - <> - {kpis.map((k) => ( -
-
{k.label}
-
- {isPercentKpi(k) ? `${Math.round(k.num)}%` : k.num.toLocaleString()} -
- -
- ))} - - ) -} +const DISABLED_EXPORT_STYLE = { opacity: 0.45, cursor: "not-allowed" } as const export function AnalyticsPage(_props: SectionPageProps) { - const toast = useToast() - const kpisQuery = useAnalyticsKpis() const pinsQuery = useAnalyticsPinsByWeek() const byCategoryQuery = useAnalyticsByCategory() @@ -141,26 +38,12 @@ export function AnalyticsPage(_props: SectionPageProps) { const topJurisdictionsQuery = useAnalyticsTopJurisdictions() const topContributorsQuery = useAnalyticsTopContributors() - const kpis = kpisQuery.data - const shownKpis = kpis ? visibleKpis(kpis) : [] - const events = eventsQuery.data - const byCategory = byCategoryQuery.data - - const canExport = !!kpis && !!events && !!byCategory - const exportCsv = () => { - if (!kpis || !events || !byCategory) return - const rows: (string | number)[][] = [["Metric", "Value", "Change"]] - shownKpis.forEach((k) => - rows.push([k.label, isPercentKpi(k) ? `${Math.round(k.num)}%` : k.num, k.delta]), - ) - rows.push(["Cleanup events (month)", events.thisMonth, ""]) - rows.push(["Volunteers", events.volunteers, ""]) - rows.push([]) - rows.push(["Category", "Reports", "Share %"]) - byCategory.rows.forEach((c) => rows.push([REPORT_CATEGORY_LABELS[c.cat], c.count, c.pct])) - downloadCsv("civfix-analytics.csv", rows) - toast("Analytics exported · civfix-analytics.csv") - } + const shownKpis = kpisQuery.data ? visibleKpis(kpisQuery.data) : null + const { canExport, exportCsv } = useAnalyticsExport( + shownKpis, + eventsQuery.data, + byCategoryQuery.data, + ) return ( <> @@ -177,259 +60,23 @@ export function AnalyticsPage(_props: SectionPageProps) { className="btn" onClick={exportCsv} disabled={!canExport} - style={!canExport ? { opacity: 0.45, cursor: "not-allowed" } : undefined} + style={!canExport ? DISABLED_EXPORT_STYLE : undefined} > Export - {kpisQuery.isLoading ? ( -
- -
- ) : kpisQuery.isError ? ( -
- kpisQuery.refetch()} /> -
- ) : shownKpis.length === 0 ? ( -
- -
- ) : ( -
- -
- )} +
- d.weeks.length === 0} - > - {(d) => } - - - d.rows.every((r) => r.count === 0)} - > - {(d) => ( -
- {d.rows.map((c) => ( -
- - {REPORT_CATEGORY_LABELS[c.cat]} - - - - {c.count} -
- ))} -
- )} -
- - d.stages.length === 0} - > - {(d) => ( -
- {d.stages.map((f, i) => ( -
-
- {f.stage} - {f.count.toLocaleString()} -
-
-
-
-
- ))} -
- )} - - - d.mapped + d.needsMapping === 0} - > - {(d) => ( -
-
- - {d.pct} - % - - routed & live -
-
- -
-
- - - {d.mapped} mapped - - - - {d.needsMapping} need mapping - -
-
- )} -
- - d.rows.every((r) => r.hours === 0)} - > - {(d) => { - const max = Math.max(...d.rows.map((x) => x.hours), 1) - return ( -
- {d.rows.map((r) => ( -
- - {REPORT_CATEGORY_LABELS[r.cat]} - - - - {humanizeHours(r.hours)} -
- ))} -
- ) - }} -
- - d.byMonth.length === 0} - > - {(d) => ( - <> -
-
- {d.thisMonth} - events this month -
-
- {d.volunteers.toLocaleString()} - volunteers -
- {d.bags > 0 ? ( -
- {d.bags.toLocaleString()} - bags collected -
- ) : null} -
- - - )} -
- - d.rows.length === 0} - > - {(d) => ( -
-
-
- Jurisdiction - Pins - Resolved -
- {d.rows.map((j, i) => ( -
- - {j.org} - - - {j.pins} - - - {j.resolved}% - -
- ))} -
-
- )} -
- - d.rows.length === 0} - > - {(d) => ( -
-
-
- Neighbor - Reports - Cleanups -
- {d.rows.map((c, i) => ( -
- - {initials(c.name)} - - {c.name} - {c.city || EMPTY_VALUE} - - - - {c.reports} - - - {c.cleanups} - -
- ))} -
-
- )} -
+ + + + + + + +
) diff --git a/apps/admin/src/features/analytics/analytics-rank-cards.tsx b/apps/admin/src/features/analytics/analytics-rank-cards.tsx new file mode 100644 index 0000000..9c4ee09 --- /dev/null +++ b/apps/admin/src/features/analytics/analytics-rank-cards.tsx @@ -0,0 +1,141 @@ +"use client" + +import type { ReactNode } from "react" + +import { INITIALS_MAX_LETTERS } from "@/lib/display" +import { EMPTY_VALUE } from "@/lib/empty-value" +import { AnalyticsCard } from "@/features/analytics/analytics-card" +import type { + useAnalyticsTopContributors, + useAnalyticsTopJurisdictions, +} from "@/features/analytics/use-analytics" + +const GOOD_VALUE_STYLE = { color: "var(--moss-700)", fontWeight: 700 } as const + +function initials(name: string): string { + const letters = name + .split(" ") + .map((word) => word[0] ?? "") + .slice(0, INITIALS_MAX_LETTERS) + .join("") + .toUpperCase() + return letters || "?" +} + +interface RankRow { + key: string + lead: ReactNode + count: ReactNode + good: ReactNode +} + +function RankTable({ + label, + variant, + headers, + rows, +}: { + label: string + variant: "jt" | "ct" + headers: readonly [string, string, string] + rows: RankRow[] +}) { + return ( +
+
+
+ {headers.map((header) => ( + + {header} + + ))} +
+ {rows.map((row) => ( +
+ {row.lead} + + {row.count} + + + {row.good} + +
+ ))} +
+
+ ) +} + +export function TopJurisdictionsCard({ + query, +}: { + query: ReturnType +}) { + return ( + data.rows.length === 0} + > + {(data) => ( + ({ + key: `${jurisdiction.org}-${i}`, + lead: ( + + {jurisdiction.org} + + ), + count: jurisdiction.pins, + good: <>{jurisdiction.resolved}%, + }))} + /> + )} + + ) +} + +export function TopContributorsCard({ + query, +}: { + query: ReturnType +}) { + return ( + data.rows.length === 0} + > + {(data) => ( + ({ + key: `${contributor.name}-${contributor.city}-${i}`, + lead: ( + + {initials(contributor.name)} + + {contributor.name} + {contributor.city || EMPTY_VALUE} + + + ), + count: contributor.reports, + good: contributor.cleanups, + }))} + /> + )} + + ) +} diff --git a/apps/admin/src/features/analytics/analytics-trend-cards.tsx b/apps/admin/src/features/analytics/analytics-trend-cards.tsx new file mode 100644 index 0000000..d540d04 --- /dev/null +++ b/apps/admin/src/features/analytics/analytics-trend-cards.tsx @@ -0,0 +1,53 @@ +"use client" + +import { AnalyticsCard } from "@/features/analytics/analytics-card" +import { BarChart } from "@/features/analytics/analytics-charts" +import type { useAnalyticsEvents, useAnalyticsPinsByWeek } from "@/features/analytics/use-analytics" + +export function PinsPerWeekCard({ query }: { query: ReturnType }) { + return ( + data.weeks.length === 0} + > + {(data) => } + + ) +} + +export function CleanupEventsCard({ query }: { query: ReturnType }) { + return ( + data.byMonth.length === 0} + > + {(data) => ( + <> +
+
+ {data.thisMonth} + events this month +
+
+ {data.volunteers.toLocaleString()} + volunteers +
+ {data.bags > 0 ? ( +
+ {data.bags.toLocaleString()} + bags collected +
+ ) : null} +
+ + + )} +
+ ) +} diff --git a/apps/admin/src/features/analytics/chart-geometry.test.ts b/apps/admin/src/features/analytics/chart-geometry.test.ts new file mode 100644 index 0000000..8d219db --- /dev/null +++ b/apps/admin/src/features/analytics/chart-geometry.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest" + +import { barHeightPcts, barValueLabel, sparkHeightPcts } from "./chart-geometry" + +describe("bar chart geometry", () => { + it("scales every bar against the tallest one", () => { + expect(barHeightPcts([2, 4, 1])).toEqual([50, 100, 25]) + }) + + it("keeps an all-zero series at zero height instead of dividing by zero", () => { + expect(barHeightPcts([0, 0])).toEqual([0, 0]) + }) + + it("returns no heights for an empty series", () => { + expect(barHeightPcts([])).toEqual([]) + }) + + it("abbreviates values from one thousand up", () => { + expect(barValueLabel(999)).toBe("999") + expect(barValueLabel(1000)).toBe("1.0k") + expect(barValueLabel(1540)).toBe("1.5k") + }) +}) + +describe("sparkline geometry", () => { + it("spans the floor to the top between the smallest and largest value", () => { + expect(sparkHeightPcts([1, 3, 2])).toEqual([10, 98, 54]) + }) + + it("draws a flat series at the floor height", () => { + expect(sparkHeightPcts([5, 5, 5])).toEqual([10, 10, 10]) + }) + + it("draws a single value at the floor height", () => { + expect(sparkHeightPcts([7])).toEqual([10]) + }) + + it("returns no heights for an empty series", () => { + expect(sparkHeightPcts([])).toEqual([]) + }) +}) diff --git a/apps/admin/src/features/analytics/chart-geometry.ts b/apps/admin/src/features/analytics/chart-geometry.ts new file mode 100644 index 0000000..e9e533f --- /dev/null +++ b/apps/admin/src/features/analytics/chart-geometry.ts @@ -0,0 +1,22 @@ +// A flat or all-minimum series still draws a visible stub instead of an empty track. +const SPARK_FLOOR_PCT = 10 +const SPARK_SPAN_PCT = 88 + +const COMPACT_BAR_VALUE_FROM = 1000 + +export function barHeightPcts(values: readonly number[]): number[] { + const max = Math.max(...values, 1) + return values.map((v) => (v / max) * 100) +} + +export function sparkHeightPcts(values: readonly number[]): number[] { + const max = Math.max(...values) + const min = Math.min(...values) + return values.map((v) => SPARK_FLOOR_PCT + ((v - min) / (max - min || 1)) * SPARK_SPAN_PCT) +} + +export function barValueLabel(value: number): string { + return value >= COMPACT_BAR_VALUE_FROM + ? `${(value / COMPACT_BAR_VALUE_FROM).toFixed(1)}k` + : String(value) +} diff --git a/apps/admin/src/features/analytics/use-analytics-export.ts b/apps/admin/src/features/analytics/use-analytics-export.ts new file mode 100644 index 0000000..3d03b2d --- /dev/null +++ b/apps/admin/src/features/analytics/use-analytics-export.ts @@ -0,0 +1,40 @@ +"use client" + +import { + REPORT_CATEGORY_LABELS, + type AnalyticsByCategoryResponse, + type AnalyticsEventsResponse, + type AnalyticsKpi, +} from "@civfix/shared" + +import { downloadCsv } from "@/lib/csv" +import { isPercentKpi } from "@/features/analytics/analytics-kpis" +import { useToast } from "@/store/ui-store" + +const EXPORT_FILENAME = "civfix-analytics.csv" + +export function useAnalyticsExport( + shownKpis: AnalyticsKpi[] | null, + events: AnalyticsEventsResponse | undefined, + byCategory: AnalyticsByCategoryResponse | undefined, +) { + const toast = useToast() + const canExport = shownKpis !== null && !!events && !!byCategory + + const exportCsv = () => { + if (shownKpis === null || !events || !byCategory) return + const rows: (string | number)[][] = [["Metric", "Value", "Change"]] + shownKpis.forEach((kpi) => + rows.push([kpi.label, isPercentKpi(kpi) ? `${Math.round(kpi.num)}%` : kpi.num, kpi.delta]), + ) + rows.push(["Cleanup events (month)", events.thisMonth, ""]) + rows.push(["Volunteers", events.volunteers, ""]) + rows.push([]) + rows.push(["Category", "Reports", "Share %"]) + byCategory.rows.forEach((row) => rows.push([REPORT_CATEGORY_LABELS[row.cat], row.count, row.pct])) + downloadCsv(EXPORT_FILENAME, rows) + toast(`Analytics exported · ${EXPORT_FILENAME}`) + } + + return { canExport, exportCsv } +} diff --git a/apps/admin/src/features/discovery/directory-query.test.ts b/apps/admin/src/features/discovery/directory-query.test.ts new file mode 100644 index 0000000..a619460 --- /dev/null +++ b/apps/admin/src/features/discovery/directory-query.test.ts @@ -0,0 +1,70 @@ +import { hashKey } from "@tanstack/react-query" +import { describe, expect, it } from "vitest" + +import { EMPTY_VALUE } from "@/lib/empty-value" + +import { directoryCounts, toDirectoryQuery } from "./discovery-ui-state" + +describe("directory query", () => { + it.each([ + ["attention", "pop", { filter: "needs_mapping", sort: "oldest" }], + ["clear", "pop", { filter: "routed", sort: "population" }], + ["all", "reports", { filter: "all", sort: "reports" }], + ["all", "oldest", { filter: "all", sort: "oldest" }], + ] as const)("maps %s sorted by %s", (filter, sort, expected) => { + expect(toDirectoryQuery(filter, sort, "all", "")).toEqual(expected) + }) + + it("adds the layer and search only when set", () => { + expect(toDirectoryQuery("all", "pop", "county", "06")).toEqual({ + filter: "all", + sort: "population", + layer: "county", + q: "06", + }) + expect(hashKey([toDirectoryQuery("all", "pop", "all", "")])).toBe( + hashKey([{ filter: "all", sort: "population" }]), + ) + }) +}) + +describe("directory counts", () => { + const list = { isLoading: false, isError: false } + + it("splits the facets across the chips and heads the list with the active filter's count", () => { + const counts = directoryCounts({ + filter: "clear", + total: 12, + facets: { routed: 5, unrouted: 7 }, + needsMapping: 3, + list, + loadedCount: 2, + }) + expect(counts).toEqual({ chips: { attention: 3, clear: 5, all: 12 }, header: 5 }) + }) + + it("heads Needs mapping with its own count query", () => { + const counts = directoryCounts({ + filter: "attention", + total: 40, + facets: null, + needsMapping: "Loading…", + list, + loadedCount: 0, + }) + expect(counts.header).toBe("Loading…") + expect(counts.chips.all).toBe(EMPTY_VALUE) + }) + + it("falls back to the loaded rows when the server sent no total", () => { + const counts = directoryCounts({ + filter: "all", + total: null, + facets: null, + needsMapping: 0, + list, + loadedCount: 4, + }) + expect(counts.header).toBe(4) + }) +}) diff --git a/apps/admin/src/features/discovery/discovery-page.tsx b/apps/admin/src/features/discovery/discovery-page.tsx index 093a075..011b3d6 100644 --- a/apps/admin/src/features/discovery/discovery-page.tsx +++ b/apps/admin/src/features/discovery/discovery-page.tsx @@ -1,864 +1,285 @@ "use client" import * as React from "react" -import dynamic from "next/dynamic" -import { - DEFAULT_FORWARD_BODY_TEMPLATE, - DEFAULT_FORWARD_SUBJECT_TEMPLATE, - type DiscoveryContact, - type JurisdictionDirectoryDTO, - type JurisdictionLayer, - type PerCategoryCounts, - type ReportCategory, -} from "@civfix/shared" +import type { JurisdictionDirectoryDTO } from "@civfix/shared" import { Icons } from "@/components/icons" -import { errorMessage } from "@/lib/error-messages" -import { EMPTY_VALUE } from "@/lib/empty-value" -import { - REPORT_CATEGORIES, - categoryLabel, - categoryPinSrc, - categoryReportTypes, -} from "@/lib/category" -import { promptDialog } from "@/components/shared/dialog" import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" -import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation" -import { - useJurisdictionDirectory, - useJurisdictionGeometry, - usePatchJurisdiction, - useSaveJurisdictionContacts, -} from "@/features/discovery/use-discovery" +import { useJurisdictionDirectory } from "@/features/discovery/use-discovery" import { + directoryCounts, getCountDisplay, getJurisdictionSort, initialDirectoryState, pickSelected, + toDirectoryQuery, + type CountDisplay, type JurisdictionFilter, type JurisdictionSort, } from "@/features/discovery/discovery-ui-state" +import { JurisdictionDetail } from "@/features/discovery/jurisdiction-detail" +import { JurisdictionRow } from "@/features/discovery/jurisdiction-row" import { - afterContactsSaved, - contactsPayload, - jurisdictionFields, - noteAndHandleFields, - parseHandle, - routingContactsPayload, - withContactEdit, - type ContactEdits, -} from "@/features/discovery/discovery-payloads" -import { ForwardTemplateModal } from "@/features/mail/forward-template-modal" -import { useForwardTemplateDefault } from "@/features/mail/use-mail" - -const BoundaryMap = dynamic( - () => import("@/components/map/boundary-map").then((m) => m.BoundaryMap), - { ssr: false }, -) -import { useToast } from "@/store/ui-store" + LAYER_OPTIONS, + UNMAPPED_GEOID, + type LayerChoice, +} from "@/features/discovery/jurisdiction-view" +import { UnmappedDetail } from "@/features/discovery/unmapped-detail" +import { SEARCH_DEBOUNCE_MS } from "@/lib/timing" import type { SectionPageProps } from "@/components/shell/page-registry" -const LAYER_LABEL: Record = { - place: "City", - county: "County", - state: "State", - federal: "Federal land", - tribal: "Tribal", -} - -const UNMAPPED_GEOID = "__unmapped__" +type DirectoryList = ReturnType -function fmtRouted(iso: string | null): string { - if (!iso) return EMPTY_VALUE - const d = new Date(iso) - if (Number.isNaN(d.getTime())) return EMPTY_VALUE - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) -} - -const HOUR_MS = 60 * 60 * 1000 +function useDirectoryFilters(focusId: string | null) { + const [initial] = React.useState(() => initialDirectoryState(focusId)) + const [filter, setFilter] = React.useState(initial.filter) + const [layer, setLayer] = React.useState("all") + const [sort, setSort] = React.useState("pop") + const [query, setQuery] = React.useState(initial.query) + const [debouncedQuery, setDebouncedQuery] = React.useState(initial.query) -function fmtAge(iso: string | null): string { - if (!iso) return EMPTY_VALUE - const then = new Date(iso).getTime() - if (Number.isNaN(then)) return EMPTY_VALUE - const diff = Date.now() - then - if (diff < 60 * 1000) return "just now" - const mins = Math.floor(diff / (60 * 1000)) - if (mins < 60) return `${mins}m` - const hours = Math.floor(diff / HOUR_MS) - if (hours < 24) return `${hours}h` - const days = Math.floor(diff / (24 * HOUR_MS)) - if (days < 14) return `${days}d` - const weeks = Math.floor(days / 7) - if (weeks < 8) return `${weeks}w` - const months = Math.floor(days / 30) - if (months < 12) return `${months}mo` - return `${Math.floor(days / 365)}y` -} + React.useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_DEBOUNCE_MS) + return () => clearTimeout(timer) + }, [query]) + React.useEffect(() => { + if (!focusId) return + const next = initialDirectoryState(focusId) + setFilter(next.filter) + setQuery(next.query) + setDebouncedQuery(next.query) + }, [focusId]) -function isOverdue(iso: string | null): boolean { - if (!iso) return false - const then = new Date(iso).getTime() - if (Number.isNaN(then)) return false - return Date.now() - then > 24 * HOUR_MS + return { filter, setFilter, layer, setLayer, sort, setSort, query, setQuery, debouncedQuery } } -const REPORT_TYPES: { id: ReportCategory; label: string; pin: string; types: string }[] = - REPORT_CATEGORIES.map((id) => ({ - id, - label: categoryLabel(id), - pin: categoryPinSrc(id), - types: categoryReportTypes(id), - })) +// Only the first load picks a jurisdiction on the operator's behalf, and a deep-linked one is never +// replaced. A pick that a filter or search leaves out stays open as it was last listed; a pick that +// drops out of the same list after a refetch (a Save & route under Needs mapping) clears, so the pane +// never shows a stale row or jumps to another jurisdiction's save buttons. Decided only on data +// fetched for the current params. +function useJurisdictionSelection({ + focusId, + items, + listQuery, + listKey, +}: { + focusId: string | null + items: JurisdictionDirectoryDTO[] + listQuery: DirectoryList + listKey: string +}) { + const [selectedId, setSelectedId] = React.useState(focusId) + const [lastSeen, setLastSeen] = React.useState(null) + React.useEffect(() => { + if (focusId) setSelectedId(focusId) + }, [focusId]) -function routingCount(counts: PerCategoryCounts, id: ReportCategory): number { - return counts[id] ?? 0 -} + const selected = pickSelected(items, selectedId, lastSeen) + React.useEffect(() => { + if (selected && selected !== lastSeen) setLastSeen(selected) + }, [selected, lastSeen]) -function dominantCategory(counts: PerCategoryCounts): ReportCategory | null { - let best: ReportCategory | null = null - let bestN = 0 - for (const c of REPORT_CATEGORIES) { - const n = counts[c] ?? 0 - if (n > bestN) { - bestN = n - best = c + const [autoPick, setAutoPick] = React.useState(focusId === null) + const seenIn = React.useRef<{ id: string; list: string } | null>(null) + React.useEffect(() => { + if (!listQuery.isSuccess || listQuery.isFetching) return + if (selectedId === null) { + if (autoPick && items[0]) setSelectedId(items[0].geoid) + return } - } - return best -} - -function needsAttention(dto: JurisdictionDirectoryDTO): boolean { - return dto.reportsWaiting > 0 && dto.method === "none" -} + setAutoPick(false) + if (items.some((x) => x.geoid === selectedId)) seenIn.current = { id: selectedId, list: listKey } + else if (seenIn.current?.id === selectedId && seenIn.current.list === listKey) setSelectedId(null) + }, [listQuery.isSuccess, listQuery.isFetching, items, selectedId, listKey, autoPick]) -function fmtPop(n: number): string { - if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k" - return String(n) + const notListed = + selectedId !== null && selected === null && !listQuery.isLoading && !listQuery.isError + return { selectedId, setSelectedId, selected, notListed } } -function JurisdictionRow({ - item, - selected, - onClick, - showOldest = false, +function DirectoryToolbar({ + filters, + chips, }: { - item: JurisdictionDirectoryDTO - selected: boolean - onClick: () => void - showOldest?: boolean + filters: ReturnType + chips: Record }) { - const isUnmapped = item.geoid === UNMAPPED_GEOID - const overdue = showOldest && isOverdue(item.oldestReportAt) - const needs = needsAttention(item) - const dom = dominantCategory(item.perCategoryCounts) - const pin = dom ? categoryPinSrc(dom) : null - const onKeyDown = (event: React.KeyboardEvent) => { - if (!isKeyboardActivationKey(event.key)) return - event.preventDefault() - onClick() - } - - if (isUnmapped) { - return ( -
-
- -
-
-
- {item.org} - - - Needs mapping - -
-
- {item.reportsWaiting} waiting - · - location didn’t resolve to a jurisdiction -
-
-
- {showOldest && item.oldestReportAt && ( - - {overdue && } - {fmtAge(item.oldestReportAt)} - - )} - - - -
-
- ) - } - + const { filter, setFilter, layer, setLayer, sort, setSort, query, setQuery } = filters return ( -
-
- {pin ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( - - )} +
+ setFilter(v as JurisdictionFilter)} + /> +
+
+ + setQuery(e.target.value)} + />
-
-
- {item.org} - {LAYER_LABEL[item.layer]} - {item.status === "bounced" ? ( - - Bounced - - ) : needs ? ( - - - Needs contact - - ) : ( - - Routed - - )} - {item.flaggedAt && ( - - Flagged - - )} - {item.geoid} -
-
- {fmtPop(item.population)} pop - · - {item.reportsWaiting} waiting - · - {item.coverage} -
+
+ Type +
-
- {showOldest ? ( - - {overdue && } - {fmtAge(item.oldestReportAt)} - - ) : ( - {fmtRouted(item.lastRouted)} - )} - - - +
+ Sort +
) } -function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) { - const toast = useToast() - const patch = usePatchJurisdiction() - const saveContacts = useSaveJurisdictionContacts() - const geometry = useJurisdictionGeometry(dto.geoid) - - const [contacts, setContacts] = React.useState>(() => { - const seed: Record = {} - dto.contacts.forEach((c: DiscoveryContact) => { - if (c.email) seed[c.category] = c.email - }) - return seed - }) - const [contactEdits, setContactEdits] = React.useState({}) - const [opNote, setOpNote] = React.useState("") - const [defaultEmail, setDefaultEmail] = React.useState(dto.email ?? "") - const [formUrl, setFormUrl] = React.useState(dto.form ?? "") - const [handle, setHandle] = React.useState(dto.handle ?? "") - - const defaultTemplate = useForwardTemplateDefault() - const [templateOpen, setTemplateOpen] = React.useState(false) - const hasCustomTemplate = - dto.forwardSubjectTemplate !== null || dto.forwardBodyTemplate !== null - const storedDefault = defaultTemplate.data - const templateFallback = - storedDefault && (storedDefault.subjectTemplate !== null || storedDefault.bodyTemplate !== null) - ? { subject: storedDefault.subjectTemplate ?? "", body: storedDefault.bodyTemplate ?? "" } - : { subject: DEFAULT_FORWARD_SUBJECT_TEMPLATE, body: DEFAULT_FORWARD_BODY_TEMPLATE } - - const counts = dto.perCategoryCounts - const setCat = (id: string, email: string) => { - setContacts((prev) => ({ ...prev, [id]: email })) - setContactEdits((prev) => withContactEdit(prev, id)) - } - const contactsSaved = (sent: ContactEdits) => - setContactEdits((prev) => afterContactsSaved(prev, sent)) - const hasDefault = defaultEmail.trim() !== "" - const missingContacts = REPORT_TYPES.filter( - (c) => routingCount(counts, c.id) > 0 && !contacts[c.id] && !hasDefault, - ).length - const filledCount = REPORT_TYPES.filter((c) => contacts[c.id]).length - const canSave = filledCount > 0 || hasDefault - const needs = needsAttention(dto) - const dom = dominantCategory(counts) - const headPin = dom ? categoryPinSrc(dom) : null - const isFlagged = dto.flaggedAt !== null - const busy = saveContacts.isPending || patch.isPending - const parsedHandle = parseHandle(handle) - const saveBlocked = busy || parsedHandle.error !== null - - const onFlag = async () => { - if (!isFlagged) { - const reason = await promptDialog({ title: "Flag jurisdiction", label: "Reason (optional)" }) - if (reason === null) return - patch.mutate({ - request: { geoid: dto.geoid, flagged: true, flagReason: reason || undefined }, - org: dto.org, - action: "flag", - }) - return - } - patch.mutate({ request: { geoid: dto.geoid, flagged: false }, org: dto.org, action: "flag" }) +function DirectoryListBody({ + listQuery, + items, + selectedGeoid, + onSelect, + showOldest, +}: { + listQuery: DirectoryList + items: JurisdictionDirectoryDTO[] + selectedGeoid: string | undefined + onSelect: (geoid: string) => void + showOldest: boolean +}) { + if (listQuery.isLoading) return + if (listQuery.isError) { + return listQuery.refetch()} /> } - - const onSaveDraft = () => { - const sentEdits = contactEdits - const contactFields = contactsPayload(sentEdits, contacts) - const jf = jurisdictionFields(defaultEmail, formUrl) - const extras = noteAndHandleFields(opNote, parsedHandle.value, dto.handle) - if ( - Object.keys(contactFields).length === 0 && - Object.keys(jf).length === 0 && - Object.keys(extras).length === 0 - ) { - toast("Nothing to save yet") - return - } - patch.mutate( - { - request: { geoid: dto.geoid, contacts: contactFields, ...jf, ...extras }, - org: dto.org, - action: "draft", - }, - { onSuccess: () => contactsSaved(sentEdits) }, + if (items.length === 0) { + return ( + } + /> ) } - - const onSaveContacts = async () => { - if (!canSave) return - const extras = noteAndHandleFields(opNote, parsedHandle.value, dto.handle) - const savesExtrasFirst = Object.keys(extras).length > 0 - const sentEdits = contactEdits - const saveAndRoute = () => - saveContacts.mutate( - { - request: { - geoid: dto.geoid, - contacts: routingContactsPayload(sentEdits, contacts), - ...jurisdictionFields(defaultEmail, formUrl), - }, - org: dto.org, - ...(savesExtrasFirst ? { savedFirst: extras } : {}), - }, - { onSuccess: () => contactsSaved(sentEdits) }, - ) - if (!savesExtrasFirst) { - saveAndRoute() - return - } - // Awaited rather than chained through a per-call callback: that is dropped once this pane unmounts, - // which would save the note but never the contacts. - try { - await patch.mutateAsync({ request: { geoid: dto.geoid, ...extras }, org: dto.org, action: "extras" }) - } catch { - // The query client already toasted the failure, and the contacts must not save without the note. - return - } - saveAndRoute() - } - return ( -
-
- - {headPin ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( - - )} - -
-
- {LAYER_LABEL[dto.layer]} · Jurisdiction · GEOID {dto.geoid} -
-

{dto.org}

-
- {dto.status === "bounced" ? ( - - Bounced - - ) : needs ? ( - - - Needs contact - - ) : ( - - Routed - - )} -
- -
-
-
-
Jurisdiction
-
-
- {geometry.data ? ( - - ) : ( -
- {geometry.isLoading ? "Loading boundary…" : dto.org} -
- )} -
-
-
-
Population
-
{dto.population.toLocaleString()}
-
- {dto.population > 0 ? "US Census ACS" : "Not available"} -
-
-
-
Reports waiting
-
{dto.reportsWaiting}
-
{LAYER_LABEL[dto.layer]}
-
-
-
-
- -
-
Notes & history
-
-
-