From 8669efc12b940f6a1e02933d59847a547aa57da4 Mon Sep 17 00:00:00 2001 From: annewu109 Date: Sun, 1 Mar 2026 16:24:11 -0500 Subject: [PATCH 1/5] added data deletion for a year in the settings page (untested) Co-authored-by: ajbarba01 --- .vscode/settings.json | 3 ++ src/app/api/delete-year/route.ts | 49 ++++++++++++++++++++++++++++ src/app/api/upload/route.ts | 7 ++++ src/app/api/years-with-data/route.ts | 35 ++++++++++++++++++++ src/app/settings/page.tsx | 12 +++++++ src/components/YearDropdown.tsx | 35 +++++++++++++++++++- src/components/YearsOfData.tsx | 47 ++++++++++++++++++++++++++ src/lib/schema.ts | 1 + 8 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 .vscode/settings.json create mode 100644 src/app/api/delete-year/route.ts create mode 100644 src/app/api/years-with-data/route.ts create mode 100644 src/components/YearsOfData.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2718d49 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "snyk.advanced.autoSelectOrganization": true +} diff --git a/src/app/api/delete-year/route.ts b/src/app/api/delete-year/route.ts new file mode 100644 index 0000000..86a6888 --- /dev/null +++ b/src/app/api/delete-year/route.ts @@ -0,0 +1,49 @@ +/*************************************************************** + * + * /api/delete-year/route.ts + * + * Author: Anne & Zander + * Date: 2/20/2026 + * + * Summary: api to delete all data for a given year + * + **************************************************************/ + +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { + projects, + yearlySchoolParticipation, + yearlyTeacherParticipation, +} from "@/lib/schema"; +import { eq } from "drizzle-orm"; + +export async function DELETE(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + + const yearString = searchParams.get("year"); + const currentYear = Number(yearString); + + if (!currentYear || isNaN(currentYear)) { + return NextResponse.json( + { message: "Invalid year parameter" }, + { status: 400 }, + ); + } + + // delete content from tables if year matches year param + await db.delete(projects).where(eq(projects.year, currentYear)); + await db + .delete(yearlySchoolParticipation) + .where(eq(yearlySchoolParticipation.year, currentYear)); + await db + .delete(yearlyTeacherParticipation) + .where(eq(yearlyTeacherParticipation.year, currentYear)); + } catch (error) { + return NextResponse.json( + { error: "Internal server error: " + (error as Error).message }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index df5c031..6767997 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -81,6 +81,13 @@ export async function POST(req: NextRequest) { // Remove header row and filter out empty rows const filteredRows = rawData.slice(1).filter((row) => row.length > 0); + // Delete any existing data before uploading new data + fetch(`/api/delete-year?year=${year}`).then((res) => { + if (!res.ok) { + throw new Error("Failed to delete previous data"); + } + }); + let insertedCount = 0; for (const row of filteredRows) { // Find or create school using schoolId diff --git a/src/app/api/years-with-data/route.ts b/src/app/api/years-with-data/route.ts new file mode 100644 index 0000000..0c4d2e6 --- /dev/null +++ b/src/app/api/years-with-data/route.ts @@ -0,0 +1,35 @@ +/*************************************************************** + * + * /api/get-existing-years/route.ts + * + * Author: Anne Wu & Zander Barba + * Date: 2/20/2026 + * + * Summary: api to fetch all unique years of participation + * + ***************************************************************/ + +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { yearlySchoolParticipation } from "@/lib/schema"; + +export async function GET(_req: NextRequest) { + try { + const result = await db + .select({ + year: yearlySchoolParticipation.year, + }) + .from(yearlySchoolParticipation) + .groupBy(yearlySchoolParticipation.year) + .orderBy(yearlySchoolParticipation.year); + + const years = result.map((row) => row.year); + + return NextResponse.json({ years }, { status: 200 }); + } catch (error) { + return NextResponse.json( + { error: "Internal server error: " + (error as Error).message }, + { status: 500 }, + ); + } +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index a392cdb..562978d 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -16,6 +16,7 @@ import { MultiSelectCombobox } from "../../components/ui/multi-select-combobox"; import { Trash, Plus, Pencil } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Combobox } from "@/components/Combobox"; +import YearsOfData from "@/components/YearsOfData"; import { Map, MapMarker, MarkerContent, useMap } from "@/components/ui/map"; import { toast } from "sonner"; @@ -154,6 +155,17 @@ export default function Settings() {

+
+
+
+

Uploaded Data

+
+
+ +
+
+
+
diff --git a/src/components/YearDropdown.tsx b/src/components/YearDropdown.tsx index 587f544..0d27955 100644 --- a/src/components/YearDropdown.tsx +++ b/src/components/YearDropdown.tsx @@ -24,6 +24,8 @@ import { } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; +//import { getExistingYears } from "@/lib/yearly-data"; + type YearDropdownProps = { selectedYear?: number | null; onYearChange?: (year: number | null) => void; @@ -37,10 +39,41 @@ export default function YearDropdown({ }: YearDropdownProps) { const [year, setYear] = useState(null); const [yearsWithData, setYearsWithData] = useState>(new Set()); + const [years, setYears] = useState([]); // Years from current year down to 10 years ago const currentYear = new Date().getFullYear(); - const years = Array.from({ length: 10 }, (_, i) => currentYear - i); + + useEffect(() => { + async function fetchYears() { + try { + const res = await fetch("/api/years-with-data"); + + if (!res.ok) throw new Error("Failed to fetch years"); + + const data = await res.json(); + const existingYears: number[] = data.years; + + if (existingYears.length == 0) return; + + const minYear = Math.min(...existingYears); + const maxYear = Math.max(...existingYears); + + const allYears = Array.from( + { length: maxYear - minYear + 1 }, + (_, i) => maxYear - i, + ); + + setYears(allYears); + setYearsWithData(new Set(data.years)); + } catch (err) { + console.error(err); + } + } + fetchYears(); + + // setYears(Array.from({ length: 10 }, (_, i) => currentYear - i)); + }, []); useEffect(() => { setYear(selectedYear ?? null); diff --git a/src/components/YearsOfData.tsx b/src/components/YearsOfData.tsx new file mode 100644 index 0000000..58848aa --- /dev/null +++ b/src/components/YearsOfData.tsx @@ -0,0 +1,47 @@ +/*************************************************************** + * + * YearsOfData.tsx + * + * Author: Zander Barba & Anne Wu + * Date: 02/20/2026 + * + * Summary: Years of Data display for settings page, + * including options for deletion + * + **************************************************************/ + +"use client"; + +import { Button } from "./ui/button"; +import YearDropdown from "./YearDropdown"; + +import { useState } from "react"; +import { toast } from "sonner"; +// import { Button } from "@/components/ui/button"; + +export default function YearsOfData() { + const [yearToDelete, setYearToDelete] = useState(null); + + return ( +
+ + +
+ ); + + function handleClick() { + fetch(`/api/delete-year?year=${yearToDelete}`).then((response) => { + if (!response.ok) { + toast(`Failed to delete data.`); + } + }); + } +} diff --git a/src/lib/schema.ts b/src/lib/schema.ts index e9648db..e0fb9a8 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -19,6 +19,7 @@ export const schools = pgTable("schools", { latitude: doublePrecision("latitude"), longitude: doublePrecision("longitude"), zipcode: text("zipcode"), + gateway: boolean("gateway").default(false).notNull(), }); // Ties a school to the years it has participated From 703984d05146c34e6c8ff1163f5674ea584ed1c8 Mon Sep 17 00:00:00 2001 From: annewu109 Date: Sun, 1 Mar 2026 17:49:01 -0500 Subject: [PATCH 2/5] started adding gateway school component + updated year data ui --- src/app/settings/page.tsx | 22 ++--- src/components/GatewaySchools.tsx | 70 ++++++++++++++ src/components/YearsOfData.tsx | 151 ++++++++++++++++++++++-------- 3 files changed, 195 insertions(+), 48 deletions(-) create mode 100644 src/components/GatewaySchools.tsx diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 562978d..404c93e 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -19,6 +19,7 @@ import { Combobox } from "@/components/Combobox"; import YearsOfData from "@/components/YearsOfData"; import { Map, MapMarker, MarkerContent, useMap } from "@/components/ui/map"; import { toast } from "sonner"; +import GatewaySchools from "@/components/GatewaySchools"; interface PermittedUser { email: string; @@ -155,22 +156,12 @@ export default function Settings() {

