From 514df6b68ecadd6b01e1b312419da59db592c1b0 Mon Sep 17 00:00:00 2001 From: Jay Stobie Date: Wed, 25 Mar 2026 19:55:47 -0500 Subject: [PATCH 1/5] added upcoming achievements to region --- src/app/stats/region/[regionId]/loader.ts | 2 + src/app/stats/region/[regionId]/page.tsx | 1 + src/components/region/AchievementsCard.tsx | 336 +++++++++++++++++++++ src/components/region/PageWrapper.tsx | 25 +- src/lib/bq/regions.ts | 112 +++++++ src/lib/types.ts | 19 ++ 6 files changed, 488 insertions(+), 7 deletions(-) create mode 100644 src/components/region/AchievementsCard.tsx diff --git a/src/app/stats/region/[regionId]/loader.ts b/src/app/stats/region/[regionId]/loader.ts index f362963..e6f4986 100644 --- a/src/app/stats/region/[regionId]/loader.ts +++ b/src/app/stats/region/[regionId]/loader.ts @@ -16,6 +16,7 @@ import { Leaders, RegionKotterList, ChartData, + RegionAchievementPax, } from "@/lib/types"; import { getPageData } from "@/lib/bq/regions"; @@ -72,6 +73,7 @@ export async function loadRegionData( upcoming: (mergedPlain.upcoming ?? []) as EventUpcoming[], kotter: (mergedPlain.kotter ?? []) as RegionKotterList[], charts: (mergedPlain.charts ?? []) as ChartData[], + achievements: (mergedPlain.achievements ?? []) as RegionAchievementPax[], }; mergedSafe.events = (mergedSafe.events ?? []).map((e: EventData) => ({ diff --git a/src/app/stats/region/[regionId]/page.tsx b/src/app/stats/region/[regionId]/page.tsx index 18f74d7..45637df 100644 --- a/src/app/stats/region/[regionId]/page.tsx +++ b/src/app/stats/region/[regionId]/page.tsx @@ -185,6 +185,7 @@ export default async function RegionDetailPage({ region_upcoming={regionData.upcoming || []} region_events={regionData.events || []} region_charts={regionData.charts || []} + region_achievements={regionData.achievements || []} searchParams={{ categoryIds, categoryMode, diff --git a/src/components/region/AchievementsCard.tsx b/src/components/region/AchievementsCard.tsx new file mode 100644 index 0000000..6cef7ea --- /dev/null +++ b/src/components/region/AchievementsCard.tsx @@ -0,0 +1,336 @@ +"use client"; + +/** + * AchievementsCard + * + * Displays upcoming PAX milestones for regional leadership: approaching + * post/Q counts and FNG anniversaries within the next 14 days. + * + * - Scope toggle (Region / Nation) adjusts which post and Q counts are + * used for milestone proximity. Anniversaries are scope-independent. + * - Type filter tabs (All / Posts / Qs / Anniversaries) narrow the list. + */ + +import { useMemo, useState } from "react"; +import { Avatar } from "@heroui/avatar"; +import { Card, CardBody, CardHeader } from "@heroui/card"; +import { Chip } from "@heroui/chip"; +import { Divider } from "@heroui/divider"; +import { ScrollShadow } from "@heroui/scroll-shadow"; +import { Link } from "@heroui/link"; +import { Tabs, Tab } from "@heroui/tabs"; +import { + Dropdown, + DropdownTrigger, + DropdownMenu, + DropdownItem, +} from "@heroui/dropdown"; +import { Button } from "@heroui/button"; +import { RegionAchievementPax } from "@/lib/types"; +import { formatDate } from "@/lib/utils"; + +type AchievementsCardProps = { + achievements: RegionAchievementPax[]; + filters?: string; +}; + +type AchievementScope = "region" | "nation"; +type AchievementType = "all" | "posts" | "qs" | "anniversaries"; + +/** A single computed achievement row shown in the card. */ +interface AchievementRow { + user_id: number; + f3_name: string; + avatar_url?: string; + type: "posts" | "qs" | "anniversary"; + milestone: number; + current: number; + remaining: number | null; // null for anniversary rows + anniversary_date: string | null; + days_until: number | null; + /** Lower = more urgent; used for sorting */ + urgency: number; +} + +const POST_THRESHOLD = 5; +const Q_THRESHOLD = 3; +const ANNIVERSARY_DAYS = 14; + +/** Derive achievement rows from raw PAX data for the given scope. */ +function computeAchievements( + achievements: RegionAchievementPax[], + scope: AchievementScope, +): AchievementRow[] { + const rows: AchievementRow[] = []; + + for (const pax of achievements) { + const posts = scope === "nation" ? pax.all_posts : pax.region_posts; + const qs = scope === "nation" ? pax.all_qs : pax.region_qs; + const nextPostMilestone = + scope === "nation" + ? pax.next_nation_post_milestone + : pax.next_region_post_milestone; + const nextQMilestone = + scope === "nation" + ? pax.next_nation_q_milestone + : pax.next_region_q_milestone; + + // Post milestone + if ( + nextPostMilestone !== null && + nextPostMilestone - posts <= POST_THRESHOLD + ) { + const remaining = nextPostMilestone - posts; + rows.push({ + user_id: pax.user_id, + f3_name: pax.f3_name, + avatar_url: pax.avatar_url, + type: "posts", + milestone: nextPostMilestone, + current: posts, + remaining, + anniversary_date: null, + days_until: null, + urgency: remaining, + }); + } + + // Q milestone + if (nextQMilestone !== null && nextQMilestone - qs <= Q_THRESHOLD) { + const remaining = nextQMilestone - qs; + rows.push({ + user_id: pax.user_id, + f3_name: pax.f3_name, + avatar_url: pax.avatar_url, + type: "qs", + milestone: nextQMilestone, + current: qs, + remaining, + anniversary_date: null, + days_until: null, + urgency: remaining, + }); + } + + // Anniversary (scope-independent; shown exactly once per PAX regardless of scope) + if ( + pax.next_anniversary_date && + pax.days_until_anniversary !== null && + pax.days_until_anniversary >= 0 && + pax.days_until_anniversary <= ANNIVERSARY_DAYS + ) { + rows.push({ + user_id: pax.user_id, + f3_name: pax.f3_name, + avatar_url: pax.avatar_url, + type: "anniversary", + milestone: 0, // milestone_value not needed for display; year computed below + current: 0, + remaining: null, + anniversary_date: pax.next_anniversary_date, + days_until: pax.days_until_anniversary, + urgency: pax.days_until_anniversary, + }); + } + } + + return rows; +} + +/** Chip color and label by achievement type. */ +function typeChip(type: AchievementRow["type"]) { + if (type === "posts") + return ( + + Posts + + ); + if (type === "qs") + return ( + + Qs + + ); + return ( + + Anniversary + + ); +} + +/** Human-readable description of a row's milestone. */ +function milestoneDescription(row: AchievementRow): string { + if (row.type === "anniversary" && row.anniversary_date) { + const dateStr = formatDate(row.anniversary_date, "M D Y"); + const daysStr = + row.days_until === 0 + ? "today!" + : row.days_until === 1 + ? "tomorrow" + : `in ${row.days_until} days`; + + const year = row.current > 0 ? row.current : undefined; + const yearLabel = + year !== undefined ? `${year}-year anniversary` : `FNG anniversary`; + + return `${yearLabel} — ${dateStr} (${daysStr})`; + } + + if (row.remaining === 1) { + return `1 ${row.type === "posts" ? "post" : "Q"} away from ${row.milestone}`; + } + return `${row.remaining} ${row.type === "posts" ? "posts" : "Qs"} away from ${row.milestone}`; +} + +export function AchievementsCard({ + achievements, + filters, +}: AchievementsCardProps) { + const [scope, setScope] = useState("region"); + const [typeFilter, setTypeFilter] = useState("all"); + + const allRows = useMemo( + () => computeAchievements(achievements, scope), + [achievements, scope], + ); + + const visibleRows = useMemo(() => { + const filtered = + typeFilter === "all" + ? allRows + : typeFilter === "anniversaries" + ? allRows.filter((r) => r.type === "anniversary") + : allRows.filter((r) => r.type === typeFilter); + + // Anniversaries first (by days_until ASC), then count milestones (by remaining ASC), then name + return [...filtered].sort((a, b) => { + const aIsAnniv = a.type === "anniversary" ? 0 : 1; + const bIsAnniv = b.type === "anniversary" ? 0 : 1; + if (aIsAnniv !== bIsAnniv) return aIsAnniv - bIsAnniv; + if (a.urgency !== b.urgency) return a.urgency - b.urgency; + return a.f3_name.localeCompare(b.f3_name); + }); + }, [allRows, typeFilter]); + + // Compute year for anniversary rows: use fng_date from achievements + // We annotate anniversary rows with the year at display time. + const anniversaryYears = useMemo(() => { + const map = new Map(); + for (const pax of achievements) { + if (pax.fng_date && pax.next_anniversary_date) { + const fng = new Date(pax.fng_date); + const anniv = new Date(pax.next_anniversary_date); + const year = anniv.getUTCFullYear() - fng.getUTCFullYear(); + map.set(pax.user_id, year); + } + } + return map; + }, [achievements]); + + return ( + + +
+
Achievements
+
+ setScope(key as AchievementScope)} + size="sm" + radius="sm" + variant="bordered" + color="secondary" + > + + + + + + + + { + const first = Array.from(keys as Set)[0]; + setTypeFilter((first ?? "all") as AchievementType); + }} + > + All + Posts + Qs + Anniversaries + + +
+
+
+ + + +
+ {visibleRows.length === 0 ? ( +
+ No upcoming achievements for this view. +
+ ) : ( + visibleRows.map((row, idx) => { + // For anniversary rows, replace the computed `current` with the actual year + const displayRow = + row.type === "anniversary" + ? { + ...row, + current: + anniversaryYears.get(row.user_id) ?? row.current, + } + : row; + + return ( +
+
+ +
+ + {row.f3_name ?? row.user_id.toString()} + + + {milestoneDescription(displayRow)} + +
+
+
+ {typeChip(row.type)} +
+
+ ); + }) + )} +
+
+
+
+ ); +} diff --git a/src/components/region/PageWrapper.tsx b/src/components/region/PageWrapper.tsx index e4874aa..d375cdc 100644 --- a/src/components/region/PageWrapper.tsx +++ b/src/components/region/PageWrapper.tsx @@ -18,6 +18,7 @@ import { EventUpcoming, RegionInfo, ChartData, + RegionAchievementPax, } from "@/lib/types"; import { SummaryCard } from "./SummaryCard"; import { LeadersCard } from "../leaders"; @@ -27,6 +28,7 @@ import { EventsCard } from "../events"; import { Filter } from "../pageFilter"; import { useMemo } from "react"; import { ChartCard } from "./ChartsCard"; +import { AchievementsCard } from "./AchievementsCard"; type RegionalPageWrapperProps = { region_id: number; @@ -37,6 +39,7 @@ type RegionalPageWrapperProps = { region_upcoming: EventUpcoming[] | null; region_events: EventData[]; region_charts: ChartData[]; + region_achievements: RegionAchievementPax[]; searchParams: { categoryIds?: string | string[]; categoryMode?: string; @@ -100,6 +103,7 @@ export function RegionalPageWrapper({ region_upcoming, region_events, region_charts, + region_achievements, searchParams, }: RegionalPageWrapperProps) { // Memoized query-string passed to events + filter components. @@ -139,16 +143,23 @@ export function RegionalPageWrapper({
- {/* Kotters + upcoming events */} + {/* Upcoming achievements */}
- - + {/* Kotters + upcoming events */} +
+ + +
{/* Event list */}
diff --git a/src/lib/bq/regions.ts b/src/lib/bq/regions.ts index 5409e8d..d107626 100644 --- a/src/lib/bq/regions.ts +++ b/src/lib/bq/regions.ts @@ -7,6 +7,7 @@ import { EventUpcoming, RegionKotterList, ChartData, + RegionAchievementPax, } from "@/lib/types"; /** @@ -352,6 +353,7 @@ export async function getPageData( unique_q_count: number; }[] | null; + achievements: RegionAchievementPax[] | null; }> { // Build WHERE clause from common filters (region-scoped). const whereSql = buildEventsWhereSql(regionId, opts); @@ -453,6 +455,85 @@ export async function getPageData( ON li.user_id = a.user_id WHERE a.user_id IS NOT NULL${eventsFilterAndSql} GROUP BY a.user_id + ), + + -- ── Achievements ──────────────────────────────────────────────────────── + -- Milestone thresholds shared across posts and Qs + achievement_thresholds AS ( + SELECT t FROM UNNEST([25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000]) AS t + ), + + -- All home-region PAX from pv_pax (source of truth for membership + FNG override) + achievement_pax_base AS ( + SELECT user_id, f3_name, avatar_url, start_date_override + FROM pv_pax + WHERE home_region_id = ${regionId} + ), + + -- Single pass over pv_events for all four count dimensions + first_event_date + -- (all-time, no date filter so milestones reflect career totals) + achievement_event_counts AS ( + SELECT + a.user_id, + COUNT(DISTINCT IF(e.region_org_id = ${regionId}, e.event_id, NULL)) AS region_posts, + COUNT(DISTINCT IF(e.region_org_id = ${regionId} AND a.q_ind = 1, e.event_id, NULL)) AS region_qs, + COUNT(DISTINCT e.event_id) AS all_posts, + COUNT(DISTINCT IF(a.q_ind = 1, e.event_id, NULL)) AS all_qs, + MIN(e.event_date) AS first_event_date + FROM pv_events e + JOIN UNNEST(e.attendance) a ON TRUE + JOIN achievement_pax_base pb ON pb.user_id = a.user_id + GROUP BY a.user_id + ), + + -- Combine pv_pax fields with computed counts; resolve fng_date using override. + -- Mirrors pax.ts: IFNULL(CAST(start_date_override AS STRING), CAST(first_event_date AS STRING)) + -- fng_date is kept as STRING so DATE(fng_date) can be used for anniversary math. + achievement_pax AS ( + SELECT + pb.user_id, + pb.f3_name, + pb.avatar_url, + COALESCE(ec.region_posts, 0) AS region_posts, + COALESCE(ec.region_qs, 0) AS region_qs, + COALESCE(ec.all_posts, 0) AS all_posts, + COALESCE(ec.all_qs, 0) AS all_qs, + IFNULL( + CAST(pb.start_date_override AS STRING), + CAST(ec.first_event_date AS STRING) + ) AS fng_date + FROM achievement_pax_base pb + LEFT JOIN achievement_event_counts ec ON ec.user_id = pb.user_id + ), + + -- Cross with thresholds to find next milestone per count dimension. + -- Also computes next_anniversary_date correctly: + -- use this year's month/day; if already past, use next year. + -- This avoids DATE_DIFF(YEAR) which counts calendar boundaries, not completed anniversaries. + achievement_pax_milestones AS ( + SELECT + p.user_id, p.f3_name, p.avatar_url, + p.region_posts, p.region_qs, p.all_posts, p.all_qs, p.fng_date, + MIN(CASE WHEN t.t > p.region_posts THEN t.t END) AS next_region_post_milestone, + MIN(CASE WHEN t.t > p.all_posts THEN t.t END) AS next_nation_post_milestone, + MIN(CASE WHEN t.t > p.region_qs THEN t.t END) AS next_region_q_milestone, + MIN(CASE WHEN t.t > p.all_qs THEN t.t END) AS next_nation_q_milestone, + -- Use DATE_ADD (not EXTRACT) so leap-year FNG dates (Feb 29) roll safely to Feb 28. + -- year_diff = calendar years between FNG year and today's year. + -- "this year's anniversary" = DATE_ADD(fng_date, INTERVAL year_diff YEAR). + -- If that date has already passed, add one more year. + IF(p.fng_date IS NOT NULL, + CASE + WHEN DATE_ADD(DATE(p.fng_date), INTERVAL (EXTRACT(YEAR FROM CURRENT_DATE()) - EXTRACT(YEAR FROM DATE(p.fng_date))) YEAR) >= CURRENT_DATE() + THEN DATE_ADD(DATE(p.fng_date), INTERVAL (EXTRACT(YEAR FROM CURRENT_DATE()) - EXTRACT(YEAR FROM DATE(p.fng_date))) YEAR) + ELSE DATE_ADD(DATE(p.fng_date), INTERVAL (EXTRACT(YEAR FROM CURRENT_DATE()) - EXTRACT(YEAR FROM DATE(p.fng_date)) + 1) YEAR) + END, + NULL + ) AS next_anniversary_date + FROM achievement_pax p + CROSS JOIN achievement_thresholds t + GROUP BY p.user_id, p.f3_name, p.avatar_url, + p.region_posts, p.region_qs, p.all_posts, p.all_qs, p.fng_date ) SELECT -- Region info as a STRUCT @@ -587,6 +668,35 @@ export async function getPageData( WHERE home_region_id = ${regionId} ) AS kotter, + -- Achievements: PAX approaching post/Q milestones or with upcoming anniversaries + ( + SELECT ARRAY_AGG( + STRUCT( + user_id, f3_name, avatar_url, + region_posts, region_qs, all_posts, all_qs, + next_region_post_milestone, + next_nation_post_milestone, + next_region_q_milestone, + next_nation_q_milestone, + fng_date, + CAST(next_anniversary_date AS STRING) AS next_anniversary_date, + IF(next_anniversary_date IS NOT NULL, + DATE_DIFF(next_anniversary_date, CURRENT_DATE(), DAY), + NULL + ) AS days_until_anniversary + ) + ORDER BY f3_name ASC + ) + FROM achievement_pax_milestones + WHERE + ((next_region_post_milestone IS NOT NULL AND next_region_post_milestone - region_posts <= 5) + OR (next_nation_post_milestone IS NOT NULL AND next_nation_post_milestone - all_posts <= 5) + OR (next_region_q_milestone IS NOT NULL AND next_region_q_milestone - region_qs <= 3) + OR (next_nation_q_milestone IS NOT NULL AND next_nation_q_milestone - all_qs <= 3) + OR (next_anniversary_date IS NOT NULL AND DATE_DIFF(next_anniversary_date, CURRENT_DATE(), DAY) BETWEEN 0 AND 14)) + AND (region_posts > 10) -- Filter out very new PAX with no significant activity (adjust threshold as needed) + ) AS achievements, + -- Charting: ${chartGranularity} aggregation with gap-filling ( WITH @@ -653,6 +763,7 @@ export async function getPageData( upcoming: EventUpcoming[]; kotter: RegionKotterList[]; charts: ChartData[]; + achievements: RegionAchievementPax[]; }>(query, userIdentifier, `fetch region data for region ${regionId}`); return { @@ -663,5 +774,6 @@ export async function getPageData( upcoming: results?.[0]?.upcoming || null, kotter: results?.[0]?.kotter || null, charts: results?.[0]?.charts || null, + achievements: results?.[0]?.achievements || null, }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 1cd3b88..d6c0c88 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -113,6 +113,7 @@ export interface RegionData { upcoming: EventUpcoming[] | null; // List of upcoming events in the region kotter: RegionKotterList[] | null; // List of Kotter events in the region charts: ChartData[] | null; // List of chart data points for the region + achievements: RegionAchievementPax[] | null; // PAX approaching milestones or with upcoming anniversaries } /* USED ONLY FOR REGION INFO */ @@ -138,6 +139,24 @@ export interface RegionSummary { pax_count_average: number; // Average number of participants (pax) per event in the region } +/* USED FOR REGION UPCOMING ACHIEVEMENTS */ +export interface RegionAchievementPax { + user_id: number; // Unique identifier for the user + f3_name: string; // F3 name (nickname) of the user + avatar_url?: string; // Optional URL to the user's avatar image + region_posts: number; // Total posts (events attended) in this region + region_qs: number; // Total Qs led in this region + all_posts: number; // Total posts across all regions + all_qs: number; // Total Qs across all regions + next_region_post_milestone: number | null; // Next post milestone in this region + next_nation_post_milestone: number | null; // Next post milestone across all regions + next_region_q_milestone: number | null; // Next Q milestone in this region + next_nation_q_milestone: number | null; // Next Q milestone across all regions + fng_date: string | null; // FNG/first-event date (ISO string) + next_anniversary_date: string | null; // Date of next FNG anniversary (ISO string) + days_until_anniversary: number | null; // Days until the next FNG anniversary +} + /* USED ONLY FOR REGION KOTTER LISTING */ export interface RegionKotterList { user_id: number; // Unique identifier for the user From 58de183efbbcd6b4ed37563e6c31dde947daf2a6 Mon Sep 17 00:00:00 2001 From: Jay Stobie Date: Mon, 30 Mar 2026 16:30:02 -0500 Subject: [PATCH 2/5] added Achievements to region pages --- src/components/region/AchievementsCard.tsx | 127 ++++++++++++++++----- src/lib/bq/regions.ts | 37 ++++-- src/lib/types.ts | 1 + 3 files changed, 124 insertions(+), 41 deletions(-) diff --git a/src/components/region/AchievementsCard.tsx b/src/components/region/AchievementsCard.tsx index 6cef7ea..fdd2e20 100644 --- a/src/components/region/AchievementsCard.tsx +++ b/src/components/region/AchievementsCard.tsx @@ -50,6 +50,10 @@ interface AchievementRow { days_until: number | null; /** Lower = more urgent; used for sorting */ urgency: number; + /** Scope-based post count — used for Q and anniversary rows */ + total_posts?: number; + /** FNG date string — used for post rows to compute longevity */ + fng_date?: string; } const POST_THRESHOLD = 5; @@ -75,49 +79,63 @@ function computeAchievements( ? pax.next_nation_q_milestone : pax.next_region_q_milestone; + const recentlyActive = + pax.last_region_event_date !== null && + (new Date().getTime() - new Date(pax.last_region_event_date).getTime()) / + (1000 * 60 * 60 * 24) <= + 30; + // Post milestone if ( nextPostMilestone !== null && nextPostMilestone - posts <= POST_THRESHOLD ) { const remaining = nextPostMilestone - posts; - rows.push({ - user_id: pax.user_id, - f3_name: pax.f3_name, - avatar_url: pax.avatar_url, - type: "posts", - milestone: nextPostMilestone, - current: posts, - remaining, - anniversary_date: null, - days_until: null, - urgency: remaining, - }); + if (remaining <= 1 || recentlyActive) { + rows.push({ + user_id: pax.user_id, + f3_name: pax.f3_name, + avatar_url: pax.avatar_url, + type: "posts", + milestone: nextPostMilestone, + current: posts, + remaining, + anniversary_date: null, + days_until: null, + urgency: remaining, + fng_date: pax.fng_date ?? undefined, + }); + } } // Q milestone if (nextQMilestone !== null && nextQMilestone - qs <= Q_THRESHOLD) { const remaining = nextQMilestone - qs; - rows.push({ - user_id: pax.user_id, - f3_name: pax.f3_name, - avatar_url: pax.avatar_url, - type: "qs", - milestone: nextQMilestone, - current: qs, - remaining, - anniversary_date: null, - days_until: null, - urgency: remaining, - }); + if (remaining <= 1 || recentlyActive) { + rows.push({ + user_id: pax.user_id, + f3_name: pax.f3_name, + avatar_url: pax.avatar_url, + type: "qs", + milestone: nextQMilestone, + current: qs, + remaining, + anniversary_date: null, + days_until: null, + urgency: remaining, + total_posts: posts, + }); + } } // Anniversary (scope-independent; shown exactly once per PAX regardless of scope) + // Require at least 25 region posts so very new PAX don't trigger anniversary notifications. if ( pax.next_anniversary_date && pax.days_until_anniversary !== null && pax.days_until_anniversary >= 0 && - pax.days_until_anniversary <= ANNIVERSARY_DAYS + pax.days_until_anniversary <= ANNIVERSARY_DAYS && + pax.region_posts >= 25 ) { rows.push({ user_id: pax.user_id, @@ -130,6 +148,7 @@ function computeAchievements( anniversary_date: pax.next_anniversary_date, days_until: pax.days_until_anniversary, urgency: pax.days_until_anniversary, + total_posts: posts, }); } } @@ -137,6 +156,33 @@ function computeAchievements( return rows; } +/** Format PAX longevity from fng_date to today as "X weeks", "X months", or "X years, Y months". */ +function formatLongevity(fngDate: string): string { + const fng = new Date(fngDate); + const now = new Date(); + const totalDays = Math.floor( + (now.getTime() - fng.getTime()) / (1000 * 60 * 60 * 24), + ); + const totalWeeks = Math.floor(totalDays / 7); + + if (totalWeeks < 8) { + return `${totalWeeks} week${totalWeeks !== 1 ? "s" : ""}`; + } + + const years = Math.floor(totalDays / 365.25); + const remainingMonths = Math.round(((totalDays % 365.25) / 365.25) * 12); + + if (years < 1) { + const totalMonths = Math.round(totalDays / 30.44); + return `${totalMonths} month${totalMonths !== 1 ? "s" : ""}`; + } + + if (remainingMonths === 0) { + return `${years} year${years !== 1 ? "s" : ""}`; + } + return `${years} year${years !== 1 ? "s" : ""}, ${remainingMonths} month${remainingMonths !== 1 ? "s" : ""}`; +} + /** Chip color and label by achievement type. */ function typeChip(type: AchievementRow["type"]) { if (type === "posts") @@ -170,16 +216,35 @@ function milestoneDescription(row: AchievementRow): string { : `in ${row.days_until} days`; const year = row.current > 0 ? row.current : undefined; - const yearLabel = - year !== undefined ? `${year}-year anniversary` : `FNG anniversary`; + const yearLabel = year !== undefined ? `${year}-years` : `FNG anniversary`; + + const eventsStr = + row.total_posts !== undefined ? ` · ${row.total_posts} events` : ""; - return `${yearLabel} — ${dateStr} (${daysStr})`; + return `${yearLabel} — ${dateStr} (${daysStr})${eventsStr}`; + } + + if (row.type === "qs") { + const base = + row.remaining === 1 + ? `1 Q away from ${row.milestone}` + : `${row.remaining} Qs away from ${row.milestone}`; + if (row.total_posts !== undefined && row.total_posts > 0) { + const qRate = Math.round((row.current / row.total_posts) * 100); + return `${base} · ${row.total_posts} posts, ${qRate}% Q rate`; + } + return base; } - if (row.remaining === 1) { - return `1 ${row.type === "posts" ? "post" : "Q"} away from ${row.milestone}`; + // posts + const base = + row.remaining === 1 + ? `1 post away from ${row.milestone}` + : `${row.remaining} posts away from ${row.milestone}`; + if (row.fng_date) { + return `${base} · PAX for ${formatLongevity(row.fng_date)}`; } - return `${row.remaining} ${row.type === "posts" ? "posts" : "Qs"} away from ${row.milestone}`; + return base; } export function AchievementsCard({ diff --git a/src/lib/bq/regions.ts b/src/lib/bq/regions.ts index d107626..80a0433 100644 --- a/src/lib/bq/regions.ts +++ b/src/lib/bq/regions.ts @@ -479,7 +479,8 @@ export async function getPageData( COUNT(DISTINCT IF(e.region_org_id = ${regionId} AND a.q_ind = 1, e.event_id, NULL)) AS region_qs, COUNT(DISTINCT e.event_id) AS all_posts, COUNT(DISTINCT IF(a.q_ind = 1, e.event_id, NULL)) AS all_qs, - MIN(e.event_date) AS first_event_date + MIN(e.event_date) AS first_event_date, + MAX(IF(e.region_org_id = ${regionId}, e.event_date, NULL)) AS last_region_event_date FROM pv_events e JOIN UNNEST(e.attendance) a ON TRUE JOIN achievement_pax_base pb ON pb.user_id = a.user_id @@ -501,7 +502,8 @@ export async function getPageData( IFNULL( CAST(pb.start_date_override AS STRING), CAST(ec.first_event_date AS STRING) - ) AS fng_date + ) AS fng_date, + ec.last_region_event_date FROM achievement_pax_base pb LEFT JOIN achievement_event_counts ec ON ec.user_id = pb.user_id ), @@ -513,7 +515,7 @@ export async function getPageData( achievement_pax_milestones AS ( SELECT p.user_id, p.f3_name, p.avatar_url, - p.region_posts, p.region_qs, p.all_posts, p.all_qs, p.fng_date, + p.region_posts, p.region_qs, p.all_posts, p.all_qs, p.fng_date, p.last_region_event_date, MIN(CASE WHEN t.t > p.region_posts THEN t.t END) AS next_region_post_milestone, MIN(CASE WHEN t.t > p.all_posts THEN t.t END) AS next_nation_post_milestone, MIN(CASE WHEN t.t > p.region_qs THEN t.t END) AS next_region_q_milestone, @@ -533,7 +535,7 @@ export async function getPageData( FROM achievement_pax p CROSS JOIN achievement_thresholds t GROUP BY p.user_id, p.f3_name, p.avatar_url, - p.region_posts, p.region_qs, p.all_posts, p.all_qs, p.fng_date + p.region_posts, p.region_qs, p.all_posts, p.all_qs, p.fng_date, p.last_region_event_date ) SELECT -- Region info as a STRUCT @@ -679,6 +681,7 @@ export async function getPageData( next_region_q_milestone, next_nation_q_milestone, fng_date, + CAST(last_region_event_date AS STRING) AS last_region_event_date, CAST(next_anniversary_date AS STRING) AS next_anniversary_date, IF(next_anniversary_date IS NOT NULL, DATE_DIFF(next_anniversary_date, CURRENT_DATE(), DAY), @@ -689,12 +692,26 @@ export async function getPageData( ) FROM achievement_pax_milestones WHERE - ((next_region_post_milestone IS NOT NULL AND next_region_post_milestone - region_posts <= 5) - OR (next_nation_post_milestone IS NOT NULL AND next_nation_post_milestone - all_posts <= 5) - OR (next_region_q_milestone IS NOT NULL AND next_region_q_milestone - region_qs <= 3) - OR (next_nation_q_milestone IS NOT NULL AND next_nation_q_milestone - all_qs <= 3) - OR (next_anniversary_date IS NOT NULL AND DATE_DIFF(next_anniversary_date, CURRENT_DATE(), DAY) BETWEEN 0 AND 14)) - AND (region_posts > 10) -- Filter out very new PAX with no significant activity (adjust threshold as needed) + ( + -- 1 away from any milestone — always show regardless of recent activity + (next_region_post_milestone IS NOT NULL AND next_region_post_milestone - region_posts = 1) + OR (next_nation_post_milestone IS NOT NULL AND next_nation_post_milestone - all_posts = 1) + OR (next_region_q_milestone IS NOT NULL AND next_region_q_milestone - region_qs = 1) + OR (next_nation_q_milestone IS NOT NULL AND next_nation_q_milestone - all_qs = 1) + -- Within milestone range and active in the last 30 days + OR ( + last_region_event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY) + AND ( + (next_region_post_milestone IS NOT NULL AND next_region_post_milestone - region_posts <= 5) + OR (next_nation_post_milestone IS NOT NULL AND next_nation_post_milestone - all_posts <= 5) + OR (next_region_q_milestone IS NOT NULL AND next_region_q_milestone - region_qs <= 3) + OR (next_nation_q_milestone IS NOT NULL AND next_nation_q_milestone - all_qs <= 3) + ) + ) + -- Upcoming anniversary (no activity gate, but min 25 region posts) + OR (next_anniversary_date IS NOT NULL AND DATE_DIFF(next_anniversary_date, CURRENT_DATE(), DAY) BETWEEN 0 AND 14 AND region_posts >= 25) + ) + AND (region_posts > 10) -- Filter out very new PAX with no significant activity ) AS achievements, -- Charting: ${chartGranularity} aggregation with gap-filling diff --git a/src/lib/types.ts b/src/lib/types.ts index d6c0c88..98de5b2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -155,6 +155,7 @@ export interface RegionAchievementPax { fng_date: string | null; // FNG/first-event date (ISO string) next_anniversary_date: string | null; // Date of next FNG anniversary (ISO string) days_until_anniversary: number | null; // Days until the next FNG anniversary + last_region_event_date: string | null; // Date of most recent event attended in this region (ISO string) } /* USED ONLY FOR REGION KOTTER LISTING */ From 43912bb18616d2c4c4c1fdc669e01461a9f327c9 Mon Sep 17 00:00:00 2001 From: Jay Stobie Date: Tue, 31 Mar 2026 16:32:30 -0500 Subject: [PATCH 3/5] added search inactive option --- src/app/api/ao/list/route.ts | 3 +- src/app/api/region/list/route.ts | 3 +- src/components/search-modal.tsx | 62 ++++++++++++++++++++++++-------- src/lib/bq/aos.ts | 3 +- src/lib/bq/regions.ts | 3 +- 5 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/app/api/ao/list/route.ts b/src/app/api/ao/list/route.ts index 0d6f324..ccbf06d 100644 --- a/src/app/api/ao/list/route.ts +++ b/src/app/api/ao/list/route.ts @@ -19,6 +19,7 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const rawQuery = searchParams.get("q") || ""; const q = rawQuery.trim(); + const includeInactive = searchParams.get("includeInactive") === "true"; // Guardrail: do not allow overly-broad or empty searches. if (q.length < 2) { @@ -26,7 +27,7 @@ export async function GET(request: Request) { } try { - const aos = await searchAOsByName(q, user.email); + const aos = await searchAOsByName(q, user.email, includeInactive); return NextResponse.json(aos, { status: 200 }); } catch (err) { console.error("AO search failed:", err); diff --git a/src/app/api/region/list/route.ts b/src/app/api/region/list/route.ts index e6283ab..31ae2a0 100644 --- a/src/app/api/region/list/route.ts +++ b/src/app/api/region/list/route.ts @@ -19,6 +19,7 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const rawQuery = searchParams.get("q") || ""; const q = rawQuery.trim(); + const includeInactive = searchParams.get("includeInactive") === "true"; // Guardrail: do not allow overly-broad or empty searches. if (q.length < 2) { @@ -26,7 +27,7 @@ export async function GET(request: Request) { } try { - const regions = await searchRegionsByName(q, user.email); + const regions = await searchRegionsByName(q, user.email, includeInactive); return NextResponse.json(regions, { status: 200 }); } catch (err) { console.error("Region search failed:", err); diff --git a/src/components/search-modal.tsx b/src/components/search-modal.tsx index 5158b0a..ca91c4d 100644 --- a/src/components/search-modal.tsx +++ b/src/components/search-modal.tsx @@ -57,6 +57,7 @@ export default function SearchModal({ }: SearchModalProps) { const router = useRouter(); const [query, setQuery] = useState(""); + const [includeInactive, setIncludeInactive] = useState(false); const [paxResults, setPaxResults] = useState([]); const [regionResults, setRegionResults] = useState([]); const [aoResults, setAoResults] = useState([]); @@ -106,6 +107,7 @@ export default function SearchModal({ useEffect(() => { if (!isOpen) { setQuery(""); + setIncludeInactive(false); setPaxResults([]); setRegionResults([]); setAoResults([]); @@ -140,13 +142,14 @@ export default function SearchModal({ let cancelled = false; const encoded = encodeURIComponent(q); + const inactiveParam = includeInactive ? "&includeInactive=true" : ""; const t = setTimeout(() => { fetchJson(`/api/pax/list?q=${encoded}`) .then((data) => { if (!cancelled) setPaxResults(data); }) - .catch((err) => { + .catch((err: unknown) => { if (!cancelled) setPaxError(err instanceof Error ? err.message : "Search failed"); }) @@ -154,11 +157,11 @@ export default function SearchModal({ if (!cancelled) setPaxLoading(false); }); - fetchJson(`/api/region/list?q=${encoded}`) + fetchJson(`/api/region/list?q=${encoded}${inactiveParam}`) .then((data) => { if (!cancelled) setRegionResults(data); }) - .catch((err) => { + .catch((err: unknown) => { if (!cancelled) setRegionError( err instanceof Error ? err.message : "Search failed", @@ -168,11 +171,11 @@ export default function SearchModal({ if (!cancelled) setRegionLoading(false); }); - fetchJson(`/api/ao/list?q=${encoded}`) + fetchJson(`/api/ao/list?q=${encoded}${inactiveParam}`) .then((data) => { if (!cancelled) setAoResults(data); }) - .catch((err) => { + .catch((err: unknown) => { if (!cancelled) setAoError(err instanceof Error ? err.message : "Search failed"); }) @@ -185,7 +188,7 @@ export default function SearchModal({ cancelled = true; clearTimeout(t); }; - }, [query]); + }, [query, includeInactive]); const navigate = useCallback( (result: SearchResult) => { @@ -251,6 +254,23 @@ export default function SearchModal({ />
+ {/* Include inactive checkbox */} +
+ setIncludeInactive(e.target.checked)} + className="h-3.5 w-3.5 cursor-pointer accent-primary" + /> + +
+ {/* Jump-to-section bar — only when multiple sections have results */} {jumpLinks.length > 1 && (
@@ -314,12 +334,19 @@ export default function SearchModal({ alt={region.region_name} size="sm" src={region.logo_url ?? undefined} - className="flex-shrink-0" + className={`flex-shrink-0 ${!region.is_active ? "opacity-50" : ""}`} />
- - {region.region_name} - +
+ + {region.region_name} + + {!region.is_active && ( + + Inactive + + )} +
); @@ -350,12 +377,19 @@ export default function SearchModal({ alt={ao.ao_name} size="sm" src={ao.logo_url ?? undefined} - className="flex-shrink-0" + className={`flex-shrink-0 ${!ao.is_active ? "opacity-50" : ""}`} />
- - {ao.ao_name} - +
+ + {ao.ao_name} + + {!ao.is_active && ( + + Inactive + + )} +
{ao.region_name} diff --git a/src/lib/bq/aos.ts b/src/lib/bq/aos.ts index 3d79795..54bb2a7 100644 --- a/src/lib/bq/aos.ts +++ b/src/lib/bq/aos.ts @@ -406,6 +406,7 @@ export async function getPageData( export async function searchAOsByName( q: string, userIdentifier?: string, + includeInactive = false, ): Promise { const term = (q || "").trim(); if (term.length < 2) return []; @@ -423,7 +424,7 @@ export async function searchAOsByName( FROM pv_aos WHERE ao_name IS NOT NULL AND LOWER(ao_name) LIKE '%${escapedTerm}%' - AND is_active = TRUE + ${includeInactive ? "" : "AND is_active = TRUE"} ORDER BY ao_name LIMIT 50 `; diff --git a/src/lib/bq/regions.ts b/src/lib/bq/regions.ts index 80a0433..26a1e3c 100644 --- a/src/lib/bq/regions.ts +++ b/src/lib/bq/regions.ts @@ -301,6 +301,7 @@ export async function getEvents( export async function searchRegionsByName( q: string, userIdentifier?: string, + includeInactive = false, ): Promise { // Normalize and guard against overly-broad queries. const term = (q || "").trim(); @@ -319,7 +320,7 @@ export async function searchRegionsByName( FROM pv_regions WHERE region_name IS NOT NULL AND LOWER(region_name) LIKE '%${escapedTerm}%' - AND is_active = TRUE + ${includeInactive ? "" : "AND is_active = TRUE"} ORDER BY region_name LIMIT 50 `; From 1eec167e94a9df44dc7c2cdddc29aec8c49f9e10 Mon Sep 17 00:00:00 2001 From: Jay Stobie Date: Tue, 31 Mar 2026 16:50:27 -0500 Subject: [PATCH 4/5] single unified search query --- src/app/api/search/route.ts | 30 +++++++++ src/components/search-modal.tsx | 110 ++++++++++++-------------------- src/lib/bq/search.ts | 70 ++++++++++++++++++++ 3 files changed, 140 insertions(+), 70 deletions(-) create mode 100644 src/app/api/search/route.ts create mode 100644 src/lib/bq/search.ts diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 0000000..765c376 --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { searchAll } from "@/lib/bq/search"; +import { getSessionUser } from "@/lib/auth/server"; + +export async function GET(request: Request) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const rawQuery = searchParams.get("q") || ""; + const q = rawQuery.trim(); + const includeInactive = searchParams.get("includeInactive") === "true"; + + if (q.length < 2) { + return NextResponse.json({ regions: [], aos: [], pax: [] }, { status: 200 }); + } + + try { + const results = await searchAll(q, user.email, includeInactive); + return NextResponse.json(results, { status: 200 }); + } catch (err) { + console.error("Search failed:", err); + return NextResponse.json( + { error: "Search failed. Please try again." }, + { status: 500 }, + ); + } +} diff --git a/src/components/search-modal.tsx b/src/components/search-modal.tsx index ca91c4d..ded6c27 100644 --- a/src/components/search-modal.tsx +++ b/src/components/search-modal.tsx @@ -18,6 +18,12 @@ type SearchResult = | { kind: "region"; item: RegionInfo } | { kind: "ao"; item: AOInfo }; +type SearchPayload = { + regions: RegionInfo[]; + aos: AOInfo[]; + pax: PAXInfo[]; +}; + function routeForResult(result: SearchResult): string { switch (result.kind) { case "pax": @@ -29,7 +35,7 @@ function routeForResult(result: SearchResult): string { } } -async function fetchJson(url: string): Promise { +async function fetchSearchResults(url: string): Promise { const res = await fetch(url, { method: "GET", headers: { Accept: "application/json" }, @@ -40,9 +46,7 @@ async function fetchJson(url: string): Promise { (body as { error?: string }).error || `Request failed: ${res.status}`, ); } - const data = await res.json(); - if (!Array.isArray(data)) throw new Error("Unexpected response format"); - return data as T[]; + return res.json() as Promise; } const SECTION_IDS = { @@ -61,12 +65,8 @@ export default function SearchModal({ const [paxResults, setPaxResults] = useState([]); const [regionResults, setRegionResults] = useState([]); const [aoResults, setAoResults] = useState([]); - const [paxLoading, setPaxLoading] = useState(false); - const [regionLoading, setRegionLoading] = useState(false); - const [aoLoading, setAoLoading] = useState(false); - const [paxError, setPaxError] = useState(null); - const [regionError, setRegionError] = useState(null); - const [aoError, setAoError] = useState(null); + const [loading, setLoading] = useState(false); + const [searchError, setSearchError] = useState(null); const [focusedIndex, setFocusedIndex] = useState(-1); const inputRef = useRef(null); const scrollContainerRef = useRef(null); @@ -81,7 +81,6 @@ export default function SearchModal({ return items; }, [regionResults, aoResults, paxResults]); - const isLoading = paxLoading || regionLoading || aoLoading; const hasQuery = query.trim().length >= 2; const hasResults = allResults.length > 0; @@ -111,33 +110,26 @@ export default function SearchModal({ setPaxResults([]); setRegionResults([]); setAoResults([]); - setPaxError(null); - setRegionError(null); - setAoError(null); + setSearchError(null); setFocusedIndex(-1); } }, [isOpen]); - // Debounced search — fires 3 parallel fetches. + // Debounced unified search — single API call. useEffect(() => { const q = query.trim(); if (q.length < 2) { setPaxResults([]); setRegionResults([]); setAoResults([]); - setPaxLoading(false); - setRegionLoading(false); - setAoLoading(false); - setPaxError(null); - setRegionError(null); - setAoError(null); + setLoading(false); + setSearchError(null); setFocusedIndex(-1); return; } - setPaxLoading(true); - setRegionLoading(true); - setAoLoading(true); + setLoading(true); + setSearchError(null); setFocusedIndex(-1); let cancelled = false; @@ -145,42 +137,22 @@ export default function SearchModal({ const inactiveParam = includeInactive ? "&includeInactive=true" : ""; const t = setTimeout(() => { - fetchJson(`/api/pax/list?q=${encoded}`) + fetchSearchResults(`/api/search?q=${encoded}${inactiveParam}`) .then((data) => { - if (!cancelled) setPaxResults(data); + if (!cancelled) { + setRegionResults(data.regions); + setAoResults(data.aos); + setPaxResults(data.pax); + } }) .catch((err: unknown) => { if (!cancelled) - setPaxError(err instanceof Error ? err.message : "Search failed"); - }) - .finally(() => { - if (!cancelled) setPaxLoading(false); - }); - - fetchJson(`/api/region/list?q=${encoded}${inactiveParam}`) - .then((data) => { - if (!cancelled) setRegionResults(data); - }) - .catch((err: unknown) => { - if (!cancelled) - setRegionError( + setSearchError( err instanceof Error ? err.message : "Search failed", ); }) .finally(() => { - if (!cancelled) setRegionLoading(false); - }); - - fetchJson(`/api/ao/list?q=${encoded}${inactiveParam}`) - .then((data) => { - if (!cancelled) setAoResults(data); - }) - .catch((err: unknown) => { - if (!cancelled) - setAoError(err instanceof Error ? err.message : "Search failed"); - }) - .finally(() => { - if (!cancelled) setAoLoading(false); + if (!cancelled) setLoading(false); }); }, 600); @@ -245,7 +217,7 @@ export default function SearchModal({ isClearable onClear={() => setQuery("")} startContent={ - isLoading ? ( + loading ? ( ) : ( @@ -303,19 +275,24 @@ export default function SearchModal({
)} - {hasQuery && !isLoading && !hasResults && ( + {hasQuery && !loading && !hasResults && !searchError && (
No results found
)} + {hasQuery && searchError && ( +
+ {searchError} +
+ )} + {/* Regions section */} {regionResults.map((region) => { const idx = allResults.findIndex( @@ -357,9 +334,8 @@ export default function SearchModal({ {aoResults.map((ao) => { @@ -403,9 +379,8 @@ export default function SearchModal({ {paxResults.map((pax) => { @@ -486,7 +461,6 @@ function ResultSection({ id, title, loading, - error, empty, showDivider = false, children, @@ -494,7 +468,6 @@ function ResultSection({ id: string; title: string; loading: boolean; - error: string | null; empty: boolean; showDivider?: boolean; children: React.ReactNode; @@ -503,8 +476,8 @@ function ResultSection({ ? children.length > 0 : !!children; - if (!loading && !error && empty) return null; - if (!loading && !error && !hasChildren) return null; + if (!loading && empty) return null; + if (!loading && !hasChildren) return null; return (
{title}
- {error && ( -
{error}
- )} {loading && !hasChildren && (
Searching... diff --git a/src/lib/bq/search.ts b/src/lib/bq/search.ts new file mode 100644 index 0000000..663a887 --- /dev/null +++ b/src/lib/bq/search.ts @@ -0,0 +1,70 @@ +import { queryBigQuery } from "@/lib/db"; +import { RegionInfo, AOInfo, PAXInfo } from "@/lib/types"; + +export type SearchAllResult = { + regions: RegionInfo[]; + aos: AOInfo[]; + pax: PAXInfo[]; +}; + +type SearchAllRow = { + regions: RegionInfo[] | null; + aos: AOInfo[] | null; + pax: PAXInfo[] | null; +}; + +export async function searchAll( + q: string, + userIdentifier?: string, + includeInactive = false, +): Promise { + const term = (q || "").trim(); + if (term.length < 2) return { regions: [], aos: [], pax: [] }; + + // Escape single quotes to prevent SQL injection via LIKE. + const escapedTerm = term.replace(/'/g, "''").toLowerCase(); + const activeFilter = includeInactive ? "" : "AND is_active = TRUE"; + + // Single query — each subquery returns an ARRAY of STRUCTs so the entire + // result comes back as one BigQuery row with three array columns. + // ARRAY_AGG returns NULL when no rows match; we coalesce to [] in JS below. + const query = `-- UNIFIED SEARCH + SELECT + ( + SELECT ARRAY_AGG(STRUCT(region_id, region_name, logo_url, is_active) + ORDER BY region_name LIMIT 50) + FROM pv_regions + WHERE region_name IS NOT NULL + AND LOWER(region_name) LIKE '%${escapedTerm}%' + ${activeFilter} + ) AS regions, + ( + SELECT ARRAY_AGG(STRUCT(ao_id, ao_name, region_id, region_name, logo_url, is_active) + ORDER BY ao_name LIMIT 50) + FROM pv_aos + WHERE ao_name IS NOT NULL + AND LOWER(ao_name) LIKE '%${escapedTerm}%' + ${activeFilter} + ) AS aos, + ( + SELECT ARRAY_AGG(STRUCT(user_id, f3_name, home_region_id, home_region_name, avatar_url, status) + ORDER BY f3_name LIMIT 50) + FROM pv_pax + WHERE f3_name IS NOT NULL + AND LOWER(f3_name) LIKE '%${escapedTerm}%' + ) AS pax + `; + + const rows = await queryBigQuery( + query, + userIdentifier, + `unified search: ${q}`, + ); + + const row = rows[0]; + return { + regions: row?.regions ?? [], + aos: row?.aos ?? [], + pax: row?.pax ?? [], + }; +} From 57cccaa7f30692a15f519b7dbeab610172d3c879 Mon Sep 17 00:00:00 2001 From: Jay Stobie Date: Thu, 2 Apr 2026 09:32:58 -0500 Subject: [PATCH 5/5] allow inactive search results --- src/app/api/search/route.ts | 5 ++++- src/components/search-modal.tsx | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 765c376..0f21e3d 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -14,7 +14,10 @@ export async function GET(request: Request) { const includeInactive = searchParams.get("includeInactive") === "true"; if (q.length < 2) { - return NextResponse.json({ regions: [], aos: [], pax: [] }, { status: 200 }); + return NextResponse.json( + { regions: [], aos: [], pax: [] }, + { status: 200 }, + ); } try { diff --git a/src/components/search-modal.tsx b/src/components/search-modal.tsx index ded6c27..b94335f 100644 --- a/src/components/search-modal.tsx +++ b/src/components/search-modal.tsx @@ -315,7 +315,9 @@ export default function SearchModal({ />
- + {region.region_name} {!region.is_active && ( @@ -357,7 +359,9 @@ export default function SearchModal({ />
- + {ao.ao_name} {!ao.is_active && (