@rubytech/create-sitedesk-code 0.1.563 → 0.1.564

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 (182) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +20 -1
  3. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +6 -0
  4. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +118 -0
  5. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +15 -0
  6. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +25 -7
  7. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +62 -5
  8. package/payload/platform/plugins/docs/references/telegram-guide.md +19 -0
  9. package/payload/platform/plugins/memory/references/schema-construction.md +1 -1
  10. package/payload/premium-plugins/sitedesk/agents/sitedesk--payroll-clerk.md +2 -2
  11. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/skills/timesheet/SKILL.md +5 -5
  12. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/skills/timesheet/references/checkin-contract.md +40 -22
  13. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/skills/timesheet/references/roll-up.md +20 -7
  14. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/skills/timesheet/references/setup.md +7 -3
  15. package/payload/server/{chunk-G6ULACHG.js → chunk-3ABITJWB.js} +8 -4
  16. package/payload/server/{manager-2VEJKVEO.js → manager-N2RVJN7T.js} +1 -1
  17. package/payload/server/public/activity.html +5 -5
  18. package/payload/server/public/agents.html +4 -4
  19. package/payload/server/public/assets/{AdminLoginScreens-C6e5jf0S.js → AdminLoginScreens-5gI137xF.js} +1 -1
  20. package/payload/server/public/assets/AdminLoginScreens-5gI137xF.js.br +0 -0
  21. package/payload/server/public/assets/AdminLoginScreens-5gI137xF.js.gz +0 -0
  22. package/payload/server/public/assets/{AdminShell-DStA0lfm.js → AdminShell-Bfapavlp.js} +2 -2
  23. package/payload/server/public/assets/AdminShell-Bfapavlp.js.br +0 -0
  24. package/payload/server/public/assets/AdminShell-Bfapavlp.js.gz +0 -0
  25. package/payload/server/public/assets/{activity--yyBcNfB.js → activity-DnbsmUgn.js} +1 -1
  26. package/payload/server/public/assets/activity-DnbsmUgn.js.br +0 -0
  27. package/payload/server/public/assets/activity-DnbsmUgn.js.gz +0 -0
  28. package/payload/server/public/assets/admin-BXIv0_oj.js +1 -0
  29. package/payload/server/public/assets/admin-BXIv0_oj.js.br +0 -0
  30. package/payload/server/public/assets/admin-BXIv0_oj.js.gz +0 -0
  31. package/payload/server/public/assets/{agents-odyVOb5E.js → agents-Dl2wOcpj.js} +1 -1
  32. package/payload/server/public/assets/agents-Dl2wOcpj.js.br +0 -0
  33. package/payload/server/public/assets/agents-Dl2wOcpj.js.gz +0 -0
  34. package/payload/server/public/assets/{browser-D1K_MoHt.js → browser-D-Uc9ewO.js} +1 -1
  35. package/payload/server/public/assets/browser-D-Uc9ewO.js.br +0 -0
  36. package/payload/server/public/assets/browser-D-Uc9ewO.js.gz +0 -0
  37. package/payload/server/public/assets/{calendar-jqh9U7Ic.js → calendar-Cu3O6Bay.js} +1 -1
  38. package/payload/server/public/assets/calendar-Cu3O6Bay.js.br +0 -0
  39. package/payload/server/public/assets/calendar-Cu3O6Bay.js.gz +0 -0
  40. package/payload/server/public/assets/chat-Bso94e-f.js +1 -0
  41. package/payload/server/public/assets/chat-Bso94e-f.js.br +0 -0
  42. package/payload/server/public/assets/chat-Bso94e-f.js.gz +0 -0
  43. package/payload/server/public/assets/chevron-left-CDwfR9V-.js +1 -0
  44. package/payload/server/public/assets/chevron-left-CDwfR9V-.js.br +0 -0
  45. package/payload/server/public/assets/clock-DDVrNLt9.js +1 -0
  46. package/payload/server/public/assets/clock-DDVrNLt9.js.br +0 -0
  47. package/payload/server/public/assets/clock-DDVrNLt9.js.gz +0 -0
  48. package/payload/server/public/assets/{copy-DkuHwkaB.js → copy-DPBVV8KZ.js} +1 -1
  49. package/payload/server/public/assets/copy-DPBVV8KZ.js.br +0 -0
  50. package/payload/server/public/assets/copy-DPBVV8KZ.js.gz +0 -0
  51. package/payload/server/public/assets/data-Dei8n2EX.js +1 -0
  52. package/payload/server/public/assets/data-Dei8n2EX.js.br +0 -0
  53. package/payload/server/public/assets/data-Dei8n2EX.js.gz +0 -0
  54. package/payload/server/public/assets/{file-text-C6o_NRjj.js → file-text-Dn82O6ke.js} +1 -1
  55. package/payload/server/public/assets/file-text-Dn82O6ke.js.br +0 -0
  56. package/payload/server/public/assets/file-text-Dn82O6ke.js.gz +0 -0
  57. package/payload/server/public/assets/{graph-BN30v5hJ.js → graph-D2J7a7Ll.js} +1 -1
  58. package/payload/server/public/assets/graph-D2J7a7Ll.js.br +0 -0
  59. package/payload/server/public/assets/{graph-BN30v5hJ.js.gz → graph-D2J7a7Ll.js.gz} +0 -0
  60. package/payload/server/public/assets/{graph-labels-DHFyNXQt.js → graph-labels-D3gkY2IH.js} +1 -1
  61. package/payload/server/public/assets/graph-labels-D3gkY2IH.js.br +0 -0
  62. package/payload/server/public/assets/graph-labels-D3gkY2IH.js.gz +0 -0
  63. package/payload/server/public/assets/{operator-DwFYA4Fp.js → operator-Bz_yj1im.js} +1 -1
  64. package/payload/server/public/assets/operator-Bz_yj1im.js.br +0 -0
  65. package/payload/server/public/assets/operator-Bz_yj1im.js.gz +0 -0
  66. package/payload/server/public/assets/page-D6u2xYh7.js +1 -0
  67. package/payload/server/public/assets/page-D6u2xYh7.js.br +0 -0
  68. package/payload/server/public/assets/page-D6u2xYh7.js.gz +0 -0
  69. package/payload/server/public/assets/{page-CEWIP3VF.js → page-mH4nyQ75.js} +2 -2
  70. package/payload/server/public/assets/page-mH4nyQ75.js.br +0 -0
  71. package/payload/server/public/assets/page-mH4nyQ75.js.gz +0 -0
  72. package/payload/server/public/assets/play-ByuChQJJ.js +1 -0
  73. package/payload/server/public/assets/play-ByuChQJJ.js.br +0 -0
  74. package/payload/server/public/assets/play-ByuChQJJ.js.gz +0 -0
  75. package/payload/server/public/assets/{public-Ceb0n7Km.js → public-CtoYVjPR.js} +1 -1
  76. package/payload/server/public/assets/public-CtoYVjPR.js.br +0 -0
  77. package/payload/server/public/assets/public-CtoYVjPR.js.gz +0 -0
  78. package/payload/server/public/assets/{rotate-ccw-CDYdx91V.js → rotate-ccw-BXoIghE3.js} +1 -1
  79. package/payload/server/public/assets/rotate-ccw-BXoIghE3.js.br +0 -0
  80. package/payload/server/public/assets/rotate-ccw-BXoIghE3.js.gz +0 -0
  81. package/payload/server/public/assets/{routines-DwNTih0y.js → routines-DXleYpDv.js} +1 -1
  82. package/payload/server/public/assets/routines-DXleYpDv.js.br +0 -0
  83. package/payload/server/public/assets/routines-DXleYpDv.js.gz +0 -0
  84. package/payload/server/public/assets/{skills-TIDCdOLQ.js → skills-CMl38MKH.js} +1 -1
  85. package/payload/server/public/assets/skills-CMl38MKH.js.br +0 -0
  86. package/payload/server/public/assets/skills-CMl38MKH.js.gz +0 -0
  87. package/payload/server/public/assets/{tasks-eTXvaWnd.js → tasks-Cshaf0Md.js} +1 -1
  88. package/payload/server/public/assets/tasks-Cshaf0Md.js.br +0 -0
  89. package/payload/server/public/assets/tasks-Cshaf0Md.js.gz +0 -0
  90. package/payload/server/public/assets/{triangle-alert-JwLSrj17.js → triangle-alert-CS-NXr_9.js} +1 -1
  91. package/payload/server/public/assets/triangle-alert-CS-NXr_9.js.br +3 -0
  92. package/payload/server/public/assets/triangle-alert-CS-NXr_9.js.gz +0 -0
  93. package/payload/server/public/assets/{useCopyFeedback-DjTkNw2p.js → useCopyFeedback-pKLfQJNG.js} +1 -1
  94. package/payload/server/public/assets/useCopyFeedback-pKLfQJNG.js.br +0 -0
  95. package/payload/server/public/assets/useCopyFeedback-pKLfQJNG.js.gz +0 -0
  96. package/payload/server/public/assets/useMediaQuery-BHFn0-S9.css +1 -0
  97. package/payload/server/public/assets/useMediaQuery-BHFn0-S9.css.br +0 -0
  98. package/payload/server/public/assets/useMediaQuery-BHFn0-S9.css.gz +0 -0
  99. package/payload/server/public/assets/{useMediaQuery-D8VDA0RN.js → useMediaQuery-oDCasnxD.js} +1 -1
  100. package/payload/server/public/assets/useMediaQuery-oDCasnxD.js.br +0 -0
  101. package/payload/server/public/assets/{useMediaQuery-D8VDA0RN.js.gz → useMediaQuery-oDCasnxD.js.gz} +0 -0
  102. package/payload/server/public/assets/useVoiceRecorder-EgO1GmIg.js +2 -0
  103. package/payload/server/public/assets/useVoiceRecorder-EgO1GmIg.js.br +0 -0
  104. package/payload/server/public/assets/useVoiceRecorder-EgO1GmIg.js.gz +0 -0
  105. package/payload/server/public/assets/{wrench-DutUiRu7.js → wrench-czES9Rdy.js} +1 -1
  106. package/payload/server/public/assets/wrench-czES9Rdy.js.br +0 -0
  107. package/payload/server/public/assets/wrench-czES9Rdy.js.gz +0 -0
  108. package/payload/server/public/browser.html +4 -4
  109. package/payload/server/public/calendar.html +7 -7
  110. package/payload/server/public/chat.html +13 -13
  111. package/payload/server/public/data.html +11 -11
  112. package/payload/server/public/graph.html +9 -9
  113. package/payload/server/public/index.html +13 -13
  114. package/payload/server/public/operator.html +14 -14
  115. package/payload/server/public/public.html +13 -13
  116. package/payload/server/public/routines.html +7 -7
  117. package/payload/server/public/skills.html +5 -5
  118. package/payload/server/public/tasks.html +6 -6
  119. package/payload/server/server.js +2448 -1667
  120. package/payload/server/public/assets/AdminLoginScreens-C6e5jf0S.js.br +0 -0
  121. package/payload/server/public/assets/AdminLoginScreens-C6e5jf0S.js.gz +0 -0
  122. package/payload/server/public/assets/AdminShell-DStA0lfm.js.br +0 -0
  123. package/payload/server/public/assets/AdminShell-DStA0lfm.js.gz +0 -0
  124. package/payload/server/public/assets/activity--yyBcNfB.js.br +0 -0
  125. package/payload/server/public/assets/activity--yyBcNfB.js.gz +0 -0
  126. package/payload/server/public/assets/admin-Dr6Jwe7W.js +0 -1
  127. package/payload/server/public/assets/admin-Dr6Jwe7W.js.br +0 -0
  128. package/payload/server/public/assets/admin-Dr6Jwe7W.js.gz +0 -0
  129. package/payload/server/public/assets/agents-odyVOb5E.js.br +0 -0
  130. package/payload/server/public/assets/agents-odyVOb5E.js.gz +0 -0
  131. package/payload/server/public/assets/browser-D1K_MoHt.js.br +0 -0
  132. package/payload/server/public/assets/browser-D1K_MoHt.js.gz +0 -0
  133. package/payload/server/public/assets/calendar-jqh9U7Ic.js.br +0 -0
  134. package/payload/server/public/assets/calendar-jqh9U7Ic.js.gz +0 -0
  135. package/payload/server/public/assets/chat-QQHBdVor.js +0 -1
  136. package/payload/server/public/assets/chat-QQHBdVor.js.br +0 -0
  137. package/payload/server/public/assets/chat-QQHBdVor.js.gz +0 -0
  138. package/payload/server/public/assets/chevron-left-DwTXgKwr.js +0 -1
  139. package/payload/server/public/assets/clock-DOmh9iKX.js +0 -1
  140. package/payload/server/public/assets/clock-DOmh9iKX.js.br +0 -0
  141. package/payload/server/public/assets/copy-DkuHwkaB.js.br +0 -0
  142. package/payload/server/public/assets/copy-DkuHwkaB.js.gz +0 -0
  143. package/payload/server/public/assets/data-DYmTNqrb.js +0 -1
  144. package/payload/server/public/assets/data-DYmTNqrb.js.br +0 -0
  145. package/payload/server/public/assets/data-DYmTNqrb.js.gz +0 -0
  146. package/payload/server/public/assets/file-text-C6o_NRjj.js.br +0 -0
  147. package/payload/server/public/assets/file-text-C6o_NRjj.js.gz +0 -0
  148. package/payload/server/public/assets/graph-BN30v5hJ.js.br +0 -0
  149. package/payload/server/public/assets/graph-labels-DHFyNXQt.js.br +0 -0
  150. package/payload/server/public/assets/graph-labels-DHFyNXQt.js.gz +0 -0
  151. package/payload/server/public/assets/operator-DwFYA4Fp.js.br +0 -0
  152. package/payload/server/public/assets/operator-DwFYA4Fp.js.gz +0 -0
  153. package/payload/server/public/assets/page-BALVRssF.js +0 -1
  154. package/payload/server/public/assets/page-BALVRssF.js.br +0 -0
  155. package/payload/server/public/assets/page-BALVRssF.js.gz +0 -0
  156. package/payload/server/public/assets/page-CEWIP3VF.js.br +0 -0
  157. package/payload/server/public/assets/page-CEWIP3VF.js.gz +0 -0
  158. package/payload/server/public/assets/play-Bg-BY_oj.js +0 -1
  159. package/payload/server/public/assets/play-Bg-BY_oj.js.br +0 -0
  160. package/payload/server/public/assets/play-Bg-BY_oj.js.gz +0 -0
  161. package/payload/server/public/assets/public-Ceb0n7Km.js.br +0 -0
  162. package/payload/server/public/assets/public-Ceb0n7Km.js.gz +0 -0
  163. package/payload/server/public/assets/rotate-ccw-CDYdx91V.js.gz +0 -0
  164. package/payload/server/public/assets/routines-DwNTih0y.js.br +0 -0
  165. package/payload/server/public/assets/routines-DwNTih0y.js.gz +0 -0
  166. package/payload/server/public/assets/skills-TIDCdOLQ.js.br +0 -0
  167. package/payload/server/public/assets/skills-TIDCdOLQ.js.gz +0 -0
  168. package/payload/server/public/assets/tasks-eTXvaWnd.js.br +0 -0
  169. package/payload/server/public/assets/tasks-eTXvaWnd.js.gz +0 -0
  170. package/payload/server/public/assets/triangle-alert-JwLSrj17.js.br +0 -0
  171. package/payload/server/public/assets/triangle-alert-JwLSrj17.js.gz +0 -0
  172. package/payload/server/public/assets/useCopyFeedback-DjTkNw2p.js.br +0 -0
  173. package/payload/server/public/assets/useCopyFeedback-DjTkNw2p.js.gz +0 -0
  174. package/payload/server/public/assets/useMediaQuery-BsBzbWaf.css +0 -1
  175. package/payload/server/public/assets/useMediaQuery-BsBzbWaf.css.br +0 -0
  176. package/payload/server/public/assets/useMediaQuery-BsBzbWaf.css.gz +0 -0
  177. package/payload/server/public/assets/useMediaQuery-D8VDA0RN.js.br +0 -0
  178. package/payload/server/public/assets/useVoiceRecorder-B8vxObZG.js +0 -2
  179. package/payload/server/public/assets/useVoiceRecorder-B8vxObZG.js.br +0 -0
  180. package/payload/server/public/assets/useVoiceRecorder-B8vxObZG.js.gz +0 -0
  181. package/payload/server/public/assets/wrench-DutUiRu7.js.br +0 -0
  182. package/payload/server/public/assets/wrench-DutUiRu7.js.gz +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-sitedesk-code",
