feat: add time-entries route with crud and timers
This commit is contained in:
parent
f3549a9d73
commit
81273a8a9a
63
app/components/SectionTimeElapsed.tsx
Normal file
63
app/components/SectionTimeElapsed.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { TimeEntry } from '~/models/timeEntry.server';
|
||||
import { Text } from '@mantine/core';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export interface Props {
|
||||
timeEntries: TimeEntry[];
|
||||
total?: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
additionalLabel?: string;
|
||||
}
|
||||
|
||||
const SectionTimeElapsed = ({
|
||||
timeEntries,
|
||||
total,
|
||||
size = 'sm',
|
||||
additionalLabel
|
||||
}: Props) => {
|
||||
const getElapsedTime = useCallback(
|
||||
() =>
|
||||
timeEntries.reduce((acc, timeEntry) => {
|
||||
if (!timeEntry.endTime) {
|
||||
const diff =
|
||||
(Date.now() - new Date(timeEntry.startTime).getTime()) / 1000;
|
||||
return acc + diff;
|
||||
}
|
||||
return (
|
||||
acc +
|
||||
(new Date(timeEntry.endTime).getTime() -
|
||||
new Date(timeEntry.startTime).getTime()) /
|
||||
1000
|
||||
);
|
||||
}, 0),
|
||||
[timeEntries]
|
||||
);
|
||||
|
||||
const [elapsed, setElapsed] = useState(total || getElapsedTime());
|
||||
|
||||
useEffect(() => {
|
||||
if (!timeEntries.some((timeEntry) => !timeEntry.endTime)) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setElapsed(getElapsedTime());
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [timeEntries, getElapsedTime]);
|
||||
|
||||
const hours = Math.floor(elapsed / 60 / 60);
|
||||
const minutes = Math.floor((elapsed - hours * 60 * 60) / 60);
|
||||
const seconds = Math.floor(elapsed - hours * 60 * 60 - minutes * 60);
|
||||
|
||||
const hoursString = `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
|
||||
return (
|
||||
<Text size={size} color="darkgray">
|
||||
{`${hoursString}${additionalLabel ? ` ${additionalLabel}` : ''}`}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionTimeElapsed;
|
||||
81
app/components/TimeElapsed.tsx
Normal file
81
app/components/TimeElapsed.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
export interface Props {
|
||||
startTime: Date | string;
|
||||
endTime?: Date | string | null;
|
||||
}
|
||||
|
||||
const TimeElapsed = ({ startTime, endTime }: Props) => {
|
||||
const [elapsed, setElapsed] = useState(
|
||||
(new Date(endTime || Date.now()).getTime() -
|
||||
new Date(startTime).getTime()) /
|
||||
1000
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (endTime) return;
|
||||
const interval = setInterval(() => {
|
||||
setElapsed(
|
||||
(new Date(endTime || Date.now()).getTime() -
|
||||
new Date(startTime).getTime()) /
|
||||
1000
|
||||
);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [startTime, endTime]);
|
||||
|
||||
const hours = Math.floor(elapsed / 60 / 60);
|
||||
const minutes = Math.floor((elapsed - hours * 60 * 60) / 60);
|
||||
const seconds = Math.floor(elapsed - hours * 60 * 60 - minutes * 60);
|
||||
|
||||
const hoursString = `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
margin: '0 1rem'
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
fontSize: '1rem',
|
||||
margin: 0
|
||||
}}
|
||||
>
|
||||
<code>{hoursString}</code>
|
||||
</pre>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
margin: 0
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{Intl.DateTimeFormat('it-IT', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}).format(new Date(startTime))}
|
||||
</span>
|
||||
<span dangerouslySetInnerHTML={{ __html: ' — ' }} />
|
||||
<span>
|
||||
{endTime
|
||||
? Intl.DateTimeFormat('it-IT', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}).format(new Date(endTime))
|
||||
: 'now'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeElapsed;
|
||||
400
app/routes/time-entries.tsx
Normal file
400
app/routes/time-entries.tsx
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
Text,
|
||||
Menu,
|
||||
ActionIcon,
|
||||
Textarea,
|
||||
Pagination,
|
||||
NativeSelect,
|
||||
Group,
|
||||
Divider,
|
||||
useMantineTheme,
|
||||
Progress,
|
||||
Badge,
|
||||
ThemeIcon,
|
||||
Alert
|
||||
} from '@mantine/core';
|
||||
import { json, LoaderArgs, MetaFunction, redirect } from '@remix-run/node';
|
||||
import {
|
||||
Form,
|
||||
Link,
|
||||
Outlet,
|
||||
useCatch,
|
||||
useLoaderData,
|
||||
useSearchParams
|
||||
} from '@remix-run/react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Edit,
|
||||
Edit3,
|
||||
Play,
|
||||
Power,
|
||||
Settings,
|
||||
Square,
|
||||
Trash
|
||||
} from 'react-feather';
|
||||
import { requireUserId } from '~/session.server';
|
||||
import { getTimeEntries, TimeEntry } from '~/models/timeEntry.server';
|
||||
import TimeElapsed from '~/components/TimeElapsed';
|
||||
import SectionTimeElapsed from '~/components/SectionTimeElapsed';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return {
|
||||
title: 'Time entries | WorkTimer',
|
||||
description: 'Manage your time entries. You must be logged in to do this.'
|
||||
};
|
||||
};
|
||||
|
||||
export async function loader({ request }: LoaderArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
if (!userId) return redirect('/login');
|
||||
|
||||
const url = new URL(request.url);
|
||||
const page = url.searchParams.get('page')
|
||||
? parseInt(url.searchParams.get('page')!, 10)
|
||||
: 1;
|
||||
const size = url.searchParams.get('size')
|
||||
? parseInt(url.searchParams.get('size')!, 10)
|
||||
: 25;
|
||||
const orderBy = url.searchParams.get('orderBy') || 'createdAt';
|
||||
const order = url.searchParams.get('order') || 'desc';
|
||||
|
||||
return json({
|
||||
...(await getTimeEntries({
|
||||
page,
|
||||
size,
|
||||
userId,
|
||||
orderBy,
|
||||
order: order === 'asc' ? 'asc' : 'desc'
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
export default function TimeEntriesPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const pageSize = useMemo(() => {
|
||||
return parseInt(searchParams.get('size') || '25', 10);
|
||||
}, [searchParams]);
|
||||
const page = useMemo(() => {
|
||||
return parseInt(searchParams.get('page') || '1', 10);
|
||||
}, [searchParams]);
|
||||
|
||||
const timeEntriesPerDay = useMemo(() => {
|
||||
const timeEntriesPerDay: Record<
|
||||
string,
|
||||
{ entries: typeof data.timeEntries; total: number }
|
||||
> = {};
|
||||
data.timeEntries.forEach((timeEntry) => {
|
||||
const date = Intl.DateTimeFormat('it-IT', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).format(new Date(timeEntry.startTime));
|
||||
|
||||
if (!timeEntriesPerDay[date])
|
||||
timeEntriesPerDay[date] = { entries: [], total: 0 };
|
||||
timeEntriesPerDay[date].total +=
|
||||
(timeEntry.endTime
|
||||
? new Date(timeEntry.endTime).getTime() -
|
||||
new Date(timeEntry.startTime).getTime()
|
||||
: Date.now() - new Date(timeEntry.startTime).getTime()) / 1000;
|
||||
timeEntriesPerDay[date].entries.push(timeEntry);
|
||||
});
|
||||
return timeEntriesPerDay;
|
||||
}, [data.timeEntries]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Paper
|
||||
component="fieldset"
|
||||
aria-controls="time-entries"
|
||||
p="sm"
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap'
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/time-entries/new"
|
||||
variant="light"
|
||||
radius={theme.radius.md}
|
||||
leftIcon={<Play />}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
<NativeSelect
|
||||
data={[
|
||||
{ label: '25 / page', value: '25' },
|
||||
{ label: '50 / page', value: '50' },
|
||||
{ label: '100 / page', value: '100' }
|
||||
]}
|
||||
value={pageSize}
|
||||
onChange={(event) => {
|
||||
setSearchParams({
|
||||
page: page.toString(),
|
||||
size: event.currentTarget.value
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{data.total / pageSize > 1 && (
|
||||
<Pagination
|
||||
style={{ marginLeft: 10 }}
|
||||
page={page}
|
||||
total={Math.ceil(data.total / pageSize)}
|
||||
onChange={(page) => {
|
||||
setSearchParams({
|
||||
page: page.toString(),
|
||||
size: pageSize.toString()
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
<Group
|
||||
mt="lg"
|
||||
mb="md"
|
||||
mx="auto"
|
||||
maw={500}
|
||||
display="flex"
|
||||
style={{
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
<Text size="sm" color="darkgray">
|
||||
{data.total} entries
|
||||
</Text>
|
||||
<Divider orientation="vertical" />
|
||||
<SectionTimeElapsed
|
||||
timeEntries={
|
||||
data.timeEntries.filter(
|
||||
(t) =>
|
||||
new Date(t.startTime) >=
|
||||
new Date(
|
||||
new Date().getFullYear(),
|
||||
new Date().getMonth(),
|
||||
new Date().getDate(),
|
||||
0,
|
||||
0,
|
||||
0
|
||||
)
|
||||
) as any as TimeEntry[]
|
||||
}
|
||||
size="sm"
|
||||
additionalLabel="today"
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
<SectionTimeElapsed
|
||||
timeEntries={
|
||||
data.timeEntries.filter(
|
||||
(t) =>
|
||||
new Date(t.startTime) >=
|
||||
new Date(new Date().getFullYear(), new Date().getMonth(), 1)
|
||||
) as any as TimeEntry[]
|
||||
}
|
||||
size="sm"
|
||||
additionalLabel="this month"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<div role="region" id="time-entries">
|
||||
{Object.entries(timeEntriesPerDay).map(([date, timeEntries]) => (
|
||||
<section key={date}>
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
marginRight: 'auto'
|
||||
}}
|
||||
>
|
||||
{date}
|
||||
</h2>
|
||||
|
||||
<SectionTimeElapsed
|
||||
timeEntries={timeEntries.entries as any as TimeEntry[]}
|
||||
size="md"
|
||||
total={timeEntries.total}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{timeEntries.entries.map((timeEntry) => (
|
||||
<Paper
|
||||
key={timeEntry.id}
|
||||
shadow="sm"
|
||||
p="md"
|
||||
radius="md"
|
||||
mb="sm"
|
||||
display="flex"
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
marginRight: 'auto'
|
||||
}}
|
||||
>
|
||||
{timeEntry.description}
|
||||
</span>
|
||||
{timeEntry.projectId && timeEntry.project && (
|
||||
<Badge color={timeEntry.project.color}>
|
||||
{timeEntry.project.name}
|
||||
</Badge>
|
||||
)}
|
||||
<TimeElapsed
|
||||
startTime={timeEntry.startTime}
|
||||
endTime={timeEntry.endTime}
|
||||
/>
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon title="Edit" mr="xs">
|
||||
<Settings size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Edit time entry</Menu.Label>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={`/time-entries/${timeEntry.id}`}
|
||||
icon={
|
||||
<Edit3 size={14} color={theme.colors.yellow[8]} />
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
<Form
|
||||
method="delete"
|
||||
action={`/time-entries/${timeEntry.id}`}
|
||||
>
|
||||
<Menu.Item
|
||||
component="button"
|
||||
type="submit"
|
||||
icon={<Trash size={14} color={theme.colors.red[8]} />}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Form>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{timeEntry.endTime ? (
|
||||
<Form method="post" action="/time-entries/new">
|
||||
<input
|
||||
type="hidden"
|
||||
name="startTime"
|
||||
value={new Date(Date.now()).toISOString()}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="description"
|
||||
value={timeEntry.description}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="projectId"
|
||||
value={timeEntry.projectId}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="userId"
|
||||
value={timeEntry.userId}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="submit"
|
||||
title="Start new entry with same description"
|
||||
>
|
||||
<ThemeIcon variant="light">
|
||||
<Play size={14} color={theme.colors.blue[7]} />
|
||||
</ThemeIcon>
|
||||
</ActionIcon>
|
||||
</Form>
|
||||
) : (
|
||||
<Form
|
||||
method="patch"
|
||||
action={`/time-entries/${timeEntry.id}`}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="endTime"
|
||||
value={new Date().toISOString()}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="submit"
|
||||
variant="filled"
|
||||
title="Stop"
|
||||
style={{
|
||||
backgroundColor: theme.colors.red[7],
|
||||
color: 'white',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
>
|
||||
<Square size={12} fill="currentColor" />
|
||||
</ActionIcon>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: { error: Error }) {
|
||||
console.error(error);
|
||||
|
||||
return (
|
||||
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
|
||||
An unexpected error occurred: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatchBoundary() {
|
||||
const caught = useCatch();
|
||||
|
||||
if (caught.status === 404) {
|
||||
return (
|
||||
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
|
||||
Not found
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected caught response with status: ${caught.status}`);
|
||||
}
|
||||
Loading…
Reference in a new issue