mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
switched issue and timer flows to mutations
This commit is contained in:
@@ -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..."
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user