diff --git a/apps/dashboard/app/activity/page.tsx b/apps/dashboard/app/activity/page.tsx index 92e6230..977a9e0 100644 --- a/apps/dashboard/app/activity/page.tsx +++ b/apps/dashboard/app/activity/page.tsx @@ -11,8 +11,8 @@ import { type ActivityEventType, } from "@guildpass/integration-client"; import type { ActivityChange } from "@guildpass/integration-client"; -import { useMemo, useState } from "react"; -import { useGuild } from "@/lib/guild/GuildProvider"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useGuild } from "@/lib/guild/GuildProvider";`nimport { usePathname, useRouter, useSearchParams } from "next/navigation";`nimport type { ActivitySortOrder } from "@/lib/activity/query"; const TYPE_ICON: Record = { "member.joined": "👤", @@ -78,14 +78,74 @@ const SEVERITY_FILTERS: { label: string; value: ActivityEventSeverity | "" }[] = { label: "Critical", value: "critical" }, ]; +const SORT_OPTIONS: { label: string; value: ActivitySortOrder }[] = [ + { label: "Newest first", value: "newest" }, + { label: "Oldest first", value: "oldest" }, +]; + +const PAGE_SIZE_OPTIONS = [10, 20, 50] as const; + +function readSort(value: string | null): ActivitySortOrder { + return value === "oldest" ? "oldest" : "newest"; +} + +function readLimit(value: string | null): number { + const parsed = Number(value); + return PAGE_SIZE_OPTIONS.includes(parsed as (typeof PAGE_SIZE_OPTIONS)[number]) ? parsed : 10; +} export default function ActivityPage() { - const { guildId, guild } = useGuild(); - const [type, setType] = useState(""); - const [source, setSource] = useState(""); - const [severity, setSeverity] = useState(""); - const [actor, setActor] = useState(""); - const [from, setFrom] = useState(""); + const { guildId, guild } = useGuild();`n const router = useRouter();`n const pathname = usePathname();`n const searchParams = useSearchParams();`n const [type, setType] = useState(() => (searchParams.get("type") as ActivityEventType | null) ?? "");`n const [source, setSource] = useState(() => (searchParams.get("source") as ActivityEventSource | null) ?? "");`n const [severity, setSeverity] = useState(() => (searchParams.get("severity") as ActivityEventSeverity | null) ?? "");`n const [actor, setActor] = useState(() => searchParams.get("actor") ?? "");`n const [from, setFrom] = useState(() => searchParams.get("from") ?? "");`n const [sort, setSort] = useState(() => readSort(searchParams.get("sort")));`n const [limit, setLimit] = useState(() => readLimit(searchParams.get("limit"))); const { intervalMs } = getActivityRefreshConfig(); + const updateActivityQuery = useCallback( + (updates: { + type?: ActivityEventType | ""; + source?: ActivityEventSource | ""; + severity?: ActivityEventSeverity | ""; + actor?: string; + from?: string; + sort?: ActivitySortOrder; + limit?: number; + }) => { + const next = new URLSearchParams(searchParams.toString()); + const setOrDelete = (key: string, value: string) => { + value.trim() ? next.set(key, value.trim()) : next.delete(key); + }; + + if (updates.type !== undefined) setOrDelete("type", updates.type); + if (updates.source !== undefined) setOrDelete("source", updates.source); + if (updates.severity !== undefined) setOrDelete("severity", updates.severity); + if (updates.actor !== undefined) setOrDelete("actor", updates.actor); + if (updates.from !== undefined) setOrDelete("from", updates.from); + if (updates.sort !== undefined) { + updates.sort === "newest" ? next.delete("sort") : next.set("sort", updates.sort); + } + if (updates.limit !== undefined) { + updates.limit === 10 ? next.delete("limit") : next.set("limit", String(updates.limit)); + } + + const query = next.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); + }, + [pathname, router, searchParams] + ); + + useEffect(() => { + const nextType = (searchParams.get("type") as ActivityEventType | null) ?? ""; + const nextSource = (searchParams.get("source") as ActivityEventSource | null) ?? ""; + const nextSeverity = (searchParams.get("severity") as ActivityEventSeverity | null) ?? ""; + const nextActor = searchParams.get("actor") ?? ""; + const nextFrom = searchParams.get("from") ?? ""; + const nextSort = readSort(searchParams.get("sort")); + const nextLimit = readLimit(searchParams.get("limit")); + + if (nextType !== type) setType(nextType); + if (nextSource !== source) setSource(nextSource); + if (nextSeverity !== severity) setSeverity(nextSeverity); + if (nextActor !== actor) setActor(nextActor); + if (nextFrom !== from) setFrom(nextFrom); + if (nextSort !== sort) setSort(nextSort); + if (nextLimit !== limit) setLimit(nextLimit); + }, [actor, from, limit, searchParams, severity, sort, source, type]); const fromIso = useMemo(() => { if (!from) return undefined; @@ -105,26 +165,25 @@ export default function ActivityPage() { loadMore, refresh, } = useActivityFeed({ - limit: 10, + limit, type: type || undefined, source: source || undefined, severity: severity || undefined, actor: actor.trim() || undefined, - from: fromIso, + from: fromIso,`n sort, autoRefresh: true, simulate: false, guildId, }); - const hasActiveFilters = Boolean(type || source || severity || actor.trim() || from); + const hasActiveFilters = Boolean(type || source || severity || actor.trim() || from || sort !== "newest" || limit !== 10); const clearFilters = () => { setType(""); setSource(""); setSeverity(""); setActor(""); - setFrom(""); - }; + setFrom("");`n setSort("newest");`n setLimit(10);`n updateActivityQuery({ type: "", source: "", severity: "", actor: "", from: "", sort: "newest", limit: 10 });`n }; return (
-
+
+ + +
{hasActiveFilters && ( diff --git a/apps/dashboard/lib/activity/query.ts b/apps/dashboard/lib/activity/query.ts index 47f2f6f..90e5ecd 100644 --- a/apps/dashboard/lib/activity/query.ts +++ b/apps/dashboard/lib/activity/query.ts @@ -46,9 +46,9 @@ const ENTITY_TYPES = new Set([ "member", "verification", "webhook", -]); +]);`n`nconst SORT_ORDERS = new Set(["newest", "oldest"]); -export interface ActivityQuery { +export type ActivitySortOrder = "newest" | "oldest";`n`nexport interface ActivityQuery { limit?: number; cursor?: string; type?: ActivityEventType; @@ -56,8 +56,7 @@ export interface ActivityQuery { severity?: ActivityEventSeverity; entityType?: ActivityEventEntity["type"]; actor?: string; - from?: string; -} + from?: string;`n sort?: ActivitySortOrder;`n} export interface ActivityQueryResult { events: ActivityEvent[]; @@ -92,7 +91,7 @@ export function filterActivityEvents( if (actorFilter && !matchesActor(event, actorFilter)) return false; return true; }) - .sort(compareActivityEvents); + .sort((a, b) => compareActivityEvents(a, b, query.sort ?? "newest")); const cursorIndex = query.cursor ? filtered.findIndex((event) => event.id === query.cursor) @@ -149,7 +148,7 @@ export function parseActivityQuery( query.actor = actor.toLowerCase(); } - const from = searchParams.get("from"); + readEnum(searchParams, "sort", SORT_ORDERS, errors, (value) => {`n query.sort = value;`n });`n`n const from = searchParams.get("from"); if (from) { const timestamp = new Date(from).getTime(); if (Number.isNaN(timestamp)) { @@ -184,11 +183,7 @@ function clampLimit(limit: number): number { return Math.min(Math.max(limit, 1), MAX_ACTIVITY_LIMIT); } -function compareActivityEvents(a: ActivityEvent, b: ActivityEvent): number { - const timeDiff = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(); - if (timeDiff !== 0) return timeDiff; - return b.id.localeCompare(a.id); -} +function compareActivityEvents(a: ActivityEvent, b: ActivityEvent, sort: ActivitySortOrder): number {`n const newestFirst = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();`n const timeDiff = sort === "oldest" ? -newestFirst : newestFirst;`n if (timeDiff !== 0) return timeDiff;`n return sort === "oldest" ? a.id.localeCompare(b.id) : b.id.localeCompare(a.id);`n} function matchesActor(event: ActivityEvent, actorFilter: string): boolean { return [event.actor.id, event.actor.name, event.actor.wallet] diff --git a/apps/dashboard/lib/hooks/useActivityFeed.ts b/apps/dashboard/lib/hooks/useActivityFeed.ts index bd6a1ce..e577c03 100644 --- a/apps/dashboard/lib/hooks/useActivityFeed.ts +++ b/apps/dashboard/lib/hooks/useActivityFeed.ts @@ -68,8 +68,7 @@ export function useActivityFeed({ severity, entityType, actor, - from, - refreshIntervalMs, + from,`n sort,`n refreshIntervalMs, autoRefresh = true, simulate = true, guildId, @@ -98,10 +97,9 @@ export function useActivityFeed({ severity, entityType, actor: actor?.trim() || undefined, - from, - guildId, + from,`n sort,`n guildId, }), - [limit, type, source, severity, entityType, actor, from, guildId] + [limit, type, source, severity, entityType, actor, from, sort, guildId] ); const activityQueryKey = useMemo( () => queryKeys.activity(guildId ?? "unscoped", { @@ -111,9 +109,8 @@ export function useActivityFeed({ severity, entityType, actor: actor?.trim() || undefined, - from, - }), - [actor, entityType, from, guildId, limit, severity, source, type] + from,`n sort,`n }), + [actor, entityType, from, guildId, limit, severity, source, sort, type] ); const cacheRevision = useQueryInvalidation(activityQueryKey); diff --git a/apps/dashboard/test/activity-query.test.ts b/apps/dashboard/test/activity-query.test.ts index d677f3d..44a7796 100644 --- a/apps/dashboard/test/activity-query.test.ts +++ b/apps/dashboard/test/activity-query.test.ts @@ -79,6 +79,15 @@ describe("activity query contract", () => { limit: 10, }); + test("sorts oldest first when requested", () => { + const result = filterActivityEvents(events, { limit: 3, sort: "oldest" }); + + assert.deepEqual( + result.events.map((event) => event.id), + ["evt_query_001", "evt_query_002", "evt_query_003"] + ); + assert.equal(result.nextCursor, "evt_query_003"); + }); assert.deepEqual( result.events.map((event) => event.id), ["evt_query_002"] @@ -99,7 +108,7 @@ describe("activity query contract", () => { test("parses and bounds valid URL query parameters", () => { const parsed = parseActivityQuery( - new URL("https://example.test/api/activity?limit=250&type=member.joined&source=webhook&severity=error&entityType=member&actor=alice&from=2025-01-01T00:00:00.000Z") + new URL("https://example.test/api/activity?limit=250&type=member.joined&source=webhook&severity=error&entityType=member&actor=alice&from=2025-01-01T00:00:00.000Z&sort=oldest") .searchParams ); @@ -112,12 +121,12 @@ describe("activity query contract", () => { assert.equal(parsed.value.severity, "error"); assert.equal(parsed.value.entityType, "member"); assert.equal(parsed.value.actor, "alice"); - assert.equal(parsed.value.from, "2025-01-01T00:00:00.000Z"); + assert.equal(parsed.value.from, "2025-01-01T00:00:00.000Z");`n assert.equal(parsed.value.sort, "oldest"); }); test("rejects invalid query parameters with field-specific errors", () => { const parsed = parseActivityQuery( - new URL("https://example.test/api/activity?limit=abc&type=not-real&from=tomorrow") + new URL("https://example.test/api/activity?limit=abc&type=not-real&from=tomorrow&sort=sideways") .searchParams ); @@ -126,7 +135,7 @@ describe("activity query contract", () => { assert.deepEqual( parsed.errors.map((error) => error.field), - ["limit", "type", "from"] + ["limit", "type", "sort", "from"] ); }); });