Skip to content
AF
Cookbook

Day-Boundary Rollup: Grouping Samples by Civil Date

Last verified August 12, 2026 · 14 min read

A copy-and-run implementation of the civil-date daily rollup: a writer that computes each sample's local date from its instant and its UTC offset at ingest, a rollup that groups strictly on that stored date and refuses to re-derive it at read time, and helpers that report how long a given civil day actually was. Plain modern JavaScript, no dependencies, Node 20 and above, with a node:test suite that injects the offset series and the sample store so nothing touches a clock or a network. The fixed-UTC-window version is exported alongside it, marked as the anti-pattern, so the tests can show exactly which samples it invents on a spring-forward day and which it loses in autumn. The pattern itself is argued on the timezones and day boundaries page; this is the code.

A daily total is a calendar question, not a time-range question. The argument for that, the three columns it forces on your schema, and the decision about whose midnight defines the day all belong to timezones and day boundaries; the live day-boundary demo lets you watch a civil day stop being 24 hours long in your own browser. This page is the code that falls out of those two.

What the recipe does#

Three moving parts, and the split between them is the whole point.

  • A writer that stamps civilDate onto every sample at ingest, from the instant and the UTC offset in effect at that instant. It refuses to store a sample whose offset it does not know rather than defaulting to UTC, because a defaulted offset silently rewrites the user's travel history and nothing downstream can tell that you guessed.
  • A rollup that groups strictly on the stored civilDate and throws if a row does not have one. It will not re-derive the date at read time. That single refusal is what makes the daily figure stable when the user's phone changes zone.
  • Civil-day helpers that answer how long a given civil day actually was, given a piecewise offset series: 23 hours on a spring-forward day, 25 on a fall-back day, and 16 or 32 on a travel day if you feed them one user's real offset history.

Plus the anti-pattern, exported on purpose: a rollup that buckets on a fixed 24-hour UTC window. It is there so the test suite can measure exactly which samples it invents and which it loses, rather than asserting in the abstract that it is bad.

The implementation#

This is the canonical file. It lives in the repo at cookbook/day-boundary-rollup.mjs and its test file runs in CI on every commit, so what you are reading is what passes.

/**
 * day-boundary-rollup.mjs
 *
 * WHAT THIS IMPLEMENTS
 *   Civil-date daily rollups for health samples. Every sample is stored as
 *   { utcInstant, utcOffsetMinutes, civilDate } where `civilDate` is computed
 *   from instant + offset at WRITE time and is never re-derived at read time.
 *   The rollup groups strictly on the stored `civilDate`, so it is correct
 *   across DST transitions (a civil day is 23, 24 or 25 hours long) and across
 *   travel (each sample carries the offset the user actually experienced).
 *
 *   Also included, deliberately, is the anti-pattern: a rollup that buckets on
 *   a fixed 24-hour UTC window. It is exported so tests can show precisely
 *   which samples it drops and which it double-counts.
 *
 * WHICH aifitnessapi.com PAGE DOCUMENTS THE PATTERN
 *   https://aifitnessapi.com/architecture/timezones-and-day-boundaries
 *   https://aifitnessapi.com/day-boundaries  (interactive demo of the same bug)
 *   https://aifitnessapi.com/cookbook/day-boundary-rollup  (this recipe)
 *
 * Node 20+. Zero runtime dependencies. No I/O, no clock reads: the store and
 * the offset series are injected.
 *
 * MIT — from aifitnessapi.com/cookbook
 */

const MS_PER_MINUTE = 60_000;
const MS_PER_HOUR = 3_600_000;
const MS_PER_DAY = 86_400_000;

/**
 * Day-rule identifiers. The rule that produced a `civilDate` is stored on the
 * row, because you will change the rule at least once and you need to know
 * which rows were written under the old one.
 */
export const DAY_RULE = Object.freeze({
  /** The zone offset in effect at each sample's own timestamp. The default. */
  SAMPLE_OFFSET: "sample-offset/v1",
  /** A fixed home or profile zone applied to every sample. */
  FIXED_HOME_ZONE: "fixed-home-zone/v1",
});

/** How the offset on a sample was obtained. Confidence, not correctness. */
export const OFFSET_SOURCE = Object.freeze({
  PROVIDER: "provider",
  DEVICE_REPORTED: "device-reported",
  INFERRED: "inferred",
});

// ---------------------------------------------------------------------------
// Civil-date arithmetic
// ---------------------------------------------------------------------------

function pad2(n) {
  return String(n).padStart(2, "0");
}

/**
 * Format the UTC calendar date of an instant as YYYY-MM-DD.
 * @param {number} instantMs
 * @returns {string}
 */
export function utcDateOf(instantMs) {
  const d = new Date(instantMs);
  return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
}

/**
 * The civil (local) date a sample belongs to, from its instant and the UTC
 * offset in effect at that instant. This is the only place the derivation
 * happens; callers store the result rather than recomputing it.
 *
 * @param {number} instantMs epoch milliseconds
 * @param {number} utcOffsetMinutes minutes east of UTC (e.g. -300 for EST)
 * @returns {string} YYYY-MM-DD
 */
export function civilDateFrom(instantMs, utcOffsetMinutes) {
  if (!Number.isFinite(instantMs)) throw new TypeError("instantMs must be a finite number");
  if (!Number.isFinite(utcOffsetMinutes)) {
    // Never default a missing offset to UTC. A guessed offset rewrites the
    // user's travel history and there is no way to tell later that you guessed.
    throw new TypeError("utcOffsetMinutes must be a finite number; refuse to default it");
  }
  return utcDateOf(instantMs + utcOffsetMinutes * MS_PER_MINUTE);
}

/** Midnight UTC of a YYYY-MM-DD string, in epoch milliseconds. */
export function utcMidnightOf(civilDate) {
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(civilDate);
  if (!m) throw new TypeError(`not a YYYY-MM-DD civil date: ${civilDate}`);
  return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
}

/** The civil date after this one. Successor arithmetic, never "+ 24 hours". */
export function nextCivilDate(civilDate) {
  return utcDateOf(utcMidnightOf(civilDate) + MS_PER_DAY);
}

// ---------------------------------------------------------------------------
// Offset series — a piecewise-constant model of one zone's UTC offset
// ---------------------------------------------------------------------------

/**
 * Build an offset series. This is a deliberately tiny stand-in for a tz
 * database: enough to reason about a specific transition in a test, not a
 * replacement for the real rules. Production code resolves offsets from the
 * platform (Intl / ZoneRules / the provider's own field) and stores the result.
 *
 * @param {Array<{from: number|null, offsetMinutes: number}>} segments
 *   `from` is the instant the offset takes effect; `null` means "since forever".
 *   Segments are sorted ascending internally.
 */
export function createOffsetSeries(segments) {
  if (!Array.isArray(segments) || segments.length === 0) {
    throw new TypeError("offset series needs at least one segment");
  }
  const sorted = segments
    .map((s) => ({ from: s.from == null ? -Infinity : s.from, offsetMinutes: s.offsetMinutes }))
    .sort((a, b) => a.from - b.from);
  if (sorted[0].from !== -Infinity) sorted[0] = { ...sorted[0], from: -Infinity };

  return {
    segments: sorted,
    /** Offset in effect at an instant. Transitions are inclusive of `from`. */
    offsetAt(instantMs) {
      let out = sorted[0].offsetMinutes;
      for (const seg of sorted) {
        if (instantMs >= seg.from) out = seg.offsetMinutes;
        else break;
      }
      return out;
    },
    /** Distinct offsets that ever apply. */
    offsets() {
      return [...new Set(sorted.map((s) => s.offsetMinutes))];
    },
    /** Transition instants (excluding the -Infinity sentinel). */
    transitions() {
      return sorted.map((s) => s.from).filter((f) => Number.isFinite(f));
    },
  };
}

/**
 * The first instant of a civil date under an offset series.
 *
 * Normally this is `utcMidnight(date) - offset`, checked against the offset
 * actually in effect there. Where a zone shifts its clocks at midnight itself,
 * local midnight may not exist; then the day starts at the transition instant.
 *
 * @returns {number} epoch milliseconds
 */
export function startOfCivilDayUtc(civilDate, series) {
  const base = utcMidnightOf(civilDate);
  const candidates = [];
  for (const offsetMinutes of series.offsets()) {
    const t = base - offsetMinutes * MS_PER_MINUTE;
    if (series.offsetAt(t) === offsetMinutes) candidates.push(t);
  }
  if (candidates.length > 0) return Math.min(...candidates);

  // Local midnight was skipped by a transition. The day begins at the first
  // transition instant that already reads as this civil date.
  for (const t of series.transitions().sort((a, b) => a - b)) {
    if (civilDateFrom(t, series.offsetAt(t)) === civilDate) return t;
  }
  throw new RangeError(`civil date ${civilDate} does not occur in this offset series`);
}

/**
 * How long a civil day actually lasts, in hours. 23 on a spring-forward day,
 * 25 on a fall-back day, 24 the rest of the year — and 16 or 32 on a travel
 * day, if you feed it a series built from one user's real offset history.
 */
export function civilDayLengthHours(civilDate, series) {
  const start = startOfCivilDayUtc(civilDate, series);
  const end = startOfCivilDayUtc(nextCivilDate(civilDate), series);
  return (end - start) / MS_PER_HOUR;
}

/** `{ civilDate, startUtc, endUtc, hours }` — the half-open bounds of a civil day. */
export function civilDayBoundsUtc(civilDate, series) {
  const startUtc = startOfCivilDayUtc(civilDate, series);
  const endUtc = startOfCivilDayUtc(nextCivilDate(civilDate), series);
  return { civilDate, startUtc, endUtc, hours: (endUtc - startUtc) / MS_PER_HOUR };
}

// ---------------------------------------------------------------------------
// Write path — civilDate is stamped here, once
// ---------------------------------------------------------------------------

/** Minimal injectable store. Swap for your real table; the writer only calls put(). */
export function createMemorySampleStore() {
  const rows = [];
  const seen = new Set();
  return {
    /** Idempotent on (provider, externalId): a replayed window is a no-op. */
    put(record) {
      const key = `${record.provider}�${record.externalId}`;
      if (seen.has(key)) return false;
      seen.add(key);
      rows.push(record);
      return true;
    },
    all() {
      return rows.slice();
    },
    get size() {
      return rows.length;
    },
  };
}

/**
 * A writer that computes the civil date once, at ingest, and refuses to store
 * a sample whose offset it does not know.
 *
 * @param {{store: {put: Function}, dayRule?: string}} deps
 */
export function createSampleWriter({ store, dayRule = DAY_RULE.SAMPLE_OFFSET }) {
  if (!store || typeof store.put !== "function") throw new TypeError("store.put is required");

  return {
    dayRule,
    /**
     * @param {{
     *   provider: string, externalId: string, metric: string, value: number,
     *   utcInstant: number|string|Date, utcOffsetMinutes: number,
     *   zoneId?: string, offsetSource?: string
     * }} sample
     */
    write(sample) {
      const utcInstant =
        sample.utcInstant instanceof Date
          ? sample.utcInstant.getTime()
          : typeof sample.utcInstant === "string"
            ? Date.parse(sample.utcInstant)
            : sample.utcInstant;

      const record = Object.freeze({
        provider: sample.provider,
        externalId: sample.externalId,
        metric: sample.metric,
        value: sample.value,
        // Three columns, not one. All written here.
        utcInstant,
        utcOffsetMinutes: sample.utcOffsetMinutes,
        civilDate: civilDateFrom(utcInstant, sample.utcOffsetMinutes),
        zoneId: sample.zoneId ?? null,
        offsetSource: sample.offsetSource ?? OFFSET_SOURCE.PROVIDER,
        dayRule,
      });

      store.put(record);
      return record;
    },
  };
}

// ---------------------------------------------------------------------------
// Read path — group on the stored civil date, recompute, never increment
// ---------------------------------------------------------------------------

/**
 * The correct rollup. Groups strictly on the stored `civilDate`; it will throw
 * rather than re-derive a missing one, because re-deriving at read time is the
 * bug this recipe exists to prevent.
 *
 * Pure function of its input: recompute a cell to answer "is this number
 * right?", never increment a counter.
 *
 * @param {Array<object>} records
 * @returns {Array<{civilDate: string, total: number, count: number, sampleIds: string[]}>}
 */
export function rollupByCivilDate(records) {
  const buckets = new Map();
  for (const r of records) {
    if (typeof r.civilDate !== "string") {
      throw new TypeError(
        `sample ${r.externalId} has no stored civilDate; the rollup will not derive one at read time`,
      );
    }
    bump(buckets, r.civilDate, r);
  }
  return finish(buckets);
}

