feat(timer): il tempo si imputa a fase e task, non solo al progetto

"Quanto e' costata questa fase" non era una domanda che si potesse fare:
time_entries aveva la sola project_id.

Migration 0018 (gia' applicata a prod): phase_id e task_id su time_entries,
piu' due indici, piu' projects.due_date che serve al blocco successivo. Solo
ADD COLUMN e CREATE INDEX.

Due scelte che vale la pena spiegare:

- ON DELETE SET NULL, non CASCADE. Cancellare un task NON deve cancellare le
  ore lavorate su di esso: sono storico fatturabile. L'entry ricade a livello
  progetto e il totale del progetto non cambia mai. Con CASCADE, ripulire una
  fase avrebbe silenziosamente abbassato il fatturato tracciato. Verificato
  sul DB di produzione dentro una transazione con ROLLBACK: cancellato il
  task, l'entry sopravvive con task_id NULL, phase_id intatto e i secondi
  invariati.
- Il timer su un task scrive ENTRAMBE le colonne. Cosi' il totale di una fase
  e' un group-by diretto su phase_id, senza risalire dai task, e comprende
  anche il tempo imputato alla fase ma a nessun task in particolare.

Resta un solo timer attivo in tutto l'hub. Da qui una conseguenza in UI: se
sta girando su un task, il timer del tab "Timer" NON si mostra acceso —
mostrarlo acceso farebbe credere che siano due cronometri diversi. Il tab lo
dice a parole e avvisa che avviarlo fermerebbe l'altro.

Le 8 entry esistenti restano valide con entrambe le colonne a NULL, cioe'
"tempo di progetto": e' esattamente cio' che sono.

PhasesTab passa ai token semantici mentre lo si tocca. Non e' zelo: ci si
infila dentro una TimerCell che i token li usa gia', e in dark mode un badge a
token dentro una card bg-white si vede. Un pezzo di DEBT-01 in meno.

Cade startTimerForClient, senza chiamanti da quando la lista progetti non ha
piu' il timer.

Build pulito. L'avvio/arresto dal browser non e' ancora stato provato: si
verifica in produzione, che e' l'unico posto dove esiste il DB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 22:38:06 +02:00
parent 4b135ce67f
commit a9358da96f
8 changed files with 259 additions and 57 deletions
+23 -10
View File
@@ -2,7 +2,7 @@
import { useState, useEffect, useTransition } from "react";
import { useRouter } from "next/navigation";
import { startTimer, startTimerForClient, stopTimer } from "@/app/admin/timer-actions";
import { startTimer, stopTimer } from "@/app/admin/timer-actions";
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
@@ -13,17 +13,24 @@ function formatDuration(seconds: number): string {
}
export function TimerCell({
clientId,
projectId,
phaseId,
taskId,
activeEntryId,
activeStartedAt,
totalTrackedSeconds,
compact = false,
}: {
clientId: string;
projectId?: string;
projectId: string;
/** Fase e task su cui imputare il tempo. Assenti = tempo di progetto. */
phaseId?: string;
taskId?: string;
/** Non-null solo se il timer attivo gira su QUESTO scope. */
activeEntryId: string | null;
activeStartedAt: Date | null;
totalTrackedSeconds: number;
/** Variante ridotta, per stare in fondo alla riga di un task. */
compact?: boolean;
}) {
const router = useRouter();
const [, startTransition] = useTransition();
@@ -48,10 +55,8 @@ export function TimerCell({
startTransition(async () => {
if (isRunning && activeEntryId) {
await stopTimer(activeEntryId);
} else if (projectId) {
await startTimer(projectId);
} else {
await startTimerForClient(clientId);
await startTimer(projectId, { phaseId, taskId });
}
router.refresh();
});
@@ -59,9 +64,15 @@ export function TimerCell({
const displayTotal = formatDuration(totalTrackedSeconds + (isRunning ? elapsed : 0));
// In compact il tempo si mostra solo se c'è: una riga di task con "0:00"
// accanto a ogni voce è rumore, e con venti task diventa una colonna di zeri.
const showTime = isRunning || totalTrackedSeconds > 0;
return (
<div
className={`inline-flex items-center gap-2 rounded-full border pl-2 pr-3 py-1 text-xs font-mono tabular-nums transition-colors ${
className={`inline-flex items-center gap-2 rounded-full border transition-colors font-mono tabular-nums ${
compact ? "pl-1 pr-2 py-0.5 text-[11px]" : "pl-2 pr-3 py-1 text-xs"
} ${
isRunning
? "bg-emerald-50/60 border-emerald-100 text-emerald-800 dark:bg-emerald-950/30 dark:border-emerald-900 dark:text-emerald-300 font-semibold"
: "bg-muted border-border text-muted-foreground"
@@ -70,7 +81,9 @@ export function TimerCell({
<button
onClick={handleToggle}
title={isRunning ? "Ferma timer" : "Avvia timer"}
className={`w-5 h-5 rounded-full flex items-center justify-center transition-colors shrink-0 ${
className={`rounded-full flex items-center justify-center transition-colors shrink-0 ${
compact ? "w-4 h-4" : "w-5 h-5"
} ${
isRunning
? "bg-emerald-500 text-white hover:bg-emerald-600"
: "bg-foreground/10 text-foreground hover:bg-foreground/20"
@@ -90,7 +103,7 @@ export function TimerCell({
)}
</button>
{isRunning ? formatDuration(elapsed) : displayTotal}
{compact && !showTime ? null : isRunning ? formatDuration(elapsed) : displayTotal}
</div>
);
}
+61 -10
View File
@@ -7,11 +7,19 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton";
import { TimerCell } from "@/components/admin/TimerCell";
import type { ClientFullDetail } from "@/lib/admin-queries";
type Props = {
phases: ClientFullDetail["phases"];
clientId: string;
/** Assenti nella vista cliente: là le fasi si leggono, non si cronometrano. */
projectId?: string;
activeTimerEntryId?: string | null;
activeTimerStartedAt?: Date | null;
activeTimerTaskId?: string | null;
taskSeconds?: Record<string, number>;
phaseSeconds?: Record<string, number>;
};
const taskStatusOptions = [
@@ -26,7 +34,25 @@ const phaseStatusOptions = [
{ value: "done", label: "Completata" },
];
export async function PhasesTab({ phases, clientId }: Props) {
/** "3h 20m" · "45m" · "—" quando non c'è tempo tracciato. */
function formatHours(seconds: number): string {
if (seconds <= 0) return "—";
const h = Math.floor(seconds / 3600);
const m = Math.round((seconds % 3600) / 60);
if (h === 0) return `${m}m`;
return m === 0 ? `${h}h` : `${h}h ${m}m`;
}
export async function PhasesTab({
phases,
clientId,
projectId,
activeTimerEntryId = null,
activeTimerStartedAt = null,
activeTimerTaskId = null,
taskSeconds = {},
phaseSeconds = {},
}: Props) {
return (
<div className="space-y-6">
{/* Add phase form */}
@@ -50,15 +76,25 @@ export async function PhasesTab({ phases, clientId }: Props) {
{/* Phases list */}
{phases.length === 0 && (
<p className="text-sm text-gray-400">Nessuna fase ancora.</p>
<p className="text-sm text-muted-foreground">Nessuna fase ancora.</p>
)}
{phases.map((phase) => (
<div
key={phase.id}
className="border border-gray-200 rounded-lg p-4 bg-white"
className="border border-border rounded-lg p-4 bg-card"
>
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-900">{phase.title}</h3>
<div className="flex items-center justify-between gap-3 mb-3 flex-wrap">
<div className="flex items-baseline gap-3">
<h3 className="font-semibold text-foreground">{phase.title}</h3>
{projectId && (
<span
className="text-xs font-mono tabular-nums text-muted-foreground"
title="Tempo tracciato su questa fase"
>
{formatHours(phaseSeconds[phase.id] ?? 0)}
</span>
)}
</div>
<div className="flex items-center gap-2">
<form
action={async (fd: FormData) => {
@@ -74,7 +110,7 @@ export async function PhasesTab({ phases, clientId }: Props) {
<select
name="status"
defaultValue={phase.status}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
className="text-xs border border-border rounded px-2 py-1 bg-background text-foreground"
>
{phaseStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
@@ -95,10 +131,25 @@ export async function PhasesTab({ phases, clientId }: Props) {
{phase.tasks.map((task) => (
<div
key={task.id}
className="flex items-center justify-between pl-3 border-l-2 border-gray-100"
className="flex items-center justify-between gap-3 pl-3 border-l-2 border-border"
>
<span className="text-sm text-gray-800">{task.title}</span>
<span className="text-sm text-foreground">{task.title}</span>
<div className="flex items-center gap-1">
{projectId && (
<TimerCell
projectId={projectId}
phaseId={phase.id}
taskId={task.id}
activeEntryId={
activeTimerTaskId === task.id ? activeTimerEntryId : null
}
activeStartedAt={
activeTimerTaskId === task.id ? activeTimerStartedAt : null
}
totalTrackedSeconds={taskSeconds[task.id] ?? 0}
compact
/>
)}
<form
action={async (fd: FormData) => {
"use server";
@@ -113,7 +164,7 @@ export async function PhasesTab({ phases, clientId }: Props) {
<select
name="status"
defaultValue={task.status}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
className="text-xs border border-border rounded px-2 py-1 bg-background text-foreground"
>
{taskStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
@@ -158,4 +209,4 @@ export async function PhasesTab({ phases, clientId }: Props) {
))}
</div>
);
}
}
+16 -5
View File
@@ -16,6 +16,7 @@ type TimerTabProps = {
acceptedTotal: string;
activeTimerEntryId: string | null;
activeTimerStartedAt: Date | null;
activeTimerScoped: boolean;
totalTrackedSeconds: number;
targetHourlyRate: number;
recentEntries: TimeEntry[];
@@ -26,19 +27,29 @@ export function TimerTab({
acceptedTotal,
activeTimerEntryId,
activeTimerStartedAt,
activeTimerScoped,
totalTrackedSeconds,
targetHourlyRate,
recentEntries,
}: TimerTabProps) {
// Il timer attivo è uno solo in tutto l'hub. Se sta girando su un task, qui
// NON va mostrato come acceso: questo è il timer di progetto, e mostrarlo
// acceso farebbe credere che siano due cronometri diversi.
const projectEntryId = activeTimerScoped ? null : activeTimerEntryId;
return (
<div className="space-y-6 max-w-sm">
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4">
<h3 className="font-medium text-[#1a1a1a] mb-4">Timer</h3>
<div className="bg-card rounded-lg border border-border p-4">
<h3 className="font-medium text-foreground mb-1">Timer di progetto</h3>
<p className="text-xs text-muted-foreground mb-4">
{activeTimerScoped
? "Un timer sta girando su un task, in «Fasi & Task». Avviando questo, quello si ferma."
: "Il tempo avviato qui non è imputato a nessuna fase. Per attribuirlo, usa il timer sul singolo task in «Fasi & Task»."}
</p>
<TimerCell
clientId={projectId}
projectId={projectId}
activeEntryId={activeTimerEntryId}
activeStartedAt={activeTimerStartedAt}
activeEntryId={projectEntryId}
activeStartedAt={projectEntryId ? activeTimerStartedAt : null}
totalTrackedSeconds={totalTrackedSeconds}
/>
</div>