7 Commits

Author SHA1 Message Date
d56eed8dde Merge pull request 'Add ability to manually adjust the running timer's start time' (#4) from feature/adjust-timer-start-time into main
Reviewed-on: #4
2026-02-23 09:57:23 +00:00
simon.franken
3fa13e1428 Use icon.svg in Navbar and LoginPage instead of Clock icon 2026-02-23 10:55:33 +01:00
simon.franken
2e629d8017 Merge branch 'main' into feature/adjust-timer-start-time 2026-02-23 10:53:54 +01:00
simon.franken
3ab39643dd Disable Stop button when no project is selected 2026-02-23 10:47:07 +01:00
simon.franken
e01e5e59df Remove cancel confirmation — discard timer immediately on click 2026-02-23 10:44:34 +01:00
simon.franken
06596dcee9 Add cancel (discard) timer feature
Allows users to discard a running timer without creating a time entry.
A trash icon in the timer widget reveals a confirmation step ('Discard / Keep')
to prevent accidental data loss. Backend exposes a new DELETE /api/timer
endpoint that simply deletes the ongoingTimer row.
2026-02-23 10:41:50 +01:00
simon.franken
7358fa6256 Add ability to manually adjust the running timer's start time
Allows users to retroactively correct the start time of an ongoing timer
without stopping it. A pencil icon in the timer widget opens an inline
time input pre-filled with the current start time; confirming sends the
new time to the backend which validates it is in the past before persisting.
2026-02-23 10:32:38 +01:00
9 changed files with 202 additions and 22 deletions

View File

@@ -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',

View File

@@ -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({

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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');
},
}; };

View File

@@ -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>

View File

@@ -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">

View File

@@ -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,
}} }}
> >

View File

@@ -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">