Coverage for src / lexigram / ui / molecules / stat_card.py: 15%

46 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-10 04:11 +0800

1from __future__ import annotations 

2 

3from typing import Any 

4 

5from lexigram.ui.core.base import Component, el 

6 

7 

8class StatCard(Component): 

9 """Dashboard metric card.""" 

10 

11 def __init__( 

12 self, 

13 label: str, 

14 value: Any, 

15 delta: str | None = None, 

16 delta_color: str = "green", 

17 icon: str | None = None, 

18 sparkline_data: list[float] | None = None, 

19 sparkline_color: str = "indigo", 

20 **props, 

21 ) -> None: 

22 super().__init__( 

23 label=label, 

24 value=value, 

25 delta=delta, 

26 delta_color=delta_color, 

27 icon=icon, 

28 sparkline_data=sparkline_data, 

29 sparkline_color=sparkline_color, 

30 **props, 

31 ) 

32 self.label = label 

33 self.value = value 

34 self.delta = delta 

35 self.delta_color = delta_color 

36 self.icon = icon 

37 self.sparkline_data = sparkline_data 

38 self.sparkline_color = sparkline_color 

39 

40 def _render_sparkline(self) -> Any: 

41 if not self.sparkline_data: 

42 return None 

43 

44 values = list(map(float, self.sparkline_data)) 

45 max_val = max(values, default=1.0) 

46 if max_val == 0: 

47 max_val = 1.0 

48 

49 width = 80 

50 height = 30 

51 

52 points = [] 

53 n = len(values) 

54 for i, val in enumerate(values): 

55 x = (i / (n - 1)) * width if n > 1 else width / 2 

56 y = height - (val / max_val * height) 

57 points.append(f"{x:.1f},{y:.1f}") 

58 

59 pts = " ".join(points) 

60 

61 return el( 

62 "div", 

63 el( 

64 "svg", 

65 el( 

66 "polyline", 

67 points=pts, 

68 fill="none", 

69 stroke="currentColor", 

70 stroke_width="2.5", 

71 stroke_linecap="round", 

72 stroke_linejoin="round", 

73 ), 

74 viewBox=f"0 0 {width} {height}", 

75 class_="w-full h-full text-primary", 

76 aria_hidden="true", 

77 ), 

78 class_="w-20 h-8 ml-auto self-end mb-1 opacity-80", 

79 ) 

80 

81 def render(self) -> Any: 

82 # Merge classes 

83 cls = "bg-card p-6 rounded-2xl shadow-sm border border-border hover:shadow-md transition-all duration-300" 

84 custom_cls = self.props.get("class_", self.props.get("class")) 

85 if custom_cls: 

86 cls = f"{cls} {custom_cls}" 

87 

88 attrs = dict( 

89 filter( 

90 lambda item: ( 

91 item[0] 

92 not in ( 

93 "label", 

94 "value", 

95 "delta", 

96 "delta_color", 

97 "icon", 

98 "sparkline_data", 

99 "sparkline_color", 

100 "class_", 

101 "class", 

102 ) 

103 ), 

104 self.props.items(), 

105 ), 

106 ) 

107 

108 # Icon placeholder (could integrate with Heroicons later) 

109 icon_el = None 

110 if self.icon: 

111 from lexigram.ui.atoms.icons import get_icon 

112 

113 icon_node = get_icon(self.icon, class_name="w-6 h-6") 

114 icon_el = el( 

115 "div", 

116 icon_node, 

117 class_="p-3 rounded-full bg-primary/10 text-primary mr-4 shadow-sm border border-primary/20", 

118 ) 

119 

120 delta_el = None 

121 if self.delta: 

122 color_cls = ( 

123 "text-success" 

124 if self.delta_color == "green" 

125 else "text-destructive" 

126 ) 

127 delta_el = el( 

128 "span", 

129 self.delta, 

130 class_=f"text-sm font-semibold {color_cls} ml-2", 

131 ) 

132 

133 return el( 

134 "div", 

135 el( 

136 "div", 

137 el( 

138 "div", 

139 icon_el, 

140 el( 

141 "div", 

142 el( 

143 "p", 

144 self.label, 

145 class_="text-sm font-semibold text-muted-foreground truncate uppercase tracking-wider", 

146 ), 

147 el( 

148 "div", 

149 el( 

150 "h3", 

151 str(self.value), 

152 class_="text-3xl font-extrabold text-foreground", 

153 ), 

154 delta_el, 

155 class_="flex items-baseline mt-1", 

156 ), 

157 ), 

158 class_="flex items-center", 

159 ), 

160 self._render_sparkline(), 

161 class_="flex justify-between items-center", 

162 ), 

163 class_=cls, 

164 **attrs, 

165 )