TutorLoom
기업·대학·교육기관을 위한 학습 플랫폼, 무료 오픈소스이며 엔터프라이즈 버전 제공
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗A personal "student operating system" built on top of the Fall Ledger Canvas calendar: a Command Center dashboard, per-class pages with flexible notes, Gmail-based email intelligence, automatic AI assignment breakdown, and a Behind/At-Risk workload status — all pointed at the same real data, so the app can answer "what should I do right now?", "am I missing anything Canvas doesn't know about?", and "am I actually going to be okay this week?"
This is the full original plan — phases 1 through 4 — see Roadmap for what's built and what's deliberately still flagged as out of scope.
ios/): the site inside a native shell,
plus sharing recordings from Voice Memos, recording that keeps going with
the screen locked, Gmail sign-in that works in an app, and due-date
reminders. See ios/README.md for building, signing and
App Store review notes.src/lib/features.ts): right after sign-up
(/welcome) the student checks the optional features they want, and can
change them any time under Account → Features. The core (Today, classes,
assignments, exams, schedule, notes, course files) is always on; turning
a feature off hides it and keeps its data. Accounts from before the
picker keep every feature they had (and get none of the new ones) until
they pick, from a card on Today. The new features:
src/lib/spaced-review.ts): flashcards come back on
a schedule (1, 3, 7, 14, then 30 days when you get them; the same day
when you miss), Today shows how many are due, and lecture notes end with
"Test yourself" questions.src/lib/exam-prep.ts): "Make a prep plan" on an
upcoming exam plans up to five 30-minute quiz sessions over the week
before it, at free times around class meetings and commitments, and
makes a flashcard deck to quiz on. Sessions show on Today and Schedule;
a missed one can move to the next free time.src/lib/grades.ts): the current grade from Canvas (when
the course shows students its total; refreshed after Today loads, at
most every six hours) or typed in, a "what do I need on the final"
calculator, and a heads-up on Today when a grade drops 3+ points from
its high in the last three weeks or is under the student's goal.TUTOR_MODE_INSTRUCTIONS).src/lib/get-help.ts): office hours and the
professor's email on a class's Overview (typed in, or found in the
syllabus with AI), a prewritten email to the professor for the
student's own mail app, searches of their school's site for tutoring
and other help, and 988.prisma/schema.prisma): User, Class,
ScheduleEvent, Assignment, Task, Exam, Email, EmailAccount, CanvasAccount,
LmsConnection, PendingChange, NoteSection, Note, Resource,
AvailabilityBlock, Lecture, ClassMaterial, MaterialSyncState — see
Data architecture./forgot-password, /reset-password): a one-time
link by email, good for an hour, stored only as an HMAC. Resetting signs
out every device and signs this one in. Emails go through Resend
(RESEND_API_KEY, EMAIL_FROM, and APP_URL for the link); until those
are set, the page points to CONTACT_EMAIL instead.CHATGPT_PLUGIN_URL links to the
plugin before then)./mcp. Read-only; disconnect under Account → Connected apps.
See chatgpt-plugin/README.md for how it's built and submitted./connect): Canvas, Schoology, D2L Brightspace or
Blackboard, each connected right in the app — no terminal needed — with
a Sync now button to re-pull any time and a report of what each sync
brought in (and why any course didn't come in: not open yet, an
invitation not accepted, assignments the LMS won't show).
/connect/canvas): a personal access token. Courses,
assignments with directions, due dates, the student's own submission
status, exams (from assignment names and the course calendar), and
course materials. scripts/sync-canvas.ts (npm run canvas:sync)
still exists as an env-var/cron-friendly alternative — both paths share
src/lib/canvas-sync.ts./connect/schoology): the student's own API key from
their school's /api page. Sections, assignments/tests/graded
discussions with directions, due dates (in the account's time zone),
graded status from the gradebook, turned-in status from the dropbox
for current work, exams (midterm/final flags, names, the calendar), and
course materials (documents, pages, attachments)./connect/brightspace,
/connect/blackboard): students can't make their own API keys for
these, so they connect with the Campus OS Connector Chrome extension
(chrome-extension/, read its README). They pair it from the Connect
page, allow their school's site, and choose courses in its popup. It
then reads Brightspace's Valence API or Blackboard's Learn REST API with
the student's own browser session: courses, assignments with directions
and due dates, turned-in and graded status, exams, and course files and
pages. It sends these to /api/lms-extension/*
(src/lib/lms/extension-import.ts), which saves them through the same
applyLmsCourses, planSync, readDocumentText and upsertMaterial
as Canvas and Schoology. Unchanged files are never downloaded again,
big files go up in 3.5 MB parts, and a course is marked done only once
every file it asked for is saved. It syncs again on its own when the
student opens the site, if it's been 6 hours. The Connect page shows
each course's progress (ExtensionProgressPanel).
Without Chrome on a computer (the iPhone/iPad app, Safari), the
calendar feed link is still there as a fallback: classes, due dates
and exams, previewed (rename or leave out) before connecting, but no
directions, submissions or files. Connecting the extension replaces a
calendar connection and folds its copies of deadlines into the full ones
(matchCalendarDuplicates), carrying over status and steps.
Every credential (token, key, feed link) is encrypted at rest the same
way Gmail's is./classes/[id]): Overview (professor, room, current
grade, next assignment/exam — all editable, since Canvas doesn't provide
any of it), Assignments, Notes, Exams, Resources, and a class-scoped AI
assistant ("Summarize my notes," "Make a study guide," "Quiz me,"
"What am I missing?," "What's due next?," "Prepare me for my next exam")
that only ever sees that one class's data.ScheduleEvent row per
weekday, shown grouped on the Overview tab), optionally between the
semester's first and last day (startsOn/endsOn), after which it drops
off the Schedule, Record's class guess and ChatGPT. Add to your
calendar gives each student a secret subscription link
(/calendar/<token>.ics, CalendarFeed) for Apple, Google or Outlook
Calendar: weekly repeating events in their time zone that update as
meetings change (src/lib/calendar-feed.ts, tested).SchoolAccessRequest keeps the requests already made and is no
longer written./email): connect your school Gmail via
OAuth (read-only, no password ever touches this app), get a summarized
Inbox Academic Feed of only the relevant emails (grouped by class and
category, clickable through to the original in Gmail), and a pending
changes queue for anything an email suggests that conflicts with what's
already on file — see Email intelligence below.Task rows so they show up everywhere the assignment does (dashboard,
class page, Assignments list). Without an API key it falls back to
src/lib/breakdown-heuristics.ts — a deterministic, assignment-type-aware
splitter (a paper gets outline/draft/revise, an exam gets
review/practice/final-review, etc.) rather than refusing to work. Small
assignments (roughly under 30 minutes) are deliberately left unsplit — a
2-minute attendance check doesn't need three steps.src/lib/risk-engine.ts): an on track /
getting behind / at risk assessment shown at the top of the dashboard, plus a lightweight badge on
each class card. Built from real signals only — overdue items, how much
is due in the next 48 hours, upcoming exams, and (only when you've logged
it) how your actual free time compares to today's workload — and when
you're behind, it says why and recommends what to tackle first, chained
in one sentence ("I'd recommend completing X, then Y") rather than a wall
of text. An optional "Explain in plain language" button re-narrates the
same facts through AI; the status itself never waits on or requires that.src/app/classes/[id]/lecture-actions.ts and
src/lib/lecture-pipeline.ts): record in the app (Record a lecture on
the dashboard, or a class's Lectures tab), upload a recording such as a
Voice Memo (direct-to-Blob), or paste a transcript, and get an AssemblyAI
transcript and AI-generated study notes automatically. The Record page
picks the class from the Schedule when one is meeting. In production,
AssemblyAI's webhook (/api/lecture-audio/transcribed) moves a lecture
on when its transcript is ready, so notes get written with the app
closed; with the page open, polling does the same. Notes are regenerated
on retry from the saved transcript if generation itself fails, so a flaky
AI call never means re-uploading audio.src/lib/lecture-notes-sync.ts):
each lecture's notes are also a real note (Note.lectureId) in a
"Lecture notes" section, and the Lectures tab shows that same note, so an
edit in either place shows in both. Deleting a lecture keeps its note.ClassMaterial model, shown on each class's
Resources tab alongside saved links): reference content — textbook
excerpts, slides, notes, syllabi — that's folded into every lecture's
note generation and the class assistant's context for that class. Four
ways in: paste text directly, paste a link (Canvas file link or a regular
webpage, fetched once and stored — never re-fetched later), upload a file
directly (PDF/PPTX/DOCX/TXT/EPUB, up to 25 MB; files over 4 MB go
browser → Blob → addClassMaterialFromBlobAction, since Vercel caps
requests at 4.5 MB), or automatic Canvas sync (see next bullet).
Imports that failed are listed first, with the reason.src/lib/canvas-materials.ts (pure
classification/dedup/diff logic), src/lib/materials-sync.ts (the
shared engine), and src/lib/canvas-materials-sync.ts /
src/lib/lms/schoology-materials.ts (each LMS's discovery) for the
implementation, and CLAUDE.md for known limitations.The schema targets Postgres (see Data architecture),
so DATABASE_URL needs to point at a real Postgres instance even for
local runs — either the same one your Vercel deployment
uses, or any other Postgres you have (a free tier from Neon/Supabase, a
local Postgres.app install, etc.). There's no more zero-config SQLite
fallback as of this update — see the Roadmap entry on why.
npm install
cp .env.example .env # then edit .env — set a real DATABASE_URL, see below
npx prisma generate
npx prisma db push # creates all the tables on that database from the schema
npm run db:seed # demo user + real Fall 2026 Cedarville data
npm run dev
Open http://localhost:3000 and log in with the demo account printed by the
seed script ([email protected] / campusos-demo — change this
password if you keep using the seeded account for anything real). The
login page shows these credentials only under npm run dev; they're public
in this repo, so never run the seed script against the production
database.
Sign-up is invite-only: new accounts need the code in SIGNUP_INVITE_CODE,
and with that variable empty, sign-up is closed (existing accounts can
still log in). Signed-in users can change their password or delete their
account under Account.
To pull your own live Canvas data instead of the seeded snapshot, the easiest way is right in the app:
canvasCourseId / canvasAssignmentId, so
it updates existing rows instead of duplicating them).If you'd rather sync from the command line or a cron job instead of
clicking a button, CANVAS_BASE_URL / CANVAS_ACCESS_TOKEN in .env plus
npm run canvas:sync still works exactly as before — both paths share the
same sync logic.
To connect Gmail, see Email intelligence — it needs a few minutes of one-time setup in Google Cloud that only you can do.
Set ANTHROPIC_API_KEY in .env to enable: the dashboard's "why this
task" explanations and cross-app Q&A, every per-class assistant action
(summarize, study guide, quiz, gap-check, exam prep), Gmail's
structured-fact extraction (recognizing "your exam moved to Wednesday" as
an actual proposed change, not just a relevant-looking email), assignment
breakdown into concrete steps, and the plain-language narrative over the
Behind/At-Risk status. Every
AI-backed feature has a deterministic fallback and works without a key —
the app never breaks or blocks on the AI being unavailable, and Gmail
specifically still filters/categorizes/tags emails by class without a key,
it just won't propose schedule changes on its own (see below). This is the
same "don't fabricate, don't depend on things that might fail" pattern the
whole app is built against.
Once you're happy running it locally, this moves it to a real URL you can
open from any device — no laptop, no Terminal, no localhost. The schema
already targets Postgres for exactly this (see prisma/schema.prisma),
and package.json's build script runs prisma db push on every deploy
(after compiling, and after prisma/pre-push.sql, which holds the few
schema steps db push won't take without a person to confirm them), so
the live database schema always matches what's committed — no separate
migration step to remember.
Steps that only you can do (account creation and dashboard clicks aren't something I can do on your behalf):
git remote add origin <the URL GitHub gives you>
git push -u origin master
DATABASE_URL for you — nothing to copy in by hand..env, with one exception:
AUTH_SECRET, ANTHROPIC_API_KEY (optional), ANTHROPIC_MODEL
(optional), CANVAS_BASE_URL / CANVAS_ACCESS_TOKEN (optional),
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET — copy these straight
across.GOOGLE_REDIRECT_URI — set this one to
https://<your-vercel-domain>/api/email/oauth/callback instead of
the localhost version.localhost entry can stay for local use.prisma db push automatically,
which creates all the tables on the fresh database before the app
builds — nothing else to run by hand.registerAction in
src/app/login/actions.ts) to create your own real account, rather
than relying on the seeded demo one. If you'd like the demo data there
too, run npm run db:seed locally once with DATABASE_URL pointed at
the same production database.After this one-time setup, every future git push to this repo redeploys
automatically — no manual steps.
Setup (Google Cloud, one-time, a few minutes):
http://localhost:3000/api/email/oauth/callback
as an authorized redirect URI (or your deployed URL's equivalent)..env (GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI).Only the read-only gmail.readonly scope is ever requested — this app can
never send, delete, or modify anything in your inbox, and your password
never touches it. Each sync reads the messages from the last 60 days of
your inbox that it hasn't seen yet (a busy inbox takes a few rounds, which
the Sync button runs on its own), classifies each one, and keeps the text
only of what's academically relevant.
How a fact from an email actually reaches your schedule — this is the part of the spec worth being precise about ("don't blindly overwrite existing data," "everything Canvas already has doesn't need Gmail's confirmation"):
This logic is centralized in src/lib/change-rules.ts (pure, unit-tested —
see Verification) and enforced in
src/lib/pending-changes.ts, which is the only code path allowed to
write a value that originated from an email into Class / Assignment /
Exam / ScheduleEvent.
Postgres (prisma/schema.prisma's datasource is provider = "postgresql",
reading DATABASE_URL) — see Deploying to Vercel
for how that gets provisioned (Prisma Postgres or Neon, both zero-config
through Vercel's Storage tab). This project started on SQLite for
zero-config local dev and moved to Postgres once it needed to run
somewhere other than one laptop — same schema either way, Prisma just
abstracts the two, and nothing in this data model uses a provider-specific
type or feature.
Key relationships (see prisma/schema.prisma for the full picture with
field-level comments):
User ─┬─ Class ─┬─ ScheduleEvent
│ ├─ Assignment ─── Task (subtasks)
│ ├─ Exam
│ ├─ NoteSection ─── Note
│ ├─ Resource
│ └─ Email
├─ EmailAccount (Gmail OAuth link — one per user)
├─ CanvasAccount (Canvas access token — one per user)
├─ LmsConnection (Schoology key or Brightspace/Blackboard feed — one per LMS)
├─ CalendarFeed (secret calendar subscription link — one per user)
├─ SchoolAccessRequest (asked their school to turn on Campus OS — one per LMS)
├─ Email ─── PendingChange (conflict-resolution queue)
└─ AvailabilityBlock (explicit free-time entries)
Two design decisions worth knowing about:
lmsProvider / lmsCourseId /
lmsItemId make sync idempotent and mean the LMS is always the source of
truth for what exists — the app extends that data (tasks, notes,
estimates) rather than replacing it. They're unique per account, not
globally: classmates share the same ids, and each gets their own rows.
Every LMS's sync writes through one function (src/lib/lms/apply.ts).
Fields no LMS provides (professor, room, current grade, meeting times)
are either typed in directly from a class's Overview/Schedule tabs, or
filled in from an email via the PendingChange path above.PendingChange row
first — see Email intelligence for exactly when
that applies automatically vs. asks you.src/lib/priority-engine.ts is pure, dependency-free logic (no Prisma, no
Next.js) that turns assignments/tasks into the ranked list, the single best
recommendation, and the "I have X minutes" match (no longer on the
dashboard as of October 2026; the matcher is kept). It's unit tested in
tests/priority-engine.test.ts — run npm test.
Two things it deliberately does not do, straight from the spec:
computeWorkloadSummary returns
availableMinutesToday: null unless you've actually logged an
AvailabilityBlock for today, and the dashboard shows "not tracked yet"
rather than a fabricated number.It's also timezone-correct: User.timezone (default America/New_York)
drives every "is this due today?" calculation via date-fns-tz, rather
than the server process's own local timezone — which matters a lot once
this is deployed somewhere that runs in UTC.
Both pieces of phase 3 follow the same pure-logic-plus-server-glue split as the priority engine, so both are unit tested directly with no database or AI call involved:
src/lib/breakdown-heuristics.ts — the no-API-key fallback for
Automatic Assignment Breakdown. Splits an assignment's existing time
estimate into steps (never invents new total effort) and picks a
different shape depending on what the assignment looks like — a paper
gets outline/draft/revise, a presentation gets research/build/practice,
an exam gets review/practice/final-review, and anything short-form (a
quiz, a discussion post) is deliberately left unsplit. Tested in
tests/breakdown-heuristics.test.ts. The real path
(src/app/assignments/actions.ts, breakdownAssignmentAction) prefers
the AI version when a key is configured — it reads the assignment's
actual description and is told explicitly never to invent specifics
(topics, page counts, requirements) that weren't given — and falls back
to the heuristic otherwise. Either way the result is stored as real
Task rows, so it shows up on the dashboard, the class page, and the
Assignments list the same way regardless of which path produced it.src/lib/risk-engine.ts — the Behind/At-Risk assessment (on track,
getting behind, at risk).
Takes the priority engine's already-computed ranked list and workload
summary (never a second, possibly-inconsistent read of your data) and
applies real thresholds: any overdue item, an hour or more behind your
logged free time, three or more things due within 48 hours, or an exam
inside 72 hours combined with a busy couple of days all push the status
toward red; a single thing due soon only counts as yellow when your
actual logged capacity can't rule it out — if you've logged enough free
time to comfortably cover today, one due-soon item alone stays green.
Tested in tests/risk-engine.test.ts, including a case built directly
from the spec's own example scenario. Recommendations are capped (2-3
items, chained in one sentence, none at all when you're on track) so a
red status reads as "here's what to do" rather than a wall of text.Built: foundation, Canvas sync, Command Center (phase 1); per-class pages, flexible Notes, Schedule (phase 2); Automatic Assignment Breakdown and the Behind/At-Risk system (phase 3); Gmail OAuth, email intelligence, PendingChange conflict resolution, Inbox Academic Feed, per-class and cross-app AI assistants (phase 4, plus the assistants originally slated for "after phase 2/4" — built once both existed for them to draw on). That closes out every numbered phase from the original spec.
Added post-launch (2026-08-22), from real usage feedback once the app was actually running:
src/lib/text.ts)
and a "See in Canvas ↗" link. The link is built from the course/assignment
ids already stored for sync idempotency (canvasAssignmentUrl() in
src/lib/canvas.ts) rather than a new stored field, and is only shown
when both ids are actually known — never a guessed URL. WorkItem (the
priority engine's item shape) gained description/canvasUrl fields to
carry this from src/lib/workload.ts through to the dashboard's
TaskRow.src/app/schedule/page.tsx from "your weekly pattern
only" into a 7-day agenda: each of the next 7 days lists that weekday's
recurring class meetings and anything with a real due date landing on
that specific calendar date (assignments, exams — both link to Canvas
when known), sorted chronologically together. Recurring meetings still
have their Remove button right there since every weekday appears
exactly once in a 7-day window, so no separate "manage my pattern" view
was needed.No schema changes in this update — both features build entirely on data
already being stored, so no new prisma db push is required.
Moved from SQLite to Postgres for deployment (2026-08-22). Reece
wanted the app reachable without his Mac running the dev server — see
Deploying to Vercel. prisma/schema.prisma's
datasource is now postgresql (was sqlite); DATABASE_URL now points
at one real hosted database used for both local runs and production,
rather than a local SQLite file that could drift from what's deployed.
package.json gained postinstall: prisma generate and the build
script now runs prisma db push first, so every deploy keeps the live
schema in sync automatically — no separate migration step for a
single-user personal project like this one. No application code changed;
Prisma abstracts the two providers identically for everything this schema
uses (no native-type overrides, and the enum-vs-string fields were
already resolved for SQLite compatibility, which carries over cleanly).
In-app Canvas connection (2026-08-22). Previously the only way to pull
real Canvas data in was the npm run canvas:sync CLI script, which needed
CANVAS_BASE_URL/CANVAS_ACCESS_TOKEN in .env and terminal access —
fine for local dev, but a dead end once the app is deployed and you're not
running a terminal against it day to day. Added a /canvas page:
paste a Canvas URL and access token, it verifies the token against the
real Canvas API before saving anything, stores it encrypted the same way
Gmail's connection is (CanvasAccount.accessTokenEnc, src/lib/crypto.ts),
runs the first sync immediately, and offers Sync now afterward. The
actual sync logic (src/lib/canvas-sync.ts) is shared between this and the
CLI script rather than duplicated, so both stay in lockstep. One schema
change: new CanvasAccount model, applied automatically on the next
deploy via the existing prisma db push build step — no separate
migration.
Lectures, class materials, and automatic Canvas material sync
(2026-09). Added the Lectures tab (audio upload or pasted transcript →
AssemblyAI transcript → Claude-generated study notes) and ClassMaterial
(reference content folded into every lecture's notes and the class
assistant's context) with four ways to add it: paste, link, direct file
upload, and — the largest piece of this update — fully automatic Canvas
sync. Connecting Canvas (or clicking "Go fetch materials" any time after)
now discovers a course's real documents across Files, Modules, Pages,
Assignment attachments, and the Syllabus, not just the Files tab, which
some instructors hide entirely (confirmed on real courses — the files are
still reachable individually even when the tab is hidden). Sync is
incremental and idempotent by design (same engine for the first sync and
every later one; re-running never duplicates a material), and scanned
PDFs with no text layer are OCR'd by sending the whole file to Claude as a
native PDF document rather than rendering pages locally — a locally-rendered
approach was tried first and abandoned after real scanned course PDFs
turned out to use JBIG2 image compression the local PDF renderer couldn't
decode. All of this was verified against a real connected Canvas account
(6 courses, 280+ materials), including confirming idempotency by
re-running and checking that every existing row was untouched. Also fixed
a real bug found along the way: the single-link Canvas-file paste feature
was fetching files using whichever account happened to be in a global env
var, rather than the signed-in user's own connected Canvas account — a
real problem once more than one person had their own connected account.
Schema changes: ClassMaterial gained provider/canvasResourceId/
resourceType/canvasUpdatedAt/lastSyncedAt/syncStatus/syncError
plus a dedup unique constraint; new Lecture and CanvasSyncCourseState
models. See CLAUDE.md for the operational details and known limitations
(a locked file has to be re-checked on every sync since Canvas doesn't
signal an unlock the way it signals a content edit; two specific scanned
files still can't be OCR'd because Anthropic's own content-filtering
policy blocks the output, which isn't something to route around).
One connected class page (2026-09). Lecture notes now also live in the
Notes tab (one linked note per lecture, editable from either tab), and books
and slides moved from the Lectures tab to Resources, next to saved links.
Added in-app recording (LectureRecorder: keeps the screen awake, pauses,
and keeps the recording for a retry or a download if the upload fails), a
/record page that picks the class from the schedule, Voice Memos import
help, and AssemblyAI's webhook so notes get written without the page open.
Fixed along the way: materials added by hand never appeared on the class
page (a { not: ... } filter also drops NULL rows); uploads over 4.5 MB
failed on Vercel; notes showed raw Markdown; and the service worker cached
Next.js page-data requests, so router.refresh() showed the previous copy
after changes (a deleted lecture stayed on screen until a reload). The
service worker (v2) now caches static files only. Schema changes:
Note.lectureId and Lecture.noteSyncedAt, both nullable.
Phone layout and first-run experience (2026-09). Phones and tablets get
a bottom tab bar (Today, Classes, Record, Schedule, More) instead of a top
menu that ran off the screen, with safe-area padding for the iPhone notch
and home indicator. The dashboard puts "What should I do right now?" first
on phones and shows long lists five at a time. Assignments are cards on
phones and grouped as Overdue, Coming up and Done (AssignmentList,
src/lib/assignment-groups.ts), with Mark as done and deletable steps. A
new account sees a welcome with "Connect Canvas" instead of an "on track"
status, empty pages link to Canvas instead of listing terminal commands,
and AI or setup failures read as plain sentences, never as environment
variable names.
Redesign (2026-10). One design system across every page, in the
spirit of a modern SaaS app. The tokens in src/app/globals.css (neutral
grays, an orange accent, a blue-to-orange brand gradient, status colors,
light and dark) feed Tailwind, and shared classes cover the repeated
pieces: .card, .btn with btn-primary/-secondary/-ghost/-brand,
.field, .badge, .chip, .eyebrow and .empty. The Geist font
replaces the old serif headings. Wide screens get a sidebar (Record,
grouped navigation, account); phones get a frosted top bar and a floating
tab bar with Record raised in the middle. Pages share PageHeader and
CardHeader (src/components/ui/) and one line-icon set
(src/components/icons.tsx), which replaces the emoji that used to mark
status, file types and pins. Each class has a colored initials tile
(src/lib/course-style.ts). The landing, log-in, offline and not-found
pages use the same look, and the iPhone app's launch screen and offline
page use the new colors. No features were added or removed.
Payments (2026-10). A 7-day free trial from sign-up (no payment
details), then a monthly or yearly subscription: through Stripe on the
website and Apple's in-app purchase in the iPhone app (Apple requires its
own there), with one account unlocked on both. FREE_ACCESS_CODE makes an
account free for good; it's redeemed on the website under Account, since
Apple doesn't allow codes that unlock features inside the app. Everything
is off until PAYMENTS_ENABLED=1, which also opens sign-up to everyone.
The rules are in src/lib/billing.ts (pure, tested); requireUser()
sends an account without access to /subscribe (the account and subscribe
pages opt out with allowWithoutAccess), and the upload routes check it
too. Subscription rows are kept current by /api/stripe/webhook
(signature-checked) and /api/apple/notifications (App Store Server
Notifications V2, with Apple's certificate chain and signature verified in
src/lib/apple-iap.ts, no new dependency). A Terms of Use page and the
privacy policy cover subscriptions. Setup: .env.example (Stripe) and
ios/README.md (App Store Connect). Schema changes: User.trialEndsAt,
freeAccessAt, appleAccountToken, stripeCustomerId (all nullable) and
the new Subscription table.
Every class syncs, for every student (2026-10-05). A friend's Canvas
sync brought in 4 of their 6 classes. The cause: Class.canvasCourseId
and Assignment/Exam.canvasAssignmentId were unique across all
accounts, so when a second student synced a course someone had already
synced, the sync updated the first student's class instead of creating
the second student's own. The second student never got any class they
shared with an earlier user, and their submissions could mark the first
student's assignments "submitted". Now:
@map); prisma/pre-push.sql swaps the indexes in one transaction
before db push runs, since db push won't add a unique index to a
table with rows without a person confirming it. The build now compiles
first, so a failed compile never touches the database.Assignment.lmsSubmission, rules in src/lib/lms/status.ts).
Rows from before this fix that are marked done while Canvas says this
student hasn't turned them in (and they're due within the last week or
later) are reopened once, and the report lists them.CANVAS_BASE_URL before); the build backfills existing ones.src/lib/lms/apply.ts), ready for
other LMSs, and the materials engine is split the same way
(src/lib/materials-sync.ts, with Canvas's discovery in
canvas-materials-sync.ts). Schema changes: Class.lmsProvider,
Assignment.lmsUrl/lmsSubmission, Exam.lmsUrl,
CanvasAccount.lastSyncReport (all nullable), the per-account unique
indexes, and CanvasSyncCourseState is MaterialSyncState in code
(@@map, same table).Schoology, Brightspace and Blackboard (2026-10-05). Students whose
school doesn't use Canvas can connect the LMS it does use, from a new
Connect page (/connect; /canvas redirects to /connect/canvas) that
says what each one brings in. Everything goes through the same writer as
Canvas, so the per-account rules, per-course isolation and sync report
apply to all of them.
src/lib/lms/schoology.ts that re-signs redirects, never sends
the key off api.schoology.com, and stays under Schoology's 50 requests per
5 seconds). It has the same reach as Canvas, materials included
(schoology-materials.ts on the shared engine).src/lib/lms/ics.ts reads iCalendar; feed-courses.ts works out which
course each item belongs to (the course id in its link, its categories, a
course code in its title, or its location) and what it is (a deadline, an
exam, or something to skip like "available from" or a class meeting).
Feeds aren't laid out the same everywhere, so the student sees the
classes found, and can rename or leave any out, before connecting, and
again later under "Edit classes" (a class left out is hidden, not
deleted).src/lib/lms/safe-fetch.ts, which
only reaches public addresses (checked at connect time and on every
redirect) with size and time caps; the existing "add a material from a
link" feature now uses it too, and a Canvas address must be a public
https host.LmsConnection table (credentials encrypted, the
student's preview choices in settings, and the last sync report).Fitting each LMS (2026-10-05). Everyone gets the same pages; what changes is where an LMS shares less. Brightspace and Blackboard don't say what's been turned in, and their calendars reach back weeks (Blackboard's, a year), so a new student's first sync would have shown everything past as overdue. Now a deadline the LMS says nothing about comes in checked off when it's already more than a day past (the sync report gives the count), a course with nothing due in the last month starts out unticked in the preview, Today (for two weeks after connecting) and Assignments remind those students to check work off, and a class's Resources and Meeting times say what doesn't come over from its LMS. The last "Canvas"-only wording (a class's Meeting times, the subscribe page's feature list) now fits whichever LMS a student uses.
App Store review account (2026-10-06). Reviewers can't sign in to a
school's LMS, so an account listed in DEMO_ACCOUNT_EMAILS can fill itself
with made-up classes (src/lib/sample-classes.ts): five courses with
meeting times, assignments due over the next weeks, exams, a syllabus each
and a note. "Add sample classes" is on its empty dashboard and "Reset
sample classes" on its Account page; no other account sees either, and the
action refuses them too. See ios/README.md for the review steps.
Known limits, not gaps in this app: Gmail (GOOGLE_CLIENT_ID etc.) and
the AI assistants (ANTHROPIC_API_KEY) both require credentials you
create yourself — see "AI features (optional)" above and .env.example
for Gmail. Without them, the app behaves exactly as designed: email intelligence
is simply unavailable until connected, and every AI-backed feature falls
back to a deterministic, non-AI answer built from real data (see the
"Optional but required for AI-backed features" comment in .env.example)
rather than failing.
Not built — one deliberately flagged gap, unrelated to the phase plan:
ScheduleException model for single-occurrence schedule changes
("class is canceled this Friday only," as opposed to a permanent room
change) — the spec's canceled-class example is real but under-scoped for
the current ScheduleEvent model, which represents a recurring pattern,
not individual occurrences. Flagging this explicitly rather than
half-building it: right now, a canceled-class email is filed in the
Inbox Academic Feed (so you'll see it) but doesn't propose a structured
change.npm test — 345 unit tests in 30 files as of the review account (343 after fitting each LMS, 337 as of Schoology/Brightspace/Blackboard, 302 after the per-student sync fix, 273 as of payments, 238 after the redesign, 235 after the iPhone app work, 223 after the 2026-09-30 data fixes, 176 as of the 2026-09 security pass, 165 as of the Canvas materials sync)
update (12 test files; the newest cover Canvas resource
classification/dedup/incremental-diff logic, retry/pagination against a
stubbed Canvas API, and the PDF OCR fallback — see CLAUDE.md for where
those live). What follows is the original verification record from the
phases 1-4 build: the original 19 on the priority
engine (urgency bucketing, ranking, the minutes-mode matcher, workload
summary, the heuristic estimator); 21 from phases 2/4 on
tests/change-rules.test.ts (the same-value/auto-apply/conflict decision
behind "email never silently overwrites your schedule") and
tests/email-classify-heuristic.test.ts (class matching and email
categorization without an API key); and 22 new ones from phase 3 —
tests/risk-engine.test.ts (14 tests: on-track/getting-behind/at-risk
thresholds, the exact "I'd recommend X, then Y" phrasing from the spec's
own example, that reasons/recommendations stay capped even with many
simultaneous signals, and that a specific "behind by X" figure is never
claimed without real logged availability) and
tests/breakdown-heuristics.test.ts (9 tests: per-assignment-type step
shapes, the small-assignment no-op case, the 5-minute-per-step floor).
All four new-this-project test files caught a real bug during
development before it shipped: email-classify-heuristic caught a
course-code matcher that mishandled multi-segment codes like
ECON-2330-03; risk-engine caught a yellow-status threshold that
ignored logged free time and flagged "getting behind" even when a
student had comfortably enough time logged for the day; and
breakdown-heuristics caught a category-matching order bug that read
"Final Project" as exam prep instead of project work.npx tsc --noEmit — clean, project-wide, in strict mode, after
prisma generate has run. (If you see implicitly has an 'any' type
errors on Prisma query results, that means prisma generate hasn't been
run yet in this checkout — it's step 1 in Quick Start for exactly this
reason. As of this update, tsc in the sandbox this was built in shows
exactly that error class and nothing else — 40 identical TS7006/related
errors, all on Prisma query-result callbacks, zero of any other kind, and
that count hasn't grown even though phase 3 added new pages and
components.)src/lib/crypto.ts,
AES-256-GCM, key derived from AUTH_SECRET) — never stored in plaintext,
and only the read-only gmail.readonly scope is ever requested. The
OAuth state parameter is verified against a short-lived signed cookie
on callback (standard CSRF protection for the redirect flow).CanvasAccount.accessTokenEnc, same AES-256-GCM helpers) — never stored
in plaintext. connectCanvasAction verifies a token actually works
against the real Canvas API before saving it, so a typo'd or already-bad
token never gets persisted in the first place.npm audit reports 0 vulnerabilities as
of the 2026-09 upgrade (14.2.35 had 23 open Next.js advisories). The
overrides entry in package.json keeps Next's bundled PostCSS on a
patched version.SIGNUP_INVITE_CODE; closed when unset), and the
code is checked before revealing whether an email already has an account.RateLimitEvent, src/lib/rate-limit.ts)
so they hold across serverless instances: failed logins per email (10 per
15 minutes) and per IP (30), sign-up attempts per IP (10 an hour), AI
requests per user (200 a day), new lectures per user (15 a day) and audio
upload tokens per user (20 a day).CalendarFeed) is the only key to that student's feed,
so it's stored like a session token (an HMAC to find it) plus an
encrypted copy for showing it again; Schedule → Make a new link replaces
it. The feed has class names, codes, meeting times and rooms only, and
stops (403) when the account has no access.X-Frame-Options: DENY, a CSP limited to
frame-ancestors/base-uri/form-action/object-src, , a
strict referrer policy and a (see ).
A script-level CSP would need per-request nonces and isn't set.npx prisma generatedb pushtsc --noEmitprisma/schema.prismaenumAssignment.statusEmail.categoryPendingChange.statusnpx prisma db pushStringWorkStatuspriority-engine.tsEmailCategoryemail-classify-heuristic.tschange-rules.tstscnpm run build — not runnable end-to-end in the sandbox this was built
in (its network policy blocks Prisma's engine download and Google Fonts
at build time, both of which are normal, unauthenticated public CDNs
that a real dev machine or CI runner won't have trouble with). Everything
short of that — the full TypeScript compile and the full test suite —
passes clean; please run npm run build once in your own environment
before deploying, and flag anything that surfaces.src/lib/google-oauth.ts,
src/lib/gmail.ts) are written directly against Google's documented
REST endpoints but could not be live-tested in this sandbox — doing
so needs a real Google Cloud OAuth client, which only you can create
(see Email intelligence). If the OAuth handshake
or a Gmail API call behaves unexpectedly on your first real connect,
that's the one piece of this update running for the first time outside
a sandbox — let me know what you see and I'll fix it.priority-engine.ts/
workload.ts; scripts/sync-canvas.ts and the Canvas client weren't
touched at all. Every new page calls requireUser(), and every new
Server Action re-checks that the record being touched actually belongs
to the logged-in user before reading or writing it. The one existing
action phase 3 changed, toggleWorkItemAction, now also revalidates
/assignments and the owning class page (previously just /dashboard)
so a subtask checked off from the new Assignments/class views reflects
everywhere immediately — the dashboard behavior it already had is
unchanged.src/lib/canvas-sync.ts is the exact same
upsert logic scripts/sync-canvas.ts already had (moved, not rewritten),
so its correctness carries over from the CLI script's real-world use.
What's genuinely new — connectCanvasAction's token-verification call,
the encrypt/decrypt round-trip, and the /canvas page itself — passed
tsc --noEmit (still exactly the same 40 baseline errors, none from the
new files) and didn't touch any of the 62 existing tests, but could
not be live-tested against the real Canvas API in this sandbox, the
same limitation as the Gmail OAuth flow — try connecting your real
account and let me know what you see if anything looks off.nosniffPermissions-Policynext.config.mjs/privacy (src/app/privacy/page.tsx).
Keep it in step with what the code stores and sends. Set CONTACT_EMAIL
to show a contact address there, on /terms, and on /support, the
public help page that's the App Store listing's Support URL (Apple wants
it to show a way to reach you, so set it in production).pending-changes.ts regardless of
what the model returns) — it cannot, for example, be tricked by a
malicious or malformed email into writing to an arbitrary database field.