gov-uk-dashboards 26.23.0__py3-none-any.whl → 26.25.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.
@@ -7,13 +7,13 @@ import polars as pl
7
7
  from dateutil.relativedelta import relativedelta
8
8
  from dash import html
9
9
  from dash.development.base_component import Component
10
- from gov_uk_dashboards.components.dash import (
11
- heading2,
12
- )
13
- from gov_uk_dashboards.formatting.number_formatting import add_commas
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
14
13
  from gov_uk_dashboards.lib.datetime_functions.datetime_functions import (
15
14
  convert_date_string_to_text_string,
16
15
  )
16
+ from gov_uk_dashboards.lib.datetime_functions.datetime_functions import convert_date
17
17
 
18
18
  from gov_uk_dashboards.constants import (
19
19
  CHANGED_FROM_GAP_STYLE,
@@ -24,7 +24,6 @@ from gov_uk_dashboards.constants import (
24
24
  METRIC_VALUE,
25
25
  PERCENTAGE_CHANGE_FROM_PREV_YEAR,
26
26
  PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR,
27
- PREVIOUS_2YEAR,
28
27
  PREVIOUS_YEAR,
29
28
  TWENTY_NINETEEN,
30
29
  TWENTY_NINETEEN_VALUE,
@@ -67,12 +66,12 @@ def get_rolling_period_context_card(
67
66
  context_card_data = {
68
67
  VALUE: latest_year_data[VALUE][0],
69
68
  DATE_VALID: latest_year_data[DATE_VALID][0],
70
- PERCENTAGE_CHANGE_FROM_PREV_YEAR: latest_year_data[
71
- PERCENTAGE_CHANGE_FROM_PREV_YEAR
72
- ][0],
73
- PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR: latest_year_data[
74
- PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR
75
- ][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
+ ),
76
75
  }
77
76
  return html.Div(
78
77
  [
@@ -97,20 +96,6 @@ def _get_rolling_period_data_content_for_x_years(
97
96
  data[DATE_VALID], abbreviate_month=False, include_year=True
98
97
  )
99
98
 
100
- current_date = data[DATE_VALID]
101
- previous_year_datetime = get_a_previous_date(current_date, "previous", False)
102
- two_years_ago_datetime = get_a_previous_date(current_date, "two_previous", False)
103
- formatted_year_ago = convert_date_string_to_text_string(
104
- previous_year_datetime.strftime("%Y-%m-%d"),
105
- abbreviate_month=False,
106
- include_year=True,
107
- )
108
- formatted_two_years_ago = convert_date_string_to_text_string(
109
- two_years_ago_datetime.strftime("%Y-%m-%d"),
110
- abbreviate_month=False,
111
- include_year=True,
112
- )
113
-
114
99
  return html.Div(
115
100
  [
116
101
  html.Div(
@@ -124,21 +109,22 @@ def _get_rolling_period_data_content_for_x_years(
124
109
  className="govuk-body",
125
110
  ),
126
111
  get_changed_from_content(
127
- calculated_percentage_change=data[PERCENTAGE_CHANGE_FROM_PREV_YEAR],
112
+ calculated_percentage_change=format_percentage(
113
+ data[PERCENTAGE_CHANGE_FROM_PREV_YEAR]
114
+ ),
128
115
  use_calculated_percentage_change=True,
129
116
  increase_is_positive=True,
130
- comparison_period_text=f"from same rolling period ending {formatted_year_ago}",
117
+ comparison_period_text="from same period previous year",
131
118
  ),
132
119
  html.Div(
133
120
  [
134
121
  get_changed_from_content(
135
- calculated_percentage_change=data[
136
- PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR
137
- ],
122
+ calculated_percentage_change=format_percentage(
123
+ data[PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR]
124
+ ),
138
125
  use_calculated_percentage_change=True,
139
126
  increase_is_positive=True,
140
- comparison_period_text="from same rolling period "
141
- f"ending {formatted_two_years_ago}",
127
+ comparison_period_text="from same period a year earlier",
142
128
  ),
143
129
  ],
144
130
  style=CHANGED_FROM_GAP_STYLE,
@@ -160,7 +146,6 @@ def get_changed_from_content(
160
146
  comparison_period_text: str = "",
161
147
  use_previous_value_rather_than_change: bool = False,
162
148
  use_difference_in_weeks_days: bool = False,
163
- percentage_change_rounding: int = 1,
164
149
  use_calculated_percentage_change: bool = False,
165
150
  use_number_rather_than_percentage: bool = False,
166
151
  ) -> Component:
@@ -190,8 +175,6 @@ def get_changed_from_content(
190
175
  use_difference_in_weeks_days (bool, optional): If True, show the difference in
191
176
  weeks/days (requires `current_value` and `previous_value` as day counts).
192
177
  Defaults to False.
193
- percentage_change_rounding (int, optional): Decimal places to round percentage change.
194
- Defaults to 1.
195
178
  use_calculated_percentage_change (bool, optional): If True, use the supplied
196
179
  `calculated_percentage_change` instead of computing it. Defaults to False.
197
180
  use_number_rather_than_percentage (bool, optional): If True, display the change as
@@ -206,7 +189,7 @@ def get_changed_from_content(
206
189
  """
207
190
  if use_previous_value_rather_than_change and use_difference_in_weeks_days:
208
191
  raise ValueError(
209
- "use_previous_value_rather_than_percentage_change and use_difference_in_weeks_days "
192
+ "use_previous_value_rather_than_change and use_difference_in_weeks_days "
210
193
  "both cannot be true"
211
194
  )
212
195
  if use_calculated_percentage_change:
@@ -250,6 +233,7 @@ def get_changed_from_content(
250
233
  # which is added from govuk-tag class
251
234
  )
252
235
  )
236
+ print("PREVIOUS VAKUE", previous_value)
253
237
  content.append(
254
238
  html.Span(
255
239
  f"{previous_value}{unit}",
@@ -290,7 +274,7 @@ def get_changed_from_content(
290
274
  )
291
275
  content.append(
292
276
  html.Span(
293
- f"{round(abs(percentage_change), percentage_change_rounding)}{unit}",
277
+ f"{format_percentage(abs(percentage_change))}{unit}",
294
278
  className="govuk-body-s govuk-!-margin-bottom-0 govuk-!-margin-right-1 "
295
279
  + "changed-from-number-formatting",
296
280
  )
@@ -343,7 +327,6 @@ def get_data_for_context_card(
343
327
  measure: str,
344
328
  df: pl.DataFrame,
345
329
  value_column: str = VALUE,
346
- include_data_from_2_years_ago: bool = False,
347
330
  display_value_as_int: bool = False,
348
331
  abbreviate_month: bool = True,
349
332
  include_percentage_change: bool = False,
@@ -358,8 +341,6 @@ def get_data_for_context_card(
358
341
  measure (str): The measure for which data is to be fetched.
359
342
  df (pl.DataFrame): The dataframe to fetch the measure from.
360
343
  value_column (str): The name of the column to get the value for.
361
- include_data_from_2_years_ago (bool): Whether to include data from 2 years ago. Defaults
362
- to False.
363
344
  display_value_as_int (bool): Whether to display the value as an int. Defaults to False.
364
345
  abbreviate_month (bool): Whether to abbreviate the month. Defaults to True.
365
346
  include_percentage_change (bool): Whether to include percentage change from previous year
@@ -409,19 +390,6 @@ def get_data_for_context_card(
409
390
  TWENTY_NINETEEN: {METRIC_VALUE: twenty_nineteen_data},
410
391
  }
411
392
 
412
- if include_data_from_2_years_ago:
413
- date_2_years_ago = get_a_previous_date(previous_year_date, "previous")
414
- data_from_2_years_ago = get_latest_data_for_year(
415
- df_measure,
416
- date_2_years_ago,
417
- value_column,
418
- abbreviate_month,
419
- data_expected_for_previous_year_and_previous_2years,
420
- include_percentage_change,
421
- previous_year_date,
422
- )
423
- data_to_return = {**data_to_return, PREVIOUS_2YEAR: data_from_2_years_ago}
424
-
425
393
  return data_to_return
426
394
 
427
395
 
@@ -451,6 +419,9 @@ def get_a_previous_date(
451
419
  return new_date
452
420
 
453
421
 
422
+ # if include_data_from_2_years_ago:
423
+
424
+
454
425
  def get_latest_data_for_year(
455
426
  df_measure: pl.DataFrame,
456
427
  date: str,
@@ -557,3 +528,451 @@ def get_latest_data_for_year(
557
528
  PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR
558
529
  )[0]
559
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
+ - "slower than ..." if percent-change is positive,
628
+ - "faster 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 = "slower than "
881
+ elif percentage_change < 0:
882
+ comparison_period_text_prefix = "faster 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
@@ -29,3 +29,10 @@ def add_commas(number: int, remove_decimal_places: bool = False) -> str:
29
29
  if remove_decimal_places:
30
30
  return f"{number:,.0f}"
31
31
  return f"{number:,}"
32
+
33
+
34
+ def format_percentage(percentage):
35
+ """Formats percentages to remove decimal place on numbers 10 or more"""
36
+ if abs(percentage) < 10:
37
+ return percentage
38
+ return int(percentage)
@@ -7,6 +7,91 @@ import re
7
7
  from typing import Optional
8
8
 
9
9
 
10
+ def convert_date(
11
+ date_input,
12
+ input_format=None,
13
+ output_format=None,
14
+ convert_to_datetime=False,
15
+ abbreviate_jun_jul=False,
16
+ ):
17
+ """
18
+ Convert a date input (string, date, or datetime) into either a datetime object or a formatted
19
+ string.
20
+
21
+ Behaviour:
22
+ - If `date_input` is a string, `input_format` must be provided and is used with
23
+ `datetime.strptime`.
24
+ - If `convert_to_datetime` is True, returns a `datetime.datetime` (at midnight if the input was
25
+ a `date`), and `output_format` is ignored.
26
+ - If `convert_to_datetime` is False, `output_format` must be provided and is used with
27
+ `strftime`.
28
+
29
+ Month abbreviation tweak:
30
+ - If `abbreviate_jun_jul` is False (default), and your `output_format` produces abbreviated
31
+ months (e.g., via `%b`), any standalone "Jun" or "Jul" tokens in the formatted output are
32
+ expanded to "June" / "July".
33
+ - If `abbreviate_jun_jul` is True, the output is left exactly as produced by `strftime`.
34
+
35
+ Args:
36
+ date_input (str | datetime.datetime | datetime.date):
37
+ The date to convert.
38
+ input_format (str | None):
39
+ Format string for parsing `date_input` when it is a string. Required if `date_input` is
40
+ a string.
41
+ output_format (str | None):
42
+ Format string used when returning a string. Required if `convert_to_datetime` is False.
43
+ convert_to_datetime (bool):
44
+ If True, return a `datetime.datetime`. If False, return a formatted string.
45
+ abbreviate_jun_jul (bool):
46
+ If False, expand "Jun"/"Jul" to "June"/"July" in the final formatted string.
47
+
48
+ Returns:
49
+ datetime.datetime | str:
50
+ A datetime object if `convert_to_datetime` is True, otherwise a formatted string.
51
+
52
+ Raises:
53
+ ValueError:
54
+ If `date_input` is a string and `input_format` is None, or if parsing fails.
55
+ If `convert_to_datetime` is False and `output_format` is None.
56
+ TypeError:
57
+ If `date_input` is not a string, date, or datetime.
58
+ """
59
+ # Parse / normalise to datetime
60
+ if isinstance(date_input, str):
61
+ if input_format is None:
62
+ raise ValueError(
63
+ "input_format must be provided when date_input is a string"
64
+ )
65
+ try:
66
+ dt = datetime.strptime(date_input, input_format)
67
+ except ValueError as e:
68
+ raise ValueError(
69
+ f"Could not parse date_input={date_input!r} with input_format={input_format!r}"
70
+ ) from e
71
+ elif isinstance(date_input, datetime):
72
+ dt = date_input
73
+ elif isinstance(date_input, date):
74
+ dt = datetime.combine(date_input, datetime.min.time())
75
+ else:
76
+ raise TypeError("date_input must be a str, datetime.datetime, or datetime.date")
77
+
78
+ if convert_to_datetime:
79
+ return dt
80
+
81
+ if output_format is None:
82
+ raise ValueError(
83
+ "output_format must be provided when convert_to_datetime is False"
84
+ )
85
+
86
+ output_str = dt.strftime(output_format)
87
+
88
+ if not abbreviate_jun_jul:
89
+ output_str = re.sub(r"\bJun\b", "June", output_str)
90
+ output_str = re.sub(r"\bJul\b", "July", output_str)
91
+
92
+ return output_str
93
+
94
+
10
95
  def convert_date_string_to_text_string(
11
96
  date_str: str,
12
97
  date_format: Optional[str] = "%Y-%m-%d",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gov_uk_dashboards
3
- Version: 26.23.0
3
+ Version: 26.25.0
4
4
  Summary: Provides access to functionality common to creating a data dashboard.
5
5
  Author: Department for Levelling Up, Housing and Communities
6
6
  Description-Content-Type: text/markdown
@@ -49,7 +49,7 @@ gov_uk_dashboards/components/dash/card_full_width.py,sha256=KnpkB3krgLxp1MoqEZaz
49
49
  gov_uk_dashboards/components/dash/collapsible_panel.py,sha256=6A90xiTLU0b5e4jaWcisTbsf_82nRBvGapbyETd5m70,1192
50
50
  gov_uk_dashboards/components/dash/comparison_la_filter_button.py,sha256=u53Vmuz4MJ0J8RSVXGEFvDgzXIp_-JpDDE4o9VijoDw,671
51
51
  gov_uk_dashboards/components/dash/context_banner.py,sha256=gy0qKhseiM1oUyRS1X8_HrJuah-WaWN6yeZOHoGzAl4,1009
52
- gov_uk_dashboards/components/dash/context_card.py,sha256=3CzgSEYeyXoKvs5kOBPlp9LDaBq7tgfelKkG-aKzwCE,21987
52
+ gov_uk_dashboards/components/dash/context_card.py,sha256=lE_srpi4HW06HzRKZFdWigLnLfZvgKqN6uOTXbgPPfI,39481
53
53
  gov_uk_dashboards/components/dash/dashboard_container.py,sha256=KC2isR0NShxUYCl_pzDEAS4WK5pFrLMp4m2We3AqiwM,512
54
54
  gov_uk_dashboards/components/dash/data_quality_banner.py,sha256=VLuAndN9xVFuMWFLqoPlolAJt08H9_rQa5x7jqde_RQ,2945
55
55
  gov_uk_dashboards/components/dash/details.py,sha256=ENkSBKd6XZTq0X7Jn0-zPaO1qR4F7zPNX-RLAXKVvAI,1107
@@ -99,7 +99,7 @@ gov_uk_dashboards/figures/styles/__init__.py,sha256=n4FMyPKfc1I0_SyWo3mklMiW7R38
99
99
  gov_uk_dashboards/figures/styles/line_style.py,sha256=L_1b_lDuU2LStewT47Ge5EhOslOPxSoA87p-SrKgJE0,560
100
100
  gov_uk_dashboards/formatting/__init__.py,sha256=bQk9_OEubUhuTRQjXUs4ZItgSY7yyDpwQcLauxf11Tk,460
101
101
  gov_uk_dashboards/formatting/human_readable.py,sha256=FYGnNHwE7btWpbi3ZNCkswbSCjhwJi6Ym-H4vMHwOlQ,2438
102
- gov_uk_dashboards/formatting/number_formatting.py,sha256=YrGpnTF1dQcL_U0IxWWpa8yabye-X6Rsy-eiJoyUILM,1121
102
+ gov_uk_dashboards/formatting/number_formatting.py,sha256=CakgGq3oevCNnpqciWYvqFKOFmRdXGUaBnP2oatFjcs,1316
103
103
  gov_uk_dashboards/formatting/round_and_add_prefix_and_suffix.py,sha256=nxD7HVMjs0_HJA0d-WqJvKu-IwZZla1khyr0YCYUr40,1457
104
104
  gov_uk_dashboards/formatting/rounding.py,sha256=Em1yri_j18IYHbZ64d3bhVKX-XEllRSM9FzAUo1o6fU,968
105
105
  gov_uk_dashboards/formatting/text_functions.py,sha256=vHstpUo16LnsSk-rzwFDNP1SIAy2npIo-4ims1xNa64,306
@@ -112,7 +112,7 @@ gov_uk_dashboards/lib/dap/__init__.py,sha256=bYga8kJuf9TGkfpnd16SInrD1FcN8iPn4Sz
112
112
  gov_uk_dashboards/lib/dap/dap_deployment.py,sha256=IM639YgCnYUHjK1lFRhljw611R9jhKhNcm6O4lSCNHI,2314
113
113
  gov_uk_dashboards/lib/dap/get_dataframe_from_cds.py,sha256=m9kxumoe9_XPvbmZFrClGGkE_XGiA7b2-wnUV75HRz8,4090
114
114
  gov_uk_dashboards/lib/datetime_functions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
115
- gov_uk_dashboards/lib/datetime_functions/datetime_functions.py,sha256=snCqQo80IgorOwHbac4Mn31qjhZlMBI74-_Tq02mPJ0,13317
115
+ gov_uk_dashboards/lib/datetime_functions/datetime_functions.py,sha256=lOJ7MnhCeVhLd45gSZS4uEI8BuaUITXHd1CQCFjIxfg,16567
116
116
  gov_uk_dashboards/lib/download_functions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
117
117
  gov_uk_dashboards/lib/download_functions/convert_fig_to_image_and_download.py,sha256=-dkYbpTL6txftFfAZW-vfW_O9efb6Dse9WJ9MM6mAqw,534
118
118
  gov_uk_dashboards/lib/download_functions/download_csv_with_headers.py,sha256=0gvQ81OA_g93w83zoXr9IqJ7_GeYgrIMCexusdmdwIM,5081
@@ -121,8 +121,8 @@ gov_uk_dashboards/lib/testing_functions/barchart_data_test_assertions.py,sha256=
121
121
  gov_uk_dashboards/lib/testing_functions/data_test_assertions.py,sha256=1Icy7NZXw6hI_-7QAUaHqjjPKViY0jcFEASlk6Ul2tg,4162
122
122
  gov_uk_dashboards/lib/testing_functions/data_test_helper_functions.py,sha256=JoptoXJORuIdrhweVlp9fhX-ew2GtIkctIotpHWBQDQ,9635
123
123
  gov_uk_dashboards/lib/testing_functions/timeseries_data_test_assertions.py,sha256=SJa3WLgFqf7Y1W0sxsOew_-4m3689cjynRjoCyBK5WQ,1240
124
- gov_uk_dashboards-26.23.0.dist-info/licenses/LICENSE,sha256=GDiD7Y2Gx7JucPV1JfVySJeah-qiSyBPdpJ6RHCEHTc,1126
125
- gov_uk_dashboards-26.23.0.dist-info/METADATA,sha256=WFFsOytCRQkFGsmcgXBepjbYpuBijAPT4KmpYikqQzc,5903
126
- gov_uk_dashboards-26.23.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
127
- gov_uk_dashboards-26.23.0.dist-info/top_level.txt,sha256=gPaN1P3-H3Rgi2me6tt-fX_cxo19CZfA4PjlZPjGRpo,18
128
- gov_uk_dashboards-26.23.0.dist-info/RECORD,,
124
+ gov_uk_dashboards-26.25.0.dist-info/licenses/LICENSE,sha256=GDiD7Y2Gx7JucPV1JfVySJeah-qiSyBPdpJ6RHCEHTc,1126
125
+ gov_uk_dashboards-26.25.0.dist-info/METADATA,sha256=qgMJmWFtHDfatFmtJM-VrDNyf3943tRAsdteFwTMD_g,5903
126
+ gov_uk_dashboards-26.25.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
127
+ gov_uk_dashboards-26.25.0.dist-info/top_level.txt,sha256=gPaN1P3-H3Rgi2me6tt-fX_cxo19CZfA4PjlZPjGRpo,18
128
+ gov_uk_dashboards-26.25.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5