🔌 API Reference
Express server endpoints for curriculum data access.
Overview
The Express server runs on http://localhost:3000 and provides REST endpoints for curriculum calendar data.
# Start the server
cd E:\Lucas\Personal-Productivity
npm start
Base URL: http://localhost:3000/api
GET /api/health
Check server and database status.
Request
GET /api/health
Response
{
"status": "ok",
"database": "connected",
"timestamp": "2025-11-28T10:30:00.000Z"
}
Error Response
{
"status": "error",
"database": "disconnected",
"error": "Connection refused"
}
GET /api/weeks
Get all 24 school weeks.
Request
GET /api/weeks
Response
[
{
"id": 1,
"week_number": 1,
"start_date": "2025-08-18",
"end_date": "2025-08-22",
"theme": "Back to School",
"goals": "Establish routines, review basics"
},
{
"id": 2,
"week_number": 2,
"start_date": "2025-08-25",
"end_date": "2025-08-29",
"theme": "Building Foundations",
"goals": "..."
}
// ... 24 weeks total
]
GET /api/weeks/:weekNumber/assignments
Get all assignments for a specific week.
Request
GET /api/weeks/1/assignments
Response
[
{
"id": 1,
"week_id": 1,
"subject_id": 1,
"day_of_week": 1,
"title": "Place Value Review",
"description": "Review place value concepts...",
"duration_minutes": 30,
"assignment_type": "lesson",
"sequence_order": 1,
"resource_id": 1,
"subject": {
"id": 1,
"name": "Math",
"color": "#3b82f6",
"emoji": "🔢"
},
"resource": {
"id": 1,
"name": "Saxon Math 5/4",
"type": "textbook"
}
},
// ... more assignments
]
Query Parameters
day |
Filter by day (1-5, Monday-Friday) |
subject |
Filter by subject ID |
GET /api/weeks/1/assignments?day=1
GET /api/weeks/1/assignments?subject=1
GET /api/subjects
Get all subjects with colors and emojis.
Request
GET /api/subjects
Response
[
{ "id": 1, "name": "Math", "color": "#3b82f6", "emoji": "🔢" },
{ "id": 2, "name": "Writing", "color": "#eab308", "emoji": "✍️" },
{ "id": 3, "name": "Reading", "color": "#22c55e", "emoji": "📖" },
{ "id": 4, "name": "Science", "color": "#a855f7", "emoji": "🔬" },
{ "id": 5, "name": "Spelling", "color": "#f97316", "emoji": "📝" },
{ "id": 6, "name": "History", "color": "#ec4899", "emoji": "🏛️" },
{ "id": 7, "name": "Art", "color": "#14b8a6", "emoji": "🎨" },
{ "id": 8, "name": "Music", "color": "#6366f1", "emoji": "🎵" },
{ "id": 9, "name": "PE", "color": "#64748b", "emoji": "⚽" }
]
GET /api/assignments/today
Get today's assignments based on current date.
Request
GET /api/assignments/today
Response
Same format as week assignments, filtered to current day.
Logic
// Calculates current week and day based on:
// - School year start date
// - Current date
// - Day of week (1=Mon, 5=Fri)
Curriculum Audit & Repair
GET /api/curriculum/audit
Scans all active (non-graded, non-cancelled) assignments for a student and flags alignment issues.
Query parameters:
student_id- UUID (defaults to Lucas)
Response:
{
"total_issues": 23,
"total_assignments_scanned": 588,
"issues": {
"weekend_dates": [{ "student_assignment_id", "title", "current_due_date", "suggested_due_date", ... }],
"out_of_range": [{ "student_assignment_id", "title", "due_date", "assigned_week", "actual_week_number", ... }],
"null_due_dates": [{ "student_assignment_id", "title", "week_number", ... }],
"sequence_breaks": [{ "resource", "earlier_title", "later_title", ... }]
},
"school_weeks": [{ "id", "week_number", "start_date", "end_date" }]
}
POST /api/curriculum/repair
Applies date fixes to misaligned assignments. Updates due_date, week_id, and day_of_week in a single transaction.
Request body:
{
"fixes": [
{ "student_assignment_id": "uuid", "new_due_date": "2026-02-11" }
]
}
Validation: Rejects weekend dates and dates outside school weeks. Rolls back all changes if entire batch fails.
Response:
{
"message": "Successfully repaired 5 assignments",
"repaired": 5,
"failed": 0,
"results": [{ "student_assignment_id", "new_due_date", "new_week_number", "new_day_of_week" }]
}
Vocabulary Review
GET /api/vocab-review/:assignmentId
Returns vocabulary terms for a review assignment. Resolves the unit key from the assignment's title/resource and fetches from vocab_sets table.
Query parameters:
student_id- UUID (optional, includes student assignment status)
Response:
{
"assignment": { "id", "title", "subject", "resource" },
"unit_key": "ckhg-unit-1",
"terms": [
{ "id", "term": "river", "definition": "A large natural stream...", "example_sentence": "The Nile is...", "display_order": 1 }
],
"student_assignment": { "id", "status", "due_date" }
}
POST /api/vocab-review/:assignmentId/complete
Auto-grades the vocabulary review and creates a submission. Calculates grade from fill-in-the-blank score.
Request body:
{
"student_id": "uuid",
"student_assignment_id": "uuid",
"score": 18,
"total": 18,
"attempts": 1,
"results": [{ "term": "river", "answer": "river", "correct": true }]
}
Response:
{
"message": "Vocabulary review completed and graded",
"grade": "A",
"score": 18,
"total": 18,
"feedback": "Vocabulary Review completed. 18/18 correct (100%). Perfect on first try!"
}
Program Links (D1 Shooter)
Resource library backing dashboards/d1-shooter.html. Rows live in the program_links table and are owned by a student, so the same endpoints serve any student id.
Table: program_links
id | UUID primary key |
student_id | UUID, references students(id), cascade delete |
category | One of Plan, Breakdown, Drill Video, Tracker, Course, Call, Other |
label | Display name (required) |
url | Web address (nullable) |
local_path | Path to a file on the local drive (nullable) |
phase_tag | Month or phase label, e.g. "Month 1" (nullable) |
notes | Free text (nullable) |
created_at / updated_at | Timestamps |
ensureProgramLinksTable() runs at server start. It creates the table and, only when no rows exist for Lucas, seeds the 13 D1 Shooter Blueprint Month 1 links.
GET /api/students/:studentId/program-links
Returns every link for the student, ordered by category then label.
{
"links": [
{ "id", "student_id", "category": "Drill Video", "label": "Kneeling Demo",
"url": "https://...", "local_path": null, "phase_tag": "Month 1",
"notes": null, "created_at", "updated_at" }
],
"categories": ["Plan", "Breakdown", "Drill Video", "Tracker", "Course", "Call", "Other"]
}
POST /api/students/:studentId/program-links
Creates a link. Returns 201 with the created row.
Request body:
{
"category": "Tracker",
"label": "Free Throw Tracker",
"url": "https://drive.google.com/file/d/.../view",
"local_path": null,
"phase_tag": "Month 1",
"notes": null
}
Validation (400 on failure): label is required, category must be one of the seven values, and at least one of url or local_path must be present.
PUT /api/program-links/:id
Partial update. Fields omitted from the body keep their stored value; passing an explicit null clears a nullable field. Returns 404 if the id does not exist, 400 if no updatable field was supplied.
{ "label": "Free Throw Tracker v2" }
DELETE /api/program-links/:id
Deletes the link. Returns { "success": true, "id": "..." }, or 404 if the id does not exist.
D1 Shooter Program Drills (Workout Card)
The prescription behind the Today card on dashboards/d1-shooter.html. Two tables: program_plans holds one record per student per month (plan name and coach notes), and program_drills holds one row per drill.
Table: program_drills
plan_month | Groups a month's prescription, e.g. "Month 1" |
drill_group | Vitamin or Workout |
sort_order | Position within the month, set by the reorder endpoint |
name, target, cue | As written on the coach's sheet |
demo_url | Any ordinary YouTube link. Watch, youtu.be, Shorts, and playlist forms are all accepted; the id is extracted client side. |
breakdowns | JSONB array of { label, url }. An array rather than a single column because Kneeling Single Hand carries both a side angle and a front angle. |
log_field | The d1_daily_log column this drill writes to, which is what the Log button jumps to. Validated against a fixed allow list. |
is_active | Inactive drills are stored but not rendered on the card |
ensureProgramDrillTables() creates both tables at startup, seeds Month 1 for Lucas when nothing is stored, then runs backfillD1Month1Demos(), which fills demo links onto rows that predate the URLs being transcribed. The backfill only writes where the field is still null, so it never overwrites a link edited on the card.
Embedding
Nothing about embedding is stored. parseYouTube() in the page reduces a stored link to a kind and an id, and youTubeEmbedUrl() rebuilds it against the privacy enhanced domain:
video: https://www.youtube-nocookie.com/embed/<id>?rel=0&controls=1&modestbranding=1&playsinline=1&autoplay=1
playlist: https://www.youtube-nocookie.com/embed/videoseries?list=<id>&rel=0&...
The library reuses these same builders through linkEmbed(url), which tries YouTube first, then a Google Drive file id, then falls back to no embed. Nothing about embedding is duplicated between the Today card and the link library.
Recognised inputs: /watch?v=, youtu.be/, /shorts/, /embed/, /live/, /v/, /playlist?list=, and a bare id. A watch link that also carries a list plays the single chosen video rather than the playlist.
GET /api/students/:studentId/program-drills?month=Month%201
Returns the plan record, the drills ordered by sort_order, every month stored for the student, and the allowed log_field values. Defaults to Month 1 when no month is given.
{
"plan": { "plan_month": "Month 1", "plan_name": "...", "coach_notes": "..." },
"drills": [ { "id", "drill_group", "sort_order", "name", "target", "cue",
"demo_url", "breakdowns": [{ "label", "url" }], "log_field", "is_active" } ],
"months": ["Month 1"],
"log_fields": ["ball_raises_reps", ...]
}
PUT /api/program-drills/:id
Partial update. Fields omitted from the body keep their stored value. Returns 400 on an unknown drill_group or log_field, an empty name, or a breakdowns value that is not an array; 404 if the id does not exist.
PUT /api/students/:studentId/program-drills/reorder
Body { "order": [id, id, ...] }. Rewrites sort_order to the position in that array, inside a transaction, scoped to the student so one student's list cannot renumber another's.
PUT /api/students/:studentId/program-plans/:month
Upserts the plan name and coach notes for a month.
D1 Shooter Settings
A small key and value store for the program level settings the print sheets need, in d1_settings, keyed on (student_id, setting_key) so another child can run the program with different targets.
program_start | Monday of week 1, default 2026-08-17. Sets the week numbering on the free throw and shot make club sheets. |
ft_target | Free throw makes per week, default 100. One of 100, 250, 500, 700. |
smc_target | Shot make club makes per week, default 700. One of 700, 1400, 2100, 2800, 3500. |
GET /api/students/:studentId/d1-settings
Returns stored values layered over the defaults, so a caller never has to handle a missing key.
{ "settings": { "program_start": "2026-08-17", "ft_target": "100", "smc_target": "700" },
"defaults": { ... } }
PUT /api/students/:studentId/d1-settings
Upserts one or more settings in a transaction and returns the full merged set. Unknown keys are rejected with 400 rather than silently stored, and program_start must be formatted YYYY-MM-DD.
D1 Shooter Coach Reminders
Coaching feedback that arrives between calls, shown in the Current Reminders block of the Today card. One row per reminder in coach_reminders.
student_id | UUID, references students(id), cascade delete |
drill_id | UUID, references program_drills(id), nullable. Null means a general reminder. ON DELETE SET NULL, so removing a drill demotes its reminders to general rather than destroying them. |
audience | Player or Parent. Parent rows render in the collapsed For Dad block and never in the player list or a drill card. |
reminder_text | What the coach said |
source_date | The day it was sent, returned as text via TO_CHAR |
is_active, deactivated_at | Retiring sets active false and stamps the timestamp. Rows are never deleted, so the week view can later report which cues were live during which week. |
sort_order | Position in the list, set by the reorder endpoint. New rows land at the end. |
ensureCoachRemindersTable() runs at startup, after ensureProgramDrillTables() so its seed can resolve drill associations by name rather than carrying ids.
GET /api/students/:studentId/coach-reminders
Active reminders ordered by sort_order. Pass ?include_inactive=true for the full history including retired rows.
{
"reminders": [ { "id", "drill_id", "audience": "Player", "reminder_text": "...",
"source_date": "2026-08-20", "is_active": true,
"deactivated_at": null, "sort_order": 1 } ],
"audiences": ["Player", "Parent"]
}
The page fetches this in parallel with the drills, so the Today card renders once with both.
POST /api/students/:studentId/coach-reminders
Creates a reminder at the end of the list. reminder_text is required, audience defaults to Player, and source_date must be formatted YYYY-MM-DD when present. Returns 201.
PUT /api/coach-reminders/:id
Partial update. Fields omitted keep their stored value. Passing is_active also maintains deactivated_at, stamping it on retirement and clearing it on reactivation.
PUT /api/coach-reminders/:id/deactivate
Retires a reminder and stamps deactivated_at. 404 if the id does not exist.
PUT /api/students/:studentId/coach-reminders/reorder
Body { "order": [id, id, ...] }. Rewrites sort_order in a transaction, scoped to the student.
D1 Shooter Daily Log
Training log backing the Daily Log section of dashboards/d1-shooter.html. One row per student per date, in the d1_daily_log table.
Table: d1_daily_log, unique on (student_id, log_date)
ball_raises_reps, kneeling_makes, tennis_ball_done | At home vitamins |
workout_done | Boolean gate for the banked make targets |
hop_touch_attempts, five_spot_attempts, forward_hop_attempts | Drills where a lower number is the better result |
mikan_seconds | Lateral Hop Mikan in its old format, seconds to reach 20 makes, lower is better. Written only for dates before the format change. |
mikan_makes | Lateral Hop Mikan in its current format, makes in 60 seconds, higher is better. Written only for dates on or after the format change. |
around_horn_makes, x_out_makes, twos_points | Drills where a higher number is the better result |
ft_student_makes, ft_partner_makes | Free throw makes out of 25. On the current page these render as Lucas and Seth; the columns are named generically because the table is keyed by student. |
workout_minutes, notes | Duration and free text |
Every numeric column is nullable. A null means the drill was not recorded, which is distinct from a logged zero: nulls stay out of week totals and are never eligible for a personal best.
Total makes
total_makes is computed on every read by d1ComputeTotalMakes() in server.js and is never stored. The same rule is mirrored client side in computeTotalMakes() so the entry form can show a live figure before saving. Changing the rule means changing both.
// Lateral Hop Mikan depends on the date. MIKAN_SCORE_START is 2026-08-20.
if (workout_done) {
total += usesMikanScore(log_date) ? mikan_makes : 20; // Lateral Hop Mikan
total += 25 + 25 + 25; // Hop Touch, Five Spot, Forward Hop
}
total += around_horn_makes
+ x_out_makes
+ round(twos_points / 2) // two points per make
+ ft_student_makes; // kneeling_makes deliberately excluded
GET /api/students/:studentId/d1-daily-log
Returns entries ordered by date, each carrying a computed total_makes. Optional start and end query parameters narrow to a date range and must be formatted YYYY-MM-DD, otherwise 400. With no range every entry is returned, which is what the page requests so it can derive personal bests, the running Shot Make Club total, and the season record from the full history.
{ "entries": [ { "id", "student_id", "log_date": "2026-08-17", ..., "total_makes": 140 } ] }
log_date is cast to text with TO_CHAR rather than returned as a DATE, so a day never shifts during JSON serialization across a timezone boundary.
POST /api/students/:studentId/d1-daily-log
Upserts one date on the (student_id, log_date) unique constraint, so saving a day that already exists overwrites it rather than inserting a duplicate. log_date is required and must be formatted YYYY-MM-DD. Blank numeric fields are stored as null. Returns the saved row with total_makes.
DELETE /api/students/:studentId/d1-daily-log/:date
Deletes that date for that student. Returns { "success": true, "id": "..." }, 404 if nothing was logged for the date, or 400 if the date is malformed.
Error Handling
All endpoints return consistent error responses:
{
"error": "Error message here",
"status": 404,
"timestamp": "2025-11-28T10:30:00.000Z"
}
Status Codes
| 200 | Success |
| 400 | Bad request (invalid parameters) |
| 404 | Resource not found |
| 500 | Server error (database issue) |
Frontend Usage
// Fetch weeks
const response = await fetch('http://localhost:3000/api/weeks');
const weeks = await response.json();
// Fetch assignments for week 1
const response = await fetch('http://localhost:3000/api/weeks/1/assignments');
const assignments = await response.json();
// Fetch with day filter
const monday = await fetch('http://localhost:3000/api/weeks/1/assignments?day=1');
const mondayWork = await monday.json();
Database Connection
// .env.local configuration
DATABASE_URL=postgresql://postgres:password@localhost:5432/andrews_family_lms
// Or individual settings
DB_HOST=localhost
DB_PORT=5432
DB_NAME=andrews_family_lms
DB_USER=postgres
DB_PASSWORD=your_password