diff --git a/api/src/services/todo.service.ts b/api/src/services/todo.service.ts index c3a659f9..e43a2340 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") @@ -15,7 +84,10 @@ export async function listOpenTodos(supabase: DB): Promise { 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)) + ) ) ), objective:objectives!requests_objective_id_fkey( @@ -32,22 +104,26 @@ export async function listOpenTodos(supabase: DB): Promise { throw new ServiceError("Failed to fetch todos", 500); } - // base.status nulls the embed for an unpublished base; it keeps the row. - return (data ?? []) - .filter((row) => row.base !== null || row.dependent_guide_base_id === null) - .map((row) => ({ - id: row.id, - guide_base_id: row.dependent_guide_base_id, - guide_slug: row.base?.slug ?? null, - guide_title: row.base?.canonical?.current?.title ?? null, - objective_slug: row.objective?.slug ?? null, - objective_title: row.objective?.current?.title ?? null, - title: row.title, - summary: row.summary, - status: row.status, - claim_count: row.claims[0]?.count ?? 0, - created_at: row.created_at, - })); + 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, + guide_slug: row.base.slug, + guide_title: row.base.canonical?.current?.title ?? null, + title: row.title, + summary: row.summary, + 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) ?? [], + })); } export async function createTodo( diff --git a/api/tests/graph.test.ts b/api/tests/graph.test.ts index e817800b..ad634bb7 100644 --- a/api/tests/graph.test.ts +++ b/api/tests/graph.test.ts @@ -9,6 +9,8 @@ import { } from "./factories/guides"; import { createPublishedObjective } from "./factories/objectives"; import { createPrerequisite, createTodo } from "./factories/graph"; +import { createSubject, tagGuideRevision } from "./factories/subjects"; +import { createPublishedObjective } from "./factories/objectives"; import { expectToMatchSpec } from "./openapi"; import { createRequestSchema } from "@bluelearn/schemas"; @@ -124,6 +126,20 @@ describe("GET /todos", () => { 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" }); it("lists objective-raised requests beside guide-raised ones", async () => { const { userId } = await makeUser(); const { from } = await seedAuthoredBases(userId); @@ -148,6 +164,15 @@ describe("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" }, + ]); guide_slug: string | null; objective_slug: string | null; objective_title: string | null; 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 195f5dc9..1099c0be 100644 --- a/app/src/lib/__tests__/groupTodos.test.ts +++ b/app/src/lib/__tests__/groupTodos.test.ts @@ -19,6 +19,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 211ab283..ab5430a4 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,12 +16,24 @@ 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"; import { useAuth } from "@/lib/authContext"; 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( @@ -28,7 +41,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, @@ -47,6 +73,7 @@ function TodosLoadError() { type TodosPageProps = { children: React.ReactNode; + aside?: React.ReactNode; }; const TodosPage = ({ children }: TodosPageProps) => { @@ -83,11 +110,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, @@ -100,12 +157,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 ( - + @@ -129,12 +186,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 @@ -145,7 +207,7 @@ function RouteComponent() { } return ( - +

{pageRows.map((group) => ( diff --git a/packages/schemas/src/graph/responses.ts b/packages/schemas/src/graph/responses.ts index 0be878a2..2d6ba280 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(), @@ -13,6 +14,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({