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..e248eb1 --- /dev/null +++ b/src/app/api/delete-year/route.ts @@ -0,0 +1,57 @@ +/*************************************************************** + * + * /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"; + +/** + * 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); + + if (!currentYear || isNaN(currentYear)) { + return NextResponse.json( + { message: "Invalid year parameter" }, + { status: 400 }, + ); + } + + // Delete rows for the specified year + 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)); + + // Return a success response so fetch.ok becomes true + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: "Internal server error: " + (error as Error).message }, + { status: 500 }, + ); + } +} 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..d8ebf73 --- /dev/null +++ b/src/app/api/schools/[name]/gateway/route.ts @@ -0,0 +1,108 @@ +/*************************************************************** + * + * /api/schools/[name]/gateway/route.ts + * + * Author: Zander & Anne + * Date: 3/1/2026 + * + * Summary: Endpoint to fetch or update a school's + * "gateway" flag in the database. + * + **************************************************************/ + +import { NextRequest, NextResponse } from "next/server"; +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 }> }, +) { + try { + const { name } = await params; + const searchName = name.replace(/-/g, " "); + + 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 }, + ); + } +} + +/** + * 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 }> }, +) { + 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 }, + ); + } + + 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 }, + ); + } + + 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 df5c031..8248f24 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -81,6 +81,16 @@ 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 + 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/app/api/years-with-data/route.ts b/src/app/api/years-with-data/route.ts new file mode 100644 index 0000000..f71e1ec --- /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 { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { yearlySchoolParticipation } from "@/lib/schema"; + +export async function GET() { + 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/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 a392cdb..34e2328 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -12,12 +12,13 @@ "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"; +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; @@ -25,7 +26,6 @@ interface PermittedUser { } export default function Settings() { - const [selectedCities, setSelectedCities] = useState([]); const [emailInput, setEmailInput] = useState(""); const [permittedUsers, setPermittedUsers] = useState([ { @@ -42,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@]+$/; @@ -157,79 +129,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 */} @@ -309,6 +212,15 @@ export default function Settings() {
+ +
+
+
+

Available Data

+ +
+
+
{/* Save Section */} diff --git a/src/components/GatewaySchools.tsx b/src/components/GatewaySchools.tsx new file mode 100644 index 0000000..829161b --- /dev/null +++ b/src/components/GatewaySchools.tsx @@ -0,0 +1,194 @@ +/*************************************************************** + * + * /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"; +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; + latitude: number | null; + longitude: number | null; + 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([]); + const [selectedSchoolId, setSelectedSchoolId] = useState(""); + + // Load all schools for dropdown + useEffect(() => { + fetch("/api/schools?list=true") + .then((res) => res.json()) + .then((data) => setSchools(data)) + .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, + })); + + /** + * 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); + + const school = schools.find((s) => String(s.id) === value); + if (!school) return; + + if (gatewaySchools.some((s) => s.id === school.id)) { + toast("School already added"); + return; + } + + 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", + ); + } + }; + + /** + * 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; + + 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) { + setGatewaySchools((prev) => [...prev, school]); + toast.error( + err instanceof Error ? err.message : "Failed to remove school", + ); + } + }; + + return ( +
+

+ Select schools to include as gateway schools. +

+ +
+ +
+ +
+ + + + + + + + + {gatewaySchools.length > 0 ? ( + gatewaySchools.map((school) => ( + + + + + )) + ) : ( + + + + )} + +
+ School + + Actions +
+ {school.name} + + +
+ No gateway schools +
+
+
+ ); +} diff --git a/src/components/GraphFilters/GraphFilters.tsx b/src/components/GraphFilters/GraphFilters.tsx index 09a18a8..1e3fb19 100644 --- a/src/components/GraphFilters/GraphFilters.tsx +++ b/src/components/GraphFilters/GraphFilters.tsx @@ -50,6 +50,7 @@ const groupByOptions = [ { value: "division", label: "Division (Junior/Senior)" }, { value: "implementation-type", label: "Implementation Type" }, { value: "project-type", label: "Project Type" }, + { value: "gateway-school", label: "Gateway School" }, ]; export type MeasuredAs = @@ -65,7 +66,8 @@ export type GroupBy = | "school-type" | "division" | "implementation-type" - | "project-type"; + | "project-type" + | "gateway-school"; export type Filters = { measuredAs: MeasuredAs; @@ -78,13 +80,14 @@ export type Filters = { teacherYearsOperator: string; teacherYearsValue: string; teacherYearsValue2?: string; // For range filtering (between) + onlyGatewaySchools: boolean; }; type GraphFiltersProps = { schools: string[]; cities: string[]; projectTypes?: string[]; // List of project type options - gatewayCities?: string[]; // List of gateway city names + gatewaySchools?: string[]; // List of gateway city names filters?: Filters; onFiltersChange: (filters: Filters) => void; }; @@ -93,7 +96,7 @@ export default function GraphFilters({ schools, cities, projectTypes = [], - gatewayCities = [], + gatewaySchools = [], onFiltersChange, filters, }: GraphFiltersProps) { @@ -102,6 +105,7 @@ export default function GraphFilters({ const [groupBy, setGroupBy] = useState("none"); const [individualProjects, setIndividualProjects] = useState(true); const [groupProjects, setGroupProjects] = useState(true); + const [onlyGatewaySchools, setOnlyGatewaySchools] = useState(false); const [selectedSchools, setSelectedSchools] = useState([]); const [selectedCities, setSelectedCities] = useState([]); const [selectedProjectTypes, setSelectedProjectTypes] = useState( @@ -124,6 +128,7 @@ export default function GraphFilters({ setTeacherYearsOperator(filters.teacherYearsOperator || "="); setTeacherYearsValue(filters.teacherYearsValue || ""); setTeacherYearsValue2(filters.teacherYearsValue2 ?? ""); + setOnlyGatewaySchools(filters.onlyGatewaySchools); const newSelectedFilters: Filter[] = []; if ((filters.selectedSchools || []).length) { @@ -141,6 +146,11 @@ export default function GraphFilters({ filterOptions.find((f) => f.value === "project-type")!, ); } + if (filters.onlyGatewaySchools) { + newSelectedFilters.push( + filterOptions.find((f) => f.value === "only-gateway-school")!, + ); + } if (filters.teacherYearsValue) { newSelectedFilters.push( filterOptions.find((f) => f.value === "teacher-participation")!, @@ -162,6 +172,7 @@ export default function GraphFilters({ teacherYearsValue, teacherYearsValue2: teacherYearsValue2 === "" ? undefined : teacherYearsValue2, + onlyGatewaySchools, ...updates, }; onFiltersChange(newFilters); @@ -183,6 +194,9 @@ export default function GraphFilters({ if (value.value === "teacher-participation" && !teacherYearsValue) { setTeacherYearsValue("1"); updateFilters({ teacherYearsValue: "1" }); + } else if (value.value === "only-gateway-school") { + setOnlyGatewaySchools(true); + updateFilters({ onlyGatewaySchools: true }); } }; @@ -209,6 +223,9 @@ export default function GraphFilters({ } else if (value.value === "city") { setSelectedCities([]); updateFilters({ selectedCities: [] }); + } else if (value.value === "only-gateway-school") { + setOnlyGatewaySchools(false); + updateFilters({ onlyGatewaySchools: false }); } }; @@ -388,7 +405,7 @@ export default function GraphFilters({ } gatewayCities={ filter.value === "city" - ? gatewayCities + ? gatewaySchools : undefined } onFinish={(values) => diff --git a/src/components/GraphFilters/constants.ts b/src/components/GraphFilters/constants.ts index ed51af6..46e3467 100644 --- a/src/components/GraphFilters/constants.ts +++ b/src/components/GraphFilters/constants.ts @@ -3,5 +3,6 @@ export const filterOptions = [ { value: "city", label: "City" }, { value: "project-type", label: "Project Type" }, { value: "teacher-participation", label: "Teacher Participation" }, + { value: "only-gateway-school", label: "Gateway School" }, ]; export type Filter = (typeof filterOptions)[number]; diff --git a/src/components/YearDropdown.tsx b/src/components/YearDropdown.tsx index 587f544..c81ef61 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,38 @@ 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) {} + } + 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..4efa04c --- /dev/null +++ b/src/components/YearsOfData.tsx @@ -0,0 +1,155 @@ +/*************************************************************** + * + * /components/YearsOfData.tsx + * + * Author: Zander & Anne + * Date: 3/1/2026 + * + * Summary: Component for displaying the existing years + * of data and option to delete. + * + **************************************************************/ + +"use client"; + +import { useState, useEffect } from "react"; +import { toast } from "sonner"; +import { Trash } from "lucide-react"; + +/** + * React component for managing yearly dataset entries. + * + * - Loads all years that exist in the system + * - Visually indicates which years contain data + * - Allows deletion of an entire year's data + */ +export default function YearsOfData() { + const [years, setYears] = useState([]); + const [yearsWithData, setYearsWithData] = useState>(new Set()); + + /** + * Loads all years from the API and determines which contain data. + * Generates a continuous descending range between min and max year. + */ + 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(); + if (!data.years || data.years.length === 0) return; + + const existingYears: number[] = data.years; + + 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(existingYears)); + } catch (err) { + toast.error("Failed to load years"); + } + } + + fetchYears(); + }, []); + + /** + * Deletes all data associated with a given year. + * Uses optimistic UI updates on success. + * + * @param year Year to delete + */ + function handleRemoveYear(year: number) { + fetch(`/api/delete-year?year=${year}`, { + method: "DELETE", + }) + .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 +
+
+ ); +} 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