@reqord/web 0.1.0

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.
Files changed (138) hide show
  1. package/LICENSE +661 -0
  2. package/next-env.d.ts +6 -0
  3. package/next.config.ts +7 -0
  4. package/package.json +59 -0
  5. package/postcss.config.mjs +7 -0
  6. package/src/__tests__/components/dashboard/critical-path-display.test.tsx +129 -0
  7. package/src/__tests__/components/dashboard/progress-bar.test.tsx +87 -0
  8. package/src/__tests__/components/dashboard/project-health.test.tsx +57 -0
  9. package/src/__tests__/components/dashboard/warning-alert.test.tsx +75 -0
  10. package/src/__tests__/components/feedback/feedback-client-view.test.tsx +84 -0
  11. package/src/__tests__/components/feedback/feedback-filters.test.tsx +51 -0
  12. package/src/__tests__/components/feedback/feedback-linked-items.test.tsx +131 -0
  13. package/src/__tests__/components/feedback/feedback-list.test.tsx +49 -0
  14. package/src/__tests__/components/feedback/feedback-table.test.tsx +165 -0
  15. package/src/__tests__/components/flags/flag-badge.test.tsx +41 -0
  16. package/src/__tests__/components/flags/flag-list.test.tsx +51 -0
  17. package/src/__tests__/components/gantt/gantt-bar.test.tsx +190 -0
  18. package/src/__tests__/components/gantt/gantt-chart.test.tsx +141 -0
  19. package/src/__tests__/components/gantt/gantt-header.test.tsx +84 -0
  20. package/src/__tests__/components/gantt/gantt-legend.test.tsx +52 -0
  21. package/src/__tests__/components/graph/dag-layout.test.ts +129 -0
  22. package/src/__tests__/components/graph/dependency-graph.test.tsx +94 -0
  23. package/src/__tests__/components/graph/drilldown-breadcrumb.test.tsx +70 -0
  24. package/src/__tests__/components/graph/drilldown-graph.test.tsx +108 -0
  25. package/src/__tests__/components/graph/edge-styles.test.ts +27 -0
  26. package/src/__tests__/components/graph/graph-page-client.test.tsx +124 -0
  27. package/src/__tests__/components/graph/issue-node.test.tsx +173 -0
  28. package/src/__tests__/components/graph/requirement-node.test.tsx +151 -0
  29. package/src/__tests__/components/graph/specification-node.test.tsx +140 -0
  30. package/src/__tests__/components/specification/spec-tabs.test.tsx +153 -0
  31. package/src/__tests__/components/specification/tab-coverage.test.tsx +70 -0
  32. package/src/__tests__/components/specification/tab-design.test.tsx +42 -0
  33. package/src/__tests__/components/specification/tab-history.test.tsx +118 -0
  34. package/src/__tests__/components/specification/tab-issues.test.tsx +126 -0
  35. package/src/__tests__/components/specification/tab-research.test.tsx +42 -0
  36. package/src/__tests__/lib/dashboard-data.test.ts +334 -0
  37. package/src/__tests__/lib/drilldown-graph-data.test.ts +267 -0
  38. package/src/__tests__/lib/gantt-data.test.ts +299 -0
  39. package/src/__tests__/lib/graph-data.test.ts +309 -0
  40. package/src/__tests__/lib/local-feedback-repository.test.ts +74 -0
  41. package/src/__tests__/lib/local-specification-repository.test.ts +194 -0
  42. package/src/__tests__/lib/reqord-root.test.ts +31 -0
  43. package/src/__tests__/lib/specification-file.test.ts +63 -0
  44. package/src/__tests__/lib/tasks-data.test.ts +104 -0
  45. package/src/app/dashboard/loading.tsx +21 -0
  46. package/src/app/dashboard/page.tsx +50 -0
  47. package/src/app/error.tsx +22 -0
  48. package/src/app/feedback/loading.tsx +13 -0
  49. package/src/app/feedback/page.tsx +48 -0
  50. package/src/app/globals.css +2 -0
  51. package/src/app/graph/page.tsx +32 -0
  52. package/src/app/layout.tsx +25 -0
  53. package/src/app/page.tsx +5 -0
  54. package/src/app/requirements/[id]/edit/page.tsx +40 -0
  55. package/src/app/requirements/[id]/loading.tsx +14 -0
  56. package/src/app/requirements/[id]/not-found.tsx +18 -0
  57. package/src/app/requirements/[id]/page.tsx +43 -0
  58. package/src/app/requirements/loading.tsx +13 -0
  59. package/src/app/requirements/new/page.tsx +14 -0
  60. package/src/app/requirements/page.tsx +35 -0
  61. package/src/app/specifications/[id]/loading.tsx +14 -0
  62. package/src/app/specifications/[id]/not-found.tsx +18 -0
  63. package/src/app/specifications/[id]/page.tsx +52 -0
  64. package/src/app/specifications/loading.tsx +13 -0
  65. package/src/app/specifications/page.tsx +42 -0
  66. package/src/components/dashboard/critical-path-display.tsx +76 -0
  67. package/src/components/dashboard/progress-bar.tsx +45 -0
  68. package/src/components/dashboard/progress-section.tsx +57 -0
  69. package/src/components/dashboard/project-health.tsx +35 -0
  70. package/src/components/dashboard/status-card.tsx +27 -0
  71. package/src/components/dashboard/status-cards.tsx +28 -0
  72. package/src/components/dashboard/warning-alert.tsx +33 -0
  73. package/src/components/dashboard/warning-alerts.tsx +24 -0
  74. package/src/components/feedback/feedback-badge.tsx +48 -0
  75. package/src/components/feedback/feedback-client-view.tsx +38 -0
  76. package/src/components/feedback/feedback-filters.tsx +86 -0
  77. package/src/components/feedback/feedback-linked-items.tsx +93 -0
  78. package/src/components/feedback/feedback-list.tsx +40 -0
  79. package/src/components/feedback/feedback-table.tsx +115 -0
  80. package/src/components/gantt/gantt-bar.tsx +65 -0
  81. package/src/components/gantt/gantt-chart.tsx +88 -0
  82. package/src/components/gantt/gantt-constants.ts +15 -0
  83. package/src/components/gantt/gantt-critical-path.tsx +38 -0
  84. package/src/components/gantt/gantt-group.tsx +25 -0
  85. package/src/components/gantt/gantt-header.tsx +47 -0
  86. package/src/components/gantt/gantt-legend.tsx +26 -0
  87. package/src/components/graph/dag-layout.ts +131 -0
  88. package/src/components/graph/dependency-graph.tsx +88 -0
  89. package/src/components/graph/drilldown-breadcrumb.tsx +35 -0
  90. package/src/components/graph/drilldown-graph.tsx +45 -0
  91. package/src/components/graph/edge-styles.ts +16 -0
  92. package/src/components/graph/graph-loader.tsx +25 -0
  93. package/src/components/graph/graph-page-client.tsx +98 -0
  94. package/src/components/graph/issue-node.tsx +46 -0
  95. package/src/components/graph/multi-level-graph.tsx +91 -0
  96. package/src/components/graph/requirement-node.tsx +69 -0
  97. package/src/components/graph/specification-node.tsx +39 -0
  98. package/src/components/requirement/delete-button.tsx +46 -0
  99. package/src/components/requirement/dependency-editor.tsx +79 -0
  100. package/src/components/requirement/markdown-editor.tsx +47 -0
  101. package/src/components/requirement/markdown-renderer.tsx +12 -0
  102. package/src/components/requirement/requirement-detail.tsx +228 -0
  103. package/src/components/requirement/requirement-form.tsx +390 -0
  104. package/src/components/requirement/requirement-table.tsx +203 -0
  105. package/src/components/requirement/requirement-tabs.tsx +65 -0
  106. package/src/components/requirement/success-criteria-editor.tsx +53 -0
  107. package/src/components/specification/spec-detail.tsx +103 -0
  108. package/src/components/specification/spec-tabs.tsx +66 -0
  109. package/src/components/specification/specification-table.tsx +193 -0
  110. package/src/components/specification/tab-coverage.tsx +52 -0
  111. package/src/components/specification/tab-design.tsx +16 -0
  112. package/src/components/specification/tab-history.tsx +61 -0
  113. package/src/components/specification/tab-issues.tsx +111 -0
  114. package/src/components/specification/tab-research.tsx +16 -0
  115. package/src/components/ui/badge.tsx +64 -0
  116. package/src/components/ui/nav.tsx +49 -0
  117. package/src/components/ui/tabs.tsx +39 -0
  118. package/src/lib/actions.ts +222 -0
  119. package/src/lib/dashboard-data.ts +224 -0
  120. package/src/lib/data.ts +21 -0
  121. package/src/lib/drilldown-graph-data.ts +98 -0
  122. package/src/lib/feedback-data.ts +33 -0
  123. package/src/lib/feedback-repository.ts +6 -0
  124. package/src/lib/file-system.ts +167 -0
  125. package/src/lib/gantt-data.ts +168 -0
  126. package/src/lib/get-repository.ts +43 -0
  127. package/src/lib/graph-data.ts +161 -0
  128. package/src/lib/id-generator.ts +23 -0
  129. package/src/lib/local-feedback-repository.ts +36 -0
  130. package/src/lib/local-repository.ts +78 -0
  131. package/src/lib/local-specification-repository.ts +61 -0
  132. package/src/lib/repository.ts +11 -0
  133. package/src/lib/reqord-root.ts +33 -0
  134. package/src/lib/specification-data.ts +28 -0
  135. package/src/lib/specification-file.ts +12 -0
  136. package/src/lib/specification-repository.ts +8 -0
  137. package/src/lib/tasks-data.ts +32 -0
  138. package/tsconfig.json +27 -0
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ type TabCoverageProps = {
6
+ successCriteria: string[] | null;
7
+ };
8
+
9
+ export function TabCoverage({ successCriteria }: TabCoverageProps) {
10
+ if (!successCriteria || successCriteria.length === 0) {
11
+ return (
12
+ <div className="py-8 text-center text-gray-500">
13
+ No success criteria available
14
+ </div>
15
+ );
16
+ }
17
+
18
+ return (
19
+ <div className="overflow-x-auto">
20
+ <table className="min-w-full divide-y divide-gray-200">
21
+ <thead className="bg-gray-50">
22
+ <tr>
23
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-16">
24
+ #
25
+ </th>
26
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
27
+ Criteria
28
+ </th>
29
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-32">
30
+ Status
31
+ </th>
32
+ </tr>
33
+ </thead>
34
+ <tbody className="bg-white divide-y divide-gray-200">
35
+ {successCriteria.map((criterion, index) => (
36
+ <tr key={index} className={index % 2 === 1 ? "bg-gray-50" : ""}>
37
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
38
+ {index + 1}
39
+ </td>
40
+ <td className="px-4 py-3 text-sm text-gray-900">
41
+ {criterion}
42
+ </td>
43
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
44
+ Unchecked
45
+ </td>
46
+ </tr>
47
+ ))}
48
+ </tbody>
49
+ </table>
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,16 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { MarkdownRenderer } from "@/components/requirement/markdown-renderer";
5
+
6
+ export function TabDesign({ content }: { content: string | null }) {
7
+ if (content === null) {
8
+ return (
9
+ <div className="py-8 text-center text-gray-500">
10
+ Design document not available
11
+ </div>
12
+ );
13
+ }
14
+
15
+ return <MarkdownRenderer content={content} />;
16
+ }
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import type { VersionHistoryEntry } from "@reqord/shared";
5
+ import { StatusBadge } from "@/components/ui/badge";
6
+
7
+ type TabHistoryProps = {
8
+ versionHistory: VersionHistoryEntry[];
9
+ };
10
+
11
+ export function TabHistory({ versionHistory }: TabHistoryProps) {
12
+ if (!versionHistory || versionHistory.length === 0) {
13
+ return (
14
+ <div className="py-8 text-center text-gray-500">
15
+ No version history yet
16
+ </div>
17
+ );
18
+ }
19
+
20
+ return (
21
+ <div className="relative pl-8 border-l-2 border-gray-200">
22
+ {versionHistory.map((entry, index) => {
23
+ const isLatest = index === 0;
24
+ const date = new Date(entry.changedAt);
25
+
26
+ return (
27
+ <div key={entry.version} className="relative mb-8 last:mb-0">
28
+ {/* Timeline dot */}
29
+ <div
30
+ className={`absolute -left-[33px] w-3 h-3 rounded-full ${
31
+ isLatest
32
+ ? "bg-blue-500 ring-2 ring-blue-500"
33
+ : "bg-gray-400"
34
+ }`}
35
+ />
36
+
37
+ {/* Content */}
38
+ <div className="space-y-2">
39
+ <div className="flex items-center gap-3">
40
+ <span className="font-mono font-semibold text-gray-900">
41
+ {entry.version}
42
+ </span>
43
+ <StatusBadge status={entry.status} />
44
+ </div>
45
+
46
+ <p className="text-sm text-gray-500">
47
+ {date.toLocaleDateString("ja-JP", {
48
+ year: "numeric",
49
+ month: "long",
50
+ day: "numeric",
51
+ })}
52
+ </p>
53
+
54
+ <p className="text-sm text-gray-700">{entry.summary}</p>
55
+ </div>
56
+ </div>
57
+ );
58
+ })}
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,111 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ export type IssueItem = {
6
+ number: number;
7
+ title: string;
8
+ url: string;
9
+ priority: string;
10
+ status: string;
11
+ };
12
+
13
+ type TabIssuesProps = {
14
+ issues: IssueItem[] | null;
15
+ };
16
+
17
+ function getPriorityColor(priority: string): string {
18
+ switch (priority) {
19
+ case "P0":
20
+ return "bg-red-100 text-red-800";
21
+ case "P1":
22
+ return "bg-orange-100 text-orange-800";
23
+ case "P2":
24
+ return "bg-yellow-100 text-yellow-800";
25
+ case "P3":
26
+ return "bg-gray-100 text-gray-800";
27
+ default:
28
+ return "bg-gray-100 text-gray-800";
29
+ }
30
+ }
31
+
32
+ function getStatusColor(status: string): string {
33
+ switch (status) {
34
+ case "open":
35
+ return "bg-gray-100 text-gray-800";
36
+ case "in_progress":
37
+ return "bg-blue-100 text-blue-800";
38
+ case "closed":
39
+ return "bg-green-100 text-green-800";
40
+ default:
41
+ return "bg-gray-100 text-gray-800";
42
+ }
43
+ }
44
+
45
+ export function TabIssues({ issues }: TabIssuesProps) {
46
+ if (!issues || issues.length === 0) {
47
+ return (
48
+ <div className="py-8 text-center text-gray-500">
49
+ No issues generated yet
50
+ </div>
51
+ );
52
+ }
53
+
54
+ return (
55
+ <div className="overflow-x-auto">
56
+ <table className="min-w-full divide-y divide-gray-200">
57
+ <thead className="bg-gray-50">
58
+ <tr>
59
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-20">
60
+ #
61
+ </th>
62
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
63
+ Title
64
+ </th>
65
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-24">
66
+ Priority
67
+ </th>
68
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-32">
69
+ Status
70
+ </th>
71
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-24">
72
+ Link
73
+ </th>
74
+ </tr>
75
+ </thead>
76
+ <tbody className="bg-white divide-y divide-gray-200">
77
+ {issues.map((issue) => (
78
+ <tr key={issue.number}>
79
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
80
+ {issue.number}
81
+ </td>
82
+ <td className="px-4 py-3 text-sm text-gray-900">
83
+ {issue.title}
84
+ </td>
85
+ <td className="px-4 py-3 whitespace-nowrap">
86
+ <span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getPriorityColor(issue.priority)}`}>
87
+ {issue.priority}
88
+ </span>
89
+ </td>
90
+ <td className="px-4 py-3 whitespace-nowrap">
91
+ <span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(issue.status)}`}>
92
+ {issue.status}
93
+ </span>
94
+ </td>
95
+ <td className="px-4 py-3 whitespace-nowrap text-sm">
96
+ <a
97
+ href={issue.url}
98
+ target="_blank"
99
+ rel="noopener noreferrer"
100
+ className="text-blue-600 hover:underline"
101
+ >
102
+ View
103
+ </a>
104
+ </td>
105
+ </tr>
106
+ ))}
107
+ </tbody>
108
+ </table>
109
+ </div>
110
+ );
111
+ }
@@ -0,0 +1,16 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { MarkdownRenderer } from "@/components/requirement/markdown-renderer";
5
+
6
+ export function TabResearch({ content }: { content: string | null }) {
7
+ if (content === null) {
8
+ return (
9
+ <div className="py-8 text-center text-gray-500">
10
+ Research document not available
11
+ </div>
12
+ );
13
+ }
14
+
15
+ return <MarkdownRenderer content={content} />;
16
+ }
@@ -0,0 +1,64 @@
1
+ import React from "react";
2
+ import type { Status, Priority, Complexity } from "@reqord/shared";
3
+
4
+ const STATUS_STYLES: Record<Status, string> = {
5
+ draft: "bg-gray-100 text-gray-700",
6
+ approved: "bg-green-100 text-green-800",
7
+ implemented: "bg-blue-100 text-blue-800",
8
+ deprecated: "bg-red-100 text-red-700",
9
+ };
10
+
11
+ const STATUS_LABELS: Record<Status, string> = {
12
+ draft: "Draft",
13
+ approved: "Approved",
14
+ implemented: "Implemented",
15
+ deprecated: "Deprecated",
16
+ };
17
+
18
+ const PRIORITY_STYLES: Record<Priority, string> = {
19
+ low: "bg-blue-100 text-blue-700",
20
+ medium: "bg-orange-100 text-orange-700",
21
+ high: "bg-red-100 text-red-700",
22
+ };
23
+
24
+ const PRIORITY_LABELS: Record<Priority, string> = {
25
+ low: "Low",
26
+ medium: "Medium",
27
+ high: "High",
28
+ };
29
+
30
+ const COMPLEXITY_STYLES: Record<Complexity, string> = {
31
+ small: "bg-emerald-100 text-emerald-700",
32
+ medium: "bg-amber-100 text-amber-700",
33
+ large: "bg-orange-100 text-orange-700",
34
+ xlarge: "bg-red-100 text-red-700",
35
+ };
36
+
37
+ const COMPLEXITY_LABELS: Record<Complexity, string> = {
38
+ small: "S",
39
+ medium: "M",
40
+ large: "L",
41
+ xlarge: "XL",
42
+ };
43
+
44
+ function Badge({ className, children }: { className: string; children: React.ReactNode }) {
45
+ return (
46
+ <span
47
+ className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${className}`}
48
+ >
49
+ {children}
50
+ </span>
51
+ );
52
+ }
53
+
54
+ export function StatusBadge({ status }: { status: Status }) {
55
+ return <Badge className={STATUS_STYLES[status]}>{STATUS_LABELS[status]}</Badge>;
56
+ }
57
+
58
+ export function PriorityBadge({ priority }: { priority: Priority }) {
59
+ return <Badge className={PRIORITY_STYLES[priority]}>{PRIORITY_LABELS[priority]}</Badge>;
60
+ }
61
+
62
+ export function ComplexityBadge({ complexity }: { complexity: Complexity }) {
63
+ return <Badge className={COMPLEXITY_STYLES[complexity]}>{COMPLEXITY_LABELS[complexity]}</Badge>;
64
+ }
@@ -0,0 +1,49 @@
1
+ import Link from "next/link";
2
+
3
+ export function Nav() {
4
+ return (
5
+ <nav className="border-b border-gray-200 bg-white">
6
+ <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
7
+ <div className="flex h-14 items-center justify-between">
8
+ <div className="flex items-center gap-8">
9
+ <Link href="/requirements" className="text-lg font-bold text-gray-900">
10
+ Reqord
11
+ </Link>
12
+ <div className="flex gap-4">
13
+ <Link
14
+ href="/dashboard"
15
+ className="text-sm font-medium text-gray-600 hover:text-gray-900"
16
+ >
17
+ Dashboard
18
+ </Link>
19
+ <Link
20
+ href="/requirements"
21
+ className="text-sm font-medium text-gray-600 hover:text-gray-900"
22
+ >
23
+ Requirements
24
+ </Link>
25
+ <Link
26
+ href="/specifications"
27
+ className="text-sm font-medium text-gray-600 hover:text-gray-900"
28
+ >
29
+ Specifications
30
+ </Link>
31
+ <Link
32
+ href="/feedback"
33
+ className="text-sm font-medium text-gray-600 hover:text-gray-900"
34
+ >
35
+ Feedback
36
+ </Link>
37
+ <Link
38
+ href="/graph"
39
+ className="text-sm font-medium text-gray-600 hover:text-gray-900"
40
+ >
41
+ Graph
42
+ </Link>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ </nav>
48
+ );
49
+ }
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ type Tab = { id: string; label: string };
6
+
7
+ type TabsProps = {
8
+ tabs: Tab[];
9
+ activeTab: string;
10
+ onTabChange: (id: string) => void;
11
+ };
12
+
13
+ export function Tabs({ tabs, activeTab, onTabChange }: TabsProps) {
14
+ return (
15
+ <div className="border-b border-gray-200">
16
+ <nav className="-mb-px flex space-x-8" aria-label="Tabs">
17
+ {tabs.map((tab) => {
18
+ const isActive = activeTab === tab.id;
19
+ return (
20
+ <button
21
+ key={tab.id}
22
+ onClick={() => onTabChange(tab.id)}
23
+ className={`
24
+ whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium
25
+ ${
26
+ isActive
27
+ ? "border-blue-500 text-blue-600"
28
+ : "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
29
+ }
30
+ `}
31
+ >
32
+ {tab.label}
33
+ </button>
34
+ );
35
+ })}
36
+ </nav>
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,222 @@
1
+ "use server";
2
+
3
+ import { revalidatePath } from "next/cache";
4
+ import { redirect } from "next/navigation";
5
+ import { RequirementSchema } from "@reqord/shared";
6
+ import { getRepository } from "./get-repository";
7
+
8
+ export type ActionResult = {
9
+ success: boolean;
10
+ error?: string;
11
+ };
12
+
13
+ export async function createRequirement(formData: FormData): Promise<ActionResult> {
14
+ const repo = getRepository();
15
+
16
+ const title = formData.get("title") as string;
17
+ const status = formData.get("status") as string;
18
+ const priority = formData.get("priority") as string;
19
+ const formatType = formData.get("formatType") as string;
20
+ const estimatedComplexity = formData.get("estimatedComplexity") as string | null;
21
+ const estimatedHours = formData.get("estimatedHours") as string | null;
22
+ const description = formData.get("description") as string | null;
23
+ const successCriteriaRaw = formData.get("successCriteria") as string | null;
24
+ const blockedByRaw = formData.get("blockedBy") as string | null;
25
+ const blocksRaw = formData.get("blocks") as string | null;
26
+ const relatedToRaw = formData.get("relatedTo") as string | null;
27
+
28
+ // User story fields
29
+ const userStoryAs = formData.get("userStoryAs") as string | null;
30
+ const userStoryIWant = formData.get("userStoryIWant") as string | null;
31
+ const userStorySoThat = formData.get("userStorySoThat") as string | null;
32
+
33
+ // EARS fields
34
+ const earsType = formData.get("earsType") as string | null;
35
+ const earsTrigger = formData.get("earsTrigger") as string | null;
36
+ const earsCondition = formData.get("earsCondition") as string | null;
37
+ const earsAction = formData.get("earsAction") as string | null;
38
+ const earsResponse = formData.get("earsResponse") as string | null;
39
+
40
+ const id = await repo.generateNextId();
41
+ const now = new Date().toISOString();
42
+
43
+ const successCriteria = successCriteriaRaw
44
+ ? JSON.parse(successCriteriaRaw) as string[]
45
+ : [];
46
+ const blockedBy = blockedByRaw ? JSON.parse(blockedByRaw) as string[] : [];
47
+ const blocks = blocksRaw ? JSON.parse(blocksRaw) as string[] : [];
48
+ const relatedTo = relatedToRaw ? JSON.parse(relatedToRaw) as string[] : [];
49
+
50
+ let format: Record<string, unknown>;
51
+ switch (formatType) {
52
+ case "user-story":
53
+ format = {
54
+ type: "user-story",
55
+ userStory: {
56
+ as: userStoryAs ?? "",
57
+ iWant: userStoryIWant ?? "",
58
+ soThat: userStorySoThat ?? "",
59
+ },
60
+ };
61
+ break;
62
+ case "ears":
63
+ format = {
64
+ type: "ears",
65
+ ears: {
66
+ type: earsType ?? "",
67
+ trigger: earsTrigger || undefined,
68
+ condition: earsCondition || undefined,
69
+ action: earsAction ?? "",
70
+ response: earsResponse || undefined,
71
+ },
72
+ };
73
+ break;
74
+ default:
75
+ format = { type: "free-form" };
76
+ break;
77
+ }
78
+
79
+ const raw = {
80
+ id,
81
+ version: "1.0.0",
82
+ title,
83
+ status,
84
+ priority,
85
+ createdAt: now,
86
+ updatedAt: now,
87
+ versionHistory: [],
88
+ files: { description: `${id}/description.md` },
89
+ successCriteria,
90
+ format,
91
+ dependencies: { blockedBy, blocks, relatedTo },
92
+ estimatedComplexity: estimatedComplexity || undefined,
93
+ estimatedHours: estimatedHours ? Number(estimatedHours) : undefined,
94
+ };
95
+
96
+ const result = RequirementSchema.safeParse(raw);
97
+ if (!result.success) {
98
+ return { success: false, error: result.error.message };
99
+ }
100
+
101
+ await repo.save(result.data);
102
+
103
+ if (description?.trim()) {
104
+ await repo.saveDescription(id, description);
105
+ }
106
+
107
+ revalidatePath("/requirements");
108
+ redirect(`/requirements/${id}`);
109
+ }
110
+
111
+ export async function updateRequirement(formData: FormData): Promise<ActionResult> {
112
+ const repo = getRepository();
113
+
114
+ const id = formData.get("id") as string;
115
+ const existing = await repo.findById(id);
116
+ if (!existing) {
117
+ return { success: false, error: `Requirement ${id} not found` };
118
+ }
119
+
120
+ const title = formData.get("title") as string;
121
+ const status = formData.get("status") as string;
122
+ const priority = formData.get("priority") as string;
123
+ const formatType = formData.get("formatType") as string;
124
+ const estimatedComplexity = formData.get("estimatedComplexity") as string | null;
125
+ const estimatedHours = formData.get("estimatedHours") as string | null;
126
+ const description = formData.get("description") as string | null;
127
+ const successCriteriaRaw = formData.get("successCriteria") as string | null;
128
+ const blockedByRaw = formData.get("blockedBy") as string | null;
129
+ const blocksRaw = formData.get("blocks") as string | null;
130
+ const relatedToRaw = formData.get("relatedTo") as string | null;
131
+
132
+ // User story fields
133
+ const userStoryAs = formData.get("userStoryAs") as string | null;
134
+ const userStoryIWant = formData.get("userStoryIWant") as string | null;
135
+ const userStorySoThat = formData.get("userStorySoThat") as string | null;
136
+
137
+ // EARS fields
138
+ const earsType = formData.get("earsType") as string | null;
139
+ const earsTrigger = formData.get("earsTrigger") as string | null;
140
+ const earsCondition = formData.get("earsCondition") as string | null;
141
+ const earsAction = formData.get("earsAction") as string | null;
142
+ const earsResponse = formData.get("earsResponse") as string | null;
143
+
144
+ const now = new Date().toISOString();
145
+ const successCriteria = successCriteriaRaw
146
+ ? JSON.parse(successCriteriaRaw) as string[]
147
+ : [];
148
+ const blockedBy = blockedByRaw ? JSON.parse(blockedByRaw) as string[] : [];
149
+ const blocks = blocksRaw ? JSON.parse(blocksRaw) as string[] : [];
150
+ const relatedTo = relatedToRaw ? JSON.parse(relatedToRaw) as string[] : [];
151
+
152
+ let format: Record<string, unknown>;
153
+ switch (formatType) {
154
+ case "user-story":
155
+ format = {
156
+ type: "user-story",
157
+ userStory: {
158
+ as: userStoryAs ?? "",
159
+ iWant: userStoryIWant ?? "",
160
+ soThat: userStorySoThat ?? "",
161
+ },
162
+ };
163
+ break;
164
+ case "ears":
165
+ format = {
166
+ type: "ears",
167
+ ears: {
168
+ type: earsType ?? "",
169
+ trigger: earsTrigger || undefined,
170
+ condition: earsCondition || undefined,
171
+ action: earsAction ?? "",
172
+ response: earsResponse || undefined,
173
+ },
174
+ };
175
+ break;
176
+ default:
177
+ format = { type: "free-form" };
178
+ break;
179
+ }
180
+
181
+ const raw = {
182
+ ...existing,
183
+ title,
184
+ status,
185
+ priority,
186
+ updatedAt: now,
187
+ successCriteria,
188
+ format,
189
+ dependencies: { blockedBy, blocks, relatedTo },
190
+ estimatedComplexity: estimatedComplexity || undefined,
191
+ estimatedHours: estimatedHours ? Number(estimatedHours) : undefined,
192
+ };
193
+
194
+ const result = RequirementSchema.safeParse(raw);
195
+ if (!result.success) {
196
+ return { success: false, error: result.error.message };
197
+ }
198
+
199
+ await repo.save(result.data);
200
+
201
+ if (description != null) {
202
+ await repo.saveDescription(id, description);
203
+ }
204
+
205
+ revalidatePath("/requirements");
206
+ revalidatePath(`/requirements/${id}`);
207
+ redirect(`/requirements/${id}`);
208
+ }
209
+
210
+ export async function deleteRequirement(id: string): Promise<ActionResult> {
211
+ const repo = getRepository();
212
+
213
+ const existing = await repo.findById(id);
214
+ if (!existing) {
215
+ return { success: false, error: `Requirement ${id} not found` };
216
+ }
217
+
218
+ await repo.deleteById(id);
219
+
220
+ revalidatePath("/requirements");
221
+ redirect("/requirements");
222
+ }