How to Implement Time Tracking Using the Kaneo Time Entries API

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 (lines 429-440):

// 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 List all entries for a task
getTimeEntry 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 Insert new entry, publish event
updateTimeEntry 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 handles insertion and event publishing:

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:

// 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:

Query Hook: Fetching Time Entries

From apps/web/src/hooks/queries/time-entry/use-get-time-entries.ts:

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:

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:

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. 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:

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 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) 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.

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 →