chore: kanban template

This commit is contained in:
e2e
2026-06-21 23:19:14 +02:00
commit 01c6c3a461
209 changed files with 215578 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import en from './en.json'
// Typed tokens: every `t('key')` is checked against en.json's shape (i18next
// flattens it into dot-path keys), so a typo or removed key is a compile error.
// Other locale files are loaded dynamically and are not key-checked against en;
// a missing key falls back to the default locale at runtime.
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'translation'
resources: {
translation: typeof en
}
}
}
// Add a language: drop `i18n/<lang>.json` in this folder — Vite's
// import.meta.glob auto-registers it below into `resources`, and
// `supportedLocales` is derived from the files present, so no edits are needed
// here. Content is reachable via the `/<lang>` URL prefix (e.g. `/de`, `/es`);
// the default locale (`en`) needs no prefix.
const localeModules = import.meta.glob('./*.json', { eager: true }) as Record<
string,
{ default: Record<string, unknown> }
>
const resources: Record<string, { translation: Record<string, unknown> }> = {}
for (const path in localeModules) {
const code = path.slice(path.lastIndexOf('/') + 1).replace(/\.json$/, '')
resources[code] = { translation: localeModules[path].default }
}
export const supportedLocales = Object.keys(resources)
export type Locale = string
export const defaultLocale = 'en'
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])
// Direction for a locale — RTL for Arabic/Hebrew/Farsi/Urdu, else LTR. Set this
// on <html dir> at the root so the browser mirrors the layout; see the
// pikku-rtl skill. Returns 'ltr' while the app is English-only.
export function localeDir(locale: string = defaultLocale): 'rtl' | 'ltr' {
return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'
}
export function detectLocale(pathname: string): Locale {
const segment = pathname.split('/')[1]
if (supportedLocales.includes(segment as Locale)) {
return segment as Locale
}
if (typeof navigator !== 'undefined') {
const browserLang = navigator.language?.split('-')[0]
if (supportedLocales.includes(browserLang as Locale)) {
return browserLang as Locale
}
}
return defaultLocale
}
// i18n debug mode: when enabled, every *translated* string is masked to block
// glyphs (█), so any readable text left on screen is text that never went
// through a token — a hardcoded/inlined string. Surfaces missing i18n at a
// glance. Toggle with `?i18n-debug` in the URL or `localStorage['i18n-debug']
// = '1'` (or `I18N_DEBUG=1` for server-rendered builds). Off by default.
export function isI18nDebug(): boolean {
if (typeof process !== 'undefined' && process.env?.I18N_DEBUG === '1') return true
if (typeof window === 'undefined') return false
const params = new URLSearchParams(window.location.search)
if (params.has('i18n-debug')) return params.get('i18n-debug') !== '0'
return window.localStorage?.getItem('i18n-debug') === '1'
}
const i18nDebugPostProcessor = {
type: 'postProcessor' as const,
name: 'i18nDebug',
process: (value: string) => (isI18nDebug() ? value.replace(/\S/g, '█') : value),
}
i18n
.use(initReactI18next)
.use(i18nDebugPostProcessor)
.init({
resources,
lng: typeof window !== 'undefined' ? detectLocale(window.location.pathname) : defaultLocale,
fallbackLng: defaultLocale,
interpolation: { escapeValue: false },
postProcess: ['i18nDebug'],
})
export default i18n

View File

@@ -0,0 +1,123 @@
{
"shell": {
"eyebrow": "Pikku Fabric template",
"title": "Kanban operations cockpit",
"subtitle": "A lightweight board for work intake, automation visibility, and deployable app surfaces. Built to grow into a real product, not stay a demo.",
"stats": {
"boardHealth": "Board health",
"cardsTracked": "Cards tracked",
"runtime": "Runtime"
},
"runtimeConnected": "connected",
"runtimeDegraded": "degraded",
"nav": {
"overview": "Overview",
"board": "Board",
"automations": "Automations"
}
},
"health": {
"idle": "idle",
"backlogged": "backlogged",
"flowing": "flowing"
},
"status": {
"todo": "Backlog",
"doing": "In progress",
"done": "Done"
},
"relativeTime": {
"seconds": "{{count}}s ago",
"minutes": "{{count}}m ago",
"hours": "{{count}}h ago",
"days": "{{count}}d ago"
},
"overview": {
"heading": "Overview",
"sub": "Snapshot of current delivery flow and template wiring.",
"loadFailed": "Failed to load cards: {{error}}",
"loading": "Loading current board state…",
"kpi": {
"boardHealth": "Board health",
"boardHealthDetail": "Live signal from current card distribution",
"backlog": "Backlog",
"backlogDetail": "Items waiting for pickup",
"inProgress": "In progress",
"inProgressDetail": "Cards currently moving",
"done": "Done",
"doneDetail": "Completed work ready to review",
"latest": "Latest card",
"latestNone": "None yet",
"latestDetail": "{{status}} · {{time}}",
"latestEmptyDetail": "Create the first card to get started"
},
"executionModel": {
"heading": "Execution model",
"sub": "What this template already includes out of the box.",
"bullets": [
"Board CRUD over Pikku RPC and HTTP wires.",
"Workflow hooks for card onboarding and notifications.",
"Queue processing for async card events.",
"MCP and CLI entrypoints for ops-driven control."
]
},
"nextScreens": {
"heading": "Suggested next screens",
"sub": "Natural expansion points once the app goes beyond the starter board.",
"bullets": [
"Card detail with history, notes, and workflow runs.",
"Release view for branch deploys and environment promotions.",
"Team inbox for triage, alerts, and assignment queues."
]
}
},
"board": {
"heading": "Board",
"sub": "Track work across the three canonical lanes.",
"loadFailed": "Failed to load cards: {{error}}",
"loading": "Loading board…",
"columnEmpty": "No cards yet.",
"createHeading": "Create card",
"createSub": "Use the generated mutation, not ad hoc fetches.",
"titleLabel": "Title",
"titlePlaceholder": "Audit workspace webhooks",
"columnLabel": "Column",
"creating": "Creating…",
"createCta": "Create card",
"createFailed": "Failed to create card: {{error}}"
},
"automations": {
"heading": "Automations",
"sub": "Operational surfaces already included in the template.",
"rails": {
"onboarding": {
"name": "Card onboarding workflow",
"detail": "Handles enrich → create → notify when new work enters the system."
},
"queue": {
"name": "Process card event queue",
"detail": "Queues side effects away from the request path for safer scaling."
},
"surface": {
"name": "Kanban MCP + CLI surface",
"detail": "Lets operators inspect or create board state from tools outside the UI."
}
},
"states": {
"active": "active",
"idle": "idle",
"warming": "warming",
"standby": "standby",
"ready": "ready"
},
"why": {
"heading": "Why routing now",
"sub": "TanStack Router gives this app room to grow past a demo board.",
"bullets": [
"Separate product surfaces without a one-file SPA.",
"Clear paths for card details, releases, and diagnostics.",
"Navigation semantics that match future expansion."
]
}
}
}

422
apps/kanban/src/index.css Normal file
View File

@@ -0,0 +1,422 @@
:root {
--bg: #081018;
--bg-accent: #101d2a;
--surface: rgba(17, 27, 39, 0.9);
--surface-strong: rgba(22, 35, 50, 0.96);
--surface-soft: rgba(255, 255, 255, 0.04);
--border: rgba(160, 196, 255, 0.16);
--border-strong: rgba(160, 196, 255, 0.28);
--text: #edf4ff;
--muted: #8ba0bb;
--primary: #82aaff;
--secondary: #5eead4;
--warning: #fbbf24;
--danger: #fb7185;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.34);
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(94, 234, 212, 0.08), transparent 28%),
radial-gradient(circle at top right, rgba(130, 170, 255, 0.12), transparent 30%),
linear-gradient(180deg, var(--bg-accent) 0%, var(--bg) 52%, #060b11 100%);
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select,
textarea {
font: inherit;
}
.app-shell {
max-width: 1240px;
margin: 0 auto;
padding: 40px 28px 56px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.7fr);
gap: 24px;
align-items: end;
margin-bottom: 24px;
}
.eyebrow {
margin-bottom: 10px;
color: var(--secondary);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 12px;
font-weight: 700;
}
.hero h1 {
margin: 0;
font-size: clamp(2.2rem, 5vw, 4.25rem);
line-height: 0.94;
letter-spacing: -0.05em;
}
.hero p {
max-width: 56ch;
margin: 14px 0 0;
color: var(--muted);
font-size: 16px;
line-height: 1.6;
}
.hero-status {
display: grid;
gap: 12px;
}
.hero-stat,
.panel,
.task-card,
.automation-card,
.kpi-card,
.column {
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--shadow);
}
.hero-stat {
padding: 16px 18px;
border-radius: 18px;
backdrop-filter: blur(10px);
}
.hero-stat span,
.kpi-card span,
.task-card-meta,
.column-empty,
.automation-card p,
.section-heading p,
.bullet-list,
.field span,
.inline-error,
.empty-state {
color: var(--muted);
}
.hero-stat strong {
display: block;
margin-top: 4px;
font-size: 22px;
text-transform: capitalize;
}
.hero-stat strong[data-tone='flowing'] {
color: var(--secondary);
}
.hero-stat strong[data-tone='backlogged'] {
color: var(--warning);
}
.hero-stat strong[data-tone='idle'] {
color: var(--primary);
}
.top-nav {
display: flex;
gap: 10px;
margin-bottom: 28px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 16px;
background: rgba(9, 15, 24, 0.72);
backdrop-filter: blur(12px);
}
.nav-link {
padding: 10px 14px;
border-radius: 12px;
color: var(--muted);
transition:
background-color 150ms ease,
color 150ms ease;
}
.nav-link:hover,
.nav-link.is-active {
color: var(--text);
background: rgba(130, 170, 255, 0.12);
}
.page-content {
min-height: 50vh;
}
.page-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr);
gap: 20px;
}
.panel {
padding: 22px;
border-radius: 22px;
backdrop-filter: blur(12px);
}
.panel.wide {
grid-column: span 2;
}
.section-heading {
margin-bottom: 18px;
}
.section-heading h2 {
margin: 0;
font-size: 24px;
letter-spacing: -0.03em;
}
.section-heading p {
margin: 6px 0 0;
line-height: 1.5;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.kpi-card {
padding: 16px;
border-radius: 16px;
background: var(--surface-strong);
}
.kpi-card strong {
display: block;
margin-top: 10px;
font-size: 22px;
line-height: 1.2;
}
.kpi-card small {
display: block;
margin-top: 8px;
color: var(--muted);
line-height: 1.45;
}
.bullet-list {
margin: 0;
padding-left: 18px;
line-height: 1.7;
}
.board-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.column {
padding: 14px;
border-radius: 18px;
background: var(--surface-strong);
}
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.column-header h3 {
margin: 0;
font-size: 15px;
}
.column-header span {
min-width: 28px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(130, 170, 255, 0.12);
text-align: center;
font-size: 12px;
color: var(--primary);
}
.column-cards {
display: grid;
gap: 10px;
}
.task-card {
padding: 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.025);
}
.task-card-title {
font-weight: 600;
line-height: 1.35;
}
.task-card-meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
font-size: 12px;
}
.column-empty,
.empty-state {
padding: 18px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.025);
line-height: 1.5;
}
.error-state,
.inline-error {
color: var(--danger);
}
.card-form {
display: grid;
gap: 16px;
}
.field {
display: grid;
gap: 8px;
}
.field span {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 700;
}
.field input,
.field select {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 14px;
background: rgba(255, 255, 255, 0.03);
color: var(--text);
outline: none;
}
.field input:focus,
.field select:focus {
border-color: var(--border-strong);
}
.primary-button {
padding: 13px 16px;
border: 0;
border-radius: 14px;
background: linear-gradient(135deg, var(--primary), #6ee7f9);
color: #071018;
font-weight: 800;
cursor: pointer;
}
.primary-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.automation-list {
display: grid;
gap: 14px;
}
.automation-card {
padding: 16px 18px;
border-radius: 16px;
background: var(--surface-strong);
}
.automation-topline {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.automation-topline h3 {
margin: 0;
font-size: 16px;
}
.automation-topline span {
padding: 5px 10px;
border-radius: 999px;
background: rgba(130, 170, 255, 0.12);
color: var(--primary);
text-transform: capitalize;
font-size: 12px;
font-weight: 700;
}
.automation-topline span[data-state='active'] {
background: rgba(94, 234, 212, 0.12);
color: var(--secondary);
}
.automation-topline span[data-state='warming'] {
background: rgba(251, 191, 36, 0.14);
color: var(--warning);
}
@media (max-width: 980px) {
.hero,
.page-grid,
.kpi-grid {
grid-template-columns: 1fr;
}
.panel.wide {
grid-column: auto;
}
.board-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.app-shell {
padding: 24px 16px 40px;
}
.top-nav {
overflow-x: auto;
}
}

View File

@@ -0,0 +1,46 @@
import { PikkuRPC } from '@project/functions-sdk/pikku/pikku-rpc.gen'
import { apiUrl } from './env'
// The Pikku-RPC shim. `rpc.invoke` is typed by the generated FlattenedRPCMap, so
// `makeApi().invoke('listCards', input)` checks the name and payload at compile
// time. Pikku emits unversioned latest-aliases (`listCards` → `listCards@v2`),
// so you call clean names. Used from TanStack Start loaders (SSR + client) and
// from event handlers for mutations.
export function makeApi(): PikkuRPC {
const rpc = new PikkuRPC()
rpc.setServerUrl(apiUrl())
return rpc
}
export async function formatApiError(error: unknown): Promise<string> {
if (error instanceof Response) {
const status = [error.status, error.statusText].filter(Boolean).join(' ')
try {
const payload = (await error.clone().json()) as {
message?: unknown
errorId?: unknown
}
if (typeof payload?.message === 'string') {
const detail =
typeof payload.errorId === 'string'
? `${payload.message} (${payload.errorId})`
: payload.message
return status ? `${status}: ${detail}` : detail
}
} catch {
// Fall through to plain text/body-less formatting.
}
try {
const text = (await error.clone().text()).trim()
if (text) return status ? `${status}: ${text}` : text
} catch {
// Ignore body-read failures and fall through.
}
return status || 'Request failed'
}
return error instanceof Error ? error.message : String(error)
}

View File

@@ -0,0 +1,17 @@
// Endpoints come from env, never hardcoded. Throws on a missing var rather than
// falling back to a localhost default, so a misconfigured deploy fails loudly.
//
// SSR (workerd inside the sandbox container) can't reach the external host port
// that VITE_API_URL points to. VITE_SERVER_API_URL holds the internal Caddy
// address (http://127.0.0.1:8080/api) and is set by generate-frontend-runtime.mjs
// for local sandbox mode. In deployed/hosted contexts VITE_SERVER_API_URL is
// unset and we fall back to VITE_API_URL, which is correct there.
export function apiUrl(): string {
const url = import.meta.env.SSR
? (import.meta.env.VITE_SERVER_API_URL ?? import.meta.env.VITE_API_URL)
: import.meta.env.VITE_API_URL
if (!url) {
throw new Error('VITE_API_URL is not set — point it at the Pikku API base URL')
}
return url
}

View File

@@ -0,0 +1,49 @@
import i18n from '../i18n/config'
export type CardStatus = 'todo' | 'doing' | 'done'
export interface Card {
cardId: string
title: string
status: CardStatus | string
position: number
createdAt: string
}
export const STATUSES: CardStatus[] = ['todo', 'doing', 'done']
export function sortCards(cards: Card[]) {
return [...cards].sort((a, b) => {
if (a.status !== b.status) return a.status.localeCompare(b.status)
return a.position - b.position
})
}
export function classifyHealth(cards: Card[]) {
const todo = cards.filter((card) => card.status === 'todo').length
const doing = cards.filter((card) => card.status === 'doing').length
if (doing === 0) return 'idle'
if (todo > doing * 2) return 'backlogged'
return 'flowing'
}
export function prettyStatus(status: string) {
switch (status) {
case 'todo':
return i18n.t('status.todo')
case 'doing':
return i18n.t('status.doing')
case 'done':
return i18n.t('status.done')
default:
return status
}
}
export function relativeTime(timestamp: string) {
const delta = Math.max(0, Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000))
if (delta < 60) return i18n.t('relativeTime.seconds', { count: delta })
if (delta < 3600) return i18n.t('relativeTime.minutes', { count: Math.floor(delta / 60) })
if (delta < 86400) return i18n.t('relativeTime.hours', { count: Math.floor(delta / 3600) })
return i18n.t('relativeTime.days', { count: Math.floor(delta / 86400) })
}

View File

@@ -0,0 +1,104 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as BoardRouteImport } from './routes/board'
import { Route as AutomationsRouteImport } from './routes/automations'
import { Route as IndexRouteImport } from './routes/index'
const BoardRoute = BoardRouteImport.update({
id: '/board',
path: '/board',
getParentRoute: () => rootRouteImport,
} as any)
const AutomationsRoute = AutomationsRouteImport.update({
id: '/automations',
path: '/automations',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/automations': typeof AutomationsRoute
'/board': typeof BoardRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/automations': typeof AutomationsRoute
'/board': typeof BoardRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/automations': typeof AutomationsRoute
'/board': typeof BoardRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/automations' | '/board'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/automations' | '/board'
id: '__root__' | '/' | '/automations' | '/board'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AutomationsRoute: typeof AutomationsRoute
BoardRoute: typeof BoardRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/board': {
id: '/board'
path: '/board'
fullPath: '/board'
preLoaderRoute: typeof BoardRouteImport
parentRoute: typeof rootRouteImport
}
'/automations': {
id: '/automations'
path: '/automations'
fullPath: '/automations'
preLoaderRoute: typeof AutomationsRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AutomationsRoute: AutomationsRoute,
BoardRoute: BoardRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

View File

@@ -0,0 +1,20 @@
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
// TanStack Start discovers this `getRouter` factory to build a router per
// request (server) and once on the client (hydration). routeTree.gen.ts is
// generated by the tanstackStart() Vite plugin from src/routes/** — don't
// hand-edit it.
export function getRouter() {
return createRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
})
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}

