Skip to content
Merged
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
85 changes: 85 additions & 0 deletions .github/workflows/vercel-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: Deploy to Vercel

on:
push:
branches:
- main
pull_request:
branches:
- main

env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

jobs:
deploy-preview:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Pull Vercel environment
run: npx vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
- name: Build project artifacts
run: npx vercel build --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy preview
id: vercel_preview
run: |
PREVIEW_URL=$(npx vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})
echo "preview_url=$PREVIEW_URL" >> "$GITHUB_OUTPUT"
- name: Comment preview URL on PR
uses: actions/github-script@v7
with:
script: |
const url = `${{ steps.vercel_preview.outputs.preview_url }}`.trim();
const body = [
"✅ Vercel preview deployment is ready.",
"",
`Preview URL: ${url}`
].join("\n");

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});

deploy-production:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Pull Vercel environment
run: npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- name: Build project artifacts
run: npx vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy production
run: npx vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,11 @@ yarn-error.log*

# ts
*.tsbuildinfo
# python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
.pytest_cache/
.mypy_cache/

45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,48 @@ Cream paper · deep emerald · warm orange · amber gold. Editorial serif
headlines for the manifesto register; clean Geist sans for everything
else. The two sister schools each get their own gradient on `/` so the
gateway never flattens them into a single voice.


## Repository map

### Web app (Next.js / TypeScript)
- `app/`: App Router pages and API routes (site pages, auth, admin, chat, uploads).
- `components/`: UI, layout, and section-level building blocks.
- `lib/`: shared business logic (auth, SSO bridge, site config, profile utilities).
- `types/`: TypeScript type augmentations (for example NextAuth).

### API service (FastAPI / Python)
- `src/sof_ai_api/main.py`: FastAPI entrypoint and router mounting.
- `src/sof_ai_api/routes/`: endpoint modules (health, progress, wallet, journals, challenges, pioneer applications).
- `src/sof_ai_api/models.py`: SQLModel entities and constraints.
- `src/sof_ai_api/ledger.py`: points/ledger domain logic.
- `src/sof_ai_api/integrations/`: external integrations (including OJS adapter/client).

### Infra and tooling
- `Dockerfile`: container build definition.
- `fly.toml`: Fly.io deploy configuration.
- `pyproject.toml`: Python package and dependency settings.
- `package.json`: Node scripts and dependencies.

## Suggested next build targets
1. **Contract tests between Next API routes and FastAPI endpoints** to prevent regressions across the TS/Python boundary.
2. **End-to-end auth flow tests** (magic link + SSO handoff/signout) to harden enrollment onboarding.
3. **Observability baseline** (request IDs, structured logs, health/readiness checks, error budget alerts).
4. **Data lifecycle docs** for progress/enrollment and ledger mutations to simplify contributor onboarding.



## Vercel deployment (CI/CD)

This repo now includes `.github/workflows/vercel-deploy.yml` for automatic deployments:

- Pull requests to `main` create a **Preview** deployment.
- Pushes to `main` create a **Production** deployment.

Configure these GitHub repository secrets before enabling the workflow:

- `VERCEL_TOKEN`
- `VERCEL_ORG_ID`
- `VERCEL_PROJECT_ID`

You can get these values from your Vercel account/project settings and by running `vercel link` locally.
61 changes: 61 additions & 0 deletions app/courses/deadlines/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Metadata } from "next"
import { MIT_COURSEWARE_DEADLINES, sortDeadlinesByDate } from "@/lib/course-deadlines"

export const metadata: Metadata = {
title: "MIT Courseware Deadlines · The VR School",
description:
"Operational deadlines for enrollment, coursework, exam registration, portfolio checkpoints, and transcript closeout.",
}

const CATEGORY_STYLE: Record<string, string> = {
Enrollment: "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300",
Coursework: "bg-violet-100 text-violet-800 dark:bg-violet-950 dark:text-violet-300",
Exams: "bg-rose-100 text-rose-800 dark:bg-rose-950 dark:text-rose-300",
Portfolio: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300",
Admin: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
}

