Initial commit: everythingbutwork MVP
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:
commit
4f33509a0f
131 files changed
+34339
No files matched your search
@@ -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 } };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import pino from 'pino';
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL ?? 'info',
|
||||
base: { service: 'cockpit-api' },
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user