How Kaneo's Time Tracking Feature Records and Calculates Entries

Kaneo stores each work interval as a time-entry record with startTime, endTime, and a pre-computed duration in seconds, calculating the total by summing cached duration values on the client.

Kaneo's open-source project management platform includes a robust time tracking system that logs work intervals as structured database records. The feature is built around a time-entry abstraction that separates active timers from completed entries, enabling both real-time tracking and accurate historical reporting. This article examines exactly how the system records timestamps, computes durations, and aggregates totals for task-level reporting.

Database Schema for Time Entries

The foundation of Kaneo's time tracking is defined in apps/api/src/database/schema.ts. Each entry maps to a PostgreSQL row with the following columns:

  • id — unique identifier
  • taskId — foreign key to the associated task
  • userId — foreign key to the user who logged time
  • description — optional free-form notes
  • startTime — timestamp when the timer began
  • endTime — timestamp when stopped; null for running entries
  • duration — cached duration in seconds; null until entry is closed
  • createdAt / updatedAt — audit timestamps

The duration field is intentionally nullable to support live timers. A running entry maintains endTime = null and duration = null, signaling the UI to compute elapsed time dynamically.

Recording a Time Entry

Creating a Running Entry

The entry point is POST /api/time-entry, handled by apps/api/src/time-entry/controllers/create-time-entry.ts. The controller accepts:

{
  "taskId": "uuid",
  "description": "optional notes",
  "startTime": "2025-01-15T09:00:00.000Z"
}

If the client omits endTime, the controller stores endTime = null and duration = null. This state marks the entry as actively running.

Stopping the Timer and Calculating Duration

When a user stops tracking, the client calls PUT /api/time-entry/:id, processed by apps/api/src/time-entry/controllers/update-time-entry.ts. The controller:

  1. Records the supplied endTime
  2. Computes duration = (endTime - startTime) / 1000 (converting milliseconds to seconds)
  3. Persists both values to the database

This pre-computation ensures consistent duration reporting regardless of subsequent clock adjustments or timezone changes.

Retrieving and Displaying Time Entries

The GET /api/time-entry/task/:taskId endpoint, implemented in apps/api/src/time-entry/controllers/get-time-entries.ts, retrieves all entries for a task:

// apps/api/src/time-entry/controllers/get-time-entries.ts
const timeEntries = await db
  .select({
    id: timeEntryTable.id,
    taskId: timeEntryTable.taskId,
    userId: timeEntryTable.userId,
    userName: userTable.name,
    description: timeEntryTable.description,
    startTime: timeEntryTable.startTime,
    endTime: timeEntryTable.endTime,
    duration: timeEntryTable.duration,
    createdAt: timeEntryTable.createdAt,
    updatedAt: timeEntryTable.updatedAt,
  })
  .from(timeEntryTable)
  .leftJoin(userTable, eq(timeEntryTable.userId, userTable.id))
  .where(eq(timeEntryTable.taskId, taskId))
  .orderBy(timeEntryTable.startTime);

The query joins the user table to include human-readable attribution and orders results chronologically by startTime.

Aggregating Total Time for Tasks

Because duration is pre-computed server-side, clients perform simple aggregation. The React hook in apps/web/src/hooks/queries/time-entry/use-get-time-entries.ts fetches entries, then components sum the values:

import { useGetTimeEntries } from '@kaneo/web/hooks/queries/time-entry';
import { sumBy } from 'lodash';

function TotalTime({ taskId }: { taskId: string }) {
  const { data: entries } = useGetTimeEntries(taskId);
  const totalSeconds = sumBy(entries ?? [], (e) => e.duration ?? 0);
  const hours = Math.floor(totalSeconds / 3600);
  const minutes = Math.floor((totalSeconds % 3600) / 60);
  return <span>{hours}h {minutes}m</span>;
}

Running entries contribute 0 to the sum since their duration is null. The UI can optionally display live elapsed time by computing Date.now() - startTime locally.

Key Implementation Files

Purpose File Path
Database schema apps/api/src/database/schema.ts
Create entry controller apps/api/src/time-entry/controllers/create-time-entry.ts
Update/stop controller apps/api/src/time-entry/controllers/update-time-entry.ts
List entries controller apps/api/src/time-entry/controllers/get-time-entries.ts
Fetch hook (React) apps/web/src/hooks/queries/time-entry/use-get-time-entries.ts
Create hook (React) apps/web/src/hooks/mutations/time-entry/use-create-time-entry.ts
Update hook (React) apps/web/src/hooks/mutations/time-entry/use-update-time-entry.ts

Summary

  • Kaneo's time tracking uses a time-entry table with startTime, endTime, and cached duration fields
  • Running entries have endTime = null and duration = null, enabling live timer displays
  • Duration is calculated server-side in update-time-entry.ts as (endTime - startTime) / 1000 seconds
  • Clients aggregate pre-computed durations; no complex date math required for historical totals
  • The React frontend uses useGetTimeEntries with sumBy from lodash for efficient summation

Frequently Asked Questions

How does Kaneo handle timezone differences in time tracking?

All timestamps are stored as UTC in PostgreSQL. The duration calculation uses raw millisecond differences, eliminating timezone conversion errors. Client-side display formatting handles localization.

What happens if a user forgets to stop a timer?

Entries remain in the running state indefinitely with endTime = null and duration = null. The UI can surface these for manual closure. No automatic timeout logic exists in the current implementation.

Can time entries be edited after stopping?

Yes. The PUT /api/time-entry/:id endpoint accepts modified startTime, endTime, or description values. The controller recalculates duration whenever endTime changes, ensuring consistency.

Does Kaneo support multiple concurrent timers per user?

The schema permits multiple running entries with different taskId values. No application-level constraint enforces a single active timer; this would require additional validation in the create controller if desired.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →