Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 5 additions & 20 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
<body>{children}</body>
</html>
);
}
133 changes: 36 additions & 97 deletions frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<main className="min-h-screen bg-zinc-50 dark:bg-zinc-950 px-4 py-8 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
{/* Top bar */}
<div className="flex items-center justify-between mb-8 pb-5 border-b border-zinc-200 dark:border-zinc-800">
<div>
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
Dashboard
</h1>
<p className="text-sm text-zinc-400 mt-0.5">{now}</p>
</div>
<div className="flex items-center gap-2 text-xs text-zinc-400">
<span className="w-2 h-2 rounded-full bg-green-500 inline-block" />
Live data
</div>
</div>

<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
{/* Widget grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<WeatherWidget />
<FinanceWidget />
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org →
</a>
</footer>
</div>

{/* Footer */}
<p className="mt-8 text-center text-xs text-zinc-400">
Weather via Open-Meteo · Markets via simulated data (Finnhub in production)
</p>
</div>
</main>
);
}
124 changes: 124 additions & 0 deletions frontend/src/components/FinanceWidget.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className="relative flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${colors[status]}`}>
{status === "live" && (
<span className="absolute inset-0 rounded-full bg-green-500 animate-ping opacity-60" />
)}
</span>
<span className="text-xs text-gray-400 capitalize">{status}</span>
</span>
);
}

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 (
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-2xl p-5 flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-emerald-50 dark:bg-emerald-950 flex items-center justify-center text-emerald-600 dark:text-emerald-400 text-base">
</div>
<div>
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">Markets</p>
<p className="text-[11px] text-zinc-400">{fromCache ? "cached" : "simulated"}</p>
</div>
</div>
<div className="flex items-center gap-3">
<PulseDot status={pulseStatus} />
<button
onClick={() => fetchFinance(true)}
disabled={status === "loading"}
className="text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 disabled:opacity-40 transition-colors"
aria-label="Refresh market data"
>
↺ Refresh
</button>
</div>
</div>

{/* Body */}
{status === "loading" && (
<div className="space-y-2 animate-pulse">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-12 bg-zinc-100 dark:bg-zinc-800 rounded-xl" />
))}
</div>
)}

{status === "error" && (
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-950/40 dark:text-red-400 rounded-lg px-4 py-3 text-center">
{error}
</div>
)}

{status === "success" && (
<div className="flex flex-col gap-2">
{data.map((q) => {
const up = q.changePercent > 0.05;
const down = q.changePercent < -0.05;
const sign = q.changePercent > 0 ? "+" : "";
return (
<div
key={q.ticker}
className="flex items-center gap-3 px-3 py-2.5 bg-zinc-50 dark:bg-zinc-800 rounded-xl"
>
<span className="text-xs font-semibold text-zinc-900 dark:text-zinc-100 w-10 font-mono">
{q.ticker}
</span>
<span className="flex-1 text-xs text-zinc-400 truncate">{q.name}</span>
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono tabular-nums">
${formatPrice(q.price)}
</span>
<span
className={`text-xs font-medium px-2 py-0.5 rounded-md tabular-nums min-w-[58px] text-right ${
up
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-400"
: down
? "bg-red-50 text-red-600 dark:bg-red-950/60 dark:text-red-400"
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400"
}`}
>
{sign}{q.changePercent.toFixed(2)}%
</span>
</div>
);
})}
</div>
)}

<p className="text-[11px] text-zinc-400 text-center">
In production: server-side Finnhub API route to keep keys off the client
</p>
</div>
);
}
Loading