export default function CourseDeadlinesPage() {
const deadlines = sortDeadlinesByDate(MIT_COURSEWARE_DEADLINES)

return (
<div className="min-h-screen bg-background text-foreground">
<section className="max-w-5xl mx-auto px-6 py-16">
<h1 className="text-4xl md:text-5xl font-serif tracking-tight mb-4">MIT Courseware Deadlines</h1>
<p className="text-muted-foreground max-w-3xl mb-10">
Single source of truth for 2026&ndash;27 operational milestones across enrollment, instruction, assessments, portfolio evidence, and final transcript closeout.
</p>

<div className="rounded-xl border border-border/60 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/30">
<tr className="text-left text-xs text-muted-foreground uppercase tracking-wider border-b border-border/60">
<th className="px-5 py-3 font-medium">Date</th>
<th className="px-5 py-3 font-medium">Milestone</th>
<th className="px-5 py-3 font-medium hidden sm:table-cell">Owner</th>
<th className="px-5 py-3 font-medium">Category</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{deadlines.map((item) => (
<tr key={item.id}>
<td className="px-5 py-3 tabular-nums whitespace-nowrap">{item.date}</td>
<td className="px-5 py-3">
<p className="font-medium">{item.title}</p>
<p className="text-xs text-muted-foreground mt-0.5">{item.notes}</p>
</td>
<td className="px-5 py-3 text-muted-foreground hidden sm:table-cell">{item.owner}</td>
<td className="px-5 py-3">
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-semibold ${CATEGORY_STYLE[item.category]}`}>
{item.category}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
)
}
134 changes: 68 additions & 66 deletions app/courses/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export const metadata: Metadata = {

const SECTIONS: UcSection[] = ["A", "B", "C", "D", "E", "F", "G"]

type CourseCatalogSearchParams = {
q?: string
honors?: string
}

function HonorsBadge() {
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300 ml-2 align-middle">
Expand All @@ -21,26 +26,55 @@ function HonorsBadge() {
)
}

export default function CourseCatalog() {
const total = COURSES.length
const honors = COURSES.filter((c) => c.ucHonors).length
function matchesSearch(courseTitle: string, query: string) {
if (!query) return true
return courseTitle.toLowerCase().includes(query.toLowerCase())
}

export default async function CourseCatalog({
searchParams,
}: {
searchParams?: Promise<CourseCatalogSearchParams>
}) {
const params = (await searchParams) ?? {}
const query = (params.q ?? "").trim()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize q before calling .trim()

searchParams values can be either string or string[] in App Router pages, but this code assumes q is always a string. If the URL contains repeated query params (for example /courses?q=math&q=cs), params.q becomes an array and (params.q ?? "").trim() throws a runtime TypeError, causing the page to fail. Please coerce q to a single string (e.g., first element) before trimming.

Useful? React with 👍 / 👎.

const honorsOnly = params.honors === "1"

const filtered = COURSES.filter((c) => matchesSearch(c.title, query) && (!honorsOnly || c.ucHonors))
const total = filtered.length
const honors = filtered.filter((c) => c.ucHonors).length
const sections = SECTIONS.slice(0, 6)
const gCourses = getCoursesBySection("G")
const gCourses = getCoursesBySection("G").filter((c) => matchesSearch(c.title, query) && (!honorsOnly || c.ucHonors))

return (
<div className="min-h-screen bg-background text-foreground">
{/* Hero */}
<section className="border-b border-border/60 bg-gradient-to-br from-stone-50 to-amber-50/40 dark:from-stone-950 dark:to-stone-900">
<div className="max-w-5xl mx-auto px-6 py-14 md:py-20">
<p className="text-xs font-mono uppercase tracking-widest text-muted-foreground mb-4">
The VR School &middot; Stanford, CA &middot; College Board Code 170588
</p>
<h1 className="text-5xl md:text-7xl font-serif tracking-tight mb-3">Course Catalog</h1>
<p className="text-lg text-muted-foreground mb-8">2026&ndash;27 Academic Year &middot; UC A-G Approved</p>
<p className="text-lg text-muted-foreground mb-2">2026&ndash;27 Academic Year &middot; UC A-G Approved</p>
<p className="text-sm text-muted-foreground mb-6">Track milestone dates at <a href="/courses/deadlines" className="underline underline-offset-4 hover:text-foreground">/courses/deadlines</a>.</p>

<form className="grid gap-3 sm:grid-cols-[1fr_auto] mb-8" action="/courses" method="get">
<input
type="text"
name="q"
defaultValue={query}
placeholder="Search course titles (e.g., AI, Physics, Calculus)"
className="w-full rounded-lg border border-border/70 bg-background px-3 py-2 text-sm"
/>
<label className="inline-flex items-center gap-2 text-sm">
<input type="checkbox" name="honors" value="1" defaultChecked={honorsOnly} />
UC Honors only
</label>
</form>

<div className="flex flex-wrap gap-8">
<div className="flex flex-col">
<span className="text-2xl font-semibold tabular-nums">{total}</span>
<span className="text-xs text-muted-foreground uppercase tracking-wider mt-0.5">Total Courses</span>
<span className="text-xs text-muted-foreground uppercase tracking-wider mt-0.5">Visible Courses</span>
</div>
<div className="flex flex-col">
<span className="text-2xl font-semibold tabular-nums">{honors}</span>
Expand All @@ -50,19 +84,15 @@ export default function CourseCatalog() {
<span className="text-2xl font-semibold tabular-nums">7</span>
<span className="text-xs text-muted-foreground uppercase tracking-wider mt-0.5">A-G Subject Areas</span>
</div>
<div className="flex flex-col">
<span className="text-2xl font-semibold tabular-nums">Online</span>
<span className="text-xs text-muted-foreground uppercase tracking-wider mt-0.5">Delivery Mode</span>
</div>
</div>
</div>
</section>

{/* A-F sections */}
<section className="max-w-5xl mx-auto px-6 py-14 space-y-16">
{sections.map((sec) => {
const meta = SECTION_META[sec]
const courses = getCoursesBySection(sec)
const courses = getCoursesBySection(sec).filter((c) => matchesSearch(c.title, query) && (!honorsOnly || c.ucHonors))
if (courses.length === 0) return null
return (
<div key={sec} id={`section-${sec}`}>
<div className="flex items-baseline gap-4 mb-6">
Expand All @@ -86,10 +116,7 @@ export default function CourseCatalog() {
{courses.map((c) => (
<tr key={c.slug} className="group hover:bg-muted/20 transition-colors">
<td className="px-5 py-3.5">
<Link
href={`/courses/${c.slug}`}
className="font-medium group-hover:text-amber-700 dark:group-hover:text-amber-400 transition-colors"
>
<Link href={`/courses/${c.slug}`} className="font-medium group-hover:text-amber-700 dark:group-hover:text-amber-400 transition-colors">
{c.title}
</Link>
{c.ucHonors && <HonorsBadge />}
Expand All @@ -109,56 +136,31 @@ export default function CourseCatalog() {
})}
</section>

<div className="rule-hairline max-w-5xl mx-auto px-6" />

{/* G: College-Prep Electives */}
<section className="max-w-5xl mx-auto px-6 py-14" id="section-G">
<div className="flex items-baseline gap-4 mb-6">
<span className="text-5xl font-serif text-muted-foreground/30 leading-none select-none">G</span>
<div>
<h2 className="text-xl font-serif">{SECTION_META.G.label}</h2>
<p className="text-xs text-muted-foreground mt-0.5">{gCourses.length} courses</p>
</div>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-2">
{gCourses.map((c) => (
<Link
key={c.slug}
href={`/courses/${c.slug}`}
className="group flex items-start gap-2 px-4 py-3 rounded-lg border border-border/40 hover:border-amber-300 dark:hover:border-amber-700 hover:bg-amber-50/30 dark:hover:bg-amber-950/20 transition-all"
>
<ArrowRight className="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-muted-foreground group-hover:text-amber-600 transition-colors" />
<span className="text-sm leading-snug">
{c.title}
{c.ucHonors && (
<span className="ml-1.5 inline-flex items-center px-1 py-0.5 rounded text-[9px] font-bold bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300">
HON
{gCourses.length > 0 && (
<>
<div className="rule-hairline max-w-5xl mx-auto px-6" />
<section className="max-w-5xl mx-auto px-6 py-14" id="section-G">
<div className="flex items-baseline gap-4 mb-6">
<span className="text-5xl font-serif text-muted-foreground/30 leading-none select-none">G</span>
<div>
<h2 className="text-xl font-serif">{SECTION_META.G.label}</h2>
<p className="text-xs text-muted-foreground mt-0.5">{gCourses.length} courses</p>
</div>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-2">
{gCourses.map((c) => (
<Link key={c.slug} href={`/courses/${c.slug}`} className="group flex items-start gap-2 px-4 py-3 rounded-lg border border-border/40 hover:border-amber-300 dark:hover:border-amber-700 hover:bg-amber-50/30 dark:hover:bg-amber-950/20 transition-all">
<ArrowRight className="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-muted-foreground group-hover:text-amber-600 transition-colors" />
<span className="text-sm leading-snug">
{c.title}
{c.ucHonors && <span className="ml-1.5 inline-flex items-center px-1 py-0.5 rounded text-[9px] font-bold bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300">HON</span>}
</span>
)}
</span>
</Link>
))}
</div>
</section>

<div className="rule-hairline max-w-5xl mx-auto px-6" />

{/* Jump nav */}
<section className="max-w-5xl mx-auto px-6 py-10">
<p className="text-xs text-muted-foreground mb-3 font-medium uppercase tracking-wider">Jump to section</p>
<div className="flex flex-wrap gap-2">
{SECTIONS.map((sec) => (
<a
key={sec}
href={`#section-${sec}`}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold transition-colors ${SECTION_META[sec].color}`}
>
<span className="font-mono">{sec}</span>
<span>{SECTION_META[sec].label}</span>
</a>
))}
</div>
</section>
</Link>
))}
</div>
</section>
</>
)}
</div>
)
}
Loading
Loading