Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions apps/admin/src/features/analytics/analytics-breakdown-cards.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="cat-bd-row">
<span className="cat-bd-dot" style={{ background: categoryCssVar(cat) }} />
<span className="cat-bd-name">{REPORT_CATEGORY_LABELS[cat]}</span>
<span className="cat-bd-track">
<span style={{ width: `${widthPct}%`, background: categoryCssVar(cat) }} />
</span>
<span className="cat-bd-n mono">{value}</span>
</div>
)
}

export function ByCategoryCard({ query }: { query: ReturnType<typeof useAnalyticsByCategory> }) {
return (
<AnalyticsCard
title="By category"
meta="this month"
query={query}
isEmpty={(data) => data.rows.every((row) => row.count === 0)}
>
{(data) => (
<div className="cat-breakdown">
{data.rows.map((row) => (
<CategoryBarRow key={row.cat} cat={row.cat} widthPct={row.pct} value={row.count} />
))}
</div>
)}
</AnalyticsCard>
)
}

export function FunnelCard({ query }: { query: ReturnType<typeof useAnalyticsFunnel> }) {
return (
<AnalyticsCard
title="Report funnel"
meta="pin → resolved"
query={query}
isEmpty={(data) => data.stages.length === 0}
>
{(data) => (
<div className="funnel">
{data.stages.map((stage, i) => (
<div key={stage.stage} className="funnel-row">
<div className="funnel-top">
<span className="funnel-stage">{stage.stage}</span>
<span className="funnel-n mono">{stage.count.toLocaleString()}</span>
</div>
<div className="funnel-track">
<div
className="funnel-fill"
style={{
width: `${stage.pct}%`,
background: i === data.stages.length - 1 ? "var(--moss)" : "var(--bloom)",
}}
/>
</div>
</div>
))}
</div>
)}
</AnalyticsCard>
)
}

export function CoverageCard({ query }: { query: ReturnType<typeof useAnalyticsCoverage> }) {
return (
<AnalyticsCard
title="Mapping coverage"
meta="jurisdictions"
query={query}
isEmpty={(data) => data.mapped + data.needsMapping === 0}
>
{(data) => (
<div className="coverage">
<div className="coverage-num">
<span className="coverage-pct">
{data.pct}
<span className="coverage-pctsign">%</span>
</span>
<span className="coverage-cap">routed &amp; live</span>
</div>
<div className="coverage-bar">
<span className="coverage-fill" style={{ width: `${data.pct}%` }} />
</div>
<div className="coverage-legend">
<span className="cl-item">
<span className="dot" style={{ background: "var(--cat-trash)" }} />
{data.mapped} mapped
</span>
<span className="cl-item">
<span className="dot" style={{ background: "var(--bloom-600)" }} />
{data.needsMapping} need mapping
</span>
</div>
</div>
)}
</AnalyticsCard>
)
}

export function ResolutionTimeCard({
query,
}: {
query: ReturnType<typeof useAnalyticsResolutionByCategory>
}) {
return (
<AnalyticsCard
title="Median resolution time"
meta="by report type"
span2
query={query}
isEmpty={(data) => data.rows.every((row) => row.hours === 0)}
>
{(data) => {
const maxHours = Math.max(...data.rows.map((row) => row.hours), 1)
return (
<div className="cat-breakdown">
{data.rows.map((row) => (
<CategoryBarRow
key={row.cat}
cat={row.cat}
widthPct={(row.hours / maxHours) * 100}
value={humanizeHours(row.hours)}
/>
))}
</div>
)
}}
</AnalyticsCard>
)
}
58 changes: 58 additions & 0 deletions apps/admin/src/features/analytics/analytics-card.tsx
Original file line number Diff line number Diff line change
@@ -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<T> {
isLoading: boolean
isError: boolean
error: unknown
data: T | undefined
refetch: () => void
}

