Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,56 @@ All tables live in a single D1 database (`data`). The init migration (`migration
- **Auth tables** (managed by BetterAuth — do not edit): `user`, `session`, `account`, `verification`
- **`course`** — course assignments. Composite primary key `(userId, course_name, position)`. Indexed on `course_name` for listing all LAs in a course.
- **`feedback`** — feedback submissions linking a giver to a recipient (references `user.id`). Stores form data as a JSON string in the `feedback` column. Indexed on `recipientId`.
- **`event_log`** — append-only audit trail (see below).

A default admin user (`pdt.laprogram@gmail.com`, role `admin`) is seeded in the init migration. Test data can be loaded from `scripts/testing.sql`.

#### Time formats

`migrations/0005_standard_timestamps.sql` put every app-owned time value on a fixed format. Nothing should be splitting a time string apart outside `lib/time.ts`.

| Kind | Storage | Example | Helpers |
|------|---------|---------|---------|
| Instant | TEXT, ISO-8601 UTC with ms | `2026-07-29T04:12:33.123Z` | `isoNow()`, `toISO()`, `fromISO()`, SQL `SQL_NOW` |
| Time of day | TEXT, 24h `HH:MM` (LA wall time) | `09:20` | `clockToMinutes()`, `minutesToClock()`, `clockLabel()` |
| Day of week | INTEGER, ISO weekday | `1` = Monday | `dayName()`, `dayOfWeek()` |
| Quarter week | INTEGER | `5` | `getCurrentWeek()` |

- `section` stores a **recurring** meeting pattern: `day_of_week` + `start_time`/`end_time`. It has no instants because a section repeats weekly.
- `availability` stores **real instants** (`start_at`/`end_at`), computed on write from `QUARTER_START` + week + the section's weekday via `weekdayInstant()`. Queries filter on `start_at` directly rather than recomputing dates — use `laDayBoundary(n)` for "n calendar days out in LA" cutoffs.
- Messy times from Airtable are normalized once, at ingest, in `init-sections`. Everything downstream sees `HH:MM`.
- `QUARTER_START` stays a plain `yyyy-MM-dd` date in KV — it is a calendar day, not an instant.

BetterAuth's tables were deliberately left alone; it already writes ISO-8601 UTC into them.

#### Observability

Every app table carries nullable `created_at` / `updated_at`. `updated_at` is maintained by `AFTER UPDATE` triggers, so plain `UPDATE` statements stay observable without every call site remembering. `availability` also has `status_changed_at`, stamped by trigger on any open/hidden/taken flip — including bulk resets.

These columns are nullable because rows predating the migration have no known creation time; they carry `NULL` rather than a value that would claim they were created at migration time.

For anything that **deletes** the row it describes (observation cancellations, admin removals, withdraws), a row timestamp cannot survive — those go to `event_log` instead, via `lib/events.ts`:

```ts
import { EVENT, eventStmt, recordEvent } from "@/lib/events";

// Prefer eventStmt inside an existing db.batch() so the log cannot drift
// from the change it describes.
await db.batch([ ...changes, eventStmt(db, { action: EVENT.ObservationSignup, ... })]);

// recordEvent runs standalone and never throws.
await recordEvent(db, { action: EVENT.AvailabilityReset, ... });
```

Actions are `domain.verb`, so a prefix match gets a whole domain:

```bash
npx wrangler d1 execute data --remote --command \
"SELECT * FROM event_log WHERE action LIKE 'observation.%' ORDER BY occurred_at DESC LIMIT 20"
```

`actor_email` / `target_email` are denormalized on purpose — `process-withdraws` deletes users, and the log has no foreign keys so it outlives what it describes.

### Migrations

D1 migrations live in `migrations/`. To create a new migration:
Expand Down
32 changes: 20 additions & 12 deletions laprogram/app/admin/components/AvailabilityAudit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import useSWR from "swr";
import { toast } from "sonner";
import { ChevronDown, Pencil } from "lucide-react";
import { fetcher, getCurrentWeek } from "@/lib/utils";
import { clockLabel, dayName } from "@/lib/time";
import { formatName, getNamePart } from "@/lib/name";
import { useTableSort } from "@/hooks/use-table-sort";
import { useToggleSet } from "@/hooks/use-toggle-set";
Expand Down Expand Up @@ -44,15 +45,21 @@ type SectionEntry = {
la_email: string;
course_name: string;
section_name: string;
section_time: string;
section_id: string;
section_day: string;
section_time_raw: string;
day_of_week: number | null;
start_time: string | null;
end_time: string | null;
section_location: string;
position: string;
weeks: Record<string, number>;
weeks: Record<number, number>;
};

/** 'Monday 9:00 AM–9:50 AM', or an em dash if the section has no time yet. */
function sectionTimeLabel(e: SectionEntry): string {
if (!e.day_of_week || !e.start_time || !e.end_time) return "—";
return `${dayName(e.day_of_week)} ${clockLabel(e.start_time)}–${clockLabel(e.end_time)}`;
}

export function AvailabilityAudit() {
const [query, setQuery] = useState("");
const [maxWeeks, setMaxWeeks] = useState<string>("");
Expand Down Expand Up @@ -121,7 +128,7 @@ export function AvailabilityAudit() {

// Build section entries grouped by la_id + section_id
const map = new Map<string, SectionEntry>();
const weekSet = new Set<string>();
const weekSet = new Set<number>();

for (const row of data) {
const key = `${row.la_id}|${row.section_id}`;
Expand All @@ -133,10 +140,10 @@ export function AvailabilityAudit() {
la_email: row.la_email,
course_name: row.course_name,
section_name: row.section_name,
section_time: row.section_time,
section_id: row.section_id,
section_day: row.section_day,
section_time_raw: row.section_time_raw,
day_of_week: row.day_of_week,
start_time: row.start_time,
end_time: row.end_time,
section_location: row.section_location,
position: row.position,
weeks: {},
Expand All @@ -150,7 +157,7 @@ export function AvailabilityAudit() {
}

const allEntries = [...map.values()];
const weeks = [...weekSet].sort((a, b) => Number(a) - Number(b));
const weeks = [...weekSet].sort((a, b) => a - b);
const positionOptions = [
...new Set(allEntries.map((e) => e.position)),
].sort();
Expand Down Expand Up @@ -349,8 +356,9 @@ export function AvailabilityAudit() {
section_id: editing.section_id,
course_name: editing.course_name,
section_name: editing.section_name,
day: editing.section_day,
time: editing.section_time_raw,
day_of_week: editing.day_of_week,
start_time: editing.start_time,
end_time: editing.end_time,
location: editing.section_location,
} satisfies Section
}
Expand Down Expand Up @@ -455,7 +463,7 @@ export function AvailabilityAudit() {
</td>
<td className="truncate py-1.5 pr-2 text-muted-foreground">
{entry.course_name} {entry.section_name} (
{entry.section_time})
{sectionTimeLabel(entry)})
</td>
<td className="truncate py-1.5 pr-2 text-muted-foreground">
{entry.position}
Expand Down
77 changes: 49 additions & 28 deletions laprogram/app/admin/components/ObservationAudit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { Fragment, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { Check, ChevronRight, Clock, Eye, Trash2 } from "lucide-react";
import { fetcher, parseSectionTime, minutesToLabel } from "@/lib/utils";
import { fetcher } from "@/lib/utils";
import { formatName, getNamePart } from "@/lib/name";
import { useTableSort } from "@/hooks/use-table-sort";
import { useToggleSet } from "@/hooks/use-toggle-set";
import { LA_POSITION_MAP } from "@/lib/constants";
import { LA_POSITION_MAP, TIMEZONE } from "@/lib/constants";
import { NameSortHeader } from "./NameSortHeader";
import { SearchBar } from "./SearchBar";
import { PositionFilter } from "./PositionFilter";
Expand Down Expand Up @@ -43,13 +43,13 @@ function positionLabel(p: string | null) {
return parts.map((x) => LA_POSITION_MAP.get(x) ?? x).join(", ");
}

function formatSubmittedAt(s: string | null): string {
if (!s) return "—";
const iso = s.includes("T") ? s : s.replace(" ", "T") + "Z";
/** Renders a stored instant in LA time. */
function formatInstant(iso: string | null): string {
if (!iso) return "—";
const date = new Date(iso);
if (isNaN(date.getTime())) return "—";
return date.toLocaleString("en-US", {
timeZone: "America/Los_Angeles",
timeZone: TIMEZONE,
month: "numeric",
day: "numeric",
hour: "numeric",
Expand All @@ -71,20 +71,33 @@ function StatusBadge({ completed }: { completed: boolean }) {
);
}

function formatTimeRange(time: string) {
try {
const [start, end] = parseSectionTime(time);
const startLabel = minutesToLabel(start);
const endLabel = minutesToLabel(end);
const startPeriod = startLabel.slice(-2);
const endPeriod = endLabel.slice(-2);
if (startPeriod === endPeriod) {
return `${startLabel.slice(0, -3)}-${endLabel}`;
}
return `${startLabel}-${endLabel}`;
} catch {
return time;
/** '9:20-9:50 AM' for a slot, collapsing the period when both halves match. */
function formatTimeRange(startAt: string | null, endAt: string | null) {
if (!startAt || !endAt) return "—";
const startLabel = formatClockLA(startAt);
const endLabel = formatClockLA(endAt);
if (startLabel.slice(-2) === endLabel.slice(-2)) {
return `${startLabel.slice(0, -3)}-${endLabel}`;
}
return `${startLabel}-${endLabel}`;
}

function formatClockLA(iso: string): string {
return new Date(iso).toLocaleString("en-US", {
timeZone: TIMEZONE,
hour: "numeric",
minute: "2-digit",
});
}

/** 'Wk 5 · Monday' for a slot. */
function formatSlotDay(week: number, startAt: string | null): string {
if (!startAt) return `Wk ${week}`;
const day = new Date(startAt).toLocaleString("en-US", {
timeZone: TIMEZONE,
weekday: "long",
});
return `Wk ${week} · ${day}`;
}

type GroupBy = "observer" | "observee";
Expand Down Expand Up @@ -534,15 +547,22 @@ export function ObservationAudit() {
{r.course_name} {r.section_name}
</td>
<td className="py-1 pr-2 whitespace-nowrap text-muted-foreground">
Wk {r.week} · {r.day}{" "}
{formatTimeRange(r.time)}
{formatSlotDay(r.week, r.start_at)}{" "}
{formatTimeRange(r.start_at, r.end_at)}
</td>
<td className="py-1 pr-2 whitespace-nowrap">
<StatusBadge completed={r.completed} />
</td>
<td className="py-1 pr-2 whitespace-nowrap text-muted-foreground">
<td
className="py-1 pr-2 whitespace-nowrap text-muted-foreground"
title={
r.signed_up_at
? `Signed up ${formatInstant(r.signed_up_at)}`
: undefined
}
>
{r.completed
? formatSubmittedAt(r.submitted_at)
? formatInstant(r.submitted_at)
: "—"}
</td>
<td className="py-1 pr-2 text-right whitespace-nowrap">
Expand Down Expand Up @@ -785,7 +805,8 @@ export function ObservationAudit() {
{s.course_name} {s.section_name}
</td>
<td className="px-2 py-1.5 whitespace-nowrap">
Wk {s.week} · {s.day} {formatTimeRange(s.time)}
{formatSlotDay(s.week, s.start_at)}{" "}
{formatTimeRange(s.start_at, s.end_at)}
</td>
<td className="px-2 py-1.5 whitespace-nowrap">
<StatusBadge completed={s.completed} />
Expand Down Expand Up @@ -840,10 +861,10 @@ export function ObservationAudit() {
<span className="font-medium text-foreground">
{toDelete.observee_name}
</span>{" "}
in {toDelete.course_name} {toDelete.section_name} (Wk{" "}
{toDelete.week}, {toDelete.day}{" "}
{formatTimeRange(toDelete.time)})? The availability slot will
be reopened.
in {toDelete.course_name} {toDelete.section_name} (
{formatSlotDay(toDelete.week, toDelete.start_at)}{" "}
{formatTimeRange(toDelete.start_at, toDelete.end_at)})? The
availability slot will be reopened.
</>
)}
</DialogDescription>
Expand Down
16 changes: 9 additions & 7 deletions laprogram/app/api/admin/audit/availability/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ export type AvailabilityAuditRow = {
la_email: string;
course_name: string;
section_name: string;
section_time: string;
section_id: string;
section_day: string;
section_time_raw: string;
/** ISO weekday, 1 = Monday. */
day_of_week: number | null;
/** Wall-clock 'HH:MM' in LA. */
start_time: string | null;
end_time: string | null;
section_location: string;
position: string;
week: string | null;
week: number | null;
slot_count: number;
};

Expand All @@ -38,9 +40,9 @@ export async function GET() {
u.email AS la_email,
s.course_name,
s.section_name,
s.day || ' ' || s.time AS section_time,
s.day AS section_day,
s.time AS section_time_raw,
s.day_of_week AS day_of_week,
s.start_time AS start_time,
s.end_time AS end_time,
s.location AS section_location,
sa.section_id,
c.position,
Expand Down
29 changes: 16 additions & 13 deletions laprogram/app/api/admin/audit/signups/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { headers } from "next/headers";
import { getAuth } from "@/lib/auth";
import { getCloudflareContext } from "@opennextjs/cloudflare";
import { getObsDate, getQuarterStart } from "@/lib/utils";
import { fromISO } from "@/lib/time";
import { formatDateLA } from "@/app/observations/signup/types";

export type SignupRow = {
Expand All @@ -16,9 +16,11 @@ export type SignupRow = {
observee_position: string | null;
course_name: string;
section_name: string;
day: string;
time: string;
week: string;
week: number;
start_at: string | null;
end_at: string | null;
/** When the observer signed up. Null for sign-ups predating the migration. */
signed_up_at: string | null;
completed: boolean;
submitted_at: string | null;
feedback: Record<string, unknown> | null;
Expand Down Expand Up @@ -52,12 +54,13 @@ export async function GET() {
// TODO: logic for extracting completed observations is not good. This should be refactored
// once the major sign-up changes go through in a few weeks this quarter

const [signupsResult, feedbackResult, quarterStart] = await Promise.all([
const [signupsResult, feedbackResult] = await Promise.all([
env.data
.prepare(
`SELECT
o.id AS id,
o.observer_id AS observer_id,
o.created_at AS signed_up_at,
observer.name AS observer_name,
observer.email AS observer_email,
(SELECT GROUP_CONCAT(DISTINCT position)
Expand All @@ -69,15 +72,15 @@ export async function GET() {
FROM course WHERE userId = o.observee_id) AS observee_position,
s.course_name AS course_name,
s.section_name AS section_name,
s.day AS day,
a.time AS time,
a.week AS week
a.week AS week,
a.start_at AS start_at,
a.end_at AS end_at
FROM observation o
JOIN availability a ON o.availability_id = a.id
JOIN section s ON a.section_id = s.id
JOIN "user" observer ON o.observer_id = observer.id
JOIN "user" observee ON o.observee_id = observee.id
ORDER BY a.week, s.day, a.time, observer.name COLLATE NOCASE`,
ORDER BY a.start_at, observer.name COLLATE NOCASE`,
)
.all<SignupQueryRow>(),
env.data
Expand All @@ -92,7 +95,6 @@ export async function GET() {
WHERE json_extract(feedback, '$.feedback_type') = 'la_observation'`,
)
.all<FeedbackMatchRow>(),
getQuarterStart(env).catch(() => null),
]);

type CompletedEntry = {
Expand Down Expand Up @@ -125,9 +127,10 @@ export async function GET() {
let completed = false;
let submitted_at: string | null = null;
let feedback: Record<string, unknown> | null = null;
if (quarterStart) {
const obsDate = getObsDate(r.week, r.day, quarterStart);
const expected = `${r.section_name} — ${formatDateLA(obsDate)}`;
// The form stored the slot as a label ("1A — 5/12"); rebuild it from the
// slot's own instant to find the matching submission.
if (r.start_at) {
const expected = `${r.section_name} — ${formatDateLA(fromISO(r.start_at))}`;
const key = `${r.observer_email.toLowerCase()}|${r.observee_id}|${expected}`;
const entry = completedKeys.get(key);
if (entry) {
Expand Down
Loading