Home

boring / v2

publicBoring-Software-Inc/v2
Overview Code History Branches Pull requestsIssuesInsights Campfire Docs
main
HomeOverview Code PRsIssues

#174 [Detail Bug] Rules page: per-rule sparklines show incorrect last-24-hours timeline (clock-hour bucket vs hours-ago)

open

Opened by detail-app[bot] · 6 days ago

detail-app[bot]opened this issueAuthor· 6 days ago

Sign in to comment.

Linked pull requests

No linked pull requests yet.

Detail Bug Report

app.detail.dev/org_0f605249-55f3-46eb-880c-20ae3e45ae99/…/bug_97137385-1d54-4dca-bc9f-7a109bb0e936

Introduced in cb4771ee2969bcfc1605311de8aa82dba7365e39 by @ripgrim on Jul 13, 2026

Summary

  • Context: 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.
  • Bug: The per-rule series is bucketed by clock hour-of-day (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.
  • Actual vs. expected: A rule failure 5 hours ago lands at the array position corresponding to 19h ago (and shifts by one slot every day), instead of 5h ago; the rule card's sparkline paints the oldest end of the window at the wrong place while the adjacent header sparkline for the same page uses the correct rolling ordering.
  • Impact: Every rule card sparkline on /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.

Code with Bug

// 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
}

Explanation

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.

Codebase Inconsistency

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.

Failing Test

// 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).

Recommended Fix

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.

History

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.