mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +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 },
|
||||
|
||||
@@ -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 ?? ""),
|
||||
|
||||
@@ -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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
19
packages/frontend/src/lib/server/issue/byId.ts
Normal file
19
packages/frontend/src/lib/server/issue/byId.ts
Normal 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();
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user