@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,181 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { db, useRouter } from "@pylonsync/react";
5
+ import { FolderKanban, Plus } from "lucide-react";
6
+ import { Button } from "@/components/ui/button";
7
+ import { PageHeader } from "@/components/page-header";
8
+ import { DataTable, type ColumnDef } from "@/components/data-table";
9
+ import { EmptyState } from "@/components/empty-state";
10
+ import { RecordDialog } from "@/components/record-dialog";
11
+ import {
12
+ budgetState,
13
+ duration,
14
+ isOpen,
15
+ minutesForProject,
16
+ parseDuration,
17
+ progress,
18
+ } from "@/lib/work";
19
+ import { Workspace, type ProjectRow } from "./workspace";
20
+
21
+ export function ProjectsView({
22
+ email,
23
+ openNew,
24
+ }: {
25
+ email: string;
26
+ openNew?: boolean;
27
+ }) {
28
+ const router = useRouter();
29
+ const [open, setOpen] = useState(Boolean(openNew));
30
+
31
+ return (
32
+ <Workspace email={email} pathname="/">
33
+ {(data) => {
34
+ const columns: ColumnDef<ProjectRow>[] = [
35
+ {
36
+ key: "name",
37
+ header: "Project",
38
+ cell: (row) => <span className="truncate font-medium">{row.name}</span>,
39
+ },
40
+ {
41
+ key: "client",
42
+ header: "Client",
43
+ cell: (row) => (
44
+ <span className="text-muted-foreground">
45
+ {data.clientName(row.clientId) ?? "—"}
46
+ </span>
47
+ ),
48
+ },
49
+ {
50
+ key: "tasks",
51
+ header: "Tasks",
52
+ cell: (row) => {
53
+ const tasks = data.tasks.filter((t) => t.projectId === row.id);
54
+ const p = progress(tasks);
55
+ return (
56
+ <span className="tabular text-muted-foreground">
57
+ {p.done}/{p.total}
58
+ </span>
59
+ );
60
+ },
61
+ },
62
+ {
63
+ key: "open",
64
+ header: "Open",
65
+ numeric: true,
66
+ cell: (row) =>
67
+ data.tasks.filter((t) => t.projectId === row.id && isOpen(t)).length,
68
+ },
69
+ {
70
+ key: "time",
71
+ header: "Logged",
72
+ numeric: true,
73
+ cell: (row) => {
74
+ const logged = minutesForProject(row.id, data.entries);
75
+ const state = budgetState(row.budgetMinutes, logged);
76
+ return (
77
+ <span
78
+ className={
79
+ state === "over"
80
+ ? "text-destructive"
81
+ : state === "near"
82
+ ? "text-stage-proposal"
83
+ : ""
84
+ }
85
+ title={
86
+ row.budgetMinutes
87
+ ? `Budget ${duration(row.budgetMinutes)}`
88
+ : undefined
89
+ }
90
+ >
91
+ {duration(logged)}
92
+ </span>
93
+ );
94
+ },
95
+ },
96
+ {
97
+ key: "due",
98
+ header: "Due",
99
+ cell: (row) => (
100
+ <span className="text-muted-foreground">
101
+ {row.dueDate
102
+ ? new Date(row.dueDate).toLocaleDateString("en-US", {
103
+ month: "short",
104
+ day: "numeric",
105
+ })
106
+ : "—"}
107
+ </span>
108
+ ),
109
+ },
110
+ ];
111
+
112
+ // Active first, then by name — a finished project shouldn\'t sit above
113
+ // the work in flight.
114
+ const rows = [...data.projects].sort((a, b) => {
115
+ const rank = (p: ProjectRow) => (p.status === "active" ? 0 : p.status === "paused" ? 1 : 2);
116
+ return rank(a) - rank(b) || a.name.localeCompare(b.name);
117
+ });
118
+
119
+ return (
120
+ <>
121
+ <PageHeader title="Projects" count={rows.length}>
122
+ <Button size="sm" onClick={() => setOpen(true)}>
123
+ <Plus />
124
+ New project
125
+ </Button>
126
+ </PageHeader>
127
+
128
+ <DataTable
129
+ rows={rows}
130
+ columns={columns}
131
+ onRowClick={(row) => router.push(`/projects/${row.id}`)}
132
+ empty={
133
+ <EmptyState
134
+ icon={<FolderKanban />}
135
+ title={data.loading ? "Loading…" : "No projects yet"}
136
+ description="A project holds a task board and the time logged against it. Create one to get started."
137
+ action={
138
+ <Button size="sm" onClick={() => setOpen(true)}>
139
+ <Plus />
140
+ New project
141
+ </Button>
142
+ }
143
+ />
144
+ }
145
+ />
146
+
147
+ <RecordDialog
148
+ open={open}
149
+ title="New project"
150
+ submitLabel="Create project"
151
+ onOpenChange={setOpen}
152
+ fields={[
153
+ { name: "name", label: "Name", required: true, placeholder: "Dispatch portal" },
154
+ {
155
+ name: "clientId",
156
+ label: "Client",
157
+ options: data.clients
158
+ .slice()
159
+ .sort((a, b) => a.name.localeCompare(b.name))
160
+ .map((c) => ({ value: c.id, label: c.name })),
161
+ },
162
+ { name: "budget", label: "Budget", placeholder: "80h" },
163
+ { name: "rate", label: "Hourly rate", placeholder: "165" },
164
+ ]}
165
+ onCreate={async (values) => {
166
+ // Parsed at the edge — everything below this line is integers.
167
+ await db.insert("Project", {
168
+ name: values.name,
169
+ clientId: values.clientId ?? null,
170
+ status: "active",
171
+ budgetMinutes: parseDuration(values.budget ?? "") ?? 0,
172
+ hourlyRateCents: Math.round(Number(values.rate ?? 0) * 100) || 0,
173
+ });
174
+ }}
175
+ />
176
+ </>
177
+ );
178
+ }}
179
+ </Workspace>
180
+ );
181
+ }
@@ -0,0 +1,12 @@
1
+ import type { Robots } from "@pylonsync/react";
2
+
3
+ // app/robots.ts → served at /robots.txt. The default export may also be async.
4
+ const SITE = process.env.SITE_URL ?? "http://localhost:4321";
5
+
6
+ export default function robots(): Robots {
7
+ return {
8
+ // Keep the authenticated app and the API out of the index.
9
+ rules: { userAgent: "*", allow: "/", disallow: ["/dashboard", "/api/"] },
10
+ sitemap: `${SITE}/sitemap.xml`,
11
+ };
12
+ }
@@ -0,0 +1,27 @@
1
+ import type { Sitemap } from "@pylonsync/react";
2
+
3
+ // app/sitemap.ts → served at /sitemap.xml. The default export can be async, so
4
+ // it can enumerate dynamic pages from your database. Point SITE_URL at your
5
+ // domain in production.
6
+ const SITE = process.env.SITE_URL ?? "http://localhost:4321";
7
+
8
+ export default async function sitemap(): Promise<Sitemap> {
9
+ // Only public pages belong here — /dashboard is private (and noindex), so
10
+ // it's intentionally left out.
11
+ const staticRoutes: Sitemap = [
12
+ { url: `${SITE}/`, changeFrequency: "weekly", priority: 1 },
13
+ { url: `${SITE}/login`, changeFrequency: "yearly", priority: 0.3 },
14
+ { url: `${SITE}/signup`, changeFrequency: "yearly", priority: 0.5 },
15
+ ];
16
+
17
+ // The export is async, so you can enumerate dynamic pages from a DB read:
18
+ //
19
+ // const posts = await fetchPublishedPosts();
20
+ // const postRoutes: Sitemap = posts.map((p) => ({
21
+ // url: `${SITE}/blog/${p.slug}`,
22
+ // lastModified: p.updatedAt,
23
+ // }));
24
+ // return [...staticRoutes, ...postRoutes];
25
+
26
+ return staticRoutes;
27
+ }
@@ -0,0 +1,190 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useMemo, useRef, useState } from "react";
4
+ import { callFn, db, useRouter } from "@pylonsync/react";
5
+ import { useAuth } from "@pylonsync/client";
6
+ import { Sidebar } from "@/components/sidebar";
7
+ import { CommandPalette } from "@/components/command-palette";
8
+ import type { SearchItem } from "@/lib/search";
9
+ import { minutesByTask, type Project, type Task, type TimeEntry } from "@/lib/work";
10
+
11
+ export interface ClientRow {
12
+ id: string;
13
+ name: string;
14
+ email?: string | null;
15
+ createdAt?: string | null;
16
+ }
17
+ export type ProjectRow = Project;
18
+ export type TaskRow = Task;
19
+ export type TimeEntryRow = TimeEntry;
20
+ export interface UserRow {
21
+ id: string;
22
+ email: string;
23
+ displayName?: string | null;
24
+ }
25
+
26
+ export interface WorkspaceData {
27
+ clients: ClientRow[];
28
+ projects: ProjectRow[];
29
+ tasks: TaskRow[];
30
+ entries: TimeEntryRow[];
31
+ /** Minutes per task, computed once per render rather than per card. */
32
+ taskMinutes: Map<string, number>;
33
+ clientName: (id: string | null | undefined) => string | null;
34
+ memberName: (id: string | null | undefined) => string | null;
35
+ members: UserRow[];
36
+ loading: boolean;
37
+ }
38
+
39
+ /**
40
+ * The app shell, and the ONLY place that touches `db`.
41
+ *
42
+ * Every view below receives plain data through the render prop and reports
43
+ * changes through callbacks, which is what lets the components in `components/`
44
+ * render from fixtures in a test with nothing mocked.
45
+ *
46
+ * The queries are live: drag a task and it moves on every teammate\'s board, so
47
+ * a standup doesn\'t start with reconciling two views of the work.
48
+ */
49
+ export function Workspace({
50
+ email,
51
+ pathname,
52
+ children,
53
+ }: {
54
+ email: string;
55
+ pathname: string;
56
+ children: (data: WorkspaceData) => React.ReactNode;
57
+ }) {
58
+ const router = useRouter();
59
+ const { signOut } = useAuth();
60
+ const { data: projects, loading } = db.useQuery<ProjectRow>("Project");
61
+ const { data: clients } = db.useQuery<ClientRow>("Client");
62
+ const { data: tasks } = db.useQuery<TaskRow>("Task");
63
+ const { data: entries } = db.useQuery<TimeEntryRow>("TimeEntry");
64
+ const { data: users } = db.useQuery<UserRow>("User");
65
+
66
+ const [paletteOpen, setPaletteOpen] = useState(false);
67
+ const seeded = useRef(false);
68
+
69
+ const projectList = projects ?? [];
70
+ const clientList = clients ?? [];
71
+ const taskList = tasks ?? [];
72
+ const entryList = entries ?? [];
73
+
74
+ useEffect(() => {
75
+ if (loading || seeded.current) return;
76
+ if (projectList.length > 0) {
77
+ seeded.current = true;
78
+ return;
79
+ }
80
+ seeded.current = true;
81
+ callFn("seedWorkspace", {}).catch(() => {
82
+ seeded.current = false;
83
+ });
84
+ }, [loading, projectList.length]);
85
+
86
+ useEffect(() => {
87
+ function onKey(event: KeyboardEvent) {
88
+ const target = event.target as HTMLElement | null;
89
+ const typing =
90
+ !!target &&
91
+ (target.tagName === "INPUT" ||
92
+ target.tagName === "TEXTAREA" ||
93
+ target.isContentEditable);
94
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
95
+ event.preventDefault();
96
+ setPaletteOpen((open) => !open);
97
+ } else if (event.key === "/" && !typing) {
98
+ event.preventDefault();
99
+ setPaletteOpen(true);
100
+ }
101
+ }
102
+ window.addEventListener("keydown", onKey);
103
+ return () => window.removeEventListener("keydown", onKey);
104
+ }, []);
105
+
106
+ // One pass over the ledger for the whole render — per-card lookups would be
107
+ // O(tasks x entries).
108
+ const taskMinutes = useMemo(() => minutesByTask(entryList), [entryList]);
109
+ const clientById = useMemo(
110
+ () => new Map(clientList.map((c) => [c.id, c] as const)),
111
+ [clientList],
112
+ );
113
+ const userById = useMemo(
114
+ () => new Map((users ?? []).map((u) => [u.id, u] as const)),
115
+ [users],
116
+ );
117
+
118
+ const searchIndex = useMemo<SearchItem[]>(
119
+ () => [
120
+ ...projectList.map((project) => ({
121
+ id: `project:${project.id}`,
122
+ type: "company" as const,
123
+ title: project.name,
124
+ subtitle: clientById.get(project.clientId ?? "")?.name,
125
+ href: `/projects/${project.id}`,
126
+ })),
127
+ ...taskList.map((task) => ({
128
+ id: `task:${task.id}`,
129
+ type: "deal" as const,
130
+ title: task.title,
131
+ href: `/projects/${task.projectId}`,
132
+ })),
133
+ ],
134
+ [projectList, taskList, clientById],
135
+ );
136
+
137
+ const data: WorkspaceData = {
138
+ clients: clientList,
139
+ projects: projectList,
140
+ tasks: taskList,
141
+ entries: entryList,
142
+ taskMinutes,
143
+ clientName: (id) => clientById.get(id ?? "")?.name ?? null,
144
+ memberName: (id) => {
145
+ const user = userById.get(id ?? "");
146
+ return user?.displayName || user?.email || null;
147
+ },
148
+ members: users ?? [],
149
+ loading,
150
+ };
151
+
152
+ return (
153
+ <div className="flex h-screen overflow-hidden">
154
+ <Sidebar
155
+ workspace="Projects"
156
+ email={email}
157
+ pathname={pathname}
158
+ counts={{
159
+ "/": projectList.filter((p) => p.status === "active").length,
160
+ "/clients": clientList.length,
161
+ }}
162
+ onOpenCommand={() => setPaletteOpen(true)}
163
+ onSignOut={async () => {
164
+ await signOut();
165
+ window.location.assign("/login");
166
+ }}
167
+ />
168
+ <main className="flex min-w-0 flex-1 flex-col">{children(data)}</main>
169
+
170
+ <CommandPalette
171
+ open={paletteOpen}
172
+ items={searchIndex}
173
+ actions={[
174
+ {
175
+ id: "new-project",
176
+ label: "New project",
177
+ run: () => router.push("/?new=project"),
178
+ },
179
+ {
180
+ id: "go-clients",
181
+ label: "Go to Clients",
182
+ run: () => router.push("/clients"),
183
+ },
184
+ ]}
185
+ onClose={() => setPaletteOpen(false)}
186
+ onSelect={(item) => router.push(item.href)}
187
+ />
188
+ </div>
189
+ );
190
+ }
@@ -0,0 +1,186 @@
1
+ import {
2
+ entity,
3
+ field,
4
+ policy,
5
+ auth,
6
+ buildManifest,
7
+ discoverAppRoutes,
8
+ font,
9
+ } from "@pylonsync/sdk";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // projects — client project delivery for a small team: projects, a task board,
13
+ // and time logged against the work.
14
+ //
15
+ // TIME IS A LEDGER. A task\'s logged hours are the sum of its TimeEntry rows,
16
+ // never a running total on the task. Two people logging against the same task at
17
+ // once is normal, and a mutable total loses one of them. It also means every
18
+ // hour on an invoice traces to who logged it and when — the question a client
19
+ // asks.
20
+ //
21
+ // • Client — who the work is for.
22
+ // • Project — budget, rate, due date, status.
23
+ // • Task — the board. Status, assignee, estimate.
24
+ // • TimeEntry — minutes against a task. Append-only.
25
+ // • User — a team member (email/password).
26
+ //
27
+ // Minutes are integers. Hours as floats produce 7.999999 on a timesheet.
28
+ //
29
+ // The realtime hook: drag a task and it moves on every teammate\'s board
30
+ // instantly, so a standup doesn\'t start with reconciling two views of the work.
31
+ //
32
+ // TRUST MODEL: an internal tool for ONE team. Every entity is readable and
33
+ // writable by any SIGNED-IN user and by nobody else. There is no client portal —
34
+ // adding one means a separate, narrower policy for that client\'s own projects.
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const TEAM = "auth.userId != null";
38
+
39
+ const Client = entity(
40
+ "Client",
41
+ {
42
+ name: field.string(),
43
+ email: field.string().optional(),
44
+ createdAt: field.datetime().defaultNow(),
45
+ },
46
+ { indexes: [{ name: "by_name", fields: ["name"], unique: false }] },
47
+ );
48
+
49
+ const Project = entity(
50
+ "Project",
51
+ {
52
+ name: field.string(),
53
+ clientId: field.id("Client").optional(),
54
+ // "active" | "paused" | "complete" — see PROJECT_STATUSES.
55
+ status: field.string().default("active"),
56
+ // Agreed budget in whole MINUTES; 0 means no budget to track against.
57
+ budgetMinutes: field.number().default(0),
58
+ hourlyRateCents: field.number().default(0),
59
+ dueDate: field.datetime().optional(),
60
+ createdAt: field.datetime().defaultNow(),
61
+ },
62
+ {
63
+ indexes: [
64
+ { name: "by_status", fields: ["status"], unique: false },
65
+ { name: "by_client", fields: ["clientId"], unique: false },
66
+ ],
67
+ },
68
+ );
69
+
70
+ const Task = entity(
71
+ "Task",
72
+ {
73
+ projectId: field.id("Project"),
74
+ title: field.string(),
75
+ // "todo" | "doing" | "review" | "done" — see TASK_STATUSES.
76
+ status: field.string().default("todo"),
77
+ assigneeId: field.id("User").optional(),
78
+ estimateMinutes: field.number().default(0),
79
+ // Manual order within a column, so dragging is stable.
80
+ position: field.number().default(0),
81
+ createdAt: field.datetime().defaultNow(),
82
+ updatedAt: field.datetime().defaultNow(),
83
+ },
84
+ {
85
+ indexes: [
86
+ { name: "by_project", fields: ["projectId"], unique: false },
87
+ { name: "by_status", fields: ["status"], unique: false },
88
+ ],
89
+ },
90
+ );
91
+
92
+ // APPEND-ONLY, like a stock ledger. `projectId` is denormalised so project
93
+ // totals don\'t need a join through Task on every read — the task can\'t change
94
+ // project, so it can\'t go stale.
95
+ const TimeEntry = entity(
96
+ "TimeEntry",
97
+ {
98
+ taskId: field.id("Task"),
99
+ projectId: field.id("Project"),
100
+ minutes: field.number(),
101
+ note: field.string().optional(),
102
+ userId: field.id("User").readonly().optional(),
103
+ spentOn: field.datetime().defaultNow(),
104
+ },
105
+ {
106
+ indexes: [
107
+ { name: "by_task", fields: ["taskId"], unique: false },
108
+ { name: "by_project", fields: ["projectId"], unique: false },
109
+ ],
110
+ },
111
+ );
112
+
113
+ const User = entity(
114
+ "User",
115
+ {
116
+ email: field.string(),
117
+ displayName: field.string().optional(),
118
+ passwordHash: field.string().serverOnly().optional(),
119
+ avatarColor: field.string().optional(),
120
+ emailVerified: field.datetime().optional(),
121
+ createdAt: field.datetime().defaultNow(),
122
+ },
123
+ { indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
124
+ );
125
+
126
+ const teamPolicy = (name: string, entityName: string) =>
127
+ policy({
128
+ name,
129
+ entity: entityName,
130
+ allowRead: TEAM,
131
+ allowInsert: TEAM,
132
+ allowUpdate: TEAM,
133
+ allowDelete: TEAM,
134
+ });
135
+
136
+ // Logged time is insert-only: editing an entry would quietly change what a
137
+ // client was billed, and deleting one would make a total unexplainable. A
138
+ // correction is a negative entry, which stays visible in the history.
139
+ const timePolicy = policy({
140
+ name: "time_append_only",
141
+ entity: "TimeEntry",
142
+ allowRead: TEAM,
143
+ allowInsert: TEAM,
144
+ allowUpdate: "false",
145
+ allowDelete: "false",
146
+ });
147
+
148
+ const userPolicy = policy({
149
+ name: "user_team_read",
150
+ entity: "User",
151
+ allowRead: TEAM,
152
+ allowInsert: "false",
153
+ allowUpdate: "false",
154
+ allowDelete: "false",
155
+ });
156
+
157
+ const manifest = buildManifest({
158
+ name: "__APP_NAME__",
159
+ version: "0.1.0",
160
+ entities: [Client, Project, Task, TimeEntry, User],
161
+ queries: [],
162
+ actions: [],
163
+ policies: [
164
+ teamPolicy("client_team", "Client"),
165
+ teamPolicy("project_team", "Project"),
166
+ teamPolicy("task_team", "Task"),
167
+ timePolicy,
168
+ userPolicy,
169
+ ],
170
+ auth: auth(),
171
+ fonts: [
172
+ font({
173
+ family: "Inter",
174
+ variable: "--font-sans",
175
+ weights: ["400", "500", "600", "700"],
176
+ subsets: ["latin"],
177
+ display: "swap",
178
+ preload: true,
179
+ }),
180
+ ],
181
+ routes: await discoverAppRoutes(),
182
+ });
183
+
184
+ console.log(JSON.stringify(manifest, null, 2));
185
+
186
+ export default manifest;
@@ -0,0 +1,4 @@
1
+ [test]
2
+ # Register a DOM (happy-dom) before any test runs so React component tests can
3
+ # render under `pylon test` / `bun test`. Harmless for pure-logic tests.
4
+ preload = ["./tests/setup.ts"]
@@ -0,0 +1,40 @@
1
+ import React from "react";
2
+ import { cn } from "@/lib/utils";
3
+ import { accentIndex, initials } from "@/lib/format";
4
+
5
+ // Deterministic accent per record — the same company keeps its colour across
6
+ // sessions and machines without storing one. Hues are muted so a dense list
7
+ // doesn't turn into confetti.
8
+ const ACCENTS = [
9
+ "bg-indigo-500/15 text-indigo-500 dark:text-indigo-300",
10
+ "bg-emerald-500/15 text-emerald-600 dark:text-emerald-300",
11
+ "bg-amber-500/15 text-amber-600 dark:text-amber-300",
12
+ "bg-rose-500/15 text-rose-500 dark:text-rose-300",
13
+ "bg-sky-500/15 text-sky-600 dark:text-sky-300",
14
+ "bg-violet-500/15 text-violet-500 dark:text-violet-300",
15
+ ];
16
+
17
+ export function Avatar({
18
+ name,
19
+ size = "md",
20
+ className,
21
+ }: {
22
+ name: string | null | undefined;
23
+ size?: "sm" | "md";
24
+ className?: string;
25
+ }) {
26
+ const label = initials(name);
27
+ return (
28
+ <span
29
+ aria-hidden="true"
30
+ className={cn(
31
+ "inline-flex shrink-0 select-none items-center justify-center rounded-full font-medium",
32
+ size === "sm" ? "size-5 text-[9px]" : "size-6 text-[10px]",
33
+ ACCENTS[accentIndex(name ?? "?", ACCENTS.length)],
34
+ className,
35
+ )}
36
+ >
37
+ {label}
38
+ </span>
39
+ );
40
+ }
@@ -0,0 +1,37 @@
1
+ import React from "react";
2
+ import { TASK_STATUSES } from "@/lib/work";
3
+
4
+ /**
5
+ * The board\'s shape while the replica hydrates. Showing the real columns keeps
6
+ * the beat before the first row lands from reading as "this project has no
7
+ * tasks", and nothing jumps when the data arrives.
8
+ */
9
+ export function BoardSkeleton() {
10
+ return (
11
+ <div className="flex h-full gap-3 overflow-hidden p-4" aria-hidden="true">
12
+ {TASK_STATUSES.map((status, column) => (
13
+ <section
14
+ key={status.id}
15
+ className="flex w-[268px] shrink-0 flex-col rounded-xl border border-border bg-surface-1/50"
16
+ >
17
+ <header className="flex items-center gap-2 px-3 py-2.5">
18
+ <span className="text-[12px] font-medium text-muted-foreground">
19
+ {status.label}
20
+ </span>
21
+ </header>
22
+ <div className="flex flex-col gap-2 px-2 pb-2">
23
+ {Array.from({ length: Math.max(1, 3 - column) }).map((_, card) => (
24
+ <div
25
+ key={card}
26
+ className="animate-pulse rounded-lg border border-border bg-card p-2.5"
27
+ >
28
+ <div className="h-3 w-3/4 rounded bg-muted" />
29
+ <div className="mt-3 h-4 w-4 rounded-full bg-muted" />
30
+ </div>
31
+ ))}
32
+ </div>
33
+ </section>
34
+ ))}
35
+ </div>
36
+ );
37
+ }