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.
- gov_uk_dashboards/__init__.py +1 -1
- gov_uk_dashboards/assets/__init__.py +1 -0
- gov_uk_dashboards/assets/dashboard.css +177 -0
- gov_uk_dashboards/assets/download-map.js +39 -0
- gov_uk_dashboards/assets/get_assets_folder.py +1 -0
- gov_uk_dashboards/assets/images/CHASE_icon.svg +17 -0
- gov_uk_dashboards/assets/images/explore_data_logo.svg +87 -0
- gov_uk_dashboards/assets/index.html +3 -0
- gov_uk_dashboards/assets/register_maps +15 -0
- gov_uk_dashboards/assets/scripts.js +4 -0
- gov_uk_dashboards/colours.py +23 -0
- gov_uk_dashboards/components/__init__.py +1 -0
- gov_uk_dashboards/components/dash/__init__.py +1 -3
- gov_uk_dashboards/components/dash/apply_and_reset_filters_buttons.py +1 -0
- gov_uk_dashboards/components/dash/banners.py +21 -0
- gov_uk_dashboards/components/dash/card.py +1 -0
- gov_uk_dashboards/components/dash/card_full_width.py +1 -0
- gov_uk_dashboards/components/dash/collapsible_panel.py +1 -0
- gov_uk_dashboards/components/dash/comparison_la_filter_button.py +1 -0
- gov_uk_dashboards/components/dash/context_banner.py +2 -1
- gov_uk_dashboards/components/dash/context_card.py +978 -0
- gov_uk_dashboards/components/dash/data_quality_banner.py +91 -0
- gov_uk_dashboards/components/dash/details.py +1 -0
- gov_uk_dashboards/components/dash/download_button.py +22 -36
- gov_uk_dashboards/components/dash/filter_panel.py +1 -0
- gov_uk_dashboards/components/dash/footer.py +81 -27
- gov_uk_dashboards/components/dash/graph.py +1 -0
- gov_uk_dashboards/components/dash/green_button.py +25 -0
- gov_uk_dashboards/components/dash/header.py +62 -9
- gov_uk_dashboards/components/dash/heading.py +8 -5
- gov_uk_dashboards/components/dash/home_page_link_button.py +9 -8
- gov_uk_dashboards/components/dash/html_list.py +1 -0
- gov_uk_dashboards/components/dash/key_value_pair.py +1 -0
- gov_uk_dashboards/components/dash/main_content.py +25 -2
- gov_uk_dashboards/components/dash/notification_banner.py +9 -5
- gov_uk_dashboards/components/dash/paragraph.py +1 -0
- gov_uk_dashboards/components/dash/phase_banner.py +7 -4
- gov_uk_dashboards/components/dash/row_component.py +1 -0
- gov_uk_dashboards/components/dash/table.py +62 -124
- gov_uk_dashboards/components/dash/tooltip.py +2 -1
- gov_uk_dashboards/components/dash/tooltip_title.py +2 -1
- gov_uk_dashboards/components/dash/visualisation_commentary.py +1 -0
- gov_uk_dashboards/components/dash/visualisation_title.py +1 -0
- gov_uk_dashboards/components/dash/warning_text.py +1 -0
- gov_uk_dashboards/components/helpers/display_chart_or_table_with_header.py +61 -12
- gov_uk_dashboards/components/helpers/get_chart_for_download.py +18 -15
- gov_uk_dashboards/components/helpers/plotting_helper_functions.py +0 -1
- gov_uk_dashboards/components/leaflet/leaflet_choropleth_map.py +108 -31
- gov_uk_dashboards/components/plotly/captioned_figure.py +6 -3
- gov_uk_dashboards/components/plotly/enums.py +2 -0
- gov_uk_dashboards/components/plotly/stacked_barchart.py +166 -73
- gov_uk_dashboards/components/plotly/time_series_chart.py +159 -20
- gov_uk_dashboards/constants.py +35 -1
- gov_uk_dashboards/figures/__init__.py +4 -2
- gov_uk_dashboards/figures/enums/__init__.py +1 -0
- gov_uk_dashboards/figures/enums/dash_patterns.py +1 -0
- gov_uk_dashboards/figures/line_chart.py +71 -71
- gov_uk_dashboards/figures/styles/__init__.py +1 -0
- gov_uk_dashboards/figures/styles/line_style.py +1 -0
- gov_uk_dashboards/formatting/human_readable.py +1 -0
- gov_uk_dashboards/formatting/number_formatting.py +14 -0
- gov_uk_dashboards/formatting/round_and_add_prefix_and_suffix.py +1 -0
- gov_uk_dashboards/formatting/text_functions.py +11 -0
- gov_uk_dashboards/lib/dap/dap_deployment.py +1 -0
- gov_uk_dashboards/lib/dap/get_dataframe_from_cds.py +96 -95
- gov_uk_dashboards/lib/datetime_functions/datetime_functions.py +118 -0
- gov_uk_dashboards/lib/download_functions/download_csv_with_headers.py +106 -83
- gov_uk_dashboards/lib/http_headers.py +10 -2
- gov_uk_dashboards/lib/logging.py +1 -0
- gov_uk_dashboards/lib/testing_functions/__init__.py +0 -0
- gov_uk_dashboards/lib/testing_functions/barchart_data_test_assertions.py +48 -0
- gov_uk_dashboards/lib/testing_functions/data_test_assertions.py +124 -0
- gov_uk_dashboards/lib/testing_functions/data_test_helper_functions.py +257 -0
- gov_uk_dashboards/lib/testing_functions/timeseries_data_test_assertions.py +29 -0
- gov_uk_dashboards/lib/warning_text_sensitive.py +44 -0
- gov_uk_dashboards/log_kpi.py +37 -0
- gov_uk_dashboards/symbols.py +1 -0
- gov_uk_dashboards/template.html +37 -0
- gov_uk_dashboards/template.py +14 -3
- {gov_uk_dashboards-21.2.2.dist-info → gov_uk_dashboards-26.26.0.dist-info}/METADATA +6 -7
- gov_uk_dashboards-26.26.0.dist-info/RECORD +128 -0
- {gov_uk_dashboards-21.2.2.dist-info → gov_uk_dashboards-26.26.0.dist-info}/WHEEL +1 -1
- gov_uk_dashboards/axes.py +0 -21
- gov_uk_dashboards/figures/chart_data.py +0 -24
- gov_uk_dashboards-21.2.2.dist-info/RECORD +0 -113
- {gov_uk_dashboards-21.2.2.dist-info → gov_uk_dashboards-26.26.0.dist-info}/licenses/LICENSE +0 -0
- {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
|