diff --git a/src/app/api/schools/[name]/route.ts b/src/app/api/schools/[name]/route.ts index 14827b1..5424197 100644 --- a/src/app/api/schools/[name]/route.ts +++ b/src/app/api/schools/[name]/route.ts @@ -74,6 +74,8 @@ export async function GET( { params }: { params: Promise<{ name: string }> }, ) { try { + const { searchParams } = new URL(req.url); + const year = Number(searchParams.get("year")); const { name } = await params; const searchName = name.replace(/-/g, " "); @@ -100,10 +102,7 @@ export async function GET( .select({ total: sum(projects.numStudents) }) .from(projects) .where( - and( - eq(projects.schoolId, school.id), - eq(projects.year, pastYear), - ), + and(eq(projects.schoolId, school.id), eq(projects.year, year)), ); const teacherCount = await db @@ -112,7 +111,7 @@ export async function GET( .where( and( eq(yearlyTeacherParticipation.schoolId, school.id), - eq(yearlyTeacherParticipation.year, pastYear), + eq(yearlyTeacherParticipation.year, year), ), ); @@ -120,10 +119,19 @@ export async function GET( .select({ count: sql`count(*)` }) .from(projects) .where( - and( - eq(projects.schoolId, school.id), - eq(projects.year, pastYear), - ), + and(eq(projects.schoolId, school.id), eq(projects.year, year)), + ); + + const projectRows = await db + .select({ + id: projects.id, + title: projects.title, + numStudents: projects.numStudents, + year: projects.year, + }) + .from(projects) + .where( + and(eq(projects.schoolId, school.id), eq(projects.year, year)), ); // First year would be minimum year found in a school's projects @@ -143,6 +151,7 @@ export async function GET( teacherCount: teacherCount[0]?.count ?? 0, projectCount: projectCount[0]?.count ?? 0, firstYear: firstYearData[0]?.year ?? null, + projects: projectRows, // TO DO: Instructional model not in database yet instructionalModel: "normal", }); diff --git a/src/app/schools/[name]/page.tsx b/src/app/schools/[name]/page.tsx index b6bcca2..526408b 100644 --- a/src/app/schools/[name]/page.tsx +++ b/src/app/schools/[name]/page.tsx @@ -18,6 +18,12 @@ import { Breadcrumbs } from "@/components/Breadcrumbs"; import { SchoolProfileSkeleton } from "@/components/skeletons/SchoolProfileSkeleton"; import { MapPlacer } from "@/components/ui/mapPlacer"; import { SchoolInfoRow } from "@/components/SchoolInfoRow"; +import YearDropdown from "@/components/YearDropdown"; +import MultiLineGraph, { GraphDataset } from "@/components/LineGraph"; +import { DataTable } from "@/components/DataTable"; +import { ColumnDef } from "@tanstack/react-table"; +import { ArrowRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; // interface such that data can be blank if API is loading type SchoolData = { @@ -27,6 +33,7 @@ type SchoolData = { teacherCount: string; projectCount: string; firstYear: string; + projects: ProjectRow[]; instructionalModel: string; }; @@ -35,6 +42,19 @@ type MapCoordinates = { longitude: number | null; }; +type ProjectRow = { + id: string; + title: string; + numStudents: number; + year: number; +}; + +type chartFilters = { + yearStart: number; + yearEnd: number; + isProjects: boolean; +}; + export default function SchoolProfilePage() { const params = useParams(); const schoolName = params.name as string; @@ -43,9 +63,31 @@ export default function SchoolProfilePage() { const [schoolData, setSchoolData] = useState(null); const [coordinates, setCoordinates] = useState(null); + const [year, setYear] = useState(2025); + const [projects, setProjects] = useState([]); + const [studentYearData, setstudentYearData] = useState< + { x: string | number; y: number }[] + >([]); + + const projectColumns: ColumnDef[] = [ + { + accessorKey: "title", + header: "Title", + }, + { + accessorKey: "numStudents", + header: "Students", + }, + { + accessorKey: "year", + header: "Year", + }, + ]; useEffect(() => { - fetch(`/api/schools/${schoolName}`) + if (!year) return; + + fetch(`/api/schools/${schoolName}?year=${year}`) .then((response) => { if (!response.ok) { throw new Error(`Failed to fetch school data`); @@ -54,6 +96,7 @@ export default function SchoolProfilePage() { }) .then((data) => { setSchoolData(data); + setProjects(data.projects); }) .catch(() => { toast.error( @@ -64,7 +107,44 @@ export default function SchoolProfilePage() { router.push("/schools"); }, 2000); }); - }, [schoolName, router]); + }, [schoolName, router, year]); + + // Fetches student data for the last 5 years + useEffect(() => { + const fetchData = async () => { + setstudentYearData([]); + for (let i = 5; i >= 0; i--) { + try { + const res = await fetch( + `/api/schools/${schoolName}?year=${year - i}`, + ); + const yearInfo = await res.json(); + + const thisYear: { x: string | number; y: number } = { + x: year - i, + y: yearInfo.studentCount, + }; + setstudentYearData((prev) => [thisYear, ...prev]); + } catch { + toast.error( + "Failed to load dashboard data. Please try again.", + ); + } + } + }; + fetchData(); + }, [year]); + + const studentData: GraphDataset = { + label: "Students by Year", + data: studentYearData, + }; + + const studentChartFilters: chartFilters = { + yearStart: year - 5, + yearEnd: year, + isProjects: false, + }; if (!schoolData) { return ; @@ -76,6 +156,16 @@ export default function SchoolProfilePage() { {/* Header with school name */}

{schoolData.name}

+ { + if (selectedYear !== null) { + setYear(selectedYear); + } + }} + school={schoolData.name} + /> {/* Stats cards */}
@@ -99,6 +189,28 @@ export default function SchoolProfilePage() { instructionalModel={schoolData.instructionalModel} firstYear={schoolData.firstYear} /> +
+
+ Total # Students + +
+ +
{/* Placeholders for charts */}
@@ -150,13 +262,12 @@ export default function SchoolProfilePage() { {/* Data table placeholder */}

- View and edit data + Project Data

-
-

- Data table placeholder -

-
+
diff --git a/src/app/schools/page.tsx b/src/app/schools/page.tsx index f67f6a7..1e34096 100644 --- a/src/app/schools/page.tsx +++ b/src/app/schools/page.tsx @@ -20,6 +20,7 @@ import YearDropdown from "@/components/YearDropdown"; export default function SchoolsPage() { const [schoolInfo, setSchoolInfo] = useState([]); + const [prevYearSchoolInfo, setPrevYearSchoolInfo] = useState([]); const [year, setYear] = useState(2025); const [search, setSearch] = useState(""); const [error, setError] = useState(null); @@ -44,6 +45,26 @@ export default function SchoolsPage() { }); }, [year]); + useEffect(() => { + if (!year) return; + + setError(null); + + fetch(`/api/schools?year=${year - 1}`) + .then((response) => { + if (!response.ok) { + throw new Error(`Failed to fetch school data`); + } + return response.json(); + }) + .then((data) => { + setPrevYearSchoolInfo(data); + }) + .catch((error) => { + setError(error.message || "Failed to load school data"); + }); + }, [year]); + return (
@@ -68,6 +89,7 @@ export default function SchoolsPage() { diff --git a/src/components/BarGraph.tsx b/src/components/BarGraph.tsx index 0c62cbc..b818eca 100644 --- a/src/components/BarGraph.tsx +++ b/src/components/BarGraph.tsx @@ -24,7 +24,7 @@ type BarGraphProps = { yAxisLabel: string; xAxisLabel: string; legendTitle?: string; - svgRefCopy: React.RefObject; + svgRefCopy?: React.RefObject; }; export default function BarGraph({ @@ -281,7 +281,9 @@ export default function BarGraph({ return transform; }); - svgRefCopy.current = svgRef.current; + if (svgRefCopy !== undefined) { + svgRefCopy.current = svgRef.current; + } }, [dataset]); return ( diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 90d7fad..bde7f9f 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -14,6 +14,9 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; import YearDropdown from "@/components/YearDropdown"; +import MultiLineGraph from "./LineGraph"; +import { GraphDataset } from "./LineGraph"; +import { useRouter } from "next/navigation"; type Stats = { totals: { @@ -26,6 +29,12 @@ type Stats = { year: number; }; +type chartFilters = { + yearStart: number; + yearEnd: number; + isProjects: boolean; +}; + export default function Dashboard() { const [year, setYear] = useState(() => new Date().getFullYear()); const [stats, setStats] = useState(null); @@ -46,25 +55,134 @@ export default function Dashboard() { fetchStats(year); }, [year]); + const [projectsyearData, setprojectsYearData] = useState< + { x: string | number; y: number }[] + >([]); + const [schoolyearData, setschoolYearData] = useState< + { x: string | number; y: number }[] + >([]); + + /* + * Fetches data for the chart with total # of projects + */ + useEffect(() => { + const fetchData = async () => { + setprojectsYearData([]); + for (let i = 5; i >= 0; i--) { + try { + const res = await fetch( + `/api/yearly-totals?year=${year - i}`, + ); + const yearInfo = await res.json(); + + const thisYear: { x: string | number; y: number } = { + x: year - i, + y: yearInfo.yearlyStats.totals.total_projects, + }; + setprojectsYearData((prev) => [thisYear, ...prev]); + } catch { + toast.error( + "Failed to load dashboard data. Please try again.", + ); + } + } + }; + fetchData(); + }, [year]); + + const projectsData: GraphDataset = { + label: "Projects by Year", + data: projectsyearData, + }; + + const schoolData: GraphDataset = { + label: "Schools by Year", + data: schoolyearData, + }; + + /* + * Fetches data for the chart with total # of schools + */ + useEffect(() => { + const fetchData = async () => { + setschoolYearData([]); + for (let i = 5; i >= 0; i--) { + try { + const res = await fetch( + `/api/yearly-totals?year=${year - i}`, + ); + const yearInfo = await res.json(); + + const thisYear: { x: string | number; y: number } = { + x: year - i, + y: yearInfo.yearlyStats.totals.total_schools, + }; + setschoolYearData((prev) => [thisYear, ...prev]); + } catch { + toast.error( + "Failed to load dashboard data. Please try again.", + ); + } + } + }; + fetchData(); + }, [year]); + + const router = useRouter(); + + const projectChartFilters = { + yearStart: year - 5, + yearEnd: year, + isProjects: true, + }; + + const schoolChartFilters = { + yearStart: year - 5, + yearEnd: year, + isProjects: false, + }; + + /** + * linkToGraph + * Routes the user to the page corresponding to a chart with the filters + * passed in + * @param filters The chart filters that are used to produce a link to a + * a chart on the actual charts page + * returns: none + */ + function linkToGraph(filters: chartFilters) { + if (filters.isProjects) { + router.push( + `/chart?type=line&startYear=${filters.yearStart}&endYear=${filters.yearEnd}&measuredAs=total-project-count`, + ); + return; + } + + router.push( + `/chart?type=line&startYear=${filters.yearStart}&endYear=${filters.yearEnd}`, + ); + } return (
-

Overview Dashboard

-
- { - if (selectedYear !== null) { - setYear(selectedYear); - } - }} - /> +
+

Overview Dashboard

+
+ { + if (selectedYear !== null) { + setYear(selectedYear); + } + }} + /> +
{stats ? (
-
+
{/* TODO: Once we store type of school, make this correct */} - +
+
+ Total # Projects + + Total # Schools +
) : null} diff --git a/src/components/DataTableSchools.tsx b/src/components/DataTableSchools.tsx index d1d43ff..a0b1e24 100644 --- a/src/components/DataTableSchools.tsx +++ b/src/components/DataTableSchools.tsx @@ -10,7 +10,9 @@ **************************************************************/ "use client"; -import React from "react"; +import React, { ReactNode } from "react"; +import { ArrowUp, ArrowDown, Minus } from "lucide-react"; + import { ColumnDef, flexRender, @@ -19,6 +21,8 @@ import { useReactTable, SortingState, getSortedRowModel, + Cell, + Row, } from "@tanstack/react-table"; import { @@ -35,6 +39,7 @@ import Link from "next/link"; interface DataTableProps { columns: ColumnDef[]; data: TData[]; + prevData: TData[]; globalFilter: string; setGlobalFilter: (value: string) => void; } @@ -42,6 +47,7 @@ interface DataTableProps { export function SchoolsDataTable({ columns, data, + prevData, globalFilter, setGlobalFilter, }: DataTableProps) { @@ -62,6 +68,61 @@ export function SchoolsDataTable({ }, }); + /** + * yoyChange + * Calculates the yoy change for applicable fields of the data table + * @param cell The cell of the table to calculate year over year change for + * returns: A lucide react icon, either up arrow, down arrow, or dash, and a + * number corresponding to the percent change + */ + function yoyChange(cell: Cell, row: Row): ReactNode { + // Check if it is in students/teachers/projects column + if ( + cell.column.getIndex() !== 5 && + cell.column.getIndex() !== 6 && + cell.column.getIndex() !== 7 + ) { + return <>; + } + const rowIndex: number = row.index; + const prevRow = prevData[rowIndex] as Record; + + if (!prevRow) return <>; + const colIndex: number = cell.column.getIndex(); + const prevYearValue: number = prevRow[cell.column.id] ?? 0; + const diff = cell.getValue() - prevYearValue; + + const percentChange = + prevYearValue !== 0 ? Math.abs(diff / prevYearValue) * 100 : 0; + + const formattedPercent = percentChange.toFixed(0); + if (percentChange < 0.5) { + return ( +
+ + {formattedPercent}% +
+ ); + } else if (diff > 0) { + return ( +
+ + {formattedPercent}% +
+ ); + } else if (diff < 0) { + return ( +
+ + {formattedPercent}% +
+ ); + } + + // If so, calc year over year change + // Render icon/number based on that + } + return ( //Example code should be changed //border for school name column disappears when scrolling right @@ -122,11 +183,12 @@ export function SchoolsDataTable({ )} ) : ( -
+
{flexRender( cell.column.columnDef.cell, cell.getContext(), )} + {yoyChange(cell, row)}
)} diff --git a/src/components/LineGraph.tsx b/src/components/LineGraph.tsx index 8f4c213..d4123d5 100644 --- a/src/components/LineGraph.tsx +++ b/src/components/LineGraph.tsx @@ -14,7 +14,7 @@ type MultiLineGraphProps = { yAxisLabel: string; xAxisLabel: string; legendTitle?: string; - svgRefCopy: React.RefObject; + svgRefCopy?: React.RefObject; }; export default function MultiLineGraph({ @@ -416,7 +416,9 @@ export default function MultiLineGraph({ }); }); - svgRefCopy.current = svgRef.current; + if (svgRefCopy != null) { + svgRefCopy.current = svgRef.current; + } }, [datasets, xAxisLabel, yAxisLabel, colorScale]); return ( diff --git a/src/components/YearDropdown.tsx b/src/components/YearDropdown.tsx index 587f544..ef02ad5 100644 --- a/src/components/YearDropdown.tsx +++ b/src/components/YearDropdown.tsx @@ -28,16 +28,17 @@ type YearDropdownProps = { selectedYear?: number | null; onYearChange?: (year: number | null) => void; showDataIndicator?: boolean; + school?: string | null; }; export default function YearDropdown({ selectedYear, onYearChange, showDataIndicator = false, + school, }: YearDropdownProps) { const [year, setYear] = useState(null); const [yearsWithData, setYearsWithData] = useState>(new Set()); - // Years from current year down to 10 years ago const currentYear = new Date().getFullYear(); const years = Array.from({ length: 10 }, (_, i) => currentYear - i); @@ -48,18 +49,42 @@ export default function YearDropdown({ // Fetch years with data useEffect(() => { - const fetchYearsWithData = async () => { - try { - const response = await fetch("/api/years"); - if (response.ok) { - const data = await response.json(); - setYearsWithData(new Set(data.yearsWithData)); - } - } catch (error) { - toast.error("Failed to load year data"); + // If a school is passed in, gets the years with data for that specific + // school + if (school != null) { + for (let i = currentYear; i > 2016; i--) { + const fetchYearsWithData = async () => { + try { + const response = await fetch( + `/api/schools/${school}?year=${i}`, + ); + if (response.ok) { + const data = await response.json(); + // Ensures that school has data for that year + if (data.studentCount !== 0) { + setYearsWithData(yearsWithData.add(i)); + } + } + } catch (error) { + toast.error("Failed to load year data"); + } + }; + fetchYearsWithData(); } - }; - fetchYearsWithData(); + } else { + const fetchYearsWithData = async () => { + try { + const response = await fetch("/api/years"); + if (response.ok) { + const data = await response.json(); + setYearsWithData(new Set(data.yearsWithData)); + } + } catch (error) { + toast.error("Failed to load year data"); + } + }; + fetchYearsWithData(); + } }, []); const handleValueChange = (value: string) => {