From a47714e486ba829224ee2589c794e91c0a04f6fe Mon Sep 17 00:00:00 2001 From: harrydawson Date: Mon, 8 Jun 2026 13:18:05 +1000 Subject: [PATCH] feat(todos): add priorities, inline renaming, and JIRA base URL - Priority field (low/medium/high) with color badges and context menu - Double-click to rename items inline - JIRA base URL config for shorthand issue key resolution - Category collapse state persisted - Improved markdown styling for descriptions - New mutations: setItemPriority, renameItem, setJiraBaseUrl, toggleCategory - Reject empty names in createCategory/renameCategory - ADR for background review agent worktrees --- CONTEXT.md | 24 ++ apps/server/src/todoPersistence.ts | 1 + apps/server/src/ws.ts | 18 +- apps/web/src/components/TodoPanel.tsx | 180 +++++++++++++-- apps/web/src/hooks/useTodos.ts | 7 +- apps/web/src/index.css | 131 +++++++++++ .../0003-background-review-agent-worktrees.md | 18 ++ packages/contracts/src/rpc.ts | 33 ++- packages/contracts/src/todos.test.ts | 12 + packages/contracts/src/todos.ts | 4 + packages/shared/src/todoStore.test.ts | 207 +++++++++++++++++- packages/shared/src/todoStore.ts | 75 ++++++- 12 files changed, 679 insertions(+), 31 deletions(-) create mode 100644 docs/adr/0003-background-review-agent-worktrees.md diff --git a/CONTEXT.md b/CONTEXT.md index 534fa346882f..3e5793639664 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -84,6 +84,22 @@ _Avoid_: Focus thread, focus window A sidebar thread indicator that shows Change Request state and glanceable metadata for the Thread's linked Change Request. _Avoid_: PR icon, badge +**Change Request Review**: +A sub-mode of a Thread that provides an interactive diff review experience for the Change Request linked to that Thread, allowing inline comments, AI-assisted review responses, and GitHub review submission — all inside the app instead of on the Git provider's website. +_Avoid_: PR review mode, review panel + +**Review Draft**: +The locally cached set of Review Comments that have been authored but not yet posted to the Git provider. Persists across server restarts and is shared by all Threads linked to the same Change Request. +_Avoid_: Pending comments, local review state + +**Review Comment**: +An inline comment anchored to a file, optional line number, and commit SHA within a Change Request Review. +_Avoid_: Inline review, PR note + +**Background Review Agent**: +A short-lived AI agent spawned from a Review Comment to respond to or act on that comment. Runs in an isolated temp worktree, pushes commits directly to the Change Request branch, and streams responses back inline. +_Avoid_: Review bot, response agent + **Delegation Badge**: A sidebar thread indicator that marks a Worker Thread as delegated from the Manager Console. _Avoid_: Agent badge, child badge @@ -143,6 +159,14 @@ _Avoid_: Project metadata, browser-only setting - A **Focus Chat Shortcut** targets the **Current Thread** - A **Logical Project Grouping** can combine multiple physical **Projects** into one sidebar presentation - A **Change Request Badge** belongs to a **Thread** when that Thread is linked to a Change Request +- A **Change Request Review** belongs to a **Thread** that is linked to a Change Request +- A **Change Request Review** contains many **Review Comments** +- A **Review Draft** belongs to a **Change Request Review** +- A **Review Comment** is anchored to a file, optionally a line, and a commit SHA +- A **Review Comment** can spawn a **Background Review Agent** +- A **Background Review Agent** runs in an isolated temp worktree +- A **Background Review Agent** pushes commits to the Change Request branch +- A **Change Request Review** is shared across all **Threads** linked to the same Change Request - A **Delegation Badge** belongs to a **Worker Thread** ## Example dialogue diff --git a/apps/server/src/todoPersistence.ts b/apps/server/src/todoPersistence.ts index a0c3e93d3dcf..6a0ee3e893c7 100644 --- a/apps/server/src/todoPersistence.ts +++ b/apps/server/src/todoPersistence.ts @@ -11,6 +11,7 @@ export const TODOS_ARCHIVE_PATH = T3CODE_DIR + "/todos-archive.json"; export interface TodosData { categories: TodoCategory[]; items: TodoItem[]; + jiraBaseUrl?: string; } export const readTodos = Effect.gen(function* () { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 57a7b29285ad..8c9812eb2be2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1013,15 +1013,27 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => Effect.gen(function* () { const current = yield* readTodos; const next = applyMutations( - { categories: current.categories, items: current.items }, + { + categories: current.categories, + items: current.items, + jiraBaseUrl: current.jiraBaseUrl, + }, input.mutations, ); const { state: deduped, archived } = archiveDoneItems(next); if (archived.length > 0) { yield* appendToArchive(archived); } - yield* writeTodos({ categories: deduped.categories, items: deduped.items }); - return { categories: deduped.categories, items: deduped.items }; + yield* writeTodos({ + categories: deduped.categories, + items: deduped.items, + jiraBaseUrl: deduped.jiraBaseUrl, + }); + return { + categories: deduped.categories, + items: deduped.items, + jiraBaseUrl: deduped.jiraBaseUrl, + }; }).pipe( Effect.mapError( (cause) => diff --git a/apps/web/src/components/TodoPanel.tsx b/apps/web/src/components/TodoPanel.tsx index 8b2c3dd53921..9fa13def6075 100644 --- a/apps/web/src/components/TodoPanel.tsx +++ b/apps/web/src/components/TodoPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { @@ -24,6 +24,7 @@ import { EyeOffIcon, GripVerticalIcon, PlusIcon, + SettingsIcon, } from "lucide-react"; import type { TodoCategory, TodoItem, ContextMenuItem, TodoMutation } from "@t3tools/contracts"; import { ensureLocalApi } from "~/localApi"; @@ -43,11 +44,56 @@ const COLOR_PALETTE = [ "#6366f1", ]; +function priorityBgColor(priority: string): string { + switch (priority) { + case "high": + return "#ef444420"; + case "medium": + return "#f59e0b20"; + case "low": + return "#3b82f620"; + default: + return "transparent"; + } +} + +function priorityFgColor(priority: string): string { + switch (priority) { + case "high": + return "#ef4444"; + case "medium": + return "#f59e0b"; + case "low": + return "#3b82f6"; + default: + return "inherit"; + } +} + function extractJiraKey(url: string): string | null { const match = url.match(/([A-Z]+-\d+)$/); return match ? (match[1] ?? null) : null; } +function isJiraKey(text: string): boolean { + return /^[A-Z]+-\d+$/.test(text.trim()); +} + +function resolveJiraUrl(input: string, baseUrl: string | undefined): string { + const trimmed = input.trim(); + if (!trimmed) return ""; + if (isJiraKey(trimmed) && baseUrl) { + const base = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; + return base + trimmed; + } + if (/^https?:\/\//.test(trimmed)) return trimmed; + if (baseUrl) { + const base = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; + return base + trimmed; + } + return trimmed; +} + function countActiveItems(items: TodoItem[], categoryId: string): number { return items.filter( (item) => @@ -56,7 +102,7 @@ function countActiveItems(items: TodoItem[], categoryId: string): number { } export function TodoPanel() { - const { categories, items, loading, error, mutate } = useTodos(); + const { categories, items, jiraBaseUrl, loading, error, mutate } = useTodos(); const [hideEmpty, setHideEmpty] = useState(false); const [draggedItem, setDraggedItem] = useState(null); const [draggedCategory, setDraggedCategory] = useState(null); @@ -215,6 +261,21 @@ export function TodoPanel() { Todos
+ - onSelectItem(item.id)}> - {item.title} + + {editing ? ( + setEditValue(e.target.value)} + onKeyDown={handleEditKeyDown} + onBlur={commitEdit} + autoFocus + /> + ) : ( + item.title + )} + {item.priority && ( + + {item.priority} + + )} {jiraKey && ( Promise; onCycleItem: (itemId: string) => void; onSelectItem: (itemId: string) => void; }) { const [collapsed, setCollapsed] = useState(category.collapsed === true); + + useEffect(() => { + setCollapsed(category.collapsed === true); + }, [category.collapsed]); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(category.name); const [adding, setAdding] = useState(false); @@ -619,12 +765,12 @@ function TodoCategoryRow({ const color = clicked.slice("color:".length); mutate([{ type: "setCategoryColor", categoryId: category.id, color }]); } else if (clicked === "jira") { - const url = window.prompt("Enter JIRA issue URL:", category.jiraLink ?? ""); + const url = window.prompt("Enter JIRA issue URL or key:", category.jiraLink ?? ""); if (url !== null) { - const trimmed = url.trim(); - if (trimmed) { + const resolved = resolveJiraUrl(url, jiraBaseUrl); + if (resolved) { mutate([ - { type: "setCategoryJiraLink", categoryId: category.id, jiraLink: trimmed }, + { type: "setCategoryJiraLink", categoryId: category.id, jiraLink: resolved }, ]); } } @@ -657,7 +803,10 @@ function TodoCategoryRow({ setCollapsed((prev) => !prev)} + onToggle={() => { + setCollapsed((prev) => !prev); + mutate([{ type: "toggleCategory", categoryId: category.id }]); + }} renaming={renaming} renameValue={renameValue} onRenameChange={setRenameValue} @@ -693,6 +842,7 @@ function TodoCategoryRow({ item={item} categoryColor={category.color} categoryJiraLink={category.jiraLink} + jiraBaseUrl={jiraBaseUrl} onCycleItem={onCycleItem} onSelectItem={onSelectItem} mutate={mutate} @@ -777,7 +927,7 @@ function ItemDetailPanel({
) : item.description ? (
{item.description} diff --git a/apps/web/src/hooks/useTodos.ts b/apps/web/src/hooks/useTodos.ts index f64cabd0b510..64055a67e096 100644 --- a/apps/web/src/hooks/useTodos.ts +++ b/apps/web/src/hooks/useTodos.ts @@ -5,6 +5,7 @@ import { ensureLocalApi } from "~/localApi"; export interface UseTodosResult { categories: TodoCategory[]; items: TodoItem[]; + jiraBaseUrl: string | undefined; loading: boolean; error: string | null; reload: () => void; @@ -14,6 +15,7 @@ export interface UseTodosResult { export function useTodos(): UseTodosResult { const [categories, setCategories] = useState([]); const [items, setItems] = useState([]); + const [jiraBaseUrl, setJiraBaseUrl] = useState(undefined); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const mountedRef = useRef(true); @@ -27,12 +29,14 @@ export function useTodos(): UseTodosResult { if (!mountedRef.current) return; setCategories([...result.categories]); setItems([...result.items]); + setJiraBaseUrl(result.jiraBaseUrl ?? undefined); setError(null); }) .catch((err: unknown) => { if (!mountedRef.current) return; setCategories([]); setItems([]); + setJiraBaseUrl(undefined); setError(err instanceof Error ? err.message : "Failed to load todos"); }) .finally(() => { @@ -46,6 +50,7 @@ export function useTodos(): UseTodosResult { if (!mountedRef.current) return; setCategories([...result.categories]); setItems([...result.items]); + setJiraBaseUrl(result.jiraBaseUrl ?? undefined); }, []); useEffect(() => { @@ -56,5 +61,5 @@ export function useTodos(): UseTodosResult { }; }, [load]); - return { categories, items, loading, error, reload: load, mutate }; + return { categories, items, jiraBaseUrl, loading, error, reload: load, mutate }; } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 0f0417d438f7..f3496dea0ace 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -270,6 +270,137 @@ label:has(> select#reasoning-effort) select { height: 100%; } +/* Todo description markdown rendering */ +.todo-markdown h1, +.todo-markdown h2, +.todo-markdown h3, +.todo-markdown h4, +.todo-markdown h5, +.todo-markdown h6 { + font-weight: 600; + line-height: 1.3; + margin: 0.65rem 0 0.25rem; +} + +.todo-markdown h1 { + font-size: 1.1rem; +} + +.todo-markdown h2 { + font-size: 1rem; +} + +.todo-markdown h3 { + font-size: 0.9rem; +} + +.todo-markdown h4, +.todo-markdown h5, +.todo-markdown h6 { + font-size: 0.85rem; +} + +.todo-markdown > :first-child { + margin-top: 0; +} + +.todo-markdown > :last-child { + margin-bottom: 0; +} + +.todo-markdown p { + margin: 0.35rem 0; +} + +.todo-markdown ul, +.todo-markdown ol { + padding-left: 1.1rem; + margin: 0.35rem 0; +} + +.todo-markdown ul { + list-style-type: disc; +} + +.todo-markdown ol { + list-style-type: decimal; +} + +.todo-markdown li + li { + margin-top: 0.15rem; +} + +.todo-markdown a { + color: var(--info-foreground); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--info-foreground) 40%, transparent); +} + +.todo-markdown a:hover { + opacity: 0.8; +} + +.todo-markdown blockquote { + border-left: 2px solid var(--border); + padding-left: 0.6rem; + margin: 0.35rem 0; + color: var(--muted-foreground); +} + +.todo-markdown :not(pre) > code { + border: 1px solid var(--border); + border-radius: 0.25rem; + background: var(--muted); + padding: 0.05rem 0.25rem; + color: var(--foreground); + font-size: 0.7rem; +} + +.todo-markdown pre { + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--muted); + padding: 0.5rem 0.6rem; + margin: 0.35rem 0; +} + +.todo-markdown pre code { + border: none; + background: transparent; + padding: 0; + font-size: 0.7rem; +} + +.todo-markdown hr { + border: none; + border-top: 1px solid var(--border); + margin: 0.5rem 0; +} + +.todo-markdown table { + width: 100%; + border-collapse: collapse; + font-size: 0.7rem; +} + +.todo-markdown th, +.todo-markdown td { + border: 1px solid var(--border); + padding: 0.2rem 0.35rem; + text-align: left; +} + +.todo-markdown th { + background: var(--muted); + font-weight: 600; +} + +.todo-markdown input[type="checkbox"] { + margin-right: 0.25rem; +} + /* Chat markdown rendering */ .chat-markdown { min-width: 0; diff --git a/docs/adr/0003-background-review-agent-worktrees.md b/docs/adr/0003-background-review-agent-worktrees.md new file mode 100644 index 000000000000..e16e72e46780 --- /dev/null +++ b/docs/adr/0003-background-review-agent-worktrees.md @@ -0,0 +1,18 @@ +# Background Review Agent — per-agent temp worktrees + +When a user requests an AI response on a Review Comment within a Change Request Review, a Background Review Agent is spawned. Multiple agents may run concurrently (one per comment). We chose to isolate each agent in its own temporary worktree rather than running them sequentially or sharing the main worktree. + +## Decisions + +- **Per-agent temp worktrees.** Each Background Review Agent gets a short-lived worktree created off the Change Request branch. Agents run in parallel without stepping on each other's working directories. +- **Direct push to PR branch.** Each agent commits and pushes to the Change Request branch as soon as its work is complete, rather than accumulating changes locally. This keeps the PR in sync with the local state. +- **Isolated branches.** Each agent pushes to the Change Request branch directly. If two agents edit the same file, Git resolves the merge on push as it would for any concurrent contributor. + +## Considered Options + +- **Sequential queue (one agent at a time):** Rejected — while simpler, it blocks the user from requesting AI responses on multiple comments simultaneously, defeating the interactive review experience. +- **Shared worktree with git branches:** Rejected — two agents cannot share a single worktree directory and switch branches independently without corrupting each other's working state. + +## Status + +proposed diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 1cba0a6f3c10..18a0564a59f4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -98,7 +98,7 @@ import { SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; -import { TodoCategory, TodoItem } from "./todos.ts"; +import { TodoCategory, TodoItem, TodoItemPriority } from "./todos.ts"; import { VcsError } from "./vcs.ts"; export const WS_METHODS = { @@ -420,6 +420,7 @@ export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { export const TodosLoadResult = Schema.Struct({ categories: Schema.Array(TodoCategory), items: Schema.Array(TodoItem), + jiraBaseUrl: Schema.optional(Schema.String), }); export type TodosLoadResult = typeof TodosLoadResult.Type; @@ -445,12 +446,16 @@ export const TodoMutationType = Schema.Literals([ "setCategoryColor", "setCategoryJiraLink", "deleteCategory", + "toggleCategory", "createItem", "cycleItemStatus", "reorderItems", "reorderCategories", "updateItemDescription", "setItemJiraLink", + "renameItem", + "setItemPriority", + "setJiraBaseUrl", "deleteItem", ]); export type TodoMutationType = typeof TodoMutationType.Type; @@ -519,23 +524,49 @@ export const SetItemJiraLinkMutation = Schema.Struct({ jiraLink: Schema.String, }); +export const SetJiraBaseUrlMutation = Schema.Struct({ + type: Schema.Literal("setJiraBaseUrl"), + jiraBaseUrl: Schema.String, +}); + +export const RenameItemMutation = Schema.Struct({ + type: Schema.Literal("renameItem"), + itemId: Schema.String, + title: TrimmedNonEmptyString, +}); + +export const SetItemPriorityMutation = Schema.Struct({ + type: Schema.Literal("setItemPriority"), + itemId: Schema.String, + priority: TodoItemPriority, +}); + export const DeleteItemMutation = Schema.Struct({ type: Schema.Literal("deleteItem"), itemId: Schema.String, }); +export const ToggleCategoryMutation = Schema.Struct({ + type: Schema.Literal("toggleCategory"), + categoryId: Schema.String, +}); + export const TodoMutation = Schema.Union([ CreateCategoryMutation, RenameCategoryMutation, SetCategoryColorMutation, SetCategoryJiraLinkMutation, DeleteCategoryMutation, + ToggleCategoryMutation, CreateItemMutation, CycleItemStatusMutation, ReorderItemsMutation, ReorderCategoriesMutation, UpdateItemDescriptionMutation, SetItemJiraLinkMutation, + RenameItemMutation, + SetItemPriorityMutation, + SetJiraBaseUrlMutation, DeleteItemMutation, ]); export type TodoMutation = typeof TodoMutation.Type; diff --git a/packages/contracts/src/todos.test.ts b/packages/contracts/src/todos.test.ts index 6206b3b0f3da..474e7200c111 100644 --- a/packages/contracts/src/todos.test.ts +++ b/packages/contracts/src/todos.test.ts @@ -10,6 +10,7 @@ import { RenameCategoryMutation, SetCategoryColorMutation, SetCategoryJiraLinkMutation, + SetJiraBaseUrlMutation, TodoMutation, TodosMutateInput, } from "./rpc.ts"; @@ -242,6 +243,17 @@ it.effect("discriminates mutation types in TodoMutation union", () => }), ); +it.effect("parses a valid SetJiraBaseUrlMutation", () => + Effect.gen(function* () { + const parsed = yield* decode(SetJiraBaseUrlMutation, { + type: "setJiraBaseUrl", + jiraBaseUrl: "https://company.atlassian.net/browse", + }); + assert.strictEqual(parsed.type, "setJiraBaseUrl"); + assert.strictEqual(parsed.jiraBaseUrl, "https://company.atlassian.net/browse"); + }), +); + it.effect("rejects invalid mutation type", () => Effect.gen(function* () { const result = yield* Effect.exit( diff --git a/packages/contracts/src/todos.ts b/packages/contracts/src/todos.ts index a5f2de6d0ca4..e20adfa2738f 100644 --- a/packages/contracts/src/todos.ts +++ b/packages/contracts/src/todos.ts @@ -4,6 +4,9 @@ import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const TodoItemStatus = Schema.Literals(["todo", "in_progress", "done"]); export type TodoItemStatus = typeof TodoItemStatus.Type; +export const TodoItemPriority = Schema.Literals(["low", "medium", "high"]); +export type TodoItemPriority = typeof TodoItemPriority.Type; + export const TodoCategory = Schema.Struct({ id: TrimmedNonEmptyString, name: TrimmedNonEmptyString, @@ -21,6 +24,7 @@ export const TodoItem = Schema.Struct({ title: TrimmedNonEmptyString, description: Schema.optional(Schema.String), status: TodoItemStatus, + priority: Schema.optional(TodoItemPriority), jiraLink: Schema.optional(Schema.String), sortOrder: Schema.Number, createdAt: Schema.String, diff --git a/packages/shared/src/todoStore.test.ts b/packages/shared/src/todoStore.test.ts index 08af70719722..b7012c446194 100644 --- a/packages/shared/src/todoStore.test.ts +++ b/packages/shared/src/todoStore.test.ts @@ -12,10 +12,13 @@ import { deleteItem, loadTodos, renameCategory, + renameItem, reorderCategories, reorderItems, setCategoryColor, setCategoryJiraLink, + setItemPriority, + setJiraBaseUrl, setItemJiraLink, toggleCategory, updateDescription, @@ -266,6 +269,22 @@ describe("todoStore", () => { expect(palette).toContain(next.categories[0]!.color); }); + it("creates a category with a custom name", () => { + const state = loadTodos([], []); + const next = createCategory(state, "Custom Cat", "#123456"); + + expect(next.categories[0]!.name).toBe("Custom Cat"); + expect(next.categories[0]!.color).toBe("#123456"); + }); + + it("rejects empty name and returns unchanged state", () => { + const state = loadTodos([], []); + const next = createCategory(state, " "); + + expect(next).toBe(state); + expect(next.categories).toHaveLength(0); + }); + it("does not mutate the original state", () => { const state = loadTodos(sampleCategories, sampleItems); const next = createCategory(state); @@ -317,6 +336,14 @@ describe("todoStore", () => { expect(next.categories).toEqual(state.categories); }); + + it("rejects empty name and returns unchanged state", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = renameCategory(state, "cat-1", " "); + + expect(next).toBe(state); + expect(next.categories[0]!.name).toBe("Backend"); + }); }); describe("setCategoryColor", () => { @@ -435,10 +462,11 @@ describe("todoStore", () => { describe("applyMutation", () => { it("applies createCategory mutation", () => { const state = loadTodos([], []); - const next = applyMutation(state, { type: "createCategory", name: "Test", color: "#123" }); + const next = applyMutation(state, { type: "createCategory", name: "Custom", color: "#abc" }); expect(next.categories).toHaveLength(1); - expect(next.categories[0]!.name).toBe("New Category"); + expect(next.categories[0]!.name).toBe("Custom"); + expect(next.categories[0]!.color).toBe("#abc"); }); it("applies renameCategory mutation", () => { @@ -499,6 +527,15 @@ describe("todoStore", () => { expect(next.categories).toHaveLength(3); }); + it("applies toggleCategory mutation", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = applyMutation(state, { type: "toggleCategory", categoryId: "cat-1" }); + + expect(next.categories[0]!.collapsed).toBe(true); + const next2 = applyMutation(next, { type: "toggleCategory", categoryId: "cat-1" }); + expect(next2.categories[0]!.collapsed).toBe(false); + }); + it("applies createItem mutation", () => { const state = loadTodos(sampleCategories, sampleItems); const next = applyMutation(state, { @@ -523,8 +560,8 @@ describe("todoStore", () => { ]); expect(next.categories).toHaveLength(2); - expect(next.categories[0]!.name).toBe("New Category"); - expect(next.categories[1]!.name).toBe("New Category"); + expect(next.categories[0]!.name).toBe("A"); + expect(next.categories[1]!.name).toBe("B"); }); it("chains mutation results", () => { @@ -951,6 +988,168 @@ describe("todoStore", () => { }); }); + describe("setJiraBaseUrl", () => { + it("sets a JIRA base URL on the state", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = setJiraBaseUrl(state, "https://company.atlassian.net/browse"); + + expect(next.jiraBaseUrl).toBe("https://company.atlassian.net/browse"); + }); + + it("clears the JIRA base URL when given empty string", () => { + const state = loadTodos(sampleCategories, sampleItems); + const withUrl = setJiraBaseUrl(state, "https://company.atlassian.net/browse"); + const withoutUrl = setJiraBaseUrl(withUrl, ""); + + expect(withoutUrl.jiraBaseUrl).toBeUndefined(); + }); + + it("returns unchanged categories and items", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = setJiraBaseUrl(state, "https://company.atlassian.net/browse"); + + expect(next.categories).toEqual(state.categories); + expect(next.items).toEqual(state.items); + }); + + it("does not mutate the original state", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = setJiraBaseUrl(state, "https://company.atlassian.net/browse"); + + expect(next).not.toBe(state); + expect(state.jiraBaseUrl).toBeUndefined(); + }); + + it("applies via applyMutation", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = applyMutation(state, { + type: "setJiraBaseUrl", + jiraBaseUrl: "https://company.atlassian.net/browse", + }); + + expect(next.jiraBaseUrl).toBe("https://company.atlassian.net/browse"); + }); + }); + + describe("renameItem", () => { + it("renames an item title", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = renameItem(state, "item-1", "Updated title"); + + expect(next.items[0]!.title).toBe("Updated title"); + }); + + it("rejects empty title and returns unchanged state", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = renameItem(state, "item-1", ""); + + expect(next).toBe(state); + expect(next.items[0]!.title).toBe("Fix auth bug"); + }); + + it("rejects whitespace-only title and returns unchanged state", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = renameItem(state, "item-1", " "); + + expect(next).toBe(state); + expect(next.items[0]!.title).toBe("Fix auth bug"); + }); + + it("updates updatedAt on rename", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = renameItem(state, "item-1", "New title"); + + expect(next.items[0]!.updatedAt).not.toBe(sampleItems[0]!.updatedAt); + }); + + it("does not mutate the original state", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = renameItem(state, "item-1", "New title"); + + expect(next).not.toBe(state); + expect(next.items).not.toBe(state.items); + expect(state.items[0]!.title).toBe("Fix auth bug"); + }); + + it("does not affect other items", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = renameItem(state, "item-1", "New title"); + + expect(next.items[1]!.title).toBe("Redesign header"); + expect(next.items[1]).toBe(state.items[1]); + }); + + it("returns unchanged state for unknown item ID", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = renameItem(state, "nonexistent", "Test"); + + expect(next.items).toEqual(state.items); + }); + + it("applies via applyMutation", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = applyMutation(state, { + type: "renameItem", + itemId: "item-1", + title: "Renamed via mutation", + }); + + expect(next.items[0]!.title).toBe("Renamed via mutation"); + }); + }); + + describe("setItemPriority", () => { + it("sets priority on an item that had none", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = setItemPriority(state, "item-1", "high"); + + expect(next.items[0]!.priority).toBe("high"); + expect(next.items[0]!.updatedAt).not.toBe(sampleItems[0]!.updatedAt); + }); + + it("updates priority on an item that already had one", () => { + const itemWithPriority: TodoItem = { ...sampleItems[0]!, priority: "low" }; + const state = loadTodos(sampleCategories, [itemWithPriority, sampleItems[1]!]); + const next = setItemPriority(state, "item-1", "medium"); + + expect(next.items[0]!.priority).toBe("medium"); + }); + + it("does not mutate the original state", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = setItemPriority(state, "item-1", "high"); + + expect(next).not.toBe(state); + expect(state.items[0]!.priority).toBeUndefined(); + }); + + it("returns unchanged state for unknown item ID", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = setItemPriority(state, "nonexistent", "low"); + + expect(next.items).toEqual(state.items); + }); + + it("does not affect other items", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = setItemPriority(state, "item-1", "high"); + + expect(next.items[1]!.priority).toBeUndefined(); + expect(next.items[1]).toBe(state.items[1]); + }); + + it("applies via applyMutation", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = applyMutation(state, { + type: "setItemPriority", + itemId: "item-1", + priority: "medium", + }); + + expect(next.items[0]!.priority).toBe("medium"); + }); + }); + describe("applyMutation with setItemJiraLink and deleteItem", () => { it("applies setItemJiraLink mutation", () => { const state = loadTodos(sampleCategories, sampleItems); diff --git a/packages/shared/src/todoStore.ts b/packages/shared/src/todoStore.ts index 6abbecc74903..16642272853d 100644 --- a/packages/shared/src/todoStore.ts +++ b/packages/shared/src/todoStore.ts @@ -1,12 +1,17 @@ -import type { TodoCategory, TodoItem, TodoMutation } from "@t3tools/contracts"; +import type { TodoCategory, TodoItem, TodoItemPriority, TodoMutation } from "@t3tools/contracts"; export interface TodoState { categories: TodoCategory[]; items: TodoItem[]; + jiraBaseUrl: string | undefined; } -export function loadTodos(categories: TodoCategory[], items: TodoItem[]): TodoState { - return { categories, items }; +export function loadTodos( + categories: TodoCategory[], + items: TodoItem[], + jiraBaseUrl: string | undefined = undefined, +): TodoState { + return { categories, items, jiraBaseUrl }; } export function toggleCategory(state: TodoState, categoryId: string): TodoState { @@ -53,11 +58,13 @@ export function reorderItems( }; } -export function createCategory(state: TodoState): TodoState { +export function createCategory(state: TodoState, name = "New Category", color?: string): TodoState { + const trimmedName = name.trim(); + if (!trimmedName) return state; const category: TodoCategory = { id: crypto.randomUUID(), - name: "New Category", - color: getRandomColor(), + name: trimmedName, + color: color?.trim() || getRandomColor(), // @effect-diagnostics-next-line globalDate:off createdAt: new Date().toISOString(), }; @@ -65,6 +72,7 @@ export function createCategory(state: TodoState): TodoState { } export function renameCategory(state: TodoState, categoryId: string, name: string): TodoState { + if (!name.trim()) return state; return { ...state, categories: state.categories.map((c) => (c.id === categoryId ? { ...c, name } : c)), @@ -137,6 +145,51 @@ export function createItem(state: TodoState, categoryId: string, title: string): }; } +export function setJiraBaseUrl(state: TodoState, jiraBaseUrl: string): TodoState { + const value = jiraBaseUrl.trim(); + return { + ...state, + ...(value ? { jiraBaseUrl: value } : { jiraBaseUrl: undefined }), + }; +} + +export function renameItem(state: TodoState, itemId: string, title: string): TodoState { + if (!title.trim()) return state; + return { + ...state, + items: state.items.map((item) => + item.id === itemId + ? { + ...item, + title, + // @effect-diagnostics-next-line globalDate:off + updatedAt: new Date().toISOString(), + } + : item, + ), + }; +} + +export function setItemPriority( + state: TodoState, + itemId: string, + priority: TodoItemPriority, +): TodoState { + return { + ...state, + items: state.items.map((item) => + item.id === itemId + ? { + ...item, + priority, + // @effect-diagnostics-next-line globalDate:off + updatedAt: new Date().toISOString(), + } + : item, + ), + }; +} + function getRandomColor(): string { const colors = [ "#3b82f6", @@ -155,7 +208,7 @@ function getRandomColor(): string { export function applyMutation(state: TodoState, mutation: TodoMutation): TodoState { switch (mutation.type) { case "createCategory": - return createCategory(state); + return createCategory(state, mutation.name, mutation.color); case "renameCategory": return renameCategory(state, mutation.categoryId, mutation.name); case "setCategoryColor": @@ -164,6 +217,8 @@ export function applyMutation(state: TodoState, mutation: TodoMutation): TodoSta return setCategoryJiraLink(state, mutation.categoryId, mutation.jiraLink); case "deleteCategory": return deleteCategory(state, mutation.categoryId).state; + case "toggleCategory": + return toggleCategory(state, mutation.categoryId); case "createItem": return createItem(state, mutation.categoryId, mutation.title); case "cycleItemStatus": @@ -176,6 +231,12 @@ export function applyMutation(state: TodoState, mutation: TodoMutation): TodoSta return updateDescription(state, mutation.itemId, mutation.description); case "setItemJiraLink": return setItemJiraLink(state, mutation.itemId, mutation.jiraLink); + case "renameItem": + return renameItem(state, mutation.itemId, mutation.title); + case "setItemPriority": + return setItemPriority(state, mutation.itemId, mutation.priority); + case "setJiraBaseUrl": + return setJiraBaseUrl(state, mutation.jiraBaseUrl); case "deleteItem": return deleteItem(state, mutation.itemId); default: