feat: dashboard UI with product cards + sparklines

This commit is contained in:
2026-05-25 14:33:46 +00:00
parent e323ed0a9c
commit f59e6c8582
3 changed files with 113 additions and 2 deletions

View File

@@ -1,3 +1,63 @@
export default function Home() { import Link from 'next/link'
return <main className="p-8"><h1 className="text-2xl">Preis-Tracker</h1></main> import { desc, eq, sql } from 'drizzle-orm'
import { db, products, priceSnapshots } from '@/lib/db'
import { ProductCard } from '@/components/ProductCard'
export const dynamic = 'force-dynamic'
export default async function Home() {
const rows = await db.execute<{
id: string; url: string; shop: string; name: string; image_url: string | null;
last_price: string | null; min_price: string | null;
}>(sql`
select p.id, p.url, p.shop, p.name, p.image_url,
(select price from price_snapshots s where s.product_id = p.id and s.price is not null order by scraped_at desc limit 1) as last_price,
(select min(price) from price_snapshots s where s.product_id = p.id and s.price is not null) as min_price
from products p where p.enabled = true order by p.created_at desc
`)
const sparklines = new Map<string, Array<{ price: number; t: string }>>()
for (const r of rows) {
const snaps = await db.select({ price: priceSnapshots.price, scrapedAt: priceSnapshots.scrapedAt })
.from(priceSnapshots)
.where(eq(priceSnapshots.productId, r.id))
.orderBy(desc(priceSnapshots.scrapedAt))
.limit(30)
sparklines.set(r.id, snaps
.filter((s) => s.price !== null)
.reverse()
.map((s) => ({ price: Number(s.price), t: s.scrapedAt.toISOString() })))
}
return (
<main className="mx-auto max-w-5xl p-6">
<header className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">Preis-Tracker</h1>
<div className="flex gap-2">
<Link href="/add" className="rounded bg-emerald-600 px-3 py-1.5 text-sm font-medium hover:bg-emerald-500">+ Produkt</Link>
<Link href="/api/auth/logout" className="rounded bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700">Logout</Link>
</div>
</header>
{rows.length === 0 ? (
<div className="rounded border border-dashed border-zinc-700 p-12 text-center text-zinc-400">
Noch keine Produkte. <Link href="/add" className="text-emerald-400 underline">Erstes hinzufügen</Link>.
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{rows.map((r) => (
<ProductCard
key={r.id}
id={r.id}
name={r.name}
shop={r.shop}
imageUrl={r.image_url}
lastPrice={r.last_price}
minPrice={r.min_price}
sparkline={sparklines.get(r.id) ?? []}
/>
))}
</div>
)}
</main>
)
} }

View File

@@ -0,0 +1,37 @@
import Link from 'next/link'
import { Sparkline } from './Sparkline'
interface Props {
id: string
name: string
shop: string
imageUrl: string | null
lastPrice: string | null
minPrice: string | null
sparkline: Array<{ price: number; t: string }>
}
export function ProductCard(p: Props) {
const last = p.lastPrice ? Number(p.lastPrice) : null
const min = p.minPrice ? Number(p.minPrice) : null
const deltaFromMin = last !== null && min !== null ? (last - min).toFixed(2) : null
return (
<Link href={`/products/${p.id}`} className="block rounded-lg border border-zinc-800 bg-zinc-900 p-4 hover:border-zinc-700 transition">
<div className="flex gap-3">
{p.imageUrl && <img src={p.imageUrl} alt="" className="h-16 w-16 object-contain rounded bg-white" />}
<div className="flex-1 min-w-0">
<div className="text-xs uppercase tracking-wide text-zinc-500">{p.shop}</div>
<div className="truncate text-sm font-medium">{p.name}</div>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-lg font-semibold">{last !== null ? `${last.toFixed(2)}` : '—'}</span>
{deltaFromMin !== null && (
<span className="text-xs text-zinc-400">+{deltaFromMin} vom Tief</span>
)}
</div>
</div>
</div>
<div className="mt-2"><Sparkline data={p.sparkline} /></div>
</Link>
)
}

View File

@@ -0,0 +1,14 @@
'use client'
import { LineChart, Line, ResponsiveContainer, YAxis } from 'recharts'
export function Sparkline({ data }: { data: Array<{ price: number; t: string }> }) {
if (data.length === 0) return <div className="h-10 text-xs text-zinc-500">keine Daten</div>
return (
<ResponsiveContainer width="100%" height={40}>
<LineChart data={data}>
<YAxis hide domain={['dataMin', 'dataMax']} />
<Line type="monotone" dataKey="price" stroke="#22c55e" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
)
}