loife is a single-user school and life dashboard. It tracks courses, assignments, readings and exams, keeps a daily journal, stores file attachments, and pushes due dates into Google Calendar so they land in whatever calendar client you already read. One GitHub account can sign in. Everyone else gets a 403.
I built it to track my own day to day. The code is MIT licensed, so take any part of it you want.
Built for a phone first. The base layout is one column with a bottom tab bar, and rows you swipe sideways for actions. At tablet width and up it becomes a sidebar with dialogs, so a laptop gets the room it has. It installs as a PWA if you want it on a home screen, and nothing is hidden on any size.
Contents
- A Today view that buckets items into overdue, today, this week and later
- Courses with meeting days, term dates, rooms, instructor contacts, a colour and an icon
- A journal with per-day entries, events and course links
- File attachments on items and journal entries, stored in a private S3 bucket
- Google Calendar sync onto a dedicated calendar named
loife, never your primary one, carrying each course's colour through to Notion Calendar - A command palette and a history page that share one filter language
- A syllabus parser that turns pasted dates into draft items
- Light and dark themes, both checked against a 4.5:1 contrast floor
Phone screens at 390x844, captured against the seeded demo database. The wide shot at the bottom is the same build at 1440px.
| Today | Courses | Journal |
|---|---|---|
![]() |
![]() |
![]() |
| Item detail | Command palette |
|---|---|
![]() |
![]() |
The same app on a wider screen, where the tab bar becomes a sidebar:
Three things are required:
- Node 22 or newer, and pnpm
- Postgres 16 or newer
- A GitHub OAuth app, which is how you sign in
Two more turn on optional features:
- An S3 compatible bucket, such as Cloudflare R2, for file attachments
- A Google Cloud project with the Calendar API enabled, for calendar sync
Docker covers Postgres and the bucket while you look around, so you can defer both. See running against Docker.
-
Install the dependencies and copy the environment template:
pnpm install && cp .env.example .env -
Generate a session secret and paste it into
.envasSESSION_SECRET:node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" -
Create a GitHub OAuth app at github.com/settings/developers. Set the callback URL to
http://localhost:3000/api/auth/callback. Copy the client ID and secret into.env. -
Find your numeric GitHub account ID and set it as
ALLOWED_GITHUB_ID:curl -s https://api.github.com/users/your_github_username_here
-
Point
DATABASE_URLat a Postgres database, then apply the schema:pnpm db:push
-
Start the dev server:
pnpm dev
The app runs at http://localhost:3000.
To fill the database with sample courses, items and journal entries, run pnpm db:seed. It writes three courses and ten items spread across overdue, today, this week and later, so every bucket on the Today view has something in it. It refuses to run against any host other than localhost.
Every variable lives in .env.example with a comment. The ones you cannot skip:
| Variable | What it is |
|---|---|
PUBLIC_URL |
Base URL of this deployment, no trailing slash. Drives both OAuth redirect URIs |
SESSION_SECRET |
32 characters or more. Seals the session cookie |
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
From your GitHub OAuth app |
ALLOWED_GITHUB_ID |
The one numeric GitHub account ID allowed to sign in |
DATABASE_URL |
Postgres connection string |
TZ |
Your IANA zone, such as America/Chicago. Day boundaries come from it |
Optional, and the features they unlock:
| Variable | Unlocks |
|---|---|
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
Calendar sync |
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET |
File attachments |
R2_ENDPOINT |
Points the S3 client at a self-hosted bucket instead of Cloudflare |
An OAuth app holds one callback URL at a time. Keep a second GitHub OAuth app pointed at http://localhost:3000/api/auth/callback so local development survives a deploy, and give each environment its own client ID and secret.
If you deploy to a different zone from the one you live in, change DISPLAY_TIME_ZONE in src/lib/datetime.ts to match TZ. The two have to name the same zone, or a server-rendered page flips its dates on hydration.
The build produces a plain Node server at .output/server/index.mjs, so any host that runs a long-lived Node process will do. There is no Dockerfile and no platform config file to keep in sync.
Two commands are all a host needs:
pnpm build && pnpm startThe server does not apply the schema on boot. Run pnpm db:push against the deployed database whenever the schema changes, before the code that needs it goes out.
This is what the repo was set up against.
- Create a project and pick Deploy from GitHub repo, then your fork.
- Add the Postgres plugin. Railway injects
DATABASE_URLinto the service for you. - Railway detects pnpm and runs
pnpm build, thenpnpm start. Runpnpm db:pushonce against the injectedDATABASE_URLto create the tables. - Add every variable from
.env.exampleunder Variables. Generate aSESSION_SECRETdifferent from your local one. - Under Settings → Networking, add a custom domain and point a CNAME at the target Railway gives you.
- Wait for the certificate to issue before you sign in. The session cookie sets
Securein production and will not survive plain HTTP. - Set
PUBLIC_URLto that domain with no trailing slash. It drives the OAuth redirect URI, so the.up.railway.appsubdomain stops working once the custom domain is live. - Set your GitHub OAuth app callback to
https://your_domain_here/api/auth/callback, and the Google one tohttps://your_domain_here/api/google/callback.
Railway bills by usage and has no free tier. The hosts below do.
These tiers change often. Read the current pricing page before you commit to one.
| Host | How it fits |
|---|---|
| Render | Free web service tier. It sleeps when idle, so the first request after a gap waits out a cold start of up to a minute. Fine for a dashboard you open a few times a day |
| Fly.io | Runs the Node process in a small VM near you, billed by usage on the smallest shared-CPU machine. You attach Postgres yourself |
| Koyeb | Free instance tier with no sleep. Deploys straight from a GitHub repo |
| Zeabur, Northflank | The same shape as Railway, each with a free tier |
| A VPS you already rent | Clone, pnpm build, then run pnpm start under systemd or PM2. docker-compose.yml already defines Postgres and a bucket |
Vercel, Netlify and Cloudflare Pages need extra work. They run functions rather than a long-lived server, so you would swap the Nitro preset in vite.config.ts. Nothing in the app blocks it. Nobody has tried it.
None of the hosts above have to supply the database. Point DATABASE_URL anywhere:
- Neon has a free serverless Postgres tier and scales to zero between requests
- Supabase has a free Postgres project, with a dashboard on top
- Xata and Prisma Postgres both offer free tiers
The app talks plain Postgres through Drizzle, so any of these work with a connection string and nothing else.
Attachments go through the S3 API, so the bucket is yours to choose:
- Cloudflare R2 is what the code targets by default. Its free tier covers 10 GB and charges nothing for egress
- Any S3 compatible provider works if you set
R2_ENDPOINT. Backblaze B2, Wasabi and a self-hosted MinIO all speak the same API - Leave the four
R2_*variables unset to run without attachments
How the app is built, for anyone reading the source rather than running it.
| Layer | Choice |
|---|---|
| Framework | TanStack Start v1 on Vite, React 19, TypeScript |
| Routing and data | TanStack Router, TanStack Query v5 |
| Styling | Tailwind v4, one CSS import and no config file |
| Components | shadcn/ui base, kibo-ui registry on top |
| Database | Postgres, Drizzle ORM, schema pushed with drizzle-kit |
| Calendar | Google Calendar API v3 over fetch |
| File storage | S3 API via @aws-sdk/client-s3 |
| Auth | Hand-rolled OAuth over fetch, sessions sealed by TanStack Start |
| Lint and format | Biome |
Route files under src/routes drive the router, src/server holds the server functions, and src/lib holds the pure logic the check scripts exercise. Components come from shadcn/ui with the kibo-ui registry on top.
| Command | Does |
|---|---|
pnpm dev |
Dev server on port 3000 |
pnpm build |
Production build into .output |
pnpm start |
Run the built server |
pnpm check |
Biome lint and format check |
pnpm db:push |
Push the schema to whatever DATABASE_URL points at |
pnpm db:studio |
Open Drizzle Studio |
pnpm db:seed |
Fill a local database with sample data |
pnpm local:up |
Start the Docker stack, described below |
One user. The GitHub callback compares the numeric account ID against ALLOWED_GITHUB_ID and answers 403 for anything else. The ID is used rather than the username, because GitHub lets a username be changed and then claimed by someone else.
Sessions are sealed cookies from TanStack Start's useSession, which handles the encryption and the signing.
src/db/schema.ts is the only description of the schema. pnpm db:push diffs it against a live database and applies the difference:
pnpm db:pushThere is no migrations folder. One person owns this database, so a generated SQL file per change bought a history nobody read and a second thing to keep in sync with the schema.
The trade is that db:push needs a reachable database and someone to run it. Deploys do not apply the schema themselves, so push before you ship a change that depends on a new column. It also asks before anything destructive, which is worth reading rather than accepting.
Docker runs Postgres and a MinIO container that stands in for R2:
pnpm local:upThat starts both containers, waits for their healthchecks, creates the bucket, and pushes the schema. Then start the app against them:
pnpm local:dev| Command | Does |
|---|---|
pnpm local:up |
Start the containers and push the schema |
pnpm local:dev |
Run the dev server against the containers |
pnpm local:down |
Stop the containers, keeping the data |
pnpm local:down --clean |
Stop them and drop the volumes |
pnpm local:restart |
Down, then up |
local:dev passes the container connection strings through the environment and never reads .env. Keep your production credentials in .env for pnpm dev, and reach for local:dev when you want a session that cannot touch either.
Postgres listens on 5433, because an installed Postgres already holds 5432. Browse the bucket at http://localhost:9001 with loife / loifelocal.
None of these need Docker or a database you started yourself.
| Command | Covers |
|---|---|
pnpm check:dates |
Today view bucketing, ordering and grace periods |
pnpm check:datetime |
Site-wide date and time formatting |
pnpm check:search |
The filter language shared by the palette and history |
pnpm check:contrast |
Every theme text pair clears 4.5:1 |
pnpm check:color |
Course colours mapping onto Google's eleven |
pnpm check:layout |
The Today page's saved section order |
pnpm check:plan |
Day planning and busy windows |
pnpm check:schedule |
Course meeting patterns and recurrence |
pnpm check:syllabus |
Parsing dates out of pasted syllabus text |
pnpm check:journal |
Journal line parsing |
pnpm check:time |
Reading a typed time six ways |
pnpm check |
Biome lint and format |
check:contrast reads the theme tokens straight out of src/styles.css and asserts every text pair clears 4.5:1. The palette is hand-picked from a VS Code theme, so nothing about it is legible by accident.
check:layout covers disagreements between what a browser saved and what the current build knows about. None of them are reachable from the UI.
check:search covers the date formats, the completion and duration filters, and the rule that a key whose value cannot be read falls back to free text rather than becoming no filter at all.
The display zone is pinned in src/lib/datetime.ts rather than read from the browser. One person's calendar should read the same on a laptop and on a phone carried to another state. TZ does the same for the server, and the two have to agree.
A course carries a hex colour and one of a hundred icons. Both appear wherever the course does: the Today list, the week strip, the month calendar and its day panel, the day plan, history, the command palette and the item detail. Anything belonging to a course takes that course's mark, so a task reads as the class it came from. A course with no icon falls back to a dot in its colour, which is what these lists drew before icons existed.
The colour also reaches your calendar. Google Calendar takes no hex. It takes one of eleven fixed event colours, and Notion Calendar draws an event in whatever colour Google reports for it. So src/lib/google-color.ts maps a course colour onto the nearest of the eleven. It compares in CIE Lab rather than RGB. In RGB, pure blue and pure green sit as far apart as two blues you cannot tell apart. The formula is CIEDE2000, which corrects for the blues. Without that correction #0000ff lands on Grape instead of Blueberry.
The eleven presets in the picker each land on a different one of Google's eleven. Pick a preset and Notion shows that colour exactly. Pick a custom colour and it rounds to the nearest, which the picker names under the swatches. pnpm check:color fails if two presets ever collide on one Google colour.
Items inherit their course's colour, so a deadline and the class it belongs to land on one colour in Notion. Recolouring a course repushes its dated items, since their events hold the old colour until something rewrites them.
Icons are lucide export names, listed in src/lib/course-icon.ts and imported by hand in src/components/course-icon.tsx. A fixed hundred rather than all of lucide, so the bundle carries a hundred glyphs instead of sixteen hundred. The two lists are typed against each other, so dropping a name from one and not the other fails the build.
public/ holds one wordmark in six shapes: favicon.svg, favicon.ico (16/32/48), apple-touch-icon.png, and the three the manifest points at. All of them are the word "loife" traced out of Outfit at weight 800 with -0.02em of tracking, in --primary.
They are generated, not drawn, and nothing in this repo generates them. There is no Python toolchain here to hang a script off. To redo them after changing the font, the weight or --primary, take the Outfit variable TTF from google/fonts and walk the glyphs with fontTools. Use SVGPathPen for the svg and FreeTypePen for the raster sizes. src/components/wordmark.tsx renders the same letters as live text and has to be kept in step by hand.
The svg and the two png sizes the manifest calls "any" are transparent. The apple touch icon and the maskable icon are not, because iOS composites alpha onto black and Android's mask wants a full bleed. Those two sit on --background.
MIT. See LICENSE. Copy whatever is useful.





