# How to Implement Time Tracking Using the Kaneo Time Entries API

> Learn to implement time tracking with the Kaneo Time Entries API. This guide details its RESTful interface, built with Hono and Drizzle, for seamless time entry management.

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

---

**Kaneo provides a fully typed, REST-style API for managing time entries linked to tasks, built on Hono and Drizzle ORM with generated TanStack Query hooks for the frontend.**

The Kaneo time entries API lets you track work duration against specific tasks with automatic duration calculation and real-time event publishing. This guide walks through the complete implementation from database schema to React hooks, based on the actual source code in the `usekaneo/kaneo` repository.

## Understanding the Time Entry Data Model

Each time entry in Kaneo belongs to a **task** and a **user**, with timestamps that drive duration calculations.

### Database Schema

The `time_entry` table is defined in [`apps/api/src/database/schema.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/database/schema.ts) (lines 429-440):

```typescript
// Core columns from timeEntryTable
{
  id: string;           // CUID2 generated
  taskId: string;       // Foreign key to taskTable
  userId: string;       // Foreign key to userTable
  description: string;  // Optional work notes
  startTime: Date;      // When tracking began
  endTime: Date | null; // When tracking ended (nullable)
  duration: number;     // Computed in seconds
  createdAt: Date;
  updatedAt: Date;
}

```

This schema supports both **manual time entry** (start/end provided together) and **live tracking** (start first, end later).

## Backend API Architecture

The backend exposes four core operations through Hono routes under the `/time-entry` namespace. All controllers use **Valibot** validation and **OpenAPI** decorators for automatic documentation.

### Available Controllers

| Controller | File Path | Purpose |
|------------|-----------|---------|
| `getTimeEntriesByTaskId` | [`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) | List all entries for a task |
| `getTimeEntry` | [`apps/api/src/time-entry/controllers/get-time-entry.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/time-entry/controllers/get-time-entry.ts) | Fetch single entry by ID |
| `createTimeEntry` | [`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) | Insert new entry, publish event |
| `updateTimeEntry` | [`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) | Modify times, recalculate duration |

### Creating a Time Entry (Backend)

The `createTimeEntry` controller in [`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) handles insertion and event publishing:

```typescript
import { createId } from "@paralleldrive/cuid2";
import { eq } from "drizzle-orm";
import { HTTPException } from "hono/http-exception";
import db from "../../database";
import { taskTable, timeEntryTable } from "../../database/schema";
import { publishEvent } from "../../events";

async function createTimeEntry({
  taskId,
  userId,
  description,
  startTime,
  endTime,
  duration,
}: {
  taskId: string;
  userId: string;
  description?: string;
  startTime: Date;
  endTime?: Date;
  duration?: number;
}) {
  const [createdTimeEntry] = await db
    .insert(timeEntryTable)
    .values({
      id: createId(),
      taskId,
      userId,
      description: description || "",
      startTime,
      endTime: endTime ?? null,
      duration: duration ?? 0,
    })
    .returning();

  if (!createdTimeEntry) {
    throw new HTTPException(500, { message: "Failed to create time entry" });
  }

  // Publish event for real-time listeners (activity feeds, websockets)
  await publishEvent("time-entry.created", {
    timeEntryId: createdTimeEntry.id,
    taskId,
    userId,
    type: "create",
    content: "started time tracking",
  });

  return createdTimeEntry;
}

```

Key behaviors:
- Uses **CUID2** for collision-resistant IDs via `@paralleldrive/cuid2`
- Publishes `time-entry.created` event for downstream consumers
- Route exposed as `POST /time-entry`

### Updating Entries with Duration Recalculation

The `updateTimeEntry` controller (lines 28-31) automatically recalculates `duration` when `endTime` changes:

```typescript
// Inside update-time-entry.ts controller
if (endTime !== undefined) {
  const end = new Date(endTime);
  const start = new Date(existingEntry.startTime);
  updateValues.duration = Math.floor((end.getTime() - start.getTime()) / 1000);
}

```

This ensures **duration always equals end minus start in seconds**, preventing data inconsistency.

## Frontend Implementation with TanStack Query

Kaneo's web app consumes the API through generated clients and TanStack Query hooks. The pattern separates **fetchers** (thin API wrappers) from **hooks** (state management).

### Generated Client Structure

The `@kaneo/libs` package generates a typed client that mirrors Hono routes. Fetchers in `apps/web/src/fetchers/time-entry/` wrap this client:

