diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 3d2693006a..72c1641ce1 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; import { useRouter, useSearchParams } from "@/lib/routing/client-router"; +import { Button } from "@kandev/ui/button"; import { Task } from "./kanban-card"; import { TaskCreateDialog } from "./task-create-dialog"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; @@ -451,6 +452,12 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanB setMoveError={s.setMoveError} handleGoToTask={s.handleGoToTask} /> + {s.isMobile && ( + + )} ); } + +function MobileSelectionToggle({ active, onToggle }: { active: boolean; onToggle: () => void }) { + const { t } = useTranslation(); + return ( +
+ +
+ ); +} diff --git a/apps/web/components/kanban/task-multi-select-toolbar.test.tsx b/apps/web/components/kanban/task-multi-select-toolbar.test.tsx new file mode 100644 index 0000000000..76dfa4832c --- /dev/null +++ b/apps/web/components/kanban/task-multi-select-toolbar.test.tsx @@ -0,0 +1,112 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { ToastProvider } from "@/components/toast-provider"; +import { TaskMultiSelectToolbar } from "./task-multi-select-toolbar"; + +const save = vi.hoisted(() => vi.fn()); +const responsive = vi.hoisted(() => ({ isMobile: false })); +vi.mock("@/lib/api/domains/settings-api", () => ({ updateUserSettings: save })); +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ isMobile: responsive.isMobile }), +})); +afterEach(() => { + cleanup(); + save.mockReset(); + responsive.isMobile = false; +}); + +function mount(ids: string[]) { + const clear = vi.fn(); + render( + + + value} + onClearSelection={clear} + onBulkDelete={vi.fn()} + onBulkArchive={vi.fn()} + onBulkMove={vi.fn()} + /> + + , + ); + return clear; +} + +it("has no color action for an empty selection", () => { + mount([]); + expect(screen.queryByTestId("bulk-color-button")).toBeNull(); +}); + +// @covers AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.4, AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.5 +it("colors mixed-workflow selections and exposes pending state without clearing selection", async () => { + let resolve: ((value: unknown) => void) | undefined; + save.mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + const clear = mount(["a", "b"]); + const trigger = screen.getByTestId("bulk-color-button"); + expect(screen.getByRole("status").textContent).toBe(""); + fireEvent.keyDown(trigger, { key: "Enter" }); + fireEvent.click(await screen.findByTestId("bulk-color-option-blue")); + await waitFor(() => + expect(save).toHaveBeenCalledExactlyOnceWith({ + sidebar_task_color_patch: { colors: { a: "blue", b: "blue" }, if_missing: false }, + }), + ); + expect((trigger as HTMLButtonElement).disabled).toBe(false); + expect(trigger.getAttribute("aria-disabled")).toBe("true"); + expect(screen.getByRole("status").textContent).toBe("Saving colors..."); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + expect(clear).not.toHaveBeenCalled(); + await act(async () => + resolve?.({ + settings: { + user_id: "default-user", + workspace_id: "", + repository_ids: [], + sidebar_task_colors: { a: "blue", b: "blue" }, + revision: 2, + }, + shell_options: [], + }), + ); + expect((trigger as HTMLButtonElement).disabled).toBe(false); +}); + +it("closes the mobile actions sheet when selection becomes empty", async () => { + responsive.isMobile = true; + const props = { + steps: [], + isProcessing: false, + canMove: false, + getEligibleSelectedIds: (value: string[]) => value, + onClearSelection: vi.fn(), + onBulkDelete: vi.fn(), + onBulkArchive: vi.fn(), + onBulkMove: vi.fn(), + }; + const view = (ids: string[]) => ( + + + + + + ); + const { rerender } = render(view(["a"])); + fireEvent.click(screen.getByTestId("bulk-actions-button")); + expect(await screen.findByRole("dialog")).not.toBeNull(); + + rerender(view([])); + await act(async () => {}); + rerender(view(["b"])); + + expect(screen.queryByRole("dialog")).toBeNull(); +}); diff --git a/apps/web/components/kanban/task-multi-select-toolbar.tsx b/apps/web/components/kanban/task-multi-select-toolbar.tsx index b0fc9c4605..935a5be3d7 100644 --- a/apps/web/components/kanban/task-multi-select-toolbar.tsx +++ b/apps/web/components/kanban/task-multi-select-toolbar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { IconTrash, IconArchive, IconChevronRight, IconX } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; @@ -13,6 +13,9 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@kandev/ui/dropdown-menu"; +import { BulkTaskColorPicker } from "@/components/task/bulk-task-color-picker"; +import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { cn } from "@/lib/utils"; import type { WorkflowStep } from "@/components/kanban-column"; import { useTranslation } from "react-i18next"; @@ -105,7 +108,7 @@ function BulkArchiveDialog({ return ( <> - - - {steps.map((step) => ( - onBulkMove(step.id)} - data-testid={`bulk-move-step-${step.id}`} + + {t("kanban:bulkSelected", { count })} + + + + {steps.length > 0 && ( + + + + + + {steps.map((step) => ( + onBulkMove(step.id)} + data-testid={`bulk-move-step-${step.id}`} + > +
+ {step.title} + + ))} + + + )} - onBulkArchive({ cascade }, selection)} - /> - - - onBulkDelete({ cascade, discardWorktreeChanges }, selection) - } - /> + onBulkArchive({ cascade }, selection)} + /> + + onBulkDelete({ cascade, discardWorktreeChanges }, selection) + } + /> + + +
{children}
+
+ + ); +} diff --git a/apps/web/components/task/bulk-task-color-picker.tsx b/apps/web/components/task/bulk-task-color-picker.tsx new file mode 100644 index 0000000000..be1a9e259c --- /dev/null +++ b/apps/web/components/task/bulk-task-color-picker.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconCheck, IconPalette, IconX } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { useTaskColorSelection } from "@/hooks/use-task-color-selection"; +import { + TASK_COLORS, + TASK_COLOR_BAR_CLASS, + TASK_COLOR_LABEL_KEYS, + type TaskColor, +} from "@/lib/task-colors"; +import { cn } from "@/lib/utils"; +import { MobilePickerSheet } from "./mobile/mobile-picker-sheet"; + +type ColorOption = { + color: TaskColor | null; + label: string; + selected: boolean; + disabled: boolean; +}; + +export function BulkTaskColorPicker({ taskIds }: { taskIds: string[] }) { + const { t } = useTranslation(); + const { isMobile } = useResponsiveBreakpoint(); + const { ids, commonColor, hasColor, setColors, isPending } = useTaskColorSelection(taskIds); + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const choose = (color: TaskColor | null) => { + void setColors(ids, color); + setOpen(false); + }; + const trigger = ( + + ); + const options = [...TASK_COLORS, null].map((color) => ({ + color, + label: color ? t(TASK_COLOR_LABEL_KEYS[color]) : t("task:groupNone"), + selected: color !== null && commonColor === color, + disabled: isPending || (color === null && !hasColor), + })); + const status = ( + + {isPending ? t("task:bulkColorSaving") : ""} + + ); + if (isMobile) + return ( + <> + {trigger} + {status} + triggerRef.current?.focus()} + /> + + ); + return ( + <> + { + if (!nextOpen || !isPending) setOpen(nextOpen); + }} + trigger={trigger} + commonColor={commonColor} + options={options} + onChoose={choose} + /> + {status} + + ); +} + +function MobileBulkColorPicker({ + open, + onOpenChange, + count, + options, + onChoose, + returnFocus, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + count: number; + options: ColorOption[]; + onChoose: (color: TaskColor | null) => void; + returnFocus: () => void; +}) { + const { t } = useTranslation(); + return ( + { + event.preventDefault(); + returnFocus(); + }} + headerAction={ + + } + > + {options.map(({ color, label, selected, disabled }) => ( + + ))} + + ); +} + +function DesktopBulkColorPicker({ + open, + onOpenChange, + trigger, + commonColor, + options, + onChoose, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger: React.ReactNode; + commonColor: TaskColor | null; + options: ColorOption[]; + onChoose: (color: TaskColor | null) => void; +}) { + const { t } = useTranslation(); + return ( + + {trigger} + +
+ {t("task:bulkColorAutomaticHint")} +
+ + {options.map(({ color, label, disabled }) => ( + onChoose(color)} + data-testid={`bulk-color-option-${color ?? "none"}`} + > + + {label} + + ))} + +
+
+ ); +} + +function ColorSwatch({ color }: { color: TaskColor | null }) { + return ( + + ); +} diff --git a/apps/web/components/task/task-switcher-color-menu.test.tsx b/apps/web/components/task/task-switcher-color-menu.test.tsx new file mode 100644 index 0000000000..6b758b6423 --- /dev/null +++ b/apps/web/components/task/task-switcher-color-menu.test.tsx @@ -0,0 +1,95 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { ToastProvider } from "@/components/toast-provider"; +import { defaultState } from "@/lib/state/default-state"; +import { TaskItemWithContextMenu } from "./task-switcher-context-menu"; + +const save = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/api/domains/settings-api", () => ({ updateUserSettings: save })); +afterEach(cleanup); +beforeEach(() => { + save.mockReset(); + save.mockReturnValue(new Promise(() => {})); +}); + +async function openColors(rowId: string, colors: Record) { + const clear = vi.fn(); + render( + + + +
Task
+
+
+
, + ); + fireEvent.contextMenu(screen.getByTestId("row")); + const trigger = await screen.findByRole("menuitem", { name: "Color" }); + act(() => trigger.focus()); + fireEvent.keyDown(trigger, { key: "ArrowRight" }); + await screen.findByRole("menuitemradio", { name: "Blue" }); + return clear; +} + +// @covers AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.1, AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.3 +it("offers bulk color without pin callbacks and keeps mixed colors unchecked", async () => { + const clear = await openColors("a", { a: "red", b: "blue" }); + expect( + screen + .getAllByRole("menuitemradio") + .every((option) => option.getAttribute("aria-checked") === "false"), + ).toBe(true); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Blue" })); + await waitFor(() => + expect(save).toHaveBeenCalledExactlyOnceWith({ + sidebar_task_color_patch: { colors: { a: "blue", b: "blue" }, if_missing: false }, + }), + ); + expect(clear).not.toHaveBeenCalled(); +}); + +it("targets an unselected context row individually", async () => { + await openColors("c", { a: "red", b: "blue" }); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Blue" })); + await waitFor(() => + expect(save).toHaveBeenCalledExactlyOnceWith({ + sidebar_task_color_patch: { colors: { c: "blue" }, if_missing: false }, + }), + ); +}); + +it("checks a unanimous color and enables clearing", async () => { + await openColors("a", { a: "red", b: "red" }); + expect(screen.getByRole("menuitemradio", { name: "Red" }).getAttribute("aria-checked")).toBe( + "true", + ); + const none = screen.getByRole("menuitem", { name: "None" }); + expect(none.getAttribute("data-disabled")).toBeNull(); + fireEvent.click(none); + await waitFor(() => + expect(save).toHaveBeenCalledExactlyOnceWith({ + sidebar_task_color_patch: { colors: { a: null, b: null }, if_missing: false }, + }), + ); +}); + +it("disables None when all manual colors are absent", async () => { + await openColors("a", {}); + expect( + screen.getByRole("menuitem", { name: "None" }).getAttribute("data-disabled"), + ).not.toBeNull(); +}); diff --git a/apps/web/components/task/task-switcher-color-menu.tsx b/apps/web/components/task/task-switcher-color-menu.tsx index b2bf4f59c9..54ed253606 100644 --- a/apps/web/components/task/task-switcher-color-menu.tsx +++ b/apps/web/components/task/task-switcher-color-menu.tsx @@ -1,14 +1,16 @@ "use client"; -import { IconCheck, IconPalette } from "@tabler/icons-react"; +import { IconPalette } from "@tabler/icons-react"; import { ContextMenuItem, + ContextMenuRadioGroup, + ContextMenuRadioItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, } from "@kandev/ui/context-menu"; -import { useSetTaskColor, useTaskColor } from "@/hooks/use-task-color"; +import { useTaskColorSelection } from "@/hooks/use-task-color-selection"; import { TASK_COLORS, TASK_COLOR_BAR_CLASS, @@ -21,21 +23,29 @@ import type { AutomaticTaskColorSource } from "@/lib/sidebar/task-color-rules"; export function TaskColorMenu({ taskId, + taskIds, disabled, automaticColorSource, }: { - taskId: string; + taskId?: string; + taskIds?: string[]; disabled?: boolean; automaticColorSource?: AutomaticTaskColorSource; }) { const { t } = useTranslation(); - const currentColor = useTaskColor(taskId); - const setColor = useSetTaskColor(); + const { + ids, + commonColor: currentColor, + hasColor, + setColors, + isPending, + } = useTaskColorSelection(taskIds ?? (taskId ? [taskId] : [])); return ( - + {t("task:color")} + {isPending && {t("task:bulkColorSaving")}} {currentColor && ( )} - + + {taskIds && ( +
+ {t("task:bulkColorAutomaticHint")} +
+ )} {automaticColorSource && ( <> @@ -54,16 +69,25 @@ export function TaskColorMenu({ )} - {TASK_COLORS.map((color) => ( - setColor(taskId, color)} - /> - ))} + + {TASK_COLORS.map((color) => ( + { + void setColors(ids, color); + }} + /> + ))} + - setColor(taskId, null)}> + { + void setColors(ids, null); + }} + > {t("task:groupNone")} @@ -74,19 +98,18 @@ export function TaskColorMenu({ function TaskColorMenuItem({ color, - selected, + disabled, onSelect, }: { color: TaskColor; - selected: boolean; + disabled?: boolean; onSelect: () => void; }) { const { t } = useTranslation(); return ( - + {t(TASK_COLOR_LABEL_KEYS[color])} - {selected && } - + ); } diff --git a/apps/web/components/task/task-switcher-context-menu-items.tsx b/apps/web/components/task/task-switcher-context-menu-items.tsx index ad7fb682b0..e23f8b2fc8 100644 --- a/apps/web/components/task/task-switcher-context-menu-items.tsx +++ b/apps/web/components/task/task-switcher-context-menu-items.tsx @@ -413,8 +413,13 @@ function BulkSelectionMenuItems(props: BulkSelectionMenuProps) { groups={[ { key: "mark", - visible: Boolean(props.onBulkPin), - content: , + visible: props.actingIds.length > 0, + content: ( + <> + + + + ), }, { key: "move", diff --git a/apps/web/components/task/task-switcher-context-menu.test.tsx b/apps/web/components/task/task-switcher-context-menu.test.tsx index 57eafe1c12..31dbc5cb52 100644 --- a/apps/web/components/task/task-switcher-context-menu.test.tsx +++ b/apps/web/components/task/task-switcher-context-menu.test.tsx @@ -275,6 +275,7 @@ describe("TaskItemWithContextMenu — grouped bulk actions", () => { expect(labels).toEqual([ "Pin 2 tasks", + "Color", "Move to", "Send to workflow", "Archive 2 tasks", @@ -405,7 +406,7 @@ describe("TaskItemWithContextMenu — pointer containment", () => { fireEvent.pointerMove(screen.getByRole("menuitem", { name: /color/i }), { pointerType: "mouse", }); - const redSwatch = await screen.findByRole("menuitem", { name: /red/i }); + const redSwatch = await screen.findByRole("menuitemradio", { name: /red/i }); fireEvent.touchStart(redSwatch); expect(onTouchStart).not.toHaveBeenCalled(); }); @@ -417,7 +418,7 @@ describe("TaskItemWithContextMenu — pointer containment", () => { fireEvent.pointerMove(screen.getByRole("menuitem", { name: /color/i }), { pointerType: "mouse", }); - const redSwatch = await screen.findByRole("menuitem", { name: /red/i }); + const redSwatch = await screen.findByRole("menuitemradio", { name: /red/i }); fireEvent.mouseDown(redSwatch); fireEvent.pointerDown(redSwatch); diff --git a/apps/web/e2e/helpers/bulk-task-colors.ts b/apps/web/e2e/helpers/bulk-task-colors.ts new file mode 100644 index 0000000000..6f1f159f3d --- /dev/null +++ b/apps/web/e2e/helpers/bulk-task-colors.ts @@ -0,0 +1,40 @@ +import { expect, type Page } from "@playwright/test"; +import type { ApiClient } from "./api-client"; +import type { SeedData } from "../fixtures/test-base"; + +export async function seedColorTasks(api: ApiClient, seed: SeedData) { + const tasks = []; + for (const title of ["Bulk color A", "Bulk color B", "Unselected color control"]) { + tasks.push( + await api.createTask(seed.workspaceId, title, { + workflow_id: seed.workflowId, + workflow_step_id: seed.startStepId, + }), + ); + } + return tasks; +} + +export async function expectStoredColors(api: ApiClient, ids: string[], color: string | null) { + await expect + .poll(async () => { + const { settings } = await api.getUserSettings(); + const colors = settings.sidebar_task_colors as Record; + return ids.map((id) => colors[id] ?? null); + }) + .toEqual(ids.map(() => color)); +} + +export async function chooseSidebarColor(page: Page, label: string) { + await page.getByRole("menuitem", { name: "Color", exact: true }).hover(); + const role = label === "None" ? "menuitem" : "menuitemradio"; + await page.getByRole(role, { name: label, exact: true }).click(); +} + +export async function expectNoHorizontalOverflow(page: Page) { + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ).toBe(true); +} diff --git a/apps/web/e2e/tests/kanban/mobile-bulk-task-colors.spec.ts b/apps/web/e2e/tests/kanban/mobile-bulk-task-colors.spec.ts new file mode 100644 index 0000000000..4554896eab --- /dev/null +++ b/apps/web/e2e/tests/kanban/mobile-bulk-task-colors.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; +import { SessionPage } from "../../pages/session-page"; +import { + seedColorTasks, + expectStoredColors, + expectNoHorizontalOverflow, +} from "../../helpers/bulk-task-colors"; + +// @covers AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.6 +test("phone selection supports color, dismissal, clearing, and bulk actions", async ({ + testPage, + apiClient, + seedData, +}) => { + await testPage.setViewportSize({ width: 393, height: 851 }); + const [a, b, control] = await seedColorTasks(apiClient, seedData); + const board = new KanbanPage(testPage); + await board.goto(); + const select = testPage.getByTestId("mobile-select-tasks"); + await select.tap(); + await expect(select).toHaveAttribute("aria-pressed", "true"); + await select.tap(); + await expect(select).toHaveAttribute("aria-pressed", "false"); + await select.tap(); + await board.taskCard(a.id).tap(); + await board.taskCard(b.id).tap(); + await expect(board.multiSelectToolbar).toContainText("2 selected"); + const color = testPage.getByTestId("bulk-color-button"); + await color.tap(); + const picker = testPage.getByTestId("bulk-color-picker"); + await expect(picker).toBeVisible(); + const dialog = testPage.getByRole("dialog", { name: "Color for 2 tasks" }); + await dialog.evaluate(async (el) => { + await Promise.all( + el + .getAnimations({ subtree: true }) + .filter((a) => Number.isFinite(a.effect?.getComputedTiming().iterations)) + .map((a) => a.finished.catch(() => undefined)), + ); + }); + const bounds = await dialog.boundingBox(); + expect(bounds!.x).toBeGreaterThanOrEqual(0); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(393); + for (const button of await picker.getByRole("button").all()) { + expect((await button.boundingBox())!.height).toBeGreaterThanOrEqual(44); + } + await expectNoHorizontalOverflow(testPage); + await testPage.screenshot({ path: "/tmp/bulk-task-colors-phone.png" }); + await dialog.getByRole("button", { name: "Close", exact: true }).tap(); + await expect(color).toBeFocused(); + await expectStoredColors(apiClient, [a.id, b.id], null); + await color.tap(); + await picker.getByTestId("bulk-color-option-green").tap(); + await expectStoredColors(apiClient, [a.id, b.id], "green"); + await expectStoredColors(apiClient, [control.id], null); + await expect(board.multiSelectToolbar).toContainText("2 selected"); + await color.tap(); + await picker.getByTestId("bulk-color-option-none").tap(); + await expectStoredColors(apiClient, [a.id, b.id], null); + await color.tap(); + await picker.getByTestId("bulk-color-option-purple").tap(); + await expectStoredColors(apiClient, [a.id, b.id], "purple"); + await testPage.getByTestId("bulk-actions-button").tap(); + await expect(testPage.getByTestId("bulk-archive-button")).toBeVisible(); + await expect(testPage.getByTestId("bulk-delete-button")).toBeVisible(); + await testPage.keyboard.press("Escape"); + for (const width of [767, 768]) { + await testPage.setViewportSize({ width, height: 851 }); + await expect(testPage.getByTestId("bulk-actions-button")).toHaveCount(width < 768 ? 1 : 0); + await expectNoHorizontalOverflow(testPage); + } + await testPage.setViewportSize({ width: 393, height: 851 }); + await testPage.goto(`/t/${control.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await testPage.reload(); + await session.waitForLoad(); + await session.mobileSessionMenu.tap(); + const drawer = testPage.getByTestId("mobile-task-switcher-list"); + await expect( + drawer + .getByTestId("sidebar-task-item") + .filter({ hasText: a.title }) + .getByTestId("task-item-color-marker"), + ).toHaveAttribute("data-color-token", "purple"); +}); diff --git a/apps/web/e2e/tests/task/bulk-task-colors.spec.ts b/apps/web/e2e/tests/task/bulk-task-colors.spec.ts new file mode 100644 index 0000000000..555fbc7d9f --- /dev/null +++ b/apps/web/e2e/tests/task/bulk-task-colors.spec.ts @@ -0,0 +1,95 @@ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; +import { SessionPage } from "../../pages/session-page"; +import { SidebarTasksPage } from "../../pages/sidebar-tasks-page"; +import { + seedColorTasks, + expectStoredColors, + chooseSidebarColor, +} from "../../helpers/bulk-task-colors"; +import type { SidebarTaskColorAutomation } from "../../../lib/task-color-automation-settings"; + +// @covers AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.1 through .4 +test("sidebar colors exactly selected tasks, preserves selection, and clears after reload", async ({ + testPage, + apiClient, + seedData, +}) => { + const [a, b, control] = await seedColorTasks(apiClient, seedData); + await testPage.goto(`/t/${control.id}`); + await new SessionPage(testPage).waitForLoad(); + const sidebar = new SidebarTasksPage(testPage); + await sidebar.cmdClick(a.id); + await sidebar.cmdClick(b.id); + await sidebar.rightClick(a.id); + await chooseSidebarColor(testPage, "Blue"); + await expectStoredColors(apiClient, [a.id, b.id], "blue"); + await expectStoredColors(apiClient, [control.id], null); + await sidebar.expectSelected(a.id); + await sidebar.expectSelected(b.id); + await expect(testPage).toHaveURL(new RegExp(`/t/${control.id}$`)); + await testPage.reload(); + await new SessionPage(testPage).waitForLoad(); + for (const id of [a.id, b.id]) { + await expect(sidebar.row(id).getByTestId("task-item-color-marker")).toHaveAttribute( + "data-color-token", + "blue", + ); + await sidebar.cmdClick(id); + } + await sidebar.rightClick(a.id); + await chooseSidebarColor(testPage, "None"); + await expectStoredColors(apiClient, [a.id, b.id], null); + await sidebar.expectSelected(a.id); +}); + +test("board bulk colors persist while automatic rules retain precedence", async ({ + testPage, + apiClient, + seedData, +}) => { + const { settings: baseline } = await apiClient.getUserSettings(); + const [a, b, control] = await seedColorTasks(apiClient, seedData); + const automation: SidebarTaskColorAutomation = { + enabled: true, + rules: [ + { + id: "bulk-color-rule", + enabled: true, + condition: { + dimension: "workflow_step", + value: { workspace_id: seedData.workspaceId, step_id: seedData.startStepId }, + label: "Start", + }, + output: { kind: "fixed", color: "red" }, + }, + ], + }; + try { + await apiClient.saveUserSettings({ sidebar_task_color_automation: automation }); + const board = new KanbanPage(testPage); + await board.goto(); + await board.selectTask(a.id); + await board.selectTask(b.id); + await testPage.getByTestId("bulk-color-button").click(); + await testPage.getByTestId("bulk-color-option-purple").click(); + await expectStoredColors(apiClient, [a.id, b.id], "purple"); + await expectStoredColors(apiClient, [control.id], null); + await expect(board.multiSelectToolbar).toContainText("2 selected"); + await testPage.goto(`/t/${control.id}`); + await new SessionPage(testPage).waitForLoad(); + await testPage.reload(); + await new SessionPage(testPage).waitForLoad(); + const sidebar = new SidebarTasksPage(testPage); + await expect(sidebar.row(a.id).getByTestId("task-item-color-marker")).toHaveAttribute( + "data-color-token", + "red", + ); + await expectStoredColors(apiClient, [a.id, b.id], "purple"); + } finally { + await apiClient.saveUserSettings({ + sidebar_task_color_automation: + baseline.sidebar_task_color_automation as SidebarTaskColorAutomation, + }); + } +}); diff --git a/apps/web/e2e/tests/task/sidebar-task-color-sync.spec.ts b/apps/web/e2e/tests/task/sidebar-task-color-sync.spec.ts index 7bc512a46d..f11746890a 100644 --- a/apps/web/e2e/tests/task/sidebar-task-color-sync.spec.ts +++ b/apps/web/e2e/tests/task/sidebar-task-color-sync.spec.ts @@ -97,7 +97,7 @@ test.describe("Sidebar personal task colors", () => { const colorMenu = testPage.getByRole("menuitem", { name: /color/i }); await expect(colorMenu).toBeVisible(); await colorMenu.hover(); - const blueMenuItem = testPage.getByRole("menuitem", { name: "Blue", exact: true }); + const blueMenuItem = testPage.getByRole("menuitemradio", { name: "Blue", exact: true }); await expect(blueMenuItem).toBeVisible(); await blueMenuItem.click(); diff --git a/apps/web/hooks/task-color-mutation.ts b/apps/web/hooks/task-color-mutation.ts new file mode 100644 index 0000000000..d20c9ffd90 --- /dev/null +++ b/apps/web/hooks/task-color-mutation.ts @@ -0,0 +1,164 @@ +import type { useAppStoreApi } from "@/components/state-provider"; +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision"; +import { requestUserSettingsUpdateWithRetry } from "@/lib/user-settings-sync"; +import type { TaskColor } from "@/lib/task-colors"; + +type Store = ReturnType; +type PendingColor = { + operation: symbol; + color: TaskColor | null; + state: "queued" | "sent"; +}; +type SyncResult = { + response: Awaited> | null; + submittedIds: string[]; +}; +type QueuedRequest = { + taskIds: string[]; + operation: symbol; + color: TaskColor | null; + resolve: (result: SyncResult) => void; + reject: (reason: unknown) => void; +}; + +export type TaskColorResult = { saved: number; total: number }; + +function createColorSync(pending: Map) { + const requests: QueuedRequest[] = []; + let requestRunning = false; + + function runNext() { + if (requestRunning) return; + let request = requests.shift(); + while (request) { + const activeRequest = request; + const submittedIds = activeRequest.taskIds.filter( + (id) => pending.get(id)?.operation === activeRequest.operation, + ); + if (submittedIds.length === 0) { + activeRequest.resolve({ response: null, submittedIds }); + request = requests.shift(); + continue; + } + submittedIds.forEach((id) => { + const entry = pending.get(id); + if (entry?.operation === activeRequest.operation) { + pending.set(id, { ...entry, state: "sent" }); + } + }); + requestRunning = true; + void requestUserSettingsUpdateWithRetry({ + sidebar_task_color_patch: { + colors: Object.fromEntries(submittedIds.map((id) => [id, activeRequest.color])), + if_missing: false, + }, + }) + .then((response) => activeRequest.resolve({ response, submittedIds })) + .catch((error: unknown) => activeRequest.reject(error)) + .finally(() => { + requestRunning = false; + runNext(); + }); + return; + } + } + + return (taskIds: string[], operation: symbol, color: TaskColor | null) => + new Promise((resolve, reject) => { + requests.push({ taskIds, operation, color, resolve, reject }); + runNext(); + }); +} + +function createColorMutation(store: Store) { + let confirmed = { ...store.getState().userSettings.sidebarTaskColors }; + let revision = store.getState().userSettings.revision; + let publishing = false; + const pending = new Map(); + const sync = createColorSync(pending); + + store.subscribe((state, previous) => { + if (publishing || state.userSettings === previous.userSettings) return; + const settings = state.userSettings; + const order = compareUserSettingsRevisions(settings.revision, revision); + if (order === 1 || (pending.size === 0 && order !== -1)) { + const previousConfirmed = confirmed; + confirmed = { ...settings.sidebarTaskColors }; + revision = settings.revision; + if (order === 1) { + pending.forEach((entry, id) => { + const colorChanged = + (settings.sidebarTaskColors[id] ?? null) !== (previousConfirmed[id] ?? null); + if (entry.state === "sent" && colorChanged) pending.delete(id); + }); + if (pending.size > 0) publish(); + } + } + }); + + function publish() { + const latest = store.getState().userSettings; + const colors = { ...confirmed }; + pending.forEach(({ color }, id) => { + colors[id] = color; + }); + publishing = true; + try { + store + .getState() + .setUserSettings({ ...latest, sidebarTaskColors: colors, revision, loaded: true }); + } finally { + publishing = false; + } + } + + function release(ids: string[], operation: symbol) { + ids.forEach((id) => { + if (pending.get(id)?.operation === operation) pending.delete(id); + }); + } + + return async (taskIds: string[], color: TaskColor | null): Promise => { + const ids = [...new Set(taskIds.filter(Boolean))]; + const operation = Symbol(); + ids.forEach((id) => pending.set(id, { operation, color, state: "queued" })); + if (ids.length === 0) return { saved: 0, total: 0 }; + publish(); + let saved = 0; + try { + for (let offset = 0; offset < ids.length; offset += 500) { + const chunk = ids.slice(offset, offset + 500); + const { response, submittedIds } = await sync(chunk, operation, color); + if (!response) { + saved += submittedIds.length; + continue; + } + const mapped = mapUserSettingsResponse(response, store.getState().userSettings); + const order = compareUserSettingsRevisions(mapped.revision, revision); + if (order === 1 || order === 0) { + confirmed = { ...mapped.sidebarTaskColors }; + revision = mapped.revision; + } + saved += submittedIds.length; + release(submittedIds, operation); + publish(); + } + } catch { + release(ids, operation); + publish(); + } + return { saved, total: ids.length }; + }; +} + +const mutations = new WeakMap>(); + +export function getTaskColorMutation(store: Store) { + let mutation = mutations.get(store); + if (!mutation) { + mutation = createColorMutation(store); + mutations.set(store, mutation); + } + return mutation; +} diff --git a/apps/web/hooks/use-task-color-selection.ts b/apps/web/hooks/use-task-color-selection.ts new file mode 100644 index 0000000000..0f9aaa3c09 --- /dev/null +++ b/apps/web/hooks/use-task-color-selection.ts @@ -0,0 +1,12 @@ +import { useAppStore } from "@/components/state-provider"; +import { useSetTaskColors } from "./use-task-color"; + +export function useTaskColorSelection(taskIds: string[]) { + const colors = useAppStore((state) => state.userSettings.sidebarTaskColors); + const mutation = useSetTaskColors(); + const ids = [...new Set(taskIds.filter(Boolean))]; + const first = colors[ids[0]] ?? null; + const commonColor = ids.every((id) => (colors[id] ?? null) === first) ? first : null; + const hasColor = ids.some((id) => colors[id] != null); + return { ...mutation, ids, commonColor, hasColor }; +} diff --git a/apps/web/hooks/use-task-color.test.tsx b/apps/web/hooks/use-task-color.test.tsx index 4551090cf0..2d45dc1045 100644 --- a/apps/web/hooks/use-task-color.test.tsx +++ b/apps/web/hooks/use-task-color.test.tsx @@ -5,7 +5,7 @@ import { ToastProvider } from "@/components/toast-provider"; import { defaultState } from "@/lib/state/default-state"; import { TASK_COLORS_STORAGE_KEY } from "@/lib/task-colors"; import type { UserSettingsResponse } from "@/lib/types/http"; -import { useSetTaskColor, useTaskColor } from "./use-task-color"; +import { useSetTaskColor, useSetTaskColors, useTaskColor } from "./use-task-color"; const mockUpdateUserSettings = vi.hoisted(() => vi.fn()); @@ -162,3 +162,287 @@ describe("useTaskColor", () => { await waitFor(() => expect(result.current.color).toBe("blue")); }); }); + +it("keeps an in-flight color optimistic across an unrelated newer settings revision", async () => { + let rejectUpdate: ((reason: Error) => void) | undefined; + mockUpdateUserSettings.mockReturnValue( + new Promise((_, reject) => { + rejectUpdate = reject; + }), + ); + const { result } = renderHook( + () => ({ color: useTaskColor("task-1"), setColor: useSetTaskColor() }), + { wrapper }, + ); + + act(() => result.current.setColor("task-1", "blue")); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1)); + act(() => { + capturedStore?.setState((state) => ({ + ...state, + userSettings: { + ...state.userSettings, + revision: 2, + sidebarTaskColors: { "task-1": "red" }, + }, + })); + }); + + expect(result.current.color).toBe("blue"); + act(() => rejectUpdate?.(new Error("offline"))); + await waitFor(() => expect(result.current.color).toBe("red")); +}); + +// @covers AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.5 +it("restores only failed IDs across independent color setters", async () => { + let resolveFirst: ((value: UserSettingsResponse) => void) | undefined; + mockUpdateUserSettings.mockImplementation(() => { + if (mockUpdateUserSettings.mock.calls.length === 1) { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + return Promise.reject(new Error("offline")); + }); + const { result } = renderHook(() => ({ first: useSetTaskColor(), second: useSetTaskColor() }), { + wrapper, + }); + act(() => { + result.current.first("task-1", "blue"); + result.current.second("task-2", "pink"); + }); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalled()); + act(() => resolveFirst?.(response({ "task-1": "blue" }, 2))); + await waitFor(() => + expect(capturedStore?.getState().userSettings.sidebarTaskColors["task-2"] ?? null).toBeNull(), + ); + expect(capturedStore?.getState().userSettings.sidebarTaskColors["task-1"]).toBe("blue"); +}); + +it("does not roll back another hook's pending optimistic color", async () => { + mockUpdateUserSettings.mockImplementation((payload) => { + if (payload.sidebar_task_color_patch.colors["task-1"]) + return Promise.reject(new Error("offline")); + return new Promise(() => {}); + }); + const { result } = renderHook( + () => ({ first: useSetTaskColor(), second: useSetTaskColor(), color: useTaskColor("task-1") }), + { wrapper }, + ); + act(() => { + result.current.first("task-1", "blue"); + result.current.second("task-2", "pink"); + }); + await waitFor(() => expect(result.current.color).toBe("red")); + expect(capturedStore?.getState().userSettings.sidebarTaskColors["task-2"]).toBe("pink"); +}); + +// @covers AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.2, AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.7 +describe("bulk colors", () => { + it("deduplicates and captures IDs in one patch, including clearing", async () => { + mockUpdateUserSettings.mockResolvedValue(response({ "task-1": null, "task-2": null }, 2)); + const { result } = renderHook(() => useSetTaskColors(), { wrapper }); + const ids = ["task-1", "task-2", "task-1", ""]; + let saving: Promise; + act(() => { + saving = result.current.setColors(ids, null); + ids.push("unselected"); + }); + expect(result.current.isPending).toBe(true); + await act(async () => { + await saving; + }); + expect(mockUpdateUserSettings).toHaveBeenCalledExactlyOnceWith({ + sidebar_task_color_patch: { colors: { "task-1": null, "task-2": null }, if_missing: false }, + }); + expect(result.current.isPending).toBe(false); + }); + + it("does nothing for an empty selection", async () => { + const { result } = renderHook(() => useSetTaskColors(), { wrapper }); + await act(async () => { + expect(await result.current.setColors([], "red")).toEqual({ saved: 0, total: 0 }); + }); + expect(mockUpdateUserSettings).not.toHaveBeenCalled(); + }); + + it.each([500, 501])("saves %i tasks in bounded sequential patches", async (count) => { + let colors = { "task-1": "red" }; + let revision = 1; + mockUpdateUserSettings.mockImplementation(async (payload) => { + colors = { ...colors, ...payload.sidebar_task_color_patch.colors }; + return response(colors as Parameters[0], ++revision); + }); + const { result } = renderHook(() => useSetTaskColors(), { wrapper }); + await act(async () => { + expect( + await result.current.setColors( + Array.from({ length: count }, (_, i) => `bulk-${i}`), + "purple", + ), + ).toEqual({ saved: count, total: count }); + }); + expect(mockUpdateUserSettings).toHaveBeenCalledTimes(Math.ceil(count / 500)); + expect( + Object.keys(mockUpdateUserSettings.mock.calls[0][0].sidebar_task_color_patch.colors), + ).toHaveLength(500); + expect(capturedStore?.getState().userSettings.sidebarTaskColors["task-1"]).toBe("red"); + }); + + it("retains confirmed chunks and restores unsaved entries after partial failure", async () => { + const ids = Array.from({ length: 501 }, (_, i) => `bulk-${i}`); + mockUpdateUserSettings + .mockResolvedValueOnce( + response( + { + "task-1": "red", + ...Object.fromEntries(ids.slice(0, 500).map((id) => [id, "purple" as const])), + }, + 2, + ), + ) + .mockRejectedValue(new Error("offline")); + const { result } = renderHook(() => useSetTaskColors(), { wrapper }); + await act(async () => { + expect(await result.current.setColors(ids, "purple")).toEqual({ saved: 500, total: 501 }); + }); + const colors = capturedStore?.getState().userSettings.sidebarTaskColors; + expect(colors?.["bulk-499"]).toBe("purple"); + expect(colors?.["bulk-500"] ?? null).toBeNull(); + expect(result.current.isPending).toBe(false); + }); + + it("guards same-tick duplicate submits and preserves a later single-task edit", async () => { + let resolve: ((value: UserSettingsResponse) => void) | undefined; + mockUpdateUserSettings + .mockReturnValueOnce( + new Promise((r) => { + resolve = r; + }), + ) + .mockResolvedValue(response({ "task-1": "pink", "task-2": "blue" }, 3)); + const { result } = renderHook(() => ({ bulk: useSetTaskColors(), single: useSetTaskColor() }), { + wrapper, + }); + let saving: Promise; + act(() => { + saving = result.current.bulk.setColors(["task-1", "task-2"], "blue"); + expect(result.current.bulk.setColors(["task-1"], "green")).toBe(saving); + result.current.single("task-1", "pink"); + }); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1)); + await act(async () => { + resolve?.(response({ "task-1": "blue", "task-2": "blue" }, 2)); + await saving; + }); + await waitFor(() => + expect(capturedStore?.getState().userSettings.sidebarTaskColors["task-1"]).toBe("pink"), + ); + expect(mockUpdateUserSettings).toHaveBeenCalledTimes(2); + }); +}); + +it("does not overwrite a newer single edit with a later chunk of an older batch", async () => { + const ids = Array.from({ length: 501 }, (_, i) => `bulk-${i}`); + let release: (() => void) | undefined; + const barrier = new Promise((resolve) => { + release = resolve; + }); + let confirmed: Parameters[0] = { "task-1": "red" }; + let revision = 1; + mockUpdateUserSettings.mockImplementation(async (payload) => { + if (mockUpdateUserSettings.mock.calls.length === 1) await barrier; + confirmed = { ...confirmed, ...payload.sidebar_task_color_patch.colors }; + return response(confirmed, ++revision); + }); + const { result } = renderHook(() => ({ bulk: useSetTaskColors(), single: useSetTaskColor() }), { + wrapper, + }); + let saving: Promise; + act(() => { + saving = result.current.bulk.setColors(ids, "blue"); + }); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1)); + act(() => { + result.current.single("bulk-500", "pink"); + }); + await act(async () => { + release?.(); + await saving; + }); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(2)); + expect(confirmed["bulk-500"]).toBe("pink"); + expect(capturedStore?.getState().userSettings.sidebarTaskColors["bulk-500"]).toBe("pink"); +}); + +it("does not count a superseded task as saved when its newer edit fails", async () => { + const ids = Array.from({ length: 501 }, (_, i) => `bulk-${i}`); + let resolveFirst: ((value: UserSettingsResponse) => void) | undefined; + mockUpdateUserSettings.mockImplementation(() => { + if (mockUpdateUserSettings.mock.calls.length === 1) { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + return Promise.reject(new Error("offline")); + }); + const { result } = renderHook(() => ({ bulk: useSetTaskColors(), single: useSetTaskColor() }), { + wrapper, + }); + let saving!: Promise<{ saved: number; total: number }>; + act(() => { + saving = result.current.bulk.setColors(ids, "blue"); + }); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1)); + act(() => { + result.current.single("bulk-500", "pink"); + }); + + let outcome: { saved: number; total: number } | undefined; + await act(async () => { + resolveFirst?.( + response(Object.fromEntries(ids.slice(0, 500).map((id) => [id, "blue" as const])), 2), + ); + outcome = await saving; + }); + + expect(outcome).toEqual({ saved: 500, total: 501 }); +}); + +it("keeps a queued newer edit optimistic when an older write emits a settings event", async () => { + let resolveFirst: ((value: UserSettingsResponse) => void) | undefined; + mockUpdateUserSettings.mockImplementation(() => { + if (mockUpdateUserSettings.mock.calls.length === 1) { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + return Promise.resolve(response({ "task-1": "pink", "task-2": "blue" }, 3)); + }); + const { result } = renderHook(() => ({ bulk: useSetTaskColors(), single: useSetTaskColor() }), { + wrapper, + }); + + act(() => { + void result.current.bulk.setColors(["task-1", "task-2"], "blue"); + result.current.single("task-1", "pink"); + }); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1)); + act(() => { + capturedStore?.setState((state) => ({ + ...state, + userSettings: { + ...state.userSettings, + revision: 2, + sidebarTaskColors: { "task-1": "blue", "task-2": "blue" }, + }, + })); + }); + + expect(capturedStore?.getState().userSettings.sidebarTaskColors["task-1"]).toBe("pink"); + act(() => resolveFirst?.(response({ "task-1": "blue", "task-2": "blue" }, 2))); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect(capturedStore?.getState().userSettings.sidebarTaskColors["task-1"]).toBe("pink"), + ); +}); diff --git a/apps/web/hooks/use-task-color.ts b/apps/web/hooks/use-task-color.ts index 4520409e80..4910314ce6 100644 --- a/apps/web/hooks/use-task-color.ts +++ b/apps/web/hooks/use-task-color.ts @@ -1,45 +1,11 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useRef, useState } from "react"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { useTranslation } from "react-i18next"; -import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; -import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision"; -import { createQueuedUserSettingsSyncWithResponse } from "@/lib/user-settings-sync"; -import type { UserSettingsUpdatePayload } from "@/lib/types/http-user-settings"; import { type TaskColor } from "@/lib/task-colors"; - -type TaskColorMap = Record; - -function cloneTaskColorMap(colors: TaskColorMap): TaskColorMap { - return { ...colors }; -} - -function optimisticTaskColorSettings( - settings: ReturnType["getState"]>["userSettings"], - taskId: string, - color: TaskColor | null, -) { - return { - ...settings, - sidebarTaskColors: { - ...settings.sidebarTaskColors, - [taskId]: color, - }, - loaded: true, - }; -} - -function responseIsCurrent( - responseRevision: number | undefined, - currentRevision: number | null, - settingsAtSubmit: object, - currentSettings: object, -) { - const order = compareUserSettingsRevisions(responseRevision, currentRevision); - return order === null ? currentSettings === settingsAtSubmit : order >= 0; -} +import { getTaskColorMutation, type TaskColorResult } from "./task-color-mutation"; export function useTaskColor(taskId: string | undefined): TaskColor | null { return useAppStore((state) => { @@ -48,101 +14,52 @@ export function useTaskColor(taskId: string | undefined): TaskColor | null { }); } -export function useSetTaskColor(): (taskId: string, color: TaskColor | null) => void { +function useColorMutation() { const store = useAppStoreApi(); - const settings = useAppStore((state) => state.userSettings); const { toast } = useToast(); const { t } = useTranslation(); - const confirmedColorsRef = useRef(cloneTaskColorMap(settings.sidebarTaskColors)); - const confirmedRevisionRef = useRef(settings.revision); - const operationRef = useRef(0); - const pendingRef = useRef(0); - const sync = useMemo( - () => - createQueuedUserSettingsSyncWithResponse<{ - taskId: string; - color: TaskColor | null; - }>( - ({ taskId, color }): UserSettingsUpdatePayload => ({ - sidebar_task_color_patch: { - colors: { [taskId]: color }, - if_missing: false, - }, - }), - ), - [], - ); - - useEffect(() => { - const current = store.getState().userSettings; - const order = compareUserSettingsRevisions(current.revision, confirmedRevisionRef.current); - if (pendingRef.current === 0) { - if (order === -1) return; - confirmedColorsRef.current = cloneTaskColorMap(current.sidebarTaskColors); - confirmedRevisionRef.current = current.revision; - return; - } - if (order === 1) { - confirmedColorsRef.current = cloneTaskColorMap(current.sidebarTaskColors); - confirmedRevisionRef.current = current.revision; - } - }, [settings, store]); - return useCallback( - (taskId: string, color: TaskColor | null) => { - if (!taskId) return; - const operation = ++operationRef.current; - pendingRef.current += 1; - const settingsAtSubmit = store.getState().userSettings; - store - .getState() - .setUserSettings(optimisticTaskColorSettings(settingsAtSubmit, taskId, color)); - - void sync({ taskId, color }) - .then((response) => { - const latest = store.getState().userSettings; - const mapped = mapUserSettingsResponse(response, latest); - const responseOrder = compareUserSettingsRevisions( - mapped.revision, - confirmedRevisionRef.current, - ); - if (responseOrder !== 1) return; - - confirmedColorsRef.current = cloneTaskColorMap(mapped.sidebarTaskColors); - confirmedRevisionRef.current = mapped.revision; - - if ( - operation !== operationRef.current || - !responseIsCurrent( - response.settings.revision, - latest.revision, - settingsAtSubmit, - latest, - ) - ) { - return; - } - store.getState().setUserSettings(mapped); - }) - .catch(() => { - if (operation !== operationRef.current) return; - const latest = store.getState().userSettings; - const order = compareUserSettingsRevisions(latest.revision, confirmedRevisionRef.current); - if (order === 1) { - confirmedColorsRef.current = cloneTaskColorMap(latest.sidebarTaskColors); - confirmedRevisionRef.current = latest.revision; - } - store.getState().setUserSettings({ - ...latest, - sidebarTaskColors: cloneTaskColorMap(confirmedColorsRef.current), - loaded: true, - }); - toast({ variant: "error", description: t("task:manualColorSaveError") }); - }) - .finally(() => { - pendingRef.current -= 1; - }); + async (ids: string[], color: TaskColor | null) => { + const result = await getTaskColorMutation(store)(ids, color); + if (result.saved < result.total) { + const description = + result.saved === 0 + ? t("task:manualColorSaveError") + : t("task:bulkColorPartialError", { saved: result.saved, total: result.total }); + toast({ variant: "error", description }); + } + return result; }, - [store, sync, t, toast], + [store, t, toast], ); } + +export function useSetTaskColor(): (taskId: string, color: TaskColor | null) => void { + const mutate = useColorMutation(); + return useCallback( + (taskId, color) => { + void mutate([taskId], color); + }, + [mutate], + ); +} + +export function useSetTaskColors() { + const mutate = useColorMutation(); + const [isPending, setPending] = useState(false); + const inFlight = useRef | null>(null); + const setColors = useCallback( + (ids: string[], color: TaskColor | null) => { + if (inFlight.current) return inFlight.current; + setPending(true); + const promise = mutate([...ids], color).finally(() => { + inFlight.current = null; + setPending(false); + }); + inFlight.current = promise; + return promise; + }, + [mutate], + ); + return { setColors, isPending }; +} diff --git a/apps/web/src/locales/en/kanban.json b/apps/web/src/locales/en/kanban.json index 2a5cb615fe..d94820d1e7 100644 --- a/apps/web/src/locales/en/kanban.json +++ b/apps/web/src/locales/en/kanban.json @@ -129,5 +129,10 @@ "boardSort": "Board sort", "boardSortCreatedDesc": "Newest first", "boardSortPriorityDesc": "Priority", - "priorityFilter": "Priority filter" + "priorityFilter": "Priority filter", + "bulkSelected_one": "{{count}} selected", + "bulkSelected_other": "{{count}} selected", + "bulkActions": "Actions", + "selectTasks": "Select tasks", + "cancelTaskSelection": "Cancel selection" } diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index cea9b256e8..a6b081a92e 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -2374,5 +2374,10 @@ "toolPayloadRemovedAt": "Tool details removed on {{date}}.", "toolPayloadRemoved": "Tool details removed.", "openTaskSwitcherFor": "Open task switcher: {{title}}", + "bulkColorPartialError": "Colors saved for {{saved}} of {{total}} tasks. Try again for the rest.", + "bulkColorSaving": "Saving colors...", + "bulkColorAutomaticHint": "Automatic rules may override the visible color.", + "bulkColorTitle_one": "Color for {{count}} task", + "bulkColorTitle_other": "Color for {{count}} tasks", "editorActions": "Editor and folder actions" } diff --git a/apps/web/src/locales/ja/kanban.json b/apps/web/src/locales/ja/kanban.json index 3ae9b3a7e3..aebb167055 100644 --- a/apps/web/src/locales/ja/kanban.json +++ b/apps/web/src/locales/ja/kanban.json @@ -129,5 +129,10 @@ "boardSort": "ボードの並び順", "boardSortCreatedDesc": "新しい順", "boardSortPriorityDesc": "優先度順", - "priorityFilter": "優先度フィルタ" + "priorityFilter": "優先度フィルタ", + "bulkSelected_one": "{{count}}件選択中", + "bulkSelected_other": "{{count}}件選択中", + "bulkActions": "操作", + "selectTasks": "タスクを選択", + "cancelTaskSelection": "選択をキャンセル" } diff --git a/apps/web/src/locales/ja/task.json b/apps/web/src/locales/ja/task.json index b1c935bb35..43d3a48639 100644 --- a/apps/web/src/locales/ja/task.json +++ b/apps/web/src/locales/ja/task.json @@ -2374,5 +2374,10 @@ "toolPayloadRemovedAt": "ツール詳細は {{date}} に削除済み。", "toolPayloadRemoved": "ツール詳細は削除済み。", "openTaskSwitcherFor": "タスク切替を開く: {{title}}", + "bulkColorPartialError": "全{{total}}件のうち{{saved}}件のタスクの色を保存しました。残りを再試行してください。", + "bulkColorSaving": "色を保存中...", + "bulkColorAutomaticHint": "自動ルールにより表示色が上書きされる場合があります。", + "bulkColorTitle_one": "{{count}}件のタスクの色", + "bulkColorTitle_other": "{{count}}件のタスクの色", "editorActions": "エディターとフォルダーの操作" } diff --git a/apps/web/src/locales/pseudo/kanban.json b/apps/web/src/locales/pseudo/kanban.json index 6761fb3043..3e0e303d7e 100644 --- a/apps/web/src/locales/pseudo/kanban.json +++ b/apps/web/src/locales/pseudo/kanban.json @@ -129,5 +129,10 @@ "boardSort": "Ɓōàŕď śōŕţ", "boardSortCreatedDesc": "Ńēŵēśţ ƒĩŕśţ", "boardSortPriorityDesc": "Ƥŕĩōŕĩţŷ", - "priorityFilter": "Ƥŕĩōŕĩţŷ ƒĩĺţēŕ" + "priorityFilter": "Ƥŕĩōŕĩţŷ ƒĩĺţēŕ", + "bulkSelected_one": "{{count}} śēĺēćţēď", + "bulkSelected_other": "{{count}} śēĺēćţēď", + "bulkActions": "Àćţĩōńś", + "selectTasks": "Śēĺēćţ ţàśķś", + "cancelTaskSelection": "Ćàńćēĺ śēĺēćţĩōń" } diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 3d0c556028..0a4f74cfdd 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -2374,5 +2374,10 @@ "toolPayloadRemovedAt": "Ţōōĺ ďēţàĩĺś ŕēḿōvēď ōń {{date}}.", "toolPayloadRemoved": "Ţōōĺ ďēţàĩĺś ŕēḿōvēď.", "openTaskSwitcherFor": "Ōƥēń ţàśķ śŵĩţćĥēŕ: {{title}}", + "bulkColorPartialError": "Ćōĺōŕś śàvēď ƒōŕ {{saved}} ōƒ {{total}} ţàśķś. Ţŕŷ àĝàĩń ƒōŕ ţĥē ŕēśţ.", + "bulkColorSaving": "Śàvĩńĝ ćōĺōŕś...", + "bulkColorAutomaticHint": "Àũţōḿàţĩć ŕũĺēś ḿàŷ ōvēŕŕĩďē ţĥē vĩśĩƀĺē ćōĺōŕ.", + "bulkColorTitle_one": "Ćōĺōŕ ƒōŕ {{count}} ţàśķ", + "bulkColorTitle_other": "Ćōĺōŕ ƒōŕ {{count}} ţàśķś", "editorActions": "Ēďĩţōŕ àńď ƒōĺďēŕ àćţĩōńś" } diff --git a/apps/web/src/locales/pt-pt/kanban.json b/apps/web/src/locales/pt-pt/kanban.json index 65c40c6570..da2b5192cf 100644 --- a/apps/web/src/locales/pt-pt/kanban.json +++ b/apps/web/src/locales/pt-pt/kanban.json @@ -129,5 +129,10 @@ "boardSort": "Ordenação do quadro", "boardSortCreatedDesc": "Mais recentes primeiro", "boardSortPriorityDesc": "Prioridade", - "priorityFilter": "Filtro de prioridade" + "priorityFilter": "Filtro de prioridade", + "bulkSelected_one": "{{count}} selecionada", + "bulkSelected_other": "{{count}} selecionadas", + "bulkActions": "Ações", + "selectTasks": "Selecionar tarefas", + "cancelTaskSelection": "Cancelar seleção" } diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index 194e6c3d8f..8b50ebf0a4 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -2374,5 +2374,10 @@ "toolPayloadRemovedAt": "Detalhes da ferramenta removidos em {{date}}.", "toolPayloadRemoved": "Detalhes da ferramenta removidos.", "openTaskSwitcherFor": "Abrir seletor de tarefas: {{title}}", + "bulkColorPartialError": "Cores guardadas para {{saved}} de {{total}} tarefas. Tente novamente para as restantes.", + "bulkColorSaving": "A guardar cores...", + "bulkColorAutomaticHint": "As regras automáticas podem substituir a cor visível.", + "bulkColorTitle_one": "Cor para {{count}} tarefa", + "bulkColorTitle_other": "Cor para {{count}} tarefas", "editorActions": "Ações do editor e da pasta" } diff --git a/apps/web/src/locales/zh-cn/kanban.json b/apps/web/src/locales/zh-cn/kanban.json index f69151b3c2..324e03bf6d 100644 --- a/apps/web/src/locales/zh-cn/kanban.json +++ b/apps/web/src/locales/zh-cn/kanban.json @@ -129,5 +129,10 @@ "boardSort": "看板排序", "boardSortCreatedDesc": "最新优先", "boardSortPriorityDesc": "优先级", - "priorityFilter": "优先级筛选" + "priorityFilter": "优先级筛选", + "bulkSelected_other": "已选择 {{count}} 个", + "bulkActions": "操作", + "selectTasks": "选择任务", + "cancelTaskSelection": "取消选择", + "bulkSelected_one": "已选择 {{count}} 个" } diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index dbb2d1a1a7..99e6a7e975 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -2374,5 +2374,10 @@ "toolPayloadRemovedAt": "工具详细数据已于 {{date}} 删除。", "toolPayloadRemoved": "工具详细数据已删除。", "openTaskSwitcherFor": "打开任务切换器:{{title}}", + "bulkColorPartialError": "已保存 {{total}} 个任务中 {{saved}} 个任务的颜色。请重试其余任务。", + "bulkColorSaving": "正在保存颜色...", + "bulkColorAutomaticHint": "自动规则可能覆盖显示的颜色。", + "bulkColorTitle_other": "为 {{count}} 个任务设置颜色", + "bulkColorTitle_one": "为 {{count}} 个任务设置颜色", "editorActions": "编辑器和文件夹操作" } diff --git a/apps/web/src/locales/zh-hk/kanban.json b/apps/web/src/locales/zh-hk/kanban.json index 266a8044ab..bda0cdb736 100644 --- a/apps/web/src/locales/zh-hk/kanban.json +++ b/apps/web/src/locales/zh-hk/kanban.json @@ -129,5 +129,10 @@ "boardSort": "看板排序", "boardSortCreatedDesc": "最新優先", "boardSortPriorityDesc": "優先級", - "priorityFilter": "優先級篩選" + "priorityFilter": "優先級篩選", + "bulkSelected_other": "已選擇 {{count}} 個", + "bulkActions": "操作", + "selectTasks": "選擇任務", + "cancelTaskSelection": "取消選擇", + "bulkSelected_one": "已選擇 {{count}} 個" } diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 6f4ddfcabc..9653af786b 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -2374,5 +2374,10 @@ "toolPayloadRemovedAt": "工具詳細數據已於 {{date}} 刪除。", "toolPayloadRemoved": "工具詳細數據已刪除。", "openTaskSwitcherFor": "開啟任務切換器:{{title}}", + "bulkColorPartialError": "已儲存 {{total}} 個任務中 {{saved}} 個任務的顏色。請重試其餘任務。", + "bulkColorSaving": "正在儲存顏色...", + "bulkColorAutomaticHint": "自動規則可能覆蓋顯示的顏色。", + "bulkColorTitle_other": "為 {{count}} 個任務設定顏色", + "bulkColorTitle_one": "為 {{count}} 個任務設定顏色", "editorActions": "編輯器和資料夾操作" } diff --git a/apps/web/src/locales/zh-tw/kanban.json b/apps/web/src/locales/zh-tw/kanban.json index 6352948144..c461ad2846 100644 --- a/apps/web/src/locales/zh-tw/kanban.json +++ b/apps/web/src/locales/zh-tw/kanban.json @@ -129,5 +129,10 @@ "boardSort": "看板排序", "boardSortCreatedDesc": "最新優先", "boardSortPriorityDesc": "優先順序", - "priorityFilter": "優先順序篩選" + "priorityFilter": "優先順序篩選", + "bulkSelected_other": "已選擇 {{count}} 個", + "bulkActions": "操作", + "selectTasks": "選擇任務", + "cancelTaskSelection": "取消選擇", + "bulkSelected_one": "已選擇 {{count}} 個" } diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index e256e2829f..3bed92720b 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -2374,5 +2374,10 @@ "toolPayloadRemovedAt": "工具詳細資料已於 {{date}} 刪除。", "toolPayloadRemoved": "工具詳細資料已刪除。", "openTaskSwitcherFor": "開啟任務切換器:{{title}}", + "bulkColorPartialError": "已儲存 {{total}} 個任務中 {{saved}} 個任務的顏色。請重試其餘任務。", + "bulkColorSaving": "正在儲存顏色...", + "bulkColorAutomaticHint": "自動規則可能覆蓋顯示的顏色。", + "bulkColorTitle_other": "為 {{count}} 個任務設定顏色", + "bulkColorTitle_one": "為 {{count}} 個任務設定顏色", "editorActions": "編輯器和資料夾操作" } diff --git a/docs/plans/bulk-task-colors/plan.md b/docs/plans/bulk-task-colors/plan.md index cbbe60f37a..034cf433e2 100644 --- a/docs/plans/bulk-task-colors/plan.md +++ b/docs/plans/bulk-task-colors/plan.md @@ -1,27 +1,32 @@ --- -status: draft +created: 2026-09-19 +status: implemented +requirements: + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006 +system_design: + - ../../specs/ui/system-design/bulk-task-colors.md +legacy_specs: [] --- # Bulk task colors ## Outcome and scope -Add personal Color actions to the sidebar bulk menu and board selection toolbar. -Reuse existing palette, persistence, and automatic-rule precedence. Phone users -perform the same operation from board selection. This package stops at design; -implementation requires a later explicit request. No production code changed. +Adds personal Color actions to the sidebar bulk menu and board selection toolbar. +Reuses the existing palette, persistence, and automatic-rule precedence. Phone users +perform the same operation from board selection. The implementation reuses the existing personal settings patch contract and preserves automatic-rule precedence. ## Sources -- [Requirements](../../specs/ui/requirements/bulk-task-colors.md): REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006, draft extension. -- [System design](../../specs/ui/system-design/bulk-task-colors.md#bulk-manual-color-editing): draft bulk design extending the current personal-color contract. +- [Requirements](../../specs/ui/requirements/bulk-task-colors.md): REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006, active extension. +- [System design](../../specs/ui/system-design/bulk-task-colors.md#bulk-manual-color-editing): current bulk design extending the current personal-color contract. - [ADR 0041](../../decisions/0041-backend-owned-portable-user-settings.md): reuse portable personal settings ownership; no new ADR needed. ## Work package -| Order | Work order | Status | Dependencies | -| --- | --- | --- | --- | -| 1 | [Task 01: Bulk color editing](task-01-bulk-color-editing.md) | pending | None | +| Order | Work order | Status | Dependencies | +| ----- | ------------------------------------------------------------ | ------ | ------------ | +| 1 | [Task 01: Bulk color editing](task-01-bulk-color-editing.md) | done | None | One vertical slice, sequential execution. No subagents are authorized. Estimated implementation and targeted checks: 3-5 hours, subject to E2E setup. @@ -107,14 +112,8 @@ Validate phone geometry at 393px and toolbar composition on either side of ## Documentation -/docs-maintainer assessment: this turn changes design only, so public docs stay -unchanged. Implementation adds a short how-to section to the existing task guide -chosen from docs/public after searching current task navigation documentation. +Public docs updated: `docs/public/tasks-and-workflows.md` now explains desktop and phone bulk color selection, clearing, automatic-rule precedence, and retry behavior. ## Results -Review remediation: add an explicit phone selection trigger and move the new -contract into draft artifacts, preserving the shipped personal-color specs. -Document catalog validation, all 36 specification-linter tests, full specification -lint, and diff whitespace validation passed after these corrections. -Implementation and rendered verification remain pending. +Implemented the shared mutation coordinator, sidebar and board Color controls, and the phone-native selection and picker flow. Focused unit/component tests passed (50), desktop Playwright passed (24), and mobile Playwright passed (2). Typecheck, focused ESLint, i18n validation, public-doc validation (62 tests and 47 pages), document catalog validation, all 36 specification-linter tests, full specification lint, and diff whitespace validation passed. PR remediation added regressions for in-flight optimistic colors, superseded save counts, focus restoration, the persistent live region, and cleared mobile action state. The 393 px phone render matched the planned inset picker with labeled touch rows, safe containment, and no horizontal overflow. diff --git a/docs/plans/bulk-task-colors/task-01-bulk-color-editing.md b/docs/plans/bulk-task-colors/task-01-bulk-color-editing.md index 29ac458378..320dfba7f3 100644 --- a/docs/plans/bulk-task-colors/task-01-bulk-color-editing.md +++ b/docs/plans/bulk-task-colors/task-01-bulk-color-editing.md @@ -1,7 +1,7 @@ --- id: "01-bulk-color-editing" title: "Apply personal colors to multi-selected tasks" -status: pending +status: done wave: 1 depends_on: [] plan: "plan.md" @@ -189,4 +189,6 @@ and mobile-sidebar-task-color-sync E2E. ## Results -Pending. Record each command result and rendered phone comparison after execution. +Implemented bulk manual colors across sidebar and board selection. A shared per-store mutation coordinator deduplicates IDs, sends sequential 500-ID patches, preserves overlapping newer edits, retains confirmed chunks after partial failure, and restores only unsaved owned entries. The desktop and phone pickers preserve selection, expose pending and retry feedback, and keep automatic-rule precedence. + +Verification passed: 50 focused Vitest tests; TypeScript typecheck; focused ESLint; complete i18n validation; 24 desktop Chromium E2E tests; 2 mobile Chrome E2E tests; 62 public-doc validator tests plus all 47 published pages; document catalog validation; 36 specification-linter tests; full specification lint; and `git diff --check`. PR remediation added regressions for in-flight optimistic colors, superseded save counts, focus restoration, the persistent live region, and cleared mobile action state. The inspected 393 px phone screenshot matched the planned inset drawer, labeled 44 px rows, single scrolling surface, focus-return behavior, and viewport containment. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 20bed943d2..9381034d56 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -627,6 +627,16 @@ On desktop and tablet, drag a card up or down within its column to reorder it re On phones, Kanban focuses one workflow and one step at a time. The board navigator always names both; open it to choose either level, or use the previous/next controls and horizontal swipe to move between steps. Choosing a workflow makes it the active workflow for board actions and task creation. Tap a card to open that task directly. Its **More options** menu opens as a touch-sized bottom surface; **Move to** changes the task's workflow or step. **Edit** can still rename a task after work starts, while its original prompt remains locked. +### Color several tasks + +Use a bulk color when a group of tasks should share the same personal marker: + +1. On desktop, select sidebar rows with `Cmd/Ctrl`-click or select cards on the board. On a phone, choose **Select tasks** above the Kanban board, then tap the cards. +2. Open **Color** from the menu for a selected sidebar row or from the board selection bar. +3. Choose one of the seven colors. Choose **None** to clear existing manual colors from the selection. + +The selection and active task remain in place, and the colors persist across reloads. Automatic color rules still take precedence over a manual color in the visible sidebar marker; the picker explains this while preserving the saved manual choice. If part of a large selection cannot be saved, Kandev keeps the completed changes, restores the unsaved tasks, and leaves the selection available to retry. + Regular Kanban does not currently expose label editing or label filters. Do not design a supported Kanban process around labels.
diff --git a/docs/specs/ui/requirements/bulk-task-colors.md b/docs/specs/ui/requirements/bulk-task-colors.md index a063343ee1..0ac808c587 100644 --- a/docs/specs/ui/requirements/bulk-task-colors.md +++ b/docs/specs/ui/requirements/bulk-task-colors.md @@ -1,5 +1,5 @@ --- -status: draft +status: active system: ui created: 2026-09-19 owners: diff --git a/docs/specs/ui/system-design/bulk-task-colors.md b/docs/specs/ui/system-design/bulk-task-colors.md index df9a77fe0f..07b7e747bb 100644 --- a/docs/specs/ui/system-design/bulk-task-colors.md +++ b/docs/specs/ui/system-design/bulk-task-colors.md @@ -1,5 +1,5 @@ --- -status: draft +status: current system: ui requirements: - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006 @@ -15,7 +15,7 @@ requirements: ## Bulk manual color editing -This draft extends the [current personal-color design](sidebar-automatic-task-colors.md). +This design extends the [current personal-color design](sidebar-automatic-task-colors.md). Delivery is tracked in the [bulk color plan](../../../plans/bulk-task-colors/plan.md). UI remains the owner because colors are personal presentation preferences, not shared task metadata.