v1.3 - added an sqlite db and built data structure
This commit is contained in:
parent
b0fba52c0e
commit
5efdafbb97
37 changed files with 6414 additions and 1988 deletions
21
src/data/bannerConfig.js
Normal file
21
src/data/bannerConfig.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Site-wide announcement banner. Set `enabled: false` to hide it entirely.
|
||||
// Bump `id` whenever the message changes — that re-shows the banner to
|
||||
// people who dismissed the previous one.
|
||||
|
||||
export const SITE_BANNER = {
|
||||
enabled: true,
|
||||
id: "feedback-2026-09",
|
||||
|
||||
// Text renders in order. A part with `href` becomes a link.
|
||||
content: [
|
||||
{ text: "This site is brand new, " },
|
||||
{
|
||||
text: "please tell us what you think",
|
||||
href: "/feedback#website",
|
||||
external: true,
|
||||
},
|
||||
{ text: "." },
|
||||
],
|
||||
|
||||
dismissible: true,
|
||||
};
|
||||
146
src/data/chapters.js
Normal file
146
src/data/chapters.js
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
LOCAL CHAPTER DATA
|
||||
|
||||
The chapter map's view of the organization list. Regions and
|
||||
chapters are the same table and the same endpoint; this hook
|
||||
reshapes them into what a map needs — slices per tile, counts
|
||||
per tile, chapters grouped by region.
|
||||
|
||||
Its return shape is unchanged from the version that called
|
||||
/regions and /chapters, so LocalChapters didn't have to move.
|
||||
|
||||
Two things live elsewhere:
|
||||
|
||||
the tile grid src/data/mapGrid.js. Where a state sits never
|
||||
changes, so it isn't worth a round trip.
|
||||
|
||||
the fetch src/data/organizations.js. One endpoint for
|
||||
every kind, so a section listing regions and
|
||||
a section listing partners read alike.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
areasSentence,
|
||||
initialsFor,
|
||||
useOrganizations,
|
||||
} from "./organizations.js";
|
||||
import {
|
||||
areaForChapter,
|
||||
buildAreaSlices,
|
||||
countChaptersByArea,
|
||||
} from "./mapGrid.js";
|
||||
|
||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||
|
||||
export { initialsFor };
|
||||
|
||||
export function useCommunity() {
|
||||
const regionsQuery = useOrganizations("region");
|
||||
const chaptersQuery = useOrganizations("chapter");
|
||||
|
||||
const rawRegions = regionsQuery.organizations;
|
||||
const rawChapters = chaptersQuery.organizations;
|
||||
|
||||
return useMemo(() => {
|
||||
/* ── Regions ───────────────────────────────────────────────
|
||||
scope and map_note are lifted out of `details` so the rest
|
||||
of the app doesn't have to know they're kind-specific. */
|
||||
const regions = rawRegions.map((org) => ({
|
||||
...org,
|
||||
scope: org.details?.scope ?? null,
|
||||
map_note: org.details?.map_note ?? null,
|
||||
areas: org.details?.areas ?? [],
|
||||
}));
|
||||
|
||||
// Flat, because buildAreaSlices takes it that way.
|
||||
const regionAreas = regions.flatMap((region) =>
|
||||
region.areas.map((area) => ({ ...area, region_id: region.id })),
|
||||
);
|
||||
|
||||
/* ── Chapters, each tagged with the tile it lights up ──────
|
||||
The database stores a real address; which square that maps
|
||||
to is a rendering question, answered here once rather than
|
||||
at every call site. */
|
||||
const chapters = rawChapters.map((org) => ({
|
||||
...org,
|
||||
region_id: org.details?.region_id ?? null,
|
||||
region_name: org.details?.region_name ?? null,
|
||||
region_color: org.details?.region_color ?? null,
|
||||
meets: org.details?.meets ?? null,
|
||||
started: org.details?.started ?? null,
|
||||
area_code: areaForChapter(org),
|
||||
}));
|
||||
|
||||
const regionById = Object.fromEntries(regions.map((r) => [r.id, r]));
|
||||
|
||||
/* { WA: [{ regionId, name, color, share, edge, note }], ... }
|
||||
California comes back as two half slices rather than a
|
||||
primary and a remainder, so the SVG never does arithmetic. */
|
||||
const slices = buildAreaSlices(regionAreas, regions);
|
||||
|
||||
const chapterCounts = countChaptersByArea(chapters);
|
||||
|
||||
const chaptersByRegion = new Map();
|
||||
for (const chapter of chapters) {
|
||||
if (!chapter.region_id) continue;
|
||||
const list = chaptersByRegion.get(chapter.region_id);
|
||||
if (list) list.push(chapter);
|
||||
else chaptersByRegion.set(chapter.region_id, [chapter]);
|
||||
}
|
||||
|
||||
const chaptersIn = (regionId) => chaptersByRegion.get(regionId) ?? [];
|
||||
|
||||
/* Sorted by name to match how the page has always shown them,
|
||||
rather than by the sort_order the API returns. */
|
||||
const domestic = regions.filter((r) => r.scope === "domestic").sort(byName);
|
||||
const international = regions
|
||||
.filter((r) => r.scope === "international")
|
||||
.sort(byName);
|
||||
const virtual = regions.filter((r) => r.scope === "virtual");
|
||||
|
||||
const areasLabelFor = (regionId) =>
|
||||
areasSentence(regionById[regionId]?.areas ?? []);
|
||||
|
||||
const subtextFor = (region) => {
|
||||
const label = areasLabelFor(region.id);
|
||||
if (label && region.map_note) return `${label} · ${region.map_note}`;
|
||||
return label || region.map_note || "";
|
||||
};
|
||||
|
||||
const regionsForArea = (areaCode) =>
|
||||
(slices[areaCode] ?? [])
|
||||
.map((slice) => regionById[slice.regionId])
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
loading: regionsQuery.loading || chaptersQuery.loading,
|
||||
error: regionsQuery.error ?? chaptersQuery.error,
|
||||
|
||||
regions,
|
||||
regionAreas,
|
||||
regionById,
|
||||
domestic,
|
||||
international,
|
||||
virtual,
|
||||
|
||||
chapters,
|
||||
chaptersIn,
|
||||
|
||||
slices,
|
||||
chapterCounts,
|
||||
regionsForArea,
|
||||
|
||||
areasLabelFor,
|
||||
subtextFor,
|
||||
};
|
||||
}, [
|
||||
rawRegions,
|
||||
rawChapters,
|
||||
regionsQuery.loading,
|
||||
regionsQuery.error,
|
||||
chaptersQuery.loading,
|
||||
chaptersQuery.error,
|
||||
]);
|
||||
}
|
||||
54
src/data/eventData.js
Normal file
54
src/data/eventData.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT DATA
|
||||
|
||||
One request, filtered per section. The Retreats page has three
|
||||
bands of events, and all three call this hook — the cache in
|
||||
api.js keys on the path, so they share a single fetch and each
|
||||
narrows the result to what it shows.
|
||||
|
||||
useEvents({ section: "national" }) one band
|
||||
useEvents({ host: "northwest" }) a region's own events
|
||||
useEvents({ status: "upcoming" }) a home page strip
|
||||
useEvents() everything
|
||||
|
||||
Filtering here rather than in the query keeps the endpoint to
|
||||
one cached response. At a few dozen events that's the right
|
||||
trade; if the list ever runs to hundreds, move the filters into
|
||||
the URL and let each become its own cache entry.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useResource } from "../lib/useResource.js";
|
||||
|
||||
const EMPTY = { events: [] };
|
||||
|
||||
export function useEvents({ section, host, status } = {}) {
|
||||
const { data, error, loading } = useResource("/events", { fallback: EMPTY });
|
||||
|
||||
const all = data?.events;
|
||||
|
||||
const events = useMemo(() => {
|
||||
let list = all ?? [];
|
||||
if (section) list = list.filter(e => e.section_id === section);
|
||||
if (host) list = list.filter(e => e.host?.id === host);
|
||||
if (status) list = list.filter(e => e.status === status);
|
||||
return list;
|
||||
}, [all, section, host, status]);
|
||||
|
||||
return { events, loading, error };
|
||||
}
|
||||
|
||||
/* Past and upcoming, split. `status` arrives already resolved — the
|
||||
explicit value when there is one, otherwise derived from ends_on
|
||||
— so nothing here needs to know which of the two it got. */
|
||||
export function splitByStatus(events = []) {
|
||||
const upcoming = [];
|
||||
const past = [];
|
||||
|
||||
for (const event of events) {
|
||||
(event.status === "past" ? past : upcoming).push(event);
|
||||
}
|
||||
|
||||
return { upcoming, past };
|
||||
}
|
||||
297
src/data/events.js
Normal file
297
src/data/events.js
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT DATA
|
||||
Plain JS instead of JSON so it imports cleanly in any environment
|
||||
(Figma included) — same shape, but comments and trailing commas
|
||||
are allowed.
|
||||
|
||||
Per event:
|
||||
org_logo hosting org's logo: a filename WITH extension in
|
||||
public/event-logos/, e.g. "ngu-logo-white-bg.png".
|
||||
null = fall back to DEFAULT_ORG_LOGO.
|
||||
image event logo / flyer, same filename rule. A name with
|
||||
no matching file just renders nothing.
|
||||
instagram handle for the follow button, e.g. "@nextgenunity".
|
||||
null = no Instagram button on this card.
|
||||
color card outline / button color. null = the section's
|
||||
defaultColor.
|
||||
gradient card background. null = no gradient.
|
||||
desc_a first paragraph
|
||||
desc_b second paragraph (pricing, lodging, a note — anything)
|
||||
status "upcoming" or "past" — grid view splits on this.
|
||||
links [] when there's nothing to click yet.
|
||||
|
||||
Per section:
|
||||
accent heading, banner, arrows, dots, fallback messages
|
||||
defaultColor card color for events that don't set their own
|
||||
defaultView "carousel" or "grid"
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const eventsData = {
|
||||
sections: [
|
||||
{
|
||||
id: "national",
|
||||
title: "National Retreats",
|
||||
blurb: "Our flagship gatherings, open to young adults across the country.",
|
||||
accent: "#138ba0",
|
||||
defaultColor: "#138ba0",
|
||||
defaultView: "carousel",
|
||||
background: "#eef9fb",
|
||||
events: [
|
||||
{
|
||||
id: "spring-2025",
|
||||
title: "Spring Retreat 2026",
|
||||
theme: "Altering Intertia",
|
||||
date: "March/April 2026",
|
||||
location: "Unity Village, MO",
|
||||
org_logo: "ngu-logo-white-bg.svg",
|
||||
image: "fall-retreat-logo.svg",
|
||||
instagram: "@nextgenerationunity",
|
||||
color: "#f1c2fe",
|
||||
gradient: "linear-gradient(150deg, rgba(240, 224, 254, 1), rgba(255, 255, 255, 0.28))",
|
||||
desc_a: "A weekend of connection, workshops, and community for young adults across the Unity movement.",
|
||||
desc_b: null,
|
||||
status: "past",
|
||||
links: []
|
||||
},
|
||||
{
|
||||
id: "fall-retreat-2026",
|
||||
title: "Fall Retreat 2026",
|
||||
theme: "Consciousness Creates",
|
||||
date: "November 12-15th, 2026",
|
||||
location: "Unity Village, MO",
|
||||
org_logo: "ngu-logo-white-bg.svg",
|
||||
image: "fall-retreat-logo.svg",
|
||||
instagram: "@nextgenerationunity",
|
||||
color: "#b89421",
|
||||
gradient: "linear-gradient(150deg, rgba(178, 150, 42, 0.45), rgba(230, 200, 120, 0.15) 65%, rgba(255, 255, 255, 0.28))",
|
||||
desc_a: "Join us for an exciting opportunity to connect with young adults from across the country through meaningful conversations, creative workshops, and shared artistic expression. All designed to shift your focus to your highest self.",
|
||||
desc_b: "Registration starting at $150, and $75 lodging cost.",
|
||||
status: "upcoming",
|
||||
links: [
|
||||
{
|
||||
label: "Register Now!",
|
||||
link: "https://ngu.churchcenter.com/registrations/events/3761999"
|
||||
},
|
||||
{
|
||||
label: "Scholarship Application",
|
||||
link: "https://ngu.churchcenter.com/people/forms/1261992"
|
||||
},
|
||||
{
|
||||
label: "Volunteer",
|
||||
link: "https://ngu.churchcenter.com/people/forms/1176908"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "spring-recharge-2027",
|
||||
title: "Spring Recharge 2027",
|
||||
theme: "TBD",
|
||||
date: "March 6th, 2027",
|
||||
location: "Online",
|
||||
org_logo: "ngu-logo-white-bg.svg",
|
||||
image: null,
|
||||
instagram: "@nextgenerationunity",
|
||||
color: "#138ba0",
|
||||
gradient: null,
|
||||
desc_a: "One-day online event to reconnect in the spring.",
|
||||
desc_b: null,
|
||||
status: "upcoming",
|
||||
links: []
|
||||
},
|
||||
{
|
||||
id: "spring-service-2027",
|
||||
title: "Service Week 2027",
|
||||
theme: "Leadership & Service",
|
||||
date: "April 4-9th, 2027",
|
||||
location: "Unity Village, MO",
|
||||
org_logo: "ngu-logo-white-bg.svg",
|
||||
image: null,
|
||||
instagram: "@nextgenerationunity",
|
||||
color: "#138ba0",
|
||||
gradient: null,
|
||||
desc_a: "Join us at beautiful Unity Village for a week of leadership development and service projects.",
|
||||
desc_b: null,
|
||||
status: "upcoming",
|
||||
links: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "regional",
|
||||
title: "Regional Retreats",
|
||||
blurb: "Smaller gatherings hosted by regions throughout the year.",
|
||||
accent: "#aac992",
|
||||
defaultColor: "#aac992",
|
||||
defaultView: "grid",
|
||||
background: "#ffffff",
|
||||
events: [
|
||||
{
|
||||
id: "northwest-2026",
|
||||
title: "Northwest Regional 2026",
|
||||
theme: "Theme name",
|
||||
date: "Date",
|
||||
location: "Location",
|
||||
org_logo: null,
|
||||
image: null,
|
||||
instagram: "@nw.ngu",
|
||||
color: null,
|
||||
gradient: null,
|
||||
desc_a: "Short description of the regional retreat goes here.",
|
||||
desc_b: null,
|
||||
status: "past",
|
||||
links: []
|
||||
},
|
||||
{
|
||||
id: "northwest-2027",
|
||||
title: "Northwest Regional 2027",
|
||||
theme: "Theme name",
|
||||
date: "Date",
|
||||
location: "Location",
|
||||
org_logo: null,
|
||||
image: null,
|
||||
instagram: "@nw.ngu",
|
||||
color: null,
|
||||
gradient: null,
|
||||
desc_a: "Short description of the regional retreat goes here.",
|
||||
desc_b: null,
|
||||
status: "upcoming",
|
||||
links: []
|
||||
},
|
||||
{
|
||||
id: "northwest-2028",
|
||||
title: "Northwest Regional 2028",
|
||||
theme: "Theme name",
|
||||
date: "Date",
|
||||
location: "Location",
|
||||
org_logo: null,
|
||||
image: null,
|
||||
instagram: "@nw.ngu",
|
||||
color: null,
|
||||
gradient: null,
|
||||
desc_a: "Short description of the regional retreat goes here.",
|
||||
desc_b: null,
|
||||
status: "upcoming",
|
||||
links: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "partner",
|
||||
title: "Partner Events",
|
||||
blurb: "Events hosted by organizations we collaborate with.",
|
||||
accent: "#7a5ea8",
|
||||
defaultColor: "#7a5ea8",
|
||||
defaultView: "grid",
|
||||
background: "#eef9fb",
|
||||
events: [
|
||||
{
|
||||
id: "partner-example-1",
|
||||
title: "Partner Event Name",
|
||||
theme: null,
|
||||
date: "Date",
|
||||
location: "Location",
|
||||
org_logo: null,
|
||||
image: null,
|
||||
instagram: null,
|
||||
color: null,
|
||||
gradient: null,
|
||||
desc_a: "Short description of the partner event goes here.",
|
||||
desc_b: "Hosted by Partner Organization.",
|
||||
status: "past",
|
||||
links: [
|
||||
{
|
||||
label: "Learn More",
|
||||
link: "partner-link"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "partner-example-2",
|
||||
title: "Partner Event Name",
|
||||
theme: null,
|
||||
date: "Date",
|
||||
location: "Location",
|
||||
org_logo: null,
|
||||
image: null,
|
||||
instagram: null,
|
||||
color: null,
|
||||
gradient: null,
|
||||
desc_a: "Short description of the partner event goes here.",
|
||||
desc_b: "Hosted by Partner Organization.",
|
||||
status: "past",
|
||||
links: [
|
||||
{
|
||||
label: "Learn More",
|
||||
link: "partner-link"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "partner-example-3",
|
||||
title: "Partner Event Name",
|
||||
theme: null,
|
||||
date: "Date",
|
||||
location: "Location",
|
||||
org_logo: null,
|
||||
image: null,
|
||||
instagram: null,
|
||||
color: null,
|
||||
gradient: null,
|
||||
desc_a: "Short description of the partner event goes here.",
|
||||
desc_b: "Hosted by Partner Organization.",
|
||||
status: "upcoming",
|
||||
links: [
|
||||
{
|
||||
label: "Learn More",
|
||||
link: "partner-link"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "partner-example-4",
|
||||
title: "Partner Event Name",
|
||||
theme: null,
|
||||
date: "Date",
|
||||
location: "Location",
|
||||
org_logo: null,
|
||||
image: null,
|
||||
instagram: null,
|
||||
color: null,
|
||||
gradient: null,
|
||||
desc_a: "Short description of the partner event goes here.",
|
||||
desc_b: "Hosted by Partner Organization.",
|
||||
status: "upcoming",
|
||||
links: [
|
||||
{
|
||||
label: "Learn More",
|
||||
link: "partner-link"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "partner-example-5",
|
||||
title: "Partner Event Name",
|
||||
theme: null,
|
||||
date: "Date",
|
||||
location: "Location",
|
||||
org_logo: null,
|
||||
image: null,
|
||||
instagram: null,
|
||||
color: null,
|
||||
gradient: null,
|
||||
desc_a: "Short description of the partner event goes here.",
|
||||
desc_b: "Hosted by Partner Organization.",
|
||||
status: "upcoming",
|
||||
links: [
|
||||
{
|
||||
label: "Learn More",
|
||||
link: "partner-link"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default eventsData;
|
||||
174
src/data/mapGrid.js
Normal file
174
src/data/mapGrid.js
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
MAP GRID
|
||||
|
||||
Pure layout. Where each tile sits, what it's called, and how to
|
||||
work out which tile a chapter belongs to. None of this is in the
|
||||
database because none of it changes — Rhode Island will not be
|
||||
moving, and no admin form should offer to move it.
|
||||
|
||||
What IS in the database is which regions cover which areas, and
|
||||
how much of each. That arrives as region_areas rows whose
|
||||
area_code matches a key in AREAS below. A code with no match
|
||||
here simply doesn't paint, which is how Africa and the UK exist
|
||||
as regions with no tile.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
export const GRID_COLS = 13;
|
||||
export const GRID_ROWS = 7;
|
||||
|
||||
/* ── States ────────────────────────────────────────────────────
|
||||
[column, row], both 1-based. A tile grid rather than true
|
||||
geography: every state reads at the same size, it stays
|
||||
legible on a phone, and there's no map library to load.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const STATE_GRID = {
|
||||
AK: [1, 1], ME: [13, 1],
|
||||
WA: [2, 2], ID: [3, 2], MT: [4, 2], ND: [5, 2], MN: [6, 2], WI: [7, 2],
|
||||
MI: [8, 3], NY: [10, 2], VT: [11, 2], NH: [12, 2],
|
||||
OR: [2, 3], NV: [3, 4], WY: [4, 3], SD: [5, 3], IA: [6, 3], IL: [7, 3],
|
||||
IN: [7, 4], OH: [8, 4], PA: [9, 2], NJ: [10, 3], MA: [11, 3],
|
||||
CA: [2, 4], UT: [3, 3], CO: [4, 4], NE: [5, 4], MO: [6, 4], KY: [7, 5],
|
||||
WV: [9, 3], VA: [9, 4], MD: [10, 5], DE: [10, 4], CT: [11, 4],
|
||||
AZ: [3, 5], NM: [4, 5], KS: [5, 5], AR: [6, 5], TN: [8, 5], NC: [10, 6],
|
||||
DC: [9, 5], RI: [12, 3],
|
||||
OK: [5, 6], LA: [6, 6], MS: [7, 6], AL: [8, 6], SC: [9, 6],
|
||||
HI: [1, 7], TX: [5, 7], GA: [9, 7], FL: [10, 7],
|
||||
};
|
||||
|
||||
/* ── Bands ─────────────────────────────────────────────────────
|
||||
Wide areas that aren't states. A band is just a tile with a
|
||||
span, which keeps the renderer from needing a second code path.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const BAND_GRID = {
|
||||
CANADA: [3, 1, 8], // col, row, span
|
||||
};
|
||||
|
||||
export const AREA_NAMES = {
|
||||
AL: "Alabama", AK: "Alaska", AZ: "Arizona", AR: "Arkansas",
|
||||
CA: "California", CO: "Colorado", CT: "Connecticut", DE: "Delaware",
|
||||
DC: "District of Columbia", FL: "Florida", GA: "Georgia", HI: "Hawai'i",
|
||||
ID: "Idaho", IL: "Illinois", IN: "Indiana", IA: "Iowa", KS: "Kansas",
|
||||
KY: "Kentucky", LA: "Louisiana", ME: "Maine", MD: "Maryland",
|
||||
MA: "Massachusetts", MI: "Michigan", MN: "Minnesota", MS: "Mississippi",
|
||||
MO: "Missouri", MT: "Montana", NE: "Nebraska", NV: "Nevada",
|
||||
NH: "New Hampshire", NJ: "New Jersey", NM: "New Mexico", NY: "New York",
|
||||
NC: "North Carolina", ND: "North Dakota", OH: "Ohio", OK: "Oklahoma",
|
||||
OR: "Oregon", PA: "Pennsylvania", RI: "Rhode Island",
|
||||
SC: "South Carolina", SD: "South Dakota", TN: "Tennessee", TX: "Texas",
|
||||
UT: "Utah", VT: "Vermont", VA: "Virginia", WA: "Washington",
|
||||
WV: "West Virginia", WI: "Wisconsin", WY: "Wyoming",
|
||||
CANADA: "Canada",
|
||||
};
|
||||
|
||||
/* ── One list the renderer walks ───────────────────────────────
|
||||
States and bands unified, so drawing the map is a single map()
|
||||
over AREAS rather than two loops with different shapes.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
export const AREAS = Object.freeze([
|
||||
...Object.entries(STATE_GRID).map(([code, [col, row]]) => ({
|
||||
code,
|
||||
name: AREA_NAMES[code] ?? code,
|
||||
col,
|
||||
row,
|
||||
span: 1,
|
||||
isState: true,
|
||||
})),
|
||||
...Object.entries(BAND_GRID).map(([code, [col, row, span]]) => ({
|
||||
code,
|
||||
name: AREA_NAMES[code] ?? code,
|
||||
col,
|
||||
row,
|
||||
span,
|
||||
isState: false,
|
||||
})),
|
||||
]);
|
||||
|
||||
export const AREA_BY_CODE = Object.fromEntries(AREAS.map((a) => [a.code, a]));
|
||||
|
||||
/* ── Which tile a chapter sits on ──────────────────────────────
|
||||
The database stores a real address — state_code for US, and a
|
||||
country code otherwise — rather than a tile name. This is the
|
||||
one place that translates between the two, so adding a Mexico
|
||||
band later means a line here and nothing in SQL.
|
||||
|
||||
Returns null for anything with no tile, which covers virtual
|
||||
chapters and any country not drawn.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const COUNTRY_AREA = {
|
||||
CA: "CANADA", // ISO country code, not California
|
||||
};
|
||||
|
||||
export function areaForChapter(chapter) {
|
||||
if (!chapter) return null;
|
||||
if (chapter.is_online) return null;
|
||||
|
||||
if (chapter.country === "US") {
|
||||
return AREA_BY_CODE[chapter.state_code] ? chapter.state_code : null;
|
||||
}
|
||||
return COUNTRY_AREA[chapter.country] ?? null;
|
||||
}
|
||||
|
||||
/* ── Slices per tile ───────────────────────────────────────────
|
||||
Turns region_areas rows into what the SVG needs: for each
|
||||
tile, the regions painting it and the fraction each takes.
|
||||
|
||||
A region with share 1 and no edge fills the tile. A shared
|
||||
tile has one row per region, each declaring its own slice, so
|
||||
California is two entries of 0.5 rather than a primary plus a
|
||||
remainder — no arithmetic, and the renderer doesn't need to
|
||||
know which region "really" owns it.
|
||||
|
||||
regionAreas [{ region_id, area_code, share, edge, note }]
|
||||
regions [{ id, name, color, ... }]
|
||||
───────────────────────────────────────────────────────────── */
|
||||
export function buildAreaSlices(regionAreas = [], regions = []) {
|
||||
const regionById = Object.fromEntries(regions.map((r) => [r.id, r]));
|
||||
const byArea = {};
|
||||
|
||||
for (const row of regionAreas) {
|
||||
const area = AREA_BY_CODE[row.area_code];
|
||||
const region = regionById[row.region_id];
|
||||
if (!area || !region) continue; // untiled region, or unknown code
|
||||
|
||||
(byArea[row.area_code] ??= []).push({
|
||||
regionId: region.id,
|
||||
name: region.name,
|
||||
color: region.color,
|
||||
share: row.share ?? 1,
|
||||
edge: row.edge ?? null,
|
||||
note: row.note ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Full-tile slice first, so a partial slice paints over it.
|
||||
for (const slices of Object.values(byArea)) {
|
||||
slices.sort((a, b) => b.share - a.share);
|
||||
}
|
||||
|
||||
return byArea;
|
||||
}
|
||||
|
||||
/* ── Chapter counts per tile ───────────────────────────────────
|
||||
{ WA: 2, MO: 1, CANADA: 1 }
|
||||
───────────────────────────────────────────────────────────── */
|
||||
export function countChaptersByArea(chapters = []) {
|
||||
const counts = {};
|
||||
for (const chapter of chapters) {
|
||||
const code = areaForChapter(chapter);
|
||||
if (code) counts[code] = (counts[code] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/* ── "AK, ID, MT, OR, UT (Salt Lake City area), WA" ────────────
|
||||
Replaces statesLabel. Note the annotation now comes straight
|
||||
from the row rather than being looked up in a splits table and
|
||||
branched on whether this region is the primary.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
export function areasLabel(regionId, regionAreas = []) {
|
||||
return regionAreas
|
||||
.filter((row) => row.region_id === regionId && row.area_code !== "CANADA")
|
||||
.map((row) => (row.note ? `${row.area_code} (${row.note})` : row.area_code))
|
||||
.sort()
|
||||
.join(", ");
|
||||
}
|
||||
90
src/data/organizations.js
Normal file
90
src/data/organizations.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ORGANIZATIONS
|
||||
|
||||
Regions, chapters, partners and NGU itself are one table and one
|
||||
endpoint. Anything that lists organizations reads them the same
|
||||
way and filters by kind:
|
||||
|
||||
const { organizations } = useOrganizations("region");
|
||||
const { organizations } = useOrganizations("partner");
|
||||
const { organizations } = useOrganizations(); // all
|
||||
|
||||
Every organization has the same card surface — name, colour,
|
||||
logo, description, links. What differs by kind sits under
|
||||
`details`, so a list component can render the common parts
|
||||
without knowing what it's holding:
|
||||
|
||||
region { scope, map_note, areas[], chapters[] }
|
||||
chapter { region_id, region_name, region_color, meets, started }
|
||||
partner {}
|
||||
|
||||
Each kind is a separate request path, so the cache in api.js
|
||||
keys them apart and two sections asking for regions share one
|
||||
fetch.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useResource } from "../lib/useResource.js";
|
||||
|
||||
const EMPTY = { organizations: [] };
|
||||
|
||||
export function useOrganizations(kind) {
|
||||
const path = kind
|
||||
? `/organizations?kind=${encodeURIComponent(kind)}`
|
||||
: "/organizations";
|
||||
|
||||
const { data, error, loading } = useResource(path, { fallback: EMPTY });
|
||||
|
||||
return {
|
||||
organizations: data?.organizations ?? [],
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Where an organization's page lives ────────────────────────
|
||||
One place to change when routes move. Kinds with no page of
|
||||
their own return null, and a list should render no link rather
|
||||
than a dead one.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
const PATHS = {
|
||||
region: "/regions",
|
||||
chapter: "/chapters",
|
||||
partner: "/partners",
|
||||
};
|
||||
|
||||
export function orgPath(org) {
|
||||
const base = PATHS[org?.kind];
|
||||
return base ? `${base}/${org.id}` : null;
|
||||
}
|
||||
|
||||
/* ── "AK, ID, MT, OR, UT (Salt Lake City area), WA" ────────────
|
||||
The areas a region covers, annotated where it holds only part
|
||||
of one. Canada is a band on the map rather than somewhere you'd
|
||||
list, so it's left out of the sentence.
|
||||
|
||||
The note comes straight off the row. The old statesLabel had to
|
||||
work out whether this region was the primary or the secondary of
|
||||
a split before it knew which note applied; there's no such thing
|
||||
any more.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
export function areasSentence(areas = []) {
|
||||
return areas
|
||||
.filter((area) => area.area_code !== "CANADA")
|
||||
.map((area) => (area.note ? `${area.area_code} (${area.note})` : area.area_code))
|
||||
.sort()
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/* Initials for a card with no logo. "NGU Lynnwood" → "NL", dropping
|
||||
the org prefix so every card doesn't read "NG". */
|
||||
export function initialsFor(name = "") {
|
||||
return name
|
||||
.replace(/^NGU\s+/i, "")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((word) => word[0].toUpperCase())
|
||||
.join("");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue