diff --git a/app/components/SectionTimeElapsed.tsx b/app/components/SectionTimeElapsed.tsx
new file mode 100644
index 0000000..3c3269d
--- /dev/null
+++ b/app/components/SectionTimeElapsed.tsx
@@ -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 (
+
+ {`${hoursString}${additionalLabel ? ` ${additionalLabel}` : ''}`}
+
+ );
+};
+
+export default SectionTimeElapsed;
diff --git a/app/components/TimeElapsed.tsx b/app/components/TimeElapsed.tsx
new file mode 100644
index 0000000..ae0aa53
--- /dev/null
+++ b/app/components/TimeElapsed.tsx
@@ -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 (
+
+
+ {hoursString}
+
+
+
+ {Intl.DateTimeFormat('it-IT', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false
+ }).format(new Date(startTime))}
+
+
+
+ {endTime
+ ? Intl.DateTimeFormat('it-IT', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false
+ }).format(new Date(endTime))
+ : 'now'}
+
+
+
+ );
+};
+
+export default TimeElapsed;
diff --git a/app/routes/time-entries.tsx b/app/routes/time-entries.tsx
new file mode 100644
index 0000000..4e1831e
--- /dev/null
+++ b/app/routes/time-entries.tsx
@@ -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();
+ 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 (
+
+
+ }
+ >
+ Start
+
+
+
{
+ setSearchParams({
+ page: page.toString(),
+ size: event.currentTarget.value
+ });
+ }}
+ />
+ {data.total / pageSize > 1 && (
+ {
+ setSearchParams({
+ page: page.toString(),
+ size: pageSize.toString()
+ });
+ }}
+ />
+ )}
+
+
+
+
+ {data.total} entries
+
+
+
+ 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"
+ />
+
+
+ new Date(t.startTime) >=
+ new Date(new Date().getFullYear(), new Date().getMonth(), 1)
+ ) as any as TimeEntry[]
+ }
+ size="sm"
+ additionalLabel="this month"
+ />
+
+
+
+ {Object.entries(timeEntriesPerDay).map(([date, timeEntries]) => (
+
+
+
+ {timeEntries.entries.map((timeEntry) => (
+
+
+
+ {timeEntry.description}
+
+ {timeEntry.projectId && timeEntry.project && (
+
+ {timeEntry.project.name}
+
+ )}
+
+
+ {timeEntry.endTime ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
+
+export function ErrorBoundary({ error }: { error: Error }) {
+ console.error(error);
+
+ return (
+ } title="Error" color="red">
+ An unexpected error occurred: {error.message}
+
+ );
+}
+
+export function CatchBoundary() {
+ const caught = useCatch();
+
+ if (caught.status === 404) {
+ return (
+ } title="Error" color="red">
+ Not found
+
+ );
+ }
+
+ throw new Error(`Unexpected caught response with status: ${caught.status}`);
+}