Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/app-portal/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ yarn-error.log*

# vercel
.vercel
*.tsbuildinfo
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default async function ApplicantDetailPage({ params }: PageProps) {

<ApplicantDetail applicant={applicant} />

<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-4 grid-cols-2 mobile:grid-cols-1 mobile-xl:grid-cols-1">
<Card>
<CardHeader>
<CardTitle className="text-base">Edit decision</CardTitle>
Expand Down
2 changes: 1 addition & 1 deletion apps/app-portal/src/app/(admin)/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default function AdminPage() {
<p className="text-gray-500 mt-1">Welcome to the admin portal.</p>
</div>

<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-6 grid-cols-3 tablet:grid-cols-2 mobile-xl:grid-cols-1 mobile:grid-cols-1">
{tiles.map((t) => (
<div key={t.title} className="rounded-xl border p-6 shadow-sm">
<h2 className="text-xl font-semibold">{t.title}</h2>
Expand Down
48 changes: 48 additions & 0 deletions apps/app-portal/src/app/(admin)/admin/stats/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React from "react";

import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";

// fallback while server component fetches stats
export default function StatsLoading(): JSX.Element {
return (
<div className="flex flex-col gap-8 p-6">
<header className="flex items-baseline justify-between gap-4">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-9 w-40" />
</header>

<section className="grid grid-cols-4 gap-4 tablet:grid-cols-2 mobile-xl:grid-cols-2 mobile:grid-cols-2">
{["a", "b", "c", "d"].map((key) => (
<Card key={key}>
<CardContent className="pt-6">
<Skeleton className="h-8 w-24" />
<Skeleton className="mt-3 h-4 w-32" />
</CardContent>
</Card>
))}
</section>

<section className="grid grid-cols-2 gap-4 mobile:grid-cols-1 mobile-xl:grid-cols-1">
<ChartCardSkeleton />
<ChartCardSkeleton />
<div className="col-span-2">
<ChartCardSkeleton />
</div>
</section>
</div>
);
}

function ChartCardSkeleton(): JSX.Element {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-48 w-full" />
</CardContent>
</Card>
);
}
4 changes: 3 additions & 1 deletion apps/app-portal/src/app/(admin)/admin/stats/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ export const dynamic = "force-dynamic"; // render on every request

export default async function StatsPage(): Promise<JSX.Element> {
const payload = await getStats();
return <StatsDashboard payload={payload} />;
return (
<StatsDashboard payload={payload} generatedAt={new Date().toISOString()} />
);
}
7 changes: 6 additions & 1 deletion apps/app-portal/src/app/(admin)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import React from "react";
import Link from "next/link";

export default function AdminLayout() {
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return (
<div className="flex min-h-screen">
<aside className="w-64 border-r p-4">
Expand All @@ -19,6 +23,7 @@ export default function AdminLayout() {
<header className="border-b p-4">
<h1 className="text-xl font-semibold">Admin Portal</h1>
</header>
{children}
</main>
</div>
);
Expand Down
18 changes: 18 additions & 0 deletions apps/app-portal/src/components/admin/stats/ChartEmpty.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from "react";
import { Inbox } from "lucide-react";

// placeholder shown inside a chart card when a metric has no data yet
interface ChartEmptyProps {
message?: string;
}

export function ChartEmpty({
message = "No data yet",
}: ChartEmptyProps): JSX.Element {
return (
<div className="flex h-48 flex-col items-center justify-center gap-2 text-center">
<Inbox className="h-8 w-8 text-heather" />
<p className="text-sm text-pavement">{message}</p>
</div>
);
}
147 changes: 114 additions & 33 deletions apps/app-portal/src/components/admin/stats/DemographicsChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,72 +3,153 @@
import React from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";

import { colors } from "@repo/tailwind-config/tokens";

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import type {
DemographicsBreakdown,
DemographicsDimension,
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DEMOGRAPHICS_DIMENSIONS,
type DemographicsBreakdown,
type DemographicsDimension,
} from "@/lib/stats/types";

import { ChartEmpty } from "./ChartEmpty";
import { TooltipRow } from "./TooltipRow";

interface DemographicsChartProps {
breakdown: DemographicsBreakdown;
dimension?: DemographicsDimension;
}

// undos type camelCase to regular word format
function formatCamelCase(key: DemographicsDimension): string {
const spaced = key.replace(/([A-Z])/g, " $1").toLowerCase();
// turns a snake_case dimension key into a readable label
function formatDimension(key: DemographicsDimension): string {
const spaced = key.replace(/_/g, " ");
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}

const config: ChartConfig = {
count: {
label: "Count",
color: "#0f172a",
color: colors.grapePurple,
},
};

const EMPTY_MESSAGE = "No data yet";

// splits a long label into two balanced lines on a word boundary
function splitLabel(label: string): [string, string?] {
const words = label.split(" ");
if (words.length < 2) return [label];

const mid = Math.ceil(words.length / 2);
return [words.slice(0, mid).join(" "), words.slice(mid).join(" ")];
}

export function DemographicsChart({
breakdown,
dimension = "school",
}: DemographicsChartProps): JSX.Element {
const entries = breakdown[dimension];
const [selected, setSelected] =
React.useState<DemographicsDimension>(dimension);
const entries = breakdown[selected];

return (
<Card>
<CardHeader>
<CardTitle>{formatCamelCase(dimension)}</CardTitle>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle>Demographics</CardTitle>
<Select
value={selected}
onValueChange={(value) => setSelected(value as DemographicsDimension)}
>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEMOGRAPHICS_DIMENSIONS.map((dimension) => (
<SelectItem key={dimension} value={dimension}>
{formatDimension(dimension)}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent>
<ChartContainer
config={config}
className="aspect-video max-h-64 w-full"
>
<BarChart
data={entries}
margin={{ left: 0, right: 8, top: 8, bottom: 0 }}
{entries.length === 0 ? (
<ChartEmpty message={EMPTY_MESSAGE} />
) : (
<ChartContainer
config={config}
className="aspect-[2] w-full"
>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis tickLine={false} axisLine={false} width={32} />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar
dataKey="count"
fill="var(--color-count)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
<BarChart
data={entries}
barCategoryGap={8}
margin={{ left: 0, right: 8, top: 8, bottom: 0 }}
>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
interval={0}
height={48}
tickMargin={8}
tick={({ x, y, payload }) => {
const [line1, line2] = splitLabel(
String(payload.value ?? ""),
);
return (
<text
x={x}
y={y}
dy={12}
textAnchor="middle"
fontSize={12}
fill="currentColor"
>
<tspan x={x}>{line1}</tspan>
{line2 ? (
<tspan x={x} dy={14}>
{line2}
</tspan>
) : null}
</text>
);
}}
/>
<YAxis tickLine={false} axisLine={false} width={32} />
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name) => (
<TooltipRow
label={config[name as string]?.label ?? name}
value={Number(value).toLocaleString()}
/>
)}
/>
}
/>
<Bar
dataKey="count"
fill="var(--color-count)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
);
Expand Down
51 changes: 51 additions & 0 deletions apps/app-portal/src/components/admin/stats/RefreshBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"use client";

import React from "react";
import { useRouter } from "next/navigation";
import { RefreshCw } from "lucide-react";

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

interface RefreshBarProps {
generatedAt: string;
}

export function RefreshBar({ generatedAt }: RefreshBarProps): JSX.Element {
const router = useRouter();
const [isPending, startTransition] = React.useTransition();

// format on client browser so label uses admin's local timezone & React matches
const [updatedLabel, setUpdatedLabel] = React.useState("");
React.useEffect(() => {
setUpdatedLabel(
new Date(generatedAt).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
}),
);
}, [generatedAt]);

const handleRefresh = (): void => {
startTransition(() => {
router.refresh();
});
};

return (
<div className="flex items-center gap-3 text-sm text-neutral-500">
<span>Last updated {updatedLabel || "…"}</span>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isPending}
>
<RefreshCw
className={cn("mr-2 h-4 w-4", isPending && "animate-spin")}
/>
Refresh
</Button>
</div>
);
}
Loading