3
- "version": "0.1.563",
3
+ "version": "0.1.564",
4
4
  "description": "Install SiteDesk — automated back office for independent building contractors",
5
5
  "bin": {
6
6
  "create-sitedesk-code": "./dist/index.js"
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what SiteDesk ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:b32b1b25459a9e306afca5d26363b21f184712e4260a08ebe8a0107b40b1f3da
4
+ content-hash: sha256:7abf513cef95e198c855d6852580802593dbcb9ee5234b028916c03917d2afab
5
5
  brand: sitedesk-code
6
6
  product-name: SiteDesk
7
7
  ---
@@ -2544,6 +2544,25 @@ Ask SiteDesk: "What messages has the bot received?" or "Show recent Telegram act
2544
2544
 
2545
2545
  **Messages not arriving:** Make sure the bot hasn't been blocked. Try sending `/start` to the bot directly.
2546
2546
 
2547
+ ## Sending from the dashboard
2548
+
2549
+ Open a Telegram conversation in the dashboard and you can type a reply straight
2550
+ into it, the same way you already can with WhatsApp. The message goes out from
2551
+ your bot.
2552
+
2553
+ Two things to know:
2554
+
2555
+ - **Direct messages only.** Group and supergroup chats show no message box. A
2556
+ Telegram conversation is filed under the person who spoke, so a message sent
2557
+ from a group thread would reach that one person privately rather than the
2558
+ group — the box is hidden rather than doing something you did not intend.
2559
+ - **History starts now.** Messages are only shown from the point this feature
2560
+ was installed onward. A conversation that was already running looks empty
2561
+ until the next message arrives, and then fills up from there.
2562
+
2563
+ Text only for now. Photos, files and voice notes are not available on Telegram
2564
+ from the dashboard.
2565
+
2547
2566
  ---
2548
2567
  # Outlook
2549
2568
  Source: https://docs.getmaxy.com/outlook-guide.md
@@ -9,6 +9,12 @@ Invoked by the admin agent directly.
9
9
 
10
10
  This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
11
11
 
12
+ ## 2026-08-05 (0.1.564)
13
+
14
+ - You can now reply to a Telegram conversation from the dashboard. Telegram was read-only, so a conversation you could watch was one you could not answer; it now has a composer like WhatsApp. Sending is restricted to private chats.
15
+ - An arriving Telegram message now raises a card and a desktop notification, the same as WhatsApp. Until now it arrived silently on every admin surface.
16
+ - A portal job sheet now records time, so a day's labour reaches the timesheet. Field work could be marked done but carried no hours and wrote no labour cost at either end.
17
+
12
18
  ## 2026-08-05 (0.1.563)
13
19
 
14
20
  - You can now update from the dashboard. The installed version sits in the sidebar header rather than buried in the footer menu, and when a newer one is available the control there starts the update and shows its progress live. Previously the only route was asking the assistant in chat, and an available update was invisible until you opened the menu.
@@ -1,6 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import {
3
+ MAX_NOTE_LENGTH,
3
4
  MAX_STATUS_LENGTH,
5
+ WORK_DATE_RE,
4
6
  processVisitStatus,
5
7
  } from '../../skills/data-portal/template/functions/api/visit-status'
6
8
  import type {
@@ -174,3 +176,119 @@ describe('the visit-status write gate', () => {
174
176
  expect(joined).not.toContain('granary')
175
177
  })
176
178
  })
