From 42a537a96798cd62dc572d957fac35975feea4fd Mon Sep 17 00:00:00 2001 From: Oliver Bryan <04oliverbryan@gmail.com> Date: Tue, 20 Jan 2026 23:13:12 +0000 Subject: [PATCH] timers should not be used on issues with multiple assignees --- bun.lock | 5 ++++ packages/backend/src/db/queries/issues.ts | 8 +++++++ packages/backend/src/routes/timer/toggle.ts | 15 +++++++++++- packages/frontend/package.json | 1 + .../src/components/issue-detail-pane.tsx | 24 ++++++++++++++----- .../frontend/src/components/timer-modal.tsx | 10 +++++--- 6 files changed, 53 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index 29a08bb..ff65780 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@sprint/shared": "workspace:*", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.19", @@ -318,6 +319,8 @@ "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], @@ -800,6 +803,8 @@ "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], diff --git a/packages/backend/src/db/queries/issues.ts b/packages/backend/src/db/queries/issues.ts index decc043..ebef156 100644 --- a/packages/backend/src/db/queries/issues.ts +++ b/packages/backend/src/db/queries/issues.ts @@ -182,3 +182,11 @@ export async function getIssuesWithUsersByProject(projectId: number): Promise { + const [result] = await db + .select({ count: sql`COUNT(*)` }) + .from(IssueAssignee) + .where(eq(IssueAssignee.issueId, issueId)); + return result?.count ?? 0; +} diff --git a/packages/backend/src/routes/timer/toggle.ts b/packages/backend/src/routes/timer/toggle.ts index af154be..bf852b0 100644 --- a/packages/backend/src/routes/timer/toggle.ts +++ b/packages/backend/src/routes/timer/toggle.ts @@ -5,7 +5,12 @@ import { TimerToggleRequestSchema, } from "@sprint/shared"; import type { AuthedRequest } from "../../auth/middleware"; -import { appendTimestamp, createTimedSession, getActiveTimedSession } from "../../db/queries"; +import { + appendTimestamp, + createTimedSession, + getActiveTimedSession, + getIssueAssigneeCount, +} from "../../db/queries"; import { parseJsonBody } from "../../validation"; export default async function timerToggle(req: AuthedRequest) { @@ -14,6 +19,14 @@ export default async function timerToggle(req: AuthedRequest) { const { issueId } = parsed.data; + const assigneeCount = await getIssueAssigneeCount(issueId); + if (assigneeCount > 1) { + return Response.json( + { error: "Timers cannot be used on issues with multiple assignees", code: "MULTIPLE_ASSIGNEES" }, + { status: 400 }, + ); + } + const activeSession = await getActiveTimedSession(req.userId, issueId); if (!activeSession) { diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 838c118..5438a2b 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@sprint/shared": "workspace:*", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.19", diff --git a/packages/frontend/src/components/issue-detail-pane.tsx b/packages/frontend/src/components/issue-detail-pane.tsx index c52ffbc..3b3e2d2 100644 --- a/packages/frontend/src/components/issue-detail-pane.tsx +++ b/packages/frontend/src/components/issue-detail-pane.tsx @@ -69,6 +69,10 @@ export function IssueDetailPane() { const [isSavingDescription, setIsSavingDescription] = useState(false); const descriptionRef = useRef(null); + const isAssignee = assigneeIds.some((id) => user?.id === Number(id)); + const actualAssigneeIds = assigneeIds.filter((id) => id !== "unassigned"); + const hasMultipleAssignees = actualAssigneeIds.length > 1; + useEffect(() => { if (!issueData) return; setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned"); @@ -421,12 +425,20 @@ export function IssueDetailPane() { -
- {assigneeIds.some((id) => user?.id === Number(id)) && ( - - )} - -
+ {isAssignee && ( +
+
+ + +
+ {hasMultipleAssignees && ( + + Timers cannot be used on issues with multiple assignees + + )} +
+ )} + - -