View File

@@ -0,0 +1,111 @@
import { createRootRoute, HeadContent, Link, Outlet, Scripts } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { defaultLocale, localeDir } from '../i18n/config'
import { classifyHealth, sortCards, type Card } from '../lib/kanban'
import { formatApiError, makeApi } from '../lib/api'
import '../i18n/config'
import '../index.css'
export interface BoardData {
cards: Card[]
error: string | null
}
// Root route renders the full HTML document for SSR (owns <html lang dir> so the
// tree mirrors for RTL locales) and the shared board chrome. Its loader fetches
// the cards once on the server; child routes read this same data via
// RootRoute.useLoaderData(), and mutations call router.invalidate() to re-run it.
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'Kanban Board' },
],
}),
loader: async (): Promise<BoardData> => {
try {
const { cards } = await makeApi().invoke('listCards', {})
return { cards: sortCards(cards as Card[]), error: null }
} catch (thrown: unknown) {
let message: string
try {
message = await formatApiError(thrown)
} catch {
message = 'Request failed'
}
return { cards: [], error: message }
}
},
component: RootDocument,
})
function RootDocument() {
const locale = defaultLocale
const { t } = useTranslation()
const { cards, error } = Route.useLoaderData()
const health = classifyHealth(cards)
return (
<html lang={locale} dir={localeDir(locale)}>
<head>
<HeadContent />
</head>
<body>
<div className="app-shell">
<header className="hero">
<div className="hero-copy">
<div className="eyebrow">{t('shell.eyebrow')}</div>
<h1>{t('shell.title')}</h1>
<p>{t('shell.subtitle')}</p>
</div>
<div className="hero-status">
<div className="hero-stat">
<span>{t('shell.stats.boardHealth')}</span>
<strong data-tone={health}>{t(`health.${health}`)}</strong>
</div>
<div className="hero-stat">
<span>{t('shell.stats.cardsTracked')}</span>
<strong>{cards.length}</strong>
</div>
<div className="hero-stat">
<span>{t('shell.stats.runtime')}</span>
<strong>{error ? t('shell.runtimeDegraded') : t('shell.runtimeConnected')}</strong>
</div>
</div>
</header>
<nav className="top-nav">
<Link
to="/"
className="nav-link"
activeOptions={{ exact: true }}
activeProps={{ className: 'nav-link is-active' }}
>
{t('shell.nav.overview')}
</Link>
<Link
to="/board"
className="nav-link"
activeProps={{ className: 'nav-link is-active' }}
>
{t('shell.nav.board')}
</Link>
<Link
to="/automations"
className="nav-link"
activeProps={{ className: 'nav-link is-active' }}
>
{t('shell.nav.automations')}
</Link>
</nav>
<main className="page-content">
<Outlet />
</main>
</div>
<Scripts />
</body>
</html>
)
}

