From 4603a9a7f087584a40460354d9574b1a8a4179ca Mon Sep 17 00:00:00 2001 From: Oliver Bryan <04oliverbryan@gmail.com> Date: Fri, 9 Jan 2026 22:25:49 +0000 Subject: [PATCH] timer components --- .../frontend/src/components/issue-timer.tsx | 108 ++++++++++++++++++ .../frontend/src/components/timer-modal.tsx | 23 ++++ .../frontend/src/components/ui/dialog.tsx | 8 +- 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 packages/frontend/src/components/issue-timer.tsx create mode 100644 packages/frontend/src/components/timer-modal.tsx diff --git a/packages/frontend/src/components/issue-timer.tsx b/packages/frontend/src/components/issue-timer.tsx new file mode 100644 index 0000000..6a1ed42 --- /dev/null +++ b/packages/frontend/src/components/issue-timer.tsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { timer } from "@/lib/server"; +import { cn } 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) { + const [timerState, setTimerState] = useState(null); + const [displayTime, setDisplayTime] = useState(0); + const [error, setError] = useState(null); + + // fetch current timer state on mount + useEffect(() => { + timer.get({ + issueId, + onSuccess: (data) => { + setTimerState(data); + if (data) { + setDisplayTime(data.workTimeMs); + } + }, + onError: setError, + }); + }, [issueId]); + + // update display time every second when running + useEffect(() => { + if (!timerState?.isRunning) return; + + const startTime = Date.now(); + const baseTime = timerState.workTimeMs; + + const interval = setInterval(() => { + setDisplayTime(baseTime + (Date.now() - startTime)); + }, 1000); + + return () => clearInterval(interval); + }, [timerState?.isRunning, timerState?.workTimeMs]); + + const handleToggle = () => { + timer.toggle({ + issueId, + onSuccess: (data) => { + setTimerState(data); + setDisplayTime(data.workTimeMs); + setError(null); + }, + onError: setError, + }); + }; + + const handleEnd = () => { + timer.end({ + issueId, + onSuccess: (data) => { + setTimerState(data); + setDisplayTime(data.workTimeMs); + setError(null); + onEnd?.(data); + }, + onError: setError, + }); + }; + + return ( +
+
+ {formatTime(displayTime)} +
+ + {error &&

{error}

} + +
+ + +
+
+ ); +} diff --git a/packages/frontend/src/components/timer-modal.tsx b/packages/frontend/src/components/timer-modal.tsx new file mode 100644 index 0000000..31f161f --- /dev/null +++ b/packages/frontend/src/components/timer-modal.tsx @@ -0,0 +1,23 @@ +import { Timer } from "lucide-react"; +import { useState } from "react"; +import { IssueTimer } from "@/components/issue-timer"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; + +export function TimerModal({ issueId }: { issueId: number }) { + const [open, setOpen] = useState(false); + + return ( + + + + + + setOpen(false)} /> + + + ); +} diff --git a/packages/frontend/src/components/ui/dialog.tsx b/packages/frontend/src/components/ui/dialog.tsx index c733d56..1948c2d 100644 --- a/packages/frontend/src/components/ui/dialog.tsx +++ b/packages/frontend/src/components/ui/dialog.tsx @@ -34,9 +34,11 @@ function DialogContent({ className, children, showCloseButton = true, + closePos = "top-right", ...props }: React.ComponentProps & { showCloseButton?: boolean; + closePos?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; }) { return ( @@ -59,7 +61,11 @@ function DialogContent({ className={cn( "cursor-pointer ring-offset-background focus:ring-ring", "data-[state=open]:bg-accent data-[state=open]:text-muted-foreground", - "absolute top-4 right-4 opacity-70", + "absolute opacity-70", + closePos === "top-left" && "top-4 left-4", + closePos === "top-right" && "top-4 right-4", + closePos === "bottom-left" && "bottom-4 left-4", + closePos === "bottom-right" && "bottom-4 right-4", "hover:opacity-100 focus:ring-2 focus:ring-offset-2 ", "ocus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none", "[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",