homework-app (legacy)
A shared homework tracking website for schools and classes. Includes journals, a social feed, and scheduled notification reminders.
Yes I know I'm a hypocrite. My philosophy is as simple as possible, but because I can and for experience, I want to use and practice different services.
What it is
A homework tracker for a class group, with journals, a social feed, and web push reminders that fire before things are due. Students post homework, teachers post announcements, everyone can comment, and the browser tells you about it without a refresh.
Live at homework.solartuff.co.id.
The repo is a monorepo of three deployable services and six backing services, wired together with Docker Compose. One LICENSE at the root covers all of it (GPL-3.0-or-later). That is a deliberate choice, not an accident of copy-pasting.
Architecture
| Service | Tech |
|---|---|
frontend | Next.js 16, React 19, Tailwind 4, TypeScript |
api | Express 5, TypeScript, Mongoose, Zod |
worker-rust | Rust, Tokio, Lapin, web-push |
mongo | MongoDB, replica set rs0 |
redis | Redis, pub/sub and cache |
rabbitmq | RabbitMQ, job fanout |
meilisearch | Meilisearch, full text search over posts |
minio | S3-compatible object storage for uploads |
cloudflared | Cloudflare Tunnel, the only public ingress |
The API is a plain layered Express app: routes -> middlewares -> controllers -> services, with Mongoose models at the bottom. Twelve routers, eleven models, and a validate.middleware wrapping the Zod schemas in src/schemas.
The replica set is not optional
Mongo runs as a single node replica set, initiated by a one-shot mongo-init-replica job on boot. That is not ceremony: creating a reply, joining a group, and changing a relationship each need to touch more than one collection atomically.
const session = await mongoose.startSession();
await session.withTransaction(async () => {
// reply + vote counters, group membership + group counters
});Standalone mongod cannot do that. A lot of self-hosted setups skip the replica set and then quietly lose data on a partial write.
Real-time is SSE, not WebSockets
Every write that should show up live does three things: save to Mongo, publish to a Redis channel, and return. It never pushes to the client directly.
await redisClient.publish('homework-updates', JSON.stringify({ action: 'create', payload: homework }));The API holds a plain array of open text/event-stream responses, and every instance subscribes to the same three channels (homework-updates, post-events, journal-events) then fans out:
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();The browser side is a one-liner per view:
const eventSource = new EventSource(`${process.env.NEXT_PUBLIC_API_URL}/events`);The reason to go through Redis is that in-memory clients arrays are per-process. Publishing to a channel means a write handled by replica A still reaches a browser connected to replica B. SSE also reconnects on its own and needs no library, no upgrade handshake, and no sticky sessions.
Auth is one JWT, verified twice
NextAuth v4 with the MongoDB adapter owns sessions in the frontend. The API does not re-implement login — it reads the same signed token with getToken() from next-auth/jwt, using the same AUTH_SECRET:
const token = await getToken({ req, secret: process.env.AUTH_SECRET, raw: true });
const decoded = await getToken({ req, secret: process.env.AUTH_SECRET });
if (decoded && decoded.sub) {
req.user = { sub: decoded.sub, role: decoded.role || 'member' };
return next();
}protect and isAdmin are then ordinary middleware over the role claim. No second token format, no shared session table, and revoking a user is a matter of the existing admin tooling.
The worker: scheduling and push
Notification scheduling is the one part of this app that is genuinely awkward in Node, so it lives in Rust. Six queues and two exchanges:
pub(crate) struct Queues;
impl Queues {
pub(crate) const HOMEWORK_CREATED: &'static str = "homework_created_queue";
pub(crate) const PREFERENCE_CHANGED: &'static str = "preference_changed_queue";
pub(crate) const HOMEWORK_DELETED: &'static str = "homework_deleted_queue";
pub(crate) const HOMEWORK_DUE_NOTIFICATION: &'static str = "homework_due_notification_queue";
pub(crate) const POST_FANOUT: &'static str = "post_fanout_queue";
pub(crate) const POST_NOTIFICATION: &'static str = "post_notification_queue";
}The API publishes and moves on. The worker consumes, and because queues are durable and messages are persistent, a worker restart during a burst of homework posts does not lose the reminders.
Scheduling is a materialized schedule, not a cron per item
Creating a homework item does not start a timer. It writes one ScheduledNotification document per subscribed user, with a deterministic id:
pub fn generate_job_id(homework_id: &str, pref_id: &str) -> String {
format!("{}-{}", homework_id, pref_id)
}homeworkId-prefId is unique, which is what makes the hard parts idempotent. Deleting the homework deletes those rows and cancels the jobs. Changing a notification preference fans out to every future homework for that user and reschedules. No timers to leak, nothing to reconcile on restart — the schedule is just rows, and an hourly node-cron job in the API sweeps the ones already sent.
VAPID signing
Push goes through web-push with isahc, and OpenSSL is built vendored so the container needs no system libssl:
let mut sig_builder = VapidSignatureBuilder::from_base64(&private_key, &subscription_info)?;
sig_builder.add_claim("sub", subject.as_str());
let signature = sig_builder.build()?;Search
Posts are indexed into Meilisearch rather than pattern-matched out of Mongo. Searchable on title and content, filterable on author, homework, parent, sortable by createdAt.
Index config is code, not a UI click, which means a fresh environment indexes correctly on first boot. When the index and the database drift, one command repairs it:
npm run make-admin --prefix api # promote a user to admin
npm run sync:search --prefix api # reindex Meilisearch from scratchThe grouper is a genetic algorithm
The seating-chart tool is a real GA, running in the browser, with the knobs exposed in a settings panel:
export type GAConfig = {
popSize: number;
mutationRate: number;
elitismCount?: number;
maxInstantGens?: number;
visualDelayMs?: number;
createInitialPop: (popSize: number) => number[][];
calculateFitness: (genome: number[]) => number;
mutate: (genome: number[], mutationRate: number) => number[];
crossover: (p1: number[], p2: number[]) => number[];
};The problem is NP-hard in practice — partitioning thirty students into tables so that friends sit together and enemies do not has no formula, so it gets evolved. Elitism keeps the top genomes, parents are drawn from the better half, and calculateFitness scores a layout on the constraints.
The first version froze the tab: thousands of generations ran in one tick and the UI died. The fix was two modes. runVisual steps a generation every visualDelayMs so you can watch the seating rearrange itself, and an instant mode batches up to maxInstantGens per frame for when you just want the answer. Same algorithm, same result, different pacing.
Markdown, end to end
Journals and posts are authored in a Monaco-backed editor and rendered by a hand-built pipeline rather than a WYSIWYG:
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath, remarkBreaks, remarkDirective]}
rehypePlugins={[
rehypeKatex, rehypeSlug, rehypeAutolinkHeadings,
rehypeExternalLinks, rehypeRaw,
]}
>rehype-katex renders the maths, rehype-slug plus rehype-autolink-headings give every section an anchor and a permalink, and rehype-raw means pasted HTML survives the round trip.
Code fences are the part I care about most, because syntax highlighting is most of what makes a code-heavy post readable. Prism with vscDarkPlus and oneLight, swapped off next-themes, a copy button, and the language label resolved from a generated GitHub Linguist extension map — so ts shows as TypeScript and not as "ts".
const match = /language-(\w+)/.exec(className);
const language = match ? match[1] : 'plaintext';
const languageName = Linguist.get(language);The map is generated, not hand-maintained, which is the only reason it stays correct as languages are added.
Uploads
Files go to MinIO through the minio client and are served from storage.solartuff.co.id. The bucket and its public read policy are created by a minio-setup one-shot job, so a blank volume still comes up working.
Development and production are different files
docker-compose.dev.yml runs the six backing services with ports published to the host, so the API and frontend run on the host with fast reload. docker-compose.yml runs all eleven services on an internal app_net bridge, where nothing is published at all:
docker compose -f docker-compose.dev.yml up -d # infra only, ports open
cd api && npm install && npm run dev
cd frontend && npm install && npm run devdocker compose up -d # everything, no public portsThe only thing crossing the boundary is cloudflared, so the host needs no open ports and no certificates. The tradeoff is that the tunnel is now a single point of failure for the whole app — if the tunnel container dies, the app is gone, regardless of how healthy everything else is.
By the numbers
| Frontend TS/TSX files | 116 |
| Frontend lines | ~10,100 |
| API TS files | 54 |
| API lines | ~3,200 |
| Worker Rust files | 9 |
| Worker Rust lines | ~780 |
| Mongoose models | 11 |
| API routers | 12 |
| Frontend pages | 21 |
| RabbitMQ queues | 6 |
| Backing services | 6 |
| Compose services | 11 |
| Commits | 53 |
| Editions | TypeScript 6, Rust 2024 |
What I'd do differently
Socket.IO is a dependency and never used. It is in api/package.json from an earlier design. The real-time layer ended up as hand-rolled SSE on top of Redis pub/sub, which is the right call, so the dependency should go.
There is no test suite. npm test in the API is still the npm init stub. The scheduling logic in scheduler.rs — preference offsets, cancelled jobs, the homeworkId-prefId idempotency key — is exactly the kind of code that should have unit tests, and it is the code I trust least.
Notifications are one collection that gets swept hourly. It works, and the sweep is cheap, but a Mongo TTL index on sendAt would let the database do the deleting instead of a cron job that has to be deployed and monitored.
The design has no token layer, and it shows. Every color is a raw Tailwind palette step picked at the call site — neutral-400 for one muted label, neutral-500 for the next, neutral-600 for a third, with gray- and zinc- mixed in next to neutral- for no reason. All eleven of the eleven neutral steps are in use. Dark mode is a hand-rolled @custom-variant plus dark: utilities repeated across 44 components, and :root defines exactly two variables, neither with a dark counterpart. There is no single place to change the palette, which is what "coherent" would have meant.