Skip to content

Commit 4fcafda

Browse files
committed
feat(timers): Add timer sessions (#69)
1 parent b1f9727 commit 4fcafda

16 files changed

Lines changed: 236 additions & 65 deletions

src/components/issue/IssueContextMenu.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { useIntl } from "react-intl";
1919
import { toast } from "sonner";
2020
import { TIssue } from "../../api/redmine/types";
2121
import { LocalIssue } from "../../hooks/useLocalIssues";
22-
import { calculateElapsedTime, Timer, TimerApi } from "../../hooks/useTimers";
22+
import { calculateTimerTotalElapsedTime, Timer, TimerApi } from "../../hooks/useTimers";
2323
import { useSettings } from "../../provider/SettingsProvider";
2424
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "../ui/context-menu";
2525
import AddIssueNotesModal from "./AddIssueNotesModal";
@@ -90,11 +90,11 @@ const IssueContextMenuItems = ({ issue, localIssue, primaryTimer, timerApi, assi
9090
{formatMessage({ id: "issues.context-menu.add-notes" })}
9191
</ContextMenuItem>
9292
<ContextMenuSeparator />
93-
<ContextMenuItem onClick={primaryTimer.isActive ? () => timerApi.pauseTimer(primaryTimer) : () => timerApi.startTimer(primaryTimer)} disabled={!canLogTime}>
94-
{primaryTimer.isActive ? <TimerOffIcon /> : <TimerIcon />}
95-
{formatMessage({ id: primaryTimer.isActive ? "timer.context-menu.pause" : "timer.context-menu.start" })}
93+
<ContextMenuItem onClick={primaryTimer.activeSession ? () => timerApi.pauseTimer(primaryTimer) : () => timerApi.startTimer(primaryTimer)} disabled={!canLogTime}>
94+
{primaryTimer.activeSession ? <TimerOffIcon /> : <TimerIcon />}
95+
{formatMessage({ id: primaryTimer.activeSession ? "timer.context-menu.pause" : "timer.context-menu.start" })}
9696
</ContextMenuItem>
97-
<ContextMenuItem onClick={() => timerApi.resetTimer(primaryTimer)} disabled={calculateElapsedTime(primaryTimer) === 0 || !canLogTime}>
97+
<ContextMenuItem onClick={() => timerApi.resetTimer(primaryTimer)} disabled={calculateTimerTotalElapsedTime(primaryTimer) === 0 || !canLogTime}>
9898
<TimerResetIcon />
9999
{formatMessage({ id: "timer.context-menu.reset" })}
100100
</ContextMenuItem>

src/components/timer/CurrentIssueTimer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useRedmineIssue } from "@/api/redmine/hooks/useRedmineIssue";
22
import { usePermissions } from "@/provider/PermissionsProvider";
33
import useRedmineUrl from "../../hooks/useRedmineUrl";
4-
import useTimers, { calculateElapsedTime } from "../../hooks/useTimers";
4+
import useTimers, { calculateTimerTotalElapsedTime } from "../../hooks/useTimers";
55
import { TimerComponents } from "./timer";
66

77
type PropTypes = {
@@ -20,7 +20,7 @@ const CurrentIssueTimerInner = ({ issueId }: PropTypes) => {
2020
const issueTimers = timers.getTimersByIssue(issue.id);
2121
const primaryTimer = issueTimers[0]!;
2222

23-
if (!canLogTime && calculateElapsedTime(primaryTimer) === 0) return;
23+
if (!canLogTime && calculateTimerTotalElapsedTime(primaryTimer) === 0) return;
2424

2525
return (
2626
<TimerComponents.Root timer={primaryTimer} issue={issue}>

src/components/timer/ProjectTimersGroup.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const ProjectTimersGroup = ({ projectGroup, className, ...props }: Projec
4242
<TimerComponents.ToggleButton />
4343
<TimerComponents.DoneButton canLogTime={issue ? hasProjectPermission(issue.project.id, "log_time") : false} />
4444
</TimerComponents.Wrapper>
45+
<TimerComponents.Sessions />
4546
</ToggleableCard>
4647
</TimerComponents.ContextMenu>
4748
</TimerComponents.Root>

src/components/timer/timer/TimerContextMenu.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,19 @@ export const TimerContextMenu = ({ children }: { children: ReactElement }) => {
1717

1818
const TimerContextMenuItems = () => {
1919
const { formatMessage } = useIntl();
20-
const { timer, timerApi, currentTime, setIsEditing } = useTimerContext();
20+
const { timer, timerApi, totalElapsedTime, setIsEditing } = useTimerContext();
2121

2222
return (
2323
<>
24-
<ContextMenuItem onClick={timer.isActive ? () => timerApi.pauseTimer(timer) : () => timerApi.startTimer(timer)}>
25-
{timer.isActive ? <TimerOffIcon /> : <TimerIcon />}
26-
{formatMessage({ id: timer.isActive ? "timer.context-menu.pause" : "timer.context-menu.start" })}
24+
<ContextMenuItem onClick={timer.activeSession ? () => timerApi.pauseTimer(timer) : () => timerApi.startTimer(timer)}>
25+
{timer.activeSession ? <TimerOffIcon /> : <TimerIcon />}
26+
{formatMessage({ id: timer.activeSession ? "timer.context-menu.pause" : "timer.context-menu.start" })}
2727
</ContextMenuItem>
2828
<ContextMenuItem onClick={() => setIsEditing(true)}>
2929
<PencilIcon />
3030
{formatMessage({ id: "timer.context-menu.edit" })}
3131
</ContextMenuItem>
32-
<ContextMenuItem onClick={() => timerApi.resetTimer(timer)} disabled={currentTime === 0}>
32+
<ContextMenuItem onClick={() => timerApi.resetTimer(timer)} disabled={totalElapsedTime === 0}>
3333
<TimerResetIcon />
3434
{formatMessage({ id: "timer.context-menu.reset" })}
3535
</ContextMenuItem>

src/components/timer/timer/TimerCounter.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { useTimerContext } from "./TimerRoot";
1212
export const TimerCounter = () => {
1313
const { formatMessage } = useIntl();
1414

15-
const { timer, currentTime, isEditing, setIsEditing } = useTimerContext();
15+
const { timer, totalElapsedTime, isEditing, setIsEditing } = useTimerContext();
1616

1717
if (isEditing) {
1818
return <EditTimer />;
@@ -21,10 +21,10 @@ export const TimerCounter = () => {
2121
return (
2222
<HelpTooltip message={formatMessage({ id: "issues.timer.action.edit.tooltip" })}>
2323
<span
24-
className={clsx("-my-1 max-w-30 shrink-0 truncate text-lg", currentTime > 0 ? "text-yellow-500" : "text-muted-foreground", timer.isActive && "font-bold")}
24+
className={clsx("text-muted-foreground -my-1 max-w-30 shrink-0 truncate text-lg", { "font-bold": !!timer.activeSession, "text-yellow-500": totalElapsedTime > 0 })}
2525
onDoubleClick={() => setIsEditing(true)}
2626
>
27-
{formatTimer(currentTime)}
27+
{formatTimer(totalElapsedTime)}
2828
</span>
2929
</HelpTooltip>
3030
);
@@ -33,16 +33,16 @@ export const TimerCounter = () => {
3333
export const EditTimer = () => {
3434
const { formatMessage } = useIntl();
3535

36-
const { timer, timerApi, currentTime, setIsEditing } = useTimerContext();
36+
const { timer, timerApi, totalElapsedTime, setIsEditing } = useTimerContext();
3737

38-
const [h, setH] = useState(() => Math.floor(currentTime / 1000 / 60 / 60).toString());
39-
const [m, setM] = useState(() => to2Digit(Math.floor((currentTime / 1000 / 60) % 60)));
40-
const [s, setS] = useState(() => to2Digit(Math.floor((currentTime / 1000) % 60)));
38+
const [h, setH] = useState(() => Math.floor(totalElapsedTime / 1000 / 60 / 60).toString());
39+
const [m, setM] = useState(() => to2Digit(Math.floor((totalElapsedTime / 1000 / 60) % 60)));
40+
const [s, setS] = useState(() => to2Digit(Math.floor((totalElapsedTime / 1000) % 60)));
4141
const updatedTime = (Number(h) * 60 * 60 + Number(m) * 60 + Number(s)) * 1000;
4242

4343
const onOverrideTime = () => {
4444
setIsEditing(false);
45-
timerApi.setElapsedTime(timer, updatedTime);
45+
timerApi.setTotalElapsedTime(timer, updatedTime);
4646
};
4747

4848
const [confirmCancelModal, setConfirmCancelModal] = useState(false);
@@ -58,7 +58,7 @@ export const EditTimer = () => {
5858
type="number"
5959
value={h}
6060
min={0}
61-
className={clsx("h-8 appearance-none p-0 text-center", currentTime > 0 ? "text-yellow-500" : "text-muted-foreground", {
61+
className={clsx("h-8 appearance-none p-0 text-center", totalElapsedTime > 0 ? "text-yellow-500" : "text-muted-foreground", {
6262
"w-4": h.length === 1,
6363
"w-6": h.length === 2,
6464
"w-8": h.length >= 3,
@@ -95,7 +95,7 @@ export const EditTimer = () => {
9595
value={m}
9696
min={0}
9797
max={59}
98-
className={clsx("h-8 w-6 appearance-none p-0 text-center", currentTime > 0 ? "text-yellow-500" : "text-muted-foreground")}
98+
className={clsx("h-8 w-6 appearance-none p-0 text-center", totalElapsedTime > 0 ? "text-yellow-500" : "text-muted-foreground")}
9999
onChange={(e) => {
100100
const { value, min, max } = e.target;
101101
setM(to2Digit(Math.max(Number(min), Math.min(Number(max), Number(value)))));
@@ -123,7 +123,7 @@ export const EditTimer = () => {
123123
value={s}
124124
min={0}
125125
max={59}
126-
className={clsx("h-8 w-6 appearance-none p-0 text-center", currentTime > 0 ? "text-yellow-500" : "text-muted-foreground")}
126+
className={clsx("h-8 w-6 appearance-none p-0 text-center", totalElapsedTime > 0 ? "text-yellow-500" : "text-muted-foreground")}
127127
onChange={(e) => {
128128
const { value, min, max } = e.target;
129129
setS(to2Digit(Math.max(Number(min), Math.min(Number(max), Number(value)))));

src/components/timer/timer/TimerDoneButton.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const TimerDoneButton = ({ canLogTime }: { canLogTime: boolean }) => {
1212
const { formatMessage } = useIntl();
1313
const { settings } = useSettings();
1414

15-
const { timer, timerApi, issue, currentTime } = useTimerContext();
15+
const { timer, timerApi, issue, totalElapsedTime } = useTimerContext();
1616

1717
const isDisabled = !canLogTime || !issue;
1818
const [createTimeEntryHours, setCreateTimeEntryHours] = useState<number | undefined>(undefined);
@@ -27,7 +27,7 @@ export const TimerDoneButton = ({ canLogTime }: { canLogTime: boolean }) => {
2727
data-disabled={isDisabled}
2828
onClick={() => {
2929
if (isDisabled) return;
30-
const time = settings.features.roundToInterval ? roundMillisecondsToInterval(currentTime, settings.features.roundingInterval, settings.features.roundingMode) : currentTime;
30+
const time = settings.features.roundToInterval ? roundMillisecondsToInterval(totalElapsedTime, settings.features.roundingInterval, settings.features.roundingMode) : totalElapsedTime;
3131
const hours = Number((time / 1000 / 60 / 60).toFixed(2));
3232
setCreateTimeEntryHours(hours);
3333
}}

src/components/timer/timer/TimerRoot.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { TIssue } from "@/api/redmine/types";
2-
import { calculateElapsedTime, Timer, TimerApi, useTimerApi } from "@/hooks/useTimers";
2+
import { calculateTimerTotalElapsedTime, Timer, TimerApi, useTimerApi } from "@/hooks/useTimers";
33
import { createContext, PropsWithChildren, use, useEffect, useEffectEvent, useState } from "react";
44
import { useInterval } from "usehooks-ts";
55

66
type TimerContextType = {
77
timer: Timer;
88
timerApi: TimerApi;
99
issue?: TIssue;
10-
currentTime: number;
10+
totalElapsedTime: number;
1111
isEditing: boolean;
1212
setIsEditing: React.Dispatch<React.SetStateAction<boolean>>;
1313
};
@@ -22,12 +22,12 @@ type TimerRootProps = PropsWithChildren & {
2222
export const TimerRoot = ({ timer, issue, children }: TimerRootProps) => {
2323
const timerApi = useTimerApi();
2424

25-
const [currentTime, setCurrentTime] = useState(() => calculateElapsedTime(timer));
25+
const [totalElapsedTime, setTotalElapsedTime] = useState(() => calculateTimerTotalElapsedTime(timer));
2626

27-
const updateTimer = useEffectEvent(() => setCurrentTime(calculateElapsedTime(timer)));
28-
useEffect(() => updateTimer(), [timer.elapsedTime]);
27+
const updateTimer = useEffectEvent(() => setTotalElapsedTime(calculateTimerTotalElapsedTime(timer)));
28+
useEffect(() => updateTimer(), [timer.elapsedTime, timer.activeSession]);
2929

30-
useInterval(() => setCurrentTime(calculateElapsedTime(timer)), timer.isActive ? 1000 : null);
30+
useInterval(() => setTotalElapsedTime(calculateTimerTotalElapsedTime(timer)), timer.activeSession ? 1000 : null);
3131

3232
const [isEditing, setIsEditing] = useState(false);
3333

@@ -37,7 +37,7 @@ export const TimerRoot = ({ timer, issue, children }: TimerRootProps) => {
3737
timer,
3838
timerApi,
3939
issue,
40-
currentTime,
40+
totalElapsedTime,
4141
isEditing,
4242
setIsEditing,
4343
}}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
2+
import { Button } from "@/components/ui/button";
3+
import { calculateActiveSessionElapsedTime } from "@/hooks/useTimers";
4+
import { formatTimer } from "@/utils/date";
5+
import clsx from "clsx";
6+
import { isToday } from "date-fns";
7+
import { TrashIcon } from "lucide-react";
8+
import { useState } from "react";
9+
import { useIntl } from "react-intl";
10+
import { useInterval } from "usehooks-ts";
11+
import { useTimerContext } from "./TimerRoot";
12+
13+
export const TimerSessions = () => {
14+
const { formatDateTimeRange } = useIntl();
15+
const { timer } = useTimerContext();
16+
17+
const [removingSessionId, setRemovingSessionId] = useState<string | null>(null);
18+
19+
if (!timer.activeSession && timer.sessions.length === 0) return null;
20+
21+
const visibleSessions = [...(timer.activeSession ? [{ id: "active", start: timer.activeSession.start, end: timer.activeSession.start }] : []), ...timer.sessions.toReversed()];
22+
23+
return (
24+
<>
25+
<div className="flex flex-col gap-y-0.5">
26+
{visibleSessions.map((session) => (
27+
<div key={session.id} className="text-muted-foreground flex items-center gap-2">
28+
<span className="grow">
29+
{formatDateTimeRange(session.start, session.end, {
30+
dateStyle: isToday(session.start) ? undefined : "short",
31+
timeStyle: "medium",
32+
})}
33+
</span>
34+
<span
35+
className={clsx({
36+
"font-semibold": session.id === "active",
37+
})}
38+
>
39+
{session.id === "active" ? <ActiveSessionElapsedTime /> : formatTimer(session.end - session.start)}
40+
</span>
41+
<Button type="button" variant="ghost" size="icon-xs" className="hover:text-destructive" onClick={() => setRemovingSessionId(session.id)}>
42+
<TrashIcon className="size-3.5" />
43+
</Button>
44+
</div>
45+
))}
46+
</div>
47+
48+
{removingSessionId && <RemoveSessionDialog sessionId={removingSessionId} onClose={() => setRemovingSessionId(null)} />}
49+
</>
50+
);
51+
};
52+
53+
const ActiveSessionElapsedTime = () => {
54+
const { timer } = useTimerContext();
55+
56+
const [elapsedTime, setElapsedTime] = useState(() => calculateActiveSessionElapsedTime(timer));
57+
useInterval(() => setElapsedTime(calculateActiveSessionElapsedTime(timer)), timer.activeSession ? 1000 : null);
58+
59+
return formatTimer(elapsedTime);
60+
};
61+
62+
const RemoveSessionDialog = ({ sessionId, onClose }: { sessionId: string; onClose: () => void }) => {
63+
const { formatDateTimeRange, formatMessage } = useIntl();
64+
const { timer, timerApi, totalElapsedTime } = useTimerContext();
65+
66+
const session = sessionId === "active" ? (timer.activeSession ? { id: "active", start: timer.activeSession.start, end: Date.now() } : undefined) : timer.sessions.find((s) => s.id === sessionId);
67+
if (!session) return null;
68+
69+
const duration = session.end - session.start;
70+
const resultingTimer = Math.max(0, totalElapsedTime - duration);
71+
72+
return (
73+
<AlertDialog open onOpenChange={() => onClose()}>
74+
<AlertDialogContent>
75+
<AlertDialogHeader>
76+
<AlertDialogTitle>{formatMessage({ id: "timer.modal.remove-session.title" })}</AlertDialogTitle>
77+
</AlertDialogHeader>
78+
79+
<div className="bg-muted/40 border-border/60 space-y-2 rounded-md border px-3 py-2 text-sm">
80+
<div className="flex items-center justify-between gap-3">
81+
<span className="text-muted-foreground">{formatMessage({ id: "timer.modal.remove-session.current" })}</span>
82+
<span>{formatTimer(totalElapsedTime)}</span>
83+
</div>
84+
<div className="flex items-center justify-between gap-3">
85+
<span className="text-muted-foreground/80 min-w-0 text-xs">
86+
{formatDateTimeRange(session.start, session.end, {
87+
dateStyle: isToday(session.start) ? undefined : "short",
88+
timeStyle: "medium",
89+
})}
90+
</span>
91+
<span className="text-destructive">-{formatTimer(duration)}</span>
92+
</div>
93+
<div className="border-border/60 flex items-center justify-between gap-3 border-t pt-2">
94+
<span className="text-muted-foreground">{formatMessage({ id: "timer.modal.remove-session.result" })}</span>
95+
<span>{formatTimer(resultingTimer)}</span>
96+
</div>
97+
</div>
98+
99+
<AlertDialogFooter>
100+
<AlertDialogCancel>{formatMessage({ id: "timer.modal.remove-session.cancel" })}</AlertDialogCancel>
101+
<AlertDialogAction
102+
variant="destructive"
103+
onClick={async () => {
104+
await timerApi.removeTimerSession(timer, sessionId);
105+
}}
106+
>
107+
{formatMessage({ id: "timer.modal.remove-session.submit" })}
108+
</AlertDialogAction>
109+
</AlertDialogFooter>
110+
</AlertDialogContent>
111+
</AlertDialog>
112+
);
113+
};

src/components/timer/timer/TimerToggleButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const TimerToggleButton = () => {
99

1010
const { timer, timerApi } = useTimerContext();
1111

12-
if (!timer.isActive) {
12+
if (!timer.activeSession) {
1313
return (
1414
<HelpTooltip message={formatMessage({ id: "issues.timer.action.start.tooltip" })}>
1515
<TimerIcon

src/components/timer/timer/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { TimerCounter, TimerCounterSkeleton } from "./TimerCounter";
33
import { TimerDoneButton, TimerDoneButtonSkeleton } from "./TimerDoneButton";
44
import { TimerNameField, TimerNameFieldSkeleton } from "./TimerNameField";
55
import { TimerRoot } from "./TimerRoot";
6+
import { TimerSessions } from "./TimerSessions";
67
import { TimerToggleButton, TimerToggleButtonSkeleton } from "./TimerToggleButton";
78
import { TimerWrapper, TimerWrapperCard } from "./TimerWrapper";
89

@@ -15,6 +16,7 @@ export const TimerComponents = {
1516
Counter: TimerCounter,
1617
ToggleButton: TimerToggleButton,
1718
DoneButton: TimerDoneButton,
19+
Sessions: TimerSessions,
1820
Skeleton: {
1921
NameField: TimerNameFieldSkeleton,
2022
Counter: TimerCounterSkeleton,

0 commit comments

Comments
 (0)