- Meeting Summaries & Recordings
+ {heading}
+ {note && {note}
}
{admin && editing === null && (
setEditing("new")}>
@@ -32,7 +36,7 @@ export function MeetingsTab() {
title="Add a meeting"
table="meetings"
fields={MEETING_FIELDS}
- initial={{ date: today, title: "Board meeting", summary: "", recordingUrl: "" }}
+ initial={{ date: today, title: "Board meeting", ...(attendance ? { absent: "" } : {}), summary: "", recordingUrl: "" }}
onClose={() => setEditing(null)}
/>
)}
@@ -44,7 +48,7 @@ export function MeetingsTab() {
table="meetings"
id={m.id}
fields={MEETING_FIELDS}
- initial={{ date: m.date, title: m.title, summary: m.summary, recordingUrl: m.recordingUrl }}
+ initial={{ date: m.date, title: m.title, ...(attendance ? { absent: m.absent } : {}), summary: m.summary, recordingUrl: m.recordingUrl }}
onClose={() => setEditing(null)}
/>
) : (
@@ -52,12 +56,13 @@ export function MeetingsTab() {
{m.title} · {fmt(m.date, { month: "short", day: "numeric", year: "numeric" })}
+ {attendance && {m.absent ? `Absent: ${m.absent}` : "Full attendance."}
}
{m.summary && {m.summary}
}
Recording:{" "}
{m.recordingUrl ? (
- Zoom recording
+ {attendance ? "Open the recording" : "Zoom recording"}
) : (
link to add
@@ -66,7 +71,7 @@ export function MeetingsTab() {
{admin && (
setEditing(m.id)}>
- Edit
+ Edit or remove
)}
diff --git a/components/board/MyPage.tsx b/components/board/MyPage.tsx
new file mode 100644
index 0000000..f9fd726
--- /dev/null
+++ b/components/board/MyPage.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { Member } from "@/lib/board/types";
+import { useBoard } from "./BoardApp";
+import { roundTasks } from "./derive";
+import { daysBetween, fmt } from "./util";
+
+// Who "My page" is for. Everyone signs in with a shared password, so the
+// viewer picks themselves and the choice is remembered in this browser.
+// When per-person sign-in exists, return the signed-in member here and
+// drop the picker; nothing else on the page needs to change.
+function useViewer(members: Member[]) {
+ const { chapter } = useBoard();
+ const key = `vin-board-viewer-${chapter.id}`;
+ const [id, setId] = useState(null);
+ useEffect(() => {
+ try {
+ const saved = Number(localStorage.getItem(key));
+ if (saved) setId(saved);
+ } catch {
+ /* private mode: start unpicked */
+ }
+ }, [key]);
+ const pick = (next: number | null) => {
+ setId(next);
+ try {
+ if (next) localStorage.setItem(key, String(next));
+ else localStorage.removeItem(key);
+ } catch {
+ /* ignore */
+ }
+ };
+ return { viewer: members.find((m) => m.id === id) ?? null, pick, canPick: true };
+}
+
+// The person someone reports to: team members to their team lead, leads
+// and the executive row to the president.
+function leadOf(m: Member, members: Member[]): Member | null {
+ const president = members.find((x) => x.tier === 0) ?? null;
+ if (m.tier === 0) return null;
+ if (m.tier === 1 || m.isLead) return president;
+ return members.find((x) => x !== m && x.isLead && (x.team === m.team || x.alsoTeam === m.team)) ?? president;
+}
+
+export function MyPage() {
+ const { chapter, round, data, today, go, setFocus } = useBoard();
+ const { viewer: me, pick, canPick } = useViewer(data.members);
+
+ if (data.members.length === 0) {
+ return (
+
+ My page
+ The member list hasn't been loaded yet. Ask {chapter.contact}.
+
+ );
+ }
+
+ const picker = canPick && (
+
+
+ Viewing as
+ pick(e.target.value ? Number(e.target.value) : null)}>
+ Choose your name
+ {data.members.map((m) => (
+
+ {m.name} · {m.role}
+
+ ))}
+
+
+
+ );
+
+ if (!me) {
+ return (
+
+ My page
+ {picker}
+ Choose your name to see your tasks, your team and what's coming up. This browser remembers it.
+
+ );
+ }
+
+ const lead = leadOf(me, data.members);
+ const mine = roundTasks(data, round?.round ?? null).find((p) => p.person === me.firstName);
+ const left = round ? daysBetween(today, round.dueDate) : null;
+ const dueDay = round ? fmt(round.dueDate, { weekday: "short", month: "short", day: "numeric" }) : "";
+ const dueText = left === null ? "" : left > 1 ? `Due ${dueDay} · ${left} days left` : left === 1 ? `Due tomorrow, ${dueDay}` : left === 0 ? "Due today" : "Past due";
+ const teammates = data.members.filter((x) => x.id !== me.id && (x.team === me.team || x.alsoTeam === me.team));
+ const upcoming = data.socials.filter((s) => s.date >= today).slice(0, 3);
+
+ const timeline = chapter.projects.timeline;
+ const week = timeline.weeks.find((w) => daysBetween(w.start, today) >= 0 && daysBetween(today, w.end) >= -timeline.graceDays);
+ const track = chapter.myPage.teamTrack[me.team];
+ const trackItem = week && track ? week.items.find(([t]) => t === track) : undefined;
+ const teamNote = chapter.myPage.teamNote[me.team];
+
+ return (
+
+ {picker}
+
+ {me.name}
+ {me.role}
+
+
+
+ Team
+
+ {me.team}
+ {me.alsoTeam && ` · also ${me.alsoTeam}`}
+
+
+ {lead && (
+
+ Your lead
+
+ {lead.name}
+ {lead.email && ` · ${lead.email}`}
+
+
+ )}
+
+ Your email
+ {me.email || "Not on file"}
+
+
+
+ Your tasks · this week
+ {mine ? (
+ <>
+
+ {dueText}.
+
+
+ {mine.items.map((a) => (
+
+ {a.task}
+
+ ))}
+
+ {
+ setFocus({ tab: "assignments", key: me.firstName });
+ go("assignments");
+ }}
+ >
+ Submit your work
+
+ >
+ ) : (
+
+ No tasks assigned to you this round.
+ {lead && lead.id !== me.id && ` ${lead.firstName} assigns tasks for ${me.team}, so check in with them if you want something to work on.`}
+
+ )}
+
+ What your team is working on
+ {week && trackItem ? (
+
+
+ Week {week.n} ({week.label}):
+ {" "}
+ {trackItem[1]}.
+
+ ) : teamNote ? (
+ {teamNote}
+ ) : null}
+
+ go("projects")}>
+ Full timeline
+
+
+
+ Your team: {teammates.map((x) => x.firstName).join(", ") || "Just you for now"}
+
+
+ Coming up
+
+ {chapter.myPage.meetingLine && (
+
+ Sun
+ {chapter.myPage.meetingLine}
+
+ )}
+ {upcoming.map((s) => (
+
+ {fmt(s.date, { month: "short", day: "numeric" })}
+
+ {s.title}
+ {s.location && ` · ${s.location}`}
+ {s.time && ` · ${s.time}`}
+
+
+ ))}
+
+
+ go("socials")}>
+ {chapter.socials.list}
+
+
+
+ );
+}
diff --git a/components/board/ProjectsTab.tsx b/components/board/ProjectsTab.tsx
index 27afab4..bc98928 100644
--- a/components/board/ProjectsTab.tsx
+++ b/components/board/ProjectsTab.tsx
@@ -1,28 +1,34 @@
"use client";
import { useState } from "react";
-import { PIR, PROJECTS, WEEKS, type Track } from "@/lib/board/static";
+import type { Para } from "@/lib/board/chapters";
import { useBoard } from "./BoardApp";
import { daysBetween } from "./util";
-const TRACKS: ("All" | Track)[] = ["All", "Data", "Backend", "App"];
+const P = ({ p }: { p: Para }) => (
+
+ {p.b && {p.b} }
+ {p.t}
+
+);
export function ProjectsTab() {
- const { today } = useBoard();
- const [track, setTrack] = useState<"All" | Track>("All");
+ const { chapter, today } = useBoard();
+ const { groups, sections, timeline } = chapter.projects;
+ const [track, setTrack] = useState("All");
return (
All Projects
- {PROJECTS.map((g) => (
+ {groups.map((g) => (
{g.group}
- {g.items.map(([name, chapter]) => (
+ {g.items.map(([name, where]) => (
{name}
- {chapter && · {chapter} }
+ {where && · {where} }
))}
@@ -30,37 +36,34 @@ export function ProjectsTab() {
))}
-
Project Price Is Right
-
-
-
{PIR.intro}
-
- Modes: {PIR.modes}
-
-
- Screens: {PIR.screens}
-
-
-
-
- How it fits together: {PIR.howItFits}
-
-
{PIR.credit}
+ {sections.map((s) => (
+
+
{s.title}
+
+ {s.cols.map((col, i) => (
+
+ {col.map((p, j) => (
+
+ ))}
+ {i === s.cols.length - 1 && s.credit &&
{s.credit}
}
+
+ ))}
+
-
+ ))}
-
6-week calendar
+
{timeline.title}
- {TRACKS.map((t) => (
+ {["All", ...timeline.tracks].map((t) => (
setTrack(t)}>
{t}
))}
- {WEEKS.map((w) => {
- // A week stays "this week" through its weekend.
- const now = daysBetween(w.start, today) >= 0 && daysBetween(today, w.end) >= -2;
+ {timeline.weeks.map((w) => {
+ // A week can stay "this week" through its weekend (Berkeley's end on Fridays).
+ const now = daysBetween(w.start, today) >= 0 && daysBetween(today, w.end) >= -timeline.graceDays;
const items = w.items.filter((i) => track === "All" || i[0] === track);
return (
diff --git a/components/board/SocialsTab.tsx b/components/board/SocialsTab.tsx
index ddff1f1..c8b7c8f 100644
--- a/components/board/SocialsTab.tsx
+++ b/components/board/SocialsTab.tsx
@@ -2,21 +2,19 @@
import { useState } from "react";
import type { Social } from "@/lib/board/types";
-import { SOCIAL_KINDS, SOCIAL_MONTHS, SOCIAL_STATUSES } from "@/lib/board/static";
+import { SOCIAL_MONTHS, SOCIAL_STATUSES } from "@/lib/board/static";
+import type { ChapterConfig } from "@/lib/board/chapters";
import { canEditSocials } from "@/lib/board/roles";
import { useBoard } from "./BoardApp";
import { RowEditor, type FieldDef } from "./RowEditor";
import { D, fmt, isoDate } from "./util";
-const FILTERS = [{ key: "All", label: "All" }, ...SOCIAL_KINDS.map((k) => ({ key: k.key as string, label: k.label }))];
-const TAG: Record = { b: "Berkeley", o: "Online", org: "Org date" };
-
-const SOCIAL_FIELDS: FieldDef[] = [
+const socialFields = (kinds: ChapterConfig["socials"]["kinds"]): FieldDef[] => [
{ key: "title", label: "Name" },
{ key: "date", label: "Date", type: "date", pair: "d" },
{ key: "time", label: "Time", placeholder: "2:00–4:00pm", pair: "d" },
{ key: "location", label: "Location", placeholder: "Place name and address" },
- { key: "kind", label: "Type", type: "select", options: SOCIAL_KINDS.map((k) => ({ value: k.key, label: k.label })), pair: "k" },
+ { key: "kind", label: "Type", type: "select", options: kinds.map((k) => ({ value: k.key, label: k.label })), pair: "k" },
{ key: "status", label: "Status", type: "select", options: SOCIAL_STATUSES.map((s) => ({ value: s, label: s })), pair: "k" },
{ key: "lead", label: "Lead" },
{ key: "cost", label: "Cost" },
@@ -24,7 +22,11 @@ const SOCIAL_FIELDS: FieldDef[] = [
];
export function SocialsTab() {
- const { data, user, today } = useBoard();
+ const { chapter, data, user, today } = useBoard();
+ const cfg = chapter.socials;
+ const FILTERS = [{ key: "All", label: "All" }, ...cfg.kinds.map((k) => ({ key: k.key as string, label: k.label }))];
+ const TAG = cfg.tags;
+ const SOCIAL_FIELDS = socialFields(cfg.kinds);
const editor = canEditSocials(user.role);
const socials = data.socials;
const [filter, setFilter] = useState("All");
@@ -63,7 +65,7 @@ export function SocialsTab() {
return (
- Fall 2026 Socials
+ {cfg.heading}
{FILTERS.map((f) => (
- Add a social
+ {cfg.add}
)}
@@ -139,10 +141,10 @@ export function SocialsTab() {
{editing === "new" && (
setEditing(null)}
onSaved={(v) => jumpTo(v.date)}
/>
@@ -213,7 +215,7 @@ export function SocialsTab() {
)}
- All socials
+ {cfg.list}
{socials.filter(show).map((s) => {
const past = s.date < today;
@@ -233,7 +235,7 @@ export function SocialsTab() {
);
})}
- Status runs Idea → Planning → Confirmed → Completed.
+ {cfg.note}
);
}
diff --git a/components/board/SubmitForm.tsx b/components/board/SubmitForm.tsx
index c9ee322..b2e0f77 100644
--- a/components/board/SubmitForm.tsx
+++ b/components/board/SubmitForm.tsx
@@ -1,22 +1,26 @@
"use client";
import { useRef, useState, type FormEvent } from "react";
-import { CURRENT_ROUND } from "@/lib/board/static";
-import { MAX_ATTACHMENT_BYTES, NEED_PROOF, OTHER_TASK, TOO_BIG, hasLink } from "@/lib/board/submit-rules";
+import { MAX_ATTACHMENT_BYTES, OTHER_TASK, TOO_BIG, hasLink, isEmail } from "@/lib/board/submit-rules";
import { useBoard } from "./BoardApp";
-import { api } from "./util";
+import { apiCall, listNames } from "./util";
const short = (s: string) => (s.length > 90 ? `${s.slice(0, 88)}…` : s);
export function SubmitForm({ person, onPerson }: { person: string; onPerson: (p: string) => void }) {
- const { data, refresh } = useBoard();
+ const { chapter, round, data, refresh } = useBoard();
const formRef = useRef(null);
const [assignment, setAssignment] = useState("");
const [status, setStatus] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
- const tasks = data.assignments.filter((a) => a.round === CURRENT_ROUND.round && a.person === person);
+ const tasks = data.assignments.filter((a) => a.round === round?.round && a.person === person);
const value = tasks.some((t) => String(t.id) === assignment) || assignment === "other" ? assignment : tasks[0] ? String(tasks[0].id) : "other";
+ const member = data.members.find((m) => m.firstName === person);
+ // Reply-to is the email on file; without one, ask for it.
+ const needsEmail = !!member && !isEmail(member.email);
+ const { base, tech, techTeams } = data.recipients;
+ const goesTo = listNames(member && techTeams.includes(member.team) ? [...base, ...tech] : base);
async function submit(e: FormEvent) {
e.preventDefault();
@@ -24,19 +28,24 @@ export function SubmitForm({ person, onPerson }: { person: string; onPerson: (p:
const text = String(form.get("text") ?? "").trim();
const files = form.getAll("files").filter((f): f is File => f instanceof File && f.size > 0);
if (!person) return setStatus({ kind: "err", text: "Choose your name before submitting." });
- if (!files.length && !hasLink(text)) return setStatus({ kind: "err", text: NEED_PROOF });
+ if (needsEmail && !isEmail(String(form.get("email") ?? "").trim())) {
+ return setStatus({ kind: "err", text: "Add your email so they can reply to you." });
+ }
+ const missing = chapter.proofRequired ? !files.length && !hasLink(text) : !files.length && !text;
+ if (missing) return setStatus({ kind: "err", text: chapter.submit.needProof });
if (files.reduce((n, f) => n + f.size, 0) > MAX_ATTACHMENT_BYTES) return setStatus({ kind: "err", text: TOO_BIG });
form.set("person", person);
form.set("assignment", value);
setBusy(true);
setStatus(null);
- const err = await api("/api/board/submit", "POST", form);
+ const { error, data: reply } = await apiCall("/api/board/submit", "POST", form);
setBusy(false);
- if (err) return setStatus({ kind: "err", text: err });
+ if (error) return setStatus({ kind: "err", text: error });
+ const to = Array.isArray(reply?.to) ? listNames(reply.to as string[]) : goesTo;
setStatus({
kind: "ok",
- text: `Sent to Rana${files.length ? ` with ${files.length} file${files.length > 1 ? "s" : ""}` : ""}. It now shows under your name.`,
+ text: `Sent to ${to}${files.length ? ` with ${files.length} file${files.length > 1 ? "s" : ""}` : ""}. It now shows under your name.`,
});
formRef.current?.reset();
refresh();
@@ -52,6 +61,7 @@ export function SubmitForm({ person, onPerson }: { person: string; onPerson: (p:
onChange={(e) => {
onPerson(e.target.value);
setAssignment("");
+ setStatus(null);
}}
>
Choose your name
@@ -62,6 +72,12 @@ export function SubmitForm({ person, onPerson }: { person: string; onPerson: (p:
))}
+ {needsEmail && (
+
+ Your email (we don't have one on file; replies go here)
+
+
+ )}
Which assignment
setAssignment(e.target.value)}>
@@ -77,12 +93,13 @@ export function SubmitForm({ person, onPerson }: { person: string; onPerson: (p:
Notes or written deliverable
-
+
- Proof (required unless you paste a link above): screenshots, docs, call logs. Up to 14 MB in total.
+ {chapter.submit.filesLabel}
+ {person && This goes to {goesTo}.
}
{busy ? "Sending…" : "Submit"}
diff --git a/components/board/derive.ts b/components/board/derive.ts
index 3cd2bcf..b1c818e 100644
--- a/components/board/derive.ts
+++ b/components/board/derive.ts
@@ -1,7 +1,7 @@
"use client";
-import type { Assignment, BoardData, Member } from "@/lib/board/types";
-import { CURRENT_ROUND, UNASSIGNED } from "@/lib/board/static";
+import type { Assignment, AssignmentStatus, BoardData, Member } from "@/lib/board/types";
+import { UNASSIGNED } from "@/lib/board/static";
export type PersonTasks = {
person: string;
@@ -9,21 +9,26 @@ export type PersonTasks = {
items: Assignment[];
due: string | null;
submitted: boolean;
+ // UCLA: one status per person. With mixed task statuses, the least done.
+ status: AssignmentStatus;
};
-// Current-round tasks grouped by person, in the order they were added.
-// Tasks with no owner yet are left out (see unassignedTasks).
-export function currentRound(data: BoardData): PersonTasks[] {
+const ORDER: AssignmentStatus[] = ["pending", "missing", "excused", "submitted", "done"];
+
+// A round's tasks grouped by person, in the order they were added. Tasks
+// with no owner yet are left out (see unassignedTasks).
+export function roundTasks(data: BoardData, round: number | null): PersonTasks[] {
const map = new Map();
for (const a of data.assignments) {
- if (a.round !== CURRENT_ROUND.round || a.person === UNASSIGNED) continue;
+ if (a.round !== round || a.person === UNASSIGNED) continue;
let p = map.get(a.person);
if (!p) {
- p = { person: a.person, role: a.personRole, items: [], due: a.dueDate, submitted: false };
+ p = { person: a.person, role: a.personRole, items: [], due: a.dueDate, submitted: false, status: a.status };
map.set(a.person, p);
}
p.items.push(a);
if (a.status === "submitted" || a.status === "done") p.submitted = true;
+ if (ORDER.indexOf(a.status) < ORDER.indexOf(p.status)) p.status = a.status;
}
for (const s of data.submissions) {
const p = map.get(s.person);
@@ -32,8 +37,8 @@ export function currentRound(data: BoardData): PersonTasks[] {
return [...map.values()];
}
-// Current-round tasks with no owner yet. Only admins receive these.
-export const unassignedTasks = (data: BoardData) =>
- data.assignments.filter((a) => a.round === CURRENT_ROUND.round && a.person === UNASSIGNED);
+// Tasks with no owner yet in a round. Only admins receive these.
+export const unassignedTasks = (data: BoardData, round: number | null) =>
+ data.assignments.filter((a) => a.round === round && a.person === UNASSIGNED);
export const inLeadership = (m: Member) => m.isLead || m.tier !== null;
diff --git a/components/board/util.ts b/components/board/util.ts
index 045c808..eb0de72 100644
--- a/components/board/util.ts
+++ b/components/board/util.ts
@@ -15,18 +15,36 @@ export const daysBetween = (from: string, to: string) => Math.round((D(to).getTi
export const isoDate = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
-// Calls a /board API route. Returns an error message, or null on success.
-export async function api(path: string, method: string, body?: unknown): Promise {
+// The chapter this page belongs to. One board hub renders per page, and it
+// sets this before anything can call the API.
+let chapter = "berkeley";
+export const setApiChapter = (c: string) => {
+ chapter = c;
+};
+// Board API routes read the chapter from ?chapter=.
+export const chapterUrl = (path: string) => `${path}${path.includes("?") ? "&" : "?"}chapter=${chapter}`;
+
+// Calls a board API route for this page's chapter. Returns an error
+// message, or null on success; `data` is the parsed reply.
+export async function apiCall(path: string, method: string, body?: unknown): Promise<{ error: string | null; data: Record | null }> {
try {
- const res = await fetch(path, {
+ const res = await fetch(chapterUrl(path), {
method,
headers: body instanceof FormData ? undefined : { "content-type": "application/json" },
body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body),
});
- if (res.ok) return null;
const j = await res.json().catch(() => null);
- return (j && typeof j.error === "string" && j.error) || "Something went wrong. Try again.";
+ if (res.ok) return { error: null, data: j };
+ return { error: (j && typeof j.error === "string" && j.error) || "Something went wrong. Try again.", data: null };
} catch {
- return "Couldn't reach the server. Check your connection and try again.";
+ return { error: "Couldn't reach the server. Check your connection and try again.", data: null };
}
}
+
+export async function api(path: string, method: string, body?: unknown): Promise {
+ return (await apiCall(path, method, body)).error;
+}
+
+// "Fiona", "Fiona and Will", "Fiona, Will and Rizwaan".
+export const listNames = (names: string[]) =>
+ names.length <= 1 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
diff --git a/docs/board-ucla-preview.html b/docs/board-ucla-preview.html
new file mode 100644
index 0000000..da10441
--- /dev/null
+++ b/docs/board-ucla-preview.html
@@ -0,0 +1,971 @@
+
+
+
+
+
+
+VINdicated at UCLA
+
+
+
+
+
+
+
+ My page
+ Home
+ Board
+ Projects
+ Assignments
+ Socials
+ Meetings
+
+
+
VINDICATED
+
UCLA chapter · Board hub, Fall 2026
+
+
+
+
+
+
+
+
+
Logistics & Quick Links
+
+
+
Announcements
+
+
+
+
+
Key Dates
+
+
+
At a Glance
+
+
+
+
+
+
+
+ Board Chart
+
+
+
+ List view
+
+
+
+
+ Name Role Team Email Phone
+
+
+
+
+
+
+
+ All Projects
+
+
+
Research Language, Trust & Access The Hidden Price Gap After the Accident: Know Your Rights
+
App Dev DMV test practice Interactive quizzes Crash list and safety scores by model
+
Outreach Bruinwalk tabling Scam-awareness listings Dorm event IG Question of the Week
+
High School First-Car Survival campaign, Fountain Valley HS
+
+
+ Project Crosscheck
+
+
+
A public, side-by-side table of NHTSA and IIHS safety ratings for model years 2023–2026, built into the VINdicated site.
+
Question for v1: how often do NHTSA and IIHS ratings disagree? Which one better predicts real-world outcomes is saved for v2.
+
+
+
Data: NHTSA ratings are public. IIHS needs an approved API key; the application is in.
+
Rules: no combined safety score, no scraping iihs.org, and the public table is generated by the pipeline, never edited by hand.
+
+
+
+ Research projects
+
+
+
Language, Trust & Access: how multilingual consumers experience communication, trust and access to information at dealerships.
+
The Hidden Price Gap: what makes car pricing hard to understand: dealer add-ons, fees, "starting at" prices and monthly payment ads.
+
+
+
After the Accident: Know Your Rights: a plain-language California guide for when something goes wrong: accidents, dealer disputes, fraud, repair and financing problems.
+
+
+
+ Fall quarter timeline
+
+
+
+
+
+
+ Deliverables · Due Sun, Oct 18
+ Set at the Oct 11 board meeting. Update Fiona on your progress, and submit anything you made below.
+
+
+
+
+
+
+
+
+ Last week · due Oct 11
+
+
+
+ Submit your work
+ Preview only. Nothing is sent from this page yet. On the live site, this form emails your submission and any files to Fiona.
+
+
+
+
+
+ Fall 2026 Events
+
+ Add an event
+
+ ←
+
+ →
+
+
+
+
+ All events
+
+ Status runs Idea → Planning → Confirmed → Completed. Days marked TBD come from the outreach timeline, which plans by week.
+
+
+
+
+ Preview: pick a member to see their page. On the live site, each person signs in with their email and sees only their own page.
+
+ Viewing as
+
+
+
+
+
+
+
+ Board Meetings
+ Sundays, 6–6:30pm.
+
+
+
+
+
+
+
+
+
diff --git a/docs/vindicated-invite-preview.html b/docs/vindicated-invite-preview.html
new file mode 100644
index 0000000..a50fd1c
--- /dev/null
+++ b/docs/vindicated-invite-preview.html
@@ -0,0 +1,136 @@
+
+
+
+
+
+VINdicated Invite Email
+
+
+
+
+
+ New invite
+ Time changed
+ Cancelled
+ The invite file
+
+
+
+
+
+
+
diff --git a/lib/board/api.ts b/lib/board/api.ts
index 0e2cba6..080e00b 100644
--- a/lib/board/api.ts
+++ b/lib/board/api.ts
@@ -1,18 +1,28 @@
import "server-only";
import { boardUser, type BoardUser } from "./session";
import type { BoardRole } from "./roles";
+import { isChapterId, type ChapterId } from "./chapters";
import { ensureSetup } from "./setup";
export const notFound = () => new Response("Not found", { status: 404 });
export const bad = (message: string, status = 400) => Response.json({ error: message }, { status });
-// Returns the signed-in user, or a 404 Response when there's no valid
-// session or the user lacks one of the allowed roles.
+// The chapter a board API request is for: ?chapter=ucla, else Berkeley.
+export function chapterOf(request: Request): ChapterId | null {
+ const c = new URL(request.url).searchParams.get("chapter") ?? "berkeley";
+ return isChapterId(c) ? c : null;
+}
+
+// Returns the signed-in user of the request's chapter, or a 404 Response
+// when there's no valid session for that chapter or the user lacks one of
+// the allowed roles. Every read and write is then scoped to user.chapter.
export async function guard(
request: Request,
allowed?: BoardRole[],
): Promise {
- const user = boardUser(request.headers);
+ const chapter = chapterOf(request);
+ if (!chapter) return notFound();
+ const user = boardUser(request.headers, chapter);
if (!user) return notFound();
if (allowed && !allowed.includes(user.role)) return notFound();
if (!process.env.DATABASE_URL) return bad("The board's database isn't connected yet.", 503);
diff --git a/lib/board/chapter-server.ts b/lib/board/chapter-server.ts
new file mode 100644
index 0000000..82a830a
--- /dev/null
+++ b/lib/board/chapter-server.ts
@@ -0,0 +1,71 @@
+import "server-only";
+import type { ChapterId } from "./chapters";
+import type { BoardRole } from "./roles";
+
+// `short` is how the submit form names them: "Sent to Fiona and Will".
+type Person = { email: string; name: string; short: string };
+
+// Server-only chapter settings: which Railway variables hold each role's
+// password, and where deliverable submissions are emailed. Change the
+// recipients here; the submit route and form read them from this file.
+export type ChapterServer = {
+ cookie: string;
+ // Checked in this order, so a password shared by two roles gets the higher one.
+ passwords: [BoardRole, string][];
+ submissions: {
+ subjectPrefix: string;
+ to: Person[];
+ cc: Person[];
+ // Submitters on these teams also go to `techTo`.
+ techTeams: string[];
+ techTo: Person[];
+ };
+};
+
+export const CHAPTER_SERVER: Record = {
+ berkeley: {
+ cookie: "board_session",
+ passwords: [
+ ["admin", "BOARD_ADMIN_PASSWORD"],
+ ["socials", "BOARD_SOCIALS_PASSWORD"],
+ ["member", "BOARD_PASSWORD"],
+ ],
+ submissions: {
+ subjectPrefix: "[VINdicated Board]",
+ to: [{ email: "ranadarwich05@gmail.com", name: "Rana Darwich", short: "Rana" }],
+ cc: [],
+ techTeams: [],
+ techTo: [],
+ },
+ },
+ ucla: {
+ cookie: "board_session_ucla",
+ passwords: [
+ ["admin", "BOARD_UCLA_ADMIN_PASSWORD"],
+ ["member", "BOARD_UCLA_PASSWORD"],
+ ],
+ submissions: {
+ subjectPrefix: "[VINdicated UCLA]",
+ to: [
+ { email: "fionarhine@ucla.edu", name: "Fiona Wangsawidjaja", short: "Fiona" },
+ { email: "prawira.j.william@gmail.com", name: "William Prawira", short: "Will" },
+ ],
+ cc: [{ email: "ranadarwich05@gmail.com", name: "Rana Darwich", short: "Rana" }],
+ techTeams: ["App Dev", "Data"],
+ techTo: [{ email: "rizwaanbana@g.ucla.edu", name: "Rizwaan Bana", short: "Rizwaan" }],
+ },
+ },
+};
+
+// Who a submission from someone on `team` goes to.
+export function submissionRecipients(chapter: ChapterId, team: string) {
+ const s = CHAPTER_SERVER[chapter].submissions;
+ const tech = s.techTeams.includes(team);
+ return { to: tech ? [...s.to, ...s.techTo] : s.to, cc: s.cc };
+}
+
+// Short names for the submit form, without email addresses.
+export function recipientNames(chapter: ChapterId) {
+ const s = CHAPTER_SERVER[chapter].submissions;
+ return { base: s.to.map((p) => p.short), tech: s.techTo.map((p) => p.short), techTeams: s.techTeams };
+}
diff --git a/lib/board/chapters.ts b/lib/board/chapters.ts
new file mode 100644
index 0000000..685f680
--- /dev/null
+++ b/lib/board/chapters.ts
@@ -0,0 +1,235 @@
+// Per-chapter settings for the board hub. Berkeley lives at /board and UCLA
+// at /board/ucla; both run the same pages and components, which read their
+// differences from here. Nothing secret goes in this file: it's shipped to
+// the browser. Passwords and email recipients are in chapter-server.ts.
+
+import { KEY_DATES, PIR, PROJECTS, QUICK_LINKS, RULE, TEAM_ORDER, TERM, WEEKS } from "./static";
+import type { Round, Tab } from "./types";
+
+export const CHAPTER_IDS = ["berkeley", "ucla"] as const;
+export type ChapterId = (typeof CHAPTER_IDS)[number];
+export const isChapterId = (v: unknown): v is ChapterId => typeof v === "string" && (CHAPTER_IDS as readonly string[]).includes(v);
+
+// A paragraph with an optional bold lead-in ("Data:" …).
+export type Para = { b?: string; t: string };
+export type TimelineWeek = { n: number; start: string; end: string; label: string; goal: string; items: [track: string, text: string][] };
+
+export type ChapterConfig = {
+ id: ChapterId;
+ path: string;
+ title: string;
+ term: string;
+ loginSub: string;
+ // Who members are told to ask when something isn't set up.
+ contact: string;
+ roleLabels: Record;
+ teams: readonly string[];
+ rule: string | null;
+ proofRequired: boolean;
+ // UCLA keeps one status per person per week and starts new weeks itself.
+ weekly: boolean;
+ roundLabel: (r: Round, current: boolean) => string;
+ glance: { members: string; submissions: string; socials: string };
+ quickLinks: { label?: string; text: string; href?: string | null; tab?: Tab }[];
+ keyDates: [date: string, text: string][];
+ photos: string[];
+ socials: {
+ heading: string;
+ kinds: { key: "b" | "o"; label: string }[];
+ tags: Record;
+ add: string;
+ list: string;
+ note: string;
+ defaultLead: string;
+ };
+ projects: {
+ groups: { group: string; items: [name: string, chapter?: string][] }[];
+ sections: { title: string; cols: Para[][]; credit?: string }[];
+ timeline: { title: string; tracks: string[]; weeks: TimelineWeek[]; graceDays: number };
+ };
+ meetings: { heading: string; note: string | null; attendance: boolean };
+ submit: { note: string; placeholder: string; filesLabel: string; needProof: string };
+ myPage: { meetingLine: string | null; teamTrack: Record; teamNote: Record };
+};
+
+const shortDay = (iso: string, o: Intl.DateTimeFormatOptions) => {
+ const [y, m, d] = iso.split("-").map(Number);
+ return new Date(y, m - 1, d).toLocaleDateString("en-US", o);
+};
+
+const BERKELEY: ChapterConfig = {
+ id: "berkeley",
+ path: "/board",
+ title: "VINdicated at Berkeley",
+ term: TERM,
+ loginSub: "UC Berkeley chapter board",
+ contact: "Rana",
+ roleLabels: { admin: "admin", socials: "socials editor", member: "board member" },
+ teams: TEAM_ORDER,
+ rule: RULE,
+ proofRequired: true,
+ weekly: false,
+ roundLabel: (r, current) =>
+ current
+ ? `Round ${r.round} · Due ${shortDay(r.dueDate, { weekday: "short", month: "short", day: "numeric" })}, 11:59pm`
+ : `Round ${r.round} archive · due ${shortDay(r.dueDate, { month: "short", day: "numeric" })}`,
+ glance: { members: "members on the Berkeley board", submissions: "round 2 submissions in", socials: "socials on the calendar" },
+ quickLinks: QUICK_LINKS,
+ keyDates: KEY_DATES,
+ photos: [],
+ socials: {
+ heading: "Fall 2026 Socials",
+ kinds: [
+ { key: "b", label: "Berkeley" },
+ { key: "o", label: "Online" },
+ ],
+ tags: { b: "Berkeley", o: "Online", org: "Org date" },
+ add: "Add a social",
+ list: "All socials",
+ note: "Status runs Idea → Planning → Confirmed → Completed.",
+ defaultLead: "TBD",
+ },
+ projects: {
+ groups: PROJECTS,
+ sections: [
+ {
+ title: "Project Price Is Right",
+ cols: [
+ [{ t: PIR.intro }, { b: "Modes:", t: PIR.modes }, { b: "Screens:", t: PIR.screens }],
+ [{ b: "How it fits together:", t: PIR.howItFits }],
+ ],
+ credit: PIR.credit,
+ },
+ ],
+ timeline: { title: "6-week calendar", tracks: ["Data", "Backend", "App"], weeks: WEEKS, graceDays: 2 },
+ },
+ meetings: { heading: "Meeting Summaries & Recordings", note: null, attendance: false },
+ submit: {
+ note: "This form emails your submission and any files to Rana. Attach proof or paste a link to it in the notes.",
+ placeholder: "Paste your write-up, call notes, links, or anything Rana should read.",
+ filesLabel: "Proof (required unless you paste a link above): screenshots, docs, call logs. Up to 14 MB in total.",
+ needProof: "Add proof: attach a file or paste a link to your post, doc or PR.",
+ },
+ myPage: {
+ meetingLine: null,
+ teamTrack: { Data: "Data", Software: "App" },
+ teamNote: {},
+ },
+};
+
+const UCLA_WEEKS: TimelineWeek[] = [
+ { n: 1, label: "Sep 20 – 26", start: "2026-09-20", end: "2026-09-26", goal: "Projects locked, recruiting open", items: [["Data", "Scope locked, IIHS API application submitted, recruiting open"], ["Research", "Finalize the three projects and start finding faculty mentors"], ["Outreach", "Bruinwalk tabling: Guess the OTD Price game"], ["High school", "Research how to start a chapter at FVHS and find an advisor"]] },
+ { n: 2, label: "Sep 27 – Oct 3", start: "2026-09-27", end: "2026-10-03", goal: "Teams in place", items: [["Data", "Team of 4 in place; NHTSA data loaded and its gaps documented"], ["Research", "Recruit and onboard members; intro to qualitative research and ethics"], ["Outreach", "IG Question of the Week; design \"too good to be true\" listings"], ["High school", "Start the chapter process and recruit through connections"]] },
+ { n: 3, label: "Oct 4 – 10", start: "2026-10-04", end: "2026-10-10", goal: "First real work", items: [["Data", "A canonical vehicle list and a pipeline every member can run"], ["Research", "Preliminary research: what's known, the debates, the gaps"], ["Outreach", "Board intro video; tape fake listings around campus"], ["High school", "Finalize materials and pick a first student activity"]] },
+ { n: 4, label: "Oct 11 – 17", start: "2026-10-11", end: "2026-10-17", goal: "Methods and first match", items: [["Data", "First working match between NHTSA and IIHS; output shape locked"], ["Research", "Set the method, source criteria and research plan"], ["Outreach", "Bruinwalk tabling: sticky-note whiteboard"], ["High school", "Launch first resources and a short student survey"]] },
+ { n: 5, label: "Oct 18 – 24", start: "2026-10-18", end: "2026-10-24", goal: "Collect", items: [["Data", "Go / no-go on IIHS access; one table with both programs' ratings"], ["Research", "Collect sources: literature, ads, complaints, legal documents"], ["Outreach", "Midterm break: event recap on IG, plan interviews"], ["High school", "First student-facing table or activity"]] },
+ { n: 6, label: "Oct 25 – 31", start: "2026-10-25", end: "2026-10-31", goal: "First findings", items: [["Data", "First findings: how often the programs disagree, and where"], ["Research", "Analyze materials for themes and patterns"], ["Outreach", "Quick on-campus interviews for IG; plan the dorm event"], ["High school", "Review results; ask the board to fact-check materials"]] },
+ { n: 7, label: "Nov 1 – 7", start: "2026-11-01", end: "2026-11-07", goal: "Broader findings", items: [["Data", "One finding no existing car site has"], ["Research", "Move from observations to broader findings"], ["Outreach", "Dorm event: car-buying basics and DIY air freshener sachets"], ["High school", "Reach out to local driving schools and partners"]] },
+ { n: 8, label: "Nov 8 – 14", start: "2026-11-08", end: "2026-11-14", goal: "MVP", items: [["Data", "MVP running end to end, rough but nothing faked"], ["Research", "Turn findings into practical takeaways"], ["Outreach", "Edit interviews"], ["High school", "Develop one outside partnership"]] },
+ { n: 9, label: "Nov 15 – 21", start: "2026-11-15", end: "2026-11-21", goal: "Write it up", items: [["Data", "MVP complete, frozen, documented and reproducible"], ["Research", "Write and revise the final product; peer review"], ["Outreach", "Edit interviews"], ["High school", "Plan a bigger second-semester event"]] },
+ { n: 10, label: "Nov 22 – 28", start: "2026-11-22", end: "2026-11-28", goal: "Demo and wrap-up", items: [["Data", "Demo and writeup"], ["Research", "Limitations and future research"], ["Outreach", "Post interview reels"], ["High school", "Compile results from the first 10 weeks"]] },
+];
+
+const UCLA: ChapterConfig = {
+ id: "ucla",
+ path: "/board/ucla",
+ title: "VINdicated at UCLA",
+ term: "UCLA chapter · Board hub, Fall 2026",
+ loginSub: "UCLA chapter board hub",
+ contact: "Fiona",
+ roleLabels: { admin: "admin", member: "member" },
+ teams: ["Data", "Research", "App Dev", "Design", "Technical Writing", "High School"],
+ rule: null,
+ proofRequired: false,
+ weekly: true,
+ roundLabel: (r, current) =>
+ current
+ ? `Deliverables · Due ${shortDay(r.dueDate, { weekday: "short", month: "short", day: "numeric" })}`
+ : `Week due ${shortDay(r.dueDate, { weekday: "short", month: "short", day: "numeric" })}`,
+ glance: { members: "people on the UCLA chart", submissions: "deliverable sets submitted", socials: "events on the calendar" },
+ quickLinks: [
+ { text: "Member Drive 2025–26", href: null },
+ { text: "2025–2026 Curriculum", href: null },
+ { text: "Google Drive for photos", href: null },
+ { text: "Outreach spreadsheets", href: null },
+ { label: "Slack", text: "vindicatedgroup", href: "https://join.slack.com/t/vindicatedgroup/shared_invite/zt-4bavmrqhi-XJb9OIt4aRq6SpGaNyoKtg" },
+ { label: "Board meetings", text: "Sundays, 6–6:30pm" },
+ ],
+ keyDates: [
+ ["2026-10-18", "Oct 11 deliverables due"],
+ ["2026-10-21", "Skribbl night with Berkeley, 9pm"],
+ ["2026-10-25", "Data: first findings (week 6)"],
+ ["2026-11-08", "Data: MVP running end to end (week 8)"],
+ ["2026-11-22", "Crosscheck demo and research writeups (week 10)"],
+ ],
+ photos: [1, 2, 3, 4, 5, 6].map((n) => `/board/ucla/board-${n}.webp`),
+ socials: {
+ heading: "Fall 2026 Events",
+ kinds: [
+ { key: "b", label: "On campus" },
+ { key: "o", label: "Online" },
+ ],
+ tags: { b: "On campus", o: "Online", org: "Date" },
+ add: "Add an event",
+ list: "All events",
+ note: "Status runs Idea → Planning → Confirmed → Completed. Days marked TBD come from the outreach timeline, which plans by week.",
+ defaultLead: "",
+ },
+ projects: {
+ groups: [
+ { group: "Data", items: [["Project Crosscheck"]] },
+ { group: "Research", items: [["Language, Trust & Access"], ["The Hidden Price Gap"], ["After the Accident: Know Your Rights"]] },
+ { group: "App Dev", items: [["DMV test practice"], ["Interactive quizzes"], ["Crash list and safety scores by model"]] },
+ { group: "Outreach", items: [["Bruinwalk tabling"], ["Scam-awareness listings"], ["Dorm event"], ["IG Question of the Week"]] },
+ { group: "High School", items: [["First-Car Survival campaign, Fountain Valley HS"]] },
+ ],
+ sections: [
+ {
+ title: "Project Crosscheck",
+ cols: [
+ [
+ { t: "A public, side-by-side table of NHTSA and IIHS safety ratings for model years 2023–2026, built into the VINdicated site." },
+ { b: "Question for v1:", t: "how often do NHTSA and IIHS ratings disagree? Which one better predicts real-world outcomes is saved for v2." },
+ ],
+ [
+ { b: "Data:", t: "NHTSA ratings are public. IIHS needs an approved API key; the application is in." },
+ { b: "Rules:", t: "no combined safety score, no scraping iihs.org, and the public table is generated by the pipeline, never edited by hand." },
+ ],
+ ],
+ },
+ {
+ title: "Research projects",
+ cols: [
+ [
+ { b: "Language, Trust & Access:", t: "how multilingual consumers experience communication, trust and access to information at dealerships." },
+ { b: "The Hidden Price Gap:", t: "what makes car pricing hard to understand: dealer add-ons, fees, \"starting at\" prices and monthly payment ads." },
+ ],
+ [
+ { b: "After the Accident: Know Your Rights:", t: "a plain-language California guide for when something goes wrong: accidents, dealer disputes, fraud, repair and financing problems." },
+ ],
+ ],
+ },
+ ],
+ timeline: { title: "Fall quarter timeline", tracks: ["Data", "Research", "Outreach", "High school"], weeks: UCLA_WEEKS, graceDays: 0 },
+ },
+ meetings: { heading: "Board Meetings", note: "Sundays, 6–6:30pm.", attendance: true },
+ submit: {
+ note: "This form emails your update and any files to the board. Attach files or paste links in the notes.",
+ placeholder: "Paste your update, links, or anything Fiona should read.",
+ filesLabel: "Files: docs, screenshots, designs. Up to 14 MB in total.",
+ needProof: "Add an update or attach a file before submitting.",
+ },
+ myPage: {
+ meetingLine: "Board meeting, Sundays 6–6:30pm",
+ teamTrack: { Data: "Data", Research: "Research", "High School": "High school" },
+ teamNote: {
+ "App Dev": "App ideas: DMV test practice, interactive quizzes, and a crash list with safety scores by model.",
+ Design: "Start or continue your plan, and update Fiona on progress.",
+ "Technical Writing": "Start or continue your plan, and update Fiona on progress.",
+ Executive: "Board operations: Sunday board meetings, deliverables, collabs and the signatory workshop.",
+ },
+ },
+};
+
+export const CHAPTERS: Record = { berkeley: BERKELEY, ucla: UCLA };
+export const chapterConfig = (id: ChapterId) => CHAPTERS[id];
diff --git a/lib/board/data.ts b/lib/board/data.ts
index 6561b96..4ea62db 100644
--- a/lib/board/data.ts
+++ b/lib/board/data.ts
@@ -1,34 +1,44 @@
import "server-only";
import { db } from "./db";
import { ensureSetup } from "./setup";
-import type { BoardRole } from "./roles";
+import { recipientNames } from "./chapter-server";
+import type { BoardUser } from "./session";
import { UNASSIGNED } from "./static";
import type { BoardData } from "./types";
-// Tasks with no owner yet go to admins only; everyone else never gets them.
-export async function loadBoard(role: BoardRole): Promise {
+// Everything one chapter's hub shows, for the signed-in user. Members never
+// receive tasks with no owner yet or admins-only announcements.
+export async function loadBoard(user: BoardUser): Promise {
await ensureSetup();
const sql = db();
- const [members, assignments, submissions, socials, announcements, meetings] = await Promise.all([
+ const c = user.chapter;
+ const admin = user.role === "admin";
+ const [rounds, members, assignments, submissions, socials, announcements, meetings] = await Promise.all([
+ sql`SELECT round, due_date AS "dueDate", is_current AS "isCurrent", show_status AS "showStatus", note
+ FROM board_rounds WHERE chapter = ${c} ORDER BY round DESC`,
sql`SELECT id, name, first_name AS "firstName", role, team, also_team AS "alsoTeam", tier,
is_lead AS "isLead", email, phone
- FROM board_members ORDER BY sort, id`,
+ FROM board_members WHERE chapter = ${c} ORDER BY sort, id`,
sql`SELECT id, round, person, person_role AS "personRole", task, proof_required AS proof,
due_date AS "dueDate", status, status_note AS "statusNote"
- FROM board_assignments ${role === "admin" ? sql`` : sql`WHERE person <> ${UNASSIGNED}`}
+ FROM board_assignments WHERE chapter = ${c} ${admin ? sql`` : sql`AND person <> ${UNASSIGNED}`}
ORDER BY round DESC, sort, id`,
sql`SELECT id, person, assignment_id AS "assignmentId", task_label AS "taskLabel", filenames,
created_at AS "createdAt"
- FROM board_submissions ORDER BY created_at`,
+ FROM board_submissions WHERE chapter = ${c} ORDER BY created_at`,
sql`SELECT id, date, time, title, location, kind, lead, status, cost, note
- FROM board_socials ORDER BY date, id`,
- sql`SELECT id, title, body, posted_on AS "postedOn", link_tab AS "linkTab", link_label AS "linkLabel"
- FROM board_announcements ORDER BY sort, posted_on DESC, id DESC`,
- sql`SELECT id, date, title, summary, recording_url AS "recordingUrl"
- FROM board_meetings ORDER BY date DESC, id DESC`,
+ FROM board_socials WHERE chapter = ${c} ORDER BY date, id`,
+ sql`SELECT id, title, body, posted_on AS "postedOn", link_tab AS "linkTab", link_label AS "linkLabel",
+ admins_only AS "adminsOnly"
+ FROM board_announcements WHERE chapter = ${c} ${admin ? sql`` : sql`AND NOT admins_only`}
+ ORDER BY sort, posted_on DESC, id DESC`,
+ sql`SELECT id, date, title, summary, absent, recording_url AS "recordingUrl"
+ FROM board_meetings WHERE chapter = ${c} ORDER BY date DESC, id DESC`,
]);
const plain = (rows: unknown) => JSON.parse(JSON.stringify(rows)) as T;
return {
+ rounds: plain(rounds),
+ recipients: recipientNames(c),
members: plain(members),
assignments: plain(assignments),
submissions: plain(submissions),
diff --git a/lib/board/editable.ts b/lib/board/editable.ts
index 0d86fec..97174df 100644
--- a/lib/board/editable.ts
+++ b/lib/board/editable.ts
@@ -1,8 +1,10 @@
import "server-only";
-import { TABLES, type TableDef } from "./tables";
+import { TABLES, oneOf, type TableDef } from "./tables";
import type { BoardRole } from "./roles";
+import { chapterConfig, type ChapterId } from "./chapters";
-// Which roles may change each table through /api/board/[table].
+// Which roles may change each table through /api/board/[table]. UCLA has no
+// socials role, so only its admins edit events.
const EDITORS: Record = {
socials: ["admin", "socials"],
assignments: ["admin"],
@@ -11,12 +13,16 @@ const EDITORS: Record = {
members: ["admin"],
};
-export function editable(name: string): { def: TableDef; roles: BoardRole[]; name: keyof typeof TABLES } | null {
+export function editable(name: string, chapter: ChapterId): { def: TableDef; roles: BoardRole[]; name: keyof typeof TABLES } | null {
if (!Object.hasOwn(TABLES, name)) return null;
const key = name as keyof typeof TABLES;
- return { def: TABLES[key], roles: EDITORS[key], name: key };
+ let def: TableDef = TABLES[key];
+ if (key === "members") {
+ const teams = ["Executive", ...chapterConfig(chapter).teams];
+ def = { ...def, fields: { ...def.fields, team: { column: "team", check: oneOf(teams, "a team") } } };
+ }
+ return { def, roles: EDITORS[key], name: key };
}
-
// Members store a first name for matching assignments and submissions.
export function derived(name: string, body: Record): Record {
if (name === "members" && typeof body.name === "string" && body.name.trim()) {
diff --git a/lib/board/roles.ts b/lib/board/roles.ts
index 4ea66cd..4530e28 100644
--- a/lib/board/roles.ts
+++ b/lib/board/roles.ts
@@ -1,26 +1,11 @@
-// Who can do what on /board. People sign in with one of three passwords,
-// all set as environment variables in Railway:
-// BOARD_PASSWORD everyone on the board: view and submit
-// BOARD_SOCIALS_PASSWORD socials editor (no one yet; set it to add one): also add/edit/delete socials
-// BOARD_ADMIN_PASSWORD admin (Rana): edit everything
-
-// Where assignment submissions are emailed.
-export const SUBMISSIONS_INBOX = { email: "ranadarwich05@gmail.com", name: "Rana Darwich" };
+// Who can do what on the board hub. People sign in with a shared password
+// for their chapter; which Railway variable holds which role's password is
+// set per chapter in chapter-server.ts:
+// Berkeley BOARD_ADMIN_PASSWORD (Rana), BOARD_SOCIALS_PASSWORD (socials
+// editor; no one yet, leave unset), BOARD_PASSWORD (everyone)
+// UCLA BOARD_UCLA_ADMIN_PASSWORD (Fiona and Will), BOARD_UCLA_PASSWORD
export type BoardRole = "admin" | "socials" | "member";
-export const ROLE_LABEL: Record = {
- admin: "admin",
- socials: "socials editor",
- member: "board member",
-};
-
-// Checked in this order, so a password shared by two roles gets the higher one.
-export const ROLE_PASSWORD_ENV: [BoardRole, string][] = [
- ["admin", "BOARD_ADMIN_PASSWORD"],
- ["socials", "BOARD_SOCIALS_PASSWORD"],
- ["member", "BOARD_PASSWORD"],
-];
-
export const canEditSocials = (role: BoardRole) => role === "admin" || role === "socials";
export const isAdmin = (role: BoardRole) => role === "admin";
diff --git a/lib/board/session.ts b/lib/board/session.ts
index a0307b0..f8c937a 100644
--- a/lib/board/session.ts
+++ b/lib/board/session.ts
@@ -1,27 +1,31 @@
import "server-only";
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
-import { ROLE_PASSWORD_ENV, type BoardRole } from "./roles";
+import type { ChapterId } from "./chapters";
+import { CHAPTER_SERVER } from "./chapter-server";
+import type { BoardRole } from "./roles";
-// Password sign-in for /board. A correct password sets a signed cookie
-// that holds the role and an expiry. The cookie also carries a short
+// Password sign-in for the board hub. A correct password sets a signed
+// cookie that holds the chapter, the role and an expiry. Each chapter has
+// its own cookie and only checks its own passwords, so a Berkeley password
+// never opens UCLA or the reverse. The cookie also carries a short
// fingerprint of the password it came from, so changing a password in
// Railway signs out everyone who used the old one.
-export const SESSION_COOKIE = "board_session";
export const SESSION_DAYS = 30;
-export type BoardUser = { role: BoardRole };
+export type BoardUser = { role: BoardRole; chapter: ChapterId };
const sha = (s: string) => createHash("sha256").update(s).digest();
-const fingerprint = (password: string) => sha(`board:${password}`).toString("base64url").slice(0, 12);
+const fingerprint = (chapter: ChapterId, password: string) =>
+ sha(`board:${chapter}:${password}`).toString("base64url").slice(0, 12);
function secret(): string | null {
const s = process.env.BOARD_SESSION_SECRET?.trim();
return s && s.length >= 32 ? s : null;
}
-function passwordFor(role: BoardRole): string | null {
- const env = ROLE_PASSWORD_ENV.find(([r]) => r === role)?.[1];
+function passwordFor(chapter: ChapterId, role: BoardRole): string | null {
+ const env = CHAPTER_SERVER[chapter].passwords.find(([r]) => r === role)?.[1];
const p = env ? process.env[env]?.trim() : "";
return p ? p : null;
}
@@ -32,62 +36,66 @@ const sign = (payload: string, key: string) => createHmac("sha256", key).update(
// of a password matched.
const same = (a: string, b: string) => timingSafeEqual(sha(a), sha(b));
-// Why sign-in can't work yet, for the server log, or null when it can.
-export function setupProblem(): string | null {
+// Why sign-in can't work yet for a chapter, for the server log, or null.
+export function setupProblem(chapter: ChapterId): string | null {
if (!secret()) return "BOARD_SESSION_SECRET is missing or shorter than 32 characters.";
- if (!ROLE_PASSWORD_ENV.some(([role]) => passwordFor(role))) {
- return "No board password is set (BOARD_PASSWORD, BOARD_SOCIALS_PASSWORD or BOARD_ADMIN_PASSWORD).";
+ const vars = CHAPTER_SERVER[chapter].passwords;
+ if (!vars.some(([role]) => passwordFor(chapter, role))) {
+ return `No ${chapter} board password is set (${vars.map(([, v]) => v).join(", ")}).`;
}
return null;
}
-// Returns the role a password unlocks, or null.
-export function roleForPassword(attempt: string): BoardRole | null {
+// Returns the role a password unlocks for this chapter, or null.
+export function roleForPassword(chapter: ChapterId, attempt: string): BoardRole | null {
let found: BoardRole | null = null;
- for (const [role] of ROLE_PASSWORD_ENV) {
- const p = passwordFor(role);
+ for (const [role] of CHAPTER_SERVER[chapter].passwords) {
+ const p = passwordFor(chapter, role);
// Keep comparing after a match so every attempt takes the same time.
if (p && same(attempt, p) && !found) found = role;
}
return found;
}
-export function createSession(role: BoardRole): string | null {
+export function createSession(chapter: ChapterId, role: BoardRole): string | null {
const key = secret();
- const password = passwordFor(role);
+ const password = passwordFor(chapter, role);
if (!key || !password) return null;
const exp = Math.floor(Date.now() / 1000) + SESSION_DAYS * 86400;
- const payload = Buffer.from(JSON.stringify({ r: role, e: exp, f: fingerprint(password) })).toString("base64url");
+ const payload = Buffer.from(JSON.stringify({ c: chapter, r: role, e: exp, f: fingerprint(chapter, password) })).toString("base64url");
return `${payload}.${sign(payload, key)}`;
}
-function readCookie(headers: Headers): string | null {
- const m = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`).exec(headers.get("cookie") ?? "");
+function readCookie(headers: Headers, name: string): string | null {
+ const m = new RegExp(`(?:^|;\\s*)${name}=([^;]+)`).exec(headers.get("cookie") ?? "");
return m ? m[1] : null;
}
-// The signed-in board user for this request, or null.
-export function boardUser(headers: Headers): BoardUser | null {
+// The signed-in user of this chapter for this request, or null.
+export function boardUser(headers: Headers, chapter: ChapterId): BoardUser | null {
const key = secret();
- const token = readCookie(headers);
+ const token = readCookie(headers, CHAPTER_SERVER[chapter].cookie);
if (!key || !token) return null;
const [payload, sig] = token.split(".");
if (!payload || !sig) return null;
const expected = sign(payload, key);
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
try {
- const { r, e, f } = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
- if (r !== "admin" && r !== "socials" && r !== "member") return null;
+ const { c, r, e, f } = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
+ // Sessions from before chapters existed have no `c` and were Berkeley's.
+ if ((c ?? "berkeley") !== chapter) return null;
+ if (!CHAPTER_SERVER[chapter].passwords.some(([role]) => role === r)) return null;
if (typeof e !== "number" || e < Date.now() / 1000) return null;
- const password = passwordFor(r);
- if (!password || f !== fingerprint(password)) return null;
- return { role: r };
+ const password = passwordFor(chapter, r);
+ if (!password) return null;
+ const ok = f === fingerprint(chapter, password) || (c === undefined && f === sha(`board:${password}`).toString("base64url").slice(0, 12));
+ return ok ? { role: r, chapter } : null;
} catch {
return null;
}
}
-export function sessionCookie(value: string, maxAgeSeconds: number) {
+export function sessionCookie(chapter: ChapterId, value: string, maxAgeSeconds: number) {
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
- return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secure}`;
+ return `${CHAPTER_SERVER[chapter].cookie}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secure}`;
}
diff --git a/lib/board/setup.ts b/lib/board/setup.ts
index 2c06601..0c324c1 100644
--- a/lib/board/setup.ts
+++ b/lib/board/setup.ts
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { db } from "./db";
import starter from "./starter-data.json";
+import ucla from "./starter-ucla.json";
// Makes the board database ready on first use, so nobody has to run
// commands: creates the tables (scripts/board-schema.sql, safe to re-run)
@@ -25,8 +26,8 @@ async function setup() {
const schema = readFileSync(join(process.cwd(), "scripts", "board-schema.sql"), "utf8");
await sql.unsafe(schema);
- const empty = async (table: string) => {
- const [{ n }] = await sql`SELECT count(*)::int AS n FROM ${sql(table)}`;
+ const empty = async (table: string, chapter = "berkeley") => {
+ const [{ n }] = await sql`SELECT count(*)::int AS n FROM ${sql(table)} WHERE chapter = ${chapter}`;
return n === 0;
};
@@ -56,6 +57,13 @@ async function setup() {
for (const m of starter.meetings) await sql`INSERT INTO board_meetings ${sql(m)}`;
}
await octoberUpdates(sql);
+ await allowExcused(sql);
+ const [{ n: berkeleyRounds }] = await sql`SELECT count(*)::int AS n FROM board_rounds WHERE chapter = 'berkeley'`;
+ if (berkeleyRounds === 0) {
+ await sql`INSERT INTO board_rounds (chapter, round, due_date, is_current) VALUES
+ ('berkeley', 1, '2026-09-26', false), ('berkeley', 2, ${starter.dueRound2}, true)`;
+ }
+ await seedUcla(sql, empty);
}
const OLD_IAN = "Start the /board page on getvindicated.org from this preview";
@@ -65,16 +73,16 @@ const OLD_HALIMA = ["Follow up with Cal TV and set a collaboration date", "Messa
// database still has the old ones, swap them for the new ones once.
async function retaskRound2(sql: ReturnType) {
const [old] = await sql`SELECT id FROM board_assignments
- WHERE round = 2 AND person = 'Ian' AND task = ${OLD_IAN}`;
+ WHERE chapter = 'berkeley' AND round = 2 AND person = 'Ian' AND task = ${OLD_IAN}`;
if (!old) return;
const tasks = (name: string) =>
(starter.round2 as [string, string, [string, string][]][]).find(([p]) => p === name)![2];
await sql.begin(async (tx) => {
const ian = tasks("Ian");
await tx`UPDATE board_assignments SET task = ${ian[1][0]}, proof_required = ${ian[1][1]} WHERE id = ${old.id}`;
- await tx`UPDATE board_assignments SET person_role = 'Software Lead' WHERE round = 2 AND person = 'Ian'`;
+ await tx`UPDATE board_assignments SET person_role = 'Software Lead' WHERE chapter = 'berkeley' AND round = 2 AND person = 'Ian'`;
- const halima = await tx`SELECT id, task FROM board_assignments WHERE round = 2 AND person = 'Halima' ORDER BY sort, id`;
+ const halima = await tx`SELECT id, task FROM board_assignments WHERE chapter = 'berkeley' AND round = 2 AND person = 'Halima' ORDER BY sort, id`;
const stale = halima.filter((r) => OLD_HALIMA.some((t) => r.task.startsWith(t)));
if (stale.length) {
const [task, proof] = tasks("Halima")[0];
@@ -101,18 +109,18 @@ async function octoberUpdates(sql: ReturnType) {
// Pierce
await tx`DELETE FROM board_members
- WHERE lower(email) = 'pierce.kosobayashi@berkeley.edu' OR name ILIKE '%Kosobayashi%'`;
- await tx`DELETE FROM board_submissions WHERE person = 'Pierce'`;
- await tx`DELETE FROM board_assignments WHERE person = 'Pierce'`;
+ WHERE chapter = 'berkeley' AND (lower(email) = 'pierce.kosobayashi@berkeley.edu' OR name ILIKE '%Kosobayashi%')`;
+ await tx`DELETE FROM board_submissions WHERE chapter = 'berkeley' AND person = 'Pierce'`;
+ await tx`DELETE FROM board_assignments WHERE chapter = 'berkeley' AND person = 'Pierce'`;
// His round 2 tasks: Daryen's court booking, then the unowned three.
const add = async (person: string, task: string, proof: string, role: string, sort: number) => {
- const [exists] = await tx`SELECT 1 FROM board_assignments WHERE round = 2 AND person = ${person} AND task = ${task}`;
+ const [exists] = await tx`SELECT 1 FROM board_assignments WHERE chapter = 'berkeley' AND round = 2 AND person = ${person} AND task = ${task}`;
if (exists) return;
await tx`INSERT INTO board_assignments (round, person, person_role, task, proof_required, due_date, sort)
VALUES (2, ${person}, ${role}, ${task}, ${proof}, ${starter.dueRound2}, ${sort})`;
};
- const [{ max }] = await tx`SELECT coalesce(max(sort), 0) AS max FROM board_assignments WHERE round = 2 AND person = 'Daryen'`;
+ const [{ max }] = await tx`SELECT coalesce(max(sort), 0) AS max FROM board_assignments WHERE chapter = 'berkeley' AND round = 2 AND person = 'Daryen'`;
const [, daryenRole, daryen] = tasksOf("Daryen");
const court = daryen[daryen.length - 1];
await add("Daryen", court[0], court[1], daryenRole, max + 1);
@@ -121,18 +129,66 @@ async function octoberUpdates(sql: ReturnType) {
// Roles on the chart
await tx`UPDATE board_members SET role = 'Outreach Lead · Social Media', team = 'Outreach', is_lead = true, tier = NULL
- WHERE name ILIKE 'Chloe%Lin'`;
+ WHERE chapter = 'berkeley' AND name ILIKE 'Chloe%Lin'`;
await tx`UPDATE board_members SET role = 'Research Analyst', team = 'Research & Legal', is_lead = false, tier = NULL
- WHERE name ILIKE 'Rami%Dakak'`;
+ WHERE chapter = 'berkeley' AND name ILIKE 'Rami%Dakak'`;
await tx`UPDATE board_members SET role = 'President · Research Lead', team = 'Executive', tier = 0, also_team = 'Research & Legal'
- WHERE name ILIKE 'Rana%Darwich'`;
+ WHERE chapter = 'berkeley' AND name ILIKE 'Rana%Darwich'`;
await tx`UPDATE board_members SET role = 'Software Lead', team = 'Software', is_lead = true, tier = NULL
- WHERE name ILIKE 'Ian%Houssaye'`;
+ WHERE chapter = 'berkeley' AND name ILIKE 'Ian%Houssaye'`;
// Socials: exactly the starter list, and no more UCSC x UCB type.
- await tx`DELETE FROM board_socials`;
+ await tx`DELETE FROM board_socials WHERE chapter = 'berkeley'`;
for (const s of starter.socials) await tx`INSERT INTO board_socials ${tx(s)}`;
await tx`ALTER TABLE board_socials DROP CONSTRAINT IF EXISTS board_socials_kind_check`;
await tx`ALTER TABLE board_socials ADD CONSTRAINT board_socials_kind_check CHECK (kind IN ('b', 'o', 'org'))`;
});
}
+
+// Adds the "excused" status (UCLA) to a database made before it existed.
+async function allowExcused(sql: ReturnType) {
+ await sql.begin(async (tx) => {
+ const [first] = await tx`INSERT INTO board_changes (name) VALUES ('2026-10-13-excused-status')
+ ON CONFLICT DO NOTHING RETURNING name`;
+ if (!first) return;
+ await tx`ALTER TABLE board_assignments DROP CONSTRAINT IF EXISTS board_assignments_status_check`;
+ await tx`ALTER TABLE board_assignments ADD CONSTRAINT board_assignments_status_check
+ CHECK (status IN ('pending', 'submitted', 'done', 'missing', 'excused'))`;
+ });
+}
+
+// UCLA's starting data from docs/board-ucla-preview.html: the deliverables
+// Fiona set on Oct 11 (due Oct 18), the Oct 4 week as an archive with no
+// status, events, announcements and meetings. Members are imported on the
+// Board tab, like Berkeley's.
+async function seedUcla(sql: ReturnType, empty: (table: string, chapter?: string) => Promise) {
+ const [{ n }] = await sql`SELECT count(*)::int AS n FROM board_rounds WHERE chapter = 'ucla'`;
+ if (n === 0) {
+ for (const r of ucla.rounds) await sql`INSERT INTO board_rounds ${sql({ ...r, chapter: "ucla" })}`;
+ }
+ if (await empty("board_assignments", "ucla")) {
+ const due = (round: number) => ucla.rounds.find((r) => r.round === round)!.due_date;
+ let sort = 0;
+ for (const [person, role, tasks] of ucla.current as [string, string, string[]][]) {
+ for (const task of tasks) {
+ await sql`INSERT INTO board_assignments (chapter, round, person, person_role, task, due_date, sort)
+ VALUES ('ucla', 2, ${person}, ${role}, ${task}, ${due(2)}, ${sort++})`;
+ }
+ }
+ for (const [person, role, task] of ucla.archive as [string, string, string][]) {
+ await sql`INSERT INTO board_assignments (chapter, round, person, person_role, task, due_date, sort)
+ VALUES ('ucla', 1, ${person}, ${role}, ${task}, ${due(1)}, ${sort++})`;
+ }
+ }
+ if (await empty("board_socials", "ucla")) {
+ for (const e of ucla.events) await sql`INSERT INTO board_socials ${sql({ ...e, chapter: "ucla" })}`;
+ }
+ if (await empty("board_announcements", "ucla")) {
+ for (const [i, a] of ucla.announcements.entries()) {
+ await sql`INSERT INTO board_announcements ${sql({ ...a, sort: i, chapter: "ucla" })}`;
+ }
+ }
+ if (await empty("board_meetings", "ucla")) {
+ for (const m of ucla.meetings) await sql`INSERT INTO board_meetings ${sql({ ...m, chapter: "ucla" })}`;
+ }
+}
diff --git a/lib/board/starter-ucla.json b/lib/board/starter-ucla.json
new file mode 100644
index 0000000..3907379
--- /dev/null
+++ b/lib/board/starter-ucla.json
@@ -0,0 +1,267 @@
+{
+ "rounds": [
+ {
+ "round": 1,
+ "due_date": "2026-10-11",
+ "is_current": false,
+ "show_status": false,
+ "note": ""
+ },
+ {
+ "round": 2,
+ "due_date": "2026-10-18",
+ "is_current": true,
+ "show_status": true,
+ "note": "Set at the Oct 11 board meeting. Update Fiona on your progress, and submit anything you made below."
+ }
+ ],
+ "current": [
+ [
+ "Fiona",
+ "President",
+ [
+ "Make sure everyone is doing their work and manage members",
+ "Sign up for the SOLE signatory workshop (sole.ucla.edu/dayosole)"
+ ]
+ ],
+ [
+ "Will",
+ "Internal Vice President",
+ [
+ "Send the when2meet for weekly in-person general meetings; everyone fills it out by Wednesday night, then tell Fiona when everyone is available",
+ "Add Nikhil to all board chats, weekly board meetings and the Drive",
+ "Sign up for the SOLE signatory workshop (sole.ucla.edu/dayosole)"
+ ]
+ ],
+ [
+ "Ayat",
+ "External Vice President",
+ [
+ "Update and manage Instagram, and mass follow to grow followers",
+ "Reach out to other clubs about collaborating or featuring us on their page",
+ "Ask consulting and data clubs (DataRes consulting, NSDC, Creative Labs UCLA) whether we can be one of their projects",
+ "Update Fiona on collab progress and cc her on every email",
+ "Sign up for the SOLE signatory workshop (sole.ucla.edu/dayosole)"
+ ]
+ ],
+ [
+ "Rizwaan",
+ "Software Lead",
+ [
+ "Urgent: set a meeting with Nikhil to onboard him, covering what he needs to do and the project specifics"
+ ]
+ ],
+ [
+ "Ray",
+ "Data Lead",
+ [
+ "Start week two of your plan",
+ "Update Fiona on week 1 progress",
+ "Keep building your team through people you know who'll want to work with you"
+ ]
+ ],
+ [
+ "Fernando",
+ "Research Lead",
+ [
+ "Start week two of your plan",
+ "Update Fiona on week 1 progress",
+ "Keep building your team through people you know who'll want to work with you"
+ ]
+ ],
+ [
+ "Celine",
+ "Design Lead",
+ [
+ "Start week one of your plan",
+ "Update Fiona on week 1 progress",
+ "Keep building your team through people you know who'll want to work with you"
+ ]
+ ],
+ [
+ "Brisa",
+ "Technical Writing Lead",
+ [
+ "Start week two of your plan",
+ "Update Fiona on week 1 progress",
+ "Keep building your team through people you know who'll want to work with you"
+ ]
+ ],
+ [
+ "Rouqaya",
+ "High School Program",
+ [
+ "Update Fiona on progress at the high school"
+ ]
+ ]
+ ],
+ "archive": [
+ [
+ "Fiona",
+ "President",
+ "Manage applications and the club application, signatory workshop"
+ ],
+ [
+ "Will",
+ "Internal Vice President",
+ "Manage applications and the club application, signatory workshop"
+ ],
+ [
+ "Ayat",
+ "External Vice President",
+ "Instagram, club collabs, signatory workshop"
+ ],
+ [
+ "Rizwaan",
+ "Software Lead",
+ "Onboard Nikhil"
+ ],
+ [
+ "Ray",
+ "Data Lead",
+ "Start week one, pick your team"
+ ],
+ [
+ "Fernando",
+ "Research Lead",
+ "Start week one, pick your team"
+ ],
+ [
+ "Celine",
+ "Design Lead",
+ "Start week one, pick your team"
+ ],
+ [
+ "Brisa",
+ "Technical Writing Lead",
+ "Start week one, pick your team"
+ ]
+ ],
+ "events": [
+ {
+ "date": "2026-10-12",
+ "time": "Day TBD (week 4)",
+ "title": "Bruinwalk tabling",
+ "location": "Bruin Walk",
+ "kind": "b",
+ "lead": "Ayat",
+ "status": "Planning",
+ "cost": "Sticky notes and a whiteboard",
+ "note": "Interactive whiteboard where students share car experiences, questions and concerns on sticky notes."
+ },
+ {
+ "date": "2026-10-21",
+ "time": "9:00–10:00pm",
+ "title": "Skribbl night with Berkeley",
+ "location": "Discord",
+ "kind": "o",
+ "lead": "TBD",
+ "status": "Planning",
+ "cost": "Free",
+ "note": "Joint online social with the Berkeley chapter."
+ },
+ {
+ "date": "2026-10-26",
+ "time": "All week (week 6)",
+ "title": "On-campus interviews for IG",
+ "location": "Around campus",
+ "kind": "b",
+ "lead": "TBD",
+ "status": "Idea",
+ "cost": "Free",
+ "note": "Quick interviews for Instagram content. Plan questions, locations and volunteers during midterm week."
+ },
+ {
+ "date": "2026-11-02",
+ "time": "Day TBD (week 7)",
+ "title": "Dorm event",
+ "location": "The Hill, TBD",
+ "kind": "b",
+ "lead": "TBD",
+ "status": "Idea",
+ "cost": "Supplies for air freshener sachets",
+ "note": "Car-buying basics plus DIY air freshener sachets. Work with RAs to reach residents."
+ },
+ {
+ "date": "2026-11-11",
+ "time": "",
+ "title": "Veterans Day",
+ "location": "",
+ "kind": "org",
+ "lead": "",
+ "status": "",
+ "cost": "",
+ "note": "Holiday."
+ },
+ {
+ "date": "2026-11-22",
+ "time": "",
+ "title": "Crosscheck demo and writeup (week 10)",
+ "location": "",
+ "kind": "org",
+ "lead": "Ray",
+ "status": "",
+ "cost": "",
+ "note": "Data team's end-of-quarter demo."
+ },
+ {
+ "date": "2026-11-26",
+ "time": "",
+ "title": "Thanksgiving",
+ "location": "",
+ "kind": "org",
+ "lead": "",
+ "status": "",
+ "cost": "",
+ "note": "Holiday through Nov 27."
+ }
+ ],
+ "announcements": [
+ {
+ "title": "Deliverables from the Oct 11 meeting",
+ "body": "Due Sunday, Oct 18. Update Fiona on your progress.",
+ "posted_on": "2026-10-11",
+ "link_tab": "assignments",
+ "link_label": "Open",
+ "admins_only": false
+ },
+ {
+ "title": "Signatory workshop",
+ "body": "Fiona, Will and Ayat: sign up for the SOLE signatory workshop at sole.ucla.edu/dayosole.",
+ "posted_on": "2026-10-11",
+ "link_tab": "",
+ "link_label": "",
+ "admins_only": false
+ },
+ {
+ "title": "This week: Bruinwalk tabling",
+ "body": "Interactive whiteboard where students share car experiences, questions and concerns on sticky notes.",
+ "posted_on": "2026-10-11",
+ "link_tab": "socials",
+ "link_label": "Open",
+ "admins_only": false
+ },
+ {
+ "title": "Finances",
+ "body": "Balance $82.55 after $140 in T-shirt sales. Fiona's $57.45 loan for shirts and stickers has been paid back.",
+ "posted_on": "2026-10-11",
+ "link_tab": "",
+ "link_label": "",
+ "admins_only": true
+ }
+ ],
+ "meetings": [
+ {
+ "date": "2026-10-11",
+ "absent": "Celine, Rizwaan, Fernando, Ayat"
+ },
+ {
+ "date": "2026-10-04",
+ "absent": "Celine and Ray (both excused)"
+ },
+ {
+ "date": "2026-09-27",
+ "absent": "Rizwaan; Will and Brisa (excused)"
+ }
+ ]
+}
diff --git a/lib/board/static.ts b/lib/board/static.ts
index dc044ba..ab42ad9 100644
--- a/lib/board/static.ts
+++ b/lib/board/static.ts
@@ -1,5 +1,6 @@
-// /board content that changes rarely and lives in code rather than the
-// database. Copied from docs/board-preview.html.
+// Berkeley /board content that changes rarely and lives in code rather
+// than the database (copied from docs/board-preview.html). chapters.ts
+// assembles it with UCLA's.
import type { Tab } from "./types";
@@ -21,10 +22,6 @@ export const KEY_DATES: [date: string, text: string][] = [
["2026-11-06", "Price Is Right: final demo"],
];
-// The round shown on the Assignments tab, and its deadline.
-export const CURRENT_ROUND = { round: 2, label: "Round 2 · Due Mon, Oct 12, 11:59pm", due: "2026-10-12" };
-export const ARCHIVE_ROUND = { round: 1, label: "Round 1 archive · due Sep 26" };
-
export const RULE =
"every task lists the proof it needs. \"I reached out\" is not proof. If any of your deliverables is missing after the deadline, you are removed from the board. No extensions unless you ask Rana before the deadline.";
@@ -60,10 +57,6 @@ export const WEEKS: { n: number; start: string; end: string; label: string; goal
// Socials calendar: months people can page through (0-based month index).
export const SOCIAL_MONTHS = { year: 2026, first: 9, last: 11 };
export const SOCIAL_STATUSES = ["Idea", "Planning", "Confirmed", "Completed"] as const;
-export const SOCIAL_KINDS: { key: "b" | "o"; label: string }[] = [
- { key: "b", label: "Berkeley" },
- { key: "o", label: "Online" },
-];
// Round tasks with no owner yet. Only admins see them.
export const UNASSIGNED = "Unassigned";
diff --git a/lib/board/submit-rules.ts b/lib/board/submit-rules.ts
index 214921d..6e6852d 100644
--- a/lib/board/submit-rules.ts
+++ b/lib/board/submit-rules.ts
@@ -7,8 +7,9 @@ export const MAX_ATTACHMENT_BYTES = 14 * 1024 * 1024;
export const TOO_BIG =
"Those files are too large to email (14 MB total). Upload them to Google Drive and paste the share link in the notes instead.";
-export const NEED_PROOF = "Add proof: attach a file or paste a link to your post, doc or PR.";
export const hasLink = (text: string) => /https?:\/\/\S+/.test(text);
export const OTHER_TASK = "Other / general update";
+
+export const isEmail = (s: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
diff --git a/lib/board/tables.ts b/lib/board/tables.ts
index 4032da6..e9686f1 100644
--- a/lib/board/tables.ts
+++ b/lib/board/tables.ts
@@ -37,6 +37,7 @@ const bool: Check = (v) => (typeof v === "boolean" ? { ok: true, value: v } : {
type Field = { column: string; check: Check; onCreate?: unknown };
export type TableDef = { table: string; fields: Record };
+export { oneOf };
export const TABLES = {
socials: {
@@ -62,7 +63,7 @@ export const TABLES = {
task: { column: "task", check: required("the task") },
proof: { column: "proof_required", check: text(500), onCreate: "" },
dueDate: { column: "due_date", check: optionalDate, onCreate: null },
- status: { column: "status", check: oneOf(["pending", "submitted", "done", "missing"], "a status"), onCreate: "pending" },
+ status: { column: "status", check: oneOf(["pending", "submitted", "done", "missing", "excused"], "a status"), onCreate: "pending" },
statusNote: { column: "status_note", check: text(300), onCreate: "" },
},
},
@@ -74,6 +75,7 @@ export const TABLES = {
postedOn: { column: "posted_on", check: date("a date") },
linkTab: { column: "link_tab", check: oneOf(["", ...TABS], "a tab"), onCreate: "" },
linkLabel: { column: "link_label", check: text(100), onCreate: "" },
+ adminsOnly: { column: "admins_only", check: bool, onCreate: false },
},
},
meetings: {
@@ -82,6 +84,7 @@ export const TABLES = {
date: { column: "date", check: date("the meeting date") },
title: { column: "title", check: required("a title", 200), onCreate: "Board meeting" },
summary: { column: "summary", check: text(4000), onCreate: "" },
+ absent: { column: "absent", check: text(1000), onCreate: "" },
recordingUrl: { column: "recording_url", check: url, onCreate: "" },
},
},
@@ -90,7 +93,8 @@ export const TABLES = {
fields: {
name: { column: "name", check: required("a name", 200) },
role: { column: "role", check: text(200), onCreate: "" },
- team: { column: "team", check: oneOf(["Executive", "Data", "Software", "Research & Legal", "Outreach"], "a team") },
+ // Checked against the chapter's teams in editable.ts.
+ team: { column: "team", check: oneOf(["Executive"], "a team") },
alsoTeam: { column: "also_team", check: (v) => (v === "" || v === null ? { ok: true, value: null } : text(100)(v)), onCreate: null },
tier: { column: "tier", check: optionalTier, onCreate: null },
isLead: { column: "is_lead", check: bool, onCreate: false },
@@ -134,18 +138,26 @@ export async function createRow(def: TableDef, body: Record, ex
return { ok: true, id: r.id as number };
}
-export async function updateRow(def: TableDef, id: number, body: Record, extra: Record = {}): Promise {
+// Every row belongs to a chapter; updates and deletes only touch rows of
+// the signed-in user's chapter.
+export async function updateRow(
+ def: TableDef,
+ chapter: string,
+ id: number,
+ body: Record,
+ extra: Record = {},
+): Promise {
const c = collect(def, body, false);
if ("error" in c) return { ok: false, error: c.error, status: 400 };
const row = { ...c.row, ...extra };
if (Object.keys(row).length === 0) return { ok: false, error: "Nothing to change.", status: 400 };
const sql = db();
- const rows = await sql`UPDATE ${sql(def.table)} SET ${sql(row)} WHERE id = ${id} RETURNING id`;
+ const rows = await sql`UPDATE ${sql(def.table)} SET ${sql(row)} WHERE id = ${id} AND chapter = ${chapter} RETURNING id`;
return rows.length ? { ok: true, id } : { ok: false, error: "Not found", status: 404 };
}
-export async function deleteRow(def: TableDef, id: number): Promise {
+export async function deleteRow(def: TableDef, chapter: string, id: number): Promise {
const sql = db();
- const rows = await sql`DELETE FROM ${sql(def.table)} WHERE id = ${id} RETURNING id`;
+ const rows = await sql`DELETE FROM ${sql(def.table)} WHERE id = ${id} AND chapter = ${chapter} RETURNING id`;
return rows.length ? { ok: true, id } : { ok: false, error: "Not found", status: 404 };
}
diff --git a/lib/board/types.ts b/lib/board/types.ts
index 43720d3..3b0fae3 100644
--- a/lib/board/types.ts
+++ b/lib/board/types.ts
@@ -13,7 +13,16 @@ export type Member = {
phone: string;
};
-export type AssignmentStatus = "pending" | "submitted" | "done" | "missing";
+export type AssignmentStatus = "pending" | "submitted" | "done" | "missing" | "excused";
+
+// A round (Berkeley) or week (UCLA) of deliverables.
+export type Round = {
+ round: number;
+ dueDate: string;
+ isCurrent: boolean;
+ showStatus: boolean;
+ note: string;
+};
export type Assignment = {
id: number;
@@ -58,6 +67,7 @@ export type Announcement = {
postedOn: string;
linkTab: string;
linkLabel: string;
+ adminsOnly: boolean;
};
export type Meeting = {
@@ -65,10 +75,15 @@ export type Meeting = {
date: string;
title: string;
summary: string;
+ absent: string;
recordingUrl: string;
};
export type BoardData = {
+ rounds: Round[];
+ // First names of who a submission goes to: everyone, and the extra
+ // people for submitters on `techTeams`.
+ recipients: { base: string[]; tech: string[]; techTeams: string[] };
members: Member[];
assignments: Assignment[];
submissions: Submission[];
@@ -77,5 +92,5 @@ export type BoardData = {
meetings: Meeting[];
};
-export const TABS = ["home", "board", "projects", "assignments", "socials", "meetings"] as const;
+export const TABS = ["me", "home", "board", "projects", "assignments", "socials", "meetings"] as const;
export type Tab = (typeof TABS)[number];
diff --git a/public/board/ucla/board-1.webp b/public/board/ucla/board-1.webp
new file mode 100644
index 0000000000000000000000000000000000000000..e2eb692b98d6c60ae0ef7c16309825bb2359b413
GIT binary patch
literal 22104
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
Taq3$#3ywk$0UL~?3xEIsty}3m
literal 0
HcmV?d00001
diff --git a/public/board/ucla/board-2.webp b/public/board/ucla/board-2.webp
new file mode 100644
index 0000000000000000000000000000000000000000..bedeb12f9c5cc116ceb0ed4207b8bf790411bce6
GIT binary patch
literal 24146
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