Coverage for src / lexigram / admin / ui / molecules / date_hierarchy.py: 20%

61 statements  

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

1"""Date hierarchy filter — Django Admin-style year/month/day drill-down. 

2 

3Renders a breadcrumb-style date navigation bar that lets users drill into 

4a dataset by year, then month, then day. Each level is a clickable link 

5that adds the appropriate ``year=``, ``month=``, ``day=`` query parameters. 

6 

7Django Admin is the only framework with built-in date hierarchy; this 

8brings that feature to lexigram-admin. 

9 

10Usage:: 

11 

12 bar = DateHierarchyFilter( 

13 field_name="created_at", 

14 year=2026, 

15 month=3, 

16 day=None, 

17 base_url="/admin/users", 

18 resource_prefix="/admin/users", 

19 ) 

20 html = bar.render() 

21""" 

22 

23from __future__ import annotations 

24 

25import calendar 

26from typing import Any 

27 

28from lexigram.ui import Component, el 

29 

30# Month name abbreviations (locale-neutral; translatable via i18n layer) 

31_MONTH_NAMES = [ 

32 "Jan", 

33 "Feb", 

34 "Mar", 

35 "Apr", 

36 "May", 

37 "Jun", 

38 "Jul", 

39 "Aug", 

40 "Sep", 

41 "Oct", 

42 "Nov", 

43 "Dec", 

44] 

45 

46 

47class DateHierarchyFilter(Component): 

48 """Year/month/day drill-down filter navigation. 

49 

50 At each level the component renders quick-link buttons: 

51 

52 - **No selection**: Shows clickable year links (last 5 years + current). 

53 - **Year selected**: Shows clickable month buttons (Jan–Dec). 

54 - **Year + month selected**: Shows clickable day buttons for that month. 

55 - **Year + month + day**: Shows breadcrumb with "×" clear button. 

56 

57 HTMX is used to reload the table without a full page refresh. 

58 

59 Args: 

60 field_name: Model field being filtered (used in URL params as 

61 ``{field_name}__year``, etc.). 

62 year: Currently selected year, or ``None``. 

63 month: Currently selected month (1–12), or ``None``. 

64 day: Currently selected day (1–31), or ``None``. 

65 base_url: Base URL for building drill-down links. 

66 resource_prefix: HTMX target resource prefix. 

67 available_years: Explicit list of years to show. If ``None``, 

68 defaults to the 5 years before and including the current year 

69 from the *year* argument or ``2026``. 

70 """ 

71 

72 def __init__( 

73 self, 

74 field_name: str = "created_at", 

75 year: int | None = None, 

76 month: int | None = None, 

77 day: int | None = None, 

78 base_url: str = "", 

79 resource_prefix: str = "", 

80 available_years: list[int] | None = None, 

81 **props: Any, 

82 ) -> None: 

83 super().__init__(**props) 

84 self.field_name = field_name 

85 self.year = year 

86 self.month = month 

87 self.day = day 

88 self.base_url = base_url.rstrip("/") 

89 self.resource_prefix = (resource_prefix or base_url).rstrip("/") 

90 _anchor = year or 2026 

91 self.available_years = available_years or list(range(_anchor - 4, _anchor + 1)) 

92 

93 # ------------------------------------------------------------------ 

94 # Public render 

95 # ------------------------------------------------------------------ 

96 

97 def render(self) -> Any: 

98 items = self._build_items() 

99 if not items: 

100 return "" 

101 

102 return el( 

103 "nav", 

104 el( 

105 "ol", 

106 *items, 

107 class_="flex flex-wrap items-center gap-1", 

108 ), 

109 class_=( 

110 "flex items-center gap-2 text-sm " 

111 "bg-card " 

112 "border border-border " 

113 "rounded-lg px-3 py-2 mb-3" 

114 ), 

115 **{"aria-label": "Date hierarchy"}, 

116 ) 

117 

