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