Coverage for src/lexigram/admin/dashboard/stats_widget.py: 100%
57 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1"""Stats overview widget with description, trend indicator and sparkline."""
3from __future__ import annotations
5from enum import StrEnum
6from typing import Any
8from lexigram.ui import Component, el
10_COL_SPAN_MAP = {1: "", 2: "lg:col-span-2", 3: "lg:col-span-3", 4: "lg:col-span-4"}
13class StatTrend(StrEnum):
14 """Direction of a stat's trend indicator."""
16 UP = "up"
17 DOWN = "down"
18 FLAT = "flat"
21_TREND_ARROW = {
22 StatTrend.UP: "▲",
23 StatTrend.DOWN: "▼",
24 StatTrend.FLAT: "—",
25}
27_TREND_COLOR = {
28 StatTrend.UP: "#16a34a",
29 StatTrend.DOWN: "#dc2626",
30 StatTrend.FLAT: "#6b7280",
31}
33_SPARKLINE_W = 100.0
34_SPARKLINE_H = 32.0
37def _sparkline_points(values: list[float]) -> str:
38 """Build an SVG polyline ``points`` string from values scaled to fit.
40 Args:
41 values: Numeric series to plot. Must contain at least two points.
43 Returns:
44 Space-separated ``x,y`` coordinates, or an empty string when too few
45 points are provided.
46 """
47 if len(values) < 2:
48 return ""
49 min_value = min(values)
50 max_value = max(values)
51 value_range = (max_value - min_value) or 1.0
52 n = len(values)
53 points: list[str] = []
54 for index, value in enumerate(values):
55 x = (index / (n - 1)) * _SPARKLINE_W
56 y = _SPARKLINE_H - ((value - min_value) / value_range) * (_SPARKLINE_H - 2) - 1
57 points.append(f"{x:.1f},{y:.1f}")
58 return " ".join(points)
61class StatsOverviewWidget(Component):
62 """A single-number overview card (Filament stats-overview parity).
64 Args:
65 title: Label shown beneath the value.
66 value: The headline statistic, rendered large.
67 description: Optional supporting text below the title.
68 icon: Optional icon glyph shown in a tinted box next to the value.
69 trend: Optional direction arrow alongside the value.
70 trend_value: Optional percentage delta rendered next to the trend arrow.
71 sparkline_data: Optional numeric series rendered as an inline SVG sparkline.
72 col_span: Dashboard grid column span (1-4, same as ``ChartWidget``).
74 Example:
75 ```python
76 StatsOverviewWidget(
77 title="Active Users",
78 value="847",
79 description="vs. last month",
80 trend=StatTrend.UP,
81 trend_value=12.5,
82 sparkline_data=[620, 640, 700, 690, 760, 820, 847],
83 )
84 ```
85 """
87 def __init__(
88 self,
89 title: str,
90 value: str,
91 *,
92 description: str = "",
93 icon: str = "",
94 trend: StatTrend | None = None,
95 trend_value: float | None = None,
96 sparkline_data: list[float] | None = None,
97 col_span: int = 1,
98 ) -> None:
99 super().__init__()
100 self.title = title
101 self.value = value
102 self.description = description
103 self.icon = icon
104 self.trend = trend
105 self.trend_value = trend_value
106 self.sparkline_data = sparkline_data or []
107 self.col_span = col_span
109 def render(self) -> Any:
110 """Render the stat card markup."""
111 span = _COL_SPAN_MAP.get(self.col_span, "")
113 left: list[Any] = []
114 if self.icon:
115 left.append(
116 el(
117 "div",
118 self.icon,
119 class_="w-10 h-10 rounded-lg bg-muted text-muted-foreground flex items-center justify-center text-lg shrink-0",
120 )
121 )
123 value_row: list[Any] = [
124 el(
125 "div",
126 self.value,
127 class_="text-2xl font-semibold text-foreground leading-none",
128 )
129 ]
130 if self.trend and self.trend in _TREND_ARROW:
131 trend_text = _TREND_ARROW[self.trend]
132 if self.trend_value is not None:
133 trend_text = f"{trend_text} {self.trend_value:+.1f}%"
134 value_row.append(
135 el(
136 "span",
137 trend_text,
138 class_="text-xs font-medium",
139 style=f"color: {_TREND_COLOR[self.trend]}",
140 )
141 )
143 body_children: list[Any] = [
144 el("div", *value_row, class_="flex items-center gap-2"),
145 el(
146 "div",
147 self.title,
148 class_="text-xs text-muted-foreground mt-1",
149 ),
150 ]
151 if self.description:
152 body_children.append(
153 el(
154 "div",
155 self.description,
156 class_="text-xs text-muted-foreground mt-0.5",
157 )
158 )
160 children: list[Any] = [
161 el(
162 "div",
163 *left,
164 el("div", *body_children),
165 class_="flex items-start gap-3",
166 )
167 ]
168 if self.sparkline_data:
169 points = _sparkline_points(self.sparkline_data)
170 if points:
171 children.append(
172 el(
173 "svg",
174 el(
175 "polyline",
176 points=points,
177 fill="none",
178 **{
179 "stroke": "var(--primary)",
180 "stroke-width": "2",
181 "stroke-linecap": "round",
182 "stroke-linejoin": "round",
183 },
184 ),
185 viewBox=f"0 0 {_SPARKLINE_W:.0f} {_SPARKLINE_H:.0f}",
186 preserveAspectRatio="none",
187 class_="w-full h-8 mt-4",
188 )
189 )
191 return el(
192 "div",
193 *children,
194 class_=f"bg-card rounded-xl shadow-sm border border-border p-5 {span}".strip(),
195 )