Build a custom personal dashboard with Claude Code — opinionated single-file guide Guide for building a custom personal dashboard using Claude Code as the primary developer, with a focus on minimal dependencies and fast iteration. It outlines a single-file starter structure for a FastAPI backend with SQLite and a React frontend, emphasizing key patterns like background task handling for data scraping and safe upsert logic that prevents failed scrapes from overwriting real data. The guide also recommends eventually splitting the reference sections into separate files for better AI assistant performance. Build a Custom Dashboard with Claude Code A single-file skill for building a personal dashboard from scratch using Claude Code as your primary developer. This bundles months of hard-won lessons from building and maintaining two custom dashboards into one reference. Everything you need is probably here, but this is not the actual system I used, it's just some helpers for another AI agent. The first thing you should do once you're rolling is move the reference sections Database, Design System, Frontend Patterns, etc. into their own files so your AI assistant can load only what's relevant per task. But start with this single file so you can see the whole picture, and tweak it according to your prefernces. --- Stack Chosen for minimal dependencies and fast iteration. A personal dashboard is a tool for one user so you just need something you can restart in a second and fix by squinting at the code. | Layer | Technology | Why | |-------|-----------|-----| | Backend | Python 3.12+ / FastAPI | Async, auto-docs, validation. You can read the code. | | Database | SQLite WAL mode | Single file on your laptop. No server. No config. | | Frontend | React + TypeScript + Vite | Mature, fast dev server, good tooling. | | Proxy | Vite dev server | Proxies /api to backend port. One origin, no CORS. | | ORM | None | Direct SQL. An ORM adds a layer of translation for no audience. | | State mgmt | None | useState / useCallback . You have one user. | | Component lib | None | Vanilla CSS. Keeps the bundle small, styling explicit. | | Routing lib | None | It's one page. | --- Architecture your-dashboard/ ├── backend/ │ ├── main.py FastAPI app — all routes │ ├── db.py SQLite connection, schema init, migrations │ ├── scrapers/ One file per data source │ │ ├── github.py │ │ ├── rss.py │ │ └── ... │ └── requirements.txt ├── frontend/ │ ├── src/ │ │ ├── App.tsx Start here. Extract components at ~800 lines. │ │ ├── App.css Single CSS file until you extract components │ │ └── components/ Created when App.tsx gets too big │ ├── vite.config.ts Proxy config lives here │ └── package.json ├── tests/ │ ├── test api.py pytest — endpoint shapes, upsert logic │ └── test ui.spec.ts Playwright — headless only, always ├── queue/ Task files for Claude Code see Queue System below └── .impeccable.md Design context for the frontend-design skill optional Per-card refresh architecture This is the core pattern that makes the dashboard feel alive without polling: - Each visual section of the dashboard maps to a data source - Each section has a GET endpoint fetch current data and a POST endpoint re-scrape that source - Each section has a refresh icon in its header that hits the POST endpoint - The frontend updates just that section's state on success python @app.get "/api/tasks" async def get tasks : return db.execute "SELECT FROM tasks WHERE status = 'open' ORDER BY priority" .fetchall @app.post "/api/refresh/tasks" async def refresh tasks background tasks: BackgroundTasks : background tasks.add task scrapers.tasks.sync from source return {"status": "refreshing"} Long-running refreshes scraping, API calls should return 202 immediately and run in a background thread. Track status in a scrape log table so the UI can show a spinner and the system can prevent duplicate concurrent runs. --- Database SQLite configuration python import sqlite3 def get db db path="dashboard.db" : conn = sqlite3.connect db path, timeout=30 conn.row factory = sqlite3.Row conn.execute "PRAGMA journal mode=WAL" conn.execute "PRAGMA busy timeout=30000" return conn | Setting | Value | Why | |---------|-------|-----| | WAL mode | Always on | Lets reads happen during writes | | busy timeout | 30000ms | Prevents "database is locked" when scraper writes overlap with frontend reads | | row factory | sqlite3.Row | Dict-like access without an ORM | | Write transactions | BEGIN IMMEDIATE | Single writer — claim the lock early, fail fast | Schema evolution no migration framework Migrations are ALTER TABLE statements that swallow "already exists" errors. Run on every app startup. Idempotent by design. python def migrate conn : migrations = "ALTER TABLE posts ADD COLUMN engagement score REAL DEFAULT 0", "ALTER TABLE tasks ADD COLUMN source TEXT DEFAULT 'manual'", for sql in migrations: try: conn.execute sql except sqlite3.OperationalError: pass column already exists conn.commit The upsert rule: never overwrite non-zero with zero This is the single most important database convention. Scrapers fail — they get rate-limited, time out, return empty responses. If you blindly upsert, a failed scrape at midnight will zero out your real data. sql INSERT INTO posts id, title, views, likes VALUES ?, ?, ?, ? ON CONFLICT id DO UPDATE SET title = excluded.title, views = CASE WHEN excluded.views 0 THEN excluded.views ELSE views END, likes = CASE WHEN excluded.likes 0 THEN excluded.likes ELSE likes END Apply this pattern to every numeric field that comes from an external source. The title a string can be overwritten freely; the counts cannot. And for that matter, never put 0 when the answer is really "null" because you don't have data. Scrape logging sql CREATE TABLE IF NOT EXISTS scrape log id INTEGER PRIMARY KEY, scraper TEXT NOT NULL, started at TEXT NOT NULL, finished at TEXT, status TEXT DEFAULT 'running', -- running | success | failed rows affected INTEGER DEFAULT 0, error TEXT ; Before starting a scraper, check if one is already running: python running = db.execute "SELECT 1 FROM scrape log WHERE scraper = ? AND status = 'running'", name, .fetchone if running: return {"status": "already running"} --- Design System Color system Pick exactly 4 colors. Assign them roles. Use CSS custom properties so theming is a one-line change. css :root { --color-primary: 722F37; / navigation, headers, emphasis / --color-accent: 2A9D8F; / interactive elements, links, success states / --color-highlight: C4813D; / warnings, fragile connections / --color-bg: FAF0E6; / page background / } The specific colors don't matter as much as having clear, non-overlapping roles. Avoid the AI color palette: cyan-on-dark, purple-to-blue gradients, neon accents on dark backgrounds. Design rules | Rule | Detail | |------|--------| | Information density over whitespace | If you have to scroll to understand state, you won't check the dashboard | | Every chart element links to detail | Click-through from everything — stats, chart bars, table rows | | Every table column must be sortable | Click header to toggle asc/desc. Missing sort = bug. | | Cap table rows at 5–10 | "Show more" toggle below. Don't render 500 rows on load. | | Per-card refresh buttons | Every section header gets a refresh icon | | No fake or interpolated data | If a scraper failed, show "no data" or last-known value | | Filter pills, not dropdowns | Clickable, highlighted when active. Filters are visible state. | | Collapsible sections | Expand/collapse with a chevron. Saves vertical space. | | Labels on everything | Don't hide context behind tooltips. You'll forget what unlabeled buttons do. | CSS anti-patterns the AI slop test These are patterns AI assistants produce by default. Push back on all of them: | Don't | Do instead | |-------|-----------| | style={{}} inline styles | CSS classes. Always. Inline styles bypass theming. | | Colored left-border "pill" indicators | Subtle background tint rgba color, 0.08 + border-radius | | Cards inside cards | Flatten the hierarchy. Not everything needs a container. | | Identical card grids icon + heading + text × N | Vary the layout. Tables, lists, inline stats — mix formats. | | Hero metric layout big number, small label, gradient | Inline the number in context where it's actionable | | Glassmorphism blur, glass cards, glow borders | Solid backgrounds. Decorative blur is never purposeful. | | Gradient text on headings or metrics | Solid color. Gradients are decoration masquerading as emphasis. | | Rounded rectangles with generic drop shadows | Sharp or very subtle radius. Drop shadows should be barely visible. | | Pure black 000 or pure white fff | Tint toward your palette. Pure B&W never appears in nature. | | Bounce/elastic easing | ease-out-quart or ease-out-expo . Real objects decelerate smoothly. | | Modals for everything | Inline expand, slide panels, or navigate. Modals are lazy. | | Gray text on colored backgrounds | Use a shade of the background color — gray looks washed out | The test: If you showed this interface to someone and said "AI made this," would they believe you immediately? If yes, fix it. Typography - Pick a distinctive display font + a clean body font. Pair, don't match. - Use a modular type scale with clamp for fluid sizing. - Vary weights and sizes to create clear hierarchy. - Don't use Inter, Roboto, Arial, Open Sans, or system defaults. These scream "didn't choose a font." - Don't use monospace as shorthand for "technical." - Don't put large rounded-corner icons above every heading — it's templated. Spacing - Create rhythm through varied spacing. Tight groupings, generous separations. Not the same padding everywhere. - Use clamp for fluid spacing that breathes on larger screens. - Don't center everything. Left-aligned text with asymmetric layouts feels more intentional. --- Scrapers Pattern One file per data source. Each scraper: 1. Fetches from one external source API, RSS, scrape 2. Transforms into your schema 3. Upserts into SQLite respecting the non-zero rule 4. Returns a count of affected rows python scrapers/github.py import os, httpx async def scrape db : resp = httpx.get "https://api.github.com/notifications", headers={"Authorization": f"Bearer {os.environ 'GITHUB TOKEN' }"} rows = 0 for item in resp.json : db.execute """ INSERT INTO respondables source, source id, title, url, created at, status VALUES 'github', ?, ?, ?, ?, 'pending' ON CONFLICT source, source id DO UPDATE SET title = excluded.title """, item 'id' , item 'subject' 'title' , item 'url' , item 'updated at' rows += 1 db.commit return rows Scraper rules - All secrets from environment variables. Never hardcode tokens in code or commands. - Idempotent. Running twice must not create duplicates. Upsert on natural keys. - Headless only. If a scraper needs a browser, Playwright in headless mode. Never pop a visible window — it steals focus and can trigger platform security flags. - No concurrent duplicates. Check scrape log before starting. - Log everything. Write to scrape log on start and on finish success or failure . You will need this to debug data gaps. --- Frontend interaction patterns Implement these from the start — retrofitting them is painful: Sortable table headers tsx const sortKey, setSortKey = useState 'created at' ; const sortDir, setSortDir = useState<'asc' | 'desc' 'desc' ; const toggleSort = key: string = { if sortKey === key setSortDir d = d === 'asc' ? 'desc' : 'asc' ; else { setSortKey key ; setSortDir 'asc' ; } }; // In the table header: