Period

NAME

App::Moneymoor::Util::Period - budget periods: monthly-with-anchor-day and every-N-weeks pay windows.

SYNOPSIS


use App::Moneymoor::Util::Period;

# The default scheme is the calendar month, which is what every
# budget written before periods existed was already doing.
my $cal = App::Moneymoor::Util::Period.default-scheme;
say $cal.period-of('2026-08-19');      # 2026-08-01
say $cal.label('2026-08-01');          # August 2026

# Paid on the 14th: the period runs 14th to 13th.
my $pay = App::Moneymoor::Util::Period.monthly(anchor-day => 14);
say $pay.period-of('2026-08-13');      # 2026-07-14
say $pay.period-of('2026-08-14');      # 2026-08-14   (the anchor day
                                       #  belongs to the period it
                                       #  starts)
say $pay.next-period('2026-08-14');    # 2026-09-14
say $pay.label('2026-08-14');          # 14 Aug โ€“ 13 Sep 2026

# Anchored on the 31st: short months clamp, and the chain recovers.
my $eom = App::Moneymoor::Util::Period.monthly(anchor-day => 31);
say $eom.next-period('2026-01-31');    # 2026-02-28
say $eom.next-period('2026-02-28');    # 2026-03-31   (not 03-28)
say $eom.prev-period('2026-02-28');    # 2026-01-31
say $eom.next-period('2028-01-31');    # 2028-02-29   (leap year)

# Paid every four weeks from a first payday. No month ever lines up
# with this, which is exactly why it exists.
my $four = App::Moneymoor::Util::Period.weekly(
    weeks => 4, anchor => '2026-08-14');
say $four.period-of('2026-09-12');     # 2026-09-11
say $four.period-of('1999-01-01');     # 1998-12-11   (years before
                                       #  the anchor is fine: k is
                                       #  negative)
say $four.label('2026-08-14');         # 14 Aug โ€“ 10 Sep 2026

# The sequence the engine iterates.
say $pay.periods-through('2026-08-14', '2026-11-14').join(' ');
# 2026-08-14 2026-09-14 2026-10-14 2026-11-14

# The serialisation the budget file stores.
say $four.to-hash;      # {anchor => 2026-08-14, type => weekly,
                        #  weeks => 4}
my $same = App::Moneymoor::Util::Period.from-hash($four.to-hash);

# The scheme itself, in words, for a settings line or a toast.
say $cal.describe;      # Calendar month
say $pay.describe;      # Monthly from the 14th
say $four.describe;     # Every 4 weeks from 14 Aug 2026

DESCRIPTION

People do not budget in calendar months. They budget from one payday to the next: the money that arrived on the 14th has to reach the 13th, and someone paid every four weeks has a window that lines up with no month at all. Moneymoor's engine therefore buckets by budget period, and this module is the only thing in the app that knows what a period is.

It is pure: no DB, no I/O, no state. An instance holds one scheme and answers questions about it.

THE TWO SCHEMES

  • monthly with an anchor-day of 1โ€“31. anchor-day == 1 is the calendar month exactly, and is the default โ€” a budget that never touches this setting behaves as it always did. 14 gives 14th โ†’ 13th.

  • weekly with weeks โ‰ฅ 1 and an anchor date, the user's first payday. Starts fall on anchor + kยท7ยทweeks days for every integer k, negative included, so a transaction dated years before the anchor still buckets โ€” a budget file whose owner sets their scheme up today and then imports last year's statements must not fall off the front of the sequence.

IDENTITY IS THE START DATE

A period is named by its own start, as a 'YYYY-MM-DD' string. That is the whole identity: there is no period id, no ordinal, no month-plus-index pair.

Two consequences the rest of the engine leans on:

  • Lexicographic order is chronological order. Zero-padded ISO dates sort as text exactly as they sort as dates, so the derivation can le / gt period keys, sort them, use them as Hash keys and compare a viewed period against a through-period without parsing anything.

  • The calendar month degenerates into the general case. Under monthly/1 every period is 'YYYY-MM-01', so the old 'YYYY-MM' keys migrate by appending '-01' and nothing else about the algebra changes.

CLAMP, NEVER ADD DAYS

The monthly sequence is generated by iterating months and clamping the anchor day into each one โ€” never by adding a day count. Under < anchor-day => 31 >:

2026-01-31 โ†’ 2026-02-28 โ†’ 2026-03-31 โ†’ 2026-04-30 โ†’ 2026-05-31
2028-01-31 โ†’ 2028-02-29 โ†’ 2028-03-31

