From 23c95940e6e6571f63b73952eefa8e5c48c96252 Mon Sep 17 00:00:00 2001 From: "Dennis (via Claude+Gemma)" Date: Sat, 23 May 2026 05:59:05 +0200 Subject: [PATCH] feat(project-stats-page): Project-Stats: Stunden total, monthly chart, top contributor [tsc:fail] --- .phase8-state.json | 5 +- GENERATION_LOG.md | 19 +++++++ apps/api/src/routes/projects.ts | 65 ++++++++++++++++++---- apps/web/src/pages/ProjectDetail.tsx | 83 +++++++++++++++++++++++++--- 4 files changed, 151 insertions(+), 21 deletions(-) diff --git a/.phase8-state.json b/.phase8-state.json index d6f164e..7aab35f 100644 --- a/.phase8-state.json +++ b/.phase8-state.json @@ -1,10 +1,11 @@ { "completed_features": [], - "current_feature": "account-deletion", + "current_feature": "project-stats-page", "started_at": "2026-05-23T05:49:48.673340", "attempted_features": [ "recent-activity-widget", "time-entry-bulk-actions", - "customer-csv-import" + "customer-csv-import", + "account-deletion" ] } \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index 1dc5ce4..7dcf131 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -966,3 +966,22 @@ src/index.ts(27,25): error TS2769: No overload matches this call. Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. Type 'Promise' provides no match for the signature '(instance: FastifyInstance, FastifyBaseLogger, FastifyTy +- `05:57:27` **INFO** Committed feature account-deletion +- `05:57:27` **INFO** Pushed: rc=0 + +## Phase-3 Feature: project-stats-page (2026-05-23 05:57:27) + +- `05:57:27` **INFO** Description: Project-Stats: Stunden total, monthly chart, top contributors +- `05:57:27` **INFO** Generating apps/api/src/routes/projects.ts (ERWEITERT — behalte alles. Füge GET /:id/stats: {totalHours, entryCoun…) +- `05:58:03` **INFO** wrote 3936 chars in 36.3s (attempt 1) +- `05:58:03` **INFO** Generating apps/web/src/pages/ProjectDetail.tsx (ERWEITERT — behalte bestehendes (header, time-entries). Füge Stats-Car…) +- `05:59:03` **INFO** wrote 7164 chars in 60.1s (attempt 1) +- `05:59:03` **INFO** Running tsc --noEmit on api… +- `05:59:05` **WARN** tsc errors: +src/index.ts(27,25): error TS2769: No overload matches this call. + Overload 1 of 3, '(plugin: FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. + Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. + Type 'Promise' provides no match for the signature '(instance: FastifyInstance, FastifyBaseLogger, FastifyTypeProvider>, opts: { ...; }, done: (err?: Error | undefined) => void): void'. + Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. + Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. + Type 'Promise' provides no match for the signature '(instance: FastifyInstance, FastifyBaseLogger, FastifyTy diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts index 357445e..f7535ea 100644 --- a/apps/api/src/routes/projects.ts +++ b/apps/api/src/routes/projects.ts @@ -1,7 +1,7 @@ import { FastifyInstance } from "fastify" import { db } from "../db" -import { projects } from "../db/schema" -import { eq } from "drizzle-orm" +import { projects, timeEntries, users } from "../db/schema" +import { eq, and, sql } from "drizzle-orm" import { z } from "zod" const ProjectSchema = z.object({ @@ -53,6 +53,56 @@ export default async function projectRoutes(fastify: FastifyInstance) { return project }) + fastify.get("/:id/stats", async (request, reply) => { + const { id } = request.params as { id: string } + + const projectExists = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, id)) + .limit(1) + + if (!projectExists.length) { + return reply.code(404).send({ message: "Project not found" }) + } + + const totalStats = await db + .select({ + totalHours: sql`sum(hours)`, + entryCount: sql`count(*)`, + }) + .from(timeEntries) + .where(eq(timeEntries.projectId, id)) + + const byUser = await db + .select({ + userId: users.id, + name: users.name, + hours: sql`sum(hours)`, + }) + .from(timeEntries) + .innerJoin(users, eq(timeEntries.userId, users.id)) + .where(eq(timeEntries.projectId, id)) + .groupBy(users.id, users.name) + + const byMonth = await db + .select({ + month: sql`date_trunc('month', date)`, + hours: sql`sum(hours)`, + }) + .from(timeEntries) + .where(eq(timeEntries.projectId, id)) + .groupBy(sql`date_trunc('month', date)`) + .orderBy(sql`date_trunc('month', date)`) + + return { + totalHours: totalStats[0]?.totalHours || 0, + entryCount: totalStats[0]?.entryCount || 0, + byUser, + byMonth, + } + }) + fastify.post("/", async (request, reply) => { const body = ProjectSchema.parse(request.body) @@ -99,13 +149,4 @@ export default async function projectRoutes(fastify: FastifyInstance) { return reply.code(204).send() }) -} - -// Helper for the filter array since 'and' is needed -function and(...conditions: any[]) { - return conditions.length > 0 ? conditions : undefined -} - -// Overriding the local 'and' to use drizzle's actual 'and' -import { and as drizzleAnd } from "drizzle-orm" -const andHelper = drizzleAnd; \ No newline at end of file +} \ No newline at end of file diff --git a/apps/web/src/pages/ProjectDetail.tsx b/apps/web/src/pages/ProjectDetail.tsx index c38565e..b3a46fa 100644 --- a/apps/web/src/pages/ProjectDetail.tsx +++ b/apps/web/src/pages/ProjectDetail.tsx @@ -1,6 +1,8 @@ import { useQuery } from "@tanstack/react-query" import { useParams, Link } from "@tanstack/react-router" import { api } from "../lib/api" +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts" +import type { Project, TimeEntry } from "@emberclone/shared" export default function ProjectDetail() { const { projectId } = useParams({ strict: false }) @@ -25,7 +27,29 @@ export default function ProjectDetail() { return
Project not found or error loading data.
} - const totalHours = entries?.reduce((sum: number, entry: any) => sum + (entry.hours || 0), 0) || 0 + const timeEntries = (entries as TimeEntry[]) || [] + const totalHours = timeEntries.reduce((sum, entry) => sum + (entry.hours || 0), 0) + + // Process Monthly Data for Chart + const monthlyData = timeEntries.reduce((acc: Record, entry) => { + const date = new Date(entry.date) + const month = date.toLocaleString('default', { month: 'short', year: '2-digit' }) + acc[month] = (acc[month] || 0) + entry.hours + return acc + }, {}) + + const chartData = Object.entries(monthlyData).map(([name, hours]) => ({ name, hours })) + + // Process Top Contributors + const contributorMap = timeEntries.reduce((acc: Record, entry) => { + const user = entry.user?.name || "Unknown" + acc[user] = (acc[user] || 0) + entry.hours + return acc + }, {}) + + const topContributors = Object.entries(contributorMap) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) return (
@@ -39,13 +63,58 @@ export default function ProjectDetail() { Customer: {project.customer?.name || "Unknown"}

-
- Total Hours - {totalHours.toFixed(2)}h +
+ Total Project Hours + {totalHours.toFixed(2)}h
-
+
+
+

Monthly Distribution

+
+ {chartData.length > 0 ? ( + + + + + + + + + + ) : ( +
No data available for chart
+ )} +
+
+ +
+

Top Contributors

+
+ {topContributors.length > 0 ? ( + topContributors.map(([name, hours]) => ( +
+
+
+ {name.charAt(0)} +
+ {name} +
+ {hours.toFixed(1)}h +
+ )) + ) : ( +
No contributors yet
+ )} +
+
+
+ +

Time Entries

@@ -60,8 +129,8 @@ export default function ProjectDetail() { - {entries && entries.length > 0 ? ( - entries.map((entry: any) => ( + {timeEntries.length > 0 ? ( + timeEntries.map((entry) => ( {new Date(entry.date).toLocaleDateString()}