@pylonsync/create-pylon 0.11.1 → 0.11.3

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 (142) hide show
  1. package/bin/create-pylon.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/_root/AGENTS.md +1 -1
  4. package/templates/agency/AGENTS.md +1 -1
  5. package/templates/agency/app/(marketing)/contact-form.tsx +22 -32
  6. package/templates/agency/app/(marketing)/layout.tsx +36 -64
  7. package/templates/agency/app/(marketing)/not-found.tsx +6 -8
  8. package/templates/agency/app/(marketing)/page.tsx +93 -144
  9. package/templates/agency/app/(marketing)/work/[slug]/page.tsx +69 -71
  10. package/templates/agency/app/(marketing)/work/page.tsx +20 -17
  11. package/templates/agency/app/globals.css +15 -6
  12. package/templates/agency/app.ts +10 -0
  13. package/templates/agency/components/marketing.tsx +40 -104
  14. package/templates/agency/lib/site.config.ts +34 -52
  15. package/templates/agency/public/images/hero.jpg +0 -0
  16. package/templates/agency/public/images/team/claire-donovan.jpg +0 -0
  17. package/templates/agency/public/images/team/dana-okafor.jpg +0 -0
  18. package/templates/agency/public/images/team/marcus-lee.jpg +0 -0
  19. package/templates/agency/public/images/work/atlas-health.jpg +0 -0
  20. package/templates/agency/public/images/work/cohort.jpg +0 -0
  21. package/templates/agency/public/images/work/ledger.jpg +0 -0
  22. package/templates/agency/public/images/work/vela.jpg +0 -0
  23. package/templates/ai-chat/AGENTS.md +1 -1
  24. package/templates/ai-studio/AGENTS.md +1 -1
  25. package/templates/barebones/AGENTS.md +1 -1
  26. package/templates/chat/AGENTS.md +1 -1
  27. package/templates/consumer/AGENTS.md +1 -1
  28. package/templates/consumer/README.md +39 -23
  29. package/templates/consumer/app/(app)/explore/page.tsx +13 -0
  30. package/templates/consumer/app/(app)/layout.tsx +17 -0
  31. package/templates/consumer/app/(app)/p/[id]/page.tsx +13 -0
  32. package/templates/consumer/app/(app)/page.tsx +15 -0
  33. package/templates/consumer/app/(app)/u/[username]/page.tsx +14 -0
  34. package/templates/consumer/app/error.tsx +1 -1
  35. package/templates/consumer/app/globals.css +25 -0
  36. package/templates/consumer/app/layout.tsx +3 -10
  37. package/templates/consumer/app/llms.ts +10 -10
  38. package/templates/consumer/app/not-found.tsx +9 -22
  39. package/templates/consumer/app/robots.ts +3 -4
  40. package/templates/consumer/app/sitemap.ts +7 -21
  41. package/templates/consumer/app.ts +152 -33
  42. package/templates/consumer/components/social/app-chrome.tsx +109 -0
  43. package/templates/consumer/components/social/avatar.tsx +50 -0
  44. package/templates/consumer/components/social/comment-form.tsx +51 -0
  45. package/templates/consumer/components/social/create-post.tsx +155 -0
  46. package/templates/consumer/components/social/edit-profile.tsx +103 -0
  47. package/templates/consumer/components/social/explore-screen.tsx +23 -0
  48. package/templates/consumer/components/social/home-screen.tsx +161 -0
  49. package/templates/consumer/components/social/post-card.tsx +77 -0
  50. package/templates/consumer/components/social/post-grid.tsx +46 -0
  51. package/templates/consumer/components/social/post-parts.tsx +146 -0
  52. package/templates/consumer/components/social/post-screen.tsx +173 -0
  53. package/templates/consumer/components/social/profile-screen.tsx +146 -0
  54. package/templates/consumer/components/social/rich-text.tsx +22 -0
  55. package/templates/consumer/components/social/session.tsx +43 -0
  56. package/templates/consumer/components/social/use-social.ts +86 -0
  57. package/templates/consumer/functions/addComment.ts +27 -0
  58. package/templates/consumer/functions/createPost.ts +39 -0
  59. package/templates/consumer/functions/ensureProfile.ts +39 -0
  60. package/templates/consumer/functions/seedFeed.ts +38 -0
  61. package/templates/consumer/functions/updateProfile.ts +61 -0
  62. package/templates/consumer/lib/seed.ts +192 -0
  63. package/templates/consumer/lib/site.ts +6 -0
  64. package/templates/consumer/lib/social.ts +245 -0
  65. package/templates/consumer/tests/social.test.ts +157 -0
  66. package/templates/creator/AGENTS.md +1 -1
  67. package/templates/creator/app/(marketing)/layout.tsx +35 -69
  68. package/templates/creator/app/(marketing)/newsletter-signup.tsx +15 -26
  69. package/templates/creator/app/(marketing)/not-found.tsx +8 -6
  70. package/templates/creator/app/(marketing)/page.tsx +90 -106
  71. package/templates/creator/app/globals.css +14 -0
  72. package/templates/creator/app/layout.tsx +1 -0
  73. package/templates/creator/app.ts +10 -0
  74. package/templates/creator/components/marketing.tsx +28 -89
  75. package/templates/creator/lib/site.config.ts +51 -29
  76. package/templates/creator/public/images/headshot.jpg +0 -0
  77. package/templates/crm/AGENTS.md +1 -1
  78. package/templates/crm/lib/seed.ts +153 -21
  79. package/templates/directory/AGENTS.md +1 -1
  80. package/templates/directory/app/(marketing)/directory-browse.tsx +99 -137
  81. package/templates/directory/app/(marketing)/layout.tsx +37 -88
  82. package/templates/directory/app/(marketing)/not-found.tsx +11 -10
  83. package/templates/directory/app/(marketing)/page.tsx +12 -33
  84. package/templates/directory/app/(marketing)/submit/page.tsx +9 -10
  85. package/templates/directory/app/(marketing)/submit-form.tsx +26 -26
  86. package/templates/directory/app/globals.css +15 -4
  87. package/templates/directory/app.ts +10 -0
  88. package/templates/directory/components/marketing.tsx +27 -91
  89. package/templates/directory/lib/site.config.ts +13 -24
  90. package/templates/helpdesk/AGENTS.md +1 -1
  91. package/templates/helpdesk/lib/seed.ts +188 -104
  92. package/templates/inventory/AGENTS.md +1 -1
  93. package/templates/inventory/lib/seed.ts +175 -44
  94. package/templates/invoices/AGENTS.md +1 -1
  95. package/templates/invoices/lib/seed.ts +74 -89
  96. package/templates/local-service/AGENTS.md +1 -1
  97. package/templates/local-service/app/(marketing)/booking-widget.tsx +31 -31
  98. package/templates/local-service/app/(marketing)/layout.tsx +25 -37
  99. package/templates/local-service/app/(marketing)/not-found.tsx +4 -4
  100. package/templates/local-service/app/(marketing)/page.tsx +145 -139
  101. package/templates/local-service/app/globals.css +11 -0
  102. package/templates/local-service/app/layout.tsx +2 -0
  103. package/templates/local-service/app.ts +9 -0
  104. package/templates/local-service/components/marketing.tsx +14 -39
  105. package/templates/local-service/lib/site.config.ts +16 -23
  106. package/templates/local-service/public/images/hero.jpg +0 -0
  107. package/templates/marketplace/AGENTS.md +1 -1
  108. package/templates/marketplace/client/SellForm.tsx +2 -0
  109. package/templates/projects/AGENTS.md +1 -1
  110. package/templates/projects/lib/seed.ts +103 -32
  111. package/templates/restaurant/AGENTS.md +1 -1
  112. package/templates/restaurant/app/(marketing)/layout.tsx +26 -48
  113. package/templates/restaurant/app/(marketing)/page.tsx +146 -123
  114. package/templates/restaurant/app/globals.css +11 -0
  115. package/templates/restaurant/app/layout.tsx +1 -0
  116. package/templates/restaurant/app.ts +10 -0
  117. package/templates/restaurant/components/marketing.tsx +10 -0
  118. package/templates/restaurant/lib/site.config.ts +16 -14
  119. package/templates/restaurant/public/images/bar.jpg +0 -0
  120. package/templates/restaurant/public/images/branzino.jpg +0 -0
  121. package/templates/restaurant/public/images/hearth.jpg +0 -0
  122. package/templates/restaurant/public/images/hero.jpg +0 -0
  123. package/templates/saas/AGENTS.md +1 -1
  124. package/templates/shop/AGENTS.md +1 -1
  125. package/templates/shop/public/images/products/cedar-smoke.jpg +0 -0
  126. package/templates/shop/public/images/products/linen.jpg +0 -0
  127. package/templates/todo/AGENTS.md +1 -1
  128. package/templates/waitlist/AGENTS.md +1 -1
  129. package/templates/waitlist/app/(marketing)/layout.tsx +39 -85
  130. package/templates/waitlist/app/(marketing)/not-found.tsx +8 -9
  131. package/templates/waitlist/app/(marketing)/page.tsx +51 -86
  132. package/templates/waitlist/app/(marketing)/waitlist-hero.tsx +43 -83
  133. package/templates/waitlist/app/globals.css +23 -0
  134. package/templates/waitlist/app/layout.tsx +1 -0
  135. package/templates/waitlist/app.ts +18 -0
  136. package/templates/waitlist/components/marketing.tsx +67 -81
  137. package/templates/waitlist/lib/site.config.ts +52 -59
  138. package/templates/consumer/app/feed-client.tsx +0 -159
  139. package/templates/consumer/app/page.tsx +0 -27
  140. package/templates/consumer/functions/_keep.ts +0 -13
  141. package/templates/consumer/functions/seedPosts.ts +0 -56
  142. package/templates/consumer/tests/example.test.ts +0 -12
