display work time in issue detail pane

This commit is contained in:
Oliver Bryan
2026-01-11 17:18:55 +00:00
parent 511a2d4bea
commit d44f378403
13 changed files with 177 additions and 30 deletions

View File

@@ -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;

View File

@@ -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))),
},
});

View File

@@ -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,
};

View File

@@ -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),
})),
);
}

View File

@@ -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({
<SmallUserDisplay user={issueData.Creator} className={"text-sm"} />
</div>
{user?.id === Number(assigneeId) && (
<div>
<TimerModal issueId={issueData.Issue.id} />
</div>
)}
<div className="flex items-center gap-2">
{user?.id === Number(assigneeId) && <TimerModal issueId={issueData.Issue.id} />}
<TimerDisplay issueId={issueData.Issue.id} />
</div>
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}

View File

@@ -1,31 +1,10 @@
import type { TimerState } from "@issue/shared";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { timer } from "@/lib/server";
import { cn } from "@/lib/utils";
import { cn, formatTime } from "@/lib/utils";
type TimerState = {
id: number;
workTimeMs: number;
breakTimeMs: number;
isRunning: boolean;
timestamps: string[];
endedAt: string | null;
} | null;
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")}`;
}
interface IssueTimerProps {
issueId: number;
onEnd?: (data: TimerState) => void;
}
export function IssueTimer({ issueId, onEnd }: IssueTimerProps) {
export function IssueTimer({ issueId, onEnd }: { issueId: number; onEnd?: (data: TimerState) => void }) {
const [timerState, setTimerState] = useState<TimerState>(null);
const [displayTime, setDisplayTime] = useState(0);
const [error, setError] = useState<string | null>(null);

View File

@@ -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<TimerState>(null);
const [workTimeMs, setWorkTimeMs] = useState(0);
const [inactiveWorkTimeMs, setInactiveWorkTimeMs] = useState(0);
const [error, setError] = useState<string | null>(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 (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-mono tabular-nums">{displayWorkTime}</span>
</div>
);
}

View File

@@ -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);
}
}

View File

@@ -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";

View File

@@ -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")}`;
}

View File

@@ -28,6 +28,7 @@ export type {
SessionRecord,
TimedSessionInsert,
TimedSessionRecord,
TimerState,
UserInsert,
UserRecord,
} from "./schema";

View File

@@ -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;