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

1"""Stats overview widget with description, trend indicator and sparkline.""" 

2 

3from __future__ import annotations 

4 

5from enum import StrEnum 

6from typing import Any 

7 

8from lexigram.ui import Component, el 

9 

10_COL_SPAN_MAP = {1: "", 2: "lg:col-span-2", 3: "lg:col-span-3", 4: "lg:col-span-4"} 

11 

12 

13class StatTrend(StrEnum): 

14 """Direction of a stat's trend indicator.""" 

15 

16 UP = "up" 

17 DOWN = "down" 

18 FLAT = "flat" 

19 

20 

21_TREND_ARROW = { 

22 StatTrend.UP: "▲", 

23 StatTrend.DOWN: "▼", 

24 StatTrend.FLAT: "—", 

25} 

26 

27_TREND_COLOR = { 

28 StatTrend.UP: "#16a34a", 

29 StatTrend.DOWN: "#dc2626", 

30 StatTrend.FLAT: "#6b7280", 

31} 

32 

33_SPARKLINE_W = 100.0 

34_SPARKLINE_H = 32.0 

35 

36 

37def _sparkline_points(values: list[float]) -> str: 

38 """Build an SVG polyline ``points`` string from values scaled to fit. 

39 

40 Args: 

41 values: Numeric series to plot. Must contain at least two points. 

42 

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) 

59 

60 

61class StatsOverviewWidget(Component): 

62 """A single-number overview card (Filament stats-overview parity). 

63 

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``). 

73 

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 """ 

86 

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 

108 

109 def render(self) -> Any: 

110 """Render the stat card markup.""" 

111 span = _COL_SPAN_MAP.get(self.col_span, "") 

112 

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 ) 

122 

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 ) 

142 

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 ) 

159 

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 ) 

190 

191 return el( 

192 "div", 

193 *children, 

194 class_=f"bg-card rounded-xl shadow-sm border border-border p-5 {span}".strip(), 

195 )