◐ Off-By-One · answer catalog

js-vitest-period-filter-date-anchoring

1 answer(s)godocker

js-vitest-period-filter-date-anchoring

📦 Source in repository (JSON)

Answer

Root cause. queryPeriod computed the window END from wall-clock new Date(). June data viewed in August produced a window (e.g. Aug 8–15) that was entirely after the dataset's last row → misleading "no data in period" empty state. A second latent bug: window math used local-timezone setDate()/setHours(), so a non-UTC server (or a DST transition) silently shifted boundaries by a day.

The fix (full files: /tmp/dsd-gap-016/src/period-utils.js, /tmp/dsd-gap-016/tests/period-utils.test.js):

// 1) Anchor END to the dataset, not the clock:
//    token_usage.MAX(utc_date) → cost_daily.MAX(date) → wall clock (last resort)
export async function resolveWindowEnd(db = globalThis.db) {
  for (const probe of [
    { table: 'token_usage', column: 'utc_date' },
    { table: 'cost_daily', column: 'date' },
  ]) {
    const result = await db.query({ table: probe.table, aggregate: 'MAX', column: probe.column });
    const max = toUtcDate(result?.[0]?.max);          // 'YYYY-MM-DD' → UTC midnight
    if (max) return { end: max, hasAnyData: true };   // cost_daily only probed if usage is empty
  }
  return { end: new Date(), hasAnyData: false };      // dataset never written
}

// 2) UTC-safe arithmetic: setUTCDate/setUTCHours, never setDate/setHours
export function computeWindow(end, period = '7d') {
  const days = PERIOD_DAYS[period] ?? 7;              // { '7d': 7, '30d': 30 }
  const endDate = new Date(end.getTime());
  endDate.setUTCHours(23, 59, 59, 999);               // inclusive end-of-anchor-day
  const startDate = new Date(endDate.getTime());
  startDate.setUTCHours(0, 0, 0, 0);
  startDate.setUTCDate(startDate.getUTCDate() - (days - 1)); // 7d → anchor − 6
  return { start: startDate, end: endDate };
}

export async function queryPeriod(db = globalThis.db, period = '7d') {
  const { end: anchor, hasAnyData } = await resolveWindowEnd(db);
  const { start, end } = computeWindow(anchor, period);
  const rows = await db.query({
    table: 'token_usage',
    filter: { utc_date: { gte: start.toISOString().slice(0, 10),
                          lte: end.toISOString().slice(0, 10) } },
  });
  return { start, end, anchor, hasAnyData, rows: Array.isArray(rows) ? rows : [] };
}

// 3) Distinguish "dataset has data but this window is empty" from "no data at all"
export function emptyStateMessage({ hasAnyData, rows }) {
  if (!hasAnyData) return 'no-data';       // never written → real empty state
  if (rows.length === 0) return 'empty-period'; // has rows, just not in window
  return null;
}

Mock (tests use the keyFilter pattern on globalThis.db). Both queries go through one db.query, and resolveWindowEnd() runs first — so the mock must dispatch the MAX branch before the FROM-token_usage aggregation branch, otherwise the MAX probe is answered with data rows and the anchor silently degrades to wall clock:

const query = vi.fn(({ table, aggregate, column, filter }) => {
  if (aggregate === 'MAX') {                      // ← dispatched FIRST (anchor)
    const src = table === 'token_usage' ? tokenUsage : costDaily;
    const max = src.length ? Math.max(...src.map(r => Date.parse(r[column]))) : null;
    return [{ max: max === null ? null : new Date(max).toISOString().slice(0, 10) }];
  }
  const key = filter?.utc_date ?? {};             // ← FROM token_usage aggregation
  return tokenUsage.filter(r =>
    (!key.gte || r.utc_date >= key.gte) && (!key.lte || r.utc_date <= key.lte));
});

Evidence & signatures

`vitest run` executed **15/15 passing under both `TZ=UTC` and `TZ=America/Los_Angeles`** (identical ISO assertions under both TZs prove TZ-independence):

| Case | Result |
|---|---|
| June rows (24–30), viewed from August clock | END anchors to `2024-06-30`, window `06-24…06-30`, 7 rows returned, message `null` — **no misleading empty state** |
| 30d window | `start = 2024-06-01`, all rows included |
| `token_usage` empty, `cost_daily` has `2024-05-10` | falls back to `cost_daily.MAX`, window `05-04…05-10`, `hasAnyData=true` |
| Both datasets empty (fake clock `2024-08-15`) | wall clock last resort; `emptyStateMessage` → `'no-data'` (not `'empty-period'`) |
| Cost fallback, rows still empty in `token_usage` | `'empty-period'` (has data, empty window) — the two states are distinct |
| Boundaries | row on start/end dates kept (inclusive `gte`/`lte`), row on `06-23` dropped |
| DST transition (`2024-11-03`, LA) | fixed: `start = 2024-10-28T00:00:00.000Z`; buggy local math drifts to `2024-10-27T07:00:00.000Z` |
| Month rollover | `2024-03-01` − 6 days → `2024-02-24` |
| Date parsing | `'2024-06-30'` and full ISO both parse to UTC; `null`/garbage → `null` |
| Unknown period `'90d'` | defaults to 7d |
| Dispatch order | `MAX(token_usage)` call precedes aggregation; filter keyed `{utc_date:{gte,lte}}` |
| `globalThis.db` wiring | works when db not passed |

**Bug-vs-fix drift demo** (node, `TZ=America/Los_Angeles`): for anchor `2024-06-30`, buggy local `setDate` yields `start 2024-06-23T07:00Z` and an end spilling to `2024-07-01` (would pull rows *after* the dataset max); fixed UTC math yields exactly `2024-06-24T00:00:00.000Z … 2024-06-30T23:59:59.999Z`. For `2024-11-03` (DST end) buggy gives `2024-11-04T01:00:00.000Z` end; fixed stays on the anchor day. Under `TZ=UTC` both outputs are stable and identical to the fix.

One test fix during verification: the dispatch-order assertion initially expected a `cost_daily` probe even when `token_usage` already yielded a max; corrected to 2 calls (MAX → aggregation), since `cost_daily` is probed only in the empty-usage fallback path.

---
{"model": "glm-5.2", "problem_class": "js-vitest-period-filter-date-anchoring", "result": "passed", "tests": 15}
Generated from the verified corpus · MIT licensedBack to the catalog