View File

@@ -0,0 +1,63 @@
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Route as RootRoute } from './__root'
export const Route = createFileRoute('/automations')({
component: AutomationsPage,
})
function AutomationsPage() {
const { t } = useTranslation()
const { cards } = RootRoute.useLoaderData()
const total = cards.length
const active = cards.filter((card) => card.status === 'doing').length
const rails = [
{
key: 'onboarding',
state: total > 0 ? 'active' : 'idle',
},
{
key: 'queue',
state: active > 0 ? 'warming' : 'standby',
},
{
key: 'surface',
state: 'ready',
},
] as const
return (
<section className="page-grid">
<div className="panel wide">
<div className="section-heading">
<h2>{t('automations.heading')}</h2>
<p>{t('automations.sub')}</p>
</div>
<div className="automation-list">
{rails.map((rail) => (
<article key={rail.key} className="automation-card">
<div className="automation-topline">
<h3>{t(`automations.rails.${rail.key}.name`)}</h3>
<span data-state={rail.state}>{t(`automations.states.${rail.state}`)}</span>
</div>
<p>{t(`automations.rails.${rail.key}.detail`)}</p>
</article>
))}
</div>
</div>
<div className="panel">
<div className="section-heading">
<h2>{t('automations.why.heading')}</h2>
<p>{t('automations.why.sub')}</p>
</div>
<ul className="bullet-list">
{(t('automations.why.bullets', { returnObjects: true }) as string[]).map((bullet) => (
<li key={bullet}>{bullet}</li>
))}
</ul>
</div>
</section>
)
}

