TreeMap
A free, open-source disk-space visualizer. Squarified treemap, duplicate hunting, disk-full forecasting, and cleanup that only ever moves files to the Trash.
Self-hosted personal dashboard with weather, calendar, email, GitHub, AI usage, health, and Spotify on one glanceable page, reachable from your phone over Tailscale.
ProductivityTypeScript · CSSGitHub @JoachimVNOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
One glanceable page for life + dev: weather, calendar, email, GitHub, transit departures,
electricity prices, and AI usage. Runs locally on
your own machine (macOS or Windows) and is just a web app at localhost:4821: open it in a browser
and you're done.Read the full README ↗One glanceable page for life + dev: weather, calendar, email, GitHub, transit departures,
electricity prices, and AI usage. Runs locally on
your own machine (macOS or Windows) and is just a web app at localhost:4821: open it in a browser
and you're done.
Everything beyond that is optional. Tailscale is only needed to reach it from your phone; without it the dashboard works fine on the machine it runs on. Individual widgets have their own requirements (iMessage is macOS-only, Health needs an iPhone Shortcut), and any widget you don't configure simply shows as "not configured" rather than breaking the page.
joavn.dev/dashboard — a fully interactive build running entirely
on fake, anonymized data (client/src/demo/), no backend involved. Published as a static build,
embedded in a separate Portfolio repo. .github/workflows/sync-portfolio.yml builds with
--base=/dashboard/ on every push to main (when client/shared sources or build config change) and
pushes the built client/dist into the Portfolio repo. sync-portfolio.sh does the same thing
locally/manually.
(Screenshots are generated from fake, anonymized data via server/scripts/screenshots.ts, and kept up to date automatically by .github/workflows/screenshots.yml.)
npm-workspaces monorepo:
client/: React + Vite + Tailwind SPA with a PWA manifest (install to phone home screen).server/: Express on 127.0.0.1:4821. Each data source is a provider polled on its own interval; results are schema-validated (zod) and cached in memory. Widgets read the cache via /api/widgets/:id.shared/: zod schemas + types shared by both.Persistent health, AI-usage, and Spotify history live in Railway Postgres so dashboard installs on
different machines see the same history. The connection string remains in ignored server/.env;
credentials and OAuth tokens stay in ignored server/.tokens/, while local-only layout state remains
in server/.data/.
DATABASE_URL is required. Set it to Railway's public Postgres URL (not the internal
postgres.railway.internal URL, which only works from Railway services), then migrate:
npm run db:migrate -w server
For an existing installation, stop every dashboard server first, back up each machine's
server/.data/, choose the authoritative history source, and import it explicitly:
npm run db:import-json -w server -- /path/to/server/.data
The importer is idempotent for the same export. Keep the JSON backups as rollback material; do not run two old JSON-backed dashboard versions while performing the cutover.
npm install
cp server/.env.example server/.env # fill in what you want enabled
cp server/config.example.json server/config.json # optional, defaults are fine
npm run dev # server :4822 + client :5173
Widgets without credentials show as "not configured" instead of breaking; enable them one at a time.
Production mode (server serves the built client at http://localhost:4821):
npm run build
npm start
macOS: installs a launchd agent that keeps npm start running and restarts it at login:
./scripts/install-launchd.sh
Production runs from its own deploy clone (~/.local/share/personal-dashboard/repo), not from
your working copy, so a dirty tree or a WIP branch can't take down the dashboard your phone is
looking at. Credentials and fetched data (.env, config.json, .tokens/, .data/) are moved once
into ~/.local/share/personal-dashboard/state and symlinked into both checkouts, so the two share a
single set of OAuth tokens: keeping two copies would mean two clients refreshing the same grant, and
Spotify and Hue rotate refresh tokens on use, so one copy would eventually be left with a dead token.
Opt in to auto-update and a second agent polls your origin/main every 5 minutes, hard-resets the
deploy clone to it and restarts, so merging to main updates the dashboard on your phone by itself:
PD_AUTO_UPDATE=1 ./scripts/install-launchd.sh # PD_UPDATE_INTERVAL=300 to change the cadence
Installing it is the one manual step, and it has to be: nothing here listens for inbound connections, so GitHub can't reach into your machine to start anything; something has to run locally once. After that it's hands-off. The updater refreshes its own copy too, so a commit that changes the update script still lands by itself; only a change to the launchd agents themselves (the poll interval, say) needs the installer re-run.
Windows: two options:
Win+R → shell:startup → add a shortcut with target
cmd /c "cd /d C:\path\to\Personal-Dashboard && npm start" (runs with a visible console).cmd, arguments
/c cd /d C:\path\to\Personal-Dashboard && npm start, and tick "Run whether user is logged on or not".Run npm run build once first on Windows so client/dist exists.
The server binds to loopback only. To reach it from your phone:
tailscale serve 4821
This proxies the dashboard onto your tailnet with HTTPS (required for PWA install). Requirements: Tailscale installed and signed in on this machine and on your phone (same tailnet), and HTTPS certificates enabled once in the admin console (Enable HTTPS).
On the phone, open the printed https://<machine>.<tailnet>.ts.net URL in Safari and use Share → Add to Home Screen: the dashboard then launches fullscreen like an app. Both your Mac and Windows PC can serve their own instance; the phone just bookmarks each machine's URL.
Setting HOST=0.0.0.0 instead exposes the dashboard unauthenticated on your LAN; only do that on networks you trust.
npm run typecheck # tsc --noEmit in all workspaces
npm test # vitest (scheduler/cache behavior)
No key needed: set WEATHER_LAT / WEATHER_LON in server/.env.
No key needed. With WEATHER_LAT / WEATHER_LON set, the widget automatically shows real-time
departures from the stops nearest those coordinates, using Entur's
national journey-planner API (covers every Norwegian operator: AtB, Ruter, Skyss, …). When the
dashboard PWA shares your phone's location, the stops follow you the same way weather does.
To make specific stops (e.g. the ones you actually take, which auto-discovery might skip in favor
of a nearer but less useful stop) take priority, list their NSR ids in server/config.json under
transit.stopIds — find ids at stoppested.entur.org (they look
like NSR:StopPlace:41613). These are shown whenever you're within transit.favoriteRadiusMeters
(default 5 km) of one of them; if you're not near any of them right now, it falls back to
auto-discovery, capped at transit.maxStops stops within transit.nearbyRadiusMeters (default
2 km). transit.departuresPerStop tunes how much the card shows per stop.
No key needed. With WEATHER_LAT / WEATHER_LON set (or the dashboard PWA sharing your phone's
location), the widget auto-detects your bidding area
(NO1 Øst … NO5 Vest) from those coordinates via Entur's reverse geocoder, the same way transit
follows the dashboard's location.
To pin a specific area instead (e.g. it's wrong near a zone border, or you want a location other
than your own), set it explicitly in server/config.json — this always overrides auto-detection:
{ "power": { "area": "NO3" } }
The card charts today's (and, after ~13:00, tomorrow's) hourly Nord Pool spot price from the free Hva koster strømmen.no API, and the command center gets "price spike now" / "cheaper power ahead" signals. Prices are the raw day-ahead spot price — grid rent, taxes, and strømstøtte are not included.
Set GITHUB_USERNAME and GITHUB_TOKEN in server/.env. Create a classic PAT (github.com → Settings → Developer settings) with the repo scope. A fine-grained PAT does not work here: GET /users/{username}/events (used for the activity feed) doesn't support fine-grained PATs at all, so private-repo activity silently never shows up.
The repo-health card shows all your owned, non-fork, non-archived repos, fetched live from the GitHub API; there's no pinned-repo list to maintain in server/config.json.
Optionally set GITHUB_ISSUES_TOKEN for the capture issue button (creates an issue on a repo from the dashboard). Falls back to GITHUB_TOKEN when unset, so it only needs setting if you want the issue-creation token scoped differently from the read/activity one.
Note: the activity feed uses GitHub's events API, which is delayed (typically minutes); it is not real-time.
Each service has its own card showing its current rolling allowance: five-hour and weekly percentages, with reset times, not token totals or estimated costs. A thin marker on each bar shows where usage "should" be if it tracked evenly with the window's elapsed time; the marker turns amber when usage is running ahead of that pace.
aiUsage.codexRefreshMs (default 30000) and reads live account limits through codex app-server every five minutes. Manual Refresh always requests a new account reading. It does not open a coding thread, consume a reset credit, or start project MCP servers. The account read uses the documented rate-limits endpoint.claude CLI. The card opens a temporary terminal and runs the interactive /usage command in screen-reader mode; it never sends a model prompt. Set aiUsage.claudeRefreshMs (default 900000 / 15 min) to change the cadence. Polling continues at quota caps so early allowance resets can be detected. Only live CLI reports and confirmed persisted snapshots supply quota values: coding transcripts can contain examples or test output and are used only for token totals.Usage snapshots never move backward in observation time, including when another machine writes to the shared database. A failed live read retains its actual observation timestamp.
The tracked .codex/config.toml deliberately inherits globally configured MCP servers. Defining a project Railway url alongside a global Railway command merges incompatible transports and prevents sessions from starting. Run npm run doctor:codex -- --config-only to validate the effective root configuration.
Each machine's dashboard reports that machine's signed-in accounts only.
No key needed, no defaults — both feed lists start empty until you add RSS feeds in server/config.json:
news.feeds: general-purpose RSS feeds (e.g. Hacker News), shown in the Personal section.aiNews.feeds: RSS feeds each tagged with a provider ("openai" or "anthropic"), shown in the AI usage section alongside the Claude Code / Codex allowance cards.{
"news": { "feeds": [{ "name": "Hacker News", "url": "https://news.ycombinator.com/rss" }] },
"aiNews": {
"feeds": [
{ "name": "OpenAI", "url": "https://openai.com/news/rss.xml", "provider": "openai" },
{ "name": "Anthropic", "url": "https://news.google.com/rss/search?q=site:anthropic.com/news&hl=en-US&gl=US&ceid=US:en", "provider": "anthropic" }
]
}
}
dashboard).server/.env:
ICLOUD_USERNAME: your Apple ID emailICLOUD_APP_PASSWORD: the generated xxxx-xxxx-xxxx-xxxx passwordBy default all event calendars are shown; to limit it, list display names in server/config.json under calendar.allowlist (e.g. ["Personal", "NTNU"]).
Apple does not always expose subscribed web calendars through CalDAV. Add any such private HTTPS ICS feed in server/.env, including a display name that can also be used in the allowlist:
CALENDAR_ICS_FEEDS='[{"name":"My site","url":"https://example.com/api/calendar/<calendar-id>"}]'
When DASHBOARD_PUSH_URL and DASHBOARD_PUSH_SECRET are already configured, all of that linked site's active countdowns are included automatically through a protected aggregate feed.
One-time setup:
personal-dashboard), enable the Gmail API, and configure the OAuth consent screen.server/.env as GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET.npm run setup:gmail -w server, open the printed URL, approve. The refresh token is saved to server/.tokens/gmail.json (owner-only permissions); restart the server.The widget requests only the gmail.metadata scope: message headers and labels, never bodies.
⚠️ Testing-mode expiry: while the OAuth consent screen is in Testing status, Google expires refresh tokens after 7 days and you'd have to re-run setup weekly. Fix: on the consent screen page, add yourself as a test user, then publish the app (it can stay unverified; only your own account uses it); published apps get long-lived refresh tokens.
One-time setup:
server/.env as SPOTIFY_CLIENT_ID / SPOTIFY_CLIENT_SECRET.npm run setup:spotify -w server. It prints an exact redirect URI (http://127.0.0.1:8888/callback); add that under the app's Settings → Redirect URIs and save.server/.tokens/spotify.json (owner-only permissions); restart the server.Read-only scopes (user-read-currently-playing, user-read-recently-played, user-top-read) power the Spotify section: now playing, recently played, and top artists/tracks over the last 4 weeks / 6 months / all time. Spotify's API has no all-time or top-albums endpoint, so those are seeded once from Spotify's long_term (multi-year) top lists and then grown from observed plays in the shared Postgres history.
STEAM_API_KEY and STEAM_ID in server/.env, then restart the server.Steam's own privacy settings gate what the widget can show: Game details must be public for library totals and achievement progress, and Friends list visibility determines whether the friends-playing signal works — with either set to private, that part of the widget degrades to a quiet empty state rather than failing. The integration is read-only (it never writes to your Steam account) and the API key is only ever used server-side, never sent to the browser.
Sits alongside Steam and Clash Royale as a tab on the Games page.
ROBLOX_ID in server/.env to your Roblox username or numeric user ID.Presence (what you're playing right now) and favorited games additionally need ROBLOSECURITY, a full-account session cookie (not a scoped API key — anyone holding it can act as your account, no 2FA required, since it is an already-logged-in session):
roblox.com, and copy the full value of .ROBLOSECURITY — including the _|WARNING:-DO-NOT-SHARE-THIS...|_ prefix, which is part of the literal value, not a comment.ROBLOSECURITY in server/.env.It expires/rotates periodically (faster if the requesting IP differs from where it was issued — running the dashboard from the same home network you copied it from helps), so expect to redo this occasionally; a run of 401s from the Roblox widget after it's been working means the cookie is dead, not a bug. Revoke it anytime by logging out of all Roblox sessions or changing your password.
CLASH_ROYALE_API_KEY and CLASH_ROYALE_ID (your player tag, e.g. #ABC123) in server/.env, then restart the server.Read-only: player profile, current deck, upcoming chest cycle, and recent battle log.
If the server's public IP changes (dynamic ISP address, or the machine moves between networks), requests start failing with HTTP 403 until you update the allowlist at developer.clashroyale.com — the server logs its current public IP once at startup ([clash-royale] server's current public IP is ...) as a quick copy-paste source when that happens.
Uses HenrikDev's unofficial Valorant API — Riot doesn't offer a personal-use stats API of its own (its official Developer keys expire every 24 hours unless you're approved for production access), and HenrikDev's is what most third-party Valorant stat sites are built on.
HENRIKDEV_API_KEY, RIOT_ID (your Riot ID as Name#Tag), and RIOT_REGION (na/eu/ap/kr, defaults to eu) in server/.env, then restart the server.Read-only: account level, current and peak competitive rank, current-season win rate, and the last 10 matches (map, agent, KDA, headshot %, result). Rank and agent icons are pulled from valorant-api.com's public asset CDN, which needs no key of its own.
Shows up as a "Code quality" block at the bottom of the GitHub page, listing every project in your org with its quality gate status, ratings, coverage, and duplication — one card per repo.
SONARCLOUD_TOKEN and SONARCLOUD_ORG (the key in sonarcloud.io/organizations/<key>) in server/.env, then restart the server.Every project in the org is shown; there's no per-repo allowlist.
Lights go through Philips' cloud (the official Remote API), the same path the Hue phone app uses, so the widget works no matter what network the Mac is on. The bridge's LAN IP is not involved.
One-time setup:
http://127.0.0.1:8842/callback.server/.env as HUE_CLIENT_ID / HUE_CLIENT_SECRET.npm run setup:hue -w server, open the printed URL, log in with your Philips Hue account and approve. The script exchanges the OAuth code and remotely provisions a bridge allowlist user (the cloud equivalent of pressing the link button), saving both to server/.tokens/hue.json (owner-only permissions).Control is read + write: toggling a light, dragging its brightness slider, or tapping one of your Hue app scenes (shown as chips grouped by room) sends the change through the cloud to the bridge. Access tokens auto-refresh; if the widget stops working after months of the server being off, the refresh token has expired; re-run step 3.
Reads ~/Library/Messages/chat.db directly (read-only), no setup beyond granting Full Disk Access:
npm run dev from Terminal.app or iTerm: add that terminal app.install-launchd.sh agent: launchd execs node directly with no GUI parent, so add the node binary itself (find it with which node, e.g. /opt/homebrew/bin/node).Shows the most recent message per conversation and an unread count. Personal chat handles are resolved through the local macOS Contacts database, while group/business display names come from Messages; unresolved handles fall back to their phone number or email. Modern attributedBody message text is decoded, and attachment-only messages show as [attachment].
⚠️ Privacy: message previews are cached server-side and served to any device that reaches this dashboard, i.e. your phone over Tailscale, not just something read and kept on the Mac.
Apple Health can't be read from a server, so the phone pushes to the dashboard instead. There's no external setup and no credentials; the widget appears immediately (empty until the first post) and is fed by an Apple Shortcut you build once.
Build a Shortcut (and, optionally, a Personal Automation that runs it on a schedule) that:
Uses Get Health Sample / Find Health Samples actions to read today's totals (steps, active energy, exercise minutes, etc.). For steps, use one action filtered to Apple Watch and one filtered to iPhone when possible.
Builds a Dictionary with any of these keys (all optional, all merged into today's entry) so you can post them from separate actions:
| key | unit |
|---|---|
watchSteps | count, from Apple Watch |
phoneSteps | count, from iPhone |
steps | count, legacy fallback only |
activeEnergyKcal | kcal |
exerciseMinutes | minutes |
standHours | hours |
heartRate | bpm |
restingHeartRate | bpm |
walkingHeartRate | bpm |
bloodOxygenPercent | percent |
Add date (YYYY-MM-DD) only to backfill a past day; it defaults to today.
Gets Contents of URL: POST http://<your-tailscale-name>:4821/api/health/ingest, Request
Body JSON, set to the dictionary.
Posts through the day overwrite that day's totals (send cumulative values). The dashboard keeps both
step sources and uses the higher one for the daily goal and trends, rather than adding them, because
the same walk may be recorded by both devices. The raw values are retained only to calculate that
single normalized step total. Step goal, exercise-minute goal and history retention live under
health in server/config.json (defaults: 10 000 steps, 30 min, 30 days).
To survive the server being off/asleep when the Shortcut fires, post a rolling window instead of a
single day: send { "days": [ ... ] } where each entry is the dictionary above with its date set
(up to 31 entries). Every day in the window is merged like a normal post, so the first successful
run after an outage backfills the gap automatically; e.g. always sending the last 7 days makes any
outage shorter than a week self-healing.
The rolling window covers an outage, but the Shortcut still has to name one machine, and the post
fails outright whenever that machine is asleep. Storage is not what goes down — every dashboard
already shares one Railway Postgres — so server/src/ingest.ts runs the ingest route, and nothing
else, as its own always-on Railway service. Point the Shortcut at it once and it stops caring which
machine is awake.
The service has no route back to a dashboard — they sit on a tailnet and are usually asleep — so it
announces each write through the one thing every installation shares, the database. It issues a
Postgres NOTIFY on the health_ingest channel and each dashboard holds a matching LISTEN,
refreshing the health and command-center providers on arrival instead of waiting out the 5-minute
poll. Delivery is a single round trip (~170 ms against Railway), coalesced over a second so a
31-day window causes one refresh rather than 31. Announcements are best-effort: a failed one costs
freshness, never the ingest, because the poll still catches up. A dashboard also refreshes whenever
it subscribes, so one that was asleep or lost its connection picks up whatever it missed on rejoin.
Deploy it as a second service in the same Railway project as the Postgres:
railway.json supplies the start command and
healthcheck; no build step runs, because the service executes TypeScript through tsx.DATABASE_URL (use the internal postgres.railway.internal URL — this service
runs inside Railway, so it doesn't need the public proxy) and HEALTH_INGEST_TOKEN, a random
string of at least 24 characters. The service refuses to start without both, so setting them
after the first deploy means redeploying.NIXPACKS_INSTALL_CMD=npm ci --omit=dev. The ingest service needs only express,
postgres, drizzle-orm, dotenv, zod and tsx; the default npm ci additionally pulls in
Playwright, Vitest and the client's toolchain — roughly 400 packages that only slow the build
down and give it more ways to fail.npm run start:ingest -w server and not the dashboard's npm start,
which would try to boot every provider and fail on the credentials this service doesn't have.Then repoint every Shortcut's Get Contents of URL — tap Show More to reach the fields below the URL:
| field | value |
|---|---|
| URL | https://<service>.up.railway.app/api/health/ingest |
| Method | POST |
| Headers | Key Authorization, Text Bearer <HEALTH_INGEST_TOKEN> |
| Request Body | JSON, set to the dictionary as before |
Three things that reliably go wrong here:
Bearer prefix is part of the header value. The word Bearer, one space, then the token.
A bare token is not a bearer credential and returns 401 unauthorized. iOS also capitalises the
first character of a text field, which silently corrupts a token starting with a lowercase letter —
paste the value rather than typing it./api/health/ingest. /api/health is the liveness probe: it answers
{"ok":true} and discards the body, so a Shortcut aimed there appears to succeed while storing
nothing. A trailing slash on /ingest/ is harmless.{"error":"unauthorized"} back means the request arrived, so the URL and method are
already right and only the header is wrong. A network-level failure looks different — iOS reports
it as "the network connection was lost".The phone no longer needs Tailscale up for health posts once this is in place, so the Shortcut also works on cellular with the VPN off.
node-pty is an optional dependency for this reason: it ships prebuilds for macOS and Windows
but not linux-x64, where it falls back to compiling against a Python toolchain the build image
doesn't have — npm ci dies on a missing Python and takes the whole build with it. Only the AI-usage
probes import it, and they never run on Linux, so a Linux install skips it rather than failing
outright.
⚠️ Unlike the dashboards, this endpoint is on the public internet rather than behind Tailscale, so
that token is the only thing in front of it. It's the one route the service exposes and it can only
write health_days, but treat the token like any other secret in server/.env. Skip this whole
section if you'd rather keep every ingress on the tailnet — the rolling window above still makes
outages self-healing.
The GitHub widget polls, so a push shows up whenever its interval comes round. If you also run the always-on ingest service, GitHub can tell it the moment something happens instead.
Set GITHUB_WEBHOOK_SECRET on the Railway service to a random string — without it the route isn't
mounted at all, so an installation that never set one up has no unauthenticated GitHub surface
sitting there. Then, per repository, Settings → Webhooks → Add webhook:
| field | value |
|---|---|
| Payload URL | https://<service>.up.railway.app/api/github/webhook |
| Content type | application/json — the default x-www-form-urlencoded will not work |
| Secret | the same GITHUB_WEBHOOK_SECRET |
| SSL verification | enabled |
| Events | Let me select individual events |
Tick pushes, pull requests, pull request reviews and review comments, issues, issue comments, branch
or tag creation and deletion, releases, forks, stars, watches, and repositories. Not send me
everything: on a repo with CI, check_run and workflow_run fire constantly and change nothing
the dashboard shows. Those are ignored server-side too (IGNORED_GITHUB_EVENTS), so a stray tick
cannot cause a refresh storm.
The webhook is a cache-invalidation signal, not a data source. A verified event is archived
under source = 'github-webhook', then announced on the provider_refresh channel so every
dashboard re-polls the GitHub API — which already knows how to assemble its own payload. That
avoids modelling every GitHub event shape, and means the widget updates rather than diverging from
what the API would say. Requests are acknowledged before the announcement, because GitHub times out
at 10 seconds and retries.
Webhooks only fire for repositories you administer, so this supplements polling rather than replacing it; activity on other people's repos still arrives on the usual interval.
Every provider's payload is archived to signal_history as it settles, so the dashboard
accumulates a queryable record instead of only ever holding the latest reading in memory. Two
things keep that from being wasteful:
SignalHistoryStore.record compares against the current
value first, so a provider polling every minute that only moves twice a day costs two rows, not
1440.ready envelopes are recorded. A failed fetch leaves the last good payload in the cache
— that is what stale means — and re-recording it would forge an observation that never happened.Nothing is ever pruned. The history is the point; no retention window applies to this table.
command-center and activity-push are excluded by default, holding no data of their own: the
first is derived from the other providers, the second is a delivery mechanism. Change that list
under history.excludeProviders in server/config.json; existing rows are left alone.
⚠️ Privacy: this includes the Gmail and iMessage payloads, which carry subjects and message
previews. They are stored indefinitely in your Railway Postgres — a step beyond the server-side
cache the iMessage widget already keeps. Add "gmail" and "imessage" to history.excludeProviders
if you would rather they stayed in memory.
Query it with select value from signal_history where source = 'clash-royale' order by recorded_at.
The Personal section's widget cards can be reordered: open Personal → Arrange (top-right), then drag a card to its new position. The order is saved server-side (server/.data/layout.json, gitignored) and shared across every device that reaches this dashboard.