Skip to content

Commit db23e4c

Browse files
Improve time tracking stability
- Address race condition in startTimeTracking by introducing duplicate guard and safer insertion - Ensure stopTimeTracking handles duplicates by closing all active entries - Add related logging to aid debugging and prevent duplicate time entries X-Lovable-Edit-ID: edt-6cd44d04-2f9c-4386-8b1e-9c1c84e43d5a
2 parents 8ea5072 + 6990bc0 commit db23e4c

File tree

2 files changed

+112
-14
lines changed

2 files changed

+112
-14
lines changed

src/lib/database.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,27 @@ export async function startTimeTracking(
122122
operatorId: string,
123123
tenantId: string
124124
) {
125-
// Check for existing active time entries for this operator
125+
// Check for existing active time entries for this operation specifically
126+
const { data: existingForOperation } = await supabase
127+
.from("time_entries")
128+
.select("id")
129+
.eq("operation_id", operationId)
130+
.eq("operator_id", operatorId)
131+
.is("end_time", null);
132+
133+
// Prevent duplicate entries for same operation (race condition protection)
134+
if (existingForOperation && existingForOperation.length > 0) {
135+
console.log("Time entry already exists for this operation, skipping duplicate");
136+
return; // Silently succeed - entry already exists
137+
}
138+
139+
// Check for existing active time entries for this operator on OTHER operations
126140
const { data: activeEntries } = await supabase
127141
.from("time_entries")
128142
.select("id, operation_id, operations(operation_name)")
129143
.eq("operator_id", operatorId)
130144
.eq("tenant_id", tenantId)
145+
.neq("operation_id", operationId) // Exclude current operation
131146
.is("end_time", null);
132147

133148
if (activeEntries && activeEntries.length > 0) {
@@ -169,7 +184,19 @@ export async function startTimeTracking(
169184
const startedAt = new Date().toISOString();
170185
const isNewStart = operation.status === "not_started";
171186

172-
// Create time entry
187+
// Create time entry - use a lock by checking again right before insert
188+
const { data: doubleCheck } = await supabase
189+
.from("time_entries")
190+
.select("id")
191+
.eq("operation_id", operationId)
192+
.eq("operator_id", operatorId)
193+
.is("end_time", null);
194+
195+
if (doubleCheck && doubleCheck.length > 0) {
196+
console.log("Time entry created by concurrent request, skipping");
197+
return;
198+
}
199+
173200
const { error: timeError } = await supabase.from("time_entries").insert({
174201
operation_id: operationId,
175202
operator_id: operatorId,
@@ -274,16 +301,34 @@ export async function startTimeTracking(
274301
}
275302

276303
export async function stopTimeTracking(operationId: string, operatorId: string) {
277-
// Find active time entry
278-
const { data: entry } = await supabase
304+
// Find active time entries (may be multiple due to race conditions)
305+
const { data: entries } = await supabase
279306
.from("time_entries")
280307
.select("id, start_time, is_paused")
281308
.eq("operation_id", operationId)
282309
.eq("operator_id", operatorId)
283310
.is("end_time", null)
284-
.single();
311+
.order("start_time", { ascending: false });
285312

286-
if (!entry) throw new Error("No active time entry found");
313+
if (!entries || entries.length === 0) throw new Error("No active time entry found");
314+
315+
// Use the most recent entry
316+
const entry = entries[0];
317+
318+
// If there are duplicates, close them all
319+
if (entries.length > 1) {
320+
console.log(`Found ${entries.length} duplicate time entries, closing all`);
321+
const now = new Date();
322+
for (let i = 1; i < entries.length; i++) {
323+
const dupEntry = entries[i];
324+
const startTime = new Date(dupEntry.start_time);
325+
const duration = Math.round((now.getTime() - startTime.getTime()) / 1000);
326+
await supabase
327+
.from("time_entries")
328+
.update({ end_time: now.toISOString(), duration })
329+
.eq("id", dupEntry.id);
330+
}
331+
}
287332

288333
// If paused, close the current pause
289334
if (entry.is_paused) {

src/pages/admin/StuckTimeEntries.tsx

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { useState } from "react";
21
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
32
import { supabase } from "@/integrations/supabase/client";
43
import { Button } from "@/components/ui/button";
54
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
65
import { Badge } from "@/components/ui/badge";
7-
import { Clock, Square, AlertTriangle } from "lucide-react";
6+
import { Clock, Square, AlertTriangle, Loader2 } from "lucide-react";
87
import { toast } from "sonner";
98
import { format } from "date-fns";
109

@@ -43,7 +42,7 @@ export default function StuckTimeEntries() {
4342
if (error) throw error;
4443
return data;
4544
},
46-
refetchInterval: 5000, // Refresh every 5 seconds
45+
refetchInterval: 5000,
4746
});
4847

4948
// Mutation to force stop a time entry
@@ -80,19 +79,73 @@ export default function StuckTimeEntries() {
8079
},
8180
});
8281

82+
// Mutation to stop all entries
83+
const stopAllMutation = useMutation({
84+
mutationFn: async () => {
85+
if (!stuckEntries || stuckEntries.length === 0) return 0;
86+
87+
const now = new Date();
88+
let stoppedCount = 0;
89+
90+
for (const entry of stuckEntries) {
91+
const startTime = new Date(entry.start_time);
92+
const durationSeconds = Math.floor((now.getTime() - startTime.getTime()) / 1000);
93+
94+
const { error } = await supabase
95+
.from("time_entries")
96+
.update({
97+
end_time: now.toISOString(),
98+
duration: durationSeconds,
99+
})
100+
.eq("id", entry.id);
101+
102+
if (!error) stoppedCount++;
103+
}
104+
105+
return stoppedCount;
106+
},
107+
onSuccess: (count) => {
108+
queryClient.invalidateQueries({ queryKey: ["stuck-time-entries"] });
109+
toast.success(`Stopped ${count} time entries`);
110+
},
111+
onError: (error: Error) => {
112+
toast.error(`Failed to stop entries: ${error.message}`);
113+
},
114+
});
115+
83116
if (isLoading) {
84117
return (
85-
<div className="p-6">
86-
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
118+
<div className="p-6 flex items-center justify-center">
119+
<Loader2 className="h-8 w-8 animate-spin text-primary" />
87120
</div>
88121
);
89122
}
90123

91124
return (
92125
<div className="p-6 space-y-4">
93-
<div className="flex items-center gap-2">
94-
<AlertTriangle className="h-6 w-6 text-yellow-500" />
95-
<h1 className="text-2xl font-bold">Stuck Time Entries</h1>
126+
<div className="flex items-center justify-between">
127+
<div className="flex items-center gap-2">
128+
<AlertTriangle className="h-6 w-6 text-yellow-500" />
129+
<h1 className="text-2xl font-bold">Stuck Time Entries</h1>
130+
{stuckEntries && stuckEntries.length > 0 && (
131+
<Badge variant="destructive">{stuckEntries.length}</Badge>
132+
)}
133+
</div>
134+
{stuckEntries && stuckEntries.length > 1 && (
135+
<Button
136+
variant="destructive"
137+
onClick={() => stopAllMutation.mutate()}
138+
disabled={stopAllMutation.isPending}
139+
className="gap-2"
140+
>
141+
{stopAllMutation.isPending ? (
142+
<Loader2 className="h-4 w-4 animate-spin" />
143+
) : (
144+
<Square className="h-4 w-4" />
145+
)}
146+
Stop All ({stuckEntries.length})
147+
</Button>
148+
)}
96149
</div>
97150

98151
<p className="text-muted-foreground">

0 commit comments

Comments
 (0)