View File

@@ -0,0 +1,121 @@
import { useState } from 'react'
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Route as RootRoute } from './__root'
import { formatApiError, makeApi } from '../lib/api'
import { type CardStatus, STATUSES, prettyStatus, relativeTime } from '../lib/kanban'
export const Route = createFileRoute('/board')({
component: BoardPage,
})
function BoardPage() {
const { t } = useTranslation()
const router = useRouter()
const { cards, error } = RootRoute.useLoaderData()
const [title, setTitle] = useState('')
const [status, setStatus] = useState<CardStatus>('todo')
const [pending, setPending] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const columns = STATUSES.map((columnStatus) => ({
key: columnStatus,
title: prettyStatus(columnStatus),
cards: cards.filter((card) => card.status === columnStatus),
}))
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (!title.trim()) return
setPending(true)
setCreateError(null)
try {
await makeApi().invoke('createCard', { title: title.trim(), status })
setTitle('')
await router.invalidate()
} catch (err) {
setCreateError(await formatApiError(err))
} finally {
setPending(false)
}
}
return (
<section className="page-grid">
<div className="panel wide">
<div className="section-heading">
<h2>{t('board.heading')}</h2>
<p>{t('board.sub')}</p>
</div>
{error ? (
<div className="empty-state error-state">{t('board.loadFailed', { error })}</div>
) : (
<div className="board-grid">
{columns.map((column) => (
<div key={column.key} className="column">
<div className="column-header">
<h3>{column.title}</h3>
<span>{column.cards.length}</span>
</div>
<div className="column-cards">
{column.cards.length === 0 ? (
<div className="column-empty">{t('board.columnEmpty')}</div>
) : (
column.cards.map((card) => (
<article key={card.cardId} className="task-card">
<div className="task-card-title">{card.title}</div>
<div className="task-card-meta">
<span>#{card.position}</span>
<span>{relativeTime(card.createdAt)}</span>
</div>
</article>
))
)}
</div>
</div>
))}
</div>
)}
</div>
<div className="panel">
<div className="section-heading">
<h2>{t('board.createHeading')}</h2>
<p>{t('board.createSub')}</p>
</div>
<form className="card-form" onSubmit={handleSubmit}>
<label className="field">
<span>{t('board.titleLabel')}</span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder={t('board.titlePlaceholder')}
/>
</label>
<label className="field">
<span>{t('board.columnLabel')}</span>
<select
value={status}
onChange={(event) => setStatus(event.target.value as CardStatus)}
>
{STATUSES.map((value) => (
<option key={value} value={value}>
{prettyStatus(value)}
</option>
))}
</select>
</label>
<button type="submit" className="primary-button" disabled={pending || !title.trim()}>
{pending ? t('board.creating') : t('board.createCta')}
</button>
{createError && (
<div className="inline-error">{t('board.createFailed', { error: createError })}</div>
)}
</form>
</div>
</section>
)
}

