@checkstack/slo-frontend 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # @checkstack/slo-frontend
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3c34b07: Complete SLO Reliability Engine frontend and backend
8
+
9
+ **Frontend** — 7 new visualization components:
10
+
11
+ - `StreakCounter`: Fire-themed compliance streak counter with color-coded flame and best-streak trophy
12
+ - `AchievementBadge`: Emoji-labeled badges for 9 achievement types with hover tooltip
13
+ - `AttributionChart`: Horizontal stacked bar showing error budget split (self/upstream/remaining)
14
+ - `DowntimeTimeline`: Dot-and-line timeline with attribution badges and timestamps
15
+ - `SloTrendChart`: Pure SVG availability trend line chart from daily snapshots
16
+ - `MilestoneFeed`: Organization-wide milestone feed on the SLO overview sidebar
17
+ - `DependencyExclusionConfig`: Interactive upstream dependency picker for SLO editor
18
+
19
+ **Backend** — Weekly digest scheduled integration event:
20
+
21
+ - `weekly-digest.ts`: Cron job (Monday 09:00 UTC) emitting SLO performance summary
22
+ - Top/worst performers, breach counts, and streak data delivered via configured notification channels
23
+ - New `sloWeeklyDigest` hook registered as integration event
24
+
25
+ ### Patch Changes
26
+
27
+ - Updated dependencies [d1a2796]
28
+ - Updated dependencies [3c34b07]
29
+ - @checkstack/common@0.6.5
30
+ - @checkstack/ui@1.2.1
31
+ - @checkstack/dashboard-frontend@0.3.26
32
+ - @checkstack/frontend-api@0.3.9
33
+ - @checkstack/slo-common@0.2.0
34
+ - @checkstack/catalog-common@1.3.1
35
+ - @checkstack/healthcheck-common@0.10.1
36
+ - @checkstack/dependency-common@0.2.1
37
+ - @checkstack/signal-frontend@0.0.15
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@checkstack/slo-frontend",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "src/index.tsx",
6
+ "checkstack": {
7
+ "type": "frontend"
8
+ },
9
+ "scripts": {
10
+ "typecheck": "tsc --noEmit",
11
+ "lint": "bun run lint:code",
12
+ "lint:code": "eslint . --max-warnings 0"
13
+ },
14
+ "dependencies": {
15
+ "@checkstack/catalog-common": "1.3.0",
16
+ "@checkstack/common": "0.6.4",
17
+ "@checkstack/dashboard-frontend": "0.3.25",
18
+ "@checkstack/frontend-api": "0.3.8",
19
+ "@checkstack/signal-frontend": "0.0.14",
20
+ "@checkstack/dependency-common": "0.2.0",
21
+ "@checkstack/healthcheck-common": "0.10.0",
22
+ "@checkstack/slo-common": "0.1.0",
23
+ "@checkstack/ui": "1.2.0",
24
+ "date-fns": "^4.1.0",
25
+ "lucide-react": "^0.344.0",
26
+ "react": "^18.2.0",
27
+ "react-router-dom": "^6.20.0"
28
+ },
29
+ "devDependencies": {
30
+ "typescript": "^5.0.0",
31
+ "@types/react": "^18.2.0",
32
+ "@checkstack/tsconfig": "0.0.5",
33
+ "@checkstack/scripts": "0.1.2"
34
+ }
35
+ }
package/src/api.ts ADDED
@@ -0,0 +1,13 @@
1
+ // Re-export types for convenience
2
+ export type {
3
+ SloObjective,
4
+ SloStatus,
5
+ SloDowntimeEvent,
6
+ SloStreak,
7
+ SloAchievement,
8
+ SloDailySnapshot,
9
+ DependencyExclusionMode,
10
+ AttributionType,
11
+ } from "@checkstack/slo-common";
12
+ // Client definition - use with usePluginClient
13
+ export { SloApi } from "@checkstack/slo-common";
@@ -0,0 +1,105 @@
1
+ import React from "react";
2
+ import { Award } from "lucide-react";
3
+ import { Badge } from "@checkstack/ui";
4
+
5
+ interface AchievementBadgeProps {
6
+ achievement: string;
7
+ unlockedAt: Date;
8
+ }
9
+
10
+ const ACHIEVEMENT_META: Record<
11
+ string,
12
+ { label: string; emoji: string; description: string }
13
+ > = {
14
+ first_steps: {
15
+ label: "First Steps",
16
+ emoji: "🎯",
17
+ description: "First SLO defined",
18
+ },
19
+ iron_uptime: {
20
+ label: "Iron Uptime",
21
+ emoji: "🛡️",
22
+ description: "30-day compliance streak",
23
+ },
24
+ diamond_uptime: {
25
+ label: "Diamond Uptime",
26
+ emoji: "💎",
27
+ description: "90-day compliance streak",
28
+ },
29
+ budget_miser: {
30
+ label: "Budget Miser",
31
+ emoji: "💰",
32
+ description: ">90% budget remaining at window end",
33
+ },
34
+ clean_sheet: {
35
+ label: "Clean Sheet",
36
+ emoji: "✨",
37
+ description: "Zero breaches in a quarter",
38
+ },
39
+ nines_club: {
40
+ label: "Nines Club",
41
+ emoji: "🏆",
42
+ description: "99.99% over 365 days",
43
+ },
44
+ cascade_breaker: {
45
+ label: "Cascade Breaker",
46
+ emoji: "🛑",
47
+ description: "Upstream resolved before SLO impact",
48
+ },
49
+ full_coverage: {
50
+ label: "Full Coverage",
51
+ emoji: "🔒",
52
+ description: "All systems in group have SLOs",
53
+ },
54
+ rapid_recovery: {
55
+ label: "Rapid Recovery",
56
+ emoji: "⚡",
57
+ description: "SLO compliance restored within 5 min",
58
+ },
59
+ };
60
+
61
+ /**
62
+ * Renders an achievement badge with emoji, label, and tooltip description.
63
+ */
64
+ export const AchievementBadge: React.FC<AchievementBadgeProps> = ({
65
+ achievement,
66
+ unlockedAt,
67
+ }) => {
68
+ const meta = ACHIEVEMENT_META[achievement];
69
+ const label = meta?.label ?? achievement;
70
+ const emoji = meta?.emoji ?? "🏅";
71
+ const description = meta?.description ?? "";
72
+
73
+ return (
74
+ <div className="group relative inline-flex" title={description}>
75
+ <Badge variant="outline" className="gap-1.5 cursor-default">
76
+ <span>{emoji}</span>
77
+ <span>{label}</span>
78
+ </Badge>
79
+ {/* Tooltip on hover */}
80
+ <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10">
81
+ <div className="bg-popover border border-border rounded-md shadow-md px-3 py-2 text-xs whitespace-nowrap">
82
+ <div className="font-medium">{label}</div>
83
+ <div className="text-muted-foreground">{description}</div>
84
+ <div className="text-muted-foreground mt-1">
85
+ {new Date(unlockedAt).toLocaleDateString("en-US", {
86
+ month: "short",
87
+ day: "numeric",
88
+ year: "numeric",
89
+ })}
90
+ </div>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ );
95
+ };
96
+
97
+ /**
98
+ * Renders a placeholder for systems with no achievements yet.
99
+ */
100
+ export const NoAchievements: React.FC = () => (
101
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
102
+ <Award className="w-4 h-4" />
103
+ <span>No achievements unlocked yet</span>
104
+ </div>
105
+ );
@@ -0,0 +1,112 @@
1
+ import React from "react";
2
+
3
+ interface AttributionChartProps {
4
+ attribution: Array<{
5
+ sourceType: "self" | "upstream";
6
+ systemId?: string;
7
+ systemName?: string;
8
+ minutes: number;
9
+ }>;
10
+ totalBudgetMinutes: number;
11
+ }
12
+
13
+ /**
14
+ * Horizontal stacked bar showing error budget consumption split by attribution.
15
+ * Self-caused downtime in red, upstream-attributed in amber, remaining in green.
16
+ */
17
+ export const AttributionChart: React.FC<AttributionChartProps> = ({
18
+ attribution,
19
+ totalBudgetMinutes,
20
+ }) => {
21
+ if (totalBudgetMinutes <= 0) return;
22
+
23
+ const selfMinutes = attribution
24
+ .filter((a) => a.sourceType === "self")
25
+ .reduce((sum, a) => sum + a.minutes, 0);
26
+
27
+ const upstreamMinutes = attribution
28
+ .filter((a) => a.sourceType === "upstream")
29
+ .reduce((sum, a) => sum + a.minutes, 0);
30
+
31
+ const selfPercent = Math.min(
32
+ (selfMinutes / totalBudgetMinutes) * 100,
33
+ 100,
34
+ );
35
+ const upstreamPercent = Math.min(
36
+ (upstreamMinutes / totalBudgetMinutes) * 100,
37
+ 100 - selfPercent,
38
+ );
39
+ const remainingPercent = Math.max(
40
+ 100 - selfPercent - upstreamPercent,
41
+ 0,
42
+ );
43
+
44
+ return (
45
+ <div className="space-y-2">
46
+ {/* Stacked bar */}
47
+ <div className="h-6 rounded-full overflow-hidden flex bg-muted/30 border border-border">
48
+ {selfPercent > 0 && (
49
+ <div
50
+ className="bg-destructive/80 transition-all duration-500"
51
+ style={{ width: `${selfPercent}%` }}
52
+ title={`Self: ${selfMinutes.toFixed(1)} min`}
53
+ />
54
+ )}
55
+ {upstreamPercent > 0 && (
56
+ <div
57
+ className="bg-amber-500/80 transition-all duration-500"
58
+ style={{ width: `${upstreamPercent}%` }}
59
+ title={`Upstream: ${upstreamMinutes.toFixed(1)} min`}
60
+ />
61
+ )}
62
+ {remainingPercent > 0 && (
63
+ <div
64
+ className="bg-emerald-500/30 transition-all duration-500"
65
+ style={{ width: `${remainingPercent}%` }}
66
+ title={`Remaining: ${(totalBudgetMinutes - selfMinutes - upstreamMinutes).toFixed(1)} min`}
67
+ />
68
+ )}
69
+ </div>
70
+
71
+ {/* Legend */}
72
+ <div className="flex flex-wrap gap-4 text-xs">
73
+ <div className="flex items-center gap-1.5">
74
+ <div className="w-2.5 h-2.5 rounded-full bg-destructive/80" />
75
+ <span className="text-muted-foreground">
76
+ Self: {selfMinutes.toFixed(1)} min
77
+ </span>
78
+ </div>
79
+ {upstreamMinutes > 0 && (
80
+ <div className="flex items-center gap-1.5">
81
+ <div className="w-2.5 h-2.5 rounded-full bg-amber-500/80" />
82
+ <span className="text-muted-foreground">
83
+ Upstream: {upstreamMinutes.toFixed(1)} min
84
+ </span>
85
+ </div>
86
+ )}
87
+ <div className="flex items-center gap-1.5">
88
+ <div className="w-2.5 h-2.5 rounded-full bg-emerald-500/30" />
89
+ <span className="text-muted-foreground">
90
+ Remaining:{" "}
91
+ {(totalBudgetMinutes - selfMinutes - upstreamMinutes).toFixed(1)}{" "}
92
+ min
93
+ </span>
94
+ </div>
95
+ </div>
96
+
97
+ {/* Per-upstream breakdown */}
98
+ {attribution.some((a) => a.sourceType === "upstream") && (
99
+ <div className="text-xs text-muted-foreground space-y-0.5 pt-1">
100
+ {attribution
101
+ .filter((a) => a.sourceType === "upstream")
102
+ .map((a) => (
103
+ <div key={a.systemId ?? "unknown"} className="flex justify-between">
104
+ <span>↳ {a.systemName ?? a.systemId ?? "Unknown"}</span>
105
+ <span>{a.minutes.toFixed(1)} min</span>
106
+ </div>
107
+ ))}
108
+ </div>
109
+ )}
110
+ </div>
111
+ );
112
+ };
@@ -0,0 +1,50 @@
1
+ import React from "react";
2
+ import { TrendingUp, TrendingDown, Minus } from "lucide-react";
3
+
4
+ interface BurnRateIndicatorProps {
5
+ burnRate: number | null;
6
+ }
7
+
8
+ /**
9
+ * Visual burn rate indicator.
10
+ * Shows how fast the error budget is being consumed relative to the window.
11
+ * - < 1.0: consuming slower than expected (good)
12
+ * - 1.0: on pace
13
+ * - > 1.0: consuming faster than expected (bad)
14
+ */
15
+ export const BurnRateIndicator: React.FC<BurnRateIndicatorProps> = ({
16
+ burnRate,
17
+ }) => {
18
+ if (burnRate === null || burnRate === undefined) {
19
+ return (
20
+ <span className="inline-flex items-center gap-1 text-sm text-muted-foreground">
21
+ <Minus className="w-3.5 h-3.5" />
22
+ <span>N/A</span>
23
+ </span>
24
+ );
25
+ }
26
+
27
+ const isGood = burnRate < 1;
28
+ const isBad = burnRate > 1.5;
29
+
30
+ return (
31
+ <span
32
+ className={`inline-flex items-center gap-1 text-sm font-medium ${
33
+ isGood
34
+ ? "text-success"
35
+ : isBad
36
+ ? "text-destructive"
37
+ : "text-muted-foreground"
38
+ }`}
39
+ >
40
+ {isGood ? (
41
+ <TrendingDown className="w-3.5 h-3.5" />
42
+ ) : isBad ? (
43
+ <TrendingUp className="w-3.5 h-3.5" />
44
+ ) : (
45
+ <Minus className="w-3.5 h-3.5" />
46
+ )}
47
+ <span>{burnRate.toFixed(2)}x</span>
48
+ </span>
49
+ );
50
+ };
@@ -0,0 +1,144 @@
1
+ import React from "react";
2
+ import { usePluginClient } from "@checkstack/frontend-api";
3
+ import { DependencyApi } from "@checkstack/dependency-common";
4
+ import { CatalogApi } from "@checkstack/catalog-common";
5
+ import type { DependencyExclusionMode } from "@checkstack/slo-common";
6
+ import {
7
+ Label,
8
+ Select,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ SelectContent,
12
+ SelectItem,
13
+ } from "@checkstack/ui";
14
+ import { ShieldCheck, ShieldAlert, ShieldOff } from "lucide-react";
15
+
16
+ interface DependencyExclusionConfigProps {
17
+ systemId: string;
18
+ mode: DependencyExclusionMode;
19
+ onModeChange: (mode: DependencyExclusionMode) => void;
20
+ excludedIds: string[];
21
+ onExcludedIdsChange: (ids: string[]) => void;
22
+ }
23
+
24
+ /**
25
+ * Configuration component for SLO dependency exclusion.
26
+ * Shows mode selector and auto-discovered upstream dependency checklist.
27
+ */
28
+ export const DependencyExclusionConfig: React.FC<
29
+ DependencyExclusionConfigProps
30
+ > = ({ systemId, mode, onModeChange, excludedIds, onExcludedIdsChange }) => {
31
+ const depClient = usePluginClient(DependencyApi);
32
+ const catalogClient = usePluginClient(CatalogApi);
33
+
34
+ // Fetch upstream dependencies for this system
35
+ const { data: depData } = depClient.getDependencies.useQuery(
36
+ { systemId, direction: "upstream" },
37
+ { enabled: !!systemId },
38
+ );
39
+
40
+ // Fetch system names for display
41
+ const { data: systemsData } = catalogClient.getSystems.useQuery({});
42
+
43
+ const upstreamDeps = depData?.dependencies ?? [];
44
+ const systemNameMap = new Map(
45
+ systemsData?.systems.map((s) => [s.id, s.name]),
46
+ );
47
+
48
+ const handleToggle = (depSystemId: string) => {
49
+ if (excludedIds.includes(depSystemId)) {
50
+ onExcludedIdsChange(excludedIds.filter((id) => id !== depSystemId));
51
+ } else {
52
+ onExcludedIdsChange([...excludedIds, depSystemId]);
53
+ }
54
+ };
55
+
56
+ return (
57
+ <div className="space-y-4">
58
+ {/* Mode selector */}
59
+ <div className="grid gap-2">
60
+ <Label>Dependency Exclusion Mode</Label>
61
+ <Select
62
+ value={mode}
63
+ onValueChange={(v) => onModeChange(v as DependencyExclusionMode)}
64
+ >
65
+ <SelectTrigger>
66
+ <SelectValue />
67
+ </SelectTrigger>
68
+ <SelectContent>
69
+ <SelectItem value="strict">
70
+ <div className="flex items-center gap-2">
71
+ <ShieldAlert className="w-4 h-4 text-destructive" />
72
+ Strict — All downtime counts
73
+ </div>
74
+ </SelectItem>
75
+ <SelectItem value="self-only">
76
+ <div className="flex items-center gap-2">
77
+ <ShieldCheck className="w-4 h-4 text-emerald-500" />
78
+ Self-Only — Exclude upstream-attributed downtime
79
+ </div>
80
+ </SelectItem>
81
+ </SelectContent>
82
+ </Select>
83
+ <p className="text-xs text-muted-foreground">
84
+ {mode === "strict"
85
+ ? "All downtime counts against the error budget, regardless of root cause."
86
+ : "Only self-caused downtime counts. Upstream failures are attributed but excluded from budget consumption."}
87
+ </p>
88
+ </div>
89
+
90
+ {/* Upstream dependency exclusion list */}
91
+ {mode === "self-only" && upstreamDeps.length > 0 && (
92
+ <div className="grid gap-2">
93
+ <Label className="flex items-center gap-2">
94
+ <ShieldOff className="w-4 h-4" />
95
+ Excluded Dependencies
96
+ </Label>
97
+ <p className="text-xs text-muted-foreground">
98
+ Check dependencies to exclude from upstream attribution. Downtime
99
+ from excluded upstreams will count as self-caused.
100
+ </p>
101
+ <div className="space-y-2 rounded-md border border-border p-3">
102
+ {upstreamDeps.map((dep) => {
103
+ const upstreamId =
104
+ dep.sourceSystemId === systemId
105
+ ? dep.targetSystemId
106
+ : dep.sourceSystemId;
107
+ const isExcluded = excludedIds.includes(upstreamId);
108
+ const name =
109
+ systemNameMap.get(upstreamId) ?? upstreamId;
110
+
111
+ return (
112
+ <label
113
+ key={dep.id}
114
+ className="flex items-center gap-2 cursor-pointer text-sm hover:bg-muted/50 rounded px-2 py-1 transition-colors"
115
+ >
116
+ <input
117
+ type="checkbox"
118
+ checked={isExcluded}
119
+ onChange={() => handleToggle(upstreamId)}
120
+ className="rounded border-border"
121
+ />
122
+ <span className={isExcluded ? "line-through text-muted-foreground" : ""}>
123
+ {name}
124
+ </span>
125
+ {isExcluded && (
126
+ <span className="text-xs text-muted-foreground">
127
+ (treated as self)
128
+ </span>
129
+ )}
130
+ </label>
131
+ );
132
+ })}
133
+ </div>
134
+ </div>
135
+ )}
136
+
137
+ {mode === "self-only" && upstreamDeps.length === 0 && (
138
+ <p className="text-xs text-muted-foreground">
139
+ No upstream dependencies configured for this system.
140
+ </p>
141
+ )}
142
+ </div>
143
+ );
144
+ };
@@ -0,0 +1,104 @@
1
+ import React from "react";
2
+ import { Badge } from "@checkstack/ui";
3
+ import { Clock } from "lucide-react";
4
+ import { formatDistanceToNow, format } from "date-fns";
5
+
6
+ interface DowntimeEvent {
7
+ id: string;
8
+ startTime: Date;
9
+ endTime: Date | null;
10
+ durationSeconds: number | null;
11
+ attributionType: "self" | "upstream";
12
+ upstreamSystemId: string | null;
13
+ upstreamSystemName: string | null;
14
+ }
15
+
16
+ interface DowntimeTimelineProps {
17
+ events: DowntimeEvent[];
18
+ }
19
+
20
+ /**
21
+ * Visual timeline of downtime events with attribution coloring.
22
+ * Shows a chronological list with duration, attribution badge, and timestamps.
23
+ */
24
+ export const DowntimeTimeline: React.FC<DowntimeTimelineProps> = ({
25
+ events,
26
+ }) => {
27
+ if (events.length === 0) {
28
+ return (
29
+ <div className="text-sm text-muted-foreground text-center py-4">
30
+ No downtime events in the current window
31
+ </div>
32
+ );
33
+ }
34
+
35
+ return (
36
+ <div className="relative">
37
+ {/* Timeline line */}
38
+ <div className="absolute left-[11px] top-2 bottom-2 w-px bg-border" />
39
+
40
+ <div className="space-y-3">
41
+ {events.map((event) => {
42
+ const isOngoing = event.endTime === null;
43
+ const isSelf = event.attributionType === "self";
44
+ const durationMinutes = event.durationSeconds
45
+ ? Math.round(event.durationSeconds / 60)
46
+ : undefined;
47
+
48
+ return (
49
+ <div key={event.id} className="flex gap-3 relative">
50
+ {/* Timeline dot */}
51
+ <div
52
+ className={`mt-1.5 w-[9px] h-[9px] rounded-full border-2 z-10 shrink-0 ${
53
+ isOngoing
54
+ ? "bg-destructive border-destructive animate-pulse"
55
+ : isSelf
56
+ ? "bg-destructive/60 border-destructive/60"
57
+ : "bg-amber-500/60 border-amber-500/60"
58
+ }`}
59
+ />
60
+
61
+ {/* Event details */}
62
+ <div className="flex-1 min-w-0">
63
+ <div className="flex items-center gap-2 flex-wrap">
64
+ <Badge
65
+ variant={isSelf ? "destructive" : "warning"}
66
+ >
67
+ {isSelf
68
+ ? "Self"
69
+ : `Upstream: ${event.upstreamSystemName ?? event.upstreamSystemId}`}
70
+ </Badge>
71
+ {isOngoing && (
72
+ <Badge variant="destructive">Ongoing</Badge>
73
+ )}
74
+ {durationMinutes !== undefined && (
75
+ <span className="text-xs text-muted-foreground tabular-nums">
76
+ {durationMinutes < 60
77
+ ? `${durationMinutes} min`
78
+ : `${Math.floor(durationMinutes / 60)}h ${durationMinutes % 60}m`}
79
+ </span>
80
+ )}
81
+ </div>
82
+ <div className="flex items-center gap-1.5 mt-1 text-xs text-muted-foreground">
83
+ <Clock className="w-3 h-3" />
84
+ <span>
85
+ {format(new Date(event.startTime), "MMM d, HH:mm")}
86
+ {event.endTime
87
+ ? ` → ${format(new Date(event.endTime), "HH:mm")}`
88
+ : ""}
89
+ </span>
90
+ <span className="text-muted-foreground/50">·</span>
91
+ <span>
92
+ {formatDistanceToNow(new Date(event.startTime), {
93
+ addSuffix: true,
94
+ })}
95
+ </span>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ );
100
+ })}
101
+ </div>
102
+ </div>
103
+ );
104
+ };
@@ -0,0 +1,48 @@
1
+ import React from "react";
2
+
3
+ interface ErrorBudgetBarProps {
4
+ consumedPercent: number;
5
+ warningThreshold: number;
6
+ criticalThreshold: number;
7
+ label?: string;
8
+ }
9
+
10
+ /**
11
+ * Visual error budget consumption bar.
12
+ * Shows green → amber → red progression as budget is consumed.
13
+ */
14
+ export const ErrorBudgetBar: React.FC<ErrorBudgetBarProps> = ({
15
+ consumedPercent,
16
+ warningThreshold,
17
+ criticalThreshold,
18
+ label,
19
+ }) => {
20
+ const remainingPercent = Math.max(0, 100 - consumedPercent);
21
+ const getBarColor = () => {
22
+ if (consumedPercent >= criticalThreshold) return "var(--destructive)";
23
+ if (consumedPercent >= warningThreshold) return "var(--warning)";
24
+ return "var(--success)";
25
+ };
26
+
27
+ return (
28
+ <div className="space-y-1.5">
29
+ {label && (
30
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
31
+ <span>{label}</span>
32
+ <span className="font-medium tabular-nums">
33
+ {remainingPercent.toFixed(1)}% remaining
34
+ </span>
35
+ </div>
36
+ )}
37
+ <div className="h-2 w-full rounded-full bg-muted overflow-hidden">
38
+ <div
39
+ className="h-full rounded-full transition-all duration-300"
40
+ style={{
41
+ width: `${Math.min(consumedPercent, 100)}%`,
42
+ backgroundColor: getBarColor(),
43
+ }}
44
+ />
45
+ </div>
46
+ </div>
47
+ );
48
+ };