From 2b2a28e72e47b4e9fa73567398748b6d3f14d377 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 5 Feb 2025 18:40:00 +1100 Subject: [PATCH] fix: iso week usage Fava appears to use ISO 8601 weeks (e.g. `2025-W01`), but unfortunately does so incorrectly due to a mismatch between ISO 8601 and Gregorian calendar week definitions. ISO 8601 defines precisely how to handle weeks, namely, that the first week of a year is the week that contains 4 January. This definition results in some occasional mismatches with the Gregorian calendar such as: - The first week of 2025 starts on 30 December 2024 - 3 January 2021 belongs to Week 53 of 2020 Fortunately, the fix is straightforward as `strftime` provide[^posix]: - `%G` for the week-based year - `%V` for the week number of the year (with no 'week 0' as `%W` can return). This change will ensure that there is no ambiguity on weeks that straddle the new year, though at the cost of introducing occasional off-by-one errors compared to previously generated data. [^posix]: See [POSIX.1-2024](https://pubs.opengroup.org/onlinepubs/9799919799/functions/strftime.html) Signed-off-by: JP-Ellis --- frontend/src/format.ts | 4 ++-- frontend/test/format.test.ts | 8 ++++---- src/fava/util/date.py | 8 ++++---- tests/test_util_date.py | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/frontend/src/format.ts b/frontend/src/format.ts index 5c23420d3..e6e464db3 100644 --- a/frontend/src/format.ts +++ b/frontend/src/format.ts @@ -51,7 +51,7 @@ export const dateFormat: Record = { quarter: (date) => `${date.getUTCFullYear().toString()}Q${(Math.floor(date.getUTCMonth() / 3) + 1).toString()}`, month: utcFormat("%b %Y"), - week: utcFormat("%YW%W"), + week: utcFormat("%GW%V"), day, }; @@ -61,7 +61,7 @@ export const timeFilterDateFormat: Record = { quarter: (date) => `${date.getUTCFullYear().toString()}-Q${(Math.floor(date.getUTCMonth() / 3) + 1).toString()}`, month: utcFormat("%Y-%m"), - week: utcFormat("%Y-W%W"), + week: utcFormat("%G-W%V"), day, }; diff --git a/frontend/test/format.test.ts b/frontend/test/format.test.ts index 65e034c49..2a78829b2 100644 --- a/frontend/test/format.test.ts +++ b/frontend/test/format.test.ts @@ -37,8 +37,8 @@ test("time filter date formatting", () => { assert.is(day(date), "2020-03-20"); assert.is(month(janfirst), "2020-01"); assert.is(month(date), "2020-03"); - assert.is(week(janfirst), "2020-W00"); - assert.is(week(date), "2020-W11"); + assert.is(week(janfirst), "2020-W01"); + assert.is(week(date), "2020-W12"); assert.is(quarter(janfirst), "2020-Q1"); assert.is(quarter(date), "2020-Q1"); assert.is(year(janfirst), "2020"); @@ -54,8 +54,8 @@ test("human-readable date formatting", () => { assert.is(day(date), "2020-03-20"); assert.is(month(janfirst), "Jan 2020"); assert.is(month(date), "Mar 2020"); - assert.is(week(janfirst), "2020W00"); - assert.is(week(date), "2020W11"); + assert.is(week(janfirst), "2020W01"); + assert.is(week(date), "2020W12"); assert.is(quarter(janfirst), "2020Q1"); assert.is(quarter(date), "2020Q1"); assert.is(year(janfirst), "2020"); diff --git a/src/fava/util/date.py b/src/fava/util/date.py index 3aa26deae..604c492ad 100644 --- a/src/fava/util/date.py +++ b/src/fava/util/date.py @@ -125,7 +125,7 @@ def format_date(self, date: datetime.date) -> str: if self is Interval.MONTH: return date.strftime("%b %Y") if self is Interval.WEEK: - return date.strftime("%YW%W") + return date.strftime("%GW%V") return date.strftime("%Y-%m-%d") def format_date_filter(self, date: datetime.date) -> str: @@ -137,7 +137,7 @@ def format_date_filter(self, date: datetime.date) -> str: if self is Interval.MONTH: return date.strftime("%Y-%m") if self is Interval.WEEK: - return date.strftime("%Y-W%W") + return date.strftime("%G-W%V") return date.strftime("%Y-%m-%d") @@ -313,7 +313,7 @@ def substitute( if interval == "week": string = string.replace( complete_match, - (today + timedelta(offset * 7)).strftime("%Y-W%W"), + (today + timedelta(offset * 7)).strftime("%G-W%V"), ) if interval == "day": string = string.replace( @@ -384,7 +384,7 @@ def parse_date( # noqa: PLR0911 if match: year, week = map(int, match.group(1, 2)) start = ( - datetime.datetime.strptime(f"{year}-W{week}-1", "%Y-W%W-%w") + datetime.datetime.strptime(f"{year}-W{week}-1", "%G-W%V-%w") .replace(tzinfo=datetime.timezone.utc) .date() ) diff --git a/tests/test_util_date.py b/tests/test_util_date.py index ddb7caaba..4005ae575 100644 --- a/tests/test_util_date.py +++ b/tests/test_util_date.py @@ -149,7 +149,7 @@ def test_interval_tuples() -> None: ("(month+24)", "2018-06"), ("week", "2016-W25"), ("week+20", "2016-W45"), - ("week+2000", "2054-W42"), + ("week+2000", "2054-W43"), ("day", "2016-06-24"), ("day+20", "2016-07-14"), ], @@ -219,8 +219,8 @@ def test_fiscal_substitute( ("2000-01-01", "2001-01-01", " 2000 "), ("2010-10-01", "2010-11-01", "2010-10"), ("2000-01-03", "2000-01-04", "2000-01-03"), - ("2015-01-05", "2015-01-12", "2015-W01"), - ("2025-01-06", "2025-01-13", "2025-W01"), + ("2014-12-29", "2015-01-05", "2015-W01"), + ("2024-12-30", "2025-01-06", "2025-W01"), ("2015-04-01", "2015-07-01", "2015-Q2"), ("2014-01-01", "2016-01-01", "2014 to 2015"), ("2014-01-01", "2016-01-01", "2014-2015"),