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 ( +
+ + +
+ { + 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]) => ( +
+
+

+ {date} +

+ + +
+ + {timeEntries.entries.map((timeEntry) => ( + +
+ + {timeEntry.description} + + {timeEntry.projectId && timeEntry.project && ( + + {timeEntry.project.name} + + )} + + + + + + + + + + Edit time entry + + } + > + Edit + +
+ } + > + Delete + +
+
+
+ {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}`); +}