thursday-agent 0.8.0 → 0.9.0

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 (264) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +3 -3
  3. package/.next/prerender-manifest.json +3 -3
  4. package/.next/server/app/_global-error/page.js.nft.json +1 -1
  5. package/.next/server/app/_global-error.html +1 -1
  6. package/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  10. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  11. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/server/app/_not-found.html +1 -1
  13. package/.next/server/app/_not-found.rsc +2 -2
  14. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  15. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  16. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  17. package/.next/server/app/api/artifact/route.js.nft.json +1 -1
  18. package/.next/server/app/api/bot/keep-working/route.js.nft.json +1 -1
  19. package/.next/server/app/api/bot/memory/files/route.js.nft.json +1 -1
  20. package/.next/server/app/api/bot/memory/route.js.nft.json +1 -1
  21. package/.next/server/app/api/bot/route.js.nft.json +1 -1
  22. package/.next/server/app/api/bot/thread/[id]/route.js.nft.json +1 -1
  23. package/.next/server/app/api/bot/thread/route.js.nft.json +1 -1
  24. package/.next/server/app/api/config/route.js.nft.json +1 -1
  25. package/.next/server/app/api/events/route.js.nft.json +1 -1
  26. package/.next/server/app/api/favicon/[host]/route.js.nft.json +1 -1
  27. package/.next/server/app/api/file/[...path]/route.js.nft.json +1 -1
  28. package/.next/server/app/api/llm-model/catalog/route.js.nft.json +1 -1
  29. package/.next/server/app/api/llm-model/credits/route.js.nft.json +1 -1
  30. package/.next/server/app/api/llm-model/route.js.nft.json +1 -1
  31. package/.next/server/app/api/llm-model/subscription/route.js.nft.json +1 -1
  32. package/.next/server/app/api/mcp/[name]/route.js.nft.json +1 -1
  33. package/.next/server/app/api/mcp/oauth/callback/route.js.nft.json +1 -1
  34. package/.next/server/app/api/mcp/route.js.nft.json +1 -1
  35. package/.next/server/app/api/mcp/tools/route.js.nft.json +1 -1
  36. package/.next/server/app/api/memory/edit/route.js +1 -1
  37. package/.next/server/app/api/memory/edit/route.js.nft.json +1 -1
  38. package/.next/server/app/api/memory/route.js.nft.json +1 -1
  39. package/.next/server/app/api/probe/route.js.nft.json +1 -1
  40. package/.next/server/app/api/reach/route.js +11 -11
  41. package/.next/server/app/api/reach/route.js.nft.json +1 -1
  42. package/.next/server/app/api/routine/route.js +6 -6
  43. package/.next/server/app/api/routine/route.js.nft.json +1 -1
  44. package/.next/server/app/api/signins/route.js.nft.json +1 -1
  45. package/.next/server/app/api/skills/[source]/[name]/route.js.nft.json +1 -1
  46. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  47. package/.next/server/app/api/thursday/call/route.js.nft.json +1 -1
  48. package/.next/server/app/api/thursday/skills/route.js.nft.json +1 -1
  49. package/.next/server/app/api/thursday/text/route.js +11 -11
  50. package/.next/server/app/api/thursday/text/route.js.nft.json +1 -1
  51. package/.next/server/app/api/thursday/tool-call/route.js +6 -6
  52. package/.next/server/app/api/thursday/tool-call/route.js.nft.json +1 -1
  53. package/.next/server/app/api/workspace/files/route.js.nft.json +1 -1
  54. package/.next/server/app/api/workspace/route.js.nft.json +1 -1
  55. package/.next/server/app/artifact/[...path]/page/server-reference-manifest.json +1 -1
  56. package/.next/server/app/artifact/[...path]/page.js.nft.json +1 -1
  57. package/.next/server/app/artifact/[...path]/page_client-reference-manifest.js +1 -1
  58. package/.next/server/app/favicon.ico/route.js.nft.json +1 -1
  59. package/.next/server/app/manifest.webmanifest/route.js.nft.json +1 -1
  60. package/.next/server/app/page/server-reference-manifest.json +56 -56
  61. package/.next/server/app/page.js +3 -3
  62. package/.next/server/app/page.js.nft.json +1 -1
  63. package/.next/server/app/page_client-reference-manifest.js +1 -1
  64. package/.next/server/chunks/[root-of-the-server]__012s2o8._.js +1 -1
  65. package/.next/server/chunks/[root-of-the-server]__04r4h-2._.js +1 -1
  66. package/.next/server/chunks/[root-of-the-server]__0mrk2it._.js +1 -1
  67. package/.next/server/chunks/[root-of-the-server]__0nmhgeo._.js +3 -0
  68. package/.next/server/chunks/[root-of-the-server]__1fy2g82._.js +3 -0
  69. package/.next/server/chunks/[root-of-the-server]__1ndi14y._.js +1 -1
  70. package/.next/server/chunks/[root-of-the-server]__1nmflqk._.js +3 -0
  71. package/.next/server/chunks/[root-of-the-server]__1v7-zke._.js +1 -1
  72. package/.next/server/chunks/[root-of-the-server]__1wnzmx9._.js +3 -0
  73. package/.next/server/chunks/[root-of-the-server]__1xv-5cq._.js +1 -1
  74. package/.next/server/chunks/[root-of-the-server]__1ygplx1._.js +1 -1
  75. package/.next/server/chunks/_05v8k21._.js +1 -1
  76. package/.next/server/chunks/_0awtznl._.js +4 -4
  77. package/.next/server/chunks/_0k6qjcf._.js +1 -1
  78. package/.next/server/chunks/_0na2fj1._.js +5 -5
  79. package/.next/server/chunks/_0x-ej3f._.js +21 -21
  80. package/.next/server/chunks/_1dc_d1q._.js +22 -20
  81. package/.next/server/chunks/_1jc5g4p._.js +2 -2
  82. package/.next/server/chunks/_1jn-aep._.js +50 -48
  83. package/.next/server/chunks/{_1u8ygbz._.js → _1m88lkh._.js} +7 -7
  84. package/.next/server/chunks/_1uuucy-._.js +4 -4
  85. package/.next/server/chunks/_1v9r2ke._.js +1 -1
  86. package/.next/server/chunks/ssr/{[root-of-the-server]__1h4bnrf._.js → [root-of-the-server]__0oo-wlv._.js} +2 -2
  87. package/.next/server/chunks/ssr/{[root-of-the-server]__1kyi568._.js → [root-of-the-server]__0tvavy9._.js} +1 -1
  88. package/.next/server/chunks/ssr/[root-of-the-server]__0tzekbl._.js +1 -1
  89. package/.next/server/chunks/ssr/[root-of-the-server]__0zuns7e._.js +3 -0
  90. package/.next/server/chunks/ssr/[root-of-the-server]__1se25x0._.js +1 -1
  91. package/.next/server/chunks/ssr/{_1i3ddn7._.js → _05pl_u8._.js} +1 -1
  92. package/.next/server/chunks/ssr/_070nv1_._.js +3 -0
  93. package/.next/server/chunks/ssr/_097-ljd._.js +1 -1
  94. package/.next/server/chunks/ssr/_0dshsh4._.js +3 -0
  95. package/.next/server/chunks/ssr/_0fqhhwi._.js +1 -1
  96. package/.next/server/chunks/ssr/{_0z8jpch._.js → _0ital-2._.js} +2 -2
  97. package/.next/server/chunks/ssr/{_19g570d._.js → _0kjj5ts._.js} +3 -3
  98. package/.next/server/chunks/ssr/{_1nod5hh._.js → _0l_mcfz._.js} +2 -2
  99. package/.next/server/chunks/ssr/_0ld9_qq._.js +1 -1
  100. package/.next/server/chunks/ssr/_0ltz2q5._.js +3 -0
  101. package/.next/server/chunks/ssr/_0scf7xy._.js +3 -0
  102. package/.next/server/chunks/ssr/_0u5xv6g._.js +3 -0
  103. package/.next/server/chunks/ssr/{_0ewory0._.js → _0urj3mc._.js} +2 -2
  104. package/.next/server/chunks/ssr/{_0pm-2p-._.js → _1-zyvdb._.js} +2 -2
  105. package/.next/server/chunks/ssr/{_1_rqu8x._.js → _12mijol._.js} +7 -7
  106. package/.next/server/chunks/ssr/_15zqt-t._.js +1 -1
  107. package/.next/server/chunks/ssr/_184327j._.js +1 -1
  108. package/.next/server/chunks/ssr/{_1y-ea28._.js → _1a-nuxx._.js} +2 -2
  109. package/.next/server/chunks/ssr/_1alxym6._.js +3 -0
  110. package/.next/server/chunks/ssr/{_0hzz82u._.js → _1d423aj._.js} +25 -23
  111. package/.next/server/chunks/ssr/{_1u1mxu9._.js → _1flbs77._.js} +2 -2
  112. package/.next/server/chunks/ssr/_1mqn9mc._.js +1 -1
  113. package/.next/server/chunks/ssr/_1neoj22._.js +1 -1
  114. package/.next/server/chunks/ssr/{_1oijqjw._.js → _1tmu8z-._.js} +2 -2
  115. package/.next/server/chunks/ssr/{_10y6r1n._.js → _1uy0xom._.js} +3 -3
  116. package/.next/server/chunks/ssr/_1x-kojb._.js +20 -0
  117. package/.next/server/chunks/ssr/_1xoch6f._.js +1 -1
  118. package/.next/server/chunks/ssr/_1z74dmq._.js +164 -0
  119. package/.next/server/chunks/ssr/features_bot_1nr94ks._.js +3 -0
  120. package/.next/server/chunks/ssr/features_bot_1s_-2vj._.js +1 -1
  121. package/.next/server/chunks/ssr/features_connectors_components_mcp-setting_tsx_0jmbgvp._.js +1 -1
  122. package/.next/server/chunks/ssr/features_memory_components_memory-setting_tsx_0oa43sw._.js +1 -1
  123. package/.next/server/chunks/ssr/features_routine_components_routine-setting_tsx_055fknz._.js +1 -1
  124. package/.next/server/chunks/ssr/{node_modules_111sh9o._.js → node_modules_03d0rfz._.js} +1 -1
  125. package/.next/server/chunks/ssr/{node_modules_0lg75qx._.js → node_modules_04_sbbc._.js} +1 -1
  126. package/.next/server/chunks/ssr/{node_modules_10gbm2y._.js → node_modules_05ybmta._.js} +1 -1
  127. package/.next/server/chunks/ssr/{node_modules_1v9tc1c._.js → node_modules_068ws5v._.js} +1 -1
  128. package/.next/server/chunks/ssr/{node_modules_1_8mv4v._.js → node_modules_06bb4q8._.js} +1 -1
  129. package/.next/server/chunks/ssr/{node_modules_01f8_se._.js → node_modules_07rs_p2._.js} +1 -1
  130. package/.next/server/chunks/ssr/{node_modules_0ii1l89._.js → node_modules_0dzm4um._.js} +1 -1
  131. package/.next/server/chunks/ssr/{node_modules_20xmwtu._.js → node_modules_0jljel9._.js} +1 -1
  132. package/.next/server/chunks/ssr/{node_modules_1ix-g49._.js → node_modules_0sgy-dc._.js} +1 -1
  133. package/.next/server/chunks/ssr/{node_modules_0woscox._.js → node_modules_0t_ez6-._.js} +1 -1
  134. package/.next/server/chunks/ssr/{node_modules_059vtf2._.js → node_modules_0th357h._.js} +1 -1
  135. package/.next/server/chunks/ssr/{node_modules_06w645b._.js → node_modules_0ubdtk-._.js} +1 -1
  136. package/.next/server/chunks/ssr/{node_modules_0b8df3s._.js → node_modules_0uyh_vb._.js} +1 -1
  137. package/.next/server/chunks/ssr/{node_modules_07ih6wb._.js → node_modules_0wnlddw._.js} +1 -1
  138. package/.next/server/chunks/ssr/{node_modules_0gt-uw9._.js → node_modules_0x630an._.js} +1 -1
  139. package/.next/server/chunks/ssr/{node_modules_1x825hz._.js → node_modules_0yvz9e0._.js} +1 -1
  140. package/.next/server/chunks/ssr/{node_modules_0t-44lj._.js → node_modules_12et8d5._.js} +1 -1
  141. package/.next/server/chunks/ssr/{node_modules_1m3gb16._.js → node_modules_13kwejm._.js} +1 -1
  142. package/.next/server/chunks/ssr/{node_modules_1p9rdsy._.js → node_modules_1af3w91._.js} +1 -1
  143. package/.next/server/chunks/ssr/{node_modules_0sz5yg1._.js → node_modules_1bucjlk._.js} +1 -1
  144. package/.next/server/chunks/ssr/{node_modules_1teyzfa._.js → node_modules_1lkix61._.js} +1 -1
  145. package/.next/server/chunks/ssr/{node_modules_1lsyy7k._.js → node_modules_1ogczzz._.js} +1 -1
  146. package/.next/server/chunks/ssr/{node_modules_1-7qawg._.js → node_modules_1s-gb-1._.js} +1 -1
  147. package/.next/server/chunks/ssr/{node_modules_0yv-re6._.js → node_modules_1uwg-gz._.js} +1 -1
  148. package/.next/server/chunks/ssr/{node_modules_03ptrl9._.js → node_modules_1wbkzl9._.js} +1 -1
  149. package/.next/server/chunks/ssr/{node_modules_00jv9_3._.js → node_modules_1z1lrbk._.js} +1 -1
  150. package/.next/server/chunks/ssr/{node_modules_@base-ui_react_0jne69c._.js → node_modules_@base-ui_react_0buz1-j._.js} +1 -1
  151. package/.next/server/chunks/ssr/{node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_00q1oa3._.js → node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_0r443fh._.js} +1 -1
  152. package/.next/server/chunks/ssr/{node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_1-2_5z7._.js → node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_10wnlbk._.js} +1 -1
  153. package/.next/server/middleware-build-manifest.js +3 -3
  154. package/.next/server/pages/404.html +1 -1
  155. package/.next/server/pages/500.html +1 -1
  156. package/.next/server/server-reference-manifest.js +1 -1
  157. package/.next/server/server-reference-manifest.json +57 -57
  158. package/.next/static/chunks/06nt9tatr-dug.js +1 -0
  159. package/.next/static/chunks/0_awn8tucznxc.js +1 -0
  160. package/.next/static/chunks/{34s52v8f841zo.js → 0ge-hx0ed83a1.js} +1 -1
  161. package/.next/static/chunks/0pda_vf-abwvx.js +1 -0
  162. package/.next/static/chunks/{2u9yjkpkoij75.js → 0qmd8n52hp-6v.js} +1 -1
  163. package/.next/static/chunks/0rowp9rx3qbxu.js +18 -0
  164. package/.next/static/chunks/{1ocy4w0v20a3v.js → 13ixzheqw7o-w.js} +2 -2
  165. package/.next/static/chunks/1grkt5gr8pbg1.js +17 -0
  166. package/.next/static/chunks/{21gf2t_whhurc.js → 1yugpvvi8qrbu.js} +2 -2
  167. package/.next/static/chunks/{1e4yrzs5evq_r.js → 245hb85nsy6j4.js} +1 -1
  168. package/.next/static/chunks/27lkanxvtdn09.js +1 -0
  169. package/.next/static/chunks/{0oz7a68qanm99.js → 27tvv1i-smrck.js} +1 -1
  170. package/.next/static/chunks/{2zhfdbq50ee5i.js → 2c64drdevdla3.js} +1 -1
  171. package/.next/static/chunks/{0k5tkt_qi52kb.js → 2ndw4w4u2x51r.js} +1 -1
  172. package/.next/static/chunks/37j27b3e1gt7r.js +1 -0
  173. package/.next/static/chunks/{2om4lkg86feb4.js → 38301ji-85i30.js} +1 -1
  174. package/.next/static/chunks/{09c13vnqy7al3.js → 3bbzsitgiltnt.js} +2 -2
  175. package/.next/static/chunks/3fec-aeteddr-.js +1 -0
  176. package/.next/static/chunks/3go3hwp1z2_h2.css +1 -0
  177. package/.next/static/chunks/3h2bun3hcggg8.js +1 -0
  178. package/.next/static/chunks/{0g1p98s2pfx2w.js → 3h2e-xv1zxh_f.js} +2 -2
  179. package/.next/static/chunks/3j1y335o95mj7.js +1 -0
  180. package/.next/static/chunks/3obf9n3cxj99k.js +1 -0
  181. package/.next/static/chunks/{2upc9mkcc3nlq.js → 427qsc6kahxyo.js} +1 -1
  182. package/README.md +31 -14
  183. package/bin/port.mjs +11 -7
  184. package/bin/thursday.mjs +1 -1
  185. package/components/ui/radio-group.tsx +2 -2
  186. package/components/ui/shiny-text.tsx +3 -22
  187. package/docs/how-it-works.md +11 -3
  188. package/docs/images/first-run.png +0 -0
  189. package/docs/images/keeps-going.png +0 -0
  190. package/docs/images/two-minds.png +0 -0
  191. package/features/ai/components/model-browser.tsx +1 -1
  192. package/features/ai/components/model-picker.tsx +1 -1
  193. package/features/ai/guide.ts +9 -4
  194. package/features/ai/prompts/bot.prompt.ts +4 -1
  195. package/features/ai/tools/routine.tool.ts +28 -16
  196. package/features/bot/bot.runner.ts +3 -1
  197. package/features/bot/components/bot-mark.tsx +6 -1
  198. package/features/bot/components/bot-setting.tsx +2 -4
  199. package/features/bot/components/bot-tool.tsx +2 -0
  200. package/features/bot/components/room-list.tsx +12 -3
  201. package/features/bot/components/room-pill.tsx +26 -19
  202. package/features/config/components/config-setting.tsx +17 -5
  203. package/features/config/components/voice-key.tsx +38 -18
  204. package/features/connectors/mcp.const.ts +90 -59
  205. package/features/intro/components/intro.tsx +50 -27
  206. package/features/routine/components/routine-mark.tsx +4 -6
  207. package/features/routine/components/routine-setting.tsx +332 -143
  208. package/features/routine/routine.query.ts +24 -3
  209. package/features/routine/routine.schema.ts +55 -4
  210. package/features/settings/components/install-app.tsx +1 -4
  211. package/features/settings/components/setting-ui.tsx +1 -1
  212. package/features/skills/skills.discover.ts +2 -1
  213. package/features/skills/skills.query.ts +5 -1
  214. package/features/skills/skills.schema.ts +2 -0
  215. package/features/thursday/components/ascii-orb.tsx +39 -10
  216. package/features/thursday/components/connect-wave.tsx +284 -0
  217. package/features/thursday/components/side-captions.tsx +29 -17
  218. package/features/thursday/components/thursday-setting.tsx +2 -2
  219. package/features/thursday/components/thursday.tsx +130 -58
  220. package/features/thursday/components/voice-picker.tsx +1 -1
  221. package/features/thursday/components/write-line.tsx +2 -2
  222. package/features/thursday/open-work.ts +11 -3
  223. package/features/thursday/tool-line.ts +94 -12
  224. package/features/thursday/use-text-call.ts +3 -2
  225. package/features/thursday/use-thursday.ts +5 -2
  226. package/features/workspace/components/artifact-view.tsx +129 -7
  227. package/guide/bots.md +17 -5
  228. package/guide/calls.md +15 -7
  229. package/guide/setup.md +10 -2
  230. package/lib/desktop-notify.ts +3 -2
  231. package/package.json +1 -1
  232. package/scripts/bot-context.test.mts +1 -1
  233. package/skills/computer/SKILL.md +1 -0
  234. package/skills/interactive-page/SKILL.md +4 -0
  235. package/.next/server/chunks/[root-of-the-server]__0n-l_n_._.js +0 -3
  236. package/.next/server/chunks/[root-of-the-server]__0wvo2of._.js +0 -3
  237. package/.next/server/chunks/[root-of-the-server]__1e6aut3._.js +0 -3
  238. package/.next/server/chunks/[root-of-the-server]__1ziojdq._.js +0 -3
  239. package/.next/server/chunks/ssr/[root-of-the-server]__1srp8l5._.js +0 -3
  240. package/.next/server/chunks/ssr/_0mes4s0._.js +0 -3
  241. package/.next/server/chunks/ssr/_0ya0cb0._.js +0 -3
  242. package/.next/server/chunks/ssr/_0yto5s7._.js +0 -20
  243. package/.next/server/chunks/ssr/_10dw2nv._.js +0 -3
  244. package/.next/server/chunks/ssr/_1p0gxeo._.js +0 -164
  245. package/.next/server/chunks/ssr/_1r41yvh._.js +0 -3
  246. package/.next/server/chunks/ssr/_1r79ksv._.js +0 -3
  247. package/.next/server/chunks/ssr/features_bot_1ogjt52._.js +0 -3
  248. package/.next/server/chunks/ssr/node_modules_@base-ui_react_0b3d4m9._.js +0 -3
  249. package/.next/static/chunks/03omwz_vi2ndg.js +0 -1
  250. package/.next/static/chunks/0_1bqgd6wt_og.js +0 -1
  251. package/.next/static/chunks/0abgkzikaznxc.js +0 -1
  252. package/.next/static/chunks/0bddkpash59-o.js +0 -17
  253. package/.next/static/chunks/0c2-bzhy3a0ib.js +0 -1
  254. package/.next/static/chunks/0iilfb1ocevnx.js +0 -1
  255. package/.next/static/chunks/0imbhx-j830a_.css +0 -1
  256. package/.next/static/chunks/24h1y9dhy3m_2.js +0 -1
  257. package/.next/static/chunks/2be08eqxsfc1y.js +0 -1
  258. package/.next/static/chunks/2d3yhjtafgmtw.js +0 -1
  259. package/.next/static/chunks/2f_5saocsko_b.js +0 -1
  260. package/.next/static/chunks/2lwbr5vx0ixaj.js +0 -18
  261. package/.next/static/chunks/44kjmi5h_p8px.js +0 -1
  262. /package/.next/static/{UiOMPYpQ4fqGyu5PzS-7j → u8kJqShUAa49vY7BxAWsw}/_buildManifest.js +0 -0
  263. /package/.next/static/{UiOMPYpQ4fqGyu5PzS-7j → u8kJqShUAa49vY7BxAWsw}/_clientMiddlewareManifest.js +0 -0
  264. /package/.next/static/{UiOMPYpQ4fqGyu5PzS-7j → u8kJqShUAa49vY7BxAWsw}/_ssgManifest.js +0 -0
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
 
3
+ import { format, formatDistanceToNowStrict } from "date-fns";
3
4
  import {
5
+ CalendarDays,
4
6
  ChevronDown,
5
7
  ChevronRight,
6
8
  Loader2,
@@ -9,7 +11,7 @@ import {
9
11
  Trash2,
10
12
  X,
11
13
  } from "lucide-react";
12
- import { useCallback, useEffect, useState } from "react";
14
+ import { useCallback, useEffect, useRef, useState } from "react";
13
15
  import { queryKey } from "@/app/api/query-key";
14
16
  import { Button } from "@/components/ui/button";
15
17
  import {
@@ -56,6 +58,7 @@ import {
56
58
  updateRoutineAction,
57
59
  } from "../routine.action";
58
60
  import {
61
+ momentOf,
59
62
  nextRun,
60
63
  type Routine,
61
64
  type RoutineInput,
@@ -283,11 +286,23 @@ type Draft = {
283
286
  bot: string;
284
287
  request: string;
285
288
  kind: RoutineSchedule["kind"];
289
+ /** The time of day, for one that starts once and one on set days alike. */
286
290
  time: string;
287
291
  days: number[];
288
- hours: string;
292
+ hours: number;
293
+ /** The day one that starts once starts, "YYYY-MM-DD". */
294
+ date: string;
289
295
  };
290
296
 
297
+ const pad = (n: number) => String(n).padStart(2, "0");
298
+
299
+ /** A day in this machine's own time as "YYYY-MM-DD", `ahead` days after `from`. */
300
+ function dayOf(ahead: number, from = new Date()): string {
301
+ const at = new Date(from);
302
+ at.setDate(at.getDate() + ahead);
303
+ return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`;
304
+ }
305
+
291
306
  const draftOf = (routine: Routine | null, bots: Bot[]): Draft => {
292
307
  const schedule = routine?.schedule;
293
308
  return {
@@ -295,22 +310,44 @@ const draftOf = (routine: Routine | null, bots: Bot[]): Draft => {
295
310
  bot: routine?.bot ?? bots.find((bot) => !bot.disabled)?.name ?? "",
296
311
  request: routine?.request ?? "",
297
312
  kind: schedule?.kind ?? "daily",
298
- time: schedule?.kind === "daily" ? schedule.time : "09:00",
313
+ time:
314
+ schedule?.kind === "daily"
315
+ ? schedule.time
316
+ : schedule?.kind === "once"
317
+ ? schedule.at.slice(11)
318
+ : "09:00",
299
319
  days: schedule?.kind === "daily" ? schedule.days : [...WEEKDAYS],
300
- hours: schedule?.kind === "every" ? String(schedule.hours) : "6",
320
+ hours: schedule?.kind === "every" ? schedule.hours : 6,
321
+ date: schedule?.kind === "once" ? schedule.at.slice(0, 10) : dayOf(1),
301
322
  };
302
323
  };
303
324
 
304
- /** The schedule a draft spells; null while it spells none (no day picked, hours not a number). */
325
+ /** The schedule a draft spells; null while it spells none (no day picked, no time). */
305
326
  function scheduleOf(draft: Draft): RoutineSchedule | null {
327
+ const timed = /^\d\d:\d\d$/.test(draft.time);
328
+ if (draft.kind === "once")
329
+ return timed && draft.date
330
+ ? { kind: "once", at: `${draft.date} ${draft.time}` }
331
+ : null;
306
332
  if (draft.kind === "daily")
307
- return draft.days.length && /^\d\d:\d\d$/.test(draft.time)
333
+ return draft.days.length && timed
308
334
  ? { kind: "daily", time: draft.time, days: draft.days }
309
335
  : null;
310
- const hours = Number(draft.hours);
311
- return Number.isInteger(hours) && hours >= ROUTINE.minHours
312
- ? { kind: "every", hours }
313
- : null;
336
+ return { kind: "every", hours: draft.hours };
337
+ }
338
+
339
+ /** One that starts once, at a moment already gone: the server refuses it too (routine.query). */
340
+ const isSpent = (schedule: RoutineSchedule | null) =>
341
+ schedule?.kind === "once" && momentOf(schedule.at) <= new Date();
342
+
343
+ /** An hour from now, on the next five minutes: what "In an hour" sets. */
344
+ function inAnHour(): Pick<Draft, "date" | "time"> {
345
+ const at = new Date(Date.now() + 3_600_000);
346
+ at.setMinutes(Math.ceil(at.getMinutes() / 5) * 5, 0, 0);
347
+ return {
348
+ date: dayOf(0, at),
349
+ time: `${pad(at.getHours())}:${pad(at.getMinutes())}`,
350
+ };
314
351
  }
315
352
 
316
353
  const DAY_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
@@ -329,6 +366,10 @@ const DAY_WORDS = [
329
366
  "Saturday",
330
367
  "Sunday",
331
368
  ];
369
+ /** The gaps between starts most routines want; another one, set by a call, shows beside them. */
370
+ const HOUR_STEPS = [1, 2, 3, 6, 12, 24].filter(
371
+ (hours) => hours >= ROUTINE.minHours,
372
+ );
332
373
 
333
374
  /**
334
375
  * A routine on a sheet down the settings window's right edge, where a thread opens
@@ -378,7 +419,11 @@ function RoutineSheet({
378
419
 
379
420
  const schedule = scheduleOf(draft);
380
421
  const input: RoutineInput | null =
381
- schedule && draft.label.trim() && draft.bot && draft.request.trim()
422
+ schedule &&
423
+ !isSpent(schedule) &&
424
+ draft.label.trim() &&
425
+ draft.bot &&
426
+ draft.request.trim()
382
427
  ? {
383
428
  label: draft.label.trim(),
384
429
  bot: draft.bot,
@@ -388,13 +433,18 @@ function RoutineSheet({
388
433
  : null;
389
434
 
390
435
  /** One field of a routine that exists, written when it differs from what is kept. */
391
- const commit = (next: Partial<RoutineInput>) => {
436
+ const commit = (next: Partial<RoutineInput> & { enabled?: boolean }) => {
392
437
  if (saved) void update(saved.id, next).catch(() => {});
393
438
  };
394
439
  const commitSchedule = (next: Partial<Draft>) => {
395
440
  patch(next);
396
441
  const spelled = scheduleOf({ ...draft, ...next });
397
- if (spelled) commit({ schedule: spelled });
442
+ if (!spelled || isSpent(spelled)) return;
443
+ // Picking the moment of one that starts once sets it, whether or not it already ran
444
+ commit({
445
+ schedule: spelled,
446
+ ...(spelled.kind === "once" ? { enabled: true } : {}),
447
+ });
398
448
  };
399
449
 
400
450
  const confirmDelete = async () => {
@@ -593,137 +643,118 @@ function RoutineSheet({
593
643
  </Field>
594
644
 
595
645
  <Field label="When">
596
- <Segmented
597
- aria-label="How it repeats"
598
- value={draft.kind}
599
- onChange={(kind) => commitSchedule({ kind })}
600
- options={[
601
- { value: "daily", label: "At a set time" },
602
- { value: "every", label: "Every few hours" },
603
- ]}
604
- />
605
- {draft.kind === "daily" ? (
606
- <>
607
- <div className="flex flex-wrap items-center gap-2 pt-1">
608
- <span className="text-sm text-muted-foreground">
609
- At
610
- </span>
611
- <Input
612
- type="time"
613
- aria-label="Time of day"
614
- value={draft.time}
615
- onChange={(event) =>
616
- patch({ time: event.target.value })
617
- }
618
- onBlur={() => commitSchedule({})}
619
- className="w-36 font-mono text-[13px]"
620
- />
621
- <span className="text-sm text-muted-foreground">
622
- on
623
- </span>
624
- </div>
625
- <div className="flex flex-wrap gap-1">
626
- {WEEKDAYS.map((day) => {
627
- const on = draft.days.includes(day);
628
- return (
629
- <button
630
- key={day}
631
- type="button"
632
- aria-pressed={on}
633
- aria-label={DAY_WORDS[day - 1]}
634
- onClick={() =>
635
- commitSchedule({
636
- days: on
637
- ? draft.days.filter((one) => one !== day)
638
- : [...draft.days, day].sort(),
639
- })
640
- }
641
- className={cn(
642
- "flex h-7 items-center justify-center rounded-full px-2.5 text-[12px] outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/50",
643
- on
644
- ? "bg-primary text-primary-foreground"
645
- : "text-muted-foreground ring-1 ring-border ring-inset hover:text-foreground",
646
- )}
646
+ <div className="space-y-3.5">
647
+ <Segmented
648
+ aria-label="How it starts"
649
+ value={draft.kind}
650
+ onChange={(kind) => commitSchedule({ kind })}
651
+ options={[
652
+ { value: "once", label: "Once" },
653
+ { value: "daily", label: "On set days" },
654
+ { value: "every", label: "Every few hours" },
655
+ ]}
656
+ />
657
+ <div className="space-y-2.5">
658
+ {draft.kind === "once" ? (
659
+ <>
660
+ <WhenRow word="On">
661
+ <OnceDays
662
+ value={draft.date}
663
+ onPick={(date) => commitSchedule({ date })}
664
+ />
665
+ </WhenRow>
666
+ <WhenRow word="At">
667
+ <TimeField
668
+ value={draft.time}
669
+ onChange={(time) => patch({ time })}
670
+ onDone={() => commitSchedule({})}
671
+ />
672
+ <span className="text-[13px] text-muted-foreground">
673
+ or
674
+ </span>
675
+ <Pill
676
+ picked={false}
677
+ onClick={() => commitSchedule(inAnHour())}
647
678
  >
648
- {DAY_SHORT[day - 1]}
649
- </button>
650
- );
651
- })}
652
- </div>
653
- <div className="flex gap-3">
654
- {DAY_SETS.map((set) => {
655
- const on =
656
- set.days.length === draft.days.length &&
657
- set.days.every((day) => draft.days.includes(day));
658
- return (
659
- <button
660
- key={set.label}
661
- type="button"
662
- onClick={() =>
663
- commitSchedule({ days: [...set.days] })
664
- }
665
- className={cn(
666
- "rounded-sm text-xs outline-none transition-colors hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50",
667
- on
668
- ? "text-foreground underline underline-offset-4"
669
- : "text-muted-foreground",
670
- )}
679
+ In an hour
680
+ </Pill>
681
+ </WhenRow>
682
+ </>
683
+ ) : draft.kind === "daily" ? (
684
+ <>
685
+ <WhenRow word="On">
686
+ {WEEKDAYS.map((day) => {
687
+ const on = draft.days.includes(day);
688
+ return (
689
+ <Pill
690
+ key={day}
691
+ picked={on}
692
+ aria-label={DAY_WORDS[day - 1]}
693
+ onClick={() =>
694
+ commitSchedule({
695
+ days: on
696
+ ? draft.days.filter(
697
+ (one) => one !== day,
698
+ )
699
+ : [...draft.days, day].sort(
700
+ (a, b) => a - b,
701
+ ),
702
+ })
703
+ }
704
+ >
705
+ {DAY_SHORT[day - 1]}
706
+ </Pill>
707
+ );
708
+ })}
709
+ </WhenRow>
710
+ {/* Shortcuts that fill the days: what is on is what the days show */}
711
+ <WhenRow>
712
+ {DAY_SETS.map((set) => (
713
+ <button
714
+ key={set.label}
715
+ type="button"
716
+ onClick={() =>
717
+ commitSchedule({ days: [...set.days] })
718
+ }
719
+ className="h-6.5 rounded-full bg-muted px-2.5 text-xs text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50"
720
+ >
721
+ {set.label}
722
+ </button>
723
+ ))}
724
+ </WhenRow>
725
+ <WhenRow word="At">
726
+ <TimeField
727
+ value={draft.time}
728
+ onChange={(time) => patch({ time })}
729
+ onDone={() => commitSchedule({})}
730
+ />
731
+ </WhenRow>
732
+ </>
733
+ ) : (
734
+ <WhenRow word="Every">
735
+ {(HOUR_STEPS.includes(draft.hours)
736
+ ? HOUR_STEPS
737
+ : [...HOUR_STEPS, draft.hours].sort(
738
+ (a, b) => a - b,
739
+ )
740
+ ).map((hours) => (
741
+ <Pill
742
+ key={hours}
743
+ picked={hours === draft.hours}
744
+ onClick={() => commitSchedule({ hours })}
745
+ className="min-w-10 px-3"
671
746
  >
672
- {set.label}
673
- </button>
674
- );
675
- })}
676
- </div>
677
- </>
678
- ) : (
679
- <>
680
- <div className="flex items-center gap-2 pt-1">
681
- <span className="text-sm text-muted-foreground">
682
- Every
683
- </span>
684
- <Input
685
- inputMode="numeric"
686
- aria-label="Hours between starts"
687
- value={draft.hours}
688
- onChange={(event) =>
689
- patch({ hours: event.target.value })
690
- }
691
- onBlur={() => commitSchedule({})}
692
- className="w-16 font-mono text-[13px]"
693
- />
694
- <span className="text-sm text-muted-foreground">
695
- hours
696
- </span>
697
- </div>
698
- <Hint>
699
- No more often than every{" "}
700
- {ROUTINE.minHours === 1
701
- ? "hour"
702
- : `${ROUTINE.minHours} hours`}
703
- , counted from its last start.
704
- </Hint>
705
- </>
706
- )}
707
- {schedule ? (
708
- // What was picked, said back in words: the check that the form was read right
709
- <p className="flex items-center gap-1.5 pt-1 text-[13px]">
710
- <RoutineMark className="size-3.5 shrink-0 text-muted-foreground" />
711
- <span className="min-w-0 truncate">
712
- {scheduleText(schedule)}
713
- <span className="text-muted-foreground">
714
- {" "}
715
- · first start{" "}
716
- {whenOf(nextRun(schedule, new Date()))}
717
- </span>
718
- </span>
719
- </p>
720
- ) : (
721
- <p className="text-xs text-destructive">
722
- {draft.kind === "daily"
723
- ? "Pick a time and at least one day."
724
- : `A whole number of hours, ${ROUTINE.minHours} or more.`}
725
- </p>
726
- )}
747
+ {hours}
748
+ </Pill>
749
+ ))}
750
+ <span className="text-[13px] text-muted-foreground">
751
+ hours
752
+ </span>
753
+ </WhenRow>
754
+ )}
755
+ </div>
756
+ <WhenSaid schedule={schedule} kind={draft.kind} />
757
+ </div>
727
758
  </Field>
728
759
 
729
760
  <Field label="Job" htmlFor="routine-request">
@@ -842,7 +873,7 @@ function missingOf(draft: Draft, schedule: RoutineSchedule | null): string {
842
873
  const lacks = [
843
874
  draft.label.trim() ? null : "a name",
844
875
  draft.bot ? null : "a bot",
845
- schedule ? null : "a time",
876
+ !schedule ? "a time" : isSpent(schedule) ? "a time still ahead" : null,
846
877
  draft.request.trim() ? null : "a job",
847
878
  ].filter((one): one is string => one !== null);
848
879
  return lacks.length > 1
@@ -854,6 +885,164 @@ const Hint = ({ children }: { children: React.ReactNode }) => (
854
885
  <p className="text-xs text-muted-foreground">{children}</p>
855
886
  );
856
887
 
888
+ /**
889
+ * One choice of the When field. What is picked is filled black and what is not is a
890
+ * hairline, as everywhere that something is picked, so no pill is left to read twice.
891
+ */
892
+ function Pill({
893
+ picked,
894
+ className,
895
+ ...button
896
+ }: React.ComponentProps<"button"> & { picked: boolean }) {
897
+ return (
898
+ <button
899
+ type="button"
900
+ aria-pressed={picked}
901
+ className={cn(
902
+ "inline-flex h-8 items-center justify-center gap-1.5 rounded-full px-3.5 text-[13px] whitespace-nowrap outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/50",
903
+ picked
904
+ ? "bg-primary font-medium text-primary-foreground"
905
+ : "text-foreground ring-1 ring-border ring-inset hover:bg-muted",
906
+ className,
907
+ )}
908
+ {...button}
909
+ />
910
+ );
911
+ }
912
+
913
+ /** One question of the When field: its word in a column of its own, then the choices. */
914
+ function WhenRow({
915
+ word,
916
+ children,
917
+ }: {
918
+ word?: string;
919
+ children: React.ReactNode;
920
+ }) {
921
+ return (
922
+ <div className="flex items-start gap-2.5">
923
+ <span className="flex h-8 w-9 shrink-0 items-center text-[13px] text-muted-foreground">
924
+ {word}
925
+ </span>
926
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
927
+ {children}
928
+ </div>
929
+ </div>
930
+ );
931
+ }
932
+
933
+ /** "Sun 21" for a day this week, "Fri, Oct 3" further out. */
934
+ function dayName(day: string): string {
935
+ const at = momentOf(`${day} 00:00`);
936
+ const days = (at.getTime() - momentOf(`${dayOf(0)} 00:00`).getTime()) / 864e5;
937
+ return format(at, days < 7 ? "EEE d" : "EEE, MMM d");
938
+ }
939
+
940
+ /** Today, tomorrow and the two days after as pills; any other day from the calendar. */
941
+ function OnceDays({
942
+ value,
943
+ onPick,
944
+ }: {
945
+ value: string;
946
+ onPick: (day: string) => void;
947
+ }) {
948
+ const picker = useRef<HTMLInputElement>(null);
949
+ const near = [0, 1, 2, 3].map((ahead) => dayOf(ahead));
950
+ const open = () => picker.current?.showPicker();
951
+ return (
952
+ <>
953
+ {near.map((day, ahead) => (
954
+ <Pill key={day} picked={day === value} onClick={() => onPick(day)}>
955
+ {ahead === 0 ? "Today" : ahead === 1 ? "Tomorrow" : dayName(day)}
956
+ </Pill>
957
+ ))}
958
+ {!near.includes(value) && (
959
+ <Pill picked onClick={open}>
960
+ {dayName(value)}
961
+ </Pill>
962
+ )}
963
+ {/* The browser's own calendar, opened from the pill it sits under */}
964
+ <span className="relative">
965
+ <Pill
966
+ picked={false}
967
+ aria-label="Another day"
968
+ onClick={open}
969
+ className="w-8 px-0"
970
+ >
971
+ <CalendarDays className="size-3.5" />
972
+ </Pill>
973
+ <input
974
+ ref={picker}
975
+ type="date"
976
+ tabIndex={-1}
977
+ aria-hidden
978
+ min={near[0]}
979
+ value={value}
980
+ onChange={(event) => event.target.value && onPick(event.target.value)}
981
+ className="pointer-events-none absolute inset-0 opacity-0"
982
+ />
983
+ </span>
984
+ </>
985
+ );
986
+ }
987
+
988
+ /** A time of day, large, without the browser's clock glyph; a click opens its picker all the same. */
989
+ function TimeField({
990
+ value,
991
+ onChange,
992
+ onDone,
993
+ }: {
994
+ value: string;
995
+ onChange: (time: string) => void;
996
+ onDone: () => void;
997
+ }) {
998
+ return (
999
+ <Input
1000
+ type="time"
1001
+ aria-label="Time of day"
1002
+ value={value}
1003
+ onChange={(event) => onChange(event.target.value)}
1004
+ onBlur={onDone}
1005
+ onClick={(event) => event.currentTarget.showPicker?.()}
1006
+ className="h-8.5 w-28 px-3 text-[15px] tabular-nums md:text-[15px] [&::-webkit-calendar-picker-indicator]:hidden"
1007
+ />
1008
+ );
1009
+ }
1010
+
1011
+ /** What was picked, said back in words: the check that the form was read right. */
1012
+ function WhenSaid({
1013
+ schedule,
1014
+ kind,
1015
+ }: {
1016
+ schedule: RoutineSchedule | null;
1017
+ kind: RoutineSchedule["kind"];
1018
+ }) {
1019
+ if (!schedule || isSpent(schedule))
1020
+ return (
1021
+ <p className="text-xs text-destructive">
1022
+ {schedule
1023
+ ? "That time has passed. Pick a later one."
1024
+ : kind === "daily"
1025
+ ? "Pick a time and at least one day."
1026
+ : "Pick a time."}
1027
+ </p>
1028
+ );
1029
+ const first = nextRun(schedule, new Date());
1030
+ return (
1031
+ <p className="flex items-start gap-2 text-[13px] leading-5">
1032
+ <RoutineMark className="mt-0.75 size-3.5 shrink-0 text-muted-foreground" />
1033
+ <span className="min-w-0">
1034
+ {scheduleText(schedule)}
1035
+ <span className="text-muted-foreground">
1036
+ {" · "}
1037
+ {schedule.kind === "once"
1038
+ ? `${formatDistanceToNowStrict(first, { addSuffix: true })}, then it switches itself off`
1039
+ : `first start ${whenOf(first)}`}
1040
+ </span>
1041
+ </span>
1042
+ </p>
1043
+ );
1044
+ }
1045
+
857
1046
  /** A form row, labelled the way a bot's page labels its own. */
858
1047
  function Field({
859
1048
  label,
@@ -6,10 +6,12 @@ import { routineTable, threadTable } from "@/database/tables";
6
6
  import { findJobBot } from "@/features/bot/bot.query";
7
7
  import { publicError } from "@/lib/public-error";
8
8
  import {
9
+ momentOf,
9
10
  nextRun,
10
11
  type Routine,
11
12
  type RoutineInput,
12
13
  type RoutineRun,
14
+ type RoutineSchedule,
13
15
  } from "./routine.schema";
14
16
 
15
17
  // Routines and the threads they opened. A run is an ordinary thread carrying `routine_id`;
@@ -75,6 +77,16 @@ async function botNamed(name: string): Promise<string> {
75
77
  return bot.name;
76
78
  }
77
79
 
80
+ /** A routine that starts once, switched on, has to have its moment ahead of it. */
81
+ function refuseSpentMoment(schedule: RoutineSchedule, enabled: boolean) {
82
+ if (
83
+ enabled &&
84
+ schedule.kind === "once" &&
85
+ momentOf(schedule.at) <= new Date()
86
+ )
87
+ publicError("That time has passed. Pick a later one.");
88
+ }
89
+
78
90
  export async function createRoutine(input: RoutineInput): Promise<Routine> {
79
91
  const [{ total }] = await database
80
92
  .select({ total: count() })
@@ -83,6 +95,7 @@ export async function createRoutine(input: RoutineInput): Promise<Routine> {
83
95
  publicError(
84
96
  `There are already ${ROUTINE.max} routines. Delete one to make another.`,
85
97
  );
98
+ refuseSpentMoment(input.schedule, true);
86
99
  const [row] = await database
87
100
  .insert(routineTable)
88
101
  .values({
@@ -111,6 +124,8 @@ export async function updateRoutine(
111
124
  if (!was) return null;
112
125
  const schedule = patch.schedule ?? was.schedule;
113
126
  const restarts = Boolean(patch.schedule) || (patch.enabled && !was.enabled);
127
+ // only a moment being given or switched on is asked about: one waiting to start is due, not spent
128
+ if (restarts) refuseSpentMoment(schedule, patch.enabled ?? was.enabled);
114
129
  const [row] = await database
115
130
  .update(routineTable)
116
131
  .set({
@@ -147,16 +162,22 @@ export async function listDueRoutines(now: Date): Promise<Row[]> {
147
162
 
148
163
  /**
149
164
  * Moves a due routine on to its next time, and says whether this caller was the one to do
150
- * it. The start it was waiting for is part of the condition, so two ticks that read the
151
- * same due row open one thread between them.
165
+ * it; one that starts once has no next time and is switched off instead. The start it was
166
+ * waiting for is part of the condition, so two ticks that read the same due row open one
167
+ * thread between them.
152
168
  */
153
169
  export async function claimRoutine(row: Row, now: Date): Promise<boolean> {
154
170
  const claimed = await database
155
171
  .update(routineTable)
156
- .set({ nextRunAt: nextRun(row.schedule, now) })
172
+ .set(
173
+ row.schedule.kind === "once"
174
+ ? { enabled: false }
175
+ : { nextRunAt: nextRun(row.schedule, now) },
176
+ )
157
177
  .where(
158
178
  and(
159
179
  eq(routineTable.id, row.id),
180
+ eq(routineTable.enabled, true),
160
181
  eq(routineTable.nextRunAt, row.nextRunAt),
161
182
  ),
162
183
  )