View File

@@ -0,0 +1,105 @@
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Route as RootRoute } from './__root'
import { classifyHealth, prettyStatus, relativeTime } from '../lib/kanban'
export const Route = createFileRoute('/')({
component: OverviewPage,
})
function KpiCard(props: { label: string; value: string; detail: string }) {
return (
<article className="kpi-card">
<span>{props.label}</span>
<strong>{props.value}</strong>
<small>{props.detail}</small>
</article>
)
}
function OverviewPage() {
const { t } = useTranslation()
const { cards, error } = RootRoute.useLoaderData()
const todo = cards.filter((card) => card.status === 'todo').length
const doing = cards.filter((card) => card.status === 'doing').length
const done = cards.filter((card) => card.status === 'done').length
const newest = cards.at(-1) ?? null
const health = classifyHealth(cards)
return (
<section className="page-grid">
<div className="panel wide">
<div className="section-heading">
<h2>{t('overview.heading')}</h2>
<p>{t('overview.sub')}</p>
</div>
{error ? (
<div className="empty-state error-state">{t('overview.loadFailed', { error })}</div>
) : (
<div className="kpi-grid">
<KpiCard
label={t('overview.kpi.boardHealth')}
value={t(`health.${health}`)}
detail={t('overview.kpi.boardHealthDetail')}
/>
<KpiCard
label={t('overview.kpi.backlog')}
value={String(todo)}
detail={t('overview.kpi.backlogDetail')}
/>
<KpiCard
label={t('overview.kpi.inProgress')}
value={String(doing)}
detail={t('overview.kpi.inProgressDetail')}
/>
<KpiCard
label={t('overview.kpi.done')}
value={String(done)}
detail={t('overview.kpi.doneDetail')}
/>
<KpiCard
label={t('overview.kpi.latest')}
value={newest ? newest.title : t('overview.kpi.latestNone')}
detail={
newest
? t('overview.kpi.latestDetail', {
status: prettyStatus(newest.status),
time: relativeTime(newest.createdAt),
})
: t('overview.kpi.latestEmptyDetail')
}
/>
</div>
)}
</div>
<div className="panel">
<div className="section-heading">
<h2>{t('overview.executionModel.heading')}</h2>
<p>{t('overview.executionModel.sub')}</p>
</div>
<ul className="bullet-list">
{(t('overview.executionModel.bullets', { returnObjects: true }) as string[]).map(
(bullet) => (
<li key={bullet}>{bullet}</li>
),
)}
</ul>
</div>
<div className="panel">
<div className="section-heading">
<h2>{t('overview.nextScreens.heading')}</h2>
<p>{t('overview.nextScreens.sub')}</p>
</div>
<ul className="bullet-list">
{(t('overview.nextScreens.bullets', { returnObjects: true }) as string[]).map(
(bullet) => (
<li key={bullet}>{bullet}</li>
),
)}
</ul>
</div>
</section>
)
}