Series 5 — Part 3 of 7

Time tracking for lawyers is not a convenience feature — it is the primary input to billing. This article covers the start/stop timer with drift correction, linking work logs to matters, the approval workflow, and the deduplication check that prevents double-billing.

Start/Stop Timer with Drift Correction

class TimeClock
{
    public function start(int $userId, int $matterId, string $description, PDO $pdo): int
    {
        // Check for an already-running timer (no open timers allowed)
        $running = $pdo->prepare('SELECT id FROM work_logs WHERE user_id = ? AND ended_at IS NULL AND deleted_at IS NULL');
        $running->execute([$userId]);
        if ($running->fetch()) {
            throw new \RuntimeException('A timer is already running. Stop it before starting a new one.');
        }

        $stmt = $pdo->prepare(
            'INSERT INTO work_logs (team_id, matter_id, user_id, description, started_at, status)
             VALUES (?, ?, ?, ?, NOW(), \'draft\')'
        );
        $stmt->execute([$this->teamId, $matterId, $userId, $description]);
        return (int) $pdo->lastInsertId();
    }

    public function stop(int $workLogId, int $userId, PDO $pdo): array
    {
        $stmt = $pdo->prepare(
            'SELECT * FROM work_logs WHERE id = ? AND user_id = ? AND ended_at IS NULL AND deleted_at IS NULL'
        );
        $stmt->execute([$workLogId, $userId]);
        $log = $stmt->fetch();

        if (!$log) {
            throw new \RuntimeException('No running timer found for this user.');
        }

        // Compute duration with server-side timestamps to avoid client clock drift
        $pdo->prepare(
            'UPDATE work_logs
             SET ended_at = NOW(),
                 duration_min = TIMESTAMPDIFF(MINUTE, started_at, NOW())
             WHERE id = ?'
        )->execute([$workLogId]);

        return $pdo->query("SELECT * FROM work_logs WHERE id = {$workLogId}")->fetch();
    }
}

The Approval Workflow

Work logs flow through: draftpending_reviewapprovedapplied (linked to a billing entry). Rejections send the log back to draft with a rejection reason. Only approved logs can be converted to billing entries.

public function submitForReview(int $workLogId, PDO $pdo): void
{
    $pdo->prepare(
        'UPDATE work_logs SET status = \'pending_review\' WHERE id = ? AND status = \'draft\''
    )->execute([$workLogId]);
}

public function approve(int $workLogId, int $approverId, PDO $pdo): void
{
    // Dedup check: has this time block already been billed?
    $stmt = $pdo->prepare(
        'SELECT COUNT(*) FROM billing_entries
         WHERE work_log_id = ? AND team_id = ?'
    );
    $stmt->execute([$workLogId, $this->teamId]);
    if ($stmt->fetchColumn() > 0) {
        throw new \RuntimeException('This work log is already linked to a billing entry.');
    }

    $pdo->prepare(
        'UPDATE work_logs SET status = \'approved\', approved_by = ?, approved_at = NOW() WHERE id = ?'
    )->execute([$approverId, $workLogId]);
}

What to Watch For

  • Time zone handlingNOW() uses the MySQL server's timezone. If your server is UTC but lawyers are in IST, store all timestamps in UTC and display in the user's timezone at the application layer.
  • Minimum billable unit — Many law firms bill in 6-minute increments. Apply the minimum unit rounding at the billing entry creation step, not the work log step, so the raw time data is preserved.
  • Concurrent timers — The check for running timers must be atomic. Use a SELECT FOR UPDATE or a unique constraint on (user_id, ended_at IS NULL) to prevent race conditions.