gov-uk-dashboards 21.2.2__py3-none-any.whl → 26.26.0__py3-none-any.whl

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.
Files changed (87) hide show
  1. gov_uk_dashboards/__init__.py +1 -1
  2. gov_uk_dashboards/assets/__init__.py +1 -0
  3. gov_uk_dashboards/assets/dashboard.css +177 -0
  4. gov_uk_dashboards/assets/download-map.js +39 -0
  5. gov_uk_dashboards/assets/get_assets_folder.py +1 -0
  6. gov_uk_dashboards/assets/images/CHASE_icon.svg +17 -0
  7. gov_uk_dashboards/assets/images/explore_data_logo.svg +87 -0
  8. gov_uk_dashboards/assets/index.html +3 -0
  9. gov_uk_dashboards/assets/register_maps +15 -0
  10. gov_uk_dashboards/assets/scripts.js +4 -0
  11. gov_uk_dashboards/colours.py +23 -0
  12. gov_uk_dashboards/components/__init__.py +1 -0
  13. gov_uk_dashboards/components/dash/__init__.py +1 -3
  14. gov_uk_dashboards/components/dash/apply_and_reset_filters_buttons.py +1 -0
  15. gov_uk_dashboards/components/dash/banners.py +21 -0
  16. gov_uk_dashboards/components/dash/card.py +1 -0
  17. gov_uk_dashboards/components/dash/card_full_width.py +1 -0
  18. gov_uk_dashboards/components/dash/collapsible_panel.py +1 -0
  19. gov_uk_dashboards/components/dash/comparison_la_filter_button.py +1 -0
  20. gov_uk_dashboards/components/dash/context_banner.py +2 -1
  21. gov_uk_dashboards/components/dash/context_card.py +978 -0
  22. gov_uk_dashboards/components/dash/data_quality_banner.py +91 -0
  23. gov_uk_dashboards/components/dash/details.py +1 -0
  24. gov_uk_dashboards/components/dash/download_button.py +22 -36
  25. gov_uk_dashboards/components/dash/filter_panel.py +1 -0
  26. gov_uk_dashboards/components/dash/footer.py +81 -27
  27. gov_uk_dashboards/components/dash/graph.py +1 -0
  28. gov_uk_dashboards/components/dash/green_button.py +25 -0
  29. gov_uk_dashboards/components/dash/header.py +62 -9
  30. gov_uk_dashboards/components/dash/heading.py +8 -5
  31. gov_uk_dashboards/components/dash/home_page_link_button.py +9 -8
  32. gov_uk_dashboards/components/dash/html_list.py +1 -0
  33. gov_uk_dashboards/components/dash/key_value_pair.py +1 -0
  34. gov_uk_dashboards/components/dash/main_content.py +25 -2
  35. gov_uk_dashboards/components/dash/notification_banner.py +9 -5
  36. gov_uk_dashboards/components/dash/paragraph.py +1 -0
  37. gov_uk_dashboards/components/dash/phase_banner.py +7 -4
  38. gov_uk_dashboards/components/dash/row_component.py +1 -0
  39. gov_uk_dashboards/components/dash/table.py +62 -124
  40. gov_uk_dashboards/components/dash/tooltip.py +2 -1
  41. gov_uk_dashboards/components/dash/tooltip_title.py +2 -1
  42. gov_uk_dashboards/components/dash/visualisation_commentary.py +1 -0
  43. gov_uk_dashboards/components/dash/visualisation_title.py +1 -0
  44. gov_uk_dashboards/components/dash/warning_text.py +1 -0
  45. gov_uk_dashboards/components/helpers/display_chart_or_table_with_header.py +61 -12
  46. gov_uk_dashboards/components/helpers/get_chart_for_download.py +18 -15
  47. gov_uk_dashboards/components/helpers/plotting_helper_functions.py +0 -1
  48. gov_uk_dashboards/components/leaflet/leaflet_choropleth_map.py +108 -31
  49. gov_uk_dashboards/components/plotly/captioned_figure.py +6 -3
  50. gov_uk_dashboards/components/plotly/enums.py +2 -0
  51. gov_uk_dashboards/components/plotly/stacked_barchart.py +166 -73
  52. gov_uk_dashboards/components/plotly/time_series_chart.py +159 -20
  53. gov_uk_dashboards/constants.py +35 -1
  54. gov_uk_dashboards/figures/__init__.py +4 -2
  55. gov_uk_dashboards/figures/enums/__init__.py +1 -0
  56. gov_uk_dashboards/figures/enums/dash_patterns.py +1 -0
  57. gov_uk_dashboards/figures/line_chart.py +71 -71
  58. gov_uk_dashboards/figures/styles/__init__.py +1 -0
  59. gov_uk_dashboards/figures/styles/line_style.py +1 -0
  60. gov_uk_dashboards/formatting/human_readable.py +1 -0
  61. gov_uk_dashboards/formatting/number_formatting.py +14 -0
  62. gov_uk_dashboards/formatting/round_and_add_prefix_and_suffix.py +1 -0
  63. gov_uk_dashboards/formatting/text_functions.py +11 -0
  64. gov_uk_dashboards/lib/dap/dap_deployment.py +1 -0
  65. gov_uk_dashboards/lib/dap/get_dataframe_from_cds.py +96 -95
  66. gov_uk_dashboards/lib/datetime_functions/datetime_functions.py +118 -0
  67. gov_uk_dashboards/lib/download_functions/download_csv_with_headers.py +106 -83
  68. gov_uk_dashboards/lib/http_headers.py +10 -2
  69. gov_uk_dashboards/lib/logging.py +1 -0
  70. gov_uk_dashboards/lib/testing_functions/__init__.py +0 -0
  71. gov_uk_dashboards/lib/testing_functions/barchart_data_test_assertions.py +48 -0
  72. gov_uk_dashboards/lib/testing_functions/data_test_assertions.py +124 -0
  73. gov_uk_dashboards/lib/testing_functions/data_test_helper_functions.py +257 -0
  74. gov_uk_dashboards/lib/testing_functions/timeseries_data_test_assertions.py +29 -0
  75. gov_uk_dashboards/lib/warning_text_sensitive.py +44 -0
  76. gov_uk_dashboards/log_kpi.py +37 -0
  77. gov_uk_dashboards/symbols.py +1 -0
  78. gov_uk_dashboards/template.html +37 -0
  79. gov_uk_dashboards/template.py +14 -3
  80. {gov_uk_dashboards-21.2.2.dist-info → gov_uk_dashboards-26.26.0.dist-info}/METADATA +6 -7
  81. gov_uk_dashboards-26.26.0.dist-info/RECORD +128 -0
  82. {gov_uk_dashboards-21.2.2.dist-info → gov_uk_dashboards-26.26.0.dist-info}/WHEEL +1 -1
  83. gov_uk_dashboards/axes.py +0 -21
  84. gov_uk_dashboards/figures/chart_data.py +0 -24
  85. gov_uk_dashboards-21.2.2.dist-info/RECORD +0 -113
  86. {gov_uk_dashboards-21.2.2.dist-info → gov_uk_dashboards-26.26.0.dist-info}/licenses/LICENSE +0 -0
  87. {gov_uk_dashboards-21.2.2.dist-info → gov_uk_dashboards-26.26.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,978 @@
1
+ """Context card functions"""
2
+
3
+ from typing import Union
4
+ import calendar
5
+ from datetime import datetime
6
+ import polars as pl
7
+ from dateutil.relativedelta import relativedelta
8
+ from dash import html
9
+ from dash.development.base_component import Component
10
+ from gov_uk_dashboards.components.dash import heading2, paragraph
11
+ from gov_uk_dashboards.components.dash.details import details
12
+ from gov_uk_dashboards.formatting.number_formatting import add_commas, format_percentage
13
+ from gov_uk_dashboards.lib.datetime_functions.datetime_functions import (
14
+ convert_date_string_to_text_string,
15
+ )
16
+ from gov_uk_dashboards.lib.datetime_functions.datetime_functions import convert_date
17
+
18
+ from gov_uk_dashboards.constants import (
19
+ CHANGED_FROM_GAP_STYLE,
20
+ DATE_VALID,
21
+ LARGE_BOLD_FONT_STYLE,
22
+ LATEST_YEAR,
23
+ MEASURE,
24
+ METRIC_VALUE,
25
+ PERCENTAGE_CHANGE_FROM_PREV_YEAR,
26
+ PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR,
27
+ PREVIOUS_YEAR,
28
+ TWENTY_NINETEEN,
29
+ TWENTY_NINETEEN_VALUE,
30
+ VALUE,
31
+ YEAR_END,
32
+ )
33
+
34
+
35
+ def get_rolling_period_context_card(
36
+ df_function, measure, heading, text_for_main_number, main_number_units=None
37
+ ):
38
+ """
39
+ Build a context card showing the latest value of a measure with comparisons
40
+ to the previous two years.
41
+
42
+ The function fetches data via `df_function`, filters it for the given measure,
43
+ extracts the most recent year, and constructs an HTML card with a heading,
44
+ main value, and percentage change context.
45
+
46
+ Args:
47
+ df_function (Callable): A function that returns a Polars DataFrame
48
+ containing the measure of interest.
49
+ measure (str): The measure identifier to filter data by.
50
+ heading (str): The heading text for the context card.
51
+ text_for_main_number (str): Supporting explanatory text displayed under
52
+ the main number.
53
+ main_number_units (str, optional): Units to display after the main number
54
+ (e.g. "%", "days"). Defaults to None.
55
+
56
+ Returns:
57
+ html.Div: A GOV.UK-styled HTML div containing the context card.
58
+ """
59
+ df = df_function()
60
+ df_filtered = df.filter(pl.col(MEASURE) == measure)
61
+
62
+ latest_year_data = df_filtered.filter(
63
+ pl.col(DATE_VALID) == df_filtered[DATE_VALID].max()
64
+ )
65
+
66
+ context_card_data = {
67
+ VALUE: latest_year_data[VALUE][0],
68
+ DATE_VALID: latest_year_data[DATE_VALID][0],
69
+ PERCENTAGE_CHANGE_FROM_PREV_YEAR: format_percentage(
70
+ latest_year_data[PERCENTAGE_CHANGE_FROM_PREV_YEAR][0]
71
+ ),
72
+ PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR: format_percentage(
73
+ latest_year_data[PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR][0]
74
+ ),
75
+ }
76
+ return html.Div(
77
+ [
78
+ heading2(heading),
79
+ html.Div(
80
+ [
81
+ _get_rolling_period_data_content_for_x_years(
82
+ context_card_data, text_for_main_number, main_number_units
83
+ ),
84
+ ],
85
+ className="govuk-body",
86
+ ),
87
+ ],
88
+ className="context-card-grid-item",
89
+ )
90
+
91
+
92
+ def _get_rolling_period_data_content_for_x_years(
93
+ data, text_for_main_number, main_number_units
94
+ ):
95
+ formatted_latest_year = convert_date_string_to_text_string(
96
+ data[DATE_VALID], abbreviate_month=False, include_year=True
97
+ )
98
+
99
+ return html.Div(
100
+ [
101
+ html.Div(
102
+ f"{add_commas(data[VALUE], True)}"
103
+ f"{' ' + main_number_units if main_number_units else ''}",
104
+ className="govuk-body govuk-!-font-weight-bold",
105
+ style=LARGE_BOLD_FONT_STYLE | {"marginBottom": "0px"},
106
+ ),
107
+ html.P(
108
+ f"{text_for_main_number} ending {formatted_latest_year}",
109
+ className="govuk-body",
110
+ ),
111
+ get_changed_from_content(
112
+ calculated_percentage_change=format_percentage(
113
+ data[PERCENTAGE_CHANGE_FROM_PREV_YEAR]
114
+ ),
115
+ use_calculated_percentage_change=True,
116
+ increase_is_positive=True,
117
+ comparison_period_text="from same period previous year",
118
+ ),
119
+ html.Div(
120
+ [
121
+ get_changed_from_content(
122
+ calculated_percentage_change=format_percentage(
123
+ data[PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR]
124
+ ),
125
+ use_calculated_percentage_change=True,
126
+ increase_is_positive=True,
127
+ comparison_period_text="from same period a year earlier",
128
+ ),
129
+ ],
130
+ style=CHANGED_FROM_GAP_STYLE,
131
+ ),
132
+ ],
133
+ # className="context-card-grid-item"
134
+ )
135
+
136
+
137
+ # pylint: disable=too-many-arguments
138
+ # pylint: disable=too-many-locals
139
+ # pylint: disable=too-many-branches
140
+ # pylint: disable=too-many-positional-arguments
141
+ def get_changed_from_content(
142
+ current_value: Union[int, float] = None,
143
+ previous_value: Union[int, float] = None,
144
+ calculated_percentage_change=None,
145
+ increase_is_positive: bool = True,
146
+ comparison_period_text: str = "",
147
+ use_previous_value_rather_than_change: bool = False,
148
+ use_difference_in_weeks_days: bool = False,
149
+ use_calculated_percentage_change: bool = False,
150
+ use_number_rather_than_percentage: bool = False,
151
+ ) -> Component:
152
+ """
153
+ Build an HTML component describing how a metric has changed over time.
154
+
155
+ Depending on arguments, this function renders either:
156
+ - a percentage change between current and previous values,
157
+ - the absolute previous value,
158
+ - or the difference expressed in weeks/days.
159
+
160
+ Styling and arrows (up, down, right) are applied to indicate direction
161
+ and whether an increase is considered positive.
162
+
163
+ Args:
164
+ current_value (Union[int, float], optional): The current metric value. Defaults to None.
165
+ previous_value (Union[int, float], optional): The previous metric value. Defaults to None.
166
+ calculated_percentage_change (float, optional): Pre-calculated percentage change
167
+ (e.g. from a database). Used only if `use_calculated_percentage_change=True`.
168
+ Defaults to None.
169
+ increase_is_positive (bool, optional): Whether an increase is considered a positive change.
170
+ Defaults to True.
171
+ comparison_period_text (str, optional): Text describing the comparison period
172
+ (e.g. "last year", "previous quarter"). Defaults to "".
173
+ use_previous_value_rather_than_change (bool, optional): If True, show the actual previous
174
+ value instead of a calculated change. Defaults to False.
175
+ use_difference_in_weeks_days (bool, optional): If True, show the difference in
176
+ weeks/days (requires `current_value` and `previous_value` as day counts).
177
+ Defaults to False.
178
+ use_calculated_percentage_change (bool, optional): If True, use the supplied
179
+ `calculated_percentage_change` instead of computing it. Defaults to False.
180
+ use_number_rather_than_percentage (bool, optional): If True, display the change as
181
+ an absolute number instead of a percentage. Defaults to False.
182
+
183
+ Returns:
184
+ Component: An HTML component (Dash) representing the change, styled with GOV.UK classes.
185
+
186
+ Raises:
187
+ ValueError: If `use_previous_value_rather_than_change` and
188
+ `use_difference_in_weeks_days` are both True.
189
+ """
190
+ if use_previous_value_rather_than_change and use_difference_in_weeks_days:
191
+ raise ValueError(
192
+ "use_previous_value_rather_than_change and use_difference_in_weeks_days "
193
+ "both cannot be true"
194
+ )
195
+ if use_calculated_percentage_change:
196
+ percentage_change = calculated_percentage_change
197
+ else:
198
+ percentage_change = ((current_value - previous_value) / previous_value) * 100
199
+
200
+ if percentage_change is None:
201
+ return None
202
+ if percentage_change > 0:
203
+ colour = "green" if increase_is_positive else "red"
204
+ arrow_direction = "up"
205
+ prefix = "up"
206
+ elif percentage_change < 0:
207
+ colour = "red" if increase_is_positive else "green"
208
+ arrow_direction = "down"
209
+ prefix = "down"
210
+ else:
211
+ colour = "grey"
212
+ arrow_direction = "right" # this needs implementing in CSS
213
+ prefix = ""
214
+
215
+ box_style_class = f"govuk-tag govuk-tag--{colour} changed-from-box-formatting"
216
+ if percentage_change != 0:
217
+ box_style_class = (
218
+ box_style_class + f" changed-from-arrow_{arrow_direction}_{colour}"
219
+ )
220
+
221
+ content = []
222
+ if use_number_rather_than_percentage:
223
+ unit = ""
224
+ else:
225
+ unit = "%"
226
+
227
+ if use_previous_value_rather_than_change:
228
+ content.append(
229
+ html.Span(
230
+ f"{prefix} from " if percentage_change != 0 else "unchanged from ",
231
+ className="govuk-body-s govuk-!-margin-bottom-0 text-color-inherit"
232
+ + " text-no-transform", # text-no-transform prevents capitalisation,
233
+ # which is added from govuk-tag class
234
+ )
235
+ )
236
+ print("PREVIOUS VAKUE", previous_value)
237
+ content.append(
238
+ html.Span(
239
+ f"{previous_value}{unit}",
240
+ className="govuk-body-s govuk-!-margin-bottom-0 govuk-!-margin-right-1 "
241
+ + "changed-from-number-formatting",
242
+ )
243
+ )
244
+ elif use_difference_in_weeks_days:
245
+ difference_in_weeks_and_days = convert_days_to_weeks_and_days(
246
+ current_value - previous_value
247
+ )
248
+ if percentage_change > 0:
249
+ comparison_period_text_prefix = "slower than "
250
+ elif percentage_change < 0:
251
+ comparison_period_text_prefix = "faster than "
252
+ else:
253
+ comparison_period_text_prefix = "unchanged from "
254
+
255
+ comparison_period_text = comparison_period_text_prefix + comparison_period_text
256
+ if percentage_change != 0:
257
+ content.append(
258
+ html.Span(
259
+ f"{difference_in_weeks_and_days}",
260
+ className="govuk-body-s govuk-!-margin-bottom-0 govuk-!-margin-right-1 "
261
+ + "changed-from-number-formatting"
262
+ + " text-no-transform", # text-no-transform prevents capitalisation,
263
+ # which is added from govuk-tag class,
264
+ )
265
+ )
266
+ else:
267
+ content.append(
268
+ html.Span(
269
+ f"{prefix} " if percentage_change != 0 else "unchanged from ",
270
+ className="govuk-body-s govuk-!-margin-bottom-0 text-color-inherit"
271
+ + " text-no-transform", # text-no-transform prevents capitalisation,
272
+ # which is added from govuk-tag class
273
+ )
274
+ )
275
+ content.append(
276
+ html.Span(
277
+ f"{format_percentage(abs(percentage_change))}{unit}",
278
+ className="govuk-body-s govuk-!-margin-bottom-0 govuk-!-margin-right-1 "
279
+ + "changed-from-number-formatting",
280
+ )
281
+ )
282
+
283
+ content.append(
284
+ html.Span(
285
+ comparison_period_text,
286
+ className="govuk-body-s govuk-!-margin-bottom-0 text-color-inherit"
287
+ + " text-no-transform", # text-no-transform prevents capitalisation,
288
+ # which is added from govuk-tag class
289
+ )
290
+ )
291
+
292
+ return html.Div(
293
+ [
294
+ html.Div(
295
+ content,
296
+ className=box_style_class,
297
+ ),
298
+ ]
299
+ )
300
+
301
+
302
+ def convert_days_to_weeks_and_days(
303
+ total_days: int,
304
+ ) -> str:
305
+ """Converts a given number of total days into a string representing the equivalent number
306
+ of weeks and days.
307
+
308
+ Args:
309
+ total_days (int): The total number of days to convert. This value can be positive
310
+ or negative; the absolute value will be used.
311
+
312
+ Returns:
313
+ str: A string in the format 'x weeks and y days', where x is the number of weeks
314
+ and y is the number of days. The correct pluralization ('week'/'weeks' and
315
+ 'day'/'days') is applied based on the values."""
316
+ if not isinstance(total_days, int):
317
+ raise ValueError("total_days must be an int")
318
+ total_days = abs(total_days)
319
+ weeks = total_days // 7
320
+ days = total_days % 7
321
+ week_or_weeks = "week" if weeks == 1 else "weeks"
322
+ day_or_days = "day" if days == 1 else "days"
323
+ return f"{weeks} {week_or_weeks} and {days} {day_or_days}"
324
+
325
+
326
+ def get_data_for_context_card(
327
+ measure: str,
328
+ df: pl.DataFrame,
329
+ value_column: str = VALUE,
330
+ display_value_as_int: bool = False,
331
+ abbreviate_month: bool = True,
332
+ include_percentage_change: bool = False,
333
+ include_2019: bool = True,
334
+ data_expected_for_previous_year_and_previous_2years: bool = True,
335
+ ) -> dict:
336
+ # pylint: disable=too-many-locals
337
+ """
338
+ Fetches the latest year, previous year, 2019 data and optionally data from 2 years ago for a
339
+ specific measure.
340
+ Args:
341
+ measure (str): The measure for which data is to be fetched.
342
+ df (pl.DataFrame): The dataframe to fetch the measure from.
343
+ value_column (str): The name of the column to get the value for.
344
+ display_value_as_int (bool): Whether to display the value as an int. Defaults to False.
345
+ abbreviate_month (bool): Whether to abbreviate the month. Defaults to True.
346
+ include_percentage_change (bool): Whether to include percentage change from previous year
347
+ and 2 years ago. Defaults to False.
348
+ include_2019 (bool): Whether to include data from 2019. Defaults to True.
349
+ data_expected_for_previous_year_and_previous_2years (bool): Whether data is expexcted for
350
+ previous 2 years. Defaults to True. If True raises a value error if data not found,
351
+ otherwise returns None.
352
+ Returns:
353
+ dict: A dictionary containing the latest year and previous year, and optionally 2019 and
354
+ optionally 2 years ago data for the specified measure and percentage change.
355
+ """
356
+ df_measure = df.filter(df[MEASURE] == measure)
357
+ if display_value_as_int:
358
+ df_measure = df_measure.with_columns(df_measure[value_column].cast(pl.Int32))
359
+ latest_date = df_measure[DATE_VALID].max()
360
+ previous_year_date = get_a_previous_date(latest_date, "previous")
361
+ latest_data = get_latest_data_for_year(
362
+ df_measure,
363
+ latest_date,
364
+ value_column,
365
+ abbreviate_month=abbreviate_month,
366
+ data_expected=True,
367
+ include_percentage_change=include_percentage_change,
368
+ )
369
+ date_of_latest_data = latest_data[DATE_VALID]
370
+
371
+ previous_year_data = get_latest_data_for_year(
372
+ df_measure,
373
+ previous_year_date,
374
+ value_column,
375
+ abbreviate_month,
376
+ data_expected_for_previous_year_and_previous_2years,
377
+ include_percentage_change,
378
+ date_of_latest_data,
379
+ )
380
+
381
+ data_to_return = {
382
+ LATEST_YEAR: latest_data,
383
+ PREVIOUS_YEAR: previous_year_data,
384
+ }
385
+
386
+ if include_2019:
387
+ twenty_nineteen_data = df_measure.get_column(TWENTY_NINETEEN_VALUE)[0]
388
+ data_to_return = {
389
+ **data_to_return,
390
+ TWENTY_NINETEEN: {METRIC_VALUE: twenty_nineteen_data},
391
+ }
392
+
393
+ return data_to_return
394
+
395
+
396
+ def get_a_previous_date(
397
+ date_str: str, desired_year="previous", return_string=True
398
+ ) -> Union[str, datetime]:
399
+ "desired_year can either be previous or a year int eg. 2024"
400
+ date = datetime.strptime(date_str, "%Y-%m-%d")
401
+ if desired_year == "previous":
402
+ desired_year = date.year - 1
403
+ if desired_year == "two_previous":
404
+ desired_year = date.year - 2
405
+ try:
406
+ new_date = date.replace(year=desired_year)
407
+ if calendar.isleap(desired_year) and date.month == 2 and date.day == 28:
408
+ new_date = datetime(desired_year, 2, 29)
409
+ except ValueError:
410
+ # This handles the case where the original date is February 29th in a leap year
411
+ # Since the resulting year won't be a leap year, we subtract one year and set the date to
412
+ # February 28th
413
+ new_date = date.replace(year=desired_year, month=2, day=28)
414
+ # Format the new date back into a string
415
+
416
+ if return_string:
417
+ new_date_str = new_date.strftime("%Y-%m-%d")
418
+ return new_date_str
419
+ return new_date
420
+
421
+
422
+ # if include_data_from_2_years_ago:
423
+
424
+
425
+ def get_latest_data_for_year(
426
+ df_measure: pl.DataFrame,
427
+ date: str,
428
+ value_column: str,
429
+ abbreviate_month: bool,
430
+ data_expected: bool = True,
431
+ include_percentage_change: bool = False,
432
+ date_of_latest_data=None,
433
+ ) -> dict:
434
+ """
435
+ Retrieve the most recent metric value for a given reference date.
436
+
437
+ This function looks up data for the specified `date` in the provided
438
+ DataFrame. If data exists, it returns the latest entry (or the entry
439
+ corresponding to one year before `date_of_latest_data`, if provided).
440
+ Optionally, percentage changes from previous years can be included.
441
+
442
+ Args:
443
+ df_measure (pl.DataFrame): Source DataFrame containing the data.
444
+ date (str): The reference date (format: YYYY-MM-DD) to search for.
445
+ value_column (str): Column name containing the metric value.
446
+ abbreviate_month (bool): Whether to abbreviate month names in output text.
447
+ data_expected (bool, optional): If True, raises a ValueError when no
448
+ matching data is found. If False, returns None values instead.
449
+ Defaults to True.
450
+ include_percentage_change (bool, optional): If True, includes percentage
451
+ change from the previous year and two years prior. Defaults to False.
452
+ date_of_latest_data (str, optional): A specific "latest data" date
453
+ (format: YYYY-MM-DD). If provided, returns the value from one year
454
+ before this date instead of the latest available.
455
+
456
+ Returns:
457
+ dict: A dictionary containing:
458
+ - YEAR_END (str): Formatted year-end date.
459
+ - METRIC_VALUE: The metric value (or None if not found).
460
+ - DATE_VALID (str): The raw date corresponding to the result.
461
+ - PERCENTAGE_CHANGE_FROM_PREV_YEAR (optional): Change vs. previous year.
462
+ - PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR (optional): Change vs. two years prior.
463
+
464
+ Raises:
465
+ ValueError: If no matching data is found when `data_expected=True`.
466
+ """
467
+ year_data = df_measure.filter(df_measure[DATE_VALID] == date)
468
+
469
+ if year_data.height == 0:
470
+ if data_expected:
471
+ raise ValueError(f"No data found for the date: {date}")
472
+ return {
473
+ YEAR_END: convert_date_string_to_text_string(
474
+ date,
475
+ include_day_of_month=False,
476
+ abbreviate_month=abbreviate_month,
477
+ ),
478
+ METRIC_VALUE: None,
479
+ DATE_VALID: date,
480
+ **(
481
+ {
482
+ PERCENTAGE_CHANGE_FROM_PREV_YEAR: None,
483
+ PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR: None,
484
+ }
485
+ if include_percentage_change
486
+ else {}
487
+ ),
488
+ }
489
+ if date_of_latest_data:
490
+ date_of_latest_data_dt = datetime.strptime(date_of_latest_data, "%Y-%m-%d")
491
+
492
+ one_year_before = date_of_latest_data_dt - relativedelta(years=1)
493
+
494
+ # If the original date was Feb 28 and the new year is a leap year, adjust to Feb 29
495
+ if (
496
+ date_of_latest_data_dt.month == 2
497
+ and date_of_latest_data_dt.day == 28
498
+ and one_year_before.year % 4 == 0
499
+ and (one_year_before.year % 100 != 0 or one_year_before.year % 400 == 0)
500
+ ):
501
+ one_year_before = one_year_before.replace(day=29)
502
+
503
+ one_year_before_str = one_year_before.strftime("%Y-%m-%d")
504
+ target_date_data = year_data.filter(pl.col(DATE_VALID) == one_year_before_str)
505
+ if target_date_data.height == 0:
506
+ raise ValueError(
507
+ f"No data found for the date {date} with date {one_year_before}"
508
+ )
509
+ else:
510
+ target_date_data = year_data.sort(DATE_VALID, descending=True).head(1)
511
+
512
+ output = {
513
+ YEAR_END: convert_date_string_to_text_string(
514
+ target_date_data.get_column(DATE_VALID)[0],
515
+ include_day_of_month=False,
516
+ abbreviate_month=abbreviate_month,
517
+ ),
518
+ METRIC_VALUE: target_date_data.get_column(value_column)[0],
519
+ DATE_VALID: target_date_data.get_column(DATE_VALID)[0],
520
+ }
521
+
522
+ if include_percentage_change:
523
+ output[PERCENTAGE_CHANGE_FROM_PREV_YEAR] = target_date_data.get_column(
524
+ PERCENTAGE_CHANGE_FROM_PREV_YEAR
525
+ )[0]
526
+
527
+ output[PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR] = target_date_data.get_column(
528
+ PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR
529
+ )[0]
530
+ return output
531
+
532
+
533
+ # pylint: disable=too-many-instance-attributes
534
+ # pylint: disable=too-few-public-methods
535
+ class ContextCard:
536
+ """Context card class"""
537
+
538
+ def __init__(
539
+ self,
540
+ df: pl.DataFrame,
541
+ measure: str,
542
+ title: str,
543
+ date_prefix: str,
544
+ units: str = None,
545
+ headline_figure_is_percentage: bool = False,
546
+ additional_text_and_position: tuple[str, int] = None,
547
+ date_format: str = "%d %b %Y",
548
+ use_previous_value_rather_than_change: bool = False, # rename????
549
+ use_difference_in_weeks_days: bool = False,
550
+ increase_is_positive: bool = True,
551
+ use_number_for_change_rather_than_percentage: bool = False, # rename????
552
+ details_summary_and_text: tuple[str, str] = None,
553
+ ):
554
+ """
555
+ A compact “context card” for a time-series measure that renders:
556
+ • a headline figure,
557
+ • a date label,
558
+ • up to two comparison tags (previous year, two years ago),
559
+ • optional title, units, inline note, and a collapsible details section.
560
+
561
+ The component supports three comparison display modes:
562
+ 1) percent change (default),
563
+ 2) show the comparison period’s value instead of percent change,
564
+ 3) show the time difference in weeks/days (for duration measures).
565
+
566
+ ----------
567
+ Parameters
568
+ ----------
569
+ df : pl.DataFrame
570
+ Input data for one or more measures across dates. Expected columns:
571
+ - MEASURE (categorical/str): identifies the measure.
572
+ - DATE_VALID (date or ISO string): observation date.
573
+ - VALUE (numeric): observed value for the measure.
574
+
575
+ Two data shapes are supported by the current implementation:
576
+
577
+ A) “Precomputed change” mode:
578
+ If the dataframe contains a column literally named
579
+ "Percentage change from prev year", `_filter_df` keeps only the latest
580
+ DATE_VALID row for the selected measure. In `_get_changed_from_content`,
581
+ percent-change tags are read directly from:
582
+ - PERCENTAGE_CHANGE_FROM_PREV_YEAR
583
+ - PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR
584
+
585
+ B) “Computed change” mode:
586
+ Otherwise, three dates are selected for the measure:
587
+ latest_date,
588
+ previous_date = get_a_previous_date(latest_date),
589
+ year_earlier_date = get_a_previous_date(previous_date),
590
+ then the frame is filtered to those dates and sorted descending.
591
+ Percent changes are computed from the numeric VALUEs in positions:
592
+ [0] latest, [1] previous year, [2] two years ago
593
+
594
+ measure : str
595
+ The measure name to filter from `df[MEASURE]`.
596
+
597
+ title : str
598
+ Optional title shown at the top of the card. If falsy, no title is rendered.
599
+
600
+ date_prefix : str
601
+ Text prefixed before the current date (e.g., "Data to", "Week ending").
602
+
603
+ units : str, optional
604
+ Units label inserted beneath the headline figure.
605
+
606
+ headline_figure_is_percentage : bool, default False
607
+ Controls headline formatting and some tag formatting.
608
+ If True, the headline uses `format_percentage(abs(value))` and appends "%".
609
+ If False, the headline uses `add_commas(value, remove_decimal_places=True)`.
610
+
611
+ additional_text_and_position : tuple[str, int], optional
612
+ An extra paragraph inserted into the content at the given index:
613
+ (text, position_index).
614
+
615
+ date_format : str, default "%d %b %Y"
616
+ Format string for the latest DATE_VALID (e.g., "05 Jan 2026").
617
+
618
+ use_previous_value_rather_than_change : bool, default False
619
+ When True, comparison tags show the comparison period’s VALUE instead of a
620
+ percent change (e.g., "up from 1,234 from previous year").
621
+ NOTE: This cannot be True at the same time as `use_difference_in_weeks_days`.
622
+
623
+ use_difference_in_weeks_days : bool, default False
624
+ When True, comparison tags show the difference between the current VALUE and
625
+ the comparison VALUE converted to weeks/days via `convert_days_to_weeks_and_days`.
626
+ Tag prefix becomes:
627
+ - "longer than ..." if percent-change is positive,
628
+ - "shorter than ..." if percent-change is negative,
629
+ - "unchanged from ..." if zero.
630
+ Requires both current and comparison values to be present.
631
+ NOTE: This cannot be True at the same time as `use_previous_value_rather_than_change`.
632
+
633
+ increase_is_positive : bool, default True
634
+ Controls the semantic mapping of change to colour/arrow:
635
+ - If True: increase → green ↑, decrease → red ↓
636
+ - If False: increase → red ↓, decrease → green ↑
637
+ Zero change renders a neutral (grey/right) style.
638
+
639
+ use_number_for_change_rather_than_percentage : bool, default False
640
+ Only affects the “previous-value” tag mode:
641
+ - If True and the comparison period is "previous year", the label text is
642
+ changed from "previous year" to "in previous year".
643
+ (No other behaviour is currently toggled by this flag in the present code.)
644
+
645
+ details_summary_and_text : tuple[str, str], optional
646
+ Renders a collapsible details section at the bottom with
647
+ (summary_text, details_body).
648
+
649
+ ----------
650
+ Behaviour summary
651
+ ----------
652
+ • The dataframe is filtered to `measure` and reduced to either:
653
+ - one latest row (if the literal column "Percentage change from prev year" exists), or
654
+ - three rows for the latest + two prior comparison dates (computed via
655
+ `get_a_previous_date`).
656
+
657
+ • Headline figure:
658
+ - If `use_difference_in_weeks_days` is True: `convert_days_to_weeks_and_days(VALUE[0])`
659
+ - Else if `headline_figure_is_percentage` is True:
660
+ `format_percentage(abs(VALUE[0])) + "%"`
661
+ - Else: `add_commas(VALUE[0], remove_decimal_places=True)`
662
+
663
+ • Date label:
664
+ Uses the latest DATE_VALID formatted via `convert_date(..., "%Y-%m-%d", date_format)`.
665
+
666
+ • Comparison tags:
667
+ Attempts to render up to two tags: vs "previous year" and vs "two years ago".
668
+ Tags are omitted if the required inputs are missing.
669
+
670
+ • Mutual exclusivity:
671
+ `use_previous_value_rather_than_change` and `use_difference_in_weeks_days`
672
+ cannot both be True (ValueError raised when building tag content).
673
+
674
+ • Layout:
675
+ Content order is:
676
+ [title?], headline, [units?], (date_prefix + date), comparison tags, [details?]
677
+ `additional_text_and_position` inserts an extra paragraph at the specified index.
678
+ """
679
+
680
+ self.measure = measure
681
+ self.title = title
682
+ self.units = units
683
+ self.headline_figure_is_percentage = headline_figure_is_percentage
684
+ self.additional_text_and_position = additional_text_and_position
685
+ self.date_prefix = date_prefix
686
+ self.date_format = date_format
687
+ self.use_previous_value_rather_than_change = (
688
+ use_previous_value_rather_than_change
689
+ )
690
+ self.use_difference_in_weeks_days = use_difference_in_weeks_days
691
+ self.increase_is_positive = increase_is_positive
692
+ self.use_number_for_change_rather_than_percentage = (
693
+ use_number_for_change_rather_than_percentage
694
+ )
695
+ self.df = self._filter_df(df)
696
+ self.headline_figure = self._get_headline_figure()
697
+ self.current_date = self._get_current_date()
698
+ self.details_summary_and_text = details_summary_and_text
699
+
700
+ def __call__(self):
701
+ card_content = [
702
+ html.Div(
703
+ self.headline_figure,
704
+ className="govuk-body govuk-!-font-weight-bold",
705
+ style=LARGE_BOLD_FONT_STYLE | {"marginBottom": "0px"},
706
+ ),
707
+ paragraph(f"{self.date_prefix} {self.current_date}"),
708
+ self._get_changed_from_content(),
709
+ ]
710
+ if self.title:
711
+ card_content.insert(0, heading2(self.title))
712
+ if self.additional_text_and_position:
713
+ card_content.insert(
714
+ self.additional_text_and_position[1],
715
+ paragraph(self.additional_text_and_position[0]),
716
+ )
717
+ if self.details_summary_and_text:
718
+ card_content.append(
719
+ html.Div(
720
+ [
721
+ details(
722
+ self.details_summary_and_text[0],
723
+ self.details_summary_and_text[1],
724
+ )
725
+ ],
726
+ style={"marginTop": "40px"}, # from h repo,
727
+ )
728
+ )
729
+ if self.units:
730
+ card_content.insert(
731
+ 1, html.Div(paragraph(self.units), style={"marginTop": "-15px"})
732
+ )
733
+ card_for_display = html.Div(
734
+ card_content,
735
+ className="context-card-grid-item",
736
+ )
737
+ return card_for_display
738
+
739
+ def _filter_df(self, df):
740
+ df_for_measure = df.filter(df[MEASURE] == self.measure)
741
+ latest_date = df_for_measure.select(pl.col(DATE_VALID).max()).item()
742
+
743
+ if "Percentage change from prev year" in df_for_measure.columns:
744
+ return df_for_measure.filter(pl.col(DATE_VALID) == latest_date)
745
+
746
+ previous_date = get_a_previous_date(latest_date)
747
+ year_earlier_date = get_a_previous_date(previous_date)
748
+
749
+ if not self.use_difference_in_weeks_days and not (
750
+ self.use_number_for_change_rather_than_percentage
751
+ and self.use_previous_value_rather_than_change
752
+ ):
753
+ df_for_measure = df_for_measure.with_columns(pl.col(VALUE).cast(pl.Int64))
754
+
755
+ return df_for_measure.filter(
756
+ pl.col(DATE_VALID).is_in([latest_date, previous_date, year_earlier_date])
757
+ ).sort(DATE_VALID, descending=True)
758
+
759
+ def _get_headline_figure(self):
760
+ unit = "%" if self.headline_figure_is_percentage else ""
761
+
762
+ return (
763
+ (
764
+ f"{str(format_percentage(abs(self.df[VALUE][0])))+unit}"
765
+ if self.headline_figure_is_percentage
766
+ else add_commas(self.df[VALUE][0], remove_decimal_places=True) + unit
767
+ )
768
+ if not self.use_difference_in_weeks_days
769
+ else convert_days_to_weeks_and_days(self.df[VALUE][0])
770
+ )
771
+
772
+ # pylint: disable=too-many-statements
773
+ def _get_current_date(self):
774
+ current_date = self.df[DATE_VALID][0]
775
+ return convert_date(current_date, "%Y-%m-%d", self.date_format)
776
+
777
+ def _get_changed_from_content(self):
778
+ if (
779
+ self.use_previous_value_rather_than_change
780
+ and self.use_difference_in_weeks_days
781
+ ):
782
+ raise ValueError(
783
+ "use_previous_value_rather_than_change and use_difference_in_weeks_days "
784
+ "both cannot be true"
785
+ )
786
+
787
+ # ---- helpers ----
788
+ def _scalar(x):
789
+ """Polars -> python scalar (handles Series/Expr-ish values)."""
790
+ if x is None:
791
+ return None
792
+ if isinstance(x, pl.Series):
793
+ return x.item() if len(x) else None
794
+ return x
795
+
796
+ def _build_tag(
797
+ *,
798
+ percentage_change: float | None,
799
+ comparison_period_text: str,
800
+ current_value: float | None = None,
801
+ previous_value: float | None = None,
802
+ ):
803
+ if percentage_change is None:
804
+ return None
805
+ increase_is_positive = getattr(self, "increase_is_positive", True)
806
+
807
+ if percentage_change > 0:
808
+ colour = "green" if increase_is_positive else "red"
809
+ arrow_direction = "up"
810
+ prefix = "up"
811
+ elif percentage_change < 0:
812
+ colour = "red" if increase_is_positive else "green"
813
+ arrow_direction = "down"
814
+ prefix = "down"
815
+ else:
816
+ colour = "grey"
817
+ arrow_direction = "right"
818
+ prefix = ""
819
+
820
+ box_style_class = (
821
+ f"govuk-tag govuk-tag--{colour} changed-from-box-formatting"
822
+ )
823
+ if percentage_change != 0:
824
+ box_style_class += f" changed-from-arrow_{arrow_direction}_{colour}"
825
+
826
+ unit = (
827
+ "%"
828
+ if (
829
+ self.headline_figure_is_percentage
830
+ and self.use_previous_value_rather_than_change
831
+ )
832
+ or not self.use_previous_value_rather_than_change
833
+ else ""
834
+ )
835
+
836
+ content = []
837
+
838
+ # Option A: show previous value rather than % change
839
+ if self.use_previous_value_rather_than_change:
840
+ if previous_value is None:
841
+ return None
842
+ if self.headline_figure_is_percentage:
843
+ previous_value = str(format_percentage(abs(previous_value)))
844
+ else:
845
+ previous_value = add_commas(
846
+ previous_value, remove_decimal_places=True
847
+ )
848
+ content.append(
849
+ html.Span(
850
+ (
851
+ f"{prefix} from "
852
+ if percentage_change != 0
853
+ else "unchanged from "
854
+ ),
855
+ className="govuk-body-s govuk-!-margin-bottom-0 text-color-inherit "
856
+ "text-no-transform",
857
+ )
858
+ )
859
+ content.append(
860
+ html.Span(
861
+ f"{previous_value}{unit}",
862
+ className="govuk-body-s govuk-!-margin-bottom-0 govuk-!-margin-right-1 "
863
+ "changed-from-number-formatting",
864
+ )
865
+ )
866
+ if self.use_number_for_change_rather_than_percentage:
867
+ if comparison_period_text == "previous year":
868
+ comparison_period_text = "in " + comparison_period_text
869
+
870
+ # Option B: show difference in weeks/days (requires values)
871
+ elif self.use_difference_in_weeks_days:
872
+ if current_value is None or previous_value is None:
873
+ return None
874
+
875
+ difference_in_weeks_and_days = convert_days_to_weeks_and_days(
876
+ current_value - previous_value
877
+ )
878
+
879
+ if percentage_change > 0:
880
+ comparison_period_text_prefix = "longer than "
881
+ elif percentage_change < 0:
882
+ comparison_period_text_prefix = "shorter than "
883
+ else:
884
+ comparison_period_text_prefix = "unchanged from "
885
+
886
+ content.append(
887
+ html.Span(
888
+ f"{difference_in_weeks_and_days}",
889
+ className="govuk-body-s govuk-!-margin-bottom-0 govuk-!-margin-right-1 "
890
+ "changed-from-number-formatting text-no-transform",
891
+ )
892
+ )
893
+
894
+ comparison_period_text = (
895
+ comparison_period_text_prefix + comparison_period_text
896
+ )
897
+
898
+ # Option C: default (% change)
899
+ else:
900
+ content.append(
901
+ html.Span(
902
+ f"{prefix} " if percentage_change != 0 else "unchanged from ",
903
+ className="govuk-body-s govuk-!-margin-bottom-0 text-color-inherit "
904
+ "text-no-transform",
905
+ )
906
+ )
907
+ content.append(
908
+ html.Span(
909
+ f"{format_percentage(abs(percentage_change))}{unit}",
910
+ className="govuk-body-s govuk-!-margin-bottom-0 govuk-!-margin-right-1 "
911
+ "changed-from-number-formatting",
912
+ )
913
+ )
914
+ comparison_period_text = "from " + comparison_period_text
915
+
916
+ content.append(
917
+ html.Span(
918
+ comparison_period_text,
919
+ className="govuk-body-s govuk-!-margin-bottom-0 text-color-inherit "
920
+ "text-no-transform",
921
+ )
922
+ )
923
+
924
+ return html.Div(
925
+ [html.Div(content, className=box_style_class)],
926
+ )
927
+
928
+ # ---- compute changes ----
929
+ current_value = None
930
+ prev_year_value = None
931
+ two_year_value = None
932
+
933
+ if self.df.height == 1:
934
+ # Using provided columns (likely already computed upstream)
935
+ pct_year = _scalar(self.df[PERCENTAGE_CHANGE_FROM_PREV_YEAR])
936
+ pct_2yr = _scalar(self.df[PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR])
937
+
938
+ # If you want to support previous-value/weeks-days in height==1 mode,
939
+ # you'd need extra columns for those values. Otherwise tags will return None for those
940
+ # modes.
941
+ else:
942
+ # Expect order: [latest, prev_year, two_year]
943
+ current_value = _scalar(self.df[VALUE][0])
944
+ prev_year_value = _scalar(self.df[VALUE][1])
945
+ two_year_value = _scalar(self.df[VALUE][2])
946
+
947
+ pct_year = (
948
+ None
949
+ if not prev_year_value
950
+ else ((current_value - prev_year_value) / prev_year_value) * 100
951
+ )
952
+ pct_2yr = (
953
+ None
954
+ if not two_year_value
955
+ else ((current_value - two_year_value) / two_year_value) * 100
956
+ )
957
+
958
+ # ---- build two tags ----
959
+ tag_last_year = _build_tag(
960
+ percentage_change=pct_year,
961
+ comparison_period_text="previous year",
962
+ current_value=current_value,
963
+ previous_value=prev_year_value,
964
+ )
965
+
966
+ tag_two_years = _build_tag(
967
+ percentage_change=pct_2yr,
968
+ comparison_period_text="two years ago",
969
+ current_value=current_value,
970
+ previous_value=two_year_value,
971
+ )
972
+ styled_tag_two_years = (
973
+ html.Div(tag_two_years, style=CHANGED_FROM_GAP_STYLE)
974
+ if tag_two_years is not None
975
+ else None
976
+ )
977
+ tags = [t for t in (tag_last_year, styled_tag_two_years) if t is not None]
978
+ return html.Div(tags) if tags else None