diff --git a/packages/backend/src/db/queries/issues.ts b/packages/backend/src/db/queries/issues.ts index 6e58f90..c47f52f 100644 --- a/packages/backend/src/db/queries/issues.ts +++ b/packages/backend/src/db/queries/issues.ts @@ -97,6 +97,35 @@ export async function getIssueByID(id: number) { return issue; } +export async function getIssueWithUsersById(issueId: number): Promise { + 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() diff --git a/packages/backend/src/db/queries/timed-sessions.ts b/packages/backend/src/db/queries/timed-sessions.ts index cb94b9e..d0a0ee4 100644 --- a/packages/backend/src/db/queries/timed-sessions.ts +++ b/packages/backend/src/db/queries/timed-sessions.ts @@ -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() diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index ef59beb..22ecdb6 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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))), diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 03e2c9f..2176fdb 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -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, diff --git a/packages/backend/src/routes/issue/by-id.ts b/packages/backend/src/routes/issue/by-id.ts new file mode 100644 index 0000000..27d8528 --- /dev/null +++ b/packages/backend/src/routes/issue/by-id.ts @@ -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); +} diff --git a/packages/backend/src/routes/timers.ts b/packages/backend/src/routes/timers.ts index 6d5a1b4..af80c77 100644 --- a/packages/backend/src/routes/timers.ts +++ b/packages/backend/src/routes/timers.ts @@ -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); diff --git a/packages/frontend/src/components/active-timers-overlay.tsx b/packages/frontend/src/components/active-timers-overlay.tsx new file mode 100644 index 0000000..d214a56 --- /dev/null +++ b/packages/frontend/src/components/active-timers-overlay.tsx @@ -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 ( +
+ {activeTimers.map((timer) => ( + + ))} +
+ ); +} + +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 ? ( + + {issueKey} + + } + /> + ) : ( + + ); + + return ( + + ); +} diff --git a/packages/frontend/src/components/issue-details.tsx b/packages/frontend/src/components/issue-details.tsx index 9f5236e..16b20eb 100644 --- a/packages/frontend/src/components/issue-details.tsx +++ b/packages/frontend/src/components/issue-details.tsx @@ -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([]); const [sprintId, setSprintId] = useState("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 && (
-
- - -
+ + 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 && ( Timers cannot be used on issues with multiple assignees diff --git a/packages/frontend/src/components/timer-controls.tsx b/packages/frontend/src/components/timer-controls.tsx new file mode 100644 index 0000000..d783dcc --- /dev/null +++ b/packages/frontend/src/components/timer-controls.tsx @@ -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(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 ( +
+
+
{issueKey}
+ + {formatTime(elapsedMs)} + + + ({formatTime(resolvedTotalMs)}) + +
+ + {running ? ( + + ) : ( + + )} + + + + +
+
+ {error && {error}} +
+ ); +} diff --git a/packages/frontend/src/components/ui/icon-button.tsx b/packages/frontend/src/components/ui/icon-button.tsx index 1f0aadc..a5add3c 100644 --- a/packages/frontend/src/components/ui/icon-button.tsx +++ b/packages/frontend/src/components/ui/icon-button.tsx @@ -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", diff --git a/packages/frontend/src/components/ui/icon.tsx b/packages/frontend/src/components/ui/icon.tsx index 098a9b8..a374aad 100644 --- a/packages/frontend/src/components/ui/icon.tsx +++ b/packages/frontend/src/components/ui/icon.tsx @@ -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 }, diff --git a/packages/frontend/src/lib/query/hooks/issues.ts b/packages/frontend/src/lib/query/hooks/issues.ts index 006f1d0..c4600fd 100644 --- a/packages/frontend/src/lib/query/hooks/issues.ts +++ b/packages/frontend/src/lib/query/hooks/issues.ts @@ -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({ + 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({ queryKey: queryKeys.issues.statusCount(organisationId ?? 0, status ?? ""), diff --git a/packages/frontend/src/lib/query/hooks/timers.ts b/packages/frontend/src/lib/query/hooks/timers.ts index bf7d6c5..97bc6fe 100644 --- a/packages/frontend/src/lib/query/hooks/timers.ts +++ b/packages/frontend/src/lib/query/hooks/timers.ts @@ -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({ + 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({ - queryKey: queryKeys.timers.active(issueId ?? 0), - queryFn: () => timer.get(issueId ?? 0), + return useQuery({ + 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({ 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({ 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() }); }, }); } diff --git a/packages/frontend/src/lib/query/keys.ts b/packages/frontend/src/lib/query/keys.ts index dff3534..67c3260 100644 --- a/packages/frontend/src/lib/query/keys.ts +++ b/packages/frontend/src/lib/query/keys.ts @@ -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, diff --git a/packages/frontend/src/lib/server/issue/byId.ts b/packages/frontend/src/lib/server/issue/byId.ts new file mode 100644 index 0000000..5679891 --- /dev/null +++ b/packages/frontend/src/lib/server/issue/byId.ts @@ -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 { + 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(); +} diff --git a/packages/frontend/src/lib/server/issue/index.ts b/packages/frontend/src/lib/server/issue/index.ts index a267e19..12bcec5 100644 --- a/packages/frontend/src/lib/server/issue/index.ts +++ b/packages/frontend/src/lib/server/issue/index.ts @@ -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"; diff --git a/packages/frontend/src/lib/server/timer/list.ts b/packages/frontend/src/lib/server/timer/list.ts index 19a8461..79dcaa3 100644 --- a/packages/frontend/src/lib/server/timer/list.ts +++ b/packages/frontend/src/lib/server/timer/list.ts @@ -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 { +export async function list(input: TimerListInput = {}): Promise { 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 = {}; diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index ca29436..7a2d4be 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -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( } /> + diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index 14eacdd..e8d8b64 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -101,6 +101,12 @@ export const IssuesByProjectQuerySchema = z.object({ export type IssuesByProjectQuery = z.infer; +export const IssueByIdQuerySchema = z.object({ + issueId: z.coerce.number().int().positive("issueId must be a positive integer"), +}); + +export type IssueByIdQuery = z.infer; + 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; +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; + +export const TimerListResponseSchema = z.array(TimerListItemSchema); + +export type TimerListResponse = z.infer; + export const StatusCountResponseSchema = z.array( z.object({ status: z.string(), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f3cc78c..cface76 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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,