Stack: React + Vite (SPA), Firebase Auth/Firestore, Vercel (Edge Functions).
Status: scoped & implemented behind a feature flag; shipping to beta.
The problem we set out to solve
Pocket Portfolio promises two things: never show 0.00 and explain every number. We already query multiple providers in parallel (Yahoo, Chart API, Stooq) under a strict time budget and return the first valid result; if nobody responds in time, we serve last-known good (LKG) marked “stale.”
Users still asked: “Was this quote live or a fallback? Which provider is sick right now?” We didn’t want people guessing. The Health Card is a small, visible answer: green = fresh within 30 s, amber = fallback recently (< 60 s), red = no success ≥ 5 min.
How we scoped it
We broke the epic into three thin, testable slices:
- Record provider events (success/failure/fallback) in the quote path.
-
Expose a typed
/api/health-price
endpoint that aggregates “lastSuccess/lastFailure/failureCount/activeFallback” per provider. -
Render a Health Card using a typed client (
getPriceHealth
) and a polling hook (usePriceHealth
).
We deferred “symbol-level freshness” and “extended outage alerts” to keep the first release small and measurable.
Assumptions (so we could ship):
- Time is UTC (
Date.now()
from edge) and rendered with client locale. - Health metrics are ephemeral; Redis/Upstash persists across Vercel edge instances when configured; otherwise in-memory for local/dev.
- Currency doesn’t influence health, but we carry portfolio base currency for display so timestamps/labels remain consistent with the rest of the dashboard.
Key design decisions (and trade-offs)
- Bounded latency over completeness. The health endpoint returns quickly even with sparse data—an empty array is OK. We’d rather show “Unknown” than block the dashboard.
- Minimal contract. Four fields per provider are enough for meaningful UI and alerting. Anything more invites bikeshedding and cross-service coupling.
- Rate-limited edge function. We added headers and a per-IP token bucket (Vercel middleware) so the 30 s polling cadence doesn’t DOS ourselves.
- UI rules are declarative. We encode thresholds in one function, shared by the card and tests, so designers can tune without hunting through JSX.
Code sample 1 — Edge function (typed health endpoint)
// File: /api/health-price/route.ts (Vercel Edge Function)
import { NextResponse } from "next/server"; // or "vercel-edge" if using a minimal runtime
export const runtime = "edge";
type Provider = "yahoo" | "chart" | "stooq";
export type ProviderHealth = {
provider: Provider;
lastSuccess?: number; // epoch ms (UTC)
lastFailure?: number; // epoch ms (UTC)
failureCount: number; // rolling window
activeFallback: boolean;
};
// Storage (Upstash if configured, else in-memory)
import { kv } from "@vercel/kv"; // thin wrapper around Upstash; fallback below
const mem = new Map<string, ProviderHealth>(); // dev only
async function readAll(): Promise<ProviderHealth[]> {
if (process.env.UPSTASH_REDIS_REST_URL && kv) {
const raw = await kv.get<Record<Provider, ProviderHealth>>("pp:health:v1");
return raw ? Object.values(raw) : [];
}
return Array.from(mem.values());
}
// (Called by /api/quote on each provider attempt)
export async function record(event: "success"|"failure", provider: Provider, usedAsFallback: boolean) {
const now = Date.now();
const current = (await readAll()).find(p => p.provider === provider) ?? { provider, failureCount: 0, activeFallback: false };
const next: ProviderHealth = {
...current,
lastSuccess: event === "success" ? now : current.lastSuccess,
lastFailure: event === "failure" ? now : current.lastFailure,
failureCount: event === "failure" ? (current.failureCount + 1) : current.failureCount,
activeFallback: usedAsFallback || (event === "failure" ? current.activeFallback : current.activeFallback),
};
if (process.env.UPSTASH_REDIS_REST_URL && kv) {
await kv.hset("pp:health:v1", { [provider]: next });
} else {
mem.set(provider, next);
}
}
export async function GET() {
const providers = await readAll();
// CORS & client cache (short)
const res = NextResponse.json({ providers } as { providers: ProviderHealth[] }, { status: 200 });
res.headers.set("Cache-Control", "max-age=5, s-maxage=5");
res.headers.set("RateLimit-Policy", "30;w=60"); // soft hint, enforced by middleware
return res;
}
Diff-able suggestion: keep ProviderHealth
in a shared @types
package so the API and UI compile against the same contract.
Code sample 2 — Client types, polling hook, and card
// File: src/services/priceHealth.ts
export interface PriceHealthResponse { providers: ProviderHealth[] }
export type Provider = "yahoo" | "chart" | "stooq";
export type ProviderHealth = { provider: Provider; lastSuccess?: number; lastFailure?: number; failureCount: number; activeFallback: boolean };
const THRESHOLDS = { freshMs: 30_000, fallbackMs: 60_000, unhealthyMs: 5 * 60_000 } as const;
export function statusOf(h: ProviderHealth, now = Date.now()): "Fresh"|"Fallback"|"Unhealthy"|"Unknown" {
if (!h.lastSuccess && !h.lastFailure) return "Unknown";
if (!h.lastSuccess || now - h.lastSuccess >= THRESHOLDS.unhealthyMs) return "Unhealthy";
if (h.activeFallback || (h.lastFailure && now - h.lastFailure < THRESHOLDS.fallbackMs)) return "Fallback";
if (now - h.lastSuccess < THRESHOLDS.freshMs) return "Fresh";
return "Fallback"; // conservative default
}
export async function getPriceHealth(signal?: AbortSignal): Promise<PriceHealthResponse> {
const r = await fetch("/api/health-price", { signal, headers: { "Accept": "application/json" } });
if (!r.ok) throw new Error(`health ${r.status}`);
return r.json();
}
// File: src/hooks/usePriceHealth.ts
import { useEffect, useRef, useState } from "react";
import { getPriceHealth, type PriceHealthResponse } from "../services/priceHealth";
export function usePriceHealth(intervalMs = 30_000) {
const [data, setData] = useState<PriceHealthResponse | null>(null);
const [error, setError] = useState<Error | null>(null);
const timer = useRef<number | null>(null);
useEffect(() => {
let abort = new AbortController();
const tick = async () => {
try {
const json = await getPriceHealth(abort.signal);
setData(json); setError(null);
} catch (e:any) { setError(e); }
};
tick();
timer.current = window.setInterval(() => { abort.abort(); abort = new AbortController(); tick(); }, intervalMs);
return () => { if (timer.current) window.clearInterval(timer.current); abort.abort(); };
}, [intervalMs]);
return { data, error, loading: !data && !error };
}
// File: src/components/HealthCard.tsx
import { statusOf } from "../services/priceHealth";
export function HealthCard({ providers }: { providers: ReturnType<typeof statusOf> extends never ? never : any }) {
// In practice, pass the raw ProviderHealth[] from hook and compute statusOf(...) per row
// Render with RAG badges; ensure accessible labels (aria-label includes provider + status + freshness age).
return null;
}
Network failure behavior: we abort fetches when polling ticks; UI shows “Unknown” + skeleton when the endpoint is temporarily unavailable.
Timezone: server times are epoch ms UTC; client renders “age” using the user’s locale.
CSV quirks: not directly involved here, but our import pipeline writes fills/fees/FX into the “Explain” drawer so the health chip never has to infer from CSV content.
Diagram (textual)
Four boxes left-to-right: Providers (Yahoo, Chart, Stooq) → Quote Engine (1.5 s budget, first-valid-wins) → Health Store (lastSuccess/lastFailure/failureCount/activeFallback in Redis/Memory) → Health Endpoint → Health Card (RAG badges). Arrows show the quote path writing events to the Health Store while the card polls the endpoint every 30 s.
What we learned
- Small contracts travel far. Four fields per provider were enough for users and for ops. Anything larger would have slowed the API and the UI decisions.
-
UI truth must match server truth. Sharing
ProviderHealth
and the status function eliminated a class of “red vs amber” bugs after design tweaks. - Bounded work beats perfect work. Time-boxed polling with short cache headers kept Vercel usage predictable and avoided the “silent freeze” trap.
Acceptance Criteria
- Card displays each configured provider with one of: Fresh, Fallback, Unhealthy, or Unknown.
-
Status mapping:
-
Fresh
ifnow - lastSuccess < 30s
. -
Fallback
ifactiveFallback = true
ornow - lastFailure < 60s
. -
Unhealthy
if no success in the last 5 min (orlastSuccess
absent). -
Unknown
when no events yet.
-
Polls
/api/health-price
every 30 s, cancels in-flight requests on unmount/retick.Accessible: badge has
aria-label
“{provider}: {status}, last success {age}”.Works with Redis and in-memory dev mode.
Does not block other dashboard widgets (TTI delta < 50 ms vs. baseline).
Definition of Done (DoD)
- All Acceptance Criteria met.
- Unit tests for
statusOf()
thresholds and hook cancellation. - Visual regression for card states (Fresh/Fallback/Unhealthy/Unknown).
- Edge function covered by a smoke test; rate-limit headers present.
- Feature flag toggles card visibility; telemetry logged (load, error).
Test notes
- Simulate provider outages by forcing
lastSuccess
older than 5 min; expect Unhealthy. - Kill the endpoint (
500
) during polling; card should show Unknown without console spam. - Verify locale rendering of “age” labels (e.g., en-GB vs en-US).
- On slow networks, confirm abort controller cancels prior fetches (no waterfall).
Documentation / Release notes
- New: Price Pipeline Health card (Dashboard → right column).
- API:
GET /api/health-price
returningProviderHealth[]
. - Env (optional):
UPSTASH_REDIS_REST_URL
,UPSTASH_REDIS_REST_TOKEN
. - Known limitations: provider list is static for this release; symbol-level freshness and outage alerts are tracked separately.
- Rollout: staged (beta cohort first); feature flag
pp.healthCard=true
.
Top comments (0)