/**
 * ANTI-PATTERN — DO NOT SHIP. Included only so a test can measure the damage.
 *
 * Buckets samples into fixed 24-hour UTC windows, i.e. `date(utcInstant)`. This
 * is what a `time_bucket('1 day', ts)` continuous aggregate does, and what any
 * "group by date(timestamp)" query does. It is wrong for every user outside
 * UTC, every day; on DST days it drops or double-counts a whole hour; and for
 * a traveller it files activity under the day they were not living in.
 *
 * @returns {Array<{civilDate: string, total: number, count: number, sampleIds: string[]}>}
 */
export function rollupByFixedUtcWindowAntiPattern(records) {
  const buckets = new Map();
  for (const r of records) bump(buckets, utcDateOf(r.utcInstant), r);
  return finish(buckets);
}

function bump(buckets, key, r) {
  let b = buckets.get(key);
  if (!b) {
    b = { civilDate: key, total: 0, count: 0, sampleIds: [] };
    buckets.set(key, b);
  }
  b.total += r.value;
  b.count += 1;
  b.sampleIds.push(r.externalId);
}

function finish(buckets) {
  return [...buckets.values()].sort((a, b) => (a.civilDate < b.civilDate ? -1 : 1));
}

/**
 * Every sample the anti-pattern files under a date the user did not experience.
 * @returns {Array<{externalId: string, civilDate: string, utcBucket: string}>}
 */
export function misfiledByUtcWindow(records) {
  const out = [];
  for (const r of records) {
    const utcBucket = utcDateOf(r.utcInstant);
    if (utcBucket !== r.civilDate) {
      out.push({ externalId: r.externalId, civilDate: r.civilDate, utcBucket });
    }
  }
  return out;
}

/**
 * Line up the correct rollup against the anti-pattern one.
 *
 * `delta` is anti − correct: positive means the UTC window double-counted
 * activity into that date, negative means it dropped activity out of it.
 */
export function compareRollups(correct, antiPattern) {
  const byDate = new Map();
  for (const b of correct) {
    byDate.set(b.civilDate, {
      civilDate: b.civilDate,
      correctTotal: b.total,
      correctCount: b.count,
      antiTotal: 0,
      antiCount: 0,
    });
  }
  for (const b of antiPattern) {
    const row = byDate.get(b.civilDate) ?? {
      civilDate: b.civilDate,
      correctTotal: 0,
      correctCount: 0,
      antiTotal: 0,
      antiCount: 0,
    };
    row.antiTotal = b.total;
    row.antiCount = b.count;
    byDate.set(b.civilDate, row);
  }
  return [...byDate.values()]
    .sort((a, b) => (a.civilDate < b.civilDate ? -1 : 1))
    .map((r) => ({ ...r, delta: r.antiTotal - r.correctTotal }));
}

Adapting it#

Offsets are seconds in most real schemas, minutes here. The recipe uses minutes because that is what most SDKs hand you, but not every zone is a whole number of hours from UTC and an hours-valued column corrupts the users who live in the ones that are not. If your storage column is seconds, convert at the writer boundary and keep the column as the wider type.

Swap the store, keep the writer. createMemorySampleStore exists so the tests need no database. In production the writer only calls put, so a Postgres upsert keyed on the provider's own record identity drops straight in. Keep the idempotency: a replayed window has to be a no-op, because at-least-once is the only delivery a resumable ingest can offer.

The offset series is a stand-in, not a tz database. createOffsetSeries models one zone's offset as piecewise-constant segments, which is enough to reason about a specific transition in a test and nowhere near enough for production. Resolve real offsets from the platform, the provider field, or the device, record which of those it was, and store the answer. Treat the tz database as a dependency with a release cadence, not as a constant.

Carry the day rule as a column. DAY_RULE.SAMPLE_OFFSET is the default here: the day the user actually lived through. If your product needs a fixed home zone instead — a coaching product where a week is a training block — that is a legitimate second rule, and versioning it on the row is the difference between a recompute and an archaeology project when you change your mind. Same machinery as metric versioning and recompute.

The rollup is a pure function, deliberately. It recomputes from raw samples every time rather than incrementing a counter. That is what lets you answer "is this number right?" by recomputing one cell. The storage layer that makes recomputing cheap at scale — partitioning, refresh, the dirty-day queue — is time-series storage for health data, and the queue that decides which cells to recompute is incremental sync.