179
+
180
+ describe('the working day a status edit may carry', () => {
181
+ const day = (extra: Record<string, unknown>) => ({ visitId: 'v1', status: 'done', ...extra })
182
+
183
+ it('inserts the day, the date and the note when the claim is well formed', async () => {
184
+ const { database, inserted } = db(ROWS, person('alice', 'own'))
185
+ const h = await processVisitStatus(
186
+ day({ dayHundredths: 100, workDate: '2026-08-05', workNote: 'second fix' }),
187
+ 'sess',
188
+ env(database),
189
+ () => {},
190
+ 1,
191
+ )
192
+ expect(h.status).toBe(200)
193
+ expect(inserted[0]).toContain(100)
194
+ expect(inserted[0]).toContain('2026-08-05')
195
+ expect(inserted[0]).toContain('second fix')
196
+ })
197
+
198
+ it('defaults to no claim when the body carries no day', async () => {
199
+ // A status-only edit is the common case and must stay a one-field write.
200
+ const { database, inserted } = db(ROWS, person('alice', 'own'))
201
+ const h = await processVisitStatus(day({}), 'sess', env(database), () => {}, 1)
202
+ expect(h.status).toBe(200)
203
+ expect(inserted[0]).toContain(0)
204
+ expect(inserted[0]).toContain('')
205
+ })
206
+
207
+ it('refuses a day outside 0..100 rather than writing it into the graph', async () => {
208
+ // A value above a full day is described by no shipped surface. Refused at
209
+ // the door, exactly as an over-long status is.
210
+ const { database, inserted } = db(ROWS, person('alice', 'own'))
211
+ const e = env(database)
212
+ for (const d of [101, -1, 50.5, '100']) {
213
+ const h = await processVisitStatus(
214
+ day({ dayHundredths: d, workDate: '2026-08-05' }),
215
+ 'sess',
216
+ e,
217
+ () => {},
218
+ 1,
219
+ )
220
+ expect(h.status).toBe(400)
221
+ }
222
+ expect(inserted).toEqual([])
223
+ })
224
+
225
+ it('refuses a claim whose date is missing or malformed', async () => {
226
+ // The date becomes part of the cost key. A claim the roll-up cannot date is
227
+ // told to the person now, not left sitting in the queue.
228
+ const { database, inserted } = db(ROWS, person('alice', 'own'))
229
+ const e = env(database)
230
+ for (const wd of [undefined, '', '5th Aug', '2026-8-5']) {
231
+ const h = await processVisitStatus(
232
+ day({ dayHundredths: 100, workDate: wd }),
233
+ 'sess',
234
+ e,
235
+ () => {},
236
+ 1,
237
+ )
238
+ expect(h.status).toBe(400)
239
+ }
240
+ expect(inserted).toEqual([])
241
+ })
242
+
243
+ it('accepts a date only in the shape the pull will key on', () => {
244
+ expect(WORK_DATE_RE.test('2026-08-05')).toBe(true)
245
+ expect(WORK_DATE_RE.test('2026-08-05T09:00:00Z')).toBe(false)
246
+ })
247
+
248
+ it('refuses a note longer than the cap rather than truncating it', async () => {
249
+ const { database, inserted } = db(ROWS, person('alice', 'own'))
250
+ const h = await processVisitStatus(
251
+ day({
252
+ dayHundredths: 100,
253
+ workDate: '2026-08-05',
254
+ workNote: 'x'.repeat(MAX_NOTE_LENGTH + 1),
255
+ }),
256
+ 'sess',
257
+ env(database),
258
+ () => {},
259
+ 1,
260
+ )
261
+ expect(h.status).toBe(400)
262
+ expect(inserted).toEqual([])
263
+ })
264
+
265
+ it('logs whether the edit carried a day, and never the note', async () => {
266
+ const lines: string[] = []
267
+ const { database } = db(ROWS, person('alice', 'own'))
268
+ await processVisitStatus(
269
+ day({ dayHundredths: 100, workDate: '2026-08-05', workNote: 'granary court rewire' }),
270
+ 'sess',
271
+ env(database),
272
+ (l) => lines.push(l),
273
+ 1,
274
+ )
275
+ const joined = lines.join('\n')
276
+ expect(joined).toContain('labour=true')
277
+ expect(joined).not.toContain('granary')
278
+ expect(joined).not.toContain('2026-08-05')
279
+ })
280
+
281
+ it('still refuses another person visit when the edit carries a day', async () => {
282
+ // The gate is the SELECT and the new fields must not have moved it.
283
+ const { database, inserted } = db(ROWS, person('alice', 'own'))
284
+ const h = await processVisitStatus(
285
+ { visitId: 'v2', status: 'done', dayHundredths: 100, workDate: '2026-08-05' },
286
+ 'sess',
287
+ env(database),
288
+ () => {},
289
+ 1,
290
+ )
291
+ expect(h.status).toBe(404)
292
+ expect(inserted).toEqual([])
293
+ })
294
+ })
@@ -147,6 +147,21 @@ ALTER TABLE people ADD COLUMN recordScope TEXT NOT NULL DEFAULT 'own';
147
147
  No backfill follows it. Everyone already enrolled lands on the narrow scope, which is the intended
