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.createdevent 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:
-
Server-side duration calculation — Never trust client-calculated durations; the
updateTimeEntrycontroller recomputes from timestamps. -
Event-driven architecture — The
publishEventcall increateTimeEntryenables real-time features without tight coupling. -
Optimistic caching with invalidation — TanStack Query's
staleTimeand explicit invalidation balance performance with freshness. -
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.tsdefines entries withtaskId,userId, timestamps, and computedduration - Frontend fetchers in
apps/web/src/fetchers/time-entry/wrap the generated@kaneo/libsclient - 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →