Slicing Time
Cut bookings and shifts into pieces where the rate changes, take out the breaks, and tag the time past a cap, so a run can price each piece.
After reading this guide, you will know:
- How a
slicestep turns a span of time into rows a calculation can price. - How to cut at clock hours and given times, and take breaks out.
- How caps tag the time past a running total, per day or per any group, and how caps build on each other.
- Which clock the cuts are read on, and what happens across a daylight saving change.
A Court Booking
A tennis court costs $20 an hour before 5pm and $32 after. A booking past two hours gets a quarter off the extra time, and past the hour after that, half. A break in the middle is not charged.
tables:
rates:
- {until: 17, hourly: 20}
- {hourly: 32}
calculations:
court:
inputs:
starts: {type: time}
ends: {type: time}
breaks: {type: list, default: []}
steps:
- name: pieces
slice:
from: starts
to: ends
without: breaks
at_hours: [17]
after:
- {minutes: 120, tag: long}
- {minutes: 60, counting: long, tag: very_long}
- name: priced
run: {calculation: piece, over: pieces}
- name: fee
sum: {output: amount, of: priced}
outputs: [priced, fee]
piece:
inputs:
from: {type: time}
minutes: {type: number}
long: {type: boolean}
very_long: {type: boolean}
steps:
- {name: hour, formula: HOUR(from)}
- name: hourly
lookup: {table: rates, where: {}, value: hourly, band: {by: hour, below: until}}
- {name: discount, formula: 'IF(very_long, 0.5, IF(long, 0.25, 0))'}
- {name: amount, formula: minutes / 60 * hourly * (1 - discount)}
outputs: [from, minutes, hourly, discount, amount]
$ echo '{"starts": "2026-11-02T14:00:00+11:00", "ends": "2026-11-02T18:30:00+11:00",
"breaks": [{"from": "2026-11-02T17:00:00+11:00", "to": "2026-11-02T17:30:00+11:00"}]}' \
| rmt run court.yml --trace
priced
from minutes hourly discount amount
2026-11-02T14:00:00+11:00 120 20 0 40
2026-11-02T16:00:00+11:00 60 20 0.25 15
2026-11-02T17:30:00+11:00 60 32 0.5 16
fee 71
How it was worked out
pieces 3 rows 1 span into 3 pieces: less breaks, cut daily at 17, long after 120 minutes, very_long after 60 minutes counting long
...
Each piece is a row with from, to and minutes, plus a true or false column for each cap, so the
calculation run over them declares the ones it reads as inputs.
The Span
from and to are formulas giving times, usually the names of two time inputs. A span that ends
before it starts fails the run.
With over, each row of a list is a span. from and to are read on each row, and are the row’s own
from and to unless given, so over: sessions is enough for rows written {from, to}. Rows written
otherwise name their fields:
slice: {over: shifts, from: started_at, to: ended_at, without: {rows: breaks, from: started_at, to: ended_at}}
Each piece keeps its row’s other fields, such as a role or a rate, with from, to and minutes set to
the piece’s own. The pieces of every row come out together in time order, whatever order the rows were
listed in, and caps count them in that order.
The Cuts
Breaks come out first, then the cuts, then the caps.
| Key | Does |
|---|---|
without: breaks |
leaves out the time covered by a list of rows with from and to. With over, it is read on each row, so without: breaks takes each shift’s own breaks. {rows: rests, from: began, to: ended} reads other fields |
at_hours: [0, 7, 18, 21] |
cuts every day a span covers at those clock hours. 0 is midnight, so list it for a booking past midnight to fall into two days. 7.5 is 7:30. A formula, such as a table’s column, works too |
at_times: '{change}' |
cuts at a list of times, such as when a price changes |
A break across a cut shortens the pieces on both sides of it.
Caps
after: {minutes: 480} counts the minutes of the pieces in time order and tags each one beyond: true
once the count reaches 480, cutting the piece that crosses it at the minute it is reached. Every piece
gets the tag, true or false. minutes can be a formula, worked out on each piece, such as
IF(CONTAINS(DATE(from), long_days), 600, 480).
| Key | Does |
|---|---|
per: DATE(from) |
keeps a count for each group: each day here, or each fortnight with ROUNDDOWN((DATE(from) - starts_on) / 14) |
counting: NOT(beyond_day) |
counts only the pieces the condition is true for. The others are tagged false and left whole |
tag: overtime |
names the tag, beyond unless given |
after can be a list of caps. They apply in order, and each sees the tags of the ones before it, which
is how a daily cap, a weekly cap on the time the daily cap left alone, and tiers of overtime past both
are written:
after:
- {minutes: 456, per: DATE(from), tag: beyond_day}
- {minutes: 2280, per: 'ROUNDDOWN((DATE(from) - cycle_starts) / 7)', counting: NOT(beyond_day), tag: beyond_week}
- {minutes: 180, per: DATE(from), counting: beyond_day OR beyond_week, tag: past_first_tier}
Which Clock
Times keep the offset they were written in, and clock hours are read on that clock: at_hours: [7]
cuts a span at 7:00 in the offset the span starts in. Write times in the offset of the place the rules
are for. A booking written in UTC is cut at 7:00 UTC.
An offset is not a time zone, so the engine cannot know where daylight saving changes. A span across a change keeps the clock it starts on: a night shift from 22:00+11:00 that ends at 08:00+10:00, after Sydney’s clocks go back, is cut at 07:00+11:00, which is 06:00 on the new clock. Its minutes are always the time that really passed, 11 hours here. To cut on the new clock, split the span at the change into two rows, each written in its own offset.
Next Steps
- Running over Lists - Pricing each piece with a run.
- Dates and Dice - Writing and reading times.
- Blueprint Format - Every key of a slice.