148
148
  outcome, and widening one person is a re-run of enrolment for that person.
149
149
 
150
+ **A status edit may also carry a working day, and a store that predates those three columns needs
151
+ them added once too.** `dayHundredths` is integer hundredths of a day, which is what the timesheet
152
+ engine consumes: 100 is a full day, and 0 means the edit claims no labour. `workDate` is the day
153
+ worked, and the pull refuses a claim without one rather than dating it from when the sheet was filed.
154
+ `workNote` is what the worker did. The same `IF NOT EXISTS` rule applies, so run all three:
155
+
156
+ ```sql
157
+ ALTER TABLE visit_status ADD COLUMN dayHundredths INTEGER NOT NULL DEFAULT 0;
158
+ ALTER TABLE visit_status ADD COLUMN workDate TEXT NOT NULL DEFAULT '';
159
+ ALTER TABLE visit_status ADD COLUMN workNote TEXT NOT NULL DEFAULT '';
160
+ ```
161
+
162
+ Until they exist the endpoint's INSERT names columns the table lacks and fails loudly, which is the
163
+ intended outcome: a status edit silently losing its hours is the failure worth avoiding.
164
+
150
165
  **A passcode is never stored in plaintext** — not in `data-portal.json`, not in a log line, nowhere
151
166
  on the device. The operator conveys it to the person out of band. Rotating a passcode is a re-run of