- [`get-time-entries.ts`](https://github.com/usekaneo/kaneo/blob/main/get-time-entries.ts)
- [`create-time-entry.ts`](https://github.com/usekaneo/kaneo/blob/main/create-time-entry.ts)
- [`update-time-entry.ts`](https://github.com/usekaneo/kaneo/blob/main/update-time-entry.ts)

### Query Hook: Fetching Time Entries

From [`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):

```typescript
import { useQuery } from "@tanstack/react-query";
import getTimeEntriesByTaskId from "@/fetchers/time-entry/get-time-entries";

export function useTaskTimeEntries(taskId: string) {
  return useQuery({
    queryKey: ["timeEntries", taskId],
    queryFn: () => getTimeEntriesByTaskId(taskId),
    staleTime: 60_000, // 1 minute cache
  });
}

```

The underlying fetcher calls `client["time-entry"].task[":taskId"].$get` with full TypeScript safety.

### Mutation Hook: Creating Time Entries

From [`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):

```typescript
import { useMutation, useQueryClient } from "@tanstack/react-query";
import createTimeEntry from "@/fetchers/time-entry/create-time-entry";

export function useCreateTimeEntry() {
  const qc = useQueryClient();

  return useMutation({
    mutationFn: (payload: {
      taskId: string;
      userId: string;
      description?: string;
      startTime: Date;
      endTime?: Date;
      duration?: number;
    }) => createTimeEntry(payload),
    onSuccess: (newEntry) => {
      // Invalidate task's entry list to trigger refetch
      qc.invalidateQueries({ queryKey: ["timeEntries", newEntry.taskId] });
    },
  });
}

```

**Best practice**: Invalidating the query cache on success keeps the UI synchronized without manual state updates.

### Mutation Hook: Updating Time Entries

From [`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):

```typescript
import { useMutation, useQueryClient } from "@tanstack/react-query";
import updateTimeEntry from "@/fetchers/time-entry/update-time-entry";

export function useUpdateTimeEntry() {
  const qc = useQueryClient();

  return useMutation({
    mutationFn: (params: {
      timeEntryId: string;
      startTime: Date;
      endTime?: Date;
      description?: string;
    }) => updateTimeEntry(params),
    onSuccess: (updated) => {
      // Refresh specific entry and parent list
      qc.invalidateQueries({ queryKey: ["timeEntry", updated.id] });
      qc.invalidateQueries({ queryKey: ["timeEntries", updated.taskId] });
    },
  });
}

```

## Visualizing Time Entries: The Timeline Component

Kaneo renders time entries through a dedicated UI component at [`apps/web/src/components/ui/timeline.tsx`](https://github.com/usekaneo/kaneo/blob/main/apps/web/src/components/ui/timeline.tsx). This component:
- Displays entries chronologically
- Provides **start/stop** controls for live tracking
- Integrates with the mutation hooks above

## Complete Integration Example

Here's a minimal React component that implements **start/stop tracking** using Kaneo's patterns:

```tsx
import { useTaskTimeEntries } from "@/hooks/queries/time-entry/use-get-time-entries";
import { useCreateTimeEntry } from "@/hooks/mutations/time-entry/use-create-time-entry";
import { useUpdateTimeEntry } from "@/hooks/mutations/time-entry/use-update-time-entry";

function TaskTimer({ taskId, userId }: { taskId: string; userId: string }) {
  const { data: entries, isLoading } = useTaskTimeEntries(taskId);
  const { mutate: createEntry } = useCreateTimeEntry();
  const { mutate: updateEntry } = useUpdateTimeEntry();

  // Find active entry (no endTime)
  const activeEntry = entries?.find(e => !e.endTime);

  const handleStart = () => {
    createEntry({
      taskId,
      userId,
      startTime: new Date(),
      description: "",
    });
  };

  const handleStop = () => {
    if (!activeEntry) return;
    
    updateEntry({
      timeEntryId: activeEntry.id,
      startTime: new Date(activeEntry.startTime),
      endTime: new Date(),
    });
  };

  if (isLoading) return <span>Loading...</span>;

  return (
    <div>
      <button 
        onClick={activeEntry ? handleStop : handleStart}
        disabled={isLoading}
      >
        {activeEntry ? "Stop Tracking" : "Start Tracking"}
      </button>
      
      {entries && <p>{entries.length} time entries logged</p>}
    </div>
  );
}

```

## Key Implementation Decisions

Based on the Kaneo source code, these patterns ensure robust time tracking:

1. **Server-side duration calculation** — Never trust client-calculated durations; the `updateTimeEntry` controller recomputes from timestamps.

2. **Event-driven architecture** — The `publishEvent` call in `createTimeEntry` enables real-time features without tight coupling.

3. **Optimistic caching with invalidation** — TanStack Query's `staleTime` and explicit invalidation balance performance with freshness.

4. **Nullable endTime for live tracking** — The schema supports in-progress entries naturally.

## Summary

- Kaneo's time entries API uses **Hono + Drizzle ORM** with OpenAPI-typed routes in `apps/api/src/time-entry/controllers/`
- The **database schema** in [`apps/api/src/database/schema.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/database/schema.ts) defines entries with `taskId`, `userId`, timestamps, and computed `duration`
- **Frontend fetchers** in `apps/web/src/fetchers/time-entry/` wrap the generated `@kaneo/libs` client
- **TanStack Query hooks** provide caching, loading states, and automatic refetch via cache invalidation
- **Events** (`time-entry.created`) enable real-time updates across the application
- The **Timeline component** ([`apps/web/src/components/ui/timeline.tsx`](https://github.com/usekaneo/kaneo/blob/main/apps/web/src/components/ui/timeline.tsx)) provides ready-made visualization

## Frequently Asked Questions

### How does Kaneo calculate time entry duration?

Kaneo calculates duration server-side in the `updateTimeEntry` controller. When `endTime` is provided, it computes `Math.floor((endTime - startTime) / 1000)` to get duration in seconds. This prevents manipulation and ensures consistency even if the client clock is wrong.

### Can I use the Kaneo time entries API from an external application?

Yes. The API follows standard REST patterns with OpenAPI documentation. You can call the Hono routes directly at `/time-entry` endpoints, or generate your own client from the OpenAPI spec. All requests require proper authentication via whatever strategy Kaneo has configured.

### What happens when I create a time entry in Kaneo?

The `createTimeEntry` controller inserts a row into `time_entryTable`, then publishes a `time-entry.created` event through the `publishEvent` system. This allows activity feeds, notifications, or WebSocket broadcasts to react to new time entries without polling.

### How do I display a running timer that updates live?

Use `useTaskTimeEntries` to fetch entries, filter for the one without `endTime`, then calculate elapsed time client-side with `Date.now() - new Date(startTime).getTime()`. When stopping, call `useUpdateTimeEntry` with the final `endTime`—the server will persist the accurate duration.