Opened by detail-app[bot] · 6 days ago
Sign in to comment.
app.detail.dev/org_0f605249-55f3-46eb-880c-20ae3e45ae99/…/bug_97137385-1d54-4dca-bc9f-7a109bb0e936
Introduced in cb4771ee2969bcfc1605311de8aa82dba7365e39 by @ripgrim on Jul 13, 2026
getRulesStats in packages/db/src/services/insights.ts computes the per-rule sparkline data shown on the /rules page; the Sparkline component (apps/web/src/components/glaze/ui/chart-strip.tsx) documents its data prop as "Values in order, oldest first", i.e. index 23 = now.extract(hour FROM started_at)) and written to stat.series[clockHour], while the same function's top-level matchesSeries/actionedSeries were converted to rolling hours-ago ordering (series[23 - h]). The per-rule block was missed when commit cb4771ee ("series bucket by hours-ago (index 23 = now), on home AND /rules") converted the rest./rules renders a wrong timeline — a moderator reading "matches per hour, last 24 hours" sees activity attributed to the wrong hours (up to a full-day skew), and the screen-reader narration (trendWords) reports a direction computed from the wrong window edges, contradicting the header sparkline rendered beside the cards from the same data source.// packages/db/src/services/insights.ts — the per-rule block of getRulesStats
const perRuleResult = await db.execute(sql`
SELECT s.rule_id AS ref,
extract(hour FROM s.started_at)::int AS bucket,
count(*)::int AS n
FROM run_steps s JOIN runs r ON r.id = s.run_id
WHERE r.repo_full_name = ${repoFullName}
AND s.node_kind = 'rule' AND s.status = 'fail'
AND s.rule_id IS NOT NULL
AND s.started_at > now() - interval '24 hours'
GROUP BY 1, 2`);
const perRuleMap = new Map<string, RuleStat>();
for (const row of perRuleResult.rows as unknown as {
ref: string;
bucket: number;
n: number;
}[]) {
let stat = perRuleMap.get(row.ref);
if (!stat) {
stat = { ref: row.ref, matches24h: 0, series: Array(24).fill(0) };
perRuleMap.set(row.ref, stat);
}
const index = Number(row.bucket);
stat.matches24h += Number(row.n);
if (index >= 0 && index < 24) {
stat.series[index] = Number(row.n); // <-- BUG 🔴 index is CLOCK hour-of-day, not hours-ago
}
}
// packages/db/src/services/insights.ts — rollingSeries helper
const h = Number(row.h);
if (h >= 0 && h < 24) {
series[23 - h] = Number(row.n); // <-- FIX 🟢 this is the ordering the Sparkline expects
}
Sparkline expects a rolling 24-slot array ordered oldest→newest (index 23 = “now”). In getRulesStats, the top-level page series (matchesSeries/actionedSeries) follow this contract by indexing series[23 - hoursAgo]. The per-rule series instead uses extract(hour FROM s.started_at) and writes directly to stat.series[clockHour], which represents wall-clock hour-of-day, not hours-ago. Since the per-rule series is passed through unchanged (RuleStat.series → trend → <Sparkline data={rule.trend}>), each rule card sparkline is misordered and can disagree with the header sparkline on the same /rules page.
On /rules, rule-header-stats.tsx renders a Sparkline using stats.matches24h.series (correct rolling ordering), while rule-card.tsx renders Sparkline using per-rule trend sourced from RuleStat.series (clock-hour ordering). Same component + same conceptual metric, but contradictory ordering.
// packages/db/src/services/perrule-order-repro.test.ts
import { StartedPostgreSqlContainer, PostgreSqlContainer } from '@testcontainers/postgresql';
import { sql } from 'drizzle-orm';
import { db, migrate } from '../db';
import { getRulesStats } from './insights';
/**
* Repro: per-rule RuleStat.series uses clock-hour bucket, while matches24h.series uses hours-ago.
*/
describe('getRulesStats per-rule series ordering', () => {
let container: StartedPostgreSqlContainer;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('test')
.withUsername('test')
.withPassword('test')
.start();
process.env.DATABASE_URL = container.getConnectionUri();
await migrate();
});
afterAll(async () => {
await container.stop();
});
it('places an event at now-5h into the same index for top-level and per-rule series', async () => {
const repoFullName = 'repro/repo';
const ruleRef = 'repro-rule@1';
// Seed a run + failing rule step 5 hours ago
await db.execute(sql`
INSERT INTO repos (full_name) VALUES (${repoFullName}) ON CONFLICT DO NOTHING;
`);
const runId = (await db.execute(sql`
INSERT INTO runs (repo_full_name, started_at)
VALUES (${repoFullName}, now() - interval '5 hours')
RETURNING id;
`)).rows[0]!.id as string;
await db.execute(sql`
INSERT INTO run_steps (run_id, node_kind, status, rule_id, started_at)
VALUES (${runId}, 'rule', 'fail', ${ruleRef}, now() - interval '5 hours');
`);
const stats = await getRulesStats({ repoFullName });
// Expected rolling index for 5h ago is 23 - 5 = 18
expect(stats.matches24h.series.findIndex((x) => x > 0)).toBe(18);
const perRule = stats.perRule.find((r) => r.ref === ruleRef);
expect(perRule).toBeDefined();
expect(perRule!.series.findIndex((x) => x > 0)).toBe(18);
});
});
Test failure observed: Expected: 18, Received: 4 (per-rule series index uses clock-hour bucket).
Reuse the same rolling hours-ago bucketing/order for the per-rule query: group by hours-ago (e.g., floor(extract(epoch FROM (now() - s.started_at)) / 3600)::int AS h) and write stat.series[23 - h] = n (mirroring matchesSeries/actionedSeries), and add an assertion/test pinning per-rule series ordering.
This bug was introduced in commit cb4771ee ("fix(web): home stat cards — number and series tell one story"). The change converted top-level series in getRulesStats to hours-ago ordering but left the per-rule extract(hour FROM s.started_at)/stat.series[index] logic unchanged, creating the ordering mismatch.