feat(project-stats-page): Project-Stats: Stunden total, monthly chart, top contributor [tsc:fail]

This commit is contained in:
Dennis (via Claude+Gemma) 2026-05-23 05:59:05 +02:00
parent 454b85b4e2
commit 23c95940e6
4 changed files with 151 additions and 21 deletions

View File

@ -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"
]
}

View File

@ -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<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, 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<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, 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<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy

View File

@ -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<number>`sum(hours)`,
entryCount: sql<number>`count(*)`,
})
.from(timeEntries)
.where(eq(timeEntries.projectId, id))
const byUser = await db
.select({
userId: users.id,
name: users.name,
hours: sql<number>`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<number>`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;
}

View File

@ -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 <div className="p-6 text-red-500">Project not found or error loading data.</div>
}
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<string, number>, 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<string, number>, 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 (
<div className="p-6 max-w-6xl mx-auto space-y-8">
@ -39,13 +63,58 @@ export default function ProjectDetail() {
Customer: <span className="font-medium text-gray-700">{project.customer?.name || "Unknown"}</span>
</p>
</div>
<div className="bg-blue-50 border border-blue-100 px-4 py-3 rounded-lg text-center">
<span className="block text-xs font-semibold text-blue-600 uppercase tracking-wider">Total Hours</span>
<span className="text-2xl font-bold text-blue-900">{totalHours.toFixed(2)}h</span>
<div className="bg-blue-600 text-white px-6 py-3 rounded-xl shadow-sm text-center">
<span className="block text-xs font-semibold uppercase tracking-wider opacity-80">Total Project Hours</span>
<span className="text-3xl font-bold">{totalHours.toFixed(2)}h</span>
</div>
</header>
<section className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<section className="lg:col-span-2 bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-6">Monthly Distribution</h3>
<div className="h-64 w-full">
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#9ca3af' }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#9ca3af' }} />
<Tooltip
cursor={{ fill: '#f9fafb' }}
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
/>
<Bar dataKey="hours" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={40} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-gray-400 italic">No data available for chart</div>
)}
</div>
</section>
<section className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-6">Top Contributors</h3>
<div className="space-y-4">
{topContributors.length > 0 ? (
topContributors.map(([name, hours]) => (
<div key={name} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center text-xs font-bold text-gray-600">
{name.charAt(0)}
</div>
<span className="text-sm font-medium text-gray-700">{name}</span>
</div>
<span className="text-sm font-semibold text-gray-900">{hours.toFixed(1)}h</span>
</div>
))
) : (
<div className="text-center py-8 text-gray-400 italic text-sm">No contributors yet</div>
)}
</div>
</section>
</div>
<section className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h2 className="text-lg font-semibold text-gray-800">Time Entries</h2>
</div>
@ -60,8 +129,8 @@ export default function ProjectDetail() {
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{entries && entries.length > 0 ? (
entries.map((entry: any) => (
{timeEntries.length > 0 ? (
timeEntries.map((entry) => (
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm text-gray-600">
{new Date(entry.date).toLocaleDateString()}