@pylonsync/create-pylon 0.3.344 → 0.3.346

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 (295) hide show
  1. package/bin/create-pylon.js +35 -0
  2. package/package.json +1 -1
  3. package/templates/crm/AGENTS.md +116 -0
  4. package/templates/crm/CLAUDE.md +7 -0
  5. package/templates/crm/README.md +96 -0
  6. package/templates/crm/app/companies/companies-view.tsx +129 -0
  7. package/templates/crm/app/companies/page.tsx +17 -0
  8. package/templates/crm/app/contacts/contacts-view.tsx +139 -0
  9. package/templates/crm/app/contacts/page.tsx +17 -0
  10. package/templates/crm/app/deals/[id]/deal-view.tsx +189 -0
  11. package/templates/crm/app/deals/[id]/page.tsx +23 -0
  12. package/templates/crm/app/error.tsx +43 -0
  13. package/templates/crm/app/globals.css +201 -0
  14. package/templates/crm/app/layout.tsx +31 -0
  15. package/templates/crm/app/login/auth-form.tsx +122 -0
  16. package/templates/crm/app/login/page.tsx +38 -0
  17. package/templates/crm/app/not-found.tsx +29 -0
  18. package/templates/crm/app/page.tsx +38 -0
  19. package/templates/crm/app/pipeline-view.tsx +99 -0
  20. package/templates/crm/app/robots.ts +12 -0
  21. package/templates/crm/app/sitemap.ts +27 -0
  22. package/templates/crm/app/workspace.tsx +231 -0
  23. package/templates/crm/app.ts +171 -0
  24. package/templates/crm/bunfig +4 -0
  25. package/templates/crm/components/activity-timeline.tsx +133 -0
  26. package/templates/crm/components/avatar.tsx +40 -0
  27. package/templates/crm/components/board-skeleton.tsx +44 -0
  28. package/templates/crm/components/command-palette.tsx +162 -0
  29. package/templates/crm/components/data-table.tsx +90 -0
  30. package/templates/crm/components/deal-card.tsx +91 -0
  31. package/templates/crm/components/deal-dialog.tsx +169 -0
  32. package/templates/crm/components/empty-state.tsx +43 -0
  33. package/templates/crm/components/kbd.tsx +23 -0
  34. package/templates/crm/components/metrics-bar.tsx +46 -0
  35. package/templates/crm/components/page-header.tsx +36 -0
  36. package/templates/crm/components/pipeline-board.tsx +105 -0
  37. package/templates/crm/components/record-dialog.tsx +126 -0
  38. package/templates/crm/components/sidebar.tsx +112 -0
  39. package/templates/crm/components/stage-badge.tsx +33 -0
  40. package/templates/crm/components/ui/badge.tsx +37 -0
  41. package/templates/crm/components/ui/button.tsx +56 -0
  42. package/templates/crm/components/ui/card.tsx +90 -0
  43. package/templates/crm/components/ui/dialog.tsx +110 -0
  44. package/templates/crm/components/ui/input.tsx +30 -0
  45. package/templates/crm/components/ui/label.tsx +21 -0
  46. package/templates/crm/components/ui/select.tsx +39 -0
  47. package/templates/crm/components/ui/table.tsx +124 -0
  48. package/templates/crm/components/ui/textarea.tsx +24 -0
  49. package/templates/crm/components.json +20 -0
  50. package/templates/crm/functions/createDeal.ts +59 -0
  51. package/templates/crm/functions/logActivity.ts +49 -0
  52. package/templates/crm/functions/moveDeal.ts +53 -0
  53. package/templates/crm/functions/seedWorkspace.ts +63 -0
  54. package/templates/crm/gitignore +10 -0
  55. package/templates/crm/lib/pipeline.ts +221 -0
  56. package/templates/crm/lib/search.ts +78 -0
  57. package/templates/crm/lib/seed.ts +130 -0
  58. package/templates/crm/lib/utils.ts +10 -0
  59. package/templates/crm/package.json +39 -0
  60. package/templates/crm/tests/components.test.tsx +245 -0
  61. package/templates/crm/tests/pipeline.test.ts +219 -0
  62. package/templates/crm/tests/search.test.ts +83 -0
  63. package/templates/crm/tests/setup.ts +5 -0
  64. package/templates/crm/tsconfig.json +33 -0
  65. package/templates/helpdesk/AGENTS.md +116 -0
  66. package/templates/helpdesk/CLAUDE.md +7 -0
  67. package/templates/helpdesk/README.md +90 -0
  68. package/templates/helpdesk/app/customers/customers-view.tsx +140 -0
  69. package/templates/helpdesk/app/customers/page.tsx +17 -0
  70. package/templates/helpdesk/app/error.tsx +43 -0
  71. package/templates/helpdesk/app/globals.css +201 -0
  72. package/templates/helpdesk/app/inbox-view.tsx +147 -0
  73. package/templates/helpdesk/app/layout.tsx +31 -0
  74. package/templates/helpdesk/app/login/auth-form.tsx +122 -0
  75. package/templates/helpdesk/app/login/page.tsx +38 -0
  76. package/templates/helpdesk/app/not-found.tsx +29 -0
  77. package/templates/helpdesk/app/page.tsx +38 -0
  78. package/templates/helpdesk/app/robots.ts +12 -0
  79. package/templates/helpdesk/app/sitemap.ts +27 -0
  80. package/templates/helpdesk/app/tickets/[id]/page.tsx +23 -0
  81. package/templates/helpdesk/app/tickets/[id]/ticket-view.tsx +164 -0
  82. package/templates/helpdesk/app/workspace.tsx +210 -0
  83. package/templates/helpdesk/app.ts +142 -0
  84. package/templates/helpdesk/bunfig +4 -0
  85. package/templates/helpdesk/components/avatar.tsx +40 -0
  86. package/templates/helpdesk/components/command-palette.tsx +162 -0
  87. package/templates/helpdesk/components/data-table.tsx +90 -0
  88. package/templates/helpdesk/components/empty-state.tsx +43 -0
  89. package/templates/helpdesk/components/kbd.tsx +23 -0
  90. package/templates/helpdesk/components/new-ticket-dialog.tsx +147 -0
  91. package/templates/helpdesk/components/page-header.tsx +36 -0
  92. package/templates/helpdesk/components/priority-badge.tsx +61 -0
  93. package/templates/helpdesk/components/record-dialog.tsx +126 -0
  94. package/templates/helpdesk/components/sidebar.tsx +111 -0
  95. package/templates/helpdesk/components/sla-indicator.tsx +47 -0
  96. package/templates/helpdesk/components/thread.tsx +143 -0
  97. package/templates/helpdesk/components/ticket-list.tsx +78 -0
  98. package/templates/helpdesk/components/ui/badge.tsx +37 -0
  99. package/templates/helpdesk/components/ui/button.tsx +56 -0
  100. package/templates/helpdesk/components/ui/card.tsx +90 -0
  101. package/templates/helpdesk/components/ui/dialog.tsx +110 -0
  102. package/templates/helpdesk/components/ui/input.tsx +30 -0
  103. package/templates/helpdesk/components/ui/label.tsx +21 -0
  104. package/templates/helpdesk/components/ui/select.tsx +39 -0
  105. package/templates/helpdesk/components/ui/table.tsx +124 -0
  106. package/templates/helpdesk/components/ui/textarea.tsx +24 -0
  107. package/templates/helpdesk/components.json +20 -0
  108. package/templates/helpdesk/functions/createTicket.ts +62 -0
  109. package/templates/helpdesk/functions/replyToTicket.ts +55 -0
  110. package/templates/helpdesk/functions/seedWorkspace.ts +52 -0
  111. package/templates/helpdesk/functions/setTicketState.ts +58 -0
  112. package/templates/helpdesk/gitignore +10 -0
  113. package/templates/helpdesk/lib/format.ts +53 -0
  114. package/templates/helpdesk/lib/search.ts +78 -0
  115. package/templates/helpdesk/lib/seed.ts +185 -0
  116. package/templates/helpdesk/lib/tickets.ts +184 -0
  117. package/templates/helpdesk/lib/utils.ts +10 -0
  118. package/templates/helpdesk/package.json +39 -0
  119. package/templates/helpdesk/tests/components.test.tsx +202 -0
  120. package/templates/helpdesk/tests/search.test.ts +83 -0
  121. package/templates/helpdesk/tests/setup.ts +5 -0
  122. package/templates/helpdesk/tests/tickets.test.ts +215 -0
  123. package/templates/helpdesk/tsconfig.json +33 -0
  124. package/templates/inventory/AGENTS.md +116 -0
  125. package/templates/inventory/CLAUDE.md +7 -0
  126. package/templates/inventory/README.md +103 -0
  127. package/templates/inventory/app/error.tsx +43 -0
  128. package/templates/inventory/app/globals.css +201 -0
  129. package/templates/inventory/app/layout.tsx +31 -0
  130. package/templates/inventory/app/login/auth-form.tsx +122 -0
  131. package/templates/inventory/app/login/page.tsx +38 -0
  132. package/templates/inventory/app/movements/movements-view.tsx +103 -0
  133. package/templates/inventory/app/movements/page.tsx +17 -0
  134. package/templates/inventory/app/not-found.tsx +29 -0
  135. package/templates/inventory/app/page.tsx +34 -0
  136. package/templates/inventory/app/products/[id]/page.tsx +23 -0
  137. package/templates/inventory/app/products/[id]/product-view.tsx +178 -0
  138. package/templates/inventory/app/products-view.tsx +213 -0
  139. package/templates/inventory/app/robots.ts +12 -0
  140. package/templates/inventory/app/sitemap.ts +27 -0
  141. package/templates/inventory/app/workspace.tsx +172 -0
  142. package/templates/inventory/app.ts +154 -0
  143. package/templates/inventory/bunfig +4 -0
  144. package/templates/inventory/components/avatar.tsx +40 -0
  145. package/templates/inventory/components/command-palette.tsx +162 -0
  146. package/templates/inventory/components/data-table.tsx +90 -0
  147. package/templates/inventory/components/empty-state.tsx +43 -0
  148. package/templates/inventory/components/kbd.tsx +23 -0
  149. package/templates/inventory/components/movement-dialog.tsx +182 -0
  150. package/templates/inventory/components/page-header.tsx +36 -0
  151. package/templates/inventory/components/record-dialog.tsx +126 -0
  152. package/templates/inventory/components/sidebar.tsx +111 -0
  153. package/templates/inventory/components/stock-level.tsx +47 -0
  154. package/templates/inventory/components/summary-bar.tsx +56 -0
  155. package/templates/inventory/components/ui/badge.tsx +37 -0
  156. package/templates/inventory/components/ui/button.tsx +56 -0
  157. package/templates/inventory/components/ui/card.tsx +90 -0
  158. package/templates/inventory/components/ui/dialog.tsx +110 -0
  159. package/templates/inventory/components/ui/input.tsx +30 -0
  160. package/templates/inventory/components/ui/label.tsx +21 -0
  161. package/templates/inventory/components/ui/select.tsx +39 -0
  162. package/templates/inventory/components/ui/table.tsx +124 -0
  163. package/templates/inventory/components/ui/textarea.tsx +24 -0
  164. package/templates/inventory/components.json +20 -0
  165. package/templates/inventory/functions/recordMovement.ts +72 -0
  166. package/templates/inventory/functions/seedWorkspace.ts +40 -0
  167. package/templates/inventory/gitignore +10 -0
  168. package/templates/inventory/lib/format.ts +53 -0
  169. package/templates/inventory/lib/search.ts +78 -0
  170. package/templates/inventory/lib/seed.ts +102 -0
  171. package/templates/inventory/lib/stock.ts +207 -0
  172. package/templates/inventory/lib/utils.ts +10 -0
  173. package/templates/inventory/package.json +39 -0
  174. package/templates/inventory/tests/components.test.tsx +155 -0
  175. package/templates/inventory/tests/search.test.ts +83 -0
  176. package/templates/inventory/tests/setup.ts +5 -0
  177. package/templates/inventory/tests/stock.test.ts +255 -0
  178. package/templates/inventory/tsconfig.json +33 -0
  179. package/templates/invoices/AGENTS.md +116 -0
  180. package/templates/invoices/CLAUDE.md +7 -0
  181. package/templates/invoices/README.md +99 -0
  182. package/templates/invoices/app/clients/clients-view.tsx +130 -0
  183. package/templates/invoices/app/clients/page.tsx +17 -0
  184. package/templates/invoices/app/error.tsx +43 -0
  185. package/templates/invoices/app/globals.css +201 -0
  186. package/templates/invoices/app/invoices/[id]/invoice-view.tsx +253 -0
  187. package/templates/invoices/app/invoices/[id]/page.tsx +23 -0
  188. package/templates/invoices/app/invoices-view.tsx +183 -0
  189. package/templates/invoices/app/layout.tsx +31 -0
  190. package/templates/invoices/app/login/auth-form.tsx +122 -0
  191. package/templates/invoices/app/login/page.tsx +38 -0
  192. package/templates/invoices/app/not-found.tsx +29 -0
  193. package/templates/invoices/app/page.tsx +36 -0
  194. package/templates/invoices/app/robots.ts +12 -0
  195. package/templates/invoices/app/sitemap.ts +27 -0
  196. package/templates/invoices/app/workspace.tsx +174 -0
  197. package/templates/invoices/app.ts +169 -0
  198. package/templates/invoices/bunfig +4 -0
  199. package/templates/invoices/components/avatar.tsx +40 -0
  200. package/templates/invoices/components/command-palette.tsx +162 -0
  201. package/templates/invoices/components/data-table.tsx +90 -0
  202. package/templates/invoices/components/empty-state.tsx +43 -0
  203. package/templates/invoices/components/kbd.tsx +23 -0
  204. package/templates/invoices/components/line-items.tsx +172 -0
  205. package/templates/invoices/components/page-header.tsx +36 -0
  206. package/templates/invoices/components/payment-dialog.tsx +132 -0
  207. package/templates/invoices/components/record-dialog.tsx +126 -0
  208. package/templates/invoices/components/sidebar.tsx +111 -0
  209. package/templates/invoices/components/status-badge.tsx +35 -0
  210. package/templates/invoices/components/summary-bar.tsx +65 -0
  211. package/templates/invoices/components/totals-panel.tsx +70 -0
  212. package/templates/invoices/components/ui/badge.tsx +37 -0
  213. package/templates/invoices/components/ui/button.tsx +56 -0
  214. package/templates/invoices/components/ui/card.tsx +90 -0
  215. package/templates/invoices/components/ui/dialog.tsx +110 -0
  216. package/templates/invoices/components/ui/input.tsx +30 -0
  217. package/templates/invoices/components/ui/label.tsx +21 -0
  218. package/templates/invoices/components/ui/select.tsx +39 -0
  219. package/templates/invoices/components/ui/table.tsx +124 -0
  220. package/templates/invoices/components/ui/textarea.tsx +24 -0
  221. package/templates/invoices/components.json +20 -0
  222. package/templates/invoices/functions/createInvoice.ts +49 -0
  223. package/templates/invoices/functions/recordPayment.ts +72 -0
  224. package/templates/invoices/functions/seedWorkspace.ts +57 -0
  225. package/templates/invoices/functions/setInvoiceStatus.ts +35 -0
  226. package/templates/invoices/gitignore +10 -0
  227. package/templates/invoices/lib/billing.ts +259 -0
  228. package/templates/invoices/lib/format.ts +53 -0
  229. package/templates/invoices/lib/search.ts +78 -0
  230. package/templates/invoices/lib/seed.ts +190 -0
  231. package/templates/invoices/lib/utils.ts +10 -0
  232. package/templates/invoices/package.json +39 -0
  233. package/templates/invoices/tests/billing.test.ts +283 -0
  234. package/templates/invoices/tests/components.test.tsx +172 -0
  235. package/templates/invoices/tests/search.test.ts +83 -0
  236. package/templates/invoices/tests/setup.ts +5 -0
  237. package/templates/invoices/tsconfig.json +33 -0
  238. package/templates/projects/AGENTS.md +116 -0
  239. package/templates/projects/CLAUDE.md +7 -0
  240. package/templates/projects/README.md +96 -0
  241. package/templates/projects/app/clients/clients-view.tsx +126 -0
  242. package/templates/projects/app/clients/page.tsx +17 -0
  243. package/templates/projects/app/error.tsx +43 -0
  244. package/templates/projects/app/globals.css +201 -0
  245. package/templates/projects/app/layout.tsx +31 -0
  246. package/templates/projects/app/login/auth-form.tsx +122 -0
  247. package/templates/projects/app/login/page.tsx +38 -0
  248. package/templates/projects/app/not-found.tsx +29 -0
  249. package/templates/projects/app/page.tsx +30 -0
  250. package/templates/projects/app/projects/[id]/page.tsx +23 -0
  251. package/templates/projects/app/projects/[id]/project-view.tsx +179 -0
  252. package/templates/projects/app/projects-view.tsx +181 -0
  253. package/templates/projects/app/robots.ts +12 -0
  254. package/templates/projects/app/sitemap.ts +27 -0
  255. package/templates/projects/app/workspace.tsx +190 -0
  256. package/templates/projects/app.ts +186 -0
  257. package/templates/projects/bunfig +4 -0
  258. package/templates/projects/components/avatar.tsx +40 -0
  259. package/templates/projects/components/board-skeleton.tsx +37 -0
  260. package/templates/projects/components/budget-bar.tsx +61 -0
  261. package/templates/projects/components/command-palette.tsx +162 -0
  262. package/templates/projects/components/data-table.tsx +90 -0
  263. package/templates/projects/components/empty-state.tsx +43 -0
  264. package/templates/projects/components/kbd.tsx +23 -0
  265. package/templates/projects/components/page-header.tsx +36 -0
  266. package/templates/projects/components/record-dialog.tsx +126 -0
  267. package/templates/projects/components/sidebar.tsx +111 -0
  268. package/templates/projects/components/task-board.tsx +109 -0
  269. package/templates/projects/components/task-card.tsx +77 -0
  270. package/templates/projects/components/time-dialog.tsx +114 -0
  271. package/templates/projects/components/ui/badge.tsx +37 -0
  272. package/templates/projects/components/ui/button.tsx +56 -0
  273. package/templates/projects/components/ui/card.tsx +90 -0
  274. package/templates/projects/components/ui/dialog.tsx +110 -0
  275. package/templates/projects/components/ui/input.tsx +30 -0
  276. package/templates/projects/components/ui/label.tsx +21 -0
  277. package/templates/projects/components/ui/select.tsx +39 -0
  278. package/templates/projects/components/ui/table.tsx +124 -0
  279. package/templates/projects/components/ui/textarea.tsx +24 -0
  280. package/templates/projects/components.json +20 -0
  281. package/templates/projects/functions/logTime.ts +49 -0
  282. package/templates/projects/functions/moveTask.ts +40 -0
  283. package/templates/projects/functions/seedWorkspace.ts +59 -0
  284. package/templates/projects/gitignore +10 -0
  285. package/templates/projects/lib/format.ts +53 -0
  286. package/templates/projects/lib/search.ts +78 -0
  287. package/templates/projects/lib/seed.ts +140 -0
  288. package/templates/projects/lib/utils.ts +10 -0
  289. package/templates/projects/lib/work.ts +233 -0
  290. package/templates/projects/package.json +39 -0
  291. package/templates/projects/tests/components.test.tsx +200 -0
  292. package/templates/projects/tests/search.test.ts +83 -0
  293. package/templates/projects/tests/setup.ts +5 -0
  294. package/templates/projects/tests/work.test.ts +238 -0
  295. package/templates/projects/tsconfig.json +33 -0
