timer routes

This commit is contained in:
Oliver Bryan
2026-01-09 21:54:21 +00:00
parent f04baeb052
commit d6604d2843
9 changed files with 252 additions and 0 deletions

View File

@@ -2,4 +2,5 @@ export * from "./issues";
export * from "./organisations"; export * from "./organisations";
export * from "./projects"; export * from "./projects";
export * from "./sessions"; export * from "./sessions";
export * from "./timed-sessions";
export * from "./users"; export * from "./users";

View File

@@ -0,0 +1,83 @@
import { TimedSession } from "@issue/shared";
import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
import { db } from "../client";
export async function createTimedSession(userId: number, issueId: number) {
const [timedSession] = await db
.insert(TimedSession)
.values({ userId, issueId, timestamps: [new Date()] })
.returning();
return timedSession;
}
export async function getActiveTimedSession(userId: number, issueId: number) {
const [timedSession] = await db
.select()
.from(TimedSession)
.where(
and(
eq(TimedSession.userId, userId),
eq(TimedSession.issueId, issueId),
isNull(TimedSession.endedAt),
),
);
return timedSession ?? null;
}
export async function getTimedSessionById(id: number) {
const [timedSession] = await db.select().from(TimedSession).where(eq(TimedSession.id, id));
return timedSession ?? null;
}
export async function appendTimestamp(timedSessionId: number, currentTimestamps: Date[]) {
const now = new Date();
const updatedTimestamps = [...currentTimestamps, now];
const [updatedTimedSession] = await db
.update(TimedSession)
.set({ timestamps: updatedTimestamps })
.where(eq(TimedSession.id, timedSessionId))
.returning();
return updatedTimedSession;
}
export async function endTimedSession(timedSessionId: number, currentTimestamps: Date[]) {
const now = new Date();
let finalTimestamps = [...currentTimestamps];
// if timer is running (odd timestamps), add final timestamp
if (finalTimestamps.length % 2 === 1) {
finalTimestamps = [...finalTimestamps, now];
}
const [endedTimedSession] = await db
.update(TimedSession)
.set({ timestamps: finalTimestamps, endedAt: now })
.where(eq(TimedSession.id, timedSessionId))
.returning();
return endedTimedSession;
}
export async function getUserTimedSessions(userId: number, limit = 50, offset = 0) {
const timedSessions = await db
.select()
.from(TimedSession)
.where(eq(TimedSession.userId, userId))
.orderBy(desc(TimedSession.createdAt))
.limit(limit)
.offset(offset);
return timedSessions;
}
export async function getCompletedTimedSessions(userId: number, limit = 50, offset = 0) {
const timedSessions = await db
.select()
.from(TimedSession)
.where(and(eq(TimedSession.userId, userId), isNotNull(TimedSession.endedAt)))
.orderBy(desc(TimedSession.createdAt))
.limit(limit)
.offset(offset);
return timedSessions;
}

View File

@@ -66,6 +66,11 @@ const main = async () => {
"/projects/by-organisation": withCors(withAuth(routes.projectsByOrganisation)), "/projects/by-organisation": withCors(withAuth(routes.projectsByOrganisation)),
"/projects/all": withCors(withAuth(routes.projectsAll)), "/projects/all": withCors(withAuth(routes.projectsAll)),
"/projects/with-creators": withCors(withAuth(routes.projectsWithCreators)), "/projects/with-creators": withCors(withAuth(routes.projectsWithCreators)),
"/timer/toggle": withCors(withAuth(withCSRF(routes.timerToggle))),
"/timer/end": withCors(withAuth(withCSRF(routes.timerEnd))),
"/timer/get": withCors(withAuth(withCSRF(routes.timerGet))),
"/timers": withCors(withAuth(withCSRF(routes.timers))),
}, },
}); });

View File

@@ -24,6 +24,10 @@ import projectDelete from "./project/delete";
import projectUpdate from "./project/update"; import projectUpdate from "./project/update";
import projectWithCreator from "./project/with-creator"; import projectWithCreator from "./project/with-creator";
import projectsWithCreators from "./project/with-creators"; import projectsWithCreators from "./project/with-creators";
import timerEnd from "./timer/end";
import timerGet from "./timer/get";
import timerToggle from "./timer/toggle";
import timers from "./timers";
import userByUsername from "./user/by-username"; import userByUsername from "./user/by-username";
import userUpdate from "./user/update"; import userUpdate from "./user/update";
import userUploadAvatar from "./user/upload-avatar"; import userUploadAvatar from "./user/upload-avatar";
@@ -65,4 +69,9 @@ export const routes = {
projectsByOrganisation, projectsByOrganisation,
projectsAll, projectsAll,
projectsWithCreators, projectsWithCreators,
timerToggle,
timerGet,
timerEnd,
timers,
}; };

View File

