From e4e436674048a8b9e04ea3ca91f536ced6498543 Mon Sep 17 00:00:00 2001 From: kirbx01 Date: Sun, 20 Sep 2026 18:58:56 +0530 Subject: [PATCH] feat: filter todos by name, subject, and objective --- api/src/services/todo.service.ts | 83 ++++++++++++++- api/tests/graph.test.ts | 41 +++++++- app/src/components/TodoFilterMenu.tsx | 118 +++++++++++++++++++++ app/src/lib/__tests__/groupTodos.test.ts | 2 + app/src/lib/__tests__/todoFilters.test.ts | 122 ++++++++++++++++++++++ app/src/lib/todoFilters.ts | 54 ++++++++++ app/src/routes/todos.tsx | 85 +++++++++++++-- packages/schemas/src/graph/responses.ts | 4 + 8 files changed, 496 insertions(+), 13 deletions(-) create mode 100644 app/src/components/TodoFilterMenu.tsx create mode 100644 app/src/lib/__tests__/todoFilters.test.ts create mode 100644 app/src/lib/todoFilters.ts diff --git a/api/src/services/todo.service.ts b/api/src/services/todo.service.ts index 5864fed6..2fba90f1 100644 --- a/api/src/services/todo.service.ts +++ b/api/src/services/todo.service.ts @@ -2,9 +2,78 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import type { TodoListItem } from "@bluelearn/schemas"; import type { Database } from "../database.types"; import { ServiceError } from "../lib/service-error"; +import { selectInBatches } from "../lib/batch"; type DB = SupabaseClient; +// Published objectives that list a requesting guide in their current revision, +// keyed by guide base id. Todos don't carry subjects/objectives directly, so +// the todos page filters by the requesting guide's associations. +async function loadGuideObjectives( + supabase: DB, + baseIds: string[] +): Promise>> { + const map = new Map>(); + if (baseIds.length === 0) return map; + + const { data: nodes, error: nodeError } = await selectInBatches( + baseIds, + (batch) => + supabase + .from("objective_revision_nodes") + .select("guide_base_id, revision_id") + .in("guide_base_id", batch) + ); + if (nodeError) { + console.error(nodeError); + throw new ServiceError("Failed to load todo objectives", 500); + } + + const revisionIds = [...new Set((nodes ?? []).map((n) => n.revision_id))]; + if (revisionIds.length === 0) return map; + + const { data: objectives, error: objectiveError } = await selectInBatches( + revisionIds, + (batch) => + supabase + .from("objectives") + .select( + `id, current_revision_id, slug, + current:objective_revisions!objectives_current_revision_id_fkey(title)` + ) + .in("current_revision_id", batch) + .eq("status", "published") + ); + if (objectiveError) { + console.error(objectiveError); + throw new ServiceError("Failed to load todo objectives", 500); + } + + const objectivesByRevision = new Map< + string, + Array<{ slug: string; title: string }> + >(); + for (const objective of objectives ?? []) { + if (!objective.current_revision_id || !objective.slug) continue; + if (!objective.current?.title) continue; + const list = objectivesByRevision.get(objective.current_revision_id) ?? []; + list.push({ slug: objective.slug, title: objective.current.title }); + objectivesByRevision.set(objective.current_revision_id, list); + } + + for (const node of nodes ?? []) { + for (const objective of objectivesByRevision.get(node.revision_id) ?? []) { + const list = map.get(node.guide_base_id) ?? []; + list.push(objective); + map.set(node.guide_base_id, list); + } + } + + for (const list of map.values()) + list.sort((a, b) => a.title.localeCompare(b.title)); + return map; +} + export async function listOpenTodos(supabase: DB): Promise { const { data, error } = await supabase .from("requests") @@ -14,7 +83,10 @@ export async function listOpenTodos(supabase: DB): Promise { base:guide_bases!requests_dependent_guide_base_id_fkey!inner( slug, canonical:guides!guide_bases_canonical_guide_id_fkey( - current:guide_revisions!guides_current_revision_id_fkey(title) + current:guide_revisions!guides_current_revision_id_fkey( + title, + subjects:guide_revision_subjects(subjects(slug, name)) + ) ) )` ) @@ -26,6 +98,11 @@ export async function listOpenTodos(supabase: DB): Promise { throw new ServiceError("Failed to fetch todos", 500); } + const objectivesByBase = await loadGuideObjectives( + supabase, + (data ?? []).map((row) => row.dependent_guide_base_id) + ); + return (data ?? []).map((row) => ({ id: row.id, guide_base_id: row.dependent_guide_base_id, @@ -36,6 +113,10 @@ export async function listOpenTodos(supabase: DB): Promise { status: row.status, claim_count: row.claims[0]?.count ?? 0, created_at: row.created_at, + subjects: (row.base.canonical?.current?.subjects ?? []) + .map((s) => s.subjects) + .filter((s): s is { slug: string; name: string } => !!s?.slug), + objectives: objectivesByBase.get(row.dependent_guide_base_id) ?? [], })); } diff --git a/api/tests/graph.test.ts b/api/tests/graph.test.ts index 728defb5..3137d766 100644 --- a/api/tests/graph.test.ts +++ b/api/tests/graph.test.ts @@ -2,8 +2,14 @@ import { describe, it, expect } from "vitest"; import app from "../src/index"; import { auth, env, jsonAuth, makeUser } from "./helpers"; import { grantRole } from "./factories/identity"; -import { createGuideBase, createGuide } from "./factories/guides"; +import { + createGuideBase, + createGuide, + createPublishedGuide, +} from "./factories/guides"; import { createPrerequisite, createTodo } from "./factories/graph"; +import { createSubject, tagGuideRevision } from "./factories/subjects"; +import { createPublishedObjective } from "./factories/objectives"; import { expectToMatchSpec } from "./openapi"; // Two published bases where `userId` authors a guide under the first, which is @@ -117,6 +123,39 @@ describe("GET /todos", () => { expect(ids).toContain(open.id); expect(ids).not.toContain(resolved.id); }); + + it("carries the requesting guide's subjects and objectives", async () => { + const { userId } = await makeUser(); + const full = await createPublishedGuide({ + title: "Calculus 1", + authorId: userId, + }); + const subject = await createSubject({ name: "Mathematics" }); + await tagGuideRevision(full.revision.id, subject.id); + const objective = await createPublishedObjective( + userId, + { base: full.base, guide: full.guide }, + { title: "Calculus Track" } + ); + const todo = await createTodo(full.base.id, { title: "Vectors" }); + + const res = await app.request("/todos", {}, env); + + expect(res.status).toBe(200); + await expectToMatchSpec(res, "GET", "/todos"); + const body = (await res.json()) as { + todos: Array<{ + id: string; + subjects: Array<{ slug: string; name: string }>; + objectives: Array<{ slug: string; title: string }>; + }>; + }; + const row = body.todos.find((t) => t.id === todo.id); + expect(row?.subjects).toEqual([{ slug: subject.slug, name: subject.name }]); + expect(row?.objectives).toEqual([ + { slug: objective.objective.slug, title: "Calculus Track" }, + ]); + }); }); describe("POST /todos", () => { diff --git a/app/src/components/TodoFilterMenu.tsx b/app/src/components/TodoFilterMenu.tsx new file mode 100644 index 00000000..482e11f8 --- /dev/null +++ b/app/src/components/TodoFilterMenu.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from "react"; +import { Search, SlidersHorizontal } from "lucide-react"; + +import type { TodoFilters } from "@/lib/todoFilters"; +import type { ComboboxItem } from "@/components/ui/combobox"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Combobox } from "@/components/ui/combobox"; + +type TodoFilterMenuProps = { + q?: string; + subject?: string; + objective?: string; + subjectItems: Array; + objectiveItems: Array; + onChange: (filters: Partial) => void; +}; + +export function TodoFilterMenu({ + q, + subject, + objective, + subjectItems, + objectiveItems, + onChange, +}: TodoFilterMenuProps) { + const [name, setName] = useState(q ?? ""); + const [open, setOpen] = useState(false); + + // Keep the local field in sync with the URL so back/forward updates it. + useEffect(() => setName(q ?? ""), [q]); + + const active = Boolean(q || subject || objective); + + return ( + + + + + + +
{ + e.preventDefault(); + onChange({ q: name.trim() || undefined }); + }} + > + setName(e.target.value)} + placeholder="Filter by name" + aria-label="Filter todo guides by name" + className="h-9 flex-1" + /> + +
+ +
+ Subject + onChange({ subject: value || undefined })} + /> +
+ +
+ Objective + + onChange({ objective: value || undefined }) + } + /> +
+ + {active && ( + + )} +
+
+ ); +} diff --git a/app/src/lib/__tests__/groupTodos.test.ts b/app/src/lib/__tests__/groupTodos.test.ts index eda439be..f7d9dd44 100644 --- a/app/src/lib/__tests__/groupTodos.test.ts +++ b/app/src/lib/__tests__/groupTodos.test.ts @@ -17,6 +17,8 @@ function makeTodoItem(overrides: Partial = {}): TodoListItem { status: "open", claim_count: 0, created_at: new Date("2026-01-01T10:00:00Z").toISOString(), + subjects: [], + objectives: [], ...overrides, }; } diff --git a/app/src/lib/__tests__/todoFilters.test.ts b/app/src/lib/__tests__/todoFilters.test.ts new file mode 100644 index 00000000..9c9436f1 --- /dev/null +++ b/app/src/lib/__tests__/todoFilters.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; + +import type { TodoListItem } from "@bluelearn/schemas"; +import { + filterTodos, + objectiveFilterItems, + subjectFilterItems, +} from "@/lib/todoFilters"; + +function makeTodoItem(overrides: Partial = {}): TodoListItem { + return { + id: crypto.randomUUID(), + guide_base_id: crypto.randomUUID(), + guide_slug: "intro-to-math", + guide_title: "Intro to Math", + title: "Understanding Derivatives", + summary: "Need a comprehensive introduction to derivatives.", + status: "open", + claim_count: 0, + created_at: new Date("2026-01-01T10:00:00Z").toISOString(), + subjects: [], + objectives: [], + ...overrides, + }; +} + +const withAssociations = (overrides: Partial): TodoListItem => + makeTodoItem({ + subjects: [ + { slug: "math", name: "Mathematics" }, + { slug: "physics", name: "Physics" }, + ], + objectives: [{ slug: "calculus-track", title: "Calculus Track" }], + ...overrides, + }); + +describe("filterTodos", () => { + it("keeps everything when no filters are set", () => { + const todos = [makeTodoItem(), makeTodoItem()]; + expect(filterTodos(todos, {})).toEqual(todos); + }); + + it("matches the name case-insensitively", () => { + const match = makeTodoItem({ title: "Linear Algebra Basics" }); + const miss = makeTodoItem({ title: "Vectors" }); + const result = filterTodos([match, miss], { q: "algeBRA bas" }); + expect(result).toEqual([match]); + }); + + it("matches by requesting guide subject", () => { + const match = withAssociations({}); + const miss = makeTodoItem({ + subjects: [{ slug: "cs", name: "Computer Science" }], + }); + const result = filterTodos([match, miss], { subject: "physics" }); + expect(result).toEqual([match]); + }); + + it("matches by requesting guide objective", () => { + const match = withAssociations({}); + const miss = makeTodoItem({ + objectives: [{ slug: "web-dev", title: "Web Dev Track" }], + }); + const result = filterTodos([match, miss], { objective: "calculus-track" }); + expect(result).toEqual([match]); + }); + + it("combines name, subject, and objective filters", () => { + const target = withAssociations({ title: "Integrals" }); + const wrongName = withAssociations({ title: "Limits" }); + const wrongSubject = withAssociations({ + title: "Integrals", + subjects: [{ slug: "cs", name: "Computer Science" }], + }); + const wrongObjective = withAssociations({ + title: "Integrals", + objectives: [{ slug: "web-dev", title: "Web Dev Track" }], + }); + + const result = filterTodos( + [target, wrongName, wrongSubject, wrongObjective], + { + q: "integ", + subject: "physics", + objective: "calculus-track", + } + ); + + expect(result).toEqual([target]); + }); +}); + +describe("subjectFilterItems", () => { + it("dedupes by slug and sorts by name", () => { + const todos = [ + withAssociations({}), + withAssociations({}), + makeTodoItem({ subjects: [{ slug: "cs", name: "Computer Science" }] }), + ]; + expect(subjectFilterItems(todos)).toEqual([ + { value: "cs", label: "Computer Science" }, + { value: "math", label: "Mathematics" }, + { value: "physics", label: "Physics" }, + ]); + }); +}); + +describe("objectiveFilterItems", () => { + it("dedupes by slug and sorts by title", () => { + const todos = [ + withAssociations({}), + withAssociations({}), + makeTodoItem({ + objectives: [{ slug: "web-dev", title: "Web Dev Track" }], + }), + ]; + expect(objectiveFilterItems(todos)).toEqual([ + { value: "calculus-track", label: "Calculus Track" }, + { value: "web-dev", label: "Web Dev Track" }, + ]); + }); +}); diff --git a/app/src/lib/todoFilters.ts b/app/src/lib/todoFilters.ts new file mode 100644 index 00000000..59df4d52 --- /dev/null +++ b/app/src/lib/todoFilters.ts @@ -0,0 +1,54 @@ +import type { TodoListItem } from "@bluelearn/schemas"; +import type { ComboboxItem } from "@/components/ui/combobox"; + +export type TodoFilters = { + q?: string; + subject?: string; + objective?: string; +}; + +// Filter open todos by the requesting guide's associations before they are +// grouped and paginated. Todos don't carry subjects/objectives directly. +export const filterTodos = ( + todos: Array, + { q, subject, objective }: TodoFilters +): Array => { + const query = q?.trim().toLowerCase(); + + return todos.filter((todo) => { + if (query && !todo.title.toLowerCase().includes(query)) return false; + if (subject && !todo.subjects.some((s) => s.slug === subject)) return false; + if (objective && !todo.objectives.some((o) => o.slug === objective)) + return false; + return true; + }); +}; + +const distinctItems = ( + rows: Array<{ value: string; label: string }> +): Array => { + const byValue = new Map(); + for (const row of rows) + if (!byValue.has(row.value)) byValue.set(row.value, row.label); + return [...byValue] + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)); +}; + +export const subjectFilterItems = ( + todos: Array +): Array => + distinctItems( + todos + .flatMap((t) => t.subjects) + .map((s) => ({ value: s.slug, label: s.name })) + ); + +export const objectiveFilterItems = ( + todos: Array +): Array => + distinctItems( + todos + .flatMap((t) => t.objectives) + .map((o) => ({ value: o.slug, label: o.title })) + ); diff --git a/app/src/routes/todos.tsx b/app/src/routes/todos.tsx index bc4cb842..6713e1e7 100644 --- a/app/src/routes/todos.tsx +++ b/app/src/routes/todos.tsx @@ -1,10 +1,11 @@ import { useMemo } from "react"; import { Link, createFileRoute, useNavigate } from "@tanstack/react-router"; -import { paginationSchema } from "@bluelearn/schemas"; +import type { TodoFilters } from "@/lib/todoFilters"; import { Separator } from "@/components/ui/separator"; import { TodoCard } from "@/components/cards/TodoCard"; import { Pagination } from "@/components/Pagination"; +import { TodoFilterMenu } from "@/components/TodoFilterMenu"; import { Empty, EmptyDescription, @@ -15,11 +16,23 @@ import { import { listTodos } from "@/lib/api/todos"; import { groupTodosByTitle } from "@/lib/groupTodos"; +import { + filterTodos, + objectiveFilterItems, + subjectFilterItems, +} from "@/lib/todoFilters"; import { usePagination } from "@/lib/usePagination"; import { buildPageMeta } from "@/lib/seo"; const PAGE_SIZE = 10; +type TodosSearch = TodoFilters & { page?: number }; + +function pageParam(value: unknown) { + const page = Number(value); + return Number.isInteger(page) && page > 1 ? page : undefined; +} + export const Route = createFileRoute("/todos")({ head: () => ({ meta: buildPageMeta( @@ -27,7 +40,20 @@ export const Route = createFileRoute("/todos")({ "Explore requested guides on Bluelearn and help fill gaps in the community's learning resources." ), }), - validateSearch: paginationSchema.pick({ page: true }), + // Drop empty filter values so the URL stays clean. + validateSearch: (raw: Record): TodosSearch => { + const q = typeof raw.q === "string" ? raw.q.trim() : ""; + const subject = typeof raw.subject === "string" ? raw.subject : undefined; + const objective = + typeof raw.objective === "string" ? raw.objective : undefined; + const page = pageParam(raw.page); + return { + ...(q ? { q } : {}), + ...(subject ? { subject } : {}), + ...(objective ? { objective } : {}), + ...(page ? { page } : {}), + }; + }, loader: ({ abortController }) => listTodos({ signal: abortController.signal }), errorComponent: TodosLoadError, @@ -46,9 +72,10 @@ function TodosLoadError() { type TodosPageProps = { children: React.ReactNode; + aside?: React.ReactNode; }; -const TodosPage = ({ children }: TodosPageProps) => { +const TodosPage = ({ children, aside }: TodosPageProps) => { return (
@@ -56,6 +83,7 @@ const TodosPage = ({ children }: TodosPageProps) => {

Guides Waiting To Be Written

+ {aside}
@@ -67,11 +95,41 @@ const TodosPage = ({ children }: TodosPageProps) => { }; function RouteComponent() { - const { page } = Route.useSearch(); + const { q, subject, objective, page = 1 } = Route.useSearch(); const todos = Route.useLoaderData(); - const navigate = useNavigate(); + const navigate = useNavigate({ from: Route.fullPath }); - const groups = useMemo(() => groupTodosByTitle(todos), [todos]); + const filteredTodos = useMemo( + () => filterTodos(todos, { q, subject, objective }), + [todos, q, subject, objective] + ); + const groups = useMemo( + () => groupTodosByTitle(filteredTodos), + [filteredTodos] + ); + + const subjectItems = useMemo(() => subjectFilterItems(todos), [todos]); + const objectiveItems = useMemo(() => objectiveFilterItems(todos), [todos]); + + const setFilters = (filters: Partial) => + navigate({ + search: (prev) => ({ + ...prev, + ...filters, + page: undefined, + }), + }); + + const filterMenu = ( + + ); const { page: activePage, @@ -84,12 +142,12 @@ function RouteComponent() { toLast, } = usePagination(groups, PAGE_SIZE, { page, - onPageChange: (p) => navigate({ to: "/todos", search: { page: p } }), + onPageChange: (p) => navigate({ search: (prev) => ({ ...prev, page: p }) }), }); if (groups.length === 0) { return ( - + @@ -113,12 +171,17 @@ function RouteComponent() { // showing the empty-state copy, which reads like there is nothing to browse. if (page > totalPages) { return ( - +

Page {page} is past the last page.{" "} Back to page 1 @@ -129,7 +192,7 @@ function RouteComponent() { } return ( - +

{pageRows.map((group) => ( diff --git a/packages/schemas/src/graph/responses.ts b/packages/schemas/src/graph/responses.ts index 40ed1ea7..24d5b779 100644 --- a/packages/schemas/src/graph/responses.ts +++ b/packages/schemas/src/graph/responses.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { edgeTypeSchema, todoStatusSchema } from "./enums"; +import { subjectReferenceSchema } from "../subjects/references"; export const todoListItemSchema = z.object({ id: z.uuid(), @@ -11,6 +12,9 @@ export const todoListItemSchema = z.object({ status: todoStatusSchema, claim_count: z.number().int(), created_at: z.iso.datetime({ offset: true }), + // The requesting guide's associations, powering the todos page filters. + subjects: z.array(subjectReferenceSchema), + objectives: z.array(z.object({ slug: z.string(), title: z.string() })), }); export const todoSchema = z.object({