Initial commit: everythingbutwork MVP
ci / quality (push) Has been cancelled
ci / build (push) Has been cancelled

Internes Project-Management-Tool für ToolCTRL — ergänzend zu Plane (Tickets)
und Outline (Doku). Umfasst Puls-Übersicht, Tagesrhythmus (Lock-in + Reflexion),
Bereichs-Detail-Views (Entwicklung/Vertrieb/Marketing), Pflicht-Minimum-Check
und Blocker-Lifecycle gemäß Projektmanagement-Leitfaden v3.0.

Stack: pnpm-Monorepo · Vite + React 18 + Tailwind · Fastify + tRPC v11 +
Drizzle ORM · Postgres 16 · Authentik OIDC (Phase 2, Dev-Auto-Login aktiv).

Planungs-Artefakte unter _bmad-output/planning-artifacts/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.7 committed 2026-05-25 22:47:26 +02:00
commit 4f33509a0f
131 files changed
+34339

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
// Ampel-Aggregations-Algorithmus (FR-1a).
export type Ampel = 'green' | 'amber' | 'red';
export type AreaAmpel = Ampel | 'grey';
export interface ProjectStatusLike {
statusCache: Ampel | null;
pflichtMinimumState: Record<string, boolean>;
archiviert: boolean;
}
export function aggregateAreaAmpel(projects: ProjectStatusLike[]): {
ampel: AreaAmpel;
counts: { green: number; amber: number; red: number; grey: number };
} {
const active = projects.filter((p) => !p.archiviert);
if (active.length === 0)
return { ampel: 'grey', counts: { green: 0, amber: 0, red: 0, grey: 0 } };
let red = 0;
let amber = 0;
let green = 0;
let grey = 0;
for (const p of active) {
// Pflicht-Minimum-Verstoss hebt mindestens auf gelb.
const hasMissingPflicht = Object.entries(p.pflichtMinimumState).some(([_, v]) => v === false);
let s: Ampel | 'grey' = p.statusCache ?? 'grey';
if (s === 'green' && hasMissingPflicht) s = 'amber';
if (s === 'red') red++;
else if (s === 'amber') amber++;
else if (s === 'green') green++;
else grey++;
}
if (red > 0) return { ampel: 'red', counts: { green, amber, red, grey } };
if (amber > 0) return { ampel: 'amber', counts: { green, amber, red, grey } };
if (green > 0) return { ampel: 'green', counts: { green, amber, red, grey } };
return { ampel: 'grey', counts: { green, amber, red, grey } };
}
+6
View File
@@ -0,0 +1,6 @@
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
base: { service: 'cockpit-api' },
});
+41
View File
@@ -0,0 +1,41 @@
// Pflicht-Minimum-Berechnung (Leitfaden §9).
// Punkte 1–4 + 6 werden manuell in der App gepflegt; Punkt 5 (Status-Ampel aktuell)
// wird hier zeitabhängig berechnet und im Cron auf alle Projekte aktualisiert.
import { eq, isNotNull, isNull, and, desc } from 'drizzle-orm';
import { project, dayEntry } from '@cockpit/db';
import { werktageBetween, todayIso } from './werktag.js';
const MAX_AGE_WERKTAGE = 7;
export async function recomputePflichtMinimumStatusActual(
db: import('@cockpit/db').Db,
): Promise<{ updated: number }> {
const projects = await db.select().from(project).where(eq(project.archiviert, false));
const today = todayIso();
let updated = 0;
for (const p of projects) {
// Letzte Reflexion mit Projektbezug? (Vereinfachung: letzter day_entry mit Ampel,
// dessen Update das Projekt-Status-Cache gesetzt hat = `status_cache` selbst.)
const lastEntry = (
await db
.select()
.from(dayEntry)
.where(and(isNotNull(dayEntry.reflectedAt)))
.orderBy(desc(dayEntry.reflectedAt))
.limit(1)
)[0];
const actual = lastEntry ? werktageBetween(lastEntry.date, today) <= MAX_AGE_WERKTAGE : false;
const newState = { ...p.pflichtMinimumState, status_aktuell: actual };
if (JSON.stringify(newState) !== JSON.stringify(p.pflichtMinimumState)) {
await db.update(project).set({ pflichtMinimumState: newState }).where(eq(project.id, p.id));
updated++;
}
}
return { updated };
}
// linter
void isNull;
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { formatIsoDate, isWerktag, nextWerktag, previousWerktag, werktageBetween } from './werktag';
describe('werktag', () => {
it('formatIsoDate liefert YYYY-MM-DD in Berlin TZ', () => {
const d = new Date(Date.UTC(2026, 4, 25, 8, 30));
expect(formatIsoDate(d)).toBe('2026-05-25');
});
it('isWerktag erkennt Mo-Fr', () => {
expect(isWerktag('2026-05-25')).toBe(true); // Mo
expect(isWerktag('2026-05-29')).toBe(true); // Fr
expect(isWerktag('2026-05-30')).toBe(false); // Sa
expect(isWerktag('2026-05-31')).toBe(false); // So
});
it('nextWerktag springt über Wochenende', () => {
expect(nextWerktag('2026-05-29')).toBe('2026-06-01'); // Fr → Mo
expect(nextWerktag('2026-05-25')).toBe('2026-05-26'); // Mo → Di
});
it('previousWerktag springt über Wochenende', () => {
expect(previousWerktag('2026-06-01')).toBe('2026-05-29'); // Mo → Fr
});
it('werktageBetween zählt korrekt', () => {
// Mo 2026-05-25 bis Mo 2026-06-01 (exkl.): 5 Werktage
expect(werktageBetween('2026-05-25', '2026-06-01')).toBe(5);
});
});
+68
View File
@@ -0,0 +1,68 @@
// Werktag-Logik in Europe/Berlin. Single source of truth fürs Backend
// (Frontend bekommt fertige Felder).
const TIMEZONE = 'Europe/Berlin';
/**
* Gibt das aktuelle Datum (oder `now`) als `YYYY-MM-DD` in Europe/Berlin zurück.
*/
export function todayIso(now: Date = new Date()): string {
return formatIsoDate(now);
}
export function formatIsoDate(d: Date): string {
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
return fmt.format(d); // en-CA liefert ISO YYYY-MM-DD
}
export function parseIsoDate(s: string): Date {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) {
throw new Error(`Ungültiges ISO-Datum: ${s}`);
}
const [y, m, d] = s.split('-').map(Number) as [number, number, number];
return new Date(Date.UTC(y, m - 1, d, 12, 0, 0)); // 12:00 UTC vermeidet TZ-Drift
}
/**
* Mo-Fr = Werktag. Feiertage Phase 2.
*/
export function isWerktag(s: string): boolean {
const d = parseIsoDate(s);
const wd = new Intl.DateTimeFormat('en-US', { timeZone: TIMEZONE, weekday: 'short' }).format(d);
return wd !== 'Sat' && wd !== 'Sun';
}
export function nextWerktag(s: string): string {
let cur = parseIsoDate(s);
do {
cur = new Date(cur.getTime() + 24 * 60 * 60 * 1000);
} while (!isWerktag(formatIsoDate(cur)));
return formatIsoDate(cur);
}
export function previousWerktag(s: string): string {
let cur = parseIsoDate(s);
do {
cur = new Date(cur.getTime() - 24 * 60 * 60 * 1000);
} while (!isWerktag(formatIsoDate(cur)));
return formatIsoDate(cur);
}
/**
* Zählt Werktage zwischen zwei ISO-Daten (inkl. start, exkl. end).
*/
export function werktageBetween(startIso: string, endIso: string): number {
let cur = parseIsoDate(startIso);
const end = parseIsoDate(endIso);
let count = 0;
while (cur.getTime() < end.getTime()) {
if (isWerktag(formatIsoDate(cur))) count++;
cur = new Date(cur.getTime() + 24 * 60 * 60 * 1000);
}
return count;
}