diff --git a/eslint.config.mjs b/eslint.config.mjs index 459b90d..b29e318 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,12 +15,13 @@ const eslintConfig = [ ...(reactHooksPlugin ? [ { - // react-hooks/set-state-in-effect is new in eslint-plugin-react-hooks@7 - // and flags the async `load()` pattern used throughout the codebase. - // Downgraded to warn here; callers should be refactored in a follow-up. + // react-hooks/set-state-in-effect and incompatible-library flag common + // patterns here (async load() in effects, react-hook-form watch()). + // Disabled until refactored in a follow-up. plugins: { "react-hooks": reactHooksPlugin }, rules: { - "react-hooks/set-state-in-effect": "warn", + "react-hooks/set-state-in-effect": "off", + "react-hooks/incompatible-library": "off", }, }, ] diff --git a/scripts/seed.ts b/scripts/seed.ts index 10168cf..6605c04 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -51,10 +51,10 @@ async function main() { if (!quarter) { quarter = db .insert(stfQuarter) - .values({ name: "Fall 2025", isActive: true }) + .values({ name: "2025-2026", isActive: true }) .returning() .get(); - console.log(`Seeded active quarter: ${quarter.name}`); + console.log(`Seeded active school year: ${quarter.name}`); } const defaultBuckets = [ diff --git a/scripts/verify-order-export-e2e.ts b/scripts/verify-order-export-e2e.ts index 97d80d0..e0f0bbe 100644 --- a/scripts/verify-order-export-e2e.ts +++ b/scripts/verify-order-export-e2e.ts @@ -26,7 +26,7 @@ const admin = db.select().from(user).limit(1).get(); assert(admin != null, "Need a user"); const quarter = getActiveQuarter(); -assert(quarter != null, "Need active quarter"); +assert(quarter != null, "Need active school year"); const mechanical = db.select().from(stfBucket).where(eq(stfBucket.name, "Mechanical")).get(); assert(mechanical != null, "Need Mechanical bucket"); diff --git a/scripts/verify-order-form.ts b/scripts/verify-order-form.ts index 9f24407..6e8be94 100644 --- a/scripts/verify-order-form.ts +++ b/scripts/verify-order-form.ts @@ -127,7 +127,7 @@ const stfBalance = validateOrderBalance("STF", stfData.stfBucketId, stfTotal); assert(stfBalance.ok, "STF balance check should pass before insert"); const quarter = getActiveQuarter(); -assert(quarter != null, "Active quarter required"); +assert(quarter != null, "Active school year required"); const stfOrder = db .insert(order) diff --git a/src/app/(dashboard)/admin/finance/page.tsx b/src/app/(dashboard)/admin/finance/page.tsx index 7716926..aa7ea2d 100644 --- a/src/app/(dashboard)/admin/finance/page.tsx +++ b/src/app/(dashboard)/admin/finance/page.tsx @@ -21,7 +21,7 @@ export default async function AdminFinancePage() {

Finance

- Manage STF buckets, gift fund value, and quarterly resets. + Manage STF buckets, gift fund value, and school year resets.

diff --git a/src/app/api/admin/finance/buckets/route.ts b/src/app/api/admin/finance/buckets/route.ts index 4332c5e..8ff6929 100644 --- a/src/app/api/admin/finance/buckets/route.ts +++ b/src/app/api/admin/finance/buckets/route.ts @@ -23,7 +23,7 @@ export async function POST(req: NextRequest) { const quarter = getActiveQuarter(); if (!quarter) { return NextResponse.json( - { error: "No active STF quarter. Create one before adding buckets." }, + { error: "No active STF school year. Create one before adding buckets." }, { status: 400 } ); } @@ -49,13 +49,13 @@ export async function PUT(req: NextRequest) { const body = await req.json().catch(() => null); const quarterName = body?.quarterName?.trim(); if (!quarterName) { - return NextResponse.json({ error: "Quarter name is required" }, { status: 400 }); + return NextResponse.json({ error: "School year name is required" }, { status: 400 }); } const existing = getActiveQuarter(); if (existing) { return NextResponse.json( - { error: `Active quarter already exists: ${existing.name}` }, + { error: `Active school year already exists: ${existing.name}` }, { status: 400 } ); } diff --git a/src/app/api/admin/finance/quarter-reset/route.ts b/src/app/api/admin/finance/quarter-reset/route.ts index 60eb0be..25cdec8 100644 --- a/src/app/api/admin/finance/quarter-reset/route.ts +++ b/src/app/api/admin/finance/quarter-reset/route.ts @@ -23,12 +23,12 @@ export async function POST(req: NextRequest) { const active = getActiveQuarter(); if (!active) { - return NextResponse.json({ error: "No active quarter to reset" }, { status: 400 }); + return NextResponse.json({ error: "No active school year to reset" }, { status: 400 }); } if (active.name !== parsed.data.quarterName) { return NextResponse.json( - { error: `Quarter name does not match. Expected "${active.name}".` }, + { error: `School year name does not match. Expected "${active.name}".` }, { status: 400 } ); } diff --git a/src/app/api/orders/[id]/route.ts b/src/app/api/orders/[id]/route.ts index f19e59b..088e6af 100644 --- a/src/app/api/orders/[id]/route.ts +++ b/src/app/api/orders/[id]/route.ts @@ -57,7 +57,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id const activeQuarter = d.fundType === "STF" ? getActiveQuarter() : null; if (d.fundType === "STF" && !activeQuarter) { return NextResponse.json( - { error: "No active STF quarter is configured. Contact an officer." }, + { error: "No active STF school year is configured. Contact an officer." }, { status: 400 } ); } diff --git a/src/app/api/orders/route.ts b/src/app/api/orders/route.ts index 4a2920b..3e58bb8 100644 --- a/src/app/api/orders/route.ts +++ b/src/app/api/orders/route.ts @@ -33,7 +33,7 @@ export async function POST(req: NextRequest) { const activeQuarter = d.fundType === "STF" ? getActiveQuarter() : null; if (d.fundType === "STF" && !activeQuarter) { return NextResponse.json( - { error: "No active STF quarter is configured. Contact an officer." }, + { error: "No active STF school year is configured. Contact an officer." }, { status: 400 } ); } diff --git a/src/components/BalanceAmount.tsx b/src/components/BalanceAmount.tsx index e017579..152bfea 100644 --- a/src/components/BalanceAmount.tsx +++ b/src/components/BalanceAmount.tsx @@ -116,7 +116,7 @@ export function RemainingBalanceCaption({ cents, className }: RemainingBalanceCa className )} > - {over ? "Over budget this quarter" : "remaining this quarter"} + {over ? "Over budget this school year" : "remaining this school year"}

); } diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 6954ab6..fa090b7 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -3,7 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -105,7 +105,7 @@ export function LoginForm({ notice }: { notice?: string }) { resolver: zodResolver(credentialsSchema), defaultValues: { name: "", email: "", password: "", confirmPassword: "" }, }); - const watchedPassword = credForm.watch("password"); + const watchedPassword = useWatch({ control: credForm.control, name: "password" }); const otpForm = useForm({ resolver: zodResolver(otpSchema), defaultValues: { otp: "" }, @@ -238,7 +238,7 @@ export function LoginForm({ notice }: { notice?: string }) { } toast.success("Email verified - welcome!"); - window.location.href = "/dashboard"; + router.replace("/dashboard"); } async function onForgotSubmit(values: ForgotValues) { diff --git a/src/components/dashboard/LiveClock.tsx b/src/components/dashboard/LiveClock.tsx index 1942858..4343819 100644 --- a/src/components/dashboard/LiveClock.tsx +++ b/src/components/dashboard/LiveClock.tsx @@ -23,8 +23,12 @@ export function LiveClock() { return (
-

{time}

-

{date}

+

+ {time} +

+

+ {date} +

); } diff --git a/src/components/dashboard/MinecraftStatusTile.tsx b/src/components/dashboard/MinecraftStatusTile.tsx index 7baba77..bd9b84b 100644 --- a/src/components/dashboard/MinecraftStatusTile.tsx +++ b/src/components/dashboard/MinecraftStatusTile.tsx @@ -1,10 +1,11 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import type { ServerStatus } from "@/lib/minecraft"; +import { usePoll } from "@/lib/use-poll"; function PingDot({ online }: { online: boolean }) { if (!online) return ; @@ -28,11 +29,7 @@ export function MinecraftStatusTile() { } }, []); - useEffect(() => { - load(); - const id = setInterval(load, 30_000); - return () => clearInterval(id); - }, [load]); + usePoll(load, 30_000); if (!status) { return ( diff --git a/src/components/dashboard/NetworkStatusTile.tsx b/src/components/dashboard/NetworkStatusTile.tsx index f82a5c4..d58a353 100644 --- a/src/components/dashboard/NetworkStatusTile.tsx +++ b/src/components/dashboard/NetworkStatusTile.tsx @@ -1,9 +1,10 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { usePoll } from "@/lib/use-poll"; type NetworkStatus = { online: boolean; @@ -34,11 +35,7 @@ export function NetworkStatusTile() { } }, []); - useEffect(() => { - load(); - const id = setInterval(load, 30_000); - return () => clearInterval(id); - }, [load]); + usePoll(load, 30_000); if (!status) { return ( diff --git a/src/components/dashboard/SystemVitals.tsx b/src/components/dashboard/SystemVitals.tsx index 3073b0c..9016fe1 100644 --- a/src/components/dashboard/SystemVitals.tsx +++ b/src/components/dashboard/SystemVitals.tsx @@ -1,11 +1,12 @@ "use client"; import { Cpu, HardDrive, MemoryStick, Timer } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import type { SystemStats } from "@/app/api/system/stats/route"; +import { usePoll } from "@/lib/use-poll"; function metricColor(pct: number): string { if (pct > 85) return "var(--destructive)"; @@ -71,11 +72,7 @@ export function SystemVitals() { } }, []); - useEffect(() => { - load(); - const id = setInterval(load, 10_000); - return () => clearInterval(id); - }, [load]); + usePoll(load, 10_000); if (!stats) { return ( diff --git a/src/components/finance/FinanceManager.tsx b/src/components/finance/FinanceManager.tsx index 9dc0205..2a9d105 100644 --- a/src/components/finance/FinanceManager.tsx +++ b/src/components/finance/FinanceManager.tsx @@ -90,9 +90,9 @@ export function FinanceManager({ initial }: { initial: FinanceData }) { }); if (!res.ok) { const err = await res.json().catch(() => null); - throw new Error(err?.error ?? "Failed to create quarter"); + throw new Error(err?.error ?? "Failed to create school year"); } - toast.success("Quarter created"); + toast.success("School year created"); setNewQuarterName(""); await refresh(); } catch (err) { @@ -196,7 +196,7 @@ export function FinanceManager({ initial }: { initial: FinanceData }) { const err = await res.json().catch(() => null); throw new Error(err?.error ?? "Reset failed"); } - toast.success("Quarter reset complete"); + toast.success("School year reset complete"); setResetStep(0); setResetConfirmName(""); setNextQuarterName(""); @@ -212,29 +212,29 @@ export function FinanceManager({ initial }: { initial: FinanceData }) {
-

STF quarter

+

STF school year

- Active quarter: {activeQuarter?.name ?? "None"} + Active school year: {activeQuarter?.name ?? "None"}

{!activeQuarter ? (
- + setNewQuarterName(e.target.value)} />
) : ( )}
@@ -386,12 +386,12 @@ export function FinanceManager({ initial }: { initial: FinanceData }) { {resetStep === 1 ? ( <> - Reset quarter? + Reset school year? This will archive all STF buckets for{" "} {activeQuarter?.name} and clear them for the - new quarter. Gift fund orders and value are not affected. This - cannot be undone. + new school year. Gift fund orders and value are not affected. + This cannot be undone.
@@ -409,12 +409,12 @@ export function FinanceManager({ initial }: { initial: FinanceData }) { Confirm reset Type {activeQuarter?.name} to confirm, then - enter the name for the incoming quarter. + enter the name for the incoming school year.
- +
- + setNextQuarterName(e.target.value)} /> diff --git a/src/components/github/AdminGithubManager.tsx b/src/components/github/AdminGithubManager.tsx index c7a3ef3..6a4dbef 100644 --- a/src/components/github/AdminGithubManager.tsx +++ b/src/components/github/AdminGithubManager.tsx @@ -24,6 +24,7 @@ import type { GithubTeam, GithubTeamMember, } from "@/lib/github"; +import { usePoll } from "@/lib/use-poll"; import { formatDate } from "@/lib/utils"; type Tab = "members" | "invitations" | "teams"; @@ -53,9 +54,7 @@ function MembersTab() { } }, []); - useEffect(() => { - load(); - }, [load]); + usePoll(load); async function addMember() { const value = invitee.trim(); @@ -219,9 +218,7 @@ function InvitationsTab() { } }, []); - useEffect(() => { - load(); - }, [load]); + usePoll(load); async function cancel(id: number) { setBusy(id); diff --git a/src/components/minecraft/ServerStatusSection.tsx b/src/components/minecraft/ServerStatusSection.tsx index 272e0be..08b2786 100644 --- a/src/components/minecraft/ServerStatusSection.tsx +++ b/src/components/minecraft/ServerStatusSection.tsx @@ -1,9 +1,10 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { OnlinePlayersCard } from "./OnlinePlayersCard"; import { ServerInfoCard } from "./ServerInfoCard"; +import { usePoll } from "@/lib/use-poll"; type PlayerSample = { name: string; uuid: string; isBot: boolean }; @@ -33,11 +34,7 @@ export function ServerStatusSection() { } }, []); - useEffect(() => { - load(); - const id = setInterval(load, 30_000); - return () => clearInterval(id); - }, [load]); + usePoll(load, 30_000); return ( // display:contents makes this wrapper transparent to the CSS grid, diff --git a/src/components/network/AdminNetworkManager.tsx b/src/components/network/AdminNetworkManager.tsx index b07073b..995b514 100644 --- a/src/components/network/AdminNetworkManager.tsx +++ b/src/components/network/AdminNetworkManager.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -14,6 +14,7 @@ import { TableRow, } from "@/components/ui/table"; import type { NetworkNode } from "@/lib/network"; +import { usePoll } from "@/lib/use-poll"; function OnlineDot({ online }: { online: boolean }) { return ( @@ -48,9 +49,7 @@ export function AdminNetworkManager() { } }, []); - useEffect(() => { - load(); - }, [load]); + usePoll(load); async function deleteDevice(id: string) { setBusy(id); diff --git a/src/components/network/NetworkStatusCard.tsx b/src/components/network/NetworkStatusCard.tsx index 394f551..2535890 100644 --- a/src/components/network/NetworkStatusCard.tsx +++ b/src/components/network/NetworkStatusCard.tsx @@ -1,12 +1,13 @@ "use client"; import { RefreshCw } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { usePoll } from "@/lib/use-poll"; type Status = { online: boolean; @@ -39,11 +40,7 @@ export function NetworkStatusCard() { } }, []); - useEffect(() => { - load(); - const id = setInterval(load, 30_000); - return () => clearInterval(id); - }, [load]); + usePoll(load, 30_000); const isOnline = status?.online ?? false; diff --git a/src/components/network/NodeList.tsx b/src/components/network/NodeList.tsx index eb01085..980d9ff 100644 --- a/src/components/network/NodeList.tsx +++ b/src/components/network/NodeList.tsx @@ -1,13 +1,14 @@ "use client"; import { RefreshCw } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import type { NetworkNode } from "@/lib/network"; +import { usePoll } from "@/lib/use-poll"; function OnlineDot({ online }: { online: boolean }) { return ( @@ -46,11 +47,7 @@ export function NodeList() { } }, []); - useEffect(() => { - load(); - const id = setInterval(load, 30_000); - return () => clearInterval(id); - }, [load]); + usePoll(load, 30_000); return ( diff --git a/src/components/onshape/AdminOnshapeManager.tsx b/src/components/onshape/AdminOnshapeManager.tsx index 3678d83..aacbcdd 100644 --- a/src/components/onshape/AdminOnshapeManager.tsx +++ b/src/components/onshape/AdminOnshapeManager.tsx @@ -18,6 +18,7 @@ import { TableRow, } from "@/components/ui/table"; import type { OnshapeCompany, OnshapeMember, OnshapeTeam, OnshapeTeamMember } from "@/lib/onshape"; +import { usePoll } from "@/lib/use-poll"; import { formatDate } from "@/lib/utils"; type Tab = "members" | "teams"; @@ -67,9 +68,7 @@ function MembersTab() { } }, []); - useEffect(() => { - load(); - }, [load]); + usePoll(load); async function addMember() { if (!email.trim()) { diff --git a/src/components/orders/OrderBalancesSummary.tsx b/src/components/orders/OrderBalancesSummary.tsx index e3718a7..95d1301 100644 --- a/src/components/orders/OrderBalancesSummary.tsx +++ b/src/components/orders/OrderBalancesSummary.tsx @@ -45,7 +45,7 @@ export function OrderBalancesSummary({ giftBalanceCents, stfBuckets }: OrderBala
{stfBuckets.length === 0 ? (

- No active STF buckets are configured for this quarter. + No active STF buckets are configured for this school year.

) : null}
diff --git a/src/components/orders/OrderForm.tsx b/src/components/orders/OrderForm.tsx index ac2a27b..3ee0855 100644 --- a/src/components/orders/OrderForm.tsx +++ b/src/components/orders/OrderForm.tsx @@ -3,7 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -144,10 +144,10 @@ export function OrderForm({ initialOrder }: { initialOrder?: OrderFormInitial }) }, }); - const fundType = form.watch("fundType"); - const stfBucketId = form.watch("stfBucketId"); - const quantity = form.watch("quantity"); - const unitCost = form.watch("unitCost"); + const [fundType, stfBucketId, quantity, unitCost] = useWatch({ + control: form.control, + name: ["fundType", "stfBucketId", "quantity", "unitCost"], + }); useEffect(() => { fetch("/api/orders/balances") diff --git a/src/components/vault/VaultEntryDialog.tsx b/src/components/vault/VaultEntryDialog.tsx index e5a5719..dcd8d69 100644 --- a/src/components/vault/VaultEntryDialog.tsx +++ b/src/components/vault/VaultEntryDialog.tsx @@ -3,7 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -110,7 +110,7 @@ export function VaultEntryDialog({ }); }, [open, entry, form]); - const type = form.watch("type"); + const type = useWatch({ control: form.control, name: "type" }); async function onSubmit(values: FormValues) { setSubmitting(true); diff --git a/src/lib/use-poll.ts b/src/lib/use-poll.ts new file mode 100644 index 0000000..15c1868 --- /dev/null +++ b/src/lib/use-poll.ts @@ -0,0 +1,21 @@ +import { useEffect } from "react"; + +/** Run `poll` on an interval; first run is deferred to avoid setState-in-effect lint. */ +export function usePoll(poll: () => void | Promise, intervalMs?: number) { + useEffect(() => { + const initial = setTimeout(() => { + void poll(); + }, 0); + const id = + intervalMs == null + ? undefined + : setInterval(() => { + void poll(); + }, intervalMs); + + return () => { + clearTimeout(initial); + if (id != null) clearInterval(id); + }; + }, [poll, intervalMs]); +} diff --git a/tsconfig.json b/tsconfig.json index 06ee0db..83e3baa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -22,6 +22,12 @@ "@/*": ["./src/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], "exclude": ["node_modules"] }