diff --git a/.phase2-state.json b/.phase2-state.json index b094cc5..d480067 100644 --- a/.phase2-state.json +++ b/.phase2-state.json @@ -1,11 +1,12 @@ { "completed_features": [], - "current_feature": "dashboard-stats", + "current_feature": "active-timer-widget", "started_at": "2026-05-23T04:42:59.289476", "attempted_features": [ "customers-crud", "projects-crud", "api-client-extensions", - "router-with-new-pages" + "router-with-new-pages", + "dashboard-stats" ] } \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index e9e189c..83f596b 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -312,3 +312,33 @@ src/routes/customers.ts(14,49): error TS7006: Parameter 'reply' implicitly has a src/routes/customers.ts(22,11): error TS2339: Property 'get' does not exist on type 'FastifyPluginAsync'. src/routes/customers.ts(22,27): error TS7006: Parameter 'request' implicitly has an 'any' type. src/routes/customers.ts(22,36): error TS7006: Parameter +- `04:47:04` **INFO** Committed feature dashboard-stats +- `04:47:05` **INFO** Pushed: rc=0 +- `04:47:05` **WARN** ⚠️ Feature dashboard-stats partial — moving on + +## Feature: active-timer-widget (2026-05-23 04:47:05) + +- `04:47:05` **INFO** Description: Aktiver Timer (start/stop) im Header sichtbar +- `04:47:05` **INFO** Files: 2 +- `04:47:05` **INFO** Generating apps/api/src/routes/time-entries.ts (ERWEITERTE time-entries-Routes. Behalte bestehende CRUD. Neu: GET /api…) +- `04:47:56` **INFO** wrote 5527 chars in 50.8s (attempt 1) +- `04:47:56` **INFO** Generating apps/web/src/components/ActiveTimer.tsx (Live-Timer Widget. useQuery({queryKey:['running-entry'], queryFn: api.…) +- `04:48:32` **INFO** wrote 3951 chars in 36.0s (attempt 1) +- `04:48:32` **INFO** Running tsc --noEmit on api… +- `04:48:33` **WARN** tsc errors: +src/routes/auth.ts(9,11): error TS2339: Property 'post' does not exist on type 'FastifyPluginAsync'. +src/routes/auth.ts(9,33): error TS7006: Parameter 'request' implicitly has an 'any' type. +src/routes/auth.ts(9,42): error TS7006: Parameter 'reply' implicitly has an 'any' type. +src/routes/auth.ts(22,27): error TS2339: Property 'jwt' does not exist on type 'FastifyPluginAsync'. +src/routes/auth.ts(44,11): error TS2339: Property 'get' does not exist on type 'FastifyPluginAsync'. +src/routes/auth.ts(44,29): error TS7006: Parameter 'request' implicitly has an 'any' type. +src/routes/auth.ts(44,38): error TS7006: Parameter 'reply' implicitly has an 'any' type. +src/routes/auth.ts(70,11): error TS2339: Property 'post' does not exist on type 'FastifyPluginAsync'. +src/routes/auth.ts(70,34): error TS7006: Parameter 'request' implicitly has an 'any' type. +src/routes/auth.ts(70,43): error TS7006: Parameter 'reply' implicitly has an 'any' type. +src/routes/customers.ts(14,11): error TS2339: Property 'addHook' does not exist on type 'FastifyPluginAsync'. +src/routes/customers.ts(14,40): error TS7006: Parameter 'request' implicitly has an 'any' type. +src/routes/customers.ts(14,49): error TS7006: Parameter 'reply' implicitly has an 'any' type. +src/routes/customers.ts(22,11): error TS2339: Property 'get' does not exist on type 'FastifyPluginAsync'. +src/routes/customers.ts(22,27): error TS7006: Parameter 'request' implicitly has an 'any' type. +src/routes/customers.ts(22,36): error TS7006: Parameter diff --git a/apps/api/src/routes/time-entries.ts b/apps/api/src/routes/time-entries.ts index 439e30f..1fdb63b 100644 --- a/apps/api/src/routes/time-entries.ts +++ b/apps/api/src/routes/time-entries.ts @@ -1,7 +1,7 @@ import { FastifyPluginAsync } from "fastify" import { db } from "../db" import { timeEntries } from "../db/schema" -import { eq, and, gte, lte, or } from "drizzle-orm" +import { eq, and, gte, lte, isNull } from "drizzle-orm" import { z } from "zod" const TimeEntrySchema = z.object({ @@ -13,6 +13,11 @@ const TimeEntrySchema = z.object({ const TimeEntryUpdateSchema = TimeEntrySchema.partial() +const StartEntrySchema = z.object({ + projectId: z.string().uuid().optional(), + description: z.string().min(1) +}) + export default async function timeEntryRoutes(fastify: FastifyPluginAsync) { fastify.addHook("preHandler", async (request, reply) => { try { @@ -49,6 +54,27 @@ export default async function timeEntryRoutes(fastify: FastifyPluginAsync) { return entries }) + fastify.get("/running", async (request, reply) => { + const user = request.user as { sub: string } + + const [entry] = await db + .select() + .from(timeEntries) + .where( + and( + eq(timeEntries.userId, user.sub), + isNull(timeEntries.endTime) + ) + ) + .limit(1) + + if (!entry) { + return reply.code(404).send({ message: "No running time entry found" }) + } + + return entry + }) + fastify.get("/:id", async (request, reply) => { const { id } = request.params as { id: string } const user = request.user as { sub: string; role: string } @@ -71,6 +97,23 @@ export default async function timeEntryRoutes(fastify: FastifyPluginAsync) { return entry }) + fastify.post("/start", async (request, reply) => { + const user = request.user as { sub: string } + const body = StartEntrySchema.parse(request.body) + + const [entry] = await db + .insert(timeEntries) + .values({ + ...body, + userId: user.sub, + startTime: new Date(), + endTime: null + }) + .returning() + + return reply.code(201).send(entry) + }) + fastify.post("/", async (request, reply) => { const user = request.user as { sub: string } const body = TimeEntrySchema.parse(request.body) @@ -88,6 +131,34 @@ export default async function timeEntryRoutes(fastify: FastifyPluginAsync) { return reply.code(201).send(entry) }) + fastify.post("/:id/stop", async (request, reply) => { + const { id } = request.params as { id: string } + const user = request.user as { sub: string; role: string } + + const [entry] = await db + .select() + .from(timeEntries) + .where( + and( + eq(timeEntries.id, id), + user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub) + ) + ) + .limit(1) + + if (!entry) { + return reply.code(404).send({ message: "Time entry not found" }) + } + + const [updated] = await db + .update(timeEntries) + .set({ endTime: new Date() }) + .where(eq(timeEntries.id, id)) + .returning() + + return updated + }) + fastify.patch("/:id", async (request, reply) => { const { id } = request.params as { id: string } const user = request.user as { sub: string; role: string } diff --git a/apps/web/src/components/ActiveTimer.tsx b/apps/web/src/components/ActiveTimer.tsx new file mode 100644 index 0000000..0e8233a --- /dev/null +++ b/apps/web/src/components/ActiveTimer.tsx @@ -0,0 +1,103 @@ +import React, { useState, useEffect } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { api } from '../lib/api' +import { Loader2, Play, Square } from 'lucide-react' + +export const ActiveTimer = () => { + const queryClient = useQueryClient() + const [description, setDescription] = useState('') + const [isInputVisible, setIsInputVisible] = useState(false) + const [elapsed, setElapsed] = useState(0) + + const { data: runningEntry, isLoading } = useQuery({ + queryKey: ['running-entry'], + queryFn: () => api.getRunningTimeEntry(), + refetchInterval: 30000, + }) + + const startMutation = useMutation({ + mutationFn: (desc: string) => api.createTimeEntry({ description, startTime: new Date().toISOString() }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['running-entry'] }) + setIsInputVisible(false) + setDescription('') + }, + }) + + const stopMutation = useMutation({ + mutationFn: (id: string) => api.stopTimeEntry(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['running-entry'] }) + }, + }) + + useEffect(() => { + let interval: NodeJS.Timeout + if (runningEntry?.startTime) { + const update = () => { + const start = new Date(runningEntry.startTime).getTime() + const now = Date.now() + setElapsed(Math.floor((now - start) / 1000)) + } + update() + interval = setInterval(update, 1000) + } else { + setElapsed(0) + } + return () => clearInterval(interval) + }, [runningEntry]) + + const formatTime = (seconds: number) => { + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = seconds % 60 + return h > 0 + ? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` + : `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` + } + + if (isLoading) return
+ + if (runningEntry) { + return ( +
+ + {runningEntry.description || 'No description'} + + + {formatTime(elapsed)} + + +
+ ) + } + + return ( +
+ {isInputVisible && ( + setDescription(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && startMutation.mutate(description)} + /> + )} + +
+ ) +} \ No newline at end of file