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-dayof 1โ31.anchor-day == 1is the calendar month exactly, and is the default โ a budget that never touches this setting behaves as it always did.14gives 14th โ 13th.weekly with
weeksโฅ 1 and ananchordate, the user's first payday. Starts fall onanchor + kยท7ยทweeksdays for every integerk, 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/gtperiod keys,sortthem, use them asHashkeys and compare a viewed period against athrough-periodwithout parsing anything.The calendar month degenerates into the general case. Under
monthly/1every 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; ananchor-dayof 32, or anext-periodof 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-periodandprev-periodtherefore insist that their argument satisfiesperiod-of($p) eq $p, and say so loudly when it does not.labelreturns''. Labels are composed insideSelkie::Storeselectors, where an exception takes out the whole subscription walk, and the frames between construction andapp/initgenuinely have no period yet. Same reasoning as the UI's oldmonth-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!)โ$anchoris aDateor 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 thesnake_casekeys the budget file stores as JSON, are< {type => 'monthly', anchor_day =14} >> and< {type => 'weekly', weeks =4, anchor => '2026-08-14'} >>.from-hashthrows on an unknown type, a missing field or a value out of range; it accepts aStrwhere anIntbelongs (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 ofPeriodthat 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;Inttype 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 aDateor 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$periodis a period start under this scheme.< .periods-through(Str:D $from, Str:D $through --> List)> โ the inclusive, contiguous run of starts from$fromto$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::Service::Budget โ the derivation, which keys every assignment, every bucketed transaction and every rollover chain by the strings this module produces.
App::Moneymoor::Screen::Main::Subscriptions โ composes the banner whose labels
labelnow supplies; its oldmonth-labelis the sublabel'smonthly/1output reproduces exactly.App::Moneymoor::Util::Money โ the other pure engine-tier utility.