function cardBody<T>(
title: string,
query: AnalyticsQuery<T>,
isEmpty: ((data: T) => boolean) | undefined,
children: (data: T) => React.ReactNode,
): React.ReactNode {
if (query.isLoading) return <LoadingState label={`Loading ${title.toLowerCase()}...`} />
if (query.isError) return <ErrorState error={query.error} onRetry={() => query.refetch()} />
if (!query.data || (isEmpty && isEmpty(query.data))) {
return <EmptyState title="No data yet" sub="Nothing to show for this window." />
}
return children(query.data)
}

export function AnalyticsCard<T>({
title,
meta,
query,
isEmpty,
span2 = false,
pad = true,
children,
}: {
title: string
meta: string
query: AnalyticsQuery<T>
isEmpty?: (data: T) => boolean
span2?: boolean
pad?: boolean
children: (data: T) => React.ReactNode
}) {
const body = cardBody(title, query, isEmpty, children)
return (
<section className={`card ${span2 ? "span-2" : ""}`}>
<div className="card-head">
<h3>{title}</h3>
<div className="spacer" />
<span className="meta">{meta}</span>
</div>
{pad ? <div className="card-pad">{body}</div> : body}
</section>
)
}
26 changes: 16 additions & 10 deletions apps/admin/src/features/analytics/analytics-charts.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="barchart">
{values.map((v, i) => (
Expand All @@ -12,9 +19,9 @@ export function BarChart({ values, labels }: { values: number[]; labels?: string
<div className="barchart-bar-wrap">
<div
className={`barchart-bar ${i === values.length - 1 ? "now" : ""}`}
style={{ height: `${(v / max) * 100}%` }}
style={{ height: `${heights[i]}%` }}
>
<span className="barchart-val">{v >= 1000 ? `${(v / 1000).toFixed(1)}k` : v}</span>
<span className="barchart-val">{barValueLabel(v)}</span>
</div>
</div>
{labels && <div className="barchart-label">{labels[i]}</div>}
Expand All @@ -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 (
<div className={`hub-spark hue-${hue}`} role="img" aria-label={label}>
{values.map((v, i) => (
{heights.map((height, i) => (
<span
// eslint-disable-next-line react/no-array-index-key
key={i}
className={`hub-spark-bar ${i === values.length - 1 ? "now" : ""}`}
className={`hub-spark-bar ${i === heights.length - 1 ? "now" : ""}`}
style={
{
height: `${10 + ((v - min) / (max - min || 1)) * 88}%`,
height: `${height}%`,
"--sh": "var(--hue)",
} as React.CSSProperties
} as CSSProperties
}
/>
))}
Expand Down
89 changes: 89 additions & 0 deletions apps/admin/src/features/analytics/analytics-kpis.tsx
Original file line number Diff line number Diff line change
@@ -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<KpiDirection, string> = {
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 (
<div className="statcell-hot" style={{ color: KPI_DELTA_COLOR[dir] }}>
{Arrow ? <Arrow size={10} /> : null} {delta}
</div>
)
}

function KpiStrip({ kpis }: { kpis: AnalyticsKpi[] }) {
return (
<>
{kpis.map((kpi) => (
<div key={kpi.label} className="statcell">
<div className="statcell-label">{kpi.label}</div>
<div className="statcell-num">
{isPercentKpi(kpi) ? `${Math.round(kpi.num)}%` : kpi.num.toLocaleString()}
</div>
<KpiDelta delta={kpi.delta} dir={kpi.dir} />
</div>
))}
</>
)
}

export function KpiSection({
kpisQuery,
shownKpis,
}: {
kpisQuery: ReturnType<typeof useAnalyticsKpis>
shownKpis: AnalyticsKpi[]
}) {
if (kpisQuery.isLoading) {
return (
<div className="strip-state">
<LoadingState label="Loading KPIs..." />
</div>
)
}
if (kpisQuery.isError) {
return (
<div className="strip-state">
<ErrorState error={kpisQuery.error} onRetry={() => kpisQuery.refetch()} />
</div>
)
}
if (shownKpis.length === 0) {
return (
<div className="strip-state">
<EmptyState title="No data yet" sub="Nothing to show for this window." />
</div>
)
}
return (
<div className="statusstrip kpi-strip">
<KpiStrip kpis={shownKpis} />
</div>
)
}
Loading