@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.
@@ -0,0 +1,107 @@
1
+ import React from "react";
2
+ import { usePluginClient } from "@checkstack/frontend-api";
3
+ import { SloApi } from "../api";
4
+ import { formatDistanceToNow } from "date-fns";
5
+ import { Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
6
+ import { Trophy } from "lucide-react";
7
+
8
+ const ACHIEVEMENT_EMOJI: Record<string, string> = {
9
+ first_steps: "๐ŸŽฏ",
10
+ iron_uptime: "๐Ÿ›ก๏ธ",
11
+ diamond_uptime: "๐Ÿ’Ž",
12
+ budget_miser: "๐Ÿ’ฐ",
13
+ clean_sheet: "โœจ",
14
+ nines_club: "๐Ÿ†",
15
+ cascade_breaker: "๐Ÿ›‘",
16
+ full_coverage: "๐Ÿ”’",
17
+ rapid_recovery: "โšก",
18
+ };
19
+
20
+ const ACHIEVEMENT_LABEL: Record<string, string> = {
21
+ first_steps: "First Steps",
22
+ iron_uptime: "Iron Uptime",
23
+ diamond_uptime: "Diamond Uptime",
24
+ budget_miser: "Budget Miser",
25
+ clean_sheet: "Clean Sheet",
26
+ nines_club: "Nines Club",
27
+ cascade_breaker: "Cascade Breaker",
28
+ full_coverage: "Full Coverage",
29
+ rapid_recovery: "Rapid Recovery",
30
+ };
31
+
32
+ interface MilestoneFeedProps {
33
+ limit?: number;
34
+ }
35
+
36
+ /**
37
+ * Organization-wide milestone feed showing recent achievement unlocks.
38
+ * Fetches from getRecentMilestones and renders a chronological feed.
39
+ */
40
+ export const MilestoneFeed: React.FC<MilestoneFeedProps> = ({
41
+ limit = 10,
42
+ }) => {
43
+ const sloClient = usePluginClient(SloApi);
44
+
45
+ const { data } = sloClient.getRecentMilestones.useQuery({ limit });
46
+
47
+ const milestones = data?.milestones;
48
+
49
+ if (!milestones || milestones.length === 0) {
50
+ return (
51
+ <Card>
52
+ <CardHeader className="pb-2">
53
+ <CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
54
+ <Trophy className="w-4 h-4" />
55
+ Recent Milestones
56
+ </CardTitle>
57
+ </CardHeader>
58
+ <CardContent>
59
+ <div className="text-sm text-muted-foreground text-center py-4">
60
+ No milestones achieved yet
61
+ </div>
62
+ </CardContent>
63
+ </Card>
64
+ );
65
+ }
66
+
67
+ return (
68
+ <Card>
69
+ <CardHeader className="pb-2">
70
+ <CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
71
+ <Trophy className="w-4 h-4" />
72
+ Recent Milestones
73
+ </CardTitle>
74
+ </CardHeader>
75
+ <CardContent>
76
+ <div className="space-y-3">
77
+ {milestones.map((milestone, index) => {
78
+ const emoji =
79
+ ACHIEVEMENT_EMOJI[milestone.achievement] ?? "๐Ÿ…";
80
+ const label =
81
+ ACHIEVEMENT_LABEL[milestone.achievement] ??
82
+ milestone.achievement;
83
+
84
+ return (
85
+ <div
86
+ key={`${milestone.systemId}-${milestone.achievement}-${index}`}
87
+ className="flex items-start gap-3"
88
+ >
89
+ <span className="text-lg shrink-0">{emoji}</span>
90
+ <div className="min-w-0 flex-1">
91
+ <div className="text-sm font-medium">{label}</div>
92
+ <div className="text-xs text-muted-foreground">
93
+ {milestone.systemName ?? milestone.systemId}
94
+ <span className="mx-1">ยท</span>
95
+ {formatDistanceToNow(new Date(milestone.unlockedAt), {
96
+ addSuffix: true,
97
+ })}
98
+ </div>
99
+ </div>
100
+ </div>
101
+ );
102
+ })}
103
+ </div>
104
+ </CardContent>
105
+ </Card>
106
+ );
107
+ };
@@ -0,0 +1,332 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { usePluginClient } from "@checkstack/frontend-api";
3
+ import { SloApi, type SloObjective } from "../api";
4
+ import type { System } from "@checkstack/catalog-common";
5
+ import type { DependencyExclusionMode } from "@checkstack/slo-common";
6
+ import { HealthCheckApi } from "@checkstack/healthcheck-common";
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ DialogFooter,
14
+ Button,
15
+ Input,
16
+ Label,
17
+ useToast,
18
+ Select,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ SelectContent,
22
+ SelectItem,
23
+ } from "@checkstack/ui";
24
+ import { extractErrorMessage } from "@checkstack/common";
25
+
26
+ interface Props {
27
+ open: boolean;
28
+ onOpenChange: (open: boolean) => void;
29
+ objective?: SloObjective;
30
+ systems: System[];
31
+ onSave: () => void;
32
+ }
33
+
34
+ export const SloEditor: React.FC<Props> = ({
35
+ open,
36
+ onOpenChange,
37
+ objective,
38
+ systems,
39
+ onSave,
40
+ }) => {
41
+ const sloClient = usePluginClient(SloApi);
42
+ const hcClient = usePluginClient(HealthCheckApi);
43
+ const toast = useToast();
44
+
45
+ // Form state
46
+ const [systemId, setSystemId] = useState("");
47
+ const [target, setTarget] = useState("99.9");
48
+ const [windowDays, setWindowDays] = useState("30");
49
+ const [dependencyExclusion, setDependencyExclusion] =
50
+ useState<DependencyExclusionMode>("strict");
51
+ const [warningPercent, setWarningPercent] = useState("50");
52
+ const [criticalPercent, setCriticalPercent] = useState("80");
53
+ const [healthCheckConfigurationId, setHealthCheckConfigurationId] =
54
+ useState<string | undefined>();
55
+ // Fetch health check associations for the selected system
56
+ const effectiveSystemId = systemId || objective?.systemId;
57
+ const { data: hcAssociationsData } =
58
+ hcClient.getSystemAssociations.useQuery(
59
+ { systemId: effectiveSystemId ?? "" },
60
+ { enabled: !!effectiveSystemId },
61
+ );
62
+
63
+ // Reset form when objective changes
64
+ useEffect(() => {
65
+ if (objective) {
66
+ setSystemId(objective.systemId);
67
+ setTarget(String(objective.target));
68
+ setWindowDays(String(objective.windowDays));
69
+ setDependencyExclusion(objective.dependencyExclusion);
70
+ setWarningPercent(
71
+ String(objective.burnRateThresholds.warningPercent),
72
+ );
73
+ setCriticalPercent(
74
+ String(objective.burnRateThresholds.criticalPercent),
75
+ );
76
+ setHealthCheckConfigurationId(
77
+ objective.healthCheckConfigurationId ?? undefined,
78
+ );
79
+ } else {
80
+ setSystemId("");
81
+ setTarget("99.9");
82
+ setWindowDays("30");
83
+ setDependencyExclusion("strict");
84
+ setWarningPercent("50");
85
+ setCriticalPercent("80");
86
+ setHealthCheckConfigurationId(undefined);
87
+ }
88
+ }, [objective, open]);
89
+
90
+ // Mutations
91
+ const createMutation = sloClient.createObjective.useMutation({
92
+ onSuccess: () => {
93
+ toast.success("SLO objective created");
94
+ onSave();
95
+ },
96
+ onError: (error) => {
97
+ toast.error(extractErrorMessage(error, "Failed to create"));
98
+ },
99
+ });
100
+
101
+ const updateMutation = sloClient.updateObjective.useMutation({
102
+ onSuccess: () => {
103
+ toast.success("SLO objective updated");
104
+ onSave();
105
+ },
106
+ onError: (error) => {
107
+ toast.error(extractErrorMessage(error, "Failed to update"));
108
+ },
109
+ });
110
+
111
+ const handleSubmit = () => {
112
+ const targetNum = Number.parseFloat(target);
113
+ const windowNum = Number.parseInt(windowDays, 10);
114
+ const warningNum = Number.parseFloat(warningPercent);
115
+ const criticalNum = Number.parseFloat(criticalPercent);
116
+
117
+ if (!systemId) {
118
+ toast.error("Please select a system");
119
+ return;
120
+ }
121
+ if (Number.isNaN(targetNum) || targetNum < 0 || targetNum > 100) {
122
+ toast.error("Target must be between 0 and 100");
123
+ return;
124
+ }
125
+ if (Number.isNaN(windowNum) || windowNum < 1) {
126
+ toast.error("Window must be at least 1 day");
127
+ return;
128
+ }
129
+
130
+ if (objective) {
131
+ updateMutation.mutate({
132
+ id: objective.id,
133
+ target: targetNum,
134
+ windowDays: windowNum,
135
+ dependencyExclusion,
136
+ burnRateThresholds: {
137
+ warningPercent: warningNum,
138
+ criticalPercent: criticalNum,
139
+ fastBurnMultiplier: 5,
140
+ },
141
+ });
142
+ } else {
143
+ createMutation.mutate({
144
+ systemId,
145
+ healthCheckConfigurationId,
146
+ target: targetNum,
147
+ windowDays: windowNum,
148
+ dependencyExclusion,
149
+ burnRateThresholds: {
150
+ warningPercent: warningNum,
151
+ criticalPercent: criticalNum,
152
+ fastBurnMultiplier: 5,
153
+ },
154
+ });
155
+ }
156
+ };
157
+
158
+ const saving = createMutation.isPending || updateMutation.isPending;
159
+
160
+ const systemAssociations = hcAssociationsData ?? [];
161
+
162
+ return (
163
+ <Dialog open={open} onOpenChange={onOpenChange}>
164
+ <DialogContent>
165
+ <DialogHeader>
166
+ <DialogTitle>
167
+ {objective ? "Edit SLO Objective" : "Create SLO Objective"}
168
+ </DialogTitle>
169
+ <DialogDescription className="sr-only">
170
+ {objective
171
+ ? "Modify SLO objective settings"
172
+ : "Define a new Service Level Objective for a system"}
173
+ </DialogDescription>
174
+ </DialogHeader>
175
+
176
+ <div className="grid gap-4 py-4">
177
+ {/* System selector - only for create */}
178
+ {!objective && (
179
+ <div className="grid gap-2">
180
+ <Label htmlFor="slo-system">System</Label>
181
+ <Select value={systemId} onValueChange={setSystemId}>
182
+ <SelectTrigger id="slo-system">
183
+ <SelectValue placeholder="Select a system" />
184
+ </SelectTrigger>
185
+ <SelectContent>
186
+ {systems.map((system) => (
187
+ <SelectItem key={system.id} value={system.id}>
188
+ {system.name}
189
+ </SelectItem>
190
+ ))}
191
+ </SelectContent>
192
+ </Select>
193
+ </div>
194
+ )}
195
+
196
+ {/* Health Check scope - only show when system has associated HCs */}
197
+ {systemAssociations.length > 0 && (
198
+ <div className="grid gap-2">
199
+ <Label>Health Check Scope</Label>
200
+ <Select
201
+ value={healthCheckConfigurationId ?? "__all__"}
202
+ onValueChange={(v) =>
203
+ setHealthCheckConfigurationId(
204
+ v === "__all__" ? undefined : v,
205
+ )
206
+ }
207
+ >
208
+ <SelectTrigger>
209
+ <SelectValue />
210
+ </SelectTrigger>
211
+ <SelectContent>
212
+ <SelectItem value="__all__">
213
+ All health checks (system-global)
214
+ </SelectItem>
215
+ {systemAssociations.map((assoc) => (
216
+ <SelectItem
217
+ key={assoc.configurationId}
218
+ value={assoc.configurationId}
219
+ >
220
+ {assoc.configurationName}
221
+ </SelectItem>
222
+ ))}
223
+ </SelectContent>
224
+ </Select>
225
+ <p className="text-xs text-muted-foreground">
226
+ {healthCheckConfigurationId
227
+ ? "This SLO only tracks downtime from the selected health check."
228
+ : "This SLO tracks all health checks for the system."}
229
+ </p>
230
+ </div>
231
+ )}
232
+
233
+
234
+ {/* Target */}
235
+ <div className="grid gap-2">
236
+ <Label htmlFor="slo-target">Availability Target (%)</Label>
237
+ <Input
238
+ id="slo-target"
239
+ type="number"
240
+ step="0.01"
241
+ min="0"
242
+ max="100"
243
+ value={target}
244
+ onChange={(e) => setTarget(e.target.value)}
245
+ placeholder="99.9"
246
+ />
247
+ <p className="text-xs text-muted-foreground">
248
+ Common targets: 99.0% (3.65 days/year), 99.9% (8.76
249
+ hours/year), 99.95% (4.38 hours/year)
250
+ </p>
251
+ </div>
252
+
253
+ {/* Rolling window */}
254
+ <div className="grid gap-2">
255
+ <Label htmlFor="slo-window">Rolling Window (days)</Label>
256
+ <Input
257
+ id="slo-window"
258
+ type="number"
259
+ min="1"
260
+ value={windowDays}
261
+ onChange={(e) => setWindowDays(e.target.value)}
262
+ placeholder="30"
263
+ />
264
+ </div>
265
+
266
+ {/* Dependency exclusion mode */}
267
+ <div className="grid gap-2">
268
+ <Label>Dependency Exclusion Mode</Label>
269
+ <Select
270
+ value={dependencyExclusion}
271
+ onValueChange={(v) =>
272
+ setDependencyExclusion(v as DependencyExclusionMode)
273
+ }
274
+ >
275
+ <SelectTrigger>
276
+ <SelectValue />
277
+ </SelectTrigger>
278
+ <SelectContent>
279
+ <SelectItem value="strict">
280
+ Strict โ€” All downtime counts
281
+ </SelectItem>
282
+ <SelectItem value="self-only">
283
+ Self-Only โ€” Exclude upstream-attributed downtime
284
+ </SelectItem>
285
+ </SelectContent>
286
+ </Select>
287
+ <p className="text-xs text-muted-foreground">
288
+ {dependencyExclusion === "strict"
289
+ ? "All downtime counts against the error budget, regardless of cause."
290
+ : "Only self-caused downtime counts. When an upstream dependency is also down, that time is excluded."}
291
+ </p>
292
+ </div>
293
+
294
+ {/* Burn rate thresholds */}
295
+ <div className="grid grid-cols-2 gap-4">
296
+ <div className="grid gap-2">
297
+ <Label htmlFor="slo-warning">Warning Threshold (%)</Label>
298
+ <Input
299
+ id="slo-warning"
300
+ type="number"
301
+ min="0"
302
+ max="100"
303
+ value={warningPercent}
304
+ onChange={(e) => setWarningPercent(e.target.value)}
305
+ />
306
+ </div>
307
+ <div className="grid gap-2">
308
+ <Label htmlFor="slo-critical">Critical Threshold (%)</Label>
309
+ <Input
310
+ id="slo-critical"
311
+ type="number"
312
+ min="0"
313
+ max="100"
314
+ value={criticalPercent}
315
+ onChange={(e) => setCriticalPercent(e.target.value)}
316
+ />
317
+ </div>
318
+ </div>
319
+ </div>
320
+
321
+ <DialogFooter>
322
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
323
+ Cancel
324
+ </Button>
325
+ <Button onClick={handleSubmit} disabled={saving}>
326
+ {saving ? "Saving..." : objective ? "Update" : "Create"}
327
+ </Button>
328
+ </DialogFooter>
329
+ </DialogContent>
330
+ </Dialog>
331
+ );
332
+ };
@@ -0,0 +1,30 @@
1
+ import { Link } from "react-router-dom";
2
+ import { Target, Settings } from "lucide-react";
3
+ import type { UserMenuItemsContext } from "@checkstack/frontend-api";
4
+ import { DropdownMenuItem } from "@checkstack/ui";
5
+ import { resolveRoute } from "@checkstack/common";
6
+ import { sloRoutes, sloAccess, pluginMetadata } from "@checkstack/slo-common";
7
+
8
+ export const SloMenuItems = ({
9
+ accessRules: userPerms,
10
+ }: UserMenuItemsContext) => {
11
+ const qualifiedId = `${pluginMetadata.pluginId}.${sloAccess.slo.manage.id}`;
12
+ const canManage = userPerms.includes("*") || userPerms.includes(qualifiedId);
13
+
14
+ return (
15
+ <>
16
+ <Link to={resolveRoute(sloRoutes.routes.overview)}>
17
+ <DropdownMenuItem icon={<Target className="w-4 h-4" />}>
18
+ SLO Dashboard
19
+ </DropdownMenuItem>
20
+ </Link>
21
+ {canManage && (
22
+ <Link to={resolveRoute(sloRoutes.routes.config)}>
23
+ <DropdownMenuItem icon={<Settings className="w-4 h-4" />}>
24
+ SLO Management
25
+ </DropdownMenuItem>
26
+ </Link>
27
+ )}
28
+ </>
29
+ );
30
+ };
@@ -0,0 +1,183 @@
1
+ import React, { useMemo } from "react";
2
+
3
+ interface Snapshot {
4
+ date: Date;
5
+ availabilityPercent: number;
6
+ budgetRemainingPercent: number;
7
+ }
8
+
9
+ interface SloTrendChartProps {
10
+ snapshots: Snapshot[];
11
+ target: number;
12
+ }
13
+
14
+ const CHART_HEIGHT = 160;
15
+ const CHART_PADDING = { top: 8, right: 12, bottom: 24, left: 48 };
16
+
17
+ /**
18
+ * Pure SVG line chart showing SLO availability trend from daily snapshots.
19
+ * Draws a target line, area fill, and data line with responsive sizing.
20
+ */
21
+ export const SloTrendChart: React.FC<SloTrendChartProps> = ({
22
+ snapshots,
23
+ target,
24
+ }) => {
25
+ const chartData = useMemo(() => {
26
+ if (snapshots.length === 0) return;
27
+
28
+ const sorted = snapshots.toSorted(
29
+ (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
30
+ );
31
+
32
+ // Y-axis range: ensure target and all data points are visible
33
+ const allValues = sorted.map((s) => s.availabilityPercent);
34
+ const minVal = Math.min(...allValues, target);
35
+ const maxVal = Math.max(...allValues, 100);
36
+ const yMin = Math.max(Math.floor(minVal - 0.5), 0);
37
+ const yMax = Math.min(Math.ceil(maxVal + 0.5), 100);
38
+ const yRange = yMax - yMin || 1;
39
+
40
+ return { sorted, yMin, yMax, yRange };
41
+ }, [snapshots, target]);
42
+
43
+ if (!chartData || chartData.sorted.length < 2) {
44
+ return (
45
+ <div className="text-sm text-muted-foreground text-center py-8">
46
+ {chartData && chartData.sorted.length === 1
47
+ ? "Need at least 2 daily snapshots to render a trend chart"
48
+ : "No daily snapshot data available yet"}
49
+ </div>
50
+ );
51
+ }
52
+
53
+ const { sorted, yMin, yMax, yRange } = chartData;
54
+ const drawWidth = 100 - CHART_PADDING.left - CHART_PADDING.right;
55
+
56
+ // Compute scaled positions (in viewBox %)
57
+ const scaleX = (index: number) =>
58
+ CHART_PADDING.left + (index / (sorted.length - 1)) * drawWidth;
59
+
60
+ const drawHeight = CHART_HEIGHT - CHART_PADDING.top - CHART_PADDING.bottom;
61
+ const scaleY = (value: number) =>
62
+ CHART_PADDING.top + ((yMax - value) / yRange) * drawHeight;
63
+
64
+ // Build SVG path for the line
65
+ const linePath = sorted
66
+ .map(
67
+ (s, i) =>
68
+ `${i === 0 ? "M" : "L"} ${scaleX(i).toFixed(2)} ${scaleY(s.availabilityPercent).toFixed(2)}`,
69
+ )
70
+ .join(" ");
71
+
72
+ // Build SVG path for the area fill
73
+ const areaPath = `${linePath} L ${scaleX(sorted.length - 1).toFixed(2)} ${scaleY(yMin).toFixed(2)} L ${scaleX(0).toFixed(2)} ${scaleY(yMin).toFixed(2)} Z`;
74
+
75
+ // Target line Y position
76
+ const targetY = scaleY(target);
77
+
78
+ // Y-axis tick values
79
+ const yTicks = [yMin, target, yMax].filter(
80
+ (v, i, arr) => arr.indexOf(v) === i,
81
+ );
82
+
83
+ // X-axis labels (first, middle, last)
84
+ const xLabels = [0, Math.floor(sorted.length / 2), sorted.length - 1]
85
+ .filter((v, i, arr) => arr.indexOf(v) === i)
86
+ .map((i) => ({
87
+ x: scaleX(i),
88
+ label: new Date(sorted[i].date).toLocaleDateString("en-US", {
89
+ month: "short",
90
+ day: "numeric",
91
+ }),
92
+ }));
93
+
94
+ return (
95
+ <svg
96
+ viewBox={`0 0 100 ${CHART_HEIGHT}`}
97
+ className="w-full"
98
+ preserveAspectRatio="none"
99
+ style={{ height: CHART_HEIGHT }}
100
+ >
101
+ {/* Area fill */}
102
+ <path d={areaPath} fill="url(#slo-gradient)" opacity={0.3} />
103
+
104
+ {/* Gradient definition */}
105
+ <defs>
106
+ <linearGradient id="slo-gradient" x1="0" y1="0" x2="0" y2="1">
107
+ <stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity={0.4} />
108
+ <stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity={0} />
109
+ </linearGradient>
110
+ </defs>
111
+
112
+ {/* Target line */}
113
+ <line
114
+ x1={CHART_PADDING.left}
115
+ y1={targetY}
116
+ x2={100 - CHART_PADDING.right}
117
+ y2={targetY}
118
+ stroke="hsl(var(--destructive))"
119
+ strokeWidth={0.3}
120
+ strokeDasharray="1 1"
121
+ opacity={0.6}
122
+ />
123
+
124
+ {/* Data line */}
125
+ <path
126
+ d={linePath}
127
+ fill="none"
128
+ stroke="hsl(var(--primary))"
129
+ strokeWidth={0.5}
130
+ strokeLinejoin="round"
131
+ strokeLinecap="round"
132
+ />
133
+
134
+ {/* Data points */}
135
+ {sorted.map((s, i) => (
136
+ <circle
137
+ key={i}
138
+ cx={scaleX(i)}
139
+ cy={scaleY(s.availabilityPercent)}
140
+ r={0.8}
141
+ fill="hsl(var(--primary))"
142
+ >
143
+ <title>
144
+ {new Date(s.date).toLocaleDateString("en-US", {
145
+ month: "short",
146
+ day: "numeric",
147
+ })}
148
+ : {s.availabilityPercent.toFixed(3)}%
149
+ </title>
150
+ </circle>
151
+ ))}
152
+
153
+ {/* Y-axis labels */}
154
+ {yTicks.map((tick) => (
155
+ <text
156
+ key={tick}
157
+ x={CHART_PADDING.left - 2}
158
+ y={scaleY(tick)}
159
+ textAnchor="end"
160
+ dominantBaseline="middle"
161
+ className="fill-muted-foreground"
162
+ fontSize={3.5}
163
+ >
164
+ {tick}%
165
+ </text>
166
+ ))}
167
+
168
+ {/* X-axis labels */}
169
+ {xLabels.map((item) => (
170
+ <text
171
+ key={item.x}
172
+ x={item.x}
173
+ y={CHART_HEIGHT - 4}
174
+ textAnchor="middle"
175
+ className="fill-muted-foreground"
176
+ fontSize={3}
177
+ >
178
+ {item.label}
179
+ </text>
180
+ ))}
181
+ </svg>
182
+ );
183
+ };
@@ -0,0 +1,45 @@
1
+ import React from "react";
2
+ import { Flame, Trophy } from "lucide-react";
3
+
4
+ interface StreakCounterProps {
5
+ currentStreak: number;
6
+ bestStreak: number;
7
+ }
8
+
9
+ /**
10
+ * Fire-themed streak counter showing consecutive SLO-compliant days.
11
+ * Current streak displays with a flame icon; best streak shown below.
12
+ */
13
+ export const StreakCounter: React.FC<StreakCounterProps> = ({
14
+ currentStreak,
15
+ bestStreak,
16
+ }) => {
17
+ const getFlameColor = () => {
18
+ if (currentStreak >= 90) return "text-amber-400";
19
+ if (currentStreak >= 30) return "text-orange-500";
20
+ if (currentStreak >= 7) return "text-orange-400";
21
+ return "text-muted-foreground";
22
+ };
23
+
24
+ return (
25
+ <div className="flex items-center gap-4">
26
+ <div className="flex items-center gap-2">
27
+ <Flame
28
+ className={`w-5 h-5 ${getFlameColor()} ${currentStreak > 0 ? "animate-pulse" : ""}`}
29
+ />
30
+ <span className="text-2xl font-bold tabular-nums">
31
+ {currentStreak}
32
+ </span>
33
+ <span className="text-sm text-muted-foreground">
34
+ {currentStreak === 1 ? "day" : "days"}
35
+ </span>
36
+ </div>
37
+ {bestStreak > 0 && bestStreak > currentStreak && (
38
+ <div className="flex items-center gap-1 text-sm text-muted-foreground">
39
+ <Trophy className="w-3.5 h-3.5" />
40
+ <span>Best: {bestStreak}d</span>
41
+ </div>
42
+ )}
43
+ </div>
44
+ );
45
+ };