feat: add 30-day activity trends to abuse signal drawer (#3216)

Adds 30-day download and install trend charts to the abuse signal drawer, places them near the top for immediate context, and improves development fixtures for realistic manual validation.
This commit is contained in:
Jesse Merhi
2026-07-22 14:49:52 +10:00
committed by GitHub
parent b34a0d69ff
commit 987ad8bdec
10 changed files with 342 additions and 39 deletions
+31 -3
View File
@@ -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);
});
});
+21 -3
View File
@@ -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<number, number>();
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"),
};
}
+58
View File
@@ -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",
+31
View File
@@ -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"),
+16
View File
@@ -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",
+68 -32
View File
@@ -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,
+4 -1
View File
@@ -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
+39
View File
@@ -143,6 +143,22 @@ function makePublisherAbuseSignal(signalOverrides: Record<string, unknown> = {})
};
}
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", () => {
+46
View File
@@ -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({
</SheetHeader>
<div className="pa-sheet-body">
<section className="pa-zone pa-signal-trends-zone">
<div className="pa-section-label">30-day activity</div>
<div className="pa-signal-trends" aria-label="30-day activity trends">
<div className="pa-signal-trend">
<div className="pa-signal-trend-label">Downloads</div>
{activityTrend ? (
<MetricTrendCard
trend={activityTrend.downloads}
ariaLabel="Daily downloads over the last 30 days"
periodLabel="30 days"
unitLabel="download"
hideIdlePeriodLabel
/>
) : activityTrend === undefined ? (
<MetricTrendCardSkeleton />
) : (
<span className="pa-hint">Trend unavailable</span>
)}
</div>
<div className="pa-signal-trend pa-signal-trend-installs">
<div className="pa-signal-trend-label">Installs</div>
{activityTrend ? (
<MetricTrendCard
trend={activityTrend.installs}
ariaLabel="Daily installs over the last 30 days"
periodLabel="30 days"
unitLabel="install"
hideIdlePeriodLabel
/>
) : activityTrend === undefined ? (
<MetricTrendCardSkeleton />
) : (
<span className="pa-hint">Trend unavailable</span>
)}
</div>
</div>
</section>
<section className="pa-zone">
<div className="pa-section-label">Signal</div>
<div className="pa-reason-list">
+28
View File
@@ -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);