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

@@ -97,6 +97,35 @@ export async function getIssueByID(id: number) {
return issue;
}
export async function getIssueWithUsersById(issueId: number): Promise<IssueResponse | null> {
const Creator = aliasedTable(User, "Creator");
const [issueWithCreator] = await db
.select({
Issue: Issue,
Creator: Creator,
})
.from(Issue)
.where(eq(Issue.id, issueId))
.innerJoin(Creator, eq(Issue.creatorId, Creator.id));
if (!issueWithCreator) return null;
const assigneesData = await db
.select({
User: User,
})
.from(IssueAssignee)
.innerJoin(User, eq(IssueAssignee.userId, User.id))
.where(eq(IssueAssignee.issueId, issueId));
return {
Issue: issueWithCreator.Issue,
Creator: issueWithCreator.Creator,
Assignees: assigneesData.map((row) => row.User),
};
}
export async function getIssueByNumber(projectId: number, number: number) {
const [issue] = await db
.select()

View File

@@ -1,4 +1,4 @@
import { TimedSession } from "@sprint/shared";
import { Issue, Project, TimedSession } from "@sprint/shared";
import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
import { db } from "../client";
@@ -80,6 +80,26 @@ export async function getUserTimedSessions(userId: number, limit = 50, offset =
return timedSessions;
}
export async function getActiveTimedSessionsWithIssue(userId: number) {
const timedSessions = await db
.select({
id: TimedSession.id,
userId: TimedSession.userId,
issueId: TimedSession.issueId,
timestamps: TimedSession.timestamps,
endedAt: TimedSession.endedAt,
createdAt: TimedSession.createdAt,
issueNumber: Issue.number,
projectKey: Project.key,
})
.from(TimedSession)
.innerJoin(Issue, eq(TimedSession.issueId, Issue.id))
.innerJoin(Project, eq(Issue.projectId, Project.id))
.where(and(eq(TimedSession.userId, userId), isNull(TimedSession.endedAt)))
.orderBy(desc(TimedSession.createdAt));
return timedSessions;
}
export async function getCompletedTimedSessions(userId: number, limit = 50, offset = 0) {
const timedSessions = await db
.select()

View File

@@ -43,6 +43,7 @@ const main = async () => {
"/user/upload-avatar": withGlobal(withAuth(withCSRF(routes.userUploadAvatar))),
"/issue/create": withGlobal(withAuth(withCSRF(routes.issueCreate))),
"/issue/by-id": withGlobal(withAuth(routes.issueById)),
"/issue/update": withGlobal(withAuth(withCSRF(routes.issueUpdate))),
"/issue/delete": withGlobal(withAuth(withCSRF(routes.issueDelete))),
"/issue-comment/create": withGlobal(withAuth(withCSRF(routes.issueCommentCreate))),

View File

@@ -2,6 +2,7 @@ import authLogin from "./auth/login";
import authLogout from "./auth/logout";
import authMe from "./auth/me";
import authRegister from "./auth/register";
import issueById from "./issue/by-id";
import issueCreate from "./issue/create";
import issueDelete from "./issue/delete";
import issueUpdate from "./issue/update";
@@ -56,6 +57,7 @@ export const routes = {
userUploadAvatar,
issueCreate,
issueById,
issueDelete,
issueUpdate,

View File

@@ -0,0 +1,29 @@
import { IssueByIdQuerySchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware";
import { getIssueOrganisationId, getIssueWithUsersById, getOrganisationMemberRole } from "../../db/queries";
import { errorResponse, parseQueryParams } from "../../validation";
export default async function issueById(req: AuthedRequest) {
const url = new URL(req.url);
const parsed = parseQueryParams(url, IssueByIdQuerySchema);
if ("error" in parsed) return parsed.error;
const { issueId } = parsed.data;
const organisationId = await getIssueOrganisationId(issueId);
if (!organisationId) {
return errorResponse(`organisation not found for issue ${issueId}`, "ORG_NOT_FOUND", 404);
}
const member = await getOrganisationMemberRole(organisationId, req.userId);
if (!member) {
return errorResponse("forbidden", "FORBIDDEN", 403);
}
const issue = await getIssueWithUsersById(issueId);
if (!issue) {
return errorResponse(`issue not found: ${issueId}`, "ISSUE_NOT_FOUND", 404);
}
return Response.json(issue);
}

View File

@@ -1,15 +1,34 @@
import { calculateBreakTimeMs, calculateWorkTimeMs, isTimerRunning } from "@sprint/shared";
import type { AuthedRequest } from "../auth/middleware";
import { getUserTimedSessions } from "../db/queries";
import { getActiveTimedSessionsWithIssue, getUserTimedSessions } from "../db/queries";
// GET /timers?limit=50&offset=0
export default async function timers(req: AuthedRequest) {
const url = new URL(req.url);
const limitParam = url.searchParams.get("limit");
const offsetParam = url.searchParams.get("offset");
const activeOnlyParam = url.searchParams.get("activeOnly");
const limit = limitParam ? Number(limitParam) : 50;
const offset = offsetParam ? Number(offsetParam) : 0;
const activeOnly = activeOnlyParam === "true" || activeOnlyParam === "1";
if (activeOnly) {
const sessions = await getActiveTimedSessionsWithIssue(req.userId);
const enriched = sessions.map((session) => ({
id: session.id,
issueId: session.issueId,
issueNumber: session.issueNumber,
projectKey: session.projectKey,
timestamps: session.timestamps,
endedAt: session.endedAt,
workTimeMs: calculateWorkTimeMs(session.timestamps),
breakTimeMs: calculateBreakTimeMs(session.timestamps),
isRunning: isTimerRunning(session.timestamps),
}));
return Response.json(enriched);
}
const sessions = await getUserTimedSessions(req.userId, limit, offset);

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>

View File

@@ -101,6 +101,12 @@ export const IssuesByProjectQuerySchema = z.object({
export type IssuesByProjectQuery = z.infer<typeof IssuesByProjectQuerySchema>;
export const IssueByIdQuerySchema = z.object({
issueId: z.coerce.number().int().positive("issueId must be a positive integer"),
});
export type IssueByIdQuery = z.infer<typeof IssueByIdQuerySchema>;
export const IssuesStatusCountQuerySchema = z.object({
organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"),
status: z.string().min(1, "Status is required").max(ISSUE_STATUS_MAX_LENGTH),
@@ -512,6 +518,24 @@ export const TimerStateSchema = z
export type TimerStateType = z.infer<typeof TimerStateSchema>;
export const TimerListItemSchema = z.object({
id: z.number(),
issueId: z.number(),
issueNumber: z.number(),
projectKey: z.string(),
workTimeMs: z.number(),
breakTimeMs: z.number(),
isRunning: z.boolean(),
timestamps: z.array(z.string()),
endedAt: z.string().nullable(),
});
export type TimerListItem = z.infer<typeof TimerListItemSchema>;
export const TimerListResponseSchema = z.array(TimerListItemSchema);
export type TimerListResponse = z.infer<typeof TimerListResponseSchema>;
export const StatusCountResponseSchema = z.array(
z.object({
status: z.string(),

View File

@@ -1,6 +1,7 @@
export type {
ApiError,
AuthResponse,
IssueByIdQuery,
IssueCommentCreateRequest,
IssueCommentDeleteRequest,
IssueCommentResponseType,
@@ -43,6 +44,8 @@ export type {
SuccessResponse,
TimerEndRequest,
TimerGetQuery,
TimerListItem,
TimerListResponse,
TimerStateType,
TimerToggleRequest,
TypeCountResponse,
@@ -54,6 +57,7 @@ export type {
export {
ApiErrorSchema,
AuthResponseSchema,
IssueByIdQuerySchema,
IssueCommentCreateRequestSchema,
IssueCommentDeleteRequestSchema,
IssueCommentRecordSchema,
@@ -101,6 +105,8 @@ export {
SuccessResponseSchema,
TimerEndRequestSchema,
TimerGetQuerySchema,
TimerListItemSchema,
TimerListResponseSchema,
TimerStateSchema,
TimerToggleRequestSchema,
TypeCountResponseSchema,