diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0d58879..98f83086 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: run: pnpm turbo run build - name: Upload Hacklytics build - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: hacklytics-out path: sites/hacklytics2027/out @@ -69,7 +69,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v7 with: name: hacklytics-out path: sites/hacklytics2027/out @@ -94,7 +94,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v7 with: name: hacklytics-out path: sites/hacklytics2027/out diff --git a/monitoring/README.md b/monitoring/README.md index 69cb9edc..46503c91 100644 --- a/monitoring/README.md +++ b/monitoring/README.md @@ -42,6 +42,12 @@ number of instances is irrelevant: - `dsgt_members_active`, `dsgt_members_lapsed` - `dsgt_bootcamp_enrolled{term}` - `dsgt_payments_by_plan{plan}` — the yearly/semester split +- `dsgt_hackathon_participants{edition,phase,status}`, `dsgt_hackathon_projects`, + `dsgt_hackathon_checkins_today`, `dsgt_hackathon_judging_projects`, + `dsgt_hackathon_votes`, `dsgt_hackathon_judging_active` — only editions that + are `open`, `closed`, or `in_progress`. A completed edition disappears on + the next refresh. Badge scans are hackathon QR rows since midnight Eastern, + not club door check-ins. - `dsgt_payments_unlinked` — paid, claimed by nobody. This is **not** zero and is not supposed to be: as of August 2026 it sits around 428 of 443 paid rows, nearly all from the August–September 2025 and January 2026 drives. Those are @@ -85,6 +91,8 @@ The alerts that matter: - `MembershipGrantFailing` — a payment was recorded and the membership was not. - `UnlinkedPaymentsClimbing` — the link paths are not claiming payments. - `ReconcileRecoveringOften` — the backstop is carrying the fast path. +- `HackathonJudgingIdle` — judging is on, tables are assigned, and no scores + have landed for 30 minutes. To route them into ClickUp, run Alertmanager with a webhook receiver and pipe its payload to `node scripts/clickup-task.mjs --stdin`. That step is not built diff --git a/monitoring/clickhouse/init/02-hackathon.sql b/monitoring/clickhouse/init/02-hackathon.sql new file mode 100644 index 00000000..12d1c5fa --- /dev/null +++ b/monitoring/clickhouse/init/02-hackathon.sql @@ -0,0 +1,57 @@ +-- Hackathon history. Same reason as the payment tables: aggregate it here, +-- not on the 0.5 GB application database. `export:clickhouse` creates these +-- too, so a volume that already ran 01-schema.sql still picks them up. + +CREATE DATABASE IF NOT EXISTS dsgt; + +CREATE TABLE IF NOT EXISTS dsgt.hackathon_registrations +( + id String, + hackathon String, + status LowCardinality(String), + registered_at DateTime64(3), + updated_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(updated_at) +ORDER BY (hackathon, id); + +CREATE TABLE IF NOT EXISTS dsgt.hackathon_projects +( + id String, + hackathon String, + status LowCardinality(String), + submitted_at Nullable(DateTime64(3)), + updated_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(updated_at) +ORDER BY (hackathon, id); + +CREATE TABLE IF NOT EXISTS dsgt.hackathon_scans +( + id String, + hackathon String, + event_name String, + checked_in_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(checked_in_at) +ORDER BY (hackathon, id); + +CREATE TABLE IF NOT EXISTS dsgt.hackathon_votes +( + id String, + hackathon String, + score Int32, + voted_at DateTime64(3), + updated_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(updated_at) +ORDER BY (hackathon, id); + +CREATE VIEW IF NOT EXISTS dsgt.hackathon_registrations_by_status AS +SELECT + hackathon, + status, + count() AS registrations +FROM dsgt.hackathon_registrations FINAL +GROUP BY hackathon, status +ORDER BY hackathon, status; diff --git a/monitoring/grafana/dashboards/dsgt-portal.json b/monitoring/grafana/dashboards/dsgt-portal.json index 1adf141f..c7a726b0 100644 --- a/monitoring/grafana/dashboards/dsgt-portal.json +++ b/monitoring/grafana/dashboards/dsgt-portal.json @@ -144,17 +144,112 @@ }, { "type": "row", - "title": "Payments (per-instance counters — a sample in production)", + "title": "Hackathon (read from the database — correct in production)", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 }, "collapsed": false, "panels": [] }, + { + "type": "stat", + "title": "Checked in", + "description": "Accepted participants whose badge was scanned, for each live edition.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 0, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_participants{env=~\"$env\",status=\"checked_in\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Approved, not checked in", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 4, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_participants{env=~\"$env\",status=\"approved\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Projects submitted", + "description": "Not drafts, and not withdrawn by an organiser.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 8, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_projects{env=~\"$env\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Badge scans today", + "description": "Hackathon QR scans since midnight Eastern. Club door check-ins are not in this number.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 12, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_checkins_today{env=~\"$env\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Judging tables", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 16, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_judging_projects{env=~\"$env\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Judge scores", + "description": "Zero while judging is switched on is what HackathonJudgingIdle fires on.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 20, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_votes{env=~\"$env\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "row", + "title": "Payments (per-instance counters — a sample in production)", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 12 }, + "collapsed": false, + "panels": [] + }, { "type": "timeseries", "title": "Grant failures", "description": "Payment recorded, membership not. Non-zero means money taken with nothing given.", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 0, "y": 7 }, + "gridPos": { "h": 7, "w": 8, "x": 0, "y": 13 }, "fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 60, "lineWidth": 1 }, @@ -174,7 +269,7 @@ "type": "timeseries", "title": "Grants by source", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 8, "y": 7 }, + "gridPos": { "h": 7, "w": 8, "x": 8, "y": 13 }, "fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 50, "stacking": { "mode": "normal" } } }, "overrides": [] @@ -191,7 +286,7 @@ "type": "timeseries", "title": "Intents minted, by what was bought", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 16, "y": 7 }, + "gridPos": { "h": 7, "w": 8, "x": 16, "y": 13 }, "fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 50 } }, "overrides": [] @@ -207,7 +302,7 @@ { "type": "row", "title": "App health", - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 20 }, "collapsed": false, "panels": [] }, @@ -215,7 +310,7 @@ "type": "timeseries", "title": "Portal API calls / sec", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 0, "y": 15 }, + "gridPos": { "h": 7, "w": 8, "x": 0, "y": 21 }, "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }, "targets": [ { @@ -229,7 +324,7 @@ "type": "timeseries", "title": "Latency p50 / p95 / p99", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 8, "y": 15 }, + "gridPos": { "h": 7, "w": 8, "x": 8, "y": 21 }, "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, "targets": [ { @@ -253,7 +348,7 @@ "type": "timeseries", "title": "Slowest procedures (p99)", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 16, "y": 15 }, + "gridPos": { "h": 7, "w": 8, "x": 16, "y": 21 }, "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, "targets": [ { @@ -267,7 +362,7 @@ "type": "timeseries", "title": "Process memory", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 6, "w": 12, "x": 0, "y": 22 }, + "gridPos": { "h": 6, "w": 12, "x": 0, "y": 28 }, "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }, "targets": [ { @@ -286,7 +381,7 @@ "type": "timeseries", "title": "Event loop lag (p99)", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 6, "w": 12, "x": 12, "y": 22 }, + "gridPos": { "h": 6, "w": 12, "x": 12, "y": 28 }, "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, "targets": [ { diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml index fd446485..dc988376 100644 --- a/monitoring/prometheus.yml +++ b/monitoring/prometheus.yml @@ -29,8 +29,9 @@ scrape_configs: # # Read the numbers it returns with the instance model in mind. App Hosting # autoscales, so each scrape lands on one arbitrary instance: - # - dsgt_members_*, dsgt_payments_* are read from the database on scrape and - # are therefore correct regardless of which instance answered. + # - dsgt_members_*, dsgt_payments_*, and dsgt_hackathon_* are read from the + # database on scrape and are therefore correct regardless of which + # instance answered. # - counters and histograms are that one instance's, since its own start, # and vanish when it scales down. Treat them as a sample, not a total. # diff --git a/monitoring/rules/hackathon.yml b/monitoring/rules/hackathon.yml new file mode 100644 index 00000000..2f275809 --- /dev/null +++ b/monitoring/rules/hackathon.yml @@ -0,0 +1,26 @@ +# Alerts for a live hackathon edition. The series are database gauges, so +# they are the same number on every instance. See monitoring/README.md. + +groups: + - name: hackathon + rules: + # Judging switched on, tables assigned, and still no scores. Thirty + # minutes is long enough that flipping the switch before judges reach + # the room does not page, and short enough that a broken submit does + # not sit through a whole judging block. + - alert: HackathonJudgingIdle + expr: >- + ( + dsgt_hackathon_judging_active > 0 + and on (edition) (dsgt_hackathon_judging_projects > 0) + ) + unless on (edition) (dsgt_hackathon_votes > 0) + for: 30m + labels: + severity: page + annotations: + summary: "Judging is on for {{ $labels.edition }} and no scores have landed" + description: >- + The edition has judging switched on and projects on the floor, and + the vote count is still zero. Judges may be blocked from + submitting, or judging was opened before anyone was at a table. diff --git a/package.json b/package.json index 6dcbb690..c88627b1 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,9 @@ "typescript": "^7.0.2" }, "devDependencies": { - "turbo": "^2.11.3", + "turbo": "^2.11.7", "vite": "^7.3.5", - "vitest": "^5.0.1" + "vitest": "^5.0.3" }, "pnpm": { "overrides": { diff --git a/packages/api/package.json b/packages/api/package.json index 4f8b792d..9f6b350f 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -46,6 +46,6 @@ "@types/sanitize-html": "^2.16.0", "typescript": "7.0.2", "vite": "^7.3.5", - "vitest": "^5.0.1" + "vitest": "^5.0.3" } } diff --git a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts index e4fefad7..50f9a94d 100644 --- a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts +++ b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts @@ -1417,17 +1417,17 @@ describe("Hackathon admin management edge cases", () => { // ===================================================================== describe("Day-of dashboard", () => { - // BUG: analyticsOverview counts eventCheckIns (the club check-in table), - // not hackathonEventAttendees, which is what scanParticipantPass writes. - it("counts today's hackathon badge scans", async () => { + it("counts today's hackathon badge scans apart from club door check-ins", async () => { const caller = adminCaller({}, "admin"); mockCount.mockImplementation((t: string) => - t === "hackathonEventAttendees" ? 7 : 0, + t === "hackathonEventAttendees" ? 7 : t === "eventCheckIns" ? 2 : 0, ); const res = await caller.admin.analyticsOverview(); - expect(res.checkinsToday).toBe(7); + expect(res.badgeScansToday).toBe(7); + expect(res.doorCheckinsToday).toBe(2); + expect(res.checkinsToday).toBe(9); }); }); }); diff --git a/packages/api/src/routers/admin.ts b/packages/api/src/routers/admin.ts index 96de8cdb..c180ea82 100644 --- a/packages/api/src/routers/admin.ts +++ b/packages/api/src/routers/admin.ts @@ -18,6 +18,7 @@ import { compareTerms, currentTerm } from "@query/db/services/membership"; import { isExpiredAdmin, isStaffRole } from "../types/portal-context"; import type { DrizzleDB } from "@query/db"; import { startOfEasternDay } from "../services/eastern-time"; +import { readLiveHackathons } from "../services/metrics"; export const adminRouter = createTRPCRouter({ isAdmin: protectedProcedure.query(async ({ ctx }) => { @@ -78,6 +79,8 @@ export const adminRouter = createTRPCRouter({ totalEvents: number; totalHackathons: number; checkinsToday: number; + badgeScansToday: number; + doorCheckinsToday: number; }>( cacheKey, async () => { @@ -114,13 +117,19 @@ export const adminRouter = createTRPCRouter({ .where(gte(eventCheckIns.checkedInAt, startOfToday)), ]); + const badgeScansToday = badgeScansResult[0]?.count ?? 0; + const doorCheckinsToday = doorCheckinsResult[0]?.count ?? 0; + const result = { totalParticipants: participantsResult[0]?.count ?? 0, totalEvents: eventsResult[0]?.count ?? 0, totalHackathons: hackathonsResult[0]?.count ?? 0, - checkinsToday: - (badgeScansResult[0]?.count ?? 0) + - (doorCheckinsResult[0]?.count ?? 0), + // Kept as the sum so a caller that only knew the blended number + // still moves. The page reads the two halves, because a hackathon + // weekend and a club meeting are not the same turnout. + badgeScansToday, + doorCheckinsToday, + checkinsToday: badgeScansToday + doorCheckinsToday, }; return result; @@ -129,6 +138,16 @@ export const adminRouter = createTRPCRouter({ ); }), + // The same counts the Prometheus gauges use, for the analytics page. A + // scrape is optional; this is what staff open during the weekend. + hackathonLive: isAdmin.query(async ({ ctx }) => { + return await ctx.cache.getOrSet( + "admin:hackathon-live", + () => readLiveHackathons(ctx.db as DrizzleDB), + 15, + ); + }), + // Membership growth for the Insights page. One pass over the member table // instead of a bucket query per month: the club is a few thousand rows, and // the cumulative series has to see everything before the window anyway. diff --git a/packages/api/src/services/hackathon-metrics.test.ts b/packages/api/src/services/hackathon-metrics.test.ts new file mode 100644 index 00000000..d7a52982 --- /dev/null +++ b/packages/api/src/services/hackathon-metrics.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { getTableName } from "drizzle-orm"; +import type { DrizzleDB } from "@query/db"; +import { readLiveHackathons, refreshDbGauges, registry } from "./metrics"; + +const rules = readFileSync( + fileURLToPath( + new URL("../../../../monitoring/rules/hackathon.yml", import.meta.url), + ), + "utf-8", +); + +type GaugeReading = { + values: { value: number; labels: Record }[]; +}; + +/** Drizzle conditions point back at their tables, so this only follows SQL text. */ +function sqlText(condition: unknown) { + const parts: string[] = []; + const visit = (value: unknown, seen: Set) => { + if (typeof value === "string") { + parts.push(value); + return; + } + if (!value || typeof value !== "object" || seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + for (const item of value) visit(item, seen); + return; + } + const record = value as { value?: unknown; queryChunks?: unknown }; + if ("value" in record) visit(record.value, seen); + if ("queryChunks" in record) visit(record.queryChunks, seen); + }; + visit(condition, new Set()); + return parts.join(" "); +} + +function readings(name: string) { + return registry.getSingleMetric(name) as { + get: () => Promise | GaugeReading; + set: (labels: Record, value: number) => void; + }; +} + +describe("hackathon gauges", () => { + it("counts a live edition and drops one that is no longer live", async () => { + readings("dsgt_hackathon_projects").set( + { edition: "Hacklytics 2026", phase: "completed" }, + 40, + ); + + const queries: string[] = []; + const rowsFor = (table: string) => { + if (table === "hackathon") { + return [ + { + id: "hack_1", + edition: "Hacklytics 2027", + phase: "in_progress", + judgingActive: true, + }, + ]; + } + if (table === "hackathon_participant") { + return [ + { hackathonId: "hack_1", status: "approved", total: 12 }, + { hackathonId: "hack_1", status: "checked_in", total: 3 }, + ]; + } + if (table === "hackathon_project") { + return [{ hackathonId: "hack_1", total: 4 }]; + } + if (table === "hackathon_event_attendee") { + return [{ hackathonId: "hack_1", total: 9 }]; + } + if (table === "judging_project") { + return [{ hackathonId: "hack_1", total: 4 }]; + } + return []; + }; + + const db = { + select() { + return { + from(table: Parameters[0]) { + const pending: any = Promise.resolve(rowsFor(getTableName(table))); + pending.from = () => pending; + pending.innerJoin = () => pending; + pending.groupBy = () => pending; + pending.where = (condition: unknown) => { + queries.push(sqlText(condition)); + return pending; + }; + return pending; + }, + }; + }, + execute: async (query: unknown) => { + const text = JSON.stringify(query); + if (text.includes("member_resume")) { + return { rows: [{ files: "1", bytes: "10" }] }; + } + if (text.includes("metadata")) { + return { rows: [{ plan: "annual", total: "2" }] }; + } + return { + rows: [{ active: "5", lapsed: "1", bootcamp: "2", unlinked: "0" }], + }; + }, + }; + + const live = await readLiveHackathons(db as unknown as DrizzleDB); + expect(live).toEqual([ + { + edition: "Hacklytics 2027", + phase: "in_progress", + judgingActive: true, + projects: 4, + checkinsToday: 9, + judgingProjects: 4, + votes: 0, + participants: { + pending: 0, + approved: 12, + rejected: 0, + waitlisted: 0, + checked_in: 3, + }, + }, + ]); + + await refreshDbGauges(db as unknown as DrizzleDB); + + const projects = (await readings("dsgt_hackathon_projects").get()).values; + expect(projects).toEqual([ + { + labels: { edition: "Hacklytics 2027", phase: "in_progress" }, + value: 4, + }, + ]); + + const participants = (await readings("dsgt_hackathon_participants").get()) + .values; + expect( + participants.find((row) => row.labels.status === "checked_in")?.value, + ).toBe(3); + expect( + participants.find((row) => row.labels.status === "pending")?.value, + ).toBe(0); + expect( + participants.find((row) => row.labels.edition === "Hacklytics 2026"), + ).toBeUndefined(); + + expect( + (await readings("dsgt_hackathon_checkins_today").get()).values[0]?.value, + ).toBe(9); + expect( + (await readings("dsgt_hackathon_votes").get()).values[0]?.value, + ).toBe(0); + expect( + (await readings("dsgt_hackathon_judging_active").get()).values[0]?.value, + ).toBe(1); + + const filters = queries.join("\n"); + expect(filters).toContain("in_progress"); + expect(filters).toContain("draft"); + expect(filters).toContain("is null"); + expect(filters).not.toContain("completed"); + }); + + it("alerts when judging is on, tables are assigned, and no scores exist", () => { + expect(rules).toContain("alert: HackathonJudgingIdle"); + expect(rules).toContain("dsgt_hackathon_judging_active"); + expect(rules).toContain("dsgt_hackathon_judging_projects"); + expect(rules).toContain("dsgt_hackathon_votes"); + expect(rules).toContain("for: 30m"); + }); +}); diff --git a/packages/api/src/services/metrics.ts b/packages/api/src/services/metrics.ts index f73541b0..6fa6508c 100644 --- a/packages/api/src/services/metrics.ts +++ b/packages/api/src/services/metrics.ts @@ -12,8 +12,33 @@ import { Gauge, collectDefaultMetrics, } from "prom-client"; -import { sql } from "drizzle-orm"; -import type { DrizzleDB } from "@query/db"; +import { + and, + count, + eq, + gt, + gte, + inArray, + isNotNull, + isNull, + lte, + ne, + sum, +} from "drizzle-orm"; +import { + type DrizzleDB, + hackathonEventAttendees, + hackathonEvents, + hackathonParticipants, + hackathonProjects, + hackathons, + judgeVotes, + judgingProjects, + memberResumes, + members, + stripePayments, +} from "@query/db"; +import { startOfEasternDay } from "./eastern-time"; export const registry = new Registry(); @@ -137,6 +162,215 @@ const gaugeRefreshFailures = new Counter({ registers: [registry], }); +// Live editions only: open (registration), closed (registration shut, event +// not finished), in progress (the weekend). A completed edition drops off on +// the next refresh, so last year does not sit on the dashboard as current. +// Same rule as the other gauges — counted from the database, so which instance +// Prometheus scraped does not matter. + +const PARTICIPANT_STATUSES = [ + "pending", + "approved", + "rejected", + "waitlisted", + "checked_in", +] as const; + +const participantGauge = new Gauge({ + name: "dsgt_hackathon_participants", + help: "Registrations for a live hackathon edition, by status.", + labelNames: ["edition", "phase", "status"] as const, + registers: [registry], +}); + +const projectGauge = new Gauge({ + name: "dsgt_hackathon_projects", + help: "Submitted projects for a live edition, excluding admin withdrawals and drafts.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +const hackathonCheckinsToday = new Gauge({ + name: "dsgt_hackathon_checkins_today", + help: "Hackathon badge scans since midnight Eastern, for a live edition.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +const hackathonJudgingProjects = new Gauge({ + name: "dsgt_hackathon_judging_projects", + help: "Projects on the judging floor for a live edition, excluding withdrawals.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +const hackathonVotes = new Gauge({ + name: "dsgt_hackathon_votes", + help: "Judge scores recorded for a live edition.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +const hackathonJudgingActive = new Gauge({ + name: "dsgt_hackathon_judging_active", + help: "1 when judging is switched on for a live edition, otherwise 0.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +export type LiveHackathon = { + edition: string; + phase: string; + judgingActive: boolean; + projects: number; + checkinsToday: number; + judgingProjects: number; + votes: number; + participants: Record<(typeof PARTICIPANT_STATUSES)[number], number>; +}; + +const LIVE_PHASES = ["open", "closed", "in_progress"] as const; + +const blankParticipants = (): LiveHackathon["participants"] => ({ + pending: 0, + approved: 0, + rejected: 0, + waitlisted: 0, + checked_in: 0, +}); + +const countByEdition = ( + rows: { hackathonId: string; total: number }[], +) => { + const totals = new Map(); + for (const row of rows) totals.set(row.hackathonId, Number(row.total)); + return totals; +}; + +// Shared with the admin analytics page. Prometheus is optional; the portal +// has to be able to show the same counts when nothing is scraping. +export async function readLiveHackathons( + db: DrizzleDB, + now = new Date(), +): Promise { + const startOfToday = startOfEasternDay(now); + + const editions = await db + .select({ + id: hackathons.id, + edition: hackathons.name, + phase: hackathons.status, + judgingActive: hackathons.judgingActive, + }) + .from(hackathons) + .where(inArray(hackathons.status, [...LIVE_PHASES])); + + if (editions.length === 0) return []; + + const ids = editions.map((edition) => edition.id); + + const [byStatus, projectRows, scanRows, judgingRows, voteRows] = + await Promise.all([ + db + .select({ + hackathonId: hackathonParticipants.hackathonId, + status: hackathonParticipants.registrationStatus, + total: count(), + }) + .from(hackathonParticipants) + .where(inArray(hackathonParticipants.hackathonId, ids)) + .groupBy( + hackathonParticipants.hackathonId, + hackathonParticipants.registrationStatus, + ), + db + .select({ + hackathonId: hackathonProjects.hackathonId, + total: count(), + }) + .from(hackathonProjects) + .where( + and( + inArray(hackathonProjects.hackathonId, ids), + ne(hackathonProjects.status, "draft"), + isNull(hackathonProjects.withdrawnByAdminAt), + ), + ) + .groupBy(hackathonProjects.hackathonId), + db + .select({ + hackathonId: hackathonEvents.hackathonId, + total: count(), + }) + .from(hackathonEventAttendees) + .innerJoin( + hackathonEvents, + eq(hackathonEvents.id, hackathonEventAttendees.eventId), + ) + .where( + and( + inArray(hackathonEvents.hackathonId, ids), + gte(hackathonEventAttendees.checkedInAt, startOfToday), + ), + ) + .groupBy(hackathonEvents.hackathonId), + db + .select({ + hackathonId: judgingProjects.hackathonId, + total: count(), + }) + .from(judgingProjects) + .where( + and( + inArray(judgingProjects.hackathonId, ids), + isNull(judgingProjects.withdrawnAt), + ), + ) + .groupBy(judgingProjects.hackathonId), + db + .select({ + hackathonId: judgingProjects.hackathonId, + total: count(), + }) + .from(judgeVotes) + .innerJoin( + judgingProjects, + eq(judgingProjects.id, judgeVotes.projectId), + ) + .where( + and( + inArray(judgingProjects.hackathonId, ids), + isNull(judgingProjects.withdrawnAt), + ), + ) + .groupBy(judgingProjects.hackathonId), + ]); + + const participantsByEdition = new Map(); + for (const row of byStatus) { + const bucket = + participantsByEdition.get(row.hackathonId) ?? blankParticipants(); + if (row.status in bucket) bucket[row.status] = Number(row.total); + participantsByEdition.set(row.hackathonId, bucket); + } + + const projects = countByEdition(projectRows); + const scans = countByEdition(scanRows); + const judging = countByEdition(judgingRows); + const votes = countByEdition(voteRows); + + return editions.map((edition) => ({ + edition: edition.edition, + phase: edition.phase, + judgingActive: edition.judgingActive, + projects: projects.get(edition.id) ?? 0, + checkinsToday: scans.get(edition.id) ?? 0, + judgingProjects: judging.get(edition.id) ?? 0, + votes: votes.get(edition.id) ?? 0, + participants: participantsByEdition.get(edition.id) ?? blankParticipants(), + })); +} + /** Long enough that a 30s scrape loop cannot turn into a query loop. */ const GAUGE_TTL_MS = 60_000; @@ -171,6 +405,14 @@ const gaugeCache = new GaugeCache(); // Eastern time like currentTerm, or the label flips four hours early at the // May and December boundaries. If the two disagree the gauge is mislabelled, // nothing more. +/** Null metadata is a year. Anything that is not a JSON object is left out. */ +function planFromMetadata(metadata: string | null) { + if (metadata == null) return "annual"; + if (!metadata.startsWith("{")) return null; + const parsed = JSON.parse(metadata) as { plan?: unknown }; + return typeof parsed.plan === "string" && parsed.plan ? parsed.plan : "annual"; +} + const currentTermLabel = (now = new Date()) => { const parts = new Intl.DateTimeFormat("en-US", { timeZone: "America/New_York", @@ -191,65 +433,103 @@ export async function refreshDbGauges(db: DrizzleDB | null | undefined) { await gaugeCache.refresh(async () => { try { const term = currentTermLabel(); - - const totals = await db.execute<{ - active: string; - lapsed: string; - bootcamp: string; - unlinked: string; - }>(sql` - select - count(*) filter ( - where "is_active" and "membership_end_date" > now() - ) as active, - count(*) filter ( - where "membership_end_date" is not null - and "membership_end_date" <= now() - ) as lapsed, - count(*) filter (where "bootcamp_term" = ${term}) as bootcamp, - ( - select count(*) from "stripe_payment" - where "payment_status" = 'paid' and "linked_user_id" is null - ) as unlinked - from "member" - `); - - const counts = totals.rows[0]; - - if (counts) { - membersActive.set(Number(counts.active)); - membersLapsed.set(Number(counts.lapsed)); - bootcampEnrolled.set({ term }, Number(counts.bootcamp)); - paymentsUnlinked.set(Number(counts.unlinked)); + const now = new Date(); + + const [ + [active], + [lapsed], + [bootcamp], + [unlinked], + paidRows, + [resumeTotals], + ] = await Promise.all([ + db + .select({ total: count() }) + .from(members) + .where( + and(eq(members.isActive, true), gt(members.membershipEndDate, now)), + ), + db + .select({ total: count() }) + .from(members) + .where( + and( + isNotNull(members.membershipEndDate), + lte(members.membershipEndDate, now), + ), + ), + db + .select({ total: count() }) + .from(members) + .where(eq(members.bootcampTerm, term)), + db + .select({ total: count() }) + .from(stripePayments) + .where( + and( + eq(stripePayments.paymentStatus, "paid"), + isNull(stripePayments.linkedUserId), + ), + ), + db + .select({ metadata: stripePayments.metadata }) + .from(stripePayments) + .where(eq(stripePayments.paymentStatus, "paid")), + db + .select({ + files: count(), + bytes: sum(memberResumes.sizeBytes), + }) + .from(memberResumes), + ]); + + membersActive.set(Number(active?.total ?? 0)); + membersLapsed.set(Number(lapsed?.total ?? 0)); + bootcampEnrolled.set({ term }, Number(bootcamp?.total ?? 0)); + paymentsUnlinked.set(Number(unlinked?.total ?? 0)); + + // Plan lives in the payment metadata string. Rows that are not JSON are + // skipped; a missing plan is the only product that existed then, a year. + // One unparseable object still fails the refresh, same as a bad cast did. + const plans = new Map(); + for (const row of paidRows) { + const plan = planFromMetadata(row.metadata); + if (!plan) continue; + plans.set(plan, (plans.get(plan) ?? 0) + 1); } - // `like '{%'` before the jsonb cast, deliberately: one row of unparseable - // metadata would error the whole statement, and rows written before the plan - // existed have no `plan` key — those bought the only thing on offer, a year. - const byPlan = await db.execute<{ plan: string; total: string }>(sql` - select - coalesce(("metadata"::jsonb ->> 'plan'), 'annual') as plan, - count(*) as total - from "stripe_payment" - where "payment_status" = 'paid' - and ("metadata" is null or "metadata" like '{%') - group by 1 - `); - paymentsByPlan.reset(); - for (const row of byPlan.rows) { - paymentsByPlan.set({ plan: row.plan }, Number(row.total)); + for (const [plan, total] of plans) { + paymentsByPlan.set({ plan }, total); } - const resumes = await db.execute<{ files: string; bytes: string }>(sql` - select count(*) as files, coalesce(sum("size_bytes"), 0) as bytes - from "member_resume" - `); - - const resumeTotals = resumes.rows[0]; - if (resumeTotals) { - resumesStored.set(Number(resumeTotals.files)); - resumeBytesStored.set(Number(resumeTotals.bytes)); + resumesStored.set(Number(resumeTotals?.files ?? 0)); + resumeBytesStored.set(Number(resumeTotals?.bytes ?? 0)); + + const live = await readLiveHackathons(db); + + // reset before set: an edition that left the live set has to disappear, + // or the dashboard keeps yesterday's registration count. + participantGauge.reset(); + projectGauge.reset(); + hackathonCheckinsToday.reset(); + hackathonJudgingProjects.reset(); + hackathonVotes.reset(); + hackathonJudgingActive.reset(); + + for (const edition of live) { + const labels = { edition: edition.edition, phase: edition.phase }; + projectGauge.set(labels, edition.projects); + hackathonCheckinsToday.set(labels, edition.checkinsToday); + hackathonJudgingProjects.set(labels, edition.judgingProjects); + hackathonVotes.set(labels, edition.votes); + hackathonJudgingActive.set(labels, edition.judgingActive ? 1 : 0); + for (const status of PARTICIPANT_STATUSES) { + participantGauge.set( + { ...labels, status }, + edition.participants[status], + ); + } } } catch { // Stale gauges beat a 500 on the scrape endpoint. diff --git a/packages/auth/package.json b/packages/auth/package.json index a8db62e8..e87f59e7 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -25,7 +25,7 @@ "devDependencies": { "@query/tsconfig": "workspace:*", "@types/node": "^22.20.4", - "@typescript-eslint/eslint-plugin": "^8.70.1", + "@typescript-eslint/eslint-plugin": "^8.71.0", "@typescript-eslint/parser": "^8.70.1", "@typescript-eslint/utils": "^8.70.1", "eslint": "^10.11.0", diff --git a/packages/db/package.json b/packages/db/package.json index 7ccc9e35..a83cbc11 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -30,7 +30,7 @@ "devDependencies": { "@query/tsconfig": "workspace:*", "@types/pg": "^8.23.1", - "dotenv": "^18.0.3", + "dotenv": "^18.0.5", "drizzle-kit": "0.31.11", "tsx": "^4.23.15", "typescript": "^7.0.2", diff --git a/packages/db/scripts/export-clickhouse.mts b/packages/db/scripts/export-clickhouse.mts index b4941d00..77535c6e 100644 --- a/packages/db/scripts/export-clickhouse.mts +++ b/packages/db/scripts/export-clickhouse.mts @@ -1,16 +1,19 @@ /** - * Copies payment and membership history from Postgres into ClickHouse. + * Copies payment, membership, and hackathon history from Postgres into ClickHouse. * * pnpm --filter @query/db export:clickhouse * - * Read-only against Postgres — it runs two SELECTs and writes nothing back. + * Read-only against Postgres — it runs SELECTs and writes nothing back. * That is deliberate: the application database is a 0.5 GB Neon instance, so * history that exists to be aggregated belongs somewhere that is not it. * - * Safe to re-run. Both ClickHouse tables are ReplacingMergeTree keyed on the + * Safe to re-run. The ClickHouse tables are ReplacingMergeTree keyed on the * row id, so a second run replaces rows rather than duplicating them, and the * whole thing can go on a cron without any watermark bookkeeping. * + * Hackathon rows are ids, edition names, and statuses. Names and emails stay + * in Postgres. + * * Env: * DATABASE_URL Postgres to read from. Prod is fine — nothing is written. * CLICKHOUSE_URL default http://localhost:8123 @@ -18,7 +21,22 @@ * CLICKHOUSE_PASSWORD default dsgt * CLICKHOUSE_DB default dsgt */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { eq } from "drizzle-orm"; import { Pool } from "pg"; +import { + hackathonEventAttendees, + hackathonEvents, + hackathonParticipants, + hackathonProjects, + hackathons, + judgeVotes, + judgingProjects, + membershipHistory, + stripePayments, +} from "../src/schemas"; const { DATABASE_URL, @@ -64,6 +82,42 @@ async function insert(table: string, rows: unknown[]) { const stamp = (value: Date | null | undefined) => (value ?? new Date(0)).toISOString().replace("T", " ").replace("Z", ""); +const stampOrNull = (value: Date | null | undefined) => + value ? stamp(value) : null; + +/** Applies monitoring/clickhouse/init/02-hackathon.sql. Init only runs on an empty volume. */ +async function ensureHackathonTables() { + const file = fileURLToPath( + new URL( + "../../../monitoring/clickhouse/init/02-hackathon.sql", + import.meta.url, + ), + ); + const statements = readFileSync(file, "utf-8") + .split("\n") + .filter((line) => !line.trim().startsWith("--")) + .join("\n") + .split(";") + .map((statement) => statement.trim()) + .filter(Boolean); + + for (const statement of statements) { + const url = `${CLICKHOUSE_URL}/?query=${encodeURIComponent(statement)}`; + const res = await fetch(url, { + method: "POST", + headers: { + "X-ClickHouse-User": CLICKHOUSE_USER, + "X-ClickHouse-Key": CLICKHOUSE_PASSWORD, + }, + }); + if (!res.ok) { + throw new Error( + `ClickHouse rejected schema setup: ${res.status} ${await res.text()}`, + ); + } + } +} + const readJson = (raw: string | null) => { if (!raw) return {} as Record; try { @@ -74,76 +128,161 @@ const readJson = (raw: string | null) => { }; const pool = new Pool({ connectionString: DATABASE_URL, max: 2 }); +const db = drizzle(pool); try { - const payments = await pool.query<{ - id: string; - created_at: Date; - updated_at: Date | null; - amount_total: number | null; - currency: string | null; - payment_status: string; - metadata: string | null; - linked_user_id: string | null; - customer_email: string; - }>( - `select id, created_at, updated_at, amount_total, currency, - payment_status, metadata, linked_user_id, customer_email - from "stripe_payment"`, - ); + await ensureHackathonTables(); + const payments = await db + .select({ + id: stripePayments.id, + createdAt: stripePayments.createdAt, + updatedAt: stripePayments.updatedAt, + amountTotal: stripePayments.amountTotal, + currency: stripePayments.currency, + paymentStatus: stripePayments.paymentStatus, + metadata: stripePayments.metadata, + linkedUserId: stripePayments.linkedUserId, + customerEmail: stripePayments.customerEmail, + }) + .from(stripePayments); - const paymentRows = payments.rows.map((row) => { + const paymentRows = payments.map((row) => { const meta = readJson(row.metadata); return { id: row.id, - created_at: stamp(row.created_at), - updated_at: stamp(row.updated_at ?? row.created_at), - amount_cents: row.amount_total ?? 0, + created_at: stamp(row.createdAt), + updated_at: stamp(row.updatedAt ?? row.createdAt), + amount_cents: row.amountTotal ?? 0, currency: row.currency ?? "usd", - payment_status: row.payment_status, + payment_status: row.paymentStatus, // Rows written before the plan existed bought the only thing on offer. plan: meta.plan === "semester" ? "semester" : "annual", bootcamp: meta.bootcamp === "true" ? 1 : 0, addon_only: meta.type === "bootcamp_addon" ? 1 : 0, - linked: row.linked_user_id ? 1 : 0, - customer_email: row.customer_email, + linked: row.linkedUserId ? 1 : 0, + customer_email: row.customerEmail, }; }); - const history = await pool.query<{ - id: string; - member_id: string; - action: string; - start_date: Date | null; - end_date: Date | null; - created_at: Date; - }>( - `select id, member_id, action, start_date, end_date, created_at - from "membership_history"`, - ); + const history = await db + .select({ + id: membershipHistory.id, + memberId: membershipHistory.memberId, + action: membershipHistory.action, + startDate: membershipHistory.startDate, + endDate: membershipHistory.endDate, + createdAt: membershipHistory.createdAt, + }) + .from(membershipHistory); const DAY_MS = 24 * 60 * 60 * 1000; - const historyRows = history.rows.map((row) => ({ + const historyRows = history.map((row) => ({ id: row.id, - member_id: row.member_id, + member_id: row.memberId, action: row.action, - start_date: stamp(row.start_date), - end_date: stamp(row.end_date), - created_at: stamp(row.created_at), + start_date: stamp(row.startDate), + end_date: stamp(row.endDate), + created_at: stamp(row.createdAt), term_days: - row.start_date && row.end_date + row.startDate && row.endDate ? Math.round( - (row.end_date.getTime() - row.start_date.getTime()) / DAY_MS, + (row.endDate.getTime() - row.startDate.getTime()) / DAY_MS, ) : 0, })); + const registrations = await db + .select({ + id: hackathonParticipants.id, + hackathon: hackathons.name, + status: hackathonParticipants.registrationStatus, + registeredAt: hackathonParticipants.registeredAt, + updatedAt: hackathonParticipants.updatedAt, + }) + .from(hackathonParticipants) + .innerJoin( + hackathons, + eq(hackathons.id, hackathonParticipants.hackathonId), + ); + + const registrationRows = registrations.map((row) => ({ + id: row.id, + hackathon: row.hackathon, + status: row.status, + registered_at: stamp(row.registeredAt), + updated_at: stamp(row.updatedAt), + })); + + const projects = await db + .select({ + id: hackathonProjects.id, + hackathon: hackathons.name, + status: hackathonProjects.status, + submittedAt: hackathonProjects.submittedAt, + updatedAt: hackathonProjects.updatedAt, + }) + .from(hackathonProjects) + .innerJoin(hackathons, eq(hackathons.id, hackathonProjects.hackathonId)); + + const projectRows = projects.map((row) => ({ + id: row.id, + hackathon: row.hackathon, + status: row.status, + submitted_at: stampOrNull(row.submittedAt), + updated_at: stamp(row.updatedAt), + })); + + const scans = await db + .select({ + id: hackathonEventAttendees.id, + hackathon: hackathons.name, + eventName: hackathonEvents.name, + checkedInAt: hackathonEventAttendees.checkedInAt, + }) + .from(hackathonEventAttendees) + .innerJoin( + hackathonEvents, + eq(hackathonEvents.id, hackathonEventAttendees.eventId), + ) + .innerJoin(hackathons, eq(hackathons.id, hackathonEvents.hackathonId)); + + const scanRows = scans.map((row) => ({ + id: row.id, + hackathon: row.hackathon, + event_name: row.eventName, + checked_in_at: stamp(row.checkedInAt), + })); + + const votes = await db + .select({ + id: judgeVotes.id, + hackathon: hackathons.name, + score: judgeVotes.score, + votedAt: judgeVotes.votedAt, + updatedAt: judgeVotes.updatedAt, + }) + .from(judgeVotes) + .innerJoin(judgingProjects, eq(judgingProjects.id, judgeVotes.projectId)) + .innerJoin(hackathons, eq(hackathons.id, judgingProjects.hackathonId)); + + const voteRows = votes.map((row) => ({ + id: row.id, + hackathon: row.hackathon, + score: row.score, + voted_at: stamp(row.votedAt), + updated_at: stamp(row.updatedAt), + })); + const written = (await insert("payments", paymentRows)) + - (await insert("membership_events", historyRows)); + (await insert("membership_events", historyRows)) + + (await insert("hackathon_registrations", registrationRows)) + + (await insert("hackathon_projects", projectRows)) + + (await insert("hackathon_scans", scanRows)) + + (await insert("hackathon_votes", voteRows)); console.log( - `Exported ${paymentRows.length} payments and ${historyRows.length} membership events (${written} rows).`, + `Exported ${paymentRows.length} payments, ${historyRows.length} membership events, ${registrationRows.length} registrations, ${projectRows.length} projects, ${scanRows.length} scans, and ${voteRows.length} votes (${written} rows).`, ); } catch (error) { console.error("Export failed:", error); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8907bf4d..efaead41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,14 +32,14 @@ importers: version: 7.0.2 devDependencies: turbo: - specifier: ^2.11.3 - version: 2.11.3 + specifier: ^2.11.7 + version: 2.11.7 vite: specifier: ^7.3.5 version: 7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) vitest: - specifier: ^5.0.1 - version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + specifier: ^5.0.3 + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) packages/api: dependencies: @@ -99,8 +99,8 @@ importers: specifier: ^7.3.5 version: 7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) vitest: - specifier: ^5.0.1 - version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + specifier: ^5.0.3 + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) packages/auth: dependencies: @@ -127,8 +127,8 @@ importers: specifier: ^22.20.4 version: 22.20.4 '@typescript-eslint/eslint-plugin': - specifier: ^8.70.1 - version: 8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) + specifier: ^8.71.0 + version: 8.71.0(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) '@typescript-eslint/parser': specifier: ^8.70.1 version: 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) @@ -173,8 +173,8 @@ importers: specifier: ^8.23.1 version: 8.23.1 dotenv: - specifier: ^18.0.3 - version: 18.0.3 + specifier: ^18.0.5 + version: 18.0.5 drizzle-kit: specifier: 0.31.11 version: 0.31.11 @@ -434,8 +434,8 @@ importers: specifier: 4.3.3 version: 4.3.3 turbo: - specifier: ^2.11.3 - version: 2.11.3 + specifier: ^2.11.7 + version: 2.11.7 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -455,8 +455,8 @@ importers: specifier: 16.3.7 version: 16.3.7(eslint@10.11.0(jiti@2.7.0)) '@typescript-eslint/eslint-plugin': - specifier: 8.70.1 - version: 8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) + specifier: 8.71.0 + version: 8.71.0(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) '@typescript-eslint/parser': specifier: 8.70.1 version: 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) @@ -477,7 +477,7 @@ importers: version: 7.1.1(eslint@10.11.0(jiti@2.7.0)) eslint-plugin-turbo: specifier: ^2.11.3 - version: 2.11.3(eslint@10.11.0(jiti@2.7.0))(turbo@2.11.3) + version: 2.11.3(eslint@10.11.0(jiti@2.7.0))(turbo@2.11.7) globals: specifier: 17.12.0 version: 17.12.0 @@ -1172,9 +1172,6 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} @@ -2101,33 +2098,33 @@ packages: peerDependencies: typescript: '>=5.7.2' - '@turbo/darwin-64@2.11.3': - resolution: {integrity: sha512-D4EIKTgPpxFQL5aZNbNqy57XOiRldM+mwP1ME9ZLM4DJoV3oNK7GKyQWVusz1umjiFNmLvunNGw7Jq4RUyYL3A==} + '@turbo/darwin-64@2.11.7': + resolution: {integrity: sha512-Z2LFOxwbiJueD1miByXJS/BrW21vv6YaQXZustty5jEX4vQYw6zyh101Sz1QO7WG0mqBzKagZ/DoAeeujI0/Sw==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.11.3': - resolution: {integrity: sha512-WOvgWIO86LzBQmL9aRFekROing1CZMVDjmdD7Dk6R65qFAFdrlKkufojenjW1AbUAqytpG6xsXzq8UEScdSdDQ==} + '@turbo/darwin-arm64@2.11.7': + resolution: {integrity: sha512-Vhv8ybgIflosNDSSRD9fNXHlCFA60dsGyTqQmc7l28SEX5jOaweCCD4BBYNd8/p0WGkw3e7tZbfA9w6FsujGvw==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.11.3': - resolution: {integrity: sha512-zIgNl9KkMnsdU54JrnazELatpMJDSktKjD3kjKVliUNMl/5uhFRrqQ+Z1qaACKw/2ybL7xzlsHir9s5q7sYlQg==} + '@turbo/linux-64@2.11.7': + resolution: {integrity: sha512-rAqRp3blQKk1+ylMGlZHAW0eJjVNuuGUm4ZrC6RU1Im8tLryvaO4a2zLQ/FQi1TRlmKqKV9YXBki2YeytlLbXA==} cpu: [x64] os: [android, linux] - '@turbo/linux-arm64@2.11.3': - resolution: {integrity: sha512-aJToWHx5JYvpPwIrAt1DuYrHWcpQGjGf0XVPRMKQBHou//JUYR6oowHeUZp9Oq7e3XDWA75E37C2GnNaBqCVnA==} + '@turbo/linux-arm64@2.11.7': + resolution: {integrity: sha512-thVYGov7S+llXAxrl3kBb/efalav0xhrjl5NHCR6AfKx14JkbqaetvbVwyss4FvZ9rt+Pv3xsTb+8SIYix8DTg==} cpu: [arm64] os: [android, linux] - '@turbo/windows-64@2.11.3': - resolution: {integrity: sha512-hu5biiwVGdMvHB/vvaoPjtudNBn3TqcsKK2A06ap3O0wnSZwAtTXe92tKTckpecDeVs+IC+4r8s2wXpbdSPWoA==} + '@turbo/windows-64@2.11.7': + resolution: {integrity: sha512-ZIoKT7J7KutgYl4bGxmmgeUQjG+7E0nY8BF9dxUOiMH2vdTsM4JQ2uSwTYFkTR2Llbg6ghGjSmuZ8LQtkLEYjg==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.11.3': - resolution: {integrity: sha512-naDEb/31UmOOJOChi+LebLMKWKa1kId0yig1yTRZzDViDg1lBw1Ptu5iQYCvOfOOrx6swUUucf3zPWuPGWz7Jw==} + '@turbo/windows-arm64@2.11.7': + resolution: {integrity: sha512-88Xv2f7SLGVJ68lRRx8o375dKmnZYNdDtvlKcPA0kZpcF93eAQd8l96BKQ0/AMHG0bMJA0Z5QqoqtetxzNsC+g==} cpu: [arm64] os: [win32] @@ -2201,6 +2198,14 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/eslint-plugin@8.71.0': + resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.71.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser@8.70.1': resolution: {integrity: sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2214,16 +2219,32 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.71.0': + resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.70.1': resolution: {integrity: sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.71.0': + resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.70.1': resolution: {integrity: sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/tsconfig-utils@8.71.0': + resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.70.1': resolution: {integrity: sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2231,16 +2252,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.71.0': + resolution: {integrity: sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.70.1': resolution: {integrity: sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.71.0': + resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.70.1': resolution: {integrity: sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.71.0': + resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.70.1': resolution: {integrity: sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2248,10 +2286,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.71.0': + resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.70.1': resolution: {integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.71.0': + resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -2372,8 +2421,8 @@ packages: cpu: [x64] os: [win32] - '@vitest/mocker@5.0.1': - resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==} + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} peerDependencies: msw: ^2.4.9 vite: ^7.3.5 @@ -2383,8 +2432,8 @@ packages: vite: optional: true - '@vitest/spy@5.0.1': - resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==} + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} '@yudiel/react-qr-scanner@2.6.0': resolution: {integrity: sha512-WjNcSt7qquYUz1LVbwKzSnRQI47sNrh4Vh6nNiGKVxnYcP+KgN5q9rcSzXyZ/7GRV9PPrnzoIuE5YdOxB9yAog==} @@ -2630,8 +2679,8 @@ packages: caniuse-lite@1.0.30001812: resolution: {integrity: sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==} - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} chart.js@4.5.1: @@ -2810,8 +2859,8 @@ packages: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} - dotenv@18.0.3: - resolution: {integrity: sha512-/8CJTyL817qPnfbQrJgOEcrU3fDrJkRIoXBE566wHuvIXKOCHUZhPqGPbCPi9SogjdGnreF0TlvnTgK6J/8zpA==} + dotenv@18.0.5: + resolution: {integrity: sha512-aBrGvt6KhjxbEnatqMWOMidftwsrCiCvqh1yNIGB9QyF0J+VcWkKDMsvYOEm7ZS82oiV+BT1sTOtIlAmUlTpwQ==} engines: {node: '>=12'} hasBin: true @@ -3391,8 +3440,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.10: - resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} + ignore@7.0.12: + resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} engines: {node: '>= 4'} image-size@2.0.4: @@ -3711,8 +3760,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magic-string@1.4.2: - resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} @@ -4337,9 +4386,6 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - sirv@2.0.4: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} @@ -4359,11 +4405,8 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - std-env@4.2.0: - resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} @@ -4485,12 +4528,12 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} - tinybench@6.1.4: - resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} engines: {node: '>=20.0.0'} - tinyexec@1.3.0: - resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} tinyglobby@0.2.17: @@ -4528,8 +4571,8 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo@2.11.3: - resolution: {integrity: sha512-Pfg2dWNOUPwJYbR+eYAcjoBbo4Kmoc8uQb80hzKrBgNEQtxvwf2ucXG7C28MyLVhV/zJSKvChrR+2CP/BONpoA==} + turbo@2.11.7: + resolution: {integrity: sha512-RhXHbqfvY6o3xt/zSHCwSNwLrZlXiqHzewi4eAj9PtZ1T1CqgMJ2yIDb7SKPTG9IikTm925mfzL6hldQVdyNWw==} hasBin: true type-check@0.4.0: @@ -4656,20 +4699,20 @@ packages: yaml: optional: true - vitest@5.0.1: - resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==} + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 5.0.1 - '@vitest/browser-preview': 5.0.1 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 - '@vitest/coverage-istanbul': 5.0.1 - '@vitest/coverage-v8': 5.0.1 - '@vitest/ui': 5.0.1 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 happy-dom: '*' jsdom: '*' vite: ^7.3.5 @@ -4740,9 +4783,9 @@ packages: engines: {node: '>= 8'} hasBin: true - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} hasBin: true word-wrap@1.2.5: @@ -4769,7 +4812,7 @@ packages: optional: true xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: - resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} + resolution: {tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} version: 0.20.3 engines: {node: '>=0.8'} hasBin: true @@ -5338,8 +5381,6 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': @@ -6126,22 +6167,22 @@ snapshots: dependencies: typescript: 7.0.2 - '@turbo/darwin-64@2.11.3': + '@turbo/darwin-64@2.11.7': optional: true - '@turbo/darwin-arm64@2.11.3': + '@turbo/darwin-arm64@2.11.7': optional: true - '@turbo/linux-64@2.11.3': + '@turbo/linux-64@2.11.7': optional: true - '@turbo/linux-arm64@2.11.3': + '@turbo/linux-arm64@2.11.7': optional: true - '@turbo/windows-64@2.11.3': + '@turbo/windows-64@2.11.7': optional: true - '@turbo/windows-arm64@2.11.3': + '@turbo/windows-arm64@2.11.7': optional: true '@tweenjs/tween.js@23.1.3': {} @@ -6224,7 +6265,7 @@ snapshots: '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) '@typescript-eslint/visitor-keys': 8.70.1 eslint: 10.11.0(jiti@2.7.0) - ignore: 7.0.10 + ignore: 7.0.12 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 @@ -6240,7 +6281,39 @@ snapshots: '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) '@typescript-eslint/visitor-keys': 8.70.1 eslint: 10.11.0(jiti@2.7.0) - ignore: 7.0.10 + ignore: 7.0.12 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@7.0.2) + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/type-utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) + '@typescript-eslint/visitor-keys': 8.71.0 + eslint: 10.11.0(jiti@2.7.0) + ignore: 7.0.12 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/type-utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) + '@typescript-eslint/visitor-keys': 8.71.0 + eslint: 10.11.0(jiti@2.7.0) + ignore: 7.0.12 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@7.0.2) typescript: 7.0.2 @@ -6274,7 +6347,7 @@ snapshots: '@typescript-eslint/project-service@8.70.1(typescript@6.0.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@6.0.2) - '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/types': 8.71.0 debug: 4.4.3 typescript: 6.0.2 transitivePeerDependencies: @@ -6283,7 +6356,25 @@ snapshots: '@typescript-eslint/project-service@8.70.1(typescript@7.0.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@7.0.2) - '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/types': 8.71.0 + debug: 4.4.3 + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.71.0(typescript@6.0.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.2) + '@typescript-eslint/types': 8.71.0 + debug: 4.4.3 + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.71.0(typescript@7.0.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@7.0.2) + '@typescript-eslint/types': 8.71.0 debug: 4.4.3 typescript: 7.0.2 transitivePeerDependencies: @@ -6294,6 +6385,11 @@ snapshots: '@typescript-eslint/types': 8.70.1 '@typescript-eslint/visitor-keys': 8.70.1 + '@typescript-eslint/scope-manager@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/tsconfig-utils@8.70.1(typescript@6.0.2)': dependencies: typescript: 6.0.2 @@ -6302,6 +6398,14 @@ snapshots: dependencies: typescript: 7.0.2 + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@6.0.2)': + dependencies: + typescript: 6.0.2 + + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@7.0.2)': + dependencies: + typescript: 7.0.2 + '@typescript-eslint/type-utils@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)': dependencies: '@typescript-eslint/types': 8.70.1 @@ -6326,8 +6430,34 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@6.0.2) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) + debug: 4.4.3 + eslint: 10.11.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2)': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@7.0.2) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) + debug: 4.4.3 + eslint: 10.11.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@7.0.2) + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.70.1': {} + '@typescript-eslint/types@8.71.0': {} + '@typescript-eslint/typescript-estree@8.70.1(typescript@6.0.2)': dependencies: '@typescript-eslint/project-service': 8.70.1(typescript@6.0.2) @@ -6358,6 +6488,36 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.71.0(typescript@6.0.2)': + dependencies: + '@typescript-eslint/project-service': 8.71.0(typescript@6.0.2) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.2) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.71.0(typescript@7.0.2)': + dependencies: + '@typescript-eslint/project-service': 8.71.0(typescript@7.0.2) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@7.0.2) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@7.0.2) + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) @@ -6380,11 +6540,38 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@6.0.2) + eslint: 10.11.0(jiti@2.7.0) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@7.0.2) + eslint: 10.11.0(jiti@2.7.0) + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.70.1': dependencies: '@typescript-eslint/types': 8.70.1 eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + eslint-visitor-keys: 5.0.1 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -6445,16 +6632,16 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitest/mocker@5.0.1(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15))': + '@vitest/mocker@5.0.3(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15))': dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.1 + '@vitest/spy': 5.0.3 estree-walker: 3.0.3 - magic-string: 1.4.2 + magic-string: 1.4.3 optionalDependencies: vite: 7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) - '@vitest/spy@5.0.1': {} + '@vitest/spy@5.0.3': {} '@yudiel/react-qr-scanner@2.6.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: @@ -6728,7 +6915,7 @@ snapshots: caniuse-lite@1.0.30001812: {} - chai@6.2.2: {} + chai@6.3.0: {} chart.js@4.5.1: dependencies: @@ -6892,7 +7079,7 @@ snapshots: dotenv@16.0.3: {} - dotenv@18.0.3: {} + dotenv@18.0.5: {} drizzle-kit@0.31.11: dependencies: @@ -7191,11 +7378,11 @@ snapshots: string.prototype.matchall: 4.1.0 string.prototype.repeat: 1.0.0 - eslint-plugin-turbo@2.11.3(eslint@10.11.0(jiti@2.7.0))(turbo@2.11.3): + eslint-plugin-turbo@2.11.3(eslint@10.11.0(jiti@2.7.0))(turbo@2.11.7): dependencies: dotenv: 16.0.3 eslint: 10.11.0(jiti@2.7.0) - turbo: 2.11.3 + turbo: 2.11.7 eslint-scope@9.1.2: dependencies: @@ -7566,7 +7753,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.10: {} + ignore@7.0.12: {} image-size@2.0.4: {} @@ -7851,9 +8038,9 @@ snapshots: magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - magic-string@1.4.2: + magic-string@1.4.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -8474,8 +8661,6 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - siginfo@2.0.0: {} - sirv@2.0.4: dependencies: '@polka/url': 1.0.0-next.29 @@ -8493,9 +8678,7 @@ snapshots: split2@4.2.0: {} - stackback@0.0.2: {} - - std-env@4.2.0: {} + std-env@4.3.0: {} stop-iteration-iterator@1.1.0: dependencies: @@ -8653,9 +8836,9 @@ snapshots: transitivePeerDependencies: - react-native-b4a - tinybench@6.1.4: {} + tinybench@6.2.0: {} - tinyexec@1.3.0: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: dependencies: @@ -8695,14 +8878,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo@2.11.3: + turbo@2.11.7: optionalDependencies: - '@turbo/darwin-64': 2.11.3 - '@turbo/darwin-arm64': 2.11.3 - '@turbo/linux-64': 2.11.3 - '@turbo/linux-arm64': 2.11.3 - '@turbo/windows-64': 2.11.3 - '@turbo/windows-arm64': 2.11.3 + '@turbo/darwin-64': 2.11.7 + '@turbo/darwin-arm64': 2.11.7 + '@turbo/linux-64': 2.11.7 + '@turbo/linux-arm64': 2.11.7 + '@turbo/windows-64': 2.11.7 + '@turbo/windows-arm64': 2.11.7 type-check@0.4.0: dependencies: @@ -8845,22 +9028,22 @@ snapshots: lightningcss: 1.32.0 tsx: 4.23.15 - vitest@5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)): + vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.1(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) - chai: 6.2.2 + '@vitest/mocker': 5.0.3(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 - magic-string: 1.4.2 + magic-string: 1.4.3 obug: 2.2.1 picomatch: 4.0.7 - std-env: 4.2.0 - tinybench: 6.1.4 - tinyexec: 1.3.0 + std-env: 4.3.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 tinyglobby: 0.2.17 vite: 7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) - why-is-node-running: 2.3.0 + why-is-node-running: 3.2.1 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 22.20.4 @@ -8946,10 +9129,7 @@ snapshots: dependencies: isexe: 2.0.0 - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 + why-is-node-running@3.2.1: {} word-wrap@1.2.5: {} diff --git a/sites/mainweb/app/(portal)/admin/analytics/page.tsx b/sites/mainweb/app/(portal)/admin/analytics/page.tsx index 13b57a95..ea777b3d 100644 --- a/sites/mainweb/app/(portal)/admin/analytics/page.tsx +++ b/sites/mainweb/app/(portal)/admin/analytics/page.tsx @@ -12,6 +12,7 @@ import { itemTitle, label, meta, + body, pageDek, sectionTitle, } from "@/components/portal/ui"; @@ -69,6 +70,13 @@ function readTokens(element: HTMLElement): ChartTokens { }; } +function phaseLabel(phase: string) { + if (phase === "open") return "Registration open"; + if (phase === "closed") return "Registration closed"; + if (phase === "in_progress") return "In progress"; + return phase; +} + /** `2026-fall` is how it is stored; nobody should have to read it that way. */ function termLabel(term: string) { const [year, season] = term.split("-"); @@ -145,6 +153,11 @@ export default function AnalyticsPage() { { enabled: !!session, refetchInterval: 15000 }, ); + const live = trpc.admin.hackathonLive.useQuery(undefined, { + enabled: !!session, + refetchInterval: 15000, + }); + // Growth moves on a monthly clock, so it is fetched once rather than polled. const growth = trpc.admin.growth.useQuery(undefined, { enabled: !!session }); @@ -475,15 +488,15 @@ export default function AnalyticsPage() { {/* Events and hackathons */}

Events and hackathons

-
+
{isLoading ? ( - [1, 2, 3, 4].map((i) => ) + [1, 2, 3, 4, 5].map((i) => ) ) : ( <> + )}
+ +
+

Live hackathon

+

+ Editions that are open, registration-closed, or in progress. These + are the same counts the metrics endpoint publishes. +

+ {live.isPending ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ ) : !live.data?.length ? ( +

+ No edition is open, registration-closed, or in progress. +

+ ) : ( + live.data.map((edition) => { + const judgingIdle = + edition.judgingActive && + edition.judgingProjects > 0 && + edition.votes === 0; + return ( +
+

{edition.edition}

+

{phaseLabel(edition.phase)}

+ {judgingIdle && ( +

+ Judging is on and no scores have landed. +

+ )} +
+ + + + + + + +
+

+ Waitlisted {edition.participants.waitlisted} · Rejected{" "} + {edition.participants.rejected} +

+
+ ); + }) + )} +
); } diff --git a/sites/mainweb/package.json b/sites/mainweb/package.json index 6b16eb4a..16866b9a 100644 --- a/sites/mainweb/package.json +++ b/sites/mainweb/package.json @@ -65,7 +65,7 @@ "eslint": "10.11.0", "postcss": "^8.5.28", "tailwindcss": "4.3.3", - "turbo": "^2.11.3", + "turbo": "^2.11.7", "typescript": "^7.0.2", "typescript-eslint": "^8.70.1" } diff --git a/tooling/eslint/package.json b/tooling/eslint/package.json index 97139cf6..4c94254d 100644 --- a/tooling/eslint/package.json +++ b/tooling/eslint/package.json @@ -19,7 +19,7 @@ "@eslint/compat": "2.1.1", "@eslint/js": "10.0.1", "@next/eslint-plugin-next": "16.3.7", - "@typescript-eslint/eslint-plugin": "8.70.1", + "@typescript-eslint/eslint-plugin": "8.71.0", "@typescript-eslint/parser": "8.70.1", "eslint-config-prettier": "10.1.8", "eslint-plugin-import": "2.32.0",