Skip to content

/calendar

docs/src/snippets/modules.test.ts
// import { LIFETIME, cycleKey, cycleWindow, monthKey, monthWindow } from "pricemeter/calendar";
const t = Date.parse("2026-09-30T22:30:00Z"); // already October 1st in Istanbul (UTC+3)
monthKey({ at: t }); // "2026-09"
monthKey({ at: t, tz: "Europe/Istanbul" }); // "2026-10"
monthWindow({ at: t, tz: "Europe/Istanbul" }); // { from: 2026-09-30T21:00Z, to: 2026-10-31T21:00Z }
// A subscription started on Jan 31st renews on the last day of shorter months.
const anchor = Date.parse("2026-01-31T00:00:00Z");
cycleKey({ at: Date.parse("2026-03-10T00:00:00Z"), anchor }); // "2026-02-28"
cycleWindow({ at: Date.parse("2026-03-10T00:00:00Z"), anchor }); // { from: 2026-02-28, to: 2026-03-31 }
LIFETIME; // "*" — the period key for counters that never reset

The core reads no calendar (A72); what “this period” means is the application’s question. pricemeter/calendar gives pure helpers to answer it: keys that pick which period a usedSoFar counter belongs to, and windows for integrate() and counter queries.

API Returns
monthKey({ at, tz? }) "2026-09": the calendar month in time zone tz (default UTC)
monthWindow({ at, tz? }) That month’s half-open [from, to) window, epoch ms
cycleKey({ at, anchor, tz? }) "2026-09-15": the local start date of the cycle anchored on anchor’s day of month
cycleWindow({ at, anchor, tz? }) That cycle’s [from, to) window. A cycle started on the 31st renews on the last day of shorter months
LIFETIME "*": the period key for counters that never reset (B16)

Time zones are resolved with Intl; days whose local midnight does not exist (DST at 00:00) are handled. Windows are half-open: a period-end observation uses at: window.to − 1, because window.to selects the next period (A98).

docs/src/snippets/modules.test.ts
const mem = memoryAdapters({ rates: { sms: flat(0) }, periodOf: (at) => monthKey({ at, tz: "Europe/Istanbul" }) });
const metering = buildMetering()
.context<{ accountId: string; tz: string }>()
.refs(["send"])
.meter("sms")
.getRate(async (meter, _dims, ctx, at) => ({
rate: { model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 0 }, { from: 500, unitPriceMicroUsd: 20_000 }], policy: { tierPeriod: "month" } },
usedSoFar: mem.store.used(ctx.accountId, meter, monthKey({ at, tz: ctx.tz })), // this local month's counter
}))
.commit(mem.commit);

The counter store and getRate must use the same key: here memoryAdapters({ periodOf }) counts rows by local month and getRate reads the same month’s counter. policy.tierPeriod: "month" records that choice on the tariff; the library does not read it. With SQLite you use a window instead of a key: usedSoFar({ account, meter, from, to }).

For a UTC calendar month, new Date(at).toISOString().slice(0, 7) is enough. The module earns its place with non-UTC time zones (22:30 UTC on Sep 30 is already October in Istanbul), cycles anchored on the subscription day (Jan 31 → Feb 28 → Mar 31) and DST transitions. If you already have a date library (Temporal, date-fns-tz, Luxon), produce the same key with it; the library never knows where the key came from.