feat: milestone dots on dashboard progress bar with legend

- Progress bar now shows auto-tracked sub-deadline milestones as dots
- Passed milestones: grayed out dot + strikethrough label
- Active milestones: amber dot with descriptive label
- Each milestone has a tooltip with context-aware text (e.g.,
  "Verificare expirata — nu se mai pot solicita clarificari")
- Legend below progress bar shows all milestone labels
- Wider progress bar (w-32) to accommodate milestone dots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-12 00:12:26 +02:00
parent f6fc63a40c
commit 0cc14a96e9
@@ -291,6 +291,7 @@ function EntryDeadlineCard({
deadline={item.deadline} deadline={item.deadline}
status={item.status} status={item.status}
entryId={entry.id} entryId={entry.id}
allDeadlines={entry.trackedDeadlines ?? []}
chainCount={chainCount} chainCount={chainCount}
isExpanded={isExpanded} isExpanded={isExpanded}
onToggleChain={() => onToggleChain(item.deadline.id)} onToggleChain={() => onToggleChain(item.deadline.id)}
@@ -319,10 +320,88 @@ function EntryDeadlineCard({
// ── Single deadline row ── // ── Single deadline row ──
// ── Milestone data for progress bar ──
interface ProgressMilestone {
position: number; // 0..1 on the progress bar
label: string;
isPassed: boolean;
typeId: string;
}
function computeMilestones(
mainDeadline: TrackedDeadline,
allDeadlines: TrackedDeadline[],
): ProgressMilestone[] {
const mainStart = new Date(mainDeadline.startDate).getTime();
const mainDue = new Date(mainDeadline.dueDate).getTime();
const totalSpan = mainDue - mainStart;
if (totalSpan <= 0) return [];
const now = Date.now();
const milestones: ProgressMilestone[] = [];
for (const dl of allDeadlines) {
if (dl.id === mainDeadline.id) continue;
const def = getDeadlineType(dl.typeId);
if (!def?.autoTrack) continue;
if (def.backgroundOnly) continue;
const dlDue = new Date(dl.dueDate).getTime();
// Only show milestones that fall within the main deadline's time range
if (dlDue <= mainStart || dlDue >= mainDue) continue;
const pos = (dlDue - mainStart) / totalSpan;
if (pos < 0.03 || pos > 0.97) continue; // too close to edges
milestones.push({
position: pos,
label: getMilestoneLabel(def.id, dlDue < now),
isPassed: dlDue < now,
typeId: def.id,
});
}
milestones.sort((a, b) => a.position - b.position);
return milestones;
}
function getMilestoneLabel(typeId: string, isPassed: boolean): string {
if (typeId.includes("verificare")) {
return isPassed
? "Verificare expirata — nu se mai pot solicita clarificari"
: "Termen verificare cerere";
}
if (typeId.includes("completari-limit")) {
return isPassed
? "Termen solicitare completari expirat"
: "Limita solicitare completari";
}
if (typeId.includes("comunicare")) {
return isPassed ? "Termen comunicare expirat" : "Termen comunicare";
}
if (typeId.includes("solicitare-aviz")) {
return isPassed
? "Termen solicitare aviz expirat"
: "Solicitare aviz primar";
}
if (typeId.includes("aviz-primar")) {
return isPassed ? "Aviz primar expirat" : "Aviz primar";
}
if (typeId.includes("depunere-comisie")) {
return isPassed ? "Depunere comisie expirata" : "Depunere la comisie";
}
const def = getDeadlineType(typeId);
return def?.label ?? typeId;
}
// ── Single deadline row ──
function DeadlineRow({ function DeadlineRow({
deadline, deadline,
status, status,
entryId, entryId,
allDeadlines,
chainCount, chainCount,
isExpanded, isExpanded,
isChainChild, isChainChild,
@@ -332,6 +411,7 @@ function DeadlineRow({
deadline: TrackedDeadline; deadline: TrackedDeadline;
status: DeadlineDisplayStatus; status: DeadlineDisplayStatus;
entryId: string; entryId: string;
allDeadlines?: TrackedDeadline[];
chainCount?: number; chainCount?: number;
isExpanded?: boolean; isExpanded?: boolean;
isChainChild?: boolean; isChainChild?: boolean;
@@ -358,98 +438,150 @@ function DeadlineRow({
? Math.min(100, Math.max(0, (elapsedDays / totalDays) * 100)) ? Math.min(100, Math.max(0, (elapsedDays / totalDays) * 100))
: 100; : 100;
return ( // Milestones on the progress bar (auto-tracked sub-deadlines)
<div const milestones = isPending && allDeadlines
className={cn( ? computeMilestones(deadline, allDeadlines)
"flex items-center gap-3 px-4 py-2", : [];
isChainChild && "bg-muted/30 pl-8",
)}
>
{/* Chain expand toggle */}
{!isChainChild && (chainCount ?? 0) > 0 ? (
<button
onClick={onToggleChain}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
) : isChainChild ? (
<span className="text-muted-foreground/40 text-xs"></span>
) : (
<span className="w-3.5" />
)}
{/* Type label */} return (
<div className="min-w-0 flex-1"> <div className={cn("px-4 py-2", isChainChild && "bg-muted/30 pl-8")}>
<span className={cn("text-xs", isChainChild && "text-muted-foreground")}> <div className="flex items-center gap-3">
{def?.label ?? deadline.typeId} {/* Chain expand toggle */}
</span> {!isChainChild && (chainCount ?? 0) > 0 ? (
{!isChainChild && (chainCount ?? 0) > 0 && ( <button
<span className="ml-2 text-[10px] text-muted-foreground"> onClick={onToggleChain}
(+{chainCount} etap{chainCount === 1 ? "a" : "e"}) className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
</span> >
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
) : isChainChild ? (
<span className="text-muted-foreground/40 text-xs">{"\u2514"}</span>
) : (
<span className="w-3.5" />
)} )}
{/* Type label */}
<div className="min-w-0 flex-1">
<span className={cn("text-xs", isChainChild && "text-muted-foreground")}>
{def?.label ?? deadline.typeId}
</span>
{!isChainChild && (chainCount ?? 0) > 0 && (
<span className="ml-2 text-[10px] text-muted-foreground">
(+{chainCount} etap{chainCount === 1 ? "a" : "e"})
</span>
)}
</div>
{/* Progress bar with milestones (only for pending) */}
{isPending && (
<div className="hidden w-32 sm:block">
<div className="relative h-4">
{/* Track */}
<div className="absolute top-[7px] left-0 right-0 h-1.5 rounded-full bg-muted">
<div
className={cn(
"h-1.5 rounded-full transition-all",
status.variant === "red"
? "bg-red-500"
: status.variant === "yellow"
? "bg-amber-500"
: "bg-green-500",
)}
style={{ width: `${progressPct}%` }}
/>
</div>
{/* Milestone dots */}
{milestones.map((ms) => (
<div
key={ms.typeId}
className="absolute top-[4px] -ml-[4px]"
style={{ left: `${ms.position * 100}%` }}
title={ms.label}
>
<div
className={cn(
"h-2 w-2 rounded-full border",
ms.isPassed
? "border-muted-foreground/50 bg-muted-foreground/30"
: "border-amber-500 bg-amber-400",
)}
/>
</div>
))}
</div>
</div>
)}
{/* Countdown / status */}
<div className="shrink-0 text-right">
{isPending && status.daysRemaining !== null ? (
<span
className={cn(
"text-xs font-medium",
VARIANT_TEXT[status.variant],
)}
>
{status.daysRemaining < 0
? `${Math.abs(status.daysRemaining)}z depasit`
: status.daysRemaining === 0
? "azi"
: `${status.daysRemaining}z ramase`}
</span>
) : (
<span className="text-[10px] text-muted-foreground">
{status.label}
</span>
)}
</div>
{/* Resolve button */}
<div className="w-7 shrink-0">
{isPending && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-green-600"
onClick={() => onResolve(entryId, deadline)}
title="Rezolva"
>
<CheckCircle2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div> </div>
{/* Progress bar (only for pending) */} {/* Milestone legend (below progress bar, only when milestones exist) */}
{isPending && ( {milestones.length > 0 && isPending && (
<div className="hidden w-24 sm:block"> <div className="ml-7 mt-1 hidden sm:flex flex-wrap gap-x-3 gap-y-0.5">
<div className="h-1.5 rounded-full bg-muted"> {milestones.map((ms) => (
<div <div key={ms.typeId} className="flex items-center gap-1 text-[9px]">
className={cn( <span
"h-1.5 rounded-full transition-all", className={cn(
status.variant === "red" "h-1.5 w-1.5 rounded-full",
? "bg-red-500" ms.isPassed
: status.variant === "yellow" ? "bg-muted-foreground/40"
? "bg-amber-500" : "bg-amber-400",
: "bg-green-500", )}
)} />
style={{ width: `${progressPct}%` }} <span
/> className={cn(
</div> ms.isPassed
? "text-muted-foreground line-through"
: "text-muted-foreground",
)}
>
{ms.isPassed
? getMilestoneLabel(ms.typeId, true)
: getMilestoneLabel(ms.typeId, false)}
</span>
</div>
))}
</div> </div>
)} )}
{/* Countdown / status */}
<div className="shrink-0 text-right">
{isPending && status.daysRemaining !== null ? (
<span
className={cn(
"text-xs font-medium",
VARIANT_TEXT[status.variant],
)}
>
{status.daysRemaining < 0
? `${Math.abs(status.daysRemaining)}z depasit`
: status.daysRemaining === 0
? "azi"
: `${status.daysRemaining}z ramase`}
</span>
) : (
<span className="text-[10px] text-muted-foreground">
{status.label}
</span>
)}
</div>
{/* Resolve button */}
<div className="w-7 shrink-0">
{isPending && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-green-600"
onClick={() => onResolve(entryId, deadline)}
title="Rezolva"
>
<CheckCircle2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div> </div>
); );
} }