This commit is contained in:
simon.franken
2026-02-16 11:01:07 +01:00
parent 7d678c1c4d
commit d3b8df3deb
8 changed files with 476 additions and 563 deletions

View File

@@ -1,5 +1,8 @@
FROM node:20-alpine
# Install OpenSSL for Prisma
RUN apk add --no-cache openssl
WORKDIR /app
# Copy package files

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,9 @@
"db:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^5.7.0",
"@prisma/client": "^6.19.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"dotenv": "^17.3.1",
"express": "^4.18.2",
"express-session": "^1.17.3",
"openid-client": "^5.6.1",
@@ -22,8 +22,8 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express-session": "^1.17.10",
"@types/node": "^20.10.5",
"prisma": "^5.7.0",
"@types/node": "^25.2.3",
"prisma": "^6.19.2",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}

View File

@@ -85,7 +85,7 @@ model OngoingTimer {
userId String @map("user_id") @db.VarChar(255) @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
projectId String? @map("project_id")
projectId String? @unique @map("project_id")
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
@@index([userId])

View File

@@ -1,14 +1,7 @@
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { authApi } from '@/api/auth';
import type { User } from '@/types';
import { createContext, useContext, useCallback, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { authApi } from "@/api/auth";
import type { User } from "@/types";
interface AuthContextType {
user: User | null;
@@ -25,7 +18,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const { data: user, isLoading } = useQuery({
queryKey: ['currentUser'],
queryKey: ["currentUser"],
queryFn: authApi.getCurrentUser,
staleTime: 5 * 60 * 1000, // 5 minutes
});
@@ -36,12 +29,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const logout = useCallback(async () => {
await authApi.logout();
queryClient.setQueryData(['currentUser'], null);
queryClient.setQueryData(["currentUser"], null);
queryClient.clear();
}, [queryClient]);
const refetchUser = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: ['currentUser'] });
await queryClient.invalidateQueries({ queryKey: ["currentUser"] });
}, [queryClient]);
return (
@@ -63,7 +56,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@@ -5,10 +5,10 @@ import {
useEffect,
useCallback,
type ReactNode,
} from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { timerApi } from '@/api/timer';
import type { OngoingTimer, TimeEntry } from '@/types';
} from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { timerApi } from "@/api/timer";
import type { OngoingTimer, TimeEntry } from "@/types";
interface TimerContextType {
ongoingTimer: OngoingTimer | null;
@@ -24,10 +24,12 @@ const TimerContext = createContext<TimerContextType | undefined>(undefined);
export function TimerProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [elapsedInterval, setElapsedInterval] = useState<NodeJS.Timeout | null>(null);
const [elapsedInterval, setElapsedInterval] = useState<ReturnType<
typeof setInterval
> | null>(null);
const { data: ongoingTimer, isLoading } = useQuery({
queryKey: ['ongoingTimer'],
queryKey: ["ongoingTimer"],
queryFn: timerApi.getOngoing,
refetchInterval: 60000, // Refetch every minute to sync with server
});
@@ -64,7 +66,7 @@ export function TimerProvider({ children }: { children: ReactNode }) {
const startMutation = useMutation({
mutationFn: timerApi.start,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ongoingTimer'] });
queryClient.invalidateQueries({ queryKey: ["ongoingTimer"] });
},
});
@@ -72,7 +74,7 @@ export function TimerProvider({ children }: { children: ReactNode }) {
const updateMutation = useMutation({
mutationFn: timerApi.update,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ongoingTimer'] });
queryClient.invalidateQueries({ queryKey: ["ongoingTimer"] });
},
});
@@ -80,8 +82,8 @@ export function TimerProvider({ children }: { children: ReactNode }) {
const stopMutation = useMutation({
mutationFn: timerApi.stop,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ongoingTimer'] });
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ["ongoingTimer"] });
queryClient.invalidateQueries({ queryKey: ["timeEntries"] });
},
});
@@ -89,14 +91,14 @@ export function TimerProvider({ children }: { children: ReactNode }) {
async (projectId?: string) => {
await startMutation.mutateAsync(projectId);
},
[startMutation]
[startMutation],
);
const updateTimerProject = useCallback(
async (projectId?: string | null) => {
await updateMutation.mutateAsync(projectId);
},
[updateMutation]
[updateMutation],
);
const stopTimer = useCallback(
@@ -108,7 +110,7 @@ export function TimerProvider({ children }: { children: ReactNode }) {
return null;
}
},
[stopMutation]
[stopMutation],
);
return (
@@ -130,7 +132,7 @@ export function TimerProvider({ children }: { children: ReactNode }) {
export function useTimer() {
const context = useContext(TimerContext);
if (context === undefined) {
throw new Error('useTimer must be used within a TimerProvider');
throw new Error("useTimer must be used within a TimerProvider");
}
return context;
}

View File

@@ -1,8 +1,12 @@
import { Link } from 'react-router-dom';
import { Clock, Calendar, Briefcase, TrendingUp } from 'lucide-react';
import { useTimeEntries } from '@/hooks/useTimeEntries';
import { formatDate, formatDurationFromDates } from '@/utils/dateUtils';
import { startOfDay, endOfDay, format as formatDateFns } from 'date-fns';
import { Link } from "react-router-dom";
import { Clock, Calendar, Briefcase, TrendingUp } from "lucide-react";
import { useTimeEntries } from "@/hooks/useTimeEntries";
import {
formatDate,
formatDurationFromDates,
formatDuration,
} from "@/utils/dateUtils";
import { startOfDay, endOfDay } from "date-fns";
export function DashboardPage() {
const today = new Date();
@@ -16,7 +20,8 @@ export function DashboardPage() {
limit: 10,
});
const totalTodaySeconds = todayEntries?.entries.reduce((total, entry) => {
const totalTodaySeconds =
todayEntries?.entries.reduce((total, entry) => {
return total + calculateDuration(entry.startTime, entry.endTime);
}, 0) || 0;
@@ -41,19 +46,23 @@ export function DashboardPage() {
<StatCard
icon={Calendar}
label="Entries Today"
value={todayEntries?.entries.length.toString() || '0'}
value={todayEntries?.entries.length.toString() || "0"}
color="green"
/>
<StatCard
icon={Briefcase}
label="Active Projects"
value={new Set(recentEntries?.entries.map(e => e.projectId)).size.toString() || '0'}
value={
new Set(
recentEntries?.entries.map((e) => e.projectId),
).size.toString() || "0"
}
color="purple"
/>
<StatCard
icon={TrendingUp}
label="Total Entries"
value={recentEntries?.pagination.total.toString() || '0'}
value={recentEntries?.pagination.total.toString() || "0"}
color="orange"
/>
</div>
@@ -61,7 +70,9 @@ export function DashboardPage() {
{/* Recent Activity */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Recent Activity</h2>
<h2 className="text-lg font-semibold text-gray-900">
Recent Activity
</h2>
<Link
to="/time-entries"
className="text-sm text-primary-600 hover:text-primary-700"
@@ -97,7 +108,9 @@ export function DashboardPage() {
<div className="flex items-center">
<div
className="w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: entry.project.color || '#6b7280' }}
style={{
backgroundColor: entry.project.color || "#6b7280",
}}
/>
<div>
<div className="text-sm font-medium text-gray-900">
@@ -130,15 +143,15 @@ interface StatCardProps {
icon: React.ElementType;
label: string;
value: string;
color: 'blue' | 'green' | 'purple' | 'orange';
color: "blue" | "green" | "purple" | "orange";
}
function StatCard({ icon: Icon, label, value, color }: StatCardProps) {
const colors = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600',
purple: 'bg-purple-50 text-purple-600',
orange: 'bg-orange-50 text-orange-600',
blue: "bg-blue-50 text-blue-600",
green: "bg-green-50 text-green-600",
purple: "bg-purple-50 text-purple-600",
orange: "bg-orange-50 text-orange-600",
};
return (
@@ -157,5 +170,7 @@ function StatCard({ icon: Icon, label, value, color }: StatCardProps) {
}
function calculateDuration(startTime: string, endTime: string): number {
return Math.floor((new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000);
return Math.floor(
(new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000,
);
}

View File

@@ -1,18 +1,18 @@
import { format, parseISO, differenceInSeconds, formatDuration as dateFnsFormatDuration } from 'date-fns';
import { format, parseISO, differenceInSeconds } from "date-fns";
export function formatDate(date: string | Date): string {
const d = typeof date === 'string' ? parseISO(date) : date;
return format(d, 'MMM d, yyyy');
const d = typeof date === "string" ? parseISO(date) : date;
return format(d, "MMM d, yyyy");
}
export function formatTime(date: string | Date): string {
const d = typeof date === 'string' ? parseISO(date) : date;
return format(d, 'h:mm a');
const d = typeof date === "string" ? parseISO(date) : date;
return format(d, "h:mm a");
}
export function formatDateTime(date: string | Date): string {
const d = typeof date === 'string' ? parseISO(date) : date;
return format(d, 'MMM d, yyyy h:mm a');
const d = typeof date === "string" ? parseISO(date) : date;
return format(d, "MMM d, yyyy h:mm a");
}
export function formatDuration(totalSeconds: number): string {
@@ -22,12 +22,12 @@ export function formatDuration(totalSeconds: number): string {
const parts = [];
if (hours > 0) {
parts.push(hours.toString().padStart(2, '0'));
parts.push(hours.toString().padStart(2, "0"));
}
parts.push(minutes.toString().padStart(2, '0'));
parts.push(seconds.toString().padStart(2, '0'));
parts.push(minutes.toString().padStart(2, "0"));
parts.push(seconds.toString().padStart(2, "0"));
return parts.join(':');
return parts.join(":");
}
export function calculateDuration(startTime: string, endTime: string): number {
@@ -36,14 +36,19 @@ export function calculateDuration(startTime: string, endTime: string): number {
return differenceInSeconds(end, start);
}
export function formatDurationFromDates(startTime: string, endTime: string): string {
export function formatDurationFromDates(
startTime: string,
endTime: string,
): string {
const seconds = calculateDuration(startTime, endTime);
return formatDuration(seconds);
}
export function getLocalISOString(date: Date = new Date()): string {
const timezoneOffset = date.getTimezoneOffset() * 60000;
const localISOTime = new Date(date.getTime() - timezoneOffset).toISOString().slice(0, 16);
const localISOTime = new Date(date.getTime() - timezoneOffset)
.toISOString()
.slice(0, 16);
return localISOTime;
}