Files
timetracker/frontend/src/components/TimerWidget.tsx
2026-03-13 17:46:53 +01:00

308 lines
12 KiB
TypeScript

import { useState, useRef } from "react";
import { Play, Square, ChevronDown, Pencil, Check, X, Trash2 } from "lucide-react";
import { useTimer } from "@/contexts/TimerContext";
import { useProjects } from "@/hooks/useProjects";
import { ProjectColorDot } from "@/components/ProjectColorDot";
function TimerDisplay({ totalSeconds }: { totalSeconds: number }) {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (n: number) => n.toString().padStart(2, "0");
return (
<span className="text-2xl font-mono font-bold text-gray-900">
{hours > 0 && (
<>
<span className="mr-0.5">{pad(hours)}</span>
<span className="text-base font-normal text-gray-500 mr-1.5">h</span>
</>
)}
<span className="mr-0.5">{pad(minutes)}</span>
<span className="text-base font-normal text-gray-500 mr-1.5">m</span>
<span className="mr-0.5">{pad(seconds)}</span>
<span className="text-base font-normal text-gray-500">s</span>
</span>
);
}
/** 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() {
const {
ongoingTimer,
isLoading,
elapsedSeconds,
startTimer,
stopTimer,
cancelTimer,
updateTimerProject,
updateTimerStartTime,
} = useTimer();
const { projects } = useProjects();
const [showProjectSelect, setShowProjectSelect] = useState(false);
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 () => {
setError(null);
try {
await startTimer();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start timer");
}
};
const handleStop = async () => {
setError(null);
try {
await stopTimer();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to stop timer");
}
};
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) => {
setError(null);
try {
await updateTimerProject(projectId);
setShowProjectSelect(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update project");
}
};
const handleClearProject = async () => {
setError(null);
try {
await updateTimerProject(null);
setShowProjectSelect(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to clear project");
}
};
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) {
return (
<div className="bg-white border-t border-gray-200 py-4 shadow-lg">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
</div>
);
}
return (
<div className="bg-white border-t border-gray-200 py-4 shadow-lg">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-wrap sm:flex-nowrap items-center gap-2 sm:justify-between">
{ongoingTimer ? (
<>
{/* 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">
{/* Timer Display + Start Time Editor */}
<div className="flex items-center space-x-2 shrink-0">
<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} />
<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>
{/* 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
onClick={handleStop}
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" />
<span>Stop</span>
</button>
</div>
</div>
{/* Project Selector — full width on mobile, auto on desktop */}
<div className="relative w-full sm:w-auto sm:flex-1 sm:mx-4">
<button
onClick={() => setShowProjectSelect(!showProjectSelect)}
className="flex items-center space-x-2 px-3 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors w-full sm:w-auto"
>
<span className="flex items-center space-x-2 min-w-0 flex-1">
{ongoingTimer.project ? (
<>
<ProjectColorDot color={ongoingTimer.project.color} />
<span className="text-sm font-medium text-gray-700 truncate">
{ongoingTimer.project.name}
</span>
</>
) : (
<span className="text-sm font-medium text-gray-500">
Select project...
</span>
)}
</span>
<ChevronDown className="h-4 w-4 text-gray-500 shrink-0" />
</button>
{showProjectSelect && (
<div className="absolute bottom-full left-0 mb-2 w-64 max-w-[calc(100vw-2rem)] bg-white rounded-lg shadow-lg border border-gray-200 max-h-64 overflow-y-auto">
<button
onClick={handleClearProject}
className="w-full px-4 py-2 text-left text-sm text-gray-500 hover:bg-gray-50 border-b border-gray-100"
>
No project
</button>
{projects?.map((project) => (
<button
key={project.id}
onClick={() => handleProjectChange(project.id)}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center space-x-2"
>
<ProjectColorDot color={project.color} />
<div className="min-w-0">
<div className="font-medium text-gray-900 truncate">
{project.name}
</div>
<div className="text-xs text-gray-500 truncate">
{project.client.name}
</div>
</div>
</button>
))}
</div>
)}
</div>
</>
) : (
<div className="flex items-center justify-between w-full">
{/* Stopped Timer Display */}
<div className="flex items-center space-x-2">
<span className="text-gray-500">Ready to track time</span>
</div>
{/* Start Button */}
<button
onClick={handleStart}
className="flex items-center space-x-2 px-6 py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 transition-colors"
>
<Play className="h-5 w-5 fill-current" />
<span>Start</span>
</button>
</div>
)}
</div>
{error && (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-2">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
</div>
);
}