@@ -0,0 +1,245 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ // This project uses the classic JSX transform, so .tsx tests import React.
3
+ import React from "react";
4
+ import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
5
+ import { PipelineBoard } from "../components/pipeline-board";
6
+ import { DataTable } from "../components/data-table";
7
+ import { CommandPalette } from "../components/command-palette";
8
+ import { EmptyState } from "../components/empty-state";
9
+ import type { Deal } from "../lib/pipeline";
10
+
11
+ afterEach(cleanup);
12
+
13
+ // Tier 2: components. No mocking needed — they take data as props and report
14
+ // changes through callbacks, because the container owns `db`.
15
+
16
+ const deals: Deal[] = [
17
+ { id: "d1", title: "Fleet dispatch rollout", value: 48_000, stage: "proposal" },
18
+ { id: "d2", title: "Class booking system", value: 7_400, stage: "lead" },
19
+ ];
20
+
21
+ const noName = () => null;
22
+
23
+ describe("PipelineBoard", () => {
24
+ test("renders a column per board stage", () => {
25
+ render(
26
+ <PipelineBoard
27
+ deals={deals}
28
+ companyName={noName}
29
+ ownerName={noName}
30
+ onMove={() => {}}
31
+ onOpen={() => {}}
32
+ />,
33
+ );
34
+ for (const label of ["Lead", "Qualified", "Proposal", "Won"]) {
35
+ expect(screen.getByLabelText(label)).toBeDefined();
36
+ }
37
+ // Lost is real in the model but deliberately not a column.
38
+ expect(screen.queryByLabelText("Lost")).toBeNull();
39
+ });
40
+
41
+ test("shows each column's total in its header", () => {
42
+ render(
43
+ <PipelineBoard
44
+ deals={deals}
45
+ companyName={noName}
46
+ ownerName={noName}
47
+ onMove={() => {}}
48
+ onOpen={() => {}}
49
+ />,
50
+ );
51
+ // Scoped to the column, because the same figure also appears on the card —
52
+ // a bare getByText would match twice and throw.
53
+ const proposal = screen.getByLabelText("Proposal");
54
+ expect(within(proposal).getAllByText("$48K").length).toBeGreaterThan(0);
55
+
56
+ const lead = screen.getByLabelText("Lead");
57
+ expect(within(lead).getAllByText("$7,400").length).toBeGreaterThan(0);
58
+
59
+ // An empty column still totals, rather than showing nothing.
60
+ const won = screen.getByLabelText("Won");
61
+ expect(within(won).getByText("$0")).toBeDefined();
62
+ });
63
+
64
+ test("opening a card reports the deal id", () => {
65
+ const opened: string[] = [];
66
+ render(
67
+ <PipelineBoard
68
+ deals={deals}
69
+ companyName={noName}
70
+ ownerName={noName}
71
+ onMove={() => {}}
72
+ onOpen={(id) => opened.push(id)}
73
+ />,
74
+ );
75
+ fireEvent.click(screen.getByText("Fleet dispatch rollout"));
76
+ expect(opened).toEqual(["d1"]);
77
+ });
78
+
79
+ test("dropping on another column reports the move", () => {
80
+ const moves: Array<[string, string]> = [];
81
+ render(
82
+ <PipelineBoard
83
+ deals={deals}
84
+ companyName={noName}
85
+ ownerName={noName}
86
+ onMove={(id, stage) => moves.push([id, stage])}
87
+ onOpen={() => {}}
88
+ />,
89
+ );
90
+ const data = new Map<string, string>();
91
+ const dataTransfer = {
92
+ setData: (k: string, v: string) => data.set(k, v),
93
+ getData: (k: string) => data.get(k) ?? "",
94
+ effectAllowed: "",
95
+ dropEffect: "",
96
+ };
97
+ fireEvent.dragStart(screen.getByText("Class booking system"), { dataTransfer });
98
+ fireEvent.drop(screen.getByLabelText("Won"), { dataTransfer });
99
+ expect(moves).toEqual([["d2", "won"]]);
100
+ });
101
+
102
+ test("dropping a card back on its own column writes nothing", () => {
103
+ // A jittery drag shouldn't produce a history entry saying it moved.
104
+ const moves: Array<[string, string]> = [];
105
+ render(
106
+ <PipelineBoard
107
+ deals={deals}
108
+ companyName={noName}
109
+ ownerName={noName}
110
+ onMove={(id, stage) => moves.push([id, stage])}
111
+ onOpen={() => {}}
112
+ />,
113
+ );
114
+ const data = new Map<string, string>();
115
+ const dataTransfer = {
116
+ setData: (k: string, v: string) => data.set(k, v),
117
+ getData: (k: string) => data.get(k) ?? "",
118
+ effectAllowed: "",
119
+ dropEffect: "",
120
+ };
121
+ fireEvent.dragStart(screen.getByText("Class booking system"), { dataTransfer });
122
+ fireEvent.drop(screen.getByLabelText("Lead"), { dataTransfer });
123
+ expect(moves).toEqual([]);
124
+ });
125
+ });
126
+
127
+ describe("DataTable", () => {
128
+ const rows = [{ id: "1", name: "Acme" }];
129
+
130
+ test("renders a cell per column", () => {
131
+ render(
132
+ <DataTable
133
+ rows={rows}
134
+ columns={[{ key: "name", header: "Company", cell: (r) => r.name }]}
135
+ />,
136
+ );
137
+ expect(screen.getByRole("table")).toBeDefined();
138
+ expect(screen.getByText("Acme")).toBeDefined();
139
+ });
140
+
141
+ test("shows the empty state instead of a bare header", () => {
142
+ render(
143
+ <DataTable
144
+ rows={[]}
145
+ columns={[{ key: "name", header: "Company", cell: () => null }]}
146
+ empty={<EmptyState title="No companies yet" />}
147
+ />,
148
+ );
149
+ expect(screen.queryByRole("table")).toBeNull();
150
+ expect(screen.getByText("No companies yet")).toBeDefined();
151
+ });
152
+
153
+ test("clicking a row reports it", () => {
154
+ const clicked: string[] = [];
155
+ render(
156
+ <DataTable
157
+ rows={rows}
158
+ columns={[{ key: "name", header: "Company", cell: (r) => r.name }]}
159
+ onRowClick={(row) => clicked.push(row.id)}
160
+ />,
161
+ );
162
+ fireEvent.click(screen.getByText("Acme"));
163
+ expect(clicked).toEqual(["1"]);
164
+ });
165
+ });
166
+
167
+ describe("CommandPalette", () => {
168
+ const items = [
169
+ { id: "1", type: "deal" as const, title: "Fleet dispatch", href: "/d/1" },
170
+ { id: "2", type: "company" as const, title: "Acme Inc", href: "/c/2" },
171
+ ];
172
+
173
+ test("renders nothing when closed", () => {
174
+ const { container } = render(
175
+ <CommandPalette open={false} items={items} onClose={() => {}} onSelect={() => {}} />,
176
+ );
177
+ expect(container.firstChild).toBeNull();
178
+ });
179
+
180
+ test("filters as you type", () => {
181
+ render(
182
+ <CommandPalette open items={items} onClose={() => {}} onSelect={() => {}} />,
183
+ );
184
+ fireEvent.change(screen.getByLabelText(/search deals/i), {
185
+ target: { value: "acme" },
186
+ });
187
+ expect(screen.getByText("Acme Inc")).toBeDefined();
188
+ expect(screen.queryByText("Fleet dispatch")).toBeNull();
189
+ });
190
+
191
+ test("Enter selects the highlighted row and closes", () => {
192
+ const selected: string[] = [];
193
+ let closed = false;
194
+ render(
195
+ <CommandPalette
196
+ open
197
+ items={items}
198
+ onClose={() => {
199
+ closed = true;
200
+ }}
201
+ onSelect={(item) => selected.push(item.id)}
202
+ />,
203
+ );
204
+ const input = screen.getByLabelText(/search deals/i);
205
+ fireEvent.change(input, { target: { value: "fleet" } });
206
+ fireEvent.keyDown(input, { key: "Enter" });
207
+ expect(selected).toEqual(["1"]);
208
+ expect(closed).toBe(true);
209
+ });
210
+
211
+ test("Escape closes without selecting", () => {
212
+ const selected: string[] = [];
213
+ let closed = false;
214
+ render(
215
+ <CommandPalette
216
+ open
217
+ items={items}
218
+ onClose={() => {
219
+ closed = true;
220
+ }}
221
+ onSelect={(item) => selected.push(item.id)}
222
+ />,
223
+ );
224
+ fireEvent.keyDown(screen.getByLabelText(/search deals/i), { key: "Escape" });
225
+ expect(closed).toBe(true);
226
+ expect(selected).toEqual([]);
227
+ });
228
+
229
+ test("actions show only with an empty query", () => {
230
+ render(
231
+ <CommandPalette
232
+ open
233
+ items={items}
234
+ actions={[{ id: "new", label: "New deal", run: () => {} }]}
235
+ onClose={() => {}}
236
+ onSelect={() => {}}
237
+ />,
238
+ );
239
+ expect(screen.getByText("New deal")).toBeDefined();
240
+ fireEvent.change(screen.getByLabelText(/search deals/i), {
241
+ target: { value: "acme" },
242
+ });
243
+ expect(screen.queryByText("New deal")).toBeNull();
244
+ });
245
+ });
@@ -0,0 +1,219 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ BOARD_STAGES,
4
+ PIPELINE,
5
+ accentIndex,
6
+ daysUntil,
7
+ groupByStage,
8
+ initials,
9
+ isOpen,
10
+ isValidStage,
11
+ metrics,
12
+ money,
13
+ nextStage,
14
+ percent,
15
+ previousStage,
16
+ relativeTime,
17
+ sumValue,
18
+ type Deal,
19
+ } from "../lib/pipeline";
20
+
21
+ const deal = (over: Partial<Deal> = {}): Deal => ({
22
+ id: "d1",
23
+ title: "Deal",
24
+ value: 1000,
25
+ stage: "lead",
26
+ ...over,
27
+ });
28
+
29
+ describe("stages", () => {
30
+ test("the board hides Lost but the model keeps it", () => {
31
+ // Lost deals still count against the win rate; they just don't deserve a
32
+ // column competing for attention with live work.
33
+ expect(BOARD_STAGES.map((s) => s.id)).toEqual([
34
+ "lead",
35
+ "qualified",
36
+ "proposal",
37
+ "won",
38
+ ]);
39
+ expect(PIPELINE.some((s) => s.id === "lost")).toBe(true);
40
+ });
41
+
42
+ test("open means not closed", () => {
43
+ expect(isOpen({ stage: "proposal" })).toBe(true);
44
+ expect(isOpen({ stage: "won" })).toBe(false);
45
+ expect(isOpen({ stage: "lost" })).toBe(false);
46
+ });
47
+
48
+ test("advance and retreat stop at the ends", () => {
49
+ expect(nextStage("lead")).toBe("qualified");
50
+ expect(nextStage("proposal")).toBe("won");
51
+ expect(nextStage("won")).toBeNull();
52
+ expect(previousStage("qualified")).toBe("lead");
53
+ expect(previousStage("lead")).toBeNull();
54
+ });
55
+
56
+ test("an unknown stage is rejected, not coerced", () => {
57
+ expect(isValidStage("proposal")).toBe(true);
58
+ expect(isValidStage("Proposal")).toBe(false);
59
+ expect(isValidStage("negotiation")).toBe(false);
60
+ expect(nextStage("nonsense")).toBeNull();
61
+ });
62
+ });
63
+
64
+ describe("groupByStage", () => {
65
+ test("puts each deal in its column, newest first", () => {
66
+ const columns = groupByStage([
67
+ deal({ id: "a", stage: "lead", createdAt: "2026-01-01" }),
68
+ deal({ id: "b", stage: "lead", createdAt: "2026-03-01" }),
69
+ deal({ id: "c", stage: "won" }),
70
+ ]);
71
+ const lead = columns.find((c) => c.stage.id === "lead");
72
+ expect(lead?.deals.map((d) => d.id)).toEqual(["b", "a"]);
73
+ expect(columns.find((c) => c.stage.id === "won")?.deals).toHaveLength(1);
74
+ });
75
+
76
+ test("totals each column", () => {
77
+ const columns = groupByStage([
78
+ deal({ id: "a", stage: "lead", value: 1000 }),
79
+ deal({ id: "b", stage: "lead", value: 250 }),
80
+ ]);
81
+ expect(columns.find((c) => c.stage.id === "lead")?.total).toBe(1250);
82
+ });
83
+
84
+ test("a deal with an unknown stage is dropped, not filed under Lead", () => {
85
+ // Silently reclassifying it would hide a typo behind a plausible board.
86
+ const columns = groupByStage([deal({ stage: "negotiation" })]);
87
+ expect(columns.every((c) => c.deals.length === 0)).toBe(true);
88
+ });
89
+
90
+ test("missing or non-numeric values count as zero", () => {
91
+ expect(sumValue([deal({ value: null }), deal({ value: undefined })])).toBe(0);
92
+ });
93
+ });
94
+
95
+ describe("metrics", () => {
96
+ const book = [
97
+ deal({ id: "1", stage: "lead", value: 10_000 }),
98
+ deal({ id: "2", stage: "proposal", value: 20_000 }),
99
+ deal({ id: "3", stage: "won", value: 30_000 }),
100
+ deal({ id: "4", stage: "lost", value: 40_000 }),
101
+ ];
102
+
103
+ test("open excludes closed deals", () => {
104
+ expect(metrics(book).open).toBe(30_000);
105
+ expect(metrics(book).openCount).toBe(2);
106
+ });
107
+
108
+ test("weighted applies each stage's probability", () => {
109
+ // 10k * 0.1 + 20k * 0.6
110
+ expect(metrics(book).weighted).toBe(13_000);
111
+ });
112
+
113
+ test("won and lost are tracked separately", () => {
114
+ expect(metrics(book).won).toBe(30_000);
115
+ expect(metrics(book).lost).toBe(40_000);
116
+ });
117
+
118
+ test("win rate is over CLOSED deals only", () => {
119
+ expect(metrics(book).winRate).toBe(0.5);
120
+ });
121
+
122
+ test("win rate is null before anything closes, not 0%", () => {
123
+ // 0% and "nothing has closed yet" mean very different things to a sales lead.
124
+ expect(metrics([deal({ stage: "lead" })]).winRate).toBeNull();
125
+ expect(percent(null)).toBe("—");
126
+ });
127
+
128
+ test("an empty book is all zeroes, not NaN", () => {
129
+ expect(metrics([])).toMatchObject({ open: 0, weighted: 0, won: 0, winRate: null });
130
+ });
131
+ });
132
+
133
+ describe("money", () => {
134
+ test("compacts so columns line up in a narrow card", () => {
135
+ expect(money(1_240_000)).toBe("$1.2M");
136
+ expect(money(2_000_000)).toBe("$2M");
137
+ expect(money(48_000)).toBe("$48K");
138
+ expect(money(9_500)).toBe("$9,500");
139
+ expect(money(0)).toBe("$0");
140
+ });
141
+
142
+ test("handles nothing and negatives", () => {
143
+ expect(money(null)).toBe("$0");
144
+ expect(money(undefined)).toBe("$0");
145
+ expect(money(-1500)).toBe("-$1,500");
146
+ });
147
+ });
148
+
149
+ describe("initials", () => {
150
+ test("takes first and last for a name", () => {
151
+ expect(initials("Dana Whitfield")).toBe("DW");
152
+ expect(initials("Acme Corporation Holdings")).toBe("AH");
153
+ });
154
+
155
+ test("uses the local part of an email", () => {
156
+ expect(initials("jordan@example.com")).toBe("JO");
157
+ expect(initials("dana.whitfield@example.com")).toBe("DW");
158
+ });
159
+
160
+ test("degrades rather than throwing", () => {
161
+ expect(initials("")).toBe("?");
162
+ expect(initials(null)).toBe("?");
163
+ expect(initials(" ")).toBe("?");
164
+ });
165
+ });
166
+
167
+ describe("accentIndex", () => {
168
+ test("is stable for the same seed", () => {
169
+ expect(accentIndex("Acme")).toBe(accentIndex("Acme"));
170
+ });
171
+
172
+ test("stays in range", () => {
173
+ for (const seed of ["", "a", "Northwind Logistics", "🙂"]) {
174
+ const index = accentIndex(seed, 6);
175
+ expect(index).toBeGreaterThanOrEqual(0);
176
+ expect(index).toBeLessThan(6);
177
+ }
178
+ });
179
+ });
180
+
181
+ describe("relativeTime", () => {
182
+ const now = Date.parse("2026-07-27T12:00:00Z");
183
+ const ago = (ms: number) => new Date(now - ms).toISOString();
184
+
185
+ test("reads naturally across the ranges", () => {
186
+ expect(relativeTime(ago(10_000), now)).toBe("just now");
187
+ expect(relativeTime(ago(5 * 60_000), now)).toBe("5m ago");
188
+ expect(relativeTime(ago(4 * 3_600_000), now)).toBe("4h ago");
189
+ expect(relativeTime(ago(3 * 86_400_000), now)).toBe("3d ago");
190
+ });
191
+
192
+ test("falls back to a date beyond a week", () => {
193
+ expect(relativeTime(ago(30 * 86_400_000), now)).toMatch(/^[A-Z][a-z]{2} \d+$/);
194
+ });
195
+
196
+ test("empty for missing or unparseable input", () => {
197
+ expect(relativeTime(null, now)).toBe("");
198
+ expect(relativeTime("not-a-date", now)).toBe("");
199
+ });
200
+ });
201
+
202
+ describe("daysUntil", () => {
203
+ const now = Date.parse("2026-07-27T18:00:00Z");
204
+
205
+ test("counts whole days, ignoring the time of day", () => {
206
+ // A deal closing "tomorrow" shouldn't read as 0 just because it's evening.
207
+ expect(daysUntil("2026-07-28T02:00:00Z", now)).toBe(1);
208
+ expect(daysUntil("2026-07-27T23:00:00Z", now)).toBe(0);
209
+ });
210
+
211
+ test("negative when overdue", () => {
212
+ expect(daysUntil("2026-07-20T12:00:00Z", now)).toBe(-7);
213
+ });
214
+
215
+ test("null when there's no date", () => {
216
+ expect(daysUntil(null, now)).toBeNull();
217
+ expect(daysUntil("nope", now)).toBeNull();
218
+ });
219
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { moveSelection, searchItems, type SearchItem } from "../lib/search";
3
+
4
+ const items: SearchItem[] = [
5
+ { id: "1", type: "company", title: "Acme Inc", subtitle: "acme.com", href: "/c" },
6
+ {
7
+ id: "2",
8
+ type: "company",
9
+ title: "Acme Corporation Holdings",
10
+ subtitle: "acmeholdings.com",
11
+ href: "/c",
12
+ },
13
+ {
14
+ id: "3",
15
+ type: "contact",
16
+ title: "Dana Whitfield",
17
+ subtitle: "Northwind Logistics",
18
+ href: "/p",
19
+ keywords: "dana@northwind.co VP Operations",
20
+ },
21
+ { id: "4", type: "deal", title: "Fleet dispatch rollout", href: "/d" },
22
+ ];
23
+
24
+ const titles = (q: string) => searchItems(items, q).map((i) => i.title);
25
+
26
+ describe("searchItems", () => {
27
+ test("an empty query lists everything", () => {
28
+ expect(searchItems(items, "").length).toBe(items.length);
29
+ expect(searchItems(items, " ").length).toBe(items.length);
30
+ });
31
+
32
+ test("prefix beats substring", () => {
33
+ expect(titles("acme")[0]).toBe("Acme Inc");
34
+ });
35
+
36
+ test("the shorter title wins a tie", () => {
37
+ // Both start with "Acme"; the more specific record should come first.
38
+ expect(titles("acme")).toEqual(["Acme Inc", "Acme Corporation Holdings"]);
39
+ });
40
+
41
+ test("matches a word inside the title", () => {
42
+ expect(titles("dispatch")).toContain("Fleet dispatch rollout");
43
+ });
44
+
45
+ test("matches the subtitle and hidden keywords", () => {
46
+ expect(titles("northwind")).toContain("Dana Whitfield");
47
+ expect(titles("dana@northwind.co")).toContain("Dana Whitfield");
48
+ expect(titles("VP")).toContain("Dana Whitfield");
49
+ });
50
+
51
+ test("is case-insensitive", () => {
52
+ expect(titles("ACME").length).toBe(2);
53
+ });
54
+
55
+ test("no match is empty, not everything", () => {
56
+ expect(titles("zzzz")).toEqual([]);
57
+ });
58
+
59
+ test("respects the limit", () => {
60
+ expect(searchItems(items, "", 2)).toHaveLength(2);
61
+ });
62
+
63
+ test("a word-boundary hit beats a mid-word one", () => {
64
+ const rows: SearchItem[] = [
65
+ { id: "a", type: "deal", title: "Broadcasting", href: "/" },
66
+ { id: "b", type: "deal", title: "New cast list", href: "/" },
67
+ ];
68
+ expect(searchItems(rows, "cast").map((r) => r.title)[0]).toBe("New cast list");
69
+ });
70
+ });
71
+
72
+ describe("moveSelection", () => {
73
+ test("wraps at both ends", () => {
74
+ expect(moveSelection(0, -1, 3)).toBe(2);
75
+ expect(moveSelection(2, 1, 3)).toBe(0);
76
+ expect(moveSelection(0, 1, 3)).toBe(1);
77
+ });
78
+
79
+ test("an empty list stays at zero rather than going negative", () => {
80
+ expect(moveSelection(0, 1, 0)).toBe(0);
81
+ expect(moveSelection(0, -1, 0)).toBe(0);
82
+ });
83
+ });
@@ -0,0 +1,5 @@
1
+ import { GlobalRegistrator } from "@happy-dom/global-registrator";
2
+
3
+ // Make document/window available so @testing-library/react can render. Loaded
4
+ // via bunfig.toml's [test] preload, before any test file runs.
5
+ GlobalRegistrator.register();
@@ -0,0 +1,33 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react",
7
+ "esModuleInterop": true,
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "lib": [
11
+ "ES2022",
12
+ "DOM"
13
+ ],
14
+ "types": [
15
+ "react",
16
+ "react-dom",
17
+ "node"
18
+ ],
19
+ "baseUrl": ".",
20
+ "paths": {
21
+ "@/*": [
22
+ "./*"
23
+ ]
24
+ }
25
+ },
26
+ "include": [
27
+ "app.ts",
28
+ "app/**/*",
29
+ "components/**/*",
30
+ "lib/**/*",
31
+ "functions/**/*"
32
+ ]
33
+ }