@@ -1,13 +1,15 @@
1
1
  // Demo data for a brand-new stock workspace.
2
2
  //
3
- // An empty product list shows nothing about levels, reorder flags, or the
4
- // ledger. The first sign-in seeds a realistic shelf — including one line out of
5
- // stock and one below its reorder point — so those states are visible rather
6
- // than theoretical.
3
+ // The first sign-in seeds a working shelf: three dozen products across six
4
+ // categories and three months of ledger. Several lines end below their reorder
5
+ // point and a few are out of stock, so those states are visible rather than
6
+ // theoretical.
7
7
  //
8
- // The movements are the interesting part: each product\'s level is the sum of
9
- // its history, so the seed writes a plausible sequence of receipts and sales
10
- // rather than a starting quantity.
8
+ // The movements are the interesting part: each product's level is the sum of
9
+ // its history, so the seed writes a sequence of receipts, sales, returns, and
10
+ // counts rather than a starting quantity. The sequence is generated from a
11
+ // per-product profile with a fixed-seed generator, so it is the same on every
12
+ // run.
11
13
 
12
14
  export interface SeedProduct {
13
15
  key: string;
@@ -17,6 +19,14 @@ export interface SeedProduct {
17
19
  cost: number;
18
20
  price: number;
19
21
  reorderPoint: number;
22
+ /** Units received per delivery. */
23
+ lot: number;
24
+ /** Deliveries in the last 90 days. */
25
+ deliveries: number;
26
+ /** Average units sold per week. */
27
+ weeklySales: number;
28
+ /** How the ledger should end. */
29
+ ending: "healthy" | "low" | "out";
20
30
  }
21
31
 
22
32
  export interface SeedMovement {
@@ -29,44 +39,165 @@ export interface SeedMovement {
29
39
  }
30
40
 
31
41
  export const SEED_PRODUCTS: SeedProduct[] = [
32
- { key: "beans", sku: "CFE-001", name: "House Blend, 1kg", category: "Coffee", cost: 9.4, price: 18, reorderPoint: 12 },
33
- { key: "decaf", sku: "CFE-002", name: "Decaf Single Origin, 1kg", category: "Coffee", cost: 11.2, price: 22, reorderPoint: 6 },
34
- { key: "cups", sku: "PKG-010", name: "12oz Cups (sleeve of 50)", category: "Packaging", cost: 4.15, price: 0, reorderPoint: 20 },
35
- { key: "lids", sku: "PKG-011", name: "12oz Lids (sleeve of 50)", category: "Packaging", cost: 2.8, price: 0, reorderPoint: 20 },
36
- { key: "grinder", sku: "EQP-100", name: "Burr Grinder", category: "Equipment", cost: 148, price: 249, reorderPoint: 2 },
37
- { key: "filters", sku: "PKG-020", name: "Paper Filters (box of 100)", category: "Packaging", cost: 3.5, price: 7, reorderPoint: 10 },
38
- ];
42
+ { key: "beans", sku: "CFE-001", name: "House Blend, 1kg", category: "Coffee", cost: 9.4, price: 18, reorderPoint: 12, lot: 60, deliveries: 4, weeklySales: 11, ending: "healthy" },
43
+ { key: "decaf", sku: "CFE-002", name: "Decaf Single Origin, 1kg", category: "Coffee", cost: 11.2, price: 22, reorderPoint: 6, lot: 24, deliveries: 3, weeklySales: 6, ending: "low" },
44
+ { key: "espresso", sku: "CFE-003", name: "Espresso Roast, 1kg", category: "Coffee", cost: 10.1, price: 20, reorderPoint: 12, lot: 48, deliveries: 4, weeklySales: 12, ending: "healthy" },
45
+ { key: "ethiopia", sku: "CFE-004", name: "Ethiopia Yirgacheffe, 250g", category: "Coffee", cost: 4.6, price: 12, reorderPoint: 10, lot: 40, deliveries: 3, weeklySales: 9, ending: "healthy" },
46
+ { key: "colombia", sku: "CFE-005", name: "Colombia Huila, 250g", category: "Coffee", cost: 4.2, price: 11, reorderPoint: 10, lot: 40, deliveries: 3, weeklySales: 10, ending: "low" },
47
+ { key: "coldbrew", sku: "CFE-006", name: "Cold Brew Concentrate, 1L", category: "Coffee", cost: 5.8, price: 14, reorderPoint: 8, lot: 30, deliveries: 4, weeklySales: 8, ending: "healthy" },
48
+ { key: "drip-bags", sku: "CFE-007", name: "Drip Bags (box of 10)", category: "Coffee", cost: 3.9, price: 9, reorderPoint: 8, lot: 36, deliveries: 2, weeklySales: 7, ending: "out" },
49
+
50
+ { key: "breakfast", sku: "TEA-001", name: "English Breakfast, 100g", category: "Tea", cost: 2.8, price: 8, reorderPoint: 6, lot: 24, deliveries: 3, weeklySales: 4, ending: "healthy" },
51
+ { key: "earlgrey", sku: "TEA-002", name: "Earl Grey, 100g", category: "Tea", cost: 3.1, price: 8.5, reorderPoint: 6, lot: 24, deliveries: 2, weeklySales: 4, ending: "healthy" },
52
+ { key: "sencha", sku: "TEA-003", name: "Sencha, 100g", category: "Tea", cost: 4.4, price: 11, reorderPoint: 4, lot: 18, deliveries: 2, weeklySales: 3, ending: "low" },
53
+ { key: "chai", sku: "TEA-004", name: "Masala Chai, 100g", category: "Tea", cost: 3.3, price: 9, reorderPoint: 6, lot: 24, deliveries: 3, weeklySales: 5, ending: "healthy" },
54
+ { key: "peppermint", sku: "TEA-005", name: "Peppermint, 50g", category: "Tea", cost: 2.1, price: 6.5, reorderPoint: 4, lot: 20, deliveries: 2, weeklySales: 3, ending: "healthy" },
55
+
56
+ { key: "cups12", sku: "PKG-010", name: "12oz Cups (sleeve of 50)", category: "Packaging", cost: 4.15, price: 0, reorderPoint: 20, lot: 100, deliveries: 3, weeklySales: 22, ending: "healthy" },
57
+ { key: "lids12", sku: "PKG-011", name: "12oz Lids (sleeve of 50)", category: "Packaging", cost: 2.8, price: 0, reorderPoint: 20, lot: 100, deliveries: 2, weeklySales: 22, ending: "out" },
58
+ { key: "cups8", sku: "PKG-012", name: "8oz Cups (sleeve of 50)", category: "Packaging", cost: 3.6, price: 0, reorderPoint: 15, lot: 80, deliveries: 3, weeklySales: 14, ending: "healthy" },
59
+ { key: "lids8", sku: "PKG-013", name: "8oz Lids (sleeve of 50)", category: "Packaging", cost: 2.4, price: 0, reorderPoint: 15, lot: 80, deliveries: 3, weeklySales: 14, ending: "low" },
60
+ { key: "sleeves", sku: "PKG-014", name: "Cup Sleeves (box of 500)", category: "Packaging", cost: 11, price: 0, reorderPoint: 4, lot: 12, deliveries: 2, weeklySales: 2, ending: "healthy" },
61
+ { key: "filters", sku: "PKG-020", name: "Paper Filters (box of 100)", category: "Packaging", cost: 3.5, price: 7, reorderPoint: 10, lot: 48, deliveries: 3, weeklySales: 9, ending: "healthy" },
62
+ { key: "bags-1kg", sku: "PKG-021", name: "Retail Bags, 1kg (100)", category: "Packaging", cost: 18, price: 0, reorderPoint: 3, lot: 10, deliveries: 2, weeklySales: 1.5, ending: "healthy" },
63
+ { key: "bags-250g", sku: "PKG-022", name: "Retail Bags, 250g (200)", category: "Packaging", cost: 22, price: 0, reorderPoint: 3, lot: 10, deliveries: 2, weeklySales: 2, ending: "low" },
64
+ { key: "napkins", sku: "PKG-030", name: "Napkins (case of 2000)", category: "Packaging", cost: 19, price: 0, reorderPoint: 3, lot: 8, deliveries: 2, weeklySales: 1.2, ending: "healthy" },
65
+ { key: "stirrers", sku: "PKG-031", name: "Wooden Stirrers (box of 1000)", category: "Packaging", cost: 6.5, price: 0, reorderPoint: 4, lot: 12, deliveries: 2, weeklySales: 1.5, ending: "healthy" },
39
66
 
40
- export const SEED_MOVEMENTS: SeedMovement[] = [
41
- // Healthy stock.
42
- { product: "beans", delta: 60, reason: "received", note: "PO-4471", age: 21 },
43
- { product: "beans", delta: -8, reason: "sold", age: 14 },
44
- { product: "beans", delta: -11, reason: "sold", age: 7 },
45
- { product: "beans", delta: -6, reason: "sold", age: 2 },
46
-
47
- // Below its reorder point of 6 — ends at 4.
48
- { product: "decaf", delta: 24, reason: "received", note: "PO-4471", age: 21 },
49
- { product: "decaf", delta: -9, reason: "sold", age: 12 },
50
- { product: "decaf", delta: -8, reason: "sold", age: 5 },
51
- { product: "decaf", delta: -3, reason: "sold", age: 1 },
52
-
53
- { product: "cups", delta: 100, reason: "received", age: 30 },
54
- { product: "cups", delta: -34, reason: "sold", age: 10 },
55
-
56
- // Out of stock — ends at 0.
57
- { product: "lids", delta: 40, reason: "received", age: 30 },
58
- { product: "lids", delta: -38, reason: "sold", age: 9 },
59
- { product: "lids", delta: -2, reason: "damaged", note: "Crushed in transit", age: 4 },
60
-
61
- { product: "grinder", delta: 6, reason: "received", note: "PO-4502", age: 40 },
62
- { product: "grinder", delta: -2, reason: "sold", age: 18 },
63
- { product: "grinder", delta: 1, reason: "returned", note: "Customer changed mind", age: 6 },
64
-
65
- { product: "filters", delta: 48, reason: "received", age: 25 },
66
- { product: "filters", delta: -12, reason: "sold", age: 8 },
67
- { product: "filters", delta: -2, reason: "count", note: "Cycle count adjustment", age: 3 },
67
+ { key: "grinder", sku: "EQP-100", name: "Burr Grinder", category: "Equipment", cost: 148, price: 249, reorderPoint: 2, lot: 6, deliveries: 2, weeklySales: 0.5, ending: "healthy" },
68
+ { key: "kettle", sku: "EQP-101", name: "Gooseneck Kettle", category: "Equipment", cost: 42, price: 79, reorderPoint: 3, lot: 10, deliveries: 2, weeklySales: 1, ending: "healthy" },
69
+ { key: "v60", sku: "EQP-102", name: "Pour-over Dripper, 02", category: "Equipment", cost: 9, price: 24, reorderPoint: 5, lot: 20, deliveries: 2, weeklySales: 2.5, ending: "healthy" },
70
+ { key: "aeropress", sku: "EQP-103", name: "AeroPress", category: "Equipment", cost: 22, price: 39, reorderPoint: 4, lot: 12, deliveries: 2, weeklySales: 2, ending: "low" },
71
+ { key: "scale", sku: "EQP-104", name: "Brew Scale with Timer", category: "Equipment", cost: 31, price: 59, reorderPoint: 2, lot: 8, deliveries: 2, weeklySales: 1, ending: "healthy" },
72
+ { key: "frenchpress", sku: "EQP-105", name: "French Press, 1L", category: "Equipment", cost: 17, price: 34, reorderPoint: 3, lot: 10, deliveries: 1, weeklySales: 1, ending: "out" },
73
+ { key: "tamper", sku: "EQP-106", name: "Espresso Tamper, 58mm", category: "Equipment", cost: 19, price: 38, reorderPoint: 2, lot: 6, deliveries: 1, weeklySales: 0.5, ending: "healthy" },
74
+
75
+ { key: "mug", sku: "MRC-200", name: "Logo Mug", category: "Merch", cost: 5.5, price: 16, reorderPoint: 10, lot: 48, deliveries: 2, weeklySales: 6, ending: "healthy" },
76
+ { key: "tumbler", sku: "MRC-201", name: "Insulated Tumbler, 16oz", category: "Merch", cost: 12, price: 32, reorderPoint: 6, lot: 24, deliveries: 2, weeklySales: 4, ending: "low" },
77
+ { key: "tote", sku: "MRC-202", name: "Canvas Tote", category: "Merch", cost: 4.2, price: 14, reorderPoint: 8, lot: 36, deliveries: 1, weeklySales: 3, ending: "healthy" },
78
+ { key: "tee", sku: "MRC-203", name: "Logo T-shirt", category: "Merch", cost: 8, price: 28, reorderPoint: 6, lot: 30, deliveries: 1, weeklySales: 2.5, ending: "healthy" },
79
+ { key: "cap", sku: "MRC-204", name: "Corduroy Cap", category: "Merch", cost: 7, price: 26, reorderPoint: 4, lot: 20, deliveries: 1, weeklySales: 1.5, ending: "healthy" },
80
+
81
+ { key: "oat", sku: "DRY-300", name: "Oat Milk, 1L (case of 6)", category: "Dairy & Alternatives", cost: 11.4, price: 0, reorderPoint: 8, lot: 30, deliveries: 6, weeklySales: 14, ending: "healthy" },
82
+ { key: "whole", sku: "DRY-301", name: "Whole Milk, 2L (case of 4)", category: "Dairy & Alternatives", cost: 7.2, price: 0, reorderPoint: 10, lot: 40, deliveries: 8, weeklySales: 24, ending: "healthy" },
83
+ { key: "almond", sku: "DRY-302", name: "Almond Milk, 1L (case of 6)", category: "Dairy & Alternatives", cost: 12.6, price: 0, reorderPoint: 4, lot: 12, deliveries: 4, weeklySales: 5, ending: "low" },
84
+ { key: "syrup-vanilla", sku: "DRY-310", name: "Vanilla Syrup, 750ml", category: "Dairy & Alternatives", cost: 6.8, price: 0, reorderPoint: 4, lot: 12, deliveries: 3, weeklySales: 3, ending: "healthy" },
85
+ { key: "syrup-caramel", sku: "DRY-311", name: "Caramel Syrup, 750ml", category: "Dairy & Alternatives", cost: 6.8, price: 0, reorderPoint: 4, lot: 12, deliveries: 3, weeklySales: 3, ending: "healthy" },
68
86
  ];
69
87
 
88
+ const LEDGER_DAYS = 90;
89
+
90
+ /** Small deterministic generator so the ledger is the same on every run. */
91
+ function generator(seed: number): () => number {
92
+ let state = seed >>> 0 || 1;
93
+ return () => {
94
+ state ^= state << 13;
95
+ state ^= state >>> 17;
96
+ state ^= state << 5;
97
+ return (state >>> 0) / 0x1_0000_0000;
98
+ };
99
+ }
100
+
101
+ function hash(text: string): number {
102
+ let h = 2166136261;
103
+ for (let i = 0; i < text.length; i++) {
104
+ h ^= text.charCodeAt(i);
105
+ h = Math.imul(h, 16777619);
106
+ }
107
+ return h >>> 0;
108
+ }
109
+
110
+ /**
111
+ * Build one product's ledger: deliveries spread over the window, sales every
112
+ * few days between them, and the occasional return, breakage, or count. The
113
+ * final sale is sized so the product ends where its profile says it should.
114
+ */
115
+ function ledgerFor(product: SeedProduct, purchaseOrder: (age: number) => string): SeedMovement[] {
116
+ const rand = generator(hash(product.key));
117
+ const out: SeedMovement[] = [];
118
+ let onHand = 0;
119
+
120
+ const deliveryAges: number[] = [];
121
+ const gap = LEDGER_DAYS / product.deliveries;
122
+ for (let i = 0; i < product.deliveries; i++) {
123
+ deliveryAges.push(Math.round(LEDGER_DAYS - i * gap - rand() * gap * 0.4));
124
+ }
125
+
126
+ for (const age of deliveryAges) {
127
+ out.push({ product: product.key, delta: product.lot, reason: "received", note: purchaseOrder(age), age });
128
+ onHand += product.lot;
129
+ }
130
+
131
+ const salesPerEvent = Math.max(1, Math.round(product.weeklySales / 2));
132
+ const eventEvery = product.weeklySales >= 1 ? 3.5 : 7 / Math.max(product.weeklySales, 0.25);
133
+ const sales: SeedMovement[] = [];
134
+ for (let age = LEDGER_DAYS - 2; age >= 1; age -= eventEvery) {
135
+ const day = Math.round(age);
136
+ const received = out.filter((m) => m.reason === "received" && m.age >= day).reduce((sum, m) => sum + m.delta, 0);
137
+ const sold = sales.reduce((sum, m) => sum - m.delta, 0);
138
+ const available = received - sold;
139
+ if (available <= 0) continue;
140
+ const jitter = 0.6 + rand() * 0.8;
141
+ const qty = Math.min(available, Math.max(1, Math.round(salesPerEvent * jitter)));
142
+ sales.push({ product: product.key, delta: -qty, reason: "sold", age: day });
143
+ }
144
+ out.push(...sales);
145
+ onHand -= sales.reduce((sum, m) => sum - m.delta, 0);
146
+
147
+ if (product.price > 0 && rand() < 0.35) {
148
+ out.push({ product: product.key, delta: 1, reason: "returned", note: "Customer return", age: Math.round(5 + rand() * 30) });
149
+ onHand += 1;
150
+ }
151
+ if (rand() < 0.3 && onHand > 2) {
152
+ const broken = 1 + Math.round(rand());
153
+ out.push({ product: product.key, delta: -broken, reason: "damaged", note: rand() < 0.5 ? "Crushed in transit" : "Dropped in the stockroom", age: Math.round(3 + rand() * 40) });
154
+ onHand -= broken;
155
+ }
156
+ if (rand() < 0.4 && onHand > 3) {
157
+ const adjust = rand() < 0.5 ? -1 : 1;
158
+ out.push({ product: product.key, delta: adjust, reason: "count", note: "Cycle count adjustment", age: Math.round(2 + rand() * 20) });
159
+ onHand += adjust;
160
+ }
161
+
162
+ // Returns, breakage, and counts land at random dates, so a sale recorded
163
+ // before them may now overdraw. Walk the ledger in date order and trim any
164
+ // outgoing movement to what was actually on the shelf.
165
+ out.sort((a, b) => b.age - a.age);
166
+ let running = 0;
167
+ for (const m of out) {
168
+ if (running + m.delta < 0) m.delta = -running;
169
+ running += m.delta;
170
+ }
171
+ for (let i = out.length - 1; i >= 0; i--) if (out[i].delta === 0) out.splice(i, 1);
172
+ onHand = running;
173
+
174
+ const target =
175
+ product.ending === "out" ? 0 : product.ending === "low" ? Math.max(1, Math.floor(product.reorderPoint * 0.5)) : Math.round(product.reorderPoint * (1.6 + rand() * 1.5));
176
+ const diff = target - onHand;
177
+ if (diff < 0) {
178
+ out.push({ product: product.key, delta: diff, reason: "sold", age: 1 });
179
+ } else if (diff > 0) {
180
+ out.push({ product: product.key, delta: diff, reason: "received", note: purchaseOrder(1), age: 1 });
181
+ }
182
+
183
+ return out.sort((a, b) => b.age - a.age);
184
+ }
185
+
186
+ export const SEED_MOVEMENTS: SeedMovement[] = (() => {
187
+ const orders = new Map<number, string>();
188
+ let next = 4471;
189
+ const purchaseOrder = (age: number) => {
190
+ const week = Math.floor(age / 7);
191
+ let po = orders.get(week);
192
+ if (!po) {
193
+ po = `PO-${next++}`;
194
+ orders.set(week, po);
195
+ }
196
+ return po;
197
+ };
198
+ return SEED_PRODUCTS.flatMap((p) => ledgerFor(p, purchaseOrder));
199
+ })();
200
+
70
201
  export interface ShapedSeed {
71
202
  products: Array<{ key: string; row: Record<string, unknown> }>;
72
203
  movements: Array<{ product: string; row: Record<string, unknown> }>;
@@ -76,7 +207,7 @@ export function shapeSeed(now: number = Date.now()): ShapedSeed {
76
207
  const daysAgo = (days: number) => new Date(now - days * 86_400_000).toISOString();
77
208
 
78
209
  return {
79
- products: SEED_PRODUCTS.map((p) => ({
210
+ products: SEED_PRODUCTS.map((p, index) => ({
80
211
  key: p.key,
81
212
  row: {
82
213
  sku: p.sku,
@@ -86,7 +217,7 @@ export function shapeSeed(now: number = Date.now()): ShapedSeed {
86
217
  unitPriceCents: Math.round(p.price * 100),
87
218
  reorderPoint: p.reorderPoint,
88
219
  archived: false,
89
- createdAt: daysAgo(60),
220
+ createdAt: daysAgo(120 - index),
90
221
  },
91
222
  })),
92
223
  movements: SEED_MOVEMENTS.map((m) => ({
@@ -65,7 +65,7 @@ this pass before the user has to ask.
65
65
  - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
68
- - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
68
+ - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
69
69
 
70
70
  ## Testing
71
71
 
@@ -1,8 +1,8 @@
1
1
  // Demo data for a brand-new billing workspace.
2
2
  //
3
- // An empty invoice list shows you nothing about totals, ageing, or partial
4
- // payment. The first sign-in seeds a realistic book — including one invoice
5
- // already overdue and one part-paid — so those states are visible rather than
3
+ // The first sign-in seeds a working book: a dozen clients and half a year of
4
+ // invoices in every status. Several are overdue, a few are part-paid, and the
5
+ // most recent ones are still drafts, so those states are visible rather than
6
6
  // theoretical.
7
7
  //
8
8
  // Pure data + pure shaping; `functions/seedWorkspace.ts` stays a thin wrapper.
@@ -25,7 +25,6 @@ export interface SeedLine {
25
25
  export interface SeedInvoice {
26
26
  key: string;
27
27
  client: string;
28
- number: string;
29
28
  status: string;
30
29
  taxRateBps: number;
31
30
  /** Days from today; negative is in the past. */
@@ -35,93 +34,78 @@ export interface SeedInvoice {
35
34
  /** Dollars already received; 0 means nothing paid. */
36
35
  paid?: number;
37
36
  paidInDays?: number;
37
+ method?: string;
38
38
  }
39
39
 
40
40
  export const SEED_CLIENTS: SeedClient[] = [
41
- {
42
- key: "northwind",
43
- name: "Northwind Logistics",
44
- email: "ap@northwind.co",
45
- address: "412 Dock Road\nRotterdam, NL",
46
- },
47
- {
48
- key: "hallmark",
49
- name: "Hallmark Dental",
50
- email: "billing@hallmarkdental.com",
51
- address: "88 Mercer Street\nAustin, TX 78701",
52
- },
53
- {
54
- key: "riverbed",
55
- name: "Riverbed Coffee",
56
- email: "tom@riverbed.coffee",
57
- address: "5 Mill Lane\nPortland, OR 97209",
58
- },
59
- {
60
- key: "quarry",
61
- name: "Quarry Design Co",
62
- email: "ben@quarry.design",
63
- address: "19 Foundry Street\nManchester, UK",
64
- },
41
+ { key: "northwind", name: "Northwind Logistics", email: "ap@northwind.co", address: "412 Dock Road\nRotterdam, NL" },
42
+ { key: "hallmark", name: "Hallmark Dental", email: "billing@hallmarkdental.com", address: "88 Mercer Street\nAustin, TX 78701" },
43
+ { key: "riverbed", name: "Riverbed Coffee", email: "tom@riverbed.coffee", address: "5 Mill Lane\nPortland, OR 97209" },
44
+ { key: "quarry", name: "Quarry Design Co", email: "ben@quarry.design", address: "19 Foundry Street\nManchester, UK" },
45
+ { key: "larkspur", name: "Larkspur Clinics", email: "accounts@larkspurclinics.com", address: "1200 Westgate Avenue\nDenver, CO 80204" },
46
+ { key: "meridian", name: "Meridian Freight", email: "payables@meridianfreight.com", address: "7 Harbour Way\nSouthampton, UK" },
47
+ { key: "kestrel", name: "Kestrel Aviation Services", email: "finance@kestrelaviation.com", address: "Hangar 4, Field Road\nWichita, KS 67209" },
48
+ { key: "orchard", name: "Orchard Learning", email: "finance@orchardlearning.org", address: "30 Chapel Street\nBristol, UK" },
49
+ { key: "bluefin", name: "Bluefin Analytics", email: "ap@bluefin.io", address: "550 Bryant Street\nSan Francisco, CA 94107" },
50
+ { key: "harbor", name: "Harbor Point Hotels", email: "invoices@harborpointhotels.com", address: "2 Seaport Boulevard\nBoston, MA 02210" },
51
+ { key: "cobalt", name: "Cobalt Staffing", email: "accounts@cobaltstaffing.com", address: "14 King Street West\nToronto, ON M5H 1A1" },
52
+ { key: "vantage", name: "Vantage Solar", email: "ap@vantagesolar.com", address: "900 Solar Drive\nPhoenix, AZ 85004" },
65
53
  ];
66
54
 
55
+ const ENGINEERING = "Senior engineering";
56
+ const DESIGN = "Product design";
57
+ const SUPPORT = "Support retainer";
58
+
67
59
  export const SEED_INVOICES: SeedInvoice[] = [
68
- // Overdue: sent, past due, nothing paid.
69
- {
70
- key: "overdue",
71
- client: "northwind",
72
- number: "INV-2026-0001",
73
- status: "sent",
74
- taxRateBps: 0,
75
- issuedInDays: -52,
76
- dueInDays: -22,
77
- lines: [
78
- { description: "Dispatch integration — discovery", quantity: 1, unitPrice: 4800 },
79
- { description: "Senior engineering", quantity: 32, unitPrice: 165 },
80
- ],
81
- },
82
- // Part-paid: a payment against a larger balance.
83
- {
84
- key: "partial",
85
- client: "hallmark",
86
- number: "INV-2026-0002",
87
- status: "sent",
88
- taxRateBps: 875,
89
- issuedInDays: -18,
90
- dueInDays: 12,
91
- lines: [
92
- { description: "Patient intake portal — build", quantity: 1, unitPrice: 12_400 },
93
- { description: "Training session", quantity: 2, unitPrice: 450 },
94
- ],
95
- paid: 5000,
96
- paidInDays: -6,
97
- },
98
- // Settled.
99
- {
100
- key: "paid",
101
- client: "riverbed",
102
- number: "INV-2026-0003",
103
- status: "paid",
104
- taxRateBps: 0,
105
- issuedInDays: -40,
106
- dueInDays: -10,
107
- lines: [{ description: "Inventory sync — monthly", quantity: 1, unitPrice: 1800 }],
108
- paid: 1800,
109
- paidInDays: -12,
110
- },
60
+ // Six months ago: all settled.
61
+ { key: "i01", client: "kestrel", status: "paid", taxRateBps: 0, issuedInDays: -180, dueInDays: -150, lines: [{ description: "Parts inventory system, phase 1", quantity: 1, unitPrice: 18_000 }, { description: ENGINEERING, quantity: 40, unitPrice: 165 }], paid: 24_600, paidInDays: -158, method: "bank" },
62
+ { key: "i02", client: "cobalt", status: "paid", taxRateBps: 1300, issuedInDays: -176, dueInDays: -146, lines: [{ description: "Timesheet approvals, build", quantity: 1, unitPrice: 9_800 }], paid: 11_074, paidInDays: -140, method: "card" },
63
+ { key: "i03", client: "riverbed", status: "paid", taxRateBps: 0, issuedInDays: -170, dueInDays: -140, lines: [{ description: "Inventory sync, monthly", quantity: 1, unitPrice: 1_800 }], paid: 1_800, paidInDays: -138, method: "bank" },
64
+ { key: "i04", client: "orchard", status: "paid", taxRateBps: 2000, issuedInDays: -165, dueInDays: -135, lines: [{ description: "Enrolment forms", quantity: 1, unitPrice: 6_400 }, { description: "Training session", quantity: 1, unitPrice: 450 }], paid: 8_220, paidInDays: -130, method: "bank" },
65
+
66
+ // Five months ago.
67
+ { key: "i05", client: "kestrel", status: "paid", taxRateBps: 0, issuedInDays: -150, dueInDays: -120, lines: [{ description: "Parts inventory system, phase 2", quantity: 1, unitPrice: 16_500 }, { description: ENGINEERING, quantity: 24, unitPrice: 165 }], paid: 20_460, paidInDays: -118, method: "bank" },
68
+ { key: "i06", client: "riverbed", status: "paid", taxRateBps: 0, issuedInDays: -140, dueInDays: -110, lines: [{ description: "Inventory sync, monthly", quantity: 1, unitPrice: 1_800 }], paid: 1_800, paidInDays: -111, method: "bank" },
69
+ { key: "i07", client: "bluefin", status: "paid", taxRateBps: 0, issuedInDays: -138, dueInDays: -108, lines: [{ description: "Reporting discovery workshop", quantity: 2, unitPrice: 2_400 }], paid: 4_800, paidInDays: -100, method: "card" },
70
+ { key: "i08", client: "vantage", status: "paid", taxRateBps: 860, issuedInDays: -132, dueInDays: -102, lines: [{ description: "Site survey app, build", quantity: 1, unitPrice: 14_200 }, { description: DESIGN, quantity: 18, unitPrice: 140 }], paid: 18_149.52, paidInDays: -95, method: "bank" },
71
+ { key: "i09", client: "larkspur", status: "void", taxRateBps: 0, issuedInDays: -128, dueInDays: -98, lines: [{ description: "Referral tracking, phase 1", quantity: 1, unitPrice: 12_000 }] },
72
+
73
+ // Four months ago.
74
+ { key: "i10", client: "larkspur", status: "paid", taxRateBps: 0, issuedInDays: -126, dueInDays: -96, lines: [{ description: "Referral tracking, phase 1 (reissued)", quantity: 1, unitPrice: 12_000 }, { description: ENGINEERING, quantity: 30, unitPrice: 165 }], paid: 16_950, paidInDays: -90, method: "bank" },
75
+ { key: "i11", client: "riverbed", status: "paid", taxRateBps: 0, issuedInDays: -110, dueInDays: -80, lines: [{ description: "Inventory sync, monthly", quantity: 1, unitPrice: 1_800 }], paid: 1_800, paidInDays: -82, method: "bank" },
76
+ { key: "i12", client: "meridian", status: "paid", taxRateBps: 2000, issuedInDays: -105, dueInDays: -75, lines: [{ description: "Carrier onboarding, discovery", quantity: 1, unitPrice: 7_500 }], paid: 9_000, paidInDays: -60, method: "bank" },
77
+ { key: "i13", client: "harbor", status: "paid", taxRateBps: 625, issuedInDays: -100, dueInDays: -70, lines: [{ description: "Guest messaging pilot, setup", quantity: 1, unitPrice: 11_000 }, { description: "SMS credits", quantity: 5000, unitPrice: 0.04 }], paid: 11_900, paidInDays: -68, method: "bank" },
78
+ { key: "i14", client: "cobalt", status: "paid", taxRateBps: 1300, issuedInDays: -96, dueInDays: -66, lines: [{ description: SUPPORT, quantity: 1, unitPrice: 1_200 }], paid: 1_356, paidInDays: -64, method: "card" },
79
+
80
+ // Three months ago.
81
+ { key: "i15", client: "larkspur", status: "paid", taxRateBps: 0, issuedInDays: -92, dueInDays: -62, lines: [{ description: "Referral tracking, phase 2", quantity: 1, unitPrice: 9_500 }, { description: ENGINEERING, quantity: 22, unitPrice: 165 }], paid: 13_130, paidInDays: -55, method: "bank" },
82
+ { key: "i16", client: "riverbed", status: "paid", taxRateBps: 0, issuedInDays: -80, dueInDays: -50, lines: [{ description: "Inventory sync, monthly", quantity: 1, unitPrice: 1_800 }], paid: 1_800, paidInDays: -52, method: "bank" },
83
+ { key: "i17", client: "bluefin", status: "paid", taxRateBps: 0, issuedInDays: -78, dueInDays: -48, lines: [{ description: "Embedded reporting, prototype", quantity: 1, unitPrice: 8_800 }, { description: DESIGN, quantity: 12, unitPrice: 140 }], paid: 10_480, paidInDays: -40, method: "bank" },
84
+ { key: "i18", client: "vantage", status: "paid", taxRateBps: 860, issuedInDays: -74, dueInDays: -44, lines: [{ description: SUPPORT, quantity: 1, unitPrice: 1_500 }], paid: 1_629, paidInDays: -42, method: "card" },
85
+ { key: "i19", client: "harbor", status: "sent", taxRateBps: 625, issuedInDays: -70, dueInDays: -40, lines: [{ description: "Guest messaging pilot, month 1", quantity: 1, unitPrice: 4_500 }, { description: "SMS credits", quantity: 12000, unitPrice: 0.04 }] },
86
+
87
+ // Two months ago. The first overdue invoices start here.
88
+ { key: "i20", client: "northwind", status: "sent", taxRateBps: 0, issuedInDays: -52, dueInDays: -22, lines: [{ description: "Dispatch integration, discovery", quantity: 1, unitPrice: 4_800 }, { description: ENGINEERING, quantity: 32, unitPrice: 165 }] },
89
+ { key: "i21", client: "meridian", status: "sent", taxRateBps: 2000, issuedInDays: -50, dueInDays: -20, lines: [{ description: "Carrier onboarding, build sprint 1", quantity: 1, unitPrice: 14_000 }, { description: ENGINEERING, quantity: 16, unitPrice: 165 }], paid: 10_000, paidInDays: -15, method: "bank" },
90
+ { key: "i22", client: "riverbed", status: "sent", taxRateBps: 0, issuedInDays: -50, dueInDays: -20, lines: [{ description: "Inventory sync, monthly", quantity: 1, unitPrice: 1_800 }] },
91
+ { key: "i23", client: "cobalt", status: "paid", taxRateBps: 1300, issuedInDays: -48, dueInDays: -18, lines: [{ description: SUPPORT, quantity: 1, unitPrice: 1_200 }], paid: 1_356, paidInDays: -20, method: "card" },
92
+ { key: "i24", client: "quarry", status: "paid", taxRateBps: 2000, issuedInDays: -45, dueInDays: -15, lines: [{ description: "Client portal, discovery", quantity: 1, unitPrice: 3_200 }], paid: 3_840, paidInDays: -16, method: "bank" },
93
+ { key: "i25", client: "orchard", status: "sent", taxRateBps: 2000, issuedInDays: -40, dueInDays: -10, lines: [{ description: "Parent communication hub, pilot", quantity: 1, unitPrice: 7_800 }, { description: "Training session", quantity: 2, unitPrice: 450 }] },
94
+
95
+ // This month.
96
+ { key: "i26", client: "hallmark", status: "sent", taxRateBps: 875, issuedInDays: -18, dueInDays: 12, lines: [{ description: "Patient intake portal, build", quantity: 1, unitPrice: 12_400 }, { description: "Training session", quantity: 2, unitPrice: 450 }], paid: 5_000, paidInDays: -6, method: "bank" },
97
+ { key: "i27", client: "riverbed", status: "sent", taxRateBps: 0, issuedInDays: -20, dueInDays: 10, lines: [{ description: "Inventory sync, monthly", quantity: 1, unitPrice: 1_800 }] },
98
+ { key: "i28", client: "bluefin", status: "sent", taxRateBps: 0, issuedInDays: -14, dueInDays: 16, lines: [{ description: "Embedded reporting, sprint 1", quantity: 1, unitPrice: 12_000 }, { description: DESIGN, quantity: 20, unitPrice: 140 }] },
99
+ { key: "i29", client: "vantage", status: "sent", taxRateBps: 860, issuedInDays: -12, dueInDays: 18, lines: [{ description: SUPPORT, quantity: 1, unitPrice: 1_500 }, { description: "Installer scheduling, discovery", quantity: 1, unitPrice: 2_800 }] },
100
+ { key: "i30", client: "kestrel", status: "sent", taxRateBps: 0, issuedInDays: -9, dueInDays: 21, lines: [{ description: "Maintenance log, discovery", quantity: 1, unitPrice: 6_000 }, { description: ENGINEERING, quantity: 12, unitPrice: 165 }] },
101
+ { key: "i31", client: "harbor", status: "sent", taxRateBps: 625, issuedInDays: -8, dueInDays: 22, lines: [{ description: "Guest messaging pilot, month 2", quantity: 1, unitPrice: 4_500 }, { description: "SMS credits", quantity: 15000, unitPrice: 0.04 }] },
102
+ { key: "i32", client: "cobalt", status: "sent", taxRateBps: 1300, issuedInDays: -5, dueInDays: 25, lines: [{ description: SUPPORT, quantity: 1, unitPrice: 1_200 }, { description: "Candidate dashboard, discovery", quantity: 1, unitPrice: 3_600 }] },
103
+
111
104
  // Still being written.
112
- {
113
- key: "draft",
114
- client: "quarry",
115
- number: "INV-2026-0004",
116
- status: "draft",
117
- taxRateBps: 2000,
118
- issuedInDays: 0,
119
- dueInDays: 30,
120
- lines: [
121
- { description: "Client portal — phase 1", quantity: 1, unitPrice: 9500 },
122
- { description: "SSO configuration", quantity: 4, unitPrice: 180 },
123
- ],
124
- },
105
+ { key: "i33", client: "quarry", status: "draft", taxRateBps: 2000, issuedInDays: 0, dueInDays: 30, lines: [{ description: "Client portal, phase 1", quantity: 1, unitPrice: 9_500 }, { description: "SSO configuration", quantity: 4, unitPrice: 180 }] },
106
+ { key: "i34", client: "northwind", status: "draft", taxRateBps: 0, issuedInDays: 0, dueInDays: 30, lines: [{ description: "Fleet dispatch rollout, sprint 1", quantity: 1, unitPrice: 16_000 }, { description: ENGINEERING, quantity: 36, unitPrice: 165 }] },
107
+ { key: "i35", client: "meridian", status: "draft", taxRateBps: 2000, issuedInDays: 0, dueInDays: 30, lines: [{ description: "Carrier onboarding, build sprint 2", quantity: 1, unitPrice: 14_000 }] },
108
+ { key: "i36", client: "larkspur", status: "draft", taxRateBps: 0, issuedInDays: 0, dueInDays: 30, lines: [{ description: "Multi-site scheduling, discovery", quantity: 1, unitPrice: 8_500 }, { description: "Site visits", quantity: 3, unitPrice: 600 }] },
125
109
  ];
126
110
 
127
111
  export interface ShapedSeed {
@@ -133,6 +117,7 @@ export interface ShapedSeed {
133
117
 
134
118
  export function shapeSeed(now: number = Date.now()): ShapedSeed {
135
119
  const at = (days: number) => new Date(now + days * 86_400_000).toISOString();
120
+ const year = new Date(now).getUTCFullYear();
136
121
 
137
122
  const lines: ShapedSeed["lines"] = [];
138
123
  const payments: ShapedSeed["payments"] = [];
@@ -154,7 +139,7 @@ export function shapeSeed(now: number = Date.now()): ShapedSeed {
154
139
  invoice: invoice.key,
155
140
  row: {
156
141
  amountCents: Math.round(invoice.paid * 100),
157
- method: "bank",
142
+ method: invoice.method ?? "bank",
158
143
  paidAt: at(invoice.paidInDays ?? 0),
159
144
  },
160
145
  });
@@ -162,20 +147,20 @@ export function shapeSeed(now: number = Date.now()): ShapedSeed {
162
147
  }
163
148
 
164
149
  return {
165
- clients: SEED_CLIENTS.map((c) => ({
150
+ clients: SEED_CLIENTS.map((c, index) => ({
166
151
  key: c.key,
167
152
  row: {
168
153
  name: c.name,
169
154
  email: c.email,
170
155
  address: c.address,
171
- createdAt: at(-90),
156
+ createdAt: at(-200 + index * 12),
172
157
  },
173
158
  })),
174
- invoices: SEED_INVOICES.map((i) => ({
159
+ invoices: SEED_INVOICES.map((i, index) => ({
175
160
  key: i.key,
176
161
  client: i.client,
177
162
  row: {
178
- number: i.number,
163
+ number: `INV-${year}-${String(index + 1).padStart(4, "0")}`,
179
164
  status: i.status,
180
165
  taxRateBps: i.taxRateBps,
181
166
  issueDate: at(i.issuedInDays),
@@ -65,7 +65,7 @@ this pass before the user has to ask.
65
65
  - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
68
- - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
68
+ - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
69
69
 
70
70
  ## Testing
71
71