mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
timer routes
This commit is contained in:
@@ -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";
|
||||||
|
|||||||
83
packages/backend/src/db/queries/timed-sessions.ts
Normal file
83
packages/backend/src/db/queries/timed-sessions.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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))),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
39
packages/backend/src/routes/timer/end.ts
Normal file
39
packages/backend/src/routes/timer/end.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
26
packages/backend/src/routes/timer/get.ts
Normal file
26
packages/backend/src/routes/timer/get.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
40
packages/backend/src/routes/timer/toggle.ts
Normal file
40
packages/backend/src/routes/timer/toggle.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
24
packages/backend/src/routes/timers.ts
Normal file
24
packages/backend/src/routes/timers.ts
Normal 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);
|
||||||
|
}
|
||||||
25
packages/shared/src/utils/time-tracking.ts
Normal file
25
packages/shared/src/utils/time-tracking.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user