Built for the Coastal Eats restaurant group (4 locations, 2 time zones) as part of a 72-hour full-stack technical assessment.
| Role | Access | |
|---|---|---|
| Admin | admin@coastaleats.com |
All locations, audit logs, analytics |
| Manager (Pacific) | mgr.marina@coastaleats.com |
Marina Bay Grill + Pacific Beach Bistro |
| Manager (Eastern) | mgr.downtown@coastaleats.com |
Downtown DC Kitchen + Harbor View Lounge |
| Staff | sarah@coastaleats.com |
Bartender + Server, Pacific time |
| Staff (near OT) | overworked@coastaleats.com |
Pre-seeded near 35h — triggers overtime warnings |
| Layer | Choice | Notes |
|---|---|---|
| Framework | Next.js 15 (App Router) | Server Components, Server Actions |
| Database | Supabase (PostgreSQL) | Via transaction pooler, port 6543 |
| ORM | Prisma 7.4 | prisma-client generator, @prisma/adapter-pg |
| Auth | NextAuth v5 | Credentials + JWT |
| UI | shadcn/ui + Tailwind v4 | Dark theme |
| Real-time | Supabase Realtime (broadcast) | No polling — WebSocket push |
| Timezone | date-fns-tz |
IANA timezone display |
| Validation | Zod | All server action inputs |
| AI Copilot | @langchain/langgraph 1.3 + @langchain/anthropic |
Multi-tool agent with HITL via interrupt(), Postgres checkpoints in same Supabase DB |
| Deployment | Vercel | With Cron (expire stale drops) |
app/
(dashboard)/
dashboard/ <- On-duty now + stats
schedule/ <- Weekly calendar (manager)
shifts/ <- My upcoming shifts (staff)
staff/ <- Staff list + OT indicators (manager)
swaps/ <- Swap & drop workflow (all roles)
availability/ <- Weekly windows + exceptions (staff)
notifications/ <- Notification center
settings/ <- Notification prefs + desired hours
analytics/ <- Hours dist + premium fairness + OT
admin/
audit-logs/ <- Full audit trail + CSV export (admin)
locations/ <- Location + manager overview (admin)
actions/
shifts.ts <- Create/assign/publish/edit/delete + what-if preview
swaps.ts <- Full swap/drop workflow + manager approve/deny
availability.ts <- Upsert windows + exceptions
settings.ts <- Notification prefs + desired hours
lib/
constraints.ts <- Double-booking, rest, skill, availability checks
overtime.ts <- 35h/40h/8h/12h/6th-7th day logic
notifications.ts <- Centralised notification factory
audit.ts <- logAudit() + getAuditLogs()
timezone.ts <- localToUtc, displayInTz, isPremiumShift
supabase-server.ts <- broadcastEvent() for real-time
supabase-client.ts <- Browser Supabase client
api/
notifications/mark-read/ <- POST: marks all read
cron/expire-drops/ <- GET: expires stale drops (Vercel Cron hourly)
admin/audit-logs/ <- GET: CSV export
Managers open /schedule, pick a week and location, and click any day cell to create a shift (location, skill, date/time, headcount). After creation, clicking a shift opens the Assign Staff dialog which shows a what-if preview before any DB write.
What-if preview flow:
- Click "Assign" next to a staff member
- System fetches their current weekly hours and runs all constraint checks — no DB write yet
- Panel shows: current hours → +shift hours → projected hours, with any violations and suggestions
- Manager can "Confirm & Assign" (blue), "Assign Anyway" (amber, for soft warnings only), or go back
- Hard blocks (double-booking, wrong skill, 7th day without override) show "Cannot Assign"
Hard blocks:
- Double-booking (same person, overlapping times, across all locations)
- Minimum 10-hour rest between shifts
- Skill mismatch
- Location certification missing
- 7th consecutive day (requires documented override reason ≥ 10 characters)
- Daily hours exceeding 12 hours
Soft warnings (override allowed):
- Staff outside their availability window
- Weekly hours approaching 35h (near overtime)
- Weekly hours at/above 40h (overtime)
- Daily hours above 8h
- 6th consecutive day worked
Staff A requests SWAP -> Staff B accepts -> Manager approves -> Assignment transferred
Staff A requests DROP -> Any staff claims -> Manager approves -> Assignment transferred
- Staff are limited to 3 active pending requests at once
- Drop requests carry an
expiresAttimestamp (2 hours before shift start). Vercel Cron marks them EXPIRED once daily (midnight UTC) and notifies the requester - If a manager edits a shift while a swap is pending, the swap is automatically cancelled with a notification to all parties
- The original assignment remains until manager approval — staff keep their shift until it's officially transferred
Every connected user receives live updates via Supabase broadcast channels (WebSocket, no polling):
| Channel | Events | Behaviour |
|---|---|---|
schedule |
shift.created, shift.updated, shift.published | router.refresh() on schedule/shifts pages |
swaps |
swap.created, swap.updated | router.refresh() on swaps page |
notifications:{userId} |
notification.new | Toast shown to that specific user |
When a manager publishes a schedule, affected staff immediately see a toast: "Your schedule for Marina Bay Grill has been published."
Simultaneous assignment conflict: If two managers click "Assign" on the same staff member at the same time, PostgreSQL's UNIQUE(shiftId, userId) constraint catches the race condition (Prisma error code P2002). The second manager receives: "This staff member was just assigned by another manager. The schedule has been refreshed."
Tracked per staff member per week. Calculations use the location's IANA timezone for day boundaries.
| Rule | Level | Effect |
|---|---|---|
| Weekly hours ≥ 35h | Warning | Shown in what-if preview + analytics |
| Weekly hours ≥ 40h | Warning | Shown in what-if preview + analytics |
| Daily hours > 8h | Warning | Shown in what-if preview |
| Daily hours > 12h | Hard block | Assignment prevented |
| 6th consecutive day | Warning | Shown in what-if preview |
| 7th consecutive day | Hard block | Requires manager override with documented reason |
- Weekly Hours Distribution — bar chart per staff, colour-coded (blue/amber/red), shows delta vs each staff member's desired hours target
- Premium Shift Fairness — last 30 days of Fri/Sat evening shifts per staff. Flags "High" (>2× average) and "None" (zero premium shifts)
- Hours vs Target — quick table showing over/under-scheduled staff vs their stated preferred hours
- Understaffed Shifts — all shifts this week where actual assignments < headcount required
Every create/update/delete/publish/assign/approve action is logged with full before/after JSON snapshots.
- Filter by date range, entity type (shift / assignment / swap_request / user / location), and location
- Export CSV — download button generates
audit-logs-YYYY-MM-DD.csvwith up to 5,000 rows matching the active filters - Agent-driven actions use the
agent.*action prefix (agent.assigned,agent.unassigned,agent.reassigned) so admins can filter human vs. AI writes
Multi-tool scheduling copilot built on LangGraph.js. Managers chat with the agent in natural language — it plans which tool to call, pauses at every write boundary for human-in-the-loop approval, and writes audit-namespaced rows so manager actions stay distinguishable from agent actions.
Log in as admin@coastaleats.com or any manager, open /copilot, and try:
| Prompt | What happens |
|---|---|
list locations |
Planner → listLocations → 4 locations |
show me this week's schedule |
Planner → getWeekSchedule → real shifts with assignment counts |
find sarah |
Planner → findStaff → returns userId, skills, certified locations |
assign sarah to a bartender shift |
Planner → findStaff → getWeekSchedule → assignStaff → approval card with projected weekly hours + soft warnings → Approve → DB write + audit + real-time broadcast |
swap sarah for john on her monday shift |
Planner → reassignShift → approval card showing both staff + impact → Approve → atomic userId update on the assignment |
remove sarah from her monday shift |
Planner → removeAssignment → approval card with shift details → Approve → assignment deleted, swap requests cancelled |
| Tool | Type | Effect |
|---|---|---|
listLocations |
read | All locations (admin) / managed locations (manager) |
findStaff |
read | Name substring + optional location filter — returns userIds needed for other tools |
getWeekSchedule |
read | Shifts with assignments for a week, location-scoped to RBAC |
previewAssignment |
read | Constraint + overtime preview without DB write — uses shared lib/preview.ts |
assignStaff |
write + HITL | Calls interrupt() for manager approval; refuses hard blocks outright |
removeAssignment |
write + HITL | Enforces publish-cutoff window; cancels pending swap requests on delete |
reassignShift |
write + HITL | Atomic staff-for-staff swap on one assignment (single DB write) |
All tools take Zod-validated args. Write tools call the same lib/constraints.ts + lib/overtime.ts engines as the human-driven Server Actions — the agent never re-implements business logic.
stateDiagram-v2
[*] --> planner
planner --> tools: AIMessage has tool_calls
planner --> [*]: no tool_calls (END)
tools --> planner: read tool result
tools --> paused: write tool calls interrupt()
paused --> tools: graph.invoke(Command(resume))
state paused {
[*] --> waiting_for_manager
waiting_for_manager --> approved
waiting_for_manager --> cancelled
}
- Write tool (
assignStaff/removeAssignment/reassignShift) runs - Tool fetches preview via
buildAssignmentPreview()(constraint + overtime check, no DB write) - If hard constraint violation → returns
blockedstatus immediately, no interrupt - Otherwise tool calls
interrupt({ type: "needs_approval", preview, args })— graph pauses - SSE route detects pause via
graph.getState().tasks[].interruptsand emits aninterruptSSE event to the client - Client (
CopilotChat.tsx) renders an inline confirm card with preview details + Approve/Cancel buttons - User clicks → client POSTs
{ threadId, resume: { approved: bool } }to/api/copilot/stream - Route calls
graph.invoke(new Command({ resume }))— the tool node re-runs,interrupt()returns the resume value - If approved: tool executes Prisma write +
logAudit({ action: "agent.assigned" })+broadcastEvent("schedule", "shift.updated", ...)for real-time refresh on the/schedulepage - If cancelled: tool returns
cancelledstatus, planner summarizes for user
The mock LLM (lib/copilot/mock-llm.ts) is a BaseChatModel subclass that emits scripted tool calls based on regex rules. Rule args can be a static object OR an async resolver that looks up real IDs from Postgres at call time:
{
match: /assign\s+sarah/i,
toolName: "assignStaff",
args: async () => {
const sarah = await prisma.user.findUnique({ where: { email: "sarah@coastaleats.com" } });
const shift = await prisma.shift.findFirst({ where: { /* unfilled bartender shift this week */ } });
return { userId: sarah!.id, shiftId: shift!.id };
},
}This means the full HITL flow demos with $0 LLM cost — the smoke test, the local browser flow, and the deployed demo all work without ANTHROPIC_API_KEY. Plug the key in .env.local to swap to real Claude (claude-sonnet-4-6) with zero code changes — the factory in lib/copilot/llm.ts switches automatically.
app/(dashboard)/copilot/
page.tsx <- Auth + role gate, renders CopilotChat
CopilotChat.tsx <- Client: chat surface, SSE parsing, approval card
app/api/copilot/stream/
route.ts <- POST: invoke graph OR resume with Command, stream SSE events
lib/copilot/
state.ts <- StateGraph annotation (messages + session)
session.ts <- CopilotSession type
llm.ts <- Factory: real ChatAnthropic OR ScriptedToolCallChatModel
mock-llm.ts <- Scripted mock with async args resolvers
checkpointer.ts <- PostgresSaver singleton, setup() on first use
graph/
index.ts <- buildCopilotGraph(session, opts?) — ToolNode + shouldContinue
planner.ts <- LLM invocation node
tools/
index.ts <- buildToolsForSession() — registers all 7 tools
list-locations.ts <- read
find-staff.ts <- read
get-week-schedule.ts <- read
preview-assignment.ts <- read
assign-staff.ts <- write + HITL
remove-assignment.ts <- write + HITL
reassign-shift.ts <- write + HITL
lib/preview.ts <- Shared buildAssignmentPreview() — used by Server Action + tool
scripts/copilot-smoke.ts <- npm run copilot:smoke — validates read loop + HITL cancel + approve + audit
scripts/seed-shifts-only.ts <- npm run copilot:seed — idempotent demo data for current+next week
npm run copilot:smokeValidates end-to-end without browser:
- Section A — read tools with mock LLM across 3 turns + checkpoint persistence
- Section B — HITL write flow with scripted mock that uses real DB IDs: invoke → assert paused on interrupt → cancel branch (no write) → approve branch (assignment created +
agent.assignedaudit row written) → cleanup
- Staff persona (separate copilot at
/copilotwith different tool surface — drop request, swap request) - Multi-agent split: planner → router → swap-negotiator subgraph
- Real-time token streaming in UI (
streamMode: "messages"+ per-token render) - Voice input (Web Speech API → planner)
- Conversation history sidebar (list all thread_ids for current user)
Decision: Historical data (past assignments, audit logs, swap records) is preserved unchanged. Future assignments at that location become impossible (the location certification check blocks it). Existing future assignments that were already scheduled are NOT automatically cancelled — a manager must review and handle those manually.
Reasoning: Automatic cancellation would cause surprise understaffing. Past records must not be altered for compliance. The manager should make a deliberate, informed decision about already-scheduled future shifts.
Decision: They are independent signals that never conflict. Availability windows are a hard constraint (assignments are blocked outside them). Desired hours are a soft target visible only in analytics for manager guidance — they never block an assignment.
Reasoning: Desired hours is a scheduling preference, not a guarantee. An employee who wants 40h/week but marks themselves unavailable on Mondays has not created a conflict — they just want 40h across the remaining days. Conflating the two would produce confusing errors and reduce scheduling flexibility.
Decision: Yes. Any shift on a calendar day (midnight-to-midnight in the location's timezone) counts as a worked day for the consecutive-day counter.
Reasoning: Labor compliance frameworks typically look at calendar days, not hours within a day. A 1-hour call-in still means the employee showed up. Using calendar days is simpler, consistent, and harder to game. The day boundary uses the location's timezone (not UTC) to match how employees and managers reason about "working a day."
Decision: The shift change is applied to the assignment and both the original and new staff member are notified. The swap record remains APPROVED — it is not reverted.
Reasoning: Reverting an approved swap because of a time change would punish staff who acted in good faith and create confusing state. The approved personnel change (who is doing the shift) is separate from the shift details (when/where). Both parties are notified of the change and can raise concerns or initiate further swaps.
Decision: Each location has exactly one IANA timezone. All times are stored in UTC and displayed in that location's timezone. Staff set availability in their own local time; the system converts at display/constraint-check time.
Reasoning: A restaurant has one physical location with one clock on the wall. Even if it sits near a state line, the staff experience one timezone. Registering it under two timezones would create scheduling paradoxes. If Coastal Eats ever acquired a genuinely split-timezone location, the correct solution is to register it as two separate locations in the system.
# Database (Supabase transaction pooler — port 6543)
DATABASE_URL="postgresql://USER:PASS@HOST:6543/postgres?pgbouncer=true"
# NextAuth
NEXTAUTH_SECRET="generate-with-openssl-rand-base64-32"
NEXTAUTH_URL="https://your-vercel-url.vercel.app"
# Supabase (real-time)
NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co"
NEXT_PUBLIC_SUPABASE_ANON_KEY="your-anon-key"
SUPABASE_SERVICE_ROLE_KEY="your-service-role-key"
# Cron protection (optional but recommended in production)
CRON_SECRET="generate-with-openssl-rand-hex-32"
# AI Copilot (optional — leave empty to use the deterministic mock LLM)
ANTHROPIC_API_KEY=""- Push this repository to GitHub
- Import at vercel.com/new
- Add all environment variables listed above
- Click Deploy
- The
vercel.jsoncron (0 0 * * *) runs/api/cron/expire-dropsonce daily at midnight UTC (Vercel Hobby plan limit — upgrade to Pro for hourly)
node --env-file=.env.local ./node_modules/tsx/dist/cli.mjs prisma/seed.tsThis creates: 6 skills, 4 locations (PT + ET), 15 users (1 admin, 2 managers, 12 staff), sample shifts, one swap request, and notifications.
| Area | Detail |
|---|---|
| Email notifications | notifyEmail preference is stored. No email transport (SMTP/Resend) is wired up — in-app notifications work fully |
| Mobile layout | Schedule calendar is desktop-first; scrolls horizontally on small screens |
| Audit log diff view | Stores full object snapshots, not field-level diffs |
| Availability DST edge | Shifts scheduled during the DST gap hour (e.g., 2:30 AM when clocks spring forward) are not specially flagged |
| Password change | No self-service password reset (not in scope for this assessment) |