The test contract#

The suite in cookbook/day-boundary-rollup.test.mjs asserts:

  • A spring-forward civil day holds 23 hours of samples and still rolls up as one date. 2026-03-08 in America/New_York runs from 2026-03-08T05:00:00Z to 2026-03-09T04:00:00Z. Hourly samples across it produce 23 rows, two distinct offsets, no local 02:00 anywhere, and exactly one rollup bucket.
  • A fall-back day holds 25, and local 01:00 appears twice at two different instants with two different offsets — the case that makes "just store local time" unrecoverable rather than merely lazy.
  • A traveller's samples land on the civil date they experienced. Six samples across a LAX-to-LHR flight roll up as 2, 3 and 1 across three civil dates. Under a UTC window the traveller's 10 June does not exist at all.
  • The traveller's 11 June is 16 hours long, computed from their own offset history, with the day starting at 2026-06-11T07:00:00Z.
  • The anti-pattern's error is exact, not approximate. On the 23-hour day the UTC window counts 24 hours: it drops the user's last four EDT hours into the 9th and pulls five hours of the 7th in, for a net of one invented hour. On the 25-hour day it drops five and pulls in four, for a net of one lost hour. The test asserts the individual sample identities, not just the totals.
  • The rollup throws on a row with no stored civil date, and the anti-pattern cheerfully derives one — which is how it gets shipped.
  • Writes are idempotent on provider record identity, so a replayed window leaves the total unchanged.

What this does not fix#

It does not make the offset true. It makes it recorded, which is a smaller claim. A user who never changes their phone's timezone while travelling hands you a confidently wrong offset and nothing in the data reveals it; that is what the offsetSource field is for, and it lets you tell those rows apart later rather than making them correct.

It also does nothing about samples that arrive days late or get retro-edited by the platform months on. Those change which days are dirty, not how a day is defined. The three columns are what make the repair cheap when it happens.

Frequently asked questions

Where in a health pipeline should the local date get computed?
At ingest, once, on the write path, and then stored as a real column. The writer in this recipe takes the instant and the UTC offset in effect at that instant and stamps the result onto the row before it reaches the store. Computing it later, in the query, means every daily read has to carry the user's full offset history in scope, gives up a plain index, and makes the answer to what were their steps on the 14th depend on where the user's phone happens to be when they ask. Denormalising one column buys a rollup that is indexable, transition-proof and travel-proof, and the cost is that you have to be willing to recompute the column when you learn an offset was wrong.
What should the writer do with a sample that arrives without an offset?
Refuse it, loudly, rather than filling in a value. This implementation throws a TypeError instead of defaulting to UTC or to the device's current zone, because a filled-in offset is indistinguishable from an observed one a week later and it quietly rewrites every trip the user ever took. The practical handling is upstream of the writer: resolve the offset from the platform field, the device, or the recording zone at capture time, record which of those it was in the offsetSource column, and let anything you genuinely cannot resolve fail into a gap you can see. A recorded guess you can identify later is survivable; an invisible one is not.
How do I exercise a 23-hour day in tests without waiting for March?
Inject the zone as a piecewise offset series rather than reading a real timezone database. The recipe's createOffsetSeries takes a list of segments, each a transition instant and the offset that takes effect there, and every helper resolves against that. So a spring-forward day is three lines of fixture, a traveller's 16-hour day is two segments built from that one user's own offset history, and the whole suite runs in milliseconds with no clock and no network. Keep the series out of production code, though: it is a stand-in built to reason about one transition, not a replacement for the real rules, which change when governments change them.
Can the rollup run against a real database rather than the in-memory store?
Yes, and it is designed for that swap. The writer only ever calls put on whatever store you hand it, so a Postgres upsert keyed on the provider's own record identity drops straight in, and the rollup is a pure function over rows so it maps onto a group-by on the civil-date column. Two properties have to survive the swap. Writes stay idempotent on provider record identity, because a replayed window must be a no-op rather than a doubling. And the rollup write is an update rather than a do-nothing on conflict, or you pin a day to whichever partial value happened to arrive first that morning.

Keep reading

Elsewhere on the site

Pages that share this one’s concepts and sources, from other sections.

Next steps

Was this page useful?

Independent comparison, last reviewed August 12, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.

← All cookbook · by AIFitnessAPI