Dashboard Mirror is a personal dashboard that mirrors the services I use as live widgets on a single page. This post covers what it shows, how an update travels from GitHub or Gmail to the screen, and how adding a new widget stays a one-file job.
Dashboard Mirror
Active
A four-week Epitech project: sign in, link your accounts, drop widgets on a grid and watch them update in real time.
The idea is simple: stop opening ten tabs to know if the CI is green, if a mail arrived or when the next RER leaves. Each widget is an instance of a provider with its own configuration and refresh interval, and you can have as many as you want.
The dashboard: weather, YouTube, Discord, RSS, GitHub contributions and Spotify, all live
Widget
What it shows
Update mode
GitHub Repo
Commits, CI state, PRs, issues, latest release
Push (webhook) + polling
Gmail
Unread mails, optionally per label
Push (Google Pub/Sub)
YouTube
New videos of a channel
Push (WebSub), polling fallback
Spotify
Current track with playback controls
Polling
Calendar (ICS)
Events from links or .ics files, as card, list, agenda or banner
Polling
IDFM
Real-time departures, trips from A to B, Vélib' stations
Polling
Weather, RSS, Discord
City weather, feed entries, your servers
Polling
Around the widgets there is the usual product surface: multiple dashboards, drag and drop, a full-screen mode, a shareable view, and export or import of a dashboard configuration. An in-app bell and optional browser push tell you when a widget changes.
The browser never talks to a backend service. The Next.js app acts as a backend-for-frontend and reaches three services through an internal Traefik gateway.
authentification/# accounts, sessions, OAuth, API keys, mail, privacy
core/# widgets API + scheduler worker
src/providers/ (highlighted)# one file per widget source
streaming/# SSE relay over Redis pub/sub
frontend/# Next.js app (BFF + UI)
shared/# auth-client, core-client, streaming-client, mail
Each service owns its own PostgreSQL database, and Redis carries pub/sub, presence, replay buffers and the BullMQ queue. Tokens are verified locally: authentification publishes a JWKS, so core and streaming never call back on each request.
Accounts are linked from the Providers page. Several accounts per provider are allowed, and the widgets that need no account, such as weather, RSS or the transit ones, are listed as built in.
Every change goes through the same pipeline, whether it comes from a poll or a webhook. The result is diffed against the widget's last value, stored if it changed, and published as a widget.updated event.
Collect
A BullMQ job polls the source at the widget's TTL, or the source calls a signed webhook on core.
Diff and store
applyWidgetUpdates() compares with lastValue. Nothing changed means nothing is published.
Publish
The event goes on Redis pub/sub, and streaming relays it over Server-Sent Events to every open tab.
Replay
Each event has an id, so a browser that reconnects sends Last-Event-ID and gets what it missed.
SSE was enough here because traffic only flows server to browser. It reconnects on its own and goes through the Next.js proxy without any special handling.
The part I like most is that the scheduler knows nothing about GitHub or the weather. A source is a provider class, and the generic infrastructure only talks to its contract.
core/src/providers/rss.tsts
const RssConfigSchema = z.object({ url: z.string().max(2000).refine(isHttpUrl), length: z.coerce.number().int().min(1).max(20).default(5),})// A PollableProvider only answers three questions:// listUnits(widgets) -> which external calls are needed// fetch(unit) -> the one source-specific step// diff(prev, next) -> did something change?
A pollable provider implements listUnits, fetch and diff. A pushable one implements verify, parse, match and reduce for its webhook. YouTube does both: WebSub push, with polling as a fallback for users without an active lease.
listUnits also deduplicates. Several widgets pointing at the same feed become one fetch unit, so the external API is called once and the result fans out to all of them.
The backend stack came out of a written comparison between Hono with Prisma, FastAPI, Rails, Go and Express, scored on team experience, ecosystem, real-time fit, Docker complexity and long-term risk. Shared TypeScript types between front and back, and a first-class SSE helper, decided it.
The same habit applied to smaller choices. For the ICS widget I benchmarked two parsers on a 397 KB synthetic school planning with recurring events and exceptions, then picked ical.js because it has no dependency and gives the same instants on any machine.
The whole stack starts from Docker Compose, and the app is then available on port 8080.
zsh
$ pnpm install
$ pnpm run docker:dev
$ pnpm run admin:promote -- you@example.com
Sign up first, promote that account to admin, then create the service accounts so the services can call each other. After that, link your providers and open the gallery with the Add a widget button to pick one.
The provider contract is stable enough that the next widgets are small. The open points are mostly operational: revoking push subscriptions when a user unlinks an account, and creating GitHub webhooks automatically instead of by hand.