Skip to content
110 changes: 93 additions & 17 deletions api/src/services/todo.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Database>;

// 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<Map<string, Array<{ slug: string; title: string }>>> {
const map = new Map<string, Array<{ slug: string; title: string }>>();
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<TodoListItem[]> {
const { data, error } = await supabase
.from("requests")
Expand All @@ -15,7 +84,10 @@ export async function listOpenTodos(supabase: DB): Promise<TodoListItem[]> {

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(
Expand All @@ -32,22 +104,26 @@ export async function listOpenTodos(supabase: DB): Promise<TodoListItem[]> {
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(
Expand Down
25 changes: 25 additions & 0 deletions api/tests/graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down
118 changes: 118 additions & 0 deletions app/src/components/TodoFilterMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<ComboboxItem>;
objectiveItems: Array<ComboboxItem>;
onChange: (filters: Partial<TodoFilters>) => 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Filter todo guides"
className={`h-9 w-9 shrink-0 rounded-md border ${active ? "text-brand-bright-blue" : ""}`}
>
<SlidersHorizontal className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>

<PopoverContent align="end" className="font-mono">
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
onChange({ q: name.trim() || undefined });
}}
>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Filter by name"
aria-label="Filter todo guides by name"
className="h-9 flex-1"
/>
<Button
type="submit"
size="icon"
aria-label="Apply name filter"
className="btn-pri h-9 w-9 shrink-0 rounded-md"
>
<Search className="h-3.5 w-3.5" />
</Button>
</form>

<div className="flex flex-col gap-1.5">
<span className="data-label">Subject</span>
<Combobox
items={subjectItems}
value={subject ?? ""}
onValueChange={(value) => onChange({ subject: value || undefined })}
/>
</div>

<div className="flex flex-col gap-1.5">
<span className="data-label">Objective</span>
<Combobox
items={objectiveItems}
value={objective ?? ""}
onValueChange={(value) =>
onChange({ objective: value || undefined })
}
/>
</div>

{active && (
<Button
type="button"
variant="ghost"
className="h-9 justify-center self-end rounded-md border border-border bg-muted"
onClick={() =>
onChange({
q: undefined,
subject: undefined,
objective: undefined,
})
}
>
Reset filters
</Button>
)}
</PopoverContent>
</Popover>
);
}
2 changes: 2 additions & 0 deletions app/src/lib/__tests__/groupTodos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ function makeTodoItem(overrides: Partial<TodoListItem> = {}): TodoListItem {
status: "open",
claim_count: 0,
created_at: new Date("2026-01-01T10:00:00Z").toISOString(),
subjects: [],
objectives: [],
...overrides,
};
}
Expand Down
Loading
Loading