One boundary convention, and an offset that resets

SkillDev tools

Pick one boundary convention for a stepping period navigator and hold it everywhere — a closed upper bound at 23:59:59 drops the last second's sub-second remainder, and half-open bounds handed to an inclusive BETWEEN double-count the shared instant — then reset the step offset whenever the granularity changes, because N periods back at one length is not N periods back at another. Covers clamping at the present, deriving the forward affordance from the same value, and why the current period's totals are not comparable to the previous one's. Use when a period navigator lands on the wrong span after switching granularity, when a boundary event is missing or counted twice, or when a first-of-the-month comparison reads catastrophically low.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the One boundary convention, and an offset that resets skill

What this skill tells your AI

The instructions your AI receives, as published by maxrave-dev/kotlin-footguns in skills/inclusive-period-boundaries-and-offset-reset/SKILL.md and read by ahel’s review.

The whole navigator is one function called with a different argument. offset counts periods backwards: 0 is the current one, 1 the one before it.

// adapted
private fun rangeFor(range: DayRange, offset: Int): Pair<LocalDateTime, LocalDateTime> {
    val today = now().date
    val length = range.lengthInDays
    val end = today.minus(DatePeriod(days = offset * length))
    val start = end.minus(DatePeriod(days = length - 1))
    return start.atTime(0, 0) to end.atTime(23, 59, 59)
}

The comparison against the previous period is the same call with offset + 1, which is the entire reason this shape is worth the indirection.

Traps

Two conventions in one feature is the normal state, not a hypothetical. Measured in a single file here: the period bounds are closed (atTime(0, 0) … atTime(23, 59, 59)), while the chart's day, week and month buckets are half-open (start of the unit … start of the unit after, the month one taking a December branch to get there). Both conventions are then handed to queries whose predicate is BETWEEN — inclusive at both ends, 11 of them, and not one written as >= … <. Each convention is defensible; together they guarantee that at least one of them is wrong, and neither produces an error.

A closed upper bound at 23:59:59 loses the rest of that second. Sub-second precision does not vanish because the bound has none: timestamps are stored to the millisecond, so events between 23:59:59.001 and 23:59:59.999 fall outside every period, forever, and are counted by no span at all. It is roughly one part in 86 400 of the day, so it is invisible in a total and it is not invisible in a "what happened last" list, where the missing row is the newest one. Use the true last representable instant, or move to half-open and change the predicate with it.

Half-open bounds fed to an inclusive predicate double-count the seam. [day, day+1] under BETWEEN counts an event landing exactly on midnight in both buckets. Rare, exact, and the kind of thing that makes two charts on the same screen disagree by one.

Half-open is the convention to standardise on, and it costs a predicate change. >= start AND < nextStart has no seam, no lost remainder, and no dependence on the storage precision. BETWEEN cannot express it — so this is a query change, not a bounds change, and doing only half the job makes things worse than either convention alone.

The two conventions also disagree about a clock-change day. "Start of day" resolved through a zone returns the first existing instant, which is 01:00 where midnight does not exist; a bare atTime(0, 0) returns a wall-clock value that no event can carry. Neither is wrong; they are simply different days, twice a year, on one of the two paths.

The offset is a count, so a granularity change carries it over silently. Three periods back at 7 days is 21 days ago; three periods back at 90 days is nine months ago. Nothing type-checks differently, nothing throws, and the user who switches from weeks to quarters lands somewhere they never asked for. Reset to the present on every granularity change — it is one field and it is the only answer that is always defensible:

// adapted
fun setDayRange(range: DayRange) {
    state.update { it.copy(dayRange = range, periodOffset = 0) }
    loadPeriod()
}

Clamp at the present, and return early when the clamp bites. Without the early return, every press of a disabled-looking arrow re-issues the whole load — a dozen queries for a state that did not change:

// adapted
fun stepPeriod(delta: Int) {
    val next = (state.value.periodOffset - delta).coerceAtLeast(0)
    if (next == state.value.periodOffset) return
    state.update { it.copy(periodOffset = next) }
    loadPeriod()
}

Derive the forward affordance from the offset, never track it. val canStepForward get() = periodOffset > 0 cannot disagree with the clamp. A separate boolean can, and will, on the frame the granularity resets.

The buckets inside a period have their own boundary rule, and it is a different one. Stepping decides which span is on screen; chopping that span into bars is a separate decision with its own failure — unequal bucket widths drawing a longer bar for the wider one (equal-buckets-or-no-buckets). The two interact only through the granularity change that resets the offset.

A length field that is a lie for one member of the enum. Fixed-length ranges step by days; a calendar year steps by years and takes a different branch entirely — so its lengthInDays is declared, unused, and wrong. Someone will use it. Either give the calendar member no length, or make the stepping unit part of the enum rather than a number that only some members honour.

The current period is truncated and the previous one is not, so every delta is biased low. With fixed lengths the day counts match but the last day is partial, which is a bias of at most one day in length. With calendar granularity it is brutal: on 2 January the current period is two days against a full previous year, and every figure on the screen reads as a collapse. Either label the current period as in-progress, or compare like-for-like by truncating the previous period to the same elapsed fraction — but decide, because "it recovers by the end of the month" is not a fix.

Verifying it

  1. Find every boundary construction and read the convention on each:

    grep -rn --include='*.kt' -E "atTime\(23|atTime\(0, 0\)|atStartOfDayIn" . | grep -v '/build/'
    

    Mixed atTime(23, …) and atStartOfDay… hits in one feature are the split described above.

  2. Count how the queries actually compare, because that decides which convention is safe:

    grep -rn --include='*.kt' 'timestamp BETWEEN' . | grep -v '/build/' | wc -l
    grep -rn --include='*.kt' -E 'timestamp >= .* timestamp <' . | grep -v '/build/' | wc -l
    

    Eleven and zero here. Every BETWEEN is a closed comparison, so every half-open bound handed to one has a seam.

  3. Count the events the closed bound drops. Where the stored column is millis of the local wall clock, this needs no zone function:

    SELECT COUNT(*) FROM activity_event WHERE timestamp % 86400000 > 86399000;
    

    Every row returned is an event no period contains. The same expression = 0 counts the events sitting exactly on a bucket seam, which are the ones counted twice.

  4. Step, then switch. Go three periods back, change the granularity, and read the span label. It must say the present. Then step forward at the boundary and confirm nothing reloads — a spinner there is the missing early return.

Signals

GitHub stars
315
Forks
6
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
inclusive-period-boundaries-and-offset-reset
Source
github.com/maxrave-dev/kotlin-footguns