diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cbd9820219..e11594ef4e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,7 +58,7 @@ model User { // `CANVAS_AUTONOMOUS_TURNS_ENABLED` env var is a master kill-switch // layered above this flag. canvasAutonomousTurns Boolean @default(false) @map("canvas_autonomous_turns") - dailyRecapEnabled Boolean @default(true) @map("daily_recap_enabled") + activityRecapEnabled Boolean @default(true) @map("daily_recap_enabled") voiceLearningEnabled Boolean @default(false) @map("voice_learning_enabled") // Per-user default model for the canvas Agent chat, stored in // `getModelValue()` "provider/name" form (e.g. "anthropic/claude-sonnet-4"). diff --git a/src/__tests__/integration/api/cron/daily-recap.test.ts b/src/__tests__/integration/api/cron/daily-recap.test.ts index aaabe0ebc2..26ff689231 100644 --- a/src/__tests__/integration/api/cron/daily-recap.test.ts +++ b/src/__tests__/integration/api/cron/daily-recap.test.ts @@ -13,12 +13,12 @@ import { NextRequest } from "next/server"; // ── Mocks ──────────────────────────────────────────────────────────────────── -const { mockExecuteScheduledDailyRecapRuns } = vi.hoisted(() => ({ - mockExecuteScheduledDailyRecapRuns: vi.fn(), +const { mockExecuteScheduledActivityRecapRuns } = vi.hoisted(() => ({ + mockExecuteScheduledActivityRecapRuns: vi.fn(), })); vi.mock("@/services/daily-recap-cron", () => ({ - executeScheduledDailyRecapRuns: mockExecuteScheduledDailyRecapRuns, + executeScheduledActivityRecapRuns: mockExecuteScheduledActivityRecapRuns, })); import { GET } from "@/app/api/cron/daily-recap/route"; @@ -82,14 +82,14 @@ describe("GET /api/cron/daily-recap", () => { const body = await res.json(); expect(body.success).toBe(true); expect(body.message).toMatch(/not configured/i); - expect(mockExecuteScheduledDailyRecapRuns).not.toHaveBeenCalled(); + expect(mockExecuteScheduledActivityRecapRuns).not.toHaveBeenCalled(); }); // ── Enabled ─────────────────────────────────────────────────────────────── - it("calls executeScheduledDailyRecapRuns and returns summary JSON when workflow ID is set", async () => { + it("calls executeScheduledActivityRecapRuns and returns summary JSON when workflow ID is set", async () => { process.env.STAKWORK_DAILY_RECAP_WORKFLOW_ID = "12345"; - mockExecuteScheduledDailyRecapRuns.mockResolvedValue({ + mockExecuteScheduledActivityRecapRuns.mockResolvedValue({ usersProcessed: 5, dispatched: 4, skipped: 1, @@ -109,12 +109,12 @@ describe("GET /api/cron/daily-recap", () => { errors: [], }); expect(typeof body.timestamp).toBe("string"); - expect(mockExecuteScheduledDailyRecapRuns).toHaveBeenCalledOnce(); + expect(mockExecuteScheduledActivityRecapRuns).toHaveBeenCalledOnce(); }); it("returns success=false when errors are present", async () => { process.env.STAKWORK_DAILY_RECAP_WORKFLOW_ID = "12345"; - mockExecuteScheduledDailyRecapRuns.mockResolvedValue({ + mockExecuteScheduledActivityRecapRuns.mockResolvedValue({ usersProcessed: 3, dispatched: 2, skipped: 0, @@ -130,9 +130,9 @@ describe("GET /api/cron/daily-recap", () => { expect(body.errors[0]).toMatchObject({ userId: "user-1", error: "network failure" }); }); - it("returns 500 when executeScheduledDailyRecapRuns throws", async () => { + it("returns 500 when executeScheduledActivityRecapRuns throws", async () => { process.env.STAKWORK_DAILY_RECAP_WORKFLOW_ID = "12345"; - mockExecuteScheduledDailyRecapRuns.mockRejectedValue(new Error("unexpected crash")); + mockExecuteScheduledActivityRecapRuns.mockRejectedValue(new Error("unexpected crash")); const res = await GET(createAuthenticatedRequest()); expect(res.status).toBe(500); diff --git a/src/__tests__/integration/api/stakwork/daily-recap-webhook.test.ts b/src/__tests__/integration/api/stakwork/daily-recap-webhook.test.ts index f8d2dce5cc..ab2309fd00 100644 --- a/src/__tests__/integration/api/stakwork/daily-recap-webhook.test.ts +++ b/src/__tests__/integration/api/stakwork/daily-recap-webhook.test.ts @@ -44,13 +44,13 @@ vi.mock("@/lib/vercel/stakwork-token", () => ({ // ─── Helpers ───────────────────────────────────────────────────────────────── -async function createUser(opts: { dailyRecapEnabled?: boolean } = {}) { +async function createUser(opts: { activityRecapEnabled?: boolean } = {}) { return db.user.create({ data: { id: generateUniqueId("user"), email: `user-${generateUniqueId()}@test.com`, name: "Test User", - dailyRecapEnabled: opts.dailyRecapEnabled ?? false, + activityRecapEnabled: opts.activityRecapEnabled ?? false, }, }); } diff --git a/src/__tests__/unit/api/user/preferences.test.ts b/src/__tests__/unit/api/user/preferences.test.ts index a197e90bc4..7b7e780ca4 100644 --- a/src/__tests__/unit/api/user/preferences.test.ts +++ b/src/__tests__/unit/api/user/preferences.test.ts @@ -51,7 +51,7 @@ describe("GET /api/user/preferences", () => { canvasAutonomousTurns: true, chatAgentModel: null, timezone: "America/Chicago", - dailyRecapEnabled: true, + activityRecapEnabled: true, }); const res = await GET(); @@ -66,7 +66,7 @@ describe("GET /api/user/preferences", () => { canvasAutonomousTurns: false, chatAgentModel: null, timezone: null, - dailyRecapEnabled: true, + activityRecapEnabled: true, }); const res = await GET(); @@ -75,33 +75,33 @@ describe("GET /api/user/preferences", () => { expect(body.timezone).toBe("UTC"); }); - test("returns dailyRecapEnabled in response", async () => { + test("returns activityRecapEnabled in response", async () => { mockUserFindUnique.mockResolvedValue({ canvasAutonomousTurns: false, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: false, + activityRecapEnabled: false, }); const res = await GET(); const body = await res.json(); expect(res.status).toBe(200); - expect(body.dailyRecapEnabled).toBe(false); + expect(body.activityRecapEnabled).toBe(false); }); - test("returns dailyRecapEnabled true by default", async () => { + test("returns activityRecapEnabled true by default", async () => { mockUserFindUnique.mockResolvedValue({ canvasAutonomousTurns: false, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: true, + activityRecapEnabled: true, }); const res = await GET(); const body = await res.json(); - expect(body.dailyRecapEnabled).toBe(true); + expect(body.activityRecapEnabled).toBe(true); }); test("returns 401 when unauthenticated", async () => { @@ -123,7 +123,7 @@ describe("PATCH /api/user/preferences — timezone", () => { canvasAutonomousTurns: false, chatAgentModel: null, timezone: "America/Chicago", - dailyRecapEnabled: true, + activityRecapEnabled: true, }); const req = makeRequest({ timezone: "America/Chicago" }); @@ -161,7 +161,7 @@ describe("PATCH /api/user/preferences — timezone", () => { canvasAutonomousTurns: true, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: true, + activityRecapEnabled: true, }); const req = makeRequest({ canvasAutonomousTurns: true }); @@ -186,65 +186,65 @@ describe("PATCH /api/user/preferences — timezone", () => { }); }); -describe("PATCH /api/user/preferences — dailyRecapEnabled", () => { +describe("PATCH /api/user/preferences — activityRecapEnabled", () => { beforeEach(() => { vi.clearAllMocks(); mockGetServerSession.mockResolvedValue({ user: { id: "user-1" } }); }); - test("updates dailyRecapEnabled to false and returns it in the response", async () => { + test("updates activityRecapEnabled to false and returns it in the response", async () => { mockUserUpdate.mockResolvedValue({ canvasAutonomousTurns: false, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: false, + activityRecapEnabled: false, }); - const req = makeRequest({ dailyRecapEnabled: false }); + const req = makeRequest({ activityRecapEnabled: false }); const res = await PATCH(req); const body = await res.json(); expect(res.status).toBe(200); - expect(body.dailyRecapEnabled).toBe(false); + expect(body.activityRecapEnabled).toBe(false); expect(mockUserUpdate).toHaveBeenCalledWith( expect.objectContaining({ - data: expect.objectContaining({ dailyRecapEnabled: false }), + data: expect.objectContaining({ activityRecapEnabled: false }), }), ); }); - test("updates dailyRecapEnabled to true", async () => { + test("updates activityRecapEnabled to true", async () => { mockUserUpdate.mockResolvedValue({ canvasAutonomousTurns: false, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: true, + activityRecapEnabled: true, }); - const req = makeRequest({ dailyRecapEnabled: true }); + const req = makeRequest({ activityRecapEnabled: true }); const res = await PATCH(req); const body = await res.json(); expect(res.status).toBe(200); - expect(body.dailyRecapEnabled).toBe(true); + expect(body.activityRecapEnabled).toBe(true); }); - test("rejects non-boolean dailyRecapEnabled with 400", async () => { - const req = makeRequest({ dailyRecapEnabled: "yes" }); + test("rejects non-boolean activityRecapEnabled with 400", async () => { + const req = makeRequest({ activityRecapEnabled: "yes" }); const res = await PATCH(req); const body = await res.json(); expect(res.status).toBe(400); - expect(body.error).toBe("dailyRecapEnabled must be a boolean"); + expect(body.error).toBe("activityRecapEnabled must be a boolean"); expect(mockUserUpdate).not.toHaveBeenCalled(); }); - test("does not include dailyRecapEnabled in update when not provided", async () => { + test("does not include activityRecapEnabled in update when not provided", async () => { mockUserUpdate.mockResolvedValue({ canvasAutonomousTurns: false, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: true, + activityRecapEnabled: true, }); const req = makeRequest({ canvasAutonomousTurns: false }); @@ -253,7 +253,7 @@ describe("PATCH /api/user/preferences — dailyRecapEnabled", () => { expect(res.status).toBe(200); expect(mockUserUpdate).toHaveBeenCalledWith( expect.objectContaining({ - data: expect.not.objectContaining({ dailyRecapEnabled: expect.anything() }), + data: expect.not.objectContaining({ activityRecapEnabled: expect.anything() }), }), ); }); @@ -280,7 +280,7 @@ describe("PATCH /api/user/preferences — voiceLearningEnabled", () => { canvasAutonomousTurns: false, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: true, + activityRecapEnabled: true, voiceLearningEnabled: true, }); @@ -302,7 +302,7 @@ describe("PATCH /api/user/preferences — voiceLearningEnabled", () => { canvasAutonomousTurns: false, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: true, + activityRecapEnabled: true, voiceLearningEnabled: false, }); @@ -319,7 +319,7 @@ describe("PATCH /api/user/preferences — voiceLearningEnabled", () => { canvasAutonomousTurns: false, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: true, + activityRecapEnabled: true, voiceLearningEnabled: false, }); diff --git a/src/__tests__/unit/components/SidebarChat.test.tsx b/src/__tests__/unit/components/SidebarChat.test.tsx index e8a68ad5f5..5558943744 100644 --- a/src/__tests__/unit/components/SidebarChat.test.tsx +++ b/src/__tests__/unit/components/SidebarChat.test.tsx @@ -164,8 +164,8 @@ vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); -vi.mock("@/components/daily-recap/DailyRecapCard", () => ({ - DailyRecapCard: () =>
, +vi.mock("@/components/daily-recap/ActivityRecapCard", () => ({ + ActivityRecapCard: () =>
, })); vi.mock("@/components/dashboard/DashboardChat/StreamScrollIndicator", () => ({ diff --git a/src/__tests__/unit/components/daily-recap/DailyRecapCard.test.tsx b/src/__tests__/unit/components/daily-recap/ActivityRecapCard.test.tsx similarity index 91% rename from src/__tests__/unit/components/daily-recap/DailyRecapCard.test.tsx rename to src/__tests__/unit/components/daily-recap/ActivityRecapCard.test.tsx index 10154cd7a6..687d2f49ef 100644 --- a/src/__tests__/unit/components/daily-recap/DailyRecapCard.test.tsx +++ b/src/__tests__/unit/components/daily-recap/ActivityRecapCard.test.tsx @@ -48,7 +48,7 @@ afterEach(() => { // ── Import after mocks ──────────────────────────────────────────────────────── -import { DailyRecapCard } from "@/components/daily-recap/DailyRecapCard"; +import { ActivityRecapCard } from "@/components/daily-recap/ActivityRecapCard"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -64,7 +64,7 @@ const GENERATED_AT = new Date().toISOString(); // ── Tests ───────────────────────────────────────────────────────────────────── -describe("DailyRecapCard", () => { +describe("ActivityRecapCard", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -73,13 +73,13 @@ describe("DailyRecapCard", () => { it("renders null (nothing in DOM) while loading", () => { fetchMock.mockReturnValueOnce(new Promise(() => {})); - const { container } = render(); + const { container } = render(); expect(container.firstChild).toBeNull(); }); it("renders null when API returns { recap: null }", async () => { mockFetch({ recap: null, generatedAt: null }); - const { container } = render(); + const { container } = render(); await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); expect(container.firstChild).toBeNull(); expect(screen.queryByTestId("daily-recap-card")).toBeNull(); @@ -87,7 +87,7 @@ describe("DailyRecapCard", () => { it("renders recap text when a recap is present", async () => { mockFetch({ recap: RECAP_TEXT, generatedAt: GENERATED_AT }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); expect(screen.getByText(RECAP_TEXT)).toBeInTheDocument(); }); @@ -96,7 +96,7 @@ describe("DailyRecapCard", () => { it("heading reads \"Recap\" (not \"Daily Recap\")", async () => { mockFetch({ recap: RECAP_TEXT, generatedAt: GENERATED_AT }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); expect(screen.getByText("Recap")).toBeInTheDocument(); expect(screen.queryByText(/Daily Recap/i)).toBeNull(); @@ -106,7 +106,7 @@ describe("DailyRecapCard", () => { it("shows relative timestamp without \"Generated\" prefix", async () => { mockFetch({ recap: "Solid day.", generatedAt: GENERATED_AT }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); expect(screen.getByText(/2 hours ago/)).toBeInTheDocument(); expect(screen.queryByText(/Generated/)).toBeNull(); @@ -114,7 +114,7 @@ describe("DailyRecapCard", () => { it("does not show a timestamp line when generatedAt is null", async () => { mockFetch({ recap: "Solid day.", generatedAt: null }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); expect(screen.queryByText(/Generated/)).toBeNull(); expect(screen.queryByText(/hours ago/)).toBeNull(); @@ -124,21 +124,21 @@ describe("DailyRecapCard", () => { it("renders X dismiss button when dismissible prop is set", async () => { mockFetch({ recap: RECAP_TEXT, generatedAt: GENERATED_AT }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); expect(screen.getByRole("button", { name: /dismiss/i })).toBeInTheDocument(); }); it("does not render X button when dismissible is not set", async () => { mockFetch({ recap: RECAP_TEXT, generatedAt: GENERATED_AT }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); expect(screen.queryByRole("button", { name: /dismiss/i })).toBeNull(); }); it("clicking X hides the card and sets sessionStorage flag", async () => { mockFetch({ recap: RECAP_TEXT, generatedAt: GENERATED_AT }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); fireEvent.click(screen.getByRole("button", { name: /dismiss/i })); @@ -150,7 +150,7 @@ describe("DailyRecapCard", () => { it("stays hidden when sessionStorage flag is pre-set", async () => { sessionStorageMock.getItem.mockReturnValue("1"); mockFetch({ recap: RECAP_TEXT, generatedAt: GENERATED_AT }); - const { container } = render(); + const { container } = render(); // Give async effects time to settle await new Promise((r) => setTimeout(r, 20)); expect(container.firstChild).toBeNull(); @@ -161,7 +161,7 @@ describe("DailyRecapCard", () => { it("renders \"My Activity\" link to /profile when showActivityLink is set", async () => { mockFetch({ recap: RECAP_TEXT, generatedAt: GENERATED_AT }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); const link = screen.getByRole("link", { name: /My Activity/i }); expect(link).toBeInTheDocument(); @@ -170,7 +170,7 @@ describe("DailyRecapCard", () => { it("does not render \"My Activity\" link when showActivityLink is not set", async () => { mockFetch({ recap: RECAP_TEXT, generatedAt: GENERATED_AT }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); expect(screen.queryByRole("link", { name: /My Activity/i })).toBeNull(); }); @@ -179,21 +179,21 @@ describe("DailyRecapCard", () => { it("renders null when fetch throws (network error)", async () => { fetchMock.mockRejectedValueOnce(new Error("network down")); - const { container } = render(); + const { container } = render(); await new Promise((r) => setTimeout(r, 0)); expect(container.firstChild).toBeNull(); }); it("renders null when fetch returns a non-ok response", async () => { fetchMock.mockResolvedValueOnce({ ok: false }); - const { container } = render(); + const { container } = render(); await new Promise((r) => setTimeout(r, 0)); expect(container.firstChild).toBeNull(); }); it("calls the correct endpoint", async () => { mockFetch({ recap: null, generatedAt: null }); - render(); + render(); await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); expect(fetchMock).toHaveBeenCalledWith("/api/user/daily-recap"); }); diff --git a/src/__tests__/unit/components/settings/DailyRecapSettings.test.tsx b/src/__tests__/unit/components/settings/ActivityRecapSettings.test.tsx similarity index 62% rename from src/__tests__/unit/components/settings/DailyRecapSettings.test.tsx rename to src/__tests__/unit/components/settings/ActivityRecapSettings.test.tsx index fbcee6f67a..cc36e58451 100644 --- a/src/__tests__/unit/components/settings/DailyRecapSettings.test.tsx +++ b/src/__tests__/unit/components/settings/ActivityRecapSettings.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { DailyRecapSettings } from "@/components/settings/DailyRecapSettings"; +import { ActivityRecapSettings } from "@/components/settings/ActivityRecapSettings"; import { toast } from "sonner"; vi.mock("sonner"); @@ -23,7 +23,7 @@ vi.mock("@/components/ui/switch", () => ({ aria-checked={checked} onClick={() => onCheckedChange(!checked)} disabled={disabled} - data-testid="daily-recap-switch" + data-testid="activity-recap-switch" /> ), })); @@ -34,69 +34,69 @@ vi.mock("@/components/ui/label", () => ({ ), })); -describe("DailyRecapSettings", () => { +describe("ActivityRecapSettings", () => { beforeEach(() => { vi.clearAllMocks(); global.fetch = vi.fn(); }); - it("renders with toggle off when dailyRecapEnabled=false from the API", async () => { + it("renders with toggle off when activityRecapEnabled=false from the API", async () => { (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true, - json: async () => ({ dailyRecapEnabled: false }), + json: async () => ({ activityRecapEnabled: false }), }); - render(); + render(); await waitFor(() => { - const toggle = screen.getByTestId("daily-recap-switch"); + const toggle = screen.getByTestId("activity-recap-switch"); expect(toggle).toHaveAttribute("aria-checked", "false"); }); expect(screen.getByText("Disabled")).toBeInTheDocument(); }); - it("renders with toggle on when dailyRecapEnabled=true from the API", async () => { + it("renders with toggle on when activityRecapEnabled=true from the API", async () => { (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true, - json: async () => ({ dailyRecapEnabled: true }), + json: async () => ({ activityRecapEnabled: true }), }); - render(); + render(); await waitFor(() => { - const toggle = screen.getByTestId("daily-recap-switch"); + const toggle = screen.getByTestId("activity-recap-switch"); expect(toggle).toHaveAttribute("aria-checked", "true"); }); expect(screen.getByText("Enabled")).toBeInTheDocument(); }); - it("clicking toggle calls PATCH /api/user/preferences with { dailyRecapEnabled: true }", async () => { + it("clicking toggle calls PATCH /api/user/preferences with { activityRecapEnabled: true }", async () => { const user = userEvent.setup(); (global.fetch as ReturnType) .mockResolvedValueOnce({ ok: true, - json: async () => ({ dailyRecapEnabled: false }), + json: async () => ({ activityRecapEnabled: false }), }) .mockResolvedValueOnce({ ok: true, - json: async () => ({ dailyRecapEnabled: true }), + json: async () => ({ activityRecapEnabled: true }), }); - render(); + render(); await waitFor(() => { - expect(screen.getByTestId("daily-recap-switch")).not.toBeDisabled(); + expect(screen.getByTestId("activity-recap-switch")).not.toBeDisabled(); }); - await user.click(screen.getByTestId("daily-recap-switch")); + await user.click(screen.getByTestId("activity-recap-switch")); await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith( "/api/user/preferences", expect.objectContaining({ method: "PATCH", - body: JSON.stringify({ dailyRecapEnabled: true }), + body: JSON.stringify({ activityRecapEnabled: true }), }) ); }); @@ -108,20 +108,20 @@ describe("DailyRecapSettings", () => { (global.fetch as ReturnType) .mockResolvedValueOnce({ ok: true, - json: async () => ({ dailyRecapEnabled: false }), + json: async () => ({ activityRecapEnabled: false }), }) .mockResolvedValueOnce({ ok: true, - json: async () => ({ dailyRecapEnabled: true }), + json: async () => ({ activityRecapEnabled: true }), }); - render(); + render(); await waitFor(() => { - expect(screen.getByTestId("daily-recap-switch")).not.toBeDisabled(); + expect(screen.getByTestId("activity-recap-switch")).not.toBeDisabled(); }); - await user.click(screen.getByTestId("daily-recap-switch")); + await user.click(screen.getByTestId("activity-recap-switch")); await waitFor(() => { expect(toast.success).toHaveBeenCalledWith("Preference saved"); @@ -134,24 +134,24 @@ describe("DailyRecapSettings", () => { (global.fetch as ReturnType) .mockResolvedValueOnce({ ok: true, - json: async () => ({ dailyRecapEnabled: false }), + json: async () => ({ activityRecapEnabled: false }), }) .mockResolvedValueOnce({ ok: false, json: async () => ({}), }); - render(); + render(); await waitFor(() => { - expect(screen.getByTestId("daily-recap-switch")).not.toBeDisabled(); + expect(screen.getByTestId("activity-recap-switch")).not.toBeDisabled(); }); - await user.click(screen.getByTestId("daily-recap-switch")); + await user.click(screen.getByTestId("activity-recap-switch")); await waitFor(() => { expect(toast.error).toHaveBeenCalledWith("Failed to save preference"); - expect(screen.getByTestId("daily-recap-switch")).toHaveAttribute( + expect(screen.getByTestId("activity-recap-switch")).toHaveAttribute( "aria-checked", "false" ); diff --git a/src/__tests__/unit/services/daily-recap-cron.test.ts b/src/__tests__/unit/services/daily-recap-cron.test.ts index 943b687a72..0a33948bd6 100644 --- a/src/__tests__/unit/services/daily-recap-cron.test.ts +++ b/src/__tests__/unit/services/daily-recap-cron.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import fs from "fs"; import path from "path"; -import { executeScheduledDailyRecapRuns } from "@/services/daily-recap-cron"; +import { executeScheduledActivityRecapRuns } from "@/services/daily-recap-cron"; import { db } from "@/lib/db"; import { StakworkRunType, WorkflowStatus } from "@prisma/client"; @@ -144,9 +144,9 @@ describe("Daily Recap Cron — vercel.json configuration", () => { }); }); -// ── executeScheduledDailyRecapRuns ─────────────────────────────────────────── +// ── executeScheduledActivityRecapRuns ─────────────────────────────────────────── -describe("executeScheduledDailyRecapRuns", () => { +describe("executeScheduledActivityRecapRuns", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -155,7 +155,7 @@ describe("executeScheduledDailyRecapRuns", () => { setupDb({ ownedWorkspace: null, membership: null }); mockedGetUserActivityFeed.mockResolvedValue([]); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); expect(result.skipped).toBe(1); expect(result.dispatched).toBe(0); @@ -166,7 +166,7 @@ describe("executeScheduledDailyRecapRuns", () => { setupDb(); mockedGetUserActivityFeed.mockResolvedValue([]); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); expect(result.skipped).toBe(1); expect(result.dispatched).toBe(0); @@ -183,7 +183,7 @@ describe("executeScheduledDailyRecapRuns", () => { }, }); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); expect(mockedDb.stakworkRun.create).toHaveBeenCalledOnce(); const createCall = vi.mocked(mockedDb.stakworkRun.create).mock.calls[0][0]; @@ -222,7 +222,7 @@ describe("executeScheduledDailyRecapRuns", () => { }); const before = Date.now(); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); const after = Date.now(); const batchCall = mockCreateBatchProjects.mock.calls[0][0]; @@ -253,7 +253,7 @@ describe("executeScheduledDailyRecapRuns", () => { }, }); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); const batchCall = mockCreateBatchProjects.mock.calls[0][0]; const vars = batchCall[0].workflow_params.set_var.attributes.vars; @@ -279,7 +279,7 @@ describe("executeScheduledDailyRecapRuns", () => { }, })); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); expect(mockCreateBatchProjects).toHaveBeenCalledTimes(2); expect(result.usersProcessed).toBe(501); @@ -296,7 +296,7 @@ describe("executeScheduledDailyRecapRuns", () => { }, }); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); const updateCalls = vi.mocked(mockedDb.stakworkRun.update).mock.calls; const backFillCall = updateCalls.find((c) => c[0].data?.projectId === 777); @@ -314,7 +314,7 @@ describe("executeScheduledDailyRecapRuns", () => { }, }); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); // Row set to FAILED const updateCalls = vi.mocked(mockedDb.stakworkRun.update).mock.calls; @@ -343,7 +343,7 @@ describe("executeScheduledDailyRecapRuns", () => { details: { body: "param is missing or the value is empty: project" }, }); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); // Both users error-logged with the real message (not '[object Object]') expect(result.errors.length).toBe(2); @@ -368,7 +368,7 @@ describe("Staleness reaper", () => { setupDb(); mockedGetUserActivityFeed.mockResolvedValue([]); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); expect(mockedDb.stakworkRun.updateMany).toHaveBeenCalledOnce(); const call = vi.mocked(mockedDb.stakworkRun.updateMany).mock.calls[0][0]; @@ -385,7 +385,7 @@ describe("Staleness reaper", () => { mockedGetUserActivityFeed.mockResolvedValue([]); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Reaped 3 stale DAILY_RECAP run(s)"), @@ -398,7 +398,7 @@ describe("Staleness reaper", () => { mockedGetUserActivityFeed.mockResolvedValue([]); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Reaped")); warnSpy.mockRestore(); @@ -416,7 +416,7 @@ describe("COMPLETED-only cursor", () => { setupDb(); mockedGetUserActivityFeed.mockResolvedValue([]); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); // First findFirst call is the cursor const cursorCall = vi.mocked(mockedDb.stakworkRun.findFirst).mock.calls[0][0]; @@ -431,7 +431,7 @@ describe("COMPLETED-only cursor", () => { }); const before = Date.now(); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); const after = Date.now(); // since is passed directly now (not days) @@ -451,7 +451,7 @@ describe("COMPLETED-only cursor", () => { data: { ref_id: "r", projects: [{ name: "daily-recap-run-1", project_id: 1 }] }, }); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); const activityCall = mockedGetUserActivityFeed.mock.calls[0][0]; expect(activityCall.since).toEqual(twoDaysAgo); @@ -465,7 +465,21 @@ describe("COMPLETED-only cursor", () => { data: { ref_id: "r", projects: [{ name: "daily-recap-run-1", project_id: 1 }] }, }); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); + + const activityCall = mockedGetUserActivityFeed.mock.calls[0][0]; + expect(activityCall.since).toEqual(twoDaysAgo); + }); + + it("passes since directly to getUserActivityFeed (not days)", async () => { + const cursor = new Date(Date.now() - 90 * 60 * 1000); // 90 min ago + setupDb({ lastRun: { createdAt: cursor } }); + mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(2)); + mockCreateBatchProjects.mockResolvedValue({ + data: { ref_id: "r", projects: [{ name: "daily-recap-run-1", project_id: 1 }] }, + }); + + await executeScheduledActivityRecapRuns(); const activityCall = mockedGetUserActivityFeed.mock.calls[0][0]; expect(activityCall.since).toEqual(cursor); @@ -478,7 +492,7 @@ describe("COMPLETED-only cursor", () => { mockedGetUserActivityFeed.mockResolvedValue([]); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("no activity since")); logSpy.mockRestore(); @@ -497,7 +511,7 @@ describe("In-flight guard", () => { setupDb({ inflightRun: freshRun }); mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(3)); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); expect(result.skipped).toBe(1); expect(result.dispatched).toBe(0); @@ -511,7 +525,7 @@ describe("In-flight guard", () => { data: { ref_id: "r", projects: [{ name: "daily-recap-run-1", project_id: 1 }] }, }); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); expect(result.skipped).toBe(0); expect(mockedDb.stakworkRun.create).toHaveBeenCalledOnce(); @@ -523,7 +537,7 @@ describe("In-flight guard", () => { mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(1)); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); expect(logSpy).toHaveBeenCalledWith( expect.stringContaining("Skipping user user-1"), @@ -539,7 +553,7 @@ describe("In-flight guard", () => { setupDb({ inflightRun: freshRun }); mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(3)); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); expect(mockedDb.stakworkRun.create).not.toHaveBeenCalled(); }); @@ -551,7 +565,7 @@ describe("In-flight guard", () => { data: { ref_id: "r", projects: [{ name: "daily-recap-run-1", project_id: 1 }] }, }); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); // Guard is the 3rd findFirst call (index 2): cursor [0], lastResultRun [1], guard [2] const guardCall = vi.mocked(mockedDb.stakworkRun.findFirst).mock.calls[2][0]; @@ -578,7 +592,7 @@ describe("window_start rolling-24h horizon", () => { }); const before = Date.now(); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); const after = Date.now(); const batchCall = mockCreateBatchProjects.mock.calls[0][0]; diff --git a/src/app/api/cron/daily-recap/route.ts b/src/app/api/cron/daily-recap/route.ts index 1820ee2b51..058cb7b309 100644 --- a/src/app/api/cron/daily-recap/route.ts +++ b/src/app/api/cron/daily-recap/route.ts @@ -1,4 +1,4 @@ -import { executeScheduledDailyRecapRuns } from "@/services/daily-recap-cron"; +import { executeScheduledActivityRecapRuns } from "@/services/daily-recap-cron"; import { NextRequest, NextResponse } from "next/server"; /** @@ -15,16 +15,16 @@ export async function GET(request: NextRequest) { } if (!process.env.STAKWORK_DAILY_RECAP_WORKFLOW_ID) { - console.log("[DailyRecapCron] STAKWORK_DAILY_RECAP_WORKFLOW_ID not configured, skipping"); + console.log("[ActivityRecapCron] STAKWORK_DAILY_RECAP_WORKFLOW_ID not configured, skipping"); return NextResponse.json({ success: true, message: "Daily recap cron not configured" }); } - console.log("[DailyRecapCron] Starting scheduled execution"); + console.log("[ActivityRecapCron] Starting scheduled execution"); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); console.log( - `[DailyRecapCron] Completed. Processed=${result.usersProcessed} ` + + `[ActivityRecapCron] Completed. Processed=${result.usersProcessed} ` + `Dispatched=${result.dispatched} Skipped=${result.skipped} Errors=${result.errors.length}`, ); @@ -39,7 +39,7 @@ export async function GET(request: NextRequest) { }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error("[DailyRecapCron] Unhandled error:", errorMessage); + console.error("[ActivityRecapCron] Unhandled error:", errorMessage); return NextResponse.json( { diff --git a/src/app/api/user/preferences/route.ts b/src/app/api/user/preferences/route.ts index e8b84d72de..283ce82c3d 100644 --- a/src/app/api/user/preferences/route.ts +++ b/src/app/api/user/preferences/route.ts @@ -24,7 +24,7 @@ export async function GET() { const user = await db.user.findUnique({ where: { id: session.user.id }, - select: { canvasAutonomousTurns: true, chatAgentModel: true, timezone: true, dailyRecapEnabled: true, voiceLearningEnabled: true }, + select: { canvasAutonomousTurns: true, chatAgentModel: true, timezone: true, activityRecapEnabled: true, voiceLearningEnabled: true }, }); if (!user) { return NextResponse.json({ error: "User not found" }, { status: 404 }); @@ -34,7 +34,7 @@ export async function GET() { canvasAutonomousTurns: user.canvasAutonomousTurns, chatAgentModel: user.chatAgentModel, timezone: user.timezone ?? "UTC", - dailyRecapEnabled: user.dailyRecapEnabled, + activityRecapEnabled: user.activityRecapEnabled, voiceLearningEnabled: user.voiceLearningEnabled, }); } @@ -48,7 +48,7 @@ export async function PATCH(request: NextRequest) { } const body = await request.json(); - const { canvasAutonomousTurns, chatAgentModel, timezone, dailyRecapEnabled, voiceLearningEnabled } = body; + const { canvasAutonomousTurns, chatAgentModel, timezone, activityRecapEnabled, voiceLearningEnabled } = body; if ( canvasAutonomousTurns !== undefined && @@ -80,9 +80,9 @@ export async function PATCH(request: NextRequest) { } } - if (dailyRecapEnabled !== undefined && typeof dailyRecapEnabled !== "boolean") { + if (activityRecapEnabled !== undefined && typeof activityRecapEnabled !== "boolean") { return NextResponse.json( - { error: "dailyRecapEnabled must be a boolean" }, + { error: "activityRecapEnabled must be a boolean" }, { status: 400 }, ); } @@ -100,10 +100,10 @@ export async function PATCH(request: NextRequest) { ...(canvasAutonomousTurns !== undefined && { canvasAutonomousTurns }), ...(chatAgentModel !== undefined && { chatAgentModel }), ...(timezone !== undefined && { timezone }), - ...(dailyRecapEnabled !== undefined && { dailyRecapEnabled }), + ...(activityRecapEnabled !== undefined && { activityRecapEnabled }), ...(voiceLearningEnabled !== undefined && { voiceLearningEnabled }), }, - select: { canvasAutonomousTurns: true, chatAgentModel: true, timezone: true, dailyRecapEnabled: true, voiceLearningEnabled: true }, + select: { canvasAutonomousTurns: true, chatAgentModel: true, timezone: true, activityRecapEnabled: true, voiceLearningEnabled: true }, }); logger.info("User preferences updated", "USER_PREFERENCES_UPDATE", { @@ -111,7 +111,7 @@ export async function PATCH(request: NextRequest) { canvasAutonomousTurns: updated.canvasAutonomousTurns, chatAgentModel: updated.chatAgentModel, timezone: updated.timezone, - dailyRecapEnabled: updated.dailyRecapEnabled, + activityRecapEnabled: updated.activityRecapEnabled, voiceLearningEnabled: updated.voiceLearningEnabled, }); @@ -119,7 +119,7 @@ export async function PATCH(request: NextRequest) { canvasAutonomousTurns: updated.canvasAutonomousTurns, chatAgentModel: updated.chatAgentModel, timezone: updated.timezone ?? "UTC", - dailyRecapEnabled: updated.dailyRecapEnabled, + activityRecapEnabled: updated.activityRecapEnabled, voiceLearningEnabled: updated.voiceLearningEnabled, }); } catch (error) { diff --git a/src/app/org/[githubLogin]/_components/SidebarChat.tsx b/src/app/org/[githubLogin]/_components/SidebarChat.tsx index 2dc3b60735..b7499b89dc 100644 --- a/src/app/org/[githubLogin]/_components/SidebarChat.tsx +++ b/src/app/org/[githubLogin]/_components/SidebarChat.tsx @@ -39,7 +39,7 @@ import { import { PlannerFormSlot } from "./PlannerFormSlot"; import { StartTasksSlot } from "./StartTasksSlot"; import { DeferredCheckCard } from "./DeferredCheckCard"; -import { DailyRecapCard } from "@/components/daily-recap/DailyRecapCard"; +import { ActivityRecapCard } from "@/components/daily-recap/ActivityRecapCard"; import { useCanvasChatStore, @@ -343,7 +343,7 @@ export function SidebarChat({ githubLogin }: SidebarChatProps) {
- + {!hasMessages && activeToolCalls.length === 0 && (
Ask the agent about anything on this canvas. diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index ff581fa632..00a624fc8d 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -2,10 +2,10 @@ import { getServerSession } from "next-auth/next"; import { authOptions } from "@/lib/auth/nextauth"; import { redirect } from "next/navigation"; import Image from "next/image"; +import Link from "next/link"; import { ActivityFeed } from "./_components/ActivityFeed"; import { BackButton } from "./_components/BackButton"; -import { DailyRecapCard } from "@/components/daily-recap/DailyRecapCard"; -import { DailyRecapSettings } from "@/components/settings/DailyRecapSettings"; +import { ActivityRecapCard } from "@/components/daily-recap/ActivityRecapCard"; export default async function ProfilePage() { const session = await getServerSession(authOptions); @@ -51,9 +51,14 @@ export default async function ProfilePage() {
- -
- + +
+ + Manage Activity Recap in Settings → +
diff --git a/src/components/daily-recap/DailyRecapCard.tsx b/src/components/daily-recap/ActivityRecapCard.tsx similarity index 91% rename from src/components/daily-recap/DailyRecapCard.tsx rename to src/components/daily-recap/ActivityRecapCard.tsx index 6c7ebc08de..3bdb3a7450 100644 --- a/src/components/daily-recap/DailyRecapCard.tsx +++ b/src/components/daily-recap/ActivityRecapCard.tsx @@ -5,12 +5,12 @@ import { formatDistanceToNow } from "date-fns"; import { Sparkles, X } from "lucide-react"; import Link from "next/link"; -interface DailyRecapData { +interface ActivityRecapData { recap: string | null; generatedAt: string | null; } -interface DailyRecapCardProps { +interface ActivityRecapCardProps { /** When true, renders an X button that hides the card for the session. */ dismissible?: boolean; /** When true, renders a "My Activity →" link to /profile. */ @@ -24,8 +24,8 @@ const SESSION_KEY = "hive:daily-recap-dismissed"; * Fetches GET /api/user/daily-recap on mount. * Returns null while loading or when no completed recap exists. */ -export function DailyRecapCard({ dismissible, showActivityLink }: DailyRecapCardProps = {}) { - const [data, setData] = useState(null); +export function ActivityRecapCard({ dismissible, showActivityLink }: ActivityRecapCardProps = {}) { + const [data, setData] = useState(null); const [dismissed, setDismissed] = useState(false); // Check session-dismissed flag on mount (dismissible mode only). @@ -43,7 +43,7 @@ export function DailyRecapCard({ dismissible, showActivityLink }: DailyRecapCard useEffect(() => { fetch("/api/user/daily-recap") .then((r) => (r.ok ? r.json() : null)) - .then((json: DailyRecapData | null) => { + .then((json: ActivityRecapData | null) => { if (json?.recap) setData(json); }) .catch(() => {/* silent — card simply doesn't render */}); diff --git a/src/components/settings/DailyRecapSettings.tsx b/src/components/settings/ActivityRecapSettings.tsx similarity index 73% rename from src/components/settings/DailyRecapSettings.tsx rename to src/components/settings/ActivityRecapSettings.tsx index 224a68aea2..6ace7222ad 100644 --- a/src/components/settings/DailyRecapSettings.tsx +++ b/src/components/settings/ActivityRecapSettings.tsx @@ -12,7 +12,7 @@ import { import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; -export function DailyRecapSettings() { +export function ActivityRecapSettings() { const [enabled, setEnabled] = useState(false); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -21,7 +21,7 @@ export function DailyRecapSettings() { fetch("/api/user/preferences") .then((r) => r.json()) .then((d) => { - setEnabled(!!d.dailyRecapEnabled); + setEnabled(!!d.activityRecapEnabled); }) .catch(() => {}) .finally(() => setLoading(false)); @@ -36,7 +36,7 @@ export function DailyRecapSettings() { const res = await fetch("/api/user/preferences", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ dailyRecapEnabled: newValue }), + body: JSON.stringify({ activityRecapEnabled: newValue }), }); if (!res.ok) throw new Error("Failed"); @@ -53,23 +53,22 @@ export function DailyRecapSettings() { return ( - Daily Recap + Activity Recap - When enabled, you will receive a daily AI-generated recap of your - activity across your workspaces. The recap is delivered each morning - and surfaced here on your profile. Applies across all your workspaces. + When enabled, you will receive an AI-generated recap of your activity + across your workspaces. Applies across all your workspaces.
-
diff --git a/src/services/daily-recap-cron.ts b/src/services/daily-recap-cron.ts index a78a80acc8..2721b2bc71 100644 --- a/src/services/daily-recap-cron.ts +++ b/src/services/daily-recap-cron.ts @@ -41,7 +41,7 @@ function chunkArray(arr: T[], size: number): T[][] { * 5. Batch-dispatch via POST /api/v1/projects/batch (≤500 per chunk). * 6. Back-fill projectId / status on the rows. */ -export async function executeScheduledDailyRecapRuns(): Promise { +export async function executeScheduledActivityRecapRuns(): Promise { const result: DailyRecapCronResult = { usersProcessed: 0, dispatched: 0, @@ -51,13 +51,13 @@ export async function executeScheduledDailyRecapRuns(): Promise 0) { - console.warn(`[DailyRecapCron] Reaped ${reaped.count} stale DAILY_RECAP run(s) → FAILED`); + console.warn(`[ActivityRecapCron] Reaped ${reaped.count} stale DAILY_RECAP run(s) → FAILED`); } // ── 1. Query eligible users ────────────────────────────────────────────── const users = await db.user.findMany({ - where: { dailyRecapEnabled: true, deleted: false }, + where: { activityRecapEnabled: true, deleted: false }, select: { id: true }, }); - console.log(`[DailyRecapCron] Found ${users.length} eligible user(s)`); + console.log(`[ActivityRecapCron] Found ${users.length} eligible user(s)`); result.usersProcessed = users.length; // Pending runs to batch-dispatch @@ -117,7 +117,7 @@ export async function executeScheduledDailyRecapRuns(): Promise p.project_id).length} succeeded, ` + `${projects.filter((p) => !p.project_id).length} failed`, ); @@ -270,7 +270,7 @@ export async function executeScheduledDailyRecapRuns(): Promise