Add ability to manually adjust the running timer's start time #4
@@ -48,6 +48,16 @@ router.put(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// DELETE /api/timer - Cancel (discard) the ongoing timer without creating a time entry
|
||||||
|
router.delete('/', requireAuth, async (req: AuthenticatedRequest, res, next) => {
|
||||||
|
try {
|
||||||
|
await timerService.cancel(req.user!.id);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/timer/stop - Stop timer
|
// POST /api/timer/stop - Stop timer
|
||||||
router.post(
|
router.post(
|
||||||
'/stop',
|
'/stop',
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const StartTimerSchema = z.object({
|
|||||||
|
|
||||||
export const UpdateTimerSchema = z.object({
|
export const UpdateTimerSchema = z.object({
|
||||||
projectId: z.string().uuid().optional().nullable(),
|
projectId: z.string().uuid().optional().nullable(),
|
||||||
|
startTime: z.string().datetime().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const StopTimerSchema = z.object({
|
export const StopTimerSchema = z.object({
|
||||||
|
|||||||
@@ -102,9 +102,24 @@ export class TimerService {
|
|||||||
projectId = data.projectId;
|
projectId = data.projectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate startTime if provided
|
||||||
|
let startTime: Date | undefined = undefined;
|
||||||
|
if (data.startTime) {
|
||||||
|
const parsed = new Date(data.startTime);
|
||||||
|
const now = new Date();
|
||||||
|
if (parsed >= now) {
|
||||||
|
throw new BadRequestError("Start time must be in the past");
|
||||||
|
}
|
||||||
|
startTime = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (projectId !== undefined) updateData.projectId = projectId;
|
||||||
|
if (startTime !== undefined) updateData.startTime = startTime;
|
||||||
|
|
||||||
return prisma.ongoingTimer.update({
|
return prisma.ongoingTimer.update({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
data: projectId !== undefined ? { projectId } : {},
|
data: updateData,
|
||||||
include: {
|
include: {
|
||||||
project: {
|
project: {
|
||||||
select: {
|
select: {
|
||||||
@@ -123,6 +138,15 @@ export class TimerService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancel(userId: string) {
|
||||||
|
const timer = await this.getOngoingTimer(userId);
|
||||||
|
if (!timer) {
|
||||||
|
throw new NotFoundError("No timer is running");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.ongoingTimer.delete({ where: { userId } });
|
||||||
|
}
|
||||||
|
|
||||||
async stop(userId: string, data?: StopTimerInput) {
|
async stop(userId: string, data?: StopTimerInput) {
|
||||||
const timer = await this.getOngoingTimer(userId);
|
const timer = await this.getOngoingTimer(userId);
|
||||||
if (!timer) {
|
if (!timer) {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export interface StartTimerInput {
|
|||||||
|
|
||||||
export interface UpdateTimerInput {
|
export interface UpdateTimerInput {
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
|
startTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StopTimerInput {
|
export interface StopTimerInput {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
import type { OngoingTimer, TimeEntry } from '@/types';
|
import type { OngoingTimer, TimeEntry } from '@/types';
|
||||||
|
|
||||||
|
export interface UpdateTimerPayload {
|
||||||
|
projectId?: string | null;
|
||||||
|
startTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const timerApi = {
|
export const timerApi = {
|
||||||
getOngoing: async (): Promise<OngoingTimer | null> => {
|
getOngoing: async (): Promise<OngoingTimer | null> => {
|
||||||
const { data } = await apiClient.get<OngoingTimer | null>('/timer');
|
const { data } = await apiClient.get<OngoingTimer | null>('/timer');
|
||||||
@@ -14,10 +19,8 @@ export const timerApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async (projectId?: string | null): Promise<OngoingTimer> => {
|
update: async (payload: UpdateTimerPayload): Promise<OngoingTimer> => {
|
||||||
const { data } = await apiClient.put<OngoingTimer>('/timer', {
|
const { data } = await apiClient.put<OngoingTimer>('/timer', payload);
|
||||||
projectId,
|
|
||||||
});
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -27,4 +30,8 @@ export const timerApi = {
|
|||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
cancel: async (): Promise<void> => {
|
||||||
|
await apiClient.delete('/timer');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -48,7 +48,7 @@ export function Navbar() {
|
|||||||
<div className="flex justify-between h-16">
|
<div className="flex justify-between h-16">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex-shrink-0 flex items-center">
|
<div className="flex-shrink-0 flex items-center">
|
||||||
<Clock className="h-8 w-8 text-primary-600" />
|
<img src="/icon.svg" alt="TimeTracker Logo" className="h-8 w-8 drop-shadow-sm" />
|
||||||
<span className="ml-2 text-xl font-bold text-gray-900">
|
<span className="ml-2 text-xl font-bold text-gray-900">
|
||||||
TimeTracker
|
TimeTracker
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { Play, Square, ChevronDown } from "lucide-react";
|
import { Play, Square, ChevronDown, Pencil, Check, X, Trash2 } from "lucide-react";
|
||||||
import { useTimer } from "@/contexts/TimerContext";
|
import { useTimer } from "@/contexts/TimerContext";
|
||||||
import { useProjects } from "@/hooks/useProjects";
|
import { useProjects } from "@/hooks/useProjects";
|
||||||
import { ProjectColorDot } from "@/components/ProjectColorDot";
|
import { ProjectColorDot } from "@/components/ProjectColorDot";
|
||||||
@@ -27,6 +27,21 @@ function TimerDisplay({ totalSeconds }: { totalSeconds: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Converts a HH:mm string to an ISO datetime, inferring the correct date.
|
||||||
|
* If the resulting time would be in the future, it is assumed to belong to the previous day.
|
||||||
|
*/
|
||||||
|
function timeInputToIso(timeValue: string): string {
|
||||||
|
const [hours, minutes] = timeValue.split(":").map(Number);
|
||||||
|
const now = new Date();
|
||||||
|
const candidate = new Date(now);
|
||||||
|
candidate.setHours(hours, minutes, 0, 0);
|
||||||
|
// If the candidate is in the future, roll back one day
|
||||||
|
if (candidate > now) {
|
||||||
|
candidate.setDate(candidate.getDate() - 1);
|
||||||
|
}
|
||||||
|
return candidate.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export function TimerWidget() {
|
export function TimerWidget() {
|
||||||
const {
|
const {
|
||||||
ongoingTimer,
|
ongoingTimer,
|
||||||
@@ -34,12 +49,19 @@ export function TimerWidget() {
|
|||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
startTimer,
|
startTimer,
|
||||||
stopTimer,
|
stopTimer,
|
||||||
|
cancelTimer,
|
||||||
updateTimerProject,
|
updateTimerProject,
|
||||||
|
updateTimerStartTime,
|
||||||
} = useTimer();
|
} = useTimer();
|
||||||
const { projects } = useProjects();
|
const { projects } = useProjects();
|
||||||
const [showProjectSelect, setShowProjectSelect] = useState(false);
|
const [showProjectSelect, setShowProjectSelect] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Start time editing state
|
||||||
|
const [editingStartTime, setEditingStartTime] = useState(false);
|
||||||
|
const [startTimeInput, setStartTimeInput] = useState("");
|
||||||
|
const startTimeInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -58,6 +80,15 @@ export function TimerWidget() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancelTimer = async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await cancelTimer();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to cancel timer");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleProjectChange = async (projectId: string) => {
|
const handleProjectChange = async (projectId: string) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -78,6 +109,42 @@ export function TimerWidget() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStartEditStartTime = () => {
|
||||||
|
if (!ongoingTimer) return;
|
||||||
|
const start = new Date(ongoingTimer.startTime);
|
||||||
|
const hh = start.getHours().toString().padStart(2, "0");
|
||||||
|
const mm = start.getMinutes().toString().padStart(2, "0");
|
||||||
|
setStartTimeInput(`${hh}:${mm}`);
|
||||||
|
setEditingStartTime(true);
|
||||||
|
// Focus the input on next render
|
||||||
|
setTimeout(() => startTimeInputRef.current?.focus(), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEditStartTime = () => {
|
||||||
|
setEditingStartTime(false);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmStartTime = async () => {
|
||||||
|
if (!startTimeInput) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const iso = timeInputToIso(startTimeInput);
|
||||||
|
await updateTimerStartTime(iso);
|
||||||
|
setEditingStartTime(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to update start time");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartTimeKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
void handleConfirmStartTime();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
handleCancelEditStartTime();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 py-4 shadow-lg">
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 py-4 shadow-lg">
|
||||||
@@ -95,21 +162,69 @@ export function TimerWidget() {
|
|||||||
<>
|
<>
|
||||||
{/* Row 1 (mobile): timer + stop side by side. On sm+ dissolves into the parent flex row via contents. */}
|
{/* Row 1 (mobile): timer + stop side by side. On sm+ dissolves into the parent flex row via contents. */}
|
||||||
<div className="flex items-center justify-between w-full sm:contents">
|
<div className="flex items-center justify-between w-full sm:contents">
|
||||||
{/* Timer Display */}
|
{/* Timer Display + Start Time Editor */}
|
||||||
<div className="flex items-center space-x-2 shrink-0">
|
<div className="flex items-center space-x-2 shrink-0">
|
||||||
<div className="w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
|
<div className="w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
|
||||||
|
{editingStartTime ? (
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span className="text-xs text-gray-500 mr-1">Started at</span>
|
||||||
|
<input
|
||||||
|
ref={startTimeInputRef}
|
||||||
|
type="time"
|
||||||
|
value={startTimeInput}
|
||||||
|
onChange={(e) => setStartTimeInput(e.target.value)}
|
||||||
|
onKeyDown={handleStartTimeKeyDown}
|
||||||
|
className="font-mono text-lg font-bold text-gray-900 border border-primary-400 rounded px-2 py-0.5 focus:outline-none focus:ring-2 focus:ring-primary-500 w-28"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleConfirmStartTime()}
|
||||||
|
title="Confirm"
|
||||||
|
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 rounded"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEditStartTime}
|
||||||
|
title="Cancel"
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
<TimerDisplay totalSeconds={elapsedSeconds} />
|
<TimerDisplay totalSeconds={elapsedSeconds} />
|
||||||
|
<button
|
||||||
|
onClick={handleStartEditStartTime}
|
||||||
|
title="Adjust start time"
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stop Button */}
|
{/* Stop + Cancel Buttons */}
|
||||||
|
<div className="flex items-center space-x-2 shrink-0 sm:order-last">
|
||||||
|
<button
|
||||||
|
onClick={() => void handleCancelTimer()}
|
||||||
|
title="Discard timer"
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
className="flex items-center space-x-2 px-6 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors shrink-0 sm:order-last"
|
disabled={!ongoingTimer.project}
|
||||||
|
title={!ongoingTimer.project ? "Select a project to stop the timer" : undefined}
|
||||||
|
className="flex items-center space-x-2 px-6 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-red-600"
|
||||||
>
|
>
|
||||||
<Square className="h-5 w-5 fill-current" />
|
<Square className="h-5 w-5 fill-current" />
|
||||||
<span>Stop</span>
|
<span>Stop</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Project Selector — full width on mobile, auto on desktop */}
|
{/* Project Selector — full width on mobile, auto on desktop */}
|
||||||
<div className="relative w-full sm:w-auto sm:flex-1 sm:mx-4">
|
<div className="relative w-full sm:w-auto sm:flex-1 sm:mx-4">
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ interface TimerContextType {
|
|||||||
elapsedSeconds: number;
|
elapsedSeconds: number;
|
||||||
startTimer: (projectId?: string) => Promise<void>;
|
startTimer: (projectId?: string) => Promise<void>;
|
||||||
updateTimerProject: (projectId?: string | null) => Promise<void>;
|
updateTimerProject: (projectId?: string | null) => Promise<void>;
|
||||||
|
updateTimerStartTime: (startTime: string) => Promise<void>;
|
||||||
|
cancelTimer: () => Promise<void>;
|
||||||
stopTimer: (projectId?: string) => Promise<TimeEntry | null>;
|
stopTimer: (projectId?: string) => Promise<TimeEntry | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +86,14 @@ export function TimerProvider({ children }: { children: ReactNode }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cancel timer mutation
|
||||||
|
const cancelMutation = useMutation({
|
||||||
|
mutationFn: timerApi.cancel,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["ongoingTimer"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Stop timer mutation
|
// Stop timer mutation
|
||||||
const stopMutation = useMutation({
|
const stopMutation = useMutation({
|
||||||
mutationFn: timerApi.stop,
|
mutationFn: timerApi.stop,
|
||||||
@@ -102,11 +112,22 @@ export function TimerProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
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 updateTimerStartTime = useCallback(
|
||||||
|
async (startTime: string) => {
|
||||||
|
await updateMutation.mutateAsync({ startTime });
|
||||||
|
},
|
||||||
|
[updateMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelTimer = useCallback(async () => {
|
||||||
|
await cancelMutation.mutateAsync();
|
||||||
|
}, [cancelMutation]);
|
||||||
|
|
||||||
const stopTimer = useCallback(
|
const stopTimer = useCallback(
|
||||||
async (projectId?: string): Promise<TimeEntry | null> => {
|
async (projectId?: string): Promise<TimeEntry | null> => {
|
||||||
try {
|
try {
|
||||||
@@ -127,6 +148,8 @@ export function TimerProvider({ children }: { children: ReactNode }) {
|
|||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
startTimer,
|
startTimer,
|
||||||
updateTimerProject,
|
updateTimerProject,
|
||||||
|
updateTimerStartTime,
|
||||||
|
cancelTimer,
|
||||||
stopTimer,
|
stopTimer,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Clock } from 'lucide-react';
|
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
@@ -8,8 +7,8 @@ export function LoginPage() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
<div className="max-w-md w-full space-y-8 p-8">
|
<div className="max-w-md w-full space-y-8 p-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto h-16 w-16 bg-primary-100 rounded-full flex items-center justify-center">
|
<div className="mx-auto h-16 w-16 flex items-center justify-center drop-shadow-sm">
|
||||||
<Clock className="h-8 w-8 text-primary-600" />
|
<img src="/icon.svg" alt="TimeTracker Logo" className="h-16 w-16" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-6 text-3xl font-bold text-gray-900">TimeTracker</h2>
|
<h2 className="mt-6 text-3xl font-bold text-gray-900">TimeTracker</h2>
|
||||||
<p className="mt-2 text-sm text-gray-600">
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
|||||||
Reference in New Issue
Block a user