@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,233 @@
1
+ // Projects, tasks, and time.
2
+ //
3
+ // TIME IS A LEDGER, like stock in the inventory template: a task's logged hours
4
+ // are the sum of its time entries, never a running total on the task. Two people
5
+ // logging against the same task at once is normal, and a mutable total loses one
6
+ // of them. It also means every hour on an invoice traces to who logged it and
7
+ // when — which is the question a client asks.
8
+ //
9
+ // Minutes are integers. Hours as floats produce 7.999999 on a timesheet, and
10
+ // nobody wants to explain that.
11
+ //
12
+ // Pure: no React, no `db`.
13
+
14
+ export interface Status {
15
+ id: string;
16
+ label: string;
17
+ /** Done and cancelled leave the active board. */
18
+ terminal?: boolean;
19
+ }
20
+
21
+ export const TASK_STATUSES: Status[] = [
22
+ { id: "todo", label: "To do" },
23
+ { id: "doing", label: "In progress" },
24
+ { id: "review", label: "Review" },
25
+ { id: "done", label: "Done", terminal: true },
26
+ ];
27
+
28
+ export const PROJECT_STATUSES: Status[] = [
29
+ { id: "active", label: "Active" },
30
+ { id: "paused", label: "Paused" },
31
+ { id: "complete", label: "Complete", terminal: true },
32
+ ];
33
+
34
+ export interface Project {
35
+ id: string;
36
+ name: string;
37
+ clientId?: string | null;
38
+ status: string;
39
+ /** Agreed budget in whole minutes, 0 for none. */
40
+ budgetMinutes?: number | null;
41
+ hourlyRateCents?: number | null;
42
+ dueDate?: string | null;
43
+ createdAt?: string | null;
44
+ }
45
+
46
+ export interface Task {
47
+ id: string;
48
+ projectId: string;
49
+ title: string;
50
+ status: string;
51
+ assigneeId?: string | null;
52
+ /** What we thought it would take, in minutes. */
53
+ estimateMinutes?: number | null;
54
+ position?: number | null;
55
+ createdAt?: string | null;
56
+ }
57
+
58
+ export interface TimeEntry {
59
+ id: string;
60
+ taskId: string;
61
+ projectId: string;
62
+ minutes: number;
63
+ note?: string | null;
64
+ userId?: string | null;
65
+ spentOn?: string | null;
66
+ }
67
+
68
+ export function taskStatusById(id: string): Status | undefined {
69
+ return TASK_STATUSES.find((s) => s.id === id);
70
+ }
71
+
72
+ export function isValidTaskStatus(id: string): boolean {
73
+ return TASK_STATUSES.some((s) => s.id === id);
74
+ }
75
+
76
+ export function isValidProjectStatus(id: string): boolean {
77
+ return PROJECT_STATUSES.some((s) => s.id === id);
78
+ }
79
+
80
+ export function isOpen(task: { status: string }): boolean {
81
+ return !taskStatusById(task.status)?.terminal;
82
+ }
83
+
84
+ export interface Column {
85
+ status: Status;
86
+ tasks: Task[];
87
+ }
88
+
89
+ /** Group a project's tasks into board columns, in their manual order. */
90
+ export function groupByStatus(tasks: Task[]): Column[] {
91
+ return TASK_STATUSES.map((status) => ({
92
+ status,
93
+ tasks: tasks
94
+ .filter((task) => task.status === status.id)
95
+ .sort(
96
+ (a, b) =>
97
+ (Number(a.position) || 0) - (Number(b.position) || 0) ||
98
+ (a.createdAt ?? "").localeCompare(b.createdAt ?? ""),
99
+ ),
100
+ }));
101
+ }
102
+
103
+ /** Minutes logged against one task. */
104
+ export function minutesForTask(taskId: string, entries: TimeEntry[]): number {
105
+ let total = 0;
106
+ for (const entry of entries) {
107
+ if (entry.taskId !== taskId) continue;
108
+ const minutes = Number(entry.minutes);
109
+ if (Number.isFinite(minutes)) total += Math.trunc(minutes);
110
+ }
111
+ return total;
112
+ }
113
+
114
+ /**
115
+ * Minutes per task in one pass — a board calling minutesForTask per card would
116
+ * be O(tasks × entries).
117
+ */
118
+ export function minutesByTask(entries: TimeEntry[]): Map<string, number> {
119
+ const totals = new Map<string, number>();
120
+ for (const entry of entries) {
121
+ const minutes = Number(entry.minutes);
122
+ if (!Number.isFinite(minutes)) continue;
123
+ totals.set(entry.taskId, (totals.get(entry.taskId) ?? 0) + Math.trunc(minutes));
124
+ }
125
+ return totals;
126
+ }
127
+
128
+ export function minutesForProject(projectId: string, entries: TimeEntry[]): number {
129
+ let total = 0;
130
+ for (const entry of entries) {
131
+ if (entry.projectId !== projectId) continue;
132
+ const minutes = Number(entry.minutes);
133
+ if (Number.isFinite(minutes)) total += Math.trunc(minutes);
134
+ }
135
+ return total;
136
+ }
137
+
138
+ export interface Progress {
139
+ total: number;
140
+ done: number;
141
+ /** 0–1, or null when there's nothing to measure. */
142
+ ratio: number | null;
143
+ }
144
+
145
+ /**
146
+ * Progress by task COUNT, not by logged time.
147
+ *
148
+ * Time spent measures effort, not completion — a project can be 90% through its
149
+ * budget and 20% done, and conflating them is how a status report lies.
150
+ * `budgetState` reports the other half separately.
151
+ */
152
+ export function progress(tasks: Task[]): Progress {
153
+ const total = tasks.length;
154
+ const done = tasks.filter((task) => !isOpen(task)).length;
155
+ return { total, done, ratio: total === 0 ? null : done / total };
156
+ }
157
+
158
+ export type BudgetState = "none" | "ok" | "near" | "over";
159
+
160
+ /** Where a project sits against its agreed budget. */
161
+ export function budgetState(
162
+ budgetMinutes: number | null | undefined,
163
+ loggedMinutes: number,
164
+ ): BudgetState {
165
+ const budget = Number(budgetMinutes);
166
+ if (!Number.isFinite(budget) || budget <= 0) return "none";
167
+ if (loggedMinutes > budget) return "over";
168
+ // 85% is where you can still do something about it.
169
+ if (loggedMinutes >= budget * 0.85) return "near";
170
+ return "ok";
171
+ }
172
+
173
+ /** What the logged time is worth at the project's rate. */
174
+ export function billableCents(
175
+ minutes: number,
176
+ hourlyRateCents: number | null | undefined,
177
+ ): number {
178
+ const rate = Number(hourlyRateCents) || 0;
179
+ return Math.round((minutes * rate) / 60);
180
+ }
181
+
182
+ /** "7h 30m" / "45m" / "0m" — the format a timesheet is read in. */
183
+ export function duration(minutes: number | null | undefined): string {
184
+ const value = Math.trunc(Number(minutes) || 0);
185
+ const sign = value < 0 ? "-" : "";
186
+ const abs = Math.abs(value);
187
+ const hours = Math.floor(abs / 60);
188
+ const rest = abs % 60;
189
+ if (hours === 0) return `${sign}${rest}m`;
190
+ if (rest === 0) return `${sign}${hours}h`;
191
+ return `${sign}${hours}h ${rest}m`;
192
+ }
193
+
194
+ export function money(cents: number | null | undefined): string {
195
+ const value = Number(cents) || 0;
196
+ const sign = value < 0 ? "-" : "";
197
+ const abs = Math.abs(value);
198
+ const dollars = Math.floor(abs / 100).toLocaleString("en-US");
199
+ const rest = String(abs % 100).padStart(2, "0");
200
+ return `${sign}$${dollars}.${rest}`;
201
+ }
202
+
203
+ /**
204
+ * Parse typed time into minutes. Accepts "90", "1.5h", "1h30", "1h 30m", "45m".
205
+ *
206
+ * People write time however they think of it, and rejecting "1h30" because it
207
+ * isn't "90" is the kind of friction that stops time being logged at all —
208
+ * which costs far more than a lenient parser.
209
+ */
210
+ export function parseDuration(input: string): number | null {
211
+ const text = input.trim().toLowerCase().replace(/\s+/g, "");
212
+ if (!text) return null;
213
+
214
+ // "1h30" / "1h30m" / "2h" / "45m"
215
+ const hm = text.match(/^(\d+(?:\.\d+)?)h(?:(\d+)m?)?$/);
216
+ if (hm) {
217
+ const hours = Number(hm[1]);
218
+ const mins = hm[2] ? Number(hm[2]) : 0;
219
+ if (!Number.isFinite(hours) || !Number.isFinite(mins) || mins >= 60) return null;
220
+ return Math.round(hours * 60 + mins);
221
+ }
222
+
223
+ const m = text.match(/^(\d+)m$/);
224
+ if (m) return Number(m[1]);
225
+
226
+ // A bare number is minutes — the unit a timesheet field is usually in.
227
+ if (/^\d+(\.\d+)?$/.test(text)) {
228
+ const value = Number(text);
229
+ return Number.isFinite(value) ? Math.round(value) : null;
230
+ }
231
+
232
+ return null;
233
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "__APP_NAME_KEBAB__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "pylon dev",
8
+ "deploy": "pylon deploy",
9
+ "check": "tsc --noEmit",
10
+ "test": "pylon test"
11
+ },
12
+ "dependencies": {
13
+ "@pylonsync/react": "^__PYLON_VERSION__",
14
+ "@pylonsync/sdk": "^__PYLON_VERSION__",
15
+ "@pylonsync/functions": "^__PYLON_VERSION__",
16
+ "@pylonsync/client": "^__PYLON_VERSION__",
17
+ "react": "^19.0.0",
18
+ "react-dom": "^19.0.0",
19
+ "tailwindcss": "^4.3.0",
20
+ "@tailwindcss/cli": "^4.3.0",
21
+ "tw-animate-css": "^1.2.0",
22
+ "class-variance-authority": "^0.7.1",
23
+ "clsx": "^2.1.1",
24
+ "tailwind-merge": "^2.5.0",
25
+ "lucide-react": "^0.460.0",
26
+ "@radix-ui/react-dialog": "^1.1.4",
27
+ "@radix-ui/react-slot": "^1.1.0"
28
+ },
29
+ "devDependencies": {
30
+ "@happy-dom/global-registrator": "^20.10.0",
31
+ "@pylonsync/cli": "^__PYLON_VERSION__",
32
+ "@testing-library/dom": "^10.4.0",
33
+ "@testing-library/react": "^16.3.0",
34
+ "@types/node": "^22.0.0",
35
+ "@types/react": "^19.0.0",
36
+ "@types/react-dom": "^19.0.0",
37
+ "typescript": "^5.6.0"
38
+ }
39
+ }
@@ -0,0 +1,200 @@
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 { TaskBoard } from "../components/task-board";
6
+ import { TimeDialog } from "../components/time-dialog";
7
+ import { BudgetBar } from "../components/budget-bar";
8
+ import type { Task } from "../lib/work";
9
+
10
+ afterEach(cleanup);
11
+
12
+ // Tier 2: components. No mocking needed — they take data as props and report
13
+ // changes through callbacks, because the container owns `db`.
14
+
15
+ const tasks: Task[] = [
16
+ { id: "t1", projectId: "p1", title: "Dispatcher board UI", status: "doing" },
17
+ { id: "t2", projectId: "p1", title: "Weekly report", status: "todo" },
18
+ ];
19
+
20
+ const noName = () => null;
21
+ const noMinutes = () => 0;
22
+
23
+ describe("TaskBoard", () => {
24
+ test("renders every column", () => {
25
+ render(
26
+ <TaskBoard
27
+ tasks={tasks}
28
+ assigneeName={noName}
29
+ minutesFor={noMinutes}
30
+ onMove={() => {}}
31
+ onOpen={() => {}}
32
+ />,
33
+ );
34
+ for (const label of ["To do", "In progress", "Review", "Done"]) {
35
+ expect(screen.getByLabelText(label)).toBeDefined();
36
+ }
37
+ });
38
+
39
+ test("dropping on another column reports the move", () => {
40
+ const moves: Array<[string, string]> = [];
41
+ render(
42
+ <TaskBoard
43
+ tasks={tasks}
44
+ assigneeName={noName}
45
+ minutesFor={noMinutes}
46
+ onMove={(id, status) => moves.push([id, status])}
47
+ onOpen={() => {}}
48
+ />,
49
+ );
50
+ const data = new Map<string, string>();
51
+ const dataTransfer = {
52
+ setData: (k: string, v: string) => data.set(k, v),
53
+ getData: (k: string) => data.get(k) ?? "",
54
+ effectAllowed: "",
55
+ dropEffect: "",
56
+ };
57
+ fireEvent.dragStart(screen.getByText("Weekly report"), { dataTransfer });
58
+ fireEvent.drop(screen.getByLabelText("Done"), { dataTransfer });
59
+ expect(moves).toEqual([["t2", "done"]]);
60
+ });
61
+
62
+ test("dropping back on the same column writes nothing", () => {
63
+ const moves: unknown[] = [];
64
+ render(
65
+ <TaskBoard
66
+ tasks={tasks}
67
+ assigneeName={noName}
68
+ minutesFor={noMinutes}
69
+ onMove={() => moves.push(1)}
70
+ onOpen={() => {}}
71
+ />,
72
+ );
73
+ const data = new Map<string, string>();
74
+ const dataTransfer = {
75
+ setData: (k: string, v: string) => data.set(k, v),
76
+ getData: (k: string) => data.get(k) ?? "",
77
+ effectAllowed: "",
78
+ dropEffect: "",
79
+ };
80
+ fireEvent.dragStart(screen.getByText("Weekly report"), { dataTransfer });
81
+ fireEvent.drop(screen.getByLabelText("To do"), { dataTransfer });
82
+ expect(moves).toEqual([]);
83
+ });
84
+
85
+ test("shows a column's logged total", () => {
86
+ render(
87
+ <TaskBoard
88
+ tasks={tasks}
89
+ assigneeName={noName}
90
+ minutesFor={(id) => (id === "t1" ? 600 : 0)}
91
+ onMove={() => {}}
92
+ onOpen={() => {}}
93
+ />,
94
+ );
95
+ expect(within(screen.getByLabelText("In progress")).getAllByText("10h").length)
96
+ .toBeGreaterThan(0);
97
+ });
98
+
99
+ test("flags a card that has blown its estimate", () => {
100
+ // Showing "3h / 8h" on every card is noise; an overrun is worth seeing.
101
+ render(
102
+ <TaskBoard
103
+ tasks={[{ ...tasks[0], estimateMinutes: 120 }]}
104
+ assigneeName={noName}
105
+ minutesFor={() => 300}
106
+ onMove={() => {}}
107
+ onOpen={() => {}}
108
+ />,
109
+ );
110
+ expect(screen.getByText("5h / 2h")).toBeDefined();
111
+ });
112
+
113
+ test("opening a card reports the task id", () => {
114
+ const opened: string[] = [];
115
+ render(
116
+ <TaskBoard
117
+ tasks={tasks}
118
+ assigneeName={noName}
119
+ minutesFor={noMinutes}
120
+ onMove={() => {}}
121
+ onOpen={(id) => opened.push(id)}
122
+ />,
123
+ );
124
+ fireEvent.click(screen.getByText("Dispatcher board UI"));
125
+ expect(opened).toEqual(["t1"]);
126
+ });
127
+ });
128
+
129
+ describe("TimeDialog", () => {
130
+ test("accepts the formats people actually type", () => {
131
+ const logged: number[] = [];
132
+ render(
133
+ <TimeDialog
134
+ open
135
+ taskTitle="Dispatcher board UI"
136
+ onOpenChange={() => {}}
137
+ onLog={(minutes) => logged.push(minutes)}
138
+ />,
139
+ );
140
+ fireEvent.change(screen.getByLabelText("Time"), { target: { value: "1h30" } });
141
+ fireEvent.click(screen.getByRole("button", { name: /log time/i }));
142
+ expect(logged).toEqual([90]);
143
+ });
144
+
145
+ test("previews what it parsed, so a typo is visible before submitting", () => {
146
+ render(
147
+ <TimeDialog open taskTitle="X" onOpenChange={() => {}} onLog={() => {}} />,
148
+ );
149
+ fireEvent.change(screen.getByLabelText("Time"), { target: { value: "90" } });
150
+ expect(screen.getByText("1h 30m")).toBeDefined();
151
+ });
152
+
153
+ test("refuses an unparseable value", () => {
154
+ const logged: number[] = [];
155
+ render(
156
+ <TimeDialog open taskTitle="X" onOpenChange={() => {}} onLog={(m) => logged.push(m)} />,
157
+ );
158
+ fireEvent.change(screen.getByLabelText("Time"), { target: { value: "soon" } });
159
+ fireEvent.click(screen.getByRole("button", { name: /log time/i }));
160
+ expect(logged).toEqual([]);
161
+ expect(screen.getByText(/try 90/i)).toBeDefined();
162
+ });
163
+
164
+ test("refuses more than a day", () => {
165
+ // A typo on a timesheet becomes a typo on an invoice.
166
+ const logged: number[] = [];
167
+ render(
168
+ <TimeDialog open taskTitle="X" onOpenChange={() => {}} onLog={(m) => logged.push(m)} />,
169
+ );
170
+ fireEvent.change(screen.getByLabelText("Time"), { target: { value: "30h" } });
171
+ fireEvent.click(screen.getByRole("button", { name: /log time/i }));
172
+ expect(logged).toEqual([]);
173
+ });
174
+
175
+ test("renders nothing when closed", () => {
176
+ const { container } = render(
177
+ <TimeDialog open={false} taskTitle="X" onOpenChange={() => {}} onLog={() => {}} />,
178
+ );
179
+ expect(container.firstChild).toBeNull();
180
+ });
181
+ });
182
+
183
+ describe("BudgetBar", () => {
184
+ test("calls out an overrun with the amount", () => {
185
+ render(<BudgetBar budgetMinutes={2400} loggedMinutes={2760} />);
186
+ expect(screen.getByText(/over budget by 6h/i)).toBeDefined();
187
+ });
188
+
189
+ test("shows billable value when there's a rate", () => {
190
+ render(
191
+ <BudgetBar budgetMinutes={2400} loggedMinutes={600} hourlyRateCents={16_500} />,
192
+ );
193
+ expect(screen.getByText(/\$1,650\.00 billable/)).toBeDefined();
194
+ });
195
+
196
+ test("says so when there's no budget rather than drawing an empty bar", () => {
197
+ render(<BudgetBar budgetMinutes={0} loggedMinutes={600} />);
198
+ expect(screen.getByText("No budget set")).toBeDefined();
199
+ });
200
+ });
@@ -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();