From befcaddbfe311b723da62b8fac349a205c54abd3 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 6 Jul 2026 12:30:48 -0500 Subject: [PATCH 1/4] feat: homepage tasks module with recent runs + one-click starter task (chat#1850) 'Your label at work' module under the valuation hero: recent task runs from the existing GET /api/tasks/runs (run name, status, finished-at), capped at five, linking to the run detail pages. Fresh accounts get one pre-wired suggestion (weekly valuation + streams report, Mondays 9am) that creates the scheduled task in one click through the existing POST /api/tasks path. Module state is a pure, fully-tested selector; the module renders nothing while loading or on failure so home never blocks on it. Co-Authored-By: Claude Fable 5 --- components/Home/HomePage.tsx | 4 + components/Home/HomeRunRow.tsx | 39 +++++++++ components/Home/StarterTaskCard.tsx | 48 +++++++++++ components/Home/TasksModule.tsx | 58 +++++++++++++ hooks/useCreateStarterTask.ts | 58 +++++++++++++ .../__tests__/buildStarterTaskParams.test.ts | 29 +++++++ lib/home/__tests__/createStarterTask.test.ts | 69 ++++++++++++++++ .../__tests__/getHomeTasksModuleState.test.ts | 82 +++++++++++++++++++ lib/home/buildStarterTaskParams.ts | 28 +++++++ lib/home/createStarterTask.ts | 23 ++++++ lib/home/getHomeTasksModuleState.ts | 35 ++++++++ 11 files changed, 473 insertions(+) create mode 100644 components/Home/HomeRunRow.tsx create mode 100644 components/Home/StarterTaskCard.tsx create mode 100644 components/Home/TasksModule.tsx create mode 100644 hooks/useCreateStarterTask.ts create mode 100644 lib/home/__tests__/buildStarterTaskParams.test.ts create mode 100644 lib/home/__tests__/createStarterTask.test.ts create mode 100644 lib/home/__tests__/getHomeTasksModuleState.test.ts create mode 100644 lib/home/buildStarterTaskParams.ts create mode 100644 lib/home/createStarterTask.ts create mode 100644 lib/home/getHomeTasksModuleState.ts diff --git a/components/Home/HomePage.tsx b/components/Home/HomePage.tsx index 9833bd99f..6b106e173 100644 --- a/components/Home/HomePage.tsx +++ b/components/Home/HomePage.tsx @@ -2,6 +2,7 @@ import { useMiniKit } from "@coinbase/onchainkit/minikit"; import NewChatBootstrap from "../VercelChat/NewChatBootstrap"; +import TasksModule from "./TasksModule"; import { useEffect } from "react"; import { UIMessage } from "ai"; @@ -16,6 +17,9 @@ const HomePage = ({ initialMessages }: { initialMessages?: UIMessage[] }) => { return (
+
+ +
); diff --git a/components/Home/HomeRunRow.tsx b/components/Home/HomeRunRow.tsx new file mode 100644 index 000000000..3ecf555ed --- /dev/null +++ b/components/Home/HomeRunRow.tsx @@ -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 }) => ( + +
+

+ {getTaskDisplayName(run.taskIdentifier)} +

+

+ {formatTimestamp(run.finishedAt ?? run.createdAt)} +

+
+ + {getStatusLabel(run.status)} + + +); + +export default HomeRunRow; diff --git a/components/Home/StarterTaskCard.tsx b/components/Home/StarterTaskCard.tsx new file mode 100644 index 000000000..fc1a1a300 --- /dev/null +++ b/components/Home/StarterTaskCard.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useArtistProvider } from "@/providers/ArtistProvider"; +import { useCreateStarterTask } from "@/hooks/useCreateStarterTask"; + +/** + * Empty-state suggestion for the homepage tasks module: one click creates + * the "Weekly valuation + streams report, Mondays" task via the existing + * task-creation path (recoupable/chat#1850). + */ +const StarterTaskCard = () => { + const { selectedArtist } = useArtistProvider(); + const { handleCreateStarterTask, isCreating, isScheduled } = + useCreateStarterTask(); + const artistName = selectedArtist?.name || "your artist"; + + if (isScheduled) { + return ( +

+ Scheduled: weekly valuation + streams report for {artistName}, Mondays + at 9am. +

+ ); + } + + return ( +
+
+

+ Weekly valuation + streams report for {artistName}, Mondays +

+

+ Valuation, ranges, and stream deltas in your inbox every Monday. +

+
+ +
+ ); +}; + +export default StarterTaskCard; diff --git a/components/Home/TasksModule.tsx b/components/Home/TasksModule.tsx new file mode 100644 index 000000000..f06a7eeec --- /dev/null +++ b/components/Home/TasksModule.tsx @@ -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 ( +
+
+

+ Your label at work +

+ + View all + +
+ {state.view === "runs" ? ( +
+ {state.runs.map((run) => ( + + ))} +
+ ) : ( + + )} +
+ ); +}; + +export default TasksModule; diff --git a/hooks/useCreateStarterTask.ts b/hooks/useCreateStarterTask.ts new file mode 100644 index 000000000..7d9439913 --- /dev/null +++ b/hooks/useCreateStarterTask.ts @@ -0,0 +1,58 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { usePrivy } from "@privy-io/react-auth"; +import { toast } from "sonner"; +import { createStarterTask } from "@/lib/home/createStarterTask"; +import { useArtistProvider } from "@/providers/ArtistProvider"; + +/** + * One-click creation of the homepage starter task ("Weekly valuation + + * streams report, Mondays"). Mirrors `useCreateTask`: auth + artist come + * from providers, the mutation goes through the existing `POST /api/tasks` + * path which also mints the schedule. + */ +export function useCreateStarterTask() { + const { getAccessToken } = usePrivy(); + const { selectedArtist } = useArtistProvider(); + const queryClient = useQueryClient(); + + const { + mutate: handleCreateStarterTask, + isPending: isCreating, + isSuccess: isScheduled, + } = useMutation({ + mutationFn: async () => { + const artistAccountId = selectedArtist?.account_id; + const artistName = selectedArtist?.name; + if (!artistAccountId || !artistName) { + throw new Error("ARTIST_REQUIRED"); + } + const accessToken = await getAccessToken(); + if (!accessToken) { + throw new Error("AUTH_REQUIRED"); + } + return createStarterTask(accessToken, { 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 }; +} diff --git a/lib/home/__tests__/buildStarterTaskParams.test.ts b/lib/home/__tests__/buildStarterTaskParams.test.ts new file mode 100644 index 000000000..b3ce4949c --- /dev/null +++ b/lib/home/__tests__/buildStarterTaskParams.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { buildStarterTaskParams } from "@/lib/home/buildStarterTaskParams"; + +describe("buildStarterTaskParams", () => { + const params = buildStarterTaskParams({ + artistName: "Del Water Gap", + artistAccountId: "artist-1", + }); + + it("titles the task after the artist", () => { + expect(params.title).toBe( + "Weekly valuation + streams report for Del Water Gap", + ); + }); + + it("schedules Mondays", () => { + expect(params.schedule).toBe("0 9 * * 1"); + }); + + it("targets the artist account", () => { + expect(params.artist_account_id).toBe("artist-1"); + }); + + it("prompts for a valuation + streams report about the artist", () => { + expect(params.prompt).toContain("Del Water Gap"); + expect(params.prompt.toLowerCase()).toContain("valuation"); + expect(params.prompt.toLowerCase()).toContain("stream"); + }); +}); diff --git a/lib/home/__tests__/createStarterTask.test.ts b/lib/home/__tests__/createStarterTask.test.ts new file mode 100644 index 000000000..0958dec58 --- /dev/null +++ b/lib/home/__tests__/createStarterTask.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createStarterTask } from "@/lib/home/createStarterTask"; +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; +import { DEFAULT_MODEL } from "@/lib/consts"; + +vi.mock("@/lib/api/getClientApiBaseUrl", () => ({ + getClientApiBaseUrl: vi.fn(), +})); + +describe("createStarterTask", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getClientApiBaseUrl).mockReturnValue( + "https://api.recoupable.com", + ); + }); + + it("POSTs the pre-wired weekly report task to /api/tasks", async () => { + const createdTask = { id: "task-1" }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + status: "success", + tasks: [createdTask], + }), + }) as unknown as typeof fetch; + + const result = await createStarterTask("test-token", { + artistName: "Del Water Gap", + artistAccountId: "artist-1", + }); + + expect(fetch).toHaveBeenCalledWith( + "https://api.recoupable.com/api/tasks", + expect.objectContaining({ + method: "POST", + headers: { + Authorization: "Bearer test-token", + "Content-Type": "application/json", + }, + }), + ); + const init = vi.mocked(fetch).mock.calls[0][1] as RequestInit; + const body = JSON.parse(String(init.body)); + expect(body.title).toBe( + "Weekly valuation + streams report for Del Water Gap", + ); + expect(body.schedule).toBe("0 9 * * 1"); + expect(body.artist_account_id).toBe("artist-1"); + expect(body.model).toBe(DEFAULT_MODEL); + expect(typeof body.prompt).toBe("string"); + expect(result).toEqual(createdTask); + }); + + it("propagates API errors", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: vi.fn().mockResolvedValue("boom"), + }) as unknown as typeof fetch; + + await expect( + createStarterTask("test-token", { + artistName: "Del Water Gap", + artistAccountId: "artist-1", + }), + ).rejects.toThrow("HTTP 500"); + }); +}); diff --git a/lib/home/__tests__/getHomeTasksModuleState.test.ts b/lib/home/__tests__/getHomeTasksModuleState.test.ts new file mode 100644 index 000000000..d53da4093 --- /dev/null +++ b/lib/home/__tests__/getHomeTasksModuleState.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { getHomeTasksModuleState } from "@/lib/home/getHomeTasksModuleState"; +import type { TaskRunItem } from "@/lib/tasks/getTaskRuns"; + +const makeRun = (id: string): TaskRunItem => ({ + id, + status: "COMPLETED", + taskIdentifier: "customer-prompt-task", + createdAt: "2026-07-06T09:00:00Z", + startedAt: "2026-07-06T09:00:01Z", + finishedAt: "2026-07-06T09:05:00Z", + durationMs: 299000, +}); + +describe("getHomeTasksModuleState", () => { + it("hides the module while runs are loading", () => { + expect( + getHomeTasksModuleState({ + runs: undefined, + runsFailed: false, + isLoading: true, + hasArtist: true, + }), + ).toEqual({ view: "hidden" }); + }); + + it("hides the module when the runs request failed", () => { + expect( + getHomeTasksModuleState({ + runs: undefined, + runsFailed: true, + isLoading: false, + hasArtist: true, + }), + ).toEqual({ view: "hidden" }); + }); + + it("shows recent runs when the account has task history", () => { + const runs = ["a", "b", "c"].map(makeRun); + expect( + getHomeTasksModuleState({ + runs, + runsFailed: false, + isLoading: false, + hasArtist: true, + }), + ).toEqual({ view: "runs", runs }); + }); + + it("caps the module at five runs", () => { + const runs = ["a", "b", "c", "d", "e", "f", "g"].map(makeRun); + const state = getHomeTasksModuleState({ + runs, + runsFailed: false, + isLoading: false, + hasArtist: true, + }); + expect(state).toEqual({ view: "runs", runs: runs.slice(0, 5) }); + }); + + it("offers the starter task to a fresh account with an artist", () => { + expect( + getHomeTasksModuleState({ + runs: [], + runsFailed: false, + isLoading: false, + hasArtist: true, + }), + ).toEqual({ view: "starter" }); + }); + + it("hides the starter card when no artist is selected", () => { + expect( + getHomeTasksModuleState({ + runs: [], + runsFailed: false, + isLoading: false, + hasArtist: false, + }), + ).toEqual({ view: "hidden" }); + }); +}); diff --git a/lib/home/buildStarterTaskParams.ts b/lib/home/buildStarterTaskParams.ts new file mode 100644 index 000000000..3d964fdd8 --- /dev/null +++ b/lib/home/buildStarterTaskParams.ts @@ -0,0 +1,28 @@ +import type { CreateTaskParams } from "@/lib/tasks/createTask"; + +interface BuildStarterTaskParamsInput { + artistName: string; + artistAccountId: string; +} + +/** + * The pre-wired one-click suggestion shown in the homepage tasks module's + * empty state: "Weekly valuation + streams report for {artist}, Mondays" + * (recoupable/chat#1850). + */ +export function buildStarterTaskParams({ + artistName, + artistAccountId, +}: BuildStarterTaskParamsInput): Omit { + return { + title: `Weekly valuation + streams report for ${artistName}`, + prompt: + `Write a weekly report email for ${artistName}. ` + + "Include the catalog's current estimated valuation with its range, " + + "how it changed since last week, and this week's streaming numbers " + + "for the top tracks with week-over-week deltas. Link every data " + + "source you cite and send the report by email.", + schedule: "0 9 * * 1", + artist_account_id: artistAccountId, + }; +} diff --git a/lib/home/createStarterTask.ts b/lib/home/createStarterTask.ts new file mode 100644 index 000000000..1a2050cb2 --- /dev/null +++ b/lib/home/createStarterTask.ts @@ -0,0 +1,23 @@ +import { createTask } from "@/lib/tasks/createTask"; +import type { Task } from "@/lib/tasks/getTasks"; +import { buildStarterTaskParams } from "@/lib/home/buildStarterTaskParams"; +import { DEFAULT_MODEL } from "@/lib/consts"; + +interface CreateStarterTaskInput { + artistName: string; + artistAccountId: string; +} + +/** + * Creates the homepage starter task through the existing task-creation + * path (`POST /api/tasks`, which also mints the schedule). + */ +export async function createStarterTask( + accessToken: string, + input: CreateStarterTaskInput, +): Promise { + return createTask(accessToken, { + ...buildStarterTaskParams(input), + model: DEFAULT_MODEL, + }); +} diff --git a/lib/home/getHomeTasksModuleState.ts b/lib/home/getHomeTasksModuleState.ts new file mode 100644 index 000000000..b33004b25 --- /dev/null +++ b/lib/home/getHomeTasksModuleState.ts @@ -0,0 +1,35 @@ +import type { TaskRunItem } from "@/lib/tasks/getTaskRuns"; + +const MAX_HOME_RUNS = 5; + +interface GetHomeTasksModuleStateParams { + runs: TaskRunItem[] | undefined; + runsFailed: boolean; + isLoading: boolean; + hasArtist: boolean; +} + +export type HomeTasksModuleState = + | { view: "hidden" } + | { view: "runs"; runs: TaskRunItem[] } + | { view: "starter" }; + +/** + * Decides what the homepage tasks module renders: recent runs for accounts + * with task history, the one-click starter suggestion for fresh accounts + * with an artist, or nothing while loading / on failure so the homepage + * never blocks on this module (recoupable/chat#1850). + */ +export function getHomeTasksModuleState({ + runs, + runsFailed, + isLoading, + hasArtist, +}: GetHomeTasksModuleStateParams): HomeTasksModuleState { + if (isLoading || runsFailed || !runs) return { view: "hidden" }; + + if (runs.length > 0) + return { view: "runs", runs: runs.slice(0, MAX_HOME_RUNS) }; + + return hasArtist ? { view: "starter" } : { view: "hidden" }; +} From a2e7819920a472ca4f1ff70e1bcfe4b690862cbe Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 7 Jul 2026 10:54:31 -0500 Subject: [PATCH 2/4] refactor(home): starter task sources its prompt from an existing /agents template (DRY) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #1853: no hand-written example prompts — the starter card is built from the agent-templates the /agents page already serves. findStarterTemplate prefers 'Weekly Performance Dashboard', falls back to any Report-tagged template, and the card hides when none exist; the task's prompt is the template's verbatim, only the title is personalized per artist. Templates come from the shared agent-templates react-query key, so the /agents page and the card can never drift. Co-Authored-By: Claude Fable 5 --- components/Home/StarterTaskCard.tsx | 18 +++---- hooks/useCreateStarterTask.ts | 35 +++++++++++--- .../__tests__/buildStarterTaskParams.test.ts | 32 +++++++++---- lib/home/__tests__/createStarterTask.test.ts | 22 +++++++-- .../__tests__/findStarterTemplate.test.ts | 48 +++++++++++++++++++ lib/home/buildStarterTaskParams.ts | 17 ++++--- lib/home/createStarterTask.ts | 5 +- lib/home/findStarterTemplate.ts | 20 ++++++++ 8 files changed, 158 insertions(+), 39 deletions(-) create mode 100644 lib/home/__tests__/findStarterTemplate.test.ts create mode 100644 lib/home/findStarterTemplate.ts diff --git a/components/Home/StarterTaskCard.tsx b/components/Home/StarterTaskCard.tsx index fc1a1a300..5a6cfd5bc 100644 --- a/components/Home/StarterTaskCard.tsx +++ b/components/Home/StarterTaskCard.tsx @@ -4,21 +4,23 @@ import { useArtistProvider } from "@/providers/ArtistProvider"; import { useCreateStarterTask } from "@/hooks/useCreateStarterTask"; /** - * Empty-state suggestion for the homepage tasks module: one click creates - * the "Weekly valuation + streams report, Mondays" task via the existing - * task-creation path (recoupable/chat#1850). + * 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 } = + const { handleCreateStarterTask, isCreating, isScheduled, starterTemplate } = useCreateStarterTask(); const artistName = selectedArtist?.name || "your artist"; + if (!starterTemplate) return null; + if (isScheduled) { return (

- Scheduled: weekly valuation + streams report for {artistName}, Mondays - at 9am. + Scheduled: {starterTemplate.title} for {artistName}, Mondays at 9am.

); } @@ -27,10 +29,10 @@ const StarterTaskCard = () => {

- Weekly valuation + streams report for {artistName}, Mondays + {starterTemplate.title} for {artistName}, Mondays

- Valuation, ranges, and stream deltas in your inbox every Monday. + {starterTemplate.description}