@@ -0,0 +1,39 @@
import { calculateBreakTimeMs, calculateWorkTimeMs } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware";
import { endTimedSession, getActiveTimedSession } from "../../db/queries";
// POST /timer/end
export default async function timerEnd(req: AuthedRequest) {
const url = new URL(req.url);
const issueId = url.searchParams.get("issueId");
if (!issueId || Number.isNaN(Number(issueId))) {
return new Response("missing issue id", { status: 400 });
}
const activeSession = await getActiveTimedSession(req.userId, Number(issueId));
if (!activeSession) {
return new Response("no active timer", { status: 400 });
}
// already ended - return existing without modification
if (activeSession.endedAt) {
return Response.json({
...activeSession,
workTimeMs: calculateWorkTimeMs(activeSession.timestamps),
breakTimeMs: calculateBreakTimeMs(activeSession.timestamps),
isRunning: false,
});
}
const ended = await endTimedSession(activeSession.id, activeSession.timestamps);
if (!ended) {
return new Response("failed to end timer", { status: 500 });
}
return Response.json({
...ended,
workTimeMs: calculateWorkTimeMs(ended.timestamps),
breakTimeMs: calculateBreakTimeMs(ended.timestamps),
isRunning: false,
});
}

View File

@@ -0,0 +1,26 @@
import { calculateBreakTimeMs, calculateWorkTimeMs, isTimerRunning } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware";
import { getActiveTimedSession } from "../../db/queries";
// GET /timer?issueId=123
export default async function timerGet(req: AuthedRequest) {
const url = new URL(req.url);
const issueId = url.searchParams.get("issueId");
if (!issueId || Number.isNaN(Number(issueId))) {
return new Response("missing issue id", { status: 400 });
}
const activeSession = await getActiveTimedSession(req.userId, Number(issueId));
if (!activeSession) {
return Response.json(null);
}
const running = isTimerRunning(activeSession.timestamps);
return Response.json({
...activeSession,
workTimeMs: calculateWorkTimeMs(activeSession.timestamps),
breakTimeMs: calculateBreakTimeMs(activeSession.timestamps),
isRunning: running,
});
}

View File

@@ -0,0 +1,40 @@
import { calculateBreakTimeMs, calculateWorkTimeMs, isTimerRunning } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware";
import { appendTimestamp, createTimedSession, getActiveTimedSession } from "../../db/queries";
// POST /timer/toggle?issueId=123
export default async function timerToggle(req: AuthedRequest) {
const url = new URL(req.url);
const issueId = url.searchParams.get("issueId");
if (!issueId || Number.isNaN(Number(issueId))) {
return new Response("missing issue id", { status: 400 });
}
const activeSession = await getActiveTimedSession(req.userId, Number(issueId));
if (!activeSession) {
// no active session, create new one with first timestamp
const newSession = await createTimedSession(req.userId, Number(issueId));
return Response.json({
...newSession,
workTimeMs: 0,
breakTimeMs: 0,
isRunning: true,
});
}
// active session exists, append timestamp (toggle)
const updated = await appendTimestamp(activeSession.id, activeSession.timestamps);
if (!updated) {
return new Response("failed to update timer", { status: 500 });
}
const running = isTimerRunning(updated.timestamps);
return Response.json({
...updated,
workTimeMs: calculateWorkTimeMs(updated.timestamps),
breakTimeMs: calculateBreakTimeMs(updated.timestamps),
isRunning: running,
});
}

View File

@@ -0,0 +1,24 @@
import { calculateBreakTimeMs, calculateWorkTimeMs, isTimerRunning } from "@issue/shared";
import type { AuthedRequest } from "../auth/middleware";
import { getUserTimedSessions } from "../db/queries";
// GET /timers?limit=50&offset=0
export default async function timers(req: AuthedRequest) {
const url = new URL(req.url);
const limitParam = url.searchParams.get("limit");
const offsetParam = url.searchParams.get("offset");
const limit = limitParam ? Number(limitParam) : 50;
const offset = offsetParam ? Number(offsetParam) : 0;
const sessions = await getUserTimedSessions(req.userId, limit, offset);
const enriched = sessions.map((session) => ({
...session,
workTimeMs: calculateWorkTimeMs(session.timestamps),
breakTimeMs: calculateBreakTimeMs(session.timestamps),
isRunning: session.endedAt === null && isTimerRunning(session.timestamps),
}));
return Response.json(enriched);
}

View File

@@ -0,0 +1,25 @@
export function isTimerRunning(timestamps: Date[]): boolean {
return timestamps.length % 2 === 1;
}
export function calculateWorkTimeMs(timestamps: Date[]): number {
let total = 0;
for (let i = 0; i < timestamps.length; i += 2) {
const start = timestamps[i];
if (!start) break;
const end = timestamps[i + 1] || new Date();
total += end.getTime() - start.getTime();
}
return total;
}
export function calculateBreakTimeMs(timestamps: Date[]): number {
let total = 0;
for (let i = 1; i < timestamps.length - 1; i += 2) {
const start = timestamps[i];
const end = timestamps[i + 1];
if (!start || !end) break;
total += end.getTime() - start.getTime();
}
return total;
}