fix
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Install OpenSSL for Prisma
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
@@ -22,4 +25,4 @@ RUN npm run build
|
|||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|
||||||
# Start the application
|
# Start the application
|
||||||
CMD ["sh", "-c", "npx prisma migrate deploy && npm start"]
|
CMD ["sh", "-c", "npx prisma migrate deploy && npm start"]
|
||||||
|
|||||||
875
backend/package-lock.json
generated
875
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,9 @@
|
|||||||
"db:seed": "tsx prisma/seed.ts"
|
"db:seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^6.19.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"openid-client": "^5.6.1",
|
"openid-client": "^5.6.1",
|
||||||
@@ -22,9 +22,9 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/express-session": "^1.17.10",
|
"@types/express-session": "^1.17.10",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^25.2.3",
|
||||||
"prisma": "^5.7.0",
|
"prisma": "^6.19.2",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ model OngoingTimer {
|
|||||||
|
|
||||||
userId String @map("user_id") @db.VarChar(255) @unique
|
userId String @map("user_id") @db.VarChar(255) @unique
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
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)
|
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import {
|
import { createContext, useContext, useCallback, type ReactNode } from "react";
|
||||||
createContext,
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
useContext,
|
import { authApi } from "@/api/auth";
|
||||||
useState,
|
import type { User } from "@/types";
|
||||||
useEffect,
|
|
||||||
useCallback,
|
|
||||||
type ReactNode,
|
|
||||||
} from 'react';
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { authApi } from '@/api/auth';
|
|
||||||
import type { User } from '@/types';
|
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -23,9 +16,9 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: user, isLoading } = useQuery({
|
const { data: user, isLoading } = useQuery({
|
||||||
queryKey: ['currentUser'],
|
queryKey: ["currentUser"],
|
||||||
queryFn: authApi.getCurrentUser,
|
queryFn: authApi.getCurrentUser,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
});
|
});
|
||||||
@@ -36,12 +29,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
await authApi.logout();
|
await authApi.logout();
|
||||||
queryClient.setQueryData(['currentUser'], null);
|
queryClient.setQueryData(["currentUser"], null);
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
const refetchUser = useCallback(async () => {
|
const refetchUser = useCallback(async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ['currentUser'] });
|
await queryClient.invalidateQueries({ queryKey: ["currentUser"] });
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,7 +56,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const context = useContext(AuthContext);
|
const context = useContext(AuthContext);
|
||||||
if (context === undefined) {
|
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;
|
return context;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useCallback,
|
useCallback,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react';
|
} from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { timerApi } from '@/api/timer';
|
import { timerApi } from "@/api/timer";
|
||||||
import type { OngoingTimer, TimeEntry } from '@/types';
|
import type { OngoingTimer, TimeEntry } from "@/types";
|
||||||
|
|
||||||
interface TimerContextType {
|
interface TimerContextType {
|
||||||
ongoingTimer: OngoingTimer | null;
|
ongoingTimer: OngoingTimer | null;
|
||||||
@@ -24,10 +24,12 @@ const TimerContext = createContext<TimerContextType | undefined>(undefined);
|
|||||||
export function TimerProvider({ children }: { children: ReactNode }) {
|
export function TimerProvider({ children }: { children: ReactNode }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
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({
|
const { data: ongoingTimer, isLoading } = useQuery({
|
||||||
queryKey: ['ongoingTimer'],
|
queryKey: ["ongoingTimer"],
|
||||||
queryFn: timerApi.getOngoing,
|
queryFn: timerApi.getOngoing,
|
||||||
refetchInterval: 60000, // Refetch every minute to sync with server
|
refetchInterval: 60000, // Refetch every minute to sync with server
|
||||||
});
|
});
|
||||||
@@ -64,7 +66,7 @@ export function TimerProvider({ children }: { children: ReactNode }) {
|
|||||||
const startMutation = useMutation({
|
const startMutation = useMutation({
|
||||||
mutationFn: timerApi.start,
|
mutationFn: timerApi.start,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ongoingTimer'] });
|
queryClient.invalidateQueries({ queryKey: ["ongoingTimer"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,7 +74,7 @@ export function TimerProvider({ children }: { children: ReactNode }) {
|
|||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: timerApi.update,
|
mutationFn: timerApi.update,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ongoingTimer'] });
|
queryClient.invalidateQueries({ queryKey: ["ongoingTimer"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,8 +82,8 @@ export function TimerProvider({ children }: { children: ReactNode }) {
|
|||||||
const stopMutation = useMutation({
|
const stopMutation = useMutation({
|
||||||
mutationFn: timerApi.stop,
|
mutationFn: timerApi.stop,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ongoingTimer'] });
|
queryClient.invalidateQueries({ queryKey: ["ongoingTimer"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
queryClient.invalidateQueries({ queryKey: ["timeEntries"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,14 +91,14 @@ export function TimerProvider({ children }: { children: ReactNode }) {
|
|||||||
async (projectId?: string) => {
|
async (projectId?: string) => {
|
||||||
await startMutation.mutateAsync(projectId);
|
await startMutation.mutateAsync(projectId);
|
||||||
},
|
},
|
||||||
[startMutation]
|
[startMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateTimerProject = useCallback(
|
const updateTimerProject = useCallback(
|
||||||
async (projectId?: string | null) => {
|
async (projectId?: string | null) => {
|
||||||
await updateMutation.mutateAsync(projectId);
|
await updateMutation.mutateAsync(projectId);
|
||||||
},
|
},
|
||||||
[updateMutation]
|
[updateMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stopTimer = useCallback(
|
const stopTimer = useCallback(
|
||||||
@@ -108,7 +110,7 @@ export function TimerProvider({ children }: { children: ReactNode }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[stopMutation]
|
[stopMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -130,7 +132,7 @@ export function TimerProvider({ children }: { children: ReactNode }) {
|
|||||||
export function useTimer() {
|
export function useTimer() {
|
||||||
const context = useContext(TimerContext);
|
const context = useContext(TimerContext);
|
||||||
if (context === undefined) {
|
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;
|
return context;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from "react-router-dom";
|
||||||
import { Clock, Calendar, Briefcase, TrendingUp } from 'lucide-react';
|
import { Clock, Calendar, Briefcase, TrendingUp } from "lucide-react";
|
||||||
import { useTimeEntries } from '@/hooks/useTimeEntries';
|
import { useTimeEntries } from "@/hooks/useTimeEntries";
|
||||||
import { formatDate, formatDurationFromDates } from '@/utils/dateUtils';
|
import {
|
||||||
import { startOfDay, endOfDay, format as formatDateFns } from 'date-fns';
|
formatDate,
|
||||||
|
formatDurationFromDates,
|
||||||
|
formatDuration,
|
||||||
|
} from "@/utils/dateUtils";
|
||||||
|
import { startOfDay, endOfDay } from "date-fns";
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -16,9 +20,10 @@ export function DashboardPage() {
|
|||||||
limit: 10,
|
limit: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalTodaySeconds = todayEntries?.entries.reduce((total, entry) => {
|
const totalTodaySeconds =
|
||||||
return total + calculateDuration(entry.startTime, entry.endTime);
|
todayEntries?.entries.reduce((total, entry) => {
|
||||||
}, 0) || 0;
|
return total + calculateDuration(entry.startTime, entry.endTime);
|
||||||
|
}, 0) || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -41,19 +46,23 @@ export function DashboardPage() {
|
|||||||
<StatCard
|
<StatCard
|
||||||
icon={Calendar}
|
icon={Calendar}
|
||||||
label="Entries Today"
|
label="Entries Today"
|
||||||
value={todayEntries?.entries.length.toString() || '0'}
|
value={todayEntries?.entries.length.toString() || "0"}
|
||||||
color="green"
|
color="green"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={Briefcase}
|
icon={Briefcase}
|
||||||
label="Active Projects"
|
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"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
label="Total Entries"
|
label="Total Entries"
|
||||||
value={recentEntries?.pagination.total.toString() || '0'}
|
value={recentEntries?.pagination.total.toString() || "0"}
|
||||||
color="orange"
|
color="orange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +70,9 @@ export function DashboardPage() {
|
|||||||
{/* Recent Activity */}
|
{/* Recent Activity */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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
|
<Link
|
||||||
to="/time-entries"
|
to="/time-entries"
|
||||||
className="text-sm text-primary-600 hover:text-primary-700"
|
className="text-sm text-primary-600 hover:text-primary-700"
|
||||||
@@ -97,7 +108,9 @@ export function DashboardPage() {
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full mr-2"
|
className="w-3 h-3 rounded-full mr-2"
|
||||||
style={{ backgroundColor: entry.project.color || '#6b7280' }}
|
style={{
|
||||||
|
backgroundColor: entry.project.color || "#6b7280",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-900">
|
<div className="text-sm font-medium text-gray-900">
|
||||||
@@ -130,15 +143,15 @@ interface StatCardProps {
|
|||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
color: 'blue' | 'green' | 'purple' | 'orange';
|
color: "blue" | "green" | "purple" | "orange";
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ icon: Icon, label, value, color }: StatCardProps) {
|
function StatCard({ icon: Icon, label, value, color }: StatCardProps) {
|
||||||
const colors = {
|
const colors = {
|
||||||
blue: 'bg-blue-50 text-blue-600',
|
blue: "bg-blue-50 text-blue-600",
|
||||||
green: 'bg-green-50 text-green-600',
|
green: "bg-green-50 text-green-600",
|
||||||
purple: 'bg-purple-50 text-purple-600',
|
purple: "bg-purple-50 text-purple-600",
|
||||||
orange: 'bg-orange-50 text-orange-600',
|
orange: "bg-orange-50 text-orange-600",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -157,5 +170,7 @@ function StatCard({ icon: Icon, label, value, color }: StatCardProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function calculateDuration(startTime: string, endTime: string): number {
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
export function formatDate(date: string | Date): string {
|
||||||
const d = typeof date === 'string' ? parseISO(date) : date;
|
const d = typeof date === "string" ? parseISO(date) : date;
|
||||||
return format(d, 'MMM d, yyyy');
|
return format(d, "MMM d, yyyy");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTime(date: string | Date): string {
|
export function formatTime(date: string | Date): string {
|
||||||
const d = typeof date === 'string' ? parseISO(date) : date;
|
const d = typeof date === "string" ? parseISO(date) : date;
|
||||||
return format(d, 'h:mm a');
|
return format(d, "h:mm a");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDateTime(date: string | Date): string {
|
export function formatDateTime(date: string | Date): string {
|
||||||
const d = typeof date === 'string' ? parseISO(date) : date;
|
const d = typeof date === "string" ? parseISO(date) : date;
|
||||||
return format(d, 'MMM d, yyyy h:mm a');
|
return format(d, "MMM d, yyyy h:mm a");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDuration(totalSeconds: number): string {
|
export function formatDuration(totalSeconds: number): string {
|
||||||
@@ -22,12 +22,12 @@ export function formatDuration(totalSeconds: number): string {
|
|||||||
|
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (hours > 0) {
|
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(minutes.toString().padStart(2, "0"));
|
||||||
parts.push(seconds.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 {
|
export function calculateDuration(startTime: string, endTime: string): number {
|
||||||
@@ -36,14 +36,19 @@ export function calculateDuration(startTime: string, endTime: string): number {
|
|||||||
return differenceInSeconds(end, start);
|
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);
|
const seconds = calculateDuration(startTime, endTime);
|
||||||
return formatDuration(seconds);
|
return formatDuration(seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocalISOString(date: Date = new Date()): string {
|
export function getLocalISOString(date: Date = new Date()): string {
|
||||||
const timezoneOffset = date.getTimezoneOffset() * 60000;
|
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;
|
return localISOTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,4 +56,4 @@ export function toISOTimezone(dateStr: string): string {
|
|||||||
// Convert a local datetime input to ISO string with timezone
|
// Convert a local datetime input to ISO string with timezone
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
return date.toISOString();
|
return date.toISOString();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user