From c44c118cb3ba6ce334a98dcf79827ccdd812f673 Mon Sep 17 00:00:00 2001 From: Rodrigo Linhas Date: Thu, 3 Sep 2026 15:52:50 +0100 Subject: [PATCH 1/3] feat(schemas): add subject filtering to profile activity - Add subject slug array filter to profileActivitySearchSchema - Add subjects array (slug + name) to profileActivityItemSchema Signed-off-by: Rodrigo Linhas --- packages/schemas/src/identity/requests.ts | 1 + packages/schemas/src/identity/responses.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/schemas/src/identity/requests.ts b/packages/schemas/src/identity/requests.ts index fa10f5d3..1dd3fb12 100644 --- a/packages/schemas/src/identity/requests.ts +++ b/packages/schemas/src/identity/requests.ts @@ -36,6 +36,7 @@ const filterList = (schema: z.ZodType) => export const profileActivitySearchSchema = z.object({ type: filterList(activityTypeFilterSchema), status: filterList(activityStatusFilterSchema), + subject: filterList(z.string().min(1)), title: z.string().min(1).optional().catch(undefined), summary: z.string().min(1).optional().catch(undefined), from: z.iso.date().optional().catch(undefined), diff --git a/packages/schemas/src/identity/responses.ts b/packages/schemas/src/identity/responses.ts index cb21c86d..a2d083e8 100644 --- a/packages/schemas/src/identity/responses.ts +++ b/packages/schemas/src/identity/responses.ts @@ -68,6 +68,7 @@ export const profileActivityItemSchema = z.object({ base_slug: z.string().nullable(), review_case_id: z.uuid().nullable(), revision_id: z.uuid().nullable(), + subjects: z.array(z.object({ slug: z.string(), name: z.string() })), }); export const meResponseSchema = z.strictObject({ From 70868aa3466cb426cac4d046b93ff9390733f8d1 Mon Sep 17 00:00:00 2001 From: Rodrigo Linhas Date: Thu, 3 Sep 2026 16:01:11 +0100 Subject: [PATCH 2/3] feat(api): expose subject tags on profile activity rows getProfileActivity() now batch-fetches guide_revision_subjects for all guide revision rows in a single query and attaches slug+name pairs to each row. Objective and review rows receive an empty subjects array. Signed-off-by: Rodrigo Linhas --- api/src/services/identity.service.ts | 39 +++++++++++++++++++++++++ api/tests/identity.test.ts | 43 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/api/src/services/identity.service.ts b/api/src/services/identity.service.ts index 11bfe31d..6d44b4ea 100644 --- a/api/src/services/identity.service.ts +++ b/api/src/services/identity.service.ts @@ -319,6 +319,7 @@ export type ProfileActivityRow = { base_slug: string | null; review_case_id: string | null; revision_id: string | null; + subjects: Array<{ slug: string; name: string }>; }; // Earliest revision id per parent, so a row can be tagged as the "creation" @@ -463,6 +464,7 @@ export async function getProfileActivity( base_slug: guide ? (slugByBase.get(guide.guide_base_id) ?? null) : null, review_case_id: caseId, revision_id: rev.id, + subjects: [], }); } } @@ -509,6 +511,7 @@ export async function getProfileActivity( base_slug: null, review_case_id: null, revision_id: rev.id, + subjects: [], }); } } @@ -615,11 +618,47 @@ export async function getProfileActivity( ? caseId : null, revision_id: rev ?? null, + subjects: [], }); } } } + // Batch-fetch subject tags for all guide revision rows in one query. + const guideRevisionIds = rows + .filter((r) => r.content_kind === "guide" && r.revision_id !== null) + .map((r) => r.revision_id as string); + + if (guideRevisionIds.length > 0) { + const { data: tagData, error: tagError } = await supabase + .from("guide_revision_subjects") + .select("guide_revision_id, subjects(slug, name)") + .in("guide_revision_id", guideRevisionIds); + + if (tagError) fail(tagError); + + // Build a map from revision_id -> subject list, then hydrate the rows. + const subjectsByRev = new Map< + string, + Array<{ slug: string; name: string }> + >(); + for (const tag of tagData ?? []) { + const subj = tag.subjects; + if (!subj || typeof subj !== "object" || Array.isArray(subj)) continue; + const { slug, name } = subj as { slug: string | null; name: string }; + if (slug === null) continue; + const list = subjectsByRev.get(tag.guide_revision_id) ?? []; + list.push({ slug, name }); + subjectsByRev.set(tag.guide_revision_id, list); + } + + for (const row of rows) { + if (row.content_kind === "guide" && row.revision_id !== null) { + row.subjects = subjectsByRev.get(row.revision_id) ?? []; + } + } + } + rows.sort((a, b) => (a.created_at < b.created_at ? 1 : -1)); return rows; } diff --git a/api/tests/identity.test.ts b/api/tests/identity.test.ts index df125df7..d7f7f72e 100644 --- a/api/tests/identity.test.ts +++ b/api/tests/identity.test.ts @@ -14,6 +14,7 @@ import { createGuideRevision, createPublishedGuide, } from "./factories/guides"; +import { createSubject, tagGuideRevision } from "./factories/subjects"; import { expectToMatchSpec } from "./openapi"; describe("GET /me", () => { @@ -197,4 +198,46 @@ describe("GET /profiles/{username}", () => { expect(res.status).toBe(404); await expectToMatchSpec(res, "GET", "/profiles/{username}"); }); + + it("includes subject tags on guide revision activity rows", async () => { + const { token, userId } = await makeUser(); + const username = await getUsername(userId); + const subject = await createSubject(); + const { revision } = await createPublishedGuide({ authorId: userId }); + await tagGuideRevision(revision.id, subject.id); + + const res = await app.request(`/profiles/${username}`, auth(token), env); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + activity: Array<{ + revision_id: string | null; + subjects: Array<{ slug: string; name: string }>; + }>; + }; + const row = body.activity.find((r) => r.revision_id === revision.id); + expect(row).toBeDefined(); + expect(row?.subjects).toEqual( + expect.arrayContaining([{ slug: subject.slug, name: subject.name }]) + ); + }); + + it("returns an empty subjects array for untagged guide revisions", async () => { + const { token, userId } = await makeUser(); + const username = await getUsername(userId); + const { revision } = await createPublishedGuide({ authorId: userId }); + + const res = await app.request(`/profiles/${username}`, auth(token), env); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + activity: Array<{ + revision_id: string | null; + subjects: Array<{ slug: string; name: string }>; + }>; + }; + const row = body.activity.find((r) => r.revision_id === revision.id); + expect(row).toBeDefined(); + expect(row?.subjects).toEqual([]); + }); }); From 4cf51bb5e7ea9991c8732fa3fdf6dd00a52df051 Mon Sep 17 00:00:00 2001 From: Rodrigo Linhas Date: Thu, 3 Sep 2026 16:01:39 +0100 Subject: [PATCH 3/3] feat(app): add subject filter to profile activity table - Extend filterActivity() to accept and apply a subject slug filter (OR semantics: a row passes if any of its subjects matches) - Add getActivitySubjectOptions() to derive unique sorted subject choices from the unfiltered activity array - Widen ChoiceColumnFilter to accept 'subject' field alongside 'type'/'status' - Add Subject column to the desktop table header and Subject chip to the mobile filter bar; both are hidden when no subjects are available - Render subject badge chips in desktop table cells and mobile cards - Update hasFilters to include the subject dimension - Expand colSpan of the empty-state row from 6 to 7 Signed-off-by: Rodrigo Linhas --- app/src/components/ActivityColumnFilters.tsx | 4 +- app/src/components/profile/ActivityTable.tsx | 62 ++++++++++++- app/src/lib/__tests__/profile.test.ts | 93 +++++++++++++++++++- app/src/lib/profile.ts | 21 ++++- 4 files changed, 175 insertions(+), 5 deletions(-) diff --git a/app/src/components/ActivityColumnFilters.tsx b/app/src/components/ActivityColumnFilters.tsx index f4ba599e..83ac898e 100644 --- a/app/src/components/ActivityColumnFilters.tsx +++ b/app/src/components/ActivityColumnFilters.tsx @@ -191,9 +191,9 @@ export function ChoiceColumnFilter({ setFilters, }: { label: string; - field: "type" | "status"; + field: "type" | "status" | "subject"; options: ReadonlyArray<{ - value: ActivityTypeFilter | ActivityStatusFilter; + value: ActivityTypeFilter | ActivityStatusFilter | string; label: string; }>; search: ActivityFilters; diff --git a/app/src/components/profile/ActivityTable.tsx b/app/src/components/profile/ActivityTable.tsx index cf2b7899..cfb96ef7 100644 --- a/app/src/components/profile/ActivityTable.tsx +++ b/app/src/components/profile/ActivityTable.tsx @@ -7,6 +7,7 @@ import { activityStatusLabel, activityTypeLabel, filterActivity, + getActivitySubjectOptions, } from "@/lib/profile"; import { formatDate } from "@/lib/guideUtils"; import { cn } from "@/lib/utils"; @@ -95,6 +96,7 @@ export function ActivityTable({ const hasFilters = Boolean( search.type?.length || search.status?.length || + search.subject?.length || search.title || search.summary || search.from || @@ -120,6 +122,10 @@ export function ActivityTable({ ? "No activity matches these filters." : "No activity available yet."; + // Derive subject options from the full activity (before filtering) so all + // available subject choices are always visible, even when other filters are active. + const subjectOptions = getActivitySubjectOptions(activity); + return ( <>
@@ -151,6 +157,15 @@ export function ActivityTable({ search={search} setFilters={setFilters} /> + {subjectOptions.length > 0 && ( + + )}
{pageRows.length === 0 ? ( @@ -188,6 +203,20 @@ export function ActivityTable({

)} + {row.subjects.length > 0 && ( +
+ {row.subjects.map((s) => ( + + {s.name} + + ))} +
+ )} +
{formatDate(new Date(row.created_at))} @@ -256,6 +285,19 @@ export function ActivityTable({ setFilters={setFilters} /> + + {subjectOptions.length > 0 ? ( + + ) : ( + "Subject" + )} + Review Case @@ -265,7 +307,7 @@ export function ActivityTable({ {pageRows.length === 0 ? ( {emptyMessage} @@ -303,6 +345,24 @@ export function ActivityTable({ + + {row.subjects.length > 0 ? ( +
+ {row.subjects.map((s) => ( + + {s.name} + + ))} +
+ ) : ( + + )} +
+ {row.review_case_id ? (