A to-do app
loife is the school dashboard I actually open before class. This is the UI work behind it: a swipe menu that fits a 375px phone, an add form cut from nine controls to four, and a grace period so ticking a box doesn't wipe the row.
- View more posts using TanStack Start
- View more posts using React 19
- View more posts using TypeScript
- View more posts using Tailwind CSS
- View more posts using shadcn/ui
- View more posts using kibo-ui
- View more posts using Drizzle
- View more posts using PostgreSQL
- View more posts using Cloudflare R2
- View more posts using Railway
A school dashboard, and also a to-do list
loife tracks my courses, assignments, readings and exams. It keeps a daily journal, holds file attachments, and pushes due dates onto a Google Calendar named loife, so they go into in the calendar app I already used.
Under all of that it’s just a to-do list.
I know it’s just a to-do list.
My project list reads like a genre study at this point. A music overlay, a game launcher, a comment system, and a to-do list. One weather app away from the full set lol.
Phone ui/ux first
The phone layout is one column with a bottom tab bar, and you swipe rows sideways for actions. At tablet width the tabs turn into a sidebar and the dialogs get laptop-sized.
The nav order took a few tries. It’s Today, Command/Search, Journal, Add, with Today twice as wide as the rest. Courses live in the command palette, since I set a term up once and then leave it alone.
Tapping a row used to drop me into an edit form. Now it opens a detail sheet with the notes, files and the three fields I change in a hurry: done, priority and when. The full edit form is still there, one button deeper.
Three layout bugs I caused
The swipe menu didn’t fit. Three labeled buttons behind the row needed 176px. On a 375px screen, Delete clipped against the right edge and the row text slid off the left. I replaced the labels with 52px icon buttons, so all three fit and the row title still starts at x=21. I measured this one instead of eyeballing it.
The week strip ran to both screen edges. It looked wider than everything under it, and the first card sat against the side of the phone. I had pulled the row full bleed with a negative margin, then padded it back in. Good carousel trick, wrong place for it.
Scroll snap made it worse. It aligns against the scrollport instead of its padding, so the browser ate the 20px gutter as soon as it picked a snap target.
iOS sized my time field for me. The native time control wanted more room than its 7rem column, so it pushed the Due row offscreen and made the whole page scroll sideways. Clearing its minimum width didn’t fix it. I added appearance: none and rebuilt the row as a grid with fixed tracks, which gives the control nowhere to push. It now fits at 320, 375, 390 and 430px.
Nine controls became four
The add form was shaped like an assignment form. It had four stacked rows of toggle buttons, a course picker hidden under a disclosure and nine controls shouting at the same volume.
The type is the only choice I make each time, so that row kept its buttons and picked up icons. I leave priority, time estimate and revision total at their defaults most days. Giving them six equal tap targets made them look more important than they are, so they’re dropdowns now.
Picking Assignment, Reading or Exam puts the course field in the body of the form. An assignment without a course is probably a mistake. A to-do without one is normal. If I already picked a course, it stays visible when I switch types instead of hiding a value the form will still save.
Then I deleted four sentences of helper text. Worth having on a form somebody meets once, noise on one I fill in three times a day.
Ticking a box shouldn’t erase the row
Finished work used to vanish as soon as I tapped the checkbox. My thumb isn’t precise, so the row hangs around long enough to undo it. P1 gets the longest grace period.
| Priority | Sticks around for |
|---|---|
| P1 | 2 hours |
| P2 | 1 hour |
| P3 | 30 minutes |
| P4 | 15 minutes |
| P5 | 10 minutes |
A setting can replace those times with one fixed delay, including zero if I want the old behavior back. The row also shows when I finished it. Reopening the task clears that time, so it doesn’t keep counting from a completion I undid.
My palette was fighting my components
I built on shadcn/ui with the kibo-ui registry on top, then kept my own color tokens. Bad mix. Kibo’s components pull their whole look from those tokens, so mine all came out a little wrong.
The borders gave it away first. Kibo uses oklch(1 0 0 / 10%), white at ten percent, while my solid gray divider looked much heavier. Kibo also expected a 0.625rem radius and a card surface two steps lighter than mine.
The stylesheet uses Kibo’s tokens now, with one change. Its primary blue is oklch(0.5808 0.245 262.881), and white text on top measures 4.44:1
. A button label needs 4.5:1
, so I dropped the lightness by 0.01 and got 4.60:1
. I can’t see the difference, which is kind of the point.
One module writes every date
Nine components each built their own Intl.DateTimeFormat without a locale or time zone. The same instant showed up as Sep 15 in one list and September 15 in another. It could also move by an hour or a day depending on where the browser thought it was.
I routed all nine through one module and pinned the zone to America/Chicago. A deadline that says 11 pm on my laptop shouldn’t say 9 pm because I carried my phone to another state. Dates use “Tue, Sep 15”, times use the 12-hour clock and relative labels switch from minutes to calendar days after an hour. At 8 pm, a 9 am deadline says “tomorrow” instead of “in 13 hours”.
Hovering a date shows the year, seconds, zone and offset. The row stays short, but I can still check the exact timestamp when something looks off.
The bits I’d keep
The grace period and the four-control add form are the two bits I’d steal from myself next time. Neither looks exciting in a repo description. Both took longer than the calendar sync.
The code is MIT and public, so take any part of it. You can’t sign into mine because the callback checks a numeric GitHub ID and answers 403 to everyone who isn’t me. That’s probably the most honest thing on my resume.