Skip to content

Commit d7ee552

Browse files
committed
feat: Added time entries overview page
1 parent 4710091 commit d7ee552

File tree

13 files changed

+271
-18
lines changed

13 files changed

+271
-18
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
- Search for issue (press `CTRL` + `K` or `CTRL` + `F`)
2828
- Remember and forgot issue (not assigned to you)
2929
- Favorite and unfavorite issue
30+
- View time entries overview
3031
- Dark & light mode (system default)
3132

3233
# Screenshots

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "redmine-time-tracking",
33
"description": "Redmine Time Tracking",
4-
"version": "1.5.0",
4+
"version": "1.5.1",
55
"author": {
66
"name": "CrawlerCode",
77
"email": "[email protected]"
@@ -27,6 +27,7 @@
2727
"@tanstack/react-query": "^4.29.12",
2828
"axios": "^1.4.0",
2929
"clsx": "^1.2.1",
30+
"date-fns": "^2.30.0",
3031
"deepmerge": "^4.3.1",
3132
"formik": "^2.4.1",
3233
"react": "^18.2.0",

pnpm-lock.yaml

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"manifest_version": 3,
33
"name": "Redmine Time Tracking",
4-
"version": "1.5.0",
4+
"version": "1.5.1",
55
"description": "Start-stop timer for Redmine",
66
"icons": {
77
"16": "logo16.png",

src/App.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { faGear, faList } from "@fortawesome/free-solid-svg-icons";
1+
import { faGear, faList, faStopwatch } from "@fortawesome/free-solid-svg-icons";
22
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
33
import clsx from "clsx";
44
import { Link, Navigate, Route, Routes, useLocation } from "react-router-dom";
55
import Toast from "./components/general/Toast";
66
import IssuesPage from "./pages/IssuesPage";
77
import SettingsPage from "./pages/SettingsPage";
8+
import TimePage from "./pages/TimePage";
89

910
function App() {
1011
const location = useLocation();
@@ -32,6 +33,20 @@ function App() {
3233
Issues
3334
</Link>
3435
</li>
36+
<li>
37+
<Link
38+
to="/time"
39+
className={clsx(
40+
"inline-flex items-center gap-x-1 p-2 rounded-t-lg",
41+
location.pathname === "/time" ? "text-primary-600 border-b-2 border-primary-600 dark:text-primary-500 dark:border-primary-500" : "border-b-2 border-transparent hover:text-gray-600 hover:border-gray-30 dark:hover:text-gray-300",
42+
"focus:ring-2 focus:ring-primary-300 focus:outline-none dark:focus:ring-primary-600 select-none"
43+
)}
44+
tabIndex={-1}
45+
>
46+
<FontAwesomeIcon icon={faStopwatch} />
47+
Time
48+
</Link>
49+
</li>
3550
<li>
3651
<Link
3752
to="/settings"
@@ -52,6 +67,7 @@ function App() {
5267
<Routes>
5368
<Route index element={<Navigate to="/issues" replace />} />
5469
<Route path="/issues" element={<IssuesPage />} />
70+
<Route path="/time" element={<TimePage />} />
5571
<Route path="/settings" element={<SettingsPage />} />
5672
<Route path="*" element={<Toast type="error" message="Page not found!" allowClose={false} />} />
5773
</Routes>

src/api/redmine.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { TAccount, TCreateTimeEntry, TIssue, TSearchResult, TTimeEntryActivity } from "../types/redmine";
1+
import { formatISO } from "date-fns";
2+
import { TAccount, TCreateTimeEntry, TIssue, TSearchResult, TTimeEntry, TTimeEntryActivity } from "../types/redmine";
23
import instance from "./axios.config";
34

45
export const getMyAccount = async (): Promise<TAccount> => {
56
return instance.get("/my/account.json").then((res) => res.data.user);
67
};
78

9+
// Issues
810
export const getAllMyOpenIssues = async (offset = 0, limit = 100): Promise<TIssue[]> => {
911
return instance.get(`/issues.json?offset=${offset}&limit=${limit}&status_id=open&assigned_to_id=me&sort=updated_on:desc`).then((res) => res.data.issues);
1012
};
@@ -13,12 +15,8 @@ export const getOpenIssues = async (ids: number[], offset = 0, limit = 100): Pro
1315
return instance.get(`/issues.json?offset=${offset}&limit=${limit}&status_id=open&issue_id=${ids.join(",")}&sort=updated_on:desc`).then((res) => res.data.issues);
1416
};
1517

16-
export const createTimeEntry = async (entry: TCreateTimeEntry) => {
17-
return instance
18-
.post("/time_entries.json", {
19-
time_entry: entry,
20-
})
21-
.then((res) => res.data);
18+
export const searchOpenIssues = async (query: string): Promise<TSearchResult[]> => {
19+
return instance.get(`/search.json?q=${query}&scope=my_project&titles_only=1&issues=1&open_issues=1`).then((res) => res.data.results);
2220
};
2321

2422
export const updateIssue = async (id: number, issue: Partial<Omit<TIssue, "id">>) => {
@@ -29,10 +27,19 @@ export const updateIssue = async (id: number, issue: Partial<Omit<TIssue, "id">>
2927
.then((res) => res.data);
3028
};
3129

32-
export const getTimeEntryActivities = async (): Promise<TTimeEntryActivity[]> => {
33-
return instance.get("/enumerations/time_entry_activities.json").then((res) => res.data.time_entry_activities);
30+
// Time entries
31+
export const getAllMyTimeEntries = async (from: Date, to: Date, offset = 0, limit = 100): Promise<TTimeEntry[]> => {
32+
return instance.get(`/time_entries.json?offset=${offset}&limit=${limit}&user_id=me&from=${formatISO(from, { representation: "date" })}&to=${formatISO(to, { representation: "date" })}`).then((res) => res.data.time_entries);
3433
};
3534

36-
export const searchOpenIssues = async (query: string): Promise<TSearchResult[]> => {
37-
return instance.get(`/search.json?q=${query}&scope=my_project&titles_only=1&issues=1&open_issues=1`).then((res) => res.data.results);
35+
export const createTimeEntry = async (entry: TCreateTimeEntry) => {
36+
return instance
37+
.post("/time_entries.json", {
38+
time_entry: entry,
39+
})
40+
.then((res) => res.data);
41+
};
42+
43+
export const getTimeEntryActivities = async (): Promise<TTimeEntryActivity[]> => {
44+
return instance.get("/enumerations/time_entry_activities.json").then((res) => res.data.time_entry_activities);
3845
};

src/components/issues/Issue.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { TIssue } from "../../types/redmine";
99
import ContextMenu from "../general/ContextMenu";
1010
import KBD from "../general/KBD";
1111
import CreateTimeEntryModal from "./CreateTimeEntryModal";
12-
import IssueInfoToolkit from "./IssueInfoToolkit";
12+
import IssueInfoTooltip from "./IssueInfoTooltip";
1313
import IssueTimer, { IssueTimerData, TimerActions, TimerRef } from "./IssueTimer";
1414

1515
export type IssueActions = {
@@ -140,7 +140,7 @@ const Issue = ({ issue, timerData, assignedToMe, favorite, remember, onStart, on
140140
</a>{" "}
141141
{issue.subject}
142142
</h1>
143-
<IssueInfoToolkit issue={issue} />
143+
<IssueInfoTooltip issue={issue} />
144144
<div className="flex flex-row justify-between gap-x-2">
145145
<div className="mt-1">
146146
<div className="w-[80px] bg-[#eeeeee]">

src/components/issues/IssueInfoToolkit.tsx renamed to src/components/issues/IssueInfoTooltip.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ type PropTypes = {
66
issue: TIssue;
77
};
88

9-
const IssueInfoToolkit = ({ issue }: PropTypes) => {
9+
const IssueInfoTooltip = ({ issue }: PropTypes) => {
1010
return (
1111
<Tooltip id={`tooltip-issue-${issue.id}`} place="right" className="z-10 opacity-100">
1212
<div className="relative max-w-[210px]">
@@ -48,4 +48,4 @@ const IssueInfoToolkit = ({ issue }: PropTypes) => {
4848
);
4949
};
5050

51-
export default IssueInfoToolkit;
51+
export default IssueInfoTooltip;

src/components/time/TimeEntry.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Tooltip } from "react-tooltip";
2+
import { TTimeEntry } from "../../types/redmine";
3+
4+
type PropTypes = {
5+
entries: TTimeEntry[];
6+
maxHours?: number;
7+
};
8+
9+
const TimeEntry = ({ entries, maxHours = 24 }: PropTypes) => {
10+
const hours = entries.reduce((sum, entry) => sum + entry.hours, 0);
11+
return (
12+
<div className="flex gap-x-0.5 items-center">
13+
{entries.map((entry) => (
14+
<>
15+
<Tooltip id={`tooltip-time-entry-${entry.id}`} place="bottom" className="z-10 opacity-100">
16+
<h4 className="text-base">
17+
{entry.issue ? (
18+
<>
19+
#{entry.issue?.id} <span className="text-sm">({entry.hours} h)</span>
20+
</>
21+
) : (
22+
<>{entry.hours} h</>
23+
)}
24+
</h4>
25+
<p>{entry.comments}</p>
26+
</Tooltip>
27+
<div
28+
className="h-4 bg-primary rounded"
29+
style={{
30+
width: `${(entry.hours / maxHours) * 100}%`,
31+
}}
32+
data-tooltip-id={`tooltip-time-entry-${entry.id}`}
33+
/>
34+
</>
35+
))}
36+
<div
37+
className="h-3 bg-gray-400/40 dark:bg-gray-700/40 rounded"
38+
style={{
39+
width: `${((maxHours - hours) / maxHours) * 100}%`,
40+
backgroundSize: "1rem 1rem",
41+
backgroundImage: "linear-gradient(45deg,rgba(255,255,255,.05) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.05) 50%,rgba(255,255,255,.05) 75%,transparent 75%,transparent)",
42+
}}
43+
/>
44+
</div>
45+
);
46+
};
47+
48+
export default TimeEntry;
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { addDays, format, isFuture, isWeekend, parseISO, previousMonday, startOfDay, subWeeks } from "date-fns";
2+
import { TTimeEntry } from "../../types/redmine";
3+
import TimeEntry from "./TimeEntry";
4+
5+
type PropTypes = {
6+
entries: TTimeEntry[];
7+
};
8+
9+
const TimeEntryList = ({ entries }: PropTypes) => {
10+
const groupedEntries = Object.values(
11+
entries.reduce(
12+
(
13+
result: {
14+
[date: string]: {
15+
date: Date;
16+
hours: number;
17+
entries: TTimeEntry[];
18+
};
19+
},
20+
entry
21+
) => {
22+
if (!(entry.spent_on in result)) {
23+
result[entry.spent_on] = {
24+
date: parseISO(entry.spent_on),
25+
hours: 0,
26+
entries: [],
27+
};
28+
}
29+
result[entry.spent_on].entries.push(entry);
30+
return result;
31+
},
32+
{}
33+
)
34+
).map((group) => {
35+
group.entries.sort((a, b) => b.hours - a.hours);
36+
group.hours = group.entries.reduce((sum, entry) => sum + entry.hours, 0);
37+
return group;
38+
});
39+
40+
const maxHours = Math.max(...groupedEntries.map(({ hours }) => hours));
41+
42+
return (
43+
<>
44+
{Array(2)
45+
.fill(previousMonday(startOfDay(new Date())))
46+
.map((d, i) => subWeeks(d, i))
47+
.map((monday) => {
48+
const days = Array(7)
49+
.fill(monday)
50+
.map((d, i) => addDays(d, 6 - i));
51+
return (
52+
<div className="flex flex-col gap-y-1 mb-5">
53+
<div className="flex items-center gap-x-3">
54+
<h1 className="text-lg">
55+
{monday.toLocaleDateString()} - {addDays(monday, 6).toLocaleDateString()}
56+
</h1>
57+
<span className="bg-primary-100 text-primary-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded dark:bg-gray-700 dark:text-primary-400 border border-primary-400">
58+
<svg aria-hidden="true" className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
59+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"></path>
60+
</svg>
61+
{groupedEntries.filter((entries) => days.find((d) => d.getTime() === entries.date.getTime())).reduce((sum, entry) => sum + entry.hours, 0)} h
62+
</span>
63+
</div>
64+
{days.map((d) => {
65+
if (isFuture(d)) return;
66+
const {
67+
date,
68+
hours,
69+
entries: groupEntries,
70+
} = groupedEntries.find((entries) => entries.date.getTime() === d.getTime()) ?? {
71+
date: d,
72+
hours: 0,
73+
entries: [],
74+
};
75+
if (isWeekend(date) && hours === 0) return;
76+
return (
77+
<div className="grid grid-cols-10 items-center gap-x-1">
78+
<h4 className="col-span-1 text-sm">{format(date, "EEE")}</h4>
79+
<h3 className="col-span-2 text-sm text-end font-semibold truncate">{hours} h</h3>
80+
<div className="col-span-7">
81+
<TimeEntry entries={groupEntries} maxHours={maxHours} />
82+
</div>
83+
</div>
84+
);
85+
})}
86+
</div>
87+
);
88+
})}
89+
</>
90+
);
91+
};
92+
93+
export default TimeEntryList;

0 commit comments

Comments
 (0)