Skip to content
oyuhPublic

About

Single-user school and life dashboard. Courses, assignments, a daily journal and file attachments, with due dates synced to Google Calendar.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

loife

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

What you get

  • 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

Screenshots

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
The Today view on a phone, with items bucketed into overdue, today and later The courses list, each with its meeting days and room The journal, showing entries grouped by day
Item detail Command palette
An assignment open, with its due date, attachments and notes The command palette filtering items by course and due date

The same app on a wider screen, where the tab bar becomes a sidebar:

loife on a desktop browser, with a left sidebar and the Today view beside it

Requirements

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.

Running it locally

  1. Install the dependencies and copy the environment template:

    pnpm install && cp .env.example .env
  2. Generate a session secret and paste it into .env as SESSION_SECRET:

    node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
  3. 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.

  4. Find your numeric GitHub account ID and set it as ALLOWED_GITHUB_ID:

    curl -s https://api.github.com/users/your_github_username_here
  5. Point DATABASE_URL at a Postgres database, then apply the schema:

    pnpm db:push
  6. 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.

Configuration

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.

Deploying

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 start

The 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.

Railway

This is what the repo was set up against.

  1. Create a project and pick Deploy from GitHub repo, then your fork.
  2. Add the Postgres plugin. Railway injects DATABASE_URL into the service for you.
  3. Railway detects pnpm and runs pnpm build, then pnpm start. Run pnpm db:push once against the injected DATABASE_URL to create the tables.
  4. Add every variable from .env.example under Variables. Generate a SESSION_SECRET different from your local one.
  5. Under Settings → Networking, add a custom domain and point a CNAME at the target Railway gives you.
  6. Wait for the certificate to issue before you sign in. The session cookie sets Secure in production and will not survive plain HTTP.
  7. Set PUBLIC_URL to that domain with no trailing slash. It drives the OAuth redirect URI, so the .up.railway.app subdomain stops working once the custom domain is live.
  8. Set your GitHub OAuth app callback to https://your_domain_here/api/auth/callback, and the Google one to https://your_domain_here/api/google/callback.

Railway bills by usage and has no free tier. The hosts below do.

Free and cheaper hosts

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.

Free Postgres

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.

Free file storage

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

Technical notes

How the app is built, for anyone reading the source rather than running it.

Stack

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.

Scripts

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

Auth

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.

Schema changes

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:push

There 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.

Running against Docker

Docker runs Postgres and a MinIO container that stands in for R2:

pnpm local:up

That 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.

Checks

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.

Time zones

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.

Course colours and icons

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.

App icons

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.

License

MIT. See LICENSE. Copy whatever is useful.

About

Single-user school and life dashboard. Courses, assignments, a daily journal and file attachments, with due dates synced to Google Calendar.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Contributors

Languages