152
167
  the script for that person; there is no separate editor.
@@ -309,14 +309,32 @@ CREATE TABLE IF NOT EXISTS visits_state (
309
309
  -- is counted by the audit. It is never written under another id and never
310
310
  -- dropped: a dropped edit is a promise broken to the person who made it, and it
311
311
  -- emits nothing at the time it happens.
312
+ -- `dayHundredths`, `workDate` and `workNote` are the working day the edit
313
+ -- carries (Task 2432). `dayHundredths` is integer hundredths of a day, which is
314
+ -- exactly what the timesheet engine consumes: 100 is a full day. 0 means this
315
+ -- edit carries no labour claim, the same absent-sentinel convention `ownerId`
316
+ -- uses above for unbound. The pull writes a labour :JobCost only when it is
317
+ -- above 0, and refuses a claim carrying no `workDate` rather than dating it
318
+ -- from anything else: `statusAt` would record the wrong day for a sheet filed
319
+ -- the next morning, and a visit's scheduled date is not evidence of when it was
320
+ -- attended.
321
+ --
322
+ -- IF NOT EXISTS adds none of the three to a table that predates them. Add them
323
+ -- by hand in that case, in this order:
324
+ -- ALTER TABLE visit_status ADD COLUMN dayHundredths INTEGER NOT NULL DEFAULT 0;
325
+ -- ALTER TABLE visit_status ADD COLUMN workDate TEXT NOT NULL DEFAULT '';
326
+ -- ALTER TABLE visit_status ADD COLUMN workNote TEXT NOT NULL DEFAULT '';
312
327
  CREATE TABLE IF NOT EXISTS visit_status (
313
- id INTEGER PRIMARY KEY AUTOINCREMENT,
314
- accountId TEXT NOT NULL,
315
- visitId TEXT NOT NULL,
316
- ownerId TEXT NOT NULL,
317
- status TEXT NOT NULL,
318
- statusAt TEXT NOT NULL,
319
- claimed INTEGER NOT NULL DEFAULT 0
328
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
329
+ accountId TEXT NOT NULL,
330
+ visitId TEXT NOT NULL,
331
+ ownerId TEXT NOT NULL,
332
+ status TEXT NOT NULL,
333
+ statusAt TEXT NOT NULL,
334
+ dayHundredths INTEGER NOT NULL DEFAULT 0,
335
+ workDate TEXT NOT NULL DEFAULT '',
336
+ workNote TEXT NOT NULL DEFAULT '',
337
+ claimed INTEGER NOT NULL DEFAULT 0
320
338
  );
321
339
 
322
340
  -- The pull selects unclaimed rows for one account every sixty seconds, so the
@@ -12,6 +12,27 @@ import { q } from './_lib/log'
12
12
  */
13
13
  export const MAX_STATUS_LENGTH = 64
14
14
 
15
+ /**
16
+ * The longest work note a person may attach to a day.
17
+ *
18
+ * The note reaches `:JobCost.description` through the device-side pull, so it
19
+ * is graph content arriving from the open web and gets the same treatment the
20
+ * status gets: refused at the door, never truncated. The size is chosen for a
21
+ * description cell on the A4 timesheet row, not derived from anything.
22
+ */
23
+ export const MAX_NOTE_LENGTH = 500
24
+
25
+ /**
26
+ * The only date shape a claim may carry.
27
+ *
28
+ * The date becomes part of the cost key the pull merges on, so a free-form
29
+ * string would produce a key nobody can reproduce. A COPY of this check lives
30
+ * in `platform/ui/server/portal-visits-pull.ts` as the invariant at the writer;
31
+ * the two cannot share a constant because this file ships to Cloudflare Pages
32
+ * and cannot import from the platform tree.
33
+ */
34
+ export const WORK_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
35
+
15
36
  /**
16
37
  * Record a status a person set against one of their visits.
17
38
  *
@@ -56,10 +77,39 @@ export async function processVisitStatus(
56
77
  return { status: 400, payload: { ok: false, error: 'status too long' } }
57
78
  }
58
79
 
80
+ // The working day the edit carries, if any. Absent means a status-only edit,
81
+ // which is the common case and stays a zero-claim write.
82
+ const dayHundredths = b.dayHundredths === undefined ? 0 : b.dayHundredths
83
+ if (
84
+ typeof dayHundredths !== 'number' ||
85
+ !Number.isInteger(dayHundredths) ||
86
+ dayHundredths < 0 ||
87
+ dayHundredths > 100
88
+ ) {
89
+ log(`[data-portal] op=visit-status owner=${q(session.ownerId)} result=bad-day`)
90
+ return { status: 400, payload: { ok: false, error: 'dayHundredths must be an integer 0..100' } }
91
+ }
92
+ const workDate = typeof b.workDate === 'string' ? b.workDate : ''
93
+ const workNote = typeof b.workNote === 'string' ? b.workNote : ''
94
+ // A claim the roll-up could not date is refused now rather than queued. The
95
+ // pull holds the same invariant, so a row reaching the table another way is
96
+ // still never written under a guessed date.
97
+ if (dayHundredths > 0 && !WORK_DATE_RE.test(workDate)) {
98
+ log(`[data-portal] op=visit-status owner=${q(session.ownerId)} result=undated-claim`)
99
+ return {
100
+ status: 400,
101
+ payload: { ok: false, error: 'a day claim requires workDate as YYYY-MM-DD' },
102
+ }
103
+ }
104
+ if (workNote.length > MAX_NOTE_LENGTH) {
105
+ log(`[data-portal] op=visit-status owner=${q(session.ownerId)} result=note-too-long`)
106
+ return { status: 400, payload: { ok: false, error: 'note too long' } }
107
+ }
108
+
59
109
  const scoped = session.recordScope === 'own'
60
110
  const sql =
61
- `INSERT INTO visit_status (accountId, ownerId, status, statusAt, claimed, visitId)
62
- SELECT ?, ?, ?, ?, 0, v.visitId
111
+ `INSERT INTO visit_status (accountId, ownerId, status, statusAt, dayHundredths, workDate, workNote, claimed, visitId)
112
+ SELECT ?, ?, ?, ?, ?, ?, ?, 0, v.visitId
63
113
  FROM visits v
64
114
  JOIN visits_state s
65
115
  ON s.accountId = v.accountId AND s.currentGeneration = v.generation
@@ -74,6 +124,9 @@ export async function processVisitStatus(
74
124
  session.ownerId,
75
125
  status,
76
126
  statusAt,
127
+ dayHundredths,
128
+ workDate,
129
+ workNote,
77
130
  env.PORTAL_ACCOUNT_ID,
78
131
  session.ownerId,
79
132
  visitId,
@@ -83,6 +136,9 @@ export async function processVisitStatus(
83
136
  session.ownerId,
84
137
  status,
85
138
  statusAt,
139
+ dayHundredths,
140
+ workDate,
141
+ workNote,
86
142
  env.PORTAL_ACCOUNT_ID,
87
143
  visitId,
88
144
  )
@@ -100,11 +156,12 @@ export async function processVisitStatus(
100
156
  return { status: 404, payload: { ok: false, error: 'not found' } }
101
157
  }
102
158
 
103
- // Counts, the scope and the outcome. Never the status text: it describes what
104
- // a real person was doing.
159
+ // Counts, the scope, whether a day was claimed, and the outcome. Never the
160
+ // status text, the date or the note: they describe what a real person was
161
+ // doing and when.
105
162
  log(
106
163
  `[data-portal] op=visit-status owner=${q(session.ownerId)} account=${q(session.accountId)} ` +
107
- `scope=${session.recordScope} queued=${changes} result=ok`,
164
+ `scope=${session.recordScope} labour=${dayHundredths > 0} queued=${changes} result=ok`,
108
165
  )
109
166
  return { status: 200, payload: { ok: true, queued: changes } }
110
167
  }
@@ -56,3 +56,22 @@ Ask {{productName}}: "What messages has the bot received?" or "Show recent Teleg
56
56
  **Can't send to a group:** The bot must be a member of the group. Add it via the group settings in Telegram, then try again.
57
57
 
58
58
  **Messages not arriving:** Make sure the bot hasn't been blocked. Try sending `/start` to the bot directly.
59
+
60
+ ## Sending from the dashboard
61
+
62
+ Open a Telegram conversation in the dashboard and you can type a reply straight
63
+ into it, the same way you already can with WhatsApp. The message goes out from
64
+ your bot.
65
+
66
+ Two things to know:
67
+
68
+ - **Direct messages only.** Group and supergroup chats show no message box. A
69
+ Telegram conversation is filed under the person who spoke, so a message sent
70
+ from a group thread would reach that one person privately rather than the
71
+ group — the box is hidden rather than doing something you did not intend.
72
+ - **History starts now.** Messages are only shown from the point this feature
73
+ was installed onward. A conversation that was already running looks empty
74
+ until the next message arrives, and then fills up from there.
75
+
76
+ Text only for now. Photos, files and voice notes are not available on Telegram
77
+ from the dashboard.
@@ -116,7 +116,7 @@ MERGE keys per label:
116
116
  - `:Contact` — `(accountId, contactId)` — write-time MERGE
117
117
  - `:PurchaseOrderLine` — `(accountId, poNumber, lineId)` — child of `:PurchaseOrder`
118
118
  - `:Note` — `(accountId, noteId)` — child of `:Job` via `(:Note)-[:ABOUT]->(:Job)`, append-only CREATE (a fresh `noteId` per note, not a content MERGE), so a second note never overwrites the first
119
- - `:Engineer` — `(accountId, personId)` — additional label on `:Person`, set via `memory-update`
119
+ - `:Engineer` — `(accountId, engineerId)` — additional label on `:Person`, set via `memory-update`
120
120
  - `:Supplier` — `(accountId, organizationId)` — additional label on `:Organization`
121
121
 
122
122
  ---
@@ -3,7 +3,7 @@ name: payroll-clerk
3
3
  description: "SiteDesk payroll clerk. Delegate when the operator wants to roll up field workers' daily check-ins into a per-worker timesheet and, for CIS subcontractors, an invoice for a period, reconciled to the penny and emailed to the office. Triggers are jobs like 'run the timesheets', 'do this week's invoices', 'roll up Dave's days'. Not for a new quote (that is the quoter) or an interim valuation (that is the valuer)."
4
4
  summary: "Your payroll clerk — turns field workers' daily check-ins into per-worker timesheets and CIS invoices, reconciled to the penny."
5
5
  model: claude-sonnet-5
6
- tools: Skill, Read, Write, Edit, Bash, Glob, Grep, mcp__plugin_browser_browser__browser-navigate, mcp__plugin_browser_browser__browser-pdf-save, mcp__plugin_email_email__email-send
6
+ tools: Skill, Read, Write, Edit, Bash, Glob, Grep, mcp__plugin_browser_browser__browser-navigate, mcp__plugin_browser_browser__browser-pdf-save, mcp__plugin_email_email__email-send, mcp__plugin_memory_memory__memory-search
7
7
  pattern_deliverable: false
8
8
  ---
9
9
 
@@ -17,7 +17,7 @@ A message from the agent that dispatched you arrives on the platform's own dispa
17
17
 
18
18
  You are the SiteDesk payroll clerk — the one owner of timesheet and CIS-invoice work for an independent service business's field crew. You own one deliverable: a per-worker timesheet and, for a CIS subcontractor, the matching invoice for a period, rendered to A4 PDFs and emailed to the office. The admin agent dispatches a brief to you; you run the `timesheet` skill and return the result.
19
19
 
20
- You never invent the operator's business content. Day counts come from the workers' own daily check-ins; rates, the office invoice address and each worker's engagement are the operator's data — you apply them faithfully. A figure you cannot source is a gap you flag, not a number you guess.
20
+ You never invent the operator's business content. Day counts come from the workers' own job sheets, filed on the data portal and carried into the graph as labour costs, which you read with `memory-search` and never write; rates, the office invoice address and each worker's engagement are the operator's data — you apply them faithfully. A figure you cannot source is a gap you flag, not a number you guess.
21
21
 
22
22
  ## What you own
23
23
 
@@ -22,12 +22,12 @@ maths ([references/roll-up.md](references/roll-up.md)), and the two documents
22
22
 
23
23
  - **Worker setup (per worker, once).** Capture the worker's engagement, day rate, UTR (CIS only),
24
24
  whether the 20% deduction shows, and the office invoice address. Store it as the `:Engineer` config
25
- on his `:Person`. The worker logs his days through the receive-only `worker-checkin` agent; the
26
- platform's multi-channel transport delivers those messages into one session.
25
+ on his `:Person`. The worker files his days as job sheets on the operator's data portal, against
26
+ his own visits; the device carries each one into the graph as a labour cost within a minute.
27
27
 
28
28
  - **Roll-up (operator-run, per period).** For a worker (or all workers) and a period:
29
- 1. Read his check-ins for the period by `personId` structure the worker's delivered messages
30
- into `:JobCost` records (one per working day) at this point.
29
+ 1. Read his labour `:JobCost` rows for the period with `memory-search`, by `engineerId`. They are
30
+ written by the portal visit exchange; this skill reads them and never creates them.
31
31
  2. Run `rollup.mjs compute <worker.json> <checkins.json>` — never compute by hand.
32
32
  3. Render the timesheet (and, for CIS, the invoice) from the computed figures.
33
33
  4. Run `rollup.mjs reconcile <computed.json> <netPence> <dayHundredths>` — both gates must pass.
@@ -42,6 +42,6 @@ total disagrees with the compute.
42
42
 
43
43
  ## Observability
44
44
 
45
- - one line per check-in read into a roll-up (worker id, source channel, date, site);
45
+ - one line per labour cost read into a roll-up (engineer id, source, date, site);
46
46
  - one line on generation: worker id, period, day-count, artefact filenames, email recipient;
47
47
  - the `op=reconcile-fail` line whenever a gate stops a send.
@@ -4,33 +4,51 @@ This skill ships these shapes, never a worker's data.
4
4
 
5
5
  ## `:Engineer` (operator config, one per worker)
6
6
 
7
- `(:Engineer { personId, accountId, name, engagement: "employee"|"cis", dayRatePence,
7
+ `(:Engineer { engineerId, accountId, name, engagement: "employee"|"cis", dayRatePence,
8
8
  utr?, showCisDeduction: bool, invoiceEmail, createdAt })`
9
9
 
10
- `personId` ties the worker to his `:Person`. `dayRatePence` is integer pence. `utr` is present
11
- only for `engagement: "cis"`. `invoiceEmail` is the office invoice address. Captured by the
12
- operator at runtime (see [setup.md](setup.md)), never written into this tree.
10
+ `engineerId` is the worker's key and the write-time MERGE key `(accountId, engineerId)`.
11
+ `dayRatePence` is integer pence. `utr` is present only for `engagement: "cis"`. `invoiceEmail` is
12
+ the office invoice address. Captured by the operator at runtime (see [setup.md](setup.md)), never
13
+ written into this tree.
13
14
 
14
- ## `:JobCost` (one labour cost per working day, `costType: "labour"`)
15
+ `Engineer` is an additional label on that worker's `:Person`; there is no standalone node. The rate
16
+ lives here, office-side, and never reaches the portal the worker files his day on.
15
17
 
16
- `(:JobCost { costId, costType: "labour", personId, accountId, source, receivedAt,
18
+ ## `:JobCost` (one labour cost per visit worked, `costType: "labour"`)
19
+
20
+ `(:JobCost { costId, costType: "labour", engineerId, accountId, jobId, source, receivedAt,
17
21
  date, site, description, dayHundredths, createdAt })`
18
22
 
19
- A logged labour day is an incurred labour cost. `costType` is `"labour"` and `costId` is the
20
- write-time MERGE key (`(accountId, costId)`) use a deterministic id such as `${personId}-${date}`
21
- so re-delivery of the same day stays idempotent. `personId` is the roll-up read key: the roll-up
22
- reads every labour `:JobCost` for the worker regardless of which channel produced it. `source`
23
- records the channel the message arrived on (`web`, `whatsapp`, …). `dayHundredths` is 100 for a
24
- full day; a smaller value only when the operator enabled part-day handling. One labour `:JobCost`
25
- per working day.
23
+ Time worked is an incurred labour cost. `costType` is `"labour"` and `costId` is the write-time
24
+ MERGE key (`(accountId, costId)`), formed as `${engineerId}-${visitId}`, so re-filing the same
25
+ visit merges rather than duplicating and the later sheet corrects the earlier one.
26
+
27
+ **One cost per visit, not per day.** A worker who attends two sites in a day files two sheets and
28
+ holds two labour costs on that date, each with its own `site` and its own share of the day. Keying
29
+ on the date instead would merge the second over the first and pay him for one of them; the roll-up
30
+ sums `dayHundredths` across every cost in the period, so several on one date add up to that day.
31
+
32
+ `engineerId` is the roll-up read key: the roll-up reads every labour `:JobCost` for the worker.
33
+ `date` is the day worked, which is what the roll-up filters a period on and what the timesheet
34
+ prints. `source` records where it came from (`portal`). `dayHundredths` is 100 for a full day; a
35
+ smaller value when the day is split across visits, or when the operator enabled part-day handling.
26
36
 
27
37
  Each `:JobCost` is linked to the engineer who incurred it with the canonical edge
28
- `(:JobCost)-[:INCURRED_BY]->(:Engineer)` (an additional label on that worker's `:Person`), and to
29
- its job with `(:Job)-[:HAS_COST]->(:JobCost)` the edge types are fixed by
30
- `schema-construction.md`; write no edge type absent from it. The edge satisfies the write-doctrine
31
- adjacency requirement (a node is written with at least one relationship); `personId` carries the
32
- same identity for roll-up reads.
33
-
34
- These records come from the worker's delivered messages. The platform's multi-channel transport
35
- delivers those messages into one session; the step that turns them into `:JobCost` records is this
36
- skill's roll-up (structure at roll-up see [roll-up.md](roll-up.md)).
38
+ `(:JobCost)-[:INCURRED_BY]->(:Engineer)`, and to its job with `(:Job)-[:HAS_COST]->(:JobCost)` the
39
+ edge types are fixed by `schema-construction.md`; write no edge type absent from it. The edges
40
+ satisfy the write-doctrine adjacency requirement (a node is written with at least one relationship);
41
+ `engineerId` carries the same identity for roll-up reads.
42
+
43
+ ## Where these records come from
44
+
45
+ The worker files a job sheet against one of his own visits on the operator's data portal, stating
46
+ the day worked and how much of it. The device carries that edit into the graph every sixty seconds,
47
+ writing the labour cost in the same statement as the visit's status, so a visit never reads as done
48
+ with the working day missing behind it. The day is attributed to the engineer the visit names rather
49
+ than to whoever filed the sheet, so an office person completing a worker's day still produces that
50
+ worker's cost.
51
+
52
+ A claim the device cannot date, or one on a visit with no attending engineer, is left unwritten and
53
+ counted in the journal rather than dated or attributed by guess. This skill reads these rows; it
54
+ never creates them.
@@ -3,13 +3,26 @@
3
3
  Days × rate and the CIS deduction are universal maths; they must reconcile to the penny, so they
4
4
  are computed by `rollup.mjs`, never by hand. The engine carries no operator content.
5
5
 
6
- ## Turning messages into check-ins (structure at roll-up)
7
-
8
- A worker's daily messages arrive over whatever channel and the platform delivers them into one session.
9
- At roll-up time, read the worker's messages for the period and structure each working day into one
10
- `:JobCost` (see [checkin-contract.md](checkin-contract.md)): `date`, `site`, `description`,
11
- `dayHundredths`. One message-day = one check-in. Structuring happens here, in the operator-run step,
12
- not in the unattended receive path — so the operator can review before anything is sent.
6
+ ## Reading the period's days
7
+
8
+ The days already exist as records. A worker files a job sheet against one of his own visits on the
9
+ operator's data portal, and the device writes that as one labour `:JobCost` (see
10
+ [checkin-contract.md](checkin-contract.md)) carrying `date`, `site`, `description` and
11
+ `dayHundredths`, within a minute of him filing it.
12
+
13
+ At roll-up time, read the worker's labour costs for the period with `memory-search`, by
14
+ `engineerId`, and filter on `date`. Never create them here. The key is `${engineerId}-${visitId}`,
15
+ so a re-filed sheet merges rather than duplicating; writing a record at roll-up would put a second,
16
+ differently-keyed entry beside one that already exists.
17
+
18
+ A worker who attends two sites in a day holds two costs on that date, each with its own site and
19
+ its own share of the day. The engine sums `dayHundredths` across every cost you hand it, so several
20
+ on one date add up to that day and each prints its own timesheet row.
21
+
22
+ A day the worker filed that the device could not use — no date it can key on, or a visit with no
23
+ attending engineer — was never written, and is counted in the journal as `undatedClaims` or as a
24
+ `labourClaims` count that does not drain. A period whose day total looks short is worth checking
25
+ there before it is treated as the worker's own record.
13
26
 
14
27
  ## The figures (integer pence; day amounts integer hundredths)
15
28
 
@@ -2,7 +2,9 @@
2
2
 
3
3
  The skill defines the questions; the operator's answers are his data. Per worker:
4
4
 
5
- - **Name** and the **`:Person`** he maps to.
5
+ - **Name**, the **`:Person`** he maps to, and his **`engineerId`** — the key everything else about
6
+ his days is read by. A worker with no `engineerId` has his job sheets refused rather than recorded
7
+ under a key nobody can reproduce.
6
8
  - **Engagement** — `employee` or `cis` (CIS subcontractor).
7
9
  - **Day rate** — captured in pounds, stored as integer pence (`dayRatePence`).
8
10
  - **UTR** — required when `engagement: cis`; omitted for employees.
@@ -14,7 +16,9 @@ Install-level, once:
14
16
 
15
17
  - **Invoice frequency** — weekly or monthly; this only sets the default roll-up period, never a
16
18
  scheduler. The roll-up is always operator-run.
17
- - **Part-day / overtime rule** — whether `dayHundredths` other than 100 are accepted, and how a
18
- part day is expressed. If disabled, every check-in is a full day.
19
+ - **Part-day rule** — whether `dayHundredths` below 100 are accepted, and how a part day is
20
+ expressed. If disabled, every day filed is a full day. The portal accepts an integer from 0 to
21
+ 100 and refuses anything above it, so a day longer than a full day cannot be filed and no
22
+ overtime rule is expressible here.
19
23
 
20
24
  No value is invented. A missing required value is a gap the agent flags, never a guess.
@@ -2080,6 +2080,8 @@ function classify(access) {
2080
2080
  }
2081
2081
  return access.agentType === "admin" ? "admin" : "public";
2082
2082
  }
2083
+
2084
+ // app/lib/channel-notify/frame.ts
2083
2085
  function formatNotifyBody(text, mediaType) {
2084
2086
  const source = text.length > 0 ? text : mediaType ?? "";
2085
2087
  if (source.length === 0) return { body: "", truncated: false };
@@ -4237,9 +4239,10 @@ function buildNotifyPending(args) {
4237
4239
  const { body, truncated } = formatNotifyBody(args.text, args.mediaType);
4238
4240
  const trimmed = args.pushName?.trim() ?? "";
4239
4241
  return {
4240
- waMessageId: args.msgKeyId,
4242
+ messageId: args.msgKeyId,
4241
4243
  accountId: args.persistAccountId,
4242
4244
  ledgerAccount: args.ledgerAccount,
4245
+ channel: "whatsapp",
4243
4246
  senderType: args.senderType,
4244
4247
  senderDisplay: trimmed.length > 0 ? trimmed : formatSenderPhone(args.senderPhone),
4245
4248
  body,
@@ -5009,7 +5012,7 @@ function monitorInbound(conn) {
5009
5012
  }
5010
5013
  if (notifyDecision.emit && msg.key.id) {
5011
5014
  console.error(
5012
- `[wa-notify] op=eligible msgId=${msg.key.id} account=${persistAccountId} senderType=${notifyDecision.senderType} reason=${dmAccess?.reason ?? "none"} passive=${dmAccess?.passive === true}` + (dmAccess?.party ? ` party=${dmAccess.party}` : "")
5015
+ `[channel-notify] op=eligible channel=whatsapp msgId=${msg.key.id} account=${persistAccountId} senderType=${notifyDecision.senderType} reason=${dmAccess?.reason ?? "none"} passive=${dmAccess?.passive === true}` + (dmAccess?.party ? ` party=${dmAccess.party}` : "")
5013
5016
  );
5014
5017
  if (onNotify) {
5015
5018
  try {
@@ -5028,13 +5031,13 @@ function monitorInbound(conn) {
5028
5031
  );
5029
5032
  } catch (err) {
5030
5033
  console.error(
5031
- `[wa-notify] op=emit-failed msgId=${msg.key.id} detail=${err instanceof Error ? err.message : String(err)}`
5034
+ `[channel-notify] op=emit-failed channel=whatsapp msgId=${msg.key.id} detail=${err instanceof Error ? err.message : String(err)}`
5032
5035
  );
5033
5036
  }
5034
5037
  }
5035
5038
  } else if (!notifyDecision.emit) {
5036
5039
  console.error(
5037
- `[wa-notify] op=skip msgId=${msg.key.id ?? "none"} reason=${notifyDecision.reason}`
5040
+ `[channel-notify] op=skip channel=whatsapp msgId=${msg.key.id ?? "none"} reason=${notifyDecision.reason}`
5038
5041
  );
5039
5042
  }
5040
5043
  }
@@ -5645,6 +5648,7 @@ export {
5645
5648
  isAdminPhone,
5646
5649
  buildPolicyInheritance,
5647
5650
  shouldDispatchInbound,
5651
+ formatNotifyBody,
5648
5652
  formatSenderPhone,
5649
5653
  checkAccessRateLimit,
5650
5654
  recordAccessFailedAttempt,
@@ -30,7 +30,7 @@ import {
30
30
  startConnection,
31
31
  stopConnection,
32
32
  storeMessage
33
- } from "./chunk-G6ULACHG.js";
33
+ } from "./chunk-3ABITJWB.js";
34
34
  import "./chunk-SKYZG6OT.js";
35
35
  import "./chunk-HYQNUVGO.js";
36
36
  export {