switched issue and timer flows to mutations

This commit is contained in:
Oliver Bryan
2026-01-20 17:04:24 +00:00
parent 83ccc64e84
commit 06bac090a2
4 changed files with 305 additions and 367 deletions

View File

@@ -1,9 +1,11 @@
import type { IssueResponse, ProjectResponse, SprintRecord, UserRecord } from "@sprint/shared"; import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { MultiAssigneeSelect } from "@/components/multi-assignee-select"; import { MultiAssigneeSelect } from "@/components/multi-assignee-select";
import { useSelection } from "@/components/selection-provider";
import { useSession } from "@/components/session-provider"; import { useSession } from "@/components/session-provider";
import SmallSprintDisplay from "@/components/small-sprint-display";
import SmallUserDisplay from "@/components/small-user-display"; import SmallUserDisplay from "@/components/small-user-display";
import { SprintSelect } from "@/components/sprint-select";
import { StatusSelect } from "@/components/status-select"; import { StatusSelect } from "@/components/status-select";
import StatusTag from "@/components/status-tag"; import StatusTag from "@/components/status-tag";
import { TimerDisplay } from "@/components/timer-display"; import { TimerDisplay } from "@/components/timer-display";
@@ -11,16 +13,23 @@ import { TimerModal } from "@/components/timer-modal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import Icon from "@/components/ui/icon"; import Icon from "@/components/ui/icon";
import { IconButton } from "@/components/ui/icon-button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { SelectTrigger } from "@/components/ui/select"; import { SelectTrigger } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { issue } from "@/lib/server"; import {
useDeleteIssue,
useOrganisationMembers,
useSelectedIssue,
useSelectedOrganisation,
useSelectedProject,
useSprints,
useUpdateIssue,
} from "@/lib/query/hooks";
import { parseError } from "@/lib/server";
import { cn, issueID } from "@/lib/utils"; import { cn, issueID } from "@/lib/utils";
import SmallSprintDisplay from "./small-sprint-display";
import { SprintSelect } from "./sprint-select";
import { IconButton } from "./ui/icon-button";
function assigneesToStringArray(assignees: UserRecord[]): string[] { function assigneesToStringArray(assignees: { id: number }[]): string[] {
if (assignees.length === 0) return ["unassigned"]; if (assignees.length === 0) return ["unassigned"];
return assignees.map((a) => a.id.toString()); return assignees.map((a) => a.id.toString());
} }
@@ -29,44 +38,39 @@ function stringArrayToAssigneeIds(assigneeIds: string[]): number[] {
return assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id)); return assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id));
} }
export function IssueDetailPane({ export function IssueDetailPane() {
project,
sprints,
issueData,
members,
statuses,
close,
onIssueUpdate,
onIssueDelete,
}: {
project: ProjectResponse;
sprints: SprintRecord[];
issueData: IssueResponse;
members: UserRecord[];
statuses: Record<string, string>;
close: () => void;
onIssueUpdate?: () => void;
onIssueDelete?: (issueId: number) => void | Promise<void>;
}) {
const { user } = useSession(); const { user } = useSession();
const [assigneeIds, setAssigneeIds] = useState<string[]>(assigneesToStringArray(issueData.Assignees)); const { selectIssue } = useSelection();
const [sprintId, setSprintId] = useState<string>(issueData.Issue.sprintId?.toString() ?? "unassigned"); const selectedOrganisation = useSelectedOrganisation();
const [status, setStatus] = useState<string>(issueData.Issue.status); const selectedProject = useSelectedProject();
const issueData = useSelectedIssue();
const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
const updateIssue = useUpdateIssue();
const deleteIssue = useDeleteIssue();
const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
const [assigneeIds, setAssigneeIds] = useState<string[]>([]);
const [sprintId, setSprintId] = useState<string>("unassigned");
const [status, setStatus] = useState<string>("");
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [linkCopied, setLinkCopied] = useState(false); const [linkCopied, setLinkCopied] = useState(false);
const copyTimeoutRef = useRef<number | null>(null); const copyTimeoutRef = useRef<number | null>(null);
const [title, setTitle] = useState(issueData.Issue.title); const [title, setTitle] = useState("");
const [originalTitle, setOriginalTitle] = useState(issueData.Issue.title); const [originalTitle, setOriginalTitle] = useState("");
const [isSavingTitle, setIsSavingTitle] = useState(false); const [isSavingTitle, setIsSavingTitle] = useState(false);
const [description, setDescription] = useState(issueData.Issue.description); const [description, setDescription] = useState("");
const [originalDescription, setOriginalDescription] = useState(issueData.Issue.description); const [originalDescription, setOriginalDescription] = useState("");
const [isEditingDescription, setIsEditingDescription] = useState(false); const [isEditingDescription, setIsEditingDescription] = useState(false);
const [isSavingDescription, setIsSavingDescription] = useState(false); const [isSavingDescription, setIsSavingDescription] = useState(false);
const descriptionRef = useRef<HTMLTextAreaElement>(null); const descriptionRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => { useEffect(() => {
if (!issueData) return;
setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned"); setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned");
setAssigneeIds(assigneesToStringArray(issueData.Assignees)); setAssigneeIds(assigneesToStringArray(issueData.Assignees));
setStatus(issueData.Issue.status); setStatus(issueData.Issue.status);
@@ -85,51 +89,51 @@ export function IssueDetailPane({
}; };
}, []); }, []);
if (!issueData || !selectedProject || !selectedOrganisation) {
return null;
}
const handleSprintChange = async (value: string) => { const handleSprintChange = async (value: string) => {
setSprintId(value); setSprintId(value);
const newSprintId = value === "unassigned" ? null : Number(value); const newSprintId = value === "unassigned" ? null : Number(value);
await issue.update({ try {
issueId: issueData.Issue.id, await updateIssue.mutateAsync({
sprintId: newSprintId, id: issueData.Issue.id,
onSuccess: () => { sprintId: newSprintId,
onIssueUpdate?.(); });
toast.success(
toast.success( <>
<> Successfully updated sprint to{" "}
Successfully updated sprint to{" "} {value === "unassigned" ? (
{value === "unassigned" ? ( "Unassigned"
"Unassigned" ) : (
) : ( <SmallSprintDisplay sprint={sprints.find((s) => s.id === newSprintId)} />
<SmallSprintDisplay sprint={sprints.find((s) => s.id === newSprintId)} /> )}{" "}
)}{" "} for {issueID(selectedProject.Project.key, issueData.Issue.number)}
for {issueID(project.Project.key, issueData.Issue.number)} </>,
</>, {
{ dismissible: false,
dismissible: false, },
}, );
); } catch (error) {
}, console.error("error updating sprint:", error);
onError: (error) => { setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned");
console.error("error updating sprint:", error); toast.error(
setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned"); <>
Error updating sprint to{" "}
toast.error( {value === "unassigned" ? (
<> "Unassigned"
Error updating sprint to{" "} ) : (
{value === "unassigned" ? ( <SmallSprintDisplay sprint={sprints.find((s) => s.id === newSprintId)} />
"Unassigned" )}{" "}
) : ( for {issueID(selectedProject.Project.key, issueData.Issue.number)}
<SmallSprintDisplay sprint={sprints.find((s) => s.id === newSprintId)} /> </>,
)}{" "} {
for {issueID(project.Project.key, issueData.Issue.number)} dismissible: false,
</>, },
{ );
dismissible: false, }
},
);
},
});
}; };
const handleAssigneeChange = async (newAssigneeIds: string[]) => { const handleAssigneeChange = async (newAssigneeIds: string[]) => {
@@ -147,64 +151,58 @@ export function IssueDetailPane({
return; return;
} }
await issue.update({ try {
issueId: issueData.Issue.id, await updateIssue.mutateAsync({
assigneeIds: newAssigneeIdNumbers, id: issueData.Issue.id,
onSuccess: () => { assigneeIds: newAssigneeIdNumbers,
const assignedUsers = members.filter((m) => newAssigneeIdNumbers.includes(m.id)); });
const displayText = const assignedUsers = members.filter((member) => newAssigneeIdNumbers.includes(member.id));
assignedUsers.length === 0 const displayText =
? "Unassigned" assignedUsers.length === 0
: assignedUsers.length === 1 ? "Unassigned"
? assignedUsers[0].name : assignedUsers.length === 1
: `${assignedUsers.length} assignees`; ? assignedUsers[0].name
toast.success( : `${assignedUsers.length} assignees`;
<div className={"flex items-center gap-2"}> toast.success(
Updated assignees to {displayText} for{" "} <div className={"flex items-center gap-2"}>
{issueID(project.Project.key, issueData.Issue.number)} Updated assignees to {displayText} for{" "}
</div>, {issueID(selectedProject.Project.key, issueData.Issue.number)}
{ </div>,
dismissible: false, {
},
);
onIssueUpdate?.();
},
onError: (error) => {
console.error("error updating assignees:", error);
setAssigneeIds(previousAssigneeIds);
toast.error(`Error updating assignees: ${error}`, {
dismissible: false, dismissible: false,
}); },
}, );
}); } catch (error) {
console.error("error updating assignees:", error);
setAssigneeIds(previousAssigneeIds);
toast.error(`Error updating assignees: ${parseError(error as Error)}`, {
dismissible: false,
});
}
}; };
const handleStatusChange = async (value: string) => { const handleStatusChange = async (value: string) => {
setStatus(value); setStatus(value);
await issue.update({ try {
issueId: issueData.Issue.id, await updateIssue.mutateAsync({
status: value, id: issueData.Issue.id,
onSuccess: () => { status: value,
toast.success( });
<> toast.success(
{issueID(project.Project.key, issueData.Issue.number)}'s status updated to{" "} <>
<StatusTag status={value} colour={statuses[value]} /> {issueID(selectedProject.Project.key, issueData.Issue.number)}'s status updated to{" "}
</>, <StatusTag status={value} colour={statuses[value]} />
{ dismissible: false }, </>,
); { dismissible: false },
onIssueUpdate?.(); );
}, } catch (error) {
onError: (error) => { console.error("error updating status:", error);
console.error("error updating status:", error); setStatus(issueData.Issue.status);
setStatus(issueData.Issue.status); toast.error(`Error updating status: ${parseError(error as Error)}`, {
dismissible: false,
toast.error(`Error updating status: ${error}`, { });
dismissible: false, }
});
},
});
}; };
const handleDelete = () => { const handleDelete = () => {
@@ -235,21 +233,19 @@ export function IssueDetailPane({
} }
setIsSavingTitle(true); setIsSavingTitle(true);
await issue.update({ try {
issueId: issueData.Issue.id, await updateIssue.mutateAsync({
title: trimmedTitle, id: issueData.Issue.id,
onSuccess: () => { title: trimmedTitle,
setOriginalTitle(trimmedTitle); });
toast.success(`${issueID(project.Project.key, issueData.Issue.number)} Title updated`); setOriginalTitle(trimmedTitle);
onIssueUpdate?.(); toast.success(`${issueID(selectedProject.Project.key, issueData.Issue.number)} Title updated`);
setIsSavingTitle(false); } catch (error) {
}, console.error("error updating title:", error);
onError: (error) => { setTitle(originalTitle);
console.error("error updating title:", error); } finally {
setTitle(originalTitle); setIsSavingTitle(false);
setIsSavingTitle(false); }
},
});
}; };
const handleDescriptionSave = async () => { const handleDescriptionSave = async () => {
@@ -262,52 +258,50 @@ export function IssueDetailPane({
} }
setIsSavingDescription(true); setIsSavingDescription(true);
await issue.update({ try {
issueId: issueData.Issue.id, await updateIssue.mutateAsync({
description: trimmedDescription, id: issueData.Issue.id,
onSuccess: () => { description: trimmedDescription,
setOriginalDescription(trimmedDescription); });
setDescription(trimmedDescription); setOriginalDescription(trimmedDescription);
toast.success(`${issueID(project.Project.key, issueData.Issue.number)} Description updated`); setDescription(trimmedDescription);
onIssueUpdate?.(); toast.success(
setIsSavingDescription(false); `${issueID(selectedProject.Project.key, issueData.Issue.number)} Description updated`,
if (trimmedDescription === "") { );
setIsEditingDescription(false); if (trimmedDescription === "") {
} setIsEditingDescription(false);
}, }
onError: (error) => { } catch (error) {
console.error("error updating description:", error); console.error("error updating description:", error);
setDescription(originalDescription); setDescription(originalDescription);
setIsSavingDescription(false); } finally {
}, setIsSavingDescription(false);
}); }
}; };
const handleConfirmDelete = async () => { const handleConfirmDelete = async () => {
await issue.delete({ try {
issueId: issueData.Issue.id, await deleteIssue.mutateAsync(issueData.Issue.id);
onSuccess: async () => { selectIssue(null);
await onIssueDelete?.(issueData.Issue.id); toast.success(`Deleted issue ${issueID(selectedProject.Project.key, issueData.Issue.number)}`, {
dismissible: false,
toast.success(`Deleted issue ${issueID(project.Project.key, issueData.Issue.number)}`, { });
} catch (error) {
console.error(
`error deleting issue ${issueID(selectedProject.Project.key, issueData.Issue.number)}`,
error,
);
toast.error(
`Error deleting issue ${issueID(selectedProject.Project.key, issueData.Issue.number)}: ${parseError(
error as Error,
)}`,
{
dismissible: false, dismissible: false,
}); },
}, );
onError: (error) => { } finally {
console.error( setDeleteOpen(false);
`error deleting issue ${issueID(project.Project.key, issueData.Issue.number)}`, }
error,
);
toast.error(
`Error deleting issue ${issueID(project.Project.key, issueData.Issue.number)}: ${error}`,
{
dismissible: false,
},
);
},
});
setDeleteOpen(false);
}; };
return ( return (
@@ -315,7 +309,7 @@ export function IssueDetailPane({
<div className="flex flex-row items-center justify-end border-b h-[25px]"> <div className="flex flex-row items-center justify-end border-b h-[25px]">
<span className="w-full"> <span className="w-full">
<p className="text-sm w-fit px-1 font-700"> <p className="text-sm w-fit px-1 font-700">
{issueID(project.Project.key, issueData.Issue.number)} {issueID(selectedProject.Project.key, issueData.Issue.number)}
</p> </p>
</span> </span>
<div className="flex items-center"> <div className="flex items-center">
@@ -325,7 +319,7 @@ export function IssueDetailPane({
<IconButton variant="destructive" onClick={handleDelete} title={"Delete issue"}> <IconButton variant="destructive" onClick={handleDelete} title={"Delete issue"}>
<Icon icon="trash" /> <Icon icon="trash" />
</IconButton> </IconButton>
<IconButton onClick={close} title={"Close"}> <IconButton onClick={() => selectIssue(null)} title={"Close"}>
<Icon icon="x" /> <Icon icon="x" />
</IconButton> </IconButton>
</div> </div>
@@ -355,14 +349,14 @@ export function IssueDetailPane({
<div className="flex w-full items-center min-w-0"> <div className="flex w-full items-center min-w-0">
<Input <Input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(event) => setTitle(event.target.value)}
onBlur={handleTitleSave} onBlur={handleTitleSave}
onKeyDown={(e) => { onKeyDown={(event) => {
if (e.key === "Enter") { if (event.key === "Enter") {
e.currentTarget.blur(); event.currentTarget.blur();
} else if (e.key === "Escape") { } else if (event.key === "Escape") {
setTitle(originalTitle); setTitle(originalTitle);
e.currentTarget.blur(); event.currentTarget.blur();
} }
}} }}
disabled={isSavingTitle} disabled={isSavingTitle}
@@ -378,15 +372,15 @@ export function IssueDetailPane({
<Textarea <Textarea
ref={descriptionRef} ref={descriptionRef}
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(event) => setDescription(event.target.value)}
onBlur={handleDescriptionSave} onBlur={handleDescriptionSave}
onKeyDown={(e) => { onKeyDown={(event) => {
if (e.key === "Escape" || (e.ctrlKey && e.key === "Enter")) { if (event.key === "Escape" || (event.ctrlKey && event.key === "Enter")) {
setDescription(originalDescription); setDescription(originalDescription);
if (originalDescription === "") { if (originalDescription === "") {
setIsEditingDescription(false); setIsEditingDescription(false);
} }
e.currentTarget.blur(); event.currentTarget.blur();
} }
}} }}
placeholder="Add a description..." placeholder="Add a description..."

View File

@@ -1,14 +1,10 @@
import { import { ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_TITLE_MAX_LENGTH } from "@sprint/shared";
ISSUE_DESCRIPTION_MAX_LENGTH,
ISSUE_TITLE_MAX_LENGTH,
type SprintRecord,
type UserRecord,
} from "@sprint/shared";
import { type FormEvent, useState } from "react"; import { type FormEvent, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { MultiAssigneeSelect } from "@/components/multi-assignee-select"; import { MultiAssigneeSelect } from "@/components/multi-assignee-select";
import { useAuthenticatedSession } from "@/components/session-provider"; import { useAuthenticatedSession } from "@/components/session-provider";
import { SprintSelect } from "@/components/sprint-select";
import { StatusSelect } from "@/components/status-select"; import { StatusSelect } from "@/components/status-select";
import StatusTag from "@/components/status-tag"; import StatusTag from "@/components/status-tag";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -23,35 +19,35 @@ import {
import { Field } from "@/components/ui/field"; import { Field } from "@/components/ui/field";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { SelectTrigger } from "@/components/ui/select"; import { SelectTrigger } from "@/components/ui/select";
import { issue, parseError } from "@/lib/server"; import {
import { cn } from "@/lib/utils"; useCreateIssue,
import { SprintSelect } from "./sprint-select"; useOrganisationMembers,
useSelectedOrganisation,
useSelectedProject,
useSprints,
} from "@/lib/query/hooks";
import { parseError } from "@/lib/server";
import { cn, issueID } from "@/lib/utils";
export function IssueModal({ export function IssueModal({ trigger }: { trigger?: React.ReactNode }) {
projectId,
sprints,
members,
statuses,
trigger,
completeAction,
errorAction,
}: {
projectId?: number;
sprints?: SprintRecord[];
members?: UserRecord[];
statuses: Record<string, string>;
trigger?: React.ReactNode;
completeAction?: (issueNumber: number) => void | Promise<void>;
errorAction?: (errorMessage: string) => void | Promise<void>;
}) {
const { user } = useAuthenticatedSession(); const { user } = useAuthenticatedSession();
const selectedOrganisation = useSelectedOrganisation();
const selectedProject = useSelectedProject();
const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
const createIssue = useCreateIssue();
const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
const statusOptions = useMemo(() => Object.keys(statuses), [statuses]);
const defaultStatus = statusOptions[0] ?? "";
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [sprintId, setSprintId] = useState<string>("unassigned"); const [sprintId, setSprintId] = useState<string>("unassigned");
const [assigneeIds, setAssigneeIds] = useState<string[]>(["unassigned"]); const [assigneeIds, setAssigneeIds] = useState<string[]>(["unassigned"]);
const [status, setStatus] = useState<string>(Object.keys(statuses)[0] ?? ""); const [status, setStatus] = useState<string>(defaultStatus);
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -61,7 +57,7 @@ export function IssueModal({
setDescription(""); setDescription("");
setSprintId("unassigned"); setSprintId("unassigned");
setAssigneeIds(["unassigned"]); setAssigneeIds(["unassigned"]);
setStatus(statuses?.[0] ?? ""); setStatus(defaultStatus);
setSubmitAttempted(false); setSubmitAttempted(false);
setSubmitting(false); setSubmitting(false);
setError(null); setError(null);
@@ -74,8 +70,8 @@ export function IssueModal({
} }
}; };
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (event: FormEvent) => {
e.preventDefault(); event.preventDefault();
setError(null); setError(null);
setSubmitAttempted(true); setSubmitAttempted(true);
@@ -92,7 +88,7 @@ export function IssueModal({
return; return;
} }
if (!projectId) { if (!selectedProject) {
setError("select a project first"); setError("select a project first");
return; return;
} }
@@ -100,42 +96,26 @@ export function IssueModal({
setSubmitting(true); setSubmitting(true);
try { try {
await issue.create({ const data = await createIssue.mutateAsync({
projectId, projectId: selectedProject.Project.id,
title, title,
description, description,
sprintId: sprintId === "unassigned" ? null : Number(sprintId), sprintId: sprintId === "unassigned" ? null : Number(sprintId),
assigneeIds: assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id)), assigneeIds: assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id)),
status: status.trim() === "" ? undefined : status, status: status.trim() === "" ? undefined : status,
onSuccess: async (data) => { });
setOpen(false); setOpen(false);
reset(); reset();
try { toast.success(`Created ${issueID(selectedProject.Project.key, data.number)}`, {
await completeAction?.(data.number); dismissible: false,
} catch (actionErr) {
console.error(actionErr);
}
},
onError: async (err) => {
const message = parseError(err);
setError(message);
setSubmitting(false);
toast.error(`Error creating issue: ${message}`, {
dismissible: false,
});
try {
await errorAction?.(message);
} catch (actionErr) {
console.error(actionErr);
}
},
}); });
} catch (err) { } catch (err) {
console.error(err); const message = parseError(err as Error);
setError("failed to create issue"); setError(message);
setSubmitting(false); setSubmitting(false);
toast.error(`Error creating issue: ${message}`, {
dismissible: false,
});
} }
}; };
@@ -143,7 +123,7 @@ export function IssueModal({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
{trigger || ( {trigger || (
<Button variant="outline" disabled={!projectId}> <Button variant="outline" disabled={!selectedProject}>
Create Issue Create Issue
</Button> </Button>
)} )}
@@ -156,15 +136,14 @@ export function IssueModal({
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="grid"> <div className="grid">
{statuses && Object.keys(statuses).length > 0 && ( {statusOptions.length > 0 && (
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Label>Status</Label> <Label>Status</Label>
<StatusSelect <StatusSelect
statuses={statuses} statuses={statuses}
value={status} value={status}
onChange={(newValue) => { onChange={(newValue) => {
if (newValue.trim() === "") return; // TODO: handle this better if (newValue.trim() === "") return;
// unsure why an empty value is being sent, but preventing it this way for now
setStatus(newValue); setStatus(newValue);
}} }}
trigger={({ isOpen, value }) => ( trigger={({ isOpen, value }) => (
@@ -188,11 +167,11 @@ export function IssueModal({
<Field <Field
label="Title" label="Title"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(event) => setTitle(event.target.value)}
validate={(v) => validate={(value) =>
v.trim() === "" value.trim() === ""
? "Cannot be empty" ? "Cannot be empty"
: v.trim().length > ISSUE_TITLE_MAX_LENGTH : value.trim().length > ISSUE_TITLE_MAX_LENGTH
? `Too long (${ISSUE_TITLE_MAX_LENGTH} character limit)` ? `Too long (${ISSUE_TITLE_MAX_LENGTH} character limit)`
: undefined : undefined
} }
@@ -203,9 +182,9 @@ export function IssueModal({
<Field <Field
label="Description (optional)" label="Description (optional)"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(event) => setDescription(event.target.value)}
validate={(v) => validate={(value) =>
v.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH value.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH
? `Too long (${ISSUE_DESCRIPTION_MAX_LENGTH} character limit)` ? `Too long (${ISSUE_DESCRIPTION_MAX_LENGTH} character limit)`
: undefined : undefined
} }
@@ -214,14 +193,14 @@ export function IssueModal({
maxLength={ISSUE_DESCRIPTION_MAX_LENGTH} maxLength={ISSUE_DESCRIPTION_MAX_LENGTH}
/> />
{sprints && sprints.length > 0 && ( {sprints.length > 0 && (
<div className="flex items-center gap-2 mt-0"> <div className="flex items-center gap-2 mt-0">
<Label className="text-sm">Sprint</Label> <Label className="text-sm">Sprint</Label>
<SprintSelect sprints={sprints} value={sprintId} onChange={setSprintId} /> <SprintSelect sprints={sprints} value={sprintId} onChange={setSprintId} />
</div> </div>
)} )}
{members && members.length > 0 && ( {members.length > 0 && (
<div className="flex items-start gap-2 mt-4"> <div className="flex items-start gap-2 mt-4">
<Label className="text-sm pt-2">Assignees</Label> <Label className="text-sm pt-2">Assignees</Label>
<MultiAssigneeSelect <MultiAssigneeSelect

View File

@@ -1,29 +1,23 @@
import type { TimerState } from "@sprint/shared"; import type { TimerState } from "@sprint/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { parseError, timer } from "@/lib/server"; import { useEndTimer, useTimerState, useToggleTimer } from "@/lib/query/hooks";
import { parseError } from "@/lib/server";
import { cn, formatTime } from "@/lib/utils"; import { cn, formatTime } from "@/lib/utils";
export function IssueTimer({ issueId, onEnd }: { issueId: number; onEnd?: (data: TimerState) => void }) { export function IssueTimer({ issueId, onEnd }: { issueId: number; onEnd?: (data: TimerState) => void }) {
const [timerState, setTimerState] = useState<TimerState>(null); const { data: timerState, error } = useTimerState(issueId);
const toggleTimer = useToggleTimer();
const endTimer = useEndTimer();
const [displayTime, setDisplayTime] = useState(0); const [displayTime, setDisplayTime] = useState(0);
const [error, setError] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
// fetch current timer state on mount
useEffect(() => { useEffect(() => {
timer.get({ if (timerState) {
issueId, setDisplayTime(timerState.workTimeMs);
onSuccess: (data) => { }
setTimerState(data); }, [timerState]);
if (data) {
setDisplayTime(data.workTimeMs);
}
},
onError: (err) => setError(parseError(err)),
});
}, [issueId]);
// update display time every second when running
useEffect(() => { useEffect(() => {
if (!timerState?.isRunning) return; if (!timerState?.isRunning) return;
@@ -37,33 +31,34 @@ export function IssueTimer({ issueId, onEnd }: { issueId: number; onEnd?: (data:
return () => clearInterval(interval); return () => clearInterval(interval);
}, [timerState?.isRunning, timerState?.workTimeMs]); }, [timerState?.isRunning, timerState?.workTimeMs]);
const handleToggle = () => { useEffect(() => {
timer.toggle({ if (!error) return;
issueId, setErrorMessage(parseError(error as Error));
onSuccess: (data) => { }, [error]);
if (data) {
setTimerState(data); const handleToggle = async () => {
setDisplayTime(data.workTimeMs); try {
} const data = await toggleTimer.mutateAsync({ issueId });
setError(null); if (data) {
}, setDisplayTime(data.workTimeMs);
onError: (err) => setError(parseError(err)), }
}); setErrorMessage(null);
} catch (err) {
setErrorMessage(parseError(err as Error));
}
}; };
const handleEnd = () => { const handleEnd = async () => {
timer.end({ try {
issueId, const data = await endTimer.mutateAsync({ issueId });
onSuccess: (data) => { if (data) {
if (data) { setDisplayTime(data.workTimeMs);
setTimerState(data); onEnd?.(data);
setDisplayTime(data.workTimeMs); }
onEnd?.(data); setErrorMessage(null);
} } catch (err) {
setError(null); setErrorMessage(parseError(err as Error));
}, }
onError: (err) => setError(parseError(err)),
});
}; };
return ( return (
@@ -72,7 +67,7 @@ export function IssueTimer({ issueId, onEnd }: { issueId: number; onEnd?: (data:
{formatTime(displayTime)} {formatTime(displayTime)}
</div> </div>
{error && <p className="text-red-500 text-sm">{error}</p>} {errorMessage && <p className="text-red-500 text-sm">{errorMessage}</p>}
<div className="flex gap-4"> <div className="flex gap-4">
<Button onClick={handleToggle}> <Button onClick={handleToggle}>

View File

@@ -1,72 +1,37 @@
import type { TimerState } from "@sprint/shared"; import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { parseError, timer } from "@/lib/server"; import { useInactiveTimers, useTimerState } from "@/lib/query/hooks";
import { parseError } from "@/lib/server";
import { formatTime } from "@/lib/utils"; import { formatTime } from "@/lib/utils";
const FALLBACK_TIME = "--:--:--"; const FALLBACK_TIME = "--:--:--";
const REFRESH_INTERVAL_MS = 10000; const REFRESH_INTERVAL_MS = 10000;
export function TimerDisplay({ issueId }: { issueId: number }) { export function TimerDisplay({ issueId }: { issueId: number }) {
const [timerState, setTimerState] = useState<TimerState>(null); const { data: timerState, error: timerError } = useTimerState(issueId, {
refetchInterval: REFRESH_INTERVAL_MS,
});
const { data: inactiveTimers = [], error: inactiveError } = useInactiveTimers(issueId, {
refetchInterval: REFRESH_INTERVAL_MS,
});
const [workTimeMs, setWorkTimeMs] = useState(0); const [workTimeMs, setWorkTimeMs] = useState(0);
const [inactiveWorkTimeMs, setInactiveWorkTimeMs] = useState(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const combinedError = timerError ?? inactiveError;
useEffect(() => { useEffect(() => {
let isMounted = true; if (combinedError) {
const message = parseError(combinedError as Error);
const fetchTimer = () => { setError(message);
timer.get({ toast.error(`Error fetching timer data: ${message}`, {
issueId, dismissible: false,
onSuccess: (data) => {
if (!isMounted) return;
setTimerState(data);
setWorkTimeMs(data?.workTimeMs ?? 0);
setError(null);
},
onError: (err) => {
if (!isMounted) return;
const message = parseError(err);
setError(message);
toast.error(`Error fetching timer data: ${message}`, {
dismissible: false,
});
},
}); });
return;
timer.getInactive({ }
issueId, setError(null);
onSuccess: (data) => { setWorkTimeMs(timerState?.workTimeMs ?? 0);
if (!isMounted) return; }, [combinedError, timerState]);
const totalWorkTime = data.reduce(
(total, session) => total + (session?.workTimeMs ?? 0),
0,
);
setInactiveWorkTimeMs(totalWorkTime);
setError(null);
},
onError: (err) => {
if (!isMounted) return;
const message = parseError(err);
setError(message);
toast.error(`Error fetching timer data: ${message}`, {
dismissible: false,
});
},
});
};
fetchTimer();
const refreshInterval = window.setInterval(fetchTimer, REFRESH_INTERVAL_MS);
return () => {
isMounted = false;
window.clearInterval(refreshInterval);
};
}, [issueId]);
useEffect(() => { useEffect(() => {
if (!timerState?.isRunning) return; if (!timerState?.isRunning) return;
@@ -80,6 +45,11 @@ export function TimerDisplay({ issueId }: { issueId: number }) {
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, [timerState?.isRunning, timerState?.workTimeMs]); }, [timerState?.isRunning, timerState?.workTimeMs]);
const inactiveWorkTimeMs = useMemo(
() => inactiveTimers.reduce((total, session) => total + (session?.workTimeMs ?? 0), 0),
[inactiveTimers],
);
const totalWorkTimeMs = inactiveWorkTimeMs + workTimeMs; const totalWorkTimeMs = inactiveWorkTimeMs + workTimeMs;
const displayWorkTime = error ? FALLBACK_TIME : formatTime(totalWorkTimeMs); const displayWorkTime = error ? FALLBACK_TIME : formatTime(totalWorkTimeMs);