+ {/* Header */}
+
+
+
+ ↗
+
+
+
Markets
+
{fromCache ? "cached" : "simulated"}
+
+
+
+
+
+
+
+
+ {/* Body */}
+ {status === "loading" && (
+
+ {[1, 2, 3, 4, 5, 6].map((i) => (
+
+ ))}
+
+ )}
+
+ {status === "error" && (
+
+ {error}
+
+ )}
+
+ {status === "success" && (
+
+ {data.map((q) => {
+ const up = q.changePercent > 0.05;
+ const down = q.changePercent < -0.05;
+ const sign = q.changePercent > 0 ? "+" : "";
+ return (
+
+
+ {q.ticker}
+
+ {q.name}
+
+ ${formatPrice(q.price)}
+
+
+ {sign}{q.changePercent.toFixed(2)}%
+
+
+ );
+ })}
+
+ )}
+
+
+ In production: server-side Finnhub API route to keep keys off the client
+
+
+ );
+}
diff --git a/frontend/src/components/WeatherWidget.tsx b/frontend/src/components/WeatherWidget.tsx
new file mode 100644
index 00000000..4910f0ce
--- /dev/null
+++ b/frontend/src/components/WeatherWidget.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { useState, useEffect, useRef } from "react";
+import { useWeather } from "@/hooks/useWeather";
+
+function PulseDot({ status }: { status: "loading" | "live" | "error" }) {
+ const colors = {
+ loading: "bg-gray-400",
+ live: "bg-green-500",
+ error: "bg-red-500",
+ };
+ return (
+
+ {/* Header */}
+
+
+
+ ☁
+
+
+
Weather
+
{fromCache ? "cached" : "live"}
+
+
+
+
+
+ {/* Search */}
+
+ setInput(e.target.value)}
+ onKeyDown={handleKey}
+ placeholder="City name"
+ className="flex-1 text-sm px-3 py-2 rounded-lg border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500/40"
+ />
+
+
+
+ {/* Body */}
+ {status === "loading" && (
+
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+ )}
+
+ {status === "error" && (
+
+ {error}
+
+ )}
+
+ {status === "success" && data && (
+ <>
+
+
+
+ {data.temperature}
+
+ °C
+
+
+ {data.description} · {data.city}, {data.country}
+
+
+
+
+ {[
+ { label: "Feels like", value: `${data.feelsLike}°C` },
+ { label: "Humidity", value: `${data.humidity}%` },
+ { label: "Wind", value: `${data.windSpeed} km/h` },
+ ].map(({ label, value }) => (
+
+ ))}
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/hooks/useFinance.ts b/frontend/src/hooks/useFinance.ts
new file mode 100644
index 00000000..5cc7d048
--- /dev/null
+++ b/frontend/src/hooks/useFinance.ts
@@ -0,0 +1,89 @@
+"use client";
+
+import { useState, useCallback, useRef } from "react";
+
+export interface StockQuote {
+ ticker: string;
+ name: string;
+ price: number;
+ changePercent: number;
+}
+
+interface CacheEntry {
+ data: StockQuote[];
+ timestamp: number;
+}
+
+const CACHE_TTL = 5 * 60 * 1000;
+let cache: CacheEntry | null = null;
+
+// Realistic base prices — in production these come from Finnhub via a Next.js API route
+// to avoid exposing the API key on the client.
+const BASE_QUOTES: StockQuote[] = [
+ { ticker: "AAPL", name: "Apple Inc.", price: 213.49, changePercent: 1.24 },
+ { ticker: "NVDA", name: "NVIDIA Corp.", price: 131.38, changePercent: 3.71 },
+ { ticker: "MSFT", name: "Microsoft Corp.", price: 447.62, changePercent: -0.83 },
+ { ticker: "BTC", name: "Bitcoin", price: 67240, changePercent: -1.52 },
+ { ticker: "ETH", name: "Ethereum", price: 3491.2, changePercent: 2.18 },
+ { ticker: "SPY", name: "S&P 500 ETF", price: 545.11, changePercent: 0.41 },
+];
+
+function addNoise(val: number, pct: number) {
+ return val + val * pct * (Math.random() * 2 - 1);
+}
+
+function simulateFetch(): Promise