jettypod 4.4.10 → 4.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/dashboard/README.md +36 -0
- package/apps/dashboard/app/favicon.ico +0 -0
- package/apps/dashboard/app/globals.css +122 -0
- package/apps/dashboard/app/layout.tsx +34 -0
- package/apps/dashboard/app/page.tsx +25 -0
- package/apps/dashboard/app/work/[id]/page.tsx +193 -0
- package/apps/dashboard/components/KanbanBoard.tsx +201 -0
- package/apps/dashboard/components/WorkItemTree.tsx +116 -0
- package/apps/dashboard/components.json +22 -0
- package/apps/dashboard/eslint.config.mjs +18 -0
- package/apps/dashboard/lib/db.ts +270 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next.config.ts +7 -0
- package/apps/dashboard/package.json +33 -0
- package/apps/dashboard/postcss.config.mjs +7 -0
- package/apps/dashboard/public/file.svg +1 -0
- package/apps/dashboard/public/globe.svg +1 -0
- package/apps/dashboard/public/next.svg +1 -0
- package/apps/dashboard/public/vercel.svg +1 -0
- package/apps/dashboard/public/window.svg +1 -0
- package/apps/dashboard/tsconfig.json +34 -0
- package/jettypod.js +41 -0
- package/lib/current-work.js +10 -18
- package/lib/migrations/016-workflow-checkpoints-table.js +70 -0
- package/lib/migrations/017-backfill-epic-id.js +54 -0
- package/lib/workflow-checkpoint.js +204 -0
- package/package.json +7 -2
- package/skills-templates/chore-mode/SKILL.md +3 -0
- package/skills-templates/epic-planning/SKILL.md +225 -154
- package/skills-templates/feature-planning/SKILL.md +172 -87
- package/skills-templates/speed-mode/SKILL.md +161 -338
- package/skills-templates/stable-mode/SKILL.md +150 -176
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
|
2
|
+
|
|
3
|
+
## Getting Started
|
|
4
|
+
|
|
5
|
+
First, run the development server:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm run dev
|
|
9
|
+
# or
|
|
10
|
+
yarn dev
|
|
11
|
+
# or
|
|
12
|
+
pnpm dev
|
|
13
|
+
# or
|
|
14
|
+
bun dev
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
18
|
+
|
|
19
|
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
|
20
|
+
|
|
21
|
+
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
|
22
|
+
|
|
23
|
+
## Learn More
|
|
24
|
+
|
|
25
|
+
To learn more about Next.js, take a look at the following resources:
|
|
26
|
+
|
|
27
|
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
28
|
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
29
|
+
|
|
30
|
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
31
|
+
|
|
32
|
+
## Deploy on Vercel
|
|
33
|
+
|
|
34
|
+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
35
|
+
|
|
36
|
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
Binary file
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "tw-animate-css";
|
|
3
|
+
|
|
4
|
+
@custom-variant dark (&:is(.dark *));
|
|
5
|
+
|
|
6
|
+
@theme inline {
|
|
7
|
+
--color-background: var(--background);
|
|
8
|
+
--color-foreground: var(--foreground);
|
|
9
|
+
--font-sans: var(--font-geist-sans);
|
|
10
|
+
--font-mono: var(--font-geist-mono);
|
|
11
|
+
--color-sidebar-ring: var(--sidebar-ring);
|
|
12
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
13
|
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
14
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
15
|
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
16
|
+
--color-sidebar-primary: var(--sidebar-primary);
|
|
17
|
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
18
|
+
--color-sidebar: var(--sidebar);
|
|
19
|
+
--color-chart-5: var(--chart-5);
|
|
20
|
+
--color-chart-4: var(--chart-4);
|
|
21
|
+
--color-chart-3: var(--chart-3);
|
|
22
|
+
--color-chart-2: var(--chart-2);
|
|
23
|
+
--color-chart-1: var(--chart-1);
|
|
24
|
+
--color-ring: var(--ring);
|
|
25
|
+
--color-input: var(--input);
|
|
26
|
+
--color-border: var(--border);
|
|
27
|
+
--color-destructive: var(--destructive);
|
|
28
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
29
|
+
--color-accent: var(--accent);
|
|
30
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
31
|
+
--color-muted: var(--muted);
|
|
32
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
33
|
+
--color-secondary: var(--secondary);
|
|
34
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
35
|
+
--color-primary: var(--primary);
|
|
36
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
37
|
+
--color-popover: var(--popover);
|
|
38
|
+
--color-card-foreground: var(--card-foreground);
|
|
39
|
+
--color-card: var(--card);
|
|
40
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
41
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
42
|
+
--radius-lg: var(--radius);
|
|
43
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
:root {
|
|
47
|
+
--radius: 0.625rem;
|
|
48
|
+
--background: oklch(1 0 0);
|
|
49
|
+
--foreground: oklch(0.145 0 0);
|
|
50
|
+
--card: oklch(1 0 0);
|
|
51
|
+
--card-foreground: oklch(0.145 0 0);
|
|
52
|
+
--popover: oklch(1 0 0);
|
|
53
|
+
--popover-foreground: oklch(0.145 0 0);
|
|
54
|
+
--primary: oklch(0.205 0 0);
|
|
55
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
56
|
+
--secondary: oklch(0.97 0 0);
|
|
57
|
+
--secondary-foreground: oklch(0.205 0 0);
|
|
58
|
+
--muted: oklch(0.97 0 0);
|
|
59
|
+
--muted-foreground: oklch(0.556 0 0);
|
|
60
|
+
--accent: oklch(0.97 0 0);
|
|
61
|
+
--accent-foreground: oklch(0.205 0 0);
|
|
62
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
63
|
+
--border: oklch(0.922 0 0);
|
|
64
|
+
--input: oklch(0.922 0 0);
|
|
65
|
+
--ring: oklch(0.708 0 0);
|
|
66
|
+
--chart-1: oklch(0.646 0.222 41.116);
|
|
67
|
+
--chart-2: oklch(0.6 0.118 184.704);
|
|
68
|
+
--chart-3: oklch(0.398 0.07 227.392);
|
|
69
|
+
--chart-4: oklch(0.828 0.189 84.429);
|
|
70
|
+
--chart-5: oklch(0.769 0.188 70.08);
|
|
71
|
+
--sidebar: oklch(0.985 0 0);
|
|
72
|
+
--sidebar-foreground: oklch(0.145 0 0);
|
|
73
|
+
--sidebar-primary: oklch(0.205 0 0);
|
|
74
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
75
|
+
--sidebar-accent: oklch(0.97 0 0);
|
|
76
|
+
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
77
|
+
--sidebar-border: oklch(0.922 0 0);
|
|
78
|
+
--sidebar-ring: oklch(0.708 0 0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.dark {
|
|
82
|
+
--background: oklch(0.145 0 0);
|
|
83
|
+
--foreground: oklch(0.985 0 0);
|
|
84
|
+
--card: oklch(0.205 0 0);
|
|
85
|
+
--card-foreground: oklch(0.985 0 0);
|
|
86
|
+
--popover: oklch(0.205 0 0);
|
|
87
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
88
|
+
--primary: oklch(0.922 0 0);
|
|
89
|
+
--primary-foreground: oklch(0.205 0 0);
|
|
90
|
+
--secondary: oklch(0.269 0 0);
|
|
91
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
92
|
+
--muted: oklch(0.269 0 0);
|
|
93
|
+
--muted-foreground: oklch(0.708 0 0);
|
|
94
|
+
--accent: oklch(0.269 0 0);
|
|
95
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
96
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
97
|
+
--border: oklch(1 0 0 / 10%);
|
|
98
|
+
--input: oklch(1 0 0 / 15%);
|
|
99
|
+
--ring: oklch(0.556 0 0);
|
|
100
|
+
--chart-1: oklch(0.488 0.243 264.376);
|
|
101
|
+
--chart-2: oklch(0.696 0.17 162.48);
|
|
102
|
+
--chart-3: oklch(0.769 0.188 70.08);
|
|
103
|
+
--chart-4: oklch(0.627 0.265 303.9);
|
|
104
|
+
--chart-5: oklch(0.645 0.246 16.439);
|
|
105
|
+
--sidebar: oklch(0.205 0 0);
|
|
106
|
+
--sidebar-foreground: oklch(0.985 0 0);
|
|
107
|
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
108
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
109
|
+
--sidebar-accent: oklch(0.269 0 0);
|
|
110
|
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
111
|
+
--sidebar-border: oklch(1 0 0 / 10%);
|
|
112
|
+
--sidebar-ring: oklch(0.556 0 0);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@layer base {
|
|
116
|
+
* {
|
|
117
|
+
@apply border-border outline-ring/50;
|
|
118
|
+
}
|
|
119
|
+
body {
|
|
120
|
+
@apply bg-background text-foreground;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { Geist, Geist_Mono } from "next/font/google";
|
|
3
|
+
import "./globals.css";
|
|
4
|
+
|
|
5
|
+
const geistSans = Geist({
|
|
6
|
+
variable: "--font-geist-sans",
|
|
7
|
+
subsets: ["latin"],
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const geistMono = Geist_Mono({
|
|
11
|
+
variable: "--font-geist-mono",
|
|
12
|
+
subsets: ["latin"],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const metadata: Metadata = {
|
|
16
|
+
title: "Create Next App",
|
|
17
|
+
description: "Generated by create next app",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default function RootLayout({
|
|
21
|
+
children,
|
|
22
|
+
}: Readonly<{
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
}>) {
|
|
25
|
+
return (
|
|
26
|
+
<html lang="en">
|
|
27
|
+
<body
|
|
28
|
+
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
29
|
+
>
|
|
30
|
+
{children}
|
|
31
|
+
</body>
|
|
32
|
+
</html>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { getKanbanData } from '@/lib/db';
|
|
2
|
+
import { KanbanBoard } from '@/components/KanbanBoard';
|
|
3
|
+
|
|
4
|
+
export default function Home() {
|
|
5
|
+
const data = getKanbanData();
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
|
9
|
+
<header className="border-b border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
|
|
10
|
+
<div className="max-w-6xl mx-auto px-4 py-4">
|
|
11
|
+
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
|
12
|
+
JettyPod Dashboard
|
|
13
|
+
</h1>
|
|
14
|
+
</div>
|
|
15
|
+
</header>
|
|
16
|
+
<main className="max-w-6xl mx-auto px-4 py-8">
|
|
17
|
+
<KanbanBoard
|
|
18
|
+
inFlight={data.inFlight}
|
|
19
|
+
backlog={data.backlog}
|
|
20
|
+
done={data.done}
|
|
21
|
+
/>
|
|
22
|
+
</main>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { getWorkItem, getChildWorkItems, getDecisionsForWorkItem } from '@/lib/db';
|
|
2
|
+
import { WorkItemTree } from '@/components/WorkItemTree';
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { notFound } from 'next/navigation';
|
|
5
|
+
|
|
6
|
+
const typeLabels: Record<string, { icon: string; label: string }> = {
|
|
7
|
+
epic: { icon: '🎯', label: 'Epic' },
|
|
8
|
+
feature: { icon: '✨', label: 'Feature' },
|
|
9
|
+
chore: { icon: '🔧', label: 'Chore' },
|
|
10
|
+
bug: { icon: '🐛', label: 'Bug' },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const statusLabels: Record<string, { label: string; color: string }> = {
|
|
14
|
+
backlog: { label: 'Backlog', color: 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300' },
|
|
15
|
+
todo: { label: 'Todo', color: 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300' },
|
|
16
|
+
in_progress: { label: 'In Progress', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300' },
|
|
17
|
+
done: { label: 'Done', color: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300' },
|
|
18
|
+
cancelled: { label: 'Cancelled', color: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const modeLabels: Record<string, { label: string; color: string }> = {
|
|
22
|
+
speed: { label: 'Speed Mode', color: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200' },
|
|
23
|
+
stable: { label: 'Stable Mode', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
|
|
24
|
+
production: { label: 'Production Mode', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
interface PageProps {
|
|
28
|
+
params: Promise<{ id: string }>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default async function WorkItemPage({ params }: PageProps) {
|
|
32
|
+
const { id } = await params;
|
|
33
|
+
const workItemId = parseInt(id, 10);
|
|
34
|
+
|
|
35
|
+
if (isNaN(workItemId)) {
|
|
36
|
+
notFound();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const item = getWorkItem(workItemId);
|
|
40
|
+
|
|
41
|
+
if (!item) {
|
|
42
|
+
notFound();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const children = getChildWorkItems(workItemId);
|
|
46
|
+
const decisions = getDecisionsForWorkItem(workItemId);
|
|
47
|
+
const parentItem = item.parent_id ? getWorkItem(item.parent_id) : null;
|
|
48
|
+
|
|
49
|
+
const typeInfo = typeLabels[item.type] || { icon: '📄', label: 'Item' };
|
|
50
|
+
const statusInfo = statusLabels[item.status] || statusLabels.backlog;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
|
54
|
+
{/* Header */}
|
|
55
|
+
<header className="border-b border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
|
|
56
|
+
<div className="max-w-4xl mx-auto px-4 py-4">
|
|
57
|
+
<Link href="/" className="text-blue-600 dark:text-blue-400 hover:underline text-sm">
|
|
58
|
+
← Back to Dashboard
|
|
59
|
+
</Link>
|
|
60
|
+
</div>
|
|
61
|
+
</header>
|
|
62
|
+
|
|
63
|
+
<main className="max-w-4xl mx-auto px-4 py-6">
|
|
64
|
+
{/* Breadcrumb */}
|
|
65
|
+
{parentItem && (
|
|
66
|
+
<div className="mb-4 text-sm text-zinc-500">
|
|
67
|
+
<Link href={`/work/${parentItem.id}`} className="hover:underline">
|
|
68
|
+
{typeLabels[parentItem.type]?.icon} #{parentItem.id} {parentItem.title}
|
|
69
|
+
</Link>
|
|
70
|
+
<span className="mx-2">→</span>
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{/* Main card */}
|
|
75
|
+
<div className="bg-white dark:bg-zinc-900 rounded-lg border border-zinc-200 dark:border-zinc-800 overflow-hidden">
|
|
76
|
+
{/* Header */}
|
|
77
|
+
<div className="px-6 py-4 border-b border-zinc-200 dark:border-zinc-800">
|
|
78
|
+
<div className="flex items-start justify-between gap-4">
|
|
79
|
+
<div>
|
|
80
|
+
<div className="flex items-center gap-2 text-sm text-zinc-500 mb-1">
|
|
81
|
+
<span>{typeInfo.icon} {typeInfo.label}</span>
|
|
82
|
+
<span>•</span>
|
|
83
|
+
<span className="font-mono">#{item.id}</span>
|
|
84
|
+
</div>
|
|
85
|
+
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-100">
|
|
86
|
+
{item.title}
|
|
87
|
+
</h1>
|
|
88
|
+
</div>
|
|
89
|
+
<div className="flex items-center gap-2">
|
|
90
|
+
{item.mode && modeLabels[item.mode] && (
|
|
91
|
+
<span className={`text-sm px-2 py-1 rounded ${modeLabels[item.mode].color}`}>
|
|
92
|
+
{modeLabels[item.mode].label}
|
|
93
|
+
</span>
|
|
94
|
+
)}
|
|
95
|
+
<span className={`text-sm px-2 py-1 rounded ${statusInfo.color}`}>
|
|
96
|
+
{statusInfo.label}
|
|
97
|
+
</span>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* Description */}
|
|
103
|
+
{item.description && (
|
|
104
|
+
<div className="px-6 py-4 border-b border-zinc-200 dark:border-zinc-800">
|
|
105
|
+
<h2 className="text-sm font-semibold text-zinc-500 uppercase tracking-wide mb-2">
|
|
106
|
+
Description
|
|
107
|
+
</h2>
|
|
108
|
+
<p className="text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap">
|
|
109
|
+
{item.description}
|
|
110
|
+
</p>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{/* Metadata */}
|
|
115
|
+
<div className="px-6 py-4 border-b border-zinc-200 dark:border-zinc-800">
|
|
116
|
+
<h2 className="text-sm font-semibold text-zinc-500 uppercase tracking-wide mb-3">
|
|
117
|
+
Details
|
|
118
|
+
</h2>
|
|
119
|
+
<dl className="grid grid-cols-2 gap-4 text-sm">
|
|
120
|
+
{item.branch_name && (
|
|
121
|
+
<div>
|
|
122
|
+
<dt className="text-zinc-500">Branch</dt>
|
|
123
|
+
<dd className="font-mono text-zinc-900 dark:text-zinc-100">{item.branch_name}</dd>
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
{item.phase && (
|
|
127
|
+
<div>
|
|
128
|
+
<dt className="text-zinc-500">Phase</dt>
|
|
129
|
+
<dd className="text-zinc-900 dark:text-zinc-100">{item.phase}</dd>
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
<div>
|
|
133
|
+
<dt className="text-zinc-500">Created</dt>
|
|
134
|
+
<dd className="text-zinc-900 dark:text-zinc-100">
|
|
135
|
+
{new Date(item.created_at).toLocaleDateString()}
|
|
136
|
+
</dd>
|
|
137
|
+
</div>
|
|
138
|
+
{item.completed_at && (
|
|
139
|
+
<div>
|
|
140
|
+
<dt className="text-zinc-500">Completed</dt>
|
|
141
|
+
<dd className="text-zinc-900 dark:text-zinc-100">
|
|
142
|
+
{new Date(item.completed_at).toLocaleDateString()}
|
|
143
|
+
</dd>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
</dl>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{/* Decisions */}
|
|
150
|
+
{decisions.length > 0 && (
|
|
151
|
+
<div className="px-6 py-4 border-b border-zinc-200 dark:border-zinc-800">
|
|
152
|
+
<h2 className="text-sm font-semibold text-zinc-500 uppercase tracking-wide mb-3">
|
|
153
|
+
Decisions
|
|
154
|
+
</h2>
|
|
155
|
+
<div className="space-y-4">
|
|
156
|
+
{decisions.map((decision) => (
|
|
157
|
+
<div key={decision.id} className="bg-zinc-50 dark:bg-zinc-800 rounded-lg p-4">
|
|
158
|
+
<div className="flex items-center gap-2 mb-2">
|
|
159
|
+
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
160
|
+
{decision.aspect}
|
|
161
|
+
</span>
|
|
162
|
+
<span className="text-xs text-zinc-500">
|
|
163
|
+
{new Date(decision.created_at).toLocaleDateString()}
|
|
164
|
+
</span>
|
|
165
|
+
</div>
|
|
166
|
+
<p className="text-zinc-700 dark:text-zinc-300 font-medium">
|
|
167
|
+
{decision.decision}
|
|
168
|
+
</p>
|
|
169
|
+
{decision.rationale && (
|
|
170
|
+
<p className="text-sm text-zinc-500 mt-1">
|
|
171
|
+
{decision.rationale}
|
|
172
|
+
</p>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{/* Children */}
|
|
181
|
+
{children.length > 0 && (
|
|
182
|
+
<div className="px-6 py-4">
|
|
183
|
+
<h2 className="text-sm font-semibold text-zinc-500 uppercase tracking-wide mb-3">
|
|
184
|
+
{item.type === 'epic' ? 'Features & Chores' : 'Child Items'} ({children.length})
|
|
185
|
+
</h2>
|
|
186
|
+
<WorkItemTree items={children.map(c => ({ ...c, children: [] }))} />
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
</main>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import type { WorkItem, InFlightItem, KanbanGroup } from '@/lib/db';
|
|
3
|
+
|
|
4
|
+
const typeIcons: Record<string, string> = {
|
|
5
|
+
epic: '🎯',
|
|
6
|
+
feature: '✨',
|
|
7
|
+
chore: '🔧',
|
|
8
|
+
bug: '🐛',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const modeLabels: Record<string, { label: string; color: string }> = {
|
|
12
|
+
speed: { label: 'speed', color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300' },
|
|
13
|
+
stable: { label: 'stable', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300' },
|
|
14
|
+
production: { label: 'prod', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/50 dark:text-purple-300' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
interface KanbanCardProps {
|
|
18
|
+
item: WorkItem;
|
|
19
|
+
epicTitle?: string | null;
|
|
20
|
+
showEpic?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function KanbanCard({ item, epicTitle, showEpic = false }: KanbanCardProps) {
|
|
24
|
+
return (
|
|
25
|
+
<Link
|
|
26
|
+
href={`/work/${item.id}`}
|
|
27
|
+
className="block bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 p-3 hover:border-zinc-300 dark:hover:border-zinc-600 hover:shadow-sm transition-all"
|
|
28
|
+
>
|
|
29
|
+
<div className="flex items-start gap-2">
|
|
30
|
+
<span className="text-sm flex-shrink-0">{typeIcons[item.type] || '📄'}</span>
|
|
31
|
+
<div className="flex-1 min-w-0">
|
|
32
|
+
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
33
|
+
<span className="text-xs text-zinc-400 font-mono">#{item.id}</span>
|
|
34
|
+
{item.mode && modeLabels[item.mode] && (
|
|
35
|
+
<span className={`text-xs px-1.5 py-0.5 rounded ${modeLabels[item.mode].color}`}>
|
|
36
|
+
{modeLabels[item.mode].label}
|
|
37
|
+
</span>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
<p className="text-sm text-zinc-900 dark:text-zinc-100 leading-snug">
|
|
41
|
+
{item.title}
|
|
42
|
+
</p>
|
|
43
|
+
{showEpic && epicTitle && (
|
|
44
|
+
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1.5 flex items-center gap-1">
|
|
45
|
+
<span>🎯</span>
|
|
46
|
+
<span>{epicTitle}</span>
|
|
47
|
+
</p>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</Link>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface EpicGroupProps {
|
|
56
|
+
epicId: number | null;
|
|
57
|
+
epicTitle: string | null;
|
|
58
|
+
items: WorkItem[];
|
|
59
|
+
isInFlight?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function EpicGroup({ epicId, epicTitle, items, isInFlight = false }: EpicGroupProps) {
|
|
63
|
+
if (items.length === 0) return null;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="mb-4">
|
|
67
|
+
{epicTitle ? (
|
|
68
|
+
<div className="flex items-center gap-2 mb-2">
|
|
69
|
+
<Link
|
|
70
|
+
href={`/work/${epicId}`}
|
|
71
|
+
className="flex items-center gap-1.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300"
|
|
72
|
+
>
|
|
73
|
+
<span>🎯</span>
|
|
74
|
+
<span>{epicTitle}</span>
|
|
75
|
+
</Link>
|
|
76
|
+
{isInFlight && (
|
|
77
|
+
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300">
|
|
78
|
+
in flight
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
) : (
|
|
83
|
+
<div className="text-xs font-medium text-zinc-400 dark:text-zinc-500 mb-2">
|
|
84
|
+
Ungrouped
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
<div className="space-y-2">
|
|
88
|
+
{items.map((item) => (
|
|
89
|
+
<KanbanCard key={item.id} item={item} />
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface KanbanColumnProps {
|
|
97
|
+
title: string;
|
|
98
|
+
children: React.ReactNode;
|
|
99
|
+
count: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function KanbanColumn({ title, children, count }: KanbanColumnProps) {
|
|
103
|
+
return (
|
|
104
|
+
<div className="flex-1 min-w-[300px] max-w-[400px]">
|
|
105
|
+
<div className="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-3 h-full">
|
|
106
|
+
<div className="flex items-center justify-between mb-3">
|
|
107
|
+
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
|
|
108
|
+
<span className="text-xs bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 px-2 py-0.5 rounded-full">
|
|
109
|
+
{count}
|
|
110
|
+
</span>
|
|
111
|
+
</div>
|
|
112
|
+
<div className="overflow-y-auto max-h-[calc(100vh-180px)]">
|
|
113
|
+
{children}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface KanbanBoardProps {
|
|
121
|
+
inFlight: InFlightItem[];
|
|
122
|
+
backlog: Map<string, KanbanGroup>;
|
|
123
|
+
done: Map<string, KanbanGroup>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function KanbanBoard({ inFlight, backlog, done }: KanbanBoardProps) {
|
|
127
|
+
const backlogCount = inFlight.length + Array.from(backlog.values()).reduce((sum, g) => sum + g.items.length, 0);
|
|
128
|
+
const doneCount = Array.from(done.values()).reduce((sum, g) => sum + g.items.length, 0);
|
|
129
|
+
|
|
130
|
+
// Build a set of epic IDs that have in-flight items
|
|
131
|
+
const inFlightEpicIds = new Set<number>();
|
|
132
|
+
for (const item of inFlight) {
|
|
133
|
+
const epicId = item.parent_id || item.epic_id;
|
|
134
|
+
if (epicId) {
|
|
135
|
+
inFlightEpicIds.add(epicId);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className="flex gap-4 overflow-x-auto pb-4">
|
|
141
|
+
{/* Backlog Column */}
|
|
142
|
+
<KanbanColumn title="Backlog" count={backlogCount}>
|
|
143
|
+
{/* In Flight Section */}
|
|
144
|
+
{inFlight.length > 0 && (
|
|
145
|
+
<div className="mb-4">
|
|
146
|
+
<div className="flex items-center gap-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 mb-2">
|
|
147
|
+
<span>🔥</span>
|
|
148
|
+
<span>In Flight</span>
|
|
149
|
+
</div>
|
|
150
|
+
<div className="space-y-2">
|
|
151
|
+
{inFlight.map((item) => (
|
|
152
|
+
<KanbanCard
|
|
153
|
+
key={item.id}
|
|
154
|
+
item={item}
|
|
155
|
+
epicTitle={item.epicTitle}
|
|
156
|
+
showEpic={true}
|
|
157
|
+
/>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{/* Divider if both sections have content */}
|
|
164
|
+
{inFlight.length > 0 && backlog.size > 0 && (
|
|
165
|
+
<hr className="border-zinc-300 dark:border-zinc-700 my-4" />
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{/* Grouped Backlog Items */}
|
|
169
|
+
{Array.from(backlog.entries()).map(([key, group]) => (
|
|
170
|
+
<EpicGroup
|
|
171
|
+
key={key}
|
|
172
|
+
epicId={group.epicId}
|
|
173
|
+
epicTitle={group.epicTitle}
|
|
174
|
+
items={group.items}
|
|
175
|
+
isInFlight={group.epicId ? inFlightEpicIds.has(group.epicId) : false}
|
|
176
|
+
/>
|
|
177
|
+
))}
|
|
178
|
+
|
|
179
|
+
{backlogCount === 0 && (
|
|
180
|
+
<p className="text-sm text-zinc-500 text-center py-4">No items in backlog</p>
|
|
181
|
+
)}
|
|
182
|
+
</KanbanColumn>
|
|
183
|
+
|
|
184
|
+
{/* Done Column */}
|
|
185
|
+
<KanbanColumn title="Done" count={doneCount}>
|
|
186
|
+
{Array.from(done.entries()).map(([key, group]) => (
|
|
187
|
+
<EpicGroup
|
|
188
|
+
key={key}
|
|
189
|
+
epicId={group.epicId}
|
|
190
|
+
epicTitle={group.epicTitle}
|
|
191
|
+
items={group.items}
|
|
192
|
+
/>
|
|
193
|
+
))}
|
|
194
|
+
|
|
195
|
+
{doneCount === 0 && (
|
|
196
|
+
<p className="text-sm text-zinc-500 text-center py-4">No completed items</p>
|
|
197
|
+
)}
|
|
198
|
+
</KanbanColumn>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|