Searching...
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 5409e8d..26a1e3c 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";
/**
@@ -300,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();
@@ -318,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
`;
@@ -352,6 +354,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 +456,87 @@ 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,
+ 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
+ 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,
+ ec.last_region_event_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, 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,
+ 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, p.last_region_event_date
)
SELECT
-- Region info as a STRUCT
@@ -587,6 +671,50 @@ 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(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),
+ NULL
+ ) AS days_until_anniversary
+ )
+ ORDER BY f3_name ASC
+ )
+ FROM achievement_pax_milestones
+ WHERE
+ (
+ -- 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
(
WITH
@@ -653,6 +781,7 @@ export async function getPageData(
upcoming: EventUpcoming[];
kotter: RegionKotterList[];
charts: ChartData[];
+ achievements: RegionAchievementPax[];
}>(query, userIdentifier, `fetch region data for region ${regionId}`);
return {
@@ -663,5 +792,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/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 ?? [],
+ };
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 1cd3b88..98de5b2 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,25 @@ 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
+ last_region_event_date: string | null; // Date of most recent event attended in this region (ISO string)
+}
+
/* USED ONLY FOR REGION KOTTER LISTING */
export interface RegionKotterList {
user_id: number; // Unique identifier for the user