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 ? (