From 6fba4971be5b3fddf9a05d228c81c24cb9672ba8 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 5 Feb 2025 18:31:49 +1100 Subject: [PATCH] feat: add fortnightly interval In some countries (such as Australia), it is very common for wages to be paid on a fortnightly basis, that is, every 2 weeks. As a result, a number of other expenses are also paid on a fortnightly basis. This commit extends the `Interval` enum by adding a `FORTNIGHT` option. I have added a test for that, and have updated the translations where possible (not that other than for English and French, the other translations may be suboptimal). Resolves: #1939 Signed-off-by: JP-Ellis --- frontend/src/format.ts | 12 ++ frontend/src/lib/interval.ts | 36 ++++-- src/fava/core/budgets.py | 1 + src/fava/help/budgets.md | 9 +- .../translations/bg/LC_MESSAGES/messages.po | 5 +- .../translations/ca/LC_MESSAGES/messages.po | 5 +- .../translations/de/LC_MESSAGES/messages.po | 5 +- .../translations/fa/LC_MESSAGES/messages.po | 5 +- .../translations/fr/LC_MESSAGES/messages.po | 5 +- .../translations/nl/LC_MESSAGES/messages.po | 5 +- .../pt_BR/LC_MESSAGES/messages.po | 5 +- .../translations/ru/LC_MESSAGES/messages.po | 5 +- .../translations/sk/LC_MESSAGES/messages.po | 5 +- .../translations/sv/LC_MESSAGES/messages.po | 5 +- .../translations/uk/LC_MESSAGES/messages.po | 5 +- .../translations/zh/LC_MESSAGES/messages.po | 5 +- .../zh_Hant_TW/LC_MESSAGES/messages.po | 5 +- src/fava/util/date.py | 121 +++++++++++++++--- tests/test_core_budgets.py | 12 ++ tests/test_util_date.py | 13 +- 20 files changed, 220 insertions(+), 49 deletions(-) diff --git a/frontend/src/format.ts b/frontend/src/format.ts index 5c23420d3..c18367e5e 100644 --- a/frontend/src/format.ts +++ b/frontend/src/format.ts @@ -51,6 +51,12 @@ export const dateFormat: Record = { quarter: (date) => `${date.getUTCFullYear().toString()}Q${(Math.floor(date.getUTCMonth() / 3) + 1).toString()}`, month: utcFormat("%b %Y"), + fortnight: (date) => { + const year = Number.parseInt(utcFormat("%G")(date)); + const week = Number.parseInt(utcFormat("%V")(date)); + const [w1, w2] = week % 2 === 0 ? [week - 1, week] : [week, week + 1]; + return `${year.toString()}W${w1.toString().padStart(2, "0")}/${w2.toString().padStart(2, "0")}`; + }, week: utcFormat("%YW%W"), day, }; @@ -61,6 +67,12 @@ export const timeFilterDateFormat: Record = { quarter: (date) => `${date.getUTCFullYear().toString()}-Q${(Math.floor(date.getUTCMonth() / 3) + 1).toString()}`, month: utcFormat("%Y-%m"), + fortnight: (date) => { + const year = Number.parseInt(utcFormat("%G")(date)); + const week = Number.parseInt(utcFormat("%V")(date)); + const [w1, w2] = week % 2 === 0 ? [week - 1, week] : [week, week + 1]; + return `${year.toString()}-W${w1.toString().padStart(2, "0")}/${w2.toString().padStart(2, "0")}`; + }, week: utcFormat("%Y-W%W"), day, }; diff --git a/frontend/src/lib/interval.ts b/frontend/src/lib/interval.ts index 440c7cc51..458b700b0 100644 --- a/frontend/src/lib/interval.ts +++ b/frontend/src/lib/interval.ts @@ -1,28 +1,36 @@ import { _ } from "../i18n"; -export type Interval = "year" | "quarter" | "month" | "week" | "day"; +export type Interval = + | "year" + | "quarter" + | "month" + | "fortnight" + | "week" + | "day"; export const DEFAULT_INTERVAL: Interval = "month"; export const INTERVALS: Interval[] = [ - "year", - "quarter", - "month", - "week", - "day", + "year", + "quarter", + "month", + "fortnight", + "week", + "day", ]; export function getInterval(s: string | null): Interval { - return INTERVALS.includes(s as Interval) ? (s as Interval) : DEFAULT_INTERVAL; + return INTERVALS.includes(s as Interval) ? (s as Interval) : DEFAULT_INTERVAL; } /** Get the translateable label for an interval. */ export function intervalLabel(s: Interval): string { - return { - year: _("Yearly"), - quarter: _("Quarterly"), - month: _("Monthly"), - week: _("Weekly"), - day: _("Daily"), - }[s]; + return { + year: _("Yearly"), + quarter: _("Quarterly"), + month: _("Monthly"), + fortnight: _("Fortnightly"), + week: _("Weekly"), + day: _("Daily"), + }[s]; } diff --git a/src/fava/core/budgets.py b/src/fava/core/budgets.py index ce8638d91..d3b09393a 100644 --- a/src/fava/core/budgets.py +++ b/src/fava/core/budgets.py @@ -103,6 +103,7 @@ def parse_budgets( interval_map = { "daily": Interval.DAY, "weekly": Interval.WEEK, + "fortnightly": Interval.FORTNIGHT, "monthly": Interval.MONTH, "quarterly": Interval.QUARTER, "yearly": Interval.YEAR, diff --git a/src/fava/help/budgets.md b/src/fava/help/budgets.md index a67fa59e0..1c790713b 100644 --- a/src/fava/help/budgets.md +++ b/src/fava/help/budgets.md @@ -6,6 +6,7 @@ Beancount file:
@@ -13,10 +14,10 @@ Beancount file: If budgets are specified, Fava's reports and charts will display remaining budgets and related information. -The budget directives can be specified `daily`, `weekly`, `monthly`, `quarterly` -and `yearly`. The specified budget is valid until another budget directive for -the account is specified. The budget is broken down to a daily budget, and -summed up for a range of dates as needed. +The budget directives can be specified `daily`, `weekly`, `fortnightly`, +`monthly`, `quarterly` and `yearly`. The specified budget is valid until another +budget directive for the account is specified. The budget is broken down to a +daily budget, and summed up for a range of dates as needed. This makes the budgets very flexible, allowing for a monthly budget, being taken over by a weekly budget, and so on. diff --git a/src/fava/translations/bg/LC_MESSAGES/messages.po b/src/fava/translations/bg/LC_MESSAGES/messages.po index 01c07f8f9..621b29831 100644 --- a/src/fava/translations/bg/LC_MESSAGES/messages.po +++ b/src/fava/translations/bg/LC_MESSAGES/messages.po @@ -431,6 +431,10 @@ msgstr "Тримесечен" msgid "Monthly" msgstr "Месечен" +#: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 +msgid "Fortnightly" +msgstr "На всеки две седмици" + #: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 msgid "Weekly" msgstr "Седмичен" @@ -592,4 +596,3 @@ msgstr "Изтриване..." #: frontend/src/sidebar/AsideContents.svelte:60 msgid "Add Journal Entry" msgstr "" - diff --git a/src/fava/translations/ca/LC_MESSAGES/messages.po b/src/fava/translations/ca/LC_MESSAGES/messages.po index c1e6e883c..2a5271bde 100644 --- a/src/fava/translations/ca/LC_MESSAGES/messages.po +++ b/src/fava/translations/ca/LC_MESSAGES/messages.po @@ -431,6 +431,10 @@ msgstr "Per trimestre" msgid "Monthly" msgstr "Per mes" +#: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 +msgid "Fortnightly" +msgstr "Per quinzena" + #: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 msgid "Weekly" msgstr "Per setmana" @@ -592,4 +596,3 @@ msgstr "S'està suprimint..." #: frontend/src/sidebar/AsideContents.svelte:60 msgid "Add Journal Entry" msgstr "" - diff --git a/src/fava/translations/de/LC_MESSAGES/messages.po b/src/fava/translations/de/LC_MESSAGES/messages.po index 5fc04681c..b29d363dc 100644 --- a/src/fava/translations/de/LC_MESSAGES/messages.po +++ b/src/fava/translations/de/LC_MESSAGES/messages.po @@ -431,6 +431,10 @@ msgstr "Quartalsweise" msgid "Monthly" msgstr "Monatlich" +#: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 +msgid "Fortnightly" +msgstr "Zweiwöchentlich" + #: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 msgid "Weekly" msgstr "Wöchentlich" @@ -592,4 +596,3 @@ msgstr "Wird gelöscht..." #: frontend/src/sidebar/AsideContents.svelte:60 msgid "Add Journal Entry" msgstr "Journal-Eintrag hinzufügen" - diff --git a/src/fava/translations/fa/LC_MESSAGES/messages.po b/src/fava/translations/fa/LC_MESSAGES/messages.po index d15520d90..4a3018094 100644 --- a/src/fava/translations/fa/LC_MESSAGES/messages.po +++ b/src/fava/translations/fa/LC_MESSAGES/messages.po @@ -431,6 +431,10 @@ msgstr "سه‌ماهه" msgid "Monthly" msgstr "ماهانه" +#: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 +msgid "Fortnightly" +msgstr "هر دو هفته یکبار" + #: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 msgid "Weekly" msgstr "هفتگی" @@ -592,4 +596,3 @@ msgstr "" #: frontend/src/sidebar/AsideContents.svelte:60 msgid "Add Journal Entry" msgstr "" - diff --git a/src/fava/translations/fr/LC_MESSAGES/messages.po b/src/fava/translations/fr/LC_MESSAGES/messages.po index fd6ef82db..0237d38b2 100644 --- a/src/fava/translations/fr/LC_MESSAGES/messages.po +++ b/src/fava/translations/fr/LC_MESSAGES/messages.po @@ -431,6 +431,10 @@ msgstr "Trimestriel" msgid "Monthly" msgstr "Mensuel" +#: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 +msgid "Fortnightly" +msgstr "Bimensuel" + #: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 msgid "Weekly" msgstr "Hebdomadaire" @@ -592,4 +596,3 @@ msgstr "Suppression..." #: frontend/src/sidebar/AsideContents.svelte:60 msgid "Add Journal Entry" msgstr "" - diff --git a/src/fava/translations/nl/LC_MESSAGES/messages.po b/src/fava/translations/nl/LC_MESSAGES/messages.po index d0d327e6e..d86737ac4 100644 --- a/src/fava/translations/nl/LC_MESSAGES/messages.po +++ b/src/fava/translations/nl/LC_MESSAGES/messages.po @@ -431,6 +431,10 @@ msgstr "Per kwartaal" msgid "Monthly" msgstr "Maandelijks" +#: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 +msgid "Fortnightly" +msgstr "Tweewekelijks" + #: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 msgid "Weekly" msgstr "Wekelijks" @@ -592,4 +596,3 @@ msgstr "" #: frontend/src/sidebar/AsideContents.svelte:60 msgid "Add Journal Entry" msgstr "" - diff --git a/src/fava/translations/pt_BR/LC_MESSAGES/messages.po b/src/fava/translations/pt_BR/LC_MESSAGES/messages.po index c6fdc9120..6c54c3586 100644 --- a/src/fava/translations/pt_BR/LC_MESSAGES/messages.po +++ b/src/fava/translations/pt_BR/LC_MESSAGES/messages.po @@ -432,6 +432,10 @@ msgstr "Trimestral" msgid "Monthly" msgstr "Mensal" +#: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 +msgid "Fortnightly" +msgstr "Quinzenal" + #: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 msgid "Weekly" msgstr "Semanal" @@ -593,4 +597,3 @@ msgstr "Apagando..." #: frontend/src/sidebar/AsideContents.svelte:60 msgid "Add Journal Entry" msgstr "Adicionar Entrada" - diff --git a/src/fava/translations/ru/LC_MESSAGES/messages.po b/src/fava/translations/ru/LC_MESSAGES/messages.po index 9191d1dfe..6b0659543 100644 --- a/src/fava/translations/ru/LC_MESSAGES/messages.po +++ b/src/fava/translations/ru/LC_MESSAGES/messages.po @@ -430,6 +430,10 @@ msgstr "По кварталам" msgid "Monthly" msgstr "По месяцам" +#: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 +msgid "Fortnightly" +msgstr "Каждые две недели" + #: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 msgid "Weekly" msgstr "По неделям" @@ -591,4 +595,3 @@ msgstr "" #: frontend/src/sidebar/AsideContents.svelte:60 msgid "Add Journal Entry" msgstr "" - diff --git a/src/fava/translations/sk/LC_MESSAGES/messages.po b/src/fava/translations/sk/LC_MESSAGES/messages.po index 818de4c33..b49b20f0f 100644 --- a/src/fava/translations/sk/LC_MESSAGES/messages.po +++ b/src/fava/translations/sk/LC_MESSAGES/messages.po @@ -436,6 +436,10 @@ msgstr "Kvartálne" msgid "Monthly" msgstr "Mesačne" +#: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 +msgid "Fortnightly" +msgstr "Dvotýždenne" + #: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 msgid "Weekly" msgstr "Týždenne" @@ -597,4 +601,3 @@ msgstr "" #: frontend/src/sidebar/AsideContents.svelte:60 msgid "Add Journal Entry" msgstr "" - diff --git a/src/fava/translations/sv/LC_MESSAGES/messages.po b/src/fava/translations/sv/LC_MESSAGES/messages.po index f28d8db1b..f942bf52a 100644 --- a/src/fava/translations/sv/LC_MESSAGES/messages.po +++ b/src/fava/translations/sv/LC_MESSAGES/messages.po @@ -431,6 +431,10 @@ msgstr "Kvartalsvis" msgid "Monthly" msgstr "Månatligt" +#: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 +msgid "Fortnightly" +msgstr "Var fjortonde dag" + #: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 msgid "Weekly" msgstr "Veckovis" @@ -592,4 +596,3 @@ msgstr "" #: frontend/src/sidebar/AsideContents.svelte:60 msgid "Add Journal Entry" msgstr "" - diff --git a/src/fava/translations/uk/LC_MESSAGES/messages.po b/src/fava/translations/uk/LC_MESSAGES/messages.po index 3d2c1c976..724c8e275 100644 --- a/src/fava/translations/uk/LC_MESSAGES/messages.po +++ b/src/fava/translations/uk/LC_MESSAGES/messages.po @@ -431,6 +431,10 @@ msgstr "Щоквартально" msgid "Monthly" msgstr "Щомісячно" +#: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 +msgid "Fortnightly" +msgstr "двотижневий" + #: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 msgid "Weekly" msgstr "Щотижнево" @@ -592,4 +596,3 @@ msgstr "" #: frontend/src/sidebar/AsideContents.svelte:60 msgid "Add Journal Entry" msgstr "" - diff --git a/src/fava/translations/zh/LC_MESSAGES/messages.po b/src/fava/translations/zh/LC_MESSAGES/messages.po index c33b23d2d..83096a00c 100644 --- a/src/fava/translations/zh/LC_MESSAGES/messages.po +++ b/src/fava/translations/zh/LC_MESSAGES/messages.po @@ -431,6 +431,10 @@ msgstr "按季" msgid "Monthly" msgstr "按月" +#: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 +msgid "Fortnightly" +msgstr "按两周" + #: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 msgid "Weekly" msgstr "按周" @@ -592,4 +596,3 @@ msgstr "正在删除..." #: frontend/src/sidebar/AsideContents.svelte:60 msgid "Add Journal Entry" msgstr "添加日记账条目" - diff --git a/src/fava/translations/zh_Hant_TW/LC_MESSAGES/messages.po b/src/fava/translations/zh_Hant_TW/LC_MESSAGES/messages.po index a5cc1dccc..c883bab52 100644 --- a/src/fava/translations/zh_Hant_TW/LC_MESSAGES/messages.po +++ b/src/fava/translations/zh_Hant_TW/LC_MESSAGES/messages.po @@ -432,6 +432,10 @@ msgstr "季度" msgid "Monthly" msgstr "月" +#: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 +msgid "Fortnightly" +msgstr "兩周" + #: frontend/src/lib/interval.ts:25 src/fava/util/date.py:105 msgid "Weekly" msgstr "週" @@ -594,4 +598,3 @@ msgstr "正在删除..." #: frontend/src/sidebar/AsideContents.svelte:60 msgid "Add Journal Entry" msgstr "" - diff --git a/src/fava/util/date.py b/src/fava/util/date.py index 3aa26deae..0451ba7a9 100644 --- a/src/fava/util/date.py +++ b/src/fava/util/date.py @@ -35,6 +35,9 @@ # this matches a week like 2016-W02 for the second week of 2016 WEEK_RE = re.compile(r"^(\d{4})-w(\d{2})$") +# this matches a fortnight like 2025-W01/02 +FORTNIGHT_RE = re.compile(r"^(\d{4})-w(\d{2})/(\d{2})$") + # this matches a quarter like 2016-Q1 for the first quarter of 2016 QUARTER_RE = re.compile(r"^(\d{4})-q([1234])$") @@ -46,7 +49,7 @@ VARIABLE_RE = re.compile( r"\(?(fiscal_year|year|fiscal_quarter|quarter" - r"|month|week|day)(?:([-+])(\d+))?\)?", + r"|month|fortnight|week|day)(?:([-+])(\d+))?\)?", ) @@ -93,6 +96,31 @@ class Interval(Enum): YEAR = "year" QUARTER = "quarter" MONTH = "month" + FORTNIGHT = "fortnight" + # Note: While a fortnight is a well-defined time interval, there are no + # standard abbreviations for it, nor are there any standards for when + # fortnights start and end. Fava implements this as follows: + # + # - Fortnights are linked to the ISO week system. + # - Fortnight 1 of the year corresponds to ISO weeks 1 and 2, fortnight 2 + # to weeks 3 and 4, and so on. + # - If a year has 53 ISO weeks, the last fortnight of the year will include + # the first week of the next year. As a result of this, there may be some + # doubling-up as the first week of the following year appears within two + # fortnights. + # + # This happens approximately every 7 years, and is only relevant for the + # last fortnight of the year, this is considered an acceptable compromise + # to maintain a simple fortnight system. + # - The fortnight is represented as `2025-W01/02` or `2025W01/02` for the + # first fortnight of 2025. The `/` is used to indicate the two weeks that + # make up the fortnight. Since every year starts with a new fortnight, + # the first week is always odd, and the second week is always even. + # - Tentatively, a day within a fortnight can be represented as + # `2025-W01/02-13` for the 13th day of the first fortnight of 2025. This + # is not yet implemented, nor might it ever be. + # - For a 53-week year, the last fortnight may be represented as either + # `W53/54` or `W53/01`. WEEK = "week" DAY = "day" @@ -103,6 +131,7 @@ def label(self) -> str: Interval.YEAR: gettext("Yearly"), Interval.QUARTER: gettext("Quarterly"), Interval.MONTH: gettext("Monthly"), + Interval.FORTNIGHT: gettext("Fortnightly"), Interval.WEEK: gettext("Weekly"), Interval.DAY: gettext("Daily"), } @@ -116,7 +145,7 @@ def get(string: str) -> Interval: except KeyError: return Interval.MONTH - def format_date(self, date: datetime.date) -> str: + def format_date(self, date: datetime.date) -> str: # noqa: PLR0911 """Format a date for this interval for human consumption.""" if self is Interval.YEAR: return date.strftime("%Y") @@ -124,11 +153,17 @@ def format_date(self, date: datetime.date) -> str: return f"{date.year}Q{(date.month - 1) // 3 + 1}" if self is Interval.MONTH: return date.strftime("%b %Y") + if self is Interval.FORTNIGHT: + year, week, _ = date.isocalendar() + w1, w2 = (week - 1, week) if week % 2 == 0 else (week, week + 1) + return f"{year}W{w1:02}/{w2:02}" if self is Interval.WEEK: return date.strftime("%YW%W") - return date.strftime("%Y-%m-%d") + if self is Interval.DAY: + return date.strftime("%Y-%m-%d") + return assert_never(self) # pragma: no cover - def format_date_filter(self, date: datetime.date) -> str: + def format_date_filter(self, date: datetime.date) -> str: # noqa: PLR0911 """Format a date for this interval for the Fava time filter.""" if self is Interval.YEAR: return date.strftime("%Y") @@ -136,12 +171,18 @@ def format_date_filter(self, date: datetime.date) -> str: return f"{date.year}-Q{(date.month - 1) // 3 + 1}" if self is Interval.MONTH: return date.strftime("%Y-%m") + if self is Interval.FORTNIGHT: + year, week, _ = date.isocalendar() + w1, w2 = (week - 1, week) if week % 2 == 0 else (week, week + 1) + return f"{year}-W{w1:02}/{w2:02}" if self is Interval.WEEK: return date.strftime("%Y-W%W") - return date.strftime("%Y-%m-%d") + if self is Interval.DAY: + return date.strftime("%Y-%m-%d") + return assert_never(self) # pragma: no cover -def get_prev_interval( +def get_prev_interval( # noqa: PLR0911 date: datetime.date, interval: Interval, ) -> datetime.date: @@ -163,9 +204,23 @@ def get_prev_interval( return datetime.date(date.year, 1, 1) if interval is Interval.MONTH: return datetime.date(date.year, date.month, 1) + if interval is Interval.FORTNIGHT: + year, week, _ = date.isocalendar() + w1 = week - 1 if week % 2 == 0 else week + if w1 > 2: + return datetime.date.fromisocalendar(year, w1, 1) + # If we are in the first fortnight of the year, finding the last + # fortnight of the previous year is a bit tricky due to being either + # starting on week 51 or 53 (if the year has 53 weeks). + try: + return datetime.date.fromisocalendar(year - 1, 53, 1) + except ValueError: + return datetime.date.fromisocalendar(year - 1, 51, 1) if interval is Interval.WEEK: return date - timedelta(date.weekday()) - return date + if interval is Interval.DAY: + return date + return assert_never(interval) # pragma: no cover def get_next_interval( # noqa: PLR0911 @@ -193,6 +248,15 @@ def get_next_interval( # noqa: PLR0911 month = (date.month % 12) + 1 year = date.year + (date.month + 1 > 12) return datetime.date(year, month, 1) + if interval is Interval.FORTNIGHT: + year, week, _ = date.isocalendar() + w1 = week - 1 if week % 2 == 0 else week + # Unfortunately, it is difficult to determine whether a year has 52 + # or 53 ISO weeks, hence the trial-and-error approach. + try: + return datetime.date.fromisocalendar(year, w1 + 2, 1) + except ValueError: + return datetime.date.fromisocalendar(year + 1, 1, 1) if interval is Interval.WEEK: return date + timedelta(7 - date.weekday()) if interval is Interval.DAY: @@ -265,7 +329,7 @@ def local_today() -> datetime.date: return datetime.date.today() # noqa: DTZ011 -def substitute( +def substitute( # noqa: PLR0914 string: str, fye: FiscalYearEnd | None = None, ) -> str: @@ -287,13 +351,16 @@ def substitute( complete_match, interval, plusminus_, mod_ = match.group(0, 1, 2, 3) mod = int(mod_) if mod_ else 0 offset = mod if plusminus_ == "+" else -mod + if interval == "fiscal_year": after_fye = (today.month, today.day) > (fye.month_of_year, fye.day) year = today.year + (1 if after_fye else 0) - fye.year_offset string = string.replace(complete_match, f"FY{year + offset}") - if interval == "year": + + elif interval == "year": string = string.replace(complete_match, str(today.year + offset)) - if interval == "fiscal_quarter": + + elif interval == "fiscal_quarter": if not fye.has_quarters(): raise FyeHasNoQuartersError target = month_offset(today.replace(day=1), offset * 3) @@ -301,25 +368,40 @@ def substitute( year = target.year + (1 if after_fye else 0) - fye.year_offset quarter = ((target.month - fye.month_of_year - 1) // 3) % 4 + 1 string = string.replace(complete_match, f"FY{year}-Q{quarter}") - if interval == "quarter": + + elif interval == "quarter": quarter_today = (today.month - 1) // 3 + 1 year = today.year + (quarter_today + offset - 1) // 4 quarter = (quarter_today + offset - 1) % 4 + 1 string = string.replace(complete_match, f"{year}-Q{quarter}") - if interval == "month": + + elif interval == "month": year = today.year + (today.month + offset - 1) // 12 month = (today.month + offset - 1) % 12 + 1 string = string.replace(complete_match, f"{year}-{month:02}") - if interval == "week": + + elif interval == "fortnight": + interval_start = get_prev_interval(today, Interval.FORTNIGHT) + new_date = interval_start + timedelta(offset * 14) + string = string.replace( + complete_match, Interval.FORTNIGHT.format_date_filter(new_date) + ) + + elif interval == "week": string = string.replace( complete_match, (today + timedelta(offset * 7)).strftime("%Y-W%W"), ) - if interval == "day": + + elif interval == "day": string = string.replace( complete_match, (today + timedelta(offset)).isoformat(), ) + + else: + msg = f"Unknown interval '{interval}'" + raise ValueError(msg) return string @@ -334,6 +416,7 @@ def parse_date( # noqa: PLR0911 - 2010-03-15, 2010-03, 2010 - 2010-W01, 2010-Q3 - FY2012, FY2012-Q2 + - 2025-W01/02 Ranges of dates can be expressed in the following forms: @@ -380,6 +463,12 @@ def parse_date( # noqa: PLR0911 start = datetime.date(year, month, day) return start, get_next_interval(start, Interval.DAY) + match = FORTNIGHT_RE.match(string) + if match: + year, w1, _ = map(int, match.group(1, 2, 3)) + start = datetime.date.fromisocalendar(year, w1, 1) + return start, get_next_interval(start, Interval.FORTNIGHT) + match = WEEK_RE.match(string) if match: year, week = map(int, match.group(1, 2)) @@ -501,7 +590,7 @@ def days_in_daterange( yield start_date + timedelta(diff) -def number_of_days_in_period(interval: Interval, date: datetime.date) -> int: +def number_of_days_in_period(interval: Interval, date: datetime.date) -> int: # noqa: PLR0911 """Get number of days in the surrounding interval. Args: @@ -516,6 +605,8 @@ def number_of_days_in_period(interval: Interval, date: datetime.date) -> int: return 1 if interval is Interval.WEEK: return 7 + if interval is Interval.FORTNIGHT: + return 14 if interval is Interval.MONTH: date = datetime.date(date.year, date.month, 1) return (get_next_interval(date, Interval.MONTH) - date).days diff --git a/tests/test_core_budgets.py b/tests/test_core_budgets.py index ea7ffbf13..6e71e5786 100644 --- a/tests/test_core_budgets.py +++ b/tests/test_core_budgets.py @@ -79,6 +79,18 @@ def test_budgets_weekly(budgets_doc: BudgetDict) -> None: assert budget["EUR"] == num +def test_budgets_fortnightly(budgets_doc: BudgetDict) -> None: + """ + 2016-05-01 custom "budget" Expenses:Books "fortnightly" 42 EUR""" + + for start, end, num in [ + (date(2016, 5, 1), date(2016, 5, 2), Decimal(21) / 7), + (date(2016, 9, 1), date(2016, 9, 2), Decimal(21) / 7), + ]: + budget = calculate_budget(budgets_doc, "Expenses:Books", start, end) + assert budget["EUR"] == num + + def test_budgets_monthly(budgets_doc: BudgetDict) -> None: """ 2014-05-01 custom "budget" Expenses:Books "monthly" 100 EUR""" diff --git a/tests/test_util_date.py b/tests/test_util_date.py index ddb7caaba..81790868b 100644 --- a/tests/test_util_date.py +++ b/tests/test_util_date.py @@ -35,6 +35,7 @@ def _to_date(string: str) -> date: [ ("2016-01-01", Interval.DAY, "2016-01-01", "2016-01-01"), ("2016-01-04", Interval.WEEK, "2016W01", "2016-W01"), + ("2016-01-04", Interval.FORTNIGHT, "2016W01/02", "2016-W01/02"), ("2016-01-04", Interval.MONTH, "Jan 2016", "2016-01"), ("2016-01-04", Interval.QUARTER, "2016Q1", "2016-Q1"), ("2016-01-04", Interval.YEAR, "2016", "2016"), @@ -58,11 +59,13 @@ def test_interval_format( [ ("2016-01-01", Interval.DAY, "2016-01-02"), ("2016-01-01", Interval.WEEK, "2016-01-04"), + ("2016-01-01", Interval.FORTNIGHT, "2016-01-04"), ("2016-01-01", Interval.MONTH, "2016-02-01"), ("2016-01-01", Interval.QUARTER, "2016-04-01"), ("2016-01-01", Interval.YEAR, "2017-01-01"), ("2016-12-31", Interval.DAY, "2017-01-01"), ("2016-12-31", Interval.WEEK, "2017-01-02"), + ("2016-12-31", Interval.FORTNIGHT, "2017-01-02"), ("2016-12-31", Interval.MONTH, "2017-01-01"), ("2016-12-31", Interval.QUARTER, "2017-01-01"), ("2016-12-31", Interval.YEAR, "2017-01-01"), @@ -84,11 +87,13 @@ def test_get_next_interval( [ ("2016-01-01", Interval.DAY, "2016-01-01"), ("2016-01-01", Interval.WEEK, "2015-12-28"), + ("2016-01-01", Interval.FORTNIGHT, "2015-12-28"), ("2016-01-01", Interval.MONTH, "2016-01-01"), ("2016-01-01", Interval.QUARTER, "2016-01-01"), ("2016-01-01", Interval.YEAR, "2016-01-01"), ("2016-12-31", Interval.DAY, "2016-12-31"), ("2016-12-31", Interval.WEEK, "2016-12-26"), + ("2016-12-31", Interval.FORTNIGHT, "2016-12-19"), ("2016-12-31", Interval.MONTH, "2016-12-01"), ("2016-12-31", Interval.QUARTER, "2016-10-01"), ("2016-12-31", Interval.YEAR, "2016-01-01"), @@ -147,6 +152,9 @@ def test_interval_tuples() -> None: ("(month)", "2016-06"), ("month+6", "2016-12"), ("(month+24)", "2018-06"), + ("fortnight", "2016-W25/26"), + ("fortnight+10", "2016-W45/46"), + ("fortnight+1000", "2054-W43/44"), ("week", "2016-W25"), ("week+20", "2016-W45"), ("week+2000", "2054-W42"), @@ -158,9 +166,8 @@ def test_substitute(string: str, output: str) -> None: # Mock the imported datetime.date in fava.util.date module # Ref: # http://www.voidspace.org.uk/python/mock/examples.html#partial-mocking - with mock.patch("fava.util.date.datetime.date") as mock_date: - mock_date.today.return_value = _to_date("2016-06-24") - mock_date.side_effect = date + with mock.patch("fava.util.date.local_today") as mock_date: + mock_date.return_value = _to_date("2016-06-24") assert substitute(string) == output