February clamps to the 28th (29th in a leap year), and then March goes straight back to the 31st. Anything built on "add 31 days", or on stepping from the clamped date, drifts: 2026-02-28 plus a month is 2026-03-28, and by December the budget's periods have walked three days away from the user's payday. The clamp is applied to the anchor, and the anchor is a property of the scheme, so the sequence cannot drift no matter how many short months it crosses.

Every calendar month contains exactly one monthly period start, for every anchor day.

DIE, OR RETURN THE EMPTY STRING

The split is deliberate:

  • The constructors and the navigation methods โ€” monthly, weekly, from-hash, period-of, next-period, prev-period, periods-through โ€” throw. They are called from the engine and the gateways, whose inputs have already been validated; an anchor-day of 32, or a next-period of a string that is not a period start under this scheme, means a caller's key has drifted. Continuing from that quietly produces a budget bucketed two ways at once, which surfaces days later as money that does not add up. next-period and prev-period therefore insist that their argument satisfies period-of($p) eq $p, and say so loudly when it does not.

  • label returns ''. Labels are composed inside Selkie::Store selectors, where an exception takes out the whole subscription walk, and the frames between construction and app/init genuinely have no period yet. Same reasoning as the UI's old month-label, which this method replaced.

LABELS

monthly/1 keeps the label the app already showed: '2026-08-01' โ†’ "August 2026". Every other scheme labels the range, start to end, where the end is the next period's start minus a day:

monthly/14, 2026-08-14      # 14 Aug โ€“ 13 Sep 2026
monthly/14, 2026-12-14      # 14 Dec 2026 โ€“ 13 Jan 2027
monthly/31, 2026-02-28      # 28 Feb โ€“ 30 Mar 2026
weekly/2,   2026-08-14      # 14 โ€“ 27 Aug 2026

A repeated month is written once, a repeated year is written once, and a range that crosses a year boundary carries the year on both sides. The separator is an en dash with spaces around it, and the day numbers are not zero-padded โ€” this is a banner line, not a key.

The month names are a module-local English table rather than anything locale-derived, for the same reason the old month-label's were: a label whose text depends on the machine's locale makes every layout pin in the test suite depend on the machine's locale.

DESCRIBING THE SCHEME, NOT A PERIOD

label names one period; describe names the scheme. They are different questions and the answers share nothing: "14 Aug โ€“ 13 Sep 2026" tells you which window you are looking at, and "Monthly from the 14th" tells you how every window is cut. The settings line that says what a budget is on, the picker that opens preselected on it, and the toast that confirms a change all want the second one:

monthly/1                     # Calendar month
monthly/14                    # Monthly from the 14th
monthly/3                     # Monthly from the 3rd
monthly/31                    # Monthly from the 31st
weekly/1  from 2026-08-14     # Every week from 14 Aug 2026
weekly/4  from 2026-08-14     # Every 4 weeks from 14 Aug 2026

The ordinal suffix is the real English rule, teens included โ€” the 11th, 12th and 13th take th where the 1st, 2nd and 3rd take st, nd and rd โ€” because an anchor day is a number the user typed and "Monthly from the 11st" reads as a bug in everything else too. The weekly date is written in label's style (abbreviated month, no zero-padded day) so the two never look like they came from different apps, and the singular case drops the count: "Every week", not "Every 1 weeks".

METHODS

Construction

  • Period.monthly(Int:D :$anchor-day = 1) โ€” throws unless 1 โ‰ค $anchor-day โ‰ค 31.

  • Period.weekly(Int:D :$weeks!, :$anchor!) โ€” $anchor is a Date or a 'YYYY-MM-DD' Str. Throws unless $weeks โ‰ฅ 1 and the anchor is a real date.

  • Period.default-scheme โ€” monthly/1, the calendar month.

  • Period.from-hash(%h) / < .to-hash(--> Hash) > โ€” the serialisation seam. The shapes, with the snake_case keys the budget file stores as JSON, are < {type => 'monthly', anchor_day = 14} >> and < {type => 'weekly', weeks = 4, anchor => '2026-08-14'} >>. from-hash throws on an unknown type, a missing field or a value out of range; it accepts a Str where an Int belongs (a JSON round-trip through a text column is entitled to stringify a number) but validates it either way, and it ignores keys it does not know so that a file written by a later version still opens. from-hash(.to-hash) reproduces the scheme exactly.

  • .new โ€” throws. There is no shape of Period that is not one of the two schemes, so the only way in is a named constructor.

