From Espresso to Edge Cache

By cr0ss published on September 16, 2025 in |Technology|Development|
Blog Image

For those who know me a personal dashboard is pretty obvious: I wanted an excuse to poke at Tremor after the Vercel acquisition and ended up building a dashboard I actually want to look at every day. Coffee, daily rituals, sleep vs. focus, and running, all of these are relevant and tracked in different apps anyway. It’s a Next.js app on Neon Postgres with Tremor for charts and Zod at the edges. The nicest part is that, now, it all feels boring in the best way.

I’ve been hosting on Vercel for years, and I’ve recommended it on more than one client project because the developer experience is just … clean. When Vercel picked up Tremor, I finally had a reason to go down the dashboard rabbit hole. I sketched a first prototype with v0, then wrote a few API routes and, with a little AI pair-programming, dusted off my SQL for Neon. I’ve always tracked myself, calories to the gram when cutting weight, training volume when adding muscle, so a quantified-me dashboard felt like a fun task. I want the numbers to be honest and near-real-time, not a pretty screenshot from last week.

The stack is intentionally simple: Next.js App Router, Tremor for charts, Neon Postgres, Zod at the boundaries, and tag-based revalidation so any write pops /dashboard fresh again. Reads are wrapped in unstable_cache(..., ['dashboard'], { tags: ['dashboard'] }). Write APIs end with revalidateTag('dashboard'), easy peasy.

The biggest early snag was Tremor’s colors: lines looked “invisible”, donut slices fell back to black, and BarList bars didn’t paint. Tailwind v4 only emits utilities it sees at build time, but Tremor attaches color classes at runtime (stroke-emerald-500, fill-sky-500, etc.). Importing Tremor’s bundled CSS clashed with my setup, so I shipped a tiny shim that defines those exact selectors (app/tremor-utilities.css). After that, passing colors={['emerald','sky','violet','amber','rose']} behaved as expected. All charts render via next/dynamic with ssr:false in components/dashboard/charts/TremorCharts.tsx, and each chart has a height so Recharts has layout when it mounts. That combination solved the hydration quirks and the “blank SVG” moments; the color shim solved the rest.

The piece I like most is the caffeine curve. It’s a simple function, but it captures a day the way I feel it. The milligrams per brew method are placeholders. Estimating caffeine without a controlled setup is more art than science, so I’ll likely move that logic into the API layer and refine it once I attach brew metadata.

1export async function qCaffeineCurveToday() {
2  const startIso = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
3  const rows = await sql/*sql*/`
4    select
5      extract(hour from timezone('Europe/Berlin', time))::int as hour,
6      sum(coalesce(caffeine_mg,
7        case type
8          when 'espresso' then 80 when 'v60' then 120 when 'moka' then 100
9          when 'aero' then 110 when 'cold_brew' then 150 else 90
10        end
11      ))::int as mg
12    from coffee_log
13    where time >= ${startIso}::timestamptz
14    group by 1
15    order by 1
16  `;
17  const byHour = new Map(rows.map((r:any) => [Number(r.hour), Number(r.mg)]));
18  return ZCaffeineCurve.parse(
19    Array.from({ length: 24 }, (_, h) => ({ hour: h, mg: byHour.get(h) ?? 0 }))
20  );
21}

On the page I keep the mapping boring: numeric hours from the DB, string labels for the chart. Tremor wrappers are client-only and minimal. The important bits are the explicit colors prop and a fixed height. Everything else is data in, picture out.

1'use client';
2import dynamic from "next/dynamic";
3const LineChart = dynamic(() => import("@tremor/react").then(m => m.LineChart), { ssr: false });
4
5export function Line({
6  title, data, index, categories, colors = ["emerald"],
7}: { title:string; data:any[]; index:string; categories:string[]; colors?: string[] }) {
8  return (
9    <div className="rounded-lg border border-neutral-800 p-4">
10      <div className="text-sm mb-3 text-neutral-400">{title}</div>
11      {data.length ? (
12        <div className="h-64">
13          <LineChart
14            className="h-full"
15            data={data}
16            index={index}
17            categories={categories}
18            colors={colors}
19            yAxisWidth={42}
20          />
21        </div>
22      ) : <div className="text-neutral-500 text-sm">No data yet.</div>}
23    </div>
24  );
25}
26

For freshness I prefer tag-based caching over plain ISR. It keeps the page dynamic while caching the expensive reads, and it matches patterns I use elsewhere. One getter, one invalidation call, done.

1import { unstable_cache } from "next/cache";
2export const getDashboardData = unstable_cache(async () => {
3  // fan out to your query functions in parallel…
4  // return a single object the page can render
5}, ["dashboard"], { tags: ["dashboard"] });
6
1import { revalidatePath, revalidateTag } from "next/cache";
2export function revalidateDashboard() {
3  revalidateTag("dashboard");
4  revalidatePath("/dashboard", "page"); // belt-and-braces
5}

Next up: I’m moving my home-brew coffee catalog to Contentful. I’ll create a Coffee content type with roaster, origin (country/region), varietal, process, roast date, tasting notes, altitude, a roaster link, maybe an image. When I brew at home I’ll link the data in Neon to the content in Contentful. That unlocks a better donut chart, first by origin country. I’m curious where the beans I drink actually come from and how that shifts over time; with global warming shrinking viable regions (high altitude, cool nights), it’s a number I want to watch.

If you peek at the code, the moving parts are small and named the way the page reads. The end result is a dashboard that’s fresh, fast, and honest about what it knows. The final dashboard can be seen here.

I'm a coffee-nerd builder who likes numbers that tell a story. This project was in parts for an interview showcase and a blueprint I’ll reuse and especially use.