Skip to content

Commit ed42990

Browse files
authored
feat(timers): Add timer sessions (#69) (#71)
1 parent e966f7e commit ed42990

25 files changed

Lines changed: 518 additions & 350 deletions

src/components/issue/Issue.tsx

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,39 @@ import { useState } from "react";
88
import { useIntl } from "react-intl";
99
import { TIssue } from "../../api/redmine/types";
1010
import { LocalIssue } from "../../hooks/useLocalIssues";
11-
import { TimerController } from "../../hooks/useTimers";
11+
import { Timer } from "../../hooks/useTimers";
1212
import { useSettings } from "../../provider/SettingsProvider";
13+
import { useTimerApi } from "../../provider/TimerApiProvider";
1314
import { clsxm } from "../../utils/clsxm";
1415
import HelpTooltip from "../general/HelpTooltip";
1516
import { ToggleableCard } from "../general/ToggleableCard";
16-
import Timer from "../timer/timer";
17+
import { TimerComponents } from "../timer/timer";
1718
import { IssueTitle, IssueTitleSkeleton } from "./IssueTitle";
1819

1920
type PropTypes = {
2021
issue: TIssue;
2122
localIssue: LocalIssue;
2223
priorityType: PriorityType;
2324
assignedToMe: boolean;
24-
timers: TimerController[];
25-
onAddTimer: () => void;
25+
timers: Timer[];
2626
};
2727

28-
const Issue = ({ issue, localIssue, priorityType, assignedToMe, timers, onAddTimer }: PropTypes) => {
28+
const Issue = ({ issue, localIssue, priorityType, assignedToMe, timers }: PropTypes) => {
2929
const { formatMessage } = useIntl();
3030

3131
const { settings } = useSettings();
3232

3333
const { hasProjectPermission } = usePermissions();
3434
const canLogTime = hasProjectPermission(issue.project.id, "log_time");
3535

36+
const timerApi = useTimerApi();
37+
3638
const primaryTimer = timers[0]!;
3739

3840
const [areTimersExpanded, setAreTimersExpanded] = useState(false);
3941

4042
return (
41-
<IssueContextMenu issue={issue} localIssue={localIssue} primaryTimer={primaryTimer} assignedToMe={assignedToMe} onAddTimer={onAddTimer}>
43+
<IssueContextMenu issue={issue} localIssue={localIssue} primaryTimer={primaryTimer} assignedToMe={assignedToMe}>
4244
<ToggleableCard
4345
role="listitem"
4446
data-type="issue"
@@ -50,7 +52,7 @@ const Issue = ({ issue, localIssue, priorityType, assignedToMe, timers, onAddTim
5052
"border-priority-high-bg ring-priority-high-bg ring-1": priorityType === "high" || priorityType === "highest",
5153
}
5254
)}
53-
{...(canLogTime && { onToggle: () => primaryTimer.toggleTimer() })}
55+
{...(canLogTime && { onToggle: () => timerApi.toggleTimer(primaryTimer) })}
5456
>
5557
<IssueTitle
5658
issue={issue}
@@ -70,13 +72,13 @@ const Issue = ({ issue, localIssue, priorityType, assignedToMe, timers, onAddTim
7072
</div>
7173
{canLogTime && !areTimersExpanded && (
7274
<div>
73-
<Timer.Root timer={primaryTimer} issue={issue}>
74-
<Timer.Wrapper>
75-
<Timer.Counter />
76-
<Timer.ToggleButton />
77-
<Timer.DoneButton canLogTime={canLogTime} />
78-
</Timer.Wrapper>
79-
</Timer.Root>
75+
<TimerComponents.Root timer={primaryTimer} issue={issue}>
76+
<TimerComponents.Wrapper>
77+
<TimerComponents.Counter />
78+
<TimerComponents.ToggleButton />
79+
<TimerComponents.DoneButton canLogTime={canLogTime} />
80+
</TimerComponents.Wrapper>
81+
</TimerComponents.Root>
8082
{timers.length > 1 && (
8183
<button type="button" className="text-muted-foreground pl-3 text-xs" onClick={() => setAreTimersExpanded(true)}>
8284
{formatMessage({ id: "issues.timers.more-timers" }, { count: timers.length - 1 })}
@@ -88,16 +90,16 @@ const Issue = ({ issue, localIssue, priorityType, assignedToMe, timers, onAddTim
8890
{canLogTime && areTimersExpanded && (
8991
<div className="mt-2 flex flex-col gap-y-1">
9092
{timers.map((timer) => (
91-
<Timer.Root key={timer.id} timer={timer} issue={issue}>
92-
<Timer.ContextMenu>
93-
<Timer.WrapperCard>
94-
<Timer.NameField />
95-
<Timer.Counter />
96-
<Timer.ToggleButton />
97-
<Timer.DoneButton canLogTime={canLogTime} />
98-
</Timer.WrapperCard>
99-
</Timer.ContextMenu>
100-
</Timer.Root>
93+
<TimerComponents.Root key={timer.id} timer={timer} issue={issue}>
94+
<TimerComponents.ContextMenu>
95+
<TimerComponents.WrapperCard>
96+
<TimerComponents.NameField />
97+
<TimerComponents.Counter />
98+
<TimerComponents.ToggleButton />
99+
<TimerComponents.DoneButton canLogTime={canLogTime} />
100+
</TimerComponents.WrapperCard>
101+
</TimerComponents.ContextMenu>
102+
</TimerComponents.Root>
101103
))}
102104
</div>
103105
)}
@@ -125,11 +127,11 @@ export const IssueSkeleton = () => (
125127
<div className="mt-0.5">
126128
<Skeleton className="h-5.5 w-20 rounded-sm" />
127129
</div>
128-
<Timer.Wrapper>
129-
<Timer.Skeleton.Counter />
130-
<Timer.Skeleton.ToggleButton />
131-
<Timer.Skeleton.DoneButton />
132-
</Timer.Wrapper>
130+
<TimerComponents.Wrapper>
131+
<TimerComponents.Skeleton.Counter />
132+
<TimerComponents.Skeleton.ToggleButton />
133+
<TimerComponents.Skeleton.DoneButton />
134+
</TimerComponents.Wrapper>
133135
</div>
134136
</ToggleableCard>
135137
);

src/components/issue/IssueContextMenu.tsx

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,18 @@ 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 { TimerController } from "../../hooks/useTimers";
22+
import { calculateTimerTotalElapsedTime, Timer } from "../../hooks/useTimers";
2323
import { useSettings } from "../../provider/SettingsProvider";
24+
import { useTimerApi } from "../../provider/TimerApiProvider";
2425
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "../ui/context-menu";
2526
import AddIssueNotesModal from "./AddIssueNotesModal";
2627
import EditIssueModal from "./EditIssueModal";
2728

2829
type PropTypes = {
2930
issue: TIssue;
3031
localIssue: LocalIssue;
31-
primaryTimer: TimerController;
32+
primaryTimer: Timer;
3233
assignedToMe: boolean;
33-
onAddTimer: () => void;
3434
};
3535

3636
export const IssueContextMenu = ({ children, ...props }: PropTypes & { children: ReactElement }) => {
@@ -52,9 +52,10 @@ export const IssueContextMenu = ({ children, ...props }: PropTypes & { children:
5252
);
5353
};
5454

55-
const IssueContextMenuItems = ({ issue, localIssue, primaryTimer, assignedToMe, onAddTimer, onEdit, onAddNotes }: PropTypes & { onEdit: () => void; onAddNotes: () => void }) => {
55+
const IssueContextMenuItems = ({ issue, localIssue, primaryTimer, assignedToMe, onEdit, onAddNotes }: PropTypes & { onEdit: () => void; onAddNotes: () => void }) => {
5656
const { formatMessage } = useIntl();
5757
const { settings } = useSettings();
58+
const timerApi = useTimerApi();
5859

5960
const { data: me } = useRedmineCurrentUser();
6061

@@ -90,15 +91,15 @@ const IssueContextMenuItems = ({ issue, localIssue, primaryTimer, assignedToMe,
9091
{formatMessage({ id: "issues.context-menu.add-notes" })}
9192
</ContextMenuItem>
9293
<ContextMenuSeparator />
93-
<ContextMenuItem onClick={primaryTimer.isActive ? primaryTimer.pauseTimer : primaryTimer.startTimer} disabled={!canLogTime}>
94-
{primaryTimer.isActive ? <TimerOffIcon /> : <TimerIcon />}
95-
{formatMessage({ id: primaryTimer.isActive ? "timer.context-menu.pause" : "timer.context-menu.start" })}
94+
<ContextMenuItem onClick={primaryTimer.activeSession ? () => timerApi.pauseTimer(primaryTimer) : () => timerApi.startTimer(primaryTimer)} disabled={!canLogTime}>
95+
{primaryTimer.activeSession ? <TimerOffIcon /> : <TimerIcon />}
96+
{formatMessage({ id: primaryTimer.activeSession ? "timer.context-menu.pause" : "timer.context-menu.start" })}
9697
</ContextMenuItem>
97-
<ContextMenuItem onClick={primaryTimer.resetTimer} disabled={primaryTimer.getElapsedTime() === 0 || !canLogTime}>
98+
<ContextMenuItem onClick={() => timerApi.resetTimer(primaryTimer)} disabled={calculateTimerTotalElapsedTime(primaryTimer) === 0 || !canLogTime}>
9899
<TimerResetIcon />
99100
{formatMessage({ id: "timer.context-menu.reset" })}
100101
</ContextMenuItem>
101-
<ContextMenuItem onClick={onAddTimer} disabled={!canLogTime}>
102+
<ContextMenuItem onClick={() => timerApi.addTimer(issue.id)} disabled={!canLogTime}>
102103
<PlusIcon />
103104
{formatMessage({ id: "timer.context-menu.add-timer" })}
104105
</ContextMenuItem>

src/components/issue/ProjectIssuesGroup.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { usePermissions } from "@/provider/PermissionsProvider";
77
import { useSettings } from "@/provider/SettingsProvider";
88
import { clsxm } from "@/utils/clsxm";
99
import { ProjectIssuesGroup as ProjectIssuesGroupType } from "@/utils/groupIssues";
10+
import { randomElement } from "@/utils/random";
1011
import clsx from "clsx";
1112
import { PinIcon, PlusIcon, SearchIcon, SquareChartGanttIcon, SquareMousePointerIcon, TimerIcon } from "lucide-react";
1213
import { ComponentProps, Fragment, useState } from "react";
@@ -18,7 +19,6 @@ import { Badge } from "../ui/badge";
1819
import CreateIssueModal from "./CreateIssueModal";
1920
import Issue, { IssueSkeleton } from "./Issue";
2021
import { useIssueSearch } from "./IssueSearch";
21-
import { randomElement } from "@/utils/random";
2222

2323
interface ProjectIssuesGroupProps extends ComponentProps<"div"> {
2424
projectGroup: ProjectIssuesGroupType;
@@ -47,7 +47,6 @@ export const ProjectIssuesGroup = ({ projectGroup, localIssues, timers, classNam
4747
priorityType={getPriorityType(issue)}
4848
assignedToMe={me ? me.id === issue.assigned_to?.id : true}
4949
timers={timers.getTimersByIssue(issue.id)}
50-
onAddTimer={() => timers.addTimer(issue.id)}
5150
/>
5251
))}
5352
</Fragment>

src/components/time-entry/CreateTimeEntryModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useRedmineProjectTimeEntryActivities } from "@/api/redmine/hooks/useRed
44
import { redmineIssuesQueries } from "@/api/redmine/queries/issues";
55
import { redmineTimeEntriesQueries } from "@/api/redmine/queries/timeEntries";
66
import { usePersistentComments } from "@/hooks/usePersistentComments";
7-
import { TimerController } from "@/hooks/useTimers";
7+
import { Timer } from "@/hooks/useTimers";
88
import { useMutation, useQueryClient } from "@tanstack/react-query";
99
import { startOfDay } from "date-fns";
1010
import { useIntl } from "react-intl";
@@ -24,7 +24,7 @@ import UserField from "./form/fields/UserField";
2424
import TimeEntryPreview from "./TimeEntryPreview";
2525

2626
type PropTypes = {
27-
timer: TimerController;
27+
timer: Timer;
2828
issue: TIssue;
2929
initialValues: Partial<TCreateTimeEntryForm>;
3030
onClose: () => void;

src/components/timer/CurrentIssueTimer.tsx

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

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

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

2525
return (
26-
<Timer.Root timer={primaryTimer} issue={issue}>
27-
<Timer.ContextMenu>
28-
<Timer.WrapperCard>
29-
<Timer.Counter />
30-
<Timer.ToggleButton />
31-
<Timer.DoneButton canLogTime={canLogTime} />
32-
</Timer.WrapperCard>
33-
</Timer.ContextMenu>
34-
</Timer.Root>
26+
<TimerComponents.Root timer={primaryTimer} issue={issue}>
27+
<TimerComponents.ContextMenu>
28+
<TimerComponents.WrapperCard>
29+
<TimerComponents.Counter />
30+
<TimerComponents.ToggleButton />
31+
<TimerComponents.DoneButton canLogTime={canLogTime} />
32+
</TimerComponents.WrapperCard>
33+
</TimerComponents.ContextMenu>
34+
</TimerComponents.Root>
3535
);
3636
};
3737

src/components/timer/ProjectTimersGroup.tsx

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { useRedmineIssuePriorities } from "@/api/redmine/hooks/useRedmineIssuePriorities";
22
import { ToggleableCard } from "@/components/general/ToggleableCard";
33
import { IssueTitle, IssueTitleSkeleton } from "@/components/issue/IssueTitle";
4-
import Timer from "@/components/timer/timer";
4+
import { TimerComponents } from "@/components/timer/timer";
55
import { Skeleton } from "@/components/ui/skeleton";
66
import { usePermissions } from "@/provider/PermissionsProvider";
77
import { useSettings } from "@/provider/SettingsProvider";
8+
import { useTimerApi } from "@/provider/TimerApiProvider";
89
import { clsxm } from "@/utils/clsxm";
910
import { ProjectTimersGroup as ProjectTimersGroupType } from "@/utils/groupTimers";
1011
import { randomElement } from "@/utils/random";
@@ -21,6 +22,8 @@ interface ProjectTimersGroupProps extends ComponentProps<"div"> {
2122
export const ProjectTimersGroup = ({ projectGroup, className, ...props }: ProjectTimersGroupProps) => {
2223
const { settings } = useSettings();
2324

25+
const timerApi = useTimerApi();
26+
2427
const { hasProjectPermission } = usePermissions();
2528

2629
const { getPriorityType } = useRedmineIssuePriorities({ enabled: settings.style.showIssuesPriority });
@@ -29,19 +32,20 @@ export const ProjectTimersGroup = ({ projectGroup, className, ...props }: Projec
2932
<div {...props} className={clsxm("flex flex-col gap-y-2", className)}>
3033
<TimerProject type={projectGroup.type} project={projectGroup.project} />
3134
{projectGroup.items.map(({ timer, issue }) => (
32-
<Timer.Root key={timer.id} timer={timer} issue={issue}>
33-
<Timer.ContextMenu>
34-
<ToggleableCard role="listitem" data-type="timer-card" className="flex flex-col gap-1" onToggle={() => timer.toggleTimer()}>
35+
<TimerComponents.Root key={timer.id} timer={timer} issue={issue}>
36+
<TimerComponents.ContextMenu>
37+
<ToggleableCard role="listitem" data-type="timer-card" className="flex flex-col gap-1" onToggle={() => timerApi.toggleTimer(timer)}>
3538
{issue ? <IssueTitle issue={issue} priorityType={getPriorityType(issue)} /> : <h1 className="truncate text-gray-500 line-through">#{timer.issueId}</h1>}
36-
<Timer.Wrapper>
37-
<Timer.NameField />
38-
<Timer.Counter />
39-
<Timer.ToggleButton />
40-
<Timer.DoneButton canLogTime={issue ? hasProjectPermission(issue.project.id, "log_time") : false} />
41-
</Timer.Wrapper>
39+
<TimerComponents.Wrapper>
40+
<TimerComponents.NameField />
41+
<TimerComponents.Counter />
42+
<TimerComponents.ToggleButton />
43+
<TimerComponents.DoneButton canLogTime={issue ? hasProjectPermission(issue.project.id, "log_time") : false} />
44+
</TimerComponents.Wrapper>
45+
<TimerComponents.Sessions />
4246
</ToggleableCard>
43-
</Timer.ContextMenu>
44-
</Timer.Root>
47+
</TimerComponents.ContextMenu>
48+
</TimerComponents.Root>
4549
))}
4650
</div>
4751
);
@@ -78,12 +82,13 @@ export const ProjectTimersGroupSkeleton = ({ groups }: { groups: number[] }) =>
7882
{groups.map((key) => (
7983
<ToggleableCard key={key} className="flex flex-col gap-1">
8084
<IssueTitleSkeleton />
81-
<Timer.Wrapper>
82-
<Timer.Skeleton.NameField />
83-
<Timer.Skeleton.Counter />
84-
<Timer.Skeleton.ToggleButton />
85-
<Timer.Skeleton.DoneButton />
86-
</Timer.Wrapper>
85+
<TimerComponents.Wrapper>
86+
<TimerComponents.Skeleton.NameField />
87+
<TimerComponents.Skeleton.Counter />
88+
<TimerComponents.Skeleton.ToggleButton />
89+
<TimerComponents.Skeleton.DoneButton />
90+
</TimerComponents.Wrapper>
91+
<TimerComponents.Skeleton.Sessions />
8792
</ToggleableCard>
8893
))}
8994
</div>

src/components/timer/timer/TimerContextMenu.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useTimerApi } from "@/provider/TimerApiProvider";
12
import { PencilIcon, TimerIcon, TimerOffIcon, TimerResetIcon, TrashIcon } from "lucide-react";
23
import { ReactElement } from "react";
34
import { useIntl } from "react-intl";
@@ -17,23 +18,24 @@ export const TimerContextMenu = ({ children }: { children: ReactElement }) => {
1718

1819
const TimerContextMenuItems = () => {
1920
const { formatMessage } = useIntl();
20-
const { timer, setIsEditing } = useTimerContext();
21+
const timerApi = useTimerApi();
22+
const { timer, totalElapsedTime, setIsEditing } = useTimerContext();
2123

2224
return (
2325
<>
24-
<ContextMenuItem onClick={timer.isActive ? timer.pauseTimer : timer.startTimer}>
25-
{timer.isActive ? <TimerOffIcon /> : <TimerIcon />}
26-
{formatMessage({ id: timer.isActive ? "timer.context-menu.pause" : "timer.context-menu.start" })}
26+
<ContextMenuItem onClick={timer.activeSession ? () => timerApi.pauseTimer(timer) : () => timerApi.startTimer(timer)}>
27+
{timer.activeSession ? <TimerOffIcon /> : <TimerIcon />}
28+
{formatMessage({ id: timer.activeSession ? "timer.context-menu.pause" : "timer.context-menu.start" })}
2729
</ContextMenuItem>
2830
<ContextMenuItem onClick={() => setIsEditing(true)}>
2931
<PencilIcon />
3032
{formatMessage({ id: "timer.context-menu.edit" })}
3133
</ContextMenuItem>
32-
<ContextMenuItem onClick={timer.resetTimer} disabled={timer.getElapsedTime() === 0}>
34+
<ContextMenuItem onClick={() => timerApi.resetTimer(timer)} disabled={totalElapsedTime === 0}>
3335
<TimerResetIcon />
3436
{formatMessage({ id: "timer.context-menu.reset" })}
3537
</ContextMenuItem>
36-
<ContextMenuItem onClick={timer.deleteTimer}>
38+
<ContextMenuItem onClick={() => timerApi.deleteTimer(timer)}>
3739
<TrashIcon />
3840
{formatMessage({ id: "timer.context-menu.delete" })}
3941
</ContextMenuItem>

0 commit comments

Comments
 (0)