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>
155 lines
5.3 KiB
TypeScript
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);
|
|
}
|