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..da182faac 100644 --- a/frontend/src/lib/interval.ts +++ b/frontend/src/lib/interval.ts @@ -1,6 +1,12 @@ 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"; @@ -8,6 +14,7 @@ export const INTERVALS: Interval[] = [ "year", "quarter", "month", + "fortnight", "week", "day", ]; @@ -22,6 +29,7 @@ export function intervalLabel(s: Interval): string { year: _("Yearly"), quarter: _("Quarterly"), month: _("Monthly"), + fortnight: _("Fortnightly"), week: _("Weekly"), day: _("Daily"), }[s]; diff --git a/frontend/test/format.test.ts b/frontend/test/format.test.ts index 65e034c49..d98db8c3a 100644 --- a/frontend/test/format.test.ts +++ b/frontend/test/format.test.ts @@ -29,16 +29,19 @@ test("locale number formatting", () => { }); test("time filter date formatting", () => { - const { day, month, week, quarter, year, ...rest } = timeFilterDateFormat; + const { day, week, fortnight, month, quarter, year, ...rest } = + timeFilterDateFormat; assert.equal(rest, {}); const janfirst = new Date("2020-01-01"); const date = new Date("2020-03-20"); assert.is(day(janfirst), "2020-01-01"); 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(fortnight(janfirst), "2020-W01/02"); + assert.is(fortnight(date), "2020-W11/12"); + assert.is(month(janfirst), "2020-01"); + assert.is(month(date), "2020-03"); assert.is(quarter(janfirst), "2020-Q1"); assert.is(quarter(date), "2020-Q1"); assert.is(year(janfirst), "2020"); @@ -46,16 +49,18 @@ test("time filter date formatting", () => { }); test("human-readable date formatting", () => { - const { day, month, week, quarter, year, ...rest } = dateFormat; + const { day, week, fortnight, month, quarter, year, ...rest } = dateFormat; assert.equal(rest, {}); const janfirst = new Date("2020-01-01"); const date = new Date("2020-03-20"); assert.is(day(janfirst), "2020-01-01"); 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(fortnight(janfirst), "2020W01/02"); + assert.is(fortnight(date), "2020W11/12"); + assert.is(month(janfirst), "Jan 2020"); + assert.is(month(date), "Mar 2020"); assert.is(quarter(janfirst), "2020Q1"); assert.is(quarter(date), "2020Q1"); assert.is(year(janfirst), "2020"); 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..9e4e678de 100644 --- a/src/fava/help/budgets.md +++ b/src/fava/help/budgets.md @@ -6,6 +6,7 @@ Beancount file:
@@ -13,13 +14,40 @@ 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. +Each budget directive has an accunt for which the budget is specified, a +frequency and amount of the budget, and a date from which the budget is valid. A +budget directive remains valid until another budget directive for the account is +specified. For example: -This makes the budgets very flexible, allowing for a monthly budget, being taken -over by a weekly budget, and so on. +
+ +In this example, the coffee budget is 4.00 EUR for 2012, then increases to 5.00 +EUR in 2013, and finally to 30 EUR per week in 2014. + +Fava supports the following frequencies for the budget: + +- `daily` +- `weekly` + - The week align with ISO weeks, and start on Monday. +- `fortnightly` + - Note that there are no standard conventions for dividing a year into + fortnights, and as such, Fava uses the following: + - The fortnight align with ISO weeks, with the first fortnight being W01 and + W02 of the year. + - For a year with 53 weeks, the last fortnight is W53 and W54 (equivalent to + the next year's W01). This unfortunately does result in overlapping + fortnights once every 7 years approximately. +- `monthly` + - This is the calendar month, and Fava internally uses the number of days in + each month to calculate the monthly budget. As a result, February with 28 + days will have a lower budget than January with 31 days. +- `quarterly` + - Based on the calendar quarter, with the quarters starting on January 1, + April 1, July 1, and October 1. +- `yearly` Fava displays budgets in both charts and reports. You can find a visualization of the global budget in the `Net Profit` and `Expenses` charts for the Income 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..bf65d3f57 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,15 @@ 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 + return datetime.date.fromisocalendar(year, w1, 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 +240,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 +321,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 +343,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 +360,42 @@ 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: + # This is unreachable, but mypy doesn't know that as it cannot + # infer the values matched by the regex + msg = f"Unknown interval '{interval}'" # pragma: no cover + raise ValueError(msg) # pragma: no cover return string @@ -334,6 +410,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 +457,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 +584,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 +599,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..f1db956fb 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,16 +87,25 @@ 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"), ("9999-12-31", Interval.QUARTER, "9999-10-01"), ("9999-12-31", Interval.YEAR, "9999-01-01"), + # 53 week year + ("2020-12-31", Interval.WEEK, "2020-12-28"), + ("2021-01-01", Interval.WEEK, "2020-12-28"), + ("2021-01-04", Interval.WEEK, "2021-01-04"), + ("2020-12-31", Interval.FORTNIGHT, "2020-12-28"), + ("2021-01-01", Interval.FORTNIGHT, "2020-12-28"), + ("2021-01-04", Interval.FORTNIGHT, "2021-01-04"), ], ) def test_get_prev_interval( @@ -147,6 +159,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"), @@ -155,15 +170,19 @@ def test_interval_tuples() -> None: ], ) 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 + # Use a specific date to make the tests deterministic. + with mock.patch("fava.util.date.local_today") as mock_date: + mock_date.return_value = _to_date("2016-06-24") assert substitute(string) == output +def test_substitute_invalid() -> None: + # Use a specific date to make the tests deterministic. + with mock.patch("fava.util.date.local_today") as mock_date: + mock_date.return_value = _to_date("2016-06-24") + assert substitute("asdasd") == "asdasd" + + @pytest.mark.parametrize( ("fye_str", "test_date", "string", "output"), [ @@ -221,6 +240,8 @@ def test_fiscal_substitute( ("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-12", "2015-W01/02"), + ("2024-12-30", "2025-01-13", "2025-W01/02"), ("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"),