feat(active-timer-widget): Aktiver Timer (start/stop) im Header sichtbar [tsc:fail]
This commit is contained in:
parent
e4e1d693e1
commit
8f2320a104
@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"completed_features": [],
|
"completed_features": [],
|
||||||
"current_feature": "dashboard-stats",
|
"current_feature": "active-timer-widget",
|
||||||
"started_at": "2026-05-23T04:42:59.289476",
|
"started_at": "2026-05-23T04:42:59.289476",
|
||||||
"attempted_features": [
|
"attempted_features": [
|
||||||
"customers-crud",
|
"customers-crud",
|
||||||
"projects-crud",
|
"projects-crud",
|
||||||
"api-client-extensions",
|
"api-client-extensions",
|
||||||
"router-with-new-pages"
|
"router-with-new-pages",
|
||||||
|
"dashboard-stats"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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,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,27): error TS7006: Parameter 'request' implicitly has an 'any' type.
|
||||||
src/routes/customers.ts(22,36): error TS7006: Parameter
|
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
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { FastifyPluginAsync } from "fastify"
|
import { FastifyPluginAsync } from "fastify"
|
||||||
import { db } from "../db"
|
import { db } from "../db"
|
||||||
import { timeEntries } from "../db/schema"
|
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"
|
import { z } from "zod"
|
||||||
|
|
||||||
const TimeEntrySchema = z.object({
|
const TimeEntrySchema = z.object({
|
||||||
@ -13,6 +13,11 @@ const TimeEntrySchema = z.object({
|
|||||||
|
|
||||||
const TimeEntryUpdateSchema = TimeEntrySchema.partial()
|
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) {
|
export default async function timeEntryRoutes(fastify: FastifyPluginAsync) {
|
||||||
fastify.addHook("preHandler", async (request, reply) => {
|
fastify.addHook("preHandler", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
@ -49,6 +54,27 @@ export default async function timeEntryRoutes(fastify: FastifyPluginAsync) {
|
|||||||
return entries
|
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) => {
|
fastify.get("/:id", async (request, reply) => {
|
||||||
const { id } = request.params as { id: string }
|
const { id } = request.params as { id: string }
|
||||||
const user = request.user as { sub: string; role: string }
|
const user = request.user as { sub: string; role: string }
|
||||||
@ -71,6 +97,23 @@ export default async function timeEntryRoutes(fastify: FastifyPluginAsync) {
|
|||||||
return entry
|
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) => {
|
fastify.post("/", async (request, reply) => {
|
||||||
const user = request.user as { sub: string }
|
const user = request.user as { sub: string }
|
||||||
const body = TimeEntrySchema.parse(request.body)
|
const body = TimeEntrySchema.parse(request.body)
|
||||||
@ -88,6 +131,34 @@ export default async function timeEntryRoutes(fastify: FastifyPluginAsync) {
|
|||||||
return reply.code(201).send(entry)
|
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) => {
|
fastify.patch("/:id", async (request, reply) => {
|
||||||
const { id } = request.params as { id: string }
|
const { id } = request.params as { id: string }
|
||||||
const user = request.user as { sub: string; role: string }
|
const user = request.user as { sub: string; role: string }
|
||||||
|
|||||||
103
apps/web/src/components/ActiveTimer.tsx
Normal file
103
apps/web/src/components/ActiveTimer.tsx
Normal file
@ -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 <div className="h-10 w-10 flex items-center justify-center"><Loader2 className="w-4 h-4 animate-spin" /></div>
|
||||||
|
|
||||||
|
if (runningEntry) {
|
||||||
|
return (
|
||||||
|
<div className="h-10 inline-flex items-center gap-3 px-3 py-1 bg-slate-100 dark:bg-slate-800 rounded-full border border-slate-200 dark:border-slate-700 text-sm font-medium">
|
||||||
|
<span className="text-slate-500 dark:text-slate-400 truncate max-w-[120px]">
|
||||||
|
{runningEntry.description || 'No description'}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums font-mono text-blue-600 dark:text-blue-400">
|
||||||
|
{formatTime(elapsed)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => stopMutation.mutate(runningEntry.id)}
|
||||||
|
disabled={stopMutation.isPending}
|
||||||
|
className="p-1 hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<Square className="w-4 h-4 fill-current" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-10 inline-flex items-center gap-2">
|
||||||
|
{isInputVisible && (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="h-8 px-2 text-sm border rounded-md bg-white dark:bg-slate-900 dark:border-slate-700 outline-none focus:ring-1 ring-blue-500 w-40"
|
||||||
|
placeholder="What are you working on?"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && startMutation.mutate(description)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => isInputVisible ? startMutation.mutate(description) : setIsInputVisible(true)}
|
||||||
|
disabled={startMutation.isPending}
|
||||||
|
className="h-8 px-3 flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{startMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3 fill-current" />}
|
||||||
|
<span>{isInputVisible ? 'Start' : 'Start Timer'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user