Files
everythingbutwork/apps/api/src/routers/dashboard.ts
T
dejanandClaude Opus 4.8 1b186d8be6
ci / quality (push) Failing after 38s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 52s
Anstehende Meilensteine im Dashboard anzeigen
Projektübergreifende Liste offener/überfälliger Meilensteine mit Datum,
sortiert nach Fälligkeit. Überfällige rot markiert, relative Tagesangabe
("in 3 Tagen" / "überfällig"), direkt verlinkt aufs Projekt. Sitzt oben
zwischen Showstoppern und kritischen Modi.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:29:16 +02:00

155 lines
5.3 KiB
TypeScript

import { and, eq, ne, isNull, isNotNull, lt, count, asc, desc } from 'drizzle-orm';
import { area, project, blocker, dayEntry, user, risk, milestone, type Area, type Project } from '@cockpit/db';
import { router, protectedProcedure } from '../trpc.js';
import { todayIso, previousWerktag } from '../lib/werktag.js';
import { aggregateAreaAmpel } from '../lib/ampel.js';
export const dashboardRouter = router({
overview: protectedProcedure.query(async ({ ctx }) => {
const todayDate = todayIso();
const [
areas,
allProjects,
openBlockers,
todayPresence,
totalActiveUsers,
feedRows,
showstopperRows,
milestoneRows,
] = await Promise.all([
ctx.db.select().from(area).orderBy(area.order),
ctx.db.select().from(project).where(eq(project.archiviert, false)),
ctx.db.select().from(blocker).where(isNull(blocker.resolvedAt)),
ctx.db
.select({ userId: dayEntry.userId, status: dayEntry.status })
.from(dayEntry)
.where(eq(dayEntry.date, todayDate)),
ctx.db.select({ c: count() }).from(user).where(eq(user.active, true)),
ctx.db
.select({
id: dayEntry.id,
userId: dayEntry.userId,
userName: user.name,
date: dayEntry.date,
ampel: dayEntry.ampel,
trend: dayEntry.trend,
reflectionText: dayEntry.reflectionText,
publishedAt: dayEntry.publishedAt,
})
.from(dayEntry)
.innerJoin(user, eq(user.id, dayEntry.userId))
// „Gestern im Team": volle Reflexionen vor heute (heutige bleiben privat
// bis morgen). Unabhängig vom publish-Cron.
.where(and(lt(dayEntry.date, todayDate), eq(dayEntry.status, 'full')))
.orderBy(desc(dayEntry.date), desc(dayEntry.reflectedAt))
.limit(50),
ctx.db
.select({
id: risk.id,
title: risk.title,
projectId: risk.projectId,
projectName: project.name,
})
.from(risk)
.innerJoin(project, eq(project.id, risk.projectId))
.where(
and(
eq(risk.showstopper, true),
eq(risk.kind, 'risiko'),
ne(risk.status, 'geschlossen'),
eq(project.archiviert, false),
),
),
// Anstehende Meilensteine (offen/überfällig, mit Datum) projektübergreifend.
ctx.db
.select({
id: milestone.id,
title: milestone.title,
due: milestone.due,
status: milestone.status,
projectId: milestone.projectId,
projectName: project.name,
areaId: project.areaId,
})
.from(milestone)
.innerJoin(project, eq(project.id, milestone.projectId))
.where(
and(
ne(milestone.status, 'done'),
isNotNull(milestone.due),
eq(project.archiviert, false),
),
)
.orderBy(asc(milestone.due))
.limit(15),
]);
const projectsByArea = new Map<string, Project[]>();
for (const p of allProjects) {
const list = projectsByArea.get(p.areaId) ?? [];
list.push(p);
projectsByArea.set(p.areaId, list);
}
const areaSummaries = areas.map((a: Area) => {
const projs = projectsByArea.get(a.id) ?? [];
const agg = aggregateAreaAmpel(projs);
return {
id: a.id,
slug: a.slug,
name: a.name,
kind: a.kind,
ampel: agg.ampel,
counts: agg.counts,
};
});
const presentToday = new Set(todayPresence.map((p) => p.userId));
const usersAll = await ctx.db
.select({ id: user.id, name: user.name })
.from(user)
.where(eq(user.active, true));
const fehlende = usersAll.filter((u) => !presentToday.has(u.id));
return {
date: todayDate,
areas: areaSummaries,
blockers: openBlockers.map((b) => ({
id: b.id,
title: b.title,
openedAt: b.openedAt,
areaId: b.areaId,
projectId: b.projectId,
})),
heuteQuote: {
gepingt: presentToday.size,
gesamt: totalActiveUsers[0]?.c ?? 0,
fehlende,
},
jourfixe: { nextDate: nextJourfixe(todayDate), label: 'Di 11:00' },
showstoppers: showstopperRows,
meilensteine: milestoneRows.map((m) => ({
...m,
ueberfaellig: m.due != null && m.due < todayDate,
})),
kritischeModi: allProjects
.filter((p) => p.modus === 'eskalation' || p.modus === 'taskforce')
.map((p) => ({ id: p.id, name: p.name, areaId: p.areaId, modus: p.modus })),
pflichtMinimumOffen: allProjects
.filter((p) => Object.values(p.pflichtMinimumState).some((v) => v === false))
.map((p) => ({ id: p.id, name: p.name, areaId: p.areaId })),
feed: feedRows,
gestern: previousWerktag(todayDate),
};
}),
});
function nextJourfixe(todayIsoDate: string): string {
// Nächster Dienstag (Default Jourfixe). Wenn heute Dienstag, dann heute.
const d = new Date(`${todayIsoDate}T00:00:00Z`);
const wd = d.getUTCDay(); // 0=So..6=Sa
const offset = (2 - wd + 7) % 7 || 0; // 2=Di
d.setUTCDate(d.getUTCDate() + offset);
return d.toISOString().slice(0, 10);
}