diff --git a/packages/backend/src/db/queries/index.ts b/packages/backend/src/db/queries/index.ts index 29c5fcd..af7d8d6 100644 --- a/packages/backend/src/db/queries/index.ts +++ b/packages/backend/src/db/queries/index.ts @@ -2,4 +2,5 @@ export * from "./issues"; export * from "./organisations"; export * from "./projects"; export * from "./sessions"; +export * from "./timed-sessions"; export * from "./users"; diff --git a/packages/backend/src/db/queries/timed-sessions.ts b/packages/backend/src/db/queries/timed-sessions.ts new file mode 100644 index 0000000..88612ab --- /dev/null +++ b/packages/backend/src/db/queries/timed-sessions.ts @@ -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; +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 8b6f4cc..f7c2a4a 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -66,6 +66,11 @@ const main = async () => { "/projects/by-organisation": withCors(withAuth(routes.projectsByOrganisation)), "/projects/all": withCors(withAuth(routes.projectsAll)), "/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))), }, }); diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index d8d3411..217398f 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -24,6 +24,10 @@ import projectDelete from "./project/delete"; import projectUpdate from "./project/update"; import projectWithCreator from "./project/with-creator"; 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 userUpdate from "./user/update"; import userUploadAvatar from "./user/upload-avatar"; @@ -65,4 +69,9 @@ export const routes = { projectsByOrganisation, projectsAll, projectsWithCreators, + + timerToggle, + timerGet, + timerEnd, + timers, }; diff --git a/packages/backend/src/routes/timer/end.ts b/packages/backend/src/routes/timer/end.ts new file mode 100644 index 0000000..aad6c82 --- /dev/null +++ b/packages/backend/src/routes/timer/end.ts @@ -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, + }); +} diff --git a/packages/backend/src/routes/timer/get.ts b/packages/backend/src/routes/timer/get.ts new file mode 100644 index 0000000..723f786 --- /dev/null +++ b/packages/backend/src/routes/timer/get.ts @@ -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, + }); +} diff --git a/packages/backend/src/routes/timer/toggle.ts b/packages/backend/src/routes/timer/toggle.ts new file mode 100644 index 0000000..9f0070f --- /dev/null +++ b/packages/backend/src/routes/timer/toggle.ts @@ -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, + }); +} diff --git a/packages/backend/src/routes/timers.ts b/packages/backend/src/routes/timers.ts new file mode 100644 index 0000000..ec53072 --- /dev/null +++ b/packages/backend/src/routes/timers.ts @@ -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); +} diff --git a/packages/shared/src/utils/time-tracking.ts b/packages/shared/src/utils/time-tracking.ts new file mode 100644 index 0000000..bdabfb8 --- /dev/null +++ b/packages/shared/src/utils/time-tracking.ts @@ -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; +}