From 356cb1092314fa158a6cb213ff896970b531ecbe Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 5 Feb 2025 18:40:00 +1100 Subject: [PATCH 1/2] 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"), From 5d0fb3d96de6998b94280dfb194ecf636b688830 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Sun, 9 Feb 2025 08:58:16 +1100 Subject: [PATCH 2/2] docs: add iso 8601 week year Add docs explaining how ISO 8601 week dates are calculated, and give examples of when they differ slightly from what one might naively expect looking at a Gregorian calendar. Signed-off-by: JP-Ellis --- src/fava/help/filters.md | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/fava/help/filters.md b/src/fava/help/filters.md index 3ad718ad7..15ccacf4d 100644 --- a/src/fava/help/filters.md +++ b/src/fava/help/filters.md @@ -4,7 +4,7 @@ With the text inputs at the top right of the page, you can filter the entries that are displayed in Fava's reports. If you use multiple filters, the entries matching all of them will be selected. -### Time +## Time Filter entries by their date. You can specify dates and intervals like years, quarters, months, weeks, and days (for example `2015`, `2012-Q1`, `2010-10`, @@ -20,18 +20,35 @@ current year up to today, or `year-1 - year` for all entries of the last and current year. To prevent subtraction, use parentheses: `(month)-10` refers to the 10th of this month, whereas `month-10` would be 10 months ago. -**Week number** Week number of the year (Monday as the first day of the week) as -a decimal number. All days in a new year preceding the first Monday are -considered to be in week 0. +### ISO 8601 Week dates -### Account +The week-based calendar follows +[ISO 8601 week-numbering system](https://en.wikipedia.org/wiki/ISO_week_date) +with each week starting on Mondays and the first week of the ISO week-based year +being the first week that has the majority of its days in January. Equivalently, +it is also the week to containing January 4th. + +Some examples where the ISO week-based calendar differs from the Gregorian +calendar: + +- Week 1 of 2025 starts on Monday 30 December 2024 and ends on Sunday 5 January + 2025\. As a result, 30 December 2024 belongs to the year 2025 of the ISO + week-based calendar, despite being in the year 2024 of the Gregorian calendar. +- Week 53 of 2020 ends on Sunday 3 January 2021. As a result, 3 January 2021 + belongs to the year 2020 of the ISO week-based calendar, despite being in the + year 2021 of the Gregorian calendar. + +Most years of ISO week-based calendars have 52 weeks, but as there are slightly +more than 52 weeks in a year, some years contain a 53rd week. + +## Account Filter entries by account, matching any entry this account is part of. The filter can be an account name, either the full account name or a component of the account name, or a regular expression matching the account name, e.g. `.*Company.*` to filter for all that contain `Company`. -### Filter by tag, link, payee and other metadata +## Filter by tag, link, payee and other metadata This final filter allows you to filter entries by various attributes.