What this is, and how it works
A financial watchlist: sign up, search for a company, add it, and get live prices, a candlestick chart, fundamentals, news and price alerts that arrive by email. Built to be read as much as clicked through — the interesting parts are the constraints and what they forced.
Stack
Two data providers, and why
Neither free tier covers the whole surface, so the app uses both and hides that fact from everything above the data layer.
One interface, swappable vendors
Callers import getQuote, getCandles, getNews and friends from one module and never learn which vendor answered. Vendor-specific code is isolated in a providers directory. That seam has already earned itself once: the chart moved from Finnhub to Twelve Data when the paywall was discovered, and nothing outside the data layer changed.
The browser never talks to a provider
Every external call runs server-side, so the API keys never reach the client and responses can be cached and rate-limited centrally. The two routes the browser can call are authenticated and bounded — an open proxy to a metered API is somebody else’s free tier.
Batching, because 8/min is the real limit
A dashboard with twelve companies makes one request, not twelve: the tickers go out as a single comma-separated quote call. Chart history is billed per request rather than per bar, so a page of 750 candles costs exactly what 180 would — meaning depth is a payload decision, not a budget one.
When a provider fails
Free tiers fail routinely, so failure is a value rather than an exception. The data layer never throws; it returns either data or one of five reasons, and every surface maps those to copy that says something true.
Stale beats empty
Quotes and news are cached in Postgres. If a refresh fails and a cached value exists, the page renders the cached number with a visible as of 14:32 badge instead of an error. A slightly old price is a far better answer than a broken card, and the badge is what keeps that honest.
Vendor internals stay on the server
Raw provider messages, status bodies and URLs are logged server-side only — some providers echo the query string, which carries the key. The browser sees mapped copy and nothing else.
Data model
Five tables. The interesting parts are the constraints — most of what could go wrong here is enforced by Postgres rather than checked in application code, because an application check races and a constraint does not.
profiles
- id
- uuid pk → auth.users, on delete cascade
- display_name
- text
- created_at
- timestamptz not null default now()
Created by a Postgres trigger on auth.users insert, never from the client. A client-created profile row is a row that may not exist — the trigger makes it an invariant instead of a hopeful sequence of two calls.
watchlist_items
- id
- uuid pk
- user_id
- uuid → auth.users, on delete cascade
- ticker
- text, upper-case + length checks
- company_name
- text
- added_at
- timestamptz not null default now()
- unique
- (user_id, ticker)
The uniqueness of a company per user lives in the database rather than in a check-before-insert, which races under concurrent requests. Tickers are stored upper-case and constrained to be — without that, 'aapl' and 'AAPL' are different rows and the unique constraint silently stops doing its job.
price_alerts
- id
- uuid pk
- user_id
- uuid → auth.users, on delete cascade
- ticker
- text, upper-case + length checks
- condition
- enum: above | below
- threshold
- numeric(20,6), check > 0
- triggered_at
- timestamptz
- active
- boolean not null default true
- created_at
- timestamptz not null default now()
threshold is numeric, not a float. Money compared with >= under binary floating point fires at the wrong number, and an alert that triggers a cent early is a bug a user notices immediately.
quote_cache · news_cache
- ticker
- text pk
- quote_json / article_json
- jsonb not null
- fetched_at
- timestamptz not null default now()
Row-level security is enabled on both with zero policies, which makes them unreachable from the browser entirely — only the server, holding the secret key, can read them. A client able to read quote_cache could enumerate every ticker the whole user base follows.
Security
The database is the boundary, not the interface. Every table holding per-user data has row-level security enabled with a policy of user_id = auth.uid(), so queries carry no ownership clause at all — Postgres decides what a session may see. A cross-user isolation suite proves it by trying.
Testing
Guards get tested by being given something they should catch. The JSON-LD escape is handed a real </script> breakout; the auth gate is probed with path traversal; the chart’s scroll-paging test was rewritten twice after it was caught passing against a deliberately broken build.
Performance and loading
Perceived speed is treated as a design surface. The shell streams immediately and each unit of data suspends on its own, so one rate-limited ticker cannot hold up eleven others.
A skeleton means we have nothing, and usually we have something
Cache-first means a returning visitor’s prices are already known and paint instantly. Skeletons are for genuine cold loads, they wait before appearing so a fast response beats them entirely, and they hold a minimum once shown — a placeholder that flashes for 80ms reads as a glitch rather than as speed.
Placeholder timing, not just placeholders
A skeleton waits before appearing, so a response that beats it is never interrupted by one, and holds a minimum once shown. A placeholder that flashes for 80ms reads as a glitch rather than as speed.
The hero chart is its own loading state
The landing page draws a gold candlestick chart in over four seconds from a committed fixture rather than a live call — the highest-traffic route is the least defensible place to spend a daily budget, and it means the first thing anyone sees can never be an error. Reduced-motion gets the finished chart immediately, not a degraded one.
Emails
Three messages leave the app: a price alert when a threshold is crossed, and the signup and password-reset mails. Each one is below, rendered from the same source that sends it — so this page cannot drift from the inbox, and nothing has to be triggered to be reviewed.
They are built to email’s constraints rather than the web’s: tables for layout because Outlook renders with Word, styles inlined because Gmail drops a stylesheet, and each button doubled as VML so it survives both. Every alert also carries a plain-text alternative.
How it ships
Nothing reaches production by being pushed. The repository is public, so the cost of a mistake is asymmetric — a leaked key on a public repo is burned the moment it is committed, whether or not anyone noticed. The process is built around that.
The four checks
Third-party actions are pinned to commit SHAs
Not to a tag. A tag is mutable, so pinning to one means the security pipeline runs whatever that tag points at today — which is the supply-chain attack the pipeline exists to catch.
Schema changes ship as SQL migrations
Row-level security policies are committed as migrations rather than clicked into a dashboard. A policy that exists only in a hosted console cannot be reviewed, cannot be diffed, and cannot be rebuilt from the repository — and the migration history is part of the project’s account of itself.
Documentation moves in the same commit as the code
The roadmap, the README and the project’s own instructions are treated as deliverables rather than notes about them. An unticked box beside working code reads as abandoned; a ticked box beside nothing reads as dishonest. Neither is recoverable by a later cleanup commit, because the history is visible.