diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts
index 9665f42..1a8c175 100644
--- a/frontend/src/api/rulebooks.ts
+++ b/frontend/src/api/rulebooks.ts
@@ -1,3 +1,5 @@
+import type { RecordUsage } from "@/types/usage";
+
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
/** How a rule reaches a session (milestone 307). */
@@ -96,6 +98,13 @@ export interface RuleHeader {
* A date (YYYY-MM-DD), or the literal "never".
*/
last_verified?: string;
+ /**
+ * Surfaced-vs-opened counts from `rule_usage_events` (milestone 333).
+ * Zero-filled by the list route, so a rule predating the table reads as
+ * "never surfaced" rather than as a missing field — which for a while is
+ * every rule on every install.
+ */
+ usage?: RecordUsage;
}
export interface ApplicableRules {
diff --git a/frontend/src/api/snippets.ts b/frontend/src/api/snippets.ts
index 25bd2d8..0931e14 100644
--- a/frontend/src/api/snippets.ts
+++ b/frontend/src/api/snippets.ts
@@ -1,3 +1,5 @@
+import type { RecordUsage } from "@/types/usage";
+
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
/** One canonical location of a reusable thing. A snippet that unifies several
@@ -50,15 +52,11 @@ export interface Snippet {
owner?: string | null;
}
-/** How often a record was put in front of an agent versus actually opened.
- * A high `surfaced_count` with `pull_count: 0` is dead weight — it occupies a
- * slot in every future auto-inject menu while never being used. */
-export interface SnippetUsage {
- surfaced_count: number;
- pull_count: number;
- last_surfaced_at: string | null;
- last_pulled_at: string | null;
-}
+/** Kept as a name because every consumer here says "snippet usage" — but it IS
+ * the shared shape, since rules answer the same question off their own table
+ * (milestone 333). The reasoning lives on `RecordUsage`; duplicating the four
+ * fields here is how the two drift. */
+export type SnippetUsage = RecordUsage;
/** Result of the last drift check — does the recorded location and code still
* match source? The check runs agent-side (Scribe has no checkout); this is the
diff --git a/frontend/src/assets/components.css b/frontend/src/assets/components.css
index 0d76513..c886ef0 100644
--- a/frontend/src/assets/components.css
+++ b/frontend/src/assets/components.css
@@ -351,3 +351,29 @@
.required { color: var(--fs-error); }
.field-hint { margin: 0.3rem 0 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
+
+/* --- usage badge ----------------------------------------------------------
+ "surfaced N×, opened M×" on a list row, for any record kind the retrieval
+ surfaces can choose: snippets and notes from note_usage_events, rules from
+ rule_usage_events. Promoted here from SnippetListView's scoped block when
+ the rule list needed the same chip (milestone 333 step 5) — a second scoped
+ copy is how the ninth duplicated CSS family starts (#3207).
+
+ Geometry and colour only. A view keeps its own spacing as a scoped
+ remainder, the way it does for every other recipe in this file. */
+.usage-tag {
+ font-size: 0.7rem;
+ padding: 0.1rem 0.4rem;
+ border-radius: 4px;
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+ background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
+ color: var(--fs-text-tertiary-fg);
+}
+
+/* Dead weight is a nudge, not an error — it warns in the warning colour rather
+ than the danger one, because the record isn't broken, just unearned. */
+.usage-tag.usage-dead {
+ background: color-mix(in srgb, var(--fs-warning) 18%, transparent);
+ color: var(--fs-warning-fg);
+}
diff --git a/frontend/src/components/UsageBadge.vue b/frontend/src/components/UsageBadge.vue
new file mode 100644
index 0000000..47fabbb
--- /dev/null
+++ b/frontend/src/components/UsageBadge.vue
@@ -0,0 +1,62 @@
+
+
+
+ {{ label() }}
+
+
+
diff --git a/frontend/src/components/rules/RuleListPane.vue b/frontend/src/components/rules/RuleListPane.vue
index 6c2a662..32d08f7 100644
--- a/frontend/src/components/rules/RuleListPane.vue
+++ b/frontend/src/components/rules/RuleListPane.vue
@@ -1,5 +1,16 @@
@@ -456,14 +431,7 @@ function usageTitle(s: SnippetListItem): string {
{{ driftBadge(s) }}
-
- {{ usageBadge(s) }}
-
+
by {{ s.owner ?? "another user" }}
@@ -757,23 +725,6 @@ function usageTitle(s: SnippetListItem): string {
color: var(--fs-error-fg);
}
-.usage-tag {
- font-size: 0.7rem;
- padding: 0.1rem 0.4rem;
- border-radius: 4px;
- white-space: nowrap;
- font-variant-numeric: tabular-nums;
- background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
- color: var(--fs-text-tertiary-fg);
-}
-
-/* Dead weight is a nudge, not an error — it warns in the warning colour rather
- than the danger one, because the record isn't broken, just unearned. */
-.usage-tag.usage-dead {
- background: color-mix(in srgb, var(--fs-warning) 18%, transparent);
- color: var(--fs-warning-fg);
-}
-
/* Header + select-mode */
.header-actions {
display: flex;
diff --git a/src/scribe/routes/rulebooks.py b/src/scribe/routes/rulebooks.py
index 17ab59c..5f6b77e 100644
--- a/src/scribe/routes/rulebooks.py
+++ b/src/scribe/routes/rulebooks.py
@@ -10,7 +10,9 @@ from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
import scribe.services.rulebooks as rulebooks_svc
from scribe.services.trash import delete as trash_delete
-from scribe.services.rule_usage import record_rule_pulled
+from scribe.services.rule_usage import (
+ empty_rule_usage, record_rule_pulled, usage_for_rules,
+)
rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
@@ -137,13 +139,24 @@ async def list_rules():
except ValueError:
return jsonify({"error": "rulebook_id, topic_id, project_id must be integers"}), 400
+ uid = get_current_user_id()
rows = await rulebooks_svc.list_rules(
- user_id=get_current_user_id(),
+ user_id=uid,
rulebook_id=rulebook_id,
topic_id=topic_id,
project_id=project_id,
)
- return jsonify({"rules": [r.to_dict() for r in rows]})
+ items = [r.to_dict() for r in rows]
+ # One aggregate for the whole page — a per-row lookup here would be N+1 by
+ # construction, the same reason the snippet list does it this way. Every
+ # row gets the key, zero-filled, so the UI renders "never surfaced" rather
+ # than having to treat a missing field as a state. That matters more here
+ # than for snippets: every rule on every install predates this table, so
+ # for a while the zero-filled shape IS the common case.
+ usage = await usage_for_rules([int(it["id"]) for it in items])
+ for it in items:
+ it["usage"] = usage.get(int(it["id"]), empty_rule_usage())
+ return jsonify({"rules": items})
@rulebooks_bp.post("/rulebook-topics//rules")
diff --git a/tests/test_routes_rulebooks.py b/tests/test_routes_rulebooks.py
index 01641f6..afe50d6 100644
--- a/tests/test_routes_rulebooks.py
+++ b/tests/test_routes_rulebooks.py
@@ -122,3 +122,27 @@ def test_rule_and_subscription_handlers_callable():
"relate_rules", "unrelate_rules",
):
assert callable(getattr(rb_routes, name))
+
+
+def test_the_rule_list_zero_fills_usage_on_every_row():
+ """Milestone 333 step 5, asserted the only way this harness allows.
+
+ There is no live-HTTP fixture here (see this module's docstring), so this
+ reads the handler's source. What it can still prove is the property that
+ gets forgotten: the route must attach the key to EVERY row, zero-filled,
+ rather than only to rows that happen to have events. Every rule on every
+ existing install predates `rule_usage_events`, so a route that only
+ attached the key when it found something would leave the badge component
+ reading `undefined` on almost every row — and the difference between "no
+ events" and "no field" is exactly the distinction #2663 is about.
+ """
+ import inspect
+
+ from scribe.routes import rulebooks as rb_routes
+
+ src = inspect.getsource(rb_routes.list_rules)
+ assert "usage_for_rules" in src, "the rule list does not read usage at all"
+ assert "empty_rule_usage()" in src, (
+ "the rule list does not zero-fill — a rule with no events would come "
+ "back without the key rather than with an empty one"
+ )
diff --git a/tests/test_services_rule_usage.py b/tests/test_services_rule_usage.py
index 3356390..6efb23c 100644
--- a/tests/test_services_rule_usage.py
+++ b/tests/test_services_rule_usage.py
@@ -116,3 +116,65 @@ def test_the_model_serialises_the_fields_the_ratio_needs():
# created_at is server-defaulted, so it is None until the row is flushed —
# `iso()` must tolerate that rather than raising on a fresh instance.
assert row["created_at"] is None
+
+
+# ─── the readout (milestone 333 step 5) ──────────────────────────────────────
+# Integration: a real GROUP BY over a real table. Step 1 unit-tested the WRITE
+# path and the zero shape and left the aggregate uncovered, which only became
+# load-bearing when the rule list started rendering it.
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_usage_for_rules_aggregates_per_rule(_dispose_engine):
+ from sqlalchemy import delete
+
+ from scribe.models import async_session
+ from scribe.models.rule_usage import RuleUsageEvent
+
+ async with async_session() as s:
+ s.add_all([
+ RuleUsageEvent(user_id=990020, rule_id=6001,
+ event=SURFACED, source="write_path_rule"),
+ RuleUsageEvent(user_id=990020, rule_id=6001,
+ event=SURFACED, source="write_path_rule"),
+ RuleUsageEvent(user_id=990020, rule_id=6001,
+ event=PULLED, source="mcp_get_rule"),
+ RuleUsageEvent(user_id=990020, rule_id=6002,
+ event=SURFACED, source="write_path_rule"),
+ ])
+ await s.commit()
+ try:
+ out = await rule_usage.usage_for_rules([6001, 6002, 6003])
+
+ assert out[6001]["surfaced_count"] == 2
+ assert out[6001]["pull_count"] == 1
+ assert out[6001]["last_surfaced_at"] is not None
+ assert out[6001]["last_pulled_at"] is not None
+
+ # Surfaced twice as often as it was opened — never, in this case.
+ assert out[6002]["surfaced_count"] == 1
+ assert out[6002]["pull_count"] == 0
+ assert out[6002]["last_pulled_at"] is None
+
+ # A rule with NO events still comes back, zero-filled. The caller must
+ # never have to tell "no events" from "not in the result" — and on any
+ # existing install that is nearly every rule.
+ assert out[6003] == rule_usage.empty_rule_usage()
+ finally:
+ async with async_session() as s:
+ await s.execute(
+ delete(RuleUsageEvent).where(RuleUsageEvent.user_id == 990020)
+ )
+ await s.commit()
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_usage_for_rules_on_an_empty_id_list_asks_the_database_nothing(
+ _dispose_engine,
+):
+ """The list route calls this with whatever the page holds, which on an
+ empty topic is nothing. An unguarded `IN ()` is both a pointless round trip
+ and, on some drivers, a syntax error."""
+ assert await rule_usage.usage_for_rules([]) == {}