Files
portfolio/components/roadmap-timeline.tsx

212 lines
7.2 KiB
TypeScript

"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { Reveal } from "@/components/reveal";
export type RoadmapStatus = "done" | "in-progress" | "next" | "planned";
export type RoadmapItem = {
title: string;
description?: string;
status: RoadmapStatus;
date?: string;
bullets?: string[];
};
function statusLabel(status: RoadmapStatus) {
switch (status) {
case "done":
return "Shipped";
case "in-progress":
return "In progress";
case "next":
return "Next up";
case "planned":
return "Planned";
}
}
function statusWeight(status: RoadmapStatus) {
// Progress-style weighting:
// done = 1.0, in-progress = 0.6, next = 0.25, planned = 0.0
switch (status) {
case "done":
return 1.0;
case "in-progress":
return 0.6;
case "next":
return 0.25;
case "planned":
return 0.0;
}
}
function statusDotClass(status: RoadmapStatus) {
// Keep neutral, glassy, specular (no hard colors).
// Use opacity + ring + subtle fill to communicate state.
switch (status) {
case "done":
return "bg-white/80 ring-1 ring-white/40 shadow-sm";
case "in-progress":
return "bg-white/60 ring-1 ring-white/30 shadow-sm";
case "next":
return "bg-white/35 ring-1 ring-white/25";
case "planned":
return "bg-white/20 ring-1 ring-white/20";
}
}
function statusPillClass(status: RoadmapStatus) {
switch (status) {
case "done":
return "glass px-2.5 py-1 text-[11px] tracking-wide text-black/70";
case "in-progress":
return "glass px-2.5 py-1 text-[11px] tracking-wide text-black/70";
case "next":
return "glass px-2.5 py-1 text-[11px] tracking-wide text-black/60";
case "planned":
return "glass px-2.5 py-1 text-[11px] tracking-wide text-black/55";
}
}
export function RoadmapTimeline({
title = "Roadmap",
subtitle = "Build plan and delivery cadence for this system.",
items,
}: {
title?: string;
subtitle?: string;
items: RoadmapItem[];
}) {
const total = Math.max(items.length, 1);
const progress =
Math.min(
1,
Math.max(
0,
items.reduce((acc, it) => acc + statusWeight(it.status), 0) / total
)
) || 0;
const progressPct = Math.round(progress * 100);
return (
<section className="relative overflow-hidden">
<div className="glass-strong noise-overlay rounded-3xl p-6 sm:p-8 shadow-sm">
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
{title}
</h2>
<p className="mt-1 text-sm sm:text-[15px] text-black/60">
{subtitle}
</p>
</div>
<div className="shrink-0 text-right">
<div className="text-[11px] uppercase tracking-[0.18em] text-black/45">
Progress
</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{progressPct}%
</div>
</div>
</div>
<div className="mt-3">
<div className="glass rounded-2xl p-3">
<div className="flex items-center justify-between text-[12px] text-black/55">
<span>Planned Shipped</span>
<span className="tabular-nums">{progressPct}%</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-black/[0.06]">
<motion.div
className="h-full rounded-full bg-white/70"
initial={{ width: 0 }}
whileInView={{ width: `${progressPct}%` }}
viewport={{ once: true, margin: "-20% 0px -20% 0px" }}
transition={{ type: "spring", stiffness: 80, damping: 18 }}
/>
</div>
</div>
</div>
</div>
<div className="mt-6 sm:mt-8">
<div className="relative">
{/* Timeline spine */}
<div className="pointer-events-none absolute left-[11px] top-1 bottom-1 w-px bg-black/10" />
<div className="flex flex-col gap-4">
{items.map((it, idx) => (
<Reveal key={`${it.title}-${idx}`}>
<div className="relative pl-10">
<div
className={[
"absolute left-[2px] top-[10px] h-[18px] w-[18px] rounded-full",
statusDotClass(it.status),
].join(" ")}
/>
<div className="glass rounded-3xl p-4 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-[15px] sm:text-base font-semibold tracking-tight">
{it.title}
</h3>
<span
className={[
"rounded-full",
statusPillClass(it.status),
].join(" ")}
>
{statusLabel(it.status)}
</span>
</div>
{(it.date || it.description) && (
<div className="mt-1 text-sm text-black/60">
{it.date ? (
<span className="tabular-nums">{it.date}</span>
) : null}
{it.date && it.description ? (
<span className="mx-2 text-black/25"></span>
) : null}
{it.description ? <span>{it.description}</span> : null}
</div>
)}
</div>
<div className="shrink-0 text-[12px] text-black/45 tabular-nums">
{String(idx + 1).padStart(2, "0")}/{String(items.length).padStart(2, "0")}
</div>
</div>
{it.bullets?.length ? (
<ul className="mt-3 grid gap-2 text-sm text-black/70">
{it.bullets.map((b, bi) => (
<li key={`${it.title}-b-${bi}`} className="flex gap-2">
<span className="mt-[7px] h-1.5 w-1.5 rounded-full bg-black/25" />
<span className="min-w-0">{b}</span>
</li>
))}
</ul>
) : null}
</div>
</div>
</Reveal>
))}
</div>
</div>
</div>
{/* Specular highlight */}
<div className="pointer-events-none specular absolute -top-24 -right-24 h-64 w-64 rounded-full opacity-50 blur-2xl" />
</div>
</section>
);
}