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; 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) { export async function getIssueByNumber(projectId: number, number: number) {
const [issue] = await db const [issue] = await db
.select() .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 { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
import { db } from "../client"; import { db } from "../client";
@@ -80,6 +80,26 @@ export async function getUserTimedSessions(userId: number, limit = 50, offset =
return timedSessions; 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) { export async function getCompletedTimedSessions(userId: number, limit = 50, offset = 0) {
const timedSessions = await db const timedSessions = await db
.select() .select()

View File

@@ -43,6 +43,7 @@ const main = async () => {
"/user/upload-avatar": withGlobal(withAuth(withCSRF(routes.userUploadAvatar))), "/user/upload-avatar": withGlobal(withAuth(withCSRF(routes.userUploadAvatar))),
"/issue/create": withGlobal(withAuth(withCSRF(routes.issueCreate))), "/issue/create": withGlobal(withAuth(withCSRF(routes.issueCreate))),
"/issue/by-id": withGlobal(withAuth(routes.issueById)),
"/issue/update": withGlobal(withAuth(withCSRF(routes.issueUpdate))), "/issue/update": withGlobal(withAuth(withCSRF(routes.issueUpdate))),
"/issue/delete": withGlobal(withAuth(withCSRF(routes.issueDelete))), "/issue/delete": withGlobal(withAuth(withCSRF(routes.issueDelete))),
"/issue-comment/create": withGlobal(withAuth(withCSRF(routes.issueCommentCreate))), "/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 authLogout from "./auth/logout";
import authMe from "./auth/me"; import authMe from "./auth/me";
import authRegister from "./auth/register"; import authRegister from "./auth/register";
import issueById from "./issue/by-id";
import issueCreate from "./issue/create"; import issueCreate from "./issue/create";
import issueDelete from "./issue/delete"; import issueDelete from "./issue/delete";
import issueUpdate from "./issue/update"; import issueUpdate from "./issue/update";
@@ -56,6 +57,7 @@ export const routes = {
userUploadAvatar, userUploadAvatar,
issueCreate, issueCreate,
issueById,
issueDelete, issueDelete,
issueUpdate, 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 { calculateBreakTimeMs, calculateWorkTimeMs, isTimerRunning } from "@sprint/shared";
import type { AuthedRequest } from "../auth/middleware"; import type { AuthedRequest } from "../auth/middleware";
import { getUserTimedSessions } from "../db/queries"; import { getActiveTimedSessionsWithIssue, getUserTimedSessions } from "../db/queries";
// GET /timers?limit=50&offset=0 // GET /timers?limit=50&offset=0
export default async function timers(req: AuthedRequest) { export default async function timers(req: AuthedRequest) {
const url = new URL(req.url); const url = new URL(req.url);
const limitParam = url.searchParams.get("limit"); const limitParam = url.searchParams.get("limit");
const offsetParam = url.searchParams.get("offset"); const offsetParam = url.searchParams.get("offset");
const activeOnlyParam = url.searchParams.get("activeOnly");
const limit = limitParam ? Number(limitParam) : 50; const limit = limitParam ? Number(limitParam) : 50;
const offset = offsetParam ? Number(offsetParam) : 0; 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); 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 { 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 { TimerControls } from "@/components/timer-controls";
import { TimerModal } from "@/components/timer-modal"; import { getWorkTimeMs } from "@/components/timer-display";
import { TypeSelect } from "@/components/type-select"; import { TypeSelect } from "@/components/type-select";
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";
@@ -19,7 +19,13 @@ 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 { useDeleteIssue, useSelectedOrganisation, useUpdateIssue } from "@/lib/query/hooks"; import {
useDeleteIssue,
useInactiveTimers,
useSelectedOrganisation,
useTimerState,
useUpdateIssue,
} from "@/lib/query/hooks";
import { parseError } from "@/lib/server"; import { parseError } from "@/lib/server";
import { cn, issueID } from "@/lib/utils"; import { cn, issueID } from "@/lib/utils";
@@ -55,6 +61,9 @@ export function IssueDetails({
const organisation = useSelectedOrganisation(); const organisation = useSelectedOrganisation();
const updateIssue = useUpdateIssue(); const updateIssue = useUpdateIssue();
const deleteIssue = useDeleteIssue(); 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 [assigneeIds, setAssigneeIds] = useState<string[]>([]);
const [sprintId, setSprintId] = useState<string>("unassigned"); const [sprintId, setSprintId] = useState<string>("unassigned");
@@ -95,6 +104,16 @@ export function IssueDetails({
setIsEditingDescription(false); setIsEditingDescription(false);
}, [issueData]); }, [issueData]);
useEffect(() => {
if (!timerState?.isRunning) return;
const interval = window.setInterval(() => {
setTimerTick((value) => value + 1);
}, 1000);
return () => window.clearInterval(interval);
}, [timerState?.isRunning]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (copyTimeoutRef.current) { 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) => { const handleSprintChange = async (value: string) => {
setSprintId(value); setSprintId(value);
const newSprintId = value === "unassigned" ? null : Number(value); const newSprintId = value === "unassigned" ? null : Number(value);
@@ -486,10 +514,17 @@ export function IssueDetails({
{organisation?.Organisation.features.issueTimeTracking && isAssignee && ( {organisation?.Organisation.features.issueTimeTracking && isAssignee && (
<div className={cn("flex flex-col gap-2", hasMultipleAssignees && "cursor-not-allowed")}> <div className={cn("flex flex-col gap-2", hasMultipleAssignees && "cursor-not-allowed")}>
<div className="flex items-center gap-2"> <TimerControls
<TimerModal issueId={issueData.Issue.id} disabled={hasMultipleAssignees} /> issueId={issueData.Issue.id}
<TimerDisplay issueId={issueData.Issue.id} /> // biome-ignore lint/complexity/noUselessFragments: <needed to represent absence>
</div> 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 && ( {hasMultipleAssignees && (
<span className="text-xs text-destructive/85 font-600"> <span className="text-xs text-destructive/85 font-600">
Timers cannot be used on issues with multiple assignees 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", outline: "border bg-transparent dark:hover:bg-muted/40",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
primary: "bg-primary text-primary-foreground hover:bg-primary/90", primary: "bg-primary text-primary-foreground hover:bg-primary/90",
dummy: "",
}, },
size: { size: {
default: "w-6 h-6", default: "w-6 h-6",

View File

@@ -19,8 +19,11 @@ import {
Moon as PixelMoon, Moon as PixelMoon,
MoreVertical as PixelMoreVertical, MoreVertical as PixelMoreVertical,
NoteDelete as PixelNoteDelete, NoteDelete as PixelNoteDelete,
Pause as PixelPause,
Play as PixelPlay,
Plus as PixelPlus, Plus as PixelPlus,
Server as PixelServer, Server as PixelServer,
CheckboxOn as PixelStop,
Sun as PixelSun, Sun as PixelSun,
Trash as PixelTrash, Trash as PixelTrash,
Undo as PixelUndo, Undo as PixelUndo,
@@ -50,9 +53,12 @@ import {
SignOutIcon as PhosphorLogOut, SignOutIcon as PhosphorLogOut,
MoonIcon as PhosphorMoon, MoonIcon as PhosphorMoon,
OctagonIcon as PhosphorOctagon, OctagonIcon as PhosphorOctagon,
PauseIcon as PhosphorPause,
PlayIcon as PhosphorPlay,
PlusIcon as PhosphorPlus, PlusIcon as PhosphorPlus,
QuestionIcon as PhosphorQuestion, QuestionIcon as PhosphorQuestion,
HardDrivesIcon as PhosphorServer, HardDrivesIcon as PhosphorServer,
StopIcon as PhosphorStop,
SunIcon as PhosphorSun, SunIcon as PhosphorSun,
TrashIcon as PhosphorTrash, TrashIcon as PhosphorTrash,
ArrowCounterClockwiseIcon as PhosphorUndo, ArrowCounterClockwiseIcon as PhosphorUndo,
@@ -87,8 +93,11 @@ import {
Home as LucideHome, Home as LucideHome,
Moon, Moon,
OctagonXIcon, OctagonXIcon,
Pause,
Play,
Plus, Plus,
ServerIcon, ServerIcon,
Square,
SquareCheck, SquareCheck,
Sun, Sun,
Timer, Timer,
@@ -103,6 +112,7 @@ import { useSessionSafe } from "@/components/session-provider";
// lucide: https://lucide.dev/icons // lucide: https://lucide.dev/icons
// pixel: https://pixelarticons.com/ (CLICK "Legacy") - these ones are free // pixel: https://pixelarticons.com/ (CLICK "Legacy") - these ones are free
// phosphor: https://phosphoricons.com/
const icons = { const icons = {
alertTriangle: { lucide: AlertTriangle, pixel: PixelAlert, phosphor: PhosphorWarning }, alertTriangle: { lucide: AlertTriangle, pixel: PixelAlert, phosphor: PhosphorWarning },
bug: { lucide: Bug, pixel: PixelDebug, phosphor: PhosphorBug }, bug: { lucide: Bug, pixel: PixelDebug, phosphor: PhosphorBug },
@@ -138,9 +148,12 @@ const icons = {
logOut: { lucide: LogOut, pixel: PixelLogout, phosphor: PhosphorLogOut }, logOut: { lucide: LogOut, pixel: PixelLogout, phosphor: PhosphorLogOut },
moon: { lucide: Moon, pixel: PixelMoon, phosphor: PhosphorMoon }, moon: { lucide: Moon, pixel: PixelMoon, phosphor: PhosphorMoon },
octagonXIcon: { lucide: OctagonXIcon, pixel: PixelClose, phosphor: PhosphorOctagon }, 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 }, plus: { lucide: Plus, pixel: PixelPlus, phosphor: PhosphorPlus },
serverIcon: { lucide: ServerIcon, pixel: PixelServer, phosphor: PhosphorServer }, serverIcon: { lucide: ServerIcon, pixel: PixelServer, phosphor: PhosphorServer },
sun: { lucide: Sun, pixel: PixelSun, phosphor: PhosphorSun }, sun: { lucide: Sun, pixel: PixelSun, phosphor: PhosphorSun },
stop: { lucide: Square, pixel: PixelStop, phosphor: PhosphorStop },
timer: { lucide: Timer, pixel: PixelClock, phosphor: PhosphorClock }, timer: { lucide: Timer, pixel: PixelClock, phosphor: PhosphorClock },
trash: { lucide: Trash, pixel: PixelTrash, phosphor: PhosphorTrash }, trash: { lucide: Trash, pixel: PixelTrash, phosphor: PhosphorTrash },
triangleAlertIcon: { lucide: TriangleAlertIcon, pixel: PixelAlert, phosphor: PhosphorWarning }, triangleAlertIcon: { lucide: TriangleAlertIcon, pixel: PixelAlert, phosphor: PhosphorWarning },

View File

@@ -1,4 +1,5 @@
import type { import type {
IssueByIdQuery,
IssueCreateRequest, IssueCreateRequest,
IssueRecord, IssueRecord,
IssueResponse, 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) { export function useIssueStatusCount(organisationId?: number | null, status?: string | null) {
return useQuery<StatusCountResponse>({ return useQuery<StatusCountResponse>({
queryKey: queryKeys.issues.statusCount(organisationId ?? 0, status ?? ""), 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys"; import { queryKeys } from "@/lib/query/keys";
import { timer } from "@/lib/server"; 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 }) { export function useTimerState(issueId?: number | null, options?: { refetchInterval?: number }) {
return useQuery<TimerState>({ return useQuery<TimerListItem[], Error, TimerState>({
queryKey: queryKeys.timers.active(issueId ?? 0), queryKey: queryKeys.timers.list(),
queryFn: () => timer.get(issueId ?? 0), queryFn: activeTimersQueryFn,
enabled: Boolean(issueId), enabled: Boolean(issueId),
refetchInterval: options?.refetchInterval, refetchInterval: options?.refetchInterval,
refetchIntervalInBackground: false, refetchIntervalInBackground: false,
select: (timers) => timers.find((timer) => timer.issueId === issueId) ?? null,
}); });
} }
@@ -29,9 +42,9 @@ export function useToggleTimer() {
return useMutation<TimerState, Error, TimerToggleRequest>({ return useMutation<TimerState, Error, TimerToggleRequest>({
mutationKey: ["timers", "toggle"], mutationKey: ["timers", "toggle"],
mutationFn: timer.toggle, mutationFn: timer.toggle,
onSuccess: (data, variables) => { onSuccess: (_data, variables) => {
queryClient.setQueryData(queryKeys.timers.active(variables.issueId), data);
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) }); 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>({ return useMutation<TimerState, Error, TimerEndRequest>({
mutationKey: ["timers", "end"], mutationKey: ["timers", "end"],
mutationFn: timer.end, mutationFn: timer.end,
onSuccess: (data, variables) => { onSuccess: (_data, variables) => {
queryClient.setQueryData(queryKeys.timers.active(variables.issueId), data);
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) }); queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.timers.list() });
}, },
}); });
} }

View File

@@ -13,6 +13,7 @@ export const queryKeys = {
issues: { issues: {
all: ["issues"] as const, all: ["issues"] as const,
byProject: (projectId: number) => [...queryKeys.issues.all, "by-project", projectId] 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) => statusCount: (organisationId: number, status: string) =>
[...queryKeys.issues.all, "status-count", organisationId, status] as const, [...queryKeys.issues.all, "status-count", organisationId, status] as const,
typeCount: (organisationId: number, type: string) => typeCount: (organisationId: number, type: string) =>
@@ -30,7 +31,7 @@ export const queryKeys = {
all: ["timers"] as const, all: ["timers"] as const,
active: (issueId: number) => [...queryKeys.timers.all, "active", issueId] as const, active: (issueId: number) => [...queryKeys.timers.all, "active", issueId] as const,
inactive: (issueId: number) => [...queryKeys.timers.all, "inactive", 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: { users: {
all: ["users"] as const, 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 { byProject } from "@/lib/server/issue/byProject";
export { create } from "@/lib/server/issue/create"; export { create } from "@/lib/server/issue/create";
export { remove as delete } from "@/lib/server/issue/delete"; 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 { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from ".."; import { getErrorMessage } from "..";
type TimerListInput = { type TimerListInput = {
limit?: number; limit?: number;
offset?: 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`); const url = new URL(`${getServerURL()}/timers`);
if (input.limit != null) url.searchParams.set("limit", `${input.limit}`); if (input.limit != null) url.searchParams.set("limit", `${input.limit}`);
if (input.offset != null) url.searchParams.set("offset", `${input.offset}`); 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 csrfToken = getCsrfToken();
const headers: HeadersInit = {}; const headers: HeadersInit = {};

View File

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

View File

@@ -101,6 +101,12 @@ export const IssuesByProjectQuerySchema = z.object({
export type IssuesByProjectQuery = z.infer<typeof IssuesByProjectQuerySchema>; 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({ export const IssuesStatusCountQuerySchema = z.object({
organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"), 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), 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 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( export const StatusCountResponseSchema = z.array(
z.object({ z.object({
status: z.string(), status: z.string(),

View File

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