118 # ------------------------------------------------------------------ 

119 # Private helpers 

120 # ------------------------------------------------------------------ 

121 

122 def _htmx_link(self, label: str, url: str, extra_cls: str = "") -> Any: 

123 """Render a single HTMX drill-down link.""" 

124 return el( 

125 "a", 

126 label, 

127 href=url, 

128 class_=( 

129 f"px-2 py-0.5 rounded text-primary " 

130 f"hover:bg-muted " 

131 f"transition-colors cursor-pointer {extra_cls}" 

132 ), 

133 **{ 

134 "hx-get": url, 

135 "hx-target": "#main-content", 

136 "hx-push-url": "true", 

137 }, 

138 ) 

139 

140 def _clear_link(self) -> Any: 

141 """Render an 'x clear' link that strips all date params.""" 

142 url = self.base_url or "?" 

143 return self._htmx_link( 

144 "\u00d7", url, extra_cls="text-muted-foreground hover:text-destructive" 

145 ) 

146 

147 def _build_url( 

148 self, 

149 *, 

150 year: int | None = None, 

151 month: int | None = None, 

152 day: int | None = None, 

153 ) -> str: 

154 """Build a URL with the given date query params.""" 

155 params: list[str] = [] 

156 if year is not None: 

157 params.append(f"{self.field_name}__year={year}") 

158 if month is not None: 

159 params.append(f"{self.field_name}__month={month}") 

160 if day is not None: 

161 params.append(f"{self.field_name}__day={day}") 

162 base = self.base_url or "." 

163 return f"{base}?{'&'.join(params)}" if params else base 

164 

165 def _build_items(self) -> list[Any]: 

166 """Build the ordered list items based on current drill-down level.""" 

167 items: list[Any] = [] 

168 

169 if self.year is None: 

170 # Level 0 — show year buttons 

171 for y in sorted(self.available_years, reverse=True): 

172 url = self._build_url(year=y) 

173 items.append(el("li", self._htmx_link(str(y), url))) 

174 return items 

175 

176 # Level 1+ — always show year breadcrumb 

177 items.append( 

178 el( 

179 "li", 

180 el( 

181 "span", 

182 self._htmx_link(str(self.year), self._build_url(year=self.year)), 

183 el("span", "/", class_="text-muted-foreground mx-1"), 

184 class_="flex items-center", 

185 ), 

186 ) 

187 ) 

188 

189 if self.month is None: 

190 # Level 1 — show month buttons 

191 for m_idx, m_name in enumerate(_MONTH_NAMES, start=1): 

192 url = self._build_url(year=self.year, month=m_idx) 

193 items.append(el("li", self._htmx_link(m_name, url))) 

194 items.append(el("li", self._clear_link())) 

195 return items 

196 

197 # Level 2 — year + month breadcrumb 

198 items.append( 

199 el( 

200 "li", 

201 el( 

202 "span", 

203 self._htmx_link( 

204 _MONTH_NAMES[self.month - 1], 

205 self._build_url(year=self.year, month=self.month), 

206 ), 

207 el("span", "/", class_="text-muted-foreground mx-1"), 

208 class_="flex items-center", 

209 ), 

210 ) 

211 ) 

212 

213 if self.day is None: 

214 # Level 2 — show day buttons 

215 _, days_in_month = calendar.monthrange(self.year, self.month) 

216 for d in range(1, days_in_month + 1): 

217 url = self._build_url(year=self.year, month=self.month, day=d) 

218 items.append(el("li", self._htmx_link(str(d), url))) 

219 items.append(el("li", self._clear_link())) 

220 return items 

221 

222 # Level 3 — full breadcrumb + clear 

223 items.append( 

224 el( 

225 "li", 

226 el( 

227 "span", 

228 str(self.day), 

229 class_="font-medium text-foreground", 

230 ), 

231 ) 

232 ) 

233 items.append(el("li", self._clear_link())) 

234 return items