diff --git a/convex/lib/downloadTrend.test.ts b/convex/lib/downloadTrend.test.ts index 0a822390..d81e7de1 100644 --- a/convex/lib/downloadTrend.test.ts +++ b/convex/lib/downloadTrend.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; -import { ACTIVITY_TREND_DAYS, buildDailyMetricTrends } from "./downloadTrend"; +import { + ACTIVITY_TREND_DAYS, + buildDailyActivityTrends, + buildDailyMetricTrends, +} from "./downloadTrend"; describe("download trend helpers", () => { it("fills missing days and totals the daily activity points", () => { - const trend = buildDailyMetricTrends( + const trend = buildDailyActivityTrends( [ { day: 20, downloads: 3, installs: 1 }, { day: 22, downloads: 8, installs: 4 }, @@ -26,15 +30,39 @@ describe("download trend helpers", () => { day: 22, value: 8, }); + expect(trend.installs.range).toBe("daily"); + expect(trend.installs.days).toBe(ACTIVITY_TREND_DAYS); + expect(trend.installs.total).toBe(5); + expect(trend.installs.points).toHaveLength(ACTIVITY_TREND_DAYS); + expect(trend.installs.points[0]).toEqual({ day: -4, value: 0 }); + expect(trend.installs.points.at(-1)).toEqual({ day: 25, value: 0 }); + expect(trend.installs.points.find((point) => point.day === 20)).toEqual({ + day: 20, + value: 1, + }); + expect(trend.installs.points.find((point) => point.day === 22)).toEqual({ + day: 22, + value: 4, + }); }); it("shows zero 30-day activity when no daily rows exist", () => { - const trend = buildDailyMetricTrends([], 25); + const trend = buildDailyActivityTrends([], 25); expect(trend.downloads.total).toBe(0); expect(trend.downloads.points).toHaveLength(ACTIVITY_TREND_DAYS); expect(trend.downloads.points[0]?.day).toBe(-4); expect(trend.downloads.points.at(-1)?.day).toBe(25); expect(trend.downloads.points.every((point) => point.value === 0)).toBe(true); + expect(trend.installs.total).toBe(0); + expect(trend.installs.points).toHaveLength(ACTIVITY_TREND_DAYS); + expect(trend.installs.points.every((point) => point.value === 0)).toBe(true); + }); + + it("keeps the public metric trend contract downloads-only", () => { + const trend = buildDailyMetricTrends([{ day: 25, downloads: 2, installs: 1 }], 25); + + expect(trend.downloads.total).toBe(2); + expect("installs" in trend).toBe(false); }); }); diff --git a/convex/lib/downloadTrend.ts b/convex/lib/downloadTrend.ts index 1b8e39b7..4306a8eb 100644 --- a/convex/lib/downloadTrend.ts +++ b/convex/lib/downloadTrend.ts @@ -20,6 +20,10 @@ export type DailyMetricTrends = { downloads: MetricTrend; }; +export type DailyActivityTrends = DailyMetricTrends & { + installs: MetricTrend; +}; + export function getActivityTrendRange(now: number) { return getActivityTrendRangeForEndDay(toDayKey(now)); } @@ -36,11 +40,15 @@ export function clampActivityTrendEndDay(endDayValue: number, now: number) { return Math.min(Math.trunc(endDayValue), toDayKey(now)); } -function buildDownloadTrend(rows: DailyMetricRow[], endDay: number): MetricTrend { +function buildMetricTrend( + rows: DailyMetricRow[], + endDay: number, + metric: "downloads" | "installs", +): MetricTrend { const { startDay } = getActivityTrendRangeForEndDay(endDay); const valuesByDay = new Map(); for (const row of rows) { - valuesByDay.set(row.day, Math.max(0, row.downloads)); + valuesByDay.set(row.day, Math.max(0, row[metric])); } const points = Array.from({ length: ACTIVITY_TREND_DAYS }, (_, index) => { @@ -57,6 +65,16 @@ function buildDownloadTrend(rows: DailyMetricRow[], endDay: number): MetricTrend export function buildDailyMetricTrends(rows: DailyMetricRow[], endDay: number): DailyMetricTrends { return { - downloads: buildDownloadTrend(rows, endDay), + downloads: buildMetricTrend(rows, endDay, "downloads"), + }; +} + +export function buildDailyActivityTrends( + rows: DailyMetricRow[], + endDay: number, +): DailyActivityTrends { + return { + ...buildDailyMetricTrends(rows, endDay), + installs: buildMetricTrend(rows, endDay, "installs"), }; } diff --git a/convex/publisherAbuse.test.ts b/convex/publisherAbuse.test.ts index 06ce2dd8..53175f3e 100644 --- a/convex/publisherAbuse.test.ts +++ b/convex/publisherAbuse.test.ts @@ -291,6 +291,16 @@ const listSignalsPageHandler = ( > )._handler; +const getSignalActivityTrendHandler = ( + publisherAbuse.getSignalActivityTrend as unknown as Wrapped< + { signalId: string; endDay: number }, + { + downloads: { total: number; points: Array<{ day: number; value: number }> }; + installs: { total: number; points: Array<{ day: number; value: number }> }; + } | null + > +)._handler; + const archiveTemporalPublisherAbuseSignalsPageHandler = ( publisherAbuse.archiveTemporalPublisherAbuseSignalsPageInternal as unknown as Wrapped< { @@ -959,6 +969,54 @@ describe("publisher abuse dry-run persistence", () => { expect(db.query).not.toHaveBeenCalled(); }); + it("returns bounded 30-day download and install trends for a signal", async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:moderator", + user: { _id: "users:moderator", role: "moderator" }, + } as never); + const rows = [ + { day: 98, downloads: 120, installs: 9 }, + { day: 100, downloads: 180, installs: 12 }, + ]; + const take = vi.fn(async () => rows); + const indexBuilder = { + eq: vi.fn().mockReturnThis(), + gte: vi.fn().mockReturnThis(), + lte: vi.fn().mockReturnThis(), + }; + const db = { + get: vi.fn(async (id: string) => + id === "publisherAbuseSignals:ratio" ? { _id: id, skillId: "skills:ratio" } : null, + ), + query: vi.fn((table: string) => { + expect(table).toBe("skillDailyStats"); + return { + withIndex: (indexName: string, callback: (q: typeof indexBuilder) => unknown) => { + expect(indexName).toBe("by_skill_day"); + callback(indexBuilder); + return { take }; + }, + }; + }), + }; + + const result = await getSignalActivityTrendHandler( + { db }, + { signalId: "publisherAbuseSignals:ratio", endDay: 100 }, + ); + + expect(result?.downloads.total).toBe(300); + expect(result?.installs.total).toBe(21); + expect(result?.downloads.points).toHaveLength(30); + expect(result?.installs.points).toHaveLength(30); + expect(result?.downloads.points.at(-1)).toEqual({ day: 100, value: 180 }); + expect(result?.installs.points.at(-1)).toEqual({ day: 100, value: 12 }); + expect(take).toHaveBeenCalledWith(30); + expect(indexBuilder.eq).toHaveBeenCalledWith("skillId", "skills:ratio"); + expect(indexBuilder.gte).toHaveBeenCalledWith("day", 71); + expect(indexBuilder.lte).toHaveBeenCalledWith("day", 100); + }); + it("lets moderators snooze, dismiss, and reopen archived signals with audit rows", async () => { vi.mocked(requireUser).mockResolvedValue({ userId: "users:moderator", diff --git a/convex/publisherAbuse.ts b/convex/publisherAbuse.ts index bf9d1706..127c9253 100644 --- a/convex/publisherAbuse.ts +++ b/convex/publisherAbuse.ts @@ -12,6 +12,12 @@ import { query, } from "./functions"; import { assertAdmin, assertModerator, requireUser, requireUserFromAction } from "./lib/access"; +import { + ACTIVITY_TREND_DAYS, + buildDailyActivityTrends, + clampActivityTrendEndDay, + getActivityTrendRangeForEndDay, +} from "./lib/downloadTrend"; import { toDayKey } from "./lib/leaderboards"; import { hasOfficialPublisherRow } from "./lib/officialPublishers"; import { @@ -483,6 +489,31 @@ export const listSignalsPage = query({ }, }); +export const getSignalActivityTrend = query({ + args: { + signalId: v.id("publisherAbuseSignals"), + endDay: v.number(), + }, + handler: async (ctx, args) => { + const auth = await requirePublisherAbuseDashboardUser(ctx); + if (!auth) return null; + + const signal = await ctx.db.get(args.signalId); + if (!signal) return null; + + const endDay = clampActivityTrendEndDay(args.endDay, Date.now()); + const { startDay } = getActivityTrendRangeForEndDay(endDay); + const rows = await ctx.db + .query("skillDailyStats") + .withIndex("by_skill_day", (q) => + q.eq("skillId", signal.skillId).gte("day", startDay).lte("day", endDay), + ) + .take(ACTIVITY_TREND_DAYS); + + return buildDailyActivityTrends(rows, endDay); + }, +}); + export const getReviewNominationDetail = query({ args: { nominationId: v.id("publisherAbuseReviewNominations"), diff --git a/convex/publisherAbuseDevSeed.test.ts b/convex/publisherAbuseDevSeed.test.ts index 68540a62..615286ea 100644 --- a/convex/publisherAbuseDevSeed.test.ts +++ b/convex/publisherAbuseDevSeed.test.ts @@ -273,6 +273,22 @@ describe("publisherAbuseDevSeed.seed", () => { expect(tables.users ?? []).toHaveLength(16); expect(tables.skills?.some((doc) => doc.slug === "demo-temporal-download-burst")).toBe(true); expect(tables.skills?.some((doc) => doc.slug === "demo-temporal-install-ratio")).toBe(true); + const burstSkill = tables.skills?.find((doc) => doc.slug === "demo-temporal-download-burst"); + const ratioSkill = tables.skills?.find((doc) => doc.slug === "demo-temporal-install-ratio"); + const burstRecentStats = (tables.skillDailyStats ?? []) + .filter((doc) => doc.skillId === burstSkill?._id) + .sort((left, right) => Number(left.day) - Number(right.day)) + .slice(-30); + const ratioRecentStats = (tables.skillDailyStats ?? []) + .filter((doc) => doc.skillId === ratioSkill?._id) + .sort((left, right) => Number(left.day) - Number(right.day)); + + expect(burstRecentStats.map((doc) => doc.downloads)).toHaveLength(30); + expect(new Set(burstRecentStats.map((doc) => doc.downloads)).size).toBeGreaterThan(10); + expect(burstRecentStats.reduce((sum, doc) => sum + Number(doc.downloads), 0)).toBe(16_200); + expect(burstRecentStats.reduce((sum, doc) => sum + Number(doc.installs), 0)).toBe(8); + expect(ratioRecentStats.reduce((sum, doc) => sum + Number(doc.downloads), 0)).toBe(2_400); + expect(ratioRecentStats.reduce((sum, doc) => sum + Number(doc.installs), 0)).toBe(288); expect(tables.publisherAbuseSignals).toEqual([ expect.objectContaining({ signalType: "sustained_downloads_flat_installs", diff --git a/convex/publisherAbuseDevSeed.ts b/convex/publisherAbuseDevSeed.ts index 4b8f28bb..76c082e0 100644 --- a/convex/publisherAbuseDevSeed.ts +++ b/convex/publisherAbuseDevSeed.ts @@ -28,6 +28,24 @@ const TEMPORAL_DEMO_SKILL_SLUG = "demo-temporal-download-burst"; const TEMPORAL_DEMO_RATIO_SKILL_SLUG = "demo-temporal-install-ratio"; const CLEAR_SEED_BATCH_SIZE = 100; +// A realistic quiet-baseline → sharp-burst → uneven-tail shape keeps the +// activity charts useful during manual review without copying production rows. +const TEMPORAL_DEMO_ACTIVITY_SHAPE = [ + 13, 5, 4, 2, 10, 8, 9, 11, 10, 11, 13, 5, 7, 11, 10, 6, 18, 6, 59, 96, 205, 98, 76, 79, 62, 99, + 81, 53, 54, 11, +] as const; +const TEMPORAL_DEMO_SPARSE_INSTALLS = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 2, 1, 0, 1, 0, 1, 0, 0, 0, 1, +] as const; + +function scaleDailySeries(shape: readonly number[], targetTotal: number) { + const sourceTotal = shape.reduce((sum, value) => sum + value, 0); + const scaled = shape.map((value) => Math.round((value * targetTotal) / sourceTotal)); + const roundingDifference = targetTotal - scaled.reduce((sum, value) => sum + value, 0); + scaled[scaled.length - 1] = (scaled.at(-1) ?? 0) + roundingDifference; + return scaled; +} + type TriageStatus = | "pending" | "reviewed_no_action" @@ -397,6 +415,16 @@ export const clearSeed = internalMutation({ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number }) { const now = args.now; const todayDay = Math.floor(now / DAY_MS); + const temporalDownloads = scaleDailySeries(TEMPORAL_DEMO_ACTIVITY_SHAPE, 16_200); + const temporalInstalls: number[] = [...TEMPORAL_DEMO_SPARSE_INSTALLS]; + const temporalDownloads30d = temporalDownloads.reduce((sum, value) => sum + value, 0); + const temporalInstalls30d = temporalInstalls.reduce((sum, value) => sum + value, 0); + const temporalDownloads7d = temporalDownloads.slice(-7).reduce((sum, value) => sum + value, 0); + const temporalInstalls7d = temporalInstalls.slice(-7).reduce((sum, value) => sum + value, 0); + const ratioDownloads = scaleDailySeries(TEMPORAL_DEMO_ACTIVITY_SHAPE, 2_400); + const ratioInstalls = scaleDailySeries(TEMPORAL_DEMO_ACTIVITY_SHAPE, 288); + const ratioDownloads7d = ratioDownloads.slice(-7).reduce((sum, value) => sum + value, 0); + const ratioInstalls7d = ratioInstalls.slice(-7).reduce((sum, value) => sum + value, 0); const temporalBenchmark = { scope: "all_active_skills" as const, sampleSize: 1000, @@ -421,11 +449,11 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number linkedUserId: temporalUserId, publishedSkills: 1, publishedPackages: 0, - totalInstalls: 0, - totalDownloads: 16_200, + totalInstalls: temporalInstalls30d, + totalDownloads: temporalDownloads30d, totalStars: 0, - skillTotalInstalls: 0, - skillTotalDownloads: 16_200, + skillTotalInstalls: temporalInstalls30d, + skillTotalDownloads: temporalDownloads30d, skillTotalStars: 0, createdAt: now - DAY_MS, updatedAt: now - HOUR_MS, @@ -433,20 +461,20 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number const temporalSkillId = await ctx.db.insert("skills", { slug: TEMPORAL_DEMO_SKILL_SLUG, displayName: "Demo Temporal Download Burst", - summary: "Synthetic fixture: high 30-day downloads with zero installs.", + summary: "Synthetic fixture: high 30-day downloads with near-flat installs.", ownerUserId: temporalUserId, ownerPublisherId: temporalPublisherId, tags: {}, badges: {}, moderationStatus: "active", - statsDownloads: 16_200, + statsDownloads: temporalDownloads30d, statsStars: 0, statsInstallsCurrent: 0, - statsInstallsAllTime: 0, + statsInstallsAllTime: temporalInstalls30d, stats: { - downloads: 16_200, + downloads: temporalDownloads30d, installsCurrent: 0, - installsAllTime: 0, + installsAllTime: temporalInstalls30d, stars: 0, versions: 1, comments: 0, @@ -488,11 +516,19 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number }); } for (let offset = 29; offset >= 0; offset -= 1) { + const index = 29 - offset; await ctx.db.insert("skillDailyStats", { skillId: temporalSkillId, day: todayDay - offset, - downloads: 540, - installs: 0, + downloads: temporalDownloads[index] ?? 0, + installs: temporalInstalls[index] ?? 0, + updatedAt: now - HOUR_MS, + }); + await ctx.db.insert("skillDailyStats", { + skillId: ratioSkillId, + day: todayDay - offset, + downloads: ratioDownloads[index] ?? 0, + installs: ratioInstalls[index] ?? 0, updatedAt: now - HOUR_MS, }); } @@ -534,12 +570,12 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number logPressure: Math.log10(18), zScore: 2.14, publishedSkills: 1, - totalInstalls: 0, + totalInstalls: temporalInstalls30d, totalStars: 0, - totalDownloads: 16_200, - installsPerSkill: 0, + totalDownloads: temporalDownloads30d, + installsPerSkill: temporalInstalls30d, starsPerSkill: 0, - downloadsPerSkill: 16_200, + downloadsPerSkill: temporalDownloads30d, reasonCodes: ["temporal_sustained_downloads_flat_installs"], temporalHighSkillCount: 1, temporalSpikeSkillCount: 0, @@ -554,14 +590,14 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number spike: false, sustained: true, pressure: 18, - recent7Downloads: 3_780, - recent7Installs: 0, + recent7Downloads: temporalDownloads7d, + recent7Installs: temporalInstalls7d, previous30Downloads: 120, baseline7Downloads: 100, spikeMultiplier: 8, - recent30Downloads: 16_200, - recent30Installs: 0, - downloadInstallRatio30: 16_200, + recent30Downloads: temporalDownloads30d, + recent30Installs: temporalInstalls30d, + downloadInstallRatio30: temporalDownloads30d / Math.max(1, temporalInstalls30d), downloads30dCohortBand: "p99", spikeMultiplierCohortBand: "p95", downloads30dVsPeerP95: 18, @@ -601,15 +637,15 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number firstSeenAt: temporalCompletedAt, lastSeenAt: temporalCompletedAt, seenCount: 1, - recent7Downloads: 3_780, - recent7Installs: 0, - recent7InstallDownloadRatio: 0, - recent30Downloads: 16_200, - recent30Installs: 0, - recent30InstallDownloadRatio: 0, - allTimeDownloads: 16_200, - allTimeInstalls: 0, - allTimeInstallDownloadRatio: 0, + recent7Downloads: temporalDownloads7d, + recent7Installs: temporalInstalls7d, + recent7InstallDownloadRatio: temporalInstalls7d / temporalDownloads7d, + recent30Downloads: temporalDownloads30d, + recent30Installs: temporalInstalls30d, + recent30InstallDownloadRatio: temporalInstalls30d / temporalDownloads30d, + allTimeDownloads: temporalDownloads30d, + allTimeInstalls: temporalInstalls30d, + allTimeInstallDownloadRatio: temporalInstalls30d / temporalDownloads30d, reviewStatus: "open", }); await ctx.db.insert("publisherAbuseSignals", { @@ -626,9 +662,9 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number firstSeenAt: temporalCompletedAt - 7 * 60_000, lastSeenAt: temporalCompletedAt, seenCount: 2, - recent7Downloads: 800, - recent7Installs: 96, - recent7InstallDownloadRatio: 0.12, + recent7Downloads: ratioDownloads7d, + recent7Installs: ratioInstalls7d, + recent7InstallDownloadRatio: ratioInstalls7d / ratioDownloads7d, recent30Downloads: 2_400, recent30Installs: 288, recent30InstallDownloadRatio: 0.12, diff --git a/specs/security-moderation.md b/specs/security-moderation.md index 594b8b3f..c6c91b28 100644 --- a/specs/security-moderation.md +++ b/specs/security-moderation.md @@ -122,7 +122,10 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic install/download ratio. Reopened repeat signals are elevated to high severity. Staff may snooze or dismiss up to 50 selected open signals in one atomic action. Bulk review must apply the same evidence checkpoint and write the same - per-signal review event as the corresponding single-signal action. + per-signal review event as the corresponding single-signal action. The signal + inspector must show the selected skill's daily downloads and installs across + the same trailing 30-day window, loaded on demand from the bounded daily-stat + index so staff can judge whether the evidence is sustained or spiky. - Hermit owns Discord notification delivery for publisher abuse Signals. ClawHub queues Hermit digests only for changed open signals: newly archived signals, manual reopens, expired snoozes with qualifying fresh evidence, and diff --git a/src/routes/-management.test.tsx b/src/routes/-management.test.tsx index 9da771c8..d0780ff0 100644 --- a/src/routes/-management.test.tsx +++ b/src/routes/-management.test.tsx @@ -143,6 +143,22 @@ function makePublisherAbuseSignal(signalOverrides: Record = {}) }; } +function makeSignalActivityTrend() { + const points = Array.from({ length: 30 }, (_, index) => ({ + day: 20_500 + index, + value: index + 1, + })); + return { + downloads: { range: "daily", days: 30, total: 465, points }, + installs: { + range: "daily", + days: 30, + total: 30, + points: points.map((point) => ({ ...point, value: point.value % 3 })), + }, + }; +} + function makeManagementUser( id: string, handle: string, @@ -458,6 +474,9 @@ describe("Management", () => { signalCountHasMore: false, }; } + if (name === "publisherAbuse:getSignalActivityTrend") { + return makeSignalActivityTrend(); + } if (name === "users:list") return { items: [], total: 0 }; return undefined; }); @@ -550,6 +569,14 @@ describe("Management", () => { expect(screen.getByText("96 installs / 800 downloads")).toBeTruthy(); expect(screen.getByText("288 installs / 2,400 downloads")).toBeTruthy(); expect(screen.getByText("1,200 installs / 10,000 downloads")).toBeTruthy(); + expect(screen.getByRole("img", { name: "Daily downloads over the last 30 days" })).toBeTruthy(); + expect(screen.getByRole("img", { name: "Daily installs over the last 30 days" })).toBeTruthy(); + expect(screen.getByText("30-day activity")).toBeTruthy(); + expect(screen.getByText("Downloads")).toBeTruthy(); + expect(screen.getByText("Installs")).toBeTruthy(); + const drawerZones = Array.from(document.querySelectorAll(".pa-sheet-body > .pa-zone")); + expect(drawerZones[0]?.textContent).toContain("30-day activity"); + expect(drawerZones[1]?.textContent).toContain("Signal"); expect( screen.getByText(/Platform 30d downloads across all 1,000 active skills: P95 900, P99 3,000/), ).toBeTruthy(); @@ -569,6 +596,18 @@ describe("Management", () => { JSON.stringify(args) === JSON.stringify({ reviewStatus: "open" }), ), ).toBe(true); + expect( + useQueryMock.mock.calls.some( + ([query, args]) => + getFunctionName(query) === "publisherAbuse:getSignalActivityTrend" && + typeof args === "object" && + args !== null && + "signalId" in args && + args.signalId === "publisherAbuseSignals:ratio" && + "endDay" in args && + typeof args.endDay === "number", + ), + ).toBe(true); }); it("opens the signals tab from the management search param", () => { diff --git a/src/routes/-management/AbusePage.tsx b/src/routes/-management/AbusePage.tsx index 589cf66b..9ca29e85 100644 --- a/src/routes/-management/AbusePage.tsx +++ b/src/routes/-management/AbusePage.tsx @@ -1,4 +1,5 @@ import { Link } from "@tanstack/react-router"; +import { useQuery } from "convex/react"; import { Ban, Clock3, @@ -13,7 +14,9 @@ import { XCircle, } from "lucide-react"; import { useEffect, useState } from "react"; +import { api } from "../../../convex/_generated/api"; import type { Id } from "../../../convex/_generated/dataModel"; +import { MetricTrendCard, MetricTrendCardSkeleton } from "../../components/MetricTrendCard"; import { Badge, type BadgeProps } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { Card } from "../../components/ui/card"; @@ -25,6 +28,7 @@ import { SheetTitle, } from "../../components/ui/sheet"; import { Textarea } from "../../components/ui/textarea"; +import { getActivityTrendEndDay } from "../../lib/activityTrend"; import { buildPublisherProfileHref, buildSkillDetailHref } from "../../lib/ownerRoute"; import { formatPercent, @@ -1141,6 +1145,10 @@ function PublisherAbuseSignalInspector({ const status = signalReviewStatus(item); const publisherHandle = signalPublisherHandle(item); const recurrenceCount = item.signal.recurrenceCount ?? 0; + const activityTrend = useQuery(api.publisherAbuse.getSignalActivityTrend, { + signalId: item.signal._id, + endDay: getActivityTrendEndDay(item.signal.lastSeenAt), + }); const hasFreshEvidence = typeof item.signal.freshDownloadsSinceSnooze === "number" && typeof item.signal.freshInstallsSinceSnooze === "number"; @@ -1189,6 +1197,44 @@ function PublisherAbuseSignalInspector({
+
+
30-day activity
+
+
+
Downloads
+ {activityTrend ? ( + + ) : activityTrend === undefined ? ( + + ) : ( + Trend unavailable + )} +
+
+
Installs
+ {activityTrend ? ( + + ) : activityTrend === undefined ? ( + + ) : ( + Trend unavailable + )} +
+
+
+
Signal
diff --git a/src/styles.css b/src/styles.css index 03a027eb..40152f68 100644 --- a/src/styles.css +++ b/src/styles.css @@ -15057,6 +15057,34 @@ code { text-align: right; } +.pa-signal-trends { + display: grid; + gap: var(--space-4); + padding-top: var(--space-2); +} + +.pa-signal-trend { + display: grid; + gap: var(--space-2); +} + +.pa-signal-trend + .pa-signal-trend { + padding-top: var(--space-3); + border-top: 1px solid var(--line); +} + +.pa-signal-trend-label { + color: var(--ink-soft); + font-size: var(--fs-xs); + font-weight: 600; +} + +.pa-signal-trend-installs { + --metric-trend-line: color-mix(in srgb, var(--status-success-fg) 76%, var(--ink)); + --metric-trend-area: color-mix(in srgb, var(--status-success-fg) 18%, transparent); + --metric-trend-marker: color-mix(in srgb, var(--status-success-fg) 36%, transparent); +} + .pa-section-label { margin: 0; color: var(--ink-soft);