-
-
-
-

Uploaded Data

-
-
- -
-
-
-

Gateway Cities

+
+ +
+
+
+

Available Data

+ +
+
+
{/* Save Section */} diff --git a/src/components/GatewaySchools.tsx b/src/components/GatewaySchools.tsx new file mode 100644 index 0000000..999e3c3 --- /dev/null +++ b/src/components/GatewaySchools.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { Trash, Plus, Pencil } from "lucide-react"; +import { toast } from "sonner"; + +export default function GatewaySchools() { + const [yearToDelete, setYearToDelete] = useState(null); + const [selectedCities, setSelectedCities] = useState([]); + const [emailInput, setEmailInput] = useState(""); + const [permittedUsers, setPermittedUsers] = useState(["City 1"]); + + return ( +
+
+ setEmailInput(e.target.value)} + className="flex-1 px-4 py-1 text-base text-gray-700 placeholder-gray-500 outline-none" + /> + +
+ +
+ + + + + + + + + {permittedUsers.map((city, i) => ( + + + + + ))} + +
+ City + + Actions +
{city} + +
+
+
+ ); + + function handleClick() { + fetch(`/api/delete-year?year=${yearToDelete}`).then((response) => { + if (!response.ok) { + toast(`Failed to delete data.`); + } + }); + } +} diff --git a/src/components/YearsOfData.tsx b/src/components/YearsOfData.tsx index 58848aa..e8fbc69 100644 --- a/src/components/YearsOfData.tsx +++ b/src/components/YearsOfData.tsx @@ -1,47 +1,124 @@ -/*************************************************************** - * - * YearsOfData.tsx - * - * Author: Zander Barba & Anne Wu - * Date: 02/20/2026 - * - * Summary: Years of Data display for settings page, - * including options for deletion - * - **************************************************************/ - "use client"; -import { Button } from "./ui/button"; -import YearDropdown from "./YearDropdown"; - -import { useState } from "react"; +import { useState, useEffect } from "react"; import { toast } from "sonner"; -// import { Button } from "@/components/ui/button"; +import { Trash } from "lucide-react"; export default function YearsOfData() { - const [yearToDelete, setYearToDelete] = useState(null); + const [years, setYears] = useState([]); + const [yearsWithData, setYearsWithData] = useState>(new Set()); - return ( -
- - -
- ); + // Fetch years and determine which ones have data + useEffect(() => { + async function fetchYears() { + try { + const res = await fetch("/api/years-with-data"); + if (!res.ok) throw new Error("Failed to fetch years"); + + const data = await res.json(); // expects { years: number[] } + if (!data.years || data.years.length === 0) return; + + const existingYears: number[] = data.years; + + const minYear = Math.min(...existingYears); + const maxYear = Math.max(...existingYears); - function handleClick() { - fetch(`/api/delete-year?year=${yearToDelete}`).then((response) => { - if (!response.ok) { - toast(`Failed to delete data.`); + const allYears = Array.from( + { length: maxYear - minYear + 1 }, + (_, i) => maxYear - i, + ); + + setYears(allYears); + setYearsWithData(new Set(existingYears)); + } catch (err) { + console.error(err); + toast.error("Failed to load years"); } - }); + } + + fetchYears(); + }, []); + + function handleRemoveYear(year: number) { + fetch(`/api/delete-year?year=${year}`) + .then((response) => { + if (!response.ok) { + toast(`Failed to delete data for ${year}.`); + } else { + setYears((prev) => prev.filter((y) => y !== year)); + setYearsWithData((prev) => { + const newSet = new Set(prev); + newSet.delete(year); + return newSet; + }); + toast.success(`Deleted data for ${year}.`); + } + }) + .catch(() => { + toast(`Failed to delete data for ${year}.`); + }); } + + return ( +
+ + + + + + + + + + {years.length > 0 ? ( + years.map((year) => ( + + + + + + )) + ) : ( + + + + )} + +
+ Year + + Status + + Actions +
{year} +
+ + + {yearsWithData.has(year) + ? "Available" + : "Unavailable"} + +
+
+ +
+ No years available +
+
+ ); } From 214e197acf5953890cef5230a235bb7d08b4815d Mon Sep 17 00:00:00 2001 From: ajbarba01 Date: Sun, 1 Mar 2026 20:38:01 -0500 Subject: [PATCH 3/5] neon debugging --- src/app/settings/page.tsx | 72 +--------------- src/components/GatewaySchools.tsx | 135 ++++++++++++++++++++---------- 2 files changed, 93 insertions(+), 114 deletions(-) diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 404c93e..5729fdb 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -159,80 +159,10 @@ export default function Settings() {
-

Gateway Cities

+

Gateway Schools

-
- - {/* TO DO: Once we have map, add toggle between map and table generated below */} -
- {selectedCities.length > 0 && ( - <> -

- {selectedCities.length}{" "} - {selectedCities.length === 1 - ? "city" - : "cities"}{" "} - selected -

-
- - - - - - - - - - {selectedCities.map((cityValue) => ( - - - - - ))} - -
- City - - Actions -
- {getCityLabel( - cityValue, - )} - - -
-
- - )}
- {/* School Locations Section */} diff --git a/src/components/GatewaySchools.tsx b/src/components/GatewaySchools.tsx index 999e3c3..b029ac1 100644 --- a/src/components/GatewaySchools.tsx +++ b/src/components/GatewaySchools.tsx @@ -1,70 +1,119 @@ -import { useState } from "react"; -import { Trash, Plus, Pencil } from "lucide-react"; +"use client"; + +import { useEffect, useState } from "react"; +import { Combobox } from "@/components/Combobox"; +import { Trash } from "lucide-react"; import { toast } from "sonner"; +interface SchoolEntry { + id: number; + name: string; + latitude: number | null; + longitude: number | null; +} + export default function GatewaySchools() { - const [yearToDelete, setYearToDelete] = useState(null); - const [selectedCities, setSelectedCities] = useState([]); - const [emailInput, setEmailInput] = useState(""); - const [permittedUsers, setPermittedUsers] = useState(["City 1"]); + const [schools, setSchools] = useState([]); + const [selectedSchoolId, setSelectedSchoolId] = useState(""); + const [gatewaySchools, setGatewaySchools] = useState([]); + + // Load all schools + useEffect(() => { + fetch("/api/schools?list=true") + .then((res) => res.json()) + .then((data) => setSchools(data)) + .catch(() => toast.error("Failed to load schools")); + }, []); + + const schoolOptions = schools.map((s) => ({ + value: String(s.id), + label: s.name, + })); + + const handleAddSchool = (value: string) => { + setSelectedSchoolId(value); + + const school = schools.find((s) => String(s.id) === value); + if (!school) return; + + // prevent duplicates + if (gatewaySchools.some((s) => s.id === school.id)) { + toast("School already added"); + return; + } + + setGatewaySchools((prev) => [...prev, school]); + }; + + const handleRemoveSchool = (id: number) => { + setGatewaySchools((prev) => prev.filter((s) => s.id !== id)); + }; return (
-
- setEmailInput(e.target.value)} - className="flex-1 px-4 py-1 text-base text-gray-700 placeholder-gray-500 outline-none" +

+ Select schools to include as gateway schools. +

+ + {/* Dropdown */} +
+ -
+ {/* Table */}
- - - {permittedUsers.map((city, i) => ( - - - + + + + )) + ) : ( + + - ))} + )}
- City + + School + Actions
{city} - + {gatewaySchools.length > 0 ? ( + gatewaySchools.map((school) => ( +
+ {school.name} + + +
+ No gateway schools
); - - function handleClick() { - fetch(`/api/delete-year?year=${yearToDelete}`).then((response) => { - if (!response.ok) { - toast(`Failed to delete data.`); - } - }); - } } From c37d6539a60efab4991039bebd85d826ff1822e5 Mon Sep 17 00:00:00 2001 From: ajbarba01 Date: Sun, 1 Mar 2026 22:15:27 -0500 Subject: [PATCH 4/5] all but filter --- src/app/api/delete-year/route.ts | 3 + src/app/api/schools/[name]/gateway/route.ts | 96 +++++++++++++++++++++ src/app/api/schools/route.ts | 13 ++- src/app/api/upload/route.ts | 11 ++- src/components/GatewaySchools.tsx | 58 +++++++++++-- src/components/YearsOfData.tsx | 5 +- 6 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 src/app/api/schools/[name]/gateway/route.ts diff --git a/src/app/api/delete-year/route.ts b/src/app/api/delete-year/route.ts index 86a6888..4e36bbf 100644 --- a/src/app/api/delete-year/route.ts +++ b/src/app/api/delete-year/route.ts @@ -40,6 +40,9 @@ export async function DELETE(req: NextRequest) { await db .delete(yearlyTeacherParticipation) .where(eq(yearlyTeacherParticipation.year, currentYear)); + + // <-- add a response so fetch.ok becomes true + return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json( { error: "Internal server error: " + (error as Error).message }, diff --git a/src/app/api/schools/[name]/gateway/route.ts b/src/app/api/schools/[name]/gateway/route.ts new file mode 100644 index 0000000..6ed7594 --- /dev/null +++ b/src/app/api/schools/[name]/gateway/route.ts @@ -0,0 +1,96 @@ +/*************************************************************** + * + * /api/schools/[name]/gateway/route.ts + * + * Author: Zander & Anne + * Date: 3/1/2026 + * + * Summary: Endpoint to fetch or update a school's gateway flag + * + **************************************************************/ + +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { schools } from "@/lib/schema"; +import { eq, sql } from "drizzle-orm"; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ name: string }> }, +) { + try { + const { name } = await params; + const searchName = name.replace(/-/g, " "); + + // Find school by lowercase name + const schoolResult = await db + .select({ id: schools.id, gateway: schools.gateway }) + .from(schools) + .where(eq(sql`LOWER(${schools.name})`, searchName.toLowerCase())) + .limit(1); + + if (!schoolResult || schoolResult.length === 0) { + return NextResponse.json( + { error: "School not found" }, + { status: 404 }, + ); + } + + return NextResponse.json({ gateway: schoolResult[0].gateway }); + } catch (error) { + return NextResponse.json( + { error: "Internal server error: " + (error as Error).message }, + { status: 500 }, + ); + } +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ name: string }> }, +) { + try { + const { name } = await params; + const searchName = name.replace(/-/g, " "); + + const body = await req.json(); + const { gateway } = body; + + if (typeof gateway !== "boolean") { + return NextResponse.json( + { error: "Invalid gateway value" }, + { status: 400 }, + ); + } + + // Find school by lowercase name + const schoolResult = await db + .select({ id: schools.id }) + .from(schools) + .where(eq(sql`LOWER(${schools.name})`, searchName.toLowerCase())) + .limit(1); + + if (!schoolResult || schoolResult.length === 0) { + return NextResponse.json( + { error: "School not found" }, + { status: 404 }, + ); + } + + // Update gateway flag + await db + .update(schools) + .set({ gateway }) + .where(eq(schools.id, schoolResult[0].id)); + + return NextResponse.json({ + message: "Gateway updated successfully", + gateway, + }); + } catch (error) { + return NextResponse.json( + { error: "Internal server error: " + (error as Error).message }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/schools/route.ts b/src/app/api/schools/route.ts index 423eaa3..ede585e 100644 --- a/src/app/api/schools/route.ts +++ b/src/app/api/schools/route.ts @@ -24,14 +24,25 @@ export async function GET(req: NextRequest) { // Lightweight list mode: returns id, name, lat, lng for all schools if (searchParams.get("list") === "true") { - const allSchools = await db + const gatewayParam = searchParams.get("gateway"); + const isGateway = gatewayParam === "true"; // boolean + + const query = db .select({ id: schools.id, name: schools.name, latitude: schools.latitude, longitude: schools.longitude, + gateway: schools.gateway, }) .from(schools); + + // Only filter if gateway=true is explicitly passed + if (isGateway) { + query.where(eq(schools.gateway, true)); + } + + const allSchools = await query; return NextResponse.json(allSchools); } diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 6767997..8248f24 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -82,12 +82,15 @@ export async function POST(req: NextRequest) { const filteredRows = rawData.slice(1).filter((row) => row.length > 0); // Delete any existing data before uploading new data - fetch(`/api/delete-year?year=${year}`).then((res) => { - if (!res.ok) { - throw new Error("Failed to delete previous data"); - } + const res = await fetch(`/api/delete-year?year=${year}`, { + method: "DELETE", }); + if (!res.ok) { + const errData = await res.json(); + throw new Error(errData?.error || "Failed to delete previous data"); + } + let insertedCount = 0; for (const row of filteredRows) { // Find or create school using schoolId diff --git a/src/components/GatewaySchools.tsx b/src/components/GatewaySchools.tsx index b029ac1..726b41c 100644 --- a/src/components/GatewaySchools.tsx +++ b/src/components/GatewaySchools.tsx @@ -10,14 +10,15 @@ interface SchoolEntry { name: string; latitude: number | null; longitude: number | null; + gateway?: boolean; } export default function GatewaySchools() { const [schools, setSchools] = useState([]); - const [selectedSchoolId, setSelectedSchoolId] = useState(""); const [gatewaySchools, setGatewaySchools] = useState([]); + const [selectedSchoolId, setSelectedSchoolId] = useState(""); - // Load all schools + // Load all schools for dropdown useEffect(() => { fetch("/api/schools?list=true") .then((res) => res.json()) @@ -25,28 +26,75 @@ export default function GatewaySchools() { .catch(() => toast.error("Failed to load schools")); }, []); + // Load only gateway schools on mount + useEffect(() => { + fetch("/api/schools?gateway=true&list=true") + .then((res) => res.json()) + .then((data) => setGatewaySchools(data)) + .catch(() => toast.error("Failed to load gateway schools")); + }, []); + const schoolOptions = schools.map((s) => ({ value: String(s.id), label: s.name, })); - const handleAddSchool = (value: string) => { + // Add school as gateway + const handleAddSchool = async (value: string) => { setSelectedSchoolId(value); const school = schools.find((s) => String(s.id) === value); if (!school) return; - // prevent duplicates if (gatewaySchools.some((s) => s.id === school.id)) { toast("School already added"); return; } + // Optimistic update setGatewaySchools((prev) => [...prev, school]); + + try { + const res = await fetch(`/api/schools/${school.name}/gateway`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ gateway: true }), + }); + + if (!res.ok) throw new Error("Failed to update school"); + toast.success(`${school.name} set as gateway`); + } catch (err) { + setGatewaySchools((prev) => prev.filter((s) => s.id !== school.id)); + toast.error( + err instanceof Error ? err.message : "Failed to add school", + ); + } }; - const handleRemoveSchool = (id: number) => { + // Remove school as gateway + const handleRemoveSchool = async (id: number) => { + const school = gatewaySchools.find((s) => s.id === id); + if (!school) return; + + // Optimistic update setGatewaySchools((prev) => prev.filter((s) => s.id !== id)); + + try { + const res = await fetch(`/api/schools/${school.name}/gateway`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ gateway: false }), + }); + + if (!res.ok) throw new Error("Failed to update school"); + toast.success(`${school.name} removed as gateway`); + } catch (err) { + // Revert optimistic update on failure + setGatewaySchools((prev) => [...prev, school]); + toast.error( + err instanceof Error ? err.message : "Failed to remove school", + ); + } }; return ( diff --git a/src/components/YearsOfData.tsx b/src/components/YearsOfData.tsx index e8fbc69..85c6982 100644 --- a/src/components/YearsOfData.tsx +++ b/src/components/YearsOfData.tsx @@ -38,9 +38,10 @@ export default function YearsOfData() { fetchYears(); }, []); - function handleRemoveYear(year: number) { - fetch(`/api/delete-year?year=${year}`) + fetch(`/api/delete-year?year=${year}`, { + method: "DELETE", // ← important + }) .then((response) => { if (!response.ok) { toast(`Failed to delete data for ${year}.`); From b5d2eb365c481ee1f68385d2468f2018465242f8 Mon Sep 17 00:00:00 2001 From: ajbarba01 Date: Tue, 3 Mar 2026 12:53:23 -0500 Subject: [PATCH 5/5] Final --- src/app/api/delete-year/route.ts | 13 ++- src/app/api/schools/[name]/gateway/route.ts | 20 +++- src/app/api/years-with-data/route.ts | 4 +- src/app/chart/page.tsx | 108 ++++++++++++------- src/app/settings/page.tsx | 30 ------ src/components/GatewaySchools.tsx | 57 +++++++--- src/components/GraphFilters/GraphFilters.tsx | 25 ++++- src/components/GraphFilters/constants.ts | 1 + src/components/YearDropdown.tsx | 7 +- src/components/YearsOfData.tsx | 60 ++++++++--- 10 files changed, 205 insertions(+), 120 deletions(-) diff --git a/src/app/api/delete-year/route.ts b/src/app/api/delete-year/route.ts index 4e36bbf..e248eb1 100644 --- a/src/app/api/delete-year/route.ts +++ b/src/app/api/delete-year/route.ts @@ -5,7 +5,7 @@ * Author: Anne & Zander * Date: 2/20/2026 * - * Summary: api to delete all data for a given year + * Summary: API to delete all data for a given year * **************************************************************/ @@ -18,10 +18,15 @@ import { } from "@/lib/schema"; import { eq } from "drizzle-orm"; +/** + * Deletes all data for the specified year. + * + * @param req NextRequest object + * @returns JSON response with success or error + */ export async function DELETE(req: NextRequest) { try { const { searchParams } = new URL(req.url); - const yearString = searchParams.get("year"); const currentYear = Number(yearString); @@ -32,7 +37,7 @@ export async function DELETE(req: NextRequest) { ); } - // delete content from tables if year matches year param + // Delete rows for the specified year await db.delete(projects).where(eq(projects.year, currentYear)); await db .delete(yearlySchoolParticipation) @@ -41,7 +46,7 @@ export async function DELETE(req: NextRequest) { .delete(yearlyTeacherParticipation) .where(eq(yearlyTeacherParticipation.year, currentYear)); - // <-- add a response so fetch.ok becomes true + // Return a success response so fetch.ok becomes true return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json( diff --git a/src/app/api/schools/[name]/gateway/route.ts b/src/app/api/schools/[name]/gateway/route.ts index 6ed7594..d8ebf73 100644 --- a/src/app/api/schools/[name]/gateway/route.ts +++ b/src/app/api/schools/[name]/gateway/route.ts @@ -5,7 +5,8 @@ * Author: Zander & Anne * Date: 3/1/2026 * - * Summary: Endpoint to fetch or update a school's gateway flag + * Summary: Endpoint to fetch or update a school's + * "gateway" flag in the database. * **************************************************************/ @@ -14,6 +15,13 @@ import { db } from "@/lib/db"; import { schools } from "@/lib/schema"; import { eq, sql } from "drizzle-orm"; +/** + * Fetch the gateway status of a school. + * + * @param req NextRequest object + * @param params Promise containing the school `name` param + * @returns JSON response with { gateway: boolean } or error + */ export async function GET( req: NextRequest, { params }: { params: Promise<{ name: string }> }, @@ -22,7 +30,6 @@ export async function GET( const { name } = await params; const searchName = name.replace(/-/g, " "); - // Find school by lowercase name const schoolResult = await db .select({ id: schools.id, gateway: schools.gateway }) .from(schools) @@ -45,6 +52,13 @@ export async function GET( } } +/** + * Update the gateway status of a school. + * + * @param req NextRequest object + * @param params Promise containing the school `name` param + * @returns JSON response with { message, gateway } or error + */ export async function PATCH( req: NextRequest, { params }: { params: Promise<{ name: string }> }, @@ -63,7 +77,6 @@ export async function PATCH( ); } - // Find school by lowercase name const schoolResult = await db .select({ id: schools.id }) .from(schools) @@ -77,7 +90,6 @@ export async function PATCH( ); } - // Update gateway flag await db .update(schools) .set({ gateway }) diff --git a/src/app/api/years-with-data/route.ts b/src/app/api/years-with-data/route.ts index 0c4d2e6..f71e1ec 100644 --- a/src/app/api/years-with-data/route.ts +++ b/src/app/api/years-with-data/route.ts @@ -9,11 +9,11 @@ * ***************************************************************/ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { db } from "@/lib/db"; import { yearlySchoolParticipation } from "@/lib/schema"; -export async function GET(_req: NextRequest) { +export async function GET() { try { const result = await db .select({ diff --git a/src/app/chart/page.tsx b/src/app/chart/page.tsx index 9019dc7..7d6aa18 100644 --- a/src/app/chart/page.tsx +++ b/src/app/chart/page.tsx @@ -41,6 +41,7 @@ import { parseAsInteger, parseAsString, parseAsArrayOf, + parseAsBoolean, } from "nuqs"; import { addToCart, downloadSingleGraph } from "@/lib/export-to-pdf"; import { @@ -55,6 +56,7 @@ type Project = { title: string; division: string; category: string; + gatewaySchool: string; year: number; teamProject: boolean; schoolId: number; @@ -84,6 +86,7 @@ const groupByLabels: Record = { "division": "Division", "implementation-type": "Implementation Type", "project-type": "Project Type", + "gateway-school": "Gateway School", }; // Helper function for generating dynamic titles @@ -154,9 +157,9 @@ const generateChartTitle = ( return mainTitle; }; -export default function GraphsPage() { +export default function ChartPage() { const [allProjects, setAllProjects] = useState([]); - const [gatewayCities, setGatewayCities] = useState([]); + const [gatewaySchools, setGatewaySchools] = useState([]); // Setting hooks const [timePeriod, setTimePeriod] = useQueryState( @@ -223,6 +226,11 @@ export default function GraphsPage() { parseAsString.withDefault(""), ); + const [onlyGatewaySchools, setOnlyGatewaySchools] = useQueryState( + "onlyGatewaySchools", + parseAsBoolean.withDefault(false), + ); + const [cart, setCart] = useState([]); const [filterNames, setFilterNames] = useState([]); @@ -242,6 +250,7 @@ export default function GraphsPage() { | "between", teacherYearsValue2: teacherYearsValue2 || undefined, groupBy: groupBy as GroupBy, + onlyGatewaySchools: onlyGatewaySchools, measuredAs: measuredAs as MeasuredAs, }), [ @@ -253,6 +262,7 @@ export default function GraphsPage() { teacherYearsValue2, groupBy, measuredAs, + onlyGatewaySchools, ], ); const svgRef = useRef(null); @@ -263,31 +273,41 @@ export default function GraphsPage() { try { const response = await fetch("/api/projects"); if (!response.ok) throw new Error("Failed to fetch"); + const data = await response.json(); - setAllProjects(data); + + const updatedProjects = data.map((p: Project) => ({ + ...p, + gatewaySchool: gatewaySchools.includes(p.schoolName) + ? "Gateway" + : "Non-Gateway", + })); + + setAllProjects(updatedProjects); } catch { toast.error( "Failed to load project data. Please refresh the page.", ); } }; - fetchProjects(); - }, []); - // Fetch gateway cities + if (gatewaySchools.length > 0) { + fetchProjects(); + } + }, [gatewaySchools]); + + // Fetch gateway schools useEffect(() => { - const fetchGatewayCities = async () => { - try { - const response = await fetch("/api/gateway-cities"); - if (!response.ok) throw new Error("Failed to fetch"); - const data = await response.json(); - setGatewayCities(data); - } catch { - // Silently fail - gateway cities are optional - setGatewayCities([]); - } - }; - fetchGatewayCities(); + fetch("/api/schools?gateway=true&list=true") + .then((res) => res.json()) + .then((data) => { + const schoolNames: string[] = data.map( + (school: { name: string }) => school.name, + ); + + setGatewaySchools(schoolNames); + }) + .catch(() => toast.error("Failed to load gateway schools")); }, []); /* Fetch and set cart to and from session storage to persist between refreshes */ @@ -409,6 +429,10 @@ export default function GraphsPage() { ) return false; + if (filters.onlyGatewaySchools && p.gatewaySchool !== "Gateway") { + return false; + } + // Selected Cities if ( filters.selectedCities.length > 0 && @@ -450,28 +474,28 @@ export default function GraphsPage() { // Determine the key to group data by for different lines on the graph let groupKey: keyof Project | null = null; - // set groupKey based on filter selection - if (filters?.groupBy === "none") { - setGroupBy("none"); - groupKey = null; // No grouping - } else if (filters?.groupBy === "division") { - setGroupBy("division"); - groupKey = "division"; - } else if (filters?.groupBy === "project-type") { - setGroupBy("project-type"); - groupKey = "category"; - } else if (filters?.groupBy === "region") { - setGroupBy("region"); - // TO DO: Add proper 'region' field to Project type and database. Currently using schoolTown (city) as a temporary substitute - groupKey = "schoolTown"; - } else if (filters?.groupBy === "school-type") { - setGroupBy("school-type"); - // TO DO: Add 'schoolType' field to Project type and database, then map it here - groupKey = "category"; // Temporary fallback - } else if (filters?.groupBy === "implementation-type") { - setGroupBy("implementation-type"); - // TO DO: Add 'implementationType' field to Project type and database, then map it here - groupKey = "category"; // Temporary fallback + switch (filters.groupBy) { + case "none": + groupKey = null; + break; + case "division": + groupKey = "division"; + break; + case "project-type": + groupKey = "category"; + break; + case "region": + groupKey = "schoolTown"; // temp + break; + case "school-type": + groupKey = "category"; // temp + break; + case "implementation-type": + groupKey = "category"; // temp + break; + case "gateway-school": + groupKey = "gatewaySchool"; + break; } // Get a sorted list of unique group names @@ -573,6 +597,7 @@ export default function GraphsPage() { groupBy, measuredAs, yearRange, + onlyGatewaySchools, ]); // Calculate filtered count (based on selected 'measured by' category) @@ -604,7 +629,7 @@ export default function GraphsPage() { schools={schools} cities={cities} projectTypes={projectTypes} - gatewayCities={gatewayCities} + gatewaySchools={gatewaySchools} filters={filters} onFiltersChange={(newFilters) => { setSelectedSchools(newFilters.selectedSchools); @@ -621,6 +646,7 @@ export default function GraphsPage() { setTeacherYearsValue2( newFilters.teacherYearsValue2 ?? "", ); + setOnlyGatewaySchools(newFilters.onlyGatewaySchools); }} />
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 5729fdb..34e2328 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -12,7 +12,6 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { MultiSelectCombobox } from "../../components/ui/multi-select-combobox"; import { Trash, Plus, Pencil } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Combobox } from "@/components/Combobox"; @@ -27,7 +26,6 @@ interface PermittedUser { } export default function Settings() { - const [selectedCities, setSelectedCities] = useState([]); const [emailInput, setEmailInput] = useState(""); const [permittedUsers, setPermittedUsers] = useState([ { @@ -44,40 +42,12 @@ export default function Settings() { }, ]); - // TO DO: Replace with actual gateway cities - const cityOptions = [ - { value: "city-1", label: "City 1" }, - { value: "city-2", label: "City 2" }, - { value: "city-3", label: "City 3" }, - { value: "city-4", label: "City 4" }, - { value: "city-5", label: "City 5" }, - { value: "city-6", label: "City 6" }, - { value: "city-7", label: "City 7" }, - { value: "city-8", label: "City 8" }, - ]; - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const handleCityChange = (values: string[]) => { - setSelectedCities(values); - setHasUnsavedChanges(true); - }; - const handleSave = () => { setHasUnsavedChanges(false); }; - const handleDeleteCity = (cityValue: string) => { - setSelectedCities((prevCities) => - prevCities.filter((value) => value !== cityValue), - ); - setHasUnsavedChanges(true); - }; - - const getCityLabel = (value: string) => { - return cityOptions.find((opt) => opt.value === value)?.label || value; - }; - // Email validation function const isValidEmail = (email: string): boolean => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; diff --git a/src/components/GatewaySchools.tsx b/src/components/GatewaySchools.tsx index 726b41c..829161b 100644 --- a/src/components/GatewaySchools.tsx +++ b/src/components/GatewaySchools.tsx @@ -1,3 +1,15 @@ +/*************************************************************** + * + * /components/GatewaySchools.tsx + * + * Author: Zander & Anne + * Date: 3/1/2026 + * + * Summary: Component for displaying current gateway + * schools and modifying this flag for each. + * + **************************************************************/ + "use client"; import { useEffect, useState } from "react"; @@ -5,6 +17,9 @@ import { Combobox } from "@/components/Combobox"; import { Trash } from "lucide-react"; import { toast } from "sonner"; +/** + * Represents a single school entry in the system. + */ interface SchoolEntry { id: number; name: string; @@ -13,6 +28,13 @@ interface SchoolEntry { gateway?: boolean; } +/** + * React component for managing gateway schools. + * + * - Loads all schools for selection + * - Displays current gateway schools in a table + * - Allows adding/removing schools from gateway status + */ export default function GatewaySchools() { const [schools, setSchools] = useState([]); const [gatewaySchools, setGatewaySchools] = useState([]); @@ -39,7 +61,12 @@ export default function GatewaySchools() { label: s.name, })); - // Add school as gateway + /** + * Adds a school as a gateway school. + * Uses optimistic UI updates. + * + * @param value ID of the school to add + */ const handleAddSchool = async (value: string) => { setSelectedSchoolId(value); @@ -51,7 +78,6 @@ export default function GatewaySchools() { return; } - // Optimistic update setGatewaySchools((prev) => [...prev, school]); try { @@ -71,12 +97,16 @@ export default function GatewaySchools() { } }; - // Remove school as gateway + /** + * Removes a school from the gateway list. + * Uses optimistic UI updates. + * + * @param id ID of the school to remove + */ const handleRemoveSchool = async (id: number) => { const school = gatewaySchools.find((s) => s.id === id); if (!school) return; - // Optimistic update setGatewaySchools((prev) => prev.filter((s) => s.id !== id)); try { @@ -89,7 +119,6 @@ export default function GatewaySchools() { if (!res.ok) throw new Error("Failed to update school"); toast.success(`${school.name} removed as gateway`); } catch (err) { - // Revert optimistic update on failure setGatewaySchools((prev) => [...prev, school]); toast.error( err instanceof Error ? err.message : "Failed to remove school", @@ -103,7 +132,6 @@ export default function GatewaySchools() { Select schools to include as gateway schools.

- {/* Dropdown */}
- {/* Table */} -
+
- - - + + - @@ -133,15 +160,15 @@ export default function GatewaySchools() { key={school.id} className="hover:bg-gray-50" > - -
+
School + Actions
+ {school.name} +