From 8c7d11d68673223c7d154b135b79304d1d4ce34a Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 1 Jul 2026 07:56:24 +0800 Subject: [PATCH] Implement dashboard with weather and financial widgets --- frontend/src/app/layout.tsx | 25 +--- frontend/src/app/page.tsx | 133 ++++++--------------- frontend/src/components/FinanceWidget.tsx | 124 +++++++++++++++++++ frontend/src/components/WeatherWidget.tsx | 138 ++++++++++++++++++++++ frontend/src/hooks/useFinance.ts | 89 ++++++++++++++ frontend/src/hooks/useWeather.ts | 103 ++++++++++++++++ 6 files changed, 495 insertions(+), 117 deletions(-) create mode 100644 frontend/src/components/FinanceWidget.tsx create mode 100644 frontend/src/components/WeatherWidget.tsx create mode 100644 frontend/src/hooks/useFinance.ts create mode 100644 frontend/src/hooks/useWeather.ts diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index f7fa87eb..21bc869a 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,34 +1,19 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Dashboard", + description: "Weather and financial dashboard", }; export default function RootLayout({ children, -}: Readonly<{ +}: { children: React.ReactNode; -}>) { +}) { return ( - - {children} - + {children} ); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index e68abe6b..1fcbd7e5 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,103 +1,42 @@ -import Image from "next/image"; +import WeatherWidget from "@/components/WeatherWidget"; +import FinanceWidget from "@/components/FinanceWidget"; + +export default function DashboardPage() { + const now = new Date().toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }); -export default function Home() { return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
+
+
+ {/* Top bar */} +
+
+

+ Dashboard +

+

{now}

+
+
+ + Live data +
+
-
- - Vercel logomark - Deploy now - - - Read our docs - + {/* Widget grid */} +
+ +
-
- -
+ + {/* Footer */} +

+ Weather via Open-Meteo · Markets via simulated data (Finnhub in production) +

