# How Kaneo's Time Tracking Feature Records and Calculates Entries

> Discover how Kaneo's time tracking feature records work intervals using startTime endTime and duration. Learn how it calculates total time by summing cached duration values on the client for precise tracking.

- Repository: [kaneo.app/kaneo](https://github.com/usekaneo/kaneo)
- Tags: how-to-guide
- Published: 2026-08-11

---

**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`](https://github.com/usekaneo/kaneo/blob/main/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`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/time-entry/controllers/create-time-entry.ts). The controller accepts:

```json
{
  "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`](https://github.com/usekaneo/kaneo/blob/main/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`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/time-entry/controllers/get-time-entries.ts), retrieves all entries for a task:

```typescript
// 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`](https://github.com/usekaneo/kaneo/blob/main/apps/web/src/hooks/queries/time-entry/use-get-time-entries.ts) fetches entries, then components sum the values:

```typescript
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`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/database/schema.ts) |
| Create entry controller | [`apps/api/src/time-entry/controllers/create-time-entry.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/time-entry/controllers/create-time-entry.ts) |
| Update/stop controller | [`apps/api/src/time-entry/controllers/update-time-entry.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/time-entry/controllers/update-time-entry.ts) |
| List entries controller | [`apps/api/src/time-entry/controllers/get-time-entries.ts`](https://github.com/usekaneo/kaneo/blob/main/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`](https://github.com/usekaneo/kaneo/blob/main/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`](https://github.com/usekaneo/kaneo/blob/main/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`](https://github.com/usekaneo/kaneo/blob/main/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`](https://github.com/usekaneo/kaneo/blob/main/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.