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

@@ -0,0 +1,71 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { trpc } from '../../lib/trpc';
import { voice } from '../../lib/voice';
import { Button } from '../ui/Button';
export function BlockerDialog({ onClose }: { onClose: () => void }) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const utils = trpc.useUtils();
const mutation = trpc.blocker.create.useMutation({
onSuccess: async () => {
toast(voice.toast.blockerOpened);
await utils.puls.overview.invalidate();
onClose();
},
});
function submit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
mutation.mutate({ title: title.trim(), description: description.trim() || undefined });
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold">Neuer Blocker</h2>
<form onSubmit={submit} className="mt-4 space-y-3">
<label className="block">
<span className="text-sm font-medium">Was klemmt?</span>
<input
required
ref={(el) => {
if (el && !title) el.focus();
}}
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="text-sm font-medium">Mehr Kontext (optional)</span>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
maxLength={1000}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</label>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={mutation.isPending}>
Erstellen
</Button>
</div>
</form>
</div>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { Outlet, useNavigate } from 'react-router';
import { useEffect, useState } from 'react';
import { TopNav } from './TopNav';
import { useCurrentUser } from '../../hooks/useCurrentUser';
import { BlockerDialog } from '../blocker/BlockerDialog';
const SHORTCUT_MAP: Record<string, string> = {
p: '/puls',
h: '/heute',
b: '/bereich',
e: '/bereich/entwicklung',
v: '/bereich/vertrieb',
m: '/bereich/marketing',
a: '/archiv',
};
export function AppShell() {
const me = useCurrentUser();
const navigate = useNavigate();
const [blockerOpen, setBlockerOpen] = useState(false);
// Dev-Mode: bei fehlender Session sofort in den Auto-Login. Auf prod hingegen
// zeigen wir die Login-Aufforderung mit dem Authentik-Link.
useEffect(() => {
if (import.meta.env.DEV && me === null) {
// Kleiner Delay, damit der useQuery sicher fertig ist und wir nicht
// mehrmals redirecten.
const t = setTimeout(() => {
if (window.location.pathname !== '/login') {
window.location.replace('/api/auth/dev-login');
}
}, 150);
return () => clearTimeout(t);
}
return undefined;
}, [me]);
// Keyboard-Shortcuts: `g <key>` → Navigation, `n` → Neuer Blocker.
useEffect(() => {
let waitingForSecond = false;
let timer: ReturnType<typeof setTimeout> | null = null;
function reset() {
waitingForSecond = false;
if (timer) clearTimeout(timer);
timer = null;
}
function onKey(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
) {
return;
}
if (e.key === '?' && !waitingForSecond) {
// eslint-disable-next-line no-alert
alert(
'Shortcuts:\n g p — Puls\n g b — Bereich\n g e/v/m — Entwicklung/Vertrieb/Marketing\n g h — Heute\n g a — Archiv\n n — Neuer Blocker\n ? — diese Hilfe',
);
return;
}
if (e.key.toLowerCase() === 'n' && !waitingForSecond) {
setBlockerOpen(true);
return;
}
if (!waitingForSecond && e.key.toLowerCase() === 'g') {
waitingForSecond = true;
timer = setTimeout(reset, 1500);
return;
}
if (waitingForSecond) {
const target = SHORTCUT_MAP[e.key.toLowerCase()];
if (target) navigate(target);
reset();
}
}
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
reset();
};
}, [navigate]);
return (
<div className="min-h-screen bg-[var(--color-bg)]">
<a href="#main" className="skip-link">
Zum Hauptinhalt springen
</a>
<TopNav />
<main id="main" className="mx-auto max-w-[1280px] px-6 py-8">
{me ? <Outlet /> : <NotLoggedInHint />}
</main>
{blockerOpen && <BlockerDialog onClose={() => setBlockerOpen(false)} />}
</div>
);
}
function NotLoggedInHint() {
return (
<div className="mx-auto mt-16 max-w-md rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-8 text-center shadow-[var(--shadow-sm)]">
<p className="text-[var(--color-ink-muted)]">Du bist nicht angemeldet.</p>
<a
href="/api/auth/login"
className="mt-4 inline-flex h-9 items-center rounded-[var(--radius-md)] bg-[var(--color-brand)] px-4 text-sm font-medium text-[var(--color-brand-fg)] hover:bg-black/85"
>
Über Authentik anmelden
</a>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { NavLink, useLocation } from 'react-router';
import { clsx } from 'clsx';
import { Flame } from 'lucide-react';
import { ToolCtrlLogo } from '../ui/ToolCtrlLogo';
import { voice } from '../../lib/voice';
import { useCurrentUser } from '../../hooks/useCurrentUser';
const TABS = [
{ to: '/puls', label: 'Puls', shortcut: 'g p', matchPrefix: '/puls' },
{ to: '/bereich', label: 'Bereich', shortcut: 'g b', matchPrefix: '/bereich' },
{ to: '/heute', label: 'Heute', shortcut: 'g h', matchPrefix: '/heute' },
{ to: '/archiv', label: 'Archiv', shortcut: 'g a', matchPrefix: '/archiv' },
] as const;
function getInitials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? '')
.join('');
}
export function TopNav() {
const me = useCurrentUser();
const location = useLocation();
return (
<header
className="sticky top-0 z-30 bg-[var(--color-bg)] border-b border-[var(--color-border)]"
role="banner"
>
<nav
className="mx-auto flex h-14 max-w-[1280px] items-center gap-2 px-6"
aria-label="Hauptnavigation"
>
<NavLink to="/puls" className="flex items-center gap-1.5 font-bold text-[var(--color-ink)]">
<span>{voice.appName}</span>
<ToolCtrlLogo size={18} />
</NavLink>
<ul className="flex items-center gap-0.5 ml-6">
{TABS.map((tab) => {
const isActive = location.pathname.startsWith(tab.matchPrefix);
return (
<li key={tab.to}>
<NavLink
to={tab.to}
className={clsx(
'inline-flex h-9 items-center rounded-[var(--radius-md)] px-3 text-sm font-medium',
'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-black/5',
isActive &&
'text-[var(--color-ink)] border-b-2 border-[var(--color-ink)] rounded-b-none',
)}
aria-keyshortcuts={tab.shortcut}
>
{tab.label}
</NavLink>
</li>
);
})}
</ul>
<div className="ml-auto flex items-center gap-3">
{me ? (
<>
<span
className={clsx(
'inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium',
me.streak > 0
? 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]'
: 'bg-black/5 text-[var(--color-ink-muted)]',
)}
title={`Aktuelle Update-Serie: ${me.streak} ${me.streak === 1 ? 'Tag' : 'Tage'}`}
>
<Flame size={12} fill={me.streak > 0 ? 'currentColor' : 'none'} />
<span className="font-mono tabular-nums">{me.streak}</span>
</span>
<div
className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--color-brand)] text-xs font-semibold text-[var(--color-brand-fg)]"
aria-label={me.name}
title={me.name}
>
{getInitials(me.name)}
</div>
</>
) : (
<a
href="/api/auth/login"
className="text-sm font-medium text-[var(--color-ink)] hover:underline"
>
Login
</a>
)}
</div>
</nav>
</header>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { clsx } from 'clsx';
import { forwardRef, type ButtonHTMLAttributes } from 'react';
type Variant = 'primary' | 'secondary' | 'ghost' | 'destructive';
type Size = 'sm' | 'md' | 'lg';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
const base =
'inline-flex items-center justify-center gap-2 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
const variantStyles: Record<Variant, string> = {
primary: 'bg-[var(--color-brand)] text-[var(--color-brand-fg)] hover:bg-black/85',
secondary: 'border border-[var(--color-ink)] text-[var(--color-ink)] hover:bg-black/5',
ghost: 'text-[var(--color-ink)] hover:bg-black/5',
destructive: 'bg-[var(--color-ampel-red-fg)] text-white hover:bg-red-800',
};
const sizeStyles: Record<Size, string> = {
sm: 'h-7 px-3 text-xs rounded-[var(--radius-md)]',
md: 'h-9 px-4 text-sm rounded-[var(--radius-md)]',
lg: 'h-11 px-6 text-base rounded-[var(--radius-md)]',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = 'primary', size = 'md', className, ...rest },
ref,
) {
return (
<button
ref={ref}
className={clsx(base, variantStyles[variant], sizeStyles[size], className)}
{...rest}
/>
);
});
+33
View File
@@ -0,0 +1,33 @@
import { clsx } from 'clsx';
import type { HTMLAttributes } from 'react';
function Root({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={clsx(
'bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)]',
className,
)}
{...rest}
/>
);
}
function Header({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
return <div className={clsx('px-6 pt-6 pb-3', className)} {...rest} />;
}
function Body({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
return <div className={clsx('px-6 py-3', className)} {...rest} />;
}
function Footer({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={clsx('px-6 pt-3 pb-6 border-t border-[var(--color-border)]', className)}
{...rest}
/>
);
}
export const Card = { Root, Header, Body, Footer };
+28
View File
@@ -0,0 +1,28 @@
// macOS-Ctrl-Caret als Brand-Akzent. Skalierbar via currentColor.
export function KeyCaret({
size = 16,
className,
'aria-hidden': ariaHidden = true,
}: {
size?: number;
className?: string;
'aria-hidden'?: boolean;
}) {
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden={ariaHidden}
>
<polyline points="3 10 8 5 13 10" />
</svg>
);
}
@@ -0,0 +1,24 @@
import { clsx } from 'clsx';
export function SkeletonCard({ className, lines = 3 }: { className?: string; lines?: number }) {
return (
<div
className={clsx(
'bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-lg)] p-6 animate-pulse',
className,
)}
aria-hidden
>
<div className="h-4 w-1/3 bg-[var(--color-border)] rounded" />
<div className="mt-3 space-y-2">
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className="h-3 bg-[var(--color-border)] rounded"
style={{ width: `${100 - i * 12}%` }}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,27 @@
// Brand-Emblem (das Tool-Ctrl-Tastenkappensymbol). Ersetzt den früher
// verwendeten Caret als Marken-Anker im Logo-Lockup und in Streaks.
export function ToolCtrlLogo({
size = 16,
className,
'aria-hidden': ariaHidden = true,
alt = 'ToolCTRL',
}: {
size?: number;
className?: string;
'aria-hidden'?: boolean;
alt?: string;
}) {
return (
<img
src="/toolctrl-emblem.svg"
width={size}
height={(size * 678) / 713}
className={className}
aria-hidden={ariaHidden}
alt={ariaHidden ? '' : alt}
draggable={false}
style={{ display: 'inline-block' }}
/>
);
}