Accessors

  • < .type(--> Str) > โ€” 'monthly' or 'weekly'.

  • < .anchor-day(--> Int) > โ€” monthly only; Int type object on a weekly scheme.

  • < .weeks(--> Int) > / < .anchor(--> Date) > โ€” weekly only.

The sequence

  • < .period-of($date --> Str) > โ€” the start of the period containing $date, which may be a Date or a 'YYYY-MM-DD' Str. Throws on a malformed date.

  • < .next-period(Str:D $period --> Str) > / < .prev-period(Str:D $period --> Str) > โ€” one step along the sequence. Throw unless $period is a period start under this scheme.

  • < .periods-through(Str:D $from, Str:D $through --> List) > โ€” the inclusive, contiguous run of starts from $from to $through. Throws unless both are period starts and $from โ‰ค $through. This is what the derivation iterates.

  • < .label(Str $period --> Str) > โ€” the human label, or ''.

The scheme in words

  • < .describe(--> Str) > โ€” the scheme itself, in a phrase fit for a settings line, a picker or a toast: 'Calendar month', 'Monthly from the 14th', 'Every 4 weeks from 14 Aug 2026'. Takes no period and cannot fail โ€” see DESCRIBING THE SCHEME, NOT A PERIOD.

SEE ALSO

App::Moneymoor v0.4.2

YNAB-style envelope budgeting: a derivation engine

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

DBIish:ver<0.6.7>:auth<zef:raku-community-modules>Notcurses::Native:ver<0.6.5+>:auth<zef:apogee>Selkie:ver<0.16.0+>:auth<zef:apogee>JSON::Fast:ver<0.19>:auth<cpan:TIMOTIMO>MacOS::NativeLib:ver<0.0.6>:auth<zef:lizmat>

Test Dependencies

Provides

  • App::Moneymoor
  • App::Moneymoor::Config
  • App::Moneymoor::DB
  • App::Moneymoor::Gateway::Account
  • App::Moneymoor::Gateway::Assignment
  • App::Moneymoor::Gateway::Category
  • App::Moneymoor::Gateway::Payee
  • App::Moneymoor::Gateway::Transaction
  • App::Moneymoor::Handlers::Boot
  • App::Moneymoor::Model::Account
  • App::Moneymoor::Model::Assignment
  • App::Moneymoor::Model::Category
  • App::Moneymoor::Model::CategoryGroup
  • App::Moneymoor::Model::Payee
  • App::Moneymoor::Model::Split
  • App::Moneymoor::Model::Transaction
  • App::Moneymoor::Screen::Accounts
  • App::Moneymoor::Screen::Budget
  • App::Moneymoor::Screen::Login
  • App::Moneymoor::Screen::Main
  • App::Moneymoor::Screen::Main::Keybinds
  • App::Moneymoor::Screen::Main::Modals
  • App::Moneymoor::Screen::Main::Subscriptions
  • App::Moneymoor::Screen::Reports
  • App::Moneymoor::Service::Budget
  • App::Moneymoor::Service::Icons
  • App::Moneymoor::Service::Target
  • App::Moneymoor::Service::Workspace
  • App::Moneymoor::StoreHandlers
  • App::Moneymoor::Theme
  • App::Moneymoor::Theme::Catppuccin
  • App::Moneymoor::Theme::Dracula
  • App::Moneymoor::Theme::Everforest
  • App::Moneymoor::Theme::Gruvbox
  • App::Moneymoor::Theme::Kanagawa
  • App::Moneymoor::Theme::Monokai
  • App::Moneymoor::Theme::Nord
  • App::Moneymoor::Theme::OneDark
  • App::Moneymoor::Theme::RosePine
  • App::Moneymoor::Theme::Solarized
  • App::Moneymoor::Theme::TokyoNight
  • App::Moneymoor::Themes
  • App::Moneymoor::UI
  • App::Moneymoor::Util::Money
  • App::Moneymoor::Util::Period
  • App::Moneymoor::View::BudgetRow
  • App::Moneymoor::View::EmptyState
  • App::Moneymoor::View::HintBar
  • App::Moneymoor::View::InspectorPane
  • App::Moneymoor::View::ModalChrome
  • App::Moneymoor::View::RegisterRow
  • App::Moneymoor::View::ReportRow
  • App::Moneymoor::Widget::BannerBar
  • App::Moneymoor::Widget::BootProgressModal

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite โ€” the markup and publishing tools behind this site.