-
Notifications
You must be signed in to change notification settings - Fork 18
feat: homepage tasks module — recent runs + one-click starter task (Catalog HQ 2/3, chat#1850) #1853
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: homepage tasks module — recent runs + one-click starter task (Catalog HQ 2/3, chat#1850) #1853
Changes from all commits
befcadd
a2e7819
49b2fc1
9e9095d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import Link from "next/link"; | ||
| import { cn } from "@/lib/utils"; | ||
| import type { TaskRunItem } from "@/lib/tasks/getTaskRuns"; | ||
| import { getTaskDisplayName } from "@/lib/tasks/getTaskDisplayName"; | ||
| import { getStatusColor } from "@/lib/tasks/getStatusColor"; | ||
| import { getStatusLabel } from "@/lib/tasks/getStatusLabel"; | ||
| import { formatTimestamp } from "@/lib/tasks/formatTimestamp"; | ||
|
|
||
| /** | ||
| * Compact task-run row for the homepage tasks module. Links to the | ||
| * existing run detail page. Per-run sent-email subjects are not exposed | ||
| * by `GET /api/tasks/runs` yet, so this shows what is available: run | ||
| * name, status, and finished-at (recoupable/chat#1850). | ||
| */ | ||
| const HomeRunRow = ({ run }: { run: TaskRunItem }) => ( | ||
| <Link | ||
| href={`/tasks/${run.id}`} | ||
| className="group -mx-2 flex items-center justify-between gap-3 rounded-lg px-2 py-2.5 transition-colors hover:bg-muted" | ||
| > | ||
| <div className="min-w-0"> | ||
| <p className="truncate text-sm font-medium text-foreground"> | ||
| {getTaskDisplayName(run.taskIdentifier)} | ||
| </p> | ||
| <p className="text-xs text-muted-foreground"> | ||
| {formatTimestamp(run.finishedAt ?? run.createdAt)} | ||
| </p> | ||
| </div> | ||
| <span | ||
| className={cn( | ||
| "shrink-0 rounded-full px-2 py-1 text-xs font-medium", | ||
| getStatusColor(run.status), | ||
| )} | ||
| > | ||
| {getStatusLabel(run.status)} | ||
| </span> | ||
| </Link> | ||
| ); | ||
|
|
||
| export default HomeRunRow; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| "use client"; | ||
|
|
||
| import { useArtistProvider } from "@/providers/ArtistProvider"; | ||
| import { useCreateStarterTask } from "@/hooks/useCreateStarterTask"; | ||
|
|
||
| /** | ||
| * Empty-state suggestion for the homepage tasks module: one click | ||
| * schedules an existing /agents Report template for the selected artist, | ||
| * Mondays at 9am, via the existing task-creation path | ||
| * (recoupable/chat#1850). Hidden when no Report template exists. | ||
| */ | ||
| const StarterTaskCard = () => { | ||
| const { selectedArtist } = useArtistProvider(); | ||
| const { handleCreateStarterTask, isCreating, isScheduled, starterTemplate } = | ||
| useCreateStarterTask(); | ||
| const artistName = selectedArtist?.name || "your artist"; | ||
|
|
||
| if (!starterTemplate) return null; | ||
|
|
||
| if (isScheduled) { | ||
| return ( | ||
| <p className="text-sm text-muted-foreground" role="status"> | ||
| Scheduled: {starterTemplate.title} for {artistName}, Mondays at 9am. | ||
| </p> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <div className="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center"> | ||
| <div> | ||
| <p className="text-sm font-medium text-foreground"> | ||
| {starterTemplate.title} for {artistName}, Mondays | ||
| </p> | ||
| <p className="text-xs text-muted-foreground"> | ||
| {starterTemplate.description} | ||
| </p> | ||
| </div> | ||
| <button | ||
| type="button" | ||
| onClick={() => handleCreateStarterTask()} | ||
| disabled={isCreating} | ||
| className="shrink-0 rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50" | ||
| > | ||
| {isCreating ? "Scheduling..." : "Schedule it"} | ||
| </button> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default StarterTaskCard; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| "use client"; | ||
|
|
||
| import Link from "next/link"; | ||
| import { useArtistProvider } from "@/providers/ArtistProvider"; | ||
| import { useTaskRuns } from "@/hooks/useTaskRuns"; | ||
| import { getHomeTasksModuleState } from "@/lib/home/getHomeTasksModuleState"; | ||
| import HomeRunRow from "./HomeRunRow"; | ||
| import StarterTaskCard from "./StarterTaskCard"; | ||
|
|
||
| /** | ||
| * "Your label at work" homepage module: recent task runs (existing | ||
| * GET /api/tasks/runs) or the one-click starter suggestion for fresh | ||
| * accounts (recoupable/chat#1850). Renders nothing while loading or on | ||
| * failure so the homepage never blocks on it. | ||
| */ | ||
| const TasksModule = () => { | ||
| const { data: runs, isLoading, isError } = useTaskRuns(); | ||
| const { selectedArtist } = useArtistProvider(); | ||
|
|
||
| const state = getHomeTasksModuleState({ | ||
| runs, | ||
| runsFailed: isError, | ||
| isLoading, | ||
| hasArtist: !!selectedArtist?.account_id, | ||
| }); | ||
|
|
||
| if (state.view === "hidden") return null; | ||
|
|
||
| return ( | ||
| <section | ||
| aria-label="Your label at work" | ||
| className="w-full rounded-xl bg-card p-6 shadow-[0px_0px_0px_1px_var(--border),0px_2px_4px_rgba(0,0,0,0.04)] dark:shadow-[0px_0px_0px_1px_var(--border),0px_2px_4px_rgba(0,0,0,0.2)]" | ||
| > | ||
| <div className="mb-3 flex items-center justify-between"> | ||
| <h2 className="text-xs uppercase tracking-[0.05em] text-muted-foreground"> | ||
| Your label at work | ||
| </h2> | ||
| <Link | ||
| href="/tasks" | ||
| className="text-xs font-medium text-foreground hover:underline" | ||
| > | ||
| View all | ||
| </Link> | ||
| </div> | ||
| {state.view === "runs" ? ( | ||
| <div className="flex flex-col"> | ||
| {state.runs.map((run) => ( | ||
| <HomeRunRow key={run.id} run={run} /> | ||
| ))} | ||
| </div> | ||
| ) : ( | ||
| <StarterTaskCard /> | ||
| )} | ||
| </section> | ||
| ); | ||
| }; | ||
|
|
||
| export default TasksModule; |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,79 @@ | ||||||
| "use client"; | ||||||
|
|
||||||
| import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | ||||||
| import { usePrivy } from "@privy-io/react-auth"; | ||||||
| import { toast } from "sonner"; | ||||||
| import { createStarterTask } from "@/lib/home/createStarterTask"; | ||||||
| import { findStarterTemplate } from "@/lib/home/findStarterTemplate"; | ||||||
| import fetchAgentTemplates from "@/lib/agent-templates/fetchAgentTemplates"; | ||||||
| import type { AgentTemplateRow } from "@/types/AgentTemplates"; | ||||||
| import { useArtistProvider } from "@/providers/ArtistProvider"; | ||||||
| import { useUserProvider } from "@/providers/UserProvder"; | ||||||
|
|
||||||
| /** | ||||||
| * One-click creation of the homepage starter task. The task is sourced | ||||||
| * from an existing /agents template (DRY — findStarterTemplate picks it | ||||||
| * from the shared agent-templates query); auth + artist come from | ||||||
| * providers and the mutation goes through the existing `POST /api/tasks` | ||||||
| * path which also mints the schedule (recoupable/chat#1850). | ||||||
| */ | ||||||
| export function useCreateStarterTask() { | ||||||
| const { getAccessToken } = usePrivy(); | ||||||
| const { selectedArtist } = useArtistProvider(); | ||||||
| const { userData } = useUserProvider(); | ||||||
| const queryClient = useQueryClient(); | ||||||
|
|
||||||
| const { data: templates } = useQuery<AgentTemplateRow[]>({ | ||||||
| queryKey: ["agent-templates"], | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Agent templates can be reused from a previous signed-in user/session because this user-specific fetch is cached under the global ["agent-templates"] key. Including userData.id in the query key would keep starter task template selection scoped to the current account. Prompt for AI agents
Suggested change
|
||||||
| queryFn: () => fetchAgentTemplates(userData!), | ||||||
| retry: 1, | ||||||
| enabled: !!userData?.id, | ||||||
| }); | ||||||
| const starterTemplate = findStarterTemplate(templates); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: When no Report starter template exists, the card disappears but the homepage module shell still renders with an empty body. Consider lifting template availability into the module state or otherwise hiding the parent section when starterTemplate is absent. Prompt for AI agents |
||||||
|
|
||||||
| const { | ||||||
| mutate: handleCreateStarterTask, | ||||||
| isPending: isCreating, | ||||||
| isSuccess: isScheduled, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The starter card can show a false "scheduled" state after artist switching, because Prompt for AI agents |
||||||
| } = useMutation({ | ||||||
| mutationFn: async () => { | ||||||
| const artistAccountId = selectedArtist?.account_id; | ||||||
| const artistName = selectedArtist?.name; | ||||||
| if (!artistAccountId || !artistName) { | ||||||
| throw new Error("ARTIST_REQUIRED"); | ||||||
| } | ||||||
|
Comment on lines
+42
to
+44
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a selected artist/workspace whose Useful? React with 👍 / 👎. |
||||||
| if (!starterTemplate) { | ||||||
| throw new Error("TEMPLATE_REQUIRED"); | ||||||
| } | ||||||
| const accessToken = await getAccessToken(); | ||||||
| if (!accessToken) { | ||||||
| throw new Error("AUTH_REQUIRED"); | ||||||
| } | ||||||
| return createStarterTask(accessToken, { | ||||||
| template: starterTemplate, | ||||||
| artistName, | ||||||
| artistAccountId, | ||||||
| }); | ||||||
| }, | ||||||
| onSuccess: async () => { | ||||||
| await queryClient.invalidateQueries({ | ||||||
| queryKey: ["scheduled-actions"], | ||||||
| exact: false, | ||||||
| }); | ||||||
| toast.success("Task scheduled for Mondays at 9am"); | ||||||
| }, | ||||||
| onError: (error) => { | ||||||
| if (error instanceof Error && error.message === "ARTIST_REQUIRED") { | ||||||
| toast.error("Please select an artist first."); | ||||||
| return; | ||||||
| } | ||||||
| if (error instanceof Error && error.message === "AUTH_REQUIRED") { | ||||||
| toast.error("Please sign in to create a task."); | ||||||
| return; | ||||||
| } | ||||||
| toast.error("Failed to create the task. Please try again."); | ||||||
| }, | ||||||
| }); | ||||||
|
|
||||||
| return { handleCreateStarterTask, isCreating, isScheduled, starterTemplate }; | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { buildStarterTaskParams } from "@/lib/home/buildStarterTaskParams"; | ||
| import type { AgentTemplateRow } from "@/types/AgentTemplates"; | ||
|
|
||
| const template = { | ||
| id: "9046c7e9-fd5a-4f08-a472-381d51bd6c90", | ||
| title: "Weekly Performance Dashboard", | ||
| description: "Weekly stats email", | ||
| prompt: | ||
| "Set up a weekly performance dashboard that emails me every Monday with my stats from all platforms.", | ||
| tags: ["Report"], | ||
| creator: null, | ||
| is_private: false, | ||
| created_at: null, | ||
| favorites_count: null, | ||
| updated_at: null, | ||
| } as AgentTemplateRow; | ||
|
|
||
| describe("buildStarterTaskParams", () => { | ||
| const params = buildStarterTaskParams({ | ||
| template, | ||
| artistName: "Del Water Gap", | ||
| artistAccountId: "artist-1", | ||
| }); | ||
|
|
||
| it("titles the task after the template and the artist", () => { | ||
| expect(params.title).toBe("Weekly Performance Dashboard — Del Water Gap"); | ||
| }); | ||
|
|
||
| it("uses the agent template's prompt verbatim (DRY — no hand-written prompt)", () => { | ||
| expect(params.prompt).toBe(template.prompt); | ||
| }); | ||
|
|
||
| it("schedules Mondays", () => { | ||
| expect(params.schedule).toBe("0 9 * * 1"); | ||
| }); | ||
|
|
||
| it("targets the artist account", () => { | ||
| expect(params.artist_account_id).toBe("artist-1"); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Users can click "Schedule it" in a state where creation is guaranteed to fail, causing an avoidable error toast loop. The button is only disabled during pending state, but
useCreateStarterTaskrequires a non-nullselectedArtist.name, so disabling when required artist fields are missing would prevent this broken path.Prompt for AI agents