improved timer system and overlay

This commit is contained in:
2026-01-26 19:19:46 +00:00
parent 72835324e1
commit 11c808ab69
20 changed files with 445 additions and 19 deletions

View 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"
/>
);
}

View File

@@ -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

View 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>
);
}

View File

@@ -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",

View File

@@ -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 },

View File

@@ -1,4 +1,5 @@
import type {
IssueByIdQuery,
IssueCreateRequest,
IssueRecord,
IssueResponse,
@@ -21,6 +22,14 @@ export function useIssues(projectId?: number | null) {
});
}
export function useIssueById(issueId?: IssueByIdQuery["issueId"] | null) {
return useQuery<IssueResponse>({
queryKey: queryKeys.issues.byId(issueId ?? 0),
queryFn: () => issue.byId(issueId ?? 0),
enabled: Boolean(issueId),
});
}
export function useIssueStatusCount(organisationId?: number | null, status?: string | null) {
return useQuery<StatusCountResponse>({
queryKey: queryKeys.issues.statusCount(organisationId ?? 0, status ?? ""),

View File

@@ -1,15 +1,28 @@
import type { TimerEndRequest, TimerState, TimerToggleRequest } from "@sprint/shared";
import type { TimerEndRequest, TimerListItem, TimerState, TimerToggleRequest } from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { timer } from "@/lib/server";
const activeTimersQueryFn = () => timer.list({ activeOnly: true });
export function useActiveTimers(options?: { refetchInterval?: number; enabled?: boolean }) {
return useQuery<TimerListItem[]>({
queryKey: queryKeys.timers.list(),
queryFn: activeTimersQueryFn,
enabled: options?.enabled ?? true,
refetchInterval: options?.refetchInterval,
refetchIntervalInBackground: false,
});
}
export function useTimerState(issueId?: number | null, options?: { refetchInterval?: number }) {
return useQuery<TimerState>({
queryKey: queryKeys.timers.active(issueId ?? 0),
queryFn: () => timer.get(issueId ?? 0),
return useQuery<TimerListItem[], Error, TimerState>({
queryKey: queryKeys.timers.list(),
queryFn: activeTimersQueryFn,
enabled: Boolean(issueId),
refetchInterval: options?.refetchInterval,
refetchIntervalInBackground: false,
select: (timers) => timers.find((timer) => timer.issueId === issueId) ?? null,
});
}
@@ -29,9 +42,9 @@ export function useToggleTimer() {
return useMutation<TimerState, Error, TimerToggleRequest>({
mutationKey: ["timers", "toggle"],
mutationFn: timer.toggle,
onSuccess: (data, variables) => {
queryClient.setQueryData(queryKeys.timers.active(variables.issueId), data);
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.timers.list() });
},
});
}
@@ -42,9 +55,9 @@ export function useEndTimer() {
return useMutation<TimerState, Error, TimerEndRequest>({
mutationKey: ["timers", "end"],
mutationFn: timer.end,
onSuccess: (data, variables) => {
queryClient.setQueryData(queryKeys.timers.active(variables.issueId), data);
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.timers.list() });
},
});
}

View File

@@ -13,6 +13,7 @@ export const queryKeys = {
issues: {
all: ["issues"] as const,
byProject: (projectId: number) => [...queryKeys.issues.all, "by-project", projectId] as const,
byId: (issueId: number) => [...queryKeys.issues.all, "by-id", issueId] as const,
statusCount: (organisationId: number, status: string) =>
[...queryKeys.issues.all, "status-count", organisationId, status] as const,
typeCount: (organisationId: number, type: string) =>
@@ -30,7 +31,7 @@ export const queryKeys = {
all: ["timers"] as const,
active: (issueId: number) => [...queryKeys.timers.all, "active", issueId] as const,
inactive: (issueId: number) => [...queryKeys.timers.all, "inactive", issueId] as const,
list: (issueId: number) => [...queryKeys.timers.all, "list", issueId] as const,
list: () => [...queryKeys.timers.all, "list"] as const,
},
users: {
all: ["users"] as const,

View File

@@ -0,0 +1,19 @@
import type { IssueByIdQuery, IssueResponse } from "@sprint/shared";
import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function byId(issueId: IssueByIdQuery["issueId"]): Promise<IssueResponse> {
const url = new URL(`${getServerURL()}/issue/by-id`);
url.searchParams.set("issueId", `${issueId}`);
const res = await fetch(url.toString(), {
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get issue (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,3 +1,4 @@
export { byId } from "@/lib/server/issue/byId";
export { byProject } from "@/lib/server/issue/byProject";
export { create } from "@/lib/server/issue/create";
export { remove as delete } from "@/lib/server/issue/delete";

View File

@@ -1,15 +1,18 @@
import type { TimerListItem } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
type TimerListInput = {
limit?: number;
offset?: number;
activeOnly?: boolean;
};
export async function list(input: TimerListInput = {}): Promise<unknown> {
export async function list(input: TimerListInput = {}): Promise<TimerListItem[]> {
const url = new URL(`${getServerURL()}/timers`);
if (input.limit != null) url.searchParams.set("limit", `${input.limit}`);
if (input.offset != null) url.searchParams.set("offset", `${input.offset}`);
if (input.activeOnly != null) url.searchParams.set("activeOnly", input.activeOnly ? "true" : "false");
const csrfToken = getCsrfToken();
const headers: HeadersInit = {};

View File

@@ -2,6 +2,7 @@ import "./App.css";
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { ActiveTimersOverlay } from "@/components/active-timers-overlay";
import { QueryProvider } from "@/components/query-provider";
import { SelectionProvider } from "@/components/selection-provider";
import { RequireAuth, SessionProvider } from "@/components/session-provider";
@@ -57,6 +58,7 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
<ActiveTimersOverlay />
<Toaster visibleToasts={1} duration={2000} />
</SelectionProvider>
</SessionProvider>