Skip to content

Commit

Permalink
feat: add fortnightly interval
Browse files Browse the repository at this point in the history
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).

As part of this commit, I have also taken the opportunity to fix a
confusion between ISO weeks and calendar weeks as the two do not always
match, and `2025-W01` is the ISO format indicating the week starting on
December 30 2024.

Resolves: beancount#1939
Signed-off-by: JP-Ellis <[email protected]>
  • Loading branch information
JP-Ellis committed Feb 4, 2025
1 parent ad5412a commit 422998b
Show file tree
Hide file tree
Showing 21 changed files with 307 additions and 130 deletions.
76 changes: 46 additions & 30 deletions frontend/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,31 @@ import type { Interval } from "./lib/interval";
* @param precision - The number of decimal digits to show.
*/
export function localeFormatter(
locale: string | null,
precision = 2,
locale: string | null,
precision = 2,
): (num: number) => string {
if (locale == null) {
return format(`.${precision.toString()}f`);
}
// this needs to be between 0 and 20
const digits = Math.max(0, Math.min(precision, 20));
const fmt = new Intl.NumberFormat(locale.replace("_", "-"), {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
return fmt.format.bind(fmt);
if (locale == null) {
return format(`.${precision.toString()}f`);
}
// this needs to be between 0 and 20
const digits = Math.max(0, Math.min(precision, 20));
const fmt = new Intl.NumberFormat(locale.replace("_", "-"), {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
return fmt.format.bind(fmt);
}

const formatterPer = format(".2f");
export function formatPercentage(number: number): string {
return `${formatterPer(Math.abs(number) * 100)}%`;
return `${formatterPer(Math.abs(number) * 100)}%`;
}

export interface FormatterContext {
/** Render an amount to a string like "2.00 USD". */
amount: (num: number, currency: string) => string;
/** Render an number for a currency like "2.00". */
num: (num: number, currency: string) => string;
/** Render an amount to a string like "2.00 USD". */
amount: (num: number, currency: string) => string;
/** Render an number for a currency like "2.00". */
num: (num: number, currency: string) => string;
}

type DateFormatter = (date: Date) => string;
Expand All @@ -47,27 +47,43 @@ export const day = utcFormat("%Y-%m-%d");

/** Date formatters for human consumption. */
export const dateFormat: Record<Interval, DateFormatter> = {
year: utcFormat("%Y"),
quarter: (date) =>
`${date.getUTCFullYear().toString()}Q${(Math.floor(date.getUTCMonth() / 3) + 1).toString()}`,
month: utcFormat("%b %Y"),
week: utcFormat("%YW%W"),
day,
year: utcFormat("%Y"),
quarter: (date) =>
`${date.getUTCFullYear().toString()}Q${(Math.floor(date.getUTCMonth() / 3) + 1).toString()}`,
month: utcFormat("%b %Y"),
// Since fortnights don't have a standard format, we extend the week format.
// For example: `2025W01-02` for the first fortnight of 2025, `2025W03-04` for
// the second fortnight of 2025, etc.
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("%GW%V"),
day,
};

/** Date formatters for the entry filter form. */
export const timeFilterDateFormat: Record<Interval, DateFormatter> = {
year: utcFormat("%Y"),
quarter: (date) =>
`${date.getUTCFullYear().toString()}-Q${(Math.floor(date.getUTCMonth() / 3) + 1).toString()}`,
month: utcFormat("%Y-%m"),
week: utcFormat("%Y-W%W"),
day,
year: utcFormat("%Y"),
quarter: (date) =>
`${date.getUTCFullYear().toString()}-Q${(Math.floor(date.getUTCMonth() / 3) + 1).toString()}`,
month: utcFormat("%Y-%m"),
// See `dateFormat` implementation for reasoning of fortnight format.
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("%G-W%V"),
day,
};

const local_day = timeFormat("%Y-%m-%d");

/** Today as a ISO-8601 date string. */
export function todayAsString(): string {
return local_day(new Date());
return local_day(new Date());
}
36 changes: 22 additions & 14 deletions frontend/src/lib/interval.ts
Original file line number Diff line number Diff line change
@@ -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];
}
99 changes: 52 additions & 47 deletions frontend/test/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,64 +2,69 @@ import { test } from "uvu";
import assert from "uvu/assert";

import {
dateFormat,
localeFormatter,
timeFilterDateFormat,
dateFormat,
localeFormatter,
timeFilterDateFormat,
} from "../src/format";

test("locale number formatting", () => {
const f = localeFormatter(null);
const de = localeFormatter("de_DE");
const ind = localeFormatter("en_IN");
assert.is(f(10), "10.00");
assert.is(de(10), "10,00");
assert.is(ind(10), "10.00");
assert.is(f(1000000), "1000000.00");
assert.is(de(1000000.000002), "1.000.000,00");
assert.is(ind(1000000.00000001), "10,00,000.00");
const f = localeFormatter(null);
const de = localeFormatter("de_DE");
const ind = localeFormatter("en_IN");
assert.is(f(10), "10.00");
assert.is(de(10), "10,00");
assert.is(ind(10), "10.00");
assert.is(f(1000000), "1000000.00");
assert.is(de(1000000.000002), "1.000.000,00");
assert.is(ind(1000000.00000001), "10,00,000.00");

const es_ar = localeFormatter("es_AR", 2);
assert.is(es_ar(1234.1234), "1.234,12");
const es_ar = localeFormatter("es_AR", 2);
assert.is(es_ar(1234.1234), "1.234,12");

// it silently clamps large or negative precisions
const de_large = localeFormatter("de_DE", 100);
assert.is(de_large(1000), "1.000,00000000000000000000");
const de_negative = localeFormatter("de_DE", -100);
assert.is(de_negative(1000), "1.000");
// it silently clamps large or negative precisions
const de_large = localeFormatter("de_DE", 100);
assert.is(de_large(1000), "1.000,00000000000000000000");
const de_negative = localeFormatter("de_DE", -100);
assert.is(de_negative(1000), "1.000");
});

test("time filter date formatting", () => {
const { day, month, week, 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(quarter(janfirst), "2020-Q1");
assert.is(quarter(date), "2020-Q1");
assert.is(year(janfirst), "2020");
assert.is(year(date), "2020");
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(week(janfirst), "2020-W01");
assert.is(week(date), "2020-W12");
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");
assert.is(year(date), "2020");
});

test("human-readable date formatting", () => {
const { day, month, week, 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(quarter(janfirst), "2020Q1");
assert.is(quarter(date), "2020Q1");
assert.is(year(janfirst), "2020");
assert.is(year(date), "2020");
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(week(janfirst), "2020W01");
assert.is(week(date), "2020W12");
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");
assert.is(year(date), "2020");
});

test.run();
1 change: 1 addition & 0 deletions src/fava/core/budgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 5 additions & 4 deletions src/fava/help/budgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@ Beancount file:
<pre><textarea is="beancount-textarea">
2012-01-01 custom "budget" Expenses:Coffee "daily" 4.00 EUR
2013-01-01 custom "budget" Expenses:Books "weekly" 20.00 EUR
2013-01-01 custom "budget" Expenses:Fuel "fortnightly" 60.00 EUR
2014-02-10 custom "budget" Expenses:Groceries "monthly" 40.00 EUR
2015-05-01 custom "budget" Expenses:Electricity "quarterly" 85.00 EUR
2016-06-01 custom "budget" Expenses:Holiday "yearly" 2500.00 EUR</textarea></pre>

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.
Expand Down
5 changes: 4 additions & 1 deletion src/fava/translations/bg/LC_MESSAGES/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -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 "Седмичен"
Expand Down Expand Up @@ -592,4 +596,3 @@ msgstr "Изтриване..."
#: frontend/src/sidebar/AsideContents.svelte:60
msgid "Add Journal Entry"
msgstr ""

5 changes: 4 additions & 1 deletion src/fava/translations/ca/LC_MESSAGES/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -592,4 +596,3 @@ msgstr "S'està suprimint..."
#: frontend/src/sidebar/AsideContents.svelte:60
msgid "Add Journal Entry"
msgstr ""

5 changes: 4 additions & 1 deletion src/fava/translations/de/LC_MESSAGES/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -592,4 +596,3 @@ msgstr "Wird gelöscht..."
#: frontend/src/sidebar/AsideContents.svelte:60
msgid "Add Journal Entry"
msgstr "Journal-Eintrag hinzufügen"

5 changes: 4 additions & 1 deletion src/fava/translations/fa/LC_MESSAGES/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -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 "هفتگی"
Expand Down Expand Up @@ -592,4 +596,3 @@ msgstr ""
#: frontend/src/sidebar/AsideContents.svelte:60
msgid "Add Journal Entry"
msgstr ""

5 changes: 4 additions & 1 deletion src/fava/translations/fr/LC_MESSAGES/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -592,4 +596,3 @@ msgstr "Suppression..."
#: frontend/src/sidebar/AsideContents.svelte:60
msgid "Add Journal Entry"
msgstr ""

Loading

0 comments on commit 422998b

Please sign in to comment.