+ + ); } diff --git a/frontend/src/components/FinanceWidget.tsx b/frontend/src/components/FinanceWidget.tsx new file mode 100644 index 00000000..dbba1e8d --- /dev/null +++ b/frontend/src/components/FinanceWidget.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useFinance } from "@/hooks/useFinance"; + +function PulseDot({ status }: { status: "loading" | "live" | "error" }) { + const colors = { + loading: "bg-gray-400", + live: "bg-green-500", + error: "bg-red-500", + }; + return ( + + + {status === "live" && ( + + )} + + {status} + + ); +} + +function formatPrice(price: number): string { + if (price >= 1000) return price.toLocaleString("en-US", { maximumFractionDigits: 0 }); + return price.toFixed(2); +} + +export default function FinanceWidget() { + const { data, status, error, fromCache, fetch: fetchFinance } = useFinance(); + const didMount = useRef(false); + + useEffect(() => { + if (!didMount.current) { + didMount.current = true; + fetchFinance(); + } + }, [fetchFinance]); + + const pulseStatus = + status === "loading" ? "loading" : status === "error" ? "error" : "live"; + + return ( +
+ {/* 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 ( + + + {status === "live" && ( + + )} + + {status} + + ); +} + +export default function WeatherWidget() { + const [city, setCity] = useState("Manila"); + const [input, setInput] = useState("Manila"); + const { data, status, error, fromCache, fetch: fetchWeather } = useWeather(); + const didMount = useRef(false); + + useEffect(() => { + if (!didMount.current) { + didMount.current = true; + fetchWeather("Manila"); + } + }, [fetchWeather]); + + function handleSearch() { + const trimmed = input.trim(); + if (!trimmed) return; + setCity(trimmed); + fetchWeather(trimmed); + } + + function handleKey(e: React.KeyboardEvent) { + if (e.key === "Enter") handleSearch(); + } + + const pulseStatus = + status === "loading" ? "loading" : status === "error" ? "error" : "live"; + + 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 }) => ( +
+

{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 { + return new Promise((resolve) => + setTimeout(() => { + resolve( + BASE_QUOTES.map((q) => ({ + ...q, + price: addNoise(q.price, 0.005), + changePercent: addNoise(q.changePercent, 0.2), + })) + ); + }, 600) + ); +} + +export type FetchStatus = "idle" | "loading" | "success" | "error"; + +export function useFinance() { + const [data, setData] = useState([]); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [fromCache, setFromCache] = useState(false); + const isFetching = useRef(false); + + const fetch_ = useCallback(async (force = false) => { + if (isFetching.current) return; + + if (!force && cache && Date.now() - cache.timestamp < CACHE_TTL) { + setData(cache.data); + setStatus("success"); + setFromCache(true); + return; + } + + isFetching.current = true; + setStatus("loading"); + setError(null); + setFromCache(false); + + try { + // In production: fetch('/api/quotes') — a Next.js route handler that + // calls Finnhub server-side, keeping the API key out of the browser. + const quotes = await simulateFetch(); + cache = { data: quotes, timestamp: Date.now() }; + setData(quotes); + setStatus("success"); + } catch (err) { + setError((err as Error).message || "Failed to fetch market data"); + setStatus("error"); + } finally { + isFetching.current = false; + } + }, []); + + return { data, status, error, fromCache, fetch: fetch_ }; +} diff --git a/frontend/src/hooks/useWeather.ts b/frontend/src/hooks/useWeather.ts new file mode 100644 index 00000000..1e9ee0b0 --- /dev/null +++ b/frontend/src/hooks/useWeather.ts @@ -0,0 +1,103 @@ +"use client"; + +import { useState, useCallback, useRef } from "react"; + +export interface WeatherData { + city: string; + country: string; + temperature: number; + feelsLike: number; + humidity: number; + windSpeed: number; + description: string; +} + +interface CacheEntry { + data: WeatherData; + timestamp: number; +} + +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const cache = new Map(); + +function wmoDescription(code: number): string { + if (code === 0) return "Clear sky"; + if (code <= 3) return "Partly cloudy"; + if (code <= 49) return "Foggy"; + if (code <= 67) return "Rainy"; + if (code <= 77) return "Snowy"; + if (code <= 82) return "Showers"; + return "Thunderstorm"; +} + +export type FetchStatus = "idle" | "loading" | "success" | "error"; + +export function useWeather() { + const [data, setData] = useState(null); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [fromCache, setFromCache] = useState(false); + const abortRef = useRef(null); + + const fetch_ = useCallback(async (city: string, force = false) => { + if (!city.trim()) return; + + const key = city.toLowerCase().trim(); + + if (!force) { + const cached = cache.get(key); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + setData(cached.data); + setStatus("success"); + setFromCache(true); + setError(null); + return; + } + } + + abortRef.current?.abort(); + abortRef.current = new AbortController(); + const signal = abortRef.current.signal; + + setStatus("loading"); + setError(null); + setFromCache(false); + + try { + const geoRes = await fetch( + `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`, + { signal } + ); + const geoJson = await geoRes.json(); + if (!geoJson.results?.length) throw new Error("City not found"); + const geo = geoJson.results[0]; + + const wxRes = await fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${geo.latitude}&longitude=${geo.longitude}¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code&wind_speed_unit=kmh&timezone=auto`, + { signal } + ); + const wxJson = await wxRes.json(); + const c = wxJson.current; + + const result: WeatherData = { + city: geo.name, + country: geo.country_code, + temperature: Math.round(c.temperature_2m), + feelsLike: Math.round(c.apparent_temperature), + humidity: Math.round(c.relative_humidity_2m), + windSpeed: Math.round(c.wind_speed_10m), + description: wmoDescription(c.weather_code), + }; + + cache.set(key, { data: result, timestamp: Date.now() }); + setData(result); + setStatus("success"); + } catch (err) { + if ((err as Error).name === "AbortError") return; + setError((err as Error).message || "Failed to fetch weather"); + setStatus("error"); + } + }, []); + + return { data, status, error, fromCache, fetch: fetch_ }; +}