mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 10:33:01 +00:00
improved timer system and overlay
This commit is contained in:
94
packages/frontend/src/components/active-timers-overlay.tsx
Normal file
94
packages/frontend/src/components/active-timers-overlay.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { IssueModal } from "@/components/issue-modal";
|
||||
import { useSessionSafe } from "@/components/session-provider";
|
||||
import { TimerControls } from "@/components/timer-controls";
|
||||
import { getWorkTimeMs } from "@/components/timer-display";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useActiveTimers, useInactiveTimers, useIssueById } from "@/lib/query/hooks";
|
||||
import { issueID } from "@/lib/utils";
|
||||
|
||||
const REFRESH_INTERVAL_MS = 10000;
|
||||
|
||||
export function ActiveTimersOverlay() {
|
||||
const session = useSessionSafe();
|
||||
const { data: activeTimers = [] } = useActiveTimers({
|
||||
refetchInterval: REFRESH_INTERVAL_MS,
|
||||
enabled: Boolean(session?.user),
|
||||
});
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
const hasRunning = useMemo(() => activeTimers.some((timer) => timer.isRunning), [activeTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRunning) return;
|
||||
const interval = window.setInterval(() => {
|
||||
setTick((value) => value + 1);
|
||||
}, 1000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [hasRunning]);
|
||||
|
||||
void tick;
|
||||
|
||||
if (!session?.user || activeTimers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-[calc(1rem+env(safe-area-inset-bottom))] left-[calc(1rem+env(safe-area-inset-left))] z-50 flex flex-col gap-2">
|
||||
{activeTimers.map((timer) => (
|
||||
<ActiveTimerItem key={timer.id} timer={timer} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveTimerItem({
|
||||
timer,
|
||||
}: {
|
||||
timer: {
|
||||
id: number;
|
||||
issueId: number;
|
||||
issueNumber: number;
|
||||
projectKey: string;
|
||||
timestamps: string[];
|
||||
isRunning: boolean;
|
||||
};
|
||||
}) {
|
||||
const { data: issueData } = useIssueById(timer.issueId);
|
||||
const { data: inactiveTimers = [] } = useInactiveTimers(timer.issueId, { refetchInterval: 10000 });
|
||||
const [open, setOpen] = useState(false);
|
||||
const issueKey = issueID(timer.projectKey, timer.issueNumber);
|
||||
|
||||
const inactiveWorkTimeMs = inactiveTimers.reduce(
|
||||
(total, session) => total + getWorkTimeMs(session?.timestamps),
|
||||
0,
|
||||
);
|
||||
const currentWorkTimeMs = getWorkTimeMs(timer.timestamps);
|
||||
const totalWorkTimeMs = inactiveWorkTimeMs + currentWorkTimeMs;
|
||||
|
||||
const issueKeyNode = issueData ? (
|
||||
<IssueModal
|
||||
issueData={issueData}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={
|
||||
<Button variant="link" size="none" className="text-sm tabular-nums truncate min-w-0 font-700">
|
||||
{issueKey}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Button variant="link" size="none" className="text-sm tabular-nums truncate min-w-0 font-700" disabled>
|
||||
{issueKey}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<TimerControls
|
||||
issueId={timer.issueId}
|
||||
issueKey={issueKeyNode}
|
||||
timestamps={timer.timestamps}
|
||||
isRunning={timer.isRunning}
|
||||
totalTimeMs={totalWorkTimeMs}
|
||||
className="border bg-background/95 pl-2 pr-1 py-1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import SmallUserDisplay from "@/components/small-user-display";
|
||||
import { SprintSelect } from "@/components/sprint-select";
|
||||
import { StatusSelect } from "@/components/status-select";
|
||||
import StatusTag from "@/components/status-tag";
|
||||
import { TimerDisplay } from "@/components/timer-display";
|
||||
import { TimerModal } from "@/components/timer-modal";
|
||||
import { TimerControls } from "@/components/timer-controls";
|
||||
import { getWorkTimeMs } from "@/components/timer-display";
|
||||
import { TypeSelect } from "@/components/type-select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
@@ -19,7 +19,13 @@ import { IconButton } from "@/components/ui/icon-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SelectTrigger } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useDeleteIssue, useSelectedOrganisation, useUpdateIssue } from "@/lib/query/hooks";
|
||||
import {
|
||||
useDeleteIssue,
|
||||
useInactiveTimers,
|
||||
useSelectedOrganisation,
|
||||
useTimerState,
|
||||
useUpdateIssue,
|
||||
} from "@/lib/query/hooks";
|
||||
import { parseError } from "@/lib/server";
|
||||
import { cn, issueID } from "@/lib/utils";
|
||||
|
||||
@@ -55,6 +61,9 @@ export function IssueDetails({
|
||||
const organisation = useSelectedOrganisation();
|
||||
const updateIssue = useUpdateIssue();
|
||||
const deleteIssue = useDeleteIssue();
|
||||
const { data: timerState } = useTimerState(issueData.Issue.id, { refetchInterval: 10000 });
|
||||
const { data: inactiveTimers = [] } = useInactiveTimers(issueData.Issue.id, { refetchInterval: 10000 });
|
||||
const [timerTick, setTimerTick] = useState(0);
|
||||
|
||||
const [assigneeIds, setAssigneeIds] = useState<string[]>([]);
|
||||
const [sprintId, setSprintId] = useState<string>("unassigned");
|
||||
@@ -95,6 +104,16 @@ export function IssueDetails({
|
||||
setIsEditingDescription(false);
|
||||
}, [issueData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!timerState?.isRunning) return;
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setTimerTick((value) => value + 1);
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [timerState?.isRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimeoutRef.current) {
|
||||
@@ -103,6 +122,15 @@ export function IssueDetails({
|
||||
};
|
||||
}, []);
|
||||
|
||||
void timerTick;
|
||||
|
||||
const inactiveWorkTimeMs = inactiveTimers.reduce(
|
||||
(total, session) => total + getWorkTimeMs(session?.timestamps),
|
||||
0,
|
||||
);
|
||||
const currentWorkTimeMs = getWorkTimeMs(timerState?.timestamps);
|
||||
const totalWorkTimeMs = inactiveWorkTimeMs + currentWorkTimeMs;
|
||||
|
||||
const handleSprintChange = async (value: string) => {
|
||||
setSprintId(value);
|
||||
const newSprintId = value === "unassigned" ? null : Number(value);
|
||||
@@ -486,10 +514,17 @@ export function IssueDetails({
|
||||
|
||||
{organisation?.Organisation.features.issueTimeTracking && isAssignee && (
|
||||
<div className={cn("flex flex-col gap-2", hasMultipleAssignees && "cursor-not-allowed")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<TimerModal issueId={issueData.Issue.id} disabled={hasMultipleAssignees} />
|
||||
<TimerDisplay issueId={issueData.Issue.id} />
|
||||
</div>
|
||||
<TimerControls
|
||||
issueId={issueData.Issue.id}
|
||||
// biome-ignore lint/complexity/noUselessFragments: <needed to represent absence>
|
||||
issueKey={<></>}
|
||||
timestamps={timerState?.timestamps}
|
||||
isRunning={timerState?.isRunning}
|
||||
totalTimeMs={totalWorkTimeMs}
|
||||
disabled={hasMultipleAssignees}
|
||||
size="sm"
|
||||
className="self-start w-fit border bg-background/95 pl-0 pr-1 py-1"
|
||||
/>
|
||||
{hasMultipleAssignees && (
|
||||
<span className="text-xs text-destructive/85 font-600">
|
||||
Timers cannot be used on issues with multiple assignees
|
||||
|
||||
105
packages/frontend/src/components/timer-controls.tsx
Normal file
105
packages/frontend/src/components/timer-controls.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { getWorkTimeMs } from "@/components/timer-display";
|
||||
import Icon from "@/components/ui/icon";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { useEndTimer, useToggleTimer } from "@/lib/query/hooks";
|
||||
import { parseError } from "@/lib/server";
|
||||
import { cn, formatTime } from "@/lib/utils";
|
||||
|
||||
export function TimerControls({
|
||||
issueId,
|
||||
issueKey,
|
||||
timestamps,
|
||||
isRunning,
|
||||
totalTimeMs,
|
||||
disabled = false,
|
||||
size = "md",
|
||||
className,
|
||||
}: {
|
||||
issueId: number;
|
||||
issueKey: ReactNode;
|
||||
timestamps?: string[] | null;
|
||||
isRunning?: boolean;
|
||||
totalTimeMs?: number;
|
||||
disabled?: boolean;
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
}) {
|
||||
const toggleTimer = useToggleTimer();
|
||||
const endTimer = useEndTimer();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const elapsedMs = getWorkTimeMs(timestamps ?? undefined);
|
||||
const resolvedTotalMs = totalTimeMs ?? elapsedMs;
|
||||
const running = Boolean(isRunning);
|
||||
const hasTimer = (timestamps?.length ?? 0) > 0;
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (disabled) return;
|
||||
try {
|
||||
await toggleTimer.mutateAsync({ issueId });
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(parseError(err as Error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnd = async () => {
|
||||
if (disabled) return;
|
||||
try {
|
||||
await endTimer.mutateAsync({ issueId });
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(parseError(err as Error));
|
||||
}
|
||||
};
|
||||
|
||||
const isCompact = size === "sm";
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
<div className={cn("flex items-center", isCompact ? "gap-2" : "gap-3")}>
|
||||
<div className="min-w-0 flex items-center">{issueKey}</div>
|
||||
<span className={cn("font-mono tabular-nums leading-none", isCompact ? "text-xs" : "text-sm")}>
|
||||
{formatTime(elapsedMs)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono tabular-nums leading-none text-muted-foreground",
|
||||
isCompact ? "text-[11.5px] font-300" : "text-xs font-300",
|
||||
)}
|
||||
>
|
||||
({formatTime(resolvedTotalMs)})
|
||||
</span>
|
||||
<div className={cn("ml-auto flex items-center", isCompact ? "gap-1" : "gap-2")}>
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
variant="dummy"
|
||||
aria-label={running ? "Pause timer" : "Resume timer"}
|
||||
disabled={disabled}
|
||||
onClick={handleToggle}
|
||||
className={"hover:opacity-70"}
|
||||
>
|
||||
{running ? (
|
||||
<Icon icon="pause" size={isCompact ? 14 : 16} />
|
||||
) : (
|
||||
<Icon icon="play" size={isCompact ? 14 : 16} />
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
variant="destructive"
|
||||
aria-label="End timer"
|
||||
disabled={disabled || !hasTimer}
|
||||
onClick={handleEnd}
|
||||
className={"hover:opacity-70"}
|
||||
>
|
||||
<Icon icon="stop" size={isCompact ? 14 : 16} color={"var(--destructive)"} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
{error && <span className="mt-1 block text-xs text-destructive">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const iconButtonVariants = cva(
|
||||
outline: "border bg-transparent dark:hover:bg-muted/40",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
dummy: "",
|
||||
},
|
||||
size: {
|
||||
default: "w-6 h-6",
|
||||
|
||||
@@ -19,8 +19,11 @@ import {
|
||||
Moon as PixelMoon,
|
||||
MoreVertical as PixelMoreVertical,
|
||||
NoteDelete as PixelNoteDelete,
|
||||
Pause as PixelPause,
|
||||
Play as PixelPlay,
|
||||
Plus as PixelPlus,
|
||||
Server as PixelServer,
|
||||
CheckboxOn as PixelStop,
|
||||
Sun as PixelSun,
|
||||
Trash as PixelTrash,
|
||||
Undo as PixelUndo,
|
||||
@@ -50,9 +53,12 @@ import {
|
||||
SignOutIcon as PhosphorLogOut,
|
||||
MoonIcon as PhosphorMoon,
|
||||
OctagonIcon as PhosphorOctagon,
|
||||
PauseIcon as PhosphorPause,
|
||||
PlayIcon as PhosphorPlay,
|
||||
PlusIcon as PhosphorPlus,
|
||||
QuestionIcon as PhosphorQuestion,
|
||||
HardDrivesIcon as PhosphorServer,
|
||||
StopIcon as PhosphorStop,
|
||||
SunIcon as PhosphorSun,
|
||||
TrashIcon as PhosphorTrash,
|
||||
ArrowCounterClockwiseIcon as PhosphorUndo,
|
||||
@@ -87,8 +93,11 @@ import {
|
||||
Home as LucideHome,
|
||||
Moon,
|
||||
OctagonXIcon,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
ServerIcon,
|
||||
Square,
|
||||
SquareCheck,
|
||||
Sun,
|
||||
Timer,
|
||||
@@ -103,6 +112,7 @@ import { useSessionSafe } from "@/components/session-provider";
|
||||
|
||||
// lucide: https://lucide.dev/icons
|
||||
// pixel: https://pixelarticons.com/ (CLICK "Legacy") - these ones are free
|
||||
// phosphor: https://phosphoricons.com/
|
||||
const icons = {
|
||||
alertTriangle: { lucide: AlertTriangle, pixel: PixelAlert, phosphor: PhosphorWarning },
|
||||
bug: { lucide: Bug, pixel: PixelDebug, phosphor: PhosphorBug },
|
||||
@@ -138,9 +148,12 @@ const icons = {
|
||||
logOut: { lucide: LogOut, pixel: PixelLogout, phosphor: PhosphorLogOut },
|
||||
moon: { lucide: Moon, pixel: PixelMoon, phosphor: PhosphorMoon },
|
||||
octagonXIcon: { lucide: OctagonXIcon, pixel: PixelClose, phosphor: PhosphorOctagon },
|
||||
pause: { lucide: Pause, pixel: PixelPause, phosphor: PhosphorPause },
|
||||
play: { lucide: Play, pixel: PixelPlay, phosphor: PhosphorPlay },
|
||||
plus: { lucide: Plus, pixel: PixelPlus, phosphor: PhosphorPlus },
|
||||
serverIcon: { lucide: ServerIcon, pixel: PixelServer, phosphor: PhosphorServer },
|
||||
sun: { lucide: Sun, pixel: PixelSun, phosphor: PhosphorSun },
|
||||
stop: { lucide: Square, pixel: PixelStop, phosphor: PhosphorStop },
|
||||
timer: { lucide: Timer, pixel: PixelClock, phosphor: PhosphorClock },
|
||||
trash: { lucide: Trash, pixel: PixelTrash, phosphor: PhosphorTrash },
|
||||
triangleAlertIcon: { lucide: TriangleAlertIcon, pixel: PixelAlert, phosphor: PhosphorWarning },
|
||||
|
||||
Reference in New Issue
Block a user