From d44f37840380800f378055dfc6c4cae82dc7c772 Mon Sep 17 00:00:00 2001 From: Oliver Bryan <04oliverbryan@gmail.com> Date: Sun, 11 Jan 2026 17:18:55 +0000 Subject: [PATCH] display work time in issue detail pane --- .../backend/src/db/queries/timed-sessions.ts | 9 +++ packages/backend/src/index.ts | 1 + packages/backend/src/routes/index.ts | 2 + .../backend/src/routes/timer/get-inactive.ts | 25 ++++++ .../src/components/issue-detail-pane.tsx | 10 +-- .../frontend/src/components/issue-timer.tsx | 27 +------ .../frontend/src/components/timer-display.tsx | 81 +++++++++++++++++++ .../src/lib/server/timer/getInactive.ts | 30 +++++++ .../frontend/src/lib/server/timer/index.ts | 1 + packages/frontend/src/lib/utils.ts | 10 +++ packages/shared/src/index.ts | 1 + packages/shared/src/schema.ts | 9 +++ todo.md | 1 - 13 files changed, 177 insertions(+), 30 deletions(-) create mode 100644 packages/backend/src/routes/timer/get-inactive.ts create mode 100644 packages/frontend/src/components/timer-display.tsx create mode 100644 packages/frontend/src/lib/server/timer/getInactive.ts diff --git a/packages/backend/src/db/queries/timed-sessions.ts b/packages/backend/src/db/queries/timed-sessions.ts index 88612ab..d81f3be 100644 --- a/packages/backend/src/db/queries/timed-sessions.ts +++ b/packages/backend/src/db/queries/timed-sessions.ts @@ -24,6 +24,15 @@ export async function getActiveTimedSession(userId: number, issueId: number) { return timedSession ?? null; } +export async function getInactiveTimedSessions(issueId: number) { + const timedSessions = await db + .select() + .from(TimedSession) + .where(and(eq(TimedSession.issueId, issueId), isNotNull(TimedSession.endedAt))) + .orderBy(desc(TimedSession.createdAt)); + return timedSessions ?? null; +} + export async function getTimedSessionById(id: number) { const [timedSession] = await db.select().from(TimedSession).where(eq(TimedSession.id, id)); return timedSession ?? null; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 8fd7f7c..1eb588c 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -72,6 +72,7 @@ const main = async () => { "/timer/toggle": withCors(withAuth(withCSRF(routes.timerToggle))), "/timer/end": withCors(withAuth(withCSRF(routes.timerEnd))), "/timer/get": withCors(withAuth(withCSRF(routes.timerGet))), + "/timer/get-inactive": withCors(withAuth(withCSRF(routes.timerGetInactive))), "/timers": withCors(withAuth(withCSRF(routes.timers))), }, }); diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 467b750..593b3e6 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -28,6 +28,7 @@ import projectWithCreator from "./project/with-creator"; import projectsWithCreators from "./project/with-creators"; import timerEnd from "./timer/end"; import timerGet from "./timer/get"; +import timerGetInactive from "./timer/get-inactive"; import timerToggle from "./timer/toggle"; import timers from "./timers"; import userByUsername from "./user/by-username"; @@ -76,6 +77,7 @@ export const routes = { timerToggle, timerGet, + timerGetInactive, timerEnd, timers, }; diff --git a/packages/backend/src/routes/timer/get-inactive.ts b/packages/backend/src/routes/timer/get-inactive.ts new file mode 100644 index 0000000..9b88b59 --- /dev/null +++ b/packages/backend/src/routes/timer/get-inactive.ts @@ -0,0 +1,25 @@ +import { calculateBreakTimeMs, calculateWorkTimeMs } from "@issue/shared"; +import type { AuthedRequest } from "../../auth/middleware"; +import { getInactiveTimedSessions } from "../../db/queries"; + +// GET /timer?issueId=123 +export default async function timerGetInactive(req: AuthedRequest) { + const url = new URL(req.url); + const issueId = url.searchParams.get("issueId"); + if (!issueId || Number.isNaN(Number(issueId))) { + return new Response("missing issue id", { status: 400 }); + } + const sessions = await getInactiveTimedSessions(Number(issueId)); + + if (!sessions[0] || !sessions) { + return Response.json(null); + } + + return Response.json( + sessions.map((session) => ({ + ...session, + workTimeMs: calculateWorkTimeMs(session.timestamps), + breakTimeMs: calculateBreakTimeMs(session.timestamps), + })), + ); +} diff --git a/packages/frontend/src/components/issue-detail-pane.tsx b/packages/frontend/src/components/issue-detail-pane.tsx index 0eca03d..b97e24d 100644 --- a/packages/frontend/src/components/issue-detail-pane.tsx +++ b/packages/frontend/src/components/issue-detail-pane.tsx @@ -5,6 +5,7 @@ import { useSession } from "@/components/session-provider"; import SmallUserDisplay from "@/components/small-user-display"; 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 { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; @@ -192,11 +193,10 @@ export function IssueDetailPane({ - {user?.id === Number(assigneeId) && ( -
- -
- )} +
+ {user?.id === Number(assigneeId) && } + +
void; -} - -export function IssueTimer({ issueId, onEnd }: IssueTimerProps) { +export function IssueTimer({ issueId, onEnd }: { issueId: number; onEnd?: (data: TimerState) => void }) { const [timerState, setTimerState] = useState(null); const [displayTime, setDisplayTime] = useState(0); const [error, setError] = useState(null); diff --git a/packages/frontend/src/components/timer-display.tsx b/packages/frontend/src/components/timer-display.tsx new file mode 100644 index 0000000..ac3335b --- /dev/null +++ b/packages/frontend/src/components/timer-display.tsx @@ -0,0 +1,81 @@ +import type { TimerState } from "@issue/shared"; +import { useEffect, useState } from "react"; +import { timer } from "@/lib/server"; +import { formatTime } from "@/lib/utils"; + +const FALLBACK_TIME = "--:--:--"; +const REFRESH_INTERVAL_MS = 10000; + +export function TimerDisplay({ issueId }: { issueId: number }) { + const [timerState, setTimerState] = useState(null); + const [workTimeMs, setWorkTimeMs] = useState(0); + const [inactiveWorkTimeMs, setInactiveWorkTimeMs] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + let isMounted = true; + + const fetchTimer = () => { + timer.get({ + issueId, + onSuccess: (data) => { + if (!isMounted) return; + setTimerState(data); + setWorkTimeMs(data?.workTimeMs ?? 0); + setError(null); + }, + onError: (message) => { + if (!isMounted) return; + setError(message); + }, + }); + + timer.getInactive({ + issueId, + onSuccess: (data) => { + if (!isMounted) return; + const sessions = (data ?? []) as TimerState[]; + const totalWorkTime = sessions.reduce( + (total, session) => total + (session?.workTimeMs ?? 0), + 0, + ); + setInactiveWorkTimeMs(totalWorkTime); + setError(null); + }, + onError: (message) => { + if (!isMounted) return; + setError(message); + }, + }); + }; + + fetchTimer(); + const refreshInterval = window.setInterval(fetchTimer, REFRESH_INTERVAL_MS); + + return () => { + isMounted = false; + window.clearInterval(refreshInterval); + }; + }, [issueId]); + + useEffect(() => { + if (!timerState?.isRunning) return; + + const startTime = Date.now(); + const baseWorkTime = timerState.workTimeMs; + const interval = window.setInterval(() => { + setWorkTimeMs(baseWorkTime + (Date.now() - startTime)); + }, 1000); + + return () => window.clearInterval(interval); + }, [timerState?.isRunning, timerState?.workTimeMs]); + + const totalWorkTimeMs = inactiveWorkTimeMs + workTimeMs; + const displayWorkTime = error ? FALLBACK_TIME : formatTime(totalWorkTimeMs); + + return ( +
+ {displayWorkTime} +
+ ); +} diff --git a/packages/frontend/src/lib/server/timer/getInactive.ts b/packages/frontend/src/lib/server/timer/getInactive.ts new file mode 100644 index 0000000..ada001b --- /dev/null +++ b/packages/frontend/src/lib/server/timer/getInactive.ts @@ -0,0 +1,30 @@ +import { getCsrfToken, getServerURL } from "@/lib/utils"; +import type { ServerQueryInput } from ".."; + +export async function getInactive({ + issueId, + onSuccess, + onError, +}: { + issueId: number; +} & ServerQueryInput) { + const url = new URL(`${getServerURL()}/timer/get-inactive`); + url.searchParams.set("issueId", `${issueId}`); + + const csrfToken = getCsrfToken(); + const headers: HeadersInit = {}; + if (csrfToken) headers["X-CSRF-Token"] = csrfToken; + + const res = await fetch(url.toString(), { + headers, + credentials: "include", + }); + + if (!res.ok) { + const error = await res.text(); + onError?.(error || `failed to get timers (${res.status})`); + } else { + const data = await res.json(); + onSuccess?.(data, res); + } +} diff --git a/packages/frontend/src/lib/server/timer/index.ts b/packages/frontend/src/lib/server/timer/index.ts index a910c46..df2c99d 100644 --- a/packages/frontend/src/lib/server/timer/index.ts +++ b/packages/frontend/src/lib/server/timer/index.ts @@ -1,4 +1,5 @@ export { end } from "@/lib/server/timer/end"; export { get } from "@/lib/server/timer/get"; +export { getInactive } from "@/lib/server/timer/getInactive"; export { list } from "@/lib/server/timer/list"; export { toggle } from "@/lib/server/timer/toggle"; diff --git a/packages/frontend/src/lib/utils.ts b/packages/frontend/src/lib/utils.ts index fa8ceae..9f96bfe 100644 --- a/packages/frontend/src/lib/utils.ts +++ b/packages/frontend/src/lib/utils.ts @@ -40,3 +40,13 @@ export function getServerURL() { } return serverURL; } + +export function formatTime(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds + .toString() + .padStart(2, "0")}`; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f505bb6..30474a9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -28,6 +28,7 @@ export type { SessionRecord, TimedSessionInsert, TimedSessionRecord, + TimerState, UserInsert, UserRecord, } from "./schema"; diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts index b7a9028..f828a79 100644 --- a/packages/shared/src/schema.ts +++ b/packages/shared/src/schema.ts @@ -186,3 +186,12 @@ export type OrganisationMemberResponse = { Organisation: OrganisationRecord; User: UserRecord; }; + +export type TimerState = { + id: number; + workTimeMs: number; + breakTimeMs: number; + isRunning: boolean; + timestamps: string[]; + endedAt: string | null; +} | null; diff --git a/todo.md b/todo.md index 6b2a168..14a488f 100644 --- a/todo.md +++ b/todo.md @@ -15,7 +15,6 @@ - more than one assignee - edit title & description - time tracking: - - add current work time on detail pane for issues with time tracked - add overlay in the bottom left for active timers if there are any. this should be minimal with the issue key (API-005), the time, and a play/pause + end button - user preferences - "assign to me by default" option for new issues