savant-code 0.0.19 → 0.0.20

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 (1591) hide show
  1. package/README.md +381 -550
  2. package/{cli/release/index.js → index.js} +0 -0
  3. package/package.json +44 -71
  4. package/.agents/skills/coding-csharp/SKILL.md +0 -82
  5. package/.agents/skills/coding-go/SKILL.md +0 -76
  6. package/.agents/skills/coding-java/SKILL.md +0 -79
  7. package/.agents/skills/coding-python/SKILL.md +0 -87
  8. package/.agents/skills/coding-rust/SKILL.md +0 -83
  9. package/.agents/skills/coding-typescript/SKILL.md +0 -90
  10. package/.agents/skills/release-workflow/SKILL.md +0 -69
  11. package/.bun-version +0 -1
  12. package/.commandcode/settings.json +0 -11
  13. package/.commandcode/taste/workflow/taste.md +0 -6
  14. package/.env.example +0 -95
  15. package/.gitattributes +0 -43
  16. package/.githooks/pre-push +0 -35
  17. package/.github/workflows/build-release-binaries.yml +0 -163
  18. package/.markdownlint.json +0 -42
  19. package/.markdownlintignore +0 -21
  20. package/.prettierignore +0 -9
  21. package/.prettierrc +0 -23
  22. package/.savantignore +0 -3
  23. package/AGENTS.md +0 -97
  24. package/ARCHITECTURE.md +0 -241
  25. package/CHANGELOG.md +0 -2631
  26. package/CONTRIBUTING.md +0 -88
  27. package/ECHO-freebuff.md +0 -22
  28. package/ECHO.md +0 -722
  29. package/FREEREADME.md +0 -35
  30. package/LEARNINGS.md +0 -345
  31. package/NOTICE +0 -15
  32. package/README.zh-CN.md +0 -479
  33. package/SECURITY.md +0 -8
  34. package/VERSION +0 -1
  35. package/WINDOWS.md +0 -238
  36. package/agents/README.md +0 -41
  37. package/agents/base-chat.ts +0 -44
  38. package/agents/basher.ts +0 -107
  39. package/agents/browser-use/browser-use.test.ts +0 -211
  40. package/agents/browser-use/browser-use.ts +0 -259
  41. package/agents/constants.ts +0 -1
  42. package/agents/context-pruner.ts +0 -1076
  43. package/agents/detective/detective.ts +0 -168
  44. package/agents/editor/best-of-n/best-of-n-selector2.ts +0 -148
  45. package/agents/editor/best-of-n/editor-implementor-gpt-5.ts +0 -7
  46. package/agents/editor/best-of-n/editor-implementor-opus.ts +0 -7
  47. package/agents/editor/best-of-n/editor-implementor.ts +0 -196
  48. package/agents/editor/best-of-n/editor-multi-prompt.ts +0 -265
  49. package/agents/file-explorer/directory-lister.ts +0 -92
  50. package/agents/file-explorer/glob-matcher.ts +0 -96
  51. package/agents/forge/forge.ts +0 -146
  52. package/agents/librarian/librarian.test.ts +0 -289
  53. package/agents/librarian/librarian.ts +0 -158
  54. package/agents/package.json +0 -11
  55. package/agents/recorder/recorder.ts +0 -104
  56. package/agents/researcher/researcher-docs.ts +0 -32
  57. package/agents/researcher/researcher-web.ts +0 -40
  58. package/agents/savant/savant-analyze.ts +0 -11
  59. package/agents/savant/savant-free-deepseek-flash.ts +0 -13
  60. package/agents/savant/savant-free-deepseek.ts +0 -13
  61. package/agents/savant/savant-free-evals.ts +0 -9
  62. package/agents/savant/savant-free-glm.ts +0 -13
  63. package/agents/savant/savant-free-kimi.ts +0 -13
  64. package/agents/savant/savant-free-mimo-pro.ts +0 -13
  65. package/agents/savant/savant-free-mimo.ts +0 -13
  66. package/agents/savant/savant-free-minimax-m3.ts +0 -13
  67. package/agents/savant/savant-free.ts +0 -9
  68. package/agents/savant/savant-scaffold.ts +0 -15
  69. package/agents/savant/savant.ts +0 -706
  70. package/agents/scout/scout.ts +0 -305
  71. package/agents/scribe/scribe.ts +0 -54
  72. package/agents/thinker/thinker-gemini.ts +0 -26
  73. package/agents/thinker/thinker-gpt.ts +0 -21
  74. package/agents/thinker/thinker-with-files-gemini.ts +0 -66
  75. package/agents/thinker/thinker.ts +0 -99
  76. package/agents/tmux-cli.ts +0 -724
  77. package/agents/tsconfig.json +0 -12
  78. package/agents/types/agent-definition.ts +0 -1
  79. package/agents/types/secret-agent-definition.ts +0 -103
  80. package/agents/types/tools.ts +0 -465
  81. package/agents/types/util-types.ts +0 -167
  82. package/agents/verifier/verifier.ts +0 -80
  83. package/art/savant-redesign.md +0 -7
  84. package/art/savant-text-graf.md +0 -5
  85. package/art/savant_ascii.md +0 -88
  86. package/assets/banner.png +0 -0
  87. package/bun.lock +0 -1979
  88. package/bunfig.toml +0 -7
  89. package/cli/README.md +0 -111
  90. package/cli/bunfig.toml +0 -2
  91. package/cli/cli_exit +0 -1
  92. package/cli/package.json +0 -66
  93. package/cli/release/README.md +0 -348
  94. package/cli/release/package.json +0 -56
  95. package/cli/release-core/README.md +0 -20
  96. package/cli/release-core/package.json +0 -4
  97. package/cli/release-core/prepare-package.js +0 -31
  98. package/cli/release-staging/README.md +0 -70
  99. package/cli/release-staging/index.js +0 -42
  100. package/cli/release-staging/package.json +0 -43
  101. package/cli/scripts/build-binary.ts +0 -469
  102. package/cli/scripts/clean.ts +0 -16
  103. package/cli/scripts/prebuild-agents.ts +0 -250
  104. package/cli/scripts/release.ts +0 -103
  105. package/cli/scripts/smoke-binary.ts +0 -280
  106. package/cli/scripts/test-sdk-file-hooks.sh +0 -87
  107. package/cli/scripts/validate-cli-with-tmux.sh +0 -43
  108. package/cli/src/__tests__/README.md +0 -256
  109. package/cli/src/__tests__/bash-mode.test.ts +0 -442
  110. package/cli/src/__tests__/cli-args.test.ts +0 -241
  111. package/cli/src/__tests__/e2e/first-time-login.test.ts +0 -173
  112. package/cli/src/__tests__/e2e/logout-relogin-flow.test.ts +0 -110
  113. package/cli/src/__tests__/e2e/returning-user-auth.test.ts +0 -111
  114. package/cli/src/__tests__/e2e-cli.test.ts +0 -185
  115. package/cli/src/__tests__/helpers/mock-api-client.ts +0 -64
  116. package/cli/src/__tests__/helpers/terminal-watchdog-fixture.ts +0 -50
  117. package/cli/src/__tests__/home-directory-detection.test.ts +0 -213
  118. package/cli/src/__tests__/integration/agent-toolnames-validation.test.ts +0 -63
  119. package/cli/src/__tests__/integration/api-integration.test.ts +0 -289
  120. package/cli/src/__tests__/integration/credentials-storage.test.ts +0 -453
  121. package/cli/src/__tests__/integration/local-agents.test.ts +0 -1187
  122. package/cli/src/__tests__/integration/login-flow-instrumentation.test.ts +0 -146
  123. package/cli/src/__tests__/integration/login-polling-working.test.ts +0 -287
  124. package/cli/src/__tests__/integration/usage-refresh-on-completion.test.ts +0 -177
  125. package/cli/src/__tests__/integration-tmux.test.ts +0 -186
  126. package/cli/src/__tests__/mocks/hover-toggle-controller.ts +0 -71
  127. package/cli/src/__tests__/path-completion.test.ts +0 -243
  128. package/cli/src/__tests__/project-files-chat-id.test.ts +0 -34
  129. package/cli/src/__tests__/release/proxy-http-get.test.ts +0 -670
  130. package/cli/src/__tests__/release/wrapper-safety.test.ts +0 -177
  131. package/cli/src/__tests__/rerender-perf.integration.test.ts +0 -283
  132. package/cli/src/__tests__/terminal-reset-sequences.test.ts +0 -36
  133. package/cli/src/__tests__/terminal-watchdog.test.ts +0 -114
  134. package/cli/src/__tests__/test-utils.ts +0 -266
  135. package/cli/src/__tests__/tmux-poc.ts +0 -150
  136. package/cli/src/__tests__/unit/agent-mode-toggle.test.ts +0 -138
  137. package/cli/src/__tests__/unit/copy-button.test.tsx +0 -188
  138. package/cli/src/__tests__/unit/create-run-config.test.ts +0 -77
  139. package/cli/src/__tests__/unit/publish-confirmation.test.ts +0 -70
  140. package/cli/src/__tests__/unit/segmented-control.test.ts +0 -167
  141. package/cli/src/__tests__/utils/env.test.ts +0 -210
  142. package/cli/src/__tests__/utils/project-picker.test.ts +0 -39
  143. package/cli/src/agents/bundled-agents.generated.d.ts +0 -14
  144. package/cli/src/agents/bundled-agents.generated.ts +0 -1962
  145. package/cli/src/app.tsx +0 -383
  146. package/cli/src/chat.tsx +0 -2008
  147. package/cli/src/cli-args.ts +0 -183
  148. package/cli/src/commands/__tests__/bash-command.test.ts +0 -449
  149. package/cli/src/commands/__tests__/command-args.test.ts +0 -325
  150. package/cli/src/commands/__tests__/copy-conversation.test.ts +0 -240
  151. package/cli/src/commands/__tests__/dev-command.test.ts +0 -128
  152. package/cli/src/commands/__tests__/diagnostics-command.test.ts +0 -35
  153. package/cli/src/commands/__tests__/image.test.ts +0 -99
  154. package/cli/src/commands/__tests__/init.test.ts +0 -479
  155. package/cli/src/commands/__tests__/loop-command.test.ts +0 -89
  156. package/cli/src/commands/__tests__/new-command-rotates-chat.test.ts +0 -87
  157. package/cli/src/commands/__tests__/permissions-command.test.ts +0 -93
  158. package/cli/src/commands/__tests__/process-diagnostics.test.ts +0 -54
  159. package/cli/src/commands/__tests__/prompt-builders.test.ts +0 -69
  160. package/cli/src/commands/__tests__/registry-gating.test.ts +0 -125
  161. package/cli/src/commands/__tests__/rewind-command.test.ts +0 -227
  162. package/cli/src/commands/__tests__/router-connect-chatgpt.test.ts +0 -72
  163. package/cli/src/commands/__tests__/router-input.test.ts +0 -302
  164. package/cli/src/commands/__tests__/router-provider-setup.test.ts +0 -180
  165. package/cli/src/commands/__tests__/savant-free-command-aliases.test.ts +0 -56
  166. package/cli/src/commands/ads.ts +0 -45
  167. package/cli/src/commands/command-registry.ts +0 -1112
  168. package/cli/src/commands/copy-conversation.ts +0 -386
  169. package/cli/src/commands/goal.ts +0 -87
  170. package/cli/src/commands/health-command.ts +0 -61
  171. package/cli/src/commands/help.ts +0 -13
  172. package/cli/src/commands/image.ts +0 -20
  173. package/cli/src/commands/init.ts +0 -121
  174. package/cli/src/commands/loop.ts +0 -182
  175. package/cli/src/commands/process-diagnostics.ts +0 -120
  176. package/cli/src/commands/prompt-builders.ts +0 -140
  177. package/cli/src/commands/publish.ts +0 -261
  178. package/cli/src/commands/rewind.ts +0 -130
  179. package/cli/src/commands/router-utils.ts +0 -80
  180. package/cli/src/commands/router.ts +0 -589
  181. package/cli/src/commands/telemetry.ts +0 -68
  182. package/cli/src/commands/usage.ts +0 -26
  183. package/cli/src/components/__tests__/ad-banner.test.tsx +0 -84
  184. package/cli/src/components/__tests__/block-helpers.test.tsx +0 -139
  185. package/cli/src/components/__tests__/clickable.test.tsx +0 -39
  186. package/cli/src/components/__tests__/grid-layout.integration.test.tsx +0 -365
  187. package/cli/src/components/__tests__/grid-layout.test.tsx +0 -1033
  188. package/cli/src/components/__tests__/markdown-content.test.tsx +0 -163
  189. package/cli/src/components/__tests__/message-block.completion.test.tsx +0 -104
  190. package/cli/src/components/__tests__/message-block.streaming.test.tsx +0 -78
  191. package/cli/src/components/__tests__/message-with-agents.test.tsx +0 -571
  192. package/cli/src/components/__tests__/multiline-input.test.tsx +0 -1129
  193. package/cli/src/components/__tests__/selectable-list.test.ts +0 -232
  194. package/cli/src/components/__tests__/shimmer-text.test.tsx +0 -32
  195. package/cli/src/components/__tests__/status-indicator.test.tsx +0 -184
  196. package/cli/src/components/__tests__/user-error-banner.test.tsx +0 -100
  197. package/cli/src/components/ad-banner.tsx +0 -351
  198. package/cli/src/components/agent-checklist.tsx +0 -385
  199. package/cli/src/components/agent-mode-toggle.tsx +0 -294
  200. package/cli/src/components/ask-user/__tests__/multiple-choice-form.test.ts +0 -432
  201. package/cli/src/components/ask-user/__tests__/validation.test.ts +0 -213
  202. package/cli/src/components/ask-user/components/accordion-question.tsx +0 -153
  203. package/cli/src/components/ask-user/components/custom-answer-input.tsx +0 -66
  204. package/cli/src/components/ask-user/components/options-list.tsx +0 -139
  205. package/cli/src/components/ask-user/components/question-header.tsx +0 -80
  206. package/cli/src/components/ask-user/components/question-option.tsx +0 -94
  207. package/cli/src/components/ask-user/constants.ts +0 -35
  208. package/cli/src/components/ask-user/index.tsx +0 -660
  209. package/cli/src/components/ask-user/utils/validation.ts +0 -85
  210. package/cli/src/components/attachment-card.tsx +0 -67
  211. package/cli/src/components/blocks/agent-block-grid.tsx +0 -58
  212. package/cli/src/components/blocks/agent-branch-item.tsx +0 -230
  213. package/cli/src/components/blocks/agent-branch-wrapper.tsx +0 -524
  214. package/cli/src/components/blocks/agent-list-branch.tsx +0 -77
  215. package/cli/src/components/blocks/ask-user-branch.tsx +0 -98
  216. package/cli/src/components/blocks/block-helpers.tsx +0 -257
  217. package/cli/src/components/blocks/blocks-renderer.tsx +0 -296
  218. package/cli/src/components/blocks/content-with-markdown.tsx +0 -34
  219. package/cli/src/components/blocks/copy-button.tsx +0 -106
  220. package/cli/src/components/blocks/copyable-block.tsx +0 -47
  221. package/cli/src/components/blocks/image-block.tsx +0 -128
  222. package/cli/src/components/blocks/implementor-row.tsx +0 -453
  223. package/cli/src/components/blocks/markdown-content.tsx +0 -360
  224. package/cli/src/components/blocks/markdown-renderables.tsx +0 -347
  225. package/cli/src/components/blocks/single-block.tsx +0 -210
  226. package/cli/src/components/blocks/thinking-block.tsx +0 -77
  227. package/cli/src/components/blocks/tool-block-group.tsx +0 -72
  228. package/cli/src/components/blocks/tool-branch.tsx +0 -196
  229. package/cli/src/components/blocks/user-content-copy.tsx +0 -171
  230. package/cli/src/components/bottom-banner.tsx +0 -145
  231. package/cli/src/components/build-mode-buttons.tsx +0 -110
  232. package/cli/src/components/button.tsx +0 -75
  233. package/cli/src/components/chat-header.tsx +0 -11
  234. package/cli/src/components/chat-history-screen.tsx +0 -417
  235. package/cli/src/components/chat-input-bar.tsx +0 -535
  236. package/cli/src/components/chatgpt-connect-banner.tsx +0 -234
  237. package/cli/src/components/clickable.tsx +0 -149
  238. package/cli/src/components/collapse-button.tsx +0 -36
  239. package/cli/src/components/command-palette.tsx +0 -166
  240. package/cli/src/components/copy-button.tsx +0 -219
  241. package/cli/src/components/dialog.tsx +0 -111
  242. package/cli/src/components/elapsed-timer.tsx +0 -56
  243. package/cli/src/components/error-boundary.tsx +0 -63
  244. package/cli/src/components/feedback-container.tsx +0 -206
  245. package/cli/src/components/feedback-icon-button.tsx +0 -89
  246. package/cli/src/components/feedback-input-mode.tsx +0 -352
  247. package/cli/src/components/file-attachment-card.tsx +0 -89
  248. package/cli/src/components/grid-layout.tsx +0 -83
  249. package/cli/src/components/help-banner.tsx +0 -136
  250. package/cli/src/components/highlighted-text.tsx +0 -52
  251. package/cli/src/components/image-card.tsx +0 -167
  252. package/cli/src/components/image-thumbnail.tsx +0 -112
  253. package/cli/src/components/input-cursor.tsx +0 -77
  254. package/cli/src/components/input-mode-banner.tsx +0 -70
  255. package/cli/src/components/load-previous-button.tsx +0 -48
  256. package/cli/src/components/login-modal.tsx +0 -450
  257. package/cli/src/components/message-block.tsx +0 -337
  258. package/cli/src/components/message-footer.tsx +0 -269
  259. package/cli/src/components/message-with-agents.tsx +0 -594
  260. package/cli/src/components/mode-divider.tsx +0 -43
  261. package/cli/src/components/model-picker.tsx +0 -371
  262. package/cli/src/components/multiline-input.tsx +0 -1276
  263. package/cli/src/components/out-of-credits-banner.tsx +0 -155
  264. package/cli/src/components/pending-attachments-banner.tsx +0 -125
  265. package/cli/src/components/pending-bash-message.tsx +0 -57
  266. package/cli/src/components/progress-bar.tsx +0 -81
  267. package/cli/src/components/project-picker-screen.tsx +0 -469
  268. package/cli/src/components/provider-picker.tsx +0 -139
  269. package/cli/src/components/publish-confirmation.tsx +0 -514
  270. package/cli/src/components/publish-container.tsx +0 -737
  271. package/cli/src/components/raised-pill.tsx +0 -89
  272. package/cli/src/components/renderers/plan-box.tsx +0 -70
  273. package/cli/src/components/review-screen.tsx +0 -114
  274. package/cli/src/components/rewind-picker.tsx +0 -276
  275. package/cli/src/components/right-sidebar.tsx +0 -296
  276. package/cli/src/components/savant-free-active-session-summary.tsx +0 -63
  277. package/cli/src/components/savant-free-landing-screen.tsx +0 -756
  278. package/cli/src/components/savant-free-model-selector.tsx +0 -815
  279. package/cli/src/components/savant-free-referral-banner.tsx +0 -423
  280. package/cli/src/components/savant-free-superseded-screen.tsx +0 -62
  281. package/cli/src/components/savant-ui/animation/pulse.tsx +0 -26
  282. package/cli/src/components/savant-ui/animation/typewriter.tsx +0 -40
  283. package/cli/src/components/savant-ui/branding.tsx +0 -59
  284. package/cli/src/components/savant-ui/data-display/badge.tsx +0 -52
  285. package/cli/src/components/savant-ui/data-display/code-block.tsx +0 -55
  286. package/cli/src/components/savant-ui/data-display/key-value.tsx +0 -34
  287. package/cli/src/components/savant-ui/data-display/sparkline.tsx +0 -42
  288. package/cli/src/components/savant-ui/data-display/timeline.tsx +0 -44
  289. package/cli/src/components/savant-ui/data-display/tree-view.tsx +0 -55
  290. package/cli/src/components/savant-ui/echo/__tests__/sidebar-collapse.test.tsx +0 -90
  291. package/cli/src/components/savant-ui/echo/agent-stack.tsx +0 -72
  292. package/cli/src/components/savant-ui/echo/agent-status.tsx +0 -66
  293. package/cli/src/components/savant-ui/echo/fid-card.tsx +0 -89
  294. package/cli/src/components/savant-ui/echo/fid-list.tsx +0 -68
  295. package/cli/src/components/savant-ui/echo/loop-status-panel.tsx +0 -64
  296. package/cli/src/components/savant-ui/echo/perfection-loop.tsx +0 -118
  297. package/cli/src/components/savant-ui/echo/phase-indicator.tsx +0 -68
  298. package/cli/src/components/savant-ui/echo/phase-info.ts +0 -72
  299. package/cli/src/components/savant-ui/echo/token-meter.tsx +0 -39
  300. package/cli/src/components/savant-ui/feedback/alert.tsx +0 -45
  301. package/cli/src/components/savant-ui/feedback/cost-tracker.tsx +0 -54
  302. package/cli/src/components/savant-ui/feedback/progress-bar.tsx +0 -71
  303. package/cli/src/components/savant-ui/feedback/spinner.tsx +0 -40
  304. package/cli/src/components/savant-ui/icon-theme-keys.ts +0 -67
  305. package/cli/src/components/savant-ui/icon.tsx +0 -53
  306. package/cli/src/components/savant-ui/index.ts +0 -75
  307. package/cli/src/components/savant-ui/input/select.tsx +0 -26
  308. package/cli/src/components/savant-ui/input/toggle.tsx +0 -28
  309. package/cli/src/components/savant-ui/layout/grid.tsx +0 -52
  310. package/cli/src/components/savant-ui/layout/header.tsx +0 -28
  311. package/cli/src/components/savant-ui/navigation/stepper.tsx +0 -41
  312. package/cli/src/components/savant-ui/primitives/key-hint.tsx +0 -45
  313. package/cli/src/components/savant-ui/primitives/key-value-row.tsx +0 -41
  314. package/cli/src/components/savant-ui/primitives/panel.tsx +0 -42
  315. package/cli/src/components/savant-ui/primitives/separator.tsx +0 -34
  316. package/cli/src/components/savant-ui/primitives/sidebar-section.tsx +0 -64
  317. package/cli/src/components/savant-ui/primitives/spacer.tsx +0 -13
  318. package/cli/src/components/savant-ui/primitives/stack.tsx +0 -35
  319. package/cli/src/components/savant-ui/theme.ts +0 -45
  320. package/cli/src/components/scroll-to-bottom-button.tsx +0 -34
  321. package/cli/src/components/segmented-control.tsx +0 -199
  322. package/cli/src/components/selectable-list.tsx +0 -249
  323. package/cli/src/components/selected-chips.tsx +0 -86
  324. package/cli/src/components/separator.tsx +0 -40
  325. package/cli/src/components/session-ended-banner.tsx +0 -183
  326. package/cli/src/components/shimmer-text.tsx +0 -254
  327. package/cli/src/components/status-bar.tsx +0 -279
  328. package/cli/src/components/subscription-limit-banner.tsx +0 -269
  329. package/cli/src/components/suggested-prompts.tsx +0 -184
  330. package/cli/src/components/suggestion-menu.tsx +0 -219
  331. package/cli/src/components/terminal-command-display.tsx +0 -152
  332. package/cli/src/components/terminal-link.tsx +0 -97
  333. package/cli/src/components/text-attachment-card.tsx +0 -77
  334. package/cli/src/components/thinking.tsx +0 -108
  335. package/cli/src/components/toast.tsx +0 -109
  336. package/cli/src/components/tools/__tests__/apply-patch.test.tsx +0 -97
  337. package/cli/src/components/tools/__tests__/code-search.test.tsx +0 -45
  338. package/cli/src/components/tools/__tests__/gravity-index.test.ts +0 -76
  339. package/cli/src/components/tools/__tests__/render-ui.test.tsx +0 -556
  340. package/cli/src/components/tools/__tests__/run-terminal-command.test.ts +0 -274
  341. package/cli/src/components/tools/__tests__/tool-call-item.test.tsx +0 -73
  342. package/cli/src/components/tools/apply-patch.tsx +0 -101
  343. package/cli/src/components/tools/code-search.tsx +0 -50
  344. package/cli/src/components/tools/composio.tsx +0 -140
  345. package/cli/src/components/tools/diff-viewer.tsx +0 -21
  346. package/cli/src/components/tools/glob.tsx +0 -66
  347. package/cli/src/components/tools/gravity-index.tsx +0 -80
  348. package/cli/src/components/tools/list-directory.tsx +0 -66
  349. package/cli/src/components/tools/read-docs.tsx +0 -36
  350. package/cli/src/components/tools/read-files.tsx +0 -97
  351. package/cli/src/components/tools/read-subtree.tsx +0 -40
  352. package/cli/src/components/tools/read-url.tsx +0 -32
  353. package/cli/src/components/tools/registry.ts +0 -131
  354. package/cli/src/components/tools/render-ui.tsx +0 -466
  355. package/cli/src/components/tools/run-terminal-command.tsx +0 -86
  356. package/cli/src/components/tools/skill.tsx +0 -26
  357. package/cli/src/components/tools/str-replace.tsx +0 -77
  358. package/cli/src/components/tools/suggest-followups.tsx +0 -373
  359. package/cli/src/components/tools/task-completed.tsx +0 -16
  360. package/cli/src/components/tools/tool-call-item.tsx +0 -163
  361. package/cli/src/components/tools/types.ts +0 -100
  362. package/cli/src/components/tools/web-search.tsx +0 -32
  363. package/cli/src/components/tools/write-file.tsx +0 -20
  364. package/cli/src/components/tools/write-todos.tsx +0 -105
  365. package/cli/src/components/top-banner.tsx +0 -187
  366. package/cli/src/components/usage-banner.tsx +0 -296
  367. package/cli/src/components/user-error-banner.tsx +0 -54
  368. package/cli/src/components/validation-error-popover.tsx +0 -191
  369. package/cli/src/data/slash-commands.ts +0 -306
  370. package/cli/src/hooks/__tests__/holds-live-savant-free-slot.test.ts +0 -49
  371. package/cli/src/hooks/__tests__/run-outcome.test.ts +0 -178
  372. package/cli/src/hooks/__tests__/session-fetch-signal.test.ts +0 -45
  373. package/cli/src/hooks/__tests__/use-activity-query.test.ts +0 -810
  374. package/cli/src/hooks/__tests__/use-ask-user-bridge.test.ts +0 -175
  375. package/cli/src/hooks/__tests__/use-auth-query.test.ts +0 -21
  376. package/cli/src/hooks/__tests__/use-chat-messages-collapse.test.ts +0 -96
  377. package/cli/src/hooks/__tests__/use-connection-status.test.ts +0 -112
  378. package/cli/src/hooks/__tests__/use-directory-browser.test.ts +0 -215
  379. package/cli/src/hooks/__tests__/use-gravity-ad.test.ts +0 -73
  380. package/cli/src/hooks/__tests__/use-grid-layout.test.ts +0 -346
  381. package/cli/src/hooks/__tests__/use-input-history.test.ts +0 -704
  382. package/cli/src/hooks/__tests__/use-loop-scheduler.test.ts +0 -247
  383. package/cli/src/hooks/__tests__/use-path-tab-completion.test.ts +0 -324
  384. package/cli/src/hooks/__tests__/use-queue-controls.test.ts +0 -68
  385. package/cli/src/hooks/__tests__/use-searchable-list.test.ts +0 -364
  386. package/cli/src/hooks/__tests__/use-send-message-timer.test.ts +0 -146
  387. package/cli/src/hooks/__tests__/use-suggestion-engine-mention.test.ts +0 -364
  388. package/cli/src/hooks/__tests__/use-suggestion-engine.test.ts +0 -485
  389. package/cli/src/hooks/__tests__/use-terminal-focus.test.ts +0 -66
  390. package/cli/src/hooks/__tests__/use-terminal-layout.test.ts +0 -675
  391. package/cli/src/hooks/__tests__/use-timeout.test.ts +0 -330
  392. package/cli/src/hooks/__tests__/use-usage-query.test.ts +0 -526
  393. package/cli/src/hooks/__tests__/use-user-details-query.test.ts +0 -225
  394. package/cli/src/hooks/helpers/__tests__/send-message.test.ts +0 -1851
  395. package/cli/src/hooks/helpers/send-message.ts +0 -553
  396. package/cli/src/hooks/run-outcome.ts +0 -64
  397. package/cli/src/hooks/stream-state.ts +0 -106
  398. package/cli/src/hooks/use-activity-query.ts +0 -696
  399. package/cli/src/hooks/use-agent-validation.ts +0 -80
  400. package/cli/src/hooks/use-ask-user-bridge.ts +0 -102
  401. package/cli/src/hooks/use-auth-query.ts +0 -261
  402. package/cli/src/hooks/use-auth-state.ts +0 -155
  403. package/cli/src/hooks/use-chat-input.ts +0 -87
  404. package/cli/src/hooks/use-chat-keyboard.ts +0 -325
  405. package/cli/src/hooks/use-chat-messages.ts +0 -273
  406. package/cli/src/hooks/use-chat-state.ts +0 -219
  407. package/cli/src/hooks/use-chat-streaming.ts +0 -270
  408. package/cli/src/hooks/use-chat-ui.ts +0 -136
  409. package/cli/src/hooks/use-clipboard.ts +0 -154
  410. package/cli/src/hooks/use-connection-status.ts +0 -156
  411. package/cli/src/hooks/use-directory-browser.ts +0 -88
  412. package/cli/src/hooks/use-elapsed-time.ts +0 -116
  413. package/cli/src/hooks/use-event.ts +0 -36
  414. package/cli/src/hooks/use-exit-handler.ts +0 -108
  415. package/cli/src/hooks/use-fetch-login-url.ts +0 -65
  416. package/cli/src/hooks/use-fids.ts +0 -48
  417. package/cli/src/hooks/use-fingerprint.ts +0 -64
  418. package/cli/src/hooks/use-gravity-ad.ts +0 -688
  419. package/cli/src/hooks/use-grid-layout.ts +0 -78
  420. package/cli/src/hooks/use-input-history.ts +0 -173
  421. package/cli/src/hooks/use-login-keyboard-handlers.ts +0 -74
  422. package/cli/src/hooks/use-login-polling.ts +0 -120
  423. package/cli/src/hooks/use-logo.tsx +0 -178
  424. package/cli/src/hooks/use-loop-scheduler.ts +0 -481
  425. package/cli/src/hooks/use-message-queue.ts +0 -301
  426. package/cli/src/hooks/use-now.ts +0 -20
  427. package/cli/src/hooks/use-path-tab-completion.ts +0 -88
  428. package/cli/src/hooks/use-publish-mutation.ts +0 -60
  429. package/cli/src/hooks/use-queue-controls.ts +0 -70
  430. package/cli/src/hooks/use-queue-ui.ts +0 -55
  431. package/cli/src/hooks/use-savant-free-ctrl-c-exit.ts +0 -23
  432. package/cli/src/hooks/use-savant-free-session-progress.ts +0 -34
  433. package/cli/src/hooks/use-savant-free-session.ts +0 -679
  434. package/cli/src/hooks/use-savant-free-streak-query.ts +0 -72
  435. package/cli/src/hooks/use-scaffold-confirm.ts +0 -62
  436. package/cli/src/hooks/use-scaffold-revert-subscriber.ts +0 -66
  437. package/cli/src/hooks/use-scroll-management.ts +0 -182
  438. package/cli/src/hooks/use-searchable-list.ts +0 -99
  439. package/cli/src/hooks/use-send-message.ts +0 -1038
  440. package/cli/src/hooks/use-sheen-animation.tsx +0 -88
  441. package/cli/src/hooks/use-subscription-query.ts +0 -106
  442. package/cli/src/hooks/use-suggestion-engine.ts +0 -793
  443. package/cli/src/hooks/use-terminal-breakpoints.ts +0 -77
  444. package/cli/src/hooks/use-terminal-dimensions.ts +0 -50
  445. package/cli/src/hooks/use-terminal-focus.ts +0 -146
  446. package/cli/src/hooks/use-terminal-layout.ts +0 -189
  447. package/cli/src/hooks/use-theme.tsx +0 -140
  448. package/cli/src/hooks/use-timeout.ts +0 -88
  449. package/cli/src/hooks/use-toast.ts +0 -114
  450. package/cli/src/hooks/use-update-preference.ts +0 -77
  451. package/cli/src/hooks/use-usage-monitor.ts +0 -59
  452. package/cli/src/hooks/use-usage-query.ts +0 -138
  453. package/cli/src/hooks/use-user-details-query.ts +0 -104
  454. package/cli/src/hooks/use-why-did-you-update.ts +0 -178
  455. package/cli/src/index.tsx +0 -484
  456. package/cli/src/init/__tests__/init-direnv.test.ts +0 -597
  457. package/cli/src/init/init-app.ts +0 -54
  458. package/cli/src/init/init-direnv.ts +0 -133
  459. package/cli/src/login/constants.ts +0 -71
  460. package/cli/src/login/login-flow.ts +0 -224
  461. package/cli/src/login/plain-login.ts +0 -107
  462. package/cli/src/login/utils.ts +0 -183
  463. package/cli/src/native/ripgrep.ts +0 -82
  464. package/cli/src/polyfills/bun-strip-ansi.ts +0 -12
  465. package/cli/src/pre-init/load-dev-env.ts +0 -98
  466. package/cli/src/pre-init/tree-sitter-wasm.ts +0 -98
  467. package/cli/src/project-files.ts +0 -102
  468. package/cli/src/state/__tests__/chat-store-focus.test.ts +0 -22
  469. package/cli/src/state/__tests__/feedback-store.test.ts +0 -260
  470. package/cli/src/state/chat-history-store.ts +0 -27
  471. package/cli/src/state/chat-store.ts +0 -791
  472. package/cli/src/state/feedback-store.ts +0 -162
  473. package/cli/src/state/gateway-catalog-store.ts +0 -45
  474. package/cli/src/state/login-store.ts +0 -150
  475. package/cli/src/state/message-block-store.ts +0 -143
  476. package/cli/src/state/model-picker-store.ts +0 -67
  477. package/cli/src/state/provider-picker-store.ts +0 -53
  478. package/cli/src/state/publish-store.ts +0 -141
  479. package/cli/src/state/review-store.ts +0 -24
  480. package/cli/src/state/rewind-picker-store.ts +0 -74
  481. package/cli/src/state/savant-free-model-store.ts +0 -59
  482. package/cli/src/state/savant-free-session-store.ts +0 -32
  483. package/cli/src/test-setup.ts +0 -20
  484. package/cli/src/testing/env.ts +0 -46
  485. package/cli/src/types/chat-state.ts +0 -15
  486. package/cli/src/types/chat.ts +0 -242
  487. package/cli/src/types/contracts/send-message.ts +0 -16
  488. package/cli/src/types/env.ts +0 -107
  489. package/cli/src/types/function-params.ts +0 -35
  490. package/cli/src/types/opentui-augmentation.d.ts +0 -11
  491. package/cli/src/types/react19-compat.d.ts +0 -19
  492. package/cli/src/types/savant-free-session.ts +0 -17
  493. package/cli/src/types/store.ts +0 -112
  494. package/cli/src/types/theme-system.ts +0 -194
  495. package/cli/src/types/utils.ts +0 -3
  496. package/cli/src/utils/__tests__/activity-tracker.test.ts +0 -440
  497. package/cli/src/utils/__tests__/agent-display.test.ts +0 -139
  498. package/cli/src/utils/__tests__/analytics-client.test.ts +0 -311
  499. package/cli/src/utils/__tests__/anonymous-id.test.ts +0 -74
  500. package/cli/src/utils/__tests__/arrays.test.ts +0 -264
  501. package/cli/src/utils/__tests__/bash-context-processor.test.ts +0 -38
  502. package/cli/src/utils/__tests__/block-processor.test.ts +0 -690
  503. package/cli/src/utils/__tests__/chat-history.test.ts +0 -247
  504. package/cli/src/utils/__tests__/chat-input-key-intercept.test.ts +0 -40
  505. package/cli/src/utils/__tests__/chat-layout.test.ts +0 -32
  506. package/cli/src/utils/__tests__/chat-meta.test.ts +0 -160
  507. package/cli/src/utils/__tests__/chatgpt-oauth.test.ts +0 -50
  508. package/cli/src/utils/__tests__/clipboard.test.ts +0 -998
  509. package/cli/src/utils/__tests__/code-search-summary.test.ts +0 -84
  510. package/cli/src/utils/__tests__/collapse-helpers.test.ts +0 -1272
  511. package/cli/src/utils/__tests__/error-handling.test.ts +0 -611
  512. package/cli/src/utils/__tests__/feedback-helpers.test.ts +0 -452
  513. package/cli/src/utils/__tests__/feedback-submission.test.ts +0 -26
  514. package/cli/src/utils/__tests__/fetch-usage.test.ts +0 -355
  515. package/cli/src/utils/__tests__/fingerprint.test.ts +0 -147
  516. package/cli/src/utils/__tests__/format-elapsed-time.test.ts +0 -103
  517. package/cli/src/utils/__tests__/format-timeout.test.ts +0 -87
  518. package/cli/src/utils/__tests__/image-dimensions.test.ts +0 -243
  519. package/cli/src/utils/__tests__/image-processor.test.ts +0 -250
  520. package/cli/src/utils/__tests__/implementor-helpers.test.ts +0 -1457
  521. package/cli/src/utils/__tests__/keyboard-actions.test.ts +0 -686
  522. package/cli/src/utils/__tests__/layout-helpers.test.ts +0 -36
  523. package/cli/src/utils/__tests__/lazy-response-ads.test.ts +0 -167
  524. package/cli/src/utils/__tests__/logger-sanitize-secrets.test.ts +0 -80
  525. package/cli/src/utils/__tests__/markdown-renderer.test.tsx +0 -518
  526. package/cli/src/utils/__tests__/markdown-streaming.test.ts +0 -64
  527. package/cli/src/utils/__tests__/message-block-helpers.test.ts +0 -1197
  528. package/cli/src/utils/__tests__/message-updater.test.ts +0 -659
  529. package/cli/src/utils/__tests__/ollama-onboarding.test.ts +0 -239
  530. package/cli/src/utils/__tests__/openrouter-models.test.ts +0 -295
  531. package/cli/src/utils/__tests__/osc-timeout-scenarios.test.ts +0 -172
  532. package/cli/src/utils/__tests__/pending-attachments.test.ts +0 -278
  533. package/cli/src/utils/__tests__/provider-setup.test.ts +0 -183
  534. package/cli/src/utils/__tests__/run-state-storage.test.ts +0 -741
  535. package/cli/src/utils/__tests__/savant-code-api.test.ts +0 -634
  536. package/cli/src/utils/__tests__/savant-free-instance-owner.test.ts +0 -69
  537. package/cli/src/utils/__tests__/savant-free-model-navigation.test.ts +0 -104
  538. package/cli/src/utils/__tests__/savant-free-premium-reset.test.ts +0 -81
  539. package/cli/src/utils/__tests__/savant-free-referral-cache.test.ts +0 -67
  540. package/cli/src/utils/__tests__/savant-free-session-display.test.ts +0 -23
  541. package/cli/src/utils/__tests__/savant-free-streak-line.test.ts +0 -76
  542. package/cli/src/utils/__tests__/sdk-event-handlers.test.ts +0 -615
  543. package/cli/src/utils/__tests__/send-message-helpers.test.ts +0 -1875
  544. package/cli/src/utils/__tests__/send-message-timer.test.ts +0 -68
  545. package/cli/src/utils/__tests__/settings.test.ts +0 -138
  546. package/cli/src/utils/__tests__/strings.test.ts +0 -210
  547. package/cli/src/utils/__tests__/syntax-theme.test.ts +0 -91
  548. package/cli/src/utils/__tests__/terminal-color-detection.test.ts +0 -425
  549. package/cli/src/utils/__tests__/terminal-enter-detection.test.ts +0 -77
  550. package/cli/src/utils/__tests__/text-layout.test.ts +0 -82
  551. package/cli/src/utils/__tests__/think-tag-parser.test.ts +0 -159
  552. package/cli/src/utils/__tests__/trace-writer.test.ts +0 -193
  553. package/cli/src/utils/__tests__/trim-chat-logs.test.ts +0 -79
  554. package/cli/src/utils/__tests__/usage-banner-state.test.ts +0 -298
  555. package/cli/src/utils/__tests__/validation-error-formatting.test.ts +0 -126
  556. package/cli/src/utils/__tests__/write-file-atomic.test.ts +0 -130
  557. package/cli/src/utils/active-run.ts +0 -22
  558. package/cli/src/utils/activity-tracker.ts +0 -66
  559. package/cli/src/utils/agent-display.ts +0 -87
  560. package/cli/src/utils/agent-helpers.ts +0 -47
  561. package/cli/src/utils/ai-message-id.ts +0 -12
  562. package/cli/src/utils/analytics.ts +0 -407
  563. package/cli/src/utils/anonymous-id.ts +0 -87
  564. package/cli/src/utils/arrays.ts +0 -117
  565. package/cli/src/utils/auth.ts +0 -258
  566. package/cli/src/utils/bash-context-processor.ts +0 -47
  567. package/cli/src/utils/bash-messages.ts +0 -108
  568. package/cli/src/utils/block-margins.ts +0 -35
  569. package/cli/src/utils/block-operations.ts +0 -548
  570. package/cli/src/utils/block-processor.ts +0 -212
  571. package/cli/src/utils/chat-history.ts +0 -265
  572. package/cli/src/utils/chat-input-key-intercept.ts +0 -52
  573. package/cli/src/utils/chat-layout.ts +0 -44
  574. package/cli/src/utils/chat-meta.ts +0 -101
  575. package/cli/src/utils/chat-scroll-accel.ts +0 -151
  576. package/cli/src/utils/chatgpt-oauth.ts +0 -350
  577. package/cli/src/utils/clipboard-image.ts +0 -610
  578. package/cli/src/utils/clipboard.ts +0 -307
  579. package/cli/src/utils/code-search-summary.ts +0 -69
  580. package/cli/src/utils/collapse-helpers.ts +0 -265
  581. package/cli/src/utils/config-dir.ts +0 -30
  582. package/cli/src/utils/constants.ts +0 -159
  583. package/cli/src/utils/create-event-handler-state.ts +0 -79
  584. package/cli/src/utils/create-run-config.ts +0 -160
  585. package/cli/src/utils/db-storage.ts +0 -254
  586. package/cli/src/utils/detect-shell.ts +0 -112
  587. package/cli/src/utils/directory-browser.ts +0 -74
  588. package/cli/src/utils/engagement.ts +0 -55
  589. package/cli/src/utils/env.ts +0 -109
  590. package/cli/src/utils/error-handling.ts +0 -246
  591. package/cli/src/utils/error-messages.ts +0 -84
  592. package/cli/src/utils/feedback-helpers.ts +0 -115
  593. package/cli/src/utils/feedback-submission.ts +0 -24
  594. package/cli/src/utils/fetch-usage.ts +0 -75
  595. package/cli/src/utils/fid-loader.ts +0 -135
  596. package/cli/src/utils/fingerprint.ts +0 -263
  597. package/cli/src/utils/finish-logic.ts +0 -154
  598. package/cli/src/utils/format-elapsed-time.ts +0 -33
  599. package/cli/src/utils/format-session-units.ts +0 -6
  600. package/cli/src/utils/format-timeout.ts +0 -28
  601. package/cli/src/utils/format-validation-errors-for-message.ts +0 -85
  602. package/cli/src/utils/git.ts +0 -17
  603. package/cli/src/utils/glyphs.ts +0 -188
  604. package/cli/src/utils/helpers.ts +0 -47
  605. package/cli/src/utils/image-display.ts +0 -73
  606. package/cli/src/utils/image-handler.ts +0 -336
  607. package/cli/src/utils/image-processor.ts +0 -133
  608. package/cli/src/utils/image-thumbnail.ts +0 -83
  609. package/cli/src/utils/implementor-helpers.ts +0 -817
  610. package/cli/src/utils/input-modes.ts +0 -196
  611. package/cli/src/utils/keyboard-actions.ts +0 -394
  612. package/cli/src/utils/keypad-keys.ts +0 -47
  613. package/cli/src/utils/layout-helpers.ts +0 -36
  614. package/cli/src/utils/lazy-response-ads.ts +0 -1
  615. package/cli/src/utils/loading-phrases.ts +0 -68
  616. package/cli/src/utils/local-agent-registry.ts +0 -611
  617. package/cli/src/utils/log-shipper.ts +0 -116
  618. package/cli/src/utils/logger.ts +0 -435
  619. package/cli/src/utils/markdown-renderer.tsx +0 -1315
  620. package/cli/src/utils/math.ts +0 -3
  621. package/cli/src/utils/message-block-helpers.ts +0 -667
  622. package/cli/src/utils/message-history.ts +0 -153
  623. package/cli/src/utils/message-tree-utils.ts +0 -67
  624. package/cli/src/utils/message-updater.ts +0 -279
  625. package/cli/src/utils/ollama-onboarding.ts +0 -215
  626. package/cli/src/utils/open-file.ts +0 -144
  627. package/cli/src/utils/open-url.ts +0 -63
  628. package/cli/src/utils/openrouter-models.ts +0 -945
  629. package/cli/src/utils/path-completion.ts +0 -111
  630. package/cli/src/utils/path-helpers.ts +0 -38
  631. package/cli/src/utils/pending-attachments.ts +0 -363
  632. package/cli/src/utils/post-processing.ts +0 -99
  633. package/cli/src/utils/project-picker.ts +0 -12
  634. package/cli/src/utils/provider-setup.ts +0 -259
  635. package/cli/src/utils/recent-projects.ts +0 -156
  636. package/cli/src/utils/renderer-cleanup.ts +0 -142
  637. package/cli/src/utils/response-ad-positions.ts +0 -1
  638. package/cli/src/utils/rewind.ts +0 -175
  639. package/cli/src/utils/run-state-storage.ts +0 -480
  640. package/cli/src/utils/savant-code-api.ts +0 -686
  641. package/cli/src/utils/savant-code-client.ts +0 -217
  642. package/cli/src/utils/savant-free-agent-selection.ts +0 -12
  643. package/cli/src/utils/savant-free-exit.ts +0 -24
  644. package/cli/src/utils/savant-free-instance-owner.ts +0 -66
  645. package/cli/src/utils/savant-free-model-navigation.ts +0 -46
  646. package/cli/src/utils/savant-free-premium-reset.ts +0 -52
  647. package/cli/src/utils/savant-free-referral-cache.ts +0 -35
  648. package/cli/src/utils/savant-free-session-display.ts +0 -21
  649. package/cli/src/utils/savant-free-streak-line.ts +0 -67
  650. package/cli/src/utils/sdk-event-handlers.ts +0 -587
  651. package/cli/src/utils/send-message-helpers.ts +0 -199
  652. package/cli/src/utils/send-message-timer.ts +0 -110
  653. package/cli/src/utils/settings.ts +0 -388
  654. package/cli/src/utils/skill-registry.ts +0 -100
  655. package/cli/src/utils/spawn-agent-matcher.ts +0 -56
  656. package/cli/src/utils/status-indicator-state.ts +0 -100
  657. package/cli/src/utils/stream-chunk-processor.ts +0 -65
  658. package/cli/src/utils/strings.ts +0 -272
  659. package/cli/src/utils/subscription.ts +0 -31
  660. package/cli/src/utils/syntax-highlighter.tsx +0 -19
  661. package/cli/src/utils/syntax-theme.ts +0 -131
  662. package/cli/src/utils/terminal-color-detection.ts +0 -472
  663. package/cli/src/utils/terminal-enter-detection.ts +0 -63
  664. package/cli/src/utils/terminal-images.ts +0 -223
  665. package/cli/src/utils/terminal-reset-sequences.ts +0 -33
  666. package/cli/src/utils/terminal-title.ts +0 -113
  667. package/cli/src/utils/terminal-watchdog.ts +0 -246
  668. package/cli/src/utils/text-layout.ts +0 -153
  669. package/cli/src/utils/theme-config.ts +0 -143
  670. package/cli/src/utils/theme-system.ts +0 -1265
  671. package/cli/src/utils/think-tag-parser.ts +0 -105
  672. package/cli/src/utils/trace-writer.ts +0 -147
  673. package/cli/src/utils/ui-constants.ts +0 -69
  674. package/cli/src/utils/usage-banner-state.ts +0 -146
  675. package/cli/src/utils/validation-error-formatting.ts +0 -92
  676. package/cli/src/utils/validation-error-helpers.ts +0 -19
  677. package/cli/src/utils/version.ts +0 -23
  678. package/cli/src/utils/word-wrap-utils.ts +0 -53
  679. package/cli/src/utils/write-file-atomic.ts +0 -82
  680. package/cli/src/utils/yield-to-event-loop.ts +0 -9
  681. package/cli/tsconfig.json +0 -26
  682. package/coding-standards/csharp.md +0 -77
  683. package/coding-standards/go.md +0 -71
  684. package/coding-standards/java.md +0 -74
  685. package/coding-standards/python.md +0 -82
  686. package/coding-standards/rust.md +0 -78
  687. package/coding-standards/typescript.md +0 -85
  688. package/coding-standards/x402.md +0 -227
  689. package/common/README.md +0 -41
  690. package/common/common_exit +0 -1
  691. package/common/package.json +0 -45
  692. package/common/src/__tests__/agent-validation.test.ts +0 -861
  693. package/common/src/__tests__/agents.test.ts +0 -16
  694. package/common/src/__tests__/dynamic-agent-template-schema.test.ts +0 -420
  695. package/common/src/__tests__/env-ci.test.ts +0 -167
  696. package/common/src/__tests__/env-process.test.ts +0 -145
  697. package/common/src/__tests__/free-agents.test.ts +0 -269
  698. package/common/src/__tests__/handlesteps-parsing.test.ts +0 -247
  699. package/common/src/__tests__/model-config.test.ts +0 -41
  700. package/common/src/__tests__/project-file-tree.test.ts +0 -82
  701. package/common/src/__tests__/response-ad-positions.test.ts +0 -78
  702. package/common/src/__tests__/savant-free-models.test.ts +0 -460
  703. package/common/src/__tests__/savant-free-referral-tiers.test.ts +0 -122
  704. package/common/src/__tests__/user-state.test.ts +0 -30
  705. package/common/src/actions.ts +0 -215
  706. package/common/src/analytics-core.ts +0 -73
  707. package/common/src/analytics.ts +0 -96
  708. package/common/src/api-keys/constants.ts +0 -26
  709. package/common/src/browser-actions.ts +0 -414
  710. package/common/src/constants/agents.ts +0 -250
  711. package/common/src/constants/analytics-events.ts +0 -318
  712. package/common/src/constants/anthropic.ts +0 -72
  713. package/common/src/constants/byok.ts +0 -2
  714. package/common/src/constants/chatgpt-oauth.ts +0 -85
  715. package/common/src/constants/composio.ts +0 -34
  716. package/common/src/constants/feedback.ts +0 -18
  717. package/common/src/constants/free-agents.ts +0 -347
  718. package/common/src/constants/gemini.ts +0 -6
  719. package/common/src/constants/grant-priorities.ts +0 -13
  720. package/common/src/constants/hosts.ts +0 -6
  721. package/common/src/constants/images.ts +0 -53
  722. package/common/src/constants/index.ts +0 -8
  723. package/common/src/constants/knowledge.ts +0 -46
  724. package/common/src/constants/limits.ts +0 -23
  725. package/common/src/constants/model-config.ts +0 -469
  726. package/common/src/constants/paths.ts +0 -69
  727. package/common/src/constants/savant-code-config.ts +0 -12
  728. package/common/src/constants/savant-free-gemini-thinker.ts +0 -30
  729. package/common/src/constants/savant-free-models.ts +0 -855
  730. package/common/src/constants/savant-free-referral-tiers.ts +0 -169
  731. package/common/src/constants/skills.ts +0 -60
  732. package/common/src/constants/subscription-plans.ts +0 -54
  733. package/common/src/constants/ui.ts +0 -25
  734. package/common/src/env-ci.ts +0 -36
  735. package/common/src/env-process.ts +0 -95
  736. package/common/src/env-schema.ts +0 -42
  737. package/common/src/env.ts +0 -69
  738. package/common/src/mcp/client.ts +0 -308
  739. package/common/src/old-constants.ts +0 -10
  740. package/common/src/project-file-tree.ts +0 -361
  741. package/common/src/reddit-capi.ts +0 -175
  742. package/common/src/schemas/feedback.ts +0 -57
  743. package/common/src/schemas/logs.ts +0 -66
  744. package/common/src/templates/agent-validation.ts +0 -436
  745. package/common/src/templates/initial-agents-dir/LICENSE +0 -202
  746. package/common/src/templates/initial-agents-dir/README.md +0 -314
  747. package/common/src/templates/initial-agents-dir/examples/01-basic-diff-reviewer.ts +0 -17
  748. package/common/src/templates/initial-agents-dir/examples/02-intermediate-git-committer.ts +0 -78
  749. package/common/src/templates/initial-agents-dir/examples/03-advanced-file-explorer.ts +0 -76
  750. package/common/src/templates/initial-agents-dir/my-custom-agent.ts +0 -40
  751. package/common/src/templates/initial-agents-dir/package.json +0 -6
  752. package/common/src/templates/initial-agents-dir/skills/README.md +0 -66
  753. package/common/src/templates/initial-agents-dir/skills/example-skill/SKILL.md +0 -29
  754. package/common/src/templates/initial-agents-dir/types/agent-definition.ts +0 -484
  755. package/common/src/templates/initial-agents-dir/types/tools.ts +0 -452
  756. package/common/src/templates/initial-agents-dir/types/util-types.ts +0 -167
  757. package/common/src/testing/TESTING_PATTERNS.md +0 -353
  758. package/common/src/testing/errors.ts +0 -33
  759. package/common/src/testing/fixtures/agent-runtime.ts +0 -325
  760. package/common/src/testing/impl/agent-runtime.ts +0 -6
  761. package/common/src/testing/index.ts +0 -84
  762. package/common/src/testing/mock-modules.ts +0 -54
  763. package/common/src/testing/mock-types.ts +0 -123
  764. package/common/src/testing/mocks/analytics.ts +0 -262
  765. package/common/src/testing/mocks/child-process.ts +0 -93
  766. package/common/src/testing/mocks/crypto.ts +0 -218
  767. package/common/src/testing/mocks/database.ts +0 -337
  768. package/common/src/testing/mocks/filesystem.ts +0 -166
  769. package/common/src/testing/mocks/index.ts +0 -83
  770. package/common/src/testing/mocks/logger.ts +0 -136
  771. package/common/src/testing/mocks/stream.ts +0 -314
  772. package/common/src/testing/mocks/timers.ts +0 -132
  773. package/common/src/testing/mocks/tree-sitter.ts +0 -127
  774. package/common/src/testing/setup.ts +0 -282
  775. package/common/src/testing-env-ci.ts +0 -15
  776. package/common/src/testing-env-process.ts +0 -78
  777. package/common/src/tools/__tests__/compile-tool-definitions.test.ts +0 -34
  778. package/common/src/tools/__tests__/thought-session.test.ts +0 -256
  779. package/common/src/tools/compile-tool-definitions.ts +0 -194
  780. package/common/src/tools/constants.ts +0 -132
  781. package/common/src/tools/list.ts +0 -193
  782. package/common/src/tools/params/__tests__/coerce-to-array.test.ts +0 -237
  783. package/common/src/tools/params/__tests__/sequential-thinking-coercion.test.ts +0 -98
  784. package/common/src/tools/params/tool/add-message.ts +0 -36
  785. package/common/src/tools/params/tool/add-subgoal.ts +0 -57
  786. package/common/src/tools/params/tool/apply-patch.ts +0 -110
  787. package/common/src/tools/params/tool/ask-user.ts +0 -181
  788. package/common/src/tools/params/tool/browser-logs.ts +0 -85
  789. package/common/src/tools/params/tool/code-search.ts +0 -144
  790. package/common/src/tools/params/tool/composio.ts +0 -131
  791. package/common/src/tools/params/tool/create-plan.ts +0 -80
  792. package/common/src/tools/params/tool/end-turn.ts +0 -54
  793. package/common/src/tools/params/tool/find-files.ts +0 -60
  794. package/common/src/tools/params/tool/glob.ts +0 -74
  795. package/common/src/tools/params/tool/gravity-index.ts +0 -92
  796. package/common/src/tools/params/tool/list-directory.ts +0 -58
  797. package/common/src/tools/params/tool/lookup-agent-info.ts +0 -37
  798. package/common/src/tools/params/tool/propose-str-replace.ts +0 -103
  799. package/common/src/tools/params/tool/propose-write-file.ts +0 -71
  800. package/common/src/tools/params/tool/read-docs.ts +0 -85
  801. package/common/src/tools/params/tool/read-files.ts +0 -61
  802. package/common/src/tools/params/tool/read-subtree.ts +0 -83
  803. package/common/src/tools/params/tool/read-url.ts +0 -81
  804. package/common/src/tools/params/tool/render-ui.ts +0 -203
  805. package/common/src/tools/params/tool/run-file-change-hooks.ts +0 -57
  806. package/common/src/tools/params/tool/run-readonly-command.ts +0 -76
  807. package/common/src/tools/params/tool/run-terminal-command.ts +0 -185
  808. package/common/src/tools/params/tool/sequential-thinking.ts +0 -110
  809. package/common/src/tools/params/tool/set-messages.ts +0 -42
  810. package/common/src/tools/params/tool/set-output.ts +0 -62
  811. package/common/src/tools/params/tool/set-scaffold-complete.ts +0 -51
  812. package/common/src/tools/params/tool/skill.ts +0 -56
  813. package/common/src/tools/params/tool/spawn-agent-inline.ts +0 -57
  814. package/common/src/tools/params/tool/spawn-agents.ts +0 -154
  815. package/common/src/tools/params/tool/str-replace.ts +0 -107
  816. package/common/src/tools/params/tool/suggest-followups.ts +0 -94
  817. package/common/src/tools/params/tool/task-completed.ts +0 -58
  818. package/common/src/tools/params/tool/think-deeply.ts +0 -53
  819. package/common/src/tools/params/tool/transition-phase.ts +0 -57
  820. package/common/src/tools/params/tool/update-subgoal.ts +0 -89
  821. package/common/src/tools/params/tool/web-search.ts +0 -73
  822. package/common/src/tools/params/tool/write-file.ts +0 -71
  823. package/common/src/tools/params/tool/write-todos.ts +0 -67
  824. package/common/src/tools/params/utils.ts +0 -152
  825. package/common/src/tools/safety-registry.ts +0 -274
  826. package/common/src/tools/safety.ts +0 -42
  827. package/common/src/tools/sequential-thinking.ts +0 -340
  828. package/common/src/tools/utils.ts +0 -25
  829. package/common/src/types/__tests__/dynamic-agent-template.test.ts +0 -20
  830. package/common/src/types/agent-template.ts +0 -223
  831. package/common/src/types/api/agents/publish.ts +0 -63
  832. package/common/src/types/contracts/agent-runtime.ts +0 -84
  833. package/common/src/types/contracts/analytics.ts +0 -12
  834. package/common/src/types/contracts/bigquery.ts +0 -55
  835. package/common/src/types/contracts/billing.ts +0 -46
  836. package/common/src/types/contracts/client.ts +0 -52
  837. package/common/src/types/contracts/database.ts +0 -104
  838. package/common/src/types/contracts/env.ts +0 -203
  839. package/common/src/types/contracts/llm.ts +0 -183
  840. package/common/src/types/contracts/logger.ts +0 -29
  841. package/common/src/types/contracts/logs.ts +0 -34
  842. package/common/src/types/contracts/trace.ts +0 -21
  843. package/common/src/types/dynamic-agent-template.ts +0 -290
  844. package/common/src/types/filesystem.ts +0 -10
  845. package/common/src/types/function-params.ts +0 -35
  846. package/common/src/types/grant.ts +0 -20
  847. package/common/src/types/gravity-index.ts +0 -170
  848. package/common/src/types/json.ts +0 -26
  849. package/common/src/types/mcp.ts +0 -26
  850. package/common/src/types/messages/content-part.ts +0 -59
  851. package/common/src/types/messages/data-content.ts +0 -14
  852. package/common/src/types/messages/provider-metadata.ts +0 -10
  853. package/common/src/types/messages/savant-code-message.ts +0 -55
  854. package/common/src/types/organization.ts +0 -118
  855. package/common/src/types/print-mode.ts +0 -167
  856. package/common/src/types/publisher.ts +0 -67
  857. package/common/src/types/savant-free-session.ts +0 -295
  858. package/common/src/types/savant-free-streak.ts +0 -6
  859. package/common/src/types/session-state.ts +0 -237
  860. package/common/src/types/skill.ts +0 -56
  861. package/common/src/types/source.ts +0 -11
  862. package/common/src/types/spawn.ts +0 -13
  863. package/common/src/types/subscription.ts +0 -68
  864. package/common/src/types/usage.ts +0 -16
  865. package/common/src/types/util.ts +0 -3
  866. package/common/src/util/__tests__/analytics-dispatcher.test.ts +0 -145
  867. package/common/src/util/__tests__/analytics-log.test.ts +0 -110
  868. package/common/src/util/__tests__/analytics-sampling.test.ts +0 -131
  869. package/common/src/util/__tests__/axiom-only-log.test.ts +0 -64
  870. package/common/src/util/__tests__/engagement-tracker.test.ts +0 -115
  871. package/common/src/util/__tests__/error-abort.test.ts +0 -822
  872. package/common/src/util/__tests__/error-api-details.test.ts +0 -190
  873. package/common/src/util/__tests__/format-code-search.test.ts +0 -60
  874. package/common/src/util/__tests__/log-mirror.test.ts +0 -31
  875. package/common/src/util/__tests__/messages.test.ts +0 -1118
  876. package/common/src/util/__tests__/partial-json-delta.test.ts +0 -505
  877. package/common/src/util/__tests__/paths.test.ts +0 -289
  878. package/common/src/util/__tests__/promise.test.ts +0 -334
  879. package/common/src/util/__tests__/protocol-config.test.ts +0 -119
  880. package/common/src/util/__tests__/rate-limit.test.ts +0 -28
  881. package/common/src/util/__tests__/reddit-savant-free-retention.test.ts +0 -120
  882. package/common/src/util/__tests__/savant-free-streak.test.ts +0 -142
  883. package/common/src/util/__tests__/saxy.test.ts +0 -1008
  884. package/common/src/util/__tests__/string.test.ts +0 -238
  885. package/common/src/util/__tests__/zoned-time.test.ts +0 -88
  886. package/common/src/util/agent-file-utils.ts +0 -110
  887. package/common/src/util/agent-id-parsing.ts +0 -147
  888. package/common/src/util/agent-name-normalization.ts +0 -39
  889. package/common/src/util/analytics-dispatcher.ts +0 -81
  890. package/common/src/util/analytics-log.ts +0 -84
  891. package/common/src/util/analytics-sampling.ts +0 -225
  892. package/common/src/util/array.ts +0 -63
  893. package/common/src/util/axiom-only-log.ts +0 -79
  894. package/common/src/util/cache-debug.ts +0 -191
  895. package/common/src/util/credentials.ts +0 -12
  896. package/common/src/util/currency.ts +0 -25
  897. package/common/src/util/dates.ts +0 -81
  898. package/common/src/util/engagement-tracker.ts +0 -133
  899. package/common/src/util/error.ts +0 -580
  900. package/common/src/util/file.ts +0 -363
  901. package/common/src/util/format-code-search.ts +0 -115
  902. package/common/src/util/lazy-response-ads.ts +0 -93
  903. package/common/src/util/log-data.ts +0 -57
  904. package/common/src/util/log-mirror.ts +0 -34
  905. package/common/src/util/lru-cache.ts +0 -67
  906. package/common/src/util/messages.ts +0 -600
  907. package/common/src/util/model-utils.ts +0 -25
  908. package/common/src/util/object.ts +0 -137
  909. package/common/src/util/param-helpers.ts +0 -49
  910. package/common/src/util/partial-json-delta.ts +0 -100
  911. package/common/src/util/paths.ts +0 -194
  912. package/common/src/util/promise.ts +0 -87
  913. package/common/src/util/protocol-config.ts +0 -141
  914. package/common/src/util/random.ts +0 -15
  915. package/common/src/util/rate-limit.ts +0 -56
  916. package/common/src/util/reddit-capi-events.ts +0 -40
  917. package/common/src/util/reddit-savant-free-retention.ts +0 -69
  918. package/common/src/util/response-ad-positions.ts +0 -54
  919. package/common/src/util/savant-free-privacy.ts +0 -78
  920. package/common/src/util/savant-free-streak.ts +0 -153
  921. package/common/src/util/saxy.ts +0 -739
  922. package/common/src/util/skills.ts +0 -32
  923. package/common/src/util/stop-sequence.ts +0 -60
  924. package/common/src/util/string.ts +0 -419
  925. package/common/src/util/system-info.ts +0 -51
  926. package/common/src/util/type-narrowing.ts +0 -125
  927. package/common/src/util/xml.ts +0 -17
  928. package/common/src/util/zod-schema.ts +0 -80
  929. package/common/src/util/zoned-time.ts +0 -136
  930. package/common/src/utils/ask-user-bridge.ts +0 -47
  931. package/common/tsconfig.json +0 -8
  932. package/database.db +0 -0
  933. package/dev/LEARNINGS.md +0 -309
  934. package/dev/fids/.gitkeep +0 -0
  935. package/dev/fids/archive/.gitkeep +0 -0
  936. package/dev/fids/archive/FID-2026-0802-008-sdk-package-audit-client-run-run-state.md +0 -238
  937. package/dev/fids/archive/FID-2026-0803-001-echo-enforcement-layer-drift.md +0 -404
  938. package/dev/fids/archive/FID-2026-0803-002-llm-providers-database-audit.md +0 -348
  939. package/dev/fids/archive/FID-2026-0803-003-sdk-impl-common-util-audit.md +0 -277
  940. package/dev/fids/archive/FID-2026-0803-004-checkpoint-rewind.md +0 -181
  941. package/dev/fids/archive/FID-2026-0803-005-quality-scan-hygiene-fixes.md +0 -265
  942. package/dev/fids/archive/FID-2026-0803-006-code-map-audit-hygiene.md +0 -243
  943. package/dev/fids/archive/FID-2026-0803-007-evals-benchmark-audit-hygiene.md +0 -296
  944. package/dev/fids/archive/FID-2026-0803-009-echo-enforcement-doc-drift.md +0 -112
  945. package/dev/fids/archive/FID-2026-0803-010-database-llm-providers-low-fixes.md +0 -255
  946. package/dev/fids/archive/FID-2026-0803-011-build-artifact-hygiene.md +0 -169
  947. package/dev/fids/archive/FID-2026-0803-012-release-readiness-audit.md +0 -162
  948. package/dev/fids/archive/FID-2026-0803-013-agent-roster-over-reporting.md +0 -146
  949. package/dev/fids/archive/FID-2026-0803-014-freebuff-to-savant-rebrand-sweep.md +0 -185
  950. package/dev/nova/reports/2026-08-02-top-25-ai-inference-providers-competitive-intel.md +0 -213
  951. package/dev/nova/specs/echo-v0.1.2-freebuff.md +0 -391
  952. package/dev/nova/specs/goal-loop-feature-spec.md +0 -245
  953. package/dev/nova/specs/launch-strategy-research-prompt.md +0 -88
  954. package/dev/releases/README.md +0 -50
  955. package/dev/scratchpad/.gitkeep +0 -0
  956. package/dev/session-summaries/.gitkeep +0 -0
  957. package/dev/session-summaries/2026-07-16-1255.md +0 -128
  958. package/dev/session-summaries/2026-07-17-1000.md +0 -64
  959. package/dev/session-summaries/2026-07-19-eslint-zero-tolerance-cleanup.md +0 -145
  960. package/dev/session-summaries/2026-07-19-fid-026-debugging-and-rename.md +0 -79
  961. package/dev/session-summaries/2026-07-19-fid-026-phase-b-rebrand.md +0 -76
  962. package/dev/session-summaries/2026-07-19-fid-027-clean-break.md +0 -47
  963. package/dev/session-summaries/2026-07-19-pre-push-house-cleaning.md +0 -100
  964. package/dev/session-summaries/2026-07-19-v0.0.2-release-session.md +0 -229
  965. package/dev/session-summaries/2026-07-20-1805-fid-loop-closure.md +0 -95
  966. package/dev/session-summaries/2026-07-22-1800.md +0 -52
  967. package/dev/session-summaries/2026-07-23-fsm-optimization.md +0 -88
  968. package/dev/session-summaries/2026-07-25-0000.md +0 -80
  969. package/dev/session-summaries/2026-07-25-1200-context-compaction.md +0 -154
  970. package/dev/session-summaries/2026-07-25-1600-layer4-reactive-compact.md +0 -96
  971. package/dev/session-summaries/2026-07-25-1700-dev-folder-audit.md +0 -161
  972. package/dev/session-summaries/2026-07-25-2000-benchmark-v2-filters.md +0 -147
  973. package/dev/session-summaries/2026-07-25-2000.md +0 -77
  974. package/dev/session-summaries/2026-07-27-0000.md +0 -63
  975. package/dev/session-summaries/2026-07-28-history-capture-handoff.md +0 -206
  976. package/dev/session-summaries/2026-07-31-freebuff-echo-compliance-remediation.md +0 -156
  977. package/dev/session-summaries/2026-07-31-pre-launch-optimization-execution.md +0 -46
  978. package/dev/session-summaries/2026-0721-1800.md +0 -44
  979. package/dev/session-summaries/2026-08-01-sidebar-folded-startup.md +0 -159
  980. package/dev/session-summaries/2026-08-02-0.0.15-release-closeout.md +0 -32
  981. package/dev/session-summaries/2026-08-02-repository-hygiene.md +0 -51
  982. package/dev/session-summaries/2026-08-03-agent-roster-fix-and-ready-check-closeout.md +0 -73
  983. package/dev/session-summaries/2026-08-03-build-artifact-hygiene-closeout.md +0 -40
  984. package/dev/session-summaries/2026-08-03-code-map-audit-closeout.md +0 -44
  985. package/dev/session-summaries/2026-08-03-database-llm-providers-low-fixes-closeout.md +0 -47
  986. package/dev/session-summaries/2026-08-03-echo-enforcement-doc-drift-closeout.md +0 -43
  987. package/dev/session-summaries/2026-08-03-evals-benchmark-audit-closeout.md +0 -50
  988. package/dev/session-summaries/2026-08-03-quality-scan-hygiene-fixes-closeout.md +0 -57
  989. package/dev/session-summaries/2026-08-03-quality-session-checkpoint-rewind-closeout.md +0 -56
  990. package/dev/session-summaries/2026-08-03-release-readiness-audit-closeout.md +0 -60
  991. package/dev/test-prompts/archive/0.0.2-final-pass.md +0 -209
  992. package/dev/test-prompts/archive/agent-capabilities-full-test.md +0 -361
  993. package/dev/test-prompts/archive/agent-capabilities-test.md +0 -471
  994. package/dev/test-prompts/archive/comprehensive-az-system-test-v1.md +0 -441
  995. package/dev/test-prompts/archive/comprehensive-az-system-test-v2-report.md +0 -377
  996. package/dev/test-prompts/archive/comprehensive-az-system-test-v2.md +0 -544
  997. package/dev/test-prompts/archive/comprehensive-az-system-test.md +0 -396
  998. package/dev/test-prompts/archive/comprehensive-az-test-final.md +0 -900
  999. package/dev/test-prompts/archive/comprehensive-az-test-v11.md +0 -141
  1000. package/dev/test-prompts/archive/comprehensive-az-test-v12.md +0 -29
  1001. package/dev/test-prompts/archive/comprehensive-az-test-v6.md +0 -631
  1002. package/dev/test-prompts/archive/comprehensive-az-test-v7.md +0 -625
  1003. package/dev/test-prompts/archive/fid-007-ability-confirmation.md +0 -248
  1004. package/dev/test-prompts/archive/fid-2026-0801-006-thinker-tool-call-boundary.md +0 -86
  1005. package/dev/test-prompts/archive/fid-2026-0801-007-child-tool-set-fallback-cli.md +0 -101
  1006. package/dev/test-prompts/archive/fid-2026-0801-008-provider-tool-call-accumulation-cli.md +0 -111
  1007. package/dev/test-prompts/archive/fid-2026-0801-012-thinker-state-output-cli.md +0 -134
  1008. package/dev/test-prompts/archive/gemini-deep-research-sidebar-highlight.md +0 -598
  1009. package/dev/test-prompts/archive/goal-loop-cli-test.md +0 -142
  1010. package/dev/test-prompts/archive/rebrand-qa.md +0 -258
  1011. package/dev/test-prompts/archive/release-az-test-fid-085.md +0 -306
  1012. package/dev/test-prompts/archive/release-az-test-fid-087.md +0 -219
  1013. package/dev/test-prompts/archive/release-az-test-fid-2026-0726-001.md +0 -270
  1014. package/dev/test-prompts/archive/release-az-test-fid-2026-0728-008.md +0 -213
  1015. package/dev/test-prompts/archive/release-az-test-fid-2026-0728-launch-tracks.md +0 -253
  1016. package/dev/test-prompts/archive/release-az-test-fid-2026-0731-pre-launch.md +0 -102
  1017. package/docs/AI Coding Agents Market Research.md +0 -516
  1018. package/docs/Agent Harness Feature Pairing Research.md +0 -166
  1019. package/docs/CLI Agent Inference Backend Research.md +0 -446
  1020. package/docs/Codebuff Rebranding And Migration Plan.md +0 -418
  1021. package/docs/ECHO-EVOLUTION-ARCH.md +0 -77
  1022. package/docs/Harness Engineering for Coding Agents Research.md +0 -196
  1023. package/docs/Launch Plan Review and Optimization.md +0 -178
  1024. package/docs/OpenTUI Sidebar Bug Fix.md +0 -245
  1025. package/docs/SAVANT-VERSIONING.md +0 -31
  1026. package/docs/Savant Code Launch Strategy.md +0 -270
  1027. package/docs/Savant-Code Business And Backend Research.md +0 -222
  1028. package/docs/agents-and-tools.md +0 -217
  1029. package/docs/cloudflare-llms-full.md +0 -9623
  1030. package/docs/design/OpenTUI Terminal Visualization Guide.md +0 -471
  1031. package/docs/design/database-architecture.md +0 -217
  1032. package/docs/design/deep-research-report.md +0 -295
  1033. package/docs/design/thinker-sequentialthinking-regression-diagnostic.md +0 -366
  1034. package/docs/discord-server-design.md +0 -167
  1035. package/docs/gravity-integration-starter.md +0 -202
  1036. package/docs/launch/hn-first-comment.md +0 -44
  1037. package/docs/launch/hn-post.md +0 -35
  1038. package/docs/launch/incident-response.md +0 -88
  1039. package/docs/launch/landing/index.html +0 -233
  1040. package/docs/launch/mastodon-thread.md +0 -52
  1041. package/docs/launch/newsletter-pitch.md +0 -49
  1042. package/docs/launch/twitter-thread.md +0 -62
  1043. package/docs/privacy.md +0 -186
  1044. package/docs/reports/Savant-Code Benchmark Specification.md +0 -206
  1045. package/docs/reports/Thinker Agent Architecture Research.md +0 -284
  1046. package/docs/reports/adoptable-features-2026-07-25.md +0 -604
  1047. package/docs/reports/adoptable-features-master.md +0 -480
  1048. package/docs/reports/codebuff-discord-feedback.md +0 -43
  1049. package/docs/reports/feature-parity-report.md +0 -810
  1050. package/docs/reports/repos/AionUi.md +0 -75
  1051. package/docs/reports/repos/OpenHands.md +0 -76
  1052. package/docs/reports/repos/SWE-agent.md +0 -82
  1053. package/docs/reports/repos/agno.md +0 -89
  1054. package/docs/reports/repos/aider.md +0 -74
  1055. package/docs/reports/repos/cline.md +0 -83
  1056. package/docs/reports/repos/codex.md +0 -83
  1057. package/docs/reports/repos/gemini-cli.md +0 -92
  1058. package/docs/reports/repos/goose.md +0 -65
  1059. package/docs/reports/repos/gpt-pilot.md +0 -52
  1060. package/docs/reports/repos/hermes-agent.md +0 -101
  1061. package/docs/reports/repos/kilocode.md +0 -79
  1062. package/docs/reports/repos/openclaude.md +0 -99
  1063. package/docs/reports/repos/openclaw.md +0 -100
  1064. package/docs/reports/repos/opencode-dev.md +0 -78
  1065. package/docs/reports/repos/zero.md +0 -123
  1066. package/docs/reports/savant-code-benchmark-v2-2026-08-03.md +0 -76
  1067. package/docs/savant-code-modes.md +0 -338
  1068. package/docs/testing.md +0 -51
  1069. package/docs/visual-mockup-neon-slate.txt +0 -41
  1070. package/eslint.config.js +0 -211
  1071. package/evals/README.md +0 -45
  1072. package/evals/benchmark/README.md +0 -449
  1073. package/evals/benchmark/agent-runner.ts +0 -217
  1074. package/evals/benchmark/analyze-task-scores.ts +0 -496
  1075. package/evals/benchmark/eval-codebuff-hard.json +0 -3343
  1076. package/evals/benchmark/eval-codebuff.json +0 -3193
  1077. package/evals/benchmark/eval-codebuff2.json +0 -2494
  1078. package/evals/benchmark/eval-manifold-hard.json +0 -2525
  1079. package/evals/benchmark/eval-manifold.json +0 -1675
  1080. package/evals/benchmark/eval-manifold2.json +0 -1945
  1081. package/evals/benchmark/eval-plane-hard.json +0 -4372
  1082. package/evals/benchmark/eval-plane.json +0 -2028
  1083. package/evals/benchmark/eval-plane2.json +0 -3517
  1084. package/evals/benchmark/eval-saleor-hard.json +0 -6081
  1085. package/evals/benchmark/eval-saleor.json +0 -1829
  1086. package/evals/benchmark/eval-saleor2.json +0 -3273
  1087. package/evals/benchmark/eval-task-generator.ts +0 -158
  1088. package/evals/benchmark/filter-supplemental-files.ts +0 -225
  1089. package/evals/benchmark/format-output.ts +0 -215
  1090. package/evals/benchmark/gen-evals.ts +0 -294
  1091. package/evals/benchmark/gen-repo-eval.ts +0 -71
  1092. package/evals/benchmark/judge.ts +0 -308
  1093. package/evals/benchmark/lessons-extractor.ts +0 -247
  1094. package/evals/benchmark/main-hard-tasks.ts +0 -49
  1095. package/evals/benchmark/main-single-eval.ts +0 -23
  1096. package/evals/benchmark/main.ts +0 -29
  1097. package/evals/benchmark/meta-analyzer.ts +0 -329
  1098. package/evals/benchmark/pick-commits.ts +0 -627
  1099. package/evals/benchmark/run-benchmark.ts +0 -657
  1100. package/evals/benchmark/runners/claude.ts +0 -176
  1101. package/evals/benchmark/runners/codex.ts +0 -143
  1102. package/evals/benchmark/runners/index.ts +0 -4
  1103. package/evals/benchmark/runners/opencode.ts +0 -253
  1104. package/evals/benchmark/runners/runner.ts +0 -13
  1105. package/evals/benchmark/runners/savant.ts +0 -148
  1106. package/evals/benchmark/setup-test-repo.ts +0 -314
  1107. package/evals/benchmark/trace-analyzer.ts +0 -257
  1108. package/evals/benchmark/trace-utils.ts +0 -80
  1109. package/evals/benchmark/types.ts +0 -83
  1110. package/evals/bunfig.toml +0 -4
  1111. package/evals/logger.ts +0 -87
  1112. package/evals/package.json +0 -31
  1113. package/evals/subagents/test-repo-utils.ts +0 -131
  1114. package/evals/tsconfig.json +0 -13
  1115. package/evals/v2/README.md +0 -149
  1116. package/evals/v2/reports/report.json +0 -261
  1117. package/evals/v2/reports/report.md +0 -15
  1118. package/evals/v2/schema/task.schema.json +0 -172
  1119. package/evals/v2/src/cli.ts +0 -216
  1120. package/evals/v2/src/golden.ts +0 -73
  1121. package/evals/v2/src/harness.ts +0 -293
  1122. package/evals/v2/src/metrics.ts +0 -326
  1123. package/evals/v2/src/registry.ts +0 -179
  1124. package/evals/v2/src/reports.ts +0 -126
  1125. package/evals/v2/src/runner.ts +0 -143
  1126. package/evals/v2/src/runners/savant.ts +0 -105
  1127. package/evals/v2/src/sandbox.ts +0 -42
  1128. package/evals/v2/src/sandboxes/docker.ts +0 -49
  1129. package/evals/v2/src/sandboxes/tempdir.ts +0 -120
  1130. package/evals/v2/src/schema.ts +0 -83
  1131. package/evals/v2/src/trace.ts +0 -186
  1132. package/evals/v2/src/verify.ts +0 -199
  1133. package/evals/v2/tasks/error_recovery/env-fault/calculator.js +0 -11
  1134. package/evals/v2/tasks/error_recovery/env-fault/calculator.test.js +0 -15
  1135. package/evals/v2/tasks/error_recovery/env-fault/golden.patch +0 -15
  1136. package/evals/v2/tasks/error_recovery/env-fault/task.yaml +0 -20
  1137. package/evals/v2/tasks/multi_agent_orchestration/options-contract/app.js +0 -5
  1138. package/evals/v2/tasks/multi_agent_orchestration/options-contract/app.test.js +0 -7
  1139. package/evals/v2/tasks/multi_agent_orchestration/options-contract/golden.patch +0 -19
  1140. package/evals/v2/tasks/multi_agent_orchestration/options-contract/greet.js +0 -3
  1141. package/evals/v2/tasks/multi_agent_orchestration/options-contract/orchestration.test.js +0 -30
  1142. package/evals/v2/tasks/multi_agent_orchestration/options-contract/task.yaml +0 -29
  1143. package/evals/v2/tasks/pure_coding/add-fix/add.js +0 -3
  1144. package/evals/v2/tasks/pure_coding/add-fix/add.test.js +0 -11
  1145. package/evals/v2/tasks/pure_coding/add-fix/golden.patch +0 -7
  1146. package/evals/v2/tasks/pure_coding/add-fix/task.yaml +0 -20
  1147. package/evals/v2/tasks/pure_coding/rename-greet/app.js +0 -5
  1148. package/evals/v2/tasks/pure_coding/rename-greet/app.test.js +0 -32
  1149. package/evals/v2/tasks/pure_coding/rename-greet/golden.patch +0 -19
  1150. package/evals/v2/tasks/pure_coding/rename-greet/greet.js +0 -5
  1151. package/evals/v2/tasks/pure_coding/rename-greet/task.yaml +0 -21
  1152. package/evals/v2/tests/golden.test.ts +0 -104
  1153. package/evals/v2/tests/harness.test.ts +0 -204
  1154. package/evals/v2/tests/metrics.test.ts +0 -353
  1155. package/evals/v2/tests/registry.test.ts +0 -210
  1156. package/evals/v2/tests/reports.test.ts +0 -218
  1157. package/evals/v2/tests/savant-runner.test.ts +0 -218
  1158. package/evals/v2/tests/schema.test.ts +0 -127
  1159. package/evals/v2/tests/tempdir-sandbox.test.ts +0 -49
  1160. package/evals/v2/tests/trace.test.ts +0 -76
  1161. package/evals/v2/tests/verify.test.ts +0 -224
  1162. package/license +0 -202
  1163. package/packages/agent-runtime/README.md +0 -46
  1164. package/packages/agent-runtime/agent_exit +0 -1
  1165. package/packages/agent-runtime/bunfig.toml +0 -2
  1166. package/packages/agent-runtime/package.json +0 -34
  1167. package/packages/agent-runtime/src/__tests__/apply-patch-tool.test.ts +0 -202
  1168. package/packages/agent-runtime/src/__tests__/cost-aggregation.test.ts +0 -397
  1169. package/packages/agent-runtime/src/__tests__/generate-diffs-prompt.test.ts +0 -107
  1170. package/packages/agent-runtime/src/__tests__/get-file-reading-updates.test.ts +0 -59
  1171. package/packages/agent-runtime/src/__tests__/gravity-index-tool.test.ts +0 -501
  1172. package/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts +0 -1512
  1173. package/packages/agent-runtime/src/__tests__/main-prompt.test.ts +0 -452
  1174. package/packages/agent-runtime/src/__tests__/n-parameter.test.ts +0 -984
  1175. package/packages/agent-runtime/src/__tests__/process-file-block.test.ts +0 -196
  1176. package/packages/agent-runtime/src/__tests__/process-str-replace.test.ts +0 -512
  1177. package/packages/agent-runtime/src/__tests__/programmatic-tool-authorization.test.ts +0 -219
  1178. package/packages/agent-runtime/src/__tests__/prompt-caching-subagents.test.ts +0 -749
  1179. package/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts +0 -454
  1180. package/packages/agent-runtime/src/__tests__/propose-tools.test.ts +0 -824
  1181. package/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts +0 -394
  1182. package/packages/agent-runtime/src/__tests__/run-agent-step-prefill.test.ts +0 -161
  1183. package/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts +0 -612
  1184. package/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts +0 -1743
  1185. package/packages/agent-runtime/src/__tests__/sandbox-generator.test.ts +0 -154
  1186. package/packages/agent-runtime/src/__tests__/spawn-agents-image-content.test.ts +0 -354
  1187. package/packages/agent-runtime/src/__tests__/spawn-agents-message-history.test.ts +0 -280
  1188. package/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts +0 -664
  1189. package/packages/agent-runtime/src/__tests__/stream-parser-abort.test.ts +0 -250
  1190. package/packages/agent-runtime/src/__tests__/stream-parser-reasoning.test.ts +0 -194
  1191. package/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts +0 -230
  1192. package/packages/agent-runtime/src/__tests__/test-utils.ts +0 -96
  1193. package/packages/agent-runtime/src/__tests__/thinker-convergence-gate.test.ts +0 -242
  1194. package/packages/agent-runtime/src/__tests__/to-token-count-input-schema.test.ts +0 -81
  1195. package/packages/agent-runtime/src/__tests__/tool-executor-sandbox.test.ts +0 -167
  1196. package/packages/agent-runtime/src/__tests__/tool-stream-parser.test.ts +0 -682
  1197. package/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts +0 -1380
  1198. package/packages/agent-runtime/src/__tests__/web-search-tool.test.ts +0 -402
  1199. package/packages/agent-runtime/src/__tests__/xml-tool-result-ordering.test.ts +0 -264
  1200. package/packages/agent-runtime/src/constants.ts +0 -12
  1201. package/packages/agent-runtime/src/context-compactor.ts +0 -425
  1202. package/packages/agent-runtime/src/find-files/__tests__/request-files-prompt.test.ts +0 -190
  1203. package/packages/agent-runtime/src/find-files/custom-file-picker-config.ts +0 -61
  1204. package/packages/agent-runtime/src/find-files/request-files-prompt.ts +0 -462
  1205. package/packages/agent-runtime/src/generate-diffs-prompt.ts +0 -38
  1206. package/packages/agent-runtime/src/get-file-reading-updates.ts +0 -27
  1207. package/packages/agent-runtime/src/llm-api/__tests__/gemini-with-fallbacks.test.ts +0 -267
  1208. package/packages/agent-runtime/src/llm-api/__tests__/serper-api.test.ts +0 -297
  1209. package/packages/agent-runtime/src/llm-api/claude.ts +0 -7
  1210. package/packages/agent-runtime/src/llm-api/context7-api.ts +0 -290
  1211. package/packages/agent-runtime/src/llm-api/gemini-with-fallbacks.ts +0 -104
  1212. package/packages/agent-runtime/src/llm-api/savant-code-web-api.ts +0 -370
  1213. package/packages/agent-runtime/src/llm-api/serper-api.ts +0 -193
  1214. package/packages/agent-runtime/src/main-prompt.ts +0 -251
  1215. package/packages/agent-runtime/src/mcp-constants.ts +0 -12
  1216. package/packages/agent-runtime/src/mcp.ts +0 -83
  1217. package/packages/agent-runtime/src/process-file-block.ts +0 -118
  1218. package/packages/agent-runtime/src/process-str-replace.ts +0 -213
  1219. package/packages/agent-runtime/src/prompt-agent-stream.ts +0 -119
  1220. package/packages/agent-runtime/src/run-agent-step.ts +0 -1632
  1221. package/packages/agent-runtime/src/run-programmatic-step.ts +0 -633
  1222. package/packages/agent-runtime/src/system-prompt/prompts.ts +0 -215
  1223. package/packages/agent-runtime/src/system-prompt/search-system-prompt.ts +0 -89
  1224. package/packages/agent-runtime/src/system-prompt/truncate-file-tree.ts +0 -414
  1225. package/packages/agent-runtime/src/templates/README.md +0 -160
  1226. package/packages/agent-runtime/src/templates/__tests__/agent-registry.test.ts +0 -489
  1227. package/packages/agent-runtime/src/templates/__tests__/strings.test.ts +0 -465
  1228. package/packages/agent-runtime/src/templates/agent-registry.ts +0 -122
  1229. package/packages/agent-runtime/src/templates/prompts.ts +0 -189
  1230. package/packages/agent-runtime/src/templates/strings.ts +0 -292
  1231. package/packages/agent-runtime/src/templates/types.ts +0 -61
  1232. package/packages/agent-runtime/src/tool-stream-parser.ts +0 -260
  1233. package/packages/agent-runtime/src/tools/filter-tool-set.ts +0 -18
  1234. package/packages/agent-runtime/src/tools/handlers/__tests__/glob.test.ts +0 -331
  1235. package/packages/agent-runtime/src/tools/handlers/__tests__/read-subtree.test.ts +0 -346
  1236. package/packages/agent-runtime/src/tools/handlers/__tests__/run-readonly-command.test.ts +0 -284
  1237. package/packages/agent-runtime/src/tools/handlers/handler-function-type.ts +0 -77
  1238. package/packages/agent-runtime/src/tools/handlers/list.ts +0 -105
  1239. package/packages/agent-runtime/src/tools/handlers/tool/__tests__/checkpoint-store.test.ts +0 -394
  1240. package/packages/agent-runtime/src/tools/handlers/tool/__tests__/skill.test.ts +0 -98
  1241. package/packages/agent-runtime/src/tools/handlers/tool/__tests__/write-file.test.ts +0 -24
  1242. package/packages/agent-runtime/src/tools/handlers/tool/add-message.ts +0 -37
  1243. package/packages/agent-runtime/src/tools/handlers/tool/add-subgoal.ts +0 -30
  1244. package/packages/agent-runtime/src/tools/handlers/tool/apply-patch.ts +0 -120
  1245. package/packages/agent-runtime/src/tools/handlers/tool/ask-user.ts +0 -25
  1246. package/packages/agent-runtime/src/tools/handlers/tool/browser-logs.ts +0 -21
  1247. package/packages/agent-runtime/src/tools/handlers/tool/checkpoint-store.ts +0 -378
  1248. package/packages/agent-runtime/src/tools/handlers/tool/code-search.ts +0 -21
  1249. package/packages/agent-runtime/src/tools/handlers/tool/composio.ts +0 -52
  1250. package/packages/agent-runtime/src/tools/handlers/tool/create-plan.ts +0 -63
  1251. package/packages/agent-runtime/src/tools/handlers/tool/end-turn.ts +0 -15
  1252. package/packages/agent-runtime/src/tools/handlers/tool/find-files.ts +0 -161
  1253. package/packages/agent-runtime/src/tools/handlers/tool/glob.ts +0 -22
  1254. package/packages/agent-runtime/src/tools/handlers/tool/gravity-index.ts +0 -230
  1255. package/packages/agent-runtime/src/tools/handlers/tool/list-directory.ts +0 -22
  1256. package/packages/agent-runtime/src/tools/handlers/tool/lookup-agent-info.ts +0 -105
  1257. package/packages/agent-runtime/src/tools/handlers/tool/propose-str-replace.ts +0 -111
  1258. package/packages/agent-runtime/src/tools/handlers/tool/propose-write-file.ts +0 -89
  1259. package/packages/agent-runtime/src/tools/handlers/tool/proposed-content-store.ts +0 -64
  1260. package/packages/agent-runtime/src/tools/handlers/tool/read-docs.ts +0 -160
  1261. package/packages/agent-runtime/src/tools/handlers/tool/read-files.ts +0 -43
  1262. package/packages/agent-runtime/src/tools/handlers/tool/read-subtree.ts +0 -198
  1263. package/packages/agent-runtime/src/tools/handlers/tool/read-url.ts +0 -21
  1264. package/packages/agent-runtime/src/tools/handlers/tool/render-ui.ts +0 -15
  1265. package/packages/agent-runtime/src/tools/handlers/tool/run-file-change-hooks.ts +0 -20
  1266. package/packages/agent-runtime/src/tools/handlers/tool/run-readonly-command.ts +0 -215
  1267. package/packages/agent-runtime/src/tools/handlers/tool/run-terminal-command.ts +0 -33
  1268. package/packages/agent-runtime/src/tools/handlers/tool/sequential-thinking.ts +0 -53
  1269. package/packages/agent-runtime/src/tools/handlers/tool/set-messages.ts +0 -19
  1270. package/packages/agent-runtime/src/tools/handlers/tool/set-output.ts +0 -104
  1271. package/packages/agent-runtime/src/tools/handlers/tool/set-scaffold-complete.ts +0 -32
  1272. package/packages/agent-runtime/src/tools/handlers/tool/skill.ts +0 -146
  1273. package/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-inline.ts +0 -149
  1274. package/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts +0 -436
  1275. package/packages/agent-runtime/src/tools/handlers/tool/spawn-agents.ts +0 -286
  1276. package/packages/agent-runtime/src/tools/handlers/tool/str-replace.ts +0 -135
  1277. package/packages/agent-runtime/src/tools/handlers/tool/suggest-followups.ts +0 -20
  1278. package/packages/agent-runtime/src/tools/handlers/tool/task-completed.ts +0 -15
  1279. package/packages/agent-runtime/src/tools/handlers/tool/think-deeply.ts +0 -25
  1280. package/packages/agent-runtime/src/tools/handlers/tool/transition-phase.ts +0 -160
  1281. package/packages/agent-runtime/src/tools/handlers/tool/update-subgoal.ts +0 -48
  1282. package/packages/agent-runtime/src/tools/handlers/tool/web-search.ts +0 -151
  1283. package/packages/agent-runtime/src/tools/handlers/tool/write-file.ts +0 -281
  1284. package/packages/agent-runtime/src/tools/handlers/tool/write-todos.ts +0 -19
  1285. package/packages/agent-runtime/src/tools/prompts.ts +0 -233
  1286. package/packages/agent-runtime/src/tools/sandbox/__tests__/engine.test.ts +0 -169
  1287. package/packages/agent-runtime/src/tools/sandbox/__tests__/shell-denylist.test.ts +0 -104
  1288. package/packages/agent-runtime/src/tools/sandbox/engine.ts +0 -109
  1289. package/packages/agent-runtime/src/tools/sandbox/index.ts +0 -14
  1290. package/packages/agent-runtime/src/tools/sandbox/shell-denylist.ts +0 -119
  1291. package/packages/agent-runtime/src/tools/stream-parser.ts +0 -407
  1292. package/packages/agent-runtime/src/tools/thinker-convergence-gate.ts +0 -162
  1293. package/packages/agent-runtime/src/tools/thought-session-store.ts +0 -43
  1294. package/packages/agent-runtime/src/tools/tool-executor.ts +0 -1098
  1295. package/packages/agent-runtime/src/util/__tests__/messages.test.ts +0 -995
  1296. package/packages/agent-runtime/src/util/__tests__/parse-tool-calls-from-text.test.ts +0 -363
  1297. package/packages/agent-runtime/src/util/__tests__/simplify-tool-results.test.ts +0 -382
  1298. package/packages/agent-runtime/src/util/__tests__/stream-xml-parser.test.ts +0 -285
  1299. package/packages/agent-runtime/src/util/__tests__/think-tags.test.ts +0 -49
  1300. package/packages/agent-runtime/src/util/__tests__/token-counter.test.ts +0 -121
  1301. package/packages/agent-runtime/src/util/activity-tracking.ts +0 -252
  1302. package/packages/agent-runtime/src/util/agent-output.ts +0 -102
  1303. package/packages/agent-runtime/src/util/cache-debug.ts +0 -330
  1304. package/packages/agent-runtime/src/util/format-value.ts +0 -16
  1305. package/packages/agent-runtime/src/util/messages.ts +0 -461
  1306. package/packages/agent-runtime/src/util/parse-tool-calls-from-text.ts +0 -122
  1307. package/packages/agent-runtime/src/util/render-read-files-result.ts +0 -18
  1308. package/packages/agent-runtime/src/util/simplify-tool-results.ts +0 -61
  1309. package/packages/agent-runtime/src/util/stream-xml-parser.ts +0 -259
  1310. package/packages/agent-runtime/src/util/think-tags.ts +0 -33
  1311. package/packages/agent-runtime/src/util/token-counter.ts +0 -136
  1312. package/packages/agent-runtime/tsconfig.json +0 -8
  1313. package/packages/code-map/README.md +0 -43
  1314. package/packages/code-map/__tests__/integration.test.ts +0 -278
  1315. package/packages/code-map/__tests__/languages.test.ts +0 -240
  1316. package/packages/code-map/__tests__/parse.test.ts +0 -640
  1317. package/packages/code-map/__tests__/test-langs/test.c +0 -42
  1318. package/packages/code-map/__tests__/test-langs/test.cpp +0 -34
  1319. package/packages/code-map/__tests__/test-langs/test.cs +0 -34
  1320. package/packages/code-map/__tests__/test-langs/test.go +0 -27
  1321. package/packages/code-map/__tests__/test-langs/test.java +0 -31
  1322. package/packages/code-map/__tests__/test-langs/test.js +0 -50
  1323. package/packages/code-map/__tests__/test-langs/test.php +0 -34
  1324. package/packages/code-map/__tests__/test-langs/test.py +0 -25
  1325. package/packages/code-map/__tests__/test-langs/test.rb +0 -28
  1326. package/packages/code-map/__tests__/test-langs/test.rs +0 -28
  1327. package/packages/code-map/__tests__/test-langs/test.ts +0 -31
  1328. package/packages/code-map/package.json +0 -33
  1329. package/packages/code-map/src/index.ts +0 -4
  1330. package/packages/code-map/src/init-node.ts +0 -185
  1331. package/packages/code-map/src/languages.ts +0 -344
  1332. package/packages/code-map/src/parse.ts +0 -410
  1333. package/packages/code-map/src/tree-sitter-queries/readme.md +0 -24
  1334. package/packages/code-map/src/tree-sitter-queries/tree-sitter-c-tags.scm +0 -16
  1335. package/packages/code-map/src/tree-sitter-queries/tree-sitter-c_sharp-tags.scm +0 -23
  1336. package/packages/code-map/src/tree-sitter-queries/tree-sitter-cpp-tags.scm +0 -29
  1337. package/packages/code-map/src/tree-sitter-queries/tree-sitter-go-tags.scm +0 -26
  1338. package/packages/code-map/src/tree-sitter-queries/tree-sitter-java-tags.scm +0 -19
  1339. package/packages/code-map/src/tree-sitter-queries/tree-sitter-javascript-tags.scm +0 -16
  1340. package/packages/code-map/src/tree-sitter-queries/tree-sitter-php-tags.scm +0 -23
  1341. package/packages/code-map/src/tree-sitter-queries/tree-sitter-python-tags.scm +0 -12
  1342. package/packages/code-map/src/tree-sitter-queries/tree-sitter-ruby-tags.scm +0 -58
  1343. package/packages/code-map/src/tree-sitter-queries/tree-sitter-rust-tags.scm +0 -26
  1344. package/packages/code-map/src/tree-sitter-queries/tree-sitter-typescript-tags.scm +0 -22
  1345. package/packages/code-map/src/types.ts +0 -11
  1346. package/packages/code-map/src/utils.ts +0 -12
  1347. package/packages/code-map/tsconfig.json +0 -10
  1348. package/packages/database/README.md +0 -43
  1349. package/packages/database/package.json +0 -28
  1350. package/packages/database/src/__tests__/service.test.ts +0 -167
  1351. package/packages/database/src/index.ts +0 -146
  1352. package/packages/database/src/service.ts +0 -342
  1353. package/packages/database/tsconfig.json +0 -9
  1354. package/packages/llm-providers/README.md +0 -42
  1355. package/packages/llm-providers/package.json +0 -42
  1356. package/packages/llm-providers/src/ollama/__tests__/detect.test.ts +0 -59
  1357. package/packages/llm-providers/src/ollama/detect.ts +0 -113
  1358. package/packages/llm-providers/src/ollama/index.ts +0 -2
  1359. package/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts +0 -855
  1360. package/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts +0 -177
  1361. package/packages/llm-providers/src/openai-compatible/chat/get-response-metadata.ts +0 -15
  1362. package/packages/llm-providers/src/openai-compatible/chat/map-openai-compatible-finish-reason.ts +0 -19
  1363. package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-api-types.ts +0 -60
  1364. package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-language-model.test.ts +0 -549
  1365. package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-language-model.ts +0 -474
  1366. package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-options.ts +0 -25
  1367. package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-metadata-extractor.ts +0 -49
  1368. package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-prepare-tools.ts +0 -162
  1369. package/packages/llm-providers/src/openai-compatible/chat/stream-transform.test.ts +0 -106
  1370. package/packages/llm-providers/src/openai-compatible/chat/stream-transform.ts +0 -585
  1371. package/packages/llm-providers/src/openai-compatible/completion/convert-to-openai-compatible-completion-prompt.ts +0 -98
  1372. package/packages/llm-providers/src/openai-compatible/completion/openai-compatible-completion-language-model.test.ts +0 -173
  1373. package/packages/llm-providers/src/openai-compatible/completion/openai-compatible-completion-language-model.ts +0 -414
  1374. package/packages/llm-providers/src/openai-compatible/completion/openai-compatible-completion-options.ts +0 -33
  1375. package/packages/llm-providers/src/openai-compatible/embedding/openai-compatible-embedding-model.ts +0 -143
  1376. package/packages/llm-providers/src/openai-compatible/embedding/openai-compatible-embedding-options.ts +0 -21
  1377. package/packages/llm-providers/src/openai-compatible/image/openai-compatible-image-model.ts +0 -129
  1378. package/packages/llm-providers/src/openai-compatible/image/openai-compatible-image-settings.ts +0 -1
  1379. package/packages/llm-providers/src/openai-compatible/index.ts +0 -27
  1380. package/packages/llm-providers/src/openai-compatible/openai-compatible-error.ts +0 -34
  1381. package/packages/llm-providers/src/openai-compatible/openai-compatible-provider.ts +0 -174
  1382. package/packages/llm-providers/src/openai-compatible/version.ts +0 -5
  1383. package/packages/llm-providers/tsconfig.json +0 -9
  1384. package/protocol.config.yaml +0 -113
  1385. package/savant-free/README.md +0 -73
  1386. package/savant-free/SPEC.md +0 -385
  1387. package/savant-free/cli/build.ts +0 -49
  1388. package/savant-free/cli/release/README.md +0 -55
  1389. package/savant-free/cli/release/index.js +0 -37
  1390. package/savant-free/cli/release/package.json +0 -43
  1391. package/savant-free/cli/release.ts +0 -128
  1392. package/savant-free/cli/smoke-test.test.ts +0 -257
  1393. package/savant-free/e2e/README.md +0 -173
  1394. package/savant-free/e2e/agent/savant-free-tester.ts +0 -52
  1395. package/savant-free/e2e/tests/ads-behavior.e2e.test.ts +0 -51
  1396. package/savant-free/e2e/tests/agent-startup.e2e.test.ts +0 -61
  1397. package/savant-free/e2e/tests/code-edit.e2e.test.ts +0 -78
  1398. package/savant-free/e2e/tests/help-command.e2e.test.ts +0 -97
  1399. package/savant-free/e2e/tests/knowledge-file.e2e.test.ts +0 -66
  1400. package/savant-free/e2e/tests/slash-commands.e2e.test.ts +0 -119
  1401. package/savant-free/e2e/tests/startup.e2e.test.ts +0 -56
  1402. package/savant-free/e2e/tests/terminal-command.e2e.test.ts +0 -71
  1403. package/savant-free/e2e/tests/version.e2e.test.ts +0 -50
  1404. package/savant-free/e2e/utils/binary-helpers.ts +0 -24
  1405. package/savant-free/e2e/utils/index.ts +0 -17
  1406. package/savant-free/e2e/utils/savant-free-session.ts +0 -229
  1407. package/savant-free/e2e/utils/tmux-custom-tools.ts +0 -156
  1408. package/savant-free/e2e/utils/tmux-helpers.ts +0 -79
  1409. package/savant-free/package.json +0 -20
  1410. package/scripts/eslint-rules/__fixtures__/debug-ast.mjs +0 -20
  1411. package/scripts/eslint-rules/__fixtures__/probe.mjs +0 -41
  1412. package/scripts/eslint-rules/__fixtures__/probe2.mjs +0 -43
  1413. package/scripts/eslint-rules/__fixtures__/rule-test.mjs +0 -44
  1414. package/scripts/eslint-rules/__fixtures__/rule-test2.mjs +0 -27
  1415. package/scripts/eslint-rules/__fixtures__/unknown-bad.ts +0 -21
  1416. package/scripts/eslint-rules/__fixtures__/unknown-good.ts +0 -35
  1417. package/scripts/eslint-rules/no-unknown-in-signatures.js +0 -141
  1418. package/scripts/release.py +0 -360
  1419. package/scripts/run-az-test.sh +0 -1150
  1420. package/scripts/sync-agents.py +0 -337
  1421. package/scripts/tmux/README.md +0 -359
  1422. package/scripts/tmux/package.json +0 -9
  1423. package/scripts/tmux/tmux-capture.sh +0 -231
  1424. package/scripts/tmux/tmux-cli.sh +0 -160
  1425. package/scripts/tmux/tmux-env.sh +0 -34
  1426. package/scripts/tmux/tmux-send.sh +0 -339
  1427. package/scripts/tmux/tmux-start.sh +0 -264
  1428. package/scripts/tmux/tmux-stop.sh +0 -157
  1429. package/scripts/tmux/tmux-viewer/README.md +0 -254
  1430. package/scripts/tmux/tmux-viewer/components/session-viewer.tsx +0 -556
  1431. package/scripts/tmux/tmux-viewer/components/theme.ts +0 -54
  1432. package/scripts/tmux/tmux-viewer/gif-encoder-2.d.ts +0 -28
  1433. package/scripts/tmux/tmux-viewer/gif-exporter.ts +0 -278
  1434. package/scripts/tmux/tmux-viewer/index.tsx +0 -237
  1435. package/scripts/tmux/tmux-viewer/package.json +0 -9
  1436. package/scripts/tmux/tmux-viewer/session-loader.ts +0 -234
  1437. package/scripts/tmux/tmux-viewer/tsconfig.json +0 -9
  1438. package/scripts/tmux/tmux-viewer/types.ts +0 -76
  1439. package/sdk/CHANGELOG.md +0 -126
  1440. package/sdk/PUBLISHING.md +0 -55
  1441. package/sdk/README.md +0 -334
  1442. package/sdk/bunfig.toml +0 -6
  1443. package/sdk/e2e/README.md +0 -158
  1444. package/sdk/e2e/custom-agents/api-integration-agent.e2e.test.ts +0 -145
  1445. package/sdk/e2e/custom-agents/apply-patch-tool.e2e.test.ts +0 -62
  1446. package/sdk/e2e/custom-agents/database-query-agent.e2e.test.ts +0 -135
  1447. package/sdk/e2e/custom-agents/weather-agent.e2e.test.ts +0 -120
  1448. package/sdk/e2e/examples/code-explainer.example.ts +0 -62
  1449. package/sdk/e2e/examples/code-reviewer.example.ts +0 -52
  1450. package/sdk/e2e/examples/commit-message-generator.example.ts +0 -61
  1451. package/sdk/e2e/examples/sdk-lint.example.ts +0 -65
  1452. package/sdk/e2e/examples/sdk-refactor.example.ts +0 -63
  1453. package/sdk/e2e/examples/sdk-test-gen.example.ts +0 -62
  1454. package/sdk/e2e/features/knowledge-files.e2e.test.ts +0 -82
  1455. package/sdk/e2e/features/max-agent-steps.e2e.test.ts +0 -65
  1456. package/sdk/e2e/features/project-files.e2e.test.ts +0 -80
  1457. package/sdk/e2e/integration/connection-check.integration.test.ts +0 -33
  1458. package/sdk/e2e/integration/event-ordering.integration.test.ts +0 -186
  1459. package/sdk/e2e/integration/event-types.integration.test.ts +0 -183
  1460. package/sdk/e2e/integration/stream-chunks.integration.test.ts +0 -191
  1461. package/sdk/e2e/streaming/concurrent-streams.e2e.test.ts +0 -164
  1462. package/sdk/e2e/streaming/subagent-streaming.e2e.test.ts +0 -158
  1463. package/sdk/e2e/utils/__tests__/event-collector.test.ts +0 -297
  1464. package/sdk/e2e/utils/e2e-mocks.ts +0 -458
  1465. package/sdk/e2e/utils/event-collector.ts +0 -145
  1466. package/sdk/e2e/utils/get-api-key.ts +0 -76
  1467. package/sdk/e2e/utils/index.ts +0 -3
  1468. package/sdk/e2e/utils/test-fixtures.ts +0 -196
  1469. package/sdk/e2e/workflows/error-recovery.e2e.test.ts +0 -112
  1470. package/sdk/e2e/workflows/multi-turn-conversation.e2e.test.ts +0 -132
  1471. package/sdk/examples/readme-example-1.ts +0 -34
  1472. package/sdk/examples/readme-example-2.ts +0 -76
  1473. package/sdk/package.json +0 -82
  1474. package/sdk/scripts/build.ts +0 -349
  1475. package/sdk/scripts/fetch-ripgrep.ts +0 -172
  1476. package/sdk/scripts/publish.ts +0 -51
  1477. package/sdk/scripts/release.js +0 -100
  1478. package/sdk/scripts/verify.ts +0 -204
  1479. package/sdk/sdk_exit +0 -1
  1480. package/sdk/smoke-test-dist.ts +0 -248
  1481. package/sdk/src/__tests__/apply-overrides-resume.test.ts +0 -83
  1482. package/sdk/src/__tests__/apply-patch.test.ts +0 -438
  1483. package/sdk/src/__tests__/build-file-tree.test.ts +0 -31
  1484. package/sdk/src/__tests__/change-file.test.ts +0 -212
  1485. package/sdk/src/__tests__/client.test.ts +0 -142
  1486. package/sdk/src/__tests__/clone-session-state.test.ts +0 -158
  1487. package/sdk/src/__tests__/code-search.test.ts +0 -896
  1488. package/sdk/src/__tests__/composio.test.ts +0 -86
  1489. package/sdk/src/__tests__/credentials.test.ts +0 -344
  1490. package/sdk/src/__tests__/database.test.ts +0 -153
  1491. package/sdk/src/__tests__/env.test.ts +0 -137
  1492. package/sdk/src/__tests__/error-utils.test.ts +0 -253
  1493. package/sdk/src/__tests__/fixtures/windows-stubborn-grandchild.ts +0 -26
  1494. package/sdk/src/__tests__/initial-session-state.test.ts +0 -447
  1495. package/sdk/src/__tests__/knowledge-file-selection.test.ts +0 -335
  1496. package/sdk/src/__tests__/load-agents.test.ts +0 -935
  1497. package/sdk/src/__tests__/load-mcp-config.test.ts +0 -285
  1498. package/sdk/src/__tests__/load-skills.test.ts +0 -325
  1499. package/sdk/src/__tests__/model-provider.test.ts +0 -44
  1500. package/sdk/src/__tests__/path-utils.test.ts +0 -66
  1501. package/sdk/src/__tests__/read-files.test.ts +0 -573
  1502. package/sdk/src/__tests__/read-url.test.ts +0 -371
  1503. package/sdk/src/__tests__/researcher-web.integration.test.ts +0 -130
  1504. package/sdk/src/__tests__/run-cancellation.test.ts +0 -1321
  1505. package/sdk/src/__tests__/run-error-preserves-history.test.ts +0 -335
  1506. package/sdk/src/__tests__/run-event-dispatch.test.ts +0 -80
  1507. package/sdk/src/__tests__/run-file-filter.test.ts +0 -530
  1508. package/sdk/src/__tests__/run-handle-event.test.ts +0 -142
  1509. package/sdk/src/__tests__/run-mcp-tool-filter.test.ts +0 -124
  1510. package/sdk/src/__tests__/run-state-child-process.test.ts +0 -72
  1511. package/sdk/src/__tests__/run-terminal-command.test.ts +0 -236
  1512. package/sdk/src/__tests__/run.integration.test.ts +0 -170
  1513. package/sdk/src/__tests__/user-knowledge-files.test.ts +0 -361
  1514. package/sdk/src/__tests__/validate-agents.test.ts +0 -943
  1515. package/sdk/src/agents/load-agents.ts +0 -339
  1516. package/sdk/src/agents/load-mcp-config.ts +0 -275
  1517. package/sdk/src/client.ts +0 -87
  1518. package/sdk/src/composio.ts +0 -78
  1519. package/sdk/src/constants.ts +0 -29
  1520. package/sdk/src/credentials.ts +0 -303
  1521. package/sdk/src/custom-tool.ts +0 -66
  1522. package/sdk/src/env.ts +0 -116
  1523. package/sdk/src/error-utils.ts +0 -127
  1524. package/sdk/src/impl/__tests__/llm-chatgpt-oauth-policy.test.ts +0 -67
  1525. package/sdk/src/impl/__tests__/llm-native-tool-call.test.ts +0 -38
  1526. package/sdk/src/impl/__tests__/llm-stream-yielded-content.test.ts +0 -118
  1527. package/sdk/src/impl/__tests__/model-provider-free-mode.test.ts +0 -166
  1528. package/sdk/src/impl/__tests__/prompt-result.test.ts +0 -210
  1529. package/sdk/src/impl/__tests__/provider-options-metadata.test.ts +0 -67
  1530. package/sdk/src/impl/agent-runtime.ts +0 -159
  1531. package/sdk/src/impl/chatgpt-backend-fetch.ts +0 -588
  1532. package/sdk/src/impl/database.ts +0 -516
  1533. package/sdk/src/impl/llm.ts +0 -852
  1534. package/sdk/src/impl/model-provider.ts +0 -638
  1535. package/sdk/src/impl/openrouter-key-resolver.ts +0 -78
  1536. package/sdk/src/index.ts +0 -133
  1537. package/sdk/src/native/ripgrep.ts +0 -141
  1538. package/sdk/src/retry-config.ts +0 -51
  1539. package/sdk/src/run-state.ts +0 -897
  1540. package/sdk/src/run.ts +0 -1222
  1541. package/sdk/src/skills/load-skills.ts +0 -253
  1542. package/sdk/src/testing/env.ts +0 -19
  1543. package/sdk/src/tools/apply-patch.ts +0 -690
  1544. package/sdk/src/tools/change-file.ts +0 -155
  1545. package/sdk/src/tools/code-search.ts +0 -547
  1546. package/sdk/src/tools/glob.ts +0 -58
  1547. package/sdk/src/tools/index.ts +0 -19
  1548. package/sdk/src/tools/list-directory.ts +0 -52
  1549. package/sdk/src/tools/path-utils.ts +0 -101
  1550. package/sdk/src/tools/read-files.ts +0 -110
  1551. package/sdk/src/tools/read-url.ts +0 -469
  1552. package/sdk/src/tools/run-file-change-hooks.ts +0 -22
  1553. package/sdk/src/tools/run-terminal-command.ts +0 -483
  1554. package/sdk/src/tools/ssrf.ts +0 -120
  1555. package/sdk/src/types/env.ts +0 -40
  1556. package/sdk/src/utils/logger.ts +0 -15
  1557. package/sdk/src/validate-agents.ts +0 -157
  1558. package/sdk/test/cjs-compatibility/package-lock.json +0 -423
  1559. package/sdk/test/cjs-compatibility/package.json +0 -21
  1560. package/sdk/test/cjs-compatibility/test-imports.js +0 -77
  1561. package/sdk/test/cjs-compatibility/test-types.ts +0 -54
  1562. package/sdk/test/cjs-compatibility/tsconfig.json +0 -17
  1563. package/sdk/test/esm-compatibility/package-lock.json +0 -423
  1564. package/sdk/test/esm-compatibility/package.json +0 -21
  1565. package/sdk/test/esm-compatibility/test-imports.js +0 -83
  1566. package/sdk/test/esm-compatibility/test-types.ts +0 -59
  1567. package/sdk/test/esm-compatibility/tsconfig.json +0 -17
  1568. package/sdk/test/ripgrep-bundling/package-lock.json +0 -423
  1569. package/sdk/test/ripgrep-bundling/package.json +0 -21
  1570. package/sdk/test/ripgrep-bundling/test-ripgrep-types.ts +0 -132
  1571. package/sdk/test/ripgrep-bundling/test-ripgrep.js +0 -257
  1572. package/sdk/test/ripgrep-bundling/tsconfig.json +0 -15
  1573. package/sdk/test/setup-env.ts +0 -54
  1574. package/sdk/test/test-sdk.ts +0 -25
  1575. package/sdk/test/tree-sitter-queries/package-lock.json +0 -47
  1576. package/sdk/test/tree-sitter-queries/package.json +0 -12
  1577. package/sdk/test/tree-sitter-queries/test-query-files.js +0 -256
  1578. package/sdk/tsconfig.build.json +0 -32
  1579. package/sdk/tsconfig.json +0 -27
  1580. package/sdk/vendor/ripgrep/arm64-darwin/rg +0 -0
  1581. package/sdk/vendor/ripgrep/arm64-linux/rg +0 -0
  1582. package/sdk/vendor/ripgrep/x64-darwin/rg +0 -0
  1583. package/sdk/vendor/ripgrep/x64-linux/rg +0 -0
  1584. package/sdk/vendor/ripgrep/x64-win32/rg.exe +0 -0
  1585. package/templates/FID-TEMPLATE.md +0 -125
  1586. package/templates/README-TEMPLATE.md +0 -87
  1587. package/templates/SESSION-SUMMARY.md +0 -135
  1588. package/tsconfig.base.json +0 -18
  1589. package/tsconfig.json +0 -27
  1590. /package/{cli/release-core/http.js → http.js} +0 -0
  1591. /package/{cli/release-core/launcher.js → launcher.js} +0 -0
@@ -1,1675 +0,0 @@
1
- {
2
- "repoUrl": "https://github.com/manifoldmarkets/manifold",
3
- "generationDate": "2025-10-12T19:12:52.933Z",
4
- "initCommand": "yarn install && git checkout -- yarn.lock",
5
- "finalCheckCommands": [
6
- "yarn --cwd common tsc --noEmit",
7
- "yarn --cwd backend/api compile",
8
- "yarn --cwd backend/scheduler compile",
9
- "yarn --cwd backend/shared build",
10
- "yarn --cwd web tsc --noEmit"
11
- ],
12
- "evalCommits": [
13
- {
14
- "id": "unify-ai-prompts",
15
- "sha": "e922ecefbc977826b7b16c1bc760e8da44387d65",
16
- "parentSha": "4e46d468c063596742b3db5140e276c7e8df9bab",
17
- "spec": "Implement a unified AI prompting interface and migrate existing endpoints to use it.\n\nScope\n- Backend shared helpers (new abstraction and OpenAI utils update)\n- Backend API endpoints that generate AI-based content\n- Scheduler job using AI summarization\n- Shared helper for AI-driven close-date inference\n- Client components for midpoint regeneration guard\n- Dependency upgrade for OpenAI SDK\n\nRequirements\n1) Add a provider-agnostic AI prompt facade\n- Create backend/shared/src/helpers/prompt-ai.ts exporting:\n - aiModels: a merged constant of model identifiers from existing provider helpers (Claude, Gemini) plus OpenAI model keys, e.g. { gpt5, gpt5mini, sonnet3, sonnet4, haiku, flash, ... }.\n - promptAI(prompt, options): a single function that routes to the appropriate provider based on model string prefix, supports:\n - system (optional system prompt)\n - webSearch (boolean)\n - reasoning (optional effort: low | medium | high)\n - parseAsJson flag: if true, parse the returned string to JSON using existing robust parser (parseAIResponseAsJson from gemini.ts); else return raw string.\n - Provider selection rule: model name starting with \"claude\" -> Claude; starting with \"gemini\" -> Gemini; otherwise -> OpenAI.\n\n2) Upgrade OpenAI SDK and move to Responses API\n- Bump backend/api/package.json dependency \"openai\" to ^5.12.2.\n- Update backend/shared/src/helpers/openai-utils.ts to:\n - Export models map for OpenAI: { gpt5: 'gpt-5', gpt5mini: 'gpt-5-mini' }.\n - Implement promptOpenAI using OpenAI.responses.create with:\n - input: user prompt\n - instructions: system (if provided)\n - tools/tool_choice when webSearch is true (use the web_search_preview_2025_03_11 tool with high search context size)\n - reasoning effort support\n - text.verbosity low\n - Return output_text as the string result and log it.\n - Expose helper to do web-search+parse JSON via existing parser (or rely on promptAI with parseAsJson: true and webSearch: true).\n\n3) Migrate AI endpoints and helpers to promptAI\nReplace provider-specific calls with promptAI across:\n- backend/api/src/generate-ai-answers.ts\n - Use promptAI with aiModels.gpt5, webSearch: true, parseAsJson: true for answers/addAnswersMode JSON.\n- backend/api/src/generate-ai-date-ranges.ts\n - Use promptAI with aiModels.gpt5mini for bucket and threshold ranges, parseAsJson: true.\n - Use promptAI with aiModels.sonnet3 and parseAsJson: true for midpoint regeneration.\n- backend/api/src/generate-ai-description.ts\n - Use promptAI with aiModels.gpt5 and webSearch: true for markdown description.\n- backend/api/src/generate-ai-market-suggestions.ts\n - Use promptAI with aiModels.gpt5, webSearch: true, parseAsJson: true for AIGeneratedMarket[] results.\n- backend/api/src/generate-ai-numeric-ranges.ts\n - Use promptAI with aiModels.gpt5mini, parseAsJson: true for thresholds and buckets.\n - For midpoint regeneration, use aiModels.gpt5mini with parseAsJson: true. Throw APIError 400 if all provided ranges are empty strings.\n- backend/api/src/generate-concise-title.ts\n - Use promptAI with aiModels.sonnet3 and system prompt for concise title generation.\n- backend/api/src/get-best-comments.ts\n - Replace Gemini usage with promptAI using aiModels.flash for both batched and final selection prompts.\n- backend/api/src/get-related-markets.ts\n - Replace Gemini call and manual parsing with promptAI using aiModels.flash and parseAsJson: true to filter IDs.\n- backend/api/src/infer-numeric-unit.ts\n - Replace Gemini call with promptAI using aiModels.flash, system prompt, parseAsJson: true to get { unit }.\n- backend/api/src/on-create-comment-on-contract.ts\n - Replace Claude/Gemini calls with promptAI:\n - Clarification detection: aiModels.gpt5, parseAsJson: true to get { isClarification, description }.\n - Needs-response check: aiModels.gpt5mini, parseAsJson: true to get { needsResponse, reason }.\n- backend/scheduler/src/jobs/auto-award-bounty.ts\n - Replace OpenAI call with promptAI using aiModels.gpt5 for comment summary.\n- backend/shared/src/helpers/ai-close-date.ts\n - Replace Gemini with promptAI using aiModels.sonnet3 to return a concise date string.\n- backend/shared/src/supabase/contracts.ts\n - Replace Gemini parse flow with promptAI using aiModels.flash, system prompt, parseAsJson true; return boolean for isSelfReferential with safe default.\n\n4) Preserve API behavior and telemetry\n- Preserve existing API signature and return types for each endpoint.\n- Retain analytics via track() calls and rate limiting via rateLimitByUser where already present.\n- Log relevant AI responses for debugging as currently done.\n\n5) Frontend guardrail adjustments\n- In web/components/new-contract/multi-numeric-date-section.tsx and multi-numeric-range-section.tsx:\n - Before calling midpoint regeneration endpoints, early return (and optionally console.log) if all answers are empty strings.\n\n6) Miscellaneous\n- Add native/.gitignore entry for Manifold.app/.\n- Ensure yarn.lock reflects the OpenAI upgrade.\n\nAcceptance Criteria\n- All replaced endpoints compile and pass type checks using promptAI.\n- Structured responses are parsed as JSON when requested and validated by existing server-side logic; errors are surfaced via APIError as before.\n- OpenAI calls use Responses API and optional web search tool; Claude/Gemini usage remains supported via promptAI where selected.\n- Existing telemetry and rate limiting remain in effect.\n- Frontend midpoint regeneration does not call server when all ranges are empty.\n- No regressions in other AI helpers; Claude/Gemini helper files remain intact and continue to expose their APIs.\n",
18
- "prompt": "Unify our AI calling pattern behind a single provider-agnostic interface and upgrade OpenAI usage. Create a shared prompt function that selects OpenAI, Claude, or Gemini by model, can optionally run web search, supports reasoning, and can return either raw text or parsed JSON. Migrate the AI endpoints and shared helpers that generate descriptions, answers, suggestions, ranges, midpoints, units, close dates, best comments, related markets, and bounty summaries to use this interface while preserving their inputs/outputs and telemetry. Upgrade the OpenAI SDK to its latest major version and use the Responses API, including web search where needed. Add light client-side guardrails to skip midpoint regeneration when no ranges are provided. Keep existing rate limits, tracking, and error handling intact.",
19
- "supplementalFiles": [
20
- "backend/shared/src/helpers/claude.ts",
21
- "backend/shared/src/helpers/gemini.ts",
22
- "backend/shared/src/helpers/perplexity.ts",
23
- "backend/api/src/helpers/endpoint.ts",
24
- "backend/api/src/helpers/rate-limit.ts",
25
- "backend/shared/src/analytics.ts",
26
- "backend/shared/src/helpers/embeddings.ts",
27
- "common/src/ai-creation-prompts.ts",
28
- "common/src/api/schema.ts"
29
- ],
30
- "fileDiffs": [
31
- {
32
- "path": "backend/api/package.json",
33
- "status": "modified",
34
- "diff": "Index: backend/api/package.json\n===================================================================\n--- backend/api/package.json\t4e46d46 (parent)\n+++ backend/api/package.json\te922ece (commit)\n@@ -54,9 +54,9 @@\n \"link-preview-js\": \"3.0.4\",\n \"lodash\": \"4.17.21\",\n \"mailgun-js\": \"0.22.0\",\n \"marked\": \"4.1.1\",\n- \"openai\": \"4.92.1\",\n+ \"openai\": \"5.12.2\",\n \"pg-promise\": \"11.10.0\",\n \"sharp\": \"0.32.6\",\n \"string-similarity\": \"4.0.4\",\n \"stripe\": \"8.194.0\",\n"
35
- },
36
- {
37
- "path": "backend/api/src/generate-ai-answers.ts",
38
- "status": "modified",
39
- "diff": "Index: backend/api/src/generate-ai-answers.ts\n===================================================================\n--- backend/api/src/generate-ai-answers.ts\t4e46d46 (parent)\n+++ backend/api/src/generate-ai-answers.ts\te922ece (commit)\n@@ -1,14 +1,14 @@\n-import { APIError, APIHandler } from './helpers/endpoint'\n-import { track } from 'shared/analytics'\n-import { promptOpenAIWebSearchParseJson } from 'shared/helpers/openai-utils'\n import {\n addAnswersModeDescription,\n multiChoiceOutcomeTypeDescriptions,\n } from 'common/ai-creation-prompts'\n+import { HOUR_MS } from 'common/util/time'\n+import { track } from 'shared/analytics'\n+import { aiModels, promptAI } from 'shared/helpers/prompt-ai'\n import { log } from 'shared/utils'\n+import { APIError, APIHandler } from './helpers/endpoint'\n import { rateLimitByUser } from './helpers/rate-limit'\n-import { HOUR_MS } from 'common/util/time'\n \n export const generateAIAnswers: APIHandler<'generate-ai-answers'> =\n rateLimitByUser(\n async (props, auth) => {\n@@ -51,12 +51,16 @@\n \"addAnswersMode\": \"ANYONE\"\n }\n `\n \n- const result = await promptOpenAIWebSearchParseJson<{\n+ const result = await promptAI<{\n answers: string[]\n addAnswersMode: 'DISABLED' | 'ONLY_CREATOR' | 'ANYONE'\n- }>(userPrompt)\n+ }>(userPrompt, {\n+ model: aiModels.gpt5,\n+ webSearch: true,\n+ parseAsJson: true,\n+ })\n log('GPT-4.1 response', result)\n \n track(auth.uid, 'generate-ai-answers', {\n question: question.substring(0, 100),\n"
40
- },
41
- {
42
- "path": "backend/api/src/generate-ai-date-ranges.ts",
43
- "status": "modified",
44
- "diff": "Index: backend/api/src/generate-ai-date-ranges.ts\n===================================================================\n--- backend/api/src/generate-ai-date-ranges.ts\t4e46d46 (parent)\n+++ backend/api/src/generate-ai-date-ranges.ts\te922ece (commit)\n@@ -1,7 +1,7 @@\n import { HOUR_MS } from 'common/util/time'\n import { track } from 'shared/analytics'\n-import { models, promptClaudeParsingJson } from 'shared/helpers/claude'\n+import { aiModels, promptAI } from 'shared/helpers/prompt-ai'\n import { log } from 'shared/utils'\n import {\n assertMidpointsAreAscending,\n assertMidpointsAreUnique,\n@@ -125,15 +125,17 @@\n const thresholdSystemPrompt = baseDateSystemPrompt('thresholds')\n \n // Generate both bucket and threshold ranges in parallel\n const [buckets, thresholds] = await Promise.all([\n- promptClaudeParsingJson<DateRangeResponse>(prompt, {\n- model: models.sonnet3,\n+ promptAI<DateRangeResponse>(prompt, {\n+ model: aiModels.gpt5mini,\n system: bucketSystemPrompt,\n+ parseAsJson: true,\n }),\n- promptClaudeParsingJson<DateRangeResponse>(prompt, {\n- model: models.sonnet3,\n+ promptAI<DateRangeResponse>(prompt, {\n+ model: aiModels.gpt5mini,\n system: thresholdSystemPrompt,\n+ parseAsJson: true,\n }),\n ])\n \n // Process bucket results\n@@ -188,10 +190,11 @@\n Return ONLY an array of midpoint dates, one for each range, without any other text or formatting.\n DO NOT return the answer array, JUST THE MIDPOINTS.\n `\n \n- const result = await promptClaudeParsingJson<string[]>(prompt, {\n- model: models.sonnet3,\n+ const result = await promptAI<string[]>(prompt, {\n+ model: aiModels.sonnet3,\n+ parseAsJson: true,\n })\n log('claudeResponse', result)\n \n track(auth.uid, 'regenerate-date-midpoints', {\n"
45
- },
46
- {
47
- "path": "backend/api/src/generate-ai-description.ts",
48
- "status": "modified",
49
- "diff": "Index: backend/api/src/generate-ai-description.ts\n===================================================================\n--- backend/api/src/generate-ai-description.ts\t4e46d46 (parent)\n+++ backend/api/src/generate-ai-description.ts\te922ece (commit)\n@@ -1,15 +1,15 @@\n-import { APIError, APIHandler } from './helpers/endpoint'\n-import { log } from 'shared/utils'\n-import { track } from 'shared/analytics'\n-import { anythingToRichText } from 'shared/tiptap'\n-import { promptOpenAIWithWebSearch } from 'shared/helpers/openai-utils'\n import {\n addAnswersModeDescription,\n outcomeTypeDescriptions,\n resolutionCriteriaPrompt,\n } from 'common/ai-creation-prompts'\n import { HOUR_MS } from 'common/util/time'\n+import { track } from 'shared/analytics'\n+import { aiModels, promptAI } from 'shared/helpers/prompt-ai'\n+import { anythingToRichText } from 'shared/tiptap'\n+import { log } from 'shared/utils'\n+import { APIError, APIHandler } from './helpers/endpoint'\n import { rateLimitByUser } from './helpers/rate-limit'\n \n export const generateAIDescription: APIHandler<'generate-ai-description'> =\n rateLimitByUser(\n@@ -83,9 +83,12 @@\n ${userQuestionAndDescription}\n \n Only return the markdown description, nothing else.\n `\n- const gptResponse = await promptOpenAIWithWebSearch(prompt)\n+ const gptResponse = await promptAI(prompt, {\n+ model: aiModels.gpt5,\n+ webSearch: true,\n+ })\n \n track(auth.uid, 'generate-ai-description', {\n question: question.substring(0, 100),\n hasExistingDescription: !!description,\n"
50
- },
51
- {
52
- "path": "backend/api/src/generate-ai-market-suggestions.ts",
53
- "status": "modified",
54
- "diff": "Index: backend/api/src/generate-ai-market-suggestions.ts\n===================================================================\n--- backend/api/src/generate-ai-market-suggestions.ts\t4e46d46 (parent)\n+++ backend/api/src/generate-ai-market-suggestions.ts\te922ece (commit)\n@@ -1,14 +1,14 @@\n-import { APIError, APIHandler, AuthedUser } from './helpers/endpoint'\n-import { AIGeneratedMarket } from 'common/contract'\n-import { log } from 'shared/utils'\n import { formattingPrompt, guidelinesPrompt } from 'common/ai-creation-prompts'\n-import { anythingToRichText } from 'shared/tiptap'\n+import { APIParams } from 'common/api/schema'\n+import { AIGeneratedMarket } from 'common/contract'\n+import { HOUR_MS } from 'common/util/time'\n import { track } from 'shared/analytics'\n+import { aiModels, promptAI } from 'shared/helpers/prompt-ai'\n+import { anythingToRichText } from 'shared/tiptap'\n+import { log } from 'shared/utils'\n+import { APIError, APIHandler, AuthedUser } from './helpers/endpoint'\n import { rateLimitByUser } from './helpers/rate-limit'\n-import { HOUR_MS } from 'common/util/time'\n-import { promptOpenAIWebSearchParseJson } from 'shared/helpers/openai-utils'\n-import { APIParams } from 'common/api/schema'\n \n export const generateSuggestions = async (\n props: APIParams<'generate-ai-market-suggestions'>,\n auth: AuthedUser\n@@ -36,11 +36,13 @@\n \n ONLY return a valid JSON array of market objects and do NOT include any other text.\n `\n \n- const response = await promptOpenAIWebSearchParseJson<AIGeneratedMarket[]>(\n- combinedPrompt\n- )\n+ const response = await promptAI<AIGeneratedMarket[]>(combinedPrompt, {\n+ model: aiModels.gpt5,\n+ webSearch: true,\n+ parseAsJson: true,\n+ })\n \n // Parse the JSON response\n let parsedMarkets: AIGeneratedMarket[] = []\n try {\n"
55
- },
56
- {
57
- "path": "backend/api/src/generate-ai-numeric-ranges.ts",
58
- "status": "modified",
59
- "diff": "Index: backend/api/src/generate-ai-numeric-ranges.ts\n===================================================================\n--- backend/api/src/generate-ai-numeric-ranges.ts\t4e46d46 (parent)\n+++ backend/api/src/generate-ai-numeric-ranges.ts\te922ece (commit)\n@@ -1,7 +1,7 @@\n import { HOUR_MS } from 'common/util/time'\n import { track } from 'shared/analytics'\n-import { models, promptClaudeParsingJson } from 'shared/helpers/claude'\n+import { aiModels, promptAI } from 'shared/helpers/prompt-ai'\n import { log } from 'shared/utils'\n import { APIError, APIHandler } from './helpers/endpoint'\n import { rateLimitByUser } from './helpers/rate-limit'\n \n@@ -45,15 +45,17 @@\n \n const bucketSystemPrompt = baseSystemPrompt('bucket')\n \n const [thresholds, buckets] = await Promise.all([\n- promptClaudeParsingJson<RangeResponse>(prompt, {\n- model: models.haiku,\n+ promptAI<RangeResponse>(prompt, {\n+ model: aiModels.gpt5mini,\n system: thresholdSystemPrompt,\n+ parseAsJson: true,\n }),\n- promptClaudeParsingJson<RangeResponse>(prompt, {\n- model: models.haiku,\n+ promptAI<RangeResponse>(prompt, {\n+ model: aiModels.gpt5mini,\n system: bucketSystemPrompt,\n+ parseAsJson: true,\n }),\n ])\n \n log('thresholds', thresholds)\n@@ -83,8 +85,12 @@\n export const regenerateNumericMidpoints: APIHandler<'regenerate-numeric-midpoints'> =\n rateLimitByUser(\n async (props, auth) => {\n const { question, description, answers, min, max, unit, tab } = props\n+ log('midpoints from answers', { answers })\n+ if (answers.every((answer) => answer.trim() === '')) {\n+ throw new APIError(400, 'No ranges provided')\n+ }\n const prompt = `${userPrompt(\n question,\n min,\n max,\n@@ -99,10 +105,11 @@\n ${tab === 'thresholds' ? thresholdExamples : bucketExamples}\n \n Return ONLY an array of midpoint numbers, one for each range, in the same order as the ranges, without any other text or formatting.`\n \n- const result = await promptClaudeParsingJson<number[]>(prompt, {\n- model: models.haiku,\n+ const result = await promptAI<number[]>(prompt, {\n+ model: aiModels.gpt5mini,\n+ parseAsJson: true,\n })\n log('claudeResponse', result)\n \n track(auth.uid, 'regenerate-numeric-midpoints', {\n"
60
- },
61
- {
62
- "path": "backend/api/src/generate-concise-title.ts",
63
- "status": "modified",
64
- "diff": "Index: backend/api/src/generate-concise-title.ts\n===================================================================\n--- backend/api/src/generate-concise-title.ts\t4e46d46 (parent)\n+++ backend/api/src/generate-concise-title.ts\te922ece (commit)\n@@ -1,9 +1,8 @@\n import { HOUR_MS } from 'common/util/time'\n-import { promptClaude } from 'shared/helpers/claude'\n+import { aiModels, promptAI } from 'shared/helpers/prompt-ai'\n import { APIHandler } from './helpers/endpoint'\n import { rateLimitByUser } from './helpers/rate-limit'\n-// import { models, promptGemini } from 'shared/helpers/gemini'\n \n export const generateConciseTitle: APIHandler<'generate-concise-title'> =\n rateLimitByUser(\n async (props) => {\n@@ -40,15 +39,12 @@\n \n const prompt = `Question: \"${question}\"\n Your concise version, without any other text or commentary:`\n \n- const response = await promptClaude(prompt, {\n+ const response = await promptAI(prompt, {\n+ model: aiModels.sonnet3,\n system,\n })\n- // const response = await promptGemini(prompt, {\n- // model: models.flash,\n- // system,\n- // })\n \n let trimmedResponse = response.trim()\n if (trimmedResponse.startsWith('\"') && trimmedResponse.endsWith('\"')) {\n trimmedResponse = trimmedResponse.slice(1, -1)\n"
65
- },
66
- {
67
- "path": "backend/api/src/get-best-comments.ts",
68
- "status": "modified",
69
- "diff": "Index: backend/api/src/get-best-comments.ts\n===================================================================\n--- backend/api/src/get-best-comments.ts\t4e46d46 (parent)\n+++ backend/api/src/get-best-comments.ts\te922ece (commit)\n@@ -1,17 +1,17 @@\n import { APIHandler } from 'api/helpers/endpoint'\n+import { ContractComment } from 'common/comment'\n+import { convertContractComment } from 'common/supabase/comments'\n+import { parseJsonContentToText } from 'common/util/parse'\n+import { uniq } from 'lodash'\n+import { aiModels, promptAI } from 'shared/helpers/prompt-ai'\n+import { getContractsDirect } from 'shared/supabase/contracts'\n+import { createSupabaseDirectClient } from 'shared/supabase/init'\n import {\n buildUserInterestsCache,\n userIdsToAverageTopicConversionScores,\n } from 'shared/topic-interests'\n-import { createSupabaseDirectClient } from 'shared/supabase/init'\n-import { convertContractComment } from 'common/supabase/comments'\n-import { parseJsonContentToText } from 'common/util/parse'\n-import { getContractsDirect } from 'shared/supabase/contracts'\n-import { uniq } from 'lodash'\n-import { ContractComment } from 'common/comment'\n import { rateLimitByUser } from './helpers/rate-limit'\n-import { promptGemini } from 'shared/helpers/gemini'\n \n export const getBestComments: APIHandler<'get-best-comments'> = rateLimitByUser(\n async (props, auth) => {\n const { limit, ignoreContractIds } = props\n@@ -151,9 +151,11 @@\n conveyed. Do NOT pick any comments that don't meet the minimum quality bar. \n Only return to me the comment ID, (ie don't say here is my top comment, just give me the ID)\n `\n \n- const batchMsgContent = await promptGemini(batchPrompt)\n+ const batchMsgContent = await promptAI(batchPrompt, {\n+ model: aiModels.flash,\n+ })\n const batchCommentIds = batchMsgContent\n ? batchMsgContent\n .split(',')\n .map((s: string) => s.trim())\n@@ -194,9 +196,9 @@\n respond with the highest quality comments you see.\n So, what are the highest quality ~${limit} comment IDs separated by commas, in order\n of descending quality? (ie don't say here are my top comments, just give me the IDs)\n `\n- const chosenComments = await promptGemini(prompt)\n+ const chosenComments = await promptAI(prompt, { model: aiModels.flash })\n const commentIds = chosenComments\n ? chosenComments\n .split(',')\n .map((s: string) => s.trim())\n"
70
- },
71
- {
72
- "path": "backend/api/src/get-related-markets.ts",
73
- "status": "modified",
74
- "diff": "Index: backend/api/src/get-related-markets.ts\n===================================================================\n--- backend/api/src/get-related-markets.ts\t4e46d46 (parent)\n+++ backend/api/src/get-related-markets.ts\te922ece (commit)\n@@ -9,9 +9,9 @@\n \n import { APIHandler } from 'api/helpers/endpoint'\n import { orderBy } from 'lodash'\n import { TOPIC_SIMILARITY_THRESHOLD } from 'shared/helpers/embeddings'\n-import { parseAIResponseAsJson, promptGemini } from 'shared/helpers/gemini'\n+import { aiModels, promptAI } from 'shared/helpers/prompt-ai'\n \n type cacheType = {\n marketIdsFromEmbeddings: string[]\n lastUpdated: number\n@@ -66,10 +66,12 @@\n \n Return a JSON array containing ONLY the IDs of markets to KEEP (those that are different enough).\n `\n \n- const response = await promptGemini(prompt)\n- const marketsToKeep = parseAIResponseAsJson(response)\n+ const marketsToKeep = await promptAI<string[]>(prompt, {\n+ model: aiModels.flash,\n+ parseAsJson: true,\n+ })\n \n if (Array.isArray(marketsToKeep) && marketsToKeep.length > 0) {\n marketsFromEmbeddings = marketsFromEmbeddings.filter((market) =>\n marketsToKeep.includes(market.id)\n"
75
- },
76
- {
77
- "path": "backend/api/src/infer-numeric-unit.ts",
78
- "status": "modified",
79
- "diff": "Index: backend/api/src/infer-numeric-unit.ts\n===================================================================\n--- backend/api/src/infer-numeric-unit.ts\t4e46d46 (parent)\n+++ backend/api/src/infer-numeric-unit.ts\te922ece (commit)\n@@ -1,7 +1,7 @@\n import { HOUR_MS } from 'common/util/time'\n import { track } from 'shared/analytics'\n-import { parseAIResponseAsJson, promptGemini } from 'shared/helpers/gemini'\n+import { aiModels, promptAI } from 'shared/helpers/prompt-ai'\n import { log } from 'shared/utils'\n import { APIError, APIHandler } from './helpers/endpoint'\n import { rateLimitByUser } from './helpers/rate-limit'\n \n@@ -30,10 +30,13 @@\n ? `Description: ${description}`\n : ''\n }\n `\n- const response = await promptGemini(prompt, { system: systemPrompt })\n- const result = parseAIResponseAsJson(response)\n+ const result = await promptAI<{ unit: string }>(prompt, {\n+ model: aiModels.flash,\n+ system: systemPrompt,\n+ parseAsJson: true,\n+ })\n log.info('Inferred unit:', { result })\n \n track(auth.uid, 'infer-numeric-unit', {\n question,\n"
80
- },
81
- {
82
- "path": "backend/api/src/on-create-comment-on-contract.ts",
83
- "status": "modified",
84
- "diff": "Index: backend/api/src/on-create-comment-on-contract.ts\n===================================================================\n--- backend/api/src/on-create-comment-on-contract.ts\t4e46d46 (parent)\n+++ backend/api/src/on-create-comment-on-contract.ts\te922ece (commit)\n@@ -16,10 +16,9 @@\n import {\n createAIDescriptionUpdateNotification,\n replied_users_info,\n } from 'shared/create-notification'\n-import { models, promptClaude } from 'shared/helpers/claude'\n-import { parseAIResponseAsJson, promptGemini } from 'shared/helpers/gemini'\n+import { aiModels, promptAI } from 'shared/helpers/prompt-ai'\n import { createCommentOnContractNotification } from 'shared/notifications/create-new-contract-comment-notif'\n import { getAnswer } from 'shared/supabase/answers'\n import { getCommentsDirect } from 'shared/supabase/contract-comments'\n import { updateContract } from 'shared/supabase/contracts'\n@@ -334,24 +333,22 @@\n NOTE: If the creator explicitly states that their comment is not a clarification, such as saying \"these comments are not a clarification,\" then you must not treat it as clarifying or changing the resolution criteria. In that case, return {\"isClarification\": false, \"description\": \"\"}.\n Only return the raw JSON object without any markdown code blocks, backticks, additional formatting, or anything else.`\n \n try {\n- const response = await promptClaude(prompt, {\n- model: models.sonnet4,\n+ const clarification = await promptAI<ClarificationResponse>(prompt, {\n+ model: aiModels.gpt5,\n+ parseAsJson: true,\n })\n log('Clarification response:', {\n question: contract.question,\n contractId: contract.id,\n slug: contract.slug,\n- response,\n+ clarification,\n })\n- if (!response) {\n+ if (!clarification) {\n log.error('No response from ai clarification')\n return\n }\n- const clarification = parseAIResponseAsJson(\n- response\n- ) as ClarificationResponse\n \n if (clarification.isClarification && clarification.description) {\n const dateParts = new Date()\n .toLocaleDateString('en-US', {\n@@ -461,11 +458,13 @@\n \n Only return the JSON object, no other text.`\n \n try {\n- const response = await promptGemini(prompt)\n- const result = parseAIResponseAsJson(response)\n- return result as { needsResponse: boolean; reason: string }\n+ const result = await promptAI<{ needsResponse: boolean; reason: string }>(\n+ prompt,\n+ { model: aiModels.gpt5mini, parseAsJson: true }\n+ )\n+ return result\n } catch (error) {\n log.error(`Error checking if comment needs response: ${error}`)\n // Default to false if there's an error\n return { needsResponse: false, reason: '' }\n"
85
- },
86
- {
87
- "path": "backend/scheduler/src/jobs/auto-award-bounty.ts",
88
- "status": "modified",
89
- "diff": "Index: backend/scheduler/src/jobs/auto-award-bounty.ts\n===================================================================\n--- backend/scheduler/src/jobs/auto-award-bounty.ts\t4e46d46 (parent)\n+++ backend/scheduler/src/jobs/auto-award-bounty.ts\te922ece (commit)\n@@ -1,12 +1,12 @@\n-import { sortBy, sumBy } from 'lodash'\n-import { BountiedQuestionContract } from 'common/contract'\n import { getAutoBountyPayoutPerHour } from 'common/bounty'\n-import { createSupabaseDirectClient } from 'shared/supabase/init'\n+import { BountiedQuestionContract } from 'common/contract'\n+import { sortBy, sumBy } from 'lodash'\n import { awardBounty } from 'shared/bounty'\n-import { promptOpenAI } from 'shared/helpers/openai-utils'\n-import { log, revalidateContractStaticProps } from 'shared/utils'\n+import { aiModels, promptAI } from 'shared/helpers/prompt-ai'\n import { updateContract } from 'shared/supabase/contracts'\n+import { createSupabaseDirectClient } from 'shared/supabase/init'\n+import { log, revalidateContractStaticProps } from 'shared/utils'\n \n export const autoAwardBounty = async () => {\n const pg = createSupabaseDirectClient()\n \n@@ -78,9 +78,9 @@\n \n The following comments have been submitted:\n \n ` + sortedComments.map((c) => `${c.likes} likes:\\n${c.content}`).join('\\n\\n')\n- const resultMessage = await promptOpenAI(prompt, 'o4-mini')\n+ const resultMessage = await promptAI(prompt, { model: aiModels.gpt5 })\n if (resultMessage) {\n await updateContract(pg, contract.id, {\n gptCommentSummary: resultMessage,\n })\n"
90
- },
91
- {
92
- "path": "backend/shared/src/helpers/ai-close-date.ts",
93
- "status": "modified",
94
- "diff": "Index: backend/shared/src/helpers/ai-close-date.ts\n===================================================================\n--- backend/shared/src/helpers/ai-close-date.ts\t4e46d46 (parent)\n+++ backend/shared/src/helpers/ai-close-date.ts\te922ece (commit)\n@@ -1,8 +1,8 @@\n import * as dayjs from 'dayjs'\n import * as utc from 'dayjs/plugin/utc'\n import { log } from 'shared/utils'\n-import { promptGemini } from './gemini'\n+import { aiModels, promptAI } from './prompt-ai'\n dayjs.extend(utc)\n \n export const getCloseDate = async (question: string, utcOffset?: number) => {\n const now = dayjs.utc().format('M/D/YYYY h:mm a')\n@@ -33,9 +33,9 @@\n \n Question: ${question}\n Now: ${now}\n End date:`\n- response = await promptGemini(prompt)\n+ response = await promptAI(prompt, { model: aiModels.sonnet3 })\n } catch (e: any) {\n log.error('Error generating close date', { e })\n return undefined\n }\n"
95
- },
96
- {
97
- "path": "backend/shared/src/helpers/openai-utils.ts",
98
- "status": "modified",
99
- "diff": "Index: backend/shared/src/helpers/openai-utils.ts\n===================================================================\n--- backend/shared/src/helpers/openai-utils.ts\t4e46d46 (parent)\n+++ backend/shared/src/helpers/openai-utils.ts\te922ece (commit)\n@@ -1,12 +1,16 @@\n-import OpenAI from 'openai'\n+import { APIError } from 'common/api/utils'\n+// import { buildArray } from 'common/util/array'\n import * as dayjs from 'dayjs'\n-import { log } from 'shared/utils'\n import * as utc from 'dayjs/plugin/utc'\n-import { APIError } from 'common/api/utils'\n-import { buildArray } from 'common/util/array'\n+import OpenAI from 'openai'\n+import { log } from 'shared/utils'\n+import { parseAIResponseAsJson } from './gemini'\n dayjs.extend(utc)\n-export type MODELS = 'o3-mini' | 'gpt-4o' | 'gpt-4.1-2025-04-14' | 'o4-mini'\n+export const models = {\n+ gpt5: 'gpt-5',\n+ gpt5mini: 'gpt-5-mini',\n+} as const\n \n export const generateEmbeddings = async (question: string) => {\n const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })\n let response\n@@ -45,66 +49,63 @@\n .then((res) => res.data[0].url)\n .catch((err) => (console.log(err), undefined))\n }\n \n+const defaultOptions: {\n+ system?: string\n+ model: (typeof models)[keyof typeof models]\n+ reasoning?: { effort: 'low' | 'medium' | 'high' }\n+ webSearch?: boolean\n+} = {\n+ model: models.gpt5,\n+}\n+\n export const promptOpenAI = async (\n prompt: string,\n- model: MODELS,\n- options: { system?: string } = {}\n+ options: typeof defaultOptions = defaultOptions\n ) => {\n- const { system } = options\n const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })\n \n- const result = await openai.chat.completions\n+ const { system, model, reasoning, webSearch } = options\n+\n+ const result = await openai.responses\n .create({\n model,\n- messages: buildArray(\n- system && {\n- role: 'system',\n- content: system,\n- },\n- { role: 'user', content: prompt }\n- ),\n+ input: prompt,\n+ ...(system ? { instructions: system } : {}),\n+ ...(webSearch\n+ ? {\n+ tools: [\n+ {\n+ type: 'web_search_preview_2025_03_11',\n+ search_context_size: 'high',\n+ },\n+ ],\n+ tool_choice: 'auto',\n+ }\n+ : {}),\n+ ...(reasoning ? { reasoning: { effort: reasoning.effort } } : {}),\n+ text: {\n+ // @ts-expect-error - verbosity is not typed\n+ verbosity: 'low',\n+ },\n })\n .catch((err) => (console.log(err), undefined))\n \n if (!result) throw new APIError(500, 'No result from OpenAI')\n \n- const message = result.choices[0].message.content\n- log('GPT4 returned message:', message)\n+ const message = (result as any).output_text as string | undefined\n+ log('OpenAI Responses returned message:', message)\n if (!message) throw new APIError(500, 'No result from OpenAI')\n return message\n }\n \n-export const promptOpenAIWithWebSearch = async (\n+export const promptOpenAIParsingAsJson = async (\n prompt: string,\n- model: MODELS = 'gpt-4.1-2025-04-14'\n+ options: typeof defaultOptions = defaultOptions\n ) => {\n- const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })\n-\n- try {\n- const result = await openai.responses.create({\n- model,\n- input: prompt,\n- tools: [{ type: 'web_search_preview', search_context_size: 'high' }], // Provide the tool definition\n- tool_choice: 'required',\n- })\n-\n- const message = result.output_text\n- log('OpenAI with tools returned message:', message)\n- if (!message) throw new Error('No message returned from OpenAI') // Changed to Error\n-\n- // Return the entire message object (could contain content or tool_calls)\n- return message\n- } catch (err: any) {\n- log.error('Error calling OpenAI with tools:', err?.message ?? err)\n- // Propagate the error or return a specific error indicator\n- // Throwing an APIError might be suitable depending on usage context\n- throw new APIError(\n- 500,\n- `OpenAI API error: ${err?.message ?? 'Unknown error'}`\n- )\n- }\n+ const res = await promptOpenAI(prompt, options)\n+ return parseAIResponseAsJson(res)\n }\n \n export const removeJsonTicksFromResponse = (response: string): string => {\n // Remove markdown code block formatting if present\n@@ -118,40 +119,11 @@\n // If no markdown formatting found, return the original response\n return response.trim()\n }\n \n-// Helper function to ensure the response is valid JSON, adapted from claude.ts\n-export const parseOpenAIResponseAsJson = (response: string): any => {\n- const cleanedResponse = removeJsonTicksFromResponse(response)\n-\n- try {\n- // Try to parse as is\n- return JSON.parse(cleanedResponse)\n- } catch (error) {\n- // If parsing fails, try to handle common issues\n-\n- // Check if it's an array wrapped in extra text\n- const arrayStart = cleanedResponse.indexOf('[')\n- const arrayEnd = cleanedResponse.lastIndexOf(']')\n-\n- if (arrayStart !== -1 && arrayEnd !== -1 && arrayEnd > arrayStart) {\n- const potentialArray = cleanedResponse.substring(arrayStart, arrayEnd + 1)\n- try {\n- return JSON.parse(potentialArray)\n- } catch (e) {\n- // If still fails, throw the original error\n- throw error\n- }\n- }\n-\n- // If we can't fix it, throw the original error\n- throw error\n- }\n-}\n-\n export const promptOpenAIWebSearchParseJson = async <T>(\n prompt: string,\n- model: MODELS = 'gpt-4.1-2025-04-14'\n+ options: typeof defaultOptions = defaultOptions\n ): Promise<T> => {\n- const response = await promptOpenAIWithWebSearch(prompt, model)\n- return parseOpenAIResponseAsJson(response)\n+ const response = await promptOpenAI(prompt, { ...options, webSearch: true })\n+ return parseAIResponseAsJson(response)\n }\n"
100
- },
101
- {
102
- "path": "backend/shared/src/helpers/prompt-ai.ts",
103
- "status": "added",
104
- "diff": "Index: backend/shared/src/helpers/prompt-ai.ts\n===================================================================\n--- backend/shared/src/helpers/prompt-ai.ts\t4e46d46 (parent)\n+++ backend/shared/src/helpers/prompt-ai.ts\te922ece (commit)\n@@ -1,1 +1,74 @@\n-[NEW FILE]\n\\n+import { models as claudeModels, promptClaude } from './claude'\n+import {\n+ models as geminiModels,\n+ parseAIResponseAsJson,\n+ promptGemini,\n+} from './gemini'\n+import { models as openaiModels, promptOpenAI } from './openai-utils'\n+\n+type ReasoningEffort = 'low' | 'medium' | 'high'\n+\n+export const aiModels = {\n+ ...openaiModels,\n+ ...claudeModels,\n+ ...geminiModels,\n+} as const\n+\n+export type PromptAIOptionsBase = {\n+ model: (typeof aiModels)[keyof typeof aiModels]\n+ system?: string\n+ webSearch?: boolean\n+ reasoning?: { effort: ReasoningEffort }\n+}\n+\n+export type PromptAIJsonOptions = PromptAIOptionsBase & { parseAsJson: true }\n+export type PromptAIStringOptions = PromptAIOptionsBase & {\n+ parseAsJson?: false\n+}\n+\n+function getProviderFromModel(\n+ model: (typeof aiModels)[keyof typeof aiModels]\n+): 'openai' | 'claude' | 'gemini' {\n+ const lower = model.toLowerCase()\n+ if (lower.startsWith('claude')) return 'claude'\n+ if (lower.startsWith('gemini')) return 'gemini'\n+ return 'openai'\n+}\n+\n+export async function promptAI<T>(\n+ prompt: string,\n+ options: PromptAIJsonOptions\n+): Promise<T>\n+export async function promptAI(\n+ prompt: string,\n+ options: PromptAIStringOptions\n+): Promise<string>\n+export async function promptAI<T = unknown>(\n+ prompt: string,\n+ options: (PromptAIJsonOptions | PromptAIStringOptions) & {\n+ model: (typeof aiModels)[keyof typeof aiModels]\n+ }\n+): Promise<string | T> {\n+ const { model, system, webSearch, reasoning } = options\n+ const provider = getProviderFromModel(model)\n+\n+ let rawResponse: string\n+\n+ if (provider === 'openai') {\n+ rawResponse = await promptOpenAI(prompt, {\n+ model: model as any,\n+ system,\n+ reasoning,\n+ webSearch,\n+ })\n+ } else if (provider === 'claude') {\n+ rawResponse = await promptClaude(prompt, { model: model as any, system })\n+ } else {\n+ rawResponse = await promptGemini(prompt, { model: model as any, system })\n+ }\n+\n+ if ('parseAsJson' in options && options.parseAsJson) {\n+ return parseAIResponseAsJson(rawResponse) as T\n+ }\n+ return rawResponse\n+}\n"
105
- },
106
- {
107
- "path": "backend/shared/src/supabase/contracts.ts",
108
- "status": "modified",
109
- "diff": "Index: backend/shared/src/supabase/contracts.ts\n===================================================================\n--- backend/shared/src/supabase/contracts.ts\t4e46d46 (parent)\n+++ backend/shared/src/supabase/contracts.ts\te922ece (commit)\n@@ -7,10 +7,10 @@\n import { isAdminId } from 'common/envs/constants'\n import { convertAnswer, convertContract } from 'common/supabase/contracts'\n import { Tables } from 'common/supabase/utils'\n import { camelCase, mapValues, sortBy } from 'lodash'\n-import { parseAIResponseAsJson, promptGemini } from 'shared/helpers/gemini'\n import { generateEmbeddings } from 'shared/helpers/openai-utils'\n+import { aiModels, promptAI } from 'shared/helpers/prompt-ai'\n import { SupabaseDirectClient } from 'shared/supabase/init'\n import { contractColumnsToSelect, log } from 'shared/utils'\n import { broadcastUpdatedContract } from 'shared/websockets/helpers'\n import { DataUpdate, update, updateData } from './utils'\n@@ -172,13 +172,17 @@\n \"isSelfReferential\": boolean,\n }`\n \n try {\n- const response = await promptGemini(contract.question, {\n- system: systemPrompt,\n- })\n- const result = parseAIResponseAsJson(response)\n- return result.isSelfReferential\n+ const result = await promptAI<{ isSelfReferential?: boolean }>(\n+ contract.question,\n+ {\n+ model: aiModels.flash,\n+ system: systemPrompt,\n+ parseAsJson: true,\n+ }\n+ )\n+ return !!result.isSelfReferential\n } catch (error) {\n log.error('Error checking for self-referential market:', {\n error: error instanceof Error ? error.message : String(error),\n })\n"
110
- },
111
- {
112
- "path": "native/.gitignore",
113
- "status": "modified",
114
- "diff": "Index: native/.gitignore\n===================================================================\n--- native/.gitignore\t4e46d46 (parent)\n+++ native/.gitignore\te922ece (commit)\n@@ -22,4 +22,5 @@\n .DS_Store\n # simulator build\n *.tar.gz\n .env.local\n+Manifold.app/\n"
115
- },
116
- {
117
- "path": "web/components/new-contract/multi-numeric-date-section.tsx",
118
- "status": "modified",
119
- "diff": "Index: web/components/new-contract/multi-numeric-date-section.tsx\n===================================================================\n--- web/components/new-contract/multi-numeric-date-section.tsx\t4e46d46 (parent)\n+++ web/components/new-contract/multi-numeric-date-section.tsx\te922ece (commit)\n@@ -160,8 +160,12 @@\n max: string,\n tab: 'thresholds' | 'buckets'\n ) => {\n setRegenerateError('')\n+ if (answers.every((answer) => answer.trim() === '')) {\n+ console.log('no answers to regenerate midpoints for')\n+ return\n+ }\n try {\n // Call regenerate-date-midpoints without tab parameter\n const result = await api('regenerate-date-midpoints', {\n question,\n"
120
- },
121
- {
122
- "path": "web/components/new-contract/multi-numeric-range-section.tsx",
123
- "status": "modified",
124
- "diff": "Index: web/components/new-contract/multi-numeric-range-section.tsx\n===================================================================\n--- web/components/new-contract/multi-numeric-range-section.tsx\t4e46d46 (parent)\n+++ web/components/new-contract/multi-numeric-range-section.tsx\te922ece (commit)\n@@ -171,8 +171,12 @@\n max: number | undefined,\n tab: 'thresholds' | 'buckets'\n ) => {\n setRegenerateError('')\n+ if (answers.every((answer) => answer.trim() === '')) {\n+ console.log('no answers to regenerate midpoints for')\n+ return\n+ }\n // Only regenerate midpoints if we have min and max\n if (min === undefined || max === undefined) return\n \n try {\n"
125
- },
126
- {
127
- "path": "yarn.lock",
128
- "status": "modified",
129
- "diff": "Index: yarn.lock\n===================================================================\n--- yarn.lock\t4e46d46 (parent)\n+++ yarn.lock\te922ece (commit)\n@@ -6541,13 +6541,8 @@\n version \"1.0.2\"\n resolved \"https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee\"\n integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==\n \n-base-64@^0.1.0:\n- version \"0.1.0\"\n- resolved \"https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb\"\n- integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==\n-\n base64-js@0.0.8:\n version \"0.0.8\"\n resolved \"https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978\"\n integrity sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==\n@@ -6838,13 +6833,8 @@\n version \"1.0.2\"\n resolved \"https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf\"\n integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==\n \n-charenc@0.0.2:\n- version \"0.0.2\"\n- resolved \"https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667\"\n- integrity sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==\n-\n cheerio-select@^2.1.0:\n version \"2.1.0\"\n resolved \"https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4\"\n integrity sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==\n@@ -7219,13 +7209,8 @@\n path-key \"^3.1.0\"\n shebang-command \"^2.0.0\"\n which \"^2.0.1\"\n \n-crypt@0.0.2:\n- version \"0.0.2\"\n- resolved \"https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b\"\n- integrity sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==\n-\n css-background-parser@^0.1.0:\n version \"0.1.0\"\n resolved \"https://registry.yarnpkg.com/css-background-parser/-/css-background-parser-0.1.0.tgz#48a17f7fe6d4d4f1bca3177ddf16c5617950741b\"\n integrity sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==\n@@ -7629,16 +7614,8 @@\n version \"4.0.2\"\n resolved \"https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d\"\n integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==\n \n-digest-fetch@^1.3.0:\n- version \"1.3.0\"\n- resolved \"https://registry.yarnpkg.com/digest-fetch/-/digest-fetch-1.3.0.tgz#898e69264d00012a23cf26e8a3e40320143fc661\"\n- integrity sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==\n- dependencies:\n- base-64 \"^0.1.0\"\n- md5 \"^2.3.0\"\n-\n dir-glob@^3.0.1:\n version \"3.0.1\"\n resolved \"https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f\"\n integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==\n@@ -9734,13 +9711,8 @@\n dependencies:\n call-bind \"^1.0.2\"\n has-tostringtag \"^1.0.0\"\n \n-is-buffer@~1.1.6:\n- version \"1.1.6\"\n- resolved \"https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be\"\n- integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==\n-\n is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7:\n version \"1.2.7\"\n resolved \"https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055\"\n integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==\n@@ -10926,17 +10898,8 @@\n version \"1.1.0\"\n resolved \"https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9\"\n integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==\n \n-md5@^2.3.0:\n- version \"2.3.0\"\n- resolved \"https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f\"\n- integrity sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==\n- dependencies:\n- charenc \"0.0.2\"\n- crypt \"0.0.2\"\n- is-buffer \"~1.1.6\"\n-\n mdn-data@2.0.28:\n version \"2.0.28\"\n resolved \"https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.28.tgz#5ec48e7bef120654539069e1ae4ddc81ca490eba\"\n integrity sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==\n@@ -11479,23 +11442,8 @@\n integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==\n dependencies:\n mimic-fn \"^2.1.0\"\n \n-openai@4.16.1:\n- version \"4.16.1\"\n- resolved \"https://registry.yarnpkg.com/openai/-/openai-4.16.1.tgz#6377682ad2af805affd1b401958fb6eb92a87d61\"\n- integrity sha512-Gr+uqUN1ICSk6VhrX64E+zL7skjI1TgPr/XUN+ZQuNLLOvx15+XZulx/lSW4wFEAQzgjBDlMBbBeikguGIjiMg==\n- dependencies:\n- \"@types/node\" \"^18.11.18\"\n- \"@types/node-fetch\" \"^2.6.4\"\n- abort-controller \"^3.0.0\"\n- agentkeepalive \"^4.2.1\"\n- digest-fetch \"^1.3.0\"\n- form-data-encoder \"1.7.2\"\n- formdata-node \"^4.3.2\"\n- node-fetch \"^2.6.7\"\n- web-streams-polyfill \"^3.2.1\"\n-\n openai@4.92.1:\n version \"4.92.1\"\n resolved \"https://registry.yarnpkg.com/openai/-/openai-4.92.1.tgz#554e1aed4a9072acf4491ee3ef725a35871276b0\"\n integrity sha512-rFjyiQF/eHXIuzyoT2qkCY/xmI+zyq9xlMZmOEFkSsyGhc8tpNaf7rW25m5uTddnk6B5gRfRX640onMhAQyTww==\n@@ -11507,8 +11455,13 @@\n form-data-encoder \"1.7.2\"\n formdata-node \"^4.3.2\"\n node-fetch \"^2.6.7\"\n \n+openai@5.12.2:\n+ version \"5.12.2\"\n+ resolved \"https://registry.yarnpkg.com/openai/-/openai-5.12.2.tgz#512ab6b80eb8414837436e208f1b951442b97761\"\n+ integrity sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==\n+\n optionator@^0.8.1:\n version \"0.8.3\"\n resolved \"https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495\"\n integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==\n"
130
- }
131
- ]
132
- },
133
- {
134
- "id": "fix-like-dedup",
135
- "sha": "4e46d468c063596742b3db5140e276c7e8df9bab",
136
- "parentSha": "22c41d39ac936ee1a5aafb672c608ae28ce73892",
137
- "spec": "Implement deterministic deduplication and conditional broadcasting for like notifications.\n\n1) Deduplicate like notifications by content, not by reaction row:\n- In backend/shared/src/notifications/create-new-like-notif.ts, when constructing the Notification for a like, set the notification id to a deterministic key based on the liker and the content that was liked. Use a format combining the liker’s user_id and the content_id with a suffix indicating a like (e.g., \"{user_id}-{content_id}-like\").\n- Ensure that this id replaces any previous use of reaction-specific IDs for the notification id, so that repeated likes on the same content by the same user do not generate multiple notifications.\n- Keep all other Notification fields consistent with existing behavior (reason: 'user_liked_your_content', appropriate source fields, text extraction, destinations via user preferences, etc.).\n\n2) Only broadcast notifications if they were inserted (avoid broadcasting on conflicts):\n- In backend/shared/src/supabase/notifications.ts, modify the insert function for a single notification so that it returns an indicator when the row was actually inserted into postgres.public.user_notifications. Use an INSERT ... ON CONFLICT DO NOTHING ... RETURNING clause to detect insertion.\n- Broadcast to the user-notifications/{userId} websocket topic only when the insert succeeded (i.e., when the RETURNING clause indicates a new row). Do not broadcast if the insert was skipped due to an existing conflicting notification.\n- Preserve existing behavior for bulk insert flows; individual notifications should only broadcast if newly inserted.\n\n3) Do not change any external API behavior:\n- The API for adding or removing reactions (e.g., backend/api/src/reaction.ts) should continue to call the like-notification creator on a new like event.\n- Notification preference checks and text content extraction should remain unchanged.\n\nAcceptance criteria:\n- Triggering a like on the same content by the same user does not result in duplicate notifications being stored or broadcast.\n- Unliking and re-liking the same content by the same user does not create additional like notifications.\n- If a notification insert is skipped due to conflict, no duplicate broadcast occurs on the websocket channel.",
138
- "prompt": "We’re seeing duplicate notifications when users like the same piece of content multiple times (e.g., unlike and re-like). Update the like-notification flow so that it deduplicates per liker and content, and only broadcasts when a new notification is actually inserted. Specifically:\n- Use a deterministic notification ID for likes based on the liker and the content they liked, so repeated likes don’t create new notifications.\n- Modify the notification insert logic to detect when an insert is skipped due to a conflict, and only broadcast the notification if it was newly inserted.\nKeep all existing notification fields, user preference checks, and API behavior unchanged.",
139
- "supplementalFiles": [
140
- "common/src/reaction.ts",
141
- "common/src/notification.ts",
142
- "backend/api/src/reaction.ts",
143
- "supabase/user_notifications.sql",
144
- "backend/shared/src/websockets/helpers.ts"
145
- ],
146
- "fileDiffs": [
147
- {
148
- "path": "backend/shared/src/notifications/create-new-like-notif.ts",
149
- "status": "modified",
150
- "diff": "Index: backend/shared/src/notifications/create-new-like-notif.ts\n===================================================================\n--- backend/shared/src/notifications/create-new-like-notif.ts\t22c41d3 (parent)\n+++ backend/shared/src/notifications/create-new-like-notif.ts\t4e46d46 (commit)\n@@ -1,18 +1,18 @@\n import { Notification } from 'common/notification'\n \n import { getContract, getPrivateUser, getUser } from 'shared/utils'\n \n-import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'\n import { Reaction } from 'common/reaction'\n+import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'\n import { createSupabaseDirectClient } from 'shared/supabase/init'\n \n+import { APIError } from 'common/api/utils'\n+import { PostComment } from 'common/comment'\n import { richTextToString } from 'common/util/parse'\n-import { insertNotificationToSupabase } from 'shared/supabase/notifications'\n import { getCommentSafe } from 'shared/supabase/contract-comments'\n+import { insertNotificationToSupabase } from 'shared/supabase/notifications'\n import { getPost } from 'shared/supabase/posts'\n-import { APIError } from 'common/api/utils'\n-import { PostComment } from 'common/comment'\n \n export const createLikeNotification = async (\n reaction: Reaction,\n commentParentType?: 'post'\n@@ -117,9 +117,9 @@\n \n text = richTextToString(comment?.content)\n }\n \n- const id = `${reaction.user_id}-${reaction_id}`\n+ const id = `${reaction.user_id}-${content_id}-like`\n const notification: Notification = {\n id,\n userId: content_owner_id,\n reason: 'user_liked_your_content',\n"
151
- },
152
- {
153
- "path": "backend/shared/src/supabase/notifications.ts",
154
- "status": "modified",
155
- "diff": "Index: backend/shared/src/supabase/notifications.ts\n===================================================================\n--- backend/shared/src/supabase/notifications.ts\t22c41d3 (parent)\n+++ backend/shared/src/supabase/notifications.ts\t4e46d46 (commit)\n@@ -1,18 +1,24 @@\n import { Notification } from 'common/notification'\n import { SupabaseDirectClient } from 'shared/supabase/init'\n-import { broadcast } from 'shared/websockets/server'\n import { bulkInsert } from 'shared/supabase/utils'\n+import { broadcast } from 'shared/websockets/server'\n \n export const insertNotificationToSupabase = async (\n notification: Notification,\n pg: SupabaseDirectClient\n ) => {\n- await pg.none(\n- `insert into postgres.public.user_notifications (user_id, notification_id, data) values ($1, $2, $3) on conflict do nothing`,\n- [notification.userId, notification.id, notification]\n+ const inserted = await pg.oneOrNone(\n+ `insert into postgres.public.user_notifications (user_id, notification_id, data)\n+ values ($1, $2, $3)\n+ on conflict do nothing\n+ returning 1 as inserted`,\n+ [notification.userId, notification.id, notification],\n+ (r) => r?.inserted as 1 | undefined\n )\n- broadcast(`user-notifications/${notification.userId}`, { notification })\n+ if (inserted) {\n+ broadcast(`user-notifications/${notification.userId}`, { notification })\n+ }\n }\n \n export const bulkInsertNotifications = async (\n notifications: Notification[],\n"
156
- }
157
- ]
158
- },
159
- {
160
- "id": "add-tv-nav",
161
- "sha": "ddb57da8602b6061410fd81c6e7f40bdeae473af",
162
- "parentSha": "862d30eaefa9392cf0167293bd2b90606ee0fd36",
163
- "spec": "Implement TV navigation and live state icon in the web sidebar.\n\nRequirements:\n1) Add a TV navigation item to the sidebar for logged-in users on both desktop and mobile:\n- Desktop: In the sidebar’s desktop nav construction, insert a new item named \"TV\" that links to \"/tv\". Use a dynamic icon that shows a live-styled TV icon when live, and a regular TV icon otherwise.\n- Mobile: In the sidebar’s mobile nav construction, insert a new item named \"TV\" that links to \"/tv\" with the same dynamic icon behavior.\n\n2) Provide a dynamic icon based on a boolean flag:\n- Introduce or utilize an options property (e.g., isLiveTV?: boolean) passed into both desktop and mobile nav builders. When true, show a live TV icon component; when false, show the regular TV icon from react-icons.\n- Ensure the options are threaded from the sidebar component into both nav builder functions.\n\n3) Update the LiveTV icon component styling:\n- In the TV icon component file, adjust the badge/indicator styling to be a small rounded dot positioned over the TV icon (move from a red dot with text to a small indigo dot without text). Keep support for a className to size the icon in nav.\n\n4) Ensure the TV route is valid:\n- Confirm or add that \"/tv\" routes to the TV page that already exists in the codebase, so that clicking the nav item loads the TV experience.\n\nConstraints and notes:\n- Do not alter unrelated nav items or routes.\n- Keep icon components consistent with the existing NavItem type, taking a className string.\n- Keep logged-in/role gates consistent with the rest of the sidebar (e.g., new TV item should appear for logged-in users, and mobile behavior should mirror desktop as described).\n- Avoid adding business logic for detecting a live stream; the boolean isLiveTV can be provided as an option.\n",
164
- "prompt": "Add a TV entry to the app’s sidebar navigation for both desktop and mobile. The item should be labeled “TV” and link to /tv. When a live show is ongoing, display a live-styled TV icon with a small badge over the TV; otherwise, show a regular TV icon. Pass a boolean option through the sidebar so the nav builders can decide which icon to render. Also update the TV icon component so its live badge is a small indigo dot positioned over the icon. Keep the rest of the sidebar and other nav items unchanged.",
165
- "supplementalFiles": [
166
- "web/components/nav/sidebar-item.tsx",
167
- "web/pages/tv/[[...scheduleId]].tsx",
168
- "web/components/tv/tv-page.tsx",
169
- "web/components/nav/bottom-nav-bar.tsx"
170
- ],
171
- "fileDiffs": [
172
- {
173
- "path": "web/components/nav/sidebar.tsx",
174
- "status": "modified",
175
- "diff": "Index: web/components/nav/sidebar.tsx\n===================================================================\n--- web/components/nav/sidebar.tsx\t862d30e (parent)\n+++ web/components/nav/sidebar.tsx\tddb57da (commit)\n@@ -18,8 +18,9 @@\n import { DAY_MS } from 'common/util/time'\n import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime'\n import { usePathname, useRouter } from 'next/navigation'\n import { IoCompassOutline } from 'react-icons/io5'\n+import { PiTelevisionSimple } from 'react-icons/pi'\n import { AppBadgesOrGetAppButton } from 'web/components/buttons/app-badges-or-get-app-button'\n import { CreateQuestionButton } from 'web/components/buttons/create-question-button'\n import { NotificationsIcon } from 'web/components/notifications-icon'\n import { useAdminOrMod } from 'web/hooks/use-admin'\n@@ -31,8 +32,9 @@\n import { SidebarSignUpButton } from '../buttons/sign-up-button'\n import { Col } from '../layout/col'\n import { AddFundsButton } from '../profile/add-funds-button'\n import { ReportsIcon } from '../reports-icon'\n+import { LiveTVIcon } from '../tv-icon'\n import { ManifoldLogo } from './manifold-logo'\n import { ProfileSummary } from './profile-summary'\n import { NavItem, SidebarItem } from './sidebar-item'\n \n@@ -55,9 +57,9 @@\n }\n \n const isNewUser = !!user && user.createdTime > Date.now() - DAY_MS\n \n- const isLiveTV = false\n+ const isLiveTV = true\n \n const navOptions = isMobile\n ? getMobileNav(() => setIsAddFundsModalOpen(!isAddFundsModalOpen), {\n isNewUser,\n@@ -136,8 +138,9 @@\n loggedIn: boolean,\n openDownloadApp: () => void,\n options: { isNewUser: boolean; isLiveTV?: boolean; isAdminOrMod: boolean }\n ) => {\n+ const { isLiveTV } = options\n if (loggedIn)\n return buildArray(\n { name: 'Browse', href: '/home', icon: SearchIcon },\n {\n@@ -145,14 +148,14 @@\n href: '/explore',\n icon: IoCompassOutline,\n iconClassName: '!h-[1.6rem] !w-[1.6rem] !mr-[0.65rem]',\n },\n- // {\n- // name: 'TV',\n- // href: '/tv',\n- // icon: PiTelevisionSimple,\n- // },\n {\n+ name: 'TV',\n+ href: '/tv',\n+ icon: isLiveTV ? LiveTVIcon : PiTelevisionSimple,\n+ },\n+ {\n name: 'Notifications',\n href: `/notifications`,\n icon: NotificationsIcon,\n },\n@@ -180,18 +183,22 @@\n const getMobileNav = (\n toggleModal: () => void,\n options: { isNewUser: boolean; isLiveTV?: boolean; isAdminOrMod: boolean }\n ) => {\n- const { isAdminOrMod } = options\n+ const { isAdminOrMod, isLiveTV } = options\n \n return buildArray<NavItem>(\n { name: 'Leagues', href: '/leagues', icon: TrophyIcon },\n {\n name: 'Share with friends',\n href: '/referrals',\n icon: StarIcon,\n },\n- // { name: 'TV', href: '/tv', icon: PiTelevisionSimple },\n+ {\n+ name: 'TV',\n+ href: '/tv',\n+ icon: isLiveTV ? LiveTVIcon : PiTelevisionSimple,\n+ },\n isAdminOrMod && {\n name: 'Reports',\n href: '/reports',\n icon: ReportsIcon,\n"
176
- },
177
- {
178
- "path": "web/components/tv-icon.tsx",
179
- "status": "modified",
180
- "diff": "Index: web/components/tv-icon.tsx\n===================================================================\n--- web/components/tv-icon.tsx\t862d30e (parent)\n+++ web/components/tv-icon.tsx\tddb57da (commit)\n@@ -5,11 +5,9 @@\n const { className } = props\n \n return (\n <Row className=\"relative justify-center\">\n- <div className=\"text-ink-0 absolute -mt-1 ml-3.5 min-w-[15px] rounded-full bg-red-500 p-[2px] text-center text-[10px] leading-3 lg:left-0 lg:-mt-1 lg:ml-2\">\n- •\n- </div>\n+ <div className=\"absolute -mt-1 ml-1 min-h-[15px] min-w-[15px] rounded-full bg-indigo-500 p-[1px] lg:left-0.5 lg:-mt-1 lg:ml-2\"></div>\n <PiTelevisionSimpleBold className={className} />\n </Row>\n )\n }\n"
181
- }
182
- ]
183
- },
184
- {
185
- "id": "slim-mcp-endpoints",
186
- "sha": "862d30eaefa9392cf0167293bd2b90606ee0fd36",
187
- "parentSha": "e8feb41e1b933fa9fcb8baa132b63399708c0643",
188
- "spec": "Implement slimmer market payloads for Model Context Protocol (MCP) endpoints and supporting utilities.\n\nRequirements:\n\n1) Add an ultra-lightweight market shape and transformer\n- In common/src/api/market-types.ts:\n - Introduce UltraLiteMarket type containing: id, url, creatorId, creatorUsername, creatorName, createdTime (ISO string), question, outcomeType, probability? (number), liquidityTier? (string label), answers? (array of { id, text, probability }), value? (for pseudo-numeric), volume (rounded integer), volume24Hours (rounded integer), isResolved, resolution?, resolutionTime? (ISO string), uniqueBettorCount.\n - Implement toUltraLiteMarket(liteMarket: LiteMarket): UltraLiteMarket that derives liquidityTier by mapping the tier index from getTierIndexFromLiquidityAndAnswers(totalLiquidity, answers?.length ?? 0) to labels: 0 = \"low\", 1 = \"medium\", 2 = \"high\", >2 = \"very high\"; return 'n/a' if tier is undefined. Convert createdTime and resolutionTime to ISO strings and round volume fields.\n\n2) Change toLiteMarket signature to accept options\n- In common/src/api/market-types.ts, change toLiteMarket(contract, includeLiteAnswers?: boolean) to toLiteMarket(contract, props: { includeLiteAnswers?: boolean } = { includeLiteAnswers: false }). Preserve existing behavior of including minimal answer info only when includeLiteAnswers is true.\n- Update all call sites to pass an options object instead of a boolean. Example: toLiteMarket(contract, { includeLiteAnswers: true }) and toLiteMarket(contract) for default behavior.\n\n3) Slim and format MCP tool responses\n- In backend/api/src/mcp.ts:\n - For the search-markets tool, request lite markets with minimal answers, convert each LiteMarket to UltraLiteMarket, then format probability and answers’ probabilities as human-readable percent strings (e.g., \"57% chance\"). Return the JSON stringified array of these slimmed maps.\n - For the get-market tool, fetch the contract by id using a direct PG client and getContract utility, convert to LiteMarket with lite answers included, then to UltraLiteMarket. Add a description string field (ensure rich text descriptions are converted to plain text). Format probabilities as percent strings as above. Return the JSON stringified single market map.\n - Ensure errors are surfaced as MCP tool execution errors with a useful message.\n\n4) Optimize get-market fetching and attach answers\n- In backend/api/src/get-market.ts:\n - Query contracts and answers in a single multi-statement SQL call using contractColumnsToSelect and where clause by id or slug.\n - Convert the contract row using convertContract, and answers using convertAnswer; attach answers if the contract type supports them.\n - Return toLiteMarket(...) when lite flag is true, else toFullMarket(...).\n\n5) Ensure search endpoints use new toLiteMarket signature\n- In backend/api/src/search-contracts.ts: pass an options object to toLiteMarket(c, { includeLiteAnswers }) instead of a boolean.\n\n6) Tier/bonus consistency for multi-answer markets\n- In common/src/tier.ts: modify getTierIndexFromLiquidityAndAnswers so that if numAnswers is falsy, it falls back to getTierIndexFromLiquidity(liquidity) instead of dividing by zero.\n- In common/src/economy.ts: update getUniqueBettorBonusAmount to always use getTierIndexFromLiquidityAndAnswers(liquidity, numAnswers), relying on the fallback behavior above; remove any separate single-answer branch.\n\n7) Imports and utilities\n- Ensure imports reflect new usage: use APIError, first, createSupabaseDirectClient, contractColumnsToSelect, convertAnswer, convertContract, removeUndefinedProps, and richTextToString where appropriate.\n\nObservable outcomes:\n- MCP search-markets and get-market tools return slimmed JSON with percent-formatted probability fields and minimal answer data, plus a string description for get-market.\n- get-market endpoint fetches contract and answers in one round-trip and includes answers in the contract object where appropriate.\n- All toLiteMarket callers compile by using the new options object signature.\n- Tier-based labels and unique bettor bonus calculations properly handle markets with and without answers.\n",
189
- "prompt": "We want to significantly reduce the amount of data returned by our MCP tools for markets while keeping them useful. Create an ultra-light market representation and use it for MCP search and single-market tools. Show probabilities as human-readable percentages, include minimal answer info with their chances, and provide a plain-text description for single-market responses. Also make the lite market transformer accept an options object and ensure search endpoints and other callers use the new signature. Optimize market fetching to grab the contract and its answers together and attach answers when relevant. Finally, make the liquidity tier and unique-bettor bonus logic consistent for both single- and multi-answer markets.",
190
- "supplementalFiles": [
191
- "backend/shared/src/utils.ts",
192
- "common/src/supabase/contracts.ts",
193
- "common/src/api/schema.ts"
194
- ],
195
- "fileDiffs": [
196
- {
197
- "path": "backend/api/src/get-market.ts",
198
- "status": "modified",
199
- "diff": "Index: backend/api/src/get-market.ts\n===================================================================\n--- backend/api/src/get-market.ts\te8feb41 (parent)\n+++ backend/api/src/get-market.ts\t862d30e (commit)\n@@ -1,19 +1,30 @@\n-import { createSupabaseDirectClient } from 'shared/supabase/init'\n-import { APIError } from 'common/api/utils'\n import { toFullMarket, toLiteMarket } from 'common/api/market-types'\n-import { convertContract } from 'common/supabase/contracts'\n+import { APIError } from 'common/api/utils'\n+import { convertAnswer, convertContract } from 'common/supabase/contracts'\n+import { first } from 'lodash'\n+import { createSupabaseDirectClient } from 'shared/supabase/init'\n+import { contractColumnsToSelect } from 'shared/utils'\n \n export const getMarket = async (\n props: ({ id: string } | { slug: string }) & { lite?: boolean }\n ) => {\n const pg = createSupabaseDirectClient()\n- const contract = await pg.oneOrNone(\n- `select * from contracts\n- where ${'id' in props ? 'id' : 'slug'} = $1`,\n- ['id' in props ? props.id : props.slug],\n- (r) => (r ? convertContract(r) : null)\n+ const whereClause = 'id' in props ? 'id' : 'slug'\n+ const whereValue = 'id' in props ? props.id : props.slug\n+\n+ const res = await pg.multi(\n+ `select ${contractColumnsToSelect} from contracts where ${whereClause} = $1 limit 1;\n+ select * from answers where contract_id = (select id from contracts where ${whereClause} = $1) order by index;`,\n+ [whereValue]\n )\n+\n+ const contract = first(res[0].map(convertContract))\n if (!contract) throw new APIError(404, 'Contract not found')\n \n+ const answers = res[1].map(convertAnswer)\n+ if (contract && 'answers' in contract) {\n+ contract.answers = answers\n+ }\n+\n return props.lite ? toLiteMarket(contract) : toFullMarket(contract)\n }\n"
200
- },
201
- {
202
- "path": "backend/api/src/mcp.ts",
203
- "status": "modified",
204
- "diff": "Index: backend/api/src/mcp.ts\n===================================================================\n--- backend/api/src/mcp.ts\te8feb41 (parent)\n+++ backend/api/src/mcp.ts\t862d30e (commit)\n@@ -6,15 +6,24 @@\n ErrorCode,\n ListToolsRequestSchema,\n McpError,\n } from '@modelcontextprotocol/sdk/types.js'\n+import {\n+ LiteMarket,\n+ toLiteMarket,\n+ toUltraLiteMarket,\n+ UltraLiteMarket,\n+} from 'common/api/market-types'\n import { API } from 'common/api/schema'\n+import { APIError } from 'common/api/utils'\n import { NON_POINTS_BETS_LIMIT } from 'common/supabase/bets'\n+import { removeUndefinedProps } from 'common/util/object'\n+import { richTextToString } from 'common/util/parse'\n import { Request, Response } from 'express'\n-import { log, metrics } from 'shared/utils'\n+import { createSupabaseDirectClient } from 'shared/supabase/init'\n+import { getContract, log, metrics } from 'shared/utils'\n import { z } from 'zod'\n import { getBets } from './get-bets'\n-import { getMarket } from './get-market'\n import { getUser } from './get-user'\n import { searchMarketsLite } from './search-contracts'\n import { searchUsers } from './search-users'\n \n@@ -219,8 +228,22 @@\n // Handle tool execution\n server.setRequestHandler(CallToolRequestSchema, async (request) => {\n const { name, arguments: args } = request.params\n metrics.inc('mcp/request_count', { name })\n+\n+ const chancifyUltraLiteMarket = (market: UltraLiteMarket) => {\n+ return removeUndefinedProps({\n+ ...market,\n+ probability: market.probability\n+ ? `${Math.round(market.probability * 100)}% chance`\n+ : undefined,\n+ answers: market.answers?.map((answer) => ({\n+ text: answer.text,\n+ probability: `${Math.round(answer.probability * 100)}% chance`,\n+ })),\n+ })\n+ }\n+\n try {\n switch (name) {\n case 'search-markets': {\n const params = API['search-markets'].props.parse(args)\n@@ -240,18 +263,22 @@\n includeLiteAnswers: true,\n }\n \n try {\n- const markets = await searchMarketsLite(\n+ const markets = (await searchMarketsLite(\n searchParams,\n undefined, // auth not required for this endpoint\n {} as Request // minimal request object since it's not used\n- )\n+ )) as LiteMarket[]\n+\n+ const marketsWithProbabilities = markets\n+ .map(toUltraLiteMarket)\n+ .map(chancifyUltraLiteMarket)\n return {\n content: [\n {\n type: 'text',\n- text: JSON.stringify(markets, null, 2),\n+ text: JSON.stringify(marketsWithProbabilities, null, 2),\n },\n ],\n }\n } catch (error: any) {\n@@ -265,14 +292,26 @@\n case 'get-market': {\n const { id } = API['market/:id'].props.parse(args)\n \n try {\n- const market = await getMarket({ id })\n+ const pg = createSupabaseDirectClient()\n+ const contract = await getContract(pg, id)\n+ if (!contract) throw new APIError(404, 'Contract not found')\n+ const market = toLiteMarket(contract, {\n+ includeLiteAnswers: true,\n+ })\n+ const marketWithProbability = removeUndefinedProps({\n+ ...chancifyUltraLiteMarket(toUltraLiteMarket(market)),\n+ description:\n+ typeof contract.description === 'string'\n+ ? contract.description\n+ : richTextToString(contract.description),\n+ })\n return {\n content: [\n {\n type: 'text',\n- text: JSON.stringify(market, null, 2),\n+ text: JSON.stringify(marketWithProbability, null, 2),\n },\n ],\n }\n } catch (error: any) {\n"
205
- },
206
- {
207
- "path": "backend/api/src/search-contracts.ts",
208
- "status": "modified",
209
- "diff": "Index: backend/api/src/search-contracts.ts\n===================================================================\n--- backend/api/src/search-contracts.ts\te8feb41 (parent)\n+++ backend/api/src/search-contracts.ts\t862d30e (commit)\n@@ -24,9 +24,9 @@\n auth\n ) => {\n const { includeLiteAnswers } = props\n const contracts = await search(props, auth?.uid)\n- return contracts.map((c) => toLiteMarket(c, includeLiteAnswers))\n+ return contracts.map((c) => toLiteMarket(c, { includeLiteAnswers }))\n }\n \n export const searchMarketsFull: APIHandler<'search-markets-full'> = async (\n props,\n"
210
- },
211
- {
212
- "path": "common/src/api/market-types.ts",
213
- "status": "modified",
214
- "diff": "Index: common/src/api/market-types.ts\n===================================================================\n--- common/src/api/market-types.ts\te8feb41 (parent)\n+++ common/src/api/market-types.ts\t862d30e (commit)\n@@ -12,9 +12,12 @@\n import { DOMAIN } from 'common/envs/constants'\n import { MAX_ID_LENGTH } from 'common/group'\n import { MAX_MULTI_NUMERIC_ANSWERS } from 'common/multi-numeric'\n import { getMappedValue } from 'common/pseudo-numeric'\n-import { liquidityTiers } from 'common/tier'\n+import {\n+ getTierIndexFromLiquidityAndAnswers,\n+ liquidityTiers,\n+} from 'common/tier'\n import { removeUndefinedProps } from 'common/util/object'\n import { richTextToString } from 'common/util/parse'\n import { randomStringRegex } from 'common/util/random'\n import { z } from 'zod'\n@@ -42,8 +45,13 @@\n pool?: { [outcome: string]: number }\n probability?: number\n p?: number\n totalLiquidity?: number\n+ answers?: {\n+ id: string\n+ text: string\n+ probability: number\n+ }[]\n // For pseudo-numeric\n value?: number\n min?: number\n max?: number\n@@ -93,9 +101,9 @@\n }\n \n export function toLiteMarket(\n contract: Contract,\n- includeLiteAnswers?: boolean\n+ props: { includeLiteAnswers?: boolean } = { includeLiteAnswers: false }\n ): LiteMarket {\n const {\n id,\n creatorId,\n@@ -125,8 +133,9 @@\n isLove,\n token,\n siblingContractId,\n } = contract\n+ const { includeLiteAnswers } = props\n \n const { p, totalLiquidity } = contract as any\n \n const probability =\n@@ -247,8 +256,99 @@\n : richTextToString(description),\n }\n }\n \n+export type UltraLiteMarket = {\n+ // Unique identifier for this market\n+ id: string\n+\n+ // Attributes about the creator\n+ creatorId: string\n+ creatorUsername: string\n+ creatorName: string\n+ createdTime: string\n+\n+ question: string\n+ url: string\n+ outcomeType: string\n+\n+ probability?: number\n+ liquidityTier?: string\n+ answers?: {\n+ id: string\n+ text: string\n+ probability: number\n+ }[]\n+ // For pseudo-numeric\n+ value?: number\n+\n+ volume: number\n+ volume24Hours: number\n+\n+ isResolved: boolean\n+ resolution?: string\n+ resolutionTime?: string\n+\n+ uniqueBettorCount: number\n+}\n+export function toUltraLiteMarket(liteMarket: LiteMarket): UltraLiteMarket {\n+ const {\n+ id,\n+ creatorId,\n+ creatorName,\n+ creatorUsername,\n+ answers,\n+ question,\n+ probability,\n+ totalLiquidity,\n+ outcomeType,\n+ volume,\n+ volume24Hours,\n+ isResolved,\n+ resolution,\n+ resolutionTime,\n+ uniqueBettorCount,\n+ url,\n+ createdTime,\n+ } = liteMarket\n+ const tier = totalLiquidity\n+ ? getTierIndexFromLiquidityAndAnswers(totalLiquidity, answers?.length ?? 0)\n+ : undefined\n+ let liquidityTier = 'n/a'\n+ if (tier !== undefined) {\n+ if (tier === 0) {\n+ liquidityTier = 'low'\n+ } else if (tier === 1) {\n+ liquidityTier = 'medium'\n+ } else if (tier === 2) {\n+ liquidityTier = 'high'\n+ } else if (tier > 2) {\n+ liquidityTier = 'very high'\n+ }\n+ }\n+ return {\n+ id,\n+ url,\n+ creatorId,\n+ creatorName,\n+ creatorUsername,\n+ answers,\n+ question,\n+ probability,\n+ liquidityTier,\n+ outcomeType,\n+ volume: Math.round(volume),\n+ volume24Hours: Math.round(volume24Hours),\n+ isResolved,\n+ resolution,\n+ resolutionTime: resolutionTime\n+ ? new Date(resolutionTime).toISOString()\n+ : undefined,\n+ createdTime: new Date(createdTime).toISOString(),\n+ uniqueBettorCount,\n+ }\n+}\n+\n function augmentAnswerWithProbability(\n contract: MultiContract,\n answer: Answer\n ): ApiAnswer {\n"
215
- },
216
- {
217
- "path": "common/src/economy.ts",
218
- "status": "modified",
219
- "diff": "Index: common/src/economy.ts\n===================================================================\n--- common/src/economy.ts\te8feb41 (parent)\n+++ common/src/economy.ts\t862d30e (commit)\n@@ -1,10 +1,10 @@\n import { OutcomeType } from 'common/contract'\n import {\n answerCostTiers,\n- liquidityTiers,\n getTierIndexFromLiquidity,\n getTierIndexFromLiquidityAndAnswers,\n+ liquidityTiers,\n } from './tier'\n \n export const DEFAULT_CASH_ANTE = 50\n export const MINIMUM_BOUNTY = 1000\n@@ -49,11 +49,8 @@\n export const getUniqueBettorBonusAmount = (\n liquidity: number,\n numAnswers: number\n ) => {\n- if (!numAnswers) {\n- return uniqueBettorBonusAmounts[getTierIndexFromLiquidity(liquidity)]\n- }\n return uniqueBettorBonusAmounts[\n getTierIndexFromLiquidityAndAnswers(liquidity, numAnswers)\n ]\n }\n"
220
- },
221
- {
222
- "path": "common/src/tier.ts",
223
- "status": "modified",
224
- "diff": "Index: common/src/tier.ts\n===================================================================\n--- common/src/tier.ts\te8feb41 (parent)\n+++ common/src/tier.ts\t862d30e (commit)\n@@ -24,8 +24,11 @@\n export function getTierIndexFromLiquidityAndAnswers(\n liquidity: number,\n numAnswers: number\n ): number {\n+ if (!numAnswers) {\n+ return getTierIndexFromLiquidity(liquidity)\n+ }\n const liquidityPerAnswer = liquidity / numAnswers\n for (\n let tierIndex = answerCostTiers.length - 1;\n tierIndex >= 0;\n"
225
- }
226
- ]
227
- },
228
- {
229
- "id": "fix-sorting-mix",
230
- "sha": "4ff62e19570460fe0d377e3e57e15ad21ae901ed",
231
- "parentSha": "0786d12616a4fa92a4dae96650ae052c529d8853",
232
- "spec": "Implement consistent mixed sorting for posts and markets and avoid client-side sorting when the topic filter indicates 'recent'.\n\nScope:\n- Search and combined results display for posts and contracts.\n\nRequirements:\n1) In web/components/search.tsx:\n- Export a new constant named SORTS_MIXING_POSTS_AND_MARKETS containing the sort keys that should mix posts and markets and be client-sorted together: 'score' and 'newest'.\n- Replace any hard-coded checks for (sort === 'score' || sort === 'newest') with SORTS_MIXING_POSTS_AND_MARKETS.includes(sort) in logic that decides whether to search and mix posts with contracts (e.g., shouldSearchPostsWithContracts).\n\n2) In web/components/contract/combined-results.tsx:\n- Import TOPIC_FILTER_KEY, SORT_KEY, and SORTS_MIXING_POSTS_AND_MARKETS from ../search.\n- Determine the local sort value from searchParams such that when searchParams[TOPIC_FILTER_KEY] === 'recent', sort is treated as undefined; otherwise, use searchParams[SORT_KEY].\n- Build the combined list of posts and contracts as follows:\n - If sort is defined and is included in SORTS_MIXING_POSTS_AND_MARKETS, sort the combined array accordingly: 'newest' sorts by descending createdTime; 'score' sorts by descending importanceScore.\n - Otherwise, do not apply client-side sorting; preserve the incoming order from upstream queries.\n- Keep existing column configuration and row render behaviors for ContractRow and PostRow unchanged. Return null when there are no combined items.\n\nAcceptance criteria:\n- Posts and contracts are mixed and client-sorted only when sort is one of the defined modes (score or newest), using the centralized constant.\n- When the topic filter is 'recent', combined results are not client-sorted and the upstream order is preserved.\n- No client comparator returns a constant 0 for unsupported sorts, ensuring consistent ordering across browsers (including Firefox).\n- All prior rendering behaviors (columns, row types, empty state) remain unchanged.",
233
- "prompt": "Unify and harden sorting behavior for mixed posts and markets in the search UI. Add a constant that identifies which sort modes should interleave posts and markets and be sorted together (use score and newest). Replace any hard-coded sort checks with membership in this constant. In the combined results component, only sort the merged list when the active sort mode is one of those allowed; otherwise, preserve the upstream order. Also, if the topic filter corresponds to a 'recent' view, disable client-side sorting entirely so the server-determined order is used. Keep existing rendering and columns intact and preserve the null return when no results exist.",
234
- "supplementalFiles": [
235
- "web/components/contract/contracts-table.tsx",
236
- "web/components/contract/contract-table-col-formats.tsx",
237
- "web/components/posts/post-row.tsx",
238
- "backend/shared/src/importance-score.ts",
239
- "backend/api/src/search-contracts.ts",
240
- "backend/shared/src/supabase/search-contracts.ts"
241
- ],
242
- "fileDiffs": [
243
- {
244
- "path": "web/components/contract/combined-results.tsx",
245
- "status": "modified",
246
- "diff": "Index: web/components/contract/combined-results.tsx\n===================================================================\n--- web/components/contract/combined-results.tsx\t0786d12 (parent)\n+++ web/components/contract/combined-results.tsx\t4ff62e1 (commit)\n@@ -1,20 +1,25 @@\n+import { Answer } from 'common/answer'\n import { Contract } from 'common/contract'\n import { TopLevelPost } from 'common/top-level-post'\n-import { ContractRow } from './contracts-table'\n-import { PostRow } from '../posts/post-row'\n-import { SearchParams, SORT_KEY } from '../search'\n-import { Key } from 'react'\n+import { buildArray } from 'common/util/array'\n import { sortBy } from 'lodash'\n-import { Answer } from 'common/answer'\n+import { Key } from 'react'\n+import { PostRow } from '../posts/post-row'\n import {\n- boostedColumn,\n- traderColumn,\n- probColumn,\n+ SearchParams,\n+ SORT_KEY,\n+ SORTS_MIXING_POSTS_AND_MARKETS,\n+ TOPIC_FILTER_KEY,\n+} from '../search'\n+import {\n actionColumn,\n+ boostedColumn,\n liquidityColumn,\n+ probColumn,\n+ traderColumn,\n } from './contract-table-col-formats'\n-import { buildArray } from 'common/util/array'\n+import { ContractRow } from './contracts-table'\n \n type CombinedResultsProps = {\n contracts: Contract[]\n posts: TopLevelPost[]\n@@ -49,16 +54,21 @@\n hideActions,\n hasBets,\n } = props\n \n- const sort = searchParams[SORT_KEY]\n+ const sort =\n+ searchParams[TOPIC_FILTER_KEY] === 'recent'\n+ ? undefined\n+ : searchParams[SORT_KEY]\n let combinedItems: (Contract | TopLevelPost)[] = []\n- combinedItems = sortBy([...contracts, ...posts], (item) => {\n- if (sort === 'newest') return -item.createdTime\n- if (sort === 'score') return -item.importanceScore\n- return 0\n- })\n-\n+ combinedItems =\n+ sort && SORTS_MIXING_POSTS_AND_MARKETS.includes(sort)\n+ ? sortBy([...contracts, ...posts], (item) => {\n+ if (sort === 'newest') return -item.createdTime\n+ if (sort === 'score') return -item.importanceScore\n+ return 0\n+ })\n+ : [...contracts, ...posts]\n if (!combinedItems.length) return null\n \n // Define columns for ContractRow, similar to how ContractsTable did\n const contractDisplayColumns = buildArray([\n"
247
- },
248
- {
249
- "path": "web/components/search.tsx",
250
- "status": "modified",
251
- "diff": "Index: web/components/search.tsx\n===================================================================\n--- web/components/search.tsx\t0786d12 (parent)\n+++ web/components/search.tsx\t4ff62e1 (commit)\n@@ -64,8 +64,10 @@\n { label: 'Low %', value: 'prob-ascending' },\n { label: '🎲 Random!', value: 'random' },\n ] as const\n \n+export const SORTS_MIXING_POSTS_AND_MARKETS = ['score', 'newest']\n+\n export const predictionMarketSorts = new Set([\n 'daily-score',\n '24-hour-vol',\n 'liquidity',\n@@ -857,9 +859,9 @@\n hb: hasBets,\n } = searchParams\n \n const shouldSearchPostsWithContracts =\n- (sort === 'score' || sort === 'newest') &&\n+ SORTS_MIXING_POSTS_AND_MARKETS.includes(sort) &&\n (!contractsOnly || !!state.posts?.length) &&\n !topicSlug &&\n forYou === '0' &&\n isPrizeMarketString === '0' &&\n"
252
- }
253
- ]
254
- },
255
- {
256
- "id": "normalize-hyphen-search",
257
- "sha": "04a6e7e4f7b48abe44efd193a26191bc6ed83b74",
258
- "parentSha": "ba89c4e95f7e21e948b5c380a52d1668e8e2f956",
259
- "spec": "Implement hyphen-insensitive market search across contract question/description and answer text by normalizing hyphen-like characters in search terms and in FTS vectors.\n\nMake the following changes:\n\n1) Add hyphen normalization helpers in backend/shared/src/helpers/search.ts\n- Introduce a function that removes hyphen variants (regular hyphen, minus, en-dash, em-dash) from a string.\n- Add a new constructPrefixTsQueryNormalized(term: string) that applies the hyphen normalization before existing sanitization, tokenization, and prefix tsquery construction. Keep the existing constructPrefixTsQuery for legacy users unchanged.\n- Log the normalized/sanitized term to aid debugging.\n\n2) Update contracts search to use normalized queries in backend/shared/src/supabase/search-contracts.ts\n- Import and use the new constructPrefixTsQueryNormalized for the 'prefix' search type when querying question_fts via to_tsquery.\n- Normalize the term via the new helper before passing it into websearch_to_tsquery for:\n - question_fts with 'without-stopwords'\n - question_nostop_fts with 'with-stopwords'\n - description_fts with 'description'\n - answers.text_fts subquery for 'answer'\n- Ensure imports are adjusted accordingly (add normalizeHyphens and constructPrefixTsQueryNormalized; keep other imports unchanged). Maintain existing sorting and filtering logic.\n\n3) Create a SQL migration to regenerate normalized FTS vectors in backend/supabase/hyphen-search-migration.sql\n- Define a Postgres function normalize_hyphens(text) that removes hyphen variants using a regex replacement.\n- Drop and recreate the following generated tsvector columns on contracts using normalize_hyphens around the source text while preserving column names:\n - question_fts: to_tsvector('english_extended', normalize_hyphens(question))\n - description_fts: to_tsvector('english_extended', normalize_hyphens(add_creator_name_to_description(data)))\n - question_nostop_fts: to_tsvector('english_nostop_with_prefix', normalize_hyphens(question))\n- Recreate corresponding GIN indexes on these columns with their original names.\n- Do not alter the answers table schema; rely on term normalization for answers search.\n\nAcceptance criteria:\n- Searching for terms with or without hyphens (e.g., \"gpt-5\", \"gpt–5\", \"gpt—5\", \"gpt5\") returns the same set of results for contract questions, descriptions, and answers.\n- The search endpoints continue to work with existing search types and sorting. No regressions in other endpoints that use constructPrefixTsQuery.\n- Database migration completes successfully, replacing the FTS vectors and indexes without changing their names.\n",
260
- "prompt": "Add hyphen-insensitive search to the market search feature. Normalize hyphen-like characters in user search terms and regenerate the full-text vector columns so that questions, descriptions, and answers match whether a hyphen is present or not (e.g., \"gpt-5\" should match \"gpt5\"). Keep the current search modes and result ordering intact. Provide a database migration that updates the FTS columns to use normalized text while keeping the same column names and indexes. Limit the change to contracts and answers search; do not modify user or group search behavior.",
261
- "supplementalFiles": [
262
- "backend/api/src/search-contracts.ts",
263
- "backend/shared/src/supabase/sql-builder.ts",
264
- "backend/shared/src/supabase/contracts.ts"
265
- ],
266
- "fileDiffs": [
267
- {
268
- "path": "backend/shared/src/helpers/search.ts",
269
- "status": "modified",
270
- "diff": "Index: backend/shared/src/helpers/search.ts\n===================================================================\n--- backend/shared/src/helpers/search.ts\tba89c4e (parent)\n+++ backend/shared/src/helpers/search.ts\t04a6e7e (commit)\n@@ -1,6 +1,10 @@\n import { log } from 'shared/utils'\n \n+export const normalizeHyphens = (text: string) => {\n+ return text.replace(/[-−–—]/g, '')\n+}\n+\n export const constructPrefixTsQuery = (term: string) => {\n const sanitized = term\n .replace(/'/g, \"''\")\n .replace(/[!&|():*<>]/g, '')\n@@ -10,8 +14,20 @@\n const tokens = sanitized.split(/\\s+/)\n return tokens.join(' & ') + ':*'\n }\n \n+export const constructPrefixTsQueryNormalized = (term: string) => {\n+ const normalizedTerm = normalizeHyphens(term)\n+ const sanitized = normalizedTerm\n+ .replace(/'/g, \"''\")\n+ .replace(/[!&|():*<>]/g, '')\n+ .trim()\n+ log(`Normalized term: \"${sanitized}\"`)\n+ if (sanitized === '') return ''\n+ const tokens = sanitized.split(/\\s+/)\n+ return tokens.join(' & ') + ':*'\n+}\n+\n export const constructIlikeQuery = (term: string) => {\n const sanitized = term\n .replace(/'/g, \"''\")\n .replace(/[_%()<>]/g, '')\n"
271
- },
272
- {
273
- "path": "backend/shared/src/supabase/search-contracts.ts",
274
- "status": "modified",
275
- "diff": "Index: backend/shared/src/supabase/search-contracts.ts\n===================================================================\n--- backend/shared/src/supabase/search-contracts.ts\tba89c4e (parent)\n+++ backend/shared/src/supabase/search-contracts.ts\t04a6e7e (commit)\n@@ -1,32 +1,35 @@\n import { Contract, isSportsContract } from 'common/contract'\n+import { PROD_MANIFOLD_LOVE_GROUP_SLUG } from 'common/envs/constants'\n+import { GROUP_SCORE_PRIOR } from 'common/feed'\n+import { tsToMillis } from 'common/supabase/utils'\n+import { answerCostTiers, getTierIndexFromLiquidity } from 'common/tier'\n+import { PrivateUser } from 'common/user'\n+import { buildArray, filterDefined } from 'common/util/array'\n+import {\n+ constructPrefixTsQueryNormalized,\n+ normalizeHyphens,\n+} from 'shared/helpers/search'\n+import { getContractPrivacyWhereSQLFilter } from 'shared/supabase/contracts'\n import { createSupabaseDirectClient } from 'shared/supabase/init'\n import {\n from,\n groupBy,\n join,\n leftJoin,\n- limit as sqlLimit,\n limit as lim,\n orderBy,\n renderSql,\n select,\n+ limit as sqlLimit,\n where,\n withClause,\n } from 'shared/supabase/sql-builder'\n-import { getContractPrivacyWhereSQLFilter } from 'shared/supabase/contracts'\n-import { PROD_MANIFOLD_LOVE_GROUP_SLUG } from 'common/envs/constants'\n-import { constructPrefixTsQuery } from 'shared/helpers/search'\n-import { buildArray, filterDefined } from 'common/util/array'\n import {\n buildUserInterestsCache,\n userIdsToAverageTopicConversionScores,\n } from 'shared/topic-interests'\n import { contractColumnsToSelectWithPrefix, log } from 'shared/utils'\n-import { PrivateUser } from 'common/user'\n-import { GROUP_SCORE_PRIOR } from 'common/feed'\n-import { tsToMillis } from 'common/supabase/utils'\n-import { answerCostTiers, getTierIndexFromLiquidity } from 'common/tier'\n \n const DEFAULT_THRESHOLD = 1000\n type TokenInputType = 'CASH' | 'MANA' | 'ALL' | 'CASH_AND_MANA'\n let importanceScoreThreshold: number | undefined = undefined\n@@ -222,9 +225,11 @@\n \n const answersSubQuery = renderSql(\n select('distinct a.contract_id'),\n from('answers a'),\n- where(`a.text_fts @@ websearch_to_tsquery('english_extended', $1)`, [term])\n+ where(`a.text_fts @@ websearch_to_tsquery('english_extended', $1)`, [\n+ normalizeHyphens(term),\n+ ])\n )\n \n const groupsFilter =\n (groupIds?.length || groupId) &&\n@@ -281,24 +286,24 @@\n term.length && [\n searchType === 'prefix' &&\n where(\n `question_fts @@ to_tsquery('english_extended', $1)`,\n- constructPrefixTsQuery(term)\n+ constructPrefixTsQueryNormalized(term)\n ),\n searchType === 'without-stopwords' &&\n where(\n `question_fts @@ websearch_to_tsquery('english_extended', $1)`,\n- term\n+ normalizeHyphens(term)\n ),\n searchType === 'with-stopwords' &&\n where(\n `question_nostop_fts @@ websearch_to_tsquery('english_nostop_with_prefix', $1)`,\n- term\n+ normalizeHyphens(term)\n ),\n searchType === 'description' &&\n where(\n `description_fts @@ websearch_to_tsquery('english_extended', $1)`,\n- term\n+ normalizeHyphens(term)\n ),\n ],\n \n orderBy(getSearchContractSortSQL(sort)),\n"
276
- },
277
- {
278
- "path": "backend/supabase/hyphen-search-migration.sql",
279
- "status": "added",
280
- "diff": "Index: backend/supabase/hyphen-search-migration.sql\n===================================================================\n--- backend/supabase/hyphen-search-migration.sql\tba89c4e (parent)\n+++ backend/supabase/hyphen-search-migration.sql\t04a6e7e (commit)\n@@ -1,1 +1,57 @@\n-[NEW FILE]\n\\n+-- Create a function to normalize hyphens for search\n+create\n+or replace function normalize_hyphens (input_text text) returns text immutable strict language sql as $$\n+ SELECT regexp_replace(input_text, '[-−–—]', '', 'g');\n+$$;\n+\n+-- Create a custom text search configuration that handles hyphens\n+create text search configuration public.english_hyphen_normalized (\n+ copy = english_extended\n+);\n+\n+-- Replace existing FTS columns with normalized versions\n+-- This approach uses the same column names to avoid breaking existing code\n+-- Drop existing generated columns and recreate with normalization\n+alter table contracts\n+drop column if exists question_fts cascade;\n+\n+alter table contracts\n+add column question_fts tsvector generated always as (\n+ to_tsvector(\n+ 'english_extended'::regconfig,\n+ normalize_hyphens (question)\n+ )\n+) stored;\n+\n+-- Recreate the question_fts index\n+create index question_fts on public.contracts using gin (question_fts);\n+\n+-- Similarly for descriptions - replace the existing column\n+alter table contracts\n+drop column if exists description_fts cascade;\n+\n+alter table contracts\n+add column description_fts tsvector generated always as (\n+ to_tsvector(\n+ 'english_extended'::regconfig,\n+ normalize_hyphens (add_creator_name_to_description (data))\n+ )\n+) stored;\n+\n+-- Recreate the description_fts index\n+create index description_fts on public.contracts using gin (description_fts);\n+\n+-- Also update question_nostop_fts for the 'with-stopwords' search type\n+alter table contracts\n+drop column if exists question_nostop_fts cascade;\n+\n+alter table contracts\n+add column question_nostop_fts tsvector generated always as (\n+ to_tsvector(\n+ 'english_nostop_with_prefix'::regconfig,\n+ normalize_hyphens (question)\n+ )\n+) stored;\n+\n+-- Recreate the question_nostop_fts index\n+create index question_nostop_fts on public.contracts using gin (question_nostop_fts);\n"
281
- }
282
- ]
283
- },
284
- {
285
- "id": "add-recent-endpoint",
286
- "sha": "ba89c4e95f7e21e948b5c380a52d1668e8e2f956",
287
- "parentSha": "b166b7c566c035f38bac4661a144e9f58915b29c",
288
- "spec": "Implement an authenticated \"recent markets\" search flow and wire it to the web search UI.\n\nRequirements:\n- Add a new API endpoint named 'recent-markets' that returns full Contract[] results using the same request props as other search endpoints and reuses the existing search logic.\n- Ensure the endpoint is authenticated (authed: true) and not publicly cached so results are not shared across users.\n- Register the endpoint in the API routes and export its handler alongside existing search handlers.\n- Update the web search component to call 'recent-markets' when the search topic is the 'recent' slug; otherwise retain the existing 'search-markets-full' behavior.\n- Maintain type safety in the web search component by updating the Promise result typing and result unpacking for users, topics, and posts as needed.\n\nImplementation details:\n1) Define endpoint schema\n- In common/src/api/schema.ts, add a new endpoint key 'recent-markets'.\n- Set method to GET, visibility to 'undocumented', authed to true, returns to [] as Contract[], and props to searchProps (the same Zod schema used by existing search endpoints).\n\n2) Implement backend handler and registration\n- In backend/api/src/search-contracts.ts, export a handler getRecentMarkets implemented as an APIHandler<'recent-markets'> that calls the internal search function with (props, auth.uid), mirroring how the full search handler passes auth.\n- In backend/api/src/routes.ts, import getRecentMarkets and add an entry 'recent-markets': getRecentMarkets to the handlers map.\n\n3) Update web search caller\n- In web/components/search.tsx, determine which endpoint to call at fetch time: if topicSlug === 'recent', use 'recent-markets'; otherwise use 'search-markets-full'.\n- Update the searchPromises typings to include the union of APIResponse<'recent-markets'> and other existing responses, and adjust the result unpacking indices with explicit casts where necessary (contracts, users, topics, posts) to maintain type correctness.\n- Keep the rest of the search behavior unchanged (filters, sorting, pagination, posts, users/topics toggles), only switching the endpoint under the 'recent' topic.\n\nAcceptance criteria:\n- A GET request to 'recent-markets' with valid searchProps returns full Contract[] results, personalized using the calling user's auth context.\n- The endpoint requires auth and is not publicly cached by the client layer.\n- The routes map includes 'recent-markets' and the handler compiles and runs.\n- Navigating to the search UI with the topic slug equal to 'recent' causes the page to call the 'recent-markets' endpoint; other topics continue to use 'search-markets-full'.\n- TypeScript builds cleanly with the new endpoint and the updated search UI typing.",
289
- "prompt": "Add an authenticated endpoint to fetch recent markets and update the search page to use it when the user is on the Recent topic.\n\nSpecifically:\n- Create a new GET API endpoint called recent-markets that accepts the same search parameters as the existing markets search and returns full contracts. It should require authentication and not be publicly cached.\n- Reuse the existing search implementation so the behavior and ranking are consistent with current search. Pass the current user's ID when performing the search.\n- Register the endpoint in the backend routes so it can be called by the client.\n- Update the web search component so that when the selected topic is the \"recent\" slug, it requests recent-markets; otherwise it continues to request the existing full search endpoint. Keep the rest of the search experience the same.\n- Ensure TypeScript types align for the new endpoint and the combined results handling in the search UI.",
290
- "supplementalFiles": [
291
- "common/src/api/market-search-types.ts",
292
- "web/lib/api/api.ts",
293
- "web/hooks/use-api-getter.ts",
294
- "common/supabase/contracts.ts"
295
- ],
296
- "fileDiffs": [
297
- {
298
- "path": "backend/api/src/routes.ts",
299
- "status": "modified",
300
- "diff": "Index: backend/api/src/routes.ts\n===================================================================\n--- backend/api/src/routes.ts\tb166b7c (parent)\n+++ backend/api/src/routes.ts\tba89c4e (commit)\n@@ -118,9 +118,13 @@\n import { refreshAllClients } from './refresh-all-clients'\n import { removeLiquidity } from './remove-liquidity'\n import { resolveMarket } from './resolve-market'\n import { saveTwitchCredentials } from './save-twitch-credentials'\n-import { searchMarketsFull, searchMarketsLite } from './search-contracts'\n+import {\n+ getRecentMarkets,\n+ searchMarketsFull,\n+ searchMarketsLite,\n+} from './search-contracts'\n import { searchGroups, searchMyGroups } from './search-groups'\n import { searchUsers } from './search-users'\n import { sellShares } from './sell-shares'\n import { setnews } from './set-news'\n@@ -240,8 +244,9 @@\n leagues: getLeagues,\n markets: getMarkets,\n 'search-markets': searchMarketsLite,\n 'search-markets-full': searchMarketsFull,\n+ 'recent-markets': getRecentMarkets,\n managram: managram,\n managrams: getManagrams,\n manalink: createManalink,\n donate: donate,\n"
301
- },
302
- {
303
- "path": "backend/api/src/search-contracts.ts",
304
- "status": "modified",
305
- "diff": "Index: backend/api/src/search-contracts.ts\n===================================================================\n--- backend/api/src/search-contracts.ts\tb166b7c (parent)\n+++ backend/api/src/search-contracts.ts\tba89c4e (commit)\n@@ -34,8 +34,15 @@\n ) => {\n return await search(props, auth?.uid)\n }\n \n+export const getRecentMarkets: APIHandler<'recent-markets'> = async (\n+ props,\n+ auth\n+) => {\n+ return await search(props, auth.uid)\n+}\n+\n const search = async (\n props: z.infer<typeof searchProps>,\n userId: string | undefined\n ) => {\n"
306
- },
307
- {
308
- "path": "common/src/api/schema.ts",
309
- "status": "modified",
310
- "diff": "Index: common/src/api/schema.ts\n===================================================================\n--- common/src/api/schema.ts\tb166b7c (parent)\n+++ common/src/api/schema.ts\tba89c4e (commit)\n@@ -858,8 +858,15 @@\n cache: DEFAULT_CACHE_STRATEGY,\n returns: [] as Contract[],\n props: searchProps,\n },\n+ 'recent-markets': {\n+ method: 'GET',\n+ visibility: 'undocumented',\n+ authed: true, // authed and no cache means users won't accidentally see results from other users\n+ returns: [] as Contract[],\n+ props: searchProps,\n+ },\n managram: {\n method: 'POST',\n visibility: 'public',\n authed: true,\n"
311
- },
312
- {
313
- "path": "web/components/search.tsx",
314
- "status": "modified",
315
- "diff": "Index: web/components/search.tsx\n===================================================================\n--- web/components/search.tsx\tb166b7c (parent)\n+++ web/components/search.tsx\tba89c4e (commit)\n@@ -1,49 +1,49 @@\n 'use client'\n+import { useEvent } from 'client-common/hooks/use-event'\n+import { usePersistentInMemoryState } from 'client-common/hooks/use-persistent-in-memory-state'\n import clsx from 'clsx'\n+import { FullUser } from 'common/api/user-types'\n import { Contract } from 'common/contract'\n import { LiteGroup } from 'common/group'\n+import { CONTRACTS_PER_SEARCH_PAGE } from 'common/supabase/contracts'\n+import { buildArray } from 'common/util/array'\n import { capitalize, groupBy, minBy, orderBy, sample, uniqBy } from 'lodash'\n import { ReactNode, useEffect, useRef, useState } from 'react'\n+import { Button } from 'web/components/buttons/button'\n import { AddContractToGroupButton } from 'web/components/topics/add-contract-to-group-modal'\n import { useDebouncedEffect } from 'web/hooks/use-debounced-effect'\n-import { useEvent } from 'client-common/hooks/use-event'\n-import { usePersistentInMemoryState } from 'client-common/hooks/use-persistent-in-memory-state'\n+import { usePersistentLocalState } from 'web/hooks/use-persistent-local-state'\n import { usePersistentQueriesState } from 'web/hooks/use-persistent-query-state'\n+import { api, searchGroups } from 'web/lib/api/api'\n import { track } from 'web/lib/service/analytics'\n+import { searchUsers } from 'web/lib/supabase/users'\n import { Col } from './layout/col'\n import { Row } from './layout/row'\n-import { FullUser } from 'common/api/user-types'\n-import { CONTRACTS_PER_SEARCH_PAGE } from 'common/supabase/contracts'\n-import { buildArray } from 'common/util/array'\n-import { Button } from 'web/components/buttons/button'\n-import { usePersistentLocalState } from 'web/hooks/use-persistent-local-state'\n-import { api, searchGroups } from 'web/lib/api/api'\n-import { searchUsers } from 'web/lib/supabase/users'\n \n-import { LoadingContractRow } from './contract/contracts-table'\n-import { ContractFilters } from './search/contract-filters'\n-import { UserResults } from './search/user-results'\n-import { BrowseTopicPills } from './topics/browse-topic-pills'\n-import { LoadMoreUntilNotVisible } from 'web/components/widgets/visibility-observer'\n+import { APIParams, APIResponse } from 'common/api/schema'\n+import { getFollowedGroupsCount } from 'common/supabase/groups'\n import { BinaryDigit } from 'common/tier'\n-import { useIsMobile } from 'web/hooks/use-is-mobile'\n-import { Spacer } from './layout/spacer'\n-import { useSweepstakes } from './sweepstakes-provider'\n+import { TopLevelPost } from 'common/top-level-post'\n import { SEARCH_TOPICS_TO_SUBTOPICS } from 'common/topics'\n-import { Carousel } from './widgets/carousel'\n-import { isEqual } from 'lodash'\n-import { SearchInput } from './search/search-input'\n import { removeEmojis } from 'common/util/string'\n+import { DAY_MS } from 'common/util/time'\n+import { isEqual } from 'lodash'\n+import { LoadMoreUntilNotVisible } from 'web/components/widgets/visibility-observer'\n+import { useAPIGetter } from 'web/hooks/use-api-getter'\n+import { useIsMobile } from 'web/hooks/use-is-mobile'\n import { useIsPageVisible } from 'web/hooks/use-page-visible'\n-import { TopLevelPost } from 'common/top-level-post'\n-import { CombinedResults } from './contract/combined-results'\n-import { APIParams } from 'common/api/schema'\n import { useUser } from 'web/hooks/use-user'\n-import { useAPIGetter } from 'web/hooks/use-api-getter'\n-import { getFollowedGroupsCount } from 'common/supabase/groups'\n import { db } from 'web/lib/supabase/db'\n-import { DAY_MS } from 'common/util/time'\n+import { CombinedResults } from './contract/combined-results'\n+import { LoadingContractRow } from './contract/contracts-table'\n+import { Spacer } from './layout/spacer'\n+import { ContractFilters } from './search/contract-filters'\n+import { SearchInput } from './search/search-input'\n+import { UserResults } from './search/user-results'\n+import { useSweepstakes } from './sweepstakes-provider'\n+import { BrowseTopicPills } from './topics/browse-topic-pills'\n+import { Carousel } from './widgets/carousel'\n \n const USERS_PER_PAGE = 100\n const TOPICS_PER_PAGE = 100\n \n@@ -911,10 +911,18 @@\n clearTimeout(timeoutId)\n setLoading(false)\n return shouldLoadMore\n }\n- const searchPromises: Promise<any>[] = [\n- api('search-markets-full', {\n+ const endpoint =\n+ topicSlug === 'recent' ? 'recent-markets' : 'search-markets-full'\n+ const searchPromises: Promise<\n+ | APIResponse<'recent-markets'>\n+ | APIResponse<'search-markets-full'>\n+ | APIResponse<'get-posts'>\n+ | APIResponse<'search-users'>\n+ | APIResponse<'search-groups'>\n+ >[] = [\n+ api(endpoint, {\n term: query,\n filter,\n sort,\n contractType,\n@@ -955,12 +963,12 @@\n if (id === requestId.current) {\n const newContracts = results[0] as Contract[]\n let postResultIndex = 1\n const newUsers = includeUsersAndTopics\n- ? results[postResultIndex++]\n+ ? (results[postResultIndex++] as FullUser[])\n : undefined\n const newTopics = includeUsersAndTopics\n- ? results[postResultIndex++]\n+ ? (results[postResultIndex++] as APIResponse<'search-groups'>)\n : undefined\n \n const newPostsResults =\n shouldSearchPostsWithContracts &&\n"
316
- }
317
- ]
318
- },
319
- {
320
- "id": "implement-mcp-tools",
321
- "sha": "3eac52d4c811dceb380a42d555bf86b58ccf7915",
322
- "parentSha": "75c29e8308b4f47ca46d03c21a6b9307ce6cd5f5",
323
- "spec": "Implement an HTTP-based Model Context Protocol (MCP) endpoint and extend market search to optionally include lite answers. Make the following changes:\n\n1) Add an MCP server and HTTP route\n- Create a new module that initializes an MCP server exposing these tools: search-markets, get-market, get-user, get-bets. Each tool must:\n - Validate input using the existing API schemas (zod-based) for their respective endpoints.\n - Delegate to the existing backend handlers for data retrieval.\n - Return a JSON-serialized payload in the MCP response format, and convert validation errors to MCP InvalidParams errors.\n - Increment a cumulative metric per request that captures the tool name.\n- Export a handler function that integrates with Express to handle POST requests by connecting the MCP server to the HTTP transport and forwarding the body.\n- Wire a POST route at /v0/mcp on the API Express app that accepts JSON, applies the existing permissive CORS middleware, and invokes the new MCP handler.\n\nFiles to edit:\n- backend/api/src/app.ts: import and register the new /v0/mcp POST route with JSON parsing and CORS.\n- backend/api/src/mcp.ts: add a new module that sets up the MCP server, declares the tools, validates inputs, delegates to existing handlers, reports errors via MCP error codes, and increments the new metric.\n- backend/api/package.json: add @modelcontextprotocol/sdk as a runtime dependency.\n\n2) Extend search-markets to include lite answers\n- Add a new optional boolean parameter includeLiteAnswers to the search-markets request schema.\n- When includeLiteAnswers is true and the returned contract is a multiple choice CPMM market, include a minimal answers array in the lite market response (id, text, probability computed from the contract state).\n- Ensure search-markets uses this parameter to produce the lite markets with or without answers accordingly.\n- Keep existing search functionality and filters intact; adjust final sorting so the returned array is de-duplicated and then ordered using the configured sort option.\n\nFiles to edit:\n- common/src/api/market-search-types.ts: add an optional includeLiteAnswers parameter to the search props schema; update default values for sort to score and token to MANA.\n- common/src/api/market-types.ts: update toLiteMarket to accept an optional includeLiteAnswers flag and include answers only when requested and applicable.\n- backend/api/src/search-contracts.ts: read includeLiteAnswers from props and pass it through to toLiteMarket; ensure final ordering uses the existing sortFields sort callback after de-duplication and limit.\n\n3) Register a metric for MCP requests\n- Add a new cumulative metric key for MCP request counts so the server can increment it per tool invocation.\n\nFiles to edit:\n- backend/shared/src/monitoring/metrics.ts: register 'mcp/request_count' as a CUMULATIVE int64 metric.\n\n4) Defaults and limits\n- Ensure the search-markets schema has the updated defaults (sort: score; token: MANA) and supports includeLiteAnswers.\n- For the MCP get-bets tool, ensure the input schema enforces the existing maximum limit constant used for non-points bets.\n\n5) Route and handlers usage\n- The MCP tools must reuse existing backend handlers (no duplication):\n - search-markets -> backend/api/src/search-contracts.ts (lite search handler)\n - get-market -> backend/api/src/get-market.ts\n - get-user -> backend/api/src/get-user.ts\n - get-bets -> backend/api/src/get-bets.ts\n- MCP requests must not require authentication for these tools.\n\n6) Error handling and transport cleanup\n- Convert zod validation failures to appropriate MCP InvalidParams errors and internal failures to MCP InternalError.\n- Ensure the HTTP transport and server close cleanly on response close to avoid leaks.\n\nAcceptance criteria:\n- POST /v0/mcp responds to ListTools with the four tools and their JSON schemas.\n- Calling any tool returns the corresponding data using existing handlers and validates parameters against existing schemas.\n- search-markets returns lite markets; when includeLiteAnswers=true, multiple choice CPMM markets include an answers array with id, text, probability.\n- The MCP server increments the 'mcp/request_count' metric with the tool name on each request.\n- Existing search responses are still correctly sorted and filtered with updated default sort and token values.",
324
- "prompt": "Add a new HTTP-based MCP endpoint to our backend API that exposes tools for searching markets and fetching markets, users, and bets. The MCP endpoint should validate inputs against our existing API schemas, reuse our existing handlers, and return JSON payloads in MCP responses. It should not require authentication. Also extend our market search so it can optionally include a minimal answers array for multiple-choice CPMM markets when requested. Finally, register and increment a metric that tracks MCP tool invocations.\n\nSpecifically:\n- Expose a POST /v0/mcp that handles MCP requests over HTTP and lists four tools: search-markets, get-market, get-user, get-bets.\n- For search-markets, support an optional includeLiteAnswers flag that, when true, adds answer id/text/probability to lite markets for multiple-choice CPMM markets.\n- Update the search defaults to sort by score and use MANA as the default token.\n- Register a cumulative metric for MCP requests and increment it per tool call.\n\nReconstruct any necessary validation and error handling from our API schemas and existing handlers. Ensure the MCP server cleans up its transport on request completion.",
325
- "supplementalFiles": [
326
- "backend/api/src/routes.ts",
327
- "backend/api/src/helpers/endpoint.ts",
328
- "backend/api/src/get-bets.ts",
329
- "backend/api/src/get-market.ts",
330
- "backend/api/src/get-user.ts",
331
- "backend/shared/src/supabase/search-contracts.ts",
332
- "common/src/api/schema.ts",
333
- "common/src/api/utils.ts",
334
- "common/src/contract.ts",
335
- "shared/utils.ts"
336
- ],
337
- "fileDiffs": [
338
- {
339
- "path": "backend/api/package.json",
340
- "status": "modified",
341
- "diff": "Index: backend/api/package.json\n===================================================================\n--- backend/api/package.json\t75c29e8 (parent)\n+++ backend/api/package.json\t3eac52d (commit)\n@@ -30,8 +30,9 @@\n \"@google-cloud/monitoring\": \"4.0.0\",\n \"@google-cloud/secret-manager\": \"4.2.1\",\n \"@google/generative-ai\": \"0.22.0\",\n \"@mendable/firecrawl-js\": \"1.8.5\",\n+ \"@modelcontextprotocol/sdk\": \"1.17.1\",\n \"@supabase/supabase-js\": \"2.38.5\",\n \"@tiptap/core\": \"2.0.0-beta.204\",\n \"@tiptap/extension-image\": \"2.0.0-beta.204\",\n \"@tiptap/extension-link\": \"2.0.0-beta.204\",\n"
342
- },
343
- {
344
- "path": "backend/api/src/app.ts",
345
- "status": "modified",
346
- "diff": "Index: backend/api/src/app.ts\n===================================================================\n--- backend/api/src/app.ts\t75c29e8 (parent)\n+++ backend/api/src/app.ts\t3eac52d (commit)\n@@ -1,18 +1,19 @@\n-import { hrtime } from 'node:process'\n+import { API, type APIPath } from 'common/api/schema'\n+import { APIError, pathWithPrefix } from 'common/api/utils'\n+import { randomString } from 'common/util/random'\n+import { assertUnreachable } from 'common/util/types'\n+import * as compression from 'compression'\n import * as cors from 'cors'\n import * as express from 'express'\n import { ErrorRequestHandler, RequestHandler } from 'express'\n-import { log, metrics } from 'shared/utils'\n-import * as compression from 'compression'\n+import { hrtime } from 'node:process'\n import { withMonitoringContext } from 'shared/monitoring/context'\n-import { APIError, pathWithPrefix } from 'common/api/utils'\n-import { API, type APIPath } from 'common/api/schema'\n-import { assertUnreachable } from 'common/util/types'\n+import { log, metrics } from 'shared/utils'\n import { typedEndpoint } from './helpers/endpoint'\n-import { randomString } from 'common/util/random'\n-import { handlers } from './routes'\n+import { handleMcpRequest } from './mcp'\n import { addOldRoutes } from './old-routes'\n+import { handlers } from './routes'\n \n export const allowCorsUnrestricted: RequestHandler = cors({\n origin: '*',\n maxAge: 86400, // 24 hours\n@@ -127,5 +128,9 @@\n } else {\n assertUnreachable(api, 'Unsupported API method')\n }\n })\n+\n+// Add MCP POST endpoint\n+app.post('/v0/mcp', express.json(), allowCorsUnrestricted, handleMcpRequest)\n+\n addOldRoutes(app)\n"
347
- },
348
- {
349
- "path": "backend/api/src/mcp.ts",
350
- "status": "added",
351
- "diff": "Index: backend/api/src/mcp.ts\n===================================================================\n--- backend/api/src/mcp.ts\t75c29e8 (parent)\n+++ backend/api/src/mcp.ts\t3eac52d (commit)\n@@ -1,1 +1,345 @@\n-[NEW FILE]\n\\n+#!/usr/bin/env node\n+import { Server } from '@modelcontextprotocol/sdk/server/index.js'\n+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHTTP.js'\n+import {\n+ CallToolRequestSchema,\n+ ErrorCode,\n+ ListToolsRequestSchema,\n+ McpError,\n+} from '@modelcontextprotocol/sdk/types.js'\n+import { API } from 'common/api/schema'\n+import { NON_POINTS_BETS_LIMIT } from 'common/supabase/bets'\n+import { Request, Response } from 'express'\n+import { log, metrics } from 'shared/utils'\n+import { z } from 'zod'\n+import { getBets } from './get-bets'\n+import { getMarket } from './get-market'\n+import { getUser } from './get-user'\n+import { searchMarketsLite } from './search-contracts'\n+\n+function getServer(): Server {\n+ const server = new Server(\n+ {\n+ name: 'manifold-markets',\n+ version: '0.2.0',\n+ },\n+ {\n+ capabilities: {\n+ tools: {},\n+ },\n+ }\n+ )\n+\n+ // List available tools\n+ server.setRequestHandler(ListToolsRequestSchema, async () => ({\n+ tools: [\n+ {\n+ name: 'search-markets',\n+ description: 'Search for prediction markets with optional filters',\n+ inputSchema: {\n+ type: 'object',\n+ properties: {\n+ term: { type: 'string', description: 'Search query' },\n+ contractType: {\n+ type: 'string',\n+ enum: [\n+ 'ALL',\n+ 'BINARY',\n+ 'MULTIPLE_CHOICE',\n+ 'POLL',\n+ 'MULTI_NUMERIC',\n+ 'DATE',\n+ ],\n+ description: 'Question type (default: ALL)',\n+ },\n+ filter: {\n+ type: 'string',\n+ enum: ['open', 'resolved', 'all'],\n+ description:\n+ 'Filter by question state. Resolved means the event has happened. (default: all)',\n+ },\n+ limit: {\n+ type: 'number',\n+ minimum: 1,\n+ maximum: 1000,\n+ description: 'Max number of results (default: 100)',\n+ },\n+ offset: {\n+ type: 'number',\n+ description: 'Offset for pagination (default: 0)',\n+ },\n+ creatorId: {\n+ type: 'string',\n+ description: 'Optional. Creator (user) ID to filter by',\n+ },\n+ sort: {\n+ type: 'string',\n+ enum: ['newest', 'score', 'liquidity'],\n+ description: 'Sort order (default: score)',\n+ },\n+ },\n+ required: ['term'],\n+ },\n+ },\n+ {\n+ name: 'get-market',\n+ description: 'Get detailed information about a specific market',\n+ inputSchema: {\n+ type: 'object',\n+ properties: {\n+ id: { type: 'string', description: 'Market ID' },\n+ },\n+ required: ['id'],\n+ },\n+ },\n+ {\n+ name: 'get-user',\n+ description: 'Get user information by username',\n+ inputSchema: {\n+ type: 'object',\n+ properties: {\n+ username: { type: 'string', description: 'Username' },\n+ },\n+ required: ['username'],\n+ },\n+ },\n+ {\n+ name: 'get-bets',\n+ description:\n+ 'Get bets from markets or for users with various filtering options',\n+ inputSchema: {\n+ type: 'object',\n+ properties: {\n+ id: {\n+ type: 'string',\n+ description: 'Optional. Bet ID to filter by',\n+ },\n+ userId: {\n+ type: 'string',\n+ description: 'Optional. User ID to filter by',\n+ },\n+ username: {\n+ type: 'string',\n+ description: 'Optional. Username to filter by',\n+ },\n+ contractId: {\n+ oneOf: [\n+ { type: 'string' },\n+ { type: 'array', items: { type: 'string' } },\n+ ],\n+ description: 'Optional. Contract ID(s) to filter by',\n+ },\n+ contractSlug: {\n+ type: 'string',\n+ description: 'Optional. Contract slug to filter by',\n+ },\n+ answerId: {\n+ type: 'string',\n+ description: 'Optional. Answer ID to filter by',\n+ },\n+ limit: {\n+ type: 'number',\n+ minimum: 0,\n+ maximum: NON_POINTS_BETS_LIMIT,\n+ description: 'Optional. Number of bets to return (default: 1000)',\n+ },\n+ before: {\n+ type: 'string',\n+ description: 'Optional. Get bets before this bet ID',\n+ },\n+ after: {\n+ type: 'string',\n+ description: 'Optional. Get bets after this bet ID',\n+ },\n+ beforeTime: {\n+ type: 'number',\n+ description: 'Optional. Get bets before this timestamp',\n+ },\n+ afterTime: {\n+ type: 'number',\n+ description: 'Optional. Get bets after this timestamp',\n+ },\n+ order: {\n+ type: 'string',\n+ enum: ['asc', 'desc'],\n+ description: 'Optional. Sort order by creation time',\n+ },\n+ kinds: {\n+ type: 'string',\n+ enum: ['open-limit'],\n+ description: 'Optional. Filter by bet kind',\n+ },\n+ minAmount: {\n+ type: 'number',\n+ minimum: 0,\n+ description: 'Optional. Minimum bet amount',\n+ },\n+ filterRedemptions: {\n+ type: 'boolean',\n+ description: 'Optional. Filter redemptions',\n+ },\n+ },\n+ required: [],\n+ },\n+ },\n+ ],\n+ }))\n+\n+ // Handle tool execution\n+ server.setRequestHandler(CallToolRequestSchema, async (request) => {\n+ const { name, arguments: args } = request.params\n+ metrics.inc('mcp/request_count', { name })\n+ try {\n+ switch (name) {\n+ case 'search-markets': {\n+ const params = API['search-markets'].props.parse(args)\n+\n+ // Map the params to match the search-markets API schema\n+ const searchParams = {\n+ term: params.term,\n+ limit: params.limit,\n+ filter: params.filter,\n+ sort: params.sort,\n+ contractType: params.contractType,\n+ offset: params.offset,\n+ token: 'MANA' as const,\n+ forYou: '0' as const,\n+ isPrizeMarket: '0' as const,\n+ includeLiteAnswers: true,\n+ }\n+\n+ try {\n+ const markets = await searchMarketsLite(\n+ searchParams,\n+ undefined, // auth not required for this endpoint\n+ {} as Request // minimal request object since it's not used\n+ )\n+ return {\n+ content: [\n+ {\n+ type: 'text',\n+ text: JSON.stringify(markets, null, 2),\n+ },\n+ ],\n+ }\n+ } catch (error: any) {\n+ throw new McpError(\n+ ErrorCode.InternalError,\n+ `Search markets error: ${error.message}`\n+ )\n+ }\n+ }\n+\n+ case 'get-market': {\n+ const { id } = API['market/:id'].props.parse(args)\n+\n+ try {\n+ const market = await getMarket({ id })\n+ return {\n+ content: [\n+ {\n+ type: 'text',\n+ text: JSON.stringify(market, null, 2),\n+ },\n+ ],\n+ }\n+ } catch (error: any) {\n+ throw new McpError(\n+ ErrorCode.InternalError,\n+ `Get market error: ${error.message}`\n+ )\n+ }\n+ }\n+\n+ case 'get-user': {\n+ const { username } = API['user/:username'].props.parse(args)\n+\n+ try {\n+ const user = await getUser({ username })\n+ return {\n+ content: [\n+ {\n+ type: 'text',\n+ text: JSON.stringify(user, null, 2),\n+ },\n+ ],\n+ }\n+ } catch (error: any) {\n+ throw new McpError(\n+ ErrorCode.InternalError,\n+ `Get user error: ${error.message}`\n+ )\n+ }\n+ }\n+\n+ case 'get-bets': {\n+ const params = API.bets.props.parse(args)\n+\n+ try {\n+ const bets = await getBets(\n+ params,\n+ undefined, // auth not required for this endpoint\n+ {} as Request // minimal request object since it's not used\n+ )\n+ return {\n+ content: [\n+ {\n+ type: 'text',\n+ text: JSON.stringify(bets, null, 2),\n+ },\n+ ],\n+ }\n+ } catch (error: any) {\n+ throw new McpError(\n+ ErrorCode.InternalError,\n+ `Get bets error: ${error.message}`\n+ )\n+ }\n+ }\n+\n+ default:\n+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`)\n+ }\n+ } catch (error) {\n+ if (error instanceof z.ZodError) {\n+ throw new McpError(\n+ ErrorCode.InvalidParams,\n+ `Invalid parameters: ${error.errors\n+ .map((e) => `${e.path.join('.')}: ${e.message}`)\n+ .join(', ')}`\n+ )\n+ }\n+ throw error\n+ }\n+ })\n+\n+ return server\n+}\n+\n+export const handleMcpRequest = async (req: Request, res: Response) => {\n+ try {\n+ const server = getServer()\n+ const transport: StreamableHTTPServerTransport =\n+ new StreamableHTTPServerTransport({\n+ sessionIdGenerator: undefined,\n+ })\n+ res.on('close', () => {\n+ transport.close()\n+ server.close()\n+ })\n+ await server.connect(transport)\n+ await transport.handleRequest(req, res, req.body)\n+ } catch (error) {\n+ log.error('Error handling MCP request:', { error })\n+ if (!res.headersSent) {\n+ res.status(500).json({\n+ jsonrpc: '2.0',\n+ error: {\n+ code: -32603,\n+ message: 'Internal server error',\n+ },\n+ id: null,\n+ })\n+ }\n+ }\n+}\n"
352
- },
353
- {
354
- "path": "backend/api/src/search-contracts.ts",
355
- "status": "modified",
356
- "diff": "Index: backend/api/src/search-contracts.ts\n===================================================================\n--- backend/api/src/search-contracts.ts\t75c29e8 (parent)\n+++ backend/api/src/search-contracts.ts\t3eac52d (commit)\n@@ -1,30 +1,32 @@\n-import { z } from 'zod'\n+import { searchProps } from 'common/api/market-search-types'\n+import { toLiteMarket } from 'common/api/market-types'\n+import { Contract } from 'common/contract'\n+import { convertContract } from 'common/supabase/contracts'\n+import { orderBy, uniqBy } from 'lodash'\n+import { getGroupIdFromSlug } from 'shared/supabase/groups'\n import {\n createSupabaseDirectClient,\n SupabaseDirectClient,\n } from 'shared/supabase/init'\n-import { type APIHandler } from './helpers/endpoint'\n import {\n- getSearchContractSQL,\n- getForYouSQL,\n- sortFields,\n basicSearchSQL,\n+ getForYouSQL,\n+ getSearchContractSQL,\n SearchTypes,\n+ sortFields,\n } from 'shared/supabase/search-contracts'\n-import { getGroupIdFromSlug } from 'shared/supabase/groups'\n-import { orderBy, uniqBy } from 'lodash'\n-import { convertContract } from 'common/supabase/contracts'\n import { log } from 'shared/utils'\n-import { toLiteMarket } from 'common/api/market-types'\n-import { searchProps } from 'common/api/market-search-types'\n+import { z } from 'zod'\n+import { type APIHandler } from './helpers/endpoint'\n \n export const searchMarketsLite: APIHandler<'search-markets'> = async (\n props,\n auth\n ) => {\n+ const { includeLiteAnswers } = props\n const contracts = await search(props, auth?.uid)\n- return contracts.map(toLiteMarket)\n+ return contracts.map((c) => toLiteMarket(c, includeLiteAnswers))\n }\n \n export const searchMarketsFull: APIHandler<'search-markets-full'> = async (\n props,\n@@ -146,13 +148,14 @@\n contractsWithoutStopwords,\n contractsWithMatchingAnswers,\n contractsWithStopwords,\n contractDescriptionMatches,\n- ] = results.map((result, i) =>\n- result.map((r: any) => ({\n- data: convertContract(r),\n- searchType: searchTypes[i],\n- }))\n+ ] = results.map(\n+ (result, i) =>\n+ result.map((r: any) => ({\n+ data: convertContract(r),\n+ searchType: searchTypes[i],\n+ })) as { data: Contract; searchType: SearchTypes }[]\n )\n \n const contractsOfSimilarRelevance = orderBy(\n [\n@@ -165,16 +168,20 @@\n (c.searchType === 'answer' ? 0.5 : 1),\n sortFields[sort].order.includes('DESC') ? 'desc' : 'asc'\n )\n \n- return uniqBy(\n- [\n- ...contractsWithStopwords,\n- ...contractsOfSimilarRelevance,\n- ...contractDescriptionMatches,\n- ].map((c) => c.data),\n- 'id'\n- ).slice(0, limit)\n+ return orderBy(\n+ uniqBy(\n+ [\n+ ...contractsWithStopwords, // most obviously relevant\n+ ...contractsOfSimilarRelevance, // next most relevant\n+ ...contractDescriptionMatches, // least obviously relevant\n+ ].map((c) => c.data),\n+ 'id'\n+ ).slice(0, limit),\n+ (c) => sortFields[sort].sortCallback(c),\n+ sortFields[sort].order.includes('DESC') ? 'desc' : 'asc'\n+ )\n }\n }\n \n const getAllSubTopicsForParentTopicIds = async (\n"
357
- },
358
- {
359
- "path": "backend/shared/src/monitoring/metrics.ts",
360
- "status": "modified",
361
- "diff": "Index: backend/shared/src/monitoring/metrics.ts\n===================================================================\n--- backend/shared/src/monitoring/metrics.ts\t75c29e8 (parent)\n+++ backend/shared/src/monitoring/metrics.ts\t3eac52d (commit)\n@@ -1,5 +1,5 @@\n-import { isEqual, flatten } from 'lodash'\n+import { flatten, isEqual } from 'lodash'\n \n // see https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.metricDescriptors#MetricKind\n export type MetricKind = 'GAUGE' | 'CUMULATIVE'\n \n@@ -90,8 +90,12 @@\n 'vercel/revalidations_failed': {\n metricKind: 'CUMULATIVE',\n valueKind: 'int64Value',\n },\n+ 'mcp/request_count': {\n+ metricKind: 'CUMULATIVE',\n+ valueKind: 'int64Value',\n+ },\n } as const satisfies { [k: string]: MetricDescriptor }\n \n // the typing for all this could be way fancier, but seems overkill\n \n"
362
- },
363
- {
364
- "path": "common/src/api/market-search-types.ts",
365
- "status": "modified",
366
- "diff": "Index: common/src/api/market-search-types.ts\n===================================================================\n--- common/src/api/market-search-types.ts\t75c29e8 (parent)\n+++ common/src/api/market-search-types.ts\t3eac52d (commit)\n@@ -1,5 +1,6 @@\n import { z } from 'zod'\n+import { coerceBoolean } from './zod-types'\n \n export const FIRESTORE_DOC_REF_ID_REGEX = /^[a-zA-Z0-9_-]{1,}$/\n \n export const searchProps = z\n@@ -36,9 +37,9 @@\n z.literal('bounty-amount'),\n z.literal('prob-descending'),\n z.literal('prob-ascending'),\n ])\n- .default('most-popular'),\n+ .default('score'),\n contractType: z\n .union([\n z.literal('ALL'),\n z.literal('BINARY'),\n@@ -77,10 +78,11 @@\n z.literal('CASH'),\n z.literal('ALL'),\n z.literal('CASH_AND_MANA'),\n ])\n- .default('ALL'),\n+ .default('MANA'),\n gids: z.string().optional(),\n liquidity: z.coerce.number().optional(),\n hasBets: z.union([z.literal('1'), z.literal('0')]).optional(),\n+ includeLiteAnswers: coerceBoolean.optional(),\n })\n .strict()\n"
367
- },
368
- {
369
- "path": "common/src/api/market-types.ts",
370
- "status": "modified",
371
- "diff": "Index: common/src/api/market-types.ts\n===================================================================\n--- common/src/api/market-types.ts\t75c29e8 (parent)\n+++ common/src/api/market-types.ts\t3eac52d (commit)\n@@ -10,16 +10,16 @@\n } from 'common/contract'\n import { MINIMUM_BOUNTY } from 'common/economy'\n import { DOMAIN } from 'common/envs/constants'\n import { MAX_ID_LENGTH } from 'common/group'\n+import { MAX_MULTI_NUMERIC_ANSWERS } from 'common/multi-numeric'\n import { getMappedValue } from 'common/pseudo-numeric'\n+import { liquidityTiers } from 'common/tier'\n import { removeUndefinedProps } from 'common/util/object'\n import { richTextToString } from 'common/util/parse'\n+import { randomStringRegex } from 'common/util/random'\n import { z } from 'zod'\n import { coerceBoolean, contentSchema } from './zod-types'\n-import { randomStringRegex } from 'common/util/random'\n-import { MAX_MULTI_NUMERIC_ANSWERS } from 'common/multi-numeric'\n-import { liquidityTiers } from 'common/tier'\n \n export type LiteMarket = {\n // Unique identifier for this market\n id: string\n@@ -91,9 +91,12 @@\n coverImageUrl?: string\n groupSlugs?: string[]\n }\n \n-export function toLiteMarket(contract: Contract): LiteMarket {\n+export function toLiteMarket(\n+ contract: Contract,\n+ includeLiteAnswers?: boolean\n+): LiteMarket {\n const {\n id,\n creatorId,\n creatorUsername,\n@@ -136,8 +139,16 @@\n const value = getMappedValue(contract, contract.prob)\n const { min, max, isLogScale } = contract\n numericValues = { value, min, max, isLogScale }\n }\n+ const answers =\n+ includeLiteAnswers && contract.mechanism === 'cpmm-multi-1'\n+ ? contract.answers?.map((answer) => ({\n+ id: answer.id,\n+ text: answer.text,\n+ probability: getAnswerProbability(contract, answer.id),\n+ }))\n+ : undefined\n \n return removeUndefinedProps({\n id,\n creatorId,\n@@ -171,8 +182,9 @@\n lastCommentTime,\n ...numericValues,\n token,\n siblingContractId,\n+ answers,\n \n // Manifold love props.\n loverUserId1,\n loverUserId2,\n"
372
- },
373
- {
374
- "path": "yarn.lock",
375
- "status": "modified",
376
- "diff": "Index: yarn.lock\n===================================================================\n--- yarn.lock\t75c29e8 (parent)\n+++ yarn.lock\t3eac52d (commit)\n@@ -2777,8 +2777,26 @@\n typescript-event-target \"^1.1.1\"\n zod \"^3.23.8\"\n zod-to-json-schema \"^3.23.0\"\n \n+\"@modelcontextprotocol/sdk@1.17.1\":\n+ version \"1.17.1\"\n+ resolved \"https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.17.1.tgz#a3628ae2ca0b4a2e6088202b5ee417d884a88537\"\n+ integrity sha512-CPle1OQehbWqd25La9Ack5B07StKIxh4+Bf19qnpZKJC1oI22Y0czZHbifjw1UoczIfKBwBDAp/dFxvHG13B5A==\n+ dependencies:\n+ ajv \"^6.12.6\"\n+ content-type \"^1.0.5\"\n+ cors \"^2.8.5\"\n+ cross-spawn \"^7.0.5\"\n+ eventsource \"^3.0.2\"\n+ eventsource-parser \"^3.0.0\"\n+ express \"^5.0.1\"\n+ express-rate-limit \"^7.5.0\"\n+ pkce-challenge \"^5.0.0\"\n+ raw-body \"^3.0.0\"\n+ zod \"^3.23.8\"\n+ zod-to-json-schema \"^3.24.1\"\n+\n \"@next/env@15.0.4\":\n version \"15.0.4\"\n resolved \"https://registry.yarnpkg.com/@next/env/-/env-15.0.4.tgz#97da0fe3bae2f2b2968c4c925d7936660f5b3836\"\n integrity sha512-WNRvtgnRVDD4oM8gbUcRc27IAhaL4eXQ/2ovGbgLnPGUvdyDr8UdXP4Q/IBDdAdojnD2eScryIDirv0YUCjUVw==\n@@ -6002,8 +6020,16 @@\n integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==\n dependencies:\n event-target-shim \"^5.0.0\"\n \n+accepts@^2.0.0:\n+ version \"2.0.0\"\n+ resolved \"https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895\"\n+ integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==\n+ dependencies:\n+ mime-types \"^3.0.0\"\n+ negotiator \"^1.0.0\"\n+\n accepts@~1.3.5, accepts@~1.3.8:\n version \"1.3.8\"\n resolved \"https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e\"\n integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==\n@@ -6060,9 +6086,9 @@\n integrity sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==\n dependencies:\n humanize-ms \"^1.2.1\"\n \n-ajv@^6.12.3, ajv@^6.12.4:\n+ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.6:\n version \"6.12.6\"\n resolved \"https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4\"\n integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==\n dependencies:\n@@ -6606,8 +6632,23 @@\n raw-body \"2.5.1\"\n type-is \"~1.6.18\"\n unpipe \"1.0.0\"\n \n+body-parser@^2.2.0:\n+ version \"2.2.0\"\n+ resolved \"https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.0.tgz#f7a9656de305249a715b549b7b8fd1ab9dfddcfa\"\n+ integrity sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==\n+ dependencies:\n+ bytes \"^3.1.2\"\n+ content-type \"^1.0.5\"\n+ debug \"^4.4.0\"\n+ http-errors \"^2.0.0\"\n+ iconv-lite \"^0.6.3\"\n+ on-finished \"^2.4.1\"\n+ qs \"^6.14.0\"\n+ raw-body \"^3.0.0\"\n+ type-is \"^2.0.0\"\n+\n boolbase@^1.0.0:\n version \"1.0.0\"\n resolved \"https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e\"\n integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==\n@@ -6692,13 +6733,21 @@\n version \"3.0.0\"\n resolved \"https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048\"\n integrity sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==\n \n-bytes@3.1.2:\n+bytes@3.1.2, bytes@^3.1.2:\n version \"3.1.2\"\n resolved \"https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5\"\n integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==\n \n+call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:\n+ version \"1.0.2\"\n+ resolved \"https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6\"\n+ integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==\n+ dependencies:\n+ es-errors \"^1.3.0\"\n+ function-bind \"^1.1.2\"\n+\n call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.4, call-bind@^1.0.5:\n version \"1.0.5\"\n resolved \"https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.5.tgz#6fa2b7845ce0ea49bf4d8b9ef64727a2c2e2e513\"\n integrity sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==\n@@ -6717,8 +6766,16 @@\n function-bind \"^1.1.2\"\n get-intrinsic \"^1.2.4\"\n set-function-length \"^1.2.1\"\n \n+call-bound@^1.0.2:\n+ version \"1.0.4\"\n+ resolved \"https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a\"\n+ integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==\n+ dependencies:\n+ call-bind-apply-helpers \"^1.0.2\"\n+ get-intrinsic \"^1.3.0\"\n+\n callsites@^3.0.0:\n version \"3.1.0\"\n resolved \"https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73\"\n integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==\n@@ -7023,8 +7080,20 @@\n integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==\n dependencies:\n safe-buffer \"5.2.1\"\n \n+content-disposition@^1.0.0:\n+ version \"1.0.0\"\n+ resolved \"https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.0.tgz#844426cb398f934caefcbb172200126bc7ceace2\"\n+ integrity sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==\n+ dependencies:\n+ safe-buffer \"5.2.1\"\n+\n+content-type@^1.0.5:\n+ version \"1.0.5\"\n+ resolved \"https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918\"\n+ integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==\n+\n content-type@~1.0.4:\n version \"1.0.4\"\n resolved \"https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b\"\n integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==\n@@ -7043,13 +7112,23 @@\n version \"1.0.6\"\n resolved \"https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c\"\n integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=\n \n+cookie-signature@^1.2.1:\n+ version \"1.2.2\"\n+ resolved \"https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793\"\n+ integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==\n+\n cookie@0.5.0:\n version \"0.5.0\"\n resolved \"https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b\"\n integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==\n \n+cookie@^0.7.1:\n+ version \"0.7.2\"\n+ resolved \"https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7\"\n+ integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==\n+\n core-decorators@^0.17.0:\n version \"0.17.0\"\n resolved \"https://registry.yarnpkg.com/core-decorators/-/core-decorators-0.17.0.tgz#3f43180a86d2ab0cc51069f46a1ec3e49e7cebd6\"\n integrity sha512-dBTL931yH4iZRlknHHkqtvPuGiDAEyTcudUnji3W0+mcNIHTrCmXvlqSyE743tzYtIeujLB00H9G/NdAmE3rPg==\n@@ -7075,9 +7154,9 @@\n version \"1.0.3\"\n resolved \"https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85\"\n integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==\n \n-cors@2.8.5:\n+cors@2.8.5, cors@^2.8.5:\n version \"2.8.5\"\n resolved \"https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29\"\n integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==\n dependencies:\n@@ -7131,8 +7210,17 @@\n path-key \"^3.1.0\"\n shebang-command \"^2.0.0\"\n which \"^2.0.1\"\n \n+cross-spawn@^7.0.5:\n+ version \"7.0.6\"\n+ resolved \"https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f\"\n+ integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==\n+ dependencies:\n+ path-key \"^3.1.0\"\n+ shebang-command \"^2.0.0\"\n+ which \"^2.0.1\"\n+\n crypt@0.0.2:\n version \"0.0.2\"\n resolved \"https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b\"\n integrity sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==\n@@ -7426,8 +7514,15 @@\n integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==\n dependencies:\n ms \"^2.1.1\"\n \n+debug@^4.3.5, debug@^4.4.0:\n+ version \"4.4.1\"\n+ resolved \"https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b\"\n+ integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==\n+ dependencies:\n+ ms \"^2.1.3\"\n+\n decompress-response@^6.0.0:\n version \"6.0.0\"\n resolved \"https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc\"\n integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==\n@@ -7494,9 +7589,9 @@\n version \"1.0.0\"\n resolved \"https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619\"\n integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=\n \n-depd@2.0.0:\n+depd@2.0.0, depd@^2.0.0:\n version \"2.0.0\"\n resolved \"https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df\"\n integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==\n \n@@ -7641,8 +7736,17 @@\n dependencies:\n no-case \"^3.0.4\"\n tslib \"^2.0.3\"\n \n+dunder-proto@^1.0.1:\n+ version \"1.0.1\"\n+ resolved \"https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a\"\n+ integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==\n+ dependencies:\n+ call-bind-apply-helpers \"^1.0.1\"\n+ es-errors \"^1.3.0\"\n+ gopd \"^1.2.0\"\n+\n duplexify@^4.0.0:\n version \"4.1.2\"\n resolved \"https://registry.yarnpkg.com/duplexify/-/duplexify-4.1.2.tgz#18b4f8d28289132fa0b9573c898d9f903f81c7b0\"\n integrity sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==\n@@ -7701,8 +7805,13 @@\n version \"9.2.2\"\n resolved \"https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72\"\n integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==\n \n+encodeurl@^2.0.0:\n+ version \"2.0.0\"\n+ resolved \"https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58\"\n+ integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==\n+\n encodeurl@~1.0.2:\n version \"1.0.2\"\n resolved \"https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59\"\n integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=\n@@ -7847,8 +7956,13 @@\n integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==\n dependencies:\n get-intrinsic \"^1.2.4\"\n \n+es-define-property@^1.0.1:\n+ version \"1.0.1\"\n+ resolved \"https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa\"\n+ integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==\n+\n es-errors@^1.2.1, es-errors@^1.3.0:\n version \"1.3.0\"\n resolved \"https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f\"\n integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==\n@@ -7880,8 +7994,15 @@\n integrity sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==\n dependencies:\n es-errors \"^1.3.0\"\n \n+es-object-atoms@^1.1.1:\n+ version \"1.1.1\"\n+ resolved \"https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1\"\n+ integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==\n+ dependencies:\n+ es-errors \"^1.3.0\"\n+\n es-set-tostringtag@^2.0.1:\n version \"2.0.1\"\n resolved \"https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz#338d502f6f674301d710b80c8592de8a15f09cd8\"\n integrity sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==\n@@ -8247,9 +8368,9 @@\n version \"2.0.3\"\n resolved \"https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64\"\n integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==\n \n-etag@~1.8.1:\n+etag@^1.8.1, etag@~1.8.1:\n version \"1.8.1\"\n resolved \"https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887\"\n integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=\n \n@@ -8257,8 +8378,20 @@\n version \"5.0.1\"\n resolved \"https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789\"\n integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==\n \n+eventsource-parser@^3.0.0, eventsource-parser@^3.0.1:\n+ version \"3.0.3\"\n+ resolved \"https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.3.tgz#e9af1d40b77e6268cdcbc767321e8b9f066adea8\"\n+ integrity sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==\n+\n+eventsource@^3.0.2:\n+ version \"3.0.7\"\n+ resolved \"https://registry.yarnpkg.com/eventsource/-/eventsource-3.0.7.tgz#1157622e2f5377bb6aef2114372728ba0c156989\"\n+ integrity sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==\n+ dependencies:\n+ eventsource-parser \"^3.0.1\"\n+\n execa@^5.0.0:\n version \"5.1.1\"\n resolved \"https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd\"\n integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==\n@@ -8317,8 +8450,13 @@\n integrity sha512-L6YQ1wQ/mNjVLAmK3AG1RK6VkokA1BIY6wmiH304Xtt/cLTps40EusZsU1Uop+v9lTDPxdtzbFmdXfFO3KEnwA==\n dependencies:\n basic-auth \"^2.0.1\"\n \n+express-rate-limit@^7.5.0:\n+ version \"7.5.1\"\n+ resolved \"https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-7.5.1.tgz#8c3a42f69209a3a1c969890070ece9e20a879dec\"\n+ integrity sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==\n+\n express@4.18.1:\n version \"4.18.1\"\n resolved \"https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf\"\n integrity sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==\n@@ -8354,8 +8492,41 @@\n type-is \"~1.6.18\"\n utils-merge \"1.0.1\"\n vary \"~1.1.2\"\n \n+express@^5.0.1:\n+ version \"5.1.0\"\n+ resolved \"https://registry.yarnpkg.com/express/-/express-5.1.0.tgz#d31beaf715a0016f0d53f47d3b4d7acf28c75cc9\"\n+ integrity sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==\n+ dependencies:\n+ accepts \"^2.0.0\"\n+ body-parser \"^2.2.0\"\n+ content-disposition \"^1.0.0\"\n+ content-type \"^1.0.5\"\n+ cookie \"^0.7.1\"\n+ cookie-signature \"^1.2.1\"\n+ debug \"^4.4.0\"\n+ encodeurl \"^2.0.0\"\n+ escape-html \"^1.0.3\"\n+ etag \"^1.8.1\"\n+ finalhandler \"^2.1.0\"\n+ fresh \"^2.0.0\"\n+ http-errors \"^2.0.0\"\n+ merge-descriptors \"^2.0.0\"\n+ mime-types \"^3.0.0\"\n+ on-finished \"^2.4.1\"\n+ once \"^1.4.0\"\n+ parseurl \"^1.3.3\"\n+ proxy-addr \"^2.0.7\"\n+ qs \"^6.14.0\"\n+ range-parser \"^1.2.1\"\n+ router \"^2.2.0\"\n+ send \"^1.1.0\"\n+ serve-static \"^2.2.0\"\n+ statuses \"^2.0.1\"\n+ type-is \"^2.0.1\"\n+ vary \"^1.1.2\"\n+\n extend@^3.0.2, extend@~3.0.2:\n version \"3.0.2\"\n resolved \"https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa\"\n integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==\n@@ -8514,8 +8685,20 @@\n parseurl \"~1.3.3\"\n statuses \"2.0.1\"\n unpipe \"~1.0.0\"\n \n+finalhandler@^2.1.0:\n+ version \"2.1.0\"\n+ resolved \"https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.1.0.tgz#72306373aa89d05a8242ed569ed86a1bff7c561f\"\n+ integrity sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==\n+ dependencies:\n+ debug \"^4.4.0\"\n+ encodeurl \"^2.0.0\"\n+ escape-html \"^1.0.3\"\n+ on-finished \"^2.4.1\"\n+ parseurl \"^1.3.3\"\n+ statuses \"^2.0.1\"\n+\n find-up@^4.0.0, find-up@^4.1.0:\n version \"4.1.0\"\n resolved \"https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19\"\n integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==\n@@ -8715,8 +8898,13 @@\n version \"0.5.2\"\n resolved \"https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7\"\n integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=\n \n+fresh@^2.0.0:\n+ version \"2.0.0\"\n+ resolved \"https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4\"\n+ integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==\n+\n fs-constants@^1.0.0:\n version \"1.0.0\"\n resolved \"https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad\"\n integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==\n@@ -8831,13 +9019,37 @@\n has-proto \"^1.0.1\"\n has-symbols \"^1.0.3\"\n hasown \"^2.0.0\"\n \n+get-intrinsic@^1.2.5, get-intrinsic@^1.3.0:\n+ version \"1.3.0\"\n+ resolved \"https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01\"\n+ integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==\n+ dependencies:\n+ call-bind-apply-helpers \"^1.0.2\"\n+ es-define-property \"^1.0.1\"\n+ es-errors \"^1.3.0\"\n+ es-object-atoms \"^1.1.1\"\n+ function-bind \"^1.1.2\"\n+ get-proto \"^1.0.1\"\n+ gopd \"^1.2.0\"\n+ has-symbols \"^1.1.0\"\n+ hasown \"^2.0.2\"\n+ math-intrinsics \"^1.1.0\"\n+\n get-package-type@^0.1.0:\n version \"0.1.0\"\n resolved \"https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a\"\n integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==\n \n+get-proto@^1.0.1:\n+ version \"1.0.1\"\n+ resolved \"https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1\"\n+ integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==\n+ dependencies:\n+ dunder-proto \"^1.0.1\"\n+ es-object-atoms \"^1.0.0\"\n+\n get-stream@^6.0.0:\n version \"6.0.1\"\n resolved \"https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7\"\n integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==\n@@ -9074,8 +9286,13 @@\n integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==\n dependencies:\n get-intrinsic \"^1.1.3\"\n \n+gopd@^1.2.0:\n+ version \"1.2.0\"\n+ resolved \"https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1\"\n+ integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==\n+\n graceful-fs@^4.1.9, graceful-fs@^4.2.4, graceful-fs@^4.2.9:\n version \"4.2.11\"\n resolved \"https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3\"\n integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==\n@@ -9172,8 +9389,13 @@\n version \"1.0.3\"\n resolved \"https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8\"\n integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==\n \n+has-symbols@^1.1.0:\n+ version \"1.1.0\"\n+ resolved \"https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338\"\n+ integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==\n+\n has-tostringtag@^1.0.0:\n version \"1.0.0\"\n resolved \"https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25\"\n integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==\n@@ -9239,9 +9461,9 @@\n domhandler \"^5.0.3\"\n domutils \"^3.0.1\"\n entities \"^4.4.0\"\n \n-http-errors@2.0.0:\n+http-errors@2.0.0, http-errors@^2.0.0:\n version \"2.0.0\"\n resolved \"https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3\"\n integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==\n dependencies:\n@@ -9332,8 +9554,15 @@\n integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==\n dependencies:\n safer-buffer \">= 2.1.2 < 3\"\n \n+iconv-lite@0.6.3, iconv-lite@^0.6.3:\n+ version \"0.6.3\"\n+ resolved \"https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501\"\n+ integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==\n+ dependencies:\n+ safer-buffer \">= 2.1.2 < 3.0.0\"\n+\n idb@7.1.1:\n version \"7.1.1\"\n resolved \"https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b\"\n integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==\n@@ -9623,8 +9852,13 @@\n version \"3.0.3\"\n resolved \"https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283\"\n integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==\n \n+is-promise@^4.0.0:\n+ version \"4.0.0\"\n+ resolved \"https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3\"\n+ integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==\n+\n is-regex@^1.1.4:\n version \"1.1.4\"\n resolved \"https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958\"\n integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==\n@@ -10687,8 +10921,13 @@\n version \"1.2.6\"\n resolved \"https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46\"\n integrity sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==\n \n+math-intrinsics@^1.1.0:\n+ version \"1.1.0\"\n+ resolved \"https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9\"\n+ integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==\n+\n md5@^2.3.0:\n version \"2.3.0\"\n resolved \"https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f\"\n integrity sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==\n@@ -10716,8 +10955,13 @@\n version \"0.3.0\"\n resolved \"https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748\"\n integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=\n \n+media-typer@^1.1.0:\n+ version \"1.1.0\"\n+ resolved \"https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561\"\n+ integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==\n+\n memoize-one@^6.0.0:\n version \"6.0.0\"\n resolved \"https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045\"\n integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==\n@@ -10726,8 +10970,13 @@\n version \"1.0.1\"\n resolved \"https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61\"\n integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=\n \n+merge-descriptors@^2.0.0:\n+ version \"2.0.0\"\n+ resolved \"https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808\"\n+ integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==\n+\n merge-stream@^2.0.0:\n version \"2.0.0\"\n resolved \"https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60\"\n integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==\n@@ -10754,15 +11003,27 @@\n version \"1.52.0\"\n resolved \"https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70\"\n integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==\n \n+mime-db@^1.54.0:\n+ version \"1.54.0\"\n+ resolved \"https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5\"\n+ integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==\n+\n mime-types@^2.0.8, mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24, mime-types@~2.1.34:\n version \"2.1.35\"\n resolved \"https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a\"\n integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==\n dependencies:\n mime-db \"1.52.0\"\n \n+mime-types@^3.0.0, mime-types@^3.0.1:\n+ version \"3.0.1\"\n+ resolved \"https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.1.tgz#b1d94d6997a9b32fd69ebaed0db73de8acb519ce\"\n+ integrity sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==\n+ dependencies:\n+ mime-db \"^1.54.0\"\n+\n mime@1.6.0:\n version \"1.6.0\"\n resolved \"https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1\"\n integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==\n@@ -10855,9 +11116,9 @@\n version \"2.1.2\"\n resolved \"https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009\"\n integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==\n \n-ms@2.1.3, ms@^2.0.0, ms@^2.1.1:\n+ms@2.1.3, ms@^2.0.0, ms@^2.1.1, ms@^2.1.3:\n version \"2.1.3\"\n resolved \"https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2\"\n integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==\n \n@@ -10904,8 +11165,13 @@\n version \"0.6.3\"\n resolved \"https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd\"\n integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==\n \n+negotiator@^1.0.0:\n+ version \"1.0.0\"\n+ resolved \"https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a\"\n+ integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==\n+\n neo-async@^2.6.2:\n version \"2.6.2\"\n resolved \"https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f\"\n integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==\n@@ -11187,9 +11453,9 @@\n call-bind \"^1.0.7\"\n define-properties \"^1.2.1\"\n es-object-atoms \"^1.0.0\"\n \n-on-finished@2.4.1:\n+on-finished@2.4.1, on-finished@^2.4.1:\n version \"2.4.1\"\n resolved \"https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f\"\n integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==\n dependencies:\n@@ -11383,9 +11649,9 @@\n integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==\n dependencies:\n entities \"^4.4.0\"\n \n-parseurl@~1.3.3:\n+parseurl@^1.3.3, parseurl@~1.3.3:\n version \"1.3.3\"\n resolved \"https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4\"\n integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==\n \n@@ -11428,8 +11694,13 @@\n version \"0.1.7\"\n resolved \"https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c\"\n integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=\n \n+path-to-regexp@^8.0.0:\n+ version \"8.2.0\"\n+ resolved \"https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz#73990cc29e57a3ff2a0d914095156df5db79e8b4\"\n+ integrity sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==\n+\n path-type@^4.0.0:\n version \"4.0.0\"\n resolved \"https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b\"\n integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==\n@@ -11577,8 +11848,13 @@\n version \"4.0.6\"\n resolved \"https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9\"\n integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==\n \n+pkce-challenge@^5.0.0:\n+ version \"5.0.0\"\n+ resolved \"https://registry.yarnpkg.com/pkce-challenge/-/pkce-challenge-5.0.0.tgz#c3a405cb49e272094a38e890a2b51da0228c4d97\"\n+ integrity sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==\n+\n pkg-dir@^4.2.0:\n version \"4.2.0\"\n resolved \"https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3\"\n integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==\n@@ -11921,9 +12197,9 @@\n \"@protobufjs/utf8\" \"^1.1.0\"\n \"@types/node\" \">=13.7.0\"\n long \"^5.0.0\"\n \n-proxy-addr@~2.0.7:\n+proxy-addr@^2.0.7, proxy-addr@~2.0.7:\n version \"2.0.7\"\n resolved \"https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025\"\n integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==\n dependencies:\n@@ -11993,8 +12269,15 @@\n integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==\n dependencies:\n side-channel \"^1.0.4\"\n \n+qs@^6.14.0:\n+ version \"6.14.0\"\n+ resolved \"https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930\"\n+ integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==\n+ dependencies:\n+ side-channel \"^1.1.0\"\n+\n qs@^6.6.0, qs@^6.9.4:\n version \"6.11.2\"\n resolved \"https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9\"\n integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==\n@@ -12048,9 +12331,9 @@\n dependencies:\n discontinuous-range \"1.0.0\"\n ret \"~0.1.10\"\n \n-range-parser@~1.2.1:\n+range-parser@^1.2.1, range-parser@~1.2.1:\n version \"1.2.1\"\n resolved \"https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031\"\n integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==\n \n@@ -12063,8 +12346,18 @@\n http-errors \"2.0.0\"\n iconv-lite \"0.4.24\"\n unpipe \"1.0.0\"\n \n+raw-body@^3.0.0:\n+ version \"3.0.0\"\n+ resolved \"https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.0.tgz#25b3476f07a51600619dae3fe82ddc28a36e5e0f\"\n+ integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==\n+ dependencies:\n+ bytes \"3.1.2\"\n+ http-errors \"2.0.0\"\n+ iconv-lite \"0.6.3\"\n+ unpipe \"1.0.0\"\n+\n rc@^1.2.7:\n version \"1.2.8\"\n resolved \"https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed\"\n integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==\n@@ -12492,8 +12785,19 @@\n version \"1.3.3\"\n resolved \"https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.3.tgz#3f67fc106288b84b71532b4a5fd9d4881e4457f0\"\n integrity sha512-85aZYCxweiD5J8yTEbw+E6A27zSnLPNDL0WfPdw3YYodq7WjnTKo0q4dtyQ2gz23iPT8Q9CUyJtAaUNcTxRf5Q==\n \n+router@^2.2.0:\n+ version \"2.2.0\"\n+ resolved \"https://registry.yarnpkg.com/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef\"\n+ integrity sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==\n+ dependencies:\n+ debug \"^4.4.0\"\n+ depd \"^2.0.0\"\n+ is-promise \"^4.0.0\"\n+ parseurl \"^1.3.3\"\n+ path-to-regexp \"^8.0.0\"\n+\n run-parallel@^1.1.9:\n version \"1.2.0\"\n resolved \"https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee\"\n integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==\n@@ -12554,9 +12858,9 @@\n call-bind \"^1.0.6\"\n es-errors \"^1.3.0\"\n is-regex \"^1.1.4\"\n \n-\"safer-buffer@>= 2.1.2 < 3\", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:\n+\"safer-buffer@>= 2.1.2 < 3\", \"safer-buffer@>= 2.1.2 < 3.0.0\", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:\n version \"2.1.2\"\n resolved \"https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a\"\n integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==\n \n@@ -12636,8 +12940,25 @@\n on-finished \"2.4.1\"\n range-parser \"~1.2.1\"\n statuses \"2.0.1\"\n \n+send@^1.1.0, send@^1.2.0:\n+ version \"1.2.0\"\n+ resolved \"https://registry.yarnpkg.com/send/-/send-1.2.0.tgz#32a7554fb777b831dfa828370f773a3808d37212\"\n+ integrity sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==\n+ dependencies:\n+ debug \"^4.3.5\"\n+ encodeurl \"^2.0.0\"\n+ escape-html \"^1.0.3\"\n+ etag \"^1.8.1\"\n+ fresh \"^2.0.0\"\n+ http-errors \"^2.0.0\"\n+ mime-types \"^3.0.1\"\n+ ms \"^2.1.3\"\n+ on-finished \"^2.4.1\"\n+ range-parser \"^1.2.1\"\n+ statuses \"^2.0.1\"\n+\n serve-static@1.15.0:\n version \"1.15.0\"\n resolved \"https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540\"\n integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==\n@@ -12646,8 +12967,18 @@\n escape-html \"~1.0.3\"\n parseurl \"~1.3.3\"\n send \"0.18.0\"\n \n+serve-static@^2.2.0:\n+ version \"2.2.0\"\n+ resolved \"https://registry.yarnpkg.com/serve-static/-/serve-static-2.2.0.tgz#9c02564ee259bdd2251b82d659a2e7e1938d66f9\"\n+ integrity sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==\n+ dependencies:\n+ encodeurl \"^2.0.0\"\n+ escape-html \"^1.0.3\"\n+ parseurl \"^1.3.3\"\n+ send \"^1.2.0\"\n+\n set-function-length@^1.1.1:\n version \"1.1.1\"\n resolved \"https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.1.1.tgz#4bc39fafb0307224a33e106a7d35ca1218d659ed\"\n integrity sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==\n@@ -12752,8 +13083,37 @@\n version \"1.8.1\"\n resolved \"https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680\"\n integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==\n \n+side-channel-list@^1.0.0:\n+ version \"1.0.0\"\n+ resolved \"https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad\"\n+ integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==\n+ dependencies:\n+ es-errors \"^1.3.0\"\n+ object-inspect \"^1.13.3\"\n+\n+side-channel-map@^1.0.1:\n+ version \"1.0.1\"\n+ resolved \"https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42\"\n+ integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==\n+ dependencies:\n+ call-bound \"^1.0.2\"\n+ es-errors \"^1.3.0\"\n+ get-intrinsic \"^1.2.5\"\n+ object-inspect \"^1.13.3\"\n+\n+side-channel-weakmap@^1.0.2:\n+ version \"1.0.2\"\n+ resolved \"https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea\"\n+ integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==\n+ dependencies:\n+ call-bound \"^1.0.2\"\n+ es-errors \"^1.3.0\"\n+ get-intrinsic \"^1.2.5\"\n+ object-inspect \"^1.13.3\"\n+ side-channel-map \"^1.0.1\"\n+\n side-channel@^1.0.4:\n version \"1.0.4\"\n resolved \"https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf\"\n integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==\n@@ -12771,8 +13131,19 @@\n es-errors \"^1.3.0\"\n get-intrinsic \"^1.2.4\"\n object-inspect \"^1.13.1\"\n \n+side-channel@^1.1.0:\n+ version \"1.1.0\"\n+ resolved \"https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9\"\n+ integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==\n+ dependencies:\n+ es-errors \"^1.3.0\"\n+ object-inspect \"^1.13.3\"\n+ side-channel-list \"^1.0.0\"\n+ side-channel-map \"^1.0.1\"\n+ side-channel-weakmap \"^1.0.2\"\n+\n signal-exit@^3.0.3, signal-exit@^3.0.7:\n version \"3.0.7\"\n resolved \"https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9\"\n integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==\n@@ -12926,8 +13297,13 @@\n version \"2.0.1\"\n resolved \"https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63\"\n integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==\n \n+statuses@^2.0.1:\n+ version \"2.0.2\"\n+ resolved \"https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382\"\n+ integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==\n+\n stream-events@^1.0.5:\n version \"1.0.5\"\n resolved \"https://registry.yarnpkg.com/stream-events/-/stream-events-1.0.5.tgz#bbc898ec4df33a4902d892333d47da9bf1c406d5\"\n integrity sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==\n@@ -13676,8 +14052,17 @@\n version \"0.21.3\"\n resolved \"https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37\"\n integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==\n \n+type-is@^2.0.0, type-is@^2.0.1:\n+ version \"2.0.1\"\n+ resolved \"https://registry.yarnpkg.com/type-is/-/type-is-2.0.1.tgz#64f6cf03f92fce4015c2b224793f6bdd4b068c97\"\n+ integrity sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==\n+ dependencies:\n+ content-type \"^1.0.5\"\n+ media-typer \"^1.1.0\"\n+ mime-types \"^3.0.0\"\n+\n type-is@~1.6.18:\n version \"1.6.18\"\n resolved \"https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131\"\n integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==\n@@ -13958,9 +14343,9 @@\n \"@jridgewell/trace-mapping\" \"^0.3.12\"\n \"@types/istanbul-lib-coverage\" \"^2.0.1\"\n convert-source-map \"^1.6.0\"\n \n-vary@^1, vary@~1.1.2:\n+vary@^1, vary@^1.1.2, vary@~1.1.2:\n version \"1.1.2\"\n resolved \"https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc\"\n integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=\n \n@@ -14299,8 +14684,13 @@\n version \"3.23.5\"\n resolved \"https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.23.5.tgz#ec23def47dcafe3a4d640eba6a346b34f9a693a5\"\n integrity sha512-5wlSS0bXfF/BrL4jPAbz9da5hDlDptdEppYfe+x4eIJ7jioqKG9uUxOwPzqof09u/XeVdrgFu29lZi+8XNDJtA==\n \n+zod-to-json-schema@^3.24.1:\n+ version \"3.24.6\"\n+ resolved \"https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz#5920f020c4d2647edfbb954fa036082b92c9e12d\"\n+ integrity sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==\n+\n zod@3.21.4:\n version \"3.21.4\"\n resolved \"https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db\"\n integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==\n"
377
- }
378
- ]
379
- },
380
- {
381
- "id": "update-metric-periods",
382
- "sha": "92e7f9845db5d661a5a22417a2d67c605ac8bcf2",
383
- "parentSha": "27592ace21c788e6a57c40a39a2a430b5e771f41",
384
- "spec": "Implement probability-based user metric updates for contracts without recent betting activity and optimize batch processing:\n\n1) Add a new metrics helper\n- File: common/src/calculate-metrics.ts\n- Export a function calculateMetricsFromProbabilityChanges(userMetrics, contractsById) that:\n - For each ContractMetric, locates the relevant Contract.\n - Determines current probability:\n - For cpmm-multi-1 with a non-null answerId, use the answer’s current prob.\n - For cpmm-1, use contract.prob.\n - For other mechanisms, return the original metric untouched.\n - Uses calculateProfitMetricsAtProbOrCancel(currentProb, metric) to update payout, profit, and profitPercent.\n - Computes period deltas for day/week/month by:\n - Reading probChanges for the contract (cpmm-1) or for the specific answer (cpmm-multi-1).\n - Computing prevProb = currentProb - probChange.\n - Calculating value change based on YES/NO shares: value = YES_shares * p + NO_shares * (1 - p) for current and previous probabilities.\n - Setting from[period] = { profit, profitPercent, invested, prevValue, value } where invested uses metric.totalAmountInvested (fallbacks allowed as needed) and profitPercent = profit / invested * 100 (guard divide-by-zero).\n - Returns the array of updated ContractMetrics.\n\n2) Update the user period metrics batch flow\n- File: backend/shared/src/update-user-metric-periods.ts\n- Import calculateMetricsFromProbabilityChanges from common/calculate-metrics.\n- For each chunk of active users:\n a) Identify contracts with recent betting activity for these users.\n - Add getContractIdsWithRecentBets(pg, userIds, since) selecting distinct cb.contract_id where cb.user_id in list and cb.created_time > since.\n b) Load bets only for those contracts.\n - Update getUnresolvedOrRecentlyResolvedBets(pg, userIds, since, contractIds) to:\n - Early return {} if contractIds is empty.\n - Restrict to cb.contract_id in contractIds.\n - Exclude cpmm-multi-1 redemptions to avoid noise: add condition (c.mechanism != 'cpmm-multi-1' or not cb.is_redemption).\n - Keep existing unresolved/recently-resolved checks for contracts/answers.\n c) Load current metrics for contracts without recent bets.\n - Add getContractMetricsWithoutRecentBets(pg, userIds, since, contractIdsWithRecentBets) that returns user_contract_metrics rows (as ContractMetric objects) where:\n - user_id in list; has_shares = true; contracts and answers are unresolved or resolved after since; and contract_id NOT IN contractIdsWithRecentBets.\n d) Build the target contract set and load data.\n - allContractIds = union of contractIdsWithRecentBets and contractIds from metrics without recent bets.\n - Fetch contracts and answers only for newContractIds (not yet in cache) using the existing multi-query. Continue to build contractsById as before.\n - When fetching user_contract_metrics from DB in this multi-query, restrict contract_id to only those in allContractIds that DO have recent bets (exclude the without-recent-bets set) to avoid duplicates. Concatenate these results with the preloaded contractMetricsWithoutRecentBets to form currentContractMetrics.\n e) Compute fresh metrics.\n - For each user:\n - freshMetricsFromBets = calculateMetricsByContractAndAnswer(grouped recent bets by contract, contractsById, userId, currentMetricsForUser).\n - userMetricsWithoutBets = contractMetricsWithoutRecentBets filtered by userId.\n - freshMetricsFromProbChanges = calculateMetricsFromProbabilityChanges(userMetricsWithoutBets, contractsById).\n - Combine and dedupe freshMetrics = [...freshMetricsFromBets, ...freshMetricsFromProbChanges].\n - Merge with current metrics (uniq by contractId+answerId) for metricsByUser.\n - Build contractMetricUpdates only when freshMetric.from differs significantly from currentMetric.from (threshold 0.1); update id, from, payout, profit, profitPercent.\n f) Persist updates using existing bulkUpdateDataQuery and bulkUpdateQuery logic.\n\n3) Keep behavioral and performance characteristics\n- Only fetch bets for contracts in contractIdsWithRecentBets; derive period changes for the rest from probChanges.\n- Maintain the existing logging and chunking behavior and do not change external API signatures of updateUserMetricPeriods callers.\n- Ensure no schema changes are required; rely on existing prob/probChanges fields on contracts and answers and existing ContractMetric fields.\n\nAcceptance criteria\n- For users with positions but no recent bets, their period from fields and profit/payout/percent are updated based on probability changes when running updateUserMetricPeriods.\n- Bets are no longer loaded for contracts without recent activity for the users.\n- The batch job produces updates comparable to prior behavior for recently bet markets while adding correct updates for inactive ones.\n- No regressions in callers like backend/api/src/get-daily-changed-metrics-and-contracts.ts.",
385
- "prompt": "Enhance the daily user metrics recalculation so it updates period metrics even when a user hasn’t bet recently on a market. Only load bets for markets where the user has recent activity; for positions without recent bets, recompute metric deltas from the contract’s (or answer’s) probability changes. Add a helper to update payout/profit/percent and period from fields based on current probability and probChanges, and wire it into the batch flow. Preserve existing chunking, logging, and write behavior.",
386
- "supplementalFiles": [
387
- "common/src/contract-metric.ts",
388
- "common/src/contract.ts",
389
- "common/src/answer.ts",
390
- "common/src/supabase/contracts.ts",
391
- "backend/shared/src/update-contract-metrics-core.ts",
392
- "backend/shared/src/update-user-portfolio-histories-core.ts",
393
- "backend/api/src/get-daily-changed-metrics-and-contracts.ts",
394
- "backend/shared/src/update-user-metrics-with-bets.ts"
395
- ],
396
- "fileDiffs": [
397
- {
398
- "path": "backend/shared/src/update-user-metric-periods.ts",
399
- "status": "modified",
400
- "diff": "Index: backend/shared/src/update-user-metric-periods.ts\n===================================================================\n--- backend/shared/src/update-user-metric-periods.ts\t27592ac (parent)\n+++ backend/shared/src/update-user-metric-periods.ts\t92e7f98 (commit)\n@@ -1,6 +1,7 @@\n import {\n calculateMetricsByContractAndAnswer,\n+ calculateMetricsFromProbabilityChanges,\n isEmptyMetric,\n } from 'common/calculate-metrics'\n import { Contract, CPMMMultiContract } from 'common/contract'\n import { ContractMetric } from 'common/contract-metric'\n@@ -77,31 +78,57 @@\n log(`Loaded ${allActiveUserIds.length} active users.`)\n const chunks = chunk(allActiveUserIds, CHUNK_SIZE)\n const metricsByUser: Record<string, ContractMetric[]> = {}\n const contractsById: Record<string, Contract> = {}\n+\n for (const activeUserIds of chunks) {\n- log(`Loading bets for ${activeUserIds.length} users`)\n- // TODO: we could calculate changes for all contracts where the user hasn't bet in the past 24 hours by\n- // using their current metrics and the prob changes of the contracts/answers. Then we'd\n- // only have to load the bets for the contracts that they've bet on in the past 24 hours.\n- // To calculte those easy changes, youd' just take their yes/no shares in each contract-answer\n- // and multiply by the prob change of the contract-answer.\n- const metricRelevantBets = await getUnresolvedOrRecentlyResolvedBets(\n+\n+ log(`Processing ${activeUserIds.length} users`)\n+\n+ // First, find contracts that users have bet on recently\n+ log(`Finding contracts with recent betting activity...`)\n+ const contractIdsWithRecentBets = await getContractIdsWithRecentBets(\n pg,\n activeUserIds,\n since\n )\n+\n+ // Load bets for contracts with recent activity\n+ log(`Loading bets for contracts with recent activity...`)\n+ const metricRelevantBets = await getUnresolvedOrRecentlyResolvedBets(\n+ pg,\n+ activeUserIds,\n+ since,\n+ contractIdsWithRecentBets\n+ )\n log(\n `Loaded ${sumBy(\n Object.values(metricRelevantBets),\n (bets) => bets.length\n )} bets.`\n )\n \n- const allBets = Object.values(metricRelevantBets).flat()\n- const contractIds = uniq(allBets.map((b) => b.contractId))\n- if (contractIds.length === 0) continue\n- const newContractIds: string[] = contractIds.filter(\n+ // Get metrics for contracts WITHOUT recent betting activity\n+ log(`Loading metrics for contracts without recent betting activity...`)\n+ const contractMetricsWithoutRecentBets =\n+ await getContractMetricsWithoutRecentBets(\n+ pg,\n+ activeUserIds,\n+ since,\n+ contractIdsWithRecentBets\n+ )\n+\n+ const contractIdsWithoutBets = uniq(\n+ contractMetricsWithoutRecentBets.map((m) => m.contractId)\n+ )\n+ const allContractIds = uniq([\n+ ...contractIdsWithRecentBets,\n+ ...contractIdsWithoutBets,\n+ ])\n+\n+ if (allContractIds.length === 0) continue\n+\n+ const newContractIds: string[] = allContractIds.filter(\n (c) => !contractsById[c]\n )\n log('Loading contracts, answers, users, and current contract metrics...')\n // We could cache the contracts and answers to query for less data\n@@ -119,11 +146,15 @@\n }\n \n select id, data from user_contract_metrics\n where user_id in ($2:list)\n- and contract_id in ($3:list);\n+ and contract_id in ($3:list)\n `,\n- [newContractIds, activeUserIds, contractIds]\n+ [\n+ newContractIds,\n+ activeUserIds,\n+ allContractIds.filter((c) => !contractIdsWithoutBets.includes(c)),\n+ ]\n )\n const contracts = results[0].map(convertContract)\n const answers = results[1].map(convertAnswer)\n contracts.forEach((c) => {\n@@ -134,11 +165,11 @@\n } as CPMMMultiContract\n else contractsById[c.id] = c\n })\n \n- const currentContractMetrics = results[2].map(\n- (r) => ({ id: r.id, ...r.data } as ContractMetric)\n- )\n+ const currentContractMetrics = results[2]\n+ .map((r) => ({ id: r.id, ...r.data } as ContractMetric))\n+ .concat(contractMetricsWithoutRecentBets)\n \n log(\n `Loaded ${contracts.length} contracts,\n ${answers.length} answers,\n@@ -157,20 +188,37 @@\n \n log('Computing metric updates...')\n for (const userId of activeUserIds) {\n const userMetricRelevantBets = metricRelevantBets[userId] ?? []\n+ const userMetricsWithoutBets = contractMetricsWithoutRecentBets.filter(\n+ (m) => m.userId === userId\n+ )\n \n+ // Calculate metrics for contracts with recent bets (existing logic)\n const metricRelevantBetsByContract = groupBy(\n userMetricRelevantBets,\n (b) => b.contractId\n )\n const currentMetricsForUser = currentMetricsByUserId[userId] ?? []\n- const freshMetrics = calculateMetricsByContractAndAnswer(\n+ const freshMetricsFromBets = calculateMetricsByContractAndAnswer(\n metricRelevantBetsByContract,\n contractsById,\n userId,\n currentMetricsForUser\n )\n+\n+ // Calculate metrics for contracts without recent bets using probability changes\n+ const freshMetricsFromProbChanges =\n+ calculateMetricsFromProbabilityChanges(\n+ userMetricsWithoutBets,\n+ contractsById\n+ )\n+\n+ const freshMetrics = [\n+ ...freshMetricsFromBets,\n+ ...freshMetricsFromProbChanges,\n+ ]\n+\n metricsByUser[userId] = uniqBy(\n [...freshMetrics, ...currentMetricsForUser],\n (m) => m.contractId + m.answerId\n )\n@@ -240,25 +288,86 @@\n log('Finished updating user period metrics')\n return { metricsByUser, contractsById }\n }\n \n-const getUnresolvedOrRecentlyResolvedBets = async (\n+// New function to get contract IDs with recent betting activity\n+const getContractIdsWithRecentBets = async (\n pg: SupabaseDirectClient,\n userIds: string[],\n since: number\n ) => {\n+ const contractIds = await pg.map(\n+ `\n+ select distinct cb.contract_id\n+ from contract_bets cb\n+ where cb.user_id in ($1:list)\n+ and cb.created_time > $2\n+ `,\n+ [userIds, new Date(since).toISOString()],\n+ (r) => r.contract_id as string\n+ )\n+\n+ return contractIds\n+}\n+\n+const getContractMetricsWithoutRecentBets = async (\n+ pg: SupabaseDirectClient,\n+ userIds: string[],\n+ since: number,\n+ contractIdsWithRecentBets: string[]\n+) => {\n+ const excludeClause =\n+ contractIdsWithRecentBets.length > 0\n+ ? 'and ucm.contract_id not in ($3:list)'\n+ : ''\n+\n+ const metrics = await pg.map(\n+ `\n+ select ucm.id, ucm.data\n+ from user_contract_metrics ucm\n+ join contracts c on ucm.contract_id = c.id\n+ left join answers a on ucm.answer_id = a.id\n+ where ucm.user_id in ($1:list)\n+ and ucm.has_shares = true\n+ and (c.resolution_time is null or c.resolution_time > $2)\n+ and (a is null or a.resolution_time is null or a.resolution_time > $2)\n+ ${excludeClause}\n+ `,\n+ [userIds, new Date(since).toISOString(), contractIdsWithRecentBets],\n+ (r) =>\n+ ({\n+ id: r.id as number,\n+ ...r.data,\n+ } as ContractMetric)\n+ )\n+\n+ return metrics\n+}\n+\n+const getUnresolvedOrRecentlyResolvedBets = async (\n+ pg: SupabaseDirectClient,\n+ userIds: string[],\n+ since: number,\n+ contractIds: string[]\n+) => {\n+ if (contractIds.length === 0) {\n+ return {}\n+ }\n+\n const bets = await pg.map(\n `\n select cb.amount, cb.shares, cb.outcome, cb.loan_amount, cb.user_id, cb.answer_id, cb.contract_id, cb.created_time, cb.is_redemption\n from contract_bets as cb\n join contracts as c on cb.contract_id = c.id\n left join answers as a on cb.answer_id = a.id\n where\n cb.user_id in ($1:list)\n+ and cb.contract_id in ($3:list)\n+ and (c.mechanism != 'cpmm-multi-1' or not cb.is_redemption)\n and (c.resolution_time is null or c.resolution_time > $2)\n and (a is null or a.resolution_time is null or a.resolution_time > $2)\n `,\n- [userIds, new Date(since).toISOString()],\n+ [userIds, new Date(since).toISOString(), contractIds],\n convertBet\n )\n \n return groupBy(\n"
401
- },
402
- {
403
- "path": "common/src/calculate-metrics.ts",
404
- "status": "modified",
405
- "diff": "Index: common/src/calculate-metrics.ts\n===================================================================\n--- common/src/calculate-metrics.ts\t27592ac (parent)\n+++ common/src/calculate-metrics.ts\t92e7f98 (commit)\n@@ -1,4 +1,5 @@\n+import { ContractMetric, isSummary } from 'common/contract-metric'\n import {\n cloneDeep,\n Dictionary,\n first,\n@@ -8,19 +9,18 @@\n sum,\n sumBy,\n uniq,\n } from 'lodash'\n+import { Bet, LimitBet } from './bet'\n import {\n calculateTotalSpentAndShares,\n getContractBetMetricsPerAnswerWithoutLoans,\n } from './calculate'\n-import { Bet, LimitBet } from './bet'\n-import { Contract, MultiContract } from './contract'\n import { computeFills, CpmmState, getCpmmProbability } from './calculate-cpmm'\n-import { removeUndefinedProps } from './util/object'\n-import { floatingEqual, logit } from './util/math'\n-import { ContractMetric, isSummary } from 'common/contract-metric'\n+import { Contract, MultiContract } from './contract'\n import { noFees } from './fees'\n+import { floatingEqual, logit } from './util/math'\n+import { removeUndefinedProps } from './util/object'\n \n export const computeInvestmentValueCustomProb = (\n bets: Bet[],\n contract: Contract,\n@@ -571,4 +571,79 @@\n }\n \n return { metricsByContract }\n }\n+\n+export const calculateMetricsFromProbabilityChanges = (\n+ userMetrics: ContractMetric[],\n+ contractsById: Record<string, Contract>\n+): ContractMetric[] => {\n+ return userMetrics.map((metric) => {\n+ const contract = contractsById[metric.contractId]\n+ if (!contract) return metric\n+\n+ let newProb: number\n+ if (contract.mechanism === 'cpmm-multi-1' && metric.answerId) {\n+ const answer = contract.answers.find((a) => a.id === metric.answerId)\n+ if (!answer) return metric\n+ newProb = answer.prob\n+ } else if (contract.mechanism === 'cpmm-1') {\n+ newProb = contract.prob\n+ } else {\n+ return metric\n+ }\n+\n+ // Calculate new metrics based on probability change\n+ const updatedMetric = calculateProfitMetricsAtProbOrCancel(newProb, metric)\n+\n+ // Calculate period profit changes (from calculatePeriodProfit logic)\n+ const calculatePeriodChange = (period: 'day' | 'week' | 'month') => {\n+ let probChange: number\n+ if (contract.mechanism === 'cpmm-multi-1' && metric.answerId) {\n+ const answer = contract.answers.find((a) => a.id === metric.answerId)\n+ probChange = answer?.probChanges[period] ?? 0\n+ } else if (contract.mechanism === 'cpmm-1') {\n+ probChange = contract.probChanges?.[period] ?? 0\n+ } else {\n+ probChange = 0\n+ }\n+\n+ const prevProb = newProb - probChange\n+ const { totalShares, totalAmountInvested = 0 } = metric\n+\n+ // Calculate value change based on shares and probability change\n+ const yesShares = totalShares.YES ?? 0\n+ const noShares = totalShares.NO ?? 0\n+\n+ const prevValue = yesShares * prevProb + noShares * (1 - prevProb)\n+ const currentValue = yesShares * newProb + noShares * (1 - newProb)\n+ const valueChange = currentValue - prevValue\n+\n+ const profit = valueChange\n+ const invested =\n+ totalAmountInvested > 0\n+ ? totalAmountInvested\n+ : Math.abs(totalAmountInvested) || 1\n+ const profitPercent = (profit / invested) * 100\n+\n+ return {\n+ profit,\n+ profitPercent,\n+ invested: totalAmountInvested,\n+ prevValue,\n+ value: currentValue,\n+ }\n+ }\n+\n+ // Update the from field with period changes\n+ const from = {\n+ day: calculatePeriodChange('day'),\n+ week: calculatePeriodChange('week'),\n+ month: calculatePeriodChange('month'),\n+ }\n+\n+ return {\n+ ...updatedMetric,\n+ from,\n+ }\n+ })\n+}\n"
406
- }
407
- ]
408
- },
409
- {
410
- "id": "update-monthly-metrics",
411
- "sha": "27592ace21c788e6a57c40a39a2a430b5e771f41",
412
- "parentSha": "20019ac35621f3db87d33e87f010cdbf2f7343b0",
413
- "spec": "Implement a daily monthly-probability-changes recomputation mode and schedule it.\n\nScope:\n- Scheduler: Add a new scheduled job that runs once per day and triggers the metrics core function in a mode that recomputes monthly probability deltas using historical data. Update the existing frequent job to call the core function in the lightweight mode.\n- Metrics core: Update the contract metrics core function to accept an optional flag indicating whether to recompute month-level probability changes from historical data or to preserve existing stored monthly deltas. Adjust the contract selection timeframe and loading of historical probabilities based on this flag.\n\nRequirements:\n1) Scheduler job changes (backend/scheduler/src/jobs/index.ts):\n- Update the existing 'update-contract-metrics' job so it calls the core update function with the flag set to false, preserving current behavior for frequent updates.\n- Add a new job named 'update-contract-metrics-full' scheduled to run daily at 5:30am. This job must call the core update function with the flag set to true.\n\n2) Metrics core changes (backend/shared/src/update-contract-metrics-core.ts):\n- Change the signature of the exported function to accept a boolean parameter indicating whether to perform a full monthly recomputation (default to false to preserve existing call-sites).\n- Use this flag to set a dynamic timeframe string used in the SQL WHERE clause that selects which contracts to process:\n - For full monthly recomputation (true): use a longer historical window (1 year) for last bet time or resolution time.\n - For normal lightweight updates (false): keep the existing 1 month window.\n- Only load month-ago probabilities from historical bets when the flag is true. When the flag is false, do not load month-ago probabilities; instead, use the existing stored monthly change values (e.g., contract.probChanges.month for CPMM contracts, and answer.probChanges.month for answers) as the baseline. Ensure the logic still falls back to pool probability when necessary.\n- Continue loading day-ago and week-ago probabilities and computing day/week deltas in both modes.\n- Ensure that volume and other metrics continue to be computed and updated as before and that bulk update utilities for contracts and answers still receive the appropriate fields; the only behavioral change should be how and when month-level deltas are recomputed and the timeframe of contracts considered for processing.\n\n3) Do not change public APIs or external types. Keep all existing exports and import paths intact.\n\nBehavioral outcome:\n- Frequent job (every ~21 minutes) updates day/week deltas and avoids recomputing monthly deltas from historical data; it uses the stored month change values to maintain consistency and minimize load.\n- Daily job recomputes monthly deltas from historical data across a longer timeframe, ensuring month-level metrics are accurate once per day.\n- Contract selection expands to a longer historical window during the daily recomputation, so contracts with older last bet times or recent resolutions are included as needed.",
414
- "prompt": "Add a daily full metrics recomputation mode for monthly probability changes and wire it into the scheduler.\n\nSpecifically:\n- Introduce an optional parameter to the contract metrics update function that toggles full monthly recomputation versus lightweight mode. In full mode, load month-ago probabilities from historical bets and use a longer historical timeframe to select contracts. In lightweight mode, continue to compute day/week deltas but preserve existing stored month deltas instead of recomputing them from history.\n- Update the scheduler to:\n - Keep the existing frequent job calling the metrics update in lightweight mode.\n - Add a new daily job (e.g., early morning) that triggers the full monthly recomputation.\n\nFollow the existing code patterns for loading historical probabilities, computing deltas, and performing bulk updates. The change should be isolated to the scheduler job configuration and the metrics core logic that selects contracts and computes month-level deltas.",
415
- "supplementalFiles": [
416
- "backend/scheduler/src/jobs/helpers.ts",
417
- "backend/scheduler/src/index.ts",
418
- "backend/shared/src/supabase/answers.ts",
419
- "backend/shared/src/supabase/utils.ts",
420
- "backend/scripts/update-metrics.ts"
421
- ],
422
- "fileDiffs": [
423
- {
424
- "path": "backend/scheduler/src/jobs/index.ts",
425
- "status": "modified",
426
- "diff": "Index: backend/scheduler/src/jobs/index.ts\n===================================================================\n--- backend/scheduler/src/jobs/index.ts\t20019ac (parent)\n+++ backend/scheduler/src/jobs/index.ts\t27592ac (commit)\n@@ -55,11 +55,16 @@\n ),\n createJob(\n 'update-contract-metrics',\n '0 */21 * * * *', // every 21 minutes - (on the 3rd minute of every hour)\n- updateContractMetricsCore\n+ () => updateContractMetricsCore(false)\n ),\n createJob(\n+ 'update-contract-metrics-full',\n+ '0 30 5 * * *', // every day at 5:30am\n+ () => updateContractMetricsCore(true)\n+ ),\n+ createJob(\n 'update-creator-metrics',\n `0 */${CREATOR_UPDATE_FREQUENCY} * * * *`, // every 57 minutes - (on the 57th minute of every hour)\n updateCreatorMetricsCore\n ),\n"
427
- },
428
- {
429
- "path": "backend/shared/src/update-contract-metrics-core.ts",
430
- "status": "modified",
431
- "diff": "Index: backend/shared/src/update-contract-metrics-core.ts\n===================================================================\n--- backend/shared/src/update-contract-metrics-core.ts\t20019ac (parent)\n+++ backend/shared/src/update-contract-metrics-core.ts\t27592ac (commit)\n@@ -1,25 +1,26 @@\n+import { LimitBet } from 'common/bet'\n+import { computeElasticity } from 'common/calculate-metrics'\n+import { Contract, CPMM } from 'common/contract'\n+import { convertAnswer, convertContract } from 'common/supabase/contracts'\n+import { hasChanges } from 'common/util/object'\n+import { DAY_MS, MONTH_MS, WEEK_MS } from 'common/util/time'\n+import { chunk, groupBy, mapValues } from 'lodash'\n import {\n createSupabaseDirectClient,\n SupabaseDirectClient,\n } from 'shared/supabase/init'\n import { contractColumnsToSelect, log } from 'shared/utils'\n-import { DAY_MS, MONTH_MS, WEEK_MS } from 'common/util/time'\n-import { Contract, CPMM } from 'common/contract'\n-import { computeElasticity } from 'common/calculate-metrics'\n-import { hasChanges } from 'common/util/object'\n-import { chunk, groupBy, mapValues } from 'lodash'\n-import { LimitBet } from 'common/bet'\n-import { bulkUpdateData } from './supabase/utils'\n-import { convertAnswer, convertContract } from 'common/supabase/contracts'\n import { bulkUpdateAnswers } from './supabase/answers'\n+import { bulkUpdateData } from './supabase/utils'\n \n-export async function updateContractMetricsCore() {\n+export async function updateContractMetricsCore(wholeMonth: boolean = false) {\n const pg = createSupabaseDirectClient()\n log('Loading contract data...')\n+ const timeFrame = wholeMonth ? '1 year' : '1 month'\n const where = `\n- where (c.resolution_time is null and c.last_bet_time > now() - interval '1 month')\n- or c.resolution_time > now() - interval '1 month'`\n+ where (c.resolution_time is null and c.last_bet_time > now() - interval '${timeFrame}')\n+ or c.resolution_time > now() - interval '${timeFrame}'`\n const results = await pg.multi(\n `\n select ${contractColumnsToSelect} from contracts c\n ${where};\n@@ -61,13 +62,15 @@\n ])\n )\n \n log('Loading historic contract probabilities...')\n- const [dayAgoProbs, weekAgoProbs, monthAgoProbs] = await Promise.all(\n- [dayAgo, weekAgo, monthAgo].map((t) =>\n- getBetProbsAt(pg, t, contractIds, sumToOneContractIds)\n- )\n- )\n+ const [dayAgoProbs, weekAgoProbs, monthAgoProbs] = await Promise.all([\n+ getBetProbsAt(pg, dayAgo, contractIds, sumToOneContractIds),\n+ getBetProbsAt(pg, weekAgo, contractIds, sumToOneContractIds),\n+ wholeMonth\n+ ? getBetProbsAt(pg, monthAgo, contractIds, sumToOneContractIds)\n+ : Promise.resolve({} as { [key: string]: number }),\n+ ])\n \n log('Loading volume...')\n const volumeAndCount = await getVolumeAndCountSince(pg, dayAgo, contractIds)\n \n@@ -94,9 +97,11 @@\n const { poolProb, resProb, resTime } = currentContractProbs[id]\n const prob = resProb ?? poolProb\n const dayAgoProb = dayAgoProbs[id] ?? poolProb\n const weekAgoProb = weekAgoProbs[id] ?? poolProb\n- const monthAgoProb = monthAgoProbs[id] ?? poolProb\n+ const monthAgoProb =\n+ (wholeMonth ? monthAgoProbs[id] : contract.probChanges.month) ??\n+ poolProb\n cpmmFields = {\n prob,\n probChanges: {\n day: resTime && resTime <= dayAgo ? 0 : prob - dayAgoProb,\n@@ -120,9 +125,12 @@\n const prob = resProb ?? poolProb\n const key = contract.id + answer.id\n const dayAgoProb = dayAgoProbs[key] ?? poolProb\n const weekAgoProb = weekAgoProbs[key] ?? poolProb\n- const monthAgoProb = monthAgoProbs[key] ?? poolProb\n+ const monthAgoProb =\n+ (wholeMonth ? monthAgoProbs[key] : answer.probChanges.month) ??\n+ poolProb\n+\n const answerCpmmFields = {\n probChanges: {\n day: resTime && resTime <= dayAgo ? 0 : prob - dayAgoProb,\n week: resTime && resTime <= weekAgo ? 0 : prob - weekAgoProb,\n"
432
- }
433
- ]
434
- },
435
- {
436
- "id": "fix-comment-count",
437
- "sha": "7e522f066de9b454d4c68a4510e96e340343e0cb",
438
- "parentSha": "02b45f2e0182fb9337b706690ab499153cdce9bd",
439
- "spec": "Objective: Ensure the contract page and comments UI display an accurate total comment count by fetching it at param construction time, including it in ContractParams, and wiring it through all components. Also update the comments tab content to only increase totals when new comments stream in.\n\nRequirements:\n\n1) Extend ContractParams with totalComments\n- In common/src/contract.ts, update the ContractParams type to include a new field totalComments: number alongside comments, totalBets, totalPositions, etc.\n\n2) Fetch and include totalComments in getContractParams\n- In common/src/contract-params.ts, update getContractParams(contract, db) to fetch the total number of comments for the given contract.\n- Add totalComments to the Promise.all request group so it resolves in parallel with existing fetches (recent comments, pinned comments, metrics, annotations, topics, dashboards, etc.).\n- Include totalComments in the return object from getContractParams via removeUndefinedProps.\n- Ensure the database query counts non-deleted comments for the contract. The count should reflect the total number of comments on the contract, not just the 25 recent comments that are otherwise fetched.\n- Important architecture constraint: common cannot import from web. Implement the count fetch using common’s supabase utilities or an existing common/shared path that queries the contract_comments table (do not import a utility from web).\n\n3) Wire totalComments through the contract page components\n- In web/components/contract/contract-page.tsx, accept totalComments in the ContractParams props and pass it down to components that display or derive their tab titles from comment counts (e.g., ContractTabs and any comments-related child).\n- In web/components/contract/contract-tabs.tsx, initialize the totalComments state from props.totalComments, not from the length of the initial comments array. Pass both totalComments and a setter to CommentsTabContent.\n\n4) Update CommentsTabContent to report increasing totals\n- In web/components/contract/comments-tab-content.tsx, rename the optional callback prop from setCommentsLength to setTotalComments and add a required numeric prop totalComments.\n- Inside useEffect, compare the computed allComments.length with totalComments. If allComments.length is greater than totalComments, call setTotalComments with the new length. Do not decrease the total to avoid regressions when some comments aren’t loaded yet.\n\n5) Ensure CommentsButton uses the canonical total\n- In web/components/comments/comments-button.tsx, continue to show the badge count from useNumContractComments(contract.id) to populate the button. When opening the modal, pass the canonical totalComments into the modal’s CommentsTabContent so the displayed counts stay consistent with the server-provided total.\n\n6) Prop and import hygiene\n- Update all imports and prop typings affected by adding totalComments and renaming setCommentsLength to setTotalComments.\n- Ensure no cross-package import violations occur: common must not import from web. Use common/shared supabase utilities to fetch the count.\n\nAcceptance criteria:\n- The contract page’s Comments tab title displays an accurate count on initial load (matching the DB total, not just initial comments array length).\n- New comments received while the page is open increase the displayed total in the tab title; totals never decrease.\n- Comments modal opened via the comments button shows totals aligned with the tab title and the server-provided initial count.\n- No build errors from import cycles or cross-package layering violations.",
440
- "prompt": "Add a canonical total comment count to the contract page. Fetch the total from the database when constructing the contract’s params and include it in ContractParams. Thread this totalComments value through the contract page and comments UI so the Comments tab title and comments modal show an accurate total on initial load. When new comments stream in, update the displayed count upward but never reduce it. Rename the CommentsTabContent callback to setTotalComments and pass totalComments so it only increases the total when local lists grow. Keep package boundaries intact (common must not import from web) and ensure all components and props compile cleanly.",
441
- "supplementalFiles": [
442
- "web/hooks/use-comments.ts",
443
- "web/lib/supabase/comments.ts",
444
- "common/src/supabase/comments.ts",
445
- "backend/supabase/contract_comments.sql",
446
- "web/components/contract/contract-table-col-formats.tsx",
447
- "web/pages/[username]/[contractSlug].tsx"
448
- ],
449
- "fileDiffs": [
450
- {
451
- "path": "common/src/contract-params.ts",
452
- "status": "modified",
453
- "diff": "Index: common/src/contract-params.ts\n===================================================================\n--- common/src/contract-params.ts\t02b45f2 (parent)\n+++ common/src/contract-params.ts\t7e522f0 (commit)\n@@ -1,10 +1,12 @@\n+import { Bet } from 'common/bet'\n import {\n getInitialAnswerProbability,\n getInitialProbability,\n } from 'common/calculate'\n-import { Contract, ContractParams, MultiContract } from 'common/contract'\n import { binAvg, maxMinBin, serializeMultiPoints } from 'common/chart'\n+import { Contract, ContractParams, MultiContract } from 'common/contract'\n+import { getChartAnnotations } from 'common/supabase/chart-annotations'\n import {\n getPinnedComments,\n getRecentTopLevelCommentsAndReplies,\n } from 'common/supabase/comments'\n@@ -12,25 +14,25 @@\n getContractMetricsCount,\n getTopContractMetrics,\n } from 'common/supabase/contract-metrics'\n import { getTopicsOnContract } from 'common/supabase/groups'\n-import { removeUndefinedProps } from 'common/util/object'\n-import { pointsToBase64 } from 'common/util/og'\n import { SupabaseClient } from 'common/supabase/utils'\n import { buildArray } from 'common/util/array'\n+import { removeUndefinedProps } from 'common/util/object'\n+import { pointsToBase64 } from 'common/util/og'\n import { groupBy, mapValues, omit, orderBy, sortBy } from 'lodash'\n-import { Bet } from 'common/bet'\n-import { getChartAnnotations } from 'common/supabase/chart-annotations'\n-import { unauthedApi } from './util/api'\n+import { getNumContractComments } from 'web/lib/supabase/comments'\n import {\n ANSWERS_TO_HIDE_GRAPH,\n getDefaultSort,\n getSortedAnswers,\n sortAnswers,\n } from './answer'\n-import { getDashboardsToDisplayOnContract } from './supabase/dashboards'\n import { getBetPointsBetween, getTotalBetCount } from './bets'\n import { MarketContract } from './contract'\n+import { getDashboardsToDisplayOnContract } from './supabase/dashboards'\n+import { unauthedApi } from './util/api'\n+\n export async function getContractParams(\n contract: Contract,\n db: SupabaseClient\n ): Promise<Omit<ContractParams, 'cash'>> {\n@@ -49,8 +51,9 @@\n lastBetArray,\n allBetPoints,\n comments,\n pinnedComments,\n+ totalComments,\n topContractMetrics,\n totalPositions,\n relatedContracts,\n chartAnnotations,\n@@ -79,8 +82,9 @@\n })\n : [],\n getRecentTopLevelCommentsAndReplies(db, contract.id, 25),\n getPinnedComments(db, contract.id),\n+ getNumContractComments(contract.id),\n contract.resolution ? getTopContractMetrics(contract.id, 10, db) : [],\n isCpmm1 || isMulti ? getContractMetricsCount(contract.id, db) : 0,\n unauthedApi('get-related-markets', {\n contractId: contract.id,\n@@ -111,16 +115,16 @@\n }\n \n const lastBet: Bet | undefined = lastBetArray[0]\n const lastBetTime = lastBet?.createdTime\n-\n return removeUndefinedProps({\n outcomeType: contract.outcomeType,\n contract,\n lastBetTime,\n pointsString,\n multiPointsString,\n comments,\n+ totalComments,\n totalPositions,\n totalBets,\n topContractMetrics,\n relatedContracts: relatedContracts.marketsFromEmbeddings as Contract[],\n"
454
- },
455
- {
456
- "path": "common/src/contract.ts",
457
- "status": "modified",
458
- "diff": "Index: common/src/contract.ts\n===================================================================\n--- common/src/contract.ts\t02b45f2 (parent)\n+++ common/src/contract.ts\t7e522f0 (commit)\n@@ -4,15 +4,15 @@\n import { ChartAnnotation } from 'common/supabase/chart-annotations'\n import { sum } from 'lodash'\n import { Answer } from './answer'\n import { getLiquidity } from './calculate-cpmm'\n+import { MultiBase64Points } from './chart'\n import { ContractComment } from './comment'\n import { ContractMetric } from './contract-metric'\n import { ENV_CONFIG } from './envs/constants'\n import { Fees } from './fees'\n import { PollOption } from './poll-option'\n import { formatMoney, formatPercent } from './util/format'\n-import { MultiBase64Points } from './chart'\n import { DAY_MS } from './util/time'\n \n /************************************************\n \n@@ -468,8 +468,9 @@\n lastBetTime?: number\n pointsString?: string\n multiPointsString?: MultiBase64Points\n comments: ContractComment[]\n+ totalComments: number\n totalPositions: number\n totalBets: number\n topContractMetrics: ContractMetric[]\n relatedContracts: Contract[]\n"
459
- },
460
- {
461
- "path": "web/components/comments/comments-button.tsx",
462
- "status": "modified",
463
- "diff": "Index: web/components/comments/comments-button.tsx\n===================================================================\n--- web/components/comments/comments-button.tsx\t02b45f2 (parent)\n+++ web/components/comments/comments-button.tsx\t7e522f0 (commit)\n@@ -1,21 +1,21 @@\n-import { useState } from 'react'\n import { ChatIcon } from '@heroicons/react/outline'\n import clsx from 'clsx'\n import { Contract } from 'common/contract'\n-import { Modal, SCROLLABLE_MODAL_CLASS } from '../layout/modal'\n-import { Col } from '../layout/col'\n-import { usePrivateUser } from 'web/hooks/use-user'\n-import { track } from 'web/lib/service/analytics'\n-import { Tooltip } from '../widgets/tooltip'\n import { User } from 'common/user'\n+import { useState } from 'react'\n+import { Button } from 'web/components/buttons/button'\n+import { CommentsTabContent } from 'web/components/contract/comments-tab-content'\n import {\n useCommentsOnContract,\n useNumContractComments,\n } from 'web/hooks/use-comments'\n-import { Button } from 'web/components/buttons/button'\n+import { usePrivateUser } from 'web/hooks/use-user'\n+import { track } from 'web/lib/service/analytics'\n+import { Col } from '../layout/col'\n+import { Modal, SCROLLABLE_MODAL_CLASS } from '../layout/modal'\n import { Row } from '../layout/row'\n-import { CommentsTabContent } from 'web/components/contract/comments-tab-content'\n+import { Tooltip } from '../widgets/tooltip'\n \n export function CommentsButton(props: {\n contract: Contract\n user: User | null | undefined\n@@ -53,8 +53,9 @@\n highlightCommentId={highlightCommentId}\n contract={contract}\n open={open}\n setOpen={setOpen}\n+ totalComments={totalComments}\n />\n )}\n </Row>\n </Tooltip>\n@@ -66,10 +67,11 @@\n contract: Contract\n open: boolean\n setOpen: (open: boolean) => void\n highlightCommentId?: string\n+ totalComments: number\n }) {\n- const { contract, highlightCommentId, open, setOpen } = props\n+ const { contract, highlightCommentId, open, setOpen, totalComments } = props\n const comments = useCommentsOnContract(contract.id) ?? []\n \n const privateUser = usePrivateUser()\n const blockedUserIds = privateUser?.blockedUserIds ?? []\n@@ -92,8 +94,9 @@\n comments={comments}\n blockedUserIds={blockedUserIds}\n highlightCommentId={highlightCommentId}\n pinnedComments={[]}\n+ totalComments={totalComments}\n />\n </Col>\n </Modal>\n )\n"
464
- },
465
- {
466
- "path": "web/components/contract/comments-tab-content.tsx",
467
- "status": "modified",
468
- "diff": "Index: web/components/contract/comments-tab-content.tsx\n===================================================================\n--- web/components/contract/comments-tab-content.tsx\t02b45f2 (parent)\n+++ web/components/contract/comments-tab-content.tsx\t7e522f0 (commit)\n@@ -34,9 +34,10 @@\n staticContract: Contract // contains the comments\n liveContract: Contract // you trade on this\n comments: ContractComment[]\n blockedUserIds: string[]\n- setCommentsLength?: (length: number) => void\n+ setTotalComments?: (length: number) => void\n+ totalComments: number\n replyTo?: Answer | Bet\n clearReply?: () => void\n className?: string\n highlightCommentId?: string\n@@ -47,9 +48,10 @@\n staticContract,\n liveContract,\n comments: staticComments,\n blockedUserIds,\n- setCommentsLength,\n+ setTotalComments,\n+ totalComments,\n replyTo,\n clearReply,\n className,\n highlightCommentId,\n@@ -237,10 +239,12 @@\n )\n : {}\n \n useEffect(() => {\n- setCommentsLength?.(allComments.length)\n- }, [allComments.length])\n+ if (allComments.length > totalComments) {\n+ setTotalComments?.(allComments.length)\n+ }\n+ }, [allComments.length, totalComments])\n \n const pinnedComments = uniqBy(\n staticPinnedComments.concat(\n allComments.filter((comment) => comment.pinned)\n"
469
- },
470
- {
471
- "path": "web/components/contract/contract-page.tsx",
472
- "status": "modified",
473
- "diff": "Index: web/components/contract/contract-page.tsx\n===================================================================\n--- web/components/contract/contract-page.tsx\t02b45f2 (parent)\n+++ web/components/contract/contract-page.tsx\t7e522f0 (commit)\n@@ -1,46 +1,50 @@\n import { StarIcon, XIcon } from '@heroicons/react/solid'\n+import { useContractBets } from 'client-common/hooks/use-bets'\n+import { getMultiBetPointsFromBets } from 'client-common/lib/choice'\n import clsx from 'clsx'\n-import {\n- isBinaryMulti,\n- tradingAllowed,\n- type Contract,\n- type ContractParams,\n-} from 'common/contract'\n-import { mergeWith, uniqBy } from 'lodash'\n-import Image from 'next/image'\n-import { useEffect, useMemo, useRef, useState } from 'react'\n import { Answer } from 'common/answer'\n import { Bet } from 'common/bet'\n import {\n HistoryPoint,\n MultiBase64Points,\n MultiPoints,\n unserializeBase64Multi,\n } from 'common/chart'\n+import {\n+ isBinaryMulti,\n+ tradingAllowed,\n+ type Contract,\n+ type ContractParams,\n+} from 'common/contract'\n+import { shouldHideGraph } from 'common/contract-params'\n+import { base64toPoints } from 'common/edge/og'\n import { HOUSE_BOT_USERNAME, SPICE_MARKET_TOOLTIP } from 'common/envs/constants'\n import { DAY_MS } from 'common/util/time'\n+import { mergeWith, uniqBy } from 'lodash'\n+import Image from 'next/image'\n+import Link from 'next/link'\n+import { useEffect, useMemo, useRef, useState } from 'react'\n import { UserBetsSummary } from 'web/components/bet/user-bet-summary'\n import { ScrollToTopButton } from 'web/components/buttons/scroll-to-top-button'\n import { SidebarSignUpButton } from 'web/components/buttons/sign-up-button'\n-import { getMultiBetPointsFromBets } from 'client-common/lib/choice'\n import { BackButton } from 'web/components/contract/back-button'\n import { ChangeBannerButton } from 'web/components/contract/change-banner-button'\n import { ContractDescription } from 'web/components/contract/contract-description'\n import { AuthorInfo } from 'web/components/contract/contract-details'\n import { ContractLeaderboard } from 'web/components/contract/contract-leaderboard'\n import { ContractOverview } from 'web/components/contract/contract-overview'\n+import { ContractSummaryStats } from 'web/components/contract/contract-summary-stats'\n import { ContractTabs } from 'web/components/contract/contract-tabs'\n import { VisibilityIcon } from 'web/components/contract/contracts-table'\n import { DangerZone } from 'web/components/contract/danger-zone'\n import { EditableQuestionTitle } from 'web/components/contract/editable-question-title'\n+import { HeaderActions } from 'web/components/contract/header-actions'\n import { MarketTopics } from 'web/components/contract/market-topics'\n import {\n RelatedContractsGrid,\n SidebarRelatedContractsList,\n } from 'web/components/contract/related-contracts-widget'\n-import { ContractSummaryStats } from 'web/components/contract/contract-summary-stats'\n-import { HeaderActions } from 'web/components/contract/header-actions'\n import { ExplainerPanel } from 'web/components/explainer-panel'\n import { Col } from 'web/components/layout/col'\n import { Row } from 'web/components/layout/row'\n import { Spacer } from 'web/components/layout/spacer'\n@@ -49,32 +53,28 @@\n import { Rating, ReviewPanel } from 'web/components/reviews/stars'\n import { GradientContainer } from 'web/components/widgets/gradient-container'\n import { Tooltip } from 'web/components/widgets/tooltip'\n import { useAdmin, useTrusted } from 'web/hooks/use-admin'\n-import { useContractBets } from 'client-common/hooks/use-bets'\n+import { precacheAnswers } from 'web/hooks/use-answers'\n import { useLiveContract } from 'web/hooks/use-contract'\n import { useHeaderIsStuck } from 'web/hooks/use-header-is-stuck'\n+import { useIsPageVisible } from 'web/hooks/use-page-visible'\n import { useRelatedMarkets } from 'web/hooks/use-related-contracts'\n import { useReview } from 'web/hooks/use-review'\n import { useSaveCampaign } from 'web/hooks/use-save-campaign'\n+import { useSaveReferral } from 'web/hooks/use-save-referral'\n import { useSaveContractVisitsLocally } from 'web/hooks/use-save-visits'\n import { useSavedContractMetrics } from 'web/hooks/use-saved-contract-metrics'\n import { useTracking } from 'web/hooks/use-tracking'\n import { usePrivateUser, useUser } from 'web/hooks/use-user'\n+import { useDisplayUserById } from 'web/hooks/use-user-supabase'\n+import { api } from 'web/lib/api/api'\n import { track } from 'web/lib/service/analytics'\n import { scrollIntoViewCentered } from 'web/lib/util/scroll'\n-import { SpiceCoin } from 'web/public/custom-components/spiceCoin'\n import { YourTrades } from 'web/pages/[username]/[contractSlug]'\n-import { precacheAnswers } from 'web/hooks/use-answers'\n-import { useIsPageVisible } from 'web/hooks/use-page-visible'\n-import { api } from 'web/lib/api/api'\n-import { shouldHideGraph } from 'common/contract-params'\n-import { CreatorSharePanel, NonCreatorSharePanel } from './creator-share-panel'\n+import { SpiceCoin } from 'web/public/custom-components/spiceCoin'\n import { FollowMarketButton } from '../buttons/follow-market-button'\n-import { useSaveReferral } from 'web/hooks/use-save-referral'\n-import { base64toPoints } from 'common/edge/og'\n-import { useDisplayUserById } from 'web/hooks/use-user-supabase'\n-import Link from 'next/link'\n+import { CreatorSharePanel, NonCreatorSharePanel } from './creator-share-panel'\n \n export function ContractPageContent(props: ContractParams) {\n const {\n comments,\n@@ -84,8 +84,9 @@\n chartAnnotations,\n topics,\n dashboards,\n pinnedComments,\n+ totalComments,\n } = props\n \n // Just use the contract that was navigated to directly\n const liveContract = useLiveContract(props.contract)\n@@ -497,8 +498,9 @@\n blockedUserIds={blockedUserIds}\n activeIndex={activeTabIndex}\n setActiveIndex={setActiveTabIndex}\n pinnedComments={pinnedComments}\n+ totalComments={totalComments}\n />\n </div>\n {showExplainerPanel && (\n <div className=\"bg-canvas-50 -mx-4 p-4 pb-0 md:-mx-8 xl:hidden\">\n"
474
- },
475
- {
476
- "path": "web/components/contract/contract-tabs.tsx",
477
- "status": "modified",
478
- "diff": "Index: web/components/contract/contract-tabs.tsx\n===================================================================\n--- web/components/contract/contract-tabs.tsx\t02b45f2 (parent)\n+++ web/components/contract/contract-tabs.tsx\t7e522f0 (commit)\n@@ -27,8 +27,9 @@\n setActiveIndex: (i: number) => void\n totalBets: number\n totalPositions: number\n pinnedComments: ContractComment[]\n+ totalComments: number\n }) {\n const {\n staticContract,\n liveContract,\n@@ -45,9 +46,9 @@\n \n const highlightedCommentId = useHashInUrlPageRouter('')\n \n const [totalPositions, setTotalPositions] = useState(props.totalPositions)\n- const [totalComments, setTotalComments] = useState(comments.length)\n+ const [totalComments, setTotalComments] = useState(props.totalComments)\n \n const commentsTitle =\n (totalComments > 0 ? `${shortFormatNumber(totalComments)} ` : '') +\n maybePluralize('Comment', totalComments)\n@@ -87,9 +88,10 @@\n staticContract={staticContract}\n liveContract={liveContract}\n comments={comments}\n pinnedComments={pinnedComments}\n- setCommentsLength={setTotalComments}\n+ setTotalComments={setTotalComments}\n+ totalComments={totalComments}\n blockedUserIds={blockedUserIds}\n replyTo={replyTo}\n clearReply={() => setReplyTo?.(undefined)}\n className=\"-ml-2 -mr-1\"\n"
479
- },
480
- {
481
- "path": "web/lib/supabase/comments.ts",
482
- "status": "modified",
483
- "diff": "Index: web/lib/supabase/comments.ts\n===================================================================\n--- web/lib/supabase/comments.ts\t02b45f2 (parent)\n+++ web/lib/supabase/comments.ts\t7e522f0 (commit)\n@@ -1,10 +1,10 @@\n-import { run } from 'common/supabase/utils'\n-import { db } from './db'\n-import { chunk } from 'lodash'\n+import { PostComment } from 'common/comment'\n import { convertContractComment } from 'common/supabase/comments'\n+import { run } from 'common/supabase/utils'\n import { filterDefined } from 'common/util/array'\n-import { PostComment } from 'common/comment'\n+import { chunk } from 'lodash'\n+import { db } from './db'\n \n export async function getComment(commentId: string) {\n const res = await db\n .from('contract_comments')\n"
484
- }
485
- ]
486
- },
487
- {
488
- "id": "lazy-comment-threads",
489
- "sha": "0b36b52e47c0b09eefc0f4c7e62b1ac9dc7f68db",
490
- "parentSha": "0b8bb4847e740a0165ee2f9c660778a886e1edd6",
491
- "spec": "Implement lazy-loading of contract comment threads and a thread-centric fetch API, refactor contract tab components, and fix pinned comments filtering.\n\nBackend\n1) API schema\n- Add GET comment-threads endpoint to common/src/api/schema.ts with props: { contractId: string; limit: number(default 10, 0..100); page: number(default 0, >=0) }, returns: { parentComments: ContractComment[]; replyComments: ContractComment[] }, and use the default cache strategy for this endpoint. \n- Add GET comment-thread endpoint to common/src/api/schema.ts with props: { contractId: string; commentId: string }, returns: { parentComment: ContractComment | null; replyComments: ContractComment[]; parentComments: ContractComment[]; nextParentComments: ContractComment[]; nextReplyComments: ContractComment[] }.\n\n2) Routes\n- Register both endpoints in backend/api/src/routes.ts handlers map with keys 'comment-threads' and 'comment-thread'. Ensure all necessary imports are added and there are no duplicates.\n\n3) API handlers\n- Create backend/api/src/get-comment-threads.ts that exports two APIHandler implementations:\n • getContractCommentThreads for 'comment-threads': accepts contractId/limit/page, instantiates a Supabase direct client, and calls a shared Supabase helper to fetch parent+reply comments for the requested page.\n • getCommentThread for 'comment-thread': accepts contractId and commentId, instantiates a Supabase direct client, and calls a shared Supabase helper returning the parent comment, its replies, surrounding parent comments immediately before/after, and the replies to those next parents.\n\n4) Supabase data helpers\n- In backend/shared/src/supabase/contract-comments.ts, implement:\n • getCommentThreads(pg, { contractId, limit, page }): Return top-level parent comments (data->>replyToCommentId is null) for the page (ordered by created_time desc), and all their non-deleted replies. Return shape: { parentComments, replyComments }. Exclude deleted comments (data->>deleted is null or 'false'). Use convertContractComment to map rows to ContractComment.\n • getCommentThread(pg, commentId, contractId): Fetch the comment, resolve parentId to its top-level parent if it’s a reply, then return:\n - parentComment (that top-level parent or null if not found)\n - replyComments (all non-deleted replies to parent)\n - parentComments (top-level parents newer than parentComment)\n - nextParentComments (up to a small window, e.g. 3, of older top-level parents)\n - nextReplyComments (all non-deleted replies to nextParentComments)\n Ensure all time comparisons are based on created_time, and deleted filters are consistent as above.\n\n5) Pinned comments filter fix\n- In common/src/supabase/comments.ts, fix the pinned query to compare against the string 'true' for data->>pinned, and to include comments where data->>deleted is null or 'false' (using an OR condition), not a boolean eq on pinned or a .not eq 'true' for deleted.\n\nFrontend\n6) New hook for threads\n- In web/hooks/use-comments.ts, add useCommentThreads(contractId: string, limit: number, disabled: boolean):\n • Maintain local state for threads array: { parent: ContractComment; replies: ContractComment[] }[], page number, and loading state.\n • Implement loadMore(): guard with loading flag, call api('comment-threads', { contractId, limit, page }), group replyComments by replyToCommentId, construct thread objects, append to existing threads, increment page, and unset loading.\n • On mount and when contractId changes (unless disabled is true), call loadMore(). Return { threads, loadMore, loading }.\n\n7) Thread-centric fetch for highlighted comment\n- In web/components/contract/comments-tab-content.tsx, use the new useCommentThreads hook to load parent threads lazily for the current contract. If a highlightCommentId is provided and not present locally, call api('comment-thread', { contractId, commentId }) and merge the returned parentComment, replyComments, parentComments, and nextParentComments/nextReplyComments into local state so that the highlighted thread and its neighbors are available in-view.\n- Continue to subscribe to new comments (useSubscribeNewComments) and include them when de-duplicating the full comment list. \n- Maintain best/newest/yes-bets/no-bets sort behavior and lumping behavior to minimize jumping. Filter out blocked user IDs, include pinned comments, and optionally display GPT summary. Use a visibility observer to trigger loadMore on scroll.\n\n8) Component refactor and imports\n- Split the large tab content out of web/components/contract/contract-tabs.tsx into:\n • web/components/contract/comments-tab-content.tsx: focused on rendering comments tab as described in (7), using the new hook and endpoints.\n • web/components/contract/bets-tab-content.tsx: renders the trades tab items (bets, grouped multi-numeric bets, and visible LPs) with an optional minimum amount filter and infinite loading of older bets.\n- Update imports in files that used to import from contract-tabs to import the new components instead (e.g., comments-button.tsx and trades-button.tsx).\n- Keep ContractTabs lightweight and route to these split components.\n\n9) Observability and behavior\n- Ensure CommentsTabContent shows pinned comments above threads, preserves existing sort options and tracking events, and uses a VisibilityObserver to call loadMore while not loading. \n- Ensure BetsTabContent filters bets by minimum amount client-side for initial bets and server-side for older bet loads, shows skeleton rows while loading more, and integrates existing liquidity display items.\n\n10) Keep all type usage consistent with existing common types (Contract, ContractComment, Bet, etc.) and reuse convertContractComment where needed. Do not import web code from backend or vice versa.\n",
492
- "prompt": "Add infinite scroll for contract comments by loading threads in pages and fetching a specific thread context for highlighted links. Provide two backend GET endpoints to support this (one to fetch a page of parent comments with their replies, and one to fetch a single thread and a few neighboring threads), wire them up in the routes and API schema, and implement the corresponding Supabase queries. On the frontend, create a hook that loads comment threads page-by-page using the API and refactor the comments tab to use this hook with a visibility observer to load more as the user scrolls. Keep pinned comments, sorting (best/newest and yes/no bet sorts), and real-time new comment behavior intact. Split the large tab content into separate comments and bets components, and update existing imports. Also fix the pinned comments filter so it correctly handles the string boolean and non-deleted records.",
493
- "supplementalFiles": [
494
- "web/lib/api/api.ts",
495
- "web/lib/supabase/comments.ts",
496
- "web/components/comments/comment-thread.tsx",
497
- "web/components/comments/comment.tsx",
498
- "web/components/widgets/visibility-observer.tsx",
499
- "web/pages/[username]/[contractSlug].tsx",
500
- "backend/api/src/get-comments.ts",
501
- "backend/shared/src/supabase/init.ts",
502
- "backend/shared/src/websockets/helpers.ts",
503
- "common/comment.ts"
504
- ],
505
- "fileDiffs": [
506
- {
507
- "path": "backend/api/src/get-comment-threads.ts",
508
- "status": "added",
509
- "diff": "Index: backend/api/src/get-comment-threads.ts\n===================================================================\n--- backend/api/src/get-comment-threads.ts\t0b8bb48 (parent)\n+++ backend/api/src/get-comment-threads.ts\t0b36b52 (commit)\n@@ -1,1 +1,24 @@\n-[NEW FILE]\n\\n+import {\n+ getCommentThreads,\n+ getCommentThread as getCommentThreadSupabase,\n+} from 'shared/supabase/contract-comments'\n+import { createSupabaseDirectClient } from 'shared/supabase/init'\n+import { type APIHandler } from './helpers/endpoint'\n+\n+export const getContractCommentThreads: APIHandler<'comment-threads'> = async (\n+ props\n+) => {\n+ const { contractId, limit, page } = props\n+ const pg = createSupabaseDirectClient()\n+ return await getCommentThreads(pg, {\n+ contractId,\n+ limit: limit ?? 50,\n+ page: page ?? 0,\n+ })\n+}\n+\n+export const getCommentThread: APIHandler<'comment-thread'> = async (props) => {\n+ const { contractId, commentId } = props\n+ const pg = createSupabaseDirectClient()\n+ return await getCommentThreadSupabase(pg, commentId, contractId)\n+}\n"
510
- },
511
- {
512
- "path": "backend/api/src/routes.ts",
513
- "status": "modified",
514
- "diff": "Index: backend/api/src/routes.ts\n===================================================================\n--- backend/api/src/routes.ts\t0b8bb48 (parent)\n+++ backend/api/src/routes.ts\t0b36b52 (commit)\n@@ -1,182 +1,186 @@\n-import { updateMe } from './update-me'\n-import { placeBet } from './place-bet'\n-import { cancelBet } from './cancel-bet'\n-import { sellShares } from './sell-shares'\n-import { createMarket } from './create-market'\n-import { createComment } from './create-comment'\n-import { resolveMarket } from './resolve-market'\n-import { closeMarket } from './close-market'\n-import { getMe } from './get-me'\n-import { saveTwitchCredentials } from './save-twitch-credentials'\n-import { addLiquidity } from './add-liquidity'\n-import { removeLiquidity } from './remove-liquidity'\n-import { searchGroups, searchMyGroups } from './search-groups'\n-import { awardBounty } from './award-bounty'\n-import { addBounty } from './add-bounty'\n-import { createAnswerCPMM } from './create-answer-cpmm'\n-import { managram } from './managram'\n-import { setnews } from './set-news'\n-import { getDashboardFromSlug } from './get-dashboard-from-slug'\n-import { unresolve } from './unresolve'\n-import { updateMarket } from 'api/update-market'\n-import { type APIPath } from 'common/api/schema'\n-import { getMarkets } from 'api/markets'\n-import { hideComment } from './hide-comment'\n-import { pinComment } from './pin-comment'\n-import { getManagrams } from './get-managrams'\n-import { getGroups } from './get-groups'\n-import { getComments } from './get-comments'\n-import { getBetPointsBetween, getBets } from './get-bets'\n-import { getLiteUser, getUser } from './get-user'\n-import { getUsers } from './get-users'\n-import { getUserBalancesByIds, getUsersByIds } from './get-users-by-ids'\n-import { getMarket } from './get-market'\n-import { getMarketProb } from './get-market-prob'\n-import { getMarketProbs } from './get-market-probs'\n-import { getGroup } from './get-group'\n-import { getPositions } from './get-positions'\n-import { getLeagues } from './get-leagues'\n-import { getContract } from './get-contract'\n-import { getSingleAnswer } from './get-answer'\n-import { getContractAnswers } from './get-contract-answers'\n-import { addOrRemoveTopicFromContract } from './add-topic-to-market'\n-import { addOrRemoveTopicFromTopic } from './add-topic-to-topic'\n-import { searchUsers } from './search-users'\n-import { searchMarketsLite, searchMarketsFull } from './search-contracts'\n-import { post } from 'api/post'\n-import { fetchLinkPreview } from './fetch-link-preview'\n-import { type APIHandler } from './helpers/endpoint'\n-import { requestLoan } from 'api/request-loan'\n-import { getHeadlines, getPoliticsHeadlines } from './get-headlines'\n-import { getBoostAnalytics } from 'api/get-boost-analytics'\n-import { addOrRemoveReaction } from './reaction'\n-import { createManalink } from './create-manalink'\n-import { unlistAndCancelUserContracts } from './unlist-and-cancel-user-contracts'\n-import { getGroupsWithTopContracts } from 'api/get-topics-with-markets'\n-import { getBalanceChanges } from 'api/get-balance-changes'\n-import { placeMultiBet } from 'api/place-multi-bet'\n-import { getPartnerStats } from './get-partner-stats'\n-import { getSeenMarketIds } from 'api/get-seen-market-ids'\n-import { recordContractView } from 'api/record-contract-view'\n import { createPublicChatMessage } from 'api/create-public-chat-message'\n-import { getFollowedGroups } from './get-followed-groups'\n-import { getUniqueBetGroupCount } from 'api/get-unique-bet-groups'\n-import { deleteGroup } from './delete-group'\n-import { recordContractInteraction } from 'api/record-contract-interaction'\n-import { getUserPortfolio } from './get-user-portfolio'\n import { createuser } from 'api/create-user'\n-import { verifyPhoneNumber } from 'api/verify-phone-number'\n-import { requestOTP } from 'api/request-phone-otp'\n-import { multiSell } from 'api/multi-sell'\n-import { convertCashToMana } from './convert-cash-to-mana'\n-import { convertSpiceToMana } from './convert-sp-to-mana'\n-import { donate } from './donate'\n+import { getBalanceChanges } from 'api/get-balance-changes'\n+import { getBestComments } from 'api/get-best-comments'\n+import { getBoostAnalytics } from 'api/get-boost-analytics'\n import { getFeed } from 'api/get-feed'\n-import { getManaSupply } from './get-mana-supply'\n-import { getUserPortfolioHistory } from './get-user-portfolio-history'\n-import { deleteMe } from './delete-me'\n-import { updateModReport } from './update-mod-report'\n-import { getModReports } from './get-mod-reports'\n-import { searchContractPositions } from 'api/search-contract-positions'\n-import { blockUser, unblockUser } from './block-user'\n-import { blockGroup, unblockGroup } from './block-group'\n-import { blockMarket, unblockMarket } from './block-market'\n-import { getTxnSummaryStats } from 'api/get-txn-summary-stats'\n+import { getInterestingGroupsFromViews } from 'api/get-interesting-groups-from-views'\n import { getManaSummaryStats } from 'api/get-mana-summary-stats'\n-import { register } from 'api/gidx/register'\n-import { uploadDocument } from 'api/gidx/upload-document'\n-import { getVerificationStatus } from 'api/gidx/get-verification-status'\n-import { getCurrentPrivateUser } from './get-current-private-user'\n-import { updatePrivateUser } from './update-private-user'\n-import { setPushToken } from './push-token'\n-import { updateNotifSettings } from './update-notif-settings'\n-import { getVerificationDocuments } from 'api/gidx/get-verification-documents'\n-import { getMonitorStatus } from 'api/gidx/get-monitor-status'\n-import { getBestComments } from 'api/get-best-comments'\n-import { recordCommentView } from 'api/record-comment-view'\n+import { getNotifications } from 'api/get-notifications'\n import {\n getChannelMemberships,\n getChannelMessages,\n getLastSeenChannelTime,\n setChannelLastSeenTime,\n } from 'api/get-private-messages'\n-import { getNotifications } from 'api/get-notifications'\n-import { getCheckoutSession } from 'api/gidx/get-checkout-session'\n-import { completeCheckoutSession } from 'api/gidx/complete-checkout-session'\n-import { getContractTopics } from './get-contract-topics'\n-import { getRelatedMarkets } from './get-related-markets'\n-import { getRelatedMarketsByGroup } from './get-related-markets-by-group'\n-import { followContract } from './follow-contract'\n+import { getSeenMarketIds } from 'api/get-seen-market-ids'\n+import { getGroupsWithTopContracts } from 'api/get-topics-with-markets'\n+import { getTxnSummaryStats } from 'api/get-txn-summary-stats'\n+import { getUniqueBetGroupCount } from 'api/get-unique-bet-groups'\n import { getUserLimitOrdersWithContracts } from 'api/get-user-limit-orders-with-contracts'\n-import { getInterestingGroupsFromViews } from 'api/get-interesting-groups-from-views'\n import { completeCashoutSession } from 'api/gidx/complete-cashout-session'\n+import { completeCheckoutSession } from 'api/gidx/complete-checkout-session'\n+import { getCheckoutSession } from 'api/gidx/get-checkout-session'\n+import { getMonitorStatus } from 'api/gidx/get-monitor-status'\n+import { getVerificationDocuments } from 'api/gidx/get-verification-documents'\n+import { getVerificationStatus } from 'api/gidx/get-verification-status'\n+import { register } from 'api/gidx/register'\n+import { uploadDocument } from 'api/gidx/upload-document'\n+import { getMarkets } from 'api/markets'\n+import { multiSell } from 'api/multi-sell'\n+import { placeMultiBet } from 'api/place-multi-bet'\n+import { post } from 'api/post'\n+import { recordCommentView } from 'api/record-comment-view'\n+import { recordContractInteraction } from 'api/record-contract-interaction'\n+import { recordContractView } from 'api/record-contract-view'\n+import { requestLoan } from 'api/request-loan'\n+import { requestOTP } from 'api/request-phone-otp'\n+import { searchContractPositions } from 'api/search-contract-positions'\n+import { updateMarket } from 'api/update-market'\n+import { verifyPhoneNumber } from 'api/verify-phone-number'\n+import { type APIPath } from 'common/api/schema'\n+import { addBounty } from './add-bounty'\n+import { addLiquidity } from './add-liquidity'\n+import { addOrRemoveTopicFromContract } from './add-topic-to-market'\n+import { addOrRemoveTopicFromTopic } from './add-topic-to-topic'\n+import { awardBounty } from './award-bounty'\n+import { blockGroup, unblockGroup } from './block-group'\n+import { blockMarket, unblockMarket } from './block-market'\n+import { blockUser, unblockUser } from './block-user'\n+import { cancelBet } from './cancel-bet'\n+import { checkSportsEvent } from './check-sports-event'\n+import { closeMarket } from './close-market'\n+import { convertCashToMana } from './convert-cash-to-mana'\n+import { convertSpiceToMana } from './convert-sp-to-mana'\n+import { createAnswerCPMM } from './create-answer-cpmm'\n+import { createComment } from './create-comment'\n+import { createManalink } from './create-manalink'\n+import { createMarket } from './create-market'\n+import { deleteGroup } from './delete-group'\n+import { deleteMe } from './delete-me'\n+import { donate } from './donate'\n+import { fetchLinkPreview } from './fetch-link-preview'\n+import { followContract } from './follow-contract'\n+import { generateAIAnswers } from './generate-ai-answers'\n+import { generateAIDescription } from './generate-ai-description'\n+import { generateAIMarketSuggestions } from './generate-ai-market-suggestions'\n+import { getSingleAnswer } from './get-answer'\n+import { getBetPointsBetween, getBets } from './get-bets'\n import { getCashouts } from './get-cashouts'\n-import { getTxns } from './get-txns'\n-import { refreshAllClients } from './refresh-all-clients'\n-import { getLeaderboard } from './get-leaderboard'\n-import { toggleSystemTradingStatus } from './toggle-system-status'\n-import { completeCashoutRequest } from './gidx/complete-cashout-request'\n+import {\n+ getCommentThread,\n+ getContractCommentThreads,\n+} from './get-comment-threads'\n+import { getComments, getUserComments } from './get-comments'\n+import { getContract } from './get-contract'\n+import { getContractAnswers } from './get-contract-answers'\n+import { getContractTopics } from './get-contract-topics'\n+import { getCurrentPrivateUser } from './get-current-private-user'\n import { getDailyChangedMetricsAndContracts } from './get-daily-changed-metrics-and-contracts'\n+import { getDashboardFromSlug } from './get-dashboard-from-slug'\n+import { getFollowedGroups } from './get-followed-groups'\n+import { getGroup } from './get-group'\n+import { getGroups } from './get-groups'\n+import { getHeadlines, getPoliticsHeadlines } from './get-headlines'\n+import { getLeaderboard } from './get-leaderboard'\n+import { getLeagues } from './get-leagues'\n+import { getManaSupply } from './get-mana-supply'\n+import { getManagrams } from './get-managrams'\n+import { getMarket } from './get-market'\n+import { getMarketProb } from './get-market-prob'\n+import { getMarketProbs } from './get-market-probs'\n import { getMarketsByIds } from './get-markets'\n-import { getTopicTopics } from './get-topic-topics'\n-import { getTopicDashboards } from './get-topic-dashboards'\n-import { generateAIMarketSuggestions } from './generate-ai-market-suggestions'\n-import { generateAIDescription } from './generate-ai-description'\n-import { generateAIAnswers } from './generate-ai-answers'\n-import { getmonthlybets2024 } from './get-monthly-bets-2024'\n import { getmaxminprofit2024 } from './get-max-min-profit-2024'\n+import { getMe } from './get-me'\n+import { getModReports } from './get-mod-reports'\n+import { getmonthlybets2024 } from './get-monthly-bets-2024'\n import { getNextLoanAmount } from './get-next-loan-amount'\n-import { checkSportsEvent } from './check-sports-event'\n+import { getPartnerStats } from './get-partner-stats'\n+import { getPositions } from './get-positions'\n+import { getRelatedMarkets } from './get-related-markets'\n+import { getRelatedMarketsByGroup } from './get-related-markets-by-group'\n+import { getTopicDashboards } from './get-topic-dashboards'\n+import { getTopicTopics } from './get-topic-topics'\n+import { getTxns } from './get-txns'\n+import { getLiteUser, getUser } from './get-user'\n+import { getUserPortfolio } from './get-user-portfolio'\n+import { getUserPortfolioHistory } from './get-user-portfolio-history'\n+import { getUsers } from './get-users'\n+import { getUserBalancesByIds, getUsersByIds } from './get-users-by-ids'\n+import { completeCashoutRequest } from './gidx/complete-cashout-request'\n+import { type APIHandler } from './helpers/endpoint'\n+import { hideComment } from './hide-comment'\n+import { managram } from './managram'\n+import { pinComment } from './pin-comment'\n+import { placeBet } from './place-bet'\n+import { setPushToken } from './push-token'\n+import { addOrRemoveReaction } from './reaction'\n+import { refreshAllClients } from './refresh-all-clients'\n+import { removeLiquidity } from './remove-liquidity'\n+import { resolveMarket } from './resolve-market'\n+import { saveTwitchCredentials } from './save-twitch-credentials'\n+import { searchMarketsFull, searchMarketsLite } from './search-contracts'\n+import { searchGroups, searchMyGroups } from './search-groups'\n+import { searchUsers } from './search-users'\n+import { sellShares } from './sell-shares'\n+import { setnews } from './set-news'\n+import { toggleSystemTradingStatus } from './toggle-system-status'\n+import { unlistAndCancelUserContracts } from './unlist-and-cancel-user-contracts'\n+import { unresolve } from './unresolve'\n+import { updateMe } from './update-me'\n+import { updateModReport } from './update-mod-report'\n+import { updateNotifSettings } from './update-notif-settings'\n+import { updatePrivateUser } from './update-private-user'\n \n-import { createTask } from './create-task'\n-import { updateTask } from './update-task'\n import { createCategory } from './create-category'\n+import { createTask } from './create-task'\n import { getCategories } from './get-categories'\n-import { updateCategory } from './update-category'\n import { getTasks } from './get-tasks'\n+import { updateCategory } from './update-category'\n+import { updateTask } from './update-task'\n \n-import { getSiteActivity } from './get-site-activity'\n-import { isSportsInterested } from './is-sports-bettor'\n-import { getSportsGames } from './get-sports-games'\n-import { getMarketProps } from './get-market-props'\n-import { getUserContractMetricsWithContracts } from './get-user-contract-metrics-with-contracts'\n-import { validateiap } from './validate-iap'\n-import { getReactions } from './get-reactions'\n-import { markallnotificationsnew } from './mark-all-notifications-new'\n+import { createPost } from './create-post'\n+import { createPostComment } from './create-post-comment'\n+import { dismissUserReport } from './dismiss-user-report'\n+import { editPostComment, updatePostComment } from './edit-post-comment'\n+import { followPost } from './follow-post'\n import {\n- getContractOptionVoters,\n- getContractVoters,\n-} from './get-contract-voters'\n-import { purchaseContractBoost } from './purchase-boost'\n+ generateAIDateRanges,\n+ regenerateDateMidpoints,\n+} from './generate-ai-date-ranges'\n import {\n generateAINumericRanges,\n regenerateNumericMidpoints,\n } from './generate-ai-numeric-ranges'\n-import {\n- generateAIDateRanges,\n- regenerateDateMidpoints,\n-} from './generate-ai-date-ranges'\n-import { inferNumericUnit } from './infer-numeric-unit'\n import { generateConciseTitle } from './generate-concise-title'\n import { getCloseDateEndpoint } from './get-close-date'\n-import { referUser } from './refer-user'\n import {\n- saveMarketDraft,\n- getMarketDrafts,\n- deleteMarketDraft,\n-} from './market-drafts'\n+ getContractOptionVoters,\n+ getContractVoters,\n+} from './get-contract-voters'\n+import { getMarketProps } from './get-market-props'\n+import { getPosts } from './get-posts'\n+import { getReactions } from './get-reactions'\n import { getSeasonInfo } from './get-season-info'\n+import { getSiteActivity } from './get-site-activity'\n+import { getSportsGames } from './get-sports-games'\n+import { getUserContractMetricsWithContracts } from './get-user-contract-metrics-with-contracts'\n+import { getUserLastActiveTime } from './get-user-last-active-time'\n+import { inferNumericUnit } from './infer-numeric-unit'\n+import { isSportsInterested } from './is-sports-bettor'\n import { markNotificationRead } from './mark-all-notifications'\n-import { createPostComment } from './create-post-comment'\n-import { createPost } from './create-post'\n+import { markallnotificationsnew } from './mark-all-notifications-new'\n+import {\n+ deleteMarketDraft,\n+ getMarketDrafts,\n+ saveMarketDraft,\n+} from './market-drafts'\n+import { purchaseContractBoost } from './purchase-boost'\n+import { referUser } from './refer-user'\n import { updatePost } from './update-post'\n-import { getPosts } from './get-posts'\n-import { dismissUserReport } from './dismiss-user-report'\n-import { followPost } from './follow-post'\n-import { editPostComment, updatePostComment } from './edit-post-comment'\n-import { getUserComments } from './get-comments'\n-import { getUserLastActiveTime } from './get-user-last-active-time'\n+import { validateiap } from './validate-iap'\n+\n export const handlers: { [k in APIPath]: APIHandler<k> } = {\n 'refresh-all-clients': refreshAllClients,\n bet: placeBet,\n 'multi-bet': placeMultiBet,\n@@ -191,8 +195,10 @@\n 'get-channel-seen-time': getLastSeenChannelTime,\n 'set-channel-seen-time': setChannelLastSeenTime,\n 'get-contract': getContract,\n comment: createComment,\n+ 'comment-threads': getContractCommentThreads,\n+ 'comment-thread': getCommentThread,\n 'hide-comment': hideComment,\n 'pin-comment': pinComment,\n comments: getComments,\n market: createMarket,\n"
515
- },
516
- {
517
- "path": "backend/shared/src/supabase/contract-comments.ts",
518
- "status": "modified",
519
- "diff": "Index: backend/shared/src/supabase/contract-comments.ts\n===================================================================\n--- backend/shared/src/supabase/contract-comments.ts\t0b8bb48 (parent)\n+++ backend/shared/src/supabase/contract-comments.ts\t0b36b52 (commit)\n@@ -1,9 +1,9 @@\n-import { convertContractComment } from 'common/supabase/comments'\n-import { SupabaseDirectClient } from 'shared/supabase/init'\n import { APIError } from 'common/api/utils'\n-import { millisToTs } from 'common/supabase/utils'\n import { ContractComment, PostComment } from 'common/comment'\n+import { convertContractComment } from 'common/supabase/comments'\n+import { millisToTs } from 'common/supabase/utils'\n+import { SupabaseDirectClient } from 'shared/supabase/init'\n \n export async function getCommentSafe(\n pg: SupabaseDirectClient,\n commentId: string\n@@ -26,8 +26,145 @@\n }\n return comment\n }\n \n+export async function getCommentThreads(\n+ pg: SupabaseDirectClient,\n+ filters: {\n+ contractId: string\n+ limit: number\n+ page: number\n+ }\n+) {\n+ const { contractId, limit, page } = filters\n+\n+ const allComments = await pg.map(\n+ `\n+ with parent_comments as (\n+ select cc.data, cc.likes, cc.comment_id from contract_comments cc\n+ where cc.contract_id = $1\n+ and (cc.data->>'replyToCommentId' is null)\n+ and (cc.data->>'deleted' is null or cc.data->>'deleted' = 'false')\n+ order by cc.created_time desc\n+ limit $2\n+ offset $3\n+ )\n+ select * from parent_comments\n+ union all\n+ select cc.data, cc.likes, cc.comment_id from contract_comments cc\n+ where cc.contract_id = $1\n+ and (cc.data->>'replyToCommentId' in (select comment_id from parent_comments))\n+ and (cc.data->>'deleted' is null or cc.data->>'deleted' = 'false')\n+ `,\n+ [contractId, limit, page * limit],\n+ convertContractComment\n+ )\n+\n+ const parentComments = allComments.filter((c) => !c.replyToCommentId)\n+ const replyComments = allComments.filter((c) => c.replyToCommentId)\n+ console.log('parentComments', parentComments.length)\n+ console.log('replyComments', replyComments.length)\n+\n+ return { parentComments, replyComments }\n+}\n+\n+export async function getCommentThread(\n+ pg: SupabaseDirectClient,\n+ commentId: string,\n+ contractId: string\n+) {\n+ const comment = await pg.oneOrNone(\n+ `\n+ select cc.data, cc.likes from contract_comments cc\n+ where cc.comment_id = $1 and cc.contract_id = $2\n+ `,\n+ [commentId, contractId],\n+ convertContractComment\n+ )\n+ if (!comment) {\n+ return {\n+ parentComment: null,\n+ replyComments: [],\n+ parentComments: [],\n+ nextParentComments: [],\n+ nextReplyComments: [],\n+ }\n+ }\n+\n+ const parentId = comment.replyToCommentId ?? comment.id\n+ const parentComment =\n+ comment.replyToCommentId && comment.replyToCommentId !== comment.id\n+ ? await pg.oneOrNone(\n+ `\n+ select cc.data, cc.likes from contract_comments cc\n+ where cc.comment_id = $1 and cc.contract_id = $2\n+ `,\n+ [parentId, contractId],\n+ convertContractComment\n+ )\n+ : comment\n+\n+ if (!parentComment) {\n+ return {\n+ parentComment: null,\n+ replyComments: [],\n+ parentComments: [],\n+ nextParentComments: [],\n+ nextReplyComments: [],\n+ }\n+ }\n+\n+ const results = await pg.multi(\n+ `\n+ select cc.data, cc.likes from contract_comments cc\n+ where cc.contract_id = $1\n+ and (cc.data->>'replyToCommentId' = $2)\n+ and (cc.data->>'deleted' is null or cc.data->>'deleted' = 'false')\n+ order by cc.created_time asc;\n+ select cc.data, cc.likes from contract_comments cc\n+ where cc.contract_id = $1\n+ and (cc.data->>'replyToCommentId' is null)\n+ and (cc.data->>'deleted' is null or cc.data->>'deleted' = 'false')\n+ and cc.created_time > $3\n+ order by cc.created_time desc;\n+ select cc.data, cc.likes from contract_comments cc\n+ where cc.contract_id = $1\n+ and (cc.data->>'replyToCommentId' is null)\n+ and (cc.data->>'deleted' is null or cc.data->>'deleted' = 'false')\n+ and cc.created_time < $3\n+ order by cc.created_time desc\n+ limit 3;\n+ `,\n+ [contractId, parentId, millisToTs(parentComment.createdTime)]\n+ )\n+ const replyComments = results[0].map(convertContractComment)\n+ const parentComments = results[1].map(convertContractComment)\n+ const nextParentComments = results[2].map(convertContractComment)\n+\n+ const nextReplyComments =\n+ nextParentComments.length > 0\n+ ? await pg.map(\n+ `\n+ select cc.data, cc.likes from contract_comments cc\n+ where cc.contract_id = $1\n+ and (cc.data->>'replyToCommentId' in ($2:csv))\n+ and (cc.data->>'deleted' is null or cc.data->>'deleted' = 'false')\n+ order by cc.created_time asc\n+ `,\n+ [contractId, nextParentComments.map((c) => c.id)],\n+ convertContractComment\n+ )\n+ : []\n+\n+ return {\n+ parentComment,\n+ replyComments,\n+ parentComments,\n+ nextParentComments,\n+ nextReplyComments,\n+ }\n+}\n+\n export async function getCommentsDirect(\n pg: SupabaseDirectClient,\n filters: {\n userId?: string\n"
520
- },
521
- {
522
- "path": "common/src/api/schema.ts",
523
- "status": "modified",
524
- "diff": "Index: common/src/api/schema.ts\n===================================================================\n--- common/src/api/schema.ts\t0b8bb48 (parent)\n+++ common/src/api/schema.ts\t0b36b52 (commit)\n@@ -951,8 +951,43 @@\n order: z.enum(['shares', 'profit']).optional(),\n })\n .strict(),\n },\n+ 'comment-threads': {\n+ method: 'GET',\n+ visibility: 'public',\n+ authed: false,\n+ props: z\n+ .object({\n+ contractId: z.string(),\n+ limit: z.coerce.number().gte(0).lte(100).default(10),\n+ page: z.coerce.number().gte(0).default(0),\n+ })\n+ .strict(),\n+ cache: DEFAULT_CACHE_STRATEGY,\n+ returns: {} as {\n+ replyComments: ContractComment[]\n+ parentComments: ContractComment[]\n+ },\n+ },\n+ 'comment-thread': {\n+ method: 'GET',\n+ visibility: 'public',\n+ authed: false,\n+ props: z\n+ .object({\n+ contractId: z.string(),\n+ commentId: z.string(),\n+ })\n+ .strict(),\n+ returns: {} as {\n+ parentComment: ContractComment | null\n+ replyComments: ContractComment[]\n+ parentComments: ContractComment[]\n+ nextParentComments: ContractComment[]\n+ nextReplyComments: ContractComment[]\n+ },\n+ },\n me: {\n method: 'GET',\n visibility: 'public',\n authed: true,\n"
525
- },
526
- {
527
- "path": "common/src/supabase/comments.ts",
528
- "status": "modified",
529
- "diff": "Index: common/src/supabase/comments.ts\n===================================================================\n--- common/src/supabase/comments.ts\t0b8bb48 (parent)\n+++ common/src/supabase/comments.ts\t0b36b52 (commit)\n@@ -60,10 +60,10 @@\n db\n .from('contract_comments')\n .select('data')\n .eq('contract_id', contractId)\n- .eq('data->>pinned', true)\n- .not('data->>deleted', 'eq', 'true')\n+ .eq('data->>pinned', 'true')\n+ .or('data->>deleted.eq.false,data->>deleted.is.null')\n .order('created_time', { ascending: false })\n )\n \n const pinnedComments = data.map((c) => c.data as ContractComment)\n"
530
- },
531
- {
532
- "path": "web/components/comments/comments-button.tsx",
533
- "status": "modified",
534
- "diff": "Index: web/components/comments/comments-button.tsx\n===================================================================\n--- web/components/comments/comments-button.tsx\t0b8bb48 (parent)\n+++ web/components/comments/comments-button.tsx\t0b36b52 (commit)\n@@ -3,9 +3,8 @@\n import clsx from 'clsx'\n import { Contract } from 'common/contract'\n import { Modal, SCROLLABLE_MODAL_CLASS } from '../layout/modal'\n import { Col } from '../layout/col'\n-import { CommentsTabContent } from '../contract/contract-tabs'\n import { usePrivateUser } from 'web/hooks/use-user'\n import { track } from 'web/lib/service/analytics'\n import { Tooltip } from '../widgets/tooltip'\n import { User } from 'common/user'\n@@ -14,8 +13,9 @@\n useNumContractComments,\n } from 'web/hooks/use-comments'\n import { Button } from 'web/components/buttons/button'\n import { Row } from '../layout/row'\n+import { CommentsTabContent } from 'web/components/contract/comments-tab-content'\n \n export function CommentsButton(props: {\n contract: Contract\n user: User | null | undefined\n"
535
- },
536
- {
537
- "path": "web/components/contract/bets-tab-content.tsx",
538
- "status": "added",
539
- "diff": "Index: web/components/contract/bets-tab-content.tsx\n===================================================================\n--- web/components/contract/bets-tab-content.tsx\t0b8bb48 (parent)\n+++ web/components/contract/bets-tab-content.tsx\t0b36b52 (commit)\n@@ -1,1 +1,234 @@\n-[NEW FILE]\n\\n+import { memo, useEffect, useRef, useState } from 'react'\n+import { Contract, CPMMNumericContract, MarketContract } from 'common/contract'\n+import { Bet } from 'common/bet'\n+import { usePersistentInMemoryState } from 'client-common/hooks/use-persistent-in-memory-state'\n+import { listenToOrderUpdates } from 'client-common/hooks/use-bets'\n+import { groupBy, minBy, sortBy, uniqBy } from 'lodash'\n+import { useLiquidity } from 'web/hooks/use-liquidity'\n+import {\n+ DEV_HOUSE_LIQUIDITY_PROVIDER_ID,\n+ HOUSE_LIQUIDITY_PROVIDER_ID,\n+} from 'common/antes'\n+import { useEvent } from 'client-common/hooks/use-event'\n+import { api } from 'web/lib/api/api'\n+import { Row } from 'web/components/layout/row'\n+import DropdownMenu from 'web/components/widgets/dropdown-menu'\n+import generateFilterDropdownItems from 'web/components/search/search-dropdown-helpers'\n+import { track } from 'web/lib/service/analytics'\n+import { ChevronDownIcon } from '@heroicons/react/solid'\n+import { Col } from 'web/components/layout/col'\n+import { FeedBet } from 'web/components/feed/feed-bets'\n+import { MultiNumericBetGroup } from 'web/components/feed/feed-multi-numeric-bet-group'\n+import { FeedLiquidity } from 'web/components/feed/feed-liquidity'\n+import { LoadMoreUntilNotVisible } from 'web/components/widgets/visibility-observer'\n+\n+export const BetsTabContent = memo(function BetsTabContent(props: {\n+ contract: Contract\n+ bets: Bet[]\n+ totalBets: number\n+ setReplyToBet?: (bet: Bet) => void\n+}) {\n+ const { contract, setReplyToBet, totalBets } = props\n+ const { outcomeType } = contract\n+ const [olderBets, setOlderBets] = useState<Bet[]>([])\n+\n+ const [minAmountFilterIndex, setMinAmountFilterIndex] =\n+ usePersistentInMemoryState(0, `bet-amount-filter-${contract.id}`)\n+ const isNumber = outcomeType === 'NUMBER'\n+\n+ // Min amount filter options\n+ const minAmountOptions = [\n+ { label: 'Any amount', value: undefined },\n+ { label: 'M$100+', value: 100 },\n+ { label: 'M$1,000+', value: 1000 },\n+ { label: 'M$10,000+', value: 10000 },\n+ ]\n+ const selectedMinAmount = minAmountOptions[minAmountFilterIndex].value\n+\n+ // Filter initial bets on client side, server will filter olderBets\n+ const filteredInitialBets = selectedMinAmount\n+ ? props.bets.filter((bet) => Math.abs(bet.amount) >= selectedMinAmount)\n+ : props.bets\n+\n+ const bets = [...filteredInitialBets, ...olderBets]\n+ listenToOrderUpdates(contract.id, setOlderBets, true)\n+\n+ const oldestBet = minBy(bets, (b) => b.createdTime)\n+\n+ const lps = useLiquidity(contract.id) ?? []\n+ const visibleLps = lps.filter(\n+ (l) =>\n+ !l.isAnte &&\n+ l.userId !== HOUSE_LIQUIDITY_PROVIDER_ID &&\n+ l.userId !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID &&\n+ l.amount > 0 &&\n+ !minAmountFilterIndex\n+ )\n+ const betsByBetGroupId = isNumber\n+ ? groupBy(bets, (bet) => bet.betGroupId ?? bet.id)\n+ : {}\n+ const groupedBets = Object.values(betsByBetGroupId)\n+\n+ const items = [\n+ ...(isNumber\n+ ? groupedBets.map((bets) => ({\n+ type: 'betGroup' as const,\n+ id: 'bets-tab-' + bets[0].betGroupId,\n+ bets,\n+ }))\n+ : bets.map((bet) => ({\n+ type: 'bet' as const,\n+ id: 'bets-tab-' + bet.id + '-' + 'false',\n+ bet,\n+ }))),\n+ ...visibleLps.map((lp) => ({\n+ type: 'liquidity' as const,\n+ id: lp.id,\n+ lp,\n+ })),\n+ ]\n+\n+ const totalItems = totalBets + visibleLps.length\n+ const totalLoadedItems = bets.length + visibleLps.length\n+\n+ const shouldLoadMore = totalLoadedItems < totalItems\n+ const [now] = useState(Date.now())\n+ const oldestBetTime = oldestBet?.createdTime ?? now\n+\n+ const loadMore = useEvent(async () => {\n+ if (!shouldLoadMore) return false\n+\n+ try {\n+ const newBets = await api('bets', {\n+ contractId: contract.id,\n+ beforeTime: oldestBetTime,\n+ limit: 50,\n+ filterRedemptions: !isNumber,\n+ includeZeroShareRedemptions: isNumber,\n+ minAmount: selectedMinAmount,\n+ })\n+\n+ if (newBets.length > 0) {\n+ setOlderBets((bets) => uniqBy([...bets, ...newBets], (b) => b.id))\n+ return true\n+ }\n+ return false\n+ } catch (err) {\n+ console.error(err)\n+ return false\n+ }\n+ })\n+ useEffect(() => {\n+ setOlderBets([])\n+ loadMore()\n+ }, [selectedMinAmount])\n+\n+ const allItems = sortBy(items, (item) =>\n+ item.type === 'bet'\n+ ? -item.bet.createdTime\n+ : item.type === 'liquidity'\n+ ? -item.lp.createdTime\n+ : item.type === 'betGroup'\n+ ? -item.bets[0].createdTime\n+ : undefined\n+ )\n+\n+ const scrollRef = useRef<HTMLDivElement>(null)\n+ const isCashContract = contract.token === 'CASH'\n+\n+ // Determine how many loading rows to show\n+ const numLoadingRows = shouldLoadMore\n+ ? Math.min(10, Math.max(0, totalBets - allItems.length))\n+ : 0\n+\n+ return (\n+ <>\n+ <div ref={scrollRef} />\n+\n+ {/* Minimum bet amount filter */}\n+ <Row className=\"mb-2\">\n+ <Row className=\"items-center gap-1\">\n+ <span className=\"text-ink-500 text-sm\">Min amount:</span>\n+ <DropdownMenu\n+ items={generateFilterDropdownItems(\n+ minAmountOptions.map((option, i) => ({\n+ label: option.label,\n+ value: i.toString(),\n+ })),\n+ (value: string) => {\n+ const newIndex = parseInt(value)\n+ setMinAmountFilterIndex(newIndex)\n+ setOlderBets([]) // Clear older bets to refetch with new filter\n+ track('change-bet-amount-filter', {\n+ contractSlug: contract.slug,\n+ contractName: contract.question,\n+ minAmount: minAmountOptions[newIndex].value,\n+ })\n+ }\n+ )}\n+ buttonContent={\n+ <Row className=\"text-ink-700 w-28 items-center text-sm\">\n+ <span className=\"whitespace-nowrap\">\n+ {minAmountOptions[minAmountFilterIndex].label}\n+ </span>\n+ <ChevronDownIcon className=\"h-4 w-4\" />\n+ </Row>\n+ }\n+ menuWidth={'w-36'}\n+ selectedItemName={minAmountOptions[minAmountFilterIndex].label}\n+ closeOnClick\n+ />\n+ </Row>\n+ </Row>\n+\n+ <Col className=\"mb-4 items-start gap-7\">\n+ {allItems.map((item) =>\n+ item.type === 'bet' ? (\n+ <FeedBet\n+ onReply={setReplyToBet}\n+ key={item.id}\n+ contract={contract as MarketContract}\n+ bet={item.bet}\n+ />\n+ ) : item.type === 'betGroup' ? (\n+ <MultiNumericBetGroup\n+ key={item.id}\n+ contract={contract as CPMMNumericContract}\n+ bets={item.bets}\n+ />\n+ ) : (\n+ <div\n+ key={item.id}\n+ className=\"-ml-2 rounded-full bg-gradient-to-r from-pink-300/50 via-purple-300/50 to-indigo-300/50 p-2\"\n+ >\n+ <FeedLiquidity\n+ liquidity={item.lp}\n+ isCashContract={isCashContract}\n+ />\n+ </div>\n+ )\n+ )}\n+ {/* Render skeleton loading rows */}\n+ {shouldLoadMore &&\n+ !minAmountFilterIndex &&\n+ Array(numLoadingRows)\n+ .fill(0)\n+ .map((_, i) => <LoadingBetRow key={`loading-${i}`} />)}\n+ </Col>\n+\n+ <LoadMoreUntilNotVisible loadMore={loadMore} />\n+ </>\n+ )\n+})\n+\n+function LoadingBetRow() {\n+ return (\n+ <div className=\"flex w-full animate-pulse gap-3 rounded-md \">\n+ {/* Avatar skeleton */}\n+ <div className=\"h-10 w-10 rounded-full bg-gray-500\" />\n+ <Col className=\"flex-1 justify-center gap-1.5\">\n+ <div className=\"h-4 w-1/2 rounded bg-gray-500\" />\n+ </Col>\n+ </div>\n+ )\n+}\n"
540
- },
541
- {
542
- "path": "web/components/contract/comments-tab-content.tsx",
543
- "status": "added",
544
- "diff": "Index: web/components/contract/comments-tab-content.tsx\n===================================================================\n--- web/components/contract/comments-tab-content.tsx\t0b8bb48 (parent)\n+++ web/components/contract/comments-tab-content.tsx\t0b36b52 (commit)\n@@ -1,1 +1,418 @@\n-[NEW FILE]\n\\n+import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'\n+import { useContractBets } from 'client-common/hooks/use-bets'\n+import { useSubscribeNewComments } from 'client-common/hooks/use-comments'\n+import { useEvent } from 'client-common/hooks/use-event'\n+import { usePersistentInMemoryState } from 'client-common/hooks/use-persistent-in-memory-state'\n+import clsx from 'clsx'\n+import { Answer } from 'common/answer'\n+import { Bet } from 'common/bet'\n+import { ContractComment } from 'common/comment'\n+import { Contract } from 'common/contract'\n+import { TRADE_TERM } from 'common/envs/constants'\n+import { buildArray } from 'common/util/array'\n+import { MINUTE_MS } from 'common/util/time'\n+import { groupBy, keyBy, mapValues, sortBy, sumBy, uniqBy } from 'lodash'\n+import { memo, useEffect, useMemo, useReducer, useRef, useState } from 'react'\n+import { Button } from 'web/components/buttons/button'\n+import { ParentFeedComment } from 'web/components/comments/comment'\n+import { ContractCommentInput } from 'web/components/comments/comment-input'\n+import { FeedCommentThread } from 'web/components/comments/comment-thread'\n+import { Col } from 'web/components/layout/col'\n+import { Row } from 'web/components/layout/row'\n+import generateFilterDropdownItems from 'web/components/search/search-dropdown-helpers'\n+import DropdownMenu from 'web/components/widgets/dropdown-menu'\n+import { LoadingIndicator } from 'web/components/widgets/loading-indicator'\n+import { Tooltip } from 'web/components/widgets/tooltip'\n+import { VisibilityObserver } from 'web/components/widgets/visibility-observer'\n+import { useCommentThreads } from 'web/hooks/use-comments'\n+import { useIsPageVisible } from 'web/hooks/use-page-visible'\n+import { useUser } from 'web/hooks/use-user'\n+import { api } from 'web/lib/api/api'\n+import { track } from 'web/lib/service/analytics'\n+\n+export const CommentsTabContent = memo(function CommentsTabContent(props: {\n+ staticContract: Contract // contains the comments\n+ liveContract: Contract // you trade on this\n+ comments: ContractComment[]\n+ blockedUserIds: string[]\n+ setCommentsLength?: (length: number) => void\n+ replyTo?: Answer | Bet\n+ clearReply?: () => void\n+ className?: string\n+ highlightCommentId?: string\n+ pinnedComments: ContractComment[]\n+ scrollToEnd?: boolean\n+}) {\n+ const {\n+ staticContract,\n+ liveContract,\n+ comments: staticComments,\n+ blockedUserIds,\n+ setCommentsLength,\n+ replyTo,\n+ clearReply,\n+ className,\n+ highlightCommentId,\n+ pinnedComments: staticPinnedComments,\n+ scrollToEnd,\n+ } = props\n+ const user = useUser()\n+\n+ const { threads, loadMore, loading } = useCommentThreads(\n+ staticContract.id,\n+ 10,\n+ !!highlightCommentId\n+ )\n+ const [highlightedThreads, setHighlightedThreads] = useState<\n+ { parent: ContractComment; replies: ContractComment[] }[]\n+ >([])\n+ const [isLoadingHighlighted, setIsLoadingHighlighted] = useState(false)\n+\n+ const bets = useContractBets(\n+ staticContract.id,\n+ {\n+ commentRepliesOnly: true,\n+ },\n+ useIsPageVisible,\n+ (params) => api('bets', params)\n+ )\n+\n+ const newComments = useSubscribeNewComments(staticContract.id)\n+\n+ const allComments = useMemo(() => {\n+ const dynamicComments = threads.flatMap((t) => [t.parent, ...t.replies])\n+ const highlightedComments = highlightedThreads.flatMap((t) => [\n+ t.parent,\n+ ...t.replies,\n+ ])\n+ return uniqBy(\n+ [\n+ ...(newComments ?? []),\n+ ...staticComments,\n+ ...dynamicComments,\n+ ...highlightedComments,\n+ ],\n+ 'id'\n+ ).filter((c) => !blockedUserIds.includes(c.userId))\n+ }, [newComments, staticComments, threads, highlightedThreads, blockedUserIds])\n+\n+ const commentExistsLocally = useMemo(\n+ () => allComments.some((c) => c.id === highlightCommentId),\n+ [allComments, highlightCommentId]\n+ )\n+\n+ const isLoadingHighlightedComment =\n+ !!highlightCommentId &&\n+ !commentExistsLocally &&\n+ (loading || isLoadingHighlighted)\n+\n+ useEffect(() => {\n+ if (highlightCommentId && !commentExistsLocally && !loading) {\n+ setIsLoadingHighlighted(true)\n+ api('comment-thread', {\n+ contractId: staticContract.id,\n+ commentId: highlightCommentId,\n+ }).then((res) => {\n+ const {\n+ parentComment,\n+ replyComments,\n+ parentComments,\n+ nextParentComments,\n+ nextReplyComments,\n+ } = res\n+ if (parentComment) {\n+ const newThreads = [\n+ { parent: parentComment, replies: replyComments },\n+ ...parentComments.map((p) => ({ parent: p, replies: [] })),\n+ ]\n+ if (nextParentComments) {\n+ const repliesByParent = groupBy(\n+ nextReplyComments,\n+ 'replyToCommentId'\n+ )\n+ nextParentComments.forEach((p) => {\n+ newThreads.push({\n+ parent: p,\n+ replies: repliesByParent[p.id] ?? [],\n+ })\n+ })\n+ }\n+ setHighlightedThreads(newThreads)\n+ }\n+ setIsLoadingHighlighted(false)\n+ })\n+ }\n+ }, [highlightCommentId, commentExistsLocally, loading])\n+\n+ const isBinary = staticContract.outcomeType === 'BINARY'\n+ const isBountiedQuestion = staticContract.outcomeType == 'BOUNTIED_QUESTION'\n+ const bestFirst =\n+ isBountiedQuestion &&\n+ (!user || user.id !== staticContract.creatorId) &&\n+ !staticContract.isAutoBounty\n+\n+ const sorts = buildArray(\n+ bestFirst ? 'Best' : 'Newest',\n+ bestFirst ? 'Newest' : 'Best',\n+ isBinary && `Yes bets`,\n+ isBinary && 'No bets'\n+ )\n+\n+ const [sortIndex, setSortIndex] = usePersistentInMemoryState(\n+ 0,\n+ `comments-sort-${staticContract.id}`\n+ )\n+ const sort = sorts[sortIndex]\n+\n+ const sortTooltip =\n+ sort === 'Best'\n+ ? isBountiedQuestion\n+ ? 'Highest bounty, then most likes'\n+ : 'Most likes first'\n+ : null\n+\n+ // replied to answers/comments are NOT newest, otherwise newest first\n+ const isReply = (c: ContractComment) => c.replyToCommentId !== undefined\n+\n+ const strictlySortedComments = sortBy(allComments, [\n+ sort === 'Best'\n+ ? (c) =>\n+ isReply(c)\n+ ? c.createdTime\n+ : // For your own recent comments, show first.\n+ c.createdTime > Date.now() - 10 * MINUTE_MS && c.userId === user?.id\n+ ? -Infinity\n+ : c.hidden\n+ ? Infinity\n+ : -(\n+ (c.bountyAwarded ?? 0) * 1000 +\n+ (c.likes ?? 0) -\n+ (c.dislikes ?? 0)\n+ )\n+ : sort === 'Yes bets'\n+ ? (c: ContractComment) => -(c.betReplyAmountsByOutcome?.['YES'] ?? 0)\n+ : sort === 'No bets'\n+ ? (c: ContractComment) => -(c.betReplyAmountsByOutcome?.['NO'] ?? 0)\n+ : // Newest\n+ (c) => c,\n+ (c) => (isReply(c) ? c.createdTime : c.hidden ? Infinity : -c.createdTime),\n+ ])\n+\n+ const commentsByParent = groupBy(\n+ strictlySortedComments,\n+ (c) => c.replyToCommentId ?? '_'\n+ )\n+\n+ const commentById = keyBy(allComments, 'id')\n+\n+ // lump comments on load/sort to prevent jumping\n+ const [frozenCommentIds, refreezeIds] = useReducer(\n+ () => strictlySortedComments.map((c) => c.id),\n+ strictlySortedComments.map((c) => c.id)\n+ )\n+ useEffect(() => {\n+ if (user) refreezeIds()\n+ }, [user?.id])\n+\n+ const firstOldCommentIndex = strictlySortedComments.findIndex((c) =>\n+ frozenCommentIds.includes(c.id)\n+ )\n+\n+ const sortedComments = [\n+ ...strictlySortedComments.slice(0, firstOldCommentIndex),\n+ // Lump the original comments in a contiguous chunk so they don't jump around.\n+ ...frozenCommentIds.map((id) => commentById[id]).filter(Boolean),\n+ ...strictlySortedComments\n+ .slice(firstOldCommentIndex)\n+ .filter((c) => !frozenCommentIds.includes(c.id)),\n+ ]\n+\n+ const parentComments = sortedComments.filter(\n+ (c) => c.replyToCommentId === undefined\n+ )\n+\n+ const childrensBounties = isBountiedQuestion\n+ ? mapValues(commentsByParent, (comments) =>\n+ sumBy(comments, (c) => c?.bountyAwarded ?? 0)\n+ )\n+ : {}\n+\n+ useEffect(() => {\n+ setCommentsLength?.(allComments.length)\n+ }, [allComments.length])\n+\n+ const pinnedComments = uniqBy(\n+ staticPinnedComments.concat(\n+ allComments.filter((comment) => comment.pinned)\n+ ),\n+ 'id'\n+ )\n+ const onVisibilityUpdated = useEvent((visible: boolean) => {\n+ if (visible && !loading) loadMore()\n+ })\n+\n+ const endOfMessagesRef = useRef<any>(null)\n+\n+ useEffect(() => {\n+ if (endOfMessagesRef && scrollToEnd)\n+ endOfMessagesRef.current?.scrollIntoView({\n+ behavior: 'auto',\n+ block: 'start',\n+ })\n+ }, [endOfMessagesRef])\n+\n+ const [expandGptSummary, setExpandGptSummary] = usePersistentInMemoryState(\n+ false,\n+ `expand-gpt-summary-${staticContract.id}`\n+ )\n+\n+ function getSortLabel(sort: string) {\n+ if (sort == 'Yes bets') return `Yes ${TRADE_TERM}s`\n+ if (sort == 'No bets') return `No ${TRADE_TERM}s`\n+ return sort\n+ }\n+\n+ return (\n+ <Col className={clsx(className, scrollToEnd && 'flex-col-reverse')}>\n+ <div ref={endOfMessagesRef} />\n+ <ContractCommentInput\n+ autoFocus={false}\n+ replyTo={replyTo}\n+ className=\"mb-4 mr-px mt-px\"\n+ playContract={staticContract}\n+ clearReply={clearReply}\n+ trackingLocation={'contract page'}\n+ commentTypes={['comment', 'repost']}\n+ />\n+\n+ {staticContract.gptCommentSummary && (\n+ <Button\n+ className=\"mb-2 rounded-md bg-teal-100 p-4\"\n+ size=\"xs\"\n+ color=\"none\"\n+ onClick={() => setExpandGptSummary((e) => !e)}\n+ >\n+ <Col className=\"gap-2 p-2\">\n+ <Row className=\"items-center gap-2\">\n+ {expandGptSummary ? (\n+ <ChevronUpIcon className=\"h-5 w-5\" />\n+ ) : (\n+ <ChevronDownIcon className=\"h-5 w-5\" />\n+ )}\n+ <div className=\"text-lg\">Comments summary</div>\n+ </Row>\n+ <div\n+ className={clsx(\n+ 'whitespace-pre-line text-left text-sm',\n+ !expandGptSummary && 'line-clamp-3'\n+ )}\n+ >\n+ {staticContract.gptCommentSummary}\n+ </div>\n+ </Col>\n+ </Button>\n+ )}\n+\n+ {allComments.length > 0 && (\n+ <Row className=\"justify-end\">\n+ <Tooltip text={sortTooltip}>\n+ <Row className=\"items-center gap-1\">\n+ <span className=\"text-ink-400 text-sm\">Sort by:</span>\n+ <DropdownMenu\n+ items={generateFilterDropdownItems(\n+ sorts.map((s, i) => ({\n+ label: getSortLabel(s),\n+ value: i + '',\n+ })),\n+ (value: string) => {\n+ const i = parseInt(value)\n+ setSortIndex(i)\n+ console.log(i)\n+ refreezeIds()\n+ track('change-comments-sort', {\n+ contractSlug: staticContract.slug,\n+ contractName: staticContract.question,\n+ totalComments: allComments.length,\n+ totalUniqueTraders: staticContract.uniqueBettorCount,\n+ })\n+ }\n+ )}\n+ buttonContent={\n+ <Row className=\"text-ink-600 w-20 items-center text-sm\">\n+ <span className=\"whitespace-nowrap\">\n+ {getSortLabel(sort)}\n+ </span>\n+ <ChevronDownIcon className=\"h-4 w-4\" />\n+ </Row>\n+ }\n+ menuWidth={'w-28'}\n+ selectedItemName={sort}\n+ closeOnClick\n+ />\n+ </Row>\n+ </Tooltip>\n+ </Row>\n+ )}\n+\n+ {pinnedComments.map((comment) => (\n+ <div key={comment.id} className={'pt-3'}>\n+ <ParentFeedComment\n+ comment={comment}\n+ playContract={staticContract}\n+ liveContract={liveContract}\n+ trackingLocation={'contract page'}\n+ seeReplies={false}\n+ numReplies={0}\n+ isPinned\n+ />\n+ </div>\n+ ))}\n+\n+ {isLoadingHighlightedComment ? (\n+ <Col className=\"h-32 items-center justify-center\">\n+ <LoadingIndicator />\n+ </Col>\n+ ) : (\n+ parentComments.map((parent) => (\n+ <FeedCommentThread\n+ key={parent.id}\n+ playContract={staticContract}\n+ liveContract={liveContract}\n+ parentComment={parent}\n+ threadComments={commentsByParent[parent.id] ?? []}\n+ trackingLocation={'contract page'}\n+ idInUrl={highlightCommentId}\n+ showReplies={\n+ !isBountiedQuestion ||\n+ (!!user && user.id === staticContract.creatorId)\n+ }\n+ childrenBountyTotal={\n+ staticContract.outcomeType == 'BOUNTIED_QUESTION'\n+ ? childrensBounties[parent.id]\n+ : undefined\n+ }\n+ bets={bets?.filter(\n+ (b: Bet) =>\n+ b.replyToCommentId &&\n+ [parent]\n+ .concat(commentsByParent[parent.id] ?? [])\n+ .map((c) => c.id)\n+ .includes(b.replyToCommentId)\n+ )}\n+ />\n+ ))\n+ )}\n+ <div className=\"relative w-full\">\n+ <VisibilityObserver\n+ onVisibilityUpdated={onVisibilityUpdated}\n+ className=\"pointer-events-none absolute bottom-0 h-[75vh]\"\n+ />\n+ </div>\n+ {loading && (\n+ <Col className=\"h-32 items-center justify-center\">\n+ <LoadingIndicator />\n+ </Col>\n+ )}\n+ </Col>\n+ )\n+})\n"
545
- },
546
- {
547
- "path": "web/components/contract/contract-tabs.tsx",
548
- "status": "modified",
549
- "diff": "Index: web/components/contract/contract-tabs.tsx\n===================================================================\n--- web/components/contract/contract-tabs.tsx\t0b8bb48 (parent)\n+++ web/components/contract/contract-tabs.tsx\t0b36b52 (commit)\n@@ -1,68 +1,19 @@\n-import {\n- groupBy,\n- keyBy,\n- minBy,\n- mapValues,\n- sortBy,\n- sumBy,\n- uniqBy,\n- maxBy,\n-} from 'lodash'\n-import { memo, useEffect, useMemo, useReducer, useRef, useState } from 'react'\n-import clsx from 'clsx'\n-import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'\n+import { useState } from 'react'\n \n import { Answer } from 'common/answer'\n-import {\n- DEV_HOUSE_LIQUIDITY_PROVIDER_ID,\n- HOUSE_LIQUIDITY_PROVIDER_ID,\n-} from 'common/antes'\n import { Bet } from 'common/bet'\n import { ContractComment } from 'common/comment'\n-import {\n- BinaryContract,\n- Contract,\n- CPMMNumericContract,\n- MarketContract,\n-} from 'common/contract'\n+import { BinaryContract, Contract } from 'common/contract'\n import { buildArray } from 'common/util/array'\n-import { shortFormatNumber, maybePluralize } from 'common/util/format'\n-import { MINUTE_MS } from 'common/util/time'\n+import { maybePluralize, shortFormatNumber } from 'common/util/format'\n+import { BetsTabContent } from 'web/components/contract/bets-tab-content'\n+import { CommentsTabContent } from 'web/components/contract/comments-tab-content'\n import { UserPositionsTable } from 'web/components/contract/user-positions-table'\n-import { LoadingIndicator } from 'web/components/widgets/loading-indicator'\n-import { Tooltip } from 'web/components/widgets/tooltip'\n-import {\n- VisibilityObserver,\n- LoadMoreUntilNotVisible,\n-} from 'web/components/widgets/visibility-observer'\n-import { useEvent } from 'client-common/hooks/use-event'\n-import { useLiquidity } from 'web/hooks/use-liquidity'\n-import { useUser } from 'web/hooks/use-user'\n+import { useHashInUrlPageRouter } from 'web/hooks/use-hash-in-url-page-router'\n import { track } from 'web/lib/service/analytics'\n-import { FeedBet } from '../feed/feed-bets'\n-import { FeedCommentThread } from '../comments/comment-thread'\n-import { ContractCommentInput } from '../comments/comment-input'\n-import { FeedLiquidity } from '../feed/feed-liquidity'\n import { Col } from '../layout/col'\n-import { Row } from '../layout/row'\n import { ControlledTabs } from '../layout/tabs'\n-import { usePersistentInMemoryState } from 'client-common/hooks/use-persistent-in-memory-state'\n-import { useSubscribeNewComments } from 'client-common/hooks/use-comments'\n-import { ParentFeedComment } from '../comments/comment'\n-import { useHashInUrlPageRouter } from 'web/hooks/use-hash-in-url-page-router'\n-import { MultiNumericBetGroup } from 'web/components/feed/feed-multi-numeric-bet-group'\n-import { Button } from '../buttons/button'\n-import DropdownMenu from '../widgets/dropdown-menu'\n-import generateFilterDropdownItems from '../search/search-dropdown-helpers'\n-import { useAPIGetter } from 'web/hooks/use-api-getter'\n-import { api } from 'web/lib/api/api'\n-import { TRADE_TERM } from 'common/envs/constants'\n-import {\n- listenToOrderUpdates,\n- useContractBets,\n-} from 'client-common/hooks/use-bets'\n-import { useIsPageVisible } from 'web/hooks/use-page-visible'\n \n export function ContractTabs(props: {\n staticContract: Contract\n liveContract: Contract\n@@ -175,580 +126,4 @@\n )}\n />\n )\n }\n-\n-const LOAD_MORE = 10\n-export const CommentsTabContent = memo(function CommentsTabContent(props: {\n- staticContract: Contract // contains the comments\n- liveContract: Contract // you trade on this\n- comments: ContractComment[]\n- blockedUserIds: string[]\n- setCommentsLength?: (length: number) => void\n- replyTo?: Answer | Bet\n- clearReply?: () => void\n- className?: string\n- highlightCommentId?: string\n- pinnedComments: ContractComment[]\n- scrollToEnd?: boolean\n-}) {\n- const {\n- staticContract,\n- liveContract,\n- blockedUserIds,\n- setCommentsLength,\n- replyTo,\n- clearReply,\n- className,\n- highlightCommentId,\n- scrollToEnd,\n- } = props\n- const user = useUser()\n-\n- // Load all comments once\n- const { data: fetchedComments, loading: commentsLoading } = useAPIGetter(\n- 'comments',\n- {\n- contractId: staticContract.id,\n- },\n- undefined,\n- 'comments-' + staticContract.id\n- )\n-\n- const bets = useContractBets(\n- staticContract.id,\n- {\n- commentRepliesOnly: true,\n- },\n- useIsPageVisible,\n- (params) => api('bets', params)\n- )\n- const latestCommentTime = useMemo(\n- () => maxBy(fetchedComments, 'createdTime')?.createdTime,\n- [fetchedComments?.length]\n- )\n-\n- const isPageVisible = useIsPageVisible()\n- const { data: newFetchedComments, loading: newCommentsLoading } =\n- useAPIGetter(\n- 'comments',\n- {\n- contractId: staticContract.id,\n- afterTime: latestCommentTime,\n- },\n- undefined,\n- 'new-comments-' + staticContract.id,\n- isPageVisible\n- )\n-\n- // Listen for new comments\n- const newComments = useSubscribeNewComments(staticContract.id)\n- const comments = uniqBy(\n- [\n- ...(newComments ?? []),\n- ...(newFetchedComments ?? []),\n- ...(fetchedComments ?? []),\n- ...props.comments,\n- ],\n- 'id'\n- ).filter((c) => !blockedUserIds.includes(c.userId))\n-\n- const commentExistsLocally = comments.some((c) => c.id === highlightCommentId)\n- const isLoadingHighlightedComment =\n- highlightCommentId &&\n- !commentExistsLocally &&\n- (commentsLoading || newCommentsLoading)\n-\n- const [parentCommentsToRender, setParentCommentsToRender] = useState(\n- props.comments.filter((c) => !c.replyToCommentId).length\n- )\n-\n- const isBinary = staticContract.outcomeType === 'BINARY'\n- const isBountiedQuestion = staticContract.outcomeType == 'BOUNTIED_QUESTION'\n- const bestFirst =\n- isBountiedQuestion &&\n- (!user || user.id !== staticContract.creatorId) &&\n- !staticContract.isAutoBounty\n-\n- const sorts = buildArray(\n- bestFirst ? 'Best' : 'Newest',\n- bestFirst ? 'Newest' : 'Best',\n- isBinary && `Yes bets`,\n- isBinary && 'No bets'\n- )\n-\n- const [sortIndex, setSortIndex] = usePersistentInMemoryState(\n- 0,\n- `comments-sort-${staticContract.id}`\n- )\n- const sort = sorts[sortIndex]\n-\n- const sortTooltip =\n- sort === 'Best'\n- ? isBountiedQuestion\n- ? 'Highest bounty, then most likes'\n- : 'Most likes first'\n- : null\n-\n- // replied to answers/comments are NOT newest, otherwise newest first\n- const isReply = (c: ContractComment) => c.replyToCommentId !== undefined\n-\n- const strictlySortedComments = sortBy(comments, [\n- sort === 'Best'\n- ? (c) =>\n- isReply(c)\n- ? c.createdTime\n- : // For your own recent comments, show first.\n- c.createdTime > Date.now() - 10 * MINUTE_MS && c.userId === user?.id\n- ? -Infinity\n- : c.hidden\n- ? Infinity\n- : -(\n- (c.bountyAwarded ?? 0) * 1000 +\n- (c.likes ?? 0) -\n- (c.dislikes ?? 0)\n- )\n- : sort === 'Yes bets'\n- ? (c: ContractComment) => -(c.betReplyAmountsByOutcome?.['YES'] ?? 0)\n- : sort === 'No bets'\n- ? (c: ContractComment) => -(c.betReplyAmountsByOutcome?.['NO'] ?? 0)\n- : // Newest\n- (c) => c,\n- (c) => (isReply(c) ? c.createdTime : c.hidden ? Infinity : -c.createdTime),\n- ])\n-\n- const commentsByParent = groupBy(\n- strictlySortedComments,\n- (c) => c.replyToCommentId ?? '_'\n- )\n-\n- const commentById = keyBy(comments, 'id')\n-\n- // lump comments on load/sort to prevent jumping\n- const [frozenCommentIds, refreezeIds] = useReducer(\n- () => strictlySortedComments.map((c) => c.id),\n- strictlySortedComments.map((c) => c.id)\n- )\n- useEffect(() => {\n- if (user) refreezeIds()\n- }, [user?.id])\n-\n- const firstOldCommentIndex = strictlySortedComments.findIndex((c) =>\n- frozenCommentIds.includes(c.id)\n- )\n-\n- const sortedComments = [\n- ...strictlySortedComments.slice(0, firstOldCommentIndex),\n- // Lump the original comments in a contiguous chunk so they don't jump around.\n- ...frozenCommentIds.map((id) => commentById[id]).filter(Boolean),\n- ...strictlySortedComments\n- .slice(firstOldCommentIndex)\n- .filter((c) => !frozenCommentIds.includes(c.id)),\n- ]\n-\n- const parentComments = sortedComments.filter(\n- (c) => c.replyToCommentId === undefined\n- )\n-\n- const childrensBounties = isBountiedQuestion\n- ? mapValues(commentsByParent, (comments) =>\n- sumBy(comments, (c) => c?.bountyAwarded ?? 0)\n- )\n- : {}\n-\n- const visibleCommentIds = useMemo(\n- () =>\n- parentComments\n- .slice(0, parentCommentsToRender)\n- .map((c) => [c.id, ...(commentsByParent[c.id] ?? []).map((c) => c.id)])\n- .flat(),\n- [comments.length]\n- )\n-\n- useEffect(() => {\n- if (highlightCommentId) {\n- const currentlyVisible = visibleCommentIds.includes(highlightCommentId)\n- if (!currentlyVisible) setParentCommentsToRender(comments.length)\n- }\n- setCommentsLength?.(comments.length)\n- }, [highlightCommentId, comments.length])\n-\n- const loadMore = () => setParentCommentsToRender((prev) => prev + LOAD_MORE)\n- const pinnedComments = uniqBy(\n- props.pinnedComments.concat(comments.filter((comment) => comment.pinned)),\n- 'id'\n- )\n- const onVisibilityUpdated = useEvent((visible: boolean) => {\n- if (visible) loadMore()\n- })\n-\n- const endOfMessagesRef = useRef<any>(null)\n-\n- useEffect(() => {\n- if (endOfMessagesRef && scrollToEnd)\n- endOfMessagesRef.current?.scrollIntoView({\n- behavior: 'auto',\n- block: 'start',\n- })\n- }, [endOfMessagesRef])\n-\n- const [expandGptSummary, setExpandGptSummary] = usePersistentInMemoryState(\n- false,\n- `expand-gpt-summary-${staticContract.id}`\n- )\n-\n- function getSortLabel(sort: string) {\n- if (sort == 'Yes bets') return `Yes ${TRADE_TERM}s`\n- if (sort == 'No bets') return `No ${TRADE_TERM}s`\n- return sort\n- }\n-\n- return (\n- <Col className={clsx(className, scrollToEnd && 'flex-col-reverse')}>\n- <div ref={endOfMessagesRef} />\n- <ContractCommentInput\n- autoFocus={false}\n- replyTo={replyTo}\n- className=\"mb-4 mr-px mt-px\"\n- playContract={staticContract}\n- clearReply={clearReply}\n- trackingLocation={'contract page'}\n- commentTypes={['comment', 'repost']}\n- />\n-\n- {staticContract.gptCommentSummary && (\n- <Button\n- className=\"mb-2 rounded-md bg-teal-100 p-4\"\n- size=\"xs\"\n- color=\"none\"\n- onClick={() => setExpandGptSummary((e) => !e)}\n- >\n- <Col className=\"gap-2 p-2\">\n- <Row className=\"items-center gap-2\">\n- {expandGptSummary ? (\n- <ChevronUpIcon className=\"h-5 w-5\" />\n- ) : (\n- <ChevronDownIcon className=\"h-5 w-5\" />\n- )}\n- <div className=\"text-lg\">Comments summary</div>\n- </Row>\n- <div\n- className={clsx(\n- 'whitespace-pre-line text-left text-sm',\n- !expandGptSummary && 'line-clamp-3'\n- )}\n- >\n- {staticContract.gptCommentSummary}\n- </div>\n- </Col>\n- </Button>\n- )}\n-\n- {comments.length > 0 && (\n- <Row className=\"justify-end\">\n- <Tooltip text={sortTooltip}>\n- <Row className=\"items-center gap-1\">\n- <span className=\"text-ink-400 text-sm\">Sort by:</span>\n- <DropdownMenu\n- items={generateFilterDropdownItems(\n- sorts.map((s, i) => ({\n- label: getSortLabel(s),\n- value: i + '',\n- })),\n- (value: string) => {\n- const i = parseInt(value)\n- setSortIndex(i)\n- console.log(i)\n- refreezeIds()\n- track('change-comments-sort', {\n- contractSlug: staticContract.slug,\n- contractName: staticContract.question,\n- totalComments: comments.length,\n- totalUniqueTraders: staticContract.uniqueBettorCount,\n- })\n- }\n- )}\n- buttonContent={\n- <Row className=\"text-ink-600 w-20 items-center text-sm\">\n- <span className=\"whitespace-nowrap\">\n- {getSortLabel(sort)}\n- </span>\n- <ChevronDownIcon className=\"h-4 w-4\" />\n- </Row>\n- }\n- menuWidth={'w-28'}\n- selectedItemName={sort}\n- closeOnClick\n- />\n- </Row>\n- </Tooltip>\n- </Row>\n- )}\n-\n- {pinnedComments.map((comment) => (\n- <div key={comment.id} className={'pt-3'}>\n- <ParentFeedComment\n- comment={comment}\n- playContract={staticContract}\n- liveContract={liveContract}\n- trackingLocation={'contract page'}\n- seeReplies={false}\n- numReplies={0}\n- isPinned\n- />\n- </div>\n- ))}\n-\n- {isLoadingHighlightedComment ? (\n- <Col className=\"h-32 items-center justify-center\">\n- <LoadingIndicator />\n- </Col>\n- ) : (\n- parentComments.slice(0, parentCommentsToRender).map((parent) => (\n- <FeedCommentThread\n- key={parent.id}\n- playContract={staticContract}\n- liveContract={liveContract}\n- parentComment={parent}\n- threadComments={commentsByParent[parent.id] ?? []}\n- trackingLocation={'contract page'}\n- idInUrl={highlightCommentId}\n- showReplies={\n- !isBountiedQuestion ||\n- (!!user && user.id === staticContract.creatorId)\n- }\n- childrenBountyTotal={\n- staticContract.outcomeType == 'BOUNTIED_QUESTION'\n- ? childrensBounties[parent.id]\n- : undefined\n- }\n- bets={bets?.filter(\n- (b) =>\n- b.replyToCommentId &&\n- [parent]\n- .concat(commentsByParent[parent.id] ?? [])\n- .map((c) => c.id)\n- .includes(b.replyToCommentId)\n- )}\n- />\n- ))\n- )}\n- <div className=\"relative w-full\">\n- <VisibilityObserver\n- onVisibilityUpdated={onVisibilityUpdated}\n- className=\"pointer-events-none absolute bottom-0 h-[75vh]\"\n- />\n- </div>\n- </Col>\n- )\n-})\n-\n-export const BetsTabContent = memo(function BetsTabContent(props: {\n- contract: Contract\n- bets: Bet[]\n- totalBets: number\n- setReplyToBet?: (bet: Bet) => void\n-}) {\n- const { contract, setReplyToBet, totalBets } = props\n- const { outcomeType } = contract\n- const [olderBets, setOlderBets] = useState<Bet[]>([])\n-\n- const [minAmountFilterIndex, setMinAmountFilterIndex] =\n- usePersistentInMemoryState(0, `bet-amount-filter-${contract.id}`)\n- const isNumber = outcomeType === 'NUMBER'\n-\n- // Min amount filter options\n- const minAmountOptions = [\n- { label: 'Any amount', value: undefined },\n- { label: 'M$100+', value: 100 },\n- { label: 'M$1,000+', value: 1000 },\n- { label: 'M$10,000+', value: 10000 },\n- ]\n- const selectedMinAmount = minAmountOptions[minAmountFilterIndex].value\n-\n- // Filter initial bets on client side, server will filter olderBets\n- const filteredInitialBets = selectedMinAmount\n- ? props.bets.filter((bet) => Math.abs(bet.amount) >= selectedMinAmount)\n- : props.bets\n-\n- const bets = [...filteredInitialBets, ...olderBets]\n- listenToOrderUpdates(contract.id, setOlderBets, true)\n-\n- const oldestBet = minBy(bets, (b) => b.createdTime)\n-\n- const lps = useLiquidity(contract.id) ?? []\n- const visibleLps = lps.filter(\n- (l) =>\n- !l.isAnte &&\n- l.userId !== HOUSE_LIQUIDITY_PROVIDER_ID &&\n- l.userId !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID &&\n- l.amount > 0 &&\n- !minAmountFilterIndex\n- )\n- const betsByBetGroupId = isNumber\n- ? groupBy(bets, (bet) => bet.betGroupId ?? bet.id)\n- : {}\n- const groupedBets = Object.values(betsByBetGroupId)\n-\n- const items = [\n- ...(isNumber\n- ? groupedBets.map((bets) => ({\n- type: 'betGroup' as const,\n- id: 'bets-tab-' + bets[0].betGroupId,\n- bets,\n- }))\n- : bets.map((bet) => ({\n- type: 'bet' as const,\n- id: 'bets-tab-' + bet.id + '-' + 'false',\n- bet,\n- }))),\n- ...visibleLps.map((lp) => ({\n- type: 'liquidity' as const,\n- id: lp.id,\n- lp,\n- })),\n- ]\n-\n- const totalItems = totalBets + visibleLps.length\n- const totalLoadedItems = bets.length + visibleLps.length\n-\n- const shouldLoadMore = totalLoadedItems < totalItems\n- const [now] = useState(Date.now())\n- const oldestBetTime = oldestBet?.createdTime ?? now\n-\n- const loadMore = useEvent(async () => {\n- if (!shouldLoadMore) return false\n-\n- try {\n- const newBets = await api('bets', {\n- contractId: contract.id,\n- beforeTime: oldestBetTime,\n- limit: 50,\n- filterRedemptions: !isNumber,\n- includeZeroShareRedemptions: isNumber,\n- minAmount: selectedMinAmount,\n- })\n-\n- if (newBets.length > 0) {\n- setOlderBets((bets) => uniqBy([...bets, ...newBets], (b) => b.id))\n- return true\n- }\n- return false\n- } catch (err) {\n- console.error(err)\n- return false\n- }\n- })\n- useEffect(() => {\n- setOlderBets([])\n- loadMore()\n- }, [selectedMinAmount])\n-\n- const allItems = sortBy(items, (item) =>\n- item.type === 'bet'\n- ? -item.bet.createdTime\n- : item.type === 'liquidity'\n- ? -item.lp.createdTime\n- : item.type === 'betGroup'\n- ? -item.bets[0].createdTime\n- : undefined\n- )\n-\n- const scrollRef = useRef<HTMLDivElement>(null)\n- const isCashContract = contract.token === 'CASH'\n-\n- // Determine how many loading rows to show\n- const numLoadingRows = shouldLoadMore\n- ? Math.min(10, Math.max(0, totalBets - allItems.length))\n- : 0\n-\n- return (\n- <>\n- <div ref={scrollRef} />\n-\n- {/* Minimum bet amount filter */}\n- <Row className=\"mb-2\">\n- <Row className=\"items-center gap-1\">\n- <span className=\"text-ink-500 text-sm\">Min amount:</span>\n- <DropdownMenu\n- items={generateFilterDropdownItems(\n- minAmountOptions.map((option, i) => ({\n- label: option.label,\n- value: i.toString(),\n- })),\n- (value: string) => {\n- const newIndex = parseInt(value)\n- setMinAmountFilterIndex(newIndex)\n- setOlderBets([]) // Clear older bets to refetch with new filter\n- track('change-bet-amount-filter', {\n- contractSlug: contract.slug,\n- contractName: contract.question,\n- minAmount: minAmountOptions[newIndex].value,\n- })\n- }\n- )}\n- buttonContent={\n- <Row className=\"text-ink-700 w-28 items-center text-sm\">\n- <span className=\"whitespace-nowrap\">\n- {minAmountOptions[minAmountFilterIndex].label}\n- </span>\n- <ChevronDownIcon className=\"h-4 w-4\" />\n- </Row>\n- }\n- menuWidth={'w-36'}\n- selectedItemName={minAmountOptions[minAmountFilterIndex].label}\n- closeOnClick\n- />\n- </Row>\n- </Row>\n-\n- <Col className=\"mb-4 items-start gap-7\">\n- {allItems.map((item) =>\n- item.type === 'bet' ? (\n- <FeedBet\n- onReply={setReplyToBet}\n- key={item.id}\n- contract={contract as MarketContract}\n- bet={item.bet}\n- />\n- ) : item.type === 'betGroup' ? (\n- <MultiNumericBetGroup\n- key={item.id}\n- contract={contract as CPMMNumericContract}\n- bets={item.bets}\n- />\n- ) : (\n- <div\n- key={item.id}\n- className=\"-ml-2 rounded-full bg-gradient-to-r from-pink-300/50 via-purple-300/50 to-indigo-300/50 p-2\"\n- >\n- <FeedLiquidity\n- liquidity={item.lp}\n- isCashContract={isCashContract}\n- />\n- </div>\n- )\n- )}\n- {/* Render skeleton loading rows */}\n- {shouldLoadMore &&\n- !minAmountFilterIndex &&\n- Array(numLoadingRows)\n- .fill(0)\n- .map((_, i) => <LoadingBetRow key={`loading-${i}`} />)}\n- </Col>\n-\n- <LoadMoreUntilNotVisible loadMore={loadMore} />\n- </>\n- )\n-})\n-\n-function LoadingBetRow() {\n- return (\n- <div className=\"flex w-full animate-pulse gap-3 rounded-md \">\n- {/* Avatar skeleton */}\n- <div className=\"h-10 w-10 rounded-full bg-gray-500\" />\n- <Col className=\"flex-1 justify-center gap-1.5\">\n- <div className=\"h-4 w-1/2 rounded bg-gray-500\" />\n- </Col>\n- </div>\n- )\n-}\n"
550
- },
551
- {
552
- "path": "web/components/contract/trades-button.tsx",
553
- "status": "modified",
554
- "diff": "Index: web/components/contract/trades-button.tsx\n===================================================================\n--- web/components/contract/trades-button.tsx\t0b8bb48 (parent)\n+++ web/components/contract/trades-button.tsx\t0b36b52 (commit)\n@@ -6,9 +6,8 @@\n import { MODAL_CLASS, Modal, SCROLLABLE_MODAL_CLASS } from '../layout/modal'\n import { Row } from '../layout/row'\n import { LoadingIndicator } from '../widgets/loading-indicator'\n import { Tooltip } from '../widgets/tooltip'\n-import { BetsTabContent } from './contract-tabs'\n import { UserPositionsTable } from 'web/components/contract/user-positions-table'\n import { UncontrolledTabs } from 'web/components/layout/tabs'\n import { Col } from 'web/components/layout/col'\n import { Avatar } from '../widgets/avatar'\n@@ -23,8 +22,9 @@\n import { shortenNumber } from 'common/util/formatNumber'\n import { useBetsOnce } from 'client-common/hooks/use-bets'\n import { api } from 'web/lib/api/api'\n import { useAPIGetter } from 'web/hooks/use-api-getter'\n+import { BetsTabContent } from 'web/components/contract/bets-tab-content'\n \n export function TradesButton(props: {\n contract: Contract\n answer?: Answer\n"
555
- },
556
- {
557
- "path": "web/hooks/use-comments.ts",
558
- "status": "modified",
559
- "diff": "Index: web/hooks/use-comments.ts\n===================================================================\n--- web/hooks/use-comments.ts\t0b8bb48 (parent)\n+++ web/hooks/use-comments.ts\t0b36b52 (commit)\n@@ -1,17 +1,17 @@\n+import { useApiSubscription } from 'client-common/hooks/use-api-subscription'\n+import { usePersistentInMemoryState } from 'client-common/hooks/use-persistent-in-memory-state'\n import { ContractComment } from 'common/comment'\n+import { convertContractComment } from 'common/supabase/comments'\n+import { groupBy, sortBy, uniqBy } from 'lodash'\n import { useEffect, useState } from 'react'\n-import { sortBy, uniqBy } from 'lodash'\n+import { api } from 'web/lib/api/api'\n import {\n getAllCommentRows,\n getComment,\n getCommentThread,\n getNumContractComments,\n } from 'web/lib/supabase/comments'\n-import { convertContractComment } from 'common/supabase/comments'\n-import { api } from 'web/lib/api/api'\n-import { usePersistentInMemoryState } from 'client-common/hooks/use-persistent-in-memory-state'\n-import { useApiSubscription } from 'client-common/hooks/use-api-subscription'\n \n export function useNumContractComments(contractId: string) {\n const [numComments, setNumComments] = useState<number>(0)\n \n@@ -98,4 +98,41 @@\n }, [limit])\n \n return comments\n }\n+\n+export const useCommentThreads = (\n+ contractId: string,\n+ limit: number,\n+ disabled: boolean\n+) => {\n+ const [threads, setThreads] = useState<\n+ { parent: ContractComment; replies: ContractComment[] }[]\n+ >([])\n+ const [page, setPage] = useState(0)\n+ const [loading, setLoading] = useState(false)\n+\n+ const loadMore = async () => {\n+ if (loading) return\n+ setLoading(true)\n+ const { parentComments, replyComments } = await api('comment-threads', {\n+ contractId,\n+ limit,\n+ page,\n+ })\n+ const repliesByParent = groupBy(replyComments, 'replyToCommentId')\n+ const newThreads = parentComments.map((p) => ({\n+ parent: p,\n+ replies: repliesByParent[p.id] ?? [],\n+ }))\n+ setThreads((t) => [...t, ...newThreads])\n+ setPage((p) => p + 1)\n+ setLoading(false)\n+ }\n+\n+ useEffect(() => {\n+ if (disabled) return\n+ loadMore()\n+ }, [contractId, disabled])\n+\n+ return { threads, loadMore, loading }\n+}\n"
560
- }
561
- ]
562
- },
563
- {
564
- "id": "update-native-notifs",
565
- "sha": "7d171eed10432a4622cc1028bbf541a1cab96bcd",
566
- "parentSha": "74465a3153ad1c23ba79ed5d8d03e6afb84de773",
567
- "spec": "Implement a native notifications and deep link refactor with Android notification icon updates, plus minor splash/auth UI changes and version bumps across the native app.\n\nRequired changes:\n\n1) Centralize and simplify notifications in native/App.tsx\n- Reorganize imports (group external libs, then internal). Ensure imports include: Clipboard from @react-native-clipboard/clipboard, ExpoClipboard, expo-constants/dev-client/linking/notifications/status-bar/web-browser/store-review, Firebase User type, platform/react-native essentials, SafeAreaProvider/SafeAreaView, WebView, and app/auth/ENV from ./init. Ensure EXTERNAL_REDIRECTS and isAdminId are from common/envs/constants.\n- Initialize Expo Notifications handler BEFORE the App component: call Notifications.setNotificationHandler with shouldPlaySound:true, shouldSetBadge:false, shouldShowBanner:true, shouldShowList:true.\n- Remove the NativeEventEmitter/LinkingManager workaround and the addNotificationResponseReceivedListener subscription ref; handle notification responses solely via Notifications.useLastNotificationResponse() inside an effect that:\n - Checks for a valid notification with DEFAULT_ACTION_IDENTIFIER, logs reason, forwards it via handlePushNotification, then calls Notifications.clearLastNotificationResponseAsync().\n- Modify handlePushNotification to:\n - Extract Notification payload, log the reason only.\n - If webview loaded and listening, send a single communicateWithWebview('notification', notification) (remove multiple delayed sends).\n - Always setEndpointWithNativeQuery to navigate to the resolved URL.\n - Remove reading/writing of lastNotificationIds; delete any persistence logic for it.\n- On app mount, call clearData('lastNotificationIds') to purge the deprecated key, and signInUserFromStorage().\n- Replace Linking.useURL/NativeEventEmitter hack with Linking.useLinkingURL() (or the equivalent expo hook) in a const linkedUrl, and keep a single effect to parse and route deep links via Linking.parse(linkedUrl). Remove Linking.getInitialURL logic and any eventEmitter.emit('url', 'blank') cache-clearing behavior.\n- Adjust fullyLoaded to include listeningToNative.current: fullyLoaded = hasLoadedWebView && fbUser && isConnected && listeningToNative.current.\n- Update container styles so:\n - display is always 'flex'.\n - backgroundColor is '#4337C9' until fullyLoaded, then switch to the dynamic backgroundColor state.\n- Render layout as:\n - <SafeAreaProvider>\n - <SafeAreaView style={styles.container} edges={[\"top\",\"bottom\",\"left\",\"right\"]}>\n - <StatusBar style={theme === 'dark' ? 'light' : 'dark'} animated={true} />\n - <SplashAuth ... />\n - <CustomWebview ... display={!!fullyLoaded} />\n - </SafeAreaView>\n - Remove the extra fragment wrapper and the inner SafeAreaView used previously.\n- Reduce overly chatty logs: comment out the verbose logs in communicateWithWebview and when signing in fb user from webview cache.\n\n2) Update CustomWebview component (native/components/custom-webview.tsx)\n- Add a new boolean prop display.\n- Wrap the WebView content inside a root <View> that sets style to { flex: display ? 1 : 0, height: display ? 'auto' : 0 }.\n- On Android’s ScrollView, also control visibility with style={{ display: display ? 'flex' : 'none' }}.\n- Pass display from App.tsx based on fullyLoaded.\n\n3) Tweak AuthPage styles (native/components/auth-page.tsx)\n- In AuthPageStyles.container, add display: 'flex'.\n- Reduce flappy marginTop from 180 to 150.\n- In card style, add minHeight: 180 to match height.\n\n4) Android notification icon, color, and version\n- In native/android/app/build.gradle, bump versionCode from 65 to 66 and versionName to 2.0.66.\n- In native/android/app/src/main/AndroidManifest.xml, add meta-data entries:\n - com.google.firebase.messaging.default_notification_channel_id = \"default\"\n - com.google.firebase.messaging.default_notification_color = @color/notification_icon_color\n - expo.modules.notifications.default_notification_color = @color/notification_icon_color\n - Keep existing icon meta-data for firebase and expo pointing to @drawable/notification_icon.\n- In native/android/app/src/main/res/values/colors.xml, add <color name=\"notification_icon_color\">#4337C9</color>.\n- Replace notification icon PNGs under drawable-hdpi, mdpi, xhdpi, xxhdpi, xxxhdpi with updated monochrome assets suited for notification status bar use.\n\n5) Expo config and assets\n- In native/app.json:\n - Bump version to 2.0.66, android.versionCode to 66, and ios.buildNumber to 1.0.66.\n - Under the expo-notifications plugin config, update:\n - icon to \"./assets/manifold_white_transparent.png\" (replace logo-96 reference),\n - color to \"#4337C9\",\n - defaultChannel to \"default\".\n- Remove native/assets/logo-96.png from use (delete the file if no longer needed elsewhere) and add the new notification icon source asset manifold_white_transparent.png under native/assets.\n\n6) iOS version bump\n- In native/ios/Manifold/Info.plist, bump CFBundleShortVersionString to 2.0.66 and CFBundleVersion to 1.0.66.\n\nAcceptance criteria:\n- Opening a push notification navigates to the correct in-app route, whether the app is foreground/background/cold-start, without duplicate handling or reliance on stored lastNotificationIds.\n- Deep links received while the app is running or on startup are parsed and routed correctly without the previous NativeEventEmitter/getInitialURL workaround.\n- The app shows a purple splash/background until the WebView is ready and listening; the WebView only appears when fullyLoaded (including the listeningToNative flag) using the new display prop.\n- Android notifications use the new monochrome icon and purple accent, and the default notification channel is set.\n- App versions reflect 2.0.66 / 1.0.66 as appropriate across Android/iOS/Expo config.",
568
- "prompt": "Refactor the native app’s notification and deep link handling to be simpler and more reliable, and update Android notification appearance with a monochrome icon and accent color. Make the WebView only appear once the app is fully ready to receive messages to avoid flicker. Also bump native app versions for Android and iOS.\n\nSpecifically:\n- Use Expo’s notification handler and last notification response hook to process taps from any app state, without persisting or de-duplicating notifications.\n- Switch deep link handling to the built-in linking hook and remove any event emitter or initial URL hacks.\n- Keep the purple splash/background visible until the WebView is truly ready, and hide the WebView until then via a display prop.\n- Update the Android notification icon and color, set a default channel, and reflect these via both the manifest and the Expo notifications plugin.\n- Bump app versions for Android and iOS accordingly.",
569
- "supplementalFiles": [
570
- "native/init.ts",
571
- "native/components/splash-auth.tsx",
572
- "native/components/logger.tsx",
573
- "native/lib/auth.ts",
574
- "native/android/app/src/main/java/com/markets/manifold/MainActivity.kt",
575
- "native/ios/Manifold/AppDelegate.swift"
576
- ],
577
- "fileDiffs": [
578
- {
579
- "path": "native/App.tsx",
580
- "status": "modified",
581
- "diff": "Index: native/App.tsx\n===================================================================\n--- native/App.tsx\t74465a3 (parent)\n+++ native/App.tsx\t7d171ee (commit)\n@@ -1,25 +1,8 @@\n-import Clipboard from '@react-native-clipboard/clipboard'\n-import { EXTERNAL_REDIRECTS, isAdminId } from 'common/envs/constants'\n-import * as ExpoClipboard from 'expo-clipboard'\n-import 'expo-dev-client'\n-import * as Notifications from 'expo-notifications'\n-import * as WebBrowser from 'expo-web-browser'\n-import { User as FirebaseUser } from 'firebase/auth'\n-import React, { useCallback, useEffect, useRef, useState } from 'react'\n-import {\n- BackHandler,\n- NativeEventEmitter,\n- Platform,\n- Share,\n- StyleSheet,\n-} from 'react-native'\n-import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'\n-import WebView from 'react-native-webview'\n-import { app, auth, ENV } from './init'\n-// @ts-ignore\n import { ReadexPro_400Regular, useFonts } from '@expo-google-fonts/readex-pro'\n+import Clipboard from '@react-native-clipboard/clipboard'\n import * as Sentry from '@sentry/react-native'\n+import { EXTERNAL_REDIRECTS, isAdminId } from 'common/envs/constants'\n import { setFirebaseUserViaJson } from 'common/firebase-auth'\n import {\n MesageTypeMap,\n nativeToWebMessage,\n@@ -30,18 +13,25 @@\n import { getSourceUrl, Notification } from 'common/notification'\n import { CustomWebview } from 'components/custom-webview'\n import { log } from 'components/logger'\n import { SplashAuth } from 'components/splash-auth'\n+import * as ExpoClipboard from 'expo-clipboard'\n import Constants from 'expo-constants'\n+import 'expo-dev-client'\n import * as Linking from 'expo-linking'\n-import { MaybeNotificationResponse, Subscription } from 'expo-notifications'\n+import * as Notifications from 'expo-notifications'\n import { StatusBar } from 'expo-status-bar'\n import * as StoreReview from 'expo-store-review'\n+import * as WebBrowser from 'expo-web-browser'\n+import { User as FirebaseUser } from 'firebase/auth'\n import { clearData, getData, storeData } from 'lib/auth'\n import { checkLocationPermission, getLocation } from 'lib/location'\n import { useIsConnected } from 'lib/use-is-connected'\n-// @ts-ignore\n-import * as LinkingManager from 'react-native/Libraries/Linking/NativeLinkingManager'\n+import React, { useCallback, useEffect, useRef, useState } from 'react'\n+import { BackHandler, Platform, Share, StyleSheet } from 'react-native'\n+import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'\n+import WebView from 'react-native-webview'\n+import { app, auth, ENV } from './init'\n \n Sentry.init({\n dsn: 'https://2353d2023dad4bc192d293c8ce13b9a1@o4504040581496832.ingest.us.sentry.io/4504040585494528',\n debug: ENV === 'DEV',\n@@ -54,13 +44,22 @@\n // const BASE_URI = 'http://192.168.1.229:3000/'\n \n const BASE_URI =\n ENV === 'DEV' ? 'https://dev.manifold.markets/' : 'https://manifold.markets/'\n-const isIOS = Platform.OS === 'ios'\n+\n+// Set up notification handler before component\n+Notifications.setNotificationHandler({\n+ handleNotification: async () => ({\n+ shouldPlaySound: true,\n+ shouldSetBadge: false,\n+ shouldShowBanner: true,\n+ shouldShowList: true,\n+ }),\n+})\n+\n const App = () => {\n // Init\n const webview = useRef<WebView>(null)\n- const notificationResponseListener = useRef<Subscription>()\n useFonts({ ReadexPro_400Regular })\n \n // This tracks if the webview has loaded its first page\n const [hasLoadedWebView, setHasLoadedWebView] = useState(false)\n@@ -84,8 +83,9 @@\n }\n \n useEffect(() => {\n signInUserFromStorage()\n+ clearData('lastNotificationIds') // no longer used, clear them from local storage\n }, [])\n \n // Sends the saved user to the web client to make the log in process faster\n const sendWebviewAuthInfo = (user: FirebaseUser) => {\n@@ -107,12 +107,9 @@\n params.set('nativePlatform', Platform.OS)\n url.search = params.toString()\n return url.toString()\n })\n- const linkedUrl = Linking.useURL()\n- const eventEmitter = new NativeEventEmitter(\n- isIOS ? LinkingManager.default : null\n- )\n+ const linkedUrl = Linking.useLinkingURL()\n \n // UI\n const [backgroundColor, setBackgroundColor] = useState('rgba(255,255,255,1)')\n const [theme, setTheme] = useState<'dark' | 'light'>('light')\n@@ -152,94 +149,49 @@\n // Perhaps this isn't current if the webview is killed for memory collection? Not sure\n const notification = response.notification.request.content\n .data as Notification\n if (notification == undefined) return\n- const lastNotificationIds = await getData<string[]>('lastNotificationIds')\n- if (\n- lastNotificationIds?.length &&\n- lastNotificationIds.some((id) => id === notification.id)\n- ) {\n- log('skipping lastNotificationResponse', notification.id)\n- return\n- }\n- log('handling notification', notification)\n+ log('handling notification', notification.reason)\n \n // Resolve the destination URL from the notification.\n const destination = getSourceUrl(notification)\n \n // If the webview is already loaded and listening, forward the message so\n // the web client can mark the notification as seen, etc.\n if (hasLoadedWebView && listeningToNative.current) {\n- // Send multiple times in case the client JavaScript isn\\'t ready yet\n- // (mirrors the logic in sendWebviewAuthInfo).\n- const timeouts = [0, 200, 800]\n- timeouts.forEach((timeout) =>\n- setTimeout(\n- () => communicateWithWebview('notification', notification),\n- timeout\n- )\n- )\n+ communicateWithWebview('notification', notification)\n }\n \n // Always set the URL so that, even if the message is missed, the webview\n // navigates to the correct page when it becomes active.\n setEndpointWithNativeQuery(destination)\n-\n- storeData('lastNotificationIds', [\n- ...(lastNotificationIds || []),\n- notification.id,\n- ])\n }\n \n useEffect(() => {\n- // This listener is fired whenever a user taps on or interacts with a notification (works when app is foregrounded)\n- notificationResponseListener.current =\n- Notifications.addNotificationResponseReceivedListener((response) => {\n- log('notification response', response)\n- handlePushNotification(response)\n- })\n-\n- return () => {\n- if (notificationResponseListener.current)\n- notificationResponseListener.current.remove()\n- }\n- }, [])\n-\n- useEffect(() => {\n- Linking.getInitialURL().then((url) => {\n- log('Initial url:', url, '- has loaded webview:', hasLoadedWebView)\n- if (url) setUrlWithNativeQuery(url)\n- })\n const backHandler = BackHandler.addEventListener(\n 'hardwareBackPress',\n handleBackButtonPress\n )\n return () => backHandler.remove()\n }, [])\n \n- const handleLastNotificationResponse = async (\n- lastNotif: MaybeNotificationResponse\n- ) => {\n+ const lastNotifResponse = Notifications.useLastNotificationResponse()\n+ useEffect(() => {\n if (\n- lastNotif &&\n- lastNotif.notification.request.content.data &&\n- lastNotif.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER\n+ lastNotifResponse &&\n+ lastNotifResponse.notification.request.content.data &&\n+ lastNotifResponse.actionIdentifier ===\n+ Notifications.DEFAULT_ACTION_IDENTIFIER\n ) {\n log(\n 'processing lastNotificationResponse',\n- lastNotif.notification.request.content.data\n+ lastNotifResponse.notification.request.content.data.reason\n )\n- handlePushNotification(lastNotif)\n- // Clearing the last notification response doesn't seem to persist across app restarts, so we store the id\n+ handlePushNotification(lastNotifResponse)\n Notifications.clearLastNotificationResponseAsync()\n }\n- }\n+ }, [lastNotifResponse])\n \n- const lastNotificationResponse = Notifications.useLastNotificationResponse()\n- useEffect(() => {\n- handleLastNotificationResponse(lastNotificationResponse)\n- }, [lastNotificationResponse])\n-\n // Handle deep links\n useEffect(() => {\n if (!linkedUrl || linkedUrl === 'blank') return\n const { hostname, path } = Linking.parse(linkedUrl)\n@@ -256,14 +208,8 @@\n )\n if (hasLoadedWebView && listeningToNative.current)\n communicateWithWebview('link', { url })\n else setEndpointWithNativeQuery(url)\n- // If we don't clear the url, we'll reopen previously opened links\n- const clearUrlCacheEvent = {\n- hostname: 'manifold.markets',\n- url: 'blank',\n- }\n- eventEmitter.emit('url', clearUrlCacheEvent)\n }\n }, [linkedUrl])\n \n const handleBackButtonPress = () => {\n@@ -383,9 +329,9 @@\n try {\n const fbUserAndPrivateUser = JSON.parse(payload)\n if (fbUserAndPrivateUser && fbUserAndPrivateUser.fbUser) {\n const fbUser = fbUserAndPrivateUser.fbUser as FirebaseUser\n- log('Signing in fb user from webview cache')\n+ // log('Signing in fb user from webview cache')\n // We don't actually use the firebase auth for anything right now, but in case we do in the future...\n await setFirebaseUserViaJson(fbUser, app)\n await storeData('user', fbUser)\n }\n@@ -457,14 +403,14 @@\n const communicateWithWebview = <T extends nativeToWebMessageType>(\n type: T,\n data: MesageTypeMap[T]\n ) => {\n- log(\n- 'Sending message to webview:',\n- type,\n- 'is listening:',\n- listeningToNative.current\n- )\n+ // log(\n+ // 'Sending message to webview:',\n+ // type,\n+ // 'is listening:',\n+ // listeningToNative.current\n+ // )\n webview.current?.postMessage(\n JSON.stringify({\n type,\n data,\n@@ -480,16 +426,17 @@\n webview.current?.reload()\n }\n \n const isConnected = useIsConnected()\n- const fullyLoaded = hasLoadedWebView && fbUser && isConnected\n+ const fullyLoaded =\n+ hasLoadedWebView && fbUser && isConnected && listeningToNative.current\n const styles = StyleSheet.create({\n container: {\n- display: fullyLoaded ? 'flex' : 'none',\n+ display: 'flex',\n flex: 1,\n justifyContent: 'center',\n overflow: 'hidden',\n- backgroundColor: backgroundColor,\n+ backgroundColor: fullyLoaded ? backgroundColor : '#4337C9',\n },\n })\n \n const handleExternalLink = useCallback(\n@@ -506,36 +453,35 @@\n [baseUri]\n )\n \n return (\n- <>\n- <SafeAreaProvider>\n+ <SafeAreaProvider>\n+ <SafeAreaView\n+ style={styles.container}\n+ edges={['top', 'bottom', 'left', 'right']}\n+ >\n+ <StatusBar\n+ animated={true}\n+ style={theme === 'dark' ? 'light' : 'dark'}\n+ hidden={false}\n+ />\n <SplashAuth\n webview={webview}\n hasLoadedWebView={hasLoadedWebView}\n fbUser={fbUser}\n isConnected={isConnected}\n />\n- <SafeAreaView\n- style={styles.container}\n- edges={['top', 'bottom', 'left', 'right']}\n- >\n- <StatusBar\n- animated={true}\n- style={theme === 'dark' ? 'light' : 'dark'}\n- hidden={false}\n- />\n- <CustomWebview\n- urlToLoad={urlToLoad}\n- webview={webview}\n- resetWebView={resetWebView}\n- setHasLoadedWebView={setHasLoadedWebView}\n- handleMessageFromWebview={handleMessageFromWebview}\n- handleExternalLink={handleExternalLink}\n- />\n- </SafeAreaView>\n- </SafeAreaProvider>\n+ <CustomWebview\n+ display={!!fullyLoaded}\n+ urlToLoad={urlToLoad}\n+ webview={webview}\n+ resetWebView={resetWebView}\n+ setHasLoadedWebView={setHasLoadedWebView}\n+ handleMessageFromWebview={handleMessageFromWebview}\n+ handleExternalLink={handleExternalLink}\n+ />\n+ </SafeAreaView>\n {/*<ExportLogsButton />*/}\n- </>\n+ </SafeAreaProvider>\n )\n }\n export default Sentry.wrap(App)\n"
582
- },
583
- {
584
- "path": "native/android/app/build.gradle",
585
- "status": "modified",
586
- "diff": "Index: native/android/app/build.gradle\n===================================================================\n--- native/android/app/build.gradle\t74465a3 (parent)\n+++ native/android/app/build.gradle\t7d171ee (commit)\n@@ -93,10 +93,10 @@\n defaultConfig {\n applicationId 'com.markets.manifold'\n minSdkVersion rootProject.ext.minSdkVersion\n targetSdkVersion rootProject.ext.targetSdkVersion\n- versionCode 65\n- versionName \"2.0.65\"\n+ versionCode 66\n+ versionName \"2.0.66\"\n }\n signingConfigs {\n debug {\n storeFile file('debug.keystore')\n"
587
- },
588
- {
589
- "path": "native/android/app/src/main/AndroidManifest.xml",
590
- "status": "modified",
591
- "diff": "Index: native/android/app/src/main/AndroidManifest.xml\n===================================================================\n--- native/android/app/src/main/AndroidManifest.xml\t74465a3 (parent)\n+++ native/android/app/src/main/AndroidManifest.xml\t7d171ee (commit)\n@@ -13,9 +13,12 @@\n <data android:scheme=\"https\"/>\n </intent>\n </queries>\n <application android:name=\".MainApplication\" android:label=\"@string/app_name\" android:icon=\"@mipmap/ic_launcher\" android:roundIcon=\"@mipmap/ic_launcher_round\" android:allowBackup=\"true\" android:theme=\"@style/AppTheme\" android:supportsRtl=\"true\">\n+ <meta-data android:name=\"com.google.firebase.messaging.default_notification_channel_id\" android:value=\"default\"/>\n+ <meta-data android:name=\"com.google.firebase.messaging.default_notification_color\" android:resource=\"@color/notification_icon_color\"/>\n <meta-data android:name=\"com.google.firebase.messaging.default_notification_icon\" android:resource=\"@drawable/notification_icon\"/>\n+ <meta-data android:name=\"expo.modules.notifications.default_notification_color\" android:resource=\"@color/notification_icon_color\"/>\n <meta-data android:name=\"expo.modules.notifications.default_notification_icon\" android:resource=\"@drawable/notification_icon\"/>\n <meta-data android:name=\"expo.modules.updates.ENABLED\" android:value=\"true\"/>\n <meta-data android:name=\"expo.modules.updates.EXPO_RUNTIME_VERSION\" android:value=\"@string/expo_runtime_version\"/>\n <meta-data android:name=\"expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH\" android:value=\"ALWAYS\"/>\n"
592
- },
593
- {
594
- "path": "native/android/app/src/main/res/drawable-hdpi/notification_icon.png",
595
- "status": "modified",
596
- "diff": "Index: native/android/app/src/main/res/drawable-hdpi/notification_icon.png\n===================================================================\n--- native/android/app/src/main/res/drawable-hdpi/notification_icon.png\t74465a3 (parent)\n+++ native/android/app/src/main/res/drawable-hdpi/notification_icon.png\t7d171ee (commit)\n@@ -1,8 +1,7 @@\n �PNG\r\n \u001a\n-\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000$\u0000\u0000\u0000$\b\u0006\u0000\u0000\u0000�\u0000��\u0000\u0000\u0006�IDATx\u0001��\tl��\u0001��������\u0007���e�Q�\u0010*b��-(D@\u000eMd�\u0002�\u0014��Y�eL\u001a�\u0000+�\u0012��l�f�8\u0006;�:�Є�i`Ӂ��\u0010\u0018��k����}W�{�㷭B��\u001e�d\t���|�a��\u0004�H@\u0001��)@p�\u0002\u0004�\u0004�\u0018H\u0000�˄�\u0003\u001a\t)n.��\u0014\u0003)$�\u0018�-Fr���\f\u0002l��Br\u0013��:X��\u0000e�I�\"ۆ�f��񘏸���.B��$\teBW���\b)�>3�\u0014��/K\u0003�����-p��+I\u0012�l�gR&{�\\JF�\u0013!HH\b���\f��\u0012���\u0017o�\u0013���\u0010 �dѳ���3�,�\u0013!\u0018@�@��4V�h\b�CR�q�\b�&I$\u0016�Y�}������(�E\u001eP$���\\\u0017[^\u001b����|r��sm1�b\u0000�\u0017\b\u0001���a��%�X�DG{���\u0010�6�`+��������\u001c;\u001c��x��q\u001e��\\Ű`�\u0003Y�|�h��a7��8��u\u001eM�*����A>\u0017�~6���-D�,�&ض�\r�ò�b\fE?ӆG����\u0003��\r��3Q\n-\u0007{���K�\u0000�Gcu�0�-.d�����D1L�Ѫ\u0000�H.2��b�Hrr��XYĪuCp�4\\\u001e�\u0017˛\u001892�9s�P6�\u0013��S���N'��Ɩ\u0018��\u000e\u0006�I��V0�$��\u001dw\u0010\tZ�Xr���(���k�9�R\u0004��\\�Y����i=k��7�G,^�=�\f�$�ظ��m�\u001e���(5�=L��͉�a:��84�t\u00198�\u0002g�F��d�S�̙��K\u001b���G\u000f\u000e\u001dJJR��wq��.�MB:�hPS\u0013Ķ\u0001\u0005�1E8b���h;\u0003]�qV���W+G�zU=����/*[q�\u0014�B6]~��w�3kv.�a�h�Iz�\u0006��XL1ga\u001e����0lt��$_`�|N�- \u001a�Hqk\b\u0001\bhm�e˦�ؼe8YY\u001a�\u001f\u0007Q�>\u0016\u0010\b��)\u001fƑ�=�]UO8h`� \u0004df;���A���\u0003]')�$\u0014\u0010�XH��\u0014`\u0001\u0002��P\u0017{K\\L����\u0000! f\u000b��\u000e����s+NS{\"�.�g���,\u001f�>즧�@�IJ��\u000bAo�•�������7�8v4Ⱥ\r�(�=�����p\b��;I�\u0010�d\u0000�[�O���\u001b��\u001c\\�$\t�\u000b\u0002\u0001\u0003O�ƕ�\u0012�oma���l�����u�W^jƲl�b\u0000\u0005<85�Ϛ����\u0010\u0002�\u0000!HH'\t�A�y\u000b_�\u0003e�O\b�St�_;�\u000b\u0017\f6�o��(\u000eIBF\u001c\u001e��G��\u0000��N�;d\u0012\u000e[�A\u000bð���8�\u0002��>:IH\tg;LF�z0\r�\u001a\u0018&��\f��\u000f���N֯�ǡ�\u0014��\u0012l�~R��{\u0007������3��|��:��\u001a.����\u0010@yY=5ǂ(\u0005:�\b\b��d{\u001d�\u0014��\u001a�,-ࡩ�l��LMu\u0000]�R��l\u0018qg:�'�(�>�^�S�����6\u000e�ŏ\u0010�\tQK0��LV�\rf�[�TU��%}t�!\u0012�H\u001f����d���\u0004#\u0016�\u0017��P�D��0wQ>\u0013'f����\b\u0014B@Q���b7\u001f\u001f�FJ\u0010\u0002,\u0004KPȴiٔ�m�Tu\u0010�A?�d\u0014�!��B\u000fo��\u000ev��,���\u001d]��2ۂ鳼̜�e��SH�P�i“\u000b�y�\u000f�\u00181\u001b� ��M��a��7���'\u0010��t0�$\t��'`r�B�X�F\n-��:1,P6\b\u0001J�7\u001e�d��\u0002V.?M,h�\u0014 #�������\u001d\u0004L��ͫ����}����\u0001,\u001b��t�P\n-:�\u0006��<Ɉ�)L��I��#8�f��\u0001?��\u000bo��e�\r��U���q.1-��H\u000e\u001f\u001d�&\u001aR�\\?��ǧQ�����^�FR:� \u0005 \u0014Mua\u001aj�l�h���4�L˦ra\u001e�\\7\u0007�ډ��$\u001a\u0003��>\u000eM2�Q/�w�a��c��\n-����H�Fj\\�VR��\u0005��\u0010�r\b\u0002~�#\u001f\u0005x��N>�\n-0t���\u0017\u0017��CY(!�_0�R\u0006\u000f��2vL\u001a��Z����H��\u000eJ�\u0006)\u0005���4�\f�OC�<�;KӘ2=��~�\u0010�K��!†u���Ɛ\u001a�M�+�mp9\u0001����\u0000G�\u0002(\u000bF�K��x\u0004ݭ�\u001a7D�&�m�\u001d�\u0003���h.P�\u001b&�?P����\u0016#��Hn1�\u0006����r���\u0000\u0000\u0000\u0000IEND�B`�\n\\n+\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000$\u0000\u0000\u0000$\b\u0006\u0000\u0000\u0000�\u0000��\u0000\u0000\u0004BIDATx\u0001��}�\u0016\u0004\u001d\u0007����Y��|#\u000e�ȍ\u0013G�ZF\r�.7�iJ�\"\bu�\"{A#�U˭hmY\rl�^֌j\u000e�\u001a*��\u0005\u0011.Z,�ڢh�\u0002)\u0018޲Ċ���o�u[����A�����9眡r\u0006�܈��\u0003U\u0015gAÙY�\u0005��,ij��0\u0015\u0007�*Ɛ�\u0002܀c�\u000e�\u0005Mý\u0017+�Q<dl�\u0017۰\u0010뜆$��\u0002_��CFh\u0018��\u0006܋\t�F�YF���[�\u0011�p]��N!�U؉�p\\\u0007\r����Z�\u001f��Z<��\n+�MƵX��8���\"ɸ$��\u000e�\u0003;��9\u001d4�L�\u001a|\u0005O��؎��`�K��\u0019���ުz\t[0/I\u0019!�E�\u001a>��x\u000e[����э�`/��\u0014|\u0006G��$\r�-�#�c\u0003��B-�tc\u0007�1\u0007�q%v\u0018E\u0003�1\u0007�`\u0006������\u001d��UI�\u0019��\u001bӱՐ�ڇ>�4(I%Y��؂�Uu\u00187♪z�(�U�\u0013-���۠�z!���1~�d5���8j���$O�X�\u000fbcUſ-�zchj�,f\u001bRU���\u000b��4\u0016�����c-��\u0004̭���$�\u0000��achh׋.-�j7>����ة�E�\u0010��SU�\r7\u000f��\u001f���]/��{\u0014��}h\u001a�d<��CX�uUuR�$��x���i\fM���F���$�0\u0003�J�\u0018�㛸\u0012o���:��7�#:H2\u0011���2B�W�7�ZUǍ�d\u00026�ϸ\n+?��U�7�Hr\u001b��\u0007]��7`\u0016f�2��mM��q\f]8�]\u001fv�\u001e|\u001c\u000fT�Kƶ\b��\u0007\u0017�\u001f��\u001e���q\u0018K�ڽ��1\u0005\u0007�Hr\u001e��5���v;�$��F|\u001f��\u001f���I&�Q\u001cŒ��k�\bUu\"�_ХE���!\u001cĵU�'-�L��:a�\u001e\u001c���\u001a0$�d<���Ϊ�7���^t\u0019�����9<����_�$��\u0016����\u0016bCU\r\u0018�d\n+\u001e�>�]U��4u֋�I��^܊�����E�W�;X�g�Hr\u001e��\n+C�\\��؃�U��\u0016M���\u0007?�\u0000���#FH2\u000e�p\f��j�po�>\u001c0(I7������\u000e\u001a:{\u0016sq\u0012שׁ#:{\u0007��Ҫ\u001a�\"Ia!6UՉ$3�\t��)�h�쇸\u000f���$��\u001eOVU�AI�a%C�]�\u001e|:�\fl�\u001a���\u0013FQƐ�0\u001d��&L�Nl�2l�\u0017�*FH�V܃;�\u0018VW�\u0017�B9MI^���\u0005��:��z<��U5`H����\u001a_Ǘ��S(��$\r\\�\u001e,�L�\u001c?�\u0006�a/&a)�TՀ�P΂$�`>\u0016`\u0016^�������/��(I\u0003�b\u000e�ckU\r8���\u0001����p��\u0003\u0000\u0000\u0000\u0000IEND�B`�\n\\n"
597
- },
598
- {
599
- "path": "native/android/app/src/main/res/drawable-mdpi/notification_icon.png",
600
- "status": "modified",
601
- "diff": "Index: native/android/app/src/main/res/drawable-mdpi/notification_icon.png\n===================================================================\n--- native/android/app/src/main/res/drawable-mdpi/notification_icon.png\t74465a3 (parent)\n+++ native/android/app/src/main/res/drawable-mdpi/notification_icon.png\t7d171ee (commit)\n@@ -1,6 +1,6 @@\n �PNG\r\n \u001a\n-\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000\u0018\u0000\u0000\u0000\u0018\b\u0006\u0000\u0000\u0000�w=�\u0000\u0000\u0003�IDATx\u0001��}L�e\u0000��������\u000e\u001088\b����lӢ6WmNr�\u000bY�\u001a���X��i+)�����e�֌�tnV�\u001f����67�fc��2�\u0002�-^�%\u0004��\u001f��\t�08��\u000f>\u001f�q]�Ò\u0011Z\u0001�%c�,1�\"\u0019âH\u0016!\u0010�P�#�\u0010�5I\u0014�\u0006�a\u001e!`��\u0000k\n-��\u0010GC�2�l�Y���'��U����C\u001bA��\u001e\u0012\u0012a8h�G\n-^}-�\u0003\u0007sК\u0019�۴\u0003�\u000edcG\f�\u000b\u0013Y���\u0010�(����\u0003tuk\u0012�%ӄ���\u0018��Z��\u0007|�|z\u0013)�!��h��r\u0006��,�m�����\n-\u0013�[\u0019ˤ1\u001b�ܜ��+\u0003��\u0018#-��$=\u000e\u001b��P�7����8���� �I&��@�p��A�\u000b\u0012\u001fkq�X\u000b/���?�&����Q,ihk\u001d%�>/^����yHK�^�_<�x\"�|Տ��C1�цK\u0017;1\u0006�2c(���8��U�TV�1�os��\u0003c���({�.g��\u0001�ϴ��w\u0018\u0003lz�O��F,�\u001c�یaJp�!&F!%���9�A\u001b+W��\u0005\u0014\u0015'��gs�����0����x~�a\b%\r�\u0014Q�\u0011\u0007�O�`Jwg��O��֑\u001cR��|}���k��$S\u001c\r%����j�\u0018�QD\u0019\u001d��<\u0012c�2�@b�\"%���U-��l,Ɍ�L\u000f�-INq1\u001c\u0012�AM0��\u0014X\u0016(�\f�\u001a\u0012�\u0004�c��I^/Ϧ�5��7\u001b\u0011���\u0018P\u0016\b\u0001;J���v�\r�I��)��<�}(������7��\u000e�\u001eAF���ogq�l\u0007�M!\u0004�h�U��/?\r�g]\b��'+���`\tx� ���t^y����\b�\u0014Q����U�Ȥ�&,4Ӷ=\u0013�\u0018h�\u001eBY��$��\u001e���{2��S\u001c*o\u0004m�&�⊁\u0013��p}�C��P�3��$\u0017��K$77�/.taI\u0018���qt��Qu�~��\u001bᓏ�@\u001bfS�Ag{�K\u0017:\tG ���ϦR�%�/kzI\t����)��g,��8�ˇ�[\u0018\u001e��$�(\u0016`\fx\\��8B[�\b�;X�6���{X��&+�˕���s�\b\u0016��\u000bƀ�\u0005M�!n�\u001e\"\u0012�@���~\u001b!�O�E2\u001a\\n�5`#$�K��$K�_W�T\u0015���\"\u0000\u0000\u0000\u0000IEND�B`�\n\\n+\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000\u0018\u0000\u0000\u0000\u0018\b\u0006\u0000\u0000\u0000�w=�\u0000\u0000\u0002ZIDATx\u0001��o��\u0003\u001c\u0007���w��+s\\�?׸\u0007Zi<�\u0014%���l�kg�h\u000f\u0016jm\n++���< ��E������4]K�\u0003\u001eL�Y���xt�(�+2o�گ~����C^/�\u001dI�&\u0019�H-��+��\"�t$Y�d�\u0005$\u0019�0��\u0017�,IRz�4���\u0010��\\�l\u001b�\u0007K�\fX@�[p\u001c�z��\\��؁ϰ7ɐ~w�\u0003|�[�HRI��Q|]U�z�p\u0010-܇��\u000b�%)�$c�����\u001e&t$\u0019�;�\u0003o�C�\u0005�b\n+�U5��x�\u0019��]���\n+W'�$+p\b/V��X���iW�1\u001dI�\u001aU�;ɞ$SX��t}��\f\u001b�j6� .�����үt=�)�Tկ\u001aI�0�9LTլ3V�#\u000bh;���=�F\u001cH2�A��g�jZ��xR�$����H�\u000f�W՜�$W�m��\u0007��[=�\f�\bv�F\\�a\\�O���`\f'4���\u0019G���~s�U8\u000f+�9��$�Ǧ�~�p\tN$\u0019��x���i$)���ú&���Nj$ٌk���N�����In�\u000el���\u001e�i\u001dIڸ��Nj$y\fW`kUE���)<�opgU��#�\r�\tw�Z��5�lDžU��\u001e-���\u001c\u00061�dX#�Ex\u0006\u000fVUtMb��0TU��S\u0016�d\u0004�\u0006\u0003X��UuPG�\u0001\u001c�1�PUO[@�\u001bI.�Z��\b��},�\u0001쬪\u0017�CY�$�c5��f쪪�����\u0013\u0004P�Qp��'\u0000\u0000\u0000\u0000IEND�B`�\n\\n"
602
- },
603
- {
604
- "path": "native/android/app/src/main/res/drawable-xhdpi/notification_icon.png",
605
- "status": "modified",
606
- "diff": "Index: native/android/app/src/main/res/drawable-xhdpi/notification_icon.png\n===================================================================\n--- native/android/app/src/main/res/drawable-xhdpi/notification_icon.png\t74465a3 (parent)\n+++ native/android/app/src/main/res/drawable-xhdpi/notification_icon.png\t7d171ee (commit)\n@@ -1,8 +1,14 @@\n �PNG\r\n \u001a\n-\u0000\u0000\u0000\rIHDR\u0000\u0000\u00000\u0000\u0000\u00000\b\u0006\u0000\u0000\u0000W\u0002��\u0000\u0000\u000b\u0000IDATx\u0001��\ttU���������_�K^\u0012�&�Pv�{۩ӱ�tl];\u0015\u001c�!Ppa逶�X<V=�B�*EkkK���푶.u\u0019O�N=\u001d�\"�\u0010V\u0013\b\u0001����{�F(B\f�he���#�^�f\t��4 �\u0006��4 \u001c�9J\u0000\r\b�i@8N\u0003Bg�ㄎ4 ��\u0006\u0004,S�\t��4Gi:�t�9Jӑ�#��i:Ӝ�ր��\u0012����S�p�\u001eN��)� \u0011�-@8c\u0014g�R`��qsol�3Fq�\u00143��몹���D*�0L�LPt�\b\u001f�\b��|kR\r�����F�O�0@)NI�\u0005Qpp�&��t�2��s�\t�\\��6�\u001d��*j>�LASY�\"��T\u0014�a\u0018B� <��\b���K:�钆�\u0016͍�{��8�\rIF�룭M�]�\tٸf��Z֬\u001f�ԙ5����Q��R��k֬\u001dΠ�A����5���\u00148-�L\u00188��\u0011��yt�\u0001Z�\u0015���K\u000eMw\b���'\u001b>��\u001b�#\u0002O>~�p��d\u0014'!\u0002����\u000e%\u0012qѲ/K*Y��\u0017\u000e���N:�0��j��\u0002�M����P\u0019u�˯P�S\u0012�TN3��r�q\f��I\"^�~s���y���d\u0014\u001f!\u0002��f�}�\u00186\"�7&�s�p��6%x��V�Yq6~�\u001bӤ3\u0001�\u0012&^Q���\u000e\u00120a��\u001c~��p��2��1L!��;n\u001f�\u0003ˆ��\u000b1~�d'ማe\u000f�P�[�pR�\u0013\t$���\u0016���D���[ho.�Jڄ#&w.x��i�|f8\u0019��0�\u0013\u0019\u0006|�\u001f�x�&\u001bs\b�OH�\u0016p\u001cM����\u0012 _�p�͆ߏ�K�\\ɂy\r̟���o�f߾,��J�ؚSQ� ���\u001b{3yr-{vgY��\u0001��e,�]P��ob�`���\u0014��5k��Hh��C{Z4u�z�׷���dQ,h�\u001f�H���\u0019�Ŷ4�(\u0003�1�5S�Y�\u001f��ŊL��.[�N2b��sέ����\u0013)\u0017��\u0014c���J����d2%|~E\"a��C���x=\u0006�%��\u000e���J���e�\u0006�n׈�a���\u001e�\f\r�be+5�\u0014ZC��9x�����$\u000ek�\u0006� �(V�\u001fƜ�g���̸�\u0001\u000f6�I�ԩ5d�%^�\u0018�+&c�4��\u0012��-��6����\b��•߬��CD\\`���b��m<�f$��חU����0��\u001aZ�g��N�pH\u0010��KxW�����\u001c���s\u0017����!\u0014\n-6S����;�\u0004+�\u000f�Q|�j��y+�\rZsZ&'py��h|!�\u0013\u0010\u00040\\BS���L\u0011�\n-h�-MCC�{��ɝw\r�@k�_?\u001d�K�<�p3�pL�Bx��\u0019ο(�\u0017a�����Z^}�\u0010�����\u001f\u0002e�֠-�lR\u0014��X��\u0001�\u0001�mN��#lK�\u0001\u0011�P\u0002�˥�z\u0014��\u0003\u0002.7<�\\�>�<,�e ���1\f��\r1\\e`[\u001ca\u001aB��\u001c���߽>�HE\u0019wݹ���:F8(�\u0016 \u0002��������6b1�p@�IW\u0004�Y\u001b�T�P\u0014���(�[X�|\u001f�\u0007x�0��?�p�t�&$\u0002\u0002JA[���k|(%��6WN�D1[\"�\u0017\u001c�\u000f\u0019\u0006\\xQ9Ѩ��\u000f�'\u001aQX%MW\u0014]�\u001a2\u0019\u001b\u0011p|\u0006\"‰�˅۾���;�\f\u001f\u0019@\u0019�2�0!Y\u0014~�j\bs�\u000e��O�c�\u0015�h���t�ao������ak�-�sX%Mw(��5���\u0007<\u0001\u0003�\u000e�\u0003�r�6�\u0001���G�\r�P�C�Z\u001f��a,��\u0004�;���+�\u0012�eщY&�\u0019�e�\u0010?\\�B��\u0006�e�\u0005�hlll[��\u0019ąNl\u000b����)\r�yf$�7�`�� o���ysw\u00110\u001d�nAkN�-�p�ݵ������$����2�֐/hJ%\u0007��@kN�*A.됈�\u001800�������D+\u0005�攔\u0001յ..�\\%K��M�B!\u0002\b�Ak�\u001a��q\u001c:1�֐nw(\u0016m�FL�ٚ\u0013)\u0005��_�`��A\u001c�\u0015�|�{\u001c�[�\",86��Iìi�(%T�LƟ\u0017�-i��X��6N�F;\u000e٢&֦�\u0004!�\u0017�1��,\u0014�65g��,P�#\f\u0013�)��{\u0007�˪�ͯZY����\u001f\\n��\f�\u001a��\\C,�����x<\u0006en\u0003�K���5X��Ur(\u0014m\u001e[���~\u001f�\u0018�n�D���TV�\u0014S\u001aOH�\u0016�o\u000f��p6������x�8A�`[t��jl\u000b�!�Dځ�]]�˥�|�f�Z\u001c\u0002\u0001\b�\u0014eJa\u0004\r<!���\u0016��\t��\u0013%^%�R�8\u001ca�M٬M$b���:��|S\r7�ܟƆ\u0014߼j\u000bnm\u0013�\n-ڡ�TNsü>TU)�,j�< \u001cӞ��ͬ�?_i#�t��\bJ�\u0006\u001c�C<fsK]���^�v�\u0001�]�>�\u00108\u000e\u001f2�\u0006AȤ-\u0002A�2S��C\u00193���V7�f�~�U\n-ۦ�L\u0011���Ŵ�}��FB>�\u0018�\u0005�?\u001b����'Vo��\\aY\u001a�\u0001;��\u0004�X��l*+�X��F^y��P@pl:0醠�T�f��\u0010/�i,���v�fv���\b����l\u0001&^^��s�bك�y��v�nᘖ���\u001e��~K���娩V(\u0005�I�U�FYp�@���\\6a\u0013R�\bz\u0005��Ĥ\u001b�A!~آ2��p,Ϭ�z\u001a�K��\u0012D�U\u0002\u0011��\u001cq��An_<�'~��3k\u000f�w\u000bǘ&�\u0018��3#˩���޽\rD ���\u001e\u001e�\u0017.��������\u0004C�-��1���%t�,�M�r��4\u0003\u0006y�nj\u001f���\u0010���\u0002�\u000e8\u0004C�R�\f�&�e�φ���C�|���W8Q<��eQ?<>Ŋ��X\u000e����\u0017�GЧ��[goc�sm\u0004|��p:�I7h\r�\\�e�5s�=͜=�ǿL�2A?\u0016\u0010[�\u0013��e\u001b��W\u0002�c��_\r���$X��=�\u0002��|�0!\\n�\u000f�Tr��{�8¤�\u001af�П��MpӍ;�\u001b\u000e�2Ak�d�M�\r>� \u0002��<�,i���\u0018<�ϴ)Qn��,�\u001e�R���4\u000f,�M{\u000e��8¶\u0000�|\u0012&Ϫ&_px�OqV�\u001fƈ�!\u001e}���?j%T%86 t���\u0005oj>!Q`\u0018B찃�\u0012��\n-0��(�]\u0010��5hܖ�u�x��8�-6�}\u0014���/���)K��>JE�����{W��\u001b4\u001f�e�w�\u000eX��<(|��)��9)ܞ=\f\u001f\u001bd��*��h\u0000��\u0015[�$X����^�P�Ũ�a^{��;n�M�\u000f�2�||&��RQ\u0013)\u0017D�eg�y�N\u0012�(F�\u001bdʌ*�g\u0010n�A�h���{x�6ʃ�e\u0001�'br\u0006h\rVI\u0013\t\u000b��46$�=-Aأ\u0018��r\u001a\u001b��cEB~A;�]L�0��HX0L͖w�h���0�\"\u0002�\r\u000e�.E\u000f���\u0014=���S�p�\u000bR�v�ޱ�t\u0000\u0000\u0000\u0000IEND�B`�\n\\n+\u0000\u0000\u0000\rIHDR\u0000\u0000\u00000\u0000\u0000\u00000\b\u0006\u0000\u0000\u0000W\u0002��\u0000\u0000\u0005�IDATx\u0001��Ah\u001d�\u001d\u0007���x�\u001c2��\rr蘰\u001e\n++�Be�e�Ce)������B\u000b�U��AK\u0015+lء\"�\u000e\u0005\u0005\u001d\n+-�bJ#\n+\u0016,,c\u0001;\u0014�A��\u000ez�!�\b=��Ã�\u0002�`M�{I�Z\u0018���[�n�m�\u001cI�r\u001f4�cIF0�\u0003{\t�A�=����0��$-߳�\u0015�4�CUնv�h�\u0011|�q��.%ن�U��C��^�\u0001\u001c�\tw� �V�E�a�]H2��M|�1}4�\"�q\u001c�\u0015\u001dǒ\u001c�\u0006I\u0006�\u0018��8��$��@�\u0007�\u000f\u001c�\r���$\u0007�\"^�_q\u001d��v�\u0011�\u001b� N��\u0000ƭQ�1|�!,�BU-�aY��x\u001b�WճX�\u0006<�K8�d����rU]�����2vZE�f�\u0013�\u0004_�Q��U4�l�y\\�_��a��!��\u0012>J2��$�؉s��\u001c~�d@\u000fI��\t��O؁Q��U4�\u0011\u0006�\u0005��\u0013\u001cӱ��\u0016�(6�|��ۍc\u0000�}�4\u00061��$#�\u0012[�hU���ژ�lU-ZE\u0003O�\u0010\u001e���\u0011~��eYU}��\u0018��n�\u0007sUu�-��\n+�b�\n+I���0�\u0007��eI6b\u0004g�A��ft�IJ����B���J�uU�aY�\u0016�pTwg�$ͪj'ـw1�70YUm�څ6f�ACoװ�-��$��T�1\u001d�:�u�1Z\u0018M�\u0015�c;�U�SU��]\u0013���Ek���\"���)��$��\u001f�U5�����y���p\u0003\u000fU��VH��\b�Z��޾A�\n+U��n��S��\u001e�\f`\u001e�0�������ƌ5j�m\u0001-]T�u<�\u0001\u001d\u001f�\"�&|��x��\u001e��%���lU-Z�����c=T�U�F\u001b/Y!�8��\u0006�����G�\u0016Fq�\u001dh�m\u0011-}T�l�'�n�WՉ$M���q\u0001���Ս��G�\r؆m����\"\u0006�\fT�\r=T�{I6��$�؏a�PU'��\u001e�Vբ��\f`\u000b�1��a�o}��ۼ�!\\��q�\u0002ob\u0001;��5J��(^Kr\u0000��9�`\u0000m\\��q\t��\u001cv4���c��=�a������Ό��c8�_�_x\u000e\u000f�\u0007U�\u0010��\u0017s�QUKM�-�\u0018�C�\r8�1��g������e<�KUu�\n+Iv�\f.aGU-Y��CU]O�FK\u0017I��\u0003�𛪚q\u0017��0��UuA\u0017Iv�\ffp�������<����0�p\u0005�T�U]$i�<�YU't7�&�u�d\u0017�`\u0006����\u0016M�-`���\f�M\u001c�{x��n��\u0004����`��歐�0^�\f�UU�\n+M�}��eI6�\u0003l¡�zO\u001fI��i���fu�d\u0003Fq�\n+I�`\n+�q��ںh�o\u0011?I�\u0017oc\u0001\u000fW�e}$\u0019�)\\�\t��D\u0013�n��\b�p��&����5��\bfp����#I\u0013��ľ�j�m\u0002sU5�$��U���I�h�o\u0001M��5\u001e���؎ǫj^\u000fI\u00061�snJ2�Wq��&�AS�`\u0001��{<�d\u001e�0]Usn�d\u0014�㕪���q\f`ڲ$S8���ek��GU�1��$M�b/��\u000fI\u0016�!��W8�K8nu\u0013����$S8�ɪ:�\u000e���d\u0004�0��h��I�UU7��d\u0010��\u000b�)\u000eb��N�C�\u001eH�\u0015��\u0007����8���Zr�$\u0007p\n+_`+&��\rw��cI6a/&�\u001570�s����$�1�6�Uմ�T�GI\u001e�8&0�6�0�&�Uմ�A�O�\fa'�`\u0018��jںu���_�/V[A�_�c�\u0000\u0000\u0000\u0000IEND�B`�\n\\n"
607
- },
608
- {
609
- "path": "native/android/app/src/main/res/drawable-xxhdpi/notification_icon.png",
610
- "status": "modified",
611
- "diff": "Index: native/android/app/src/main/res/drawable-xxhdpi/notification_icon.png\n===================================================================\n--- native/android/app/src/main/res/drawable-xxhdpi/notification_icon.png\t74465a3 (parent)\n+++ native/android/app/src/main/res/drawable-xxhdpi/notification_icon.png\t7d171ee (commit)\n@@ -1,30 +1,25 @@\n �PNG\r\n \u001a\n-\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000H\u0000\u0000\u0000H\b\u0006\u0000\u0000\u0000U��G\u0000\u0000\u0015�IDATx\u0001��\u0007��u���������9gz��I!\u0001B�ATJ$!�\"��\\a\u0015DA�¢����\u0004\u0010\u0002RteWD\bj (뢏��M@@A\u0004�HBKH�I&�眙���[2�'�I&�\t\n-�y���'f�T\u0004\u0014\u0010vO\u0001at\u0014\u0010@\u0001\u0001\u0014\u0010ޥ�\u0000\n-\b����^\n-\b�R�%�L\u0001\u0001\u0014\u0010v���^\n-\b� \"J�\u0016\b�02\u0005��SvO\u0018�\u0002��)\"��\u0002ž) (�v*`+�){��?�=SFG\u0019=et�w��\u000e�~#2�7\"�~#2�7\"�~#2�7\"�~#2|�\u0018\u000bD��0|�\u0018\u000bri����0|H�\u000ehQ�wv95�A��C��! \u0002}��ܿ��څS9�35�Y0�\u000f��\u0003�\n-�\u0003��\u0016'�\\�eA��0��`\u0007\u0004U>P�\u000f�1P\u0004f|4��)Q|\u001fjjB�Q\u000bcx߉�\b�0*�10\u0006l\u001b,�}f�Pt�y''��lD �\bP���<E����mC&�l�T�9E�3�^\u0012�\\��z���\u001b}İOT�\"\u0018`�a\t\n-y���\u0002ѨŌ\u0019A�\u0019E\f�@\u0010ֶ��8<����ȄI!|UD\u0018�a/\u0018\u0003�<\u001c|\\�g_>�%��P�\u00180��mÆ�>_���ں\b/���WW�\b�-\u000e>\"L�&Ų�}a\fx%H�\f���o�{ \u0017\\��I�lݬ\u0018�\u0011ٌ�10�V�\u001d\u001a�k�1~B���(}�\u001e߻���\u00048%\u0010a�<\u0017\u0012-�c>RI��|y\u001f'�\u0010CDhi\t3�\"�1�l��W\u000en\rsޗ\u001a8aN\u0015������\u0013O\f�2��{��0\n-\"�K)�\u0015\u00167�8�\t\u0013�\u0010\u0011�a��l#�>��um>��0ZƂ�\u0001��s���Ghk���K9:��p\u001c���0�;���`۰y��Qǖ���œ�5�B\u0006UX�j��7�\u0004B�*#�\u0019\u0005߅��!�[�JSs���\u0019jk�\u0004��`�p�խ�m-�\u0015CD�\u0004��#߃���Skp\u001c�'\u001e�2����\n-d�.��a&%\r����*�\"\u0006J��s\u0003�|[\u001d�Ϩ\u0007�m\u001d9jj��r\u001e\u000f��\u0017+�\u0014\u000b ˆ\f#\u0010\u00011\u0010��|eA\u000b�\u000e���\u000b�<�t\u000bC\u0019���\"/�؋�)_�b<�fDq� ˆT�P��)Q�ψ��[�g\u0006\t��͍.�!�x<Ȅ�l�\u0019E�Q1\u0002�\u001e��!½���ɳ\u001b�\\e��-��� �\b��ΰau��\u0001\u0011��0\u0002c�c��U׏㘏V�ё㊋7���\"�+��<�@\u0017�����\u0013�|eA\u000bY�\b\u0004\u0005\u0011v˲�6p�\u0004\u0015�\u0001���!ֽY \u0014\u0012\n-E�t�!\u0014��m\n-�ʱG\"\u0010\f\t����Ϋ�\u001bwM��C����s�5����^�\u001ePF hX���δ�\u0015`T\f�#З5���\t|��*��j���\\KY��d}J%%\u00184�ʢ\u0005\u001bX��\u0010�\u001d�d���l�S\\e����l>1�\u0012Dx�?�(��@����Qb��\t\u0011����\u000e�R\t��\u0016\u000f�h\u0012W-�D]}����6>s�\u001b<�h?_�|\u0015\u0013Z�hk���\u001f�\u0018� �2*�]�\u0003��\u000b�^���Sjغ%��~�I�d�Y\u001fM2��Jb1\u001bc\t����¢�7�yS�\u0003�%Y����+x�*�a�ж����\u001244Dho����Y\u001a�\u0005���\u0012ж����4Ԇ\t*��\u0013\u001106�\r)G\u001d�`��'qđ��t\u0017��6�R�%Qa8�\u0013�l���\u001e\n-�b\f�2*6���M>�]��9��\u0011\f\u001a�Q�/^�L,f\u0011�X\u0004�\u0006�2d�\u001c��\u0010�\n-\u001b����v\u0016-n���tn+��;��h1���\b�\u0012���s��UX���_����\tnIq�7�*�8>--!�+\r���������v�\u0005�h�O�Q^\u0011�����vk;�����\f:�हq��ctu�ypy���\u0006�\b\b�b�g���m�i�����-�B��\u0013\b\b���P�c��\u001c��J\u001c>+I��&\u0014��=HF�ٕ)\u0016�\u0006��:�/\\܂S���}=ī\u0005�\u0003\u0011\u0018�U���2�\u001fX�֭9�yw?\u0013�[\u0014\u000b�*�@gw�Bޣi\\�d�E:�aY��� ��IS#|si33\u000f�`p���\u000fo��j'\u0012\u0013���AE�p�Y��*O=ه�*aE\u0000a�l�����\t��\u0014��O����5E�w\u0015I�\u001c�~���14��r�$���$\u001e�0\n-�\u000fu�����xhJ\u0007����\u000b/i�/�Y�6Cy��\n-\u001a��ͮd�W^Lc\\\u0017�\u0005U@@<�\r���>�\u001d�q�=�\u0000\u0014�r��5\\|Q=\r�Qֿ=������c)j�\r(�>\u0014J0iz��3b���X�X\u001a_���+6N!\u0010�L�㛋��>HH�lA\u0000˅`\u0004��R(xXF�L\u001a\u0002F\u0001�s�����\u0007�i�\u0010b��Z��i\u001c\u000b���@W\u0011U�o\n-r��r��\u001c�Z�&\u001c\u0000�\u0001\u0011�\u0005-p�.�CCC���\u0000Vw�lA��\u0016�]��ig�\u0011�ؼ�f��.܄햨,\u0013�\u0007U�\u0003\u0002=�駗�,\u000f���^�^�#^\u0001��\u0015�.\u0018[�U\t�j\bE��\u0005!\u000b�\u0010 0��t�����:\u000b+ �0�)*X>�-n�_�r��8w��J�\"���1ǔ��\u0014��C�[�!\u0012\u0000\u0011v�\u00020�ﱵ�@(l\u0018_\u0013��B�\t\u001e|d:�>��Ԁ��wn�Sg��<�\u0012�\b�*��W\u0012�Ü0�\u0012U��G;ɋ\"�=�]\u0010\u0001��\f\u0013\u0010a\u0007\u0001rF\u0019*z\u0018\u0011�\t\u000b\u0014\u0010@A\f�@��s�\rm�N�0yJ�%KƳ��\r�rZ�`����tty�4\u0018�c\u0007\u0011�K�l�R$\u00100L:$Ju���\u00176RV\u0016`��!n�~3�����RpJ�0a�e��v�\u001b�TPU\u001db��A�}:˸&���lvG�%\u0005D _�\u0010\u0003���w�\n-��.U(�\b����Wo��[�yh97/�Ȥ�e��%��i��*���D\u0005:�\u0015�,�Yg�\u0010\b\u0018�\u0011V>��-K:ps\u000ee���\u001e\u0002�\u001eTVZ\u001cb5��<��\u001e�\"\u0002ʘ\u0018��2,�q\u0011\u0011�q\u001bw\b�\u0001�\u001d|\u001fB6t���OWn���ȑ�*�'\u0002<�����.�\u0010��\u000e\"�+D\u0012�D�b�h�&�r�?W���7b\u0017\u001d�\u0006|��\u0018\u0003�z|��t\u0015u�\u0011־5�C����\u001b|�11�-aX>�!\u0002��!�\u000b\"��^�\u0010�\bkW\r���m�u}<Oi�\\�,\"�\u0002�0ˆ��\u0012-����&��|\u000b������s�\u0015o�be��)\u0006_�-Dž�*�ٳ�(\u0016=�z2E��p\u001de�l���*d�\u001eۅÆ�-�(���\u0010�\t�/�c��1N�_�9�5��\u0014��.e\t!\u0010\u0014�vyL=0���[�6�\f�\u0012\u001eX�����`ԣ�\\�\\vK\u0015�.L>���Ӣ���x�4V\b\\\u0007D\u0018\u0013�1��P\u0018t�.\u001c�0\u0001\u0010�n��䉆\u0005״�x�����z�\u0014\u0016/�@GW\tw\u0010.8�����L$b���C,�w\u000b��|�\u0003&X8%Pe�\u0014\b��|\n->wA5�D��>���^+P[/�2f6c\u0010�����<%\u00142ؖ ��\u0000żR\u001d���蠲2�G�����5����q��\r�:�\u0012�\u0016~��N�~����E��78%e;Uv�2P�+��G9hF�b�㿞�%X�>�\u0019�@\u0004ry\u001f��\t\u0004\r�\u0012P��\u0016(�\\���\"\u001c7��C\u000e�SS\u001bbh�a��\u000e���\u001ej�J�Zp\u001dFŲ��\r\u001eW-���<Ȫ?���\u0017�L�d�:�\u0013���\n-vP\u0018L�8�O(h\bX�*{$\u0002\u0001\u0003�{\u001c֯�2�0�UA~��\u0001n����7�k�(\u0015@�Q\u0011�L\u0016�8$�q'Ԓ͸<p_\u0017��-D\u0004�R�S�\u001f\u0005�\u001d�0�\u001d�nٌ�� ��q\u001c\u001f;`��\f����[v\u0000����\u000f��K\u001b�ȱ�\f\r9<��\u001e���\u000e�\u0005��zC1���WR\u0003�9��\"\u001a���+�<�2K؆�n\u000f1 \u0002\b�\u0010V!\u001a�@T��`Y`,P\u0005�c'6c`,��PJE�HĦ�ޢ�\u001b\u0010@y\u000f\u00110\u0016�������3�6��\u0018����������i\u0002F�G\u0005�\u0003\u0011FM\u0015\\\u0017\u001a\u001bmf�T�*�ׇ���\td2.��G.�\u001dr�f=2\u0019�\\ʥ����|R}\u001e�[��!�pThm1�%�1\u0010\u0003�\u0019%_p�'\u0002ԴXlnW\u0002!�Wv0\u0006r9�\b\u0007����;��|���\u0015},�y\u0013ۺ=�*\u0005�\u0001�g�Y\u0016��p�)�L�\u001c�XtI&\u0003|lv\u0005�%\u0018#\u0018#\u0018\u0003\"�v��>���>Ţ���<��.���M ,����\u0018�@\u000e�P�,���P�C \f(�\u0001\u0001����8��K.i��i\tz{�,_���Xڋ\u0018��J�sA��X6�|\u000fT�%;(��p��\u0015��\u0016\u000f-k��g\t�lj+-*�,\u0012�\u0016�\n-��2�p�\"\u0016�\b\u0004�h̦�1JY\u0019�@]]\u0004|�/��B�ò\f�r�€\u0012�\u0014\u0010p<\u0018\u001cR���\u001a���\u0016��A:;s\\�`\u0003���\u001c�q0��K���M>�\u0007��\r�b'\"P�(G\u001d\u0017��q��\u0012?�����x\u000eX\u0002\"�zPJC�@�\u0016�A��\u00196_�����(��˿����[I\u0006؉�\u0018\b�\u0010�f\\�\u0011�+l�E�\u0013�PP��a���ff\u001eVA&���O:�ᆭT���\n-pK���\u00140��\n-7��Lcc�;o�Bv��\u0018��X�v��\u001d��\u0011��<��[X��e�D\u000b�\u00061`\f\bBY%$|�\u001d��\u0012\\~]\u000b\r\rQ�6�x��\u000e�.룵���@���\u0019+�l�C\u0004�q�|Q)d�#�\\uC\u000b�MQ�������y�\u0014\u0015Q�\f�%@�%;\b�C�%���w�j�s[�p�b0�!\n-\"\f\u0013�Ԑ��c\"L�^N_o��\u001f\u001a`�x\u000b�e�z�`l%;\u0004�m8�\u001a>wA\u0013���믥Y��h{5ǸZ�))�b3\u0006\n-X@.�]$j(��K�h`��Z��\u0001֬N�pa;���D#�\u0001U@�%; �k��:�=��`а����__�*\u0001�� \u0006�i�yU�B�_?7H�\"�r�/)�\u0014� �oV\u001a&\u0007����9��������ɭ�m�8.�Z���-�1�@\u0018\u001c�خ�9�/��@�LM2�v���6�����F \u0014\u0002U@�=�m}>_����}�D�Y�����C\u000f\u000e0e��ux�b\u000e�\u001f\u0014��S�\u0018H�x��^$�8%�\t�\u0006�����,�v\"e�\u0000�]E�~�FV�\"M�8�+����f,\u0014\"!\u0018\u001a��=e����k���'O?���BP��\u0019�e�`\u000f�;����4�|������\u0007��:�P*�\b;��/p�\tI�kB��R��k��\u0002��m�@�R�\u0018�/��O�\u0011\fY��~��N\u0007�Vg\u00197�� �\u001eٌ�\u0002�ra0塀1��y��Z���2tu���P�h��B���\u000f\"���� ?�\u001czt��j\u000bUU!~��V���EE\f\u001c\u0007D�A\u0015Ba!�7�<��P�b�^��>͵B $ttyL�\u0012b��V�\u001f\u001cǶ�\u0007�o��{�\t�S�4�.�f�6_����\n-��й��7>���,nsȡ\u0015�qV-s��(�2���GW�G�\u0006�\u0005;\u0000�0�\u0018(栱9ķ�FyE���͕_�ĸ:��\u0013;\u0000���9��`�\u0019�tlͱhA\u001b�uBуR^8��\u001an�}\n-�-\u0011�~+˝__�=���\\'��-�f,\u0014\u0014\u0018\u0018t�k�V�j��:��\u0013>\u001e���ʘ1����U�>W��?\f�ܳ��{5˖�\"�A��\u0012|\u000b\u0012�!�]<�X,�3O���[8`���[�\u0019��OVc���\u001e\u001c�LQ\b�l�Y��q'V\u0011\fZ<��.��N'��.2u��TT��f\u001f��\u0010\t\t����_H��g�����˘=��\u0003\u000fJ2��Z>v|\u0015�\u0001�W^J��K)^��\u0010=y��ۛ�qX��V\u000f��om!\u0010��\\v�\u0018��Q>~|��S\u0013tw����i�\"Lm�r��f\u000e��$�sY��6n����\u0004�k\u0004�\u0004\bcb�/\u0014|\u0005ˀe\u000bV\u0004\u0012���L���3���9\tf�*g�!q�R��gՑN���\\��c��\u0014������\bąH\u0000PP\u0005�\u0001a��A@�3��\u001e\u0011X�����\u0012W.l��3\u001b��-V�!͝��ycU����RAQ\u001f\u0010��jm�h\u0011�J\u0018�>�\u000e��Bs�A�����+/�y�\u0014o��\u0003�%����� F�\\��� �r�L�g�ϥ�_\t\u0005�P�a�\u000f�\u0003��D���\u0006ry�g�Ϲ��pƙ�x����\u001e\u0016.�LwO��\u001a�)�\b�ʷZ�/Z��H\u0004P�}\u0010��\u0010���!���\u0005�z2��GR��<��RU\u001df��\u0004G\u001f�`��\n-�N���\u0010z;\u001d^]�\u0012�d�A=8�j�9��|�e��2\u000e;��L��\u001b����{]�>���{ �����+R\u0005�\u0018\u0016�A�D\u0019 .�����\u001b�9rf�\u0013�s�Q�L?0��9��;��L���U\u0003<�d��Wf�T\ts�UaYB}C�R���UinZ���\u001b\u001d\u001a�\r�\"�>�+�Ĭ���!U\u0010\u0001�\u0006\u0011(�`ۀOe��4!�Q3\u0013||N�ISbTׄq\u001c��\u0002�=%\u000e>$�e\tٌˏ\u001f���\u0007�\u0018�rIT\t���M\u0001��oL�a��0;\b-�\u0006|��Z�\u0017�{����R\u001d\rr�W�\u001c}L\u0005\u0013&��2.�*\f\u000e����\r<�� \u00151�%\u0005����\u0003�\n-(�\u0002\u0006�\u0004�m��\"�^�I���#�\fq���$\u0013\u0001���ޭ\u000eu�\u0004����CD\u0004|\u000fJ��B�x�\b�or��ovb#ē�l\u0012J%\u0010����J��\u0018\u0016�\u0014�D\u0010����A��\t��\u000f�\u000f�\u0007ð߈\f��Ȱ߈\f��Ȱ߈\f��Ȱ߈\f���\u0000;J+\u001e��'q\u0000\u0000\u0000\u0000IEND�B`�\n\\n+\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000H\u0000\u0000\u0000H\b\u0006\u0000\u0000\u0000U��G\u0000\u0000\n+�IDATx\u0001��\u000b��\u0005�\u0000�����[��\u000e\u000b�%�'\r��[ϵ�\u0004��f�3\u000e��U�ըgؕ\u00167r\bX�z��\u001e��1�i��hK磻\u001eF����Erʭ\u0011�\bA7��\u0003|ϙ\u001f3����?�\u00063���aÆ\r\u001b6lذ���.��'�Ә\u0017\u0011O�\r5�E2�\u0005\u0017c\u001a�̛7������Lɮs :\u0015&b��Pɮ3\u0005�\u0015Z�a7T�\u000bdf\u0003��V�\u0016���\u001ca7S֏�,��\n+ߏ�͆�x\u001c���\u0016��\u000f�\u0005�v#%}��\u0006Lǵ�\u0016�3��И��(nT�@�wIf�f�i�٤\u000e%}�8��x��\u0002|� e�\u0018����Èx\u001e]h�_\u001bb�٘���]܅�ԡd\u00072�\u0013\u000b1�[ڰ03�\fN'�b\r\u001eWX�p�!��{�\u001c܌\t\n+ϨC�;df\u001b.E\u001b\u001eE���cNf6�\t�وN쇕X��\u0014�h�̱�@f��?�\u00124+��\u000fԡd;�9\u0001��G�\u00043�\u000b�`\u0011V�opYf�~\u0007`\u0012\u001apkDT\u0015Vc\t\u001a�i�����ى\u001f�lT�QaQD��C�6�ٌ���u�\u0015\u0011��<*�%.�\u0016|\u0002�_\u001bڱ\u001e��-\u001b�\f����\u0015;!3��Y�\u0013�\u001c��\u0011o�K�Jޔ��8\u001f'�5̉�G\u0015�\n+\r�\u000fW�\u0011���i�3\r\u0015<\u0018\u0011=�����)֡\u0015mꔙM�\u0004\u000bp\u0010�\u000b�a%Z�\u001aϩS93��l�D\u000f\u0016���\u001c�=��\u0018�+1\u0012��3��\u0007#b�~df\u0013NC\u0015w�C��YL�љ���j\u0000�وN��1x\u0019W�\"�\u001eW#�(^P�\u0012>��Є-���p;��L�\n+\u001a#b=��wЊ98����\u0019+��;DD\u000f\u001eA\u0013\u000e��js\u0016n��X�Y�\u0013\u0011�\u0018��x\u001d�E�Fu*a.F)4��8\u000eG�\u0003\u0018�Ќ&o����'�A'.��&����ct۱�P8\u0012\u0007�GfV2�\",���\u0005>�oFD�B\u0007Z�\u0016O�\t%\\��p\u0015�ř��C�/>�Є\u0011������_�\u0018ܒ���@f\u001e��X��\"b�\u001d���X�6t�Cf\u001e�{p\t\u001ap\r>\u0012\u0011K#��M�ل��\u0005OD�2;�\u001c\u0011��Gf�(�F��-�\u0002\\�\u0013�lf^\u0011\u0011=�n2އUX����Ѹ�v2���\u0000�X���+\"6x�\u0016���b;�d\u0000\u0011QUhD��DD\u0015��JTp\u001eN���\u001c�#�'��7��\bzp|f6�&3+�\u0007܀v��9�9\"6�C�Ё^t�Ie�Y�v��\u001d\"b\u0013���\u0003q\u0016���\u0017�HDlE+&c+�\u0019\u0011[���\u0004>�\u00133�\u001e�\u001fWb\u001azq\u000b�\u0012\u0011��v\u0012���\u0011��N*�MU��o\u000bp\u001f\u001a�\u0010�\n+�a\u001cV�I\u0003{\t˱\u0015�p\u001c��iX����ֿ�،�\u0018��ڬVhշ��\fO�\u0010���V��2\u0016GD�\u0000\"b\u0013��U\u001c��0\u0019+�\u0005,��\u001e���CЊ��3�PV��\n+#�!\"\u0012?����=L�\u001d���X�\u0006�Y�\u000bx\u0015mh@\u0017΍�5js\u0006\u0012?D�A(�M�\u0006\u0010\u0011��%�\u001aS\u0014�a��L�l�b=�\u0015\u001f\u0011k� 3Ga\u0012^�O#b�A(�M�B��<��b\u000eF�\u0019�d\u0000�9\rW��؀�⑈xM�:�>��2�TV��\n+��AD�f�5�\u0003s�)<�;�@f��\u0002|\u0016����xZ\u001d2s\u0004&a\fe\u0006��6�\n+�j\u0014\u0011���:\u000e�t���_F�R���v,�q؊��ƈX�~-�\u0010\u001a��N��\u0012�Q-�M\u000f��@u���̜�ј�K3�X��\r8\u0016�p$�c!���\u001e;�\u0005\u001d�E�:df\u0005�8\u0018G�\rݡ\u0006�y\u0018��8�'\"�ꐙ��\u000e\u0013q\u001f��\u0014��&|\u001f\u0017E�R�����B�\u001d\u0011�{��l�>�\u0007���q0\u000e�_x������b=ơ\u0015��!\"�g�\u001c܃\u0013Њ\u000e��n̍�U\u0006o*6�;ޔ�e쏃ъ6�)�a\u001c��P�(l�\u0012�-�M\u0015=\n+\u0015;�\u0007�\u0015\u0017�\u0003��/�5\"�\u001a��<\u0004��\"���y8\f���\u0018�\n+J\n+�x\u0018��$���\n+?�\u0005x��6��[��X�\u000e�ٌO�<lƳ�\u0011\u0011K\r�3Ѐ1�R���x\t/`\u0005\u001e��X\u0015\u0011��,�P܏\u0016<����5�TV�*z\u0014F�Cf\u001e�/�\u0013H,�ˆXn�d�(L�f��*��*t�\u0019tGD��df#�b.Z�\u0004\u0017D�\u001a۔զ\u0007�\u0014ޯF�ن;юF|\u0015��EC�\u0003�ï1\u000bK�JD��߱�\u0012\u0007b9.���SV��؜��Cb�\u0001d�H����\u000f+1'\"�5�2s\u0004&a\f���\"��\u001f�وcq\u000f\u001a�\u0018gG�\u001a�PV���E�~df3��S�\u001b�a!��AfN@\u0007\u001e���\f�\u0005\u001fB\u0003\u001e���~df#N�B4�~̊�5v��v�C\u0015\u0015}��\t�\n+NP�\u0019�#b�\u001adf\u0013n�\u0007�E�n`-�P�2�c�\u0010\u0007b1f�y}(��+؄=3sLD��6�ل�1\u000f�X��𭈨�Af��q\u0014���js2��\u0015\u0011k�!3+8\u0011��\u0001�qJD��GY�zPE\u0003\u001am���1\u0003_�^�!�`IDlV��lĩ����u�R��\n+��!3+�\ff�\u0001�cVD�\u001a@Y�֣\u0017\u0015����l�B\u001c���.\\\u0016\u0011+��\u0010�ľ�\u001a�\"�j\u0000�ن���R}���\u0018�;1\u000fϫAY�^��\u0007Fe�Gq\u001b�b\r������Cf6�z\u001c��࢈�U�Sр\u001f�y\u0015��5���\u00111S\u001d�j�\u0001o`$�F;Z�\u0004.��{�)3+�\":�\n+\u0017ED�\u001adf\u0005S�\tK��df\u0005��l��\u0016,P��\u001aE�\u0006�����\u0003p\u001f���ꔙ\r8\u0011ga#��OԮ\u001dmX��\u0011��۝��\u0018�۰ \"^Q����C ЋE(a/�W�I��\u0016|\r�GDU\r2�\u0001�q\u0000\u001e�\u0013���Q��ϡ\u0007�F�L;��>��\u0000���;�\u001a�e��X�e\u0011�I?2�\u0019sІ�X\u0018\u0011U��\u000fG�\u0011\u000fDD՛2s\u0014fc\u00066�&\\a\u0010���0~�6L��hC\u001bN�Z<������c�a\n+�1'\"֫�Xt*ty�\\�@\u0005��\u001d\u0011�� �A��\u0006��T��\u0016�`����7�\b���q:��\u0012.ĝ\u0011�E\u001d2��X���8!3Ga.>�W0?\"�5\u0004�\u0006!\"�`\u0005Vd��@\u0007�\u0012\u001d� .�+X�_�h�\u001b�(\"���I\n+�3s\f��\u0019x\u0005\u000bp�!\u0012�\u0005�ق\u0016L�'p8\u001a�H�p\u0007��򈨪Qf��sX��Ņ8\u0006\u0015��m\u0011�c��?��l��p\u001a�c,�TX�.t�Y���^}�̋�\u0000�!Љ\u0017�Ո��\u0010\u000bD�و\t8\u0002G�\bL@#����=,�3\u0011��v2��o�8lF\t�1\u0017�F�&C,�\"��'�b<N��Ќ�؀��$\u0016�+\"6f�Q�\u001d�\u0014zq\n+\u001e���wA�Mdf\u0005�8\r\u0013т\u0016�^t��c0\u0002������.\n+���,a\f:p4\u000eF\u0007���9���\u0011Q�.\n+����\u0013�\u0016����\u000fg�ረ\u001a6lذaÆ\r\u001b6lW�?x�\n+��\u0016�F\u0000\u0000\u0000\u0000IEND�B`�\n\\n"
612
- },
613
- {
614
- "path": "native/android/app/src/main/res/drawable-xxxhdpi/notification_icon.png",
615
- "status": "modified",
616
- "diff": "Index: native/android/app/src/main/res/drawable-xxxhdpi/notification_icon.png\n===================================================================\n--- native/android/app/src/main/res/drawable-xxxhdpi/notification_icon.png\t74465a3 (parent)\n+++ native/android/app/src/main/res/drawable-xxxhdpi/notification_icon.png\t7d171ee (commit)\n@@ -1,52 +1,12 @@\n �PNG\r\n \u001a\n-\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000`\u0000\u0000\u0000`\b\u0006\u0000\u0000\u0000�w8\u0000\u0000#SIDATx\u0001��\t��u�������~��oN\u0018��F<QDE\"\u0019\u000e\u000fd4�ʵ��mW�\u000eS�l��r7m�,��03�R+@<8<R<SSD\u0011��af�������~���,\u0016Q�1b\u001f<�R?��,��M�w$\n-* \n-*t\u0013\u0005\u0015ޑ(��(�T@�n*t\u0013��\n-��\n-oK�n*�F\u0014T�&�.Tx[��\u0002��B7QP\u0001QP�M��\u0002��\u0002��›D�\u0002��T��f\\���&�\u0002��\u0014\u0010vR@\u0000a�\u0014\u0010v��\u0000�_\t%��\u0000\n-\u0018vR@؝��\u0001\u0014\u0010�J\u0001�Nʮ\u0004\u0010@\u0001\u0001\u0014\u0010v��a'\u0005��\f��aϔ�\f�( Ơ��*�tSvRޚ�+��)�S�����Wʞ);)�R�L�I�Iyk��])�L�j\u0005Q�\u0002�AoJ7\u0015�A�����p�~e8h�2\u001c�_\u0019\u000eگ\f\u0007�W���+�A���\u0000�\n-(\u0007,�\u0001L\u0015\"1��@�\u0003��\u0000�\n-�(�l\u000eȦ\u0015�\u0003U\u000e8�\u0003�*�\u001e4��\u001c:)Bu?��\u0016�q9�\u0018\u000e0j!�\u0010�=\u0011p�\u0017*���\u0013��Oƒ�\u0014�\"�p@1\u001ch\u0004\u0010hD9�C\u0003\u0010�ڡ�L�^��?\u0007����\u0001�p\u0000Q�D���\u0011�+���nx\u0019�Jh��(kP\\\u000fP\u000e\u0018�\u0003�\b�\u0003k��2�/�\bA��\u0006\u000f�\u0003�1��ߕZP�{�ҋ�\u0001UP�}�z���r��Q�8���տ��* �]��\u0017\u0001�\u0013T��Wl\u0000\b{��\u000bT!\u001a\u0013\u001c\u0017\"1�\u0018P�w)�����\u000f}����\b��1B��O�C�\u001d�9�qx)�Bi�к�r�#E\u001e}�H6�\u0018\u0007P���>R\u000b�\u0012aգ>�Q<�\u0003ǻ8\u000e\u0004\u0001��+\u0014\b\u0002e(‰S�\u0012jk��Ғg��r�I�C&{lZ�SV-\u0014\u000b B�S\u0005ǁx�0I��Ύ�ud�>_��z�]\u0016/*؀�b�\u0007�PV)��h���X�ƍ�sߢa�|*@\f�.�B\u0015bqa�c\u0001\u0017~��ڡ�����Ƌi#\u0014O��\u001c\u001ba�\n-��\t�\u0007U��\bVa��<7�ԗ[n=��Vr�ľL��d��\u0001^D�[���\u0006PVi���ȷ�Y�ŗ�f�R��\u0018��\u001f�e�\u0013E⥂*�N���:,�N�1Bh�\u001f�hޞ'了��b��b\\z�*�@Y���\u0011��&e�c����)-�\b���y��.ƌs\b|eo\u0019�\u0003\u001b@�BX���\u0015_Nr�\u0015c0F�}��2c� ~u�\u0000�-*��\u0010l�{�\n-ф�mm�yg�\u00197����\rin�a��\u0016\u001fk�Р�1z\b�C-x\u0011�ƅ��\n-���\u0012�?~\u0018���C\u0015|�\u0012z�\u001d�97K�A\u0006��^sy����J���>�*�׾9�h�!�\u0006U��w�p�m�s�W[9cV��V�1�k\"�y��5\u0001W�g5ɤG�ɥ̈́6o(���Lz��\u001f'\u0014� \u0006Ty�n��B�f�ӯ����A4�Y��\u0019B���\u001a\n-\u0005����a5.�\u000f� �^1�\u000b6����nY��\r\u0011�u�8JK=�EKss��\bo���F��ϖ�la��r�Z�5�@&�\u001c5�a��Մ�����w�L\u001a��rY�Ύ\u0002��>Q��\u001d\n-9�\u0018�3�`\\(�\u0010�/)2�������9��<��J��,\"t[�r+?�Y\u0017u�;�\u0014\u0011��a/Y\u000b�Ra�ƀ�#\r7�2���(�y��C�\u001b\t��\u0001/��\n-\n-���7�=���.��\u0003b\t�Z�\u0015�\u0013�?�s���\f\u001e\\Jh��6�7ǐ�.��\u0014�ښ'T^\u0011a�T�t�b\u001c�\u0013�\u0010+\u0011Taޢ<7\\W�/�<�C�W\u0012��\u0003~t�*��s�\b��`�vF`(\u0016\u0001�]1�\u0005�\u0010/\u0011R�\u0016�\u000b?�c\u001c�\u0007$\b=�d\u0013gt=�#��Q��~��?�i\u001b���\u00187�t,�\u001b��S�1Pe�X\u000bƁF��j\b���\u0005͌Ā�\u0006�i{�Pi�Lj1\u0011��[�\u0011�\rU\u0010�d��᥀��[\u001eyh\u0004_�l,e�\u0011B\u001b7�����šUY�\u001fZIh��\u000en�V\u0007���䳊\b��\u001d��\u0017�BNy�E�\u001d��a�$��k�a�\u001a BW:�G݈\u0018\u001f<i5k�t\u0012\u001aV��7O��g�8��8�#U�D�q}�?_�`��rB�֦���\u0014�����Jh��\u001c!/b\u0018Z\u0017e}F\u0011��\u0011�\u0001x\u0011!�\u0010�-*P��8�W\u001f�\u0007�\r�ǒE[�s�r�^�哟\u0019L�%���\u000e\u0018\u0003\"�k���\n-�K�%O�<��(Ǝ� ��vΟ��i\r\u001e�\u0011�ۋ�H&]@����in�\u0012�pt\u001f\u0016>0��\u001e�\u0013/\u0015Ty[�\u0010K\bϭ�i8�\u000f��K�G���\u0006\u0001�P��q}\u000eU�\r\u001e\u0012'�\"\u0002(�H-��\u000b]��\u0005\u000f\u0017���\u0001\\��C\u0019<��P{[���k\u00053f�Í�\u0017/*c��\n-B۶e���6�Mt)\u0016��°\u0007��z�E��K�,}|\u0004GM�C����\u001d��W�|��Wd-\u0001ٌE�nјC9�M��\\}�J�Y��̓\u0007q�/\u00062q��J���Q$\n-��ʔ#<�>��Ў�\u001cw����\t\u000eż��0�ΰvU�L�'4`@��*({�\u0016\u001c\u0007�\u0015Ÿ\u001e*RVex���LJ\u0013�8�^^��'�^�\u0015W��(ϯ\u000b�}F_b1�ГK�yj�O�R���'.{ \u0002�'��H��\u000f�1��~�������-���\u001a��%��7JUu���(���PR�Ё2�p�\u001f�<Cm�*.��8D��es�+�����\u001e�m�1�J!\u0012\u0015\u0016.-r�M}��?A�\u0017v���\"��GȤ\u0015-(\u00155�ח\u0015H�\n-�����\u001b�\u0016C�+�[�\u0001�K� P�-�s�5U���FPY\u0019%T,Z�x�F>r�\u0016�?�e�L�-�\u0002�̈ṟ}\buv\u0016��f&\u001f��(��[P\u000b�R�G��s�\u0010f�<\bk\u0015\u0011(-���qx���V\t%\u0012.�t�r��\u0011��k�\f\u001a����\u001f�\b|���lڰ�E?�2��%ݡ\u0018�7�\u0001ߧ�ԓ�\u0012��\u0003\u001e���؄�Z@�Z��\n-\u000f=��֚��\u0004\u0015\u0015\u0011��%������_) ��\u0014ֽ\u0014�ڤ<x\u001d�N\u0019D��[���5��M)fO�P,(A\u0000K���ug?��#�^xn\u0007��?Ϝ\u0019\u0011Rm�qxO\\�\u0017U()\u0013\u001e~�ȍ߭⬳�\u00122F\b%J\\z\u0004����tt\u0014Hu\u0016�\u001b�$\u001au��\u001d@\u0010���2gz��_���\u0003cL�1�x���\u001cˆ5/Ӽ�R�ϐ�(���qa��\u0001_���ѣ�\t�^�ɍ7��=-B�KA@-8\u000eoP���;\u0004J�\u001e#�EXz��Z�/\u0002\u0002�B4&D�0q�>?��}c$uuIz,}��K�i=훕��\u0011R��@>�L\u0018�p�\u0007j\b\u0015�\u0001��v;\u0013j\u001d�\u0019�8�g.o!\b`\u0000B�\u000e�L�O$jH��tv\u0014hi��ؘc�\u001c\u001b7�ٴ���\u000b\n-�\r\u0014\u001e|�H�Q�X��\u0005�\u0002\n-��r�\u0007�̘��\u0017�\u0012�#���\u0013㦟��~�+��\u000b�(�E�\u0001D��K�\u0002���/^�\u0010z��f@\bY\u000bƀ\bXK�m[���Q��\u0011Q~���\u0001#\u001c�EE\u0003()\u0013:[-\u000f>�s�O���#\u0016w\b��En�u\u001d\u0017����<\u0006�\u0016:[\u00151\u0010/\u0011�/)r�M}\u001800A�Wڸ��]�vR�\\F�\u0017.��\b五\u0011':�sS�\rk�ѧ�˫/�y�q�MXv\u0012F!�?�0�\u0010C<!\u0014��P4�0\b�Z\u0005\u0001�`\u00038i��'N^łg�\f�-e��2�~v\u0014\u0013�]�i'E\b|�� ݮ�:1�Q\u0013�\bmo���\u001f�1�x�l�b\f;\t\u0004\u0001\fưq}�\u001eCjc�@q\u001c\u0010�d���\"�1\u0013\u001c�}f4\u0013��K��W��\u001f_[�\u001d��3#B.�dҊq�\u0018(\u0016 \u0006�Ϩ!�V���&\u0006b�\r�� \u0006�\u0005�����s\u0005\u0016��EW��)\u000e�O��0#���\u001e#?�\u0012/\u0015�\u0003[�\u0004\u0014\n-�P4j��\u0015��N\u0002���(\u0015�\b\\z�kt�\u0017\b\u001d3�\u000f\u000f�7��\u001eɓ(\u0015�\t��|�*��c��}f\u0007O����\u0005U�$\u0002���\u001f.�[�'��\t\r\u0018\u0018\u0007�h\\p=����g�*�\u0007\u000fc�}\tY�̟��1c_��̙\u001e!ծ\u0004>��͍\b�\u001e���2F�.'�vm��_���S\u001c\n-ye_\u0019�@\u0004�y�;�a�T�DR\b��թt�+]�J>�\u0004>x�кI)�\u0003B��Cy?C\u0010�\b�D �R\u0006�qxrA�k�y�b�\u0012:������\u0003���@4&�\u0011&O�C(�����-\u001c]��\u0017\u0015\u0011va-$�\f�_-�N\u0017\t��\u001b�2�ƍ\u0001���̟[˿_s\b}��\t57g����p�&N��Q3�!ծ\u0018ÛԂ��j,�\u001b�a�\u0010z�&��D�BPd���\r\u0011(�\u0014\u0005�7\b��\boR\u0005��\u0006�\\. \u0014�\f�U���\u0001�����8�nW&�t���\u0014�kW��/�!�\u000b���X���Z��ת�\u001b�$���v~��\f��\u0014!�Uv�\u0010�Ë�\u0007�����7NUu�W;\u0003�\u001b\u0013��{�2jT9=�{���.Z���\u0003\u001afF��T�\u0005��W\n-n\u0004�6\u0005\\��\u0004�\u001f^E�i{��o\u0007ӏ�Ȥ\u00151�3�;\u0011\u0010\u0001���`\foPr����\u0019�+\r�\u001c��\u000b�@g�r��\b�|��?ܳ��1�%_\u001e���q�|�\n-�5��Ë��\"\u0018\u0007�e7��B#�\u001d-yB��o3��v(�F�\u0013��\u0002n��\u001a&Nz\u001d\u001b(�Իt�*� �.�B<!<�*��sk��\u001dBO<��ӯ��\u0013B\u0010�+\f�H\u0001\u0011�e�\u0001!�3��\u001b�\u0019E�݈@�C9�>�Ygo扥�\t�b\u000e?��x��PEh˖.n�����]r]�\b�S0\u0006�\b\u001b7f\t%\u0012.\u001f��0��\"�6�O�^擟n���\u0011\u0012IC�S1\u000e�S�Ġ��r�\u0007\"\u001csl\u001fB��\u0002w�����\\rY�\u0018z�a_) t�d\u0002B�cH�9�v(�\u0000�nT!�Uf��Q?e\r��h'Է&NEe�гO��J[@,.�Evc-Db��@\u0016�l6 ���X�h\u000bS떳t^��\u0019\u00112)�XP�a��Q��|��t\u001f�����~\u0007wߛ���`\u0003z�a_\t�t�t��\u001cWH�9��@���N\u0004\u0002\u001f�\u0011&\u001e���s_g{c�\u001e�t�����i\u0013=2i�8��\u0006�,\u0017ښ,s\u0017\u0015��wC9����\n-�c��(p��`Ƭ����P{�Cg��N�\u0003�.��j�)S�\u0012��\u0003���ĸ\u0012�Z@�5�^�J����\u001e�I�6\u0014#��D �U*j\f-�,W]����\"�X�a���y��EJ��\r�\n-b��J���H�Z�����s�\u0012�:���e���2���6\u001afF\b�\u0014���\u0014����S>����!���^[���~�f�$�\\FA�5.�H\u0000U� t�\u0003z����\u00011�-c��C\u00199��;��ֽ�U�8\u0004�5|�_F�eS��\\�b�L��\u0016��\\(\u0016�y�\n-\\��U|�_FPQ\u0011!�\u0017-��g#\u001f=w\u000b'�w9��#զ�\u00011�#�`\f4\u0003'�֏�*<�`;5\b��\u0016��k\\�����\u0007H�|z��:�\"\u0002\n-\b{f\\Hu(������3�v\r���H<�p�UcX��e^}�Ș�.�<V$�\u000e�\u0017�1}�@zlۚ���7�8��\bA\u0000�\u000e�qA�w�\n-����\t�+��d��rB�ק���;8z�G6���W\u0019z�Z(CH�\u0002�UB%%.!\u0011ޙ�1�jW\u001afF��3�<x�fB��\u0011��i,���]�s|�8Z{(�g\u000e����\u001a9��e̿����\u0011�Y��S�\u0003��5/\u0002+󖆳�Ẇ�Ë��\r�\u0018@�u.�H\u0000k���BgG@\u0010(�\b��C7\u0001����թ���(����瞍q��>\f\u001e\\�\u000fo\u001f�3O�r��É�\u001cB]�\"��u\u001d\u0017��i�z\f\u001c)t�)ƀ\b{O!\u0016\u0017��\u000e��q\u000e?��P��,���\u000eN��R�+\"�:þ\u0012P\u000b�2Hu\u0004��%\u0014O8tS@�;\u0002j�/*3�����5����БGU�ًG\u0011�9�V���g/X��_haΌ\b�\u0007��b\f����\u0002��X_JJ=BO=���/�$�\u0005\u001b�0�\u0002U�ą�vK�h\t�b\u000e!U\u0010��\u0002�+�K�gW�Y�X\u000b!߷��U��c^a�_�̙��թ\u0004E0���\u0018Hw(��z\u001cw|_B�T�{~���\u0004>��\b;)��*��*��*��+.�@\u0015�(t�Z�EK(\u0016s� �\u0000\u0010ޙ�\u0002�\n-a�򀦭ʒE#�6}\u0000�ຆ��\u001c?�q5߼��S>�*��\u0015c\u0000�=Q\u000b�����\"��Y?��\u0011z��\u001d�qO�3O��I[�qA\u0004Ā\b;)��*X\u000bj�Z�ZP\u000bjA\u0015İG.�@\u0015\\O�h�\u0014\n-�P4�P�\u0010\u0004�\boK\u0015\u001c\u0017�%¼E\u0005.</�׮\u0019ɰ�$=��s3���:V<\u001fpƬ\b]�J�+ưo\u0004l\u0000�\u0011N��!T(X���\u0000\u001cV?糼Ţ(�\u0013�@5Bi\u001f!Q)D\u0013\u0010�\n-n\u0004\\O\b�2ʞ��\u0012ǁ�uJ!\u001f\u0010�F\u001c��1�E\u0010\u0003jyKj!�\u00142ie��\u0002?����>QG<�\u0012��\u0002~���|�ӍL��r�4��6E\u0004İO�B\"),}��W��d�$� \u0002�u\u0014_�̒�\u0005�r\u0001�l@&��\n-�J���>�T@�ӧ�#��#��5��Ų��ҲҲ\u0006�\u0005�Nr�\u0013�^b\u001c�9��r\u0001�H�PQcȦ-ф\u0010\u0004��&U\u0010��*��\u000b�T�\u0019�|b\u0014ǟPC�\r�S��5k��/�8�>B>�dR�\u0018z�q@\u0015�QN�ݏ�\bx��oM�����b�\u0012��\u001fX\n-yK6�\u0013�ʟ���uWn��\u0010�\r@�]��\u00121�\u0003%�\u000b\by������b���\u000f\b;�\u0005/\n-�'�]��+_H��+F2`@�\u001eK\u0016m�gm �_h�\u0019!ݡ��\u0018zM4&�y��˟K2fl\u0005�m[�X�z��\n-�h�!\u001ew�F\u001d\"Q�H��y\u0006�5\u0018#�p]��\u001a�J��$���\u000eZ\u001a\u0003\u0012�B\u0010�\u000b�^\"�\u001b�l6 �z��\n-C.�H�\u0001\u0014\u0014\u0014(-\u0017vl�<�r���9�3Ϫ��\f���\u0002?��\u001a.�r\u0007�N��8B�]\u0011\u0001\u0011ޒ*8\u000eDb\u0002\n-ł�� ��\u0012\u0003��,�sV\r�g\b�y�&.����D�D\u0019Zi�Sg��1TV;�W\u001a�+\\��\u001d�I��r��r� ��\u000e-��ê�}��\u001aBzd\u001b+\u001e)Rw��_d7.�D�n�L@�u\re�\u000e�FŌ\u0002�`\\(M\n-\u000f,.r�t�ew��ë���V���Z�[R�aF���R,(\"왂1 \u0006�{�H��\u000e%eB\u0010�\bo�8��d�HC����&�ys\u0017���3f�\u0010�P,(�\u000fżҸ!`�+>��JW\u00016��\u0000M���ۥ}��e}P\u0005�5\u0014��;�X�������\\|\u001fTٍK/�d|B�+$�\u001cR>�b\t�Ze��<��v5�|�p**���E���o䜏m�C]N���P\u0004\u0010a�\u0014�\u0003ƁG��<x�Pjj�\\{�z\u001a7\u0006�K\u0004kٍ\r �\u0014\u0016?Y��\u001c@2�\u0011Z�X\u0013/m\u000b�\u001d��թ8.\u0018\u0003���(\u0015�\u0006d\u0004 pB��e}@�f�_��~�@zl���w���\u001f�$�i'E�g\u0015\u001b�\b�q�e]]\u0001!c�ҤC#J����SE:�+�\u001e�cƬA�غ��\u001b����ޔⴓ\"�E%ݮ���) \u0010+\u0011�]R��uL�>�����⬳73gz�T�b\f�p<Ȧ���]N�ܗP{[�_����\u0013\\�iE\fX\u000bX�?�Z�D!\u001a\u0017�7υ�%��#�\u001b^F��\u001em�_\u001b�\u0013d��\u0019\u0011�\u001d\n-\u0002\"�%�ޢ�A�J��(+w\u0019����\u0002\r���իG2�.I�'�n�s�ѾI9cV�T�E\u0015���\u0014H�\u000b�\u0016���=�L�>�P:]�?u0��!�SD؅Z()\u0013�/.��\u001f���_�П�ma�#\u0005fO��I+\"��\u0006�H\n-���pi���Q\r\u001f���x�%��U䶟��s_lf��\u001eј�٦\u0018����K\u0014�\u0000�t@�hT�L�_�:��?:�x�%�N\u0017��\u0017���f�'y\f\u001c%t�ZĀ\b�H-�U\u0019�.�s�O\u0007p�Y��|�r�w^�\u001f��=�#�VD؅�A>���pR}\r�\\6��w7s� ��\u0007\u0011�J\u0001��*a�\"��a�'������zU\u0007�\\��_ݝ�aF�lFɦ\u0015���\\zQ\u0015B:\u0015У�*�C�G3m�@z�z��k�Z�\u001d�d�3#B6�dҊ\u0018��\r��Z��0�\r�Us��#�q�\u000fV�o��03B�M\u0011�n\"\u0011�G}>wu9�G$\t��J\u001b������\"�2J\u000f\u001b@,!8.�[T�+_H���G2``��*<p�f�>}#�\u0019�L��jWĀ\u0018��K/Q�R �\n-�V1F�u� <��c��M�ް�c�\f\r3\"��\u00151 �^�\u0016�*�{\u0017\u0016���r>��ш��W���/�p��\b\u001dm�1�F\u0015\u0010،��5�\b�*\u000f,�N��� \u00026��r��ɲ�e�;�\u0018ȇ�\u0019��\u0019Bmmyn��\u001a��F+�| �\u0018!ծ\u0018�wť�(��\b�΀b�\u0012�98�\u0010ڴ1���\u001a�K�\u000f�\u0012�\u0006�I[�\u0003ւ�\u0006�mY\u000b�ra�b�O2��_\u001f��\u0019B��n�\u0013��J��(�v�\u0018ޒ����\u0001\u0017���V\u0012Z�6�u��`�\u0007=�i%$\u0006�ʄ�\u0016\u0017�4���\u0017�r�Q��X�R+_��\u001a�|�HÌ(��\u0012\u0004�qx�\\z�\u0002�2�h�\u0018G�[;v���q��1�7O5��#\u001dʪ\fb �U�\u0005\u0010\u0001\u0011vc\u0003H$�ͫ\u0002�<�����RR�\u0011z��m4�����Q�:\u0015��d\u0003��\t/l\b��G�\u0011�:��,�N\n-0\u0006|\u001fJ�\u0004\u0014�-��oT񯗌��*J��-�{#\u001f9w\u000b'�w9�ޣ�M1\u000e����bA��gX��\"\u000f޿�\u0013N�Kuu�БGUs�Q��ؑ�K+;y��V\u001e��b��\u0005�\b'\u001c�RV)\u0014\u000b��)� B7U��\bmM�xR��O�QU\u001d#���[8�~-�N���(ւ\b�Q�h\u001cZ�Y�<9ʄ��\t56f��\u0007�L?�%�Rʫ�-k\u0002����;o(��>\u0004\u0011�5n�p������\u0014��E\b|Hw(�a�8�\u0007�jz�\u0005\u0005b%·���c��ёB\tH�\u001dJJ<\u0012\t��CJ8�>�|z\u0015sN/a�(x}y��O\u0014�oP��\u001a�%��\n-(�\u0011�XP������1�.I�\u0015�L=r%S�w\t�>��G�Ra��E��[��pL5���o�\u001f�s�!.ј0I�i�\u0011�;��O�A�nO?��\u0005g����\n-L��M+~\u0011�a\u001f���������� �i�\u0018\u0007\u0005�#�-�����-4�t�X\u0012\t�D�%Q�Q;���S�r�\u0019U�>5N�p��\u0017�<�l��z�z�����K����1�?����\riΩ���\f�\u0012C!\u000f\"��A.\u0003}�˾^G�,BGG�o}\u001d\u0003*\f�\u0011\u0016<\\�\u001b���׌e��\u0004�\\6�[��p�FF�\u0019\u0006�qHw(\" �>\u0012\u0001�]�\u0007��\u0012���z\u000fUHwXn����he��\b��Y�q�+\u0019=��ʪ(5����3��\u0001|�3\u0019^]��\u0013�����i�~-�c\u001aő\u0013�\t�h��\u000bW�D��ҐI+��my\u0011��'����>\f\u001cTB�/��`��\u0005�\u001e��9ˣ\u000f\u000fg�I\u0003�a}�k�^�O~�aΌ\b���I+ơW��\u000fD���O)\u0018��\u0011S]�v!ݡ|�+;PZ�3=¬9�\u001c{\\%�F�S^\u0011a��\u0004\u0003\u0006$��1��?������GU\u0013�ޘ�\u0013\u001f^��'\u0002&��I+ѸP,(jA\f�Q\u000b��\u00013f�#T(X\u0016�k&O�ĩ%\\z�\b�Ԗ���[���\r$�\tsfD��PB���\\�O\u0002(�\u0000\n-����0��#Բ�r�%-@\u000b\u001f>-Œ�ʙ8�����H&#\f\u001d�d�0\b\u0002�q���\"�OK�,����$\u0011�<¡��A\u0015�]J��1��*D\u0013���\\��2F�,#���V���V�\u000f�3�h�!��,���Y˥��0sr\u0004�\u0013Rm�qx߸��\b\u0018\u0007T��S\u0011\u0003eՆ��\fj�qS��^Ԍ����\u0019cƩ\u0015\u001csl%Ç'))�\b�\u001eS�eW��ښ�+�u�ԓ�,����%\u0005�\b�NrIV\b�\u0002\u0014��\u0006�y�\u001a��~8�P,Z����c�����v�q�j�^P�aF�l���R���J�'=��\u00030\u0006\"1�\u0018h�jyz�\u000f\b\u0017|,F��\n-��XŰ�R�q���Ҝ�\u0015\u001d<�D\u001b\u000b~��c/�\f�P7ɡ�ڰm}�a�F��-�\u0013�9�hɑ�\u0005\f\u001a\\B�Z��y�8�C��4ڡ_�!ݩ��~R\u0010\u0001���I�(� TA-Db\u0010�\u000b�\"l_\u001f��z�\u0000\u0017~<���+8��Jj�&��\u001c����YV�����Z��wi^z- ���\u0007F0��AX�\u0018#�hi��\u001bWs͵\u001d�:5�*�2�qx�)���I��g�0���q��\b�\u000b6����gW�8\b��\u0004'M�䈣*��-%\u0012u�\n-��\u0019^~��g�n��/���2��[\\�\u0010z�\u0016.�h-+�\u001c0i�KWJ\t\u0002\u0010��AA\u0004lN�'=�\u001c \u001c\u0007�q�Z��J��M\u0001\u00031�sQ����\u001cqd%����y�\u001e������\bŢ巿Y�'.�Ɣ#]ʫ\f])�\u0018��\u0014D��\\\u000e A\u0000�NE\u0004�\u001c�0�\b�\\VYrW���(�p�p�\u0017K�:���\u000e�dР\u0012\u001cW\u0010\u0011B[�tqݷVsӏӜ^\u001f�XP�:\u0015�߸\u001c`��[.��\u0000Dž!�8\fs\u001d����\u001d\u0019��^���-|��%L�`%�\u001fQɚ5).>k=�v�aF���b-\u0018����\u0000%B7\u001b@!Pȁ\u0002��\u0019F�\u001c�\u0019�Wק���N\u000e)��k\u001d�)\u0013\u001cJ\u000eu�lS�\u0003\"�w.�W\b\bP�C>�8\u001e\u001c1��H\u0003ٴR\u0017s�}%ۥ\u0018�\u0018.�Lj�8�\u00162iE\u0004\u001c\u0007�9E-\u0018�?\u0014���D�\u0016\u0004�$��1\u001c�_\u0019\u000eگ\f\u0007�W���+�A�����p�~e8h�2\u001c�_\u0019\u000eگ\f\u0007�W�\u000f�|\u00032�þ�\u0000\u0000\u0000\u0000IEND�B`�\n\\n+\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000`\u0000\u0000\u0000`\b\u0006\u0000\u0000\u0000�w8\u0000\u0000\n+�IDATx\u0001���qۈz\u0006��j�?H\u0005��`�\n+�X��\n+LV\u0010�\u0002�\u0015ȩ�t\u0005�*��\u0002�Vp�\n+�\u000e��\f<���K\")���9�={��ٳgϞ={��ٳgϾ��7��=\u0016XWU�\u0017q�o I�\u0016\r^����{87�4~\u0011'~rI\u001a�|��8���|��/����ܧ��\"N�Ē4�LFlM�$�_��\u0007J�`c������̶�\u001d+�s���N<@�\u0006w���.I�靛���ެ�\u0003%钼N�8����Ak���\u0013J�@g�����7i�t~�$׸�+\\;��oH���O����t:����쟾�$�$�qi6:���Hr��/{�d�i�4{g֛u��$gx����9��/H�¥���]'i\u001dQ�\u0005Z�]U\r>��\u0011��\"I�%y�;4>����\u0001N|F�\u00156fo�jkv�ޤ�]���tf�|���'��Ir�W>�w�\u0013��ŵٶ�^�\f&gXc0ip��q\u001c/Ͷ>uo�y\u0002IZ�Ǚ�[�f�\u001c�ğ$iq��侪�f�\u000f�j�\u0012�I��\u0003%Y�5\u0019�j�/�j��d��uDI.�\u001e\u000b�\u0011\u0017x��dWU�\u0003�� I�\r\u001a�\u0001\u0017� ISU#�\u0018M�$�\u000e��f�|�;�3G��I���ـeU����\u001d��?$ip��d�eU�>��Y�\u000fU5���2���:�ޗ�f/\u001d(I�;�̶XV�`rnv�\b�\u001f�ܠ3��\u000e��?��\u0002\u000b�eU�� �k�2{QU�GH���PU/|E��hM���v��d�k4&#��j�$\r��d���t\u0004'I����5n�\n+���\u0019\u0016f�?���ؚ�%i=�K�w��Yg\u000fI6ؠ1�aYU[\u001f��zGr�K�i|�\n+�I�M���uf[�vo��#$Y$y��Y�\u0017U5�Թ٭#9�\u001b��\u001e�x�7x�%�xQ��WTՈ%v&-n<@�\u0016\u000b���F�PU\u0003v&m��\u0007H��=Z�������_$iЙ�U�;�Ӫz��\u0006����\u001a�\\�\u000e\rΒl�j���������49��W$y�Wf#.��ޗuf�#:�\bU5z��\u001a�6[%Y��ά�p���}A�&�\u001d^��㿪��ם��:�\u0013�k|EU��2�$�|F�\u000e�I_U�\u0007��\u0001;�.I�/����83{[U˪\u001a}E�\u0006��XU�#:�x�&�o���ؚm��>unv��z�Ο$��\u001d\u001a�\u0011\u0017Uu�a:�ޑ�xbU�ƽI��$��u&#z���ٹ?$i����l����=ܹ٭#;�x�\u000f�,<�\u0005\u0006�\u0005�|��Cc�W�葪��h�%i�\u001e�ٶ�^T���t&cU�������l�\u0001�j�\u001a�I�dc�����z��X��XW��#%��zO��wRU\u0003.�VI�љ�U��߭O\rXV��~��n=�\u0013��3k=BU�cmvi��S�\u0005^�X�eU\r�י�U�{\u0002�\u001eog�x���&�\r�>vk\u000fI:lИ]U�[\u0007Hҡ1�=�\u0013?@U]���{���\u001a7hLvxQUo\u001d����\u00139�x��?�o���.I�\u0001�4I����\u001e/�jp\u001c��XU�'r⑪jt\u0004U5�\u0002�I�kߐ�\f�Fk����U5:�$\u001d\u001a��\u0013:q��\u0001�j�%F�U�k_��5�И�XV�k�unv�Ȓ4IΒ4��s�3�\u000eTUC�+lL.�����>H�`��l�EU�\u001c_g2VU�@I���7�hM�S?���&Y���&�PUC�\u00167X����+O I�Ƥ�HIZ��\r-�|���~F\u001f$YT�΁��u�`er����l�UUm=�s�[_�d�\u0016-��\u0016��\u0019�>���3Y`�8�ТE�Wf\u0003�U5xZ��XU�\u000f�4hq���b��v\u0018Й�XV�p�'RUc�+���\u0016WU5zBI:4&C�K��\u0016�o\u001bq��\u001e�ɝوeU\r�pj?;�\u0016�� �\n+�>��������\fg��\u001e\u0003~�PU;���\u001dZ�\u0011˪\u001a|pj?;��\u0011$�ƥ�\u000e\u0017U5�~:_6`��\u0018�j�\u0015I\u0016�Ak2bYU�?9u��p�$\u000bܠ5뱮��w��Cc�À�1TսGH��\u000e�ɈeU\r���\u001e��>�\u000fZ{Jr�\u001b4fo������MUm�!I�;4&#�U5��S?H��xe6⢪��\u0018�Yo\u000fIZܡ1\u0019����\u0017��߈\u0006\u000b����\r��\u0006,�j�\u0003$�И�U5z�$-�И\fXV��+N�o�\u0019\u0016\u001e(I�\u001b,��VՕ=%iq���.�����#%iq��d���F�p�;Ir��X�����+{J��\u0006g蒜�Og�{�$-�И\fXV��\u0001N�o�$mU\r>#I�\r:�\u0001\u0017U�s�\r\u0016&CU�{�$\u001d\u001a���F\u000f��\f7hL\u0006,�j�@����f��H�b��l���\u001a\u001d �%:�\u0011k�97��@IVؘ\rXV��\u0011N=�$+\\�1\u0019qUU[\u0007J���UU\r�ә�\u001e �\n+\u001b�\u0001˪\u001a=ҩ�\rfg��A�\rVf;\\T��@I\u001alИl�jk\u000fI�И�U5��$+l�z��j��S�\u001b�E�\u0005nК�XW��8�њ�pe�f��!�\n+\u001b�mU�\u001d��q�G�\u000e\u001b4fWU�֑$YaevQU��uf��H���l[Uk\u0007:����7[�\u0006�ɈeU�u$IZ\\�]U�`OIZ,L��\u001a}A�\u00156f۪Z;�S�ј�㢪FG���\u0006�I_Uo\u001d�٭/Hr�k�mU�\u001dɩÌh�F\\U�踮њ�v�ά�\u0019I6X�m�j�N\u001d�\u001e�Y��Iv��\u0006\u0007H���좪F\u0007H�ba�W��/�l�2�V�ڑ�:�\u001a��%Z�\u0005.q�dD�۪�=B�\u0016�fWU58�K�[�d��ٛ�z�\t�:@U�x��I\u0016�p�3�\u0006+����q[U�o۠1�����z�d��ٺ���Hy\u0002I\u001at8G��zܢ��џ$�ƥ�\u000e/�jt�$-ޛ�Uu�$\u001b���U����\u0013KҠ�9:_��\u0016=�pc���\u0006G��\u001a�&���C�\rVf��zb�;K��\u001c\u001d\u001a�7�1ySU�\u001dI�ca�&�X���j�;(?P�\u000e�����\u0001��W��\u0001��xo�c�;�f���N�O\"I����y\u0003��\u0006���\u001a�&Wx��l]U[�Q�\t%i�\u0012\u001d\u0016>o�\u001e�j�\u0000I���Ɉ�dĺ�z�Y��%Y��K�>o�\u001e�WU�3��x�S#�U5�\u0001��H�\u0005:�D��F�����\u0007I�q�c#�U5�A��T�\u0005�p�����;^aa6bYU�\u001f��\u0002�4�p�η�XV��\u0007+��$\r�p�\u000e���XV��'P~qI:��È��\u001a<{��ٳgϞ={��ٳgϞ={���?���Wsy\fn\u0000\u0000\u0000\u0000IEND�B`�\n\\n"
617
- },
618
- {
619
- "path": "native/android/app/src/main/res/values/colors.xml",
620
- "status": "modified",
621
- "diff": "Index: native/android/app/src/main/res/values/colors.xml\n===================================================================\n--- native/android/app/src/main/res/values/colors.xml\t74465a3 (parent)\n+++ native/android/app/src/main/res/values/colors.xml\t7d171ee (commit)\n@@ -2,5 +2,6 @@\n <color name=\"splashscreen_background\">#4337C9</color>\n <color name=\"iconBackground\">#4337C9</color>\n <color name=\"colorPrimary\">#023c69</color>\n <color name=\"colorPrimaryDark\">#4337C9</color>\n+ <color name=\"notification_icon_color\">#4337C9</color>\n </resources>\n\\n"
622
- },
623
- {
624
- "path": "native/app.json",
625
- "status": "modified",
626
- "diff": "Index: native/app.json\n===================================================================\n--- native/app.json\t74465a3 (parent)\n+++ native/app.json\t7d171ee (commit)\n@@ -5,9 +5,9 @@\n \"owner\": \"iansp\",\n \"scheme\": \"com.markets.manifold\",\n \"newArchEnabled\": false,\n \"jsEngine\": \"hermes\",\n- \"version\": \"2.0.65\",\n+ \"version\": \"2.0.66\",\n \"orientation\": \"portrait\",\n \"icon\": \"./assets/logo.png\",\n \"userInterfaceStyle\": \"light\",\n \"plugins\": [\n@@ -15,9 +15,11 @@\n \"expo-font\",\n [\n \"expo-notifications\",\n {\n- \"icon\": \"./assets/logo-96.png\"\n+ \"icon\": \"./assets/manifold_white_transparent.png\",\n+ \"color\": \"#4337C9\",\n+ \"defaultChannel\": \"default\"\n }\n ],\n [\n \"@sentry/react-native/expo\",\n@@ -63,9 +65,9 @@\n \"foregroundImage\": \"./assets/adaptive-icon.png\",\n \"backgroundColor\": \"#4337C9\"\n },\n \"package\": \"com.markets.manifold\",\n- \"versionCode\": 65\n+ \"versionCode\": 66\n },\n \"ios\": {\n \"infoPlist\": {\n \"NSCameraUsageDescription\": \"Pictures can be attached to the content you create.\",\n@@ -77,8 +79,8 @@\n \"associatedDomains\": [\n \"applinks:manifold.markets\",\n \"webcredentials:manifold.markets\"\n ],\n- \"buildNumber\": \"1.0.65\"\n+ \"buildNumber\": \"1.0.66\"\n }\n }\n }\n"
627
- },
628
- {
629
- "path": "native/assets/logo-96.png",
630
- "status": "deleted",
631
- "diff": "Index: native/assets/logo-96.png\n===================================================================\n--- native/assets/logo-96.png\t74465a3 (parent)\n+++ native/assets/logo-96.png\t7d171ee (commit)\n@@ -1,49 +1,1 @@\n-�PNG\r\n-\u001a\n-\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000`\u0000\u0000\u0000`\b\u0006\u0000\u0000\u0000�w8\u0000\u0000\u0000\u0001sRGB\u0000��\u001c�\u0000\u0000\u0000�eXIfMM\u0000*\u0000\u0000\u0000\b\u0000\u0005\u0001\u0012\u0000\u0003\u0000\u0000\u0000\u0001\u0000\u0001\u0000\u0000\u0001\u001a\u0000\u0005\u0000\u0000\u0000\u0001\u0000\u0000\u0000J\u0001\u001b\u0000\u0005\u0000\u0000\u0000\u0001\u0000\u0000\u0000R\u0001(\u0000\u0003\u0000\u0000\u0000\u0001\u0000\u0002\u0000\u0000�i\u0000\u0004\u0000\u0000\u0000\u0001\u0000\u0000\u0000Z\u0000\u0000\u0000\u0000\u0000\u0000\u0000�\u0000\u0000\u0000\u0001\u0000\u0000\u0000�\u0000\u0000\u0000\u0001\u0000\u0003�\u0001\u0000\u0003\u0000\u0000\u0000\u0001\u0000\u0001\u0000\u0000�\u0002\u0000\u0004\u0000\u0000\u0000\u0001\u0000\u0000\u0000`�\u0003\u0000\u0004\u0000\u0000\u0000\u0001\u0000\u0000\u0000`\u0000\u0000\u0000\u0000�e)�\u0000\u0000\u0000\tpHYs\u0000\u0000\u0017\u0012\u0000\u0000\u0017\u0012\u0001g��R\u0000\u0000\u0001YiTXtXML:com.adobe.xmp\u0000\u0000\u0000\u0000\u0000<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" x:xmptk=\"XMP Core 6.0.0\">\n- <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n- <rdf:Description rdf:about=\"\"\n- xmlns:tiff=\"http://ns.adobe.com/tiff/1.0/\">\n- <tiff:Orientation>1</tiff:Orientation>\n- </rdf:Description>\n- </rdf:RDF>\n-</x:xmpmeta>\n-\u0019^�\u0007\u0000\u0000#�IDATx\u0001�]\txT��~�MBV\u0012¾\u0006\u0002\t\n-hQ\u0004\u0004�\b\b\b\n-\u0016�պ`��j�[�ߥu����Jŵ�\u0016w�ʦl���\n-�\u0002aO l!d_�63���ܹ�\u0010�LH\"�?9�̝�{�Y��;�y��\u0001c�~Y\u0006\u0004TÔ�\u0000�Zߧꪞ}U]�����zv;�w]}��O�����o�}߮g?oh����U�����ݖ�wͫ�s}��ou@`@@���\u0000\u0004�Y�D{�6\u001e�޷��V�j��v��t��o\u001b���<��TWW\u0015�}믣�v{v}�vj��-k��k�k_}��{�6���qլS�}����\u0018MO���v�\u0011\\�j���x��ݣ��s�NͿ������ݳ�׼�u����~��~cߩ����׼W���;v,���\u0000.(w0o�,���{-C\u0001\u000f���Z���V��@+\u0003��T\u000b�ke@\u000b\u0011��f[\u0019�/�Z�^+\u0003Z���6��\u0000)�B�Z\u0019�B����V\u0006�K�\u0016��ʀ\u0016\"��͞�\f0�\u0014�\u0000�R�G�wR3@�w�\u0005 (�^���\t'-\u0003\f�C��\u0003.��T#�$e�I�\u0000\u0011_\u0004�Lsc�P\u0007�u\fBaN5��Z<��Iǀj7�&<\u0000�ֺp�-m�ʛ����dD�\u0006�YE��I��=�\u0018`��$r&c\u0003�~��\u0010�G�H�\u001a\u0017�}\u001b\\\\\u0019\u0001'�~pR1@�'<\"\u0000�>u�?E!�w4�m���{�\"�L�j�7n�\u0013SQ'\u0015\u0003�^\u0002���pcҔ�\b\f\f��e1�[�6�����+@jQ���4�eO�#��3�zߑt�f�q��P�vz�1u;u\u0012\u0003<����\u0001�3�!@`����j�]\u001cD#�o\u0016\u0006h am\u0002�vW#0(\u0000\u001a��\u0003i�\r�}h��R�x�����q\u0018��U��.>\u0014\u0003�\u0005���h������tƱ�?DF\u0007��\u001e\u00176\u0010�����\u0003��&�th\u0004\u0013�����ˆJv}�IJϝ�xU�\u0019\\s\u0013A\u0013���I�\u001a9���C~^\u0005Rw\u0015��QQ!8eD\b��,8�R\fP��d�����J$\rv es|��/�X �*)��\u0010�&1@��&��hU%n�\u0019�}�\u0006��彰�\u000b\u0017\u0014m\u000en�@\f\u0015��P?Za;V�p�_�!ԣ���||�m���&<\u0018��\u000e\u001c��F\bWJK\u0014��\rA\u0000\u0017:�_�gg���\fĩ\u0003bq��\u00187%\n-�6�������I�e\u0016�.\u0010�U�\u000f��?�3�b��xyi5Λ����9P\\P�tU�\t\u0007S����;a�����\u000b?�B�D+�)88\u0010={�!\u001dE\u0018tܳ��mF�)PQ1�?�;\u0011�;\u0010kV�ňQ\u001d�/\u0014\u0014T��u�H�\u001f\u0004��\u0003��\u001c�\n-\u0010��\u0006`9���Q���$C|��m���\t]��k��`y��gt��\u0003�YM�\u000f��;����/۠����ʾ�%��L1rr�f��ͮ�<9f��\f�������F\u001b�\u00158��~k\u0004\u0016~>�\u0010_cӜU��:\u0017o�/C���� ���\u001a\u000f\u001a-+L�2j�\u0015N\\��p��P2BC�\u0014Y$�\u001a��\u0015W�ơ�\n-�uo\u001e�M\bEa��L��5�S\u001by\b�φ4\u0017��[;H׫�[�m�\u0007�V���i��H��$\u0003ؗ=\u0016S��\u001f�yDR�\u000e���v'��\u0015S/���X\u0013�\r�9WV���w�0�C0\u0019b��/\u0000i\u0014I$�\u0011Q\u0001سɅ�S\u001dx����\fAU�\u001b��L1e��������\u001b\"�iY\u00154\u00111��E\u001b�\u0011:�~�-\bC��3�k��n\u001e��\u000bƎM�(*�4�����\u0004��H�x�m�MR��4��|��U�\u0010�-)�q�%�\f���\u000e�y皲9\u000fϽT��AA(/m����\u0001\"� ��}.tJ\f����#.��H�\u0005����\u0015��{E�\u000b��gpZXX\u0010\u001e�k\u0012N\u001d\u001b��t�AK�e�6Ԕ/����\u0018t�fm�)���ޢrt'\u0003>�ޅ<�!���\u000e�\u001f\u001d��B�as��\u001f\u0006�q���Y��\u0002O>\u001e�W�\u001e�S<�O�{n�.\n-\\9\u0019`�[<�0�0ﳊ����>�\u0018�Ai�/.p��\f��7��Sg!_��uY��҉�-�+u��[\u0007��g���v��0��d\u0014탑�P�iU��%B��3nB\u0007Ӟt���HT��Q\u0013��r�,2\"\u0004}�\u001c�O��k�.�1i�ʩ��L=�эOW��mw&\u001b�C\u001dh߹��o���� \u001f�KK-��G\n-я��(k����\u0006\u0019���Pе�w~��\u001b\u001f%\u0011mD�]l�:\u0007SG��\u0003�%�O���'\f?\u001f���i\u0016F�\u0010���%b�WUt\u00193��t�_\u0007�����p$'ǘ���.��'��\t��S�Ȱ\u0018\u0010� \u0012J\bE�\u0011\u0012����W��J�\n-:ʞ\u0011p\u0018{i\u001b�H\u001d�����S\t�xy\u0006.\u001c�����p���|�\u001fF\u0001����U��\n-\r|�w�\"���?�\u001aX��/���5M��^���KŹSCЛ뮠@��*QQ��\u0003pǍ;�{��3�lI/|�i�Qej����\"�׻��:=�v��\u0017V�ʆ�m(�Z�\u001d�Ͼ�r朗O�Y�h�\u000f��6�?�\u0016����J�1�3�xj\u0000\u0004�U\n-�+��߷a��=\b����\u001b��/٢��CG0��|�;$�\u0018`�F~��\u0000C|\u0002���\u0000,[S�5����\u0006Ǜ�w�( #R�%߉�\u0016T�9ƨ�\u0011AP��P��\u0018.��;�x�\u001d(+#4`9obW�y�\u000b\u0016��$�\n-�wS��\u0017Ѫ\u001duZ\b�8��|ss�1wN\u001eF\u000f\u000e2�\u000e!��\t�ؽ�\u0002G�X}t�lAQ��>���Z��ӟ��Bt\\ ��&\u0019���7\u001c\u000ek�nޔ��~�\twߓ�K��b#�\u000eS����6\u0015!�/�\u0011�yb\u0011�f#?ꄡZN�Hb�-I����ё�U���\u001c���t@��p�o\u001f��v���\r%\u0003$y\u0001��d����C�蹗��G�.���F*\u0005O\u000f\u001e(�=.��\u0016���Z\u0010\u000b)��0��Y�����o��&\u0017��VႱ\u000e���i���\u0010��DB�ŕ��\bF|�0� �e\fյ\u000bH�hO�kC\u001b��\u000f��w�c� �\t�}0o\u001f.�� ��\u0012�\u000b9΃$���Cq�Y�\u0010\u0016\u0015U��ײ1b@0ʩ���\u0000��\u0012�|�i\u0015���H�������\u0007�{��oǪ�\u0012N��JIQ5=�\u000e���y4�v\u001b�@m�rG?�ߛ��/��?\u0011RM�\"�-�V\u0019=���\b},Y���p��aW62[��\u0013�\u000bu\"��\u0012\t�zN�Y���o�Ui\u001a��ؿ$v��SV5�~�@뺫y�����D>i��bL9�aT�\u001c�k69���\u001d\u0011C'��\f��~TA��ڗ\u0010����\u0017k|�@\u0005i�\u0011t4�\"�g�#\u000e��Ӽ\"T!�\u000e��ن�$���\n-\u00079���\n-��r\u001bbq�T\u0012X��ƅ�\u001c�rF\u0006>^���|\u001e������1��>��+T\tvQl7c�\u000b��\u0014�~���WN��ϖ q�G�ظ�jm�DBY\u0016\u0014�����߁�ÖW��Cb�ԩ�\u0018���B���\u0001�\u0010��LL\u0019�\u0019��*5n\u0014�\u001fZ-B7��\u0007a�9\u0016\u0012��0�{�0\u0006��������|k]\u0001�8�}��u�\b�LGh �x�1xr�33�))�e\u0015؟^����ӻ\u0004`�W�\u001b�8�PT\r�\u0005A\"i\"��<\u0014���C�|���>\u0016<M���-��&(����&� ��?��?��':�dd�'�|�RK�m�a�\u0015�2,CP08�O(��_��}�WT�\u0010\u0016۔P\u0015�\u001a_J��+/v�e�N��n���h�+{p�-�\u0018;,\u0004]��.W<����1&Uع��\n-�l�dz/�b�\u0018G�ԏ��\u0003\u0006HEȚ�32\b�\f��mB|�`l��\u0002[8��t�Y%\u0000}I�N�\u0005��)�&P.ݩ�M�+�\u0019�$I�m\u0011v̐\u0010\\5q\u0017\u0016�\u000fE�\u001e���'\u001as��\u001dr�\u000e3\u0019�m���Ё7a����\u0015t9�y\u0004o����éZ86��˶%,ݨ��ҽG\u0018r)�Z\u001d�OT\\\u0000�Ӊ���{�W�؟��T_���O�\u001b�ʍ:�>��H��O\u0015�lm�c�[�_M��hQ\u0016�4�����{��\n-Rmq^\u001d����~]��ϖ\u001a�vʨ \\@u2�z\\�Ե��5،4����P��v=�\u0000�\u001e��V\u0012���n\u0010U�\u001d7mGa��>8sH<�|��l�z���O��¯f�qs�\u0010���r\rڐC�W���bZ����i\u0015^�չ�\u0015�T}Eф�o�/\u001a�-\u001d�%��c��}HJފ�\u001b��������خJ0m�]����h����ݴC�z�\u0010�H\u000bͩ��V\u0006�QM��j�=}0}i�S-��\"J��\u0016q��*��\u0001�]����\u0003�8�8�\u0006b:\u0012��O�����jtK\n-º�UD\u001e��dk�L<�\u001b^}��\tp�2ӭ\r96b��6\u0004a\u0017}��3�qR���Y�Z�(�\u00102uk�ُt?���h�?�6Y��IB���\u001f>���\n-[�o��_�ұ�\u001f�F��C� �6��,�a��T��)S;z-�OWf!����D���:\u0019��5YY�Z�N\n-��O������{Z\u0001{����=\f��0�Dq\u0012&J���zR/C�\u000b��3���s��G�jFo<��8�����)�\u0019\u000f���mk\u0001^��\u0011t�%\u0006x�\u001f��.BIה/]\u0006\t�`��\"\u0017z%�`��Sq���^\u0002~�>\u001b�Lڌ�-2\u001b�§R;\u001a���M�\u001f��3\u0000te8\u0006\r�T�\u001cp/�+\u0017ㆇ\u00185%M���p\u0013$��:\u001fB\u001eө\u0018`Z9�\u0000��\u0018\u001aZ�T��]�\u00174Q��i\f���l�?o�y*�����Q������y]۟��F(����\rק9\u000b\t\u0011nfR*ss,$$���[]��K\u0003з���$\u001c����!Cwr?�ƙ���hm��mS�A�p�.\u0017.���w�^�y�qK�Vws��\u0019�@/\u001c���ee֨�ɀ�@\u0003�j2@��^1=�\u0017�\r%�=��k\u000e�^da��֩��W����\u000b1z\u0004�g]n^�\u0000Hm��g!!�!�^�\u000b��\u0016fߛ^��n܌k��4{XxT��Q��z3\u0002~�=\u0007���,�����\u001a^�2���`�@����W]ٯ\u001eϵ�\f08۳:�\u001c�0 (\u0010Q���z\u0006*.�(�<Md��\u0010�\u001d�f�\bUiߡ\r�ҪVY�e\u000e�\u001dtcs3�M�hE��h��28�\t[���Zj���\u00071:!\u0005k\u0016�\u001b�}�\u0004��y(����;q�u�^��ƍ��K\u0017x,-o!��*�\f��.D|\u000f�e3���)\u0006��RکZj��Y\u0005���z�0���wBp�.��/=�EG��okJ���\u0018m>%U��y�����e�$\n-@!�4��MN�t$�\bB\u000f\u0006K��h��\u001f�yJq\u00195��\u0015�`^\u0016�G��\u001d�B��4ԍ�y�\u0019��x\u0006TZzT4\"���ϑ��u\u000eX�H�@���=n�w�\u000e�\u0017��V�h��1�dC��^m�3:���&���ԉ���M�1���\u0006}��\u0014:Ѯ�\u0013��{��F��J��d��\u001fS8\u000fY��}��o�nkl\u0015=�����J�g�Q+�����G�\u0019`�k\u0018@��OL 2�>\u001e\u000e�!� ]Z�� qH\u0010^{�\f�|l�q�)�z��\u0012q��X�B�JK\r�2�;��O�-\u0016��;\b\u0003\u0006Z(�IC�ݷ�1�m��pa\"m\u0015�i�\u0018qCt�櫶esO�l9\u001f��%�\u000f\u001b����\u0015�PC�x�\u0003K�\u0011�ZU�\u0001�b���b���O��D_\u0012)��Qu\u0015�_ͦ<!\u0004\u000f�o\u0001��H�o�K4>���Kž����e\u0015��w߲���\u001dX�,\u0001����m�P�\u0011<�X*�A�].\u0002�\u00149��W�W��r-_T�$��u�L\u000f��J�\u0006>�υ8���\u000e�4$P�4]�&�\u0000�.��&�K�]�����\u001aV\u0011\u0003\u001a,��$O��r���>�^�\u0003�5y\u001f\u001f����v\u0001xoa9Ι�\u0006�щ�K|9�&\u000fۄ������g&0O\u0019������~����\u0000PG\u0003�u��\u0015�qH�T��g.vc~^��\u0000�If}D{:�\n-]F}���p�\u0000\u0016U�\u0012𧰞,l9�&Mދ�\u001br�[ݺE��9�xᙎx򙁌CXFZ)7��f��f���΁�K׈6Z\u0011�/��cb}!���.��U\u001b\u001a^V�\f��<�K\u001fV�q;4�M��\u0006�Mf�\b�\u0015\u0010\u0016-5��&*��\u0019 ��+9���r\u0018Ok��ˏ����\u000e7���\u001b�ڵ�\u00107��D\u000ff�q���c�h�3#�+U������S}�1�/�e\u001b8*�e�\u0000�\u0019?�g�?�^\u0012�\u0004\u00152k����a;#�?x��\u001b�`��|�S+�\u0002h�\u0002;\u0003M�mт�藴\u0005;��\u0013m|�Y5��\u00185Qw�u>�{�/ƞ\u0015�a�-�YB����rLD�v�yW\u0000\u0007�y������\u0019����\u001c�\u000f4\u0010eN(�͗\u0001\u000e���\u001cV�&��\u0014�MOq!+��FT\"�\u001d��LT�(�1�gf��G\u000bi���Re����z\u001dt-���\n-���ф45�ohx�1�\b#za\\Yn��(\u0006h\u0013��\u0011\u0002\"jK\u0002b��F\u0000�\u0011�fc��Dž9�.i��\u001ed��f�\u0001��O\rR*@\u0010S���+���p�W�뵯7d����6f\u001fO��4\u0018�\u0015rE\u001f��{�B�JH�q�c�z\"^��.��ŠT\u0010R�v\"�����}��\u0017\u001a���\u001dߍ�g����P�l�)�3O4Q�/f�,\fP'\n-~��9�\u000eefA|���*��4�Vt_�n�o�\n-g?�\u0001W\\�@g�549��y3��qF\u000f\u000eƙ�Z\u001b�-�����=��5�T��GcM���X���{�2)�m<�\u001a��\u001cʼ8Bc���I׷��ۅ�\"�\u0001\u001f\u0002 \u0005y�ȒY:œ��m�Y�`FӀ������7긯�1ل^�4C��p˴|m�%\u0010�\u0005{�J\u0007����؄@�[�\u0017�϶�P��D���ix��R�\t���|9ͅ�e\u0019k\u001c\u0005��IS,�K��7W>)��'�^�H��Z�P���i���`���\u001cF|wˇ��|K�1@DQ\u0018�f�qI��\u0005��6�q��f�_��=CKt��\n-��(�~w\":{�\u001d5@e��v�^Du\n-0�^��aZ=��wb�|W�'�n���\u0010�Ș'�*�\u0014�v\u00163�\"ĄV�`�8��L\u000e\n-���H>,��o�����\u0019�w����L��\b�tc7\u001f\u0003�x\u0018��qIǴ\rD�$�+��f�g2�r\u000f��vs\u0015]\u0007]q���)�\n-U�~!\rwݓk���9�\u000fyԵ��1R��\u001by;��RW}�P\u0012���n�{z\u0007CX����<�ɘo(�z�H\u0014�\u001a�\\�q�C,\u001dt�sĴ\r��=�)�\u00063U%�{��'w\"�[D+�f�g�\u001e��*$\f�=���\f�'�vIS\"4�#��(��.�z��M}������B��I�\u0003=�&M^i��ݾ\u001b\u001f���T&A�z\\��\u0012�\u0004�F,b*�I��S�L\u0016�oH�<�����'�ҩa��\u0019V�݁\u0003���\u0002n�\u0011�d3RQ�̽.�o�\u001e�t�RF\u0007\u000f�9R�Y��\n-��\u001d���x�JE|������3\u0014�1��4��\u0001�!����\u0000�a;EPIQrI��!�Y\u0004�\u0016��`�\u001d~{co.s������>\\r�A��L�����\riQ5H|\u0012R����ߥ\u001f�D�\u000e�x��t\u0013\u000f\u0016�\u0012$�Y�z��XW�\u000f\u001e��=��fu�I��A\u0006\n-e\t���\u001a�\u0012\u0006\u0002H�>\u001a\u0014�̿\u000f2q�Sv5�|��5���\u000e0%�GR���\u0012㗒kD}�6�fg�풖��KZ��Q��m!�V���\t��w��$�:w�ci^'��`ō%��\u0016J�\b\u0011F\"/�Y�\"�6�E�ˮ.5�6%���5�j\u0010\r]9�F�\u001a��GX���p�|)�y�L}�8�\f�\f���m�ޢ\n-\u0003��{�\u000f��t\u0003x��U����t�Hte�h��8k#�^ih�v�\r_E\u0010�$�f\u0017�G�\u0007}2�\u0012�xLIN4_�+\u0014y�'\u0013Mǘ���\u000e� �ف�SV�\"�V|^w/�\u0015�Y�Y���F�Ts�\u0006zRz?�wu���С��v6����Ou����a�j�^%�΂��~�\u0003��=�K|�1�}j'S)����@$��ಈ__i�\u0015 �(\u0002[�\u0013\u0013Ph�\u0000*�W��\u0010GO�����L�W���Gf�\r\rA��V֚\b_�`�_�����BP����l⪣��I�\u0013�|��y��̅\u001a\u0004��K%t��نW9��\u0007s�qzW�p�1\u0006M�m(\u001e��䮶�\u0002\u0019�N�ʱ`��M�U���K��sˌԗQ���\u001a\f\u0000���[\u0001l,�#�K�.�q\u000e��~��>^�ˉ��k6\u001b�+�U�AF�?R�v%�\"������0�Z)e�<��.<���um�\u001a�W-\u0007����q�\u000f\u000f�03��*����KMΒ6^��/!+�B��\b,Z;�K|���\u0016\u001f�i��`��c����l�\u0000\u000e�p�X��l�\u0007(�X��.�\u0017�g��~��\u0010h$��K>�G�Նt�\u000e�/��v�]1�í�����n���t�Be+\u001f���� \u0014I�\u0001��L�£\u001d�XcUī�GW�\u000f�Gė�\u0013RZ��IDӅ��z�v�\u001a�����i��<�=�ط�K\u0011\u00104�4\u001b\u0003����\u000f)fB�2ޔ\u001fj�\u001bۿ�\u0004��{\u001a�z�\u0018��\u0014f&'\u0007�����F\\���ND��\u0016N����\t�=I�2W)�W]�A�-��s��e�w\u001dw�y㛮���ґ��\u001f*d.�\u0015�RU��h&�&\\\u001e:*�\u001c�8��p�l�>\u000f�ޚ�u��\f\\�\n-���\u001f�c�a_k�\u0013�Q�Z��\t����\u0003{v\u000b��\u0015��Bp�\u0005axI\u0005>��M\u001b�������E`-�ڊ�Q0�\u0000\u0013�N??\u0004�>�̈\u001b_bY��!�\u0018��\u0014�\u0018\t:��H����!��~iGc�����\u001fF1�Z1��#5%�|�\u0003m���A���=潷әݽ\r��n�\u001ck\u001d0�W��kl\t\u0018;��:�ݸ��\u0006�W\n-����G\u0017�=�=tBҷ��c׎\"|�.\u000fK\u0017\u0014��S��z;�LZ�T-B@���\u0011�Z�wE���нw�\u000e@׮\u0011�YE��df����\u001e�����Wm\b>*@߁��:v�#��L��h�fs\u001e�V9\u0007�\\شǍ�\u0017t��\u000b�{Ǒ��`3�H5)�:����\u0007)������3Cwy�� ���Q\u0013M5t��\u0018���iWD��Q�愣�[bH���0�\u000e�+�.���E�bM\u001e>�[L��4G��\r�O����*�\u0006ҠS\u0016��-n��q���J\u0015\u0019w�.\u00139����\u0015-��Y�R����\u000b���vu�I5T\u001cY*t\u0001Uί�������lh���,�:c\u000f\u000e�d��u��4��Q9jϷ4�\n-�mT�An�o78��QPs�(\f\u001f\u0019G�W\f�y8÷(��$ܵ��������&��y%�\u0019��ѯ�|�1\u0019�\u000f�����^tN\n-\"�j\"��\u0005+k�|�\u000fY�ʬ+a.�\u0007�\u0006�\u000bW�\u0012���|�Q%2��Ǭ��t{�6������\u001bs�0=�0�p�*�Q�\u0019\b�]\u0001-�\u0000-ymx�p�^R��N\u001e|� ��0I\u0017Ec؈Xs�(�s��&��~nM���dn\t%�\u0012�?�Q�t2UtR�_m6�\u0019�\u0013\u0013\u0007n\u0000yH�����ɧ�ͯ��!�\u001d\u0013���\u0019t;�l���=iDu��a\\�>�����\u0016\\6�\u00164�jCWޗ���e@�� ߾%��М�\u0016Y\u001a���x��\f�&���?�q�`:]\u0005\u0013.��Y�bM\u0016�T�\\��7v|\u0017:����_\t\u0005�U��x��)H�OV\u000e�l\u0019Zr\u000b\b��8�͖�}#\u0010|�\u0004�xҏk,^�M\u0013х!�#p�G}�Yp�g%ϲ]w\u001e]�\u001d�NI|%��4\u0013�M[�G�0�n� \u0012�]\u001b\\%�\u0015��\u001a\u0007�\u0014�\u0007��CnJ���9�x�\u0003�'�0}<\u0016�}�� s�\bU�^�/e\t�\u0016\u0016Va��(��R�#�`80\u0000��\u0016���֯�(�S��!\u0014�@�W0h�*'�7��-d�l����\u0014d\u001e�K\u000f���_{Q\u0006��ލ;���y\\��W4�\u0004���Pi\u0011\u0015�P���je�$��\u0016|]�_aQ�����0���D8��̓\u001av��ݮ~�c��B���a��\"svX�8��?��\u0014���<�l\bs��b��0y^\u001f{d\u001bƌ���}V�ڃ\u001e�;�?E W8m\u00022S�Fm��\u001eG\u0013�\u0014\r�\tw�\ta@m\u0003��j��5;����\u0013�\u001c$f\\\u0016��\u0013��!q\f�Gz]\u001av\u001b9<n�}\u001b��6\u001f��-�j�����K\u0018\u001a���E�Ct\u0019\u000f<ˁ=?��\u0014i\u000fQԮ+��Td\t+�e�/\u000e��o�б�uv��M��\tן\u001e\u00034\u0019�\f�\u0006\u001d�\u0010���\u0004\u001e&\u00017��\u0006��k�l�q\u0013�2�\u0012��S�7I�&�a��-\u0005�%��G����\f�3\u0018�lI\u001f�as�P��\\�f���Ls�-������a�s�i2�\u001e�\b!\\.��_1\u0011��:���X���d�\u0000���p�\u0019\u0017K+�-z�ȫb�v��2�6�G��Y��D�3\u0004���o�K/w޸\u001b��s�CyfM��![�n�\u0019�?m\u0006�5A�}�zd\u0004�����,�9�{ɍ�\u0018=��8=��r\u0012��\u0011�\u001d�����6r�\u00015�C��0�t�s��\u0016�[\u0002��5\u000f\r�'�\u0007�3�c\u001e�~\u0017a{�\u001a:�qp���S.����z�ύe�9�X�Js��Μ=�P�\u0017JL���kE����:?���e�M I��ԓ�ϖ�[�\u0003��P1#���_�\u001cA/g,\u0006�\u0016���b�4=\u001d\u0015\fw��oTH�mfZ-���'?\u0003~@.NI<11[�)E���`�O+������G��D��Śŝ��!4���C\u0006xfot>�\b������Ep9�4\u0006\u0015����}m|�2�e-�ںn�{��\u0001ܬ\u0005gM��k�6nn��\t\u0010�\u0018\n-��c���lcJ�Ҕ���o�\u0013��\u000b���H\n-�2�DR�}�2��\u0001'�\u0002'���\u0015�ʀ\u0013L�\u0013�}�\n-he�\t��\t�u\u0005�2�\u0004S�\u0004wߺ\u0002N0\u0003�\u000f��&\u001enXpZ\u0000\u0000\u0000\u0000IEND�B`�\n\\n+[DELETED]\n\\n"
632
- },
633
- {
634
- "path": "native/assets/manifold_white_transparent.png",
635
- "status": "added",
636
- "diff": "Index: native/assets/manifold_white_transparent.png\n===================================================================\n--- native/assets/manifold_white_transparent.png\t74465a3 (parent)\n+++ native/assets/manifold_white_transparent.png\t7d171ee (commit)\n@@ -1,1 +1,22 @@\n-[NEW FILE]\n\\n+�PNG\r\n+\u001a\n+\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000`\u0000\u0000\u0000`\b\u0006\u0000\u0000\u0000�w8\u0000\u0000\u0000\u0004gAMA\u0000\u0000��\u000b�a\u0005\u0000\u0000\n+IiCCPsRGB IEC61966-2.1\u0000\u0000H��SwX��\u0016>��e\u000fVB��l�\u0000\"#�\b�\u0010Y�\u0010�\u0000a�\u0010\u0012@Ņ�\n+V\u0014\u0015\u0011�HUĂ�\n+H���(�gA��Z�U\\8�\u001fܧ�}z�����������y�\u000f�\u0011\u0012&��j\u00009R�<:�\u001f�OH�ɽ�\u0002\u0015H�\u0004 \u0010���g\u0005�\u0000\u0000�\u0003yx~t�?�\u0001�o\u0000\u0002\u0000p�.$\u0012�����P&W\u0000 �\u0000�\"\u0012�\u000b\u0001�R\u0000�.T�\u0014\u0000�\u0018\u0000�S�d\n+\u0000�\u0000\u0000ly|B\"\u0000�\r\u0000��I>\u0005\u0000ة��\u0017\u0000آ\u001c�\b\u0000�\u0001\u0000�(G$\u0002@�\u0000`U�R,\u0002��\u0000��@\".\u0004��\u0001�Y�2G\u0002��\u0005\u0000v�X�\u000f@`\u0000��B,�\u0000 8\u0002\u0000C\u001e\u0013�\u0003 L\u0003�0ҿ�_p��H\u0001\u0000�˕͗K�3\u0014���\u001aw����!��l�Ba\u0017)\u0010f\t�\"���#\u0013H�\u0003L�\f\u0000\u0000\u001a����8?������f�l��Ţ�k�o\">!�����\u0002\u0004\u0000\u0010N���_���\u0003p�\u0001�u�k�[\u0000�V\u0000h��]3�\t�Z\n+�z��y8�@\u001e��P�<\u001d\u001c\n+\u000b\u000b�%b��0�>�3�o��~��@\u001e��z�\u0000q�@������qanv�R���\u0004B1n��#�Dž��)��4�\\,\u0015��X��P\"M�y�R�D!ɕ�\u0012�2�\u001f��\t�w\r\u0000��O�N�\u0007��l�~�\u0001\u0002�\u000eX�v\u0000@~�-�\u001a\u000b�\u0000\u0010g42y�\u0000\u0000����@+\u0001\u0000͗��\u0000\u0000��\u0018\\��\u0017L�\b\u0000\u0000D��*�A\u0007\f�\u0014��\u000e��\u001d��\u0017\u0002a\u0006D@\f$�<\u0010B\u0006�\u001c\n+�\u0018�A\u0019T�:�\u0004��\u0003\u001a�\u0011��\u0010��18\r��\u0012\\��p\u0017\u0006`\u0018��\u0018��\t\u0004A�\b\u0013a!:�\u0011b��\"�\b\u0017��\u0004\"aH4��� �\u0014Q\"��r�\u0002�Bj�]H#�-r\u00149�\\@���� 2����G1���Q\u0003�\u0002u@��\u001f\u001a�Ơs�t4\u000f]���k�\u001a�\u001e=�����K�ut\u0000}��c��1\u000ef��a\\��E`�X\u001a&�\u0016c�X5V�5c\u001dX7v\u0015\u001b��a�\b$\u0002��\u0013�\b^�\u0010�l���GXLXC�%�#�\u0012�\bW\t��1�'\"��O�%z\u0012��xb:��XF�&�!\u001e!�%^'\u000e\u0013_�H$\u000eɒ�N\n+!%�2I\u000bIkH�H-�S�>�\u0010i�L&�m���\b��� ����\u000f�O�����\u0014:ň�L\t�$R��\u0012J5e?�\u0004��2B���Qͩ��\b��:�ZIm�vP/S��\u00134u�%͛\u0016Cˤ-��Кigi�h/�t�\t݃\u001eE�З�k�\u0007����w\f\r�\r��Hb(\u0019k\u0019{\u0019�\u0018�\u0019/�L�\u0005ӗ��T0�2\u001b�g�\u000f�oUX*�*|\u0015��\u0012�:�V�~��TUsU?�y�\u000bT�U\u000f�^V}�FU�P�\t�\u0016�թ\u001dU��6��RwR�P�Q_��_���c\r���F��H�Tc��\u0019�!\u0016�2e�XB�rV\u0003�,k�Mb[���Lv\u0005�\u001bv/{LSCs�f�f�f��q�\u0001\u000eƱ��9ٜJ�!�\r�{-\u0003-?-��j�f�~�7�zھ�b�r�\u0016����up�@�,��:m:�u\t�6�Q����u��>�c�y�\t���\u000e���G�m���\u0017�����\u001f704\b6�\u0019l18c�̐c�k�i�����\u0011�h���h��I�'�&�g�5x\u0017>f�o\u001cb�4�e�k<abi2ۤĤ��)͔k�f�Ѵ�t���,ܬج��9՜k�a�ټ�����E��J�6�ǖږ|�\u0005�M����V>VyV�V׬I�\\�,�m�WlP\u001bW�\f�:�˶�����v�m�\u0014�\u0014�)�)�Sn�1���\n+��\u0006�9�a�%�m��\u001d�\u001c\u0012\u001d�;t;|rtu�vlp���4éĩ��Wg\u001bg�s��5\u0017�K��\u0012�v�\u0017Sm���n�z˕�\u001a�ҵ������ܭ�m���=�}��M.�\u001b�]�=�A���X�q�㝧�����/^v^Y^��\u001eO��&��0m���[��{`:>=e���\u0003>�>\u0002�z�����\"�=�#~�~�~\u0007���;�������y�\u0016�N\u0005`\u0001�\u0001�\u0001��\u001a��\u0003k\u0003\u001f\u0004�\u0004�\u00075\u0005�\u0005�\u0006/\f>\u0015B\f\t\rY\u001fr�o�\u0017�\u001b�c3�g,��\u0015�\b�\u0015Z\u001b�0�&L\u001e�\u0011���\b�\u0010~o��L�̶\b��Gl��\u001fi\u0019�\u0017�}\u0014)*2�.�Q�Stqt�,֬�Y�g��񏩌�;�j�rvg�jlRlc웸�����x��E�\u0012t\u0013$\t�����=��s\u0002�l�3��T�tc��ܢ�\u0017���˞w<Y5Y�|8��\u0012��?� BP/\u0018O�nM\u001d\u0013򄛅OE����Q���J<��V��8�;}C�h�OFu�3\tOR+y�\u0019��#�MVD�ެ��q�-9�����R\ri��+�0�(�Of++�\r�y�m�\u001b�����#�s��\u0015l�Lѣ�R�P\u000e\u0016L/�+x[\u0018[x�H�HZ�3�f���#\u000b�\u0016|���P���ظxY��\"�E�\u0016#�S\u0017w.1]R�dxi��}�h˲��P�XRU�jy��R�ҥ�C+�W4�����n��Z�c\u0015a�dU�j��[V*\u0017�_�p�����F���WN_�|�ym���J����H��n��Y��J�jA�І�\r�\u001b��\u001b_mJ�t�zj��ʹ���\u00035a5�[̶���6��z�]�V������&�ֿ�w{�\u000e�\u001d\u0015;��켵+xWk�E}�n��ݏ\u001ab\u001b���~ݸGwOŞ�{�{\u0007�E��jtolܯ���\tmR6�\u001eH:p囀oڛ�w�pZ*\u000e�A��'ߦ|{�P������ߙ��\b�Hy+�:�u�-�m�=���茣�\u001d^\u001dG���~�1�cu�5�W���(=��䂓�d���N?=ԙ�y�L��k]Q]�gCϞ?\u0017t�L�_�����]�p�\"�b�%�K�=�=G~p��H�[o�e���W<�t�M�;����j��s���.]�y����\u001b�n&�\u001c�%���v��\u0017w\n+�L�]z�x������\u0007�\u000f����e�m��`�`��Y\u000f�\u000e\t�����Ӈ��G�G�#F#���\u001f\u001f\u001b\r\u001a��dΓ᧲�\u0013��~V�y�s������K�X���\u000b��Ͽ�y��r﫩�:�#�\u001f��y=�����}���ǽ\u001f�(�@�P���cǧ�O�>�|��/����-G8�\u0000\u0000\u0000 cHRM\u0000\u0000z&\u0000\u0000��\u0000\u0000�\u0000\u0000\u0000��\u0000\u0000u0\u0000\u0000�`\u0000\u0000:�\u0000\u0000\u0017p��Q<\u0000\u0000\u0000\tpHYs\u0000\u0000.#\u0000\u0000.#\u0001x�?v\u0000\u0000\b�IDATx��]��6\u0012}{u�\u000f�\u0001/\u0002�\u0011X\u001b��\u0011�\u001b�i#�\u001c\u0001�\u0001�\u0011p\u001c��\u0011P\u001b\u0001�\u0011�\u0017A�\u000fPV\u0013\u0004I\u0010\u0000Ei�Wյ\u001c|t7�\u0001-\u0002��~ \"��\u000e��ځ�;v\u00026�N���\t�\u0018;\u0001\u001bc'`c�\u0004l�����\u0013�1v\u00026�3\u0011P\u0003�\u0000��~$ų\u0010�\u0000H\u0000\u0002�O[:�\u001a�B�'��`�x\u0017x\u0006\u0002\u0004��*Sw�b%<\u0003\u0001�Q��Q��x\u0006\u0002\\�>�ۉ���\u0004\b�V@\u000b��Q��XB�\u0000Pu\"�p�\u0001Ş_\u0000��~~\u001fi��|D\u0010QM7�]�o�P��Mٕ]��`J\u0014\u0011�b����LC�+\u000f0c�\u001aV^�r�Q��Tq�IA%�\u001fz9�S��hC��W����\u000f+�w!�ّ\u001fYY\u001b�q\u0001�c�W�e5�!Y�`��J�]r ��l�\u0000]�Te�\b�\u000b:�\t�dL㨯R\u0005�SN#cw��H�RP\u000e�z��\u0005�W@\u0000��[Z\u0010\u0000�H�f���o�z���<\u001b\u00120c�y��5ڂ�\u0015I��V����\u000bZ�͈��\u001c�s+$��N7GA�W�\\��V��ՁL�y�*��yp�vu�@Xr�>4�7.�ʚ\u0014�x\n+\u00120iGt?_\u0000�8�x\u0004�\u001b�G��\u0004\u0014�\"rQ��=�ҏ��\u0010i�\n+\u0001\u0013\u0003>�\u000b�\u0018_�O�_�Xd3���\u0015\u000e�N�́��֌�#fE��d\u0013�|W��H+\u0006D&��8T�N%�\t�b\"�\u0004\u0015���:G�Vp8\u000169DaiA���G����\"kNr�RM�I$����SG@Aa8:\u0014���KIྸ��r\\��%�g\"3�\\~�M\u0019h�I@(N\u000e���ތ\u001a�ק��l-\u0019|FÔSMحX;��֨| �\u0013�[�\u000b��Y\u001f4mW~�y/\u0006�����X\u00110�������q$�~����z\u0001�h���\u0000x����\u0000�/\u0000~\u001di/`.\u0004\u0000&\u0016���m\u001e\u000b\u0019\u0013l\u0016�gf�fmK\u000f�%k�/�X��D}h\u001a~�ْ/\u001c���t��<�NY\u0003�\u000b�fm�\u0002$�WM�\u00134<�={ڪX\u001f\u0015\u0010�U\b�=�\u001e�\u0001�9�ɚ\n+�4��+�\u0007\u001a\u001e�\u0015��\u0005�\u0003|KN��9�Ӿ�\u0006 g��\u0001>\u0015\u0013��I�i�,�Y�2���\t��44���\u0005Ǯ�\u0011��Wt��\u0015�Q���\u0002ױ���\b�\u000e�\u000eF����\u0015�2b0��4<H\u000b�}���mTBnE|cϙg�\u0016�g�Ό$n���8�\u001f3/�+{��W۟\u0003t�\u0011��p�k)\u0017���s�C/���\"n�.�.0{��@���E��\u0018G���ٲ<F�O�\"@�w�KӚ�M5c\u0012�\u0002�س\b��\u0002��3t�)��#Y�\u0017���\u0006��\u0015��k��Ilu3�\u000b���\u001a��@���\u001b��1~��\u0004�\u001f (\u0005\t���\u0011�OP������Klʹk�\tMAm\"�[�ӄ�߷i\u0007�\u00038��~A���X(�!�\u0000\bZ\u0001�fm�,���/&ڞ�Y�i� -DJfC��_t~�P\u0005g�`\n+�r+�����jSSܷ`S�;\u001b:��\u0003�7ƒ���(\u0004�ƿҔ�\u001e�F�A�;sIf\u0002\u0015�SˡC��������Α;��\t����f�ft���Lc�D�R��C��\u000b\u001a��\\Q�}�!j\u0016 ۷C7����r\f\r\rӧ��埫~�/G\u000b�G8[�/]y��}����#��`޸�G�\u0016f?���ҕ��6\u001f��\u000b�%9c�p��4\\�yB�sR�Od'�dr�\"w*�W�&k\u0015���7�,\u0002u�(пw�\u0006�O�$��\u00035Qw��\u000f�<�\ff�.��[��!E\n+�Wd�\f}G\u0001�������B�H�[��.�%ѿ1��\u0011|\u0000�)\b�2\f�q�a�9E�K�~bҞ��4H;\\�$�d\u0005^�:�Z_�A\u0013��_O\u0005?����fa?A�\rJ\u001d1�\u0014��/U��ώ)�8��ϲ\u0005}$������\n+s��\u0006�!a�m\u0015�\u001f�?z�� ��/�=\u001c��5|\u0016��?Z�^S�A���\u001b�KW3��ZK�f�Ub\u0006_2�r&H\u0015�QS�#���\u0019�C1\u001d�¾\u0007�\b>Q\\\n+��=��6\u0012fi*V�\u0002�<�\"l\u0003f�p��\"��\u0003\u0010�~r��\u001d���wb�\u001f\u001c�9\r_��\b{S�>F/��;{s�^з'1A�K�dՕ��\r�;H\u0013��ޗ\u0011�\u000eLO���\u000e~E\u0011op1���_\t�h��!c�6��\u000b�+�ho\u0007��\u001dO*\u0002\n+2+B[\u000e\u001ec\u001d�\t����0]sD&\u000f>E\u0012\u0000挶�Ӕ~W+-;��J���i�J�)!\u0001\u001cgJ��\u0015�Oms\u0001���'�\u001d��)�\u0016�@[�iZ�[���h(\r�\r�9���%z��\u000f�R�\u001b\r�\u0019����ҝB�d���6�e�L`79\u0001��\u0012�i\u001c�s^\u0005\u0006J3]�D\u0003/���Q_Zc8%���\u0000.Y\u0017�3�C�22jַJ�k��\n+���|v\u0011��\u0004p\u0011��\u0015M����A\u0000�gi3�&D�e��ӊ\u0004Ē��:�Пb$��es��ӝ\b�Eu��4\u000e^wJl�a�E'�e�.���\bXJFM�%K`O2�\u0015�/��-��\u0000\u0004��)h��YMq��\u0005�u���Od�XǢ��;A������W�o0�W~���!��=Z���|�ڿ\u000b���\u0012���\u0010�\u0013Ư\u0007��\u0004�\u000f�\u0007Q���X8Z��ٹ\u0007��\"%\u001bI\u001d\u001c��{�b�mh:{�)�\u0007\u0019��O\u0018�R��2~F?���r�wxf\u00028\u0004\f\tSdp�x��\u0003�\u0000\u000e���\u0010V}�\u0007\t>�>\t��p#���o\\O��@�C���\u000f�w�����\u0013�1v\u00026�N���\t�\u0018;\u0001\u001bc'`c�\u0004l�����\u0013�1�\u000f9\t�1=I\u0012\u0014\u0000\u0000\u0000\u000eeXIfMM\u0000*\u0000\u0000\u0000\b\u0000\u0000\u0000\u0000\u0000\u0000\u0000�S�\u0000\u0000\u0000\u0000IEND�B`�\n\\n"
637
- },
638
- {
639
- "path": "native/components/auth-page.tsx",
640
- "status": "modified",
641
- "diff": "Index: native/components/auth-page.tsx\n===================================================================\n--- native/components/auth-page.tsx\t74465a3 (parent)\n+++ native/components/auth-page.tsx\t7d171ee (commit)\n@@ -223,15 +223,16 @@\n \n export const AuthPageStyles = StyleSheet.create({\n container: {\n flex: 1,\n+ display: 'flex',\n justifyContent: 'center',\n backgroundColor: '#4337C9',\n },\n flappy: {\n height: 175,\n resizeMode: 'contain',\n- marginTop: 180,\n+ marginTop: 150,\n },\n googleButton: {\n backgroundColor: 'white',\n borderRadius: 5,\n@@ -259,8 +260,9 @@\n width: 300,\n paddingTop: 20,\n padding: 35,\n height: 180,\n+ minHeight: 180,\n alignItems: 'center',\n },\n modalView: {\n margin: 20,\n"
642
- },
643
- {
644
- "path": "native/components/custom-webview.tsx",
645
- "status": "modified",
646
- "diff": "Index: native/components/custom-webview.tsx\n===================================================================\n--- native/components/custom-webview.tsx\t74465a3 (parent)\n+++ native/components/custom-webview.tsx\t7d171ee (commit)\n@@ -33,16 +33,18 @@\n resetWebView: () => void\n setHasLoadedWebView: (loaded: boolean) => void\n handleMessageFromWebview: (m: any) => Promise<void>\n handleExternalLink: (url: string) => void\n+ display: boolean\n }) => {\n const {\n urlToLoad,\n webview,\n resetWebView,\n setHasLoadedWebView,\n handleMessageFromWebview,\n handleExternalLink,\n+ display,\n } = props\n \n const [refreshing, setRefreshing] = useState(false)\n const [refresherEnabled, setEnableRefresher] = useState(true)\n@@ -55,12 +57,14 @@\n } else if (refresherEnabled) {\n setEnableRefresher(false)\n }\n }\n+\n return (\n- <>\n+ <View style={{ flex: display ? 1 : 0, height: display ? 'auto' : 0 }}>\n {Platform.OS === 'android' ? (\n <ScrollView\n+ style={{ display: display ? 'flex' : 'none' }}\n contentContainerStyle={[styles.container, { position: 'relative' }]}\n refreshControl={\n <RefreshControl\n refreshing={refreshing}\n@@ -127,9 +131,9 @@\n }}\n />\n </View>\n )}\n- </>\n+ </View>\n )\n }\n \n const styles = StyleSheet.create({\n"
647
- },
648
- {
649
- "path": "native/ios/Manifold/Info.plist",
650
- "status": "modified",
651
- "diff": "Index: native/ios/Manifold/Info.plist\n===================================================================\n--- native/ios/Manifold/Info.plist\t74465a3 (parent)\n+++ native/ios/Manifold/Info.plist\t7d171ee (commit)\n@@ -20,9 +20,9 @@\n <string>$(PRODUCT_NAME)</string>\n <key>CFBundlePackageType</key>\n <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>\n <key>CFBundleShortVersionString</key>\n- <string>2.0.65</string>\n+ <string>2.0.66</string>\n <key>CFBundleSignature</key>\n <string>????</string>\n <key>CFBundleURLTypes</key>\n <array>\n@@ -40,9 +40,9 @@\n </array>\n </dict>\n </array>\n <key>CFBundleVersion</key>\n- <string>1.0.65</string>\n+ <string>1.0.66</string>\n <key>ITSAppUsesNonExemptEncryption</key>\n <false/>\n <key>LSMinimumSystemVersion</key>\n <string>12.0</string>\n"
652
- }
653
- ]
654
- },
655
- {
656
- "id": "modernize-bottom-nav",
657
- "sha": "74465a3153ad1c23ba79ed5d8d03e6afb84de773",
658
- "parentSha": "baff7da01053a8ea6b270846ce660b070724d1f2",
659
- "spec": "Implement mobile bottom navigation updates with active-state solid icons and touch animations, and add solid/outline support for the notifications icon.\n\nRequired changes:\n\n1) Bottom navigation behavior and visuals (web/components/nav/bottom-nav-bar.tsx)\n- Use the solid icon variant for a nav item when its route is the current page. Base the active state on comparing the first path segment of the current pathname to the nav item's href path segment.\n- Pass a solid icon component for each applicable item (e.g., Search, Explore, Inbox), falling back to the outline icon otherwise.\n- For the Inbox item, use the NotificationsIcon with a new solid prop. Render the outline icon when inactive and the filled icon when active.\n- Update class names for items and icons: selected item should only change text color (no background), adopt the new icon sizing, and apply the new touch-press animation class on touch.\n- On the Profile item, add a ring highlight around the avatar when the current page is the user's profile. Adjust the TokenNumber coin vertical offset as in the diff, and remove the sweeps cash TokenNumber display from this bottom bar item.\n\n2) Nav item type extension (web/components/nav/sidebar-item.tsx)\n- Extend the NavItem type with an optional solidIcon?: React.ComponentType<{ className?: string }>. Do not change the existing API otherwise.\n\n3) Notifications icon solid/outline support (web/components/notifications-icon.tsx)\n- Add a boolean prop `solid` (optional) to NotificationsIcon. When `solid` is true, render a filled bell icon; otherwise render the outline bell. Continue to render the unseen notifications bubble behavior as before.\n- Replace outline/solid icons with IoNotificationsOutline and IoNotifications from react-icons/io5. Preserve the className prop behavior and external interface.\n\n4) Touch press animation CSS (web/styles/globals.css)\n- Add keyframes and utility classes for a touch press effect (bounce and scale transitions). Provide classes used by the bottom navigation for press-in and release behavior as in the diff: touch-bounce, touch-scale-down, touch-press-effect, and touch-release, with appropriate durations and easing.\n\nAcceptance criteria:\n- On mobile, active tabs in the bottom navigation show their solid icon variant; inactive tabs show outline icons. Specifically, Explore shows a filled compass when active, Browse uses its solid search icon when active, and Inbox uses a filled bell when active.\n- Tapping bottom nav items applies a visible press effect consistent with the new CSS classes.\n- The Profile bottom nav item shows a ring around the user avatar when viewing the user's own profile. Only the main token balance is shown (no sweeps balance) in this item.\n- The NavItem type supports an optional solidIcon and does not break existing sidebar usage.\n- NotificationsIcon accepts a solid flag and correctly switches between outline and filled icons without breaking unseen count behavior.\n",
660
- "prompt": "Modernize the mobile bottom navigation to use filled icons for the active tab, introduce a subtle touch-press animation, and make the notifications icon switch between outline and filled variants based on active state. Add support in the nav item model for providing a solid icon alongside the outline icon. Visually highlight the profile avatar in the bottom bar when the user is on their profile. Keep the current desktop sidebar behavior intact.\n\nHigh-level requirements:\n- Active bottom nav items use a filled icon; inactive items use an outline icon. Ensure this includes Search/Browse, Explore, and Notifications.\n- The notifications bell component should support switching between outline and filled variants via a simple prop, while retaining unseen count behavior.\n- Add a lightweight tap animation for bottom nav items to feel snappier on touch.\n- When the user is on their own profile page, the bottom bar profile avatar should get a clear active highlight. The bottom bar should show only the primary token balance for the profile item.\n- Extend the nav item type to optionally accept a solid icon without changing existing desktop usage.\n\nDo not change unrelated functionality or desktop navigation. Ensure that existing pages and routes continue to work and that the new behavior is visible in the mobile layout.",
661
- "supplementalFiles": [
662
- "web/components/nav/sidebar.tsx",
663
- "web/components/layout/page.tsx",
664
- "web/pages/_app.tsx",
665
- "web/components/notifications/notification-dropdown.tsx",
666
- "web/components/nav/profile-summary.tsx"
667
- ],
668
- "fileDiffs": [
669
- {
670
- "path": "web/components/nav/bottom-nav-bar.tsx",
671
- "status": "modified",
672
- "diff": "Index: web/components/nav/bottom-nav-bar.tsx\n===================================================================\n--- web/components/nav/bottom-nav-bar.tsx\tbaff7da (parent)\n+++ web/components/nav/bottom-nav-bar.tsx\t74465a3 (commit)\n@@ -10,14 +10,22 @@\n QuestionMarkCircleIcon,\n SearchIcon,\n UserCircleIcon,\n } from '@heroicons/react/outline'\n-import { MenuAlt3Icon, XIcon } from '@heroicons/react/solid'\n+import {\n+ MenuAlt3Icon,\n+ QuestionMarkCircleIcon as QuestionMarkCircleIconSolid,\n+ // SearchIcon as SearchIconSolid,\n+ UserCircleIcon as UserCircleIconSolid,\n+ XIcon,\n+} from '@heroicons/react/solid'\n import clsx from 'clsx'\n import { User } from 'common/user'\n import Link from 'next/link'\n import { usePathname } from 'next/navigation'\n import { Fragment, useState } from 'react'\n+import { FaSearch as SearchIconSolid } from 'react-icons/fa'\n+import { IoCompass, IoCompassOutline } from 'react-icons/io5'\n import { NotificationsIcon } from 'web/components/notifications-icon'\n import { useIsIframe } from 'web/hooks/use-is-iframe'\n import { useUser } from 'web/hooks/use-user'\n import { firebaseLogin } from 'web/lib/firebase/users'\n@@ -27,62 +35,83 @@\n import { Avatar } from '../widgets/avatar'\n import { TokenNumber } from '../widgets/token-number'\n import Sidebar from './sidebar'\n import { NavItem } from './sidebar-item'\n-import { IoCompassOutline } from 'react-icons/io5'\n \n export const BOTTOM_NAV_BAR_HEIGHT = 58\n \n const itemClass =\n 'sm:hover:bg-ink-200 block w-full py-1 px-3 text-center sm:hover:text-primary-700 transition-colors'\n-const selectedItemClass = 'bg-ink-100 text-primary-700'\n-const touchItemClass = 'bg-primary-100'\n-const iconClassName = 'mx-auto my-1 h-7 w-7'\n+const selectedItemClass = 'text-primary-700'\n+const touchItemClass = 'touch-press-effect'\n+const iconClassName = 'mx-auto my-1 h-[1.6rem] w-[1.6rem]'\n+const exploreIconClassName =\n+ ' h-[1.8rem] w-[1.8rem] !mb-[0.19rem] !mt-[0.135rem]'\n \n+// Wrapper components for NotificationsIcon to work with the navigation system\n+const NotificationsIconOutline = (props: { className?: string }) => (\n+ <NotificationsIcon {...props} solid={false} />\n+)\n+const NotificationsIconSolid = (props: { className?: string }) => (\n+ <NotificationsIcon {...props} solid={true} />\n+)\n+\n function getNavigation(user: User) {\n return [\n {\n name: 'Browse',\n href: '/home',\n icon: SearchIcon,\n+ solidIcon: SearchIconSolid,\n },\n {\n name: 'Explore',\n href: '/explore',\n icon: IoCompassOutline,\n- iconClassName: '!h-[1.9rem] !w-[1.9rem] !mb-[0.19rem] !mt-[0.13rem]',\n+ solidIcon: IoCompass,\n+ iconClassName: exploreIconClassName,\n },\n {\n name: 'Profile',\n href: `/${user.username}`,\n },\n {\n name: 'Inbox',\n href: `/notifications`,\n- icon: NotificationsIcon,\n+ icon: NotificationsIconOutline,\n+ solidIcon: NotificationsIconSolid,\n },\n ]\n }\n \n const signedOutNavigation = () => [\n- { name: 'Browse', href: '/browse', icon: SearchIcon, alwaysShowName: true },\n {\n+ name: 'Browse',\n+ href: '/browse',\n+ icon: SearchIcon,\n+ solidIcon: SearchIconSolid,\n+ alwaysShowName: true,\n+ },\n+ {\n name: 'Explore',\n href: '/explore',\n icon: IoCompassOutline,\n- iconClassName: '!h-[1.9rem] !w-[1.9rem] !mb-[0.19rem] !mt-[0.13rem]',\n+ solidIcon: IoCompass,\n+ iconClassName: exploreIconClassName,\n // prefetch: false, // should we not prefetch this?\n },\n // { name: 'News', href: '/news', icon: NewspaperIcon, alwaysShowName: true },\n {\n name: 'About',\n href: '/about',\n icon: QuestionMarkCircleIcon,\n+ solidIcon: QuestionMarkCircleIconSolid,\n },\n {\n name: 'Sign in',\n onClick: firebaseLogin,\n icon: UserCircleIcon,\n+ solidIcon: UserCircleIconSolid,\n },\n ]\n \n // From https://codepen.io/chris__sev/pen/QWGvYbL\n@@ -145,42 +174,41 @@\n const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)\n const [touched, setTouched] = useState(false)\n \n if (item.name === 'Profile' && user) {\n+ const isOnUserProfile = currentPage === `/${user.username}`\n+\n return (\n <Link\n prefetch={item?.prefetch ?? true}\n href={item.href ?? '#'}\n className={clsx(\n itemClass,\n touched && touchItemClass,\n- currentPage === `/${user.username}` && selectedItemClass,\n+ isOnUserProfile && selectedItemClass,\n className\n )}\n onClick={track}\n onTouchStart={() => setTouched(true)}\n onTouchEnd={() => setTouched(false)}\n >\n <Col className=\"relative mx-auto h-full w-full items-center\">\n- <Avatar size=\"sm\" avatarUrl={user.avatarUrl} noLink />\n- <Row className=\"gap-1\">\n+ <div\n+ className={clsx(\n+ 'rounded-full',\n+ isOnUserProfile && 'ring-2 ring-violet-600'\n+ )}\n+ >\n+ <Avatar size=\"sm\" avatarUrl={user.avatarUrl} noLink />\n+ </div>\n+ <Row className=\"mt-0.5 gap-1\">\n <TokenNumber\n amount={user?.balance}\n className=\"text-violet-600 dark:text-violet-400\"\n numberType=\"short\"\n isInline\n- coinClassName=\"!top-[0.15em]\"\n+ coinClassName=\"!top-[0.1rem]\"\n />\n- {user?.cashBalance >= 1 && (\n- <TokenNumber\n- amount={user?.cashBalance}\n- className=\"text-amber-600 dark:text-amber-400\"\n- coinType=\"sweepies\"\n- numberType=\"short\"\n- isInline\n- coinClassName=\"!top-[0.15em]\"\n- />\n- )}\n </Row>\n </Col>\n </Link>\n )\n@@ -208,8 +236,13 @@\n \n const currentBasePath = currentPage?.split('/')[1] ?? ''\n const itemPath = item.href.split('/')[1]\n const isCurrentPage = currentBasePath === itemPath\n+\n+ // Use solid icon if available and page is active\n+ const IconComponent =\n+ isCurrentPage && item.solidIcon ? item.solidIcon : item.icon\n+\n return (\n <Link\n href={item.href}\n className={clsx(\n@@ -221,10 +254,10 @@\n onClick={track}\n onTouchStart={() => setTouched(true)}\n onTouchEnd={() => setTouched(false)}\n >\n- {item.icon && (\n- <item.icon className={clsx(iconClassName, item.iconClassName)} />\n+ {IconComponent && (\n+ <IconComponent className={clsx(iconClassName, item.iconClassName)} />\n )}\n {children}\n {item.name}\n </Link>\n"
673
- },
674
- {
675
- "path": "web/components/nav/sidebar-item.tsx",
676
- "status": "modified",
677
- "diff": "Index: web/components/nav/sidebar-item.tsx\n===================================================================\n--- web/components/nav/sidebar-item.tsx\tbaff7da (parent)\n+++ web/components/nav/sidebar-item.tsx\t74465a3 (commit)\n@@ -9,8 +9,9 @@\n trackingEventName?: string\n href?: string\n onClick?: () => void\n icon?: React.ComponentType<{ className?: string }>\n+ solidIcon?: React.ComponentType<{ className?: string }>\n iconClassName?: string\n external?: boolean\n alwaysShowName?: boolean\n prefetch?: boolean\n"
678
- },
679
- {
680
- "path": "web/components/notifications-icon.tsx",
681
- "status": "modified",
682
- "diff": "Index: web/components/notifications-icon.tsx\n===================================================================\n--- web/components/notifications-icon.tsx\tbaff7da (parent)\n+++ web/components/notifications-icon.tsx\t74465a3 (commit)\n@@ -1,26 +1,34 @@\n 'use client'\n-import { BellIcon } from '@heroicons/react/outline'\n-import { Row } from 'web/components/layout/row'\n-import { useEffect } from 'react'\n-import { usePrivateUser } from 'web/hooks/use-user'\n-import { PrivateUser } from 'common/user'\n-import { usePathname } from 'next/navigation'\n-import { usePersistentLocalState } from 'web/hooks/use-persistent-local-state'\n+// import { BellIcon } from '@heroicons/react/outline'\n import {\n NOTIFICATIONS_PER_PAGE,\n useGroupedUnseenNotifications,\n } from 'client-common/hooks/use-notifications'\n-import { api } from 'web/lib/api/api'\n+import { PrivateUser } from 'common/user'\n+import { usePathname } from 'next/navigation'\n+import { useEffect } from 'react'\n+import {\n+ IoNotificationsOutline as BellIcon,\n+ IoNotifications as BellIconSolid,\n+} from 'react-icons/io5'\n+import { Row } from 'web/components/layout/row'\n+import { usePersistentLocalState } from 'web/hooks/use-persistent-local-state'\n import { useUnseenPrivateMessageChannels } from 'web/hooks/use-private-messages'\n+import { usePrivateUser } from 'web/hooks/use-user'\n+import { api } from 'web/lib/api/api'\n \n-export function NotificationsIcon(props: { className?: string }) {\n+export function NotificationsIcon(props: {\n+ className?: string\n+ solid?: boolean\n+}) {\n const privateUser = usePrivateUser()\n- const { className } = props\n+ const { className, solid } = props\n+ const Icon = solid ? BellIconSolid : BellIcon\n return (\n <Row className=\"relative justify-center\">\n {privateUser && <UnseenNotificationsBubble privateUser={privateUser} />}\n- <BellIcon className={className} />\n+ <Icon className={className} />\n </Row>\n )\n }\n \n"
683
- },
684
- {
685
- "path": "web/styles/globals.css",
686
- "status": "modified",
687
- "diff": "Index: web/styles/globals.css\n===================================================================\n--- web/styles/globals.css\tbaff7da (parent)\n+++ web/styles/globals.css\t74465a3 (commit)\n@@ -240,4 +240,52 @@\n \n .text-6xl .coin-offset {\n --coin-top-offset: calc(0em);\n }\n+\n+/* Touch animation: shrink then grow back */\n+@keyframes bounce-press {\n+ 0% {\n+ transform: scale(1);\n+ }\n+ 50% {\n+ transform: scale(0.92);\n+ }\n+ 100% {\n+ transform: scale(1);\n+ }\n+}\n+\n+.touch-bounce {\n+ animation: bounce-press 0.2s ease-in-out;\n+}\n+\n+/* Alternative with active state for continuous press */\n+.touch-scale-down {\n+ transform: scale(0.95);\n+ transition: transform 0.1s ease-in-out;\n+}\n+\n+/* Combined effect: bounce then hold */\n+@keyframes touch-press-bounce {\n+ 0% {\n+ transform: scale(1);\n+ }\n+ 25% {\n+ transform: scale(0.92);\n+ }\n+ 50% {\n+ transform: scale(0.97);\n+ }\n+ 100% {\n+ transform: scale(0.95);\n+ }\n+}\n+\n+.touch-press-effect {\n+ animation: touch-press-bounce 0.15s ease-out forwards;\n+}\n+\n+.touch-release {\n+ transform: scale(1);\n+ transition: transform 0.1s ease-out;\n+}\n"
688
- }
689
- ]
690
- },
691
- {
692
- "id": "add-new-comments",
693
- "sha": "96f34d675776de903e986ab05e2e0a7be6135034",
694
- "parentSha": "2c5247ef0780d66fdc99559349f1104a04b01b3e",
695
- "spec": "Implement a new \"New Comments\" sorting mode for posts that surfaces posts with the most recent comment activity first and exposes a consistent \"Active\" timestamp in the UI.\n\nBackend API:\n- Update the get-posts API schema to accept a new sortBy value:\n - In common/src/api/schema.ts, extend the 'get-posts' props.sortBy enum to include 'new-comments'. Default remains 'created_time'.\n- Update the get-posts endpoint SQL ordering:\n - In backend/api/src/get-posts.ts, change the orderBy clause to:\n - If sortBy === 'new-comments', order by last_comment_time DESC NULLS LAST.\n - Otherwise, continue ordering by the existing op.<sortBy> DESC.\n - Ensure the SELECT/GROUP BY still provides last_comment_time as an aggregate (max(opc.created_time)) so ordering is valid and the mapped response has lastCommentTime available.\n- The response mapping should continue to populate lastCommentTime (epoch ms) from the aggregated last_comment_time field and include commentCount, reactionCount, and uniqueUsers as currently done.\n\nFrontend UI:\n- Post activity timestamp display:\n - In web/components/top-level-posts/post-card.tsx, always render an \"Active {fromNow(...)}\" label using post.lastCommentTime if present, otherwise fall back to post.createdTime (remove the conditional that hides the label when lastCommentTime is undefined).\n- Posts page filters and layout:\n - In web/pages/posts.tsx, add a new viewType 'new-comments' to the state union (current: 'latest' | 'best' | 'changelog'; new: include 'new-comments').\n - Replace the dynamic page title with a static \"Posts\" title.\n - Add a new \"New Comments\" filter button that sets viewType to 'new-comments' and shallow-pushes '/posts'.\n - Rename the \"Latest\" button label to \"New\" (keeps same behavior as created_time sorting).\n - Adjust the logic that decides when to fetch posts on the client:\n - Change shouldFetchDifferentPosts to fetch when viewType !== 'best' (best posts are provided via static props).\n - Update the API getter's sortBy mapping:\n - 'new-comments' -> 'new-comments'\n - 'latest' or 'changelog' -> 'created_time'\n - otherwise -> 'importance_score'\n - Continue to pass isChangeLog only when viewType === 'changelog'.\n - Adjust \"New Post\" button layout:\n - Show a mobile-only New Post button inline with the title (sm:hidden) and a desktop-only version in the filter row (hidden on mobile, visible on sm and up), both linking to /create-post and tracking the existing analytics event.\n\nBehavioral outcomes:\n- Calling GET /get-posts with sortBy=new-comments returns posts ordered by most recent comment time (nulls last), still respecting other filters (e.g., isChangeLog) and visibility.\n- The Posts page displays an additional \"New Comments\" toggle; selecting it shows posts ordered by recent comment activity.\n- Each PostCard shows an Active timestamp based on last comment time, or the creation time when there are no comments.\n- Existing \"Best\" and \"New\" modes function as before with the static-props cache for best posts maintained.",
696
- "prompt": "Add a new posts browse mode that surfaces posts with the most recent comment activity first. Update the server endpoint that returns posts so it accepts a new sort option and orders by the time of the latest comment when that option is chosen. Update the posts page to include a new filter for this mode alongside existing filters, and update the post card UI to always show an \"Active\" time using the latest comment time when available, falling back to the creation time. Keep the existing best and new modes behavior, and preserve the cached best posts behavior.",
697
- "supplementalFiles": [
698
- "backend/api/src/routes.ts",
699
- "common/src/top-level-post.ts",
700
- "web/hooks/use-api-getter.ts",
701
- "web/lib/api/api.ts"
702
- ],
703
- "fileDiffs": [
704
- {
705
- "path": "backend/api/src/get-posts.ts",
706
- "status": "modified",
707
- "diff": "Index: backend/api/src/get-posts.ts\n===================================================================\n--- backend/api/src/get-posts.ts\t2c5247e (parent)\n+++ backend/api/src/get-posts.ts\t96f34d6 (commit)\n@@ -40,9 +40,13 @@\n where(\n `to_tsvector('english', op.data->>'title') @@ websearch_to_tsquery('english', $2)`\n ),\n groupBy('op.id'),\n- orderBy(`op.${sortBy} DESC`),\n+ orderBy(\n+ sortBy === 'new-comments'\n+ ? 'last_comment_time DESC NULLS LAST'\n+ : `op.${sortBy} DESC`\n+ ),\n limitSql(limit, offset)\n )\n \n const query = renderSql(...sqlParts)\n"
708
- },
709
- {
710
- "path": "common/src/api/schema.ts",
711
- "status": "modified",
712
- "diff": "Index: common/src/api/schema.ts\n===================================================================\n--- common/src/api/schema.ts\t2c5247e (parent)\n+++ common/src/api/schema.ts\t96f34d6 (commit)\n@@ -1,82 +1,81 @@\n-import { z } from 'zod'\n+import { MAX_ANSWER_LENGTH, type Answer } from 'common/answer'\n+import { coerceBoolean, contentSchema } from 'common/api/zod-types'\n+import { AnyBalanceChangeType } from 'common/balance-change'\n+import type { Bet, LimitBet } from 'common/bet'\n+import { ChatMessage, PrivateChatMessage } from 'common/chat-message'\n import {\n- Group,\n- MAX_ID_LENGTH,\n- MySearchGroupShape,\n- LiteGroup,\n- SearchGroupParams,\n- SearchGroupShape,\n- Topic,\n-} from 'common/group'\n-import {\n- createMarketProps,\n- resolveMarketProps,\n- type LiteMarket,\n- FullMarket,\n- updateMarketProps,\n-} from './market-types'\n-import { type Answer } from 'common/answer'\n-import {\n Comment,\n CommentWithTotalReplies,\n MAX_COMMENT_LENGTH,\n PostComment,\n type ContractComment,\n } from 'common/comment'\n-import { CandidateBet } from 'common/new-bet'\n-import type { Bet, LimitBet } from 'common/bet'\n-import { coerceBoolean, contentSchema } from 'common/api/zod-types'\n import { AIGeneratedMarket, Contract, MarketContract } from 'common/contract'\n-import type { Txn, ManaPayTxn } from 'common/txn'\n-import { LiquidityProvision } from 'common/liquidity-provision'\n-import { DisplayUser, FullUser } from './user-types'\n+import { Dashboard } from 'common/dashboard'\n+import { SWEEPS_MIN_BET } from 'common/economy'\n+import {\n+ Group,\n+ LiteGroup,\n+ MAX_ID_LENGTH,\n+ MySearchGroupShape,\n+ SearchGroupParams,\n+ SearchGroupShape,\n+ Topic,\n+} from 'common/group'\n import { League } from 'common/leagues'\n-import { searchProps } from './market-search-types'\n-import { MAX_ANSWER_LENGTH } from 'common/answer'\n import { type LinkPreview } from 'common/link-preview'\n+import { LiquidityProvision } from 'common/liquidity-provision'\n+import { CandidateBet } from 'common/new-bet'\n import { Headline } from 'common/news'\n-import { Row } from 'common/supabase/utils'\n-import { AnyBalanceChangeType } from 'common/balance-change'\n-import { Dashboard } from 'common/dashboard'\n-import { ChatMessage, PrivateChatMessage } from 'common/chat-message'\n-import { PrivateUser, User } from '../user'\n-import { ManaSupply } from 'common/stats'\n-import { Repost } from 'common/repost'\n import { PERIODS } from 'common/period'\n-import { SWEEPS_MIN_BET } from 'common/economy'\n import {\n LivePortfolioMetrics,\n PortfolioMetrics,\n } from 'common/portfolio-metrics'\n+import { Repost } from 'common/repost'\n+import { ManaSupply } from 'common/stats'\n+import { Row } from 'common/supabase/utils'\n+import type { ManaPayTxn, Txn } from 'common/txn'\n+import { z } from 'zod'\n import { ModReport } from '../mod-report'\n+import { PrivateUser, User } from '../user'\n+import { searchProps } from './market-search-types'\n+import {\n+ FullMarket,\n+ createMarketProps,\n+ resolveMarketProps,\n+ updateMarketProps,\n+ type LiteMarket,\n+} from './market-types'\n+import { DisplayUser, FullUser } from './user-types'\n \n-import { RegistrationReturnType } from 'common/reason-codes'\n+import { ContractMetric } from 'common/contract-metric'\n import {\n CheckoutSession,\n GIDXDocument,\n GPSProps,\n PaymentDetail,\n- checkoutParams,\n- verificationParams,\n- cashoutRequestParams,\n PendingCashoutStatusData,\n cashoutParams,\n+ cashoutRequestParams,\n+ checkoutParams,\n+ verificationParams,\n } from 'common/gidx/gidx'\n-import { notification_preference } from 'common/user-notification-preferences'\n-import { PrivateMessageChannel } from 'common/supabase/private-messages'\n import { Notification } from 'common/notification'\n+import { RegistrationReturnType } from 'common/reason-codes'\n import { NON_POINTS_BETS_LIMIT } from 'common/supabase/bets'\n-import { ContractMetric } from 'common/contract-metric'\n+import { PrivateMessageChannel } from 'common/supabase/private-messages'\n+import { notification_preference } from 'common/user-notification-preferences'\n \n import { JSONContent } from '@tiptap/core'\n-import { Task, TaskCategory } from 'common/todo'\n-import { ChartAnnotation } from 'common/supabase/chart-annotations'\n-import { Dictionary } from 'lodash'\n-import { Reaction } from 'common/reaction'\n-import { YEAR_MS } from 'common/util/time'\n import { MarketDraft } from 'common/drafts'\n+import { Reaction } from 'common/reaction'\n+import { ChartAnnotation } from 'common/supabase/chart-annotations'\n+import { Task, TaskCategory } from 'common/todo'\n import { TopLevelPost } from 'common/top-level-post'\n+import { YEAR_MS } from 'common/util/time'\n+import { Dictionary } from 'lodash'\n // mqp: very unscientific, just balancing our willingness to accept load\n // with user willingness to put up with stale data\n export const DEFAULT_CACHE_STRATEGY =\n 'public, max-age=5, stale-while-revalidate=10'\n@@ -1424,9 +1423,9 @@\n cache: DEFAULT_CACHE_STRATEGY,\n props: z\n .object({\n sortBy: z\n- .enum(['created_time', 'importance_score'])\n+ .enum(['created_time', 'importance_score', 'new-comments'])\n .optional()\n .default('created_time'),\n term: z.string().optional(),\n limit: z.coerce.number().gte(0).lte(200).default(100),\n"
713
- },
714
- {
715
- "path": "web/components/top-level-posts/post-card.tsx",
716
- "status": "modified",
717
- "diff": "Index: web/components/top-level-posts/post-card.tsx\n===================================================================\n--- web/components/top-level-posts/post-card.tsx\t2c5247e (parent)\n+++ web/components/top-level-posts/post-card.tsx\t96f34d6 (commit)\n@@ -83,13 +83,11 @@\n }}\n />\n </Row>\n <Row className=\"items-center gap-2\">\n- {post.lastCommentTime && (\n- <span className=\"text-ink-400 text-sm\">\n- Active {fromNow(post.lastCommentTime)}\n- </span>\n- )}\n+ <span className=\"text-ink-400 text-sm\">\n+ Active {fromNow(post.lastCommentTime ?? post.createdTime)}\n+ </span>\n {isAdminOrMod && dropdownItems.length > 0 && (\n <DropdownMenu\n items={dropdownItems}\n buttonContent={<DotsHorizontalIcon className=\"h-5 w-5\" />}\n"
718
- },
719
- {
720
- "path": "web/pages/posts.tsx",
721
- "status": "modified",
722
- "diff": "Index: web/pages/posts.tsx\n===================================================================\n--- web/pages/posts.tsx\t2c5247e (parent)\n+++ web/pages/posts.tsx\t96f34d6 (commit)\n@@ -32,11 +32,11 @@\n const { bestPosts } = props\n const user = useUser()\n const router = useRouter()\n \n- const [viewType, setViewType] = useState<'latest' | 'best' | 'changelog'>(\n- 'best'\n- )\n+ const [viewType, setViewType] = useState<\n+ 'latest' | 'best' | 'changelog' | 'new-comments'\n+ >('best')\n \n useEffect(() => {\n if (!router.isReady) return\n const filter = router.query.filter as string | undefined\n@@ -46,14 +46,19 @@\n setViewType('best')\n }\n }, [router.isReady, router.query.filter])\n \n- const shouldFetchDifferentPosts =\n- viewType === 'latest' || viewType === 'changelog'\n+ // cache delivers best posts\n+ const shouldFetchDifferentPosts = viewType !== 'best'\n const { data: differentPosts, loading } = useAPIGetter(\n 'get-posts',\n {\n- sortBy: 'created_time',\n+ sortBy:\n+ viewType === 'new-comments'\n+ ? 'new-comments'\n+ : viewType === 'changelog' || viewType === 'latest'\n+ ? 'created_time'\n+ : 'importance_score',\n isChangeLog: viewType === 'changelog',\n },\n undefined,\n undefined,\n@@ -65,16 +70,24 @@\n <Page trackPageView={'posts page'}>\n <Col className=\" px-2 py-3\">\n <Row className=\"my-4 items-start justify-between sm:mt-0\">\n <Col className=\"w-full\">\n- <Title className=\"mx-4 !mb-0 sm:mx-0\">\n- {viewType === 'latest'\n- ? 'Latest Posts'\n- : viewType === 'best'\n- ? 'Best Posts'\n- : 'Changelog Posts'}\n- </Title>\n- <Row className=\"mx-4 mt-2 gap-2 sm:mx-0\">\n+ <Row className=\"items-center justify-between\">\n+ <Title className=\"mx-4 !mb-0 sm:mx-0\"> Posts</Title>\n+ {user && (\n+ <Col className=\"mr-1 self-end sm:hidden\">\n+ <Link\n+ href={'/create-post'}\n+ onClick={() => track('latest posts click create post')}\n+ className={clsx(buttonClass('xs', 'indigo'), 'self-end')}\n+ >\n+ <PencilIcon className=\"mr-1 h-4 w-4\" />\n+ New Post\n+ </Link>\n+ </Col>\n+ )}\n+ </Row>\n+ <Row className=\"mx-4 mt-4 gap-2 sm:mx-0\">\n <Button\n size=\"xs\"\n color={viewType === 'best' ? 'indigo' : 'gray-outline'}\n onClick={() => {\n@@ -91,12 +104,23 @@\n setViewType('latest')\n router.push('/posts', undefined, { shallow: true })\n }}\n >\n- Latest\n+ New\n </Button>\n <Button\n size=\"xs\"\n+ className=\"whitespace-nowrap\"\n+ color={viewType === 'new-comments' ? 'indigo' : 'gray-outline'}\n+ onClick={() => {\n+ setViewType('new-comments')\n+ router.push('/posts', undefined, { shallow: true })\n+ }}\n+ >\n+ New Comments\n+ </Button>\n+ <Button\n+ size=\"xs\"\n color={viewType === 'changelog' ? 'indigo' : 'gray-outline'}\n onClick={() => {\n setViewType('changelog')\n router.push('/posts?filter=changelog', undefined, {\n@@ -106,9 +130,9 @@\n >\n Changelog\n </Button>\n {user && (\n- <Col className=\"w-full\">\n+ <Col className=\"hidden w-full sm:flex\">\n <Link\n href={'/create-post'}\n onClick={() => track('latest posts click create post')}\n className={clsx(buttonClass('xs', 'indigo'), 'self-end')}\n"
723
- }
724
- ]
725
- },
726
- {
727
- "id": "add-last-active",
728
- "sha": "2b93dec5603c23080f65356daa69121c4cb9c6a7",
729
- "parentSha": "b3b3911535880532e6e1f481bd4eeae9894957d5",
730
- "spec": "- Add a developer guidance rule file at .cursor/rules/general-knowledge.mdc\n - Create a new file containing the Manifold coding guide; ensure alwaysApply is set to false in frontmatter.\n\n- Expose a post's most recent comment time via the get-posts endpoint\n - In backend/api/src/get-posts.ts:\n - Extend the SELECT to include max(opc.created_time) as last_comment_time.\n - Ensure grouping by post is compatible with the aggregate (groupBy('op.id') is present).\n - Map the SQL result field last_comment_time to a new TopLevelPost property lastCommentTime as a Unix ms timestamp (use Date.parse) or null if missing.\n\n- Add a corresponding field to the shared post type\n - In common/src/top-level-post.ts:\n - Add an optional field lastCommentTime?: number | null (document it as only available via the get-posts endpoint, consistent with uniqueUsers).\n\n- Show last active time and update moderation control on post cards\n - In web/components/top-level-posts/post-card.tsx:\n - Display an \"Active {relative time}\" label in the card header when post.lastCommentTime is present, using fromNow(lastCommentTime).\n - Replace the standalone \"Make Unlisted\" button with a kebab (three-dot) dropdown menu for admins/mods, with a single action that toggles between \"List\" and \"Unlist\" based on current visibility; reuse the existing update-post API logic for the action.\n - Remove the isLoading state related to the prior button.\n - Simplify PostCard props to accept only { post } (remove onPostClick) and update PostCardList accordingly to no longer pass onPostClick.\n - Keep the full-card overlay link to /post/[slug] and continue tracking with 'select post card'.\n\n- Functional acceptance criteria\n - The get-posts response includes lastCommentTime (ms) per post when comments exist; otherwise null/absent.\n - Post cards show an \"Active …\" relative timestamp in the top-right when a recent comment exists; nothing is shown if none exists.\n - Admin/mod users see a dropdown (three dots) with a single \"List\"/\"Unlist\" action that toggles post visibility without a loading spinner; clicking it successfully updates the post and shows toast feedback.\n - No consumer relies on PostCard.onPostClick; PostCardList renders correctly without that prop.\n",
731
- "prompt": "Enhance the posts UI to surface recent activity and streamline moderation:\n\n- Add a last active timestamp to each post card showing when the most recent comment was made. Source this from the database by returning the latest comment time in the posts list API. Thread this field through the shared post type and into the card component, and display a concise relative time label in the card header when available.\n\n- Replace the post card's direct Unlist button with a three-dot dropdown for admins/moderators that toggles between listing and unlisting a post. Keep the card’s full clickable overlay and tracking intact, and simplify the card/list component props accordingly.\n\n- Include a developer guidance file for the codebase in the editor rules directory so that future contributors have context.",
732
- "supplementalFiles": [
733
- "supabase/old_post_comments.sql",
734
- "backend/api/src/routes.ts",
735
- "web/pages/posts.tsx",
736
- "web/components/widgets/dropdown-menu.tsx",
737
- "common/src/util/array.ts",
738
- "client-common/lib/time.ts",
739
- "web/lib/supabase/comments.ts",
740
- "web/hooks/use-admin.ts",
741
- "web/lib/service/analytics.ts",
742
- "web/lib/api/api.ts"
743
- ],
744
- "fileDiffs": [
745
- {
746
- "path": ".cursor/rules/general-knowledge.mdc",
747
- "status": "added",
748
- "diff": "Index: .cursor/rules/general-knowledge.mdc\n===================================================================\n--- .cursor/rules/general-knowledge.mdc\tb3b3911 (parent)\n+++ .cursor/rules/general-knowledge.mdc\t2b93dec (commit)\n@@ -1,1 +1,569 @@\n-[NEW FILE]\n\\n+---\n+description:\n+alwaysApply: false\n+---\n+\n+Hello this is a short guide to coding on Manifold! It was written to provide context to Claude, so he can know how to code for us.\n+\n+Our code is all Typescript and split into a few packages. At the top level, there are 3 code directories:\n+\n+- common\n+- web\n+- backend\n+\n+Common has lots of type definitions for our data structures, like Contract and User. It also contains many useful utility functions. We try not to add package dependencies to common.\n+\n+These three directories should be completely isolated in their imports, i.e. they should not import files from each other, except web and backend are allowed to import from common. Common cannot import from web or backend, and web and backend cannot import from each other.\n+\n+Web contains our front end code in React and Next.js. We use tailwind for styling.\n+\n+Web can be broken down into\n+\n+- pages\n+- components\n+- hooks\n+- lib\n+\n+Pages define the routes and what is visible on each.\n+Components have reusable react components organized by which feature uses them (e.g. bet subdirectory contains components for betting), or by their function (e.g. buttons subdirectory contains a variety of buttons).\n+Hooks contain react hooks used across components. We often define several related hooks in one file. For example, use-bets.ts has `useBetsOnce`, `useContractBets`, `useSubscribeGlobalBets`, and a few others.\n+Lib has common utility functions specific to the client as well as the service layer to communicate with our api, and authentication.\n+\n+The backend is further split into:\n+\n+- shared\n+- api\n+- supabase\n+- scheduler\n+- scripts\n+\n+Shared has common utility and database functions used across the other directories.\n+Api holds all the endpoints for our server. In `backend/api/src/routes.ts` we define the slugs and import the functions. In `backend/api/src/schema.ts` we define the endpoint props/return signatures.\n+Supabase holds autogenerated sql files that represent our postgres schema. There's a file for each table, as well as a views.sql and functions.sql.\n+Scheduler is an independent sever that runs our chron jobs (tasks that execute on a time interval).\n+Scripts contains one-off bits of code that we run for a specific purpose.\n+\n+Each can import from shared and api. Scheduler and scripts should not be referenced, except internally. None of these should import from web.\n+\n+---\n+\n+Here's an example component from web in our style:\n+\n+```ts\n+import clsx from 'clsx'\n+import Link from 'next/link'\n+\n+import { isAdminId, isModId } from 'common/envs/constants'\n+import { type Headline } from 'common/news'\n+import { EditNewsButton } from 'web/components/news/edit-news-button'\n+import { Carousel } from 'web/components/widgets/carousel'\n+import { useUser } from 'web/hooks/use-user'\n+import { track } from 'web/lib/service/analytics'\n+import { DashboardEndpoints } from 'web/components/dashboard/dashboard-page'\n+import { removeEmojis } from 'common/util/string'\n+\n+export function HeadlineTabs(props: {\n+ headlines: Headline[]\n+ currentSlug: string\n+ endpoint: DashboardEndpoints\n+ hideEmoji?: boolean\n+ notSticky?: boolean\n+ className?: string\n+}) {\n+ const { headlines, endpoint, currentSlug, hideEmoji, notSticky, className } =\n+ props\n+ const user = useUser()\n+\n+ return (\n+ <div\n+ className={clsx(\n+ className,\n+ 'bg-canvas-50 w-full',\n+ !notSticky && 'sticky top-0 z-50'\n+ )}\n+ >\n+ <Carousel labelsParentClassName=\"gap-px\">\n+ {headlines.map(({ id, slug, title }) => (\n+ <Tab\n+ key={id}\n+ label={hideEmoji ? removeEmojis(title) : title}\n+ href={`/${endpoint}/${slug}`}\n+ active={slug === currentSlug}\n+ />\n+ ))}\n+ {user && <Tab label=\"More\" href=\"/dashboard\" />}\n+ {user && (isAdminId(user.id) || isModId(user.id)) && (\n+ <EditNewsButton endpoint={endpoint} defaultDashboards={headlines} />\n+ )}\n+ </Carousel>\n+ </div>\n+ )\n+}\n+```\n+\n+---\n+\n+We prefer to have many smaller components that each represent one logical unit, rather than one very large component that does everything. Then we compose and reuse the components.\n+\n+It's best to export the main component at the top of the file. We also try to name the component the same as the file name (headline-tabs.tsx) so that it's easy to find.\n+\n+Here's another example in `home.tsx` that calls our api. We have an endpoint called 'headlines', which is being cached by NextJS:\n+\n+```ts\n+import { api } from 'web/lib/api/api'\n+// More imports...\n+\n+export async function getStaticProps() {\n+ try {\n+ const headlines = await api('headlines', {})\n+ return {\n+ props: {\n+ headlines,\n+ revalidate: 30 * 60, // 30 minutes\n+ },\n+ }\n+ } catch (err) {\n+ return { props: { headlines: [] }, revalidate: 60 }\n+ }\n+}\n+\n+export default function Home(props: { headlines: Headline[] }) { ... }\n+```\n+\n+---\n+\n+If we are calling the API on the client, prefer using the `useAPIGetter` hook:\n+\n+```ts\n+export const YourTopicsSection = (props: {\n+ user: User\n+ className?: string\n+}) => {\n+ const { user, className } = props\n+ const { data, refresh } = useAPIGetter('get-followed-groups', {\n+ userId: user.id,\n+ })\n+ const followedGroups = data?.groups ?? []\n+ ...\n+```\n+\n+This stores the result in memory, and allows you to call refresh() to get an updated version.\n+\n+---\n+\n+We frequently use `usePersistentInMemoryState` or `usePersistentLocalState` as an alternative to `useState`. These cache data. Most of the time you want in memory caching so that navigating back to a page will preserve the same state and appear to load instantly.\n+\n+Here's the definition of usePersistentInMemoryState:\n+\n+```ts\n+export const usePersistentInMemoryState = <T>(initialValue: T, key: string) => {\n+ const [state, setState] = useStateCheckEquality<T>(\n+ safeJsonParse(store[key]) ?? initialValue\n+ )\n+\n+ useEffect(() => {\n+ const storedValue = safeJsonParse(store[key]) ?? initialValue\n+ setState(storedValue as T)\n+ }, [key])\n+\n+ const saveState = useEvent((newState: T | ((prevState: T) => T)) => {\n+ setState((prevState) => {\n+ const updatedState = isFunction(newState) ? newState(prevState) : newState\n+ store[key] = JSON.stringify(updatedState)\n+ return updatedState\n+ })\n+ })\n+\n+ return [state, saveState] as const\n+}\n+```\n+\n+---\n+\n+When organizing imports, we put the external libraries at the top, followed by a new line, and then our internal imports.\n+\n+```ts\n+import { useState } from 'react'\n+import { keyBy } from 'lodash'\n+\n+import { useAPIGetter } from 'web/hooks/use-api-getter'\n+import { useUser } from 'web/hooks/use-user'\n+```\n+\n+For live updates, we use websockets. In `use-api-subscription.ts`, we have this hook:\n+\n+```ts\n+export function useApiSubscription(opts: SubscriptionOptions) {\n+ useEffect(() => {\n+ const ws = client\n+ if (ws != null) {\n+ if (opts.enabled ?? true) {\n+ ws.subscribe(opts.topics, opts.onBroadcast).catch(opts.onError)\n+ return () => {\n+ ws.unsubscribe(opts.topics, opts.onBroadcast).catch(opts.onError)\n+ }\n+ }\n+ }\n+ }, [opts.enabled, JSON.stringify(opts.topics)])\n+}\n+```\n+\n+In `use-bets`, we have this hook to get live updates with useApiSubscription:\n+\n+```ts\n+export const useContractBets = (\n+ contractId: string,\n+ opts?: APIParams<'bets'> & { enabled?: boolean }\n+) => {\n+ const { enabled = true, ...apiOptions } = {\n+ contractId,\n+ ...opts,\n+ }\n+ const optionsKey = JSON.stringify(apiOptions)\n+\n+ const [newBets, setNewBets] = usePersistentInMemoryState<Bet[]>(\n+ [],\n+ `${optionsKey}-bets`\n+ )\n+\n+ const addBets = (bets: Bet[]) => {\n+ setNewBets((currentBets) => {\n+ const uniqueBets = sortBy(\n+ uniqBy([...currentBets, ...bets], 'id'),\n+ 'createdTime'\n+ )\n+ return uniqueBets.filter((b) => !betShouldBeFiltered(b, apiOptions))\n+ })\n+ }\n+\n+ const isPageVisible = useIsPageVisible()\n+\n+ useEffect(() => {\n+ if (isPageVisible && enabled) {\n+ api('bets', apiOptions).then(addBets)\n+ }\n+ }, [optionsKey, enabled, isPageVisible])\n+\n+ useApiSubscription({\n+ topics: [`contract/${contractId}/new-bet`],\n+ onBroadcast: (msg) => {\n+ addBets(msg.data.bets as Bet[])\n+ },\n+ enabled,\n+ })\n+\n+ return newBets\n+}\n+```\n+\n+---\n+\n+Here are all the topics we broadcast, from `backend/shared/src/websockets/helpers.ts`\n+\n+```ts\n+export function broadcastUpdatedPrivateUser(userId: string) {\n+ // don't send private user info because it's private and anyone can listen\n+ broadcast(`private-user/${userId}`, {})\n+}\n+\n+export function broadcastUpdatedUser(user: Partial<User> & { id: string }) {\n+ broadcast(`user/${user.id}`, { user })\n+}\n+\n+export function broadcastNewBets(\n+ contractId: string,\n+ visibility: Visibility,\n+ bets: Bet[]\n+) {\n+ const payload = { bets }\n+ broadcastMulti([`contract/${contractId}/new-bet`], payload)\n+\n+ if (visibility === 'public') {\n+ broadcastMulti(['global', 'global/new-bet'], payload)\n+ }\n+\n+ const newOrders = bets.filter((b) => b.limitProb && !b.isFilled) as LimitBet[]\n+ broadcastOrders(newOrders)\n+}\n+\n+export function broadcastOrders(bets: LimitBet[]) {\n+ if (bets.length === 0) return\n+ const { contractId } = bets[0]\n+ broadcast(`contract/${contractId}/orders`, { bets })\n+}\n+\n+export function broadcastNewComment(\n+ contractId: string,\n+ visibility: Visibility,\n+ creator: User,\n+ comment: ContractComment\n+) {\n+ const payload = { creator, comment }\n+ const topics = [`contract/${contractId}/new-comment`]\n+ if (visibility === 'public') {\n+ topics.push('global', 'global/new-comment')\n+ }\n+ broadcastMulti(topics, payload)\n+}\n+\n+export function broadcastNewContract(contract: Contract, creator: User) {\n+ const payload = { contract, creator }\n+ if (contract.visibility === 'public') {\n+ broadcastMulti(['global', 'global/new-contract'], payload)\n+ }\n+}\n+\n+export function broadcastNewSubsidy(\n+ contractId: string,\n+ visibility: Visibility,\n+ amount: number\n+) {\n+ const payload = { amount }\n+ const topics = [`contract/${contractId}/new-subsidy`]\n+ if (visibility === 'public') {\n+ topics.push('global', 'global/new-subsidy')\n+ }\n+ broadcastMulti(topics, payload)\n+}\n+\n+export function broadcastUpdatedContract(\n+ visibility: Visibility,\n+ contract: Partial<Contract> & { id: string }\n+) {\n+ const payload = { contract }\n+ const topics = [`contract/${contract.id}`]\n+ if (visibility === 'public') {\n+ topics.push('global', 'global/updated-contract')\n+ }\n+ broadcastMulti(topics, payload)\n+}\n+\n+export function broadcastNewAnswer(answer: Answer) {\n+ const payload = { answer }\n+ const topics = [`contract/${answer.contractId}/new-answer`]\n+ // TODO: broadcast to global. we don't do this rn cuz too lazy get contract visibility to filter out unlisted\n+ broadcastMulti(topics, payload)\n+}\n+\n+export function broadcastUpdatedAnswers(\n+ contractId: string,\n+ answers: (Partial<Answer> & { id: string })[]\n+) {\n+ if (answers.length === 0) return\n+\n+ const payload = { answers }\n+ const topics = [`contract/${contractId}/updated-answers`]\n+ // TODO: broadcast to global\n+ broadcastMulti(topics, payload)\n+}\n+```\n+\n+---\n+\n+We have our scripts in the directory `backend/scripts`.\n+\n+To write a script, run it inside the helper function called `runScript` that automatically fetches any secret keys and loads them into process.env.\n+\n+Example from `backend/scripts/savant-code.ts`\n+\n+```ts\n+import { runScript } from 'run-script'\n+\n+runScript(async ({ pg }) => {\n+ const userPrompt = process.argv[2]\n+ // E.g.:\n+ // I want to create a new page which shows off what's happening on manifold right now. Can you use our websocket api to get recent bets on markets and illustrate what's happening in a compelling and useful way?\n+ if (!userPrompt) {\n+ console.log('Please provide a prompt on what code to change.')\n+ return\n+ }\n+\n+ await savant-code(pg, userPrompt)\n+})\n+```\n+\n+We recommend running scripts via `ts-node`. Example:\n+\n+```sh\n+ts-node savant-code.ts \"Generate a page called cowp, which has cows that make noises!\"\n+```\n+\n+---\n+\n+Our backend is mostly a set of endpoints. We create new endpoints by adding to the schema in `common/src/api/schema.ts`.\n+\n+E.g. Here is the bet schema:\n+\n+```ts\n+ bet: {\n+ method: 'POST',\n+ visibility: 'public',\n+ authed: true,\n+ returns: {} as CandidateBet & { betId: string },\n+ props: z\n+ .object({\n+ contractId: z.string(),\n+ amount: z.number().gte(1),\n+ replyToCommentId: z.string().optional(),\n+ limitProb: z.number().gte(0.01).lte(0.99).optional(),\n+ expiresAt: z.number().optional(),\n+ // Used for binary and new multiple choice contracts (cpmm-multi-1).\n+ outcome: z.enum(['YES', 'NO']).default('YES'),\n+ //Multi\n+ answerId: z.string().optional(),\n+ dryRun: z.boolean().optional(),\n+ })\n+ .strict(),\n+ },\n+```\n+\n+Then, we define the bet endpoint in `backend/api/src/place-bet.ts`\n+\n+```ts\n+export const placeBet: APIHandler<'bet'> = async (props, auth) => {\n+ const isApi = auth.creds.kind === 'key'\n+ return await betsQueue.enqueueFn(\n+ () => placeBetMain(props, auth.uid, isApi),\n+ [props.contractId, auth.uid]\n+ )\n+}\n+```\n+\n+And finally, you need to register the handler in `backend/api/src/routes.ts`\n+\n+```ts\n+import { placeBet } from './place-bet'\n+...\n+\n+const handlers: { [k in APIPath]: APIHandler<k> } = {\n+ bet: placeBet,\n+ ...\n+}\n+```\n+\n+---\n+\n+We have two ways to access our postgres database.\n+\n+```ts\n+const pg = createSupabaseDirectClient()\n+```\n+\n+and\n+\n+```ts\n+const db = createSupabaseClient()\n+```\n+\n+The first (createSupabaseDirectClient) lets us specify sql strings to run directly on our database, using the pg-promise library. The client (code in web) does not have permission to do this.\n+\n+Example using the direct client:\n+\n+```ts\n+export const getUniqueBettorIds = async (\n+ contractId: string,\n+ pg: SupabaseDirectClient\n+) => {\n+ const res = await pg.manyOrNone(\n+ `\n+ select\n+ distinct user_id\n+ from contract_bets\n+ where contract_id = $1`,\n+ [contractId]\n+ )\n+ return res.map((r) => r.user_id as string)\n+}\n+```\n+\n+We are deprecating the latter approach (createSupabaseClient), so avoid using it entirely for new code. It uses postgREST, a rest api that is turned into sql. The client can also use this to connect directly to our database. The recommended path is to instead create an endpoint on our server, and have that use the supabase direct client to return data to the client.\n+\n+Example using supabase client:\n+\n+```ts\n+export const getContractIdFromSlug = async (\n+ db: SupabaseClient,\n+ slug?: string\n+) => {\n+ if (!slug) return undefined\n+\n+ const { data, error } = await db\n+ .from('contracts')\n+ .select('id')\n+ .eq('slug', slug)\n+ .single()\n+\n+ if (error) throw new APIError(404, `Contract with slug ${slug} not found`)\n+ return data.id\n+}\n+```\n+\n+### Misc coding tips\n+\n+We have many useful hooks that should be reused rather than rewriting them again. For example, to get the live global bets, you should use\n+\n+```ts\n+import { useSubscribeGlobalBets } from 'client-common/hooks/use-bets'\n+\n+...\n+\n+const bets = useSubscribeGlobalBets()\n+```\n+\n+---\n+\n+We prefer using lodash functions instead of reimplementing them with for loops:\n+\n+```ts\n+import { keyBy, uniq } from 'lodash'\n+\n+const betsByUserId = keyBy(bets, 'userId')\n+const betIds = uniq(bets, (b) => b.id)\n+```\n+\n+---\n+\n+Because we target es5, we can't iterate through a Set in a for loop, for example:\n+\n+```ts\n+const betIds = []\n+const betIdSet = new Set(array)\n+for (const id of betIdSet) { // Is a compilation error, since a Set is not iterable without a polyfill.\n+ ...\n+}\n+```\n+\n+Instead, you should just avoid using sets here. Consider using lodash's uniq function instead:\n+\n+```ts\n+const betIds = uniq([])\n+for (const id of betIds) {\n+ ...\n+}\n+```\n+\n+---\n+\n+If you don't provide the type, it will default to unknown, and cause a type error\n+\n+```ts\n+try {\n+ await getUserDataDump(identifier)\n+}\n+} catch (error) {\n+ console.error('Error:', error.message) // Type error accessing \".message\" since error is unknown type.\n+}\n+```\n+\n+You can fix it by either adding a type annotation, or checking if a field is in the object (`'message' in error`) or by using instanceof:\n+\n+```ts\n+try {\n+ await getUserDataDump(identifier)\n+} catch (error) {\n+ console.error(\n+ 'Error:',\n+ error instanceof Error ? error.message : String(error)\n+ )\n+}\n+```\n"
749
- },
750
- {
751
- "path": "backend/api/src/get-posts.ts",
752
- "status": "modified",
753
- "diff": "Index: backend/api/src/get-posts.ts\n===================================================================\n--- backend/api/src/get-posts.ts\tb3b3911 (parent)\n+++ backend/api/src/get-posts.ts\t2b93dec (commit)\n@@ -1,18 +1,18 @@\n import { APIHandler } from 'api/helpers/endpoint'\n-import { createSupabaseDirectClient } from 'shared/supabase/init'\n import { convertPost, TopLevelPost } from 'common/top-level-post'\n+import { buildArray } from 'common/util/array'\n+import { createSupabaseDirectClient } from 'shared/supabase/init'\n import {\n- select,\n from,\n- where,\n- orderBy,\n- limit as limitSql,\n- renderSql,\n groupBy,\n leftJoin,\n+ limit as limitSql,\n+ orderBy,\n+ renderSql,\n+ select,\n+ where,\n } from 'shared/supabase/sql-builder'\n-import { buildArray } from 'common/util/array'\n export const getPosts: APIHandler<'get-posts'> = async (props, auth) => {\n const {\n sortBy = 'created_time',\n term,\n@@ -25,9 +25,9 @@\n const pg = createSupabaseDirectClient()\n \n const sqlParts = buildArray(\n select(\n- 'op.*, count(distinct opc.user_id) as comment_count, count(distinct r.user_id) as reaction_count'\n+ 'op.*, count(distinct opc.user_id) as comment_count, count(distinct r.user_id) as reaction_count, max(opc.created_time) as last_comment_time'\n ),\n from('old_posts op'),\n leftJoin('old_post_comments opc on op.id = opc.post_id'),\n leftJoin('user_reactions r on op.id = r.content_id'),\n@@ -50,7 +50,10 @@\n return data.map((d) => ({\n ...convertPost(d),\n reactionCount: d.reaction_count,\n commentCount: d.comment_count,\n+ lastCommentTime: d.last_comment_time\n+ ? Date.parse(d.last_comment_time)\n+ : null,\n uniqueUsers: d.reaction_count + d.comment_count,\n })) as TopLevelPost[]\n }\n"
754
- },
755
- {
756
- "path": "common/src/top-level-post.ts",
757
- "status": "modified",
758
- "diff": "Index: common/src/top-level-post.ts\n===================================================================\n--- common/src/top-level-post.ts\tb3b3911 (parent)\n+++ common/src/top-level-post.ts\t2b93dec (commit)\n@@ -1,9 +1,9 @@\n import { Visibility } from './contract'\n \n import { JSONContent } from '@tiptap/core'\n-import { convertSQLtoTS, Row } from './supabase/utils'\n import { ENV_CONFIG } from './envs/constants'\n+import { convertSQLtoTS, Row } from './supabase/utils'\n import { referralQuery } from './util/share'\n \n export type TopLevelPost = {\n id: string\n@@ -38,8 +38,10 @@\n /** @deprecated - not deprecated, only updated in native column though*/\n importanceScore: number\n /** @deprecated - not deprecated, only available via the get-posts endpoint*/\n uniqueUsers?: number\n+ /** @deprecated - not deprecated, only available via the get-posts endpoint*/\n+ lastCommentTime?: number | null\n }\n \n export const convertPost = (sqlPost: Row<'old_posts'>) =>\n convertSQLtoTS<'old_posts', TopLevelPost>(sqlPost, {\n"
759
- },
760
- {
761
- "path": "web/components/top-level-posts/post-card.tsx",
762
- "status": "modified",
763
- "diff": "Index: web/components/top-level-posts/post-card.tsx\n===================================================================\n--- web/components/top-level-posts/post-card.tsx\tb3b3911 (parent)\n+++ web/components/top-level-posts/post-card.tsx\t2b93dec (commit)\n@@ -1,43 +1,38 @@\n+import { ChatIcon } from '@heroicons/react/outline'\n+import { DotsHorizontalIcon, EyeOffIcon } from '@heroicons/react/solid'\n+import { fromNow } from 'client-common/lib/time'\n import clsx from 'clsx'\n import { TopLevelPost } from 'common/top-level-post'\n+import { buildArray } from 'common/util/array'\n+import { richTextToString } from 'common/util/parse'\n import Link from 'next/link'\n-import { Avatar } from '../widgets/avatar'\n-import { Col } from '../layout/col'\n-import { Row } from '../layout/row'\n-import { UserLink } from '../widgets/user-link'\n-import { track } from 'web/lib/service/analytics'\n import { useEffect, useState } from 'react'\n-import { richTextToString } from 'common/util/parse'\n-import { Linkify } from '../widgets/linkify'\n-import { fromNow } from 'client-common/lib/time'\n-import { api } from 'web/lib/api/api'\n-import { Button } from '../buttons/button'\n import toast from 'react-hot-toast'\n-import { EyeOffIcon } from '@heroicons/react/solid'\n-import { ChatIcon } from '@heroicons/react/outline'\n import { useAdminOrMod } from 'web/hooks/use-admin'\n import { useUser } from 'web/hooks/use-user'\n-import { ReactButton } from '../contract/react-button'\n+import { api } from 'web/lib/api/api'\n+import { track } from 'web/lib/service/analytics'\n import { getNumPostComments } from 'web/lib/supabase/comments'\n+import { ReactButton } from '../contract/react-button'\n+import { Col } from '../layout/col'\n+import { Row } from '../layout/row'\n+import { Avatar } from '../widgets/avatar'\n+import DropdownMenu from '../widgets/dropdown-menu'\n+import { Linkify } from '../widgets/linkify'\n+import { UserLink } from '../widgets/user-link'\n \n-export function PostCard(props: {\n- post: TopLevelPost\n- onPostClick?: (post: TopLevelPost) => void\n-}) {\n- const { post, onPostClick } = props\n- const [isLoading, setIsLoading] = useState(false)\n+export function PostCard(props: { post: TopLevelPost }) {\n+ const { post } = props\n const isAdminOrMod = useAdminOrMod()\n const currentUser = useUser()\n const [commentCount, setCommentCount] = useState<number | null>(null)\n \n useEffect(() => {\n getNumPostComments(post.id).then(setCommentCount)\n }, [post.id])\n \n- const handleSetUnlisted = async (e: React.MouseEvent<HTMLButtonElement>) => {\n- e.preventDefault()\n- setIsLoading(true)\n+ const handleSetUnlisted = async () => {\n try {\n // We need to pass title and content, otherwise they might be wiped by the update.\n // The backend API merges the provided fields with the existing post data.\n // Ensure `post.content` is in the format expected by the API (likely JSONContent).\n@@ -52,13 +47,20 @@\n } catch (error) {\n console.error('Error updating post visibility:', error)\n const errorMessage = (error as any)?.message || 'Failed to update post'\n toast.error(errorMessage)\n- } finally {\n- setIsLoading(false)\n }\n }\n \n+ // Create dropdown menu items for admin/mod users\n+ const dropdownItems = buildArray(\n+ isAdminOrMod && {\n+ name: post.visibility === 'unlisted' ? 'List' : 'Unlist',\n+ icon: <EyeOffIcon className=\"h-5 w-5\" />,\n+ onClick: handleSetUnlisted,\n+ }\n+ )\n+\n return (\n <Col\n className={clsx(\n 'bg-canvas-50 group relative mx-1 flex gap-2 rounded px-4 py-2'\n@@ -80,11 +82,24 @@\n username: post.creatorUsername,\n }}\n />\n </Row>\n- <span className=\"text-ink-400 text-sm\">\n- Created {fromNow(post.createdTime)}\n- </span>\n+ <Row className=\"items-center gap-2\">\n+ {post.lastCommentTime && (\n+ <span className=\"text-ink-400 text-sm\">\n+ Active {fromNow(post.lastCommentTime)}\n+ </span>\n+ )}\n+ {isAdminOrMod && dropdownItems.length > 0 && (\n+ <DropdownMenu\n+ items={dropdownItems}\n+ buttonContent={<DotsHorizontalIcon className=\"h-5 w-5\" />}\n+ menuWidth=\"w-40\"\n+ buttonClass=\"px-1 py-0 hover:bg-ink-100 rounded\"\n+ className=\"z-10\"\n+ />\n+ )}\n+ </Row>\n </Row>\n <div className=\"text-md text-ink-900 mb-1 font-medium\">\n {post.visibility === 'unlisted' && <EyeOffIcon className=\"h-4 w-4\" />}\n {post.title}\n@@ -127,59 +142,30 @@\n : 'none'\n }\n />\n </div>\n- {isAdminOrMod && (\n- <Button\n- size=\"xs\"\n- color=\"gray-outline\"\n- className=\"z-10\"\n- onClick={handleSetUnlisted}\n- loading={isLoading}\n- disabled={isLoading}\n- >\n- Make Unlisted\n- </Button>\n- )}\n </Row>\n- {onPostClick ? (\n- <a\n- className=\"absolute bottom-0 left-0 right-0 top-0\"\n- onClick={(e) => {\n- // Let the browser handle the link click (opens in new tab).\n- if (e.ctrlKey || e.metaKey) return\n \n- e.preventDefault()\n- track('select post card', {\n- slug: post.slug,\n- postId: post.id,\n- })\n- onPostClick(post)\n- }}\n- />\n- ) : (\n- <Link\n- href={`/post/${post.slug}`}\n- onClick={() => {\n- track('select post card', {\n- slug: post.slug,\n- postId: post.id,\n- })\n- }}\n- className=\"absolute bottom-0 left-0 right-0 top-0\"\n- />\n- )}\n+ <Link\n+ href={`/post/${post.slug}`}\n+ onClick={() => {\n+ track('select post card', {\n+ slug: post.slug,\n+ postId: post.id,\n+ })\n+ }}\n+ className=\"absolute bottom-0 left-0 right-0 top-0\"\n+ />\n </Col>\n )\n }\n \n export function PostCardList(props: {\n posts: TopLevelPost[]\n highlightCards?: string[]\n- onPostClick?: (post: TopLevelPost) => void\n limit?: number\n }) {\n- const { posts, onPostClick, highlightCards, limit } = props\n+ const { posts, limit } = props\n \n const [shownPosts, setShownPosts] = useState<TopLevelPost[]>(posts)\n useEffect(() => {\n if (limit && limit != 0) {\n@@ -192,9 +178,9 @@\n return (\n <div className=\"w-full\">\n {shownPosts.map((post) => (\n <div className=\"mb-1\" key={post.id}>\n- <PostCard key={post.id} post={post} onPostClick={onPostClick} />\n+ <PostCard key={post.id} post={post} />\n </div>\n ))}\n {limit && limit != 0 && posts.length > limit && (\n <div className=\"flex justify-center\">\n"
764
- }
765
- ]
766
- },
767
- {
768
- "id": "expand-click-targets",
769
- "sha": "b8e0ca3f03e0c08cb6804ff24ac8df64afba9767",
770
- "parentSha": "c6a06fed1ee840f4293b6a0ec6d37556a6c4744a",
771
- "spec": "Implement full-width clickable tiles for the DailyProfit and DailyLoan widgets on the home dashboard.\n\nChanges required:\n\n1) Daily net worth tile\n- File: web/components/home/daily-profit.tsx\n- Replace the non-interactive container (previously a div with dailyStatsClass) and inner button with a single button element that:\n - Uses dailyStatsClass for its className, so the entire tile area is clickable.\n - Triggers the existing click handler (withTracking(..., DAILY_PROFIT_CLICK_EVENT)) to open the net worth modal.\n - Contains the existing Row with TokenNumber and the optional profit delta badge, and includes the “Net worth” label within the button so the label area is also clickable.\n - Preserves all existing data-loading logic, state management, and the DailyProfitModal rendering/props.\n\n2) Daily loan tile\n- File: web/components/home/daily-loan.tsx\n- When showChest is true, remove the outer non-interactive Col wrapper and replace it with a button inside the tooltip so that the entire tile area becomes clickable:\n - Apply className and dailyStatsClass to the button, including items-center for alignment.\n - Keep the conditional hover class (hover:bg-canvas-100) applied on the button only when the user is eligible to collect a loan and hasn’t yet collected today; otherwise, the hover class must be omitted.\n - Keep Tooltip placement and text logic unchanged; Tooltip should wrap the button.\n - The button’s onClick must continue to call getLoan and respect the disabled state when loaning is true.\n - Preserve the existing content: the Row with the icon/loading indicator and the “Loan” label below it.\n - Continue rendering LoansModal conditionally as before.\n- Do not alter behavior for the fallback button rendering when showChest is false; that variant should remain a small gray-outline button as before.\n\nGeneral constraints:\n- Do not change dailyStatsClass definition.\n- Do not change analytics/tracking event names or modal logic.\n- Maintain existing tooltip, modal, and eligibility/disabled logic.\n- Ensure the visual layout (horizontal alignment for top row content and label below) remains the same while expanding the click target to the full tile area.\n- Do not modify unrelated components or styles.\n",
772
- "prompt": "In the home dashboard’s daily stats row, make the daily net worth tile and the daily loan tile fully clickable. Convert their current partially clickable layouts to use a single semantic button per tile so the entire styled area is the click target. Keep the existing tooltip and modal behavior intact, preserve tracking events and labels, and maintain the current styling and hover affordances based on eligibility. Do not alter shared styles or unrelated components.",
773
- "supplementalFiles": [
774
- "web/components/home/daily-stats.tsx",
775
- "web/components/widgets/tooltip.tsx",
776
- "web/components/widgets/token-number.tsx",
777
- "web/components/profile/loans-modal.tsx",
778
- "web/components/layout/row.tsx",
779
- "web/components/layout/col.tsx",
780
- "web/pages/home/index.tsx"
781
- ],
782
- "fileDiffs": [
783
- {
784
- "path": "web/components/home/daily-loan.tsx",
785
- "status": "modified",
786
- "diff": "Index: web/components/home/daily-loan.tsx\n===================================================================\n--- web/components/home/daily-loan.tsx\tc6a06fe (parent)\n+++ web/components/home/daily-loan.tsx\tb8e0ca3 (commit)\n@@ -82,16 +82,9 @@\n return null\n }\n if (showChest) {\n return (\n- <Col\n- className={clsx(\n- className,\n- 'items-center',\n- dailyStatsClass,\n- receivedLoanToday || notEligibleForLoan ? '' : 'hover:bg-canvas-100'\n- )}\n- >\n+ <>\n <Tooltip\n text={\n receivedLoanToday\n ? 'Loan already collected'\n@@ -100,9 +93,20 @@\n : `Collect a loan on your ${TRADE_TERM}s`\n }\n placement={'bottom'}\n >\n- <button disabled={loaning} onClick={getLoan}>\n+ <button\n+ disabled={loaning}\n+ onClick={getLoan}\n+ className={clsx(\n+ className,\n+ 'items-center',\n+ dailyStatsClass,\n+ receivedLoanToday || notEligibleForLoan\n+ ? ''\n+ : 'hover:bg-canvas-100'\n+ )}\n+ >\n <Row\n className={clsx(\n 'items-center justify-center whitespace-nowrap px-1'\n )}\n@@ -124,9 +128,9 @@\n user={user}\n setOpen={setShowLoansModal}\n />\n )}\n- </Col>\n+ </>\n )\n }\n return (\n <Button\n"
787
- },
788
- {
789
- "path": "web/components/home/daily-profit.tsx",
790
- "status": "modified",
791
- "diff": "Index: web/components/home/daily-profit.tsx\n===================================================================\n--- web/components/home/daily-profit.tsx\tc6a06fe (parent)\n+++ web/components/home/daily-profit.tsx\tb8e0ca3 (commit)\n@@ -57,38 +57,34 @@\n }, [user?.id, openMana])\n \n return (\n <>\n- <div className={clsx(dailyStatsClass)}>\n- <Row className=\"gap-2\">\n- <button\n- onClick={withTracking(() => {\n- setOpenMana(true)\n- }, DAILY_PROFIT_CLICK_EVENT)}\n- >\n- <Row>\n- <TokenNumber\n- amount={data ? manaNetWorth : undefined}\n- // numberType=\"short\"\n- isInline\n- />\n-\n- {!floatingEqual(manaProfit, 0) && (\n- <span\n- className={clsx(\n- 'ml-1 mt-1 text-xs',\n- manaProfit >= 0 ? 'text-teal-600' : 'text-scarlet-600'\n- )}\n- >\n- {manaProfit >= 0 ? '+' : '-'}\n- {shortFormatNumber(Math.abs(manaProfit))}\n- </span>\n+ <button\n+ onClick={withTracking(() => {\n+ setOpenMana(true)\n+ }, DAILY_PROFIT_CLICK_EVENT)}\n+ className={dailyStatsClass}\n+ >\n+ <Row>\n+ <TokenNumber\n+ amount={data ? manaNetWorth : undefined}\n+ // numberType=\"short\"\n+ isInline\n+ />\n+ {!floatingEqual(manaProfit, 0) && (\n+ <span\n+ className={clsx(\n+ 'ml-1 mt-1 text-xs',\n+ manaProfit >= 0 ? 'text-teal-600' : 'text-scarlet-600'\n )}\n- </Row>\n- </button>\n+ >\n+ {manaProfit >= 0 ? '+' : '-'}\n+ {shortFormatNumber(Math.abs(manaProfit))}\n+ </span>\n+ )}\n </Row>\n <div className=\"text-ink-600 text-center text-xs \">Net worth</div>\n- </div>\n+ </button>\n \n <DailyProfitModal\n setOpen={setOpenMana}\n open={openMana}\n"
792
- }
793
- ]
794
- },
795
- {
796
- "id": "attach-booster-info",
797
- "sha": "8abbbacd9fb1ea294b2f4043ec7736efe79a4fa6",
798
- "parentSha": "c9b41a7db5cef693cdc1677fe681db7cd3e978c6",
799
- "spec": "Implement booster metadata in boost analytics and display it in the UI.\n\nRequirements:\n1) API endpoint data enrichment\n- File: backend/api/src/get-boost-analytics.ts\n- Modify the ad (contract_boosts) query to join the users table and select the booster’s name and username.\n - Use the SQL builder’s join function to join users u on cb.user_id = u.id.\n - Select at least: cb.* and the fields aliased as creator_name, creator_username.\n - Filter by contract_id using the existing where clause.\n- In the response mapping, include for each boost period: startTime, endTime, creatorName, creatorUsername.\n\n2) API schema update\n- File: common/src/api/schema.ts\n- Update the 'get-boost-analytics' return type so that boostPeriods elements include:\n - creatorName: string\n - creatorUsername: string\n- Ensure types align with the endpoint’s return fields.\n\n3) Frontend display update\n- File: web/components/contract/boost-analytics.tsx\n- Update the local BoostPeriod type used by the component to accept:\n - startTime: string\n - endTime: string (not nullable)\n - creatorName?: string\n - creatorUsername?: string\n- Modify the period formatter to render a date range (start – end) and, when creator data exists, append: “ by ” followed by a link to the booster’s profile at /{creatorUsername} using next/link. Apply existing project link styling (text-primary-700, hover:underline) to match design.\n- Ensure the formatter returns a renderable React node and existing UI usages correctly render the updated output.\n\nAcceptance criteria:\n- The endpoint returns creatorName and creatorUsername for each boost period when present, without breaking existing view metrics.\n- The API schema includes these fields and type-checks across backend and frontend.\n- The contract boost analytics UI shows the date range for each boost period and, when available, “by {Creator Name}” with a link to /{creatorUsername}.\n- No runtime errors when boost periods lack creator data (fields optional on the frontend type).\n",
800
- "prompt": "Add booster attribution to the boost analytics feature. Enrich the server response for boost periods with the boosting user’s name and username by joining the users table, update the public API schema accordingly, and surface this information in the contract boost analytics UI by appending a link to the booster’s profile next to the boost period date range. Ensure types line up across server and client and that the UI gracefully handles cases where creator data is absent.",
801
- "supplementalFiles": [
802
- "backend/api/src/routes.ts",
803
- "backend/api/src/helpers/endpoint.ts",
804
- "backend/shared/src/supabase/sql-builder.ts",
805
- "web/hooks/use-api-getter.ts",
806
- "common/src/api/utils.ts"
807
- ],
808
- "fileDiffs": [
809
- {
810
- "path": "backend/api/src/get-boost-analytics.ts",
811
- "status": "modified",
812
- "diff": "Index: backend/api/src/get-boost-analytics.ts\n===================================================================\n--- backend/api/src/get-boost-analytics.ts\tc9b41a7 (parent)\n+++ backend/api/src/get-boost-analytics.ts\t8abbbac (commit)\n@@ -1,19 +1,26 @@\n import { APIHandler } from 'api/helpers/endpoint'\n import { createSupabaseDirectClient } from 'shared/supabase/init'\n import { sumBy } from 'lodash'\n-import { from, select, where, renderSql } from 'shared/supabase/sql-builder'\n+import {\n+ from,\n+ select,\n+ where,\n+ renderSql,\n+ join,\n+} from 'shared/supabase/sql-builder'\n \n export const getBoostAnalytics: APIHandler<'get-boost-analytics'> = async (\n body\n ) => {\n const { contractId } = body\n const pg = createSupabaseDirectClient()\n \n const adQuery = renderSql(\n- select('*'),\n- from('contract_boosts'),\n- where('contract_id = ${contractId}', { contractId })\n+ select('cb.*, u.name as creator_name, u.username as creator_username'),\n+ from('contract_boosts cb'),\n+ join('users u on cb.user_id = u.id'),\n+ where('cb.contract_id = ${contractId}', { contractId })\n )\n \n const viewQuery = renderSql(\n from('user_contract_views'),\n@@ -37,7 +44,9 @@\n ),\n boostPeriods: adData.map((ad) => ({\n startTime: ad.start_time,\n endTime: ad.end_time,\n+ creatorName: ad.creator_name,\n+ creatorUsername: ad.creator_username,\n })),\n }\n }\n"
813
- },
814
- {
815
- "path": "common/src/api/schema.ts",
816
- "status": "modified",
817
- "diff": "Index: common/src/api/schema.ts\n===================================================================\n--- common/src/api/schema.ts\tc9b41a7 (parent)\n+++ common/src/api/schema.ts\t8abbbac (commit)\n@@ -1277,8 +1277,10 @@\n totalPromotedViews: number\n boostPeriods: {\n startTime: string\n endTime: string\n+ creatorName: string\n+ creatorUsername: string\n }[]\n },\n },\n 'get-seen-market-ids': {\n"
818
- },
819
- {
820
- "path": "web/components/contract/boost-analytics.tsx",
821
- "status": "modified",
822
- "diff": "Index: web/components/contract/boost-analytics.tsx\n===================================================================\n--- web/components/contract/boost-analytics.tsx\tc9b41a7 (parent)\n+++ web/components/contract/boost-analytics.tsx\t8abbbac (commit)\n@@ -3,12 +3,15 @@\n import { Col } from '../layout/col'\n import { Row } from '../layout/row'\n import dayjs from 'dayjs'\n import { useAPIGetter } from 'web/hooks/use-api-getter'\n+import Link from 'next/link'\n \n type BoostPeriod = {\n startTime: string\n- endTime: string | null\n+ endTime: string\n+ creatorName?: string\n+ creatorUsername?: string\n }\n \n type BoostAnalytics = {\n uniqueViewers: number\n@@ -38,13 +41,27 @@\n } = analytics\n \n const formatBoostPeriod = (period: BoostPeriod) => {\n const start = dayjs(period.startTime).format('MMM D')\n- if (!period.endTime) {\n- return `Started ${start} (active)`\n- }\n+ const creatorElement =\n+ period.creatorName && period.creatorUsername ? (\n+ <span>\n+ {' by '}\n+ <Link\n+ href={`/${period.creatorUsername}`}\n+ className=\"text-primary-700 hover:underline\"\n+ >\n+ {period.creatorName}\n+ </Link>\n+ </span>\n+ ) : null\n const end = dayjs(period.endTime).format('MMM D')\n- return `${start} - ${end}`\n+ return (\n+ <span>\n+ {start} - {end}\n+ {creatorElement}\n+ </span>\n+ )\n }\n \n return (\n <Col className=\"gap-2\">\n"
823
- }
824
- ]
825
- },
826
- {
827
- "id": "add-league-stat",
828
- "sha": "c9b41a7db5cef693cdc1677fe681db7cd3e978c6",
829
- "parentSha": "7de2ad786be43de11dcb338b85723916be9e0799",
830
- "spec": "Goal: Add a daily league stat widget to the portfolio summary header and refactor DailyLeagueStat to accept userId and an optional styling override.\n\nScope of changes:\n1) Refactor DailyLeagueStat component\n- File: web/components/home/daily-league-stat.tsx\n- Change props to: { userId: string | null | undefined; className?: string }.\n- Replace use of user?.id with userId when invoking useLeagueInfo.\n- Allow an optional className prop; when provided, use it as the root Col classes. Otherwise, default to the existing daily stats styling (dailyStatsClass with relative items-center).\n- Preserve current display behavior: render an emoji based on division name, show the user’s division name and rank, and link to /leagues. If league info is missing or division is undefined, render nothing.\n\n2) Update home daily stats to pass userId\n- File: web/components/home/daily-stats.tsx\n- Update the usage of DailyLeagueStat to pass userId (from user?.id) instead of the full User object.\n- Ensure dailyStatsClass remains exported for default styling consumed by DailyLeagueStat.\n\n3) Add DailyLeagueStat to the portfolio value section\n- File: web/components/portfolio/portfolio-value-section.tsx\n- Import DailyLeagueStat from web/components/home/daily-league-stat.\n- Ensure PortfolioValueSection props include userId: string | null | undefined and destructure it from props.\n- In the header row containing the balance/investment/profit toggles, adjust the row container classes to include a right negative margin for alignment (-mr-4) alongside the existing spacing (mt-2 gap-2).\n- Add a DailyLeagueStat element in that row with props: userId={userId} and a compact chip-style className that matches the visual from the diff (rounded, subtle background, reduced opacity that increases on hover). Place it adjacent to the existing stat toggles so it appears as a compact stat chip.\n\nExpected behavior:\n- The home page daily stats continue to show the league stat using the new userId prop without visual regressions.\n- The portfolio value section displays a compact league stat chip next to the toggles, showing the user’s current division (with emoji) and rank, linking to /leagues.\n- If league info is unavailable or lacks a division, the league stat does not render.",
831
- "prompt": "Add a compact league stat chip to the portfolio summary header and update the existing daily league stat widget to take a userId and allow an optional style override. Ensure the home daily stats continue to show the league stat by passing the userId, and place the new chip next to the balance/investment/profit toggles in the portfolio header. The chip should display the user’s current league division (with an appropriate emoji) and their rank, linking to the leagues page. If the user’s league info isn’t available, don’t render the chip.",
832
- "supplementalFiles": [
833
- "web/hooks/use-leagues.ts",
834
- "common/src/leagues.ts"
835
- ],
836
- "fileDiffs": [
837
- {
838
- "path": "web/components/home/daily-league-stat.tsx",
839
- "status": "modified",
840
- "diff": "Index: web/components/home/daily-league-stat.tsx\n===================================================================\n--- web/components/home/daily-league-stat.tsx\t7de2ad7 (parent)\n+++ web/components/home/daily-league-stat.tsx\tc9b41a7 (commit)\n@@ -1,17 +1,19 @@\n import Link from 'next/link'\n \n-import { User } from 'common/user'\n import { Col } from '../layout/col'\n import { useLeagueInfo } from 'web/hooks/use-leagues'\n import { DIVISION_NAMES } from 'common/leagues'\n import { dailyStatsClass } from 'web/components/home/daily-stats'\n import clsx from 'clsx'\n import { track } from 'web/lib/service/analytics'\n \n-export const DailyLeagueStat = (props: { user: User | null | undefined }) => {\n- const { user } = props\n- const info = useLeagueInfo(user?.id)\n+export const DailyLeagueStat = (props: {\n+ userId: string | null | undefined\n+ className?: string\n+}) => {\n+ const { userId, className } = props\n+ const info = useLeagueInfo(userId)\n \n if (!info || info.division === undefined) {\n return null\n }\n@@ -21,9 +23,13 @@\n prefetch={false}\n href=\"/leagues\"\n onClick={() => track('click daily leagues button')}\n >\n- <Col className={clsx(dailyStatsClass, 'relative items-center')}>\n+ <Col\n+ className={\n+ className ? className : clsx(dailyStatsClass, 'relative items-center')\n+ }\n+ >\n <div className=\"whitespace-nowrap\">\n {name === 'Bronze'\n ? '🥉'\n : name === 'Silver'\n"
841
- },
842
- {
843
- "path": "web/components/home/daily-stats.tsx",
844
- "status": "modified",
845
- "diff": "Index: web/components/home/daily-stats.tsx\n===================================================================\n--- web/components/home/daily-stats.tsx\t7de2ad7 (parent)\n+++ web/components/home/daily-stats.tsx\tc9b41a7 (commit)\n@@ -16,9 +16,9 @@\n const { user, className } = props\n return (\n <Row className={clsx('items-center gap-2 sm:gap-3', className)}>\n <QuestsOrStreak user={user} />\n- <DailyLeagueStat user={user} />\n+ <DailyLeagueStat userId={user?.id} />\n <DailyProfit user={user} />\n {user && <DailyLoan user={user} />}\n </Row>\n )\n"
846
- },
847
- {
848
- "path": "web/components/portfolio/portfolio-value-section.tsx",
849
- "status": "modified",
850
- "diff": "Index: web/components/portfolio/portfolio-value-section.tsx\n===================================================================\n--- web/components/portfolio/portfolio-value-section.tsx\t7de2ad7 (parent)\n+++ web/components/portfolio/portfolio-value-section.tsx\tc9b41a7 (commit)\n@@ -21,8 +21,9 @@\n import { getPortfolioValues } from '../portfolio-helpers'\n import { useSweepstakes } from '../sweepstakes-provider'\n import { SPICE_TO_MANA_CONVERSION_RATE } from 'common/envs/constants'\n import { filterDefined } from 'common/util/array'\n+import { DailyLeagueStat } from '../home/daily-league-stat'\n \n export type PortfolioHoveredGraphType =\n | 'balance'\n | 'investment'\n@@ -299,8 +300,9 @@\n graphValues,\n portfolioFocus,\n setPortfolioFocus,\n hideSweepsToggle,\n+ userId,\n } = props\n \n function togglePortfolioFocus(toggleTo: PortfolioMode) {\n setPortfolioFocus(portfolioFocus === toggleTo ? 'all' : toggleTo)\n@@ -351,9 +353,9 @@\n >\n net worth\n </span>\n </span>\n- <Row className=\"mt-2 gap-2\">\n+ <Row className=\"-mr-4 mt-2 gap-2\">\n <PortfolioGraphNumber\n prefersPlay={prefersPlay}\n numberType={'balance'}\n descriptor=\"balance\"\n@@ -412,8 +414,15 @@\n : 'bg-canvas-50 text-ink-1000'\n )}\n onClick={() => togglePortfolioFocus('profit')}\n />\n+ <DailyLeagueStat\n+ userId={userId}\n+ className={clsx(\n+ 'group cursor-pointer select-none rounded px-2 py-1 transition-colors',\n+ 'bg-canvas-50 text-ink-1000 opacity-[0.75] hover:opacity-100'\n+ )}\n+ />\n </Row>\n </Col>\n {portfolioGraphElement && (\n <SizedContainer\n"
851
- }
852
- ]
853
- },
854
- {
855
- "id": "update-post-validation",
856
- "sha": "94c2f0dcd82ccba7138395612a8fa83712096c0e",
857
- "parentSha": "f5cfe579a7c2f5ceac6a467836c61ac97ae1ce70",
858
- "spec": "Implement improved post creation validation behavior and title length support:\n\n- Shared API schema\n - In common/src/api/schema.ts, update the 'create-post' endpoint props to allow a longer title by increasing the z.string().max value on title from 120 to 160.\n\n- Web client: create-post page\n - In web/pages/create-post.tsx, update imports to include APIError from the web API client module.\n - In the form submission logic (the function that calls api('create-post', ...)), change error handling to detect server-side validation failures. When an APIError is thrown with a message indicating request validation failure and includes a structured details array of { field: string; error: string }, display the first field-level error to the user in the existing error UI. For other APIError cases, surface the API-provided message. For non-APIError exceptions, log the error and display the existing generic error message.\n - Ensure that on success the existing behavior (clearing the editor content and any navigation/notifications) remains unchanged.\n\n- Assumptions/compatibility\n - The backend is already producing APIError instances for validation failures with message text that includes 'validating request' and a details array of field-level errors; the client-side change should consume this contract.\n - Do not alter backend behavior beyond the schema change; no route or handler changes are required for this task.\n\n- Acceptance criteria\n - The API allows creating a post with a title up to 160 characters; attempts with longer titles fail with a validation error.\n - When the API rejects the request due to validation (e.g., title too long), the create-post page displays a clear field-specific message derived from the APIError details (e.g., 'title: String must contain at most 160 character(s)').\n - Other API errors show their message; unknown errors show the existing generic fallback message.\n",
859
- "prompt": "Increase the maximum allowed title length for creating a post and improve the create-post page to display server-side validation errors clearly. Update the shared API schema so the title can be up to 160 characters, and modify the create-post page to catch API errors: if the server returns a validation error, show the relevant field error to the user; otherwise show the server message or a generic fallback. Keep existing success behavior the same.",
860
- "supplementalFiles": [
861
- "backend/api/src/create-post.ts",
862
- "backend/api/src/routes.ts",
863
- "backend/api/src/helpers/endpoint.ts",
864
- "web/lib/api/api.ts",
865
- "common/src/api/zod-types.ts",
866
- "common/src/top-level-post.ts",
867
- "common/src/api/utils.ts"
868
- ],
869
- "fileDiffs": [
870
- {
871
- "path": "common/src/api/schema.ts",
872
- "status": "modified",
873
- "diff": "Index: common/src/api/schema.ts\n===================================================================\n--- common/src/api/schema.ts\tf5cfe57 (parent)\n+++ common/src/api/schema.ts\t94c2f0d (commit)\n@@ -2305,9 +2305,9 @@\n authed: true,\n returns: {} as { post: TopLevelPost },\n props: z\n .object({\n- title: z.string().min(1).max(120),\n+ title: z.string().min(1).max(160),\n content: contentSchema,\n isAnnouncement: z.boolean().optional(),\n visibility: z.enum(['public', 'unlisted']).optional(),\n isChangeLog: z.boolean().optional(),\n"
874
- },
875
- {
876
- "path": "web/pages/create-post.tsx",
877
- "status": "modified",
878
- "diff": "Index: web/pages/create-post.tsx\n===================================================================\n--- web/pages/create-post.tsx\tf5cfe57 (parent)\n+++ web/pages/create-post.tsx\t94c2f0d (commit)\n@@ -9,9 +9,9 @@\n import { useUser } from 'web/hooks/use-user'\n import { linkClass } from 'web/components/widgets/site-link'\n import clsx from 'clsx'\n import Link from 'next/link'\n-import { api } from 'web/lib/api/api'\n+import { api, APIError } from 'web/lib/api/api'\n import { Page } from 'web/components/layout/page'\n import { DAY_MS } from 'common/util/time'\n import ShortToggle from 'web/components/widgets/short-toggle'\n import { Row } from 'web/components/layout/row'\n@@ -82,10 +82,18 @@\n isChangeLog,\n }\n \n const result = await api('create-post', newPost).catch((e) => {\n- console.log(e)\n- setError('There was an error creating the post, please try again')\n+ if (e instanceof APIError) {\n+ if (e.message.includes('validating request')) {\n+ const details = e.details as { field: string; error: string }[]\n+ const detail = details[0]\n+ setError(`${detail.field}: ${detail.error}`)\n+ } else setError(e.message)\n+ } else {\n+ console.error(e)\n+ setError('There was an error creating the post, please try again')\n+ }\n return e\n })\n if (result.post) {\n editor.commands.clearContent(true)\n"
879
- }
880
- ]
881
- },
882
- {
883
- "id": "enhance-add-answer",
884
- "sha": "18d88f1fb4a5e0cc83c42b5b10c9a622286cd0e3",
885
- "parentSha": "b31def268b43513aecbae373c758175f0f0f87b0",
886
- "spec": "- Update web/components/answers/create-answer-panel.tsx to make the Add action always visible and context-aware:\n - Remove the plus-magnifier icon usage and display only the standard search icon at the left of the input regardless of permissions. Clean up imports to drop the plus icon.\n - Introduce a local UI state (e.g., a boolean) to track when the user has initiated adding a new answer without typing any text.\n - Change Add button behavior so that when the user can add answers:\n - The Add button is always rendered (even when the input is empty).\n - Clicking Add with an empty input switches the panel into an \"adding\" state, focuses the input, and updates the input placeholder to prompt the user to enter a new answer.\n - Clicking Add when there is input text submits the new answer as before, shows a loading state while submitting, then clears the input and sets the sort to \"new\" on success.\n - Adjust the input placeholder logic:\n - Default when adding is allowed and not in adding mode: \"Search or add answer\".\n - When in adding mode (no text yet): \"Enter new answer\".\n - When adding is not allowed: \"Search answers\".\n - When the input value changes, clear the adding state (so typing returns the UI to normal search/add mode).\n - On input blur, if the input is empty, close the search dropdown if applicable and exit the adding state.\n - Update the right-side buttons row layout to ensure vertical alignment and height accommodate the Add button (e.g., set a fixed height to prevent overlap with the input padding).\n - Update the Add button’s visual state:\n - When the input has text, use the primary solid style (indigo) and display the creation cost (MoneyDisplay) after the label.\n - When the input is empty, use the primary outline style (indigo-outline) and display a simple \"answer\" label next to \"Add\" instead of a cost.\n - Keep the existing submission tracking event and wire it to the new click handler that either focuses or submits based on text presence.\n\n- Extend the shared Button component in web/components/buttons/button.tsx:\n - Add a new color variant named indigo-white to the ColorType so it can be consumed by the design system consistently.\n - Define the indigo-white styles to render as primary-colored text on a white/transparent background with a subtle primary hover background and appropriate disabled states (primary-tinted text and transparent background when disabled).\n - Ensure this addition does not affect existing color variants or defaults.\n\n- Maintain existing behaviors:\n - The answer cost display should still use getAnswerCostFromLiquidity from common/src/tier.ts when text is present.\n - Successful submission should continue to clear the input and set the answer sort to \"new\".\n - Keep the X/Clear button behavior for clearing the input unchanged.",
887
- "prompt": "Make the \"Add answer\" call-to-action in the multi-answer search/create panel more obvious and intuitive. Always show the Add button when adding is permitted. If the input is empty and the user clicks Add, focus the input and prompt them to enter a new answer; if there is text, submit as usual and show the cost next to the label. Use a single search icon at the left of the input (don’t switch icons), update placeholders to reflect search vs. adding mode, and tidy the layout so buttons don’t overlap the input. Also add a new button color variant for primary-colored text on a white/transparent background with a subtle primary hover state to the shared Button component. Keep existing API calls, sorting updates after submit, and cost calculation behavior intact.",
888
- "supplementalFiles": [
889
- "web/components/answers/answers-panel.tsx",
890
- "common/src/tier.ts",
891
- "web/pages/styles.tsx",
892
- "web/components/widgets/input.tsx",
893
- "web/components/bet/money-display.tsx"
894
- ],
895
- "fileDiffs": [
896
- {
897
- "path": "web/components/answers/create-answer-panel.tsx",
898
- "status": "modified",
899
- "diff": "Index: web/components/answers/create-answer-panel.tsx\n===================================================================\n--- web/components/answers/create-answer-panel.tsx\tb31def2 (parent)\n+++ web/components/answers/create-answer-panel.tsx\t18d88f1 (commit)\n@@ -3,9 +3,9 @@\n import { MultiSort } from 'common/answer'\n import { MultiContract, SORTS } from 'common/contract'\n import { getAnswerCostFromLiquidity } from 'common/tier'\n import { useLayoutEffect, useRef, useState } from 'react'\n-import { FaSearch, FaSearchPlus } from 'react-icons/fa'\n+import { FaSearch } from 'react-icons/fa'\n import { api } from 'web/lib/api/api'\n import { withTracking } from 'web/lib/service/analytics'\n import { Button } from '../buttons/button'\n import DropdownMenu from '../widgets/dropdown-menu'\n@@ -66,8 +66,9 @@\n \n const canAddAnswer = props.canAddAnswer\n \n const [isSubmitting, setIsSubmitting] = useState(false)\n+ const [isAddingAnswer, setIsAddingAnswer] = useState(false)\n \n const canSubmit = text && !isSubmitting\n \n const submitAnswer = async () => {\n@@ -86,8 +87,17 @@\n setSort('new')\n }\n }\n \n+ const handleAddAnswerClick = () => {\n+ if (!text) {\n+ setIsAddingAnswer(true)\n+ inputRef.current?.focus()\n+ } else {\n+ submitAnswer()\n+ }\n+ }\n+\n const isCashContract = contract.token === 'CASH'\n \n const inputRef = useRef<HTMLInputElement>(null)\n const buttonsRef = useRef<HTMLDivElement>(null)\n@@ -105,24 +115,34 @@\n <div className=\"relative w-full\">\n <Input\n value={text}\n ref={inputRef}\n- onChange={(e) => setText(e.target.value)}\n+ onChange={(e) => {\n+ setText(e.target.value)\n+ if (isAddingAnswer) {\n+ setIsAddingAnswer(false)\n+ }\n+ }}\n className=\"!bg-canvas-50 !h-8 w-full flex-grow !rounded-full !pl-7 !text-sm\"\n placeholder={\n- canAddAnswer ? 'Search or Add answers' : 'Search answers'\n+ isAddingAnswer\n+ ? 'Enter new answer'\n+ : canAddAnswer\n+ ? 'Search or add answer'\n+ : 'Search answers'\n }\n- onBlur={() => !text && setIsSearchOpen?.(false)}\n+ onBlur={() => {\n+ if (!text) {\n+ setIsSearchOpen?.(false)\n+ setIsAddingAnswer(false)\n+ }\n+ }}\n />\n- {canAddAnswer ? (\n- <FaSearchPlus className=\"text-ink-400 dark:text-ink-500 absolute left-2 top-2 h-4 w-4 \" />\n- ) : (\n- <FaSearch className=\"text-ink-400 dark:text-ink-500 absolute left-2 top-2 h-4 w-4\" />\n- )}\n+ <FaSearch className=\"text-ink-400 dark:text-ink-500 absolute left-2 top-2 h-4 w-4\" />\n {(text || canAddAnswer) && (\n <Row\n ref={buttonsRef}\n- className=\"absolute right-1 top-0.5 items-center gap-0.5\"\n+ className=\"absolute right-1 top-0.5 h-7 items-center gap-0.5 \"\n >\n {text && (\n <button\n className={clsx('group h-full')}\n@@ -131,26 +151,30 @@\n <XCircleIcon className=\"fill-ink-300 group-hover:fill-ink-400 h-7 w-7 items-center transition-colors\" />\n </button>\n )}\n \n- {canAddAnswer && text && (\n+ {canAddAnswer && (\n <Button\n- className=\"!rounded-full\"\n+ className=\" !rounded-full\"\n size=\"2xs\"\n+ color={text ? 'indigo' : 'indigo-outline'}\n loading={isSubmitting}\n- disabled={!canSubmit}\n- onClick={withTracking(submitAnswer, 'submit answer')}\n+ onClick={withTracking(handleAddAnswerClick, 'submit answer')}\n >\n- <span className=\"font-semibold\">Add</span>\n- <span className=\"text-ink-200 dark:text-ink-800 ml-1\">\n- <MoneyDisplay\n- amount={getAnswerCostFromLiquidity(\n- contract.totalLiquidity,\n- contract.answers.length\n- )}\n- isCashContract={isCashContract}\n- />\n- </span>\n+ <span className=\"\">Add</span>\n+ {text ? (\n+ <span className=\"text-ink-200 dark:text-ink-800 ml-1\">\n+ <MoneyDisplay\n+ amount={getAnswerCostFromLiquidity(\n+ contract.totalLiquidity,\n+ contract.answers.length\n+ )}\n+ isCashContract={isCashContract}\n+ />\n+ </span>\n+ ) : (\n+ <span className=\"ml-1\">answer</span>\n+ )}\n </Button>\n )}\n </Row>\n )}\n"
900
- },
901
- {
902
- "path": "web/components/buttons/button.tsx",
903
- "status": "modified",
904
- "diff": "Index: web/components/buttons/button.tsx\n===================================================================\n--- web/components/buttons/button.tsx\tb31def2 (parent)\n+++ web/components/buttons/button.tsx\t18d88f1 (commit)\n@@ -30,8 +30,9 @@\n | 'purple-outline'\n | 'violet'\n | 'azure'\n | 'sienna'\n+ | 'indigo-white'\n \n const sizeClasses = {\n '2xs': 'px-2 py-1 text-xs',\n xs: 'px-2.5 py-1.5 text-sm',\n@@ -88,8 +89,10 @@\n color === 'gradient-pink' && [gradient, 'from-primary-500 to-fuchsia-500'],\n color === 'pink' && [solid, 'bg-fuchsia-500 hover:bg-fuchsia-600'],\n color === 'gray-white' &&\n 'text-ink-600 hover:bg-ink-200 disabled:text-ink-300 disabled:bg-transparent',\n+ color === 'indigo-white' &&\n+ 'text-primary-600 hover:bg-primary-200 disabled:text-primary-300 disabled:bg-transparent',\n color === 'gold' && [\n gradient,\n 'enabled:!bg-gradient-to-br from-yellow-400 via-yellow-100 to-yellow-300 dark:from-yellow-600 dark:via-yellow-200 dark:to-yellow-400 !text-gray-900',\n ],\n"
905
- }
906
- ]
907
- },
908
- {
909
- "id": "coarsen-last-active",
910
- "sha": "b31def268b43513aecbae373c758175f0f0f87b0",
911
- "parentSha": "b62da9abba0cb1429828ddbe19279be30c00949f",
912
- "spec": "Implement coarse-grained last active time on both backend and frontend.\n\nBackend:\n- File: backend/api/src/get-user-last-active-time.ts\n - Update the SQL query to round each of last_card_view_ts, last_page_view_ts, and last_promoted_view_ts to the start of their day using date_trunc('day', ...), then convert to milliseconds with ts_to_millis.\n - Apply this rounding consistently in both the SELECT greatest(...) expression and the ORDER BY greatest(...) expression.\n - Preserve the existing WHERE conditions (user_id match and at least one of the timestamps non-null), LIMIT 1, and return shape { lastActiveTime: number | null }.\n\nFrontend:\n- File: web/components/user/user-hovercard.tsx\n - Remove the usage and import of RelativeTimestampNoTooltip for the \"Last active:\" line.\n - Add a local helper function (e.g., formatLastActive) that takes a millisecond timestamp and returns a coarse string:\n - If the value is 0, return \"Never\".\n - If the date is the same day as now, return \"today\".\n - If exactly one day ago, return \"yesterday\".\n - If within 2–7 days, return \"{N} days ago\".\n - If within 8–30 days, return weeks via floor(days/7): \"1 week ago\" or \"{N} weeks ago\".\n - If within 31–365 days, return months via floor(days/30): \"1 month ago\" or \"{N} months ago\".\n - If more than a year, return \"over a year\".\n - Continue to compute lastActiveTime as the max of the API result and user.lastBetTime. Use the new formatter’s output for display next to the \"Last active:\" label.\n - Keep dayjs available for date calculations.\n\nScope constraints:\n- Do not change the API schema or routes beyond the rounding behavior in the SQL.\n- Do not alter how the hovercard fetches data or computes lastActiveTime other than substituting the display formatter.\n- No new dependencies are required.",
913
- "prompt": "Make the user hovercard’s \"Last active\" display less granular. On the backend, update the logic that calculates a user’s last active time so that it rounds activity timestamps to the start of the day before comparing them. On the frontend hovercard, replace the relative timestamp display with a simple, human-friendly label (e.g., today, yesterday, N days/weeks/months ago, over a year) and keep showing \"Never\" when there’s no activity. Keep the existing endpoint, routing, and the logic that takes the maximum of activity and last bet time; only change the rounding on the backend and the displayed formatting on the frontend.",
914
- "supplementalFiles": [
915
- "backend/api/src/routes.ts",
916
- "common/src/api/schema.ts",
917
- "web/components/relative-timestamp.tsx",
918
- "web/hooks/use-api-getter.ts",
919
- "web/lib/util/shortenedFromNow.ts",
920
- "web/components/widgets/datetime-tooltip.tsx"
921
- ],
922
- "fileDiffs": [
923
- {
924
- "path": "backend/api/src/get-user-last-active-time.ts",
925
- "status": "modified",
926
- "diff": "Index: backend/api/src/get-user-last-active-time.ts\n===================================================================\n--- backend/api/src/get-user-last-active-time.ts\tb62da9a (parent)\n+++ backend/api/src/get-user-last-active-time.ts\tb31def2 (commit)\n@@ -8,22 +8,22 @@\n const pg = createSupabaseDirectClient()\n \n const result = await pg.oneOrNone(\n `select greatest(\n- coalesce(ts_to_millis(last_card_view_ts), 0),\n- coalesce(ts_to_millis(last_page_view_ts), 0), \n- coalesce(ts_to_millis(last_promoted_view_ts), 0)\n+ coalesce(ts_to_millis(date_trunc('day', last_card_view_ts)), 0),\n+ coalesce(ts_to_millis(date_trunc('day', last_page_view_ts)), 0),\n+ coalesce(ts_to_millis(date_trunc('day', last_promoted_view_ts)), 0)\n ) as last_active_time\n- from user_contract_views \n+ from user_contract_views\n where user_id = $1\n- and (last_card_view_ts is not null \n- or last_page_view_ts is not null \n- or last_promoted_view_ts is not null)\n+ and (last_card_view_ts is not null\n+ or last_page_view_ts is not null\n+ or last_promoted_view_ts is not null)\n order by greatest(\n- coalesce(ts_to_millis(last_card_view_ts), 0),\n- coalesce(ts_to_millis(last_page_view_ts), 0), \n- coalesce(ts_to_millis(last_promoted_view_ts), 0)\n- ) desc\n+ coalesce(ts_to_millis(date_trunc('day', last_card_view_ts)), 0),\n+ coalesce(ts_to_millis(date_trunc('day', last_page_view_ts)), 0),\n+ coalesce(ts_to_millis(date_trunc('day', last_promoted_view_ts)), 0)\n+ ) desc\n limit 1`,\n [userId]\n )\n \n"
927
- },
928
- {
929
- "path": "web/components/user/user-hovercard.tsx",
930
- "status": "modified",
931
- "diff": "Index: web/components/user/user-hovercard.tsx\n===================================================================\n--- web/components/user/user-hovercard.tsx\tb62da9a (parent)\n+++ web/components/user/user-hovercard.tsx\tb31def2 (commit)\n@@ -6,9 +6,8 @@\n import { Avatar } from '../widgets/avatar'\n import { FollowButton } from '../buttons/follow-button'\n import { StackedUserNames } from '../widgets/user-link'\n import { Linkify } from '../widgets/linkify'\n-import { RelativeTimestampNoTooltip } from '../relative-timestamp'\n import dayjs from 'dayjs'\n import { Col } from '../layout/col'\n import { FullUser } from 'common/api/user-types'\n import { SimpleCopyTextButton } from 'web/components/buttons/copy-link-button'\n@@ -34,8 +33,32 @@\n userId: string\n className?: string | undefined\n }\n \n+function formatLastActive(lastActiveTime: number) {\n+ if (lastActiveTime === 0) return 'Never'\n+\n+ const now = dayjs()\n+ const lastActiveDate = dayjs(lastActiveTime)\n+ const days = now.diff(lastActiveDate, 'day')\n+\n+ if (days === 0) return 'today'\n+ if (days === 1) return 'yesterday'\n+ if (days <= 7) return `${days} days ago`\n+ if (days <= 30) {\n+ const weeks = Math.floor(days / 7)\n+ if (weeks === 1) return '1 week ago'\n+ return `${weeks} weeks ago`\n+ }\n+ if (days <= 365) {\n+ const months = Math.floor(days / 30)\n+ if (months === 1) return '1 month ago'\n+ if (months <= 12) return `${months} months ago`\n+ return 'in the last year'\n+ }\n+ return 'over a year'\n+}\n+\n export function UserHovercard({\n children,\n userId,\n className,\n@@ -167,16 +190,9 @@\n \n <div className=\"py-1\">\n <div className=\"text-ink-700 block px-4 py-2 text-sm\">\n <span className=\"font-semibold\">Last active:</span>{' '}\n- {lastActiveTime !== 0 ? (\n- <RelativeTimestampNoTooltip\n- time={lastActiveTime}\n- className=\"text-ink-700\"\n- />\n- ) : (\n- 'Never'\n- )}\n+ {formatLastActive(lastActiveTime)}\n </div>\n </div>\n </div>\n ) : null\n"
932
- }
933
- ]
934
- },
935
- {
936
- "id": "add-last-active",
937
- "sha": "b62da9abba0cb1429828ddbe19279be30c00949f",
938
- "parentSha": "cba2ea1b6f187af3d36865310719c3c17bbe91c7",
939
- "spec": "Implement a new API endpoint and update the user hovercard to show a user's last active time.\n\n1) Add a new GET API endpoint definition\n- File: common/src/api/schema.ts\n- Add a new entry named 'get-user-last-active-time' with:\n - method: GET\n - authed: false\n - visibility: undocumented\n - cache: use the default cache strategy constant already used for other GET endpoints\n - props: an object with a single userId: string\n - returns: an object { lastActiveTime: number | null }\n\n2) Create the backend handler for the endpoint\n- File: backend/api/src/get-user-last-active-time.ts (new)\n- Export a handler typed as APIHandler<'get-user-last-active-time'>\n- Accept the props containing userId and create a direct supabase client\n- Run a single SQL query against user_contract_views to compute the maximum activity time in milliseconds, using greatest over the three last_*_ts columns converted to millis via ts_to_millis, filtering rows by the provided userId and ensuring at least one of the three timestamps is non-null; order by that computed value descending and take the first row, returning the computed value as lastActiveTime, or null if none exists\n- Return { lastActiveTime: number | null } from the handler\n\n3) Register the new handler in the API routes map\n- File: backend/api/src/routes.ts\n- Import the handler from './get-user-last-active-time'\n- Add a new entry mapping the 'get-user-last-active-time' API path to the imported handler in the handlers object\n\n4) Update the user hovercard to display the last active time\n- File: web/components/user/user-hovercard.tsx\n- Import and use the client hook to GET the new endpoint with { userId }\n- Compute lastActiveTime in the component as the maximum of the returned lastActiveTime and the user's lastBetTime (if present)\n- Replace the previous admin-only Last [trade term] display with an unconditional Last active display that shows a relative timestamp when lastActiveTime is non-zero, or 'Never' otherwise\n- Remove any no-longer-used imports and variables tied to the old Last [trade term] UI section\n\nExpected behavior:\n- The API endpoint returns the latest user activity time derived from user_contract_views (or null if none)\n- The user hovercard shows a 'Last active:' line for all viewers, based on the latest of view activity and lastBetTime, using the existing relative timestamp component\n- No authentication is required to fetch this data, and the endpoint uses the default caching strategy",
940
- "prompt": "Add a public GET endpoint that returns a user's last active time and show it on the user hovercard. The endpoint should compute the latest activity timestamp based on a user's contract view times and return it as milliseconds since epoch (or null if not available). Register the endpoint in the backend routes. Then update the user hovercard to query this endpoint for the viewed user and render a 'Last active' line that shows a relative time based on the maximum of the returned value and the user's last bet time. Remove the previous admin-only last bet display and make the 'Last active' visible to all users.",
941
- "supplementalFiles": [
942
- "backend/api/src/helpers/endpoint.ts",
943
- "web/hooks/use-api-getter.ts",
944
- "client-common/src/hooks/use-api-getter.ts",
945
- "web/components/relative-timestamp.tsx",
946
- "backend/shared/src/supabase/init.ts",
947
- "backend/supabase/user_contract_views.sql"
948
- ],
949
- "fileDiffs": [
950
- {
951
- "path": "backend/api/src/get-user-last-active-time.ts",
952
- "status": "added",
953
- "diff": "Index: backend/api/src/get-user-last-active-time.ts\n===================================================================\n--- backend/api/src/get-user-last-active-time.ts\tcba2ea1 (parent)\n+++ backend/api/src/get-user-last-active-time.ts\tb62da9a (commit)\n@@ -1,1 +1,33 @@\n-[NEW FILE]\n\\n+import { APIHandler } from 'api/helpers/endpoint'\n+import { createSupabaseDirectClient } from 'shared/supabase/init'\n+\n+export const getUserLastActiveTime: APIHandler<\n+ 'get-user-last-active-time'\n+> = async (body) => {\n+ const { userId } = body\n+ const pg = createSupabaseDirectClient()\n+\n+ const result = await pg.oneOrNone(\n+ `select greatest(\n+ coalesce(ts_to_millis(last_card_view_ts), 0),\n+ coalesce(ts_to_millis(last_page_view_ts), 0), \n+ coalesce(ts_to_millis(last_promoted_view_ts), 0)\n+ ) as last_active_time\n+ from user_contract_views \n+ where user_id = $1\n+ and (last_card_view_ts is not null \n+ or last_page_view_ts is not null \n+ or last_promoted_view_ts is not null)\n+ order by greatest(\n+ coalesce(ts_to_millis(last_card_view_ts), 0),\n+ coalesce(ts_to_millis(last_page_view_ts), 0), \n+ coalesce(ts_to_millis(last_promoted_view_ts), 0)\n+ ) desc\n+ limit 1`,\n+ [userId]\n+ )\n+\n+ return {\n+ lastActiveTime: result?.last_active_time || null,\n+ }\n+}\n"
954
- },
955
- {
956
- "path": "backend/api/src/routes.ts",
957
- "status": "modified",
958
- "diff": "Index: backend/api/src/routes.ts\n===================================================================\n--- backend/api/src/routes.ts\tcba2ea1 (parent)\n+++ backend/api/src/routes.ts\tb62da9a (commit)\n@@ -174,8 +174,9 @@\n import { dismissUserReport } from './dismiss-user-report'\n import { followPost } from './follow-post'\n import { editPostComment, updatePostComment } from './edit-post-comment'\n import { getUserComments } from './get-comments'\n+import { getUserLastActiveTime } from './get-user-last-active-time'\n export const handlers: { [k in APIPath]: APIHandler<k> } = {\n 'refresh-all-clients': refreshAllClients,\n bet: placeBet,\n 'multi-bet': placeMultiBet,\n@@ -359,5 +360,6 @@\n 'dismiss-user-report': dismissUserReport,\n 'follow-post': followPost,\n 'edit-post-comment': editPostComment,\n 'user-comments': getUserComments,\n+ 'get-user-last-active-time': getUserLastActiveTime,\n } as const\n"
959
- },
960
- {
961
- "path": "common/src/api/schema.ts",
962
- "status": "modified",
963
- "diff": "Index: common/src/api/schema.ts\n===================================================================\n--- common/src/api/schema.ts\tcba2ea1 (parent)\n+++ common/src/api/schema.ts\tb62da9a (commit)\n@@ -183,12 +183,14 @@\n method: 'POST',\n visibility: 'public',\n authed: true,\n returns: {} as { success: boolean },\n- props: z.object({ \n- commentPath: z.string(),\n- action: z.enum(['hide', 'delete']).optional().default('hide')\n- }).strict(),\n+ props: z\n+ .object({\n+ commentPath: z.string(),\n+ action: z.enum(['hide', 'delete']).optional().default('hide'),\n+ })\n+ .strict(),\n },\n 'pin-comment': {\n method: 'POST',\n visibility: 'undocumented',\n@@ -2376,8 +2378,20 @@\n })\n .strict(),\n returns: {} as { success: boolean },\n },\n+ 'get-user-last-active-time': {\n+ method: 'GET',\n+ authed: false,\n+ visibility: 'undocumented',\n+ cache: DEFAULT_CACHE_STRATEGY,\n+ props: z\n+ .object({\n+ userId: z.string(),\n+ })\n+ .strict(),\n+ returns: {} as { lastActiveTime: number | null },\n+ },\n } as const)\n \n export type APIPath = keyof typeof API\n export type APISchema<N extends APIPath> = (typeof API)[N]\n"
964
- },
965
- {
966
- "path": "web/components/user/user-hovercard.tsx",
967
- "status": "modified",
968
- "diff": "Index: web/components/user/user-hovercard.tsx\n===================================================================\n--- web/components/user/user-hovercard.tsx\tcba2ea1 (parent)\n+++ web/components/user/user-hovercard.tsx\tb62da9a (commit)\n@@ -10,10 +10,10 @@\n import { RelativeTimestampNoTooltip } from '../relative-timestamp'\n import dayjs from 'dayjs'\n import { Col } from '../layout/col'\n import { FullUser } from 'common/api/user-types'\n-import { TRADE_TERM } from 'common/envs/constants'\n import { SimpleCopyTextButton } from 'web/components/buttons/copy-link-button'\n+import { useAPIGetter } from 'web/hooks/use-api-getter'\n import {\n autoUpdate,\n flip,\n offset,\n@@ -96,8 +96,15 @@\n \n const followingIds = useFollows(userId)\n const followerIds = useFollowers(userId)\n const isMod = useAdminOrMod()\n+ const { data: lastActiveData } = useAPIGetter('get-user-last-active-time', {\n+ userId,\n+ })\n+ const lastActiveTime = Math.max(\n+ lastActiveData?.lastActiveTime ?? 0,\n+ user?.lastBetTime ?? 0\n+ )\n \n return user ? (\n <div\n ref={ref}\n@@ -157,23 +164,21 @@\n </Row>\n </Col>\n </div>\n \n- {isMod && (\n- <div className=\"py-1\">\n- <div className=\"text-ink-700 block px-4 py-2 text-sm\">\n- <span className=\"font-semibold\">Last {TRADE_TERM}:</span>{' '}\n- {user.lastBetTime ? (\n- <RelativeTimestampNoTooltip\n- time={user.lastBetTime}\n- className=\"text-ink-700\"\n- />\n- ) : (\n- 'Never'\n- )}\n- </div>\n+ <div className=\"py-1\">\n+ <div className=\"text-ink-700 block px-4 py-2 text-sm\">\n+ <span className=\"font-semibold\">Last active:</span>{' '}\n+ {lastActiveTime !== 0 ? (\n+ <RelativeTimestampNoTooltip\n+ time={lastActiveTime}\n+ className=\"text-ink-700\"\n+ />\n+ ) : (\n+ 'Never'\n+ )}\n </div>\n- )}\n+ </div>\n </div>\n ) : null\n }\n )\n"
969
- }
970
- ]
971
- },
972
- {
973
- "id": "reduce-metric-cardinality",
974
- "sha": "163d1e9ae2c07d6508ff07f763e904602e2849cc",
975
- "parentSha": "fe94154f14b329c4c31f79c5383625e7442837da",
976
- "spec": "Implement a reduction in metric label cardinality across HTTP, Postgres, and WebSocket instrumentation to prevent quota exhaustion in Google Cloud Monitoring.\n\nRequired changes:\n\n1) HTTP metrics (backend/api/src/app.ts)\n- Increment the http/request_count metric using only baseEndpoint and method labels. Do not include endpoint.\n- On response close, push the http/request_latency metric using only baseEndpoint and method labels. Do not include endpoint.\n- Maintain existing logging behavior and latency calculation (hrtime.bigint to milliseconds).\n\n2) Postgres metrics (backend/shared/src/supabase/init.ts)\n- In the transaction duration reporting logic, push pg/transaction_duration with:\n - If monitoring context includes baseEndpoint: labels { baseEndpoint, successStr }.\n - If monitoring context includes job: labels { job, successStr }.\n - Otherwise: labels { successStr } only.\n- Do not include endpoint or query labels in pg/transaction_duration.\n- In the per-query instrumentation, increment pg/query_count with:\n - baseEndpoint when present (label { baseEndpoint }),\n - job when present (label { job }),\n - no labels otherwise.\n- Do not include endpoint in pg/query_count.\n\n3) WebSocket metrics (backend/shared/src/websockets/server.ts)\n- Add a helper function that maps topic strings into a small set of categories to avoid high-cardinality labels. The mapping should at least include: answer (answer/*), contract (contract/*), user (user/*), private-user (private-user/*), global (global or global/*), post (post/*), and default to other.\n- In broadcastMulti, replace metrics.inc('ws/broadcasts_sent', { topic }) with metrics.inc('ws/broadcasts_sent', { category: <derived-category> }).\n- Keep existing broadcast and subscription behavior unchanged; only change the labels emitted with the metrics.\n\nDo not modify any other behavior, logging, or metric names. Preserve existing monitoring context creation and usage; only change how labels are selected and which labels are emitted. No changes are needed to where baseEndpoint is computed, only to how it is used for labeling.",
977
- "prompt": "Reduce metrics label cardinality to prevent monitoring quota issues. For HTTP request metrics, stop labeling by full endpoint and only use a stable base endpoint identifier and method. For Postgres metrics, remove per-query and per-endpoint labeling; prefer base endpoint or job where available and otherwise emit without those labels. For WebSocket broadcast metrics, avoid per-topic labels by categorizing topics into a small set (e.g., contract, answer, user, private-user, global, post, other) and label by category instead of topic. Keep all existing functionality otherwise unchanged.",
978
- "supplementalFiles": [
979
- "backend/shared/src/monitoring/metrics.ts",
980
- "backend/shared/src/monitoring/context.ts",
981
- "backend/shared/src/monitoring/metric-writer.ts",
982
- "backend/shared/src/websockets/helpers.ts",
983
- "backend/shared/src/websockets/switchboard.ts",
984
- "backend/shared/src/monitoring/log.ts"
985
- ],
986
- "fileDiffs": [
987
- {
988
- "path": "backend/api/src/app.ts",
989
- "status": "modified",
990
- "diff": "Index: backend/api/src/app.ts\n===================================================================\n--- backend/api/src/app.ts\tfe94154 (parent)\n+++ backend/api/src/app.ts\t163d1e9 (commit)\n@@ -49,14 +49,13 @@\n log(`${method} ${url} ${process.env.PORT}`)\n } else if (!ignoredEndpoints.some((e) => endpoint.startsWith(e))) {\n log(`${method} ${url}`)\n }\n- metrics.inc('http/request_count', { endpoint, baseEndpoint, method })\n+ metrics.inc('http/request_count', { baseEndpoint, method })\n res.on('close', () => {\n const endTs = hrtime.bigint()\n const latencyMs = Number(endTs - startTs) / 1e6 // Convert to milliseconds\n metrics.push('http/request_latency', latencyMs, {\n- endpoint,\n method,\n baseEndpoint,\n })\n })\n"
991
- },
992
- {
993
- "path": "backend/shared/src/supabase/init.ts",
994
- "status": "modified",
995
- "diff": "Index: backend/shared/src/supabase/init.ts\n===================================================================\n--- backend/shared/src/supabase/init.ts\tfe94154 (parent)\n+++ backend/shared/src/supabase/init.ts\t163d1e9 (commit)\n@@ -31,27 +31,24 @@\n const mctx = getMonitoringContext()\n if (mctx?.baseEndpoint) {\n metrics.push('pg/transaction_duration', duration, {\n baseEndpoint: mctx.baseEndpoint,\n- endpoint: mctx.endpoint,\n- query,\n successStr,\n })\n } else if (mctx?.job) {\n metrics.push('pg/transaction_duration', duration, {\n job: mctx.job,\n- query,\n successStr,\n })\n } else {\n- metrics.push('pg/transaction_duration', duration, { query, successStr })\n+ metrics.push('pg/transaction_duration', duration, { successStr })\n }\n }\n },\n query() {\n const ctx = getMonitoringContext()\n- if (ctx?.endpoint) {\n- metrics.inc('pg/query_count', { endpoint: ctx.endpoint })\n+ if (ctx?.baseEndpoint) {\n+ metrics.inc('pg/query_count', { baseEndpoint: ctx.baseEndpoint })\n } else if (ctx?.job) {\n metrics.inc('pg/query_count', { job: ctx.job })\n } else {\n metrics.inc('pg/query_count')\n"
996
- },
997
- {
998
- "path": "backend/shared/src/websockets/server.ts",
999
- "status": "modified",
1000
- "diff": "Index: backend/shared/src/websockets/server.ts\n===================================================================\n--- backend/shared/src/websockets/server.ts\tfe94154 (parent)\n+++ backend/shared/src/websockets/server.ts\t163d1e9 (commit)\n@@ -14,8 +14,27 @@\n \n // if a connection doesn't ping for this long, we assume the other side is toast\n const CONNECTION_TIMEOUT_MS = 60 * 1000\n \n+// Categorize topics to avoid unbounded metric cardinality\n+function getTopicCategory(topic: string): string {\n+ if (topic.startsWith('answer/')) {\n+ return 'answer'\n+ } else if (topic.startsWith('contract/')) {\n+ return 'contract'\n+ } else if (topic.startsWith('user/')) {\n+ return 'user' \n+ } else if (topic.startsWith('private-user/')) {\n+ return 'private-user'\n+ } else if (topic === 'global' || topic.startsWith('global/')) {\n+ return 'global'\n+ } else if (topic.startsWith('post/')) {\n+ return 'post'\n+ } else {\n+ return 'other'\n+ }\n+}\n+\n export class MessageParseError extends Error {\n details?: unknown\n constructor(message: string, details?: unknown) {\n super(message)\n@@ -113,9 +132,11 @@\n \n for (const topic of topics) {\n const msg = { type: 'broadcast', topic, data }\n sendToSubscribers(topic, msg)\n- metrics.inc('ws/broadcasts_sent', { topic })\n+ // Categorize topics to avoid unbounded cardinality\n+ const topicCategory = getTopicCategory(topic)\n+ metrics.inc('ws/broadcasts_sent', { category: topicCategory })\n }\n }\n \n export function broadcast(topic: string, data: BroadcastPayload) {\n"
1001
- }
1002
- ]
1003
- },
1004
- {
1005
- "id": "stabilize-metrics-recalc",
1006
- "sha": "2078049375d3ac1666a65748e21dbb500ba0cae2",
1007
- "parentSha": "bf0b74aba5f219de90924f5b0bd3f859454c9340",
1008
- "spec": "Implement a robust, race-aware recalculation of user contract metrics using bets and enable it from the recalc script.\n\nScope\n- backend/shared/src/update-user-metrics-with-bets.ts\n- backend/scripts/recalculate-user-contract-metrics.ts\n\nRequirements\n1) Bets-based recalculation flow\n- In update-user-metrics-with-bets.ts, implement an iterative batching process over all active users (or a provided subset). Process user IDs in chunks (e.g., size 100) until there are no more retries needed.\n- For each batch:\n - Record the current time as betLoadTime before loading any bets to establish a cutoff for concurrent changes.\n - Load the unresolved or recently resolved bets for only the users in the current batch, with a lower bound of either the provided since or one week ago.\n - Load the set of contracts referenced by those bets using getContractsDirect, and load current user_contract_metrics entries matching these users and contracts.\n - For each user in the batch, compute fresh per-contract/answer metrics using calculateMetricsByContractAndAnswer. Compare to the user’s current metrics and keep only those with significant changes (use hasSignificantDeepChanges with threshold 0.1) or new entries.\n- Race mitigation and write behavior:\n - Identify any users in this batch who placed bets after betLoadTime (query contract_bets by user_id in the batch with created_time >= betLoadTime). Exclude their metric updates from this write pass and collect their user IDs for retry.\n - Write only updates for users who did not place new bets since betLoadTime via bulkUpdateContractMetrics.\n- After completing all batches for the current pass, set the next userIdsToProcess to the unique set of users that were deferred due to new bets. Repeat the batching pass until no users remain to retry.\n- Log key steps: counts of users processed, bets loaded, contracts loaded, existing metrics loaded, number of computed updates, number of updates written, and number of users deferred for retry.\n\n2) Script entry point toggle\n- In backend/scripts/recalculate-user-contract-metrics.ts, remove any gating flag that blocks the USING_BETS path. Specifically, drop FIXED_DEPRECATION_WARNING and ensure the script calls the bets-based recalculation path when USING_BETS is true.\n- Keep all existing optional migration toggles intact. The change is solely to make the USING_BETS conditional take effect without requiring any extra flag.\n\nBehavioral outcomes\n- Running the recalc script with USING_BETS=true should:\n - Process users in chunks, compute fresh metrics from unresolved/recently resolved bets, and write only stable updates (excluding users who bet mid-run), with deferred users retried automatically until no users remain in the retry queue.\n - Avoid writing stale metrics in the presence of concurrent bet activity.\n- No changes to public API; changes are internal to batch scripts and shared metrics updater.\n\nNon-goals\n- Do not alter calculateMetricsByContractAndAnswer logic.\n- Do not change the schema or tables.\n- Do not add new endpoints.\n",
1009
- "prompt": "We need to harden and enable our bets-driven recalculation of user contract metrics. Update the shared updater so it processes users in chunks, detects users who place new bets during the run, excludes their updates from the current pass, and retries those users afterward until stable. Load contracts directly and compute fresh metrics per user, writing only significant changes. Then, update the recalc script so the bets-based path runs when enabled without any extra gating flag. Keep logging informative and avoid altering existing calculation logic or schemas.",
1010
- "supplementalFiles": [
1011
- "backend/shared/src/supabase/contracts.ts",
1012
- "backend/shared/src/helpers/user-contract-metrics.ts",
1013
- "common/src/calculate-metrics.ts",
1014
- "common/src/contract-metric.ts",
1015
- "common/src/supabase/bets.ts",
1016
- "backend/shared/src/update-user-metric-periods.ts"
1017
- ],
1018
- "fileDiffs": [
1019
- {
1020
- "path": "backend/scripts/recalculate-user-contract-metrics.ts",
1021
- "status": "modified",
1022
- "diff": "Index: backend/scripts/recalculate-user-contract-metrics.ts\n===================================================================\n--- backend/scripts/recalculate-user-contract-metrics.ts\tbf0b74a (parent)\n+++ backend/scripts/recalculate-user-contract-metrics.ts\t2078049 (commit)\n@@ -11,9 +11,8 @@\n const FIX_PERIODS = false\n const UPDATE_PORTFOLIO_HISTORIES = false\n const MIGRATE_LOAN_DATA = false\n const USING_BETS = false\n-const FIXED_DEPRECATION_WARNING = false\n if (require.main === module) {\n runScript(async ({ pg }) => {\n if (MIGRATE_PROFIT_DATA) {\n await migrateProfitData(pg)\n@@ -39,9 +38,9 @@\n // `,\n // [startTime],\n // (row) => [row.id, row.created_time]\n // )\n- if (USING_BETS && FIXED_DEPRECATION_WARNING) {\n+ if (USING_BETS) {\n await recalculateUsingBets(allUserIds)\n return\n }\n \n"
1023
- },
1024
- {
1025
- "path": "backend/shared/src/update-user-metrics-with-bets.ts",
1026
- "status": "modified",
1027
- "diff": "Index: backend/shared/src/update-user-metrics-with-bets.ts\n===================================================================\n--- backend/shared/src/update-user-metrics-with-bets.ts\tbf0b74a (parent)\n+++ backend/shared/src/update-user-metrics-with-bets.ts\t2078049 (commit)\n@@ -3,23 +3,17 @@\n createSupabaseDirectClient,\n SupabaseDirectClient,\n } from 'shared/supabase/init'\n import { log } from 'shared/utils'\n-import { groupBy, sortBy, sumBy, uniq } from 'lodash'\n-import { Contract, CPMMMultiContract } from 'common/contract'\n+import { chunk, groupBy, sortBy, sumBy, uniq } from 'lodash'\n import { calculateMetricsByContractAndAnswer } from 'common/calculate-metrics'\n-import { bulkUpdateContractMetrics } from 'shared/helpers/user-contract-metrics'\n-import { buildArray } from 'common/util/array'\n import { hasSignificantDeepChanges } from 'common/util/object'\n-import { Bet } from 'common/bet'\n-import { getAnswersForContractsDirect } from 'shared/supabase/answers'\n import { convertBet } from 'common/supabase/bets'\n import { ContractMetric } from 'common/contract-metric'\n+import { bulkUpdateContractMetrics } from 'shared/helpers/user-contract-metrics'\n+import { buildArray } from 'common/util/array'\n+import { getContractsDirect } from './supabase/contracts'\n \n-/** @deprecated between the time the bets are loaded and the metrics are written,\n- * the user could place a sell bet that repays a loan, which would not be\n- * applied to the updated metrics when written. It should check for any sale bets\n- * and rerun the metrics calculation. **/\n export async function updateUserMetricsWithBets(\n userIds?: string[],\n since?: number\n ) {\n@@ -28,122 +22,129 @@\n const weekAgo = now - DAY_MS * 7\n const pg = createSupabaseDirectClient()\n \n log('Loading active users...')\n- const activeUserIds = userIds?.length\n+ const allActiveUserIds = userIds?.length\n ? userIds\n : await pg.map(\n `select distinct user_id from contract_bets`,\n [],\n (r) => r.user_id as string\n )\n \n- log(`Loaded ${activeUserIds.length} active users.`)\n+ log(`Loaded ${allActiveUserIds.length} active users.`)\n \n- log('Loading bets...')\n+ let userIdsToProcess = allActiveUserIds\n+ const allUsersToRetry: string[] = []\n \n- // We need to update metrics for contracts that resolved up through a week ago,\n- // so we can calculate the daily/weekly profit on them\n- const metricRelevantBets = await getUnresolvedOrRecentlyResolvedBets(\n- pg,\n- activeUserIds,\n- useSince ? since : weekAgo\n- )\n- log(\n- `Loaded ${sumBy(\n- Object.values(metricRelevantBets),\n- (bets) => bets.length\n- )} bets.`\n- )\n+ while (userIdsToProcess.length > 0) {\n+ const userBatches = chunk(userIdsToProcess, 100)\n+ for (const userBatch of userBatches) {\n+ const betLoadTime = Date.now()\n+ log('Loading bets...')\n \n- log('Loading contracts...')\n- const allBets = Object.values(metricRelevantBets).flat()\n- const contracts = await getRelevantContracts(pg, allBets)\n- log('Loading answers...')\n- const answersByContractId = await getAnswersForContractsDirect(\n- pg,\n- contracts.filter((c) => c.mechanism === 'cpmm-multi-1').map((c) => c.id)\n- )\n- log(`Loaded ${contracts.length} contracts and their answers.`)\n+ // We need to update metrics for contracts that resolved up through a week ago,\n+ // so we can calculate the daily/weekly profit on them\n+ const metricRelevantBets = await getUnresolvedOrRecentlyResolvedBets(\n+ pg,\n+ userBatch,\n+ useSince ? since : weekAgo\n+ )\n+ log(\n+ `Loaded ${sumBy(\n+ Object.values(metricRelevantBets),\n+ (bets) => bets.length\n+ )} bets for ${userBatch.length} users.`\n+ )\n \n- const contractsById = Object.fromEntries(contracts.map((c) => [c.id, c]))\n+ log('Loading contracts...')\n+ const allBets = Object.values(metricRelevantBets).flat()\n+ const betContractIds = uniq(allBets.map((b) => b.contractId))\n+ const [contracts, currentContractMetrics] = await Promise.all([\n+ getContractsDirect(betContractIds, pg),\n+ pg.map(\n+ `select data from user_contract_metrics\n+ where user_id in ($1:list)\n+ and contract_id in ($2:list)\n+ `,\n+ [userBatch, betContractIds],\n+ (r) => r.data as ContractMetric\n+ ),\n+ ])\n+ const contractsById = Object.fromEntries(contracts.map((c) => [c.id, c]))\n+ log(`Loaded ${contracts.length} contracts and their answers.`)\n+ log(`Loaded ${currentContractMetrics.length} current contract metrics.`)\n \n- for (const [contractId, answers] of Object.entries(answersByContractId)) {\n- // Denormalize answers onto the contract.\n- // eslint-disable-next-line no-extra-semi\n- ;(contractsById[contractId] as CPMMMultiContract).answers = answers\n- }\n+ const currentMetricsByUserId = groupBy(\n+ currentContractMetrics,\n+ (m) => m.userId\n+ )\n \n- log('Loading current contract metrics...')\n- const currentContractMetrics = await pg.map(\n- `select data from user_contract_metrics\n- where user_id in ($1:list)\n- and contract_id in ($2:list)\n- `,\n- [activeUserIds, contracts.map((c) => c.id)],\n- (r) => r.data as ContractMetric\n- )\n- log(`Loaded ${currentContractMetrics.length} current contract metrics.`)\n+ const contractMetricUpdates: ContractMetric[] = []\n \n- const currentMetricsByUserId = groupBy(\n- currentContractMetrics,\n- (m) => m.userId\n- )\n+ log('Computing metric updates...')\n+ for (const userId of userBatch) {\n+ const userMetricRelevantBets = metricRelevantBets[userId] ?? []\n+ const metricRelevantBetsByContract = groupBy(\n+ userMetricRelevantBets,\n+ (b) => b.contractId\n+ )\n+ const currentMetricsForUser = currentMetricsByUserId[userId] ?? []\n+ const freshMetrics = calculateMetricsByContractAndAnswer(\n+ metricRelevantBetsByContract,\n+ contractsById,\n+ userId,\n+ currentMetricsForUser\n+ )\n+ contractMetricUpdates.push(\n+ ...freshMetrics.filter((freshMetric) => {\n+ const currentMetric = currentMetricsForUser.find(\n+ (m) =>\n+ freshMetric.contractId === m.contractId &&\n+ freshMetric.answerId === m.answerId\n+ )\n+ if (!currentMetric) return true\n+ return hasSignificantDeepChanges(currentMetric, freshMetric, 0.1)\n+ })\n+ )\n+ }\n+ log(`Computed ${contractMetricUpdates.length} metric updates.`)\n+ const userIdsWithUpdates = uniq(\n+ contractMetricUpdates.map((m) => m.userId)\n+ )\n+ const justBetUserIds = await pg.map(\n+ `select distinct user_id from contract_bets where user_id in ($1:list) and created_time >= $2`,\n+ [userIdsWithUpdates, new Date(betLoadTime).toISOString()],\n+ (r) => r.user_id as string\n+ )\n \n- const contractMetricUpdates = []\n-\n- log('Computing metric updates...')\n- for (const userId of activeUserIds) {\n- const userMetricRelevantBets = metricRelevantBets[userId] ?? []\n- const metricRelevantBetsByContract = groupBy(\n- userMetricRelevantBets,\n- (b) => b.contractId\n- )\n- const currentMetricsForUser = currentMetricsByUserId[userId] ?? []\n- const freshMetrics = calculateMetricsByContractAndAnswer(\n- metricRelevantBetsByContract,\n- contractsById,\n- userId,\n- currentMetricsForUser\n- )\n- contractMetricUpdates.push(\n- ...freshMetrics.filter((freshMetric) => {\n- const currentMetric = currentMetricsForUser.find(\n- (m) =>\n- freshMetric.contractId === m.contractId &&\n- freshMetric.answerId === m.answerId\n+ if (justBetUserIds.length > 0) {\n+ log(\n+ `Found ${justBetUserIds.length} users with new bets. Retrying them later.`\n )\n- if (!currentMetric) return true\n- return hasSignificantDeepChanges(currentMetric, freshMetric, 0.1)\n- })\n- )\n- }\n- log(`Computed ${contractMetricUpdates.length} metric updates.`)\n+ allUsersToRetry.push(...justBetUserIds)\n+ }\n \n- log('Writing updates and inserts...')\n- await Promise.all(\n- buildArray(\n- contractMetricUpdates.length > 0 &&\n- bulkUpdateContractMetrics(contractMetricUpdates)\n- .catch((e) => log.error('Error upserting contract metrics', e))\n- .then(() => log('Finished updating contract metrics.'))\n- )\n- )\n+ const updatesToWrite = contractMetricUpdates.filter(\n+ (m) => !justBetUserIds.includes(m.userId)\n+ )\n \n- // await revalidateStaticProps('/leaderboards')\n-\n+ log(`Writing ${updatesToWrite.length} updates and inserts...`)\n+ await Promise.all(\n+ buildArray(\n+ updatesToWrite.length > 0 &&\n+ bulkUpdateContractMetrics(updatesToWrite)\n+ .catch((e) => log.error('Error upserting contract metrics', e))\n+ .then(() => log('Finished updating contract metrics.'))\n+ )\n+ )\n+ }\n+ userIdsToProcess = uniq(allUsersToRetry)\n+ allUsersToRetry.length = 0\n+ }\n log('Done.')\n }\n \n-const getRelevantContracts = async (pg: SupabaseDirectClient, bets: Bet[]) => {\n- const betContractIds = uniq(bets.map((b) => b.contractId))\n- return await pg.map(\n- `select data from contracts where id in ($1:list)`,\n- [betContractIds],\n- (r) => r.data as Contract\n- )\n-}\n-\n const getUnresolvedOrRecentlyResolvedBets = async (\n pg: SupabaseDirectClient,\n userIds: string[],\n since: number\n"
1028
- }
1029
- ]
1030
- },
1031
- {
1032
- "id": "add-comment-deletion",
1033
- "sha": "74afc2ed1385d862c4fc99a201f51978ab7e9d0e",
1034
- "parentSha": "7bc53425e344d772c1ad9b323b7748ed8f144669",
1035
- "spec": "Implement a comment deletion feature alongside existing hide functionality, with strict permissions and end-to-end behavior across types, API, backend logic, DB queries, and UI.\n\nRequirements\n1) Types\n- Extend the ContractComment type to include three new optional fields: deleted (boolean), deletedTime (number), deleterId (string).\n- File to modify: common/src/comment.ts\n\n2) API schema\n- Update the hide-comment API schema to accept an optional action parameter with allowed values 'hide' and 'delete', defaulting to 'hide'.\n- The endpoint continues to return { success: boolean }.\n- File to modify: common/src/api/schema.ts\n\n3) Backend endpoint behavior\n- Update the hide-comment handler to support both actions: 'hide' toggles hidden status, and 'delete' toggles deleted status.\n- Permission checks:\n - For action='delete': Only admins and moderators may delete; contract creators are not permitted.\n - For action='hide': Contract creators, admins, and moderators may hide/unhide.\n - Prevent hiding or deleting comments authored by admins or moderators.\n- Update tracking events:\n - For action='hide': track 'hide_comment' with { contractId, commentId, hidden: <new hidden state>, userId }.\n - For action='delete': track 'delete_comment' with { contractId, commentId, deleted: <new deleted state>, userId }.\n- Revalidate the relevant contract after the update.\n- Files to modify: backend/api/src/hide-comment.ts\n\n4) Database read filtering\n- Exclude deleted comments from all comment-fetching queries at the DB layer.\n- For SQL (pg) queries over contract_comments, add a filter equivalent to (cc.data->>'deleted' is null or cc.data->>'deleted' = 'false').\n- For Supabase client queries, add a filter equivalent to .not('data->>deleted', 'eq', 'true').\n- Apply these filters in the relevant fetching utilities used by both backend and web clients (including queries for top-level comments, replies, pinned comments, user comments, recent comments, etc.).\n- Files to modify:\n - backend/shared/src/supabase/contract-comments.ts (all relevant selects joining/reading contract_comments and user comments)\n - common/src/supabase/comments.ts (recent top-level + replies, pinned comments)\n - web/lib/supabase/comments.ts (thread, children, recent, by contract, afterTime, multi-contract queries)\n\n5) UI behavior\n- Rendering: Do not render deleted comments anywhere in the UI. Components displaying a single comment or parent/child threads should return nothing for deleted comments.\n- Files to modify: web/components/comments/comment.tsx (both FeedComment and ParentFeedComment early-return null when comment.deleted is true)\n\n6) UI controls\n- Add a Delete option to the comment action menu (visible to mods/admins; not visible or inactive if already deleted).\n- Use a trash icon and red styling to distinguish the delete action.\n- On selection, call the hide-comment API with action='delete' for the specific comment path; perform an optimistic update setting deleted: true and roll back on error; show success and error toasts accordingly.\n- File to modify: web/components/comments/comment-header.tsx\n\n7) Analytics\n- Ensure the backend hide-comment handler emits 'hide_comment' or 'delete_comment' events with the appropriate boolean property (hidden or deleted) and identifiers (contractId, commentId, userId).\n\n8) Documentation (internal summary)\n- Add a brief markdown overview documenting the feature intent, permission matrix, affected components, and DB filtering rationale.\n- File to add: comment-deletion-feature-summary.md\n\nConstraints and notes\n- Do not alter API routing; the path remains 'hide-comment' and is already registered in backend/api/src/routes.ts.\n- Preserve existing hide functionality, including UI placeholder behavior for hidden comments; deleted comments must be fully excluded from rendering and DB results as described.\n- Ensure permission enforcement prevents hiding or deleting comments authored by admins/mods.\n- Maintain optimistic UI updates with proper rollback on failure and user feedback toasts.",
1036
- "prompt": "Add a full-stack comment deletion capability (distinct from hide) for moderators and admins. Deleted comments should be entirely removed from rendering and filtered out by all comment queries, while the existing hide behavior remains unchanged for less severe moderation. Update the types and API schema to carry a delete action, extend the existing moderation endpoint to enforce permissions (delete: mods/admins only; hide: contract creator, mod, or admin), and ensure comments authored by admins/mods cannot be hidden or deleted. Provide a Delete option in the comment menu (mods/admins only) that performs an optimistic update and calls the existing comment moderation API with the delete action. Finally, ensure analytics track both hide and delete actions appropriately and include a short internal markdown summary describing the feature and permission matrix.",
1037
- "supplementalFiles": [
1038
- "backend/api/src/routes.ts",
1039
- "backend/api/src/helpers/endpoint.ts",
1040
- "web/hooks/use-comments.ts",
1041
- "web/components/comments/comment-thread.tsx",
1042
- "web/components/comments/comment-actions.tsx",
1043
- "web/lib/service/analytics.ts",
1044
- "backend/shared/src/analytics.ts",
1045
- "web/lib/supabase/super-ban-user.ts",
1046
- "docs/docs/api.md"
1047
- ],
1048
- "fileDiffs": [
1049
- {
1050
- "path": "backend/api/src/hide-comment.ts",
1051
- "status": "modified",
1052
- "diff": "Index: backend/api/src/hide-comment.ts\n===================================================================\n--- backend/api/src/hide-comment.ts\t7bc5342 (parent)\n+++ backend/api/src/hide-comment.ts\t74afc2e (commit)\n@@ -10,9 +10,9 @@\n import { APIError, type APIHandler } from './helpers/endpoint'\n import { trackPublicEvent } from 'shared/analytics'\n \n export const hideComment: APIHandler<'hide-comment'> = async (\n- { commentPath },\n+ { commentPath, action = 'hide' },\n auth\n ) => {\n // Comment path is of the form /[username]/[contractId]/comment/[commentId] because firebase\n const [, contractId, , commentId] = commentPath.split('/')\n@@ -35,38 +35,62 @@\n const contract = await getContract(pg, contractId)\n if (!contract) throw new APIError(404, 'Contract not found')\n \n const isContractCreator = contract.creatorId === auth.uid\n+ const isAdmin = isAdminId(auth.uid)\n+ const isMod = isModId(auth.uid)\n \n- if (!isAdminId(auth.uid) && !isContractCreator && !isModId(auth.uid)) {\n- throw new APIError(\n- 403,\n- 'Only the market creator or mod can hide/unhide comments'\n- )\n+ // For delete action, only admins and mods can delete (not contract creators)\n+ if (action === 'delete') {\n+ if (!isAdmin && !isMod) {\n+ throw new APIError(\n+ 403,\n+ 'Only admins and mods can delete comments'\n+ )\n+ }\n+ } else {\n+ // For hide action, contract creators, admins, and mods can hide\n+ if (!isAdmin && !isContractCreator && !isMod) {\n+ throw new APIError(\n+ 403,\n+ 'Only the market creator, admin, or mod can hide/unhide comments'\n+ )\n+ }\n }\n \n const comment = await getComment(pg, commentId)\n- const hide = !comment.hidden\n- if ((isAdminId(comment.userId) || isModId(comment.userId)) && hide) {\n- throw new APIError(403, 'You cannot hide comments from admins or mods')\n+ \n+ if ((isAdminId(comment.userId) || isModId(comment.userId)) && (action === 'delete' || !comment.hidden)) {\n+ throw new APIError(403, 'You cannot hide or delete comments from admins or mods')\n }\n \n- // update the comment\n- await updateData(pg, 'contract_comments', 'comment_id', {\n- comment_id: commentId,\n- hidden: hide,\n- hiddenTime: hide ? Date.now() : undefined,\n- hiderId: auth.uid,\n- })\n+ // Update the comment based on action\n+ if (action === 'delete') {\n+ const shouldDelete = !comment.deleted\n+ await updateData(pg, 'contract_comments', 'comment_id', {\n+ comment_id: commentId,\n+ deleted: shouldDelete,\n+ deletedTime: shouldDelete ? Date.now() : undefined,\n+ deleterId: auth.uid,\n+ })\n+ } else {\n+ const hide = !comment.hidden\n+ await updateData(pg, 'contract_comments', 'comment_id', {\n+ comment_id: commentId,\n+ hidden: hide,\n+ hiddenTime: hide ? Date.now() : undefined,\n+ hiderId: auth.uid,\n+ })\n+ }\n \n await revalidateContractStaticProps(contract)\n return {\n result: { success: true },\n continue: async () => {\n- await trackPublicEvent(auth.uid, 'hide_comment', {\n+ await trackPublicEvent(auth.uid, action === 'delete' ? 'delete_comment' : 'hide_comment', {\n contractId,\n commentId,\n- hidden: hide,\n+ [action === 'delete' ? 'deleted' : 'hidden']: action === 'delete' ? !comment.deleted : !comment.hidden,\n userId: auth.uid,\n })\n },\n }\n"
1053
- },
1054
- {
1055
- "path": "backend/shared/src/supabase/contract-comments.ts",
1056
- "status": "modified",
1057
- "diff": "Index: backend/shared/src/supabase/contract-comments.ts\n===================================================================\n--- backend/shared/src/supabase/contract-comments.ts\t7bc5342 (parent)\n+++ backend/shared/src/supabase/contract-comments.ts\t74afc2e (commit)\n@@ -75,9 +75,10 @@\n and ($3 is null or contract_id = $3)\n and ($4 is null or user_id = $4)\n and ($5 is null or cc.data->>'replyToCommentId' = $5) \n and ($6 is null or cc.comment_id = $6) \n- and ($7 is null or cc.created_time > $7) \n+ and ($7 is null or cc.created_time > $7)\n+ and (cc.data->>'deleted' is null or cc.data->>'deleted' = 'false') \n order by ${orderBy}\n limit $1\n offset $2\n `,\n@@ -118,8 +119,9 @@\n WHERE\n c.visibility = 'public'\n AND cc.user_id = $3 -- userId (must be present here)\n AND ($4 IS NULL OR cc.created_time > $4) -- afterTime\n+ AND (cc.data->>'deleted' IS NULL OR cc.data->>'deleted' = 'false')\n \n UNION ALL\n \n -- Old Post Comments\n"
1058
- },
1059
- {
1060
- "path": "comment-deletion-feature-summary.md",
1061
- "status": "added",
1062
- "diff": "Index: comment-deletion-feature-summary.md\n===================================================================\n--- comment-deletion-feature-summary.md\t7bc5342 (parent)\n+++ comment-deletion-feature-summary.md\t74afc2e (commit)\n@@ -1,1 +1,96 @@\n-[NEW FILE]\n\\n+# Comment Deletion Feature Implementation Summary\n+\n+## Overview\n+Added a new \"delete\" option to the comments dropdown menu that allows admins/mods to completely hide comments from rendering, rather than just showing \"comment hidden\" like the existing hide functionality.\n+\n+## Key Features\n+- **Admin/Mod Only**: Delete functionality is restricted to admins and moderators only (more restrictive than hide)\n+- **Complete Removal**: Deleted comments are completely filtered out and don't render at all\n+- **Preservation of Hide**: Existing hide functionality remains unchanged for less severe moderation\n+- **Protection**: Prevents deletion of admin/mod comments by other users\n+\n+## Technical Implementation\n+\n+### 1. Data Model Changes\n+**File**: `common/src/comment.ts`\n+- Added `deleted?: boolean` field to Comment type\n+- Added `deletedTime?: number` field for tracking deletion timestamp \n+- Added `deleterId?: string` field for tracking who deleted the comment\n+\n+### 2. API Schema Updates\n+**File**: `common/src/api/schema.ts`\n+- Modified `hide-comment` API to accept optional `action` parameter\n+- Action values: `'hide'` (default) or `'delete'`\n+\n+### 3. Backend API Logic\n+**File**: `backend/api/src/hide-comment.ts`\n+- **Delete Action**: Only admins/mods can delete (not contract creators)\n+- **Hide Action**: Contract creators, admins, and mods can hide\n+- Added protection against deleting admin/mod comments\n+- Proper tracking events for both actions\n+- Updates appropriate database fields based on action type\n+\n+### 4. Frontend Components\n+\n+#### Comment Rendering\n+**Files**: `web/components/comments/comment.tsx`\n+- `FeedComment` and `ParentFeedComment` return `null` for deleted comments\n+- `HideableContent` component skips rendering deleted comments entirely\n+\n+#### UI Controls\n+**File**: `web/components/comments/comment-header.tsx`\n+- Added delete option to dropdown menu (mods only)\n+- Uses TrashIcon with red color styling\n+- Calls hide-comment API with `action='delete'`\n+- Includes optimistic updates and error handling\n+\n+### 5. Database Query Updates\n+**Files**: Multiple comment fetching functions updated\n+- `backend/shared/src/supabase/contract-comments.ts`\n+- `common/src/supabase/comments.ts`\n+- `web/lib/supabase/comments.ts`\n+\n+**Query Modifications**: All comment queries now filter out deleted comments using conditions like:\n+```sql\n+.not('data->>deleted', 'eq', 'true')\n+-- or\n+(cc.data->>'deleted' is null or cc.data->>'deleted' = 'false')\n+```\n+\n+**Affected Functions**:\n+- `getCommentsDirect`\n+- `getPostAndContractComments` \n+- `getRecentTopLevelCommentsAndReplies`\n+- `getPinnedComments`\n+- `getCommentThread`\n+- `getAllCommentRows`\n+- `getCommentRows`\n+- `getNewCommentRows`\n+- `getRecentCommentsOnContracts`\n+\n+## Permission Matrix\n+\n+| Action | Contract Creator | Admin | Mod |\n+|--------|-----------------|-------|-----|\n+| Hide Comment | ✅ | ✅ | ✅ |\n+| Delete Comment | ❌ | ✅ | ✅ |\n+| Delete Admin/Mod Comment | ❌ | ❌ | ❌ |\n+\n+## User Experience\n+- **Seamless Removal**: Deleted comments completely disappear from view\n+- **Optimistic Updates**: UI responds immediately with rollback on API failure\n+- **Clear Distinction**: Different visual treatment (TrashIcon) for delete vs hide actions\n+- **No Placeholder**: Unlike hidden comments, deleted comments show no trace\n+\n+## Data Integrity\n+- Maintains audit trail with `deletedTime` and `deleterId` fields\n+- Comments remain in database for potential recovery/audit purposes\n+- Proper tracking events ensure moderation actions are logged\n+- Database-level filtering prevents accidental exposure of deleted content\n+\n+## Benefits\n+1. **Stronger Moderation**: More severe action than hiding for problematic content\n+2. **Clean User Experience**: Complete removal vs. \"comment hidden\" placeholder\n+3. **Granular Control**: Different permission levels for hide vs delete\n+4. **Audit Trail**: Full tracking of deletion actions for accountability\n+5. **Performance**: Database-level filtering reduces unnecessary data processing\n\\n"
1063
- },
1064
- {
1065
- "path": "common/src/api/schema.ts",
1066
- "status": "modified",
1067
- "diff": "Index: common/src/api/schema.ts\n===================================================================\n--- common/src/api/schema.ts\t7bc5342 (parent)\n+++ common/src/api/schema.ts\t74afc2e (commit)\n@@ -183,9 +183,12 @@\n method: 'POST',\n visibility: 'public',\n authed: true,\n returns: {} as { success: boolean },\n- props: z.object({ commentPath: z.string() }).strict(),\n+ props: z.object({ \n+ commentPath: z.string(),\n+ action: z.enum(['hide', 'delete']).optional().default('hide')\n+ }).strict(),\n },\n 'pin-comment': {\n method: 'POST',\n visibility: 'undocumented',\n"
1068
- },
1069
- {
1070
- "path": "common/src/comment.ts",
1071
- "status": "modified",
1072
- "diff": "Index: common/src/comment.ts\n===================================================================\n--- common/src/comment.ts\t7bc5342 (parent)\n+++ common/src/comment.ts\t74afc2e (commit)\n@@ -29,8 +29,11 @@\n hiderId?: string\n pinned?: boolean\n pinnedTime?: number\n pinnerId?: string\n+ deleted?: boolean\n+ deletedTime?: number\n+ deleterId?: string\n visibility: Visibility\n editedTime?: number\n isApi?: boolean\n } & T\n"
1073
- },
1074
- {
1075
- "path": "common/src/supabase/comments.ts",
1076
- "status": "modified",
1077
- "diff": "Index: common/src/supabase/comments.ts\n===================================================================\n--- common/src/supabase/comments.ts\t7bc5342 (parent)\n+++ common/src/supabase/comments.ts\t74afc2e (commit)\n@@ -11,8 +11,9 @@\n .from('contract_comments')\n .select('data')\n .eq('contract_id', contractId)\n .is('data->>replyToCommentId', null)\n+ .not('data->>deleted', 'eq', 'true')\n .order('created_time', { ascending: false } as any)\n .limit(approximateTotalComments)\n )\n const { data: children } = await run(\n@@ -20,8 +21,9 @@\n .from('contract_comments')\n .select('data')\n .eq('contract_id', contractId)\n .not('data->>replyToCommentId', 'is', null)\n+ .not('data->>deleted', 'eq', 'true')\n .in(\n 'data->>replyToCommentId',\n parents.map((p) => (p.data as ContractComment).id)\n )\n@@ -59,8 +61,9 @@\n .from('contract_comments')\n .select('data')\n .eq('contract_id', contractId)\n .eq('data->>pinned', true)\n+ .not('data->>deleted', 'eq', 'true')\n .order('created_time', { ascending: false })\n )\n \n const pinnedComments = data.map((c) => c.data as ContractComment)\n"
1078
- },
1079
- {
1080
- "path": "web/components/comments/comment-header.tsx",
1081
- "status": "modified",
1082
- "diff": "Index: web/components/comments/comment-header.tsx\n===================================================================\n--- web/components/comments/comment-header.tsx\t7bc5342 (parent)\n+++ web/components/comments/comment-header.tsx\t74afc2e (commit)\n@@ -4,8 +4,9 @@\n FlagIcon,\n LinkIcon,\n PencilIcon,\n PlusCircleIcon,\n+ TrashIcon,\n XCircleIcon,\n } from '@heroicons/react/solid'\n import { ThumbDownIcon } from '@heroicons/react/outline'\n import clsx from 'clsx'\n@@ -570,8 +571,27 @@\n // undo optimistic update\n updateComment({ pinned: wasPinned })\n }\n },\n+ },\n+ isMod && !comment.deleted && {\n+ name: 'Delete',\n+ icon: <TrashIcon className=\"h-5 w-5 text-red-600\" />,\n+ onClick: async () => {\n+ const commentPath = `contracts/${playContract.id}/comments/${comment.id}`\n+ const wasDeleted = comment.deleted\n+ updateComment({ deleted: true })\n+\n+ try {\n+ await api('hide-comment', { commentPath, action: 'delete' })\n+ toast.success('Comment deleted')\n+ } catch (e) {\n+ toast.error('Error deleting comment')\n+ console.error(e)\n+ // undo optimistic update\n+ updateComment({ deleted: wasDeleted })\n+ }\n+ },\n }\n )}\n />\n {annotating && (\n"
1083
- },
1084
- {
1085
- "path": "web/components/comments/comment.tsx",
1086
- "status": "modified",
1087
- "diff": "Index: web/components/comments/comment.tsx\n===================================================================\n--- web/components/comments/comment.tsx\t7bc5342 (parent)\n+++ web/components/comments/comment.tsx\t74afc2e (commit)\n@@ -59,8 +59,13 @@\n // for optimistic updates\n const [comment, updateComment] = usePartialUpdater(props.comment)\n useEffect(() => updateComment(props.comment), [props.comment])\n \n+ // Don't render deleted comments at all\n+ if (comment.deleted) {\n+ return null\n+ }\n+\n const groupedBets = useMemo(() => {\n // Sort the bets by createdTime\n const sortedBets = orderBy(bets, 'createdTime', 'asc')\n \n@@ -274,8 +279,14 @@\n childrenBountyTotal,\n bets,\n isPinned,\n } = props\n+ \n+ // Don't render deleted comments at all\n+ if (comment.deleted) {\n+ return null\n+ }\n+\n const { ref } = useIsVisible(\n () =>\n track('view comment thread', {\n contractId: playContract.id,\n@@ -321,8 +332,9 @@\n \n function HideableContent(props: { comment: ContractComment }) {\n const { comment } = props\n const { text, content } = comment\n+ \n //hides if enough dislikes\n const dislikes = comment.dislikes ?? 0\n const likes = comment.likes ?? 0\n const majorityDislikes = dislikes > 10 && dislikes / (likes + dislikes) >= 0.8\n"
1088
- },
1089
- {
1090
- "path": "web/lib/supabase/comments.ts",
1091
- "status": "modified",
1092
- "diff": "Index: web/lib/supabase/comments.ts\n===================================================================\n--- web/lib/supabase/comments.ts\t7bc5342 (parent)\n+++ web/lib/supabase/comments.ts\t74afc2e (commit)\n@@ -31,8 +31,9 @@\n .from('contract_comments')\n .select()\n .eq('contract_id', comment.contractId)\n .not('data->>replyToCommentId', 'is', null)\n+ .not('data->>deleted', 'eq', 'true')\n .in('data->>replyToCommentId', [comment.replyToCommentId])\n .order('created_time', { ascending: true })\n ),\n ])\n@@ -46,8 +47,9 @@\n .from('contract_comments')\n .select()\n .eq('contract_id', comment.contractId)\n .not('data->>replyToCommentId', 'is', null)\n+ .not('data->>deleted', 'eq', 'true')\n .in('data->>replyToCommentId', [commentId])\n .order('created_time', { ascending: true })\n )\n console.log('comment', comment)\n@@ -59,8 +61,9 @@\n const { data } = await run(\n db\n .from('contract_comments')\n .select('*')\n+ .not('data->>deleted', 'eq', 'true')\n .order('created_time', {\n ascending: false,\n })\n .limit(limit)\n@@ -73,8 +76,9 @@\n db\n .from('contract_comments')\n .select()\n .eq('contract_id', contractId)\n+ .not('data->>deleted', 'eq', 'true')\n .order('created_time', { ascending: false })\n )\n return data\n }\n@@ -88,8 +92,9 @@\n .from('contract_comments')\n .select()\n .eq('contract_id', contractId)\n .gt('created_time', afterTime)\n+ .not('data->>deleted', 'eq', 'true')\n .order('created_time', { ascending: false })\n \n if (userId) q = q.eq('user_id', userId)\n \n@@ -108,8 +113,9 @@\n db\n .from('contract_comments')\n .select()\n .in('contract_id', ids)\n+ .not('data->>deleted', 'eq', 'true')\n .limit(limit)\n .order('created_time', { ascending: false })\n )\n return data\n"
1093
- }
1094
- ]
1095
- },
1096
- {
1097
- "id": "add-min-bet-filter",
1098
- "sha": "0ce35f5f3c2cdbeac1f48d94eb37abfbad0730f5",
1099
- "parentSha": "1413c780ef6b191a7e282ea91aa58f7591dc0d6c",
1100
- "spec": "Implement a minimum bet amount filter for contract bets and switch the Trades tab to infinite loading.\n\nBackend API and schema\n1) Update common/src/api/schema.ts\n- In API['bets'].props, add an optional minAmount: coerce to number, positive, optional. This ensures api('bets', { minAmount }) is type-safe and validated.\n- Do not alter bet-points props.\n\n2) Update backend/api/src/get-bets.ts\n- Read minAmount from the validated props of getBetsInternal.\n- Add minAmount to the opts object passed to getBetsWithFilter so it propagates to the shared query layer.\n\n3) Update backend/shared/src/supabase/bets.ts\n- Extend getBetsWithFilter to accept minAmount via APIParams<'bets'>.\n- If minAmount is provided, add a WHERE clause to filter by absolute bet amount: abs((contract_bets.data->>'amount')::numeric) >= ${minAmount} using the existing SQL builder's where() with a bound parameter.\n- Keep the existing conditions intact (filterRedemptions, includeZeroShareRedemptions, etc.).\n\nFrontend Trades tab UI and loading\n4) Update web/components/contract/contract-tabs.tsx (BetsTabContent component)\n- Replace the paginated loading (page state, ITEMS_PER_PAGE slicing, Pagination component) with infinite loading using LoadMoreUntilNotVisible from web/components/widgets/visibility-observer.\n- Add a minimum bet amount dropdown control above the list:\n - Use DropdownMenu and generateFilterDropdownItems.\n - Options: Any amount (undefined), M$100+ (100), M$1,000+ (1000), M$10,000+ (10000).\n - Persist the selected option index using usePersistentInMemoryState keyed by `bet-amount-filter-${contract.id}`; default to the first option.\n- Apply the filter:\n - For the initial props.bets array, filter client-side by Math.abs(bet.amount) >= selectedMinAmount when selectedMinAmount is defined.\n - Preserve prior grouping/ordering (NUMBER contracts remain grouped by betGroupId; other contracts list single bets).\n- Infinite loading:\n - Implement a loadMore function that:\n - Exits early if nothing more to load; otherwise calls api('bets', { contractId, beforeTime: oldestLoadedBetTime, limit: 50, filterRedemptions: !isNumber, includeZeroShareRedemptions: isNumber, minAmount: selectedMinAmount }).\n - Appends unique results to olderBets and returns true if any new bets were added; false otherwise.\n - Reset olderBets and trigger an initial load when the min-amount selection changes.\n - Render <LoadMoreUntilNotVisible loadMore={loadMore} /> at the end of the list.\n- Skeleton loading rows:\n - Show skeleton rows only if more items are expected AND the filter is set to Any amount (i.e., minAmount is undefined).\n - Limit the number of skeleton rows to at most 10.\n- Remove the Pagination import and component usage; import and use LoadMoreUntilNotVisible.\n- Track selection changes with an analytics event (e.g., 'change-bet-amount-filter') including contract slug/name and the selected minimum amount.\n\nImports/cleanup\n5) Adjust imports in contract-tabs.tsx:\n- Remove Pagination import.\n- Import { VisibilityObserver, LoadMoreUntilNotVisible } from widgets/visibility-observer.\n- Ensure ChevronDownIcon, DropdownMenu, and generateFilterDropdownItems are imported for the dropdown.\n\nBehavioral expectations\n- Calling GET /bets with minAmount should return only bets whose absolute amount meets/exceeds the threshold, without affecting existing filters or ordering.\n- The Trades tab should show a Min amount dropdown. Changing the filter immediately re-queries older results with the server-side filter and filters the initial batch client-side.\n- The list loads more automatically until not visible, without a manual pagination control.\n- NUMBER contracts continue to group and order bet groups as before.",
1101
- "prompt": "Add a minimum bet amount filter to the bets endpoint and surface it on the contract Trades tab with infinite loading.\n\nGoals:\n- Extend the GET /bets API to accept an optional minAmount parameter that filters by the absolute value of the bet amount.\n- Wire this through the backend query so only bets with |amount| >= minAmount are returned when provided.\n- On the Trades tab in a contract page, add a Min amount dropdown with a few thresholds, persist the selection, and filter the initially provided bets client-side while passing minAmount to subsequent server fetches.\n- Replace the existing paginated list with infinite loading that autoloads more until the sentinel is no longer visible.\n\nNotes:\n- Preserve existing filtering and grouping behavior for different contract types, including multi-numeric bet grouping.\n- Hide loading skeleton rows when a min-amount filter is active; otherwise show a small number of skeleton rows when more data is expected.\n- Keep the code style and existing patterns (DropdownMenu, generateFilterDropdownItems, usePersistentInMemoryState, LoadMoreUntilNotVisible).",
1102
- "supplementalFiles": [
1103
- "web/components/widgets/visibility-observer.tsx",
1104
- "web/components/widgets/dropdown-menu.tsx",
1105
- "web/components/search/search-dropdown-helpers.tsx",
1106
- "backend/api/src/routes.ts",
1107
- "web/lib/api/api.ts"
1108
- ],
1109
- "fileDiffs": [
1110
- {
1111
- "path": "backend/api/src/get-bets.ts",
1112
- "status": "modified",
1113
- "diff": "Index: backend/api/src/get-bets.ts\n===================================================================\n--- backend/api/src/get-bets.ts\t1413c78 (parent)\n+++ backend/api/src/get-bets.ts\t0ce35f5 (commit)\n@@ -21,8 +21,9 @@\n beforeTime,\n afterTime,\n order,\n kinds,\n+ minAmount,\n filterRedemptions,\n includeZeroShareRedemptions,\n commentRepliesOnly,\n count,\n@@ -80,8 +81,9 @@\n : afterTime ?? afterBetTime,\n limit,\n order,\n kinds,\n+ minAmount,\n filterRedemptions,\n includeZeroShareRedemptions,\n count,\n points,\n"
1114
- },
1115
- {
1116
- "path": "backend/shared/src/supabase/bets.ts",
1117
- "status": "modified",
1118
- "diff": "Index: backend/shared/src/supabase/bets.ts\n===================================================================\n--- backend/shared/src/supabase/bets.ts\t1413c78 (parent)\n+++ backend/shared/src/supabase/bets.ts\t0ce35f5 (commit)\n@@ -48,8 +48,9 @@\n limit: limitValue,\n kinds,\n count,\n points,\n+ minAmount,\n } = options\n \n const conditions = buildArray(\n !contractId && [\n@@ -86,9 +87,14 @@\n \n filterRedemptions && where('is_redemption = false'),\n \n !includeZeroShareRedemptions &&\n- where(`(shares != 0 or is_redemption = false or loan_amount != 0)`)\n+ where(`(shares != 0 or is_redemption = false or loan_amount != 0)`),\n+\n+ minAmount !== undefined &&\n+ where(`abs((contract_bets.data->>'amount')::numeric) >= ${minAmount}`, {\n+ minAmount,\n+ })\n )\n const selection = count\n ? select('count(contract_bets.*)')\n : points\n"
1119
- },
1120
- {
1121
- "path": "common/src/api/schema.ts",
1122
- "status": "modified",
1123
- "diff": "Index: common/src/api/schema.ts\n===================================================================\n--- common/src/api/schema.ts\t1413c78 (parent)\n+++ common/src/api/schema.ts\t0ce35f5 (commit)\n@@ -411,8 +411,9 @@\n beforeTime: z.coerce.number().optional(),\n afterTime: z.coerce.number().optional(),\n order: z.enum(['asc', 'desc']).optional(),\n kinds: z.enum(['open-limit']).optional(),\n+ minAmount: z.coerce.number().positive().optional(),\n // undocumented fields. idk what a good api interface would be\n filterRedemptions: coerceBoolean.optional(),\n includeZeroShareRedemptions: coerceBoolean.optional(),\n commentRepliesOnly: coerceBoolean.optional(),\n"
1124
- },
1125
- {
1126
- "path": "web/components/contract/contract-tabs.tsx",
1127
- "status": "modified",
1128
- "diff": "Index: web/components/contract/contract-tabs.tsx\n===================================================================\n--- web/components/contract/contract-tabs.tsx\t1413c78 (parent)\n+++ web/components/contract/contract-tabs.tsx\t0ce35f5 (commit)\n@@ -29,11 +29,13 @@\n import { shortFormatNumber, maybePluralize } from 'common/util/format'\n import { MINUTE_MS } from 'common/util/time'\n import { UserPositionsTable } from 'web/components/contract/user-positions-table'\n import { LoadingIndicator } from 'web/components/widgets/loading-indicator'\n-import { Pagination } from 'web/components/widgets/pagination'\n import { Tooltip } from 'web/components/widgets/tooltip'\n-import { VisibilityObserver } from 'web/components/widgets/visibility-observer'\n+import {\n+ VisibilityObserver,\n+ LoadMoreUntilNotVisible,\n+} from 'web/components/widgets/visibility-observer'\n import { useEvent } from 'client-common/hooks/use-event'\n import { useLiquidity } from 'web/hooks/use-liquidity'\n import { useUser } from 'web/hooks/use-user'\n import { track } from 'web/lib/service/analytics'\n@@ -549,17 +551,30 @@\n const { contract, setReplyToBet, totalBets } = props\n const { outcomeType } = contract\n const [olderBets, setOlderBets] = useState<Bet[]>([])\n \n- const [page, setPage] = useState(0)\n+ const [minAmountFilterIndex, setMinAmountFilterIndex] =\n+ usePersistentInMemoryState(0, `bet-amount-filter-${contract.id}`)\n const isNumber = outcomeType === 'NUMBER'\n- const ITEMS_PER_PAGE = 50 * (isNumber ? contract.answers.length : 1)\n- const bets = [...props.bets, ...olderBets]\n+\n+ // Min amount filter options\n+ const minAmountOptions = [\n+ { label: 'Any amount', value: undefined },\n+ { label: 'M$100+', value: 100 },\n+ { label: 'M$1,000+', value: 1000 },\n+ { label: 'M$10,000+', value: 10000 },\n+ ]\n+ const selectedMinAmount = minAmountOptions[minAmountFilterIndex].value\n+\n+ // Filter initial bets on client side, server will filter olderBets\n+ const filteredInitialBets = selectedMinAmount\n+ ? props.bets.filter((bet) => Math.abs(bet.amount) >= selectedMinAmount)\n+ : props.bets\n+\n+ const bets = [...filteredInitialBets, ...olderBets]\n listenToOrderUpdates(contract.id, setOlderBets, true)\n \n const oldestBet = minBy(bets, (b) => b.createdTime)\n- const start = page * ITEMS_PER_PAGE\n- const end = start + ITEMS_PER_PAGE\n \n const lps = useLiquidity(contract.id) ?? []\n const visibleLps = lps.filter(\n (l) =>\n@@ -594,52 +609,100 @@\n \n const totalItems = totalBets + visibleLps.length\n const totalLoadedItems = bets.length + visibleLps.length\n \n- const limit = (items.length - (page + 1) * ITEMS_PER_PAGE) * -1\n- const shouldLoadMore = limit > 0 && totalLoadedItems < totalItems\n+ const shouldLoadMore = totalLoadedItems < totalItems\n const [now] = useState(Date.now())\n const oldestBetTime = oldestBet?.createdTime ?? now\n- useEffect(() => {\n- if (!shouldLoadMore) return\n- api('bets', {\n- contractId: contract.id,\n- beforeTime: oldestBetTime,\n- limit,\n- filterRedemptions: !isNumber,\n- includeZeroShareRedemptions: isNumber,\n- })\n- .then((olderBets) => {\n- setOlderBets((bets) => uniqBy([...bets, ...olderBets], (b) => b.id))\n+\n+ const loadMore = useEvent(async () => {\n+ if (!shouldLoadMore) return false\n+\n+ try {\n+ const newBets = await api('bets', {\n+ contractId: contract.id,\n+ beforeTime: oldestBetTime,\n+ limit: 50,\n+ filterRedemptions: !isNumber,\n+ includeZeroShareRedemptions: isNumber,\n+ minAmount: selectedMinAmount,\n })\n- .catch((err) => {\n- console.error(err)\n- })\n- }, [contract.id, limit, oldestBetTime, shouldLoadMore])\n \n- const pageItems = sortBy(items, (item) =>\n+ if (newBets.length > 0) {\n+ setOlderBets((bets) => uniqBy([...bets, ...newBets], (b) => b.id))\n+ return true\n+ }\n+ return false\n+ } catch (err) {\n+ console.error(err)\n+ return false\n+ }\n+ })\n+ useEffect(() => {\n+ setOlderBets([])\n+ loadMore()\n+ }, [selectedMinAmount])\n+\n+ const allItems = sortBy(items, (item) =>\n item.type === 'bet'\n ? -item.bet.createdTime\n : item.type === 'liquidity'\n ? -item.lp.createdTime\n : item.type === 'betGroup'\n ? -item.bets[0].createdTime\n : undefined\n- ).slice(start, end)\n+ )\n \n const scrollRef = useRef<HTMLDivElement>(null)\n const isCashContract = contract.token === 'CASH'\n \n- // Determine how many loading rows to show (up to ITEMS_PER_PAGE)\n+ // Determine how many loading rows to show\n const numLoadingRows = shouldLoadMore\n- ? Math.min(ITEMS_PER_PAGE, Math.max(0, totalBets - pageItems.length))\n+ ? Math.min(10, Math.max(0, totalBets - allItems.length))\n : 0\n \n return (\n <>\n <div ref={scrollRef} />\n+\n+ {/* Minimum bet amount filter */}\n+ <Row className=\"mb-4 justify-end\">\n+ <Row className=\"items-center gap-1\">\n+ <span className=\"text-ink-400 text-sm\">Min amount:</span>\n+ <DropdownMenu\n+ items={generateFilterDropdownItems(\n+ minAmountOptions.map((option, i) => ({\n+ label: option.label,\n+ value: i.toString(),\n+ })),\n+ (value: string) => {\n+ const newIndex = parseInt(value)\n+ setMinAmountFilterIndex(newIndex)\n+ setOlderBets([]) // Clear older bets to refetch with new filter\n+ track('change-bet-amount-filter', {\n+ contractSlug: contract.slug,\n+ contractName: contract.question,\n+ minAmount: minAmountOptions[newIndex].value,\n+ })\n+ }\n+ )}\n+ buttonContent={\n+ <Row className=\"text-ink-600 w-28 items-center text-sm\">\n+ <span className=\"whitespace-nowrap\">\n+ {minAmountOptions[minAmountFilterIndex].label}\n+ </span>\n+ <ChevronDownIcon className=\"h-4 w-4\" />\n+ </Row>\n+ }\n+ menuWidth={'w-36'}\n+ selectedItemName={minAmountOptions[minAmountFilterIndex].label}\n+ closeOnClick\n+ />\n+ </Row>\n+ </Row>\n+\n <Col className=\"mb-4 items-start gap-7\">\n- {pageItems.map((item) =>\n+ {allItems.map((item) =>\n item.type === 'bet' ? (\n <FeedBet\n onReply={setReplyToBet}\n key={item.id}\n@@ -665,23 +728,15 @@\n )\n )}\n {/* Render skeleton loading rows */}\n {shouldLoadMore &&\n+ !minAmountFilterIndex &&\n Array(numLoadingRows)\n .fill(0)\n .map((_, i) => <LoadingBetRow key={`loading-${i}`} />)}\n </Col>\n- <Pagination\n- page={page}\n- pageSize={ITEMS_PER_PAGE}\n- totalItems={totalItems}\n- setPage={(page) => {\n- setPage(page)\n- scrollRef.current?.scrollIntoView({\n- block: 'center',\n- })\n- }}\n- />\n+\n+ <LoadMoreUntilNotVisible loadMore={loadMore} />\n </>\n )\n })\n \n"
1129
- }
1130
- ]
1131
- },
1132
- {
1133
- "id": "add-post-boosts",
1134
- "sha": "d414e8e3dbe6dcc4466b20cedfb0ff19af01efa8",
1135
- "parentSha": "21d26b6620450a4f2cca836de07ba6e1e8c2b296",
1136
- "spec": "Implement a unified boosting feature that supports both contracts and posts, with purchase via mana or Stripe, and visible prominence on the homepage for 24 hours.\n\nAPI and schema:\n- Add a new API endpoint 'purchase-boost' in common/src/api/schema.ts that:\n - Accepts exactly one of contractId or postId (both optional individually but required exclusively), a startTime (ms), and method in ['mana', 'cash', 'admin-free'].\n - Returns { success: boolean; checkoutUrl?: string }.\n - Enforce via zod refine that exactly one of contractId or postId is provided.\n- Implement backend/api/src/purchase-boost.ts to handle boost purchases for contracts or posts:\n - Validate visibility and target existence using getContract or getPost.\n - Enforce a max number of concurrent funded boosts per time period and prevent duplicate boosts for the same target when millis_to_ts(startTime) is between start_time and end_time.\n - For method 'cash':\n - Insert a contract_boosts row with funded=false covering [startTime, startTime + 24h].\n - Create a Stripe checkout session with price id depending on env (DEV_BOOST_STRIPE_PRICE_ID/PROD_BOOST_STRIPE_PRICE_ID), success/cancel URLs to the content URL, and metadata: userId, boostId, contractId or postId.\n - Return checkoutUrl and track an analytics event for boost initiated (cash).\n - For method 'mana' or 'admin-free':\n - Insert a contract_boosts row with funded=true.\n - If not admin-free, run a mana transfer using runTxnInBetQueue of BOOST_COST_MANA from the user to the house (prod or dev constant provider id).\n - Return success and track boost purchased (mana). If the start time is in the past, immediately apply the boost effect.\n - Implement immediate boost for contracts via boostContractImmediately, and for posts via a new boostPostImmediately function that flags the post as boosted and ensures a high minimum importance score (set boosted=true, importance_score >= 0.9 on old_posts).\n- Update backend/api/src/routes.ts to register 'purchase-boost' and remove/migrate 'purchase-contract-boost'.\n- Update backend/api/src/stripe-endpoints.ts to support post boosts in checkout.session.completed webhook:\n - Handle metadata with boostId and either contractId or postId.\n - Mark the corresponding contract_boosts row funded and, if its start_time is now/past, immediately boost the contract or post.\n - Track an analytics event for boost purchased (cash) with contractId or postId.\n\nDatabase and shared types:\n- Modify backend/supabase/contract_boosts.sql to:\n - Allow nullable contract_id and add post_id referencing old_posts(id).\n - Add a constraint that exactly one of contract_id or post_id is non-null.\n - Keep/ensure funded boolean exists (default true) and created_time default now().\n - Add an index on (post_id, start_time, end_time) in addition to the contracts index.\n- Modify backend/supabase/old_posts.sql to add boosted boolean default false; keep importance_score column.\n- Update common/src/supabase/schema.ts types for contract_boosts to reflect contract_id and post_id possibly null, and include created_time and funded in Row/Insert/Update.\n- Update common/src/txn.ts ContractBoostPurchaseTxn data type so data can include either contractId or postId (both optional but one present).\n- Ensure TopLevelPost includes boosted: boolean in common/src/top-level-post.ts.\n- In backend/shared/src/supabase/posts.ts, include boosted in SELECT for getPost so boosted is passed through convertPost.\n\nImportance scoring:\n- Update backend/shared/src/importance-score.ts:\n - For existing contract boosts, only include boosts where contract_id is not null.\n - Add logic to fetch active post boosts (post_id is not null) and, in post scoring, add a boostScore component; ensure boosted posts have at least 0.9 importance_score while boosted. Only update rows when the score changes beyond a small epsilon.\n\nFrontend:\n- Update web/components/contract/add-boost-button.tsx to call 'purchase-boost' instead of 'purchase-contract-boost'.\n- Add web/components/posts/add-post-boost-button.tsx to present a Boost button and purchase modal for posts:\n - Show Boosted/Boost state based on post.boosted.\n - Allow selecting a start date (default now; clamp to today or future), and purchasing via mana or cash. Redirect to checkoutUrl if provided.\n - Offer an admin-only free boost action using method 'admin-free'. Handle insufficient funds by showing AddFundsModal.\n- Show a boosted indicator on post rows by importing and using BoostedTooltip; adjust layout widths to accommodate the icon.\n- On the post page (web/pages/post/[slug]/index.tsx), render AddPostBoostButton for public posts.\n\nDocs:\n- Update backend/api/knowledge.md to document that boosts apply to contracts and posts, last 24 hours, appear prominently, and can be paid with mana or cash; document API accepting either contractId or postId.\n\nCreation defaults:\n- Ensure backend/api/src/create-post.ts sets boosted=false when creating a new post.\n\nAcceptance considerations:\n- Cash flow: Stripe session is created with correct metadata; webhook funding and immediate boost paths work for both contracts and posts.\n- Mana flow: transaction deducted from user balance; immediate boost applied if start time is current/past.\n- Importance: boosted posts surface on homepage by having elevated importance_score; boosted flag is surfaced to clients and displayed in UI.\n- Backward compatibility: All client code calls 'purchase-boost' endpoint; previous 'purchase-contract-boost' is no longer referenced.",
1137
- "prompt": "Add a unified boosting feature that lets users promote either a market or a post for 24 hours, paid with either mana or cash.\n\nRequirements:\n- Create a single API that accepts either a contract id or a post id (but not both) with a requested start time and a payment method (mana, cash, or admin-free). Enforce mutual exclusivity between contract and post, and limit the number of concurrent boosts per time window while preventing duplicate boosts on the same item/time.\n- For cash, insert an unfunded boost and return a Stripe checkout URL; fund the boost via the Stripe webhook and, if the start time has begun, immediately activate the boost. For mana, charge the fixed mana price and activate immediately if the start time has begun.\n- When activating, toggle the item’s boosted flag to true and ensure its importance score is high enough to be prominently featured during the boost period.\n- Update server-side types and DB schema so a boost row points to exactly one of contract_id or post_id. Expose any new fields on the post type to clients and include boosted in post fetches.\n- Update the importance scoring to recognize active post boosts (similar to markets) so boosted posts appear prominently.\n- Update the UI to allow boosting from the post page and to show a boosted indicator next to posts in lists. Migrate existing contract boost UI to call the unified endpoint.\n- Document how boosts work (scope, duration, payment options) for both contracts and posts.\n\nEnsure end-to-end flows work for both targets: initiating a cash purchase, webhook funding, immediate activation if applicable, and mana/admin-free paths.",
1138
- "supplementalFiles": [
1139
- "backend/shared/src/supabase/contracts.ts",
1140
- "web/components/contract/boost-column.tsx",
1141
- "common/src/economy.ts",
1142
- "backend/shared/src/txn/run-txn.ts",
1143
- "web/hooks/use-admin.ts",
1144
- "common/src/contract.ts"
1145
- ],
1146
- "fileDiffs": [
1147
- {
1148
- "path": "backend/api/knowledge.md",
1149
- "status": "modified",
1150
- "diff": "Index: backend/api/knowledge.md\n===================================================================\n--- backend/api/knowledge.md\t21d26b6 (parent)\n+++ backend/api/knowledge.md\td414e8e (commit)\n@@ -83,16 +83,23 @@\n \n - Use the `createSupabaseDirectClient` function from `shared/supabase/init` for database operations.\n - For environment-specific IDs and constants, check common/antes.ts first as it contains important platform-wide constants like HOUSE_LIQUIDITY_PROVIDER_ID.\n \n-\n ## Sports Markets\n \n - Each sports event can have at most one MANA market and one CASH market\n - If creation of the MANA market fails (e.g., due to duplicate sportsEventId), do not attempt to create the CASH market\n - The MANA market is considered the \"source of truth\" - the CASH market should only exist if there is a corresponding MANA market\n - Prefer lightweight check endpoints over complex validation in create/update endpoints when the check might be useful in other contexts\n \n+## Boosts\n+\n+- The `contract_boosts` table supports boosting both contracts and posts\n+- Either `contract_id` or `post_id` must be specified, but not both\n+- The `purchase-boost` endpoint accepts either `contractId` or `postId` parameter\n+- Boosted content gets higher importance scores and appears more prominently on the homepage\n+- Boosts last for 24 hours and can be paid for with mana or cash\n+\n This uses the pg promise library, where you pass raw sql strings like so:\n \n ```ts\n import { createSupabaseClient } from 'shared/supabase/init'\n"
1151
- },
1152
- {
1153
- "path": "backend/api/src/create-post.ts",
1154
- "status": "modified",
1155
- "diff": "Index: backend/api/src/create-post.ts\n===================================================================\n--- backend/api/src/create-post.ts\t21d26b6 (parent)\n+++ backend/api/src/create-post.ts\td414e8e (commit)\n@@ -41,8 +41,9 @@\n creatorAvatarUrl: creator.avatarUrl,\n visibility: visibility ?? 'public',\n isAnnouncement,\n isChangeLog,\n+ boosted: false,\n importanceScore: NEW_MARKET_IMPORTANCE_SCORE,\n })\n \n // currently uses the trigger to populate group_id, creator_id, created_time.\n"
1156
- },
1157
- {
1158
- "path": "backend/api/src/purchase-boost.ts",
1159
- "status": "added",
1160
- "diff": "Index: backend/api/src/purchase-boost.ts\n===================================================================\n--- backend/api/src/purchase-boost.ts\t21d26b6 (parent)\n+++ backend/api/src/purchase-boost.ts\td414e8e (commit)\n@@ -1,1 +1,229 @@\n-[NEW FILE]\n\\n+import { APIError, APIHandler } from './helpers/endpoint'\n+import {\n+ createSupabaseDirectClient,\n+ SupabaseDirectClient,\n+} from 'shared/supabase/init'\n+import { DAY_MS } from 'common/util/time'\n+import {\n+ DEV_HOUSE_LIQUIDITY_PROVIDER_ID,\n+ HOUSE_LIQUIDITY_PROVIDER_ID,\n+} from 'common/antes'\n+import { getContract, isProd } from 'shared/utils'\n+import { runTxnInBetQueue, TxnData } from 'shared/txn/run-txn'\n+import { ContractBoostPurchaseTxn } from 'common/txn'\n+import { Row } from 'common/supabase/utils'\n+import {\n+ BOOST_COST_MANA,\n+ DEV_BOOST_STRIPE_PRICE_ID,\n+ PROD_BOOST_STRIPE_PRICE_ID,\n+} from 'common/economy'\n+import { trackPublicEvent } from 'shared/analytics'\n+import Stripe from 'stripe'\n+import { contractUrl } from 'common/contract'\n+import { boostContractImmediately } from 'shared/supabase/contracts'\n+import { isAdminId, isModId } from 'common/envs/constants'\n+import { getPost } from 'shared/supabase/posts'\n+import { TopLevelPost } from 'common/top-level-post'\n+\n+const MAX_ACTIVE_BOOSTS = 5\n+\n+const initStripe = () => {\n+ const apiKey = process.env.STRIPE_APIKEY as string\n+ return new Stripe(apiKey, { apiVersion: '2020-08-27', typescript: true })\n+}\n+\n+//TODO; we could add a 'paid' column that is default true but for those paying with USD,\n+// defaults to false until the strpe webhook marks it as true\n+export const purchaseContractBoost: APIHandler<'purchase-boost'> = async (\n+ props,\n+ auth\n+) => {\n+ const { contractId, postId, startTime, method } = props\n+ const userId = auth.uid\n+\n+ const pg = createSupabaseDirectClient()\n+\n+ // Validate that either contract or post exists and user can see it\n+ let contract = null\n+ let post = null\n+ let contentUrl = ''\n+ let contentSlug = ''\n+\n+ if (contractId) {\n+ contract = await getContract(pg, contractId)\n+ if (!contract) {\n+ throw new APIError(404, 'Contract not found')\n+ }\n+ contentUrl = contractUrl(contract)\n+ contentSlug = contract.slug\n+ } else if (postId) {\n+ post = await getPost(pg, postId)\n+ if (!post) {\n+ throw new APIError(404, 'Post not found')\n+ }\n+ contentUrl = `/post/${post.slug}`\n+ contentSlug = post.slug\n+ }\n+\n+ const fundViaCash = method === 'cash'\n+ const freeAdminBoost = method === 'admin-free'\n+\n+ // Check if user is admin/mod for free boost\n+ if (freeAdminBoost && !isAdminId(userId) && !isModId(userId)) {\n+ throw new APIError(403, 'Only admins and mods can use free boosts')\n+ }\n+\n+ // Check if there's already an active boost for the same time period\n+ const activeBoost = await pg.manyOrNone<Row<'contract_boosts'>>(\n+ `select * from contract_boosts \n+ where millis_to_ts($1) between start_time and end_time\n+ and funded`,\n+ [startTime]\n+ )\n+\n+ // Check if the specific content (contract or post) already has a boost for this time\n+ const contentHasBoost = activeBoost.some(\n+ (b) =>\n+ (contractId && b.contract_id === contractId) ||\n+ (postId && b.post_id === postId)\n+ )\n+\n+ if (contentHasBoost) {\n+ throw new APIError(\n+ 400,\n+ `${\n+ contractId ? 'Contract' : 'Post'\n+ } already has an active boost for that time`\n+ )\n+ }\n+\n+ if (activeBoost.length >= MAX_ACTIVE_BOOSTS) {\n+ throw new APIError(\n+ 400,\n+ 'That time period has the maximum number of boosts. Please select a different time.'\n+ )\n+ }\n+\n+ if (fundViaCash) {\n+ // insert the boost as unfunded and then in the stripe endpoint, query for the boost and mark it as funded\n+ const boost = await pg.one(\n+ `insert into contract_boosts (contract_id, post_id, user_id, start_time, end_time, funded)\n+ values ($1, $2, $3, millis_to_ts($4), millis_to_ts($5), false)\n+ returning id`,\n+ [\n+ contractId ?? null,\n+ postId ?? null,\n+ userId,\n+ startTime,\n+ startTime + DAY_MS,\n+ ]\n+ )\n+\n+ // Create Stripe checkout session\n+ const stripe = initStripe()\n+ const priceId = isProd()\n+ ? PROD_BOOST_STRIPE_PRICE_ID\n+ : DEV_BOOST_STRIPE_PRICE_ID\n+\n+ const session = await stripe.checkout.sessions.create({\n+ metadata: {\n+ userId,\n+ boostId: boost.id,\n+ contractId: contractId ?? '',\n+ postId: postId ?? '',\n+ },\n+ line_items: [\n+ {\n+ price: priceId,\n+ quantity: 1,\n+ },\n+ ],\n+ mode: 'payment',\n+ allow_promotion_codes: true,\n+ success_url: contentUrl + '?boostSuccess=true',\n+ cancel_url: contentUrl + '?boostSuccess=false',\n+ })\n+ if (!session.url) {\n+ throw new APIError(500, 'Failed to create Stripe checkout session')\n+ }\n+\n+ return {\n+ result: { success: true, checkoutUrl: session.url },\n+ continue: async () => {\n+ trackPublicEvent(\n+ auth.uid,\n+ `${contractId ? 'contract' : 'post'} boost initiated`,\n+ {\n+ contractId,\n+ postId,\n+ slug: contentSlug,\n+ paymentMethod: 'cash',\n+ }\n+ )\n+ },\n+ }\n+ } else {\n+ // Start transaction for mana payment\n+ await pg.tx(async (tx) => {\n+ const boost = await tx.one(\n+ `insert into contract_boosts (contract_id, post_id, user_id, start_time, end_time, funded)\n+ values ($1, $2, $3, millis_to_ts($4), millis_to_ts($5), true)\n+ returning id`,\n+ [\n+ contractId ?? null,\n+ postId ?? null,\n+ userId,\n+ startTime,\n+ startTime + DAY_MS,\n+ ]\n+ )\n+ if (!freeAdminBoost) {\n+ const txnData: TxnData = {\n+ category: 'CONTRACT_BOOST_PURCHASE',\n+ fromType: 'USER',\n+ toType: 'BANK',\n+ token: 'M$',\n+ data: { contractId, postId, boostId: boost.id },\n+ amount: BOOST_COST_MANA,\n+ fromId: userId,\n+ toId: isProd()\n+ ? HOUSE_LIQUIDITY_PROVIDER_ID\n+ : DEV_HOUSE_LIQUIDITY_PROVIDER_ID,\n+ } as ContractBoostPurchaseTxn\n+ await runTxnInBetQueue(tx, txnData)\n+ }\n+ })\n+ }\n+\n+ return {\n+ result: { success: true },\n+ continue: async () => {\n+ trackPublicEvent(\n+ auth.uid,\n+ `${contractId ? 'contract' : 'post'} boost purchased`,\n+ {\n+ contractId,\n+ postId,\n+ slug: contentSlug,\n+ paymentMethod: 'mana',\n+ }\n+ )\n+ if (startTime <= Date.now() && !fundViaCash && contract) {\n+ await boostContractImmediately(pg, contract)\n+ }\n+ if (startTime <= Date.now() && !fundViaCash && post) {\n+ await boostPostImmediately(pg, post)\n+ }\n+ },\n+ }\n+}\n+\n+export const boostPostImmediately = async (\n+ pg: SupabaseDirectClient,\n+ post: TopLevelPost\n+) => {\n+ await pg.none(\n+ `update old_posts set boosted = true, importance_score = 0.9 where id = $1`,\n+ [post.id]\n+ )\n+}\n"
1161
- },
1162
- {
1163
- "path": "backend/api/src/routes.ts",
1164
- "status": "modified",
1165
- "diff": "Index: backend/api/src/routes.ts\n===================================================================\n--- backend/api/src/routes.ts\t21d26b6 (parent)\n+++ backend/api/src/routes.ts\td414e8e (commit)\n@@ -146,9 +146,9 @@\n import {\n getContractOptionVoters,\n getContractVoters,\n } from './get-contract-voters'\n-import { purchaseContractBoost } from './purchase-contract-boost'\n+import { purchaseContractBoost } from './purchase-boost'\n import {\n generateAINumericRanges,\n regenerateNumericMidpoints,\n } from './generate-ai-numeric-ranges'\n@@ -337,9 +337,9 @@\n 'comment-reactions': getReactions,\n 'mark-all-notifications-new': markallnotificationsnew,\n 'get-contract-voters': getContractVoters,\n 'get-contract-option-voters': getContractOptionVoters,\n- 'purchase-contract-boost': purchaseContractBoost,\n+ 'purchase-boost': purchaseContractBoost,\n 'generate-ai-numeric-ranges': generateAINumericRanges,\n 'regenerate-numeric-midpoints': regenerateNumericMidpoints,\n 'infer-numeric-unit': inferNumericUnit,\n 'generate-ai-date-ranges': generateAIDateRanges,\n"
1166
- },
1167
- {
1168
- "path": "backend/api/src/stripe-endpoints.ts",
1169
- "status": "modified",
1170
- "diff": "Index: backend/api/src/stripe-endpoints.ts\n===================================================================\n--- backend/api/src/stripe-endpoints.ts\t21d26b6 (parent)\n+++ backend/api/src/stripe-endpoints.ts\td414e8e (commit)\n@@ -11,16 +11,19 @@\n import { updateUser } from 'shared/supabase/users'\n import { WEB_PRICES } from 'common/economy'\n import { getContract } from 'shared/utils'\n import { boostContractImmediately } from 'shared/supabase/contracts'\n+import { getPost } from 'shared/supabase/posts'\n+import { boostPostImmediately } from './purchase-boost'\n \n export type StripeSession = Stripe.Event.Data.Object & {\n id: string\n metadata: {\n userId: string\n priceInDollars?: string\n boostId?: string\n contractId?: string\n+ postId?: string\n }\n }\n \n export type StripeTransaction = {\n@@ -101,9 +104,12 @@\n }\n \n if (event.type === 'checkout.session.completed') {\n const session = event.data.object as StripeSession\n- if (session.metadata.boostId && session.metadata.contractId) {\n+ if (\n+ session.metadata.boostId &&\n+ (session.metadata.contractId || session.metadata.postId)\n+ ) {\n await handleBoostPayment(session)\n } else {\n await issueMoneys(session)\n }\n@@ -209,10 +215,10 @@\n }\n }\n \n const handleBoostPayment = async (session: StripeSession) => {\n- const { boostId, contractId, userId } = session.metadata\n- if (!boostId || !contractId || !userId) {\n+ const { boostId, contractId, postId, userId } = session.metadata\n+ if (!boostId || (!contractId && !postId) || !userId) {\n log.error('Invalid boost payment metadata', session.metadata)\n throw new APIError(400, 'Invalid boost payment metadata')\n }\n \n@@ -221,24 +227,39 @@\n const boost = await pg.tx(async (tx) =>\n tx.one(\n `update contract_boosts \n set funded = true \n- where id = $1 and contract_id = $2 and user_id = $3\n+ where id = $1 and user_id = $2 and (\n+ (contract_id = $3 and post_id is null) or \n+ (post_id = $4 and contract_id is null)\n+ )\n returning *`,\n- [boostId, contractId, userId]\n+ [boostId, userId, contractId ?? null, postId ?? null]\n )\n )\n \n if (new Date(boost.start_time) <= new Date()) {\n- const contract = await getContract(pg, contractId)\n- if (!contract) throw new APIError(404, 'Contract not found')\n- await boostContractImmediately(pg, contract)\n+ if (contractId) {\n+ const contract = await getContract(pg, contractId)\n+ if (!contract) throw new APIError(404, 'Contract not found')\n+ await boostContractImmediately(pg, contract)\n+ }\n+ if (postId) {\n+ const post = await getPost(pg, postId)\n+ if (!post) throw new APIError(404, 'Post not found')\n+ await boostPostImmediately(pg, post)\n+ }\n }\n \n- await trackPublicEvent(userId, 'contract boost purchased', {\n- contractId,\n- boostId,\n- paymentMethod: 'cash',\n- })\n+ await trackPublicEvent(\n+ userId,\n+ `${contractId ? 'contract' : 'post'} boost purchased`,\n+ {\n+ contractId,\n+ postId,\n+ boostId,\n+ paymentMethod: 'cash',\n+ }\n+ )\n }\n \n const firestore = admin.firestore()\n"
1171
- },
1172
- {
1173
- "path": "backend/shared/src/importance-score.ts",
1174
- "status": "modified",
1175
- "diff": "Index: backend/shared/src/importance-score.ts\n===================================================================\n--- backend/shared/src/importance-score.ts\t21d26b6 (parent)\n+++ backend/shared/src/importance-score.ts\td414e8e (commit)\n@@ -104,9 +104,9 @@\n ...(await getContractTraders(pg, weekAgo, contractIds)),\n ...(await getContractVoters(pg, weekAgo, contractIds)),\n }\n const activeBoosts = await pg.manyOrNone<Row<'contract_boosts'>>(\n- `select * from contract_boosts where start_time <= now() and end_time > now() and funded`\n+ `select * from contract_boosts where start_time <= now() and end_time > now() and funded and contract_id is not null`\n )\n \n const contractsWithUpdates: Contract[] = []\n const marketComponents: Record<string, any>[] = []\n@@ -559,8 +559,13 @@\n weekAgo,\n posts.map((p) => p.id)\n )\n \n+ // Get active post boosts\n+ const activePostBoosts = await pg.manyOrNone<Row<'contract_boosts'>>(\n+ `select * from contract_boosts where start_time <= now() and end_time > now() and funded and post_id is not null`\n+ )\n+\n const postsWithUpdates: { id: string; importance_score: number }[] = []\n \n for (const post of posts) {\n const postId = post.id\n@@ -571,15 +576,23 @@\n const likesToday = dailyPostLikeCounts[postId] ?? 0\n const likesWeek = weeklyPostLikeCounts[postId] ?? 0 // This is total for the week up to 'weekAgo'\n \n const todayActivity = commentsToday + likesToday\n-\n const weekActivityTotal = commentsWeek + likesWeek\n \n+ // Check if this post is boosted\n+ const isBoosted = activePostBoosts.some((b) => b.post_id === postId)\n+ const boostScore = isBoosted ? 3 : 0\n+\n const rawScore =\n- normalize(todayActivity, 100) * 2 + normalize(weekActivityTotal, 250)\n+ normalize(todayActivity, 100) * 2 +\n+ normalize(weekActivityTotal, 250) +\n+ boostScore\n \n- const newImportanceScore = normalize(rawScore, 3)\n+ const newImportanceScore = Math.max(\n+ normalize(rawScore, 3),\n+ isBoosted ? 0.9 : 0\n+ )\n \n // Only update if the score has changed significantly\n const epsilon = 0.01\n if (Math.abs(newImportanceScore - currentImportanceScore) > epsilon) {\n"
1176
- },
1177
- {
1178
- "path": "backend/shared/src/supabase/posts.ts",
1179
- "status": "modified",
1180
- "diff": "Index: backend/shared/src/supabase/posts.ts\n===================================================================\n--- backend/shared/src/supabase/posts.ts\t21d26b6 (parent)\n+++ backend/shared/src/supabase/posts.ts\td414e8e (commit)\n@@ -5,9 +5,9 @@\n pg: SupabaseDirectClient,\n postId: string\n ): Promise<TopLevelPost | null> {\n const row = await pg.oneOrNone(\n- `SELECT data, importance_score FROM old_posts WHERE id = $1`,\n+ `SELECT data, importance_score, boosted FROM old_posts WHERE id = $1`,\n [postId],\n convertPost\n )\n return row\n"
1181
- },
1182
- {
1183
- "path": "backend/supabase/contract_boosts.sql",
1184
- "status": "modified",
1185
- "diff": "Index: backend/supabase/contract_boosts.sql\n===================================================================\n--- backend/supabase/contract_boosts.sql\t21d26b6 (parent)\n+++ backend/supabase/contract_boosts.sql\td414e8e (commit)\n@@ -1,18 +1,31 @@\n create table if not exists\n contract_boosts (\n id bigint generated always as identity primary key,\n- contract_id text not null references contracts (id),\n+ contract_id text references contracts (id),\n+ post_id text references old_posts (id),\n user_id text not null references users (id),\n start_time timestamptz not null,\n end_time timestamptz not null,\n created_time timestamptz not null default now(),\n- funded boolean not null default true\n+ funded boolean not null default true,\n+ constraint contract_boosts_content_check check (\n+ (\n+ contract_id is not null\n+ and post_id is null\n+ )\n+ or (\n+ contract_id is null\n+ and post_id is not null\n+ )\n+ )\n );\n \n -- Indexes\n create index if not exists contract_boosts_contract_id_idx on contract_boosts (contract_id, start_time, end_time);\n \n+create index if not exists contract_boosts_post_id_idx on contract_boosts (post_id, start_time, end_time);\n+\n create index if not exists contract_boosts_user_id_idx on contract_boosts (user_id);\n \n -- Row Level Security\n alter table contract_boosts enable row level security;\n"
1186
- },
1187
- {
1188
- "path": "backend/supabase/old_posts.sql",
1189
- "status": "modified",
1190
- "diff": "Index: backend/supabase/old_posts.sql\n===================================================================\n--- backend/supabase/old_posts.sql\t21d26b6 (parent)\n+++ backend/supabase/old_posts.sql\td414e8e (commit)\n@@ -7,8 +7,9 @@\n group_id text,\n id text primary key default uuid_generate_v4 () not null,\n visibility text,\n importance_score numeric default 0 not null,\n+ boosted boolean default false not null\n );\n \n -- Foreign Keys\n alter table old_posts\n"
1191
- },
1192
- {
1193
- "path": "common/src/api/schema.ts",
1194
- "status": "modified",
1195
- "diff": "Index: common/src/api/schema.ts\n===================================================================\n--- common/src/api/schema.ts\t21d26b6 (parent)\n+++ common/src/api/schema.ts\td414e8e (commit)\n@@ -2073,19 +2073,28 @@\n authed: true,\n props: z.object({ contractId: z.string(), optionId: z.string() }),\n returns: [] as DisplayUser[],\n },\n- 'purchase-contract-boost': {\n+ 'purchase-boost': {\n method: 'POST',\n visibility: 'public',\n authed: true,\n props: z\n .object({\n- contractId: z.string(),\n+ contractId: z.string().optional(),\n+ postId: z.string().optional(),\n startTime: z.number().positive().finite().safe(),\n method: z.enum(['mana', 'cash', 'admin-free']),\n })\n- .strict(),\n+ .strict()\n+ .refine(\n+ (data) =>\n+ (data.contractId && !data.postId) ||\n+ (!data.contractId && data.postId),\n+ {\n+ message: 'Either contractId or postId must be provided, but not both',\n+ }\n+ ),\n returns: {} as { success: boolean; checkoutUrl?: string },\n },\n 'generate-ai-numeric-ranges': {\n method: 'POST',\n"
1196
- },
1197
- {
1198
- "path": "common/src/supabase/schema.ts",
1199
- "status": "modified",
1200
- "diff": "Index: common/src/supabase/schema.ts\n===================================================================\n--- common/src/supabase/schema.ts\t21d26b6 (parent)\n+++ common/src/supabase/schema.ts\td414e8e (commit)\n@@ -268,27 +268,36 @@\n Relationships: []\n }\n contract_boosts: {\n Row: {\n- contract_id: string\n+ contract_id: string | null\n+ post_id: string | null\n end_time: string\n id: number\n start_time: string\n user_id: string\n+ created_time: string\n+ funded: boolean\n }\n Insert: {\n- contract_id: string\n+ contract_id?: string | null\n+ post_id?: string | null\n end_time: string\n id?: never\n start_time: string\n user_id: string\n+ created_time?: string\n+ funded?: boolean\n }\n Update: {\n- contract_id?: string\n+ contract_id?: string | null\n+ post_id?: string | null\n end_time?: string\n id?: never\n start_time?: string\n user_id?: string\n+ created_time?: string\n+ funded?: boolean\n }\n Relationships: [\n {\n foreignKeyName: 'contract_boosts_contract_id_fkey'\n"
1201
- },
1202
- {
1203
- "path": "common/src/top-level-post.ts",
1204
- "status": "modified",
1205
- "diff": "Index: common/src/top-level-post.ts\n===================================================================\n--- common/src/top-level-post.ts\t21d26b6 (parent)\n+++ common/src/top-level-post.ts\td414e8e (commit)\n@@ -33,8 +33,9 @@\n visibility: Visibility\n unlistedById?: string\n isAnnouncement?: boolean\n isChangeLog?: boolean\n+ boosted: boolean\n /** @deprecated - not deprecated, only updated in native column though*/\n importanceScore: number\n /** @deprecated - not deprecated, only available via the get-posts endpoint*/\n uniqueUsers?: number\n"
1206
- },
1207
- {
1208
- "path": "common/src/txn.ts",
1209
- "status": "modified",
1210
- "diff": "Index: common/src/txn.ts\n===================================================================\n--- common/src/txn.ts\t21d26b6 (parent)\n+++ common/src/txn.ts\td414e8e (commit)\n@@ -595,9 +595,10 @@\n fromType: 'USER'\n toType: 'BANK'\n token: 'M$'\n data: {\n- contractId: string\n+ contractId?: string\n+ postId?: string\n boostId: string\n }\n }\n \n"
1211
- },
1212
- {
1213
- "path": "web/components/contract/add-boost-button.tsx",
1214
- "status": "modified",
1215
- "diff": "Index: web/components/contract/add-boost-button.tsx\n===================================================================\n--- web/components/contract/add-boost-button.tsx\t21d26b6 (parent)\n+++ web/components/contract/add-boost-button.tsx\td414e8e (commit)\n@@ -96,9 +96,9 @@\n \n const purchaseBoost = async (paymentMethod: 'mana' | 'cash') => {\n setLoading(paymentMethod)\n try {\n- const result = (await api('purchase-contract-boost', {\n+ const result = (await api('purchase-boost', {\n contractId: contract.id,\n startTime,\n method: paymentMethod,\n })) as { success: boolean; checkoutUrl?: string }\n@@ -121,9 +121,9 @@\n \n const handleAdminFreeBoost = async () => {\n setLoading('admin-free')\n try {\n- const result = (await api('purchase-contract-boost', {\n+ const result = (await api('purchase-boost', {\n contractId: contract.id,\n startTime,\n method: 'admin-free',\n })) as { success: boolean }\n"
1216
- },
1217
- {
1218
- "path": "web/components/posts/add-post-boost-button.tsx",
1219
- "status": "added",
1220
- "diff": "Index: web/components/posts/add-post-boost-button.tsx\n===================================================================\n--- web/components/posts/add-post-boost-button.tsx\t21d26b6 (parent)\n+++ web/components/posts/add-post-boost-button.tsx\td414e8e (commit)\n@@ -1,1 +1,208 @@\n-[NEW FILE]\n\\n+import { useState } from 'react'\n+import { Button } from '../buttons/button'\n+import { Modal } from '../layout/modal'\n+import { Col } from '../layout/col'\n+import { Row } from '../layout/row'\n+import { TopLevelPost } from 'common/top-level-post'\n+import { useUser } from 'web/hooks/use-user'\n+import { api } from 'web/lib/api/api'\n+import { AddFundsModal } from '../add-funds-modal'\n+import toast from 'react-hot-toast'\n+import { BsRocketTakeoff } from 'react-icons/bs'\n+import { BOOST_COST_MANA } from 'common/economy'\n+import dayjs from 'dayjs'\n+import { Input } from '../widgets/input'\n+import { HOUR_MS } from 'common/util/time'\n+import { formatMoney } from 'common/util/format'\n+import { useAdminOrMod } from 'web/hooks/use-admin'\n+\n+export function AddPostBoostButton(props: { post: TopLevelPost }) {\n+ const { post } = props\n+ const [showPurchase, setShowPurchase] = useState(false)\n+ const user = useUser()\n+\n+ if (!user) return null\n+\n+ const disabled = post.visibility !== 'public'\n+\n+ if (disabled) return null\n+ const { boosted } = post\n+ return (\n+ <>\n+ <Button\n+ onClick={() => setShowPurchase(true)}\n+ color={boosted ? 'indigo-outline' : 'gradient-pink'}\n+ className={'w-28'}\n+ data-boost-button\n+ >\n+ <BsRocketTakeoff className=\"mr-1 h-5 w-5\" />\n+ {boosted ? 'Boosted' : 'Boost'}\n+ </Button>\n+\n+ <PostBoostPurchaseModal\n+ open={showPurchase}\n+ setOpen={setShowPurchase}\n+ post={post}\n+ />\n+ </>\n+ )\n+}\n+\n+function PostBoostPurchaseModal(props: {\n+ open: boolean\n+ setOpen: (open: boolean) => void\n+ post: TopLevelPost\n+}) {\n+ const { open, setOpen, post } = props\n+ const [loading, setLoading] = useState<string>()\n+ const [fundsModalOpen, setFundsModalOpen] = useState(false)\n+ const now = Date.now()\n+ const [startTime, setStartTime] = useState(now)\n+ const user = useUser()\n+ const isAdminOrMod = useAdminOrMod()\n+\n+ if (!user) return null\n+\n+ const notEnoughFunds = (user.balance ?? 0) < BOOST_COST_MANA\n+\n+ const purchaseBoost = async (paymentMethod: 'mana' | 'cash') => {\n+ setLoading(paymentMethod)\n+ try {\n+ const result = (await api('purchase-boost', {\n+ postId: post.id,\n+ startTime,\n+ method: paymentMethod,\n+ })) as { success: boolean; checkoutUrl?: string }\n+\n+ if (result.checkoutUrl) {\n+ window.location.href = result.checkoutUrl\n+ return\n+ }\n+\n+ toast.success(\n+ 'Post boosted! It will be featured on the homepage for 24 hours.'\n+ )\n+ setOpen(false)\n+ } catch (e) {\n+ console.error(e)\n+ toast.error(e instanceof Error ? e.message : 'Error purchasing boost')\n+ }\n+ setLoading(undefined)\n+ }\n+\n+ const handleAdminFreeBoost = async () => {\n+ setLoading('admin-free')\n+ try {\n+ const result = (await api('purchase-boost', {\n+ postId: post.id,\n+ startTime,\n+ method: 'admin-free',\n+ })) as { success: boolean }\n+\n+ if (result.success) {\n+ toast.success(\n+ 'Post boosted for free! It will be featured on the homepage for 24 hours.'\n+ )\n+ setOpen(false)\n+ }\n+ } catch (e) {\n+ console.error(e)\n+ toast.error(e instanceof Error ? e.message : 'Error applying free boost')\n+ }\n+ setLoading(undefined)\n+ }\n+\n+ return (\n+ <>\n+ <Modal open={open} setOpen={setOpen} size=\"sm\">\n+ <Col className=\"bg-canvas-0 gap-4 rounded-lg p-6\">\n+ <Row className=\"items-center gap-2 text-xl font-semibold\">\n+ <BsRocketTakeoff className=\"h-6 w-6\" />\n+ Boost this post\n+ </Row>\n+\n+ <div className=\"text-ink-600\">\n+ Boost this post's visibility on the homepage{' '}\n+ {Math.abs(startTime - now) < HOUR_MS\n+ ? 'for the next 24 hours'\n+ : `from ${dayjs(startTime).format('MMM D')} to ${dayjs(startTime)\n+ .add(24, 'hours')\n+ .format('MMM D')}`}\n+ </div>\n+\n+ <Row className=\"items-center gap-2\">\n+ <div className=\"text-ink-600\">Start time:</div>\n+ <Input\n+ type={'date'}\n+ className=\"dark:date-range-input-white\"\n+ onClick={(e) => e.stopPropagation()}\n+ onChange={(e) => {\n+ const start = dayjs(e.target.value).startOf('day').valueOf()\n+ if (start < Date.now()) {\n+ setStartTime(Date.now())\n+ } else {\n+ setStartTime(start)\n+ }\n+ }}\n+ min={dayjs().format('YYYY-MM-DD')}\n+ max=\"3000-12-31\"\n+ disabled={!!loading}\n+ value={dayjs(startTime).format('YYYY-MM-DD')}\n+ />\n+ </Row>\n+\n+ <Row className=\"gap-2\">\n+ <Button\n+ color=\"indigo\"\n+ onClick={() => purchaseBoost('mana')}\n+ loading={loading === 'mana'}\n+ disabled={!!loading || notEnoughFunds}\n+ className=\"flex-1\"\n+ >\n+ Pay {formatMoney(BOOST_COST_MANA)}\n+ </Button>\n+ <Button\n+ color=\"indigo\"\n+ onClick={() => purchaseBoost('cash')}\n+ loading={loading === 'cash'}\n+ className=\"flex-1\"\n+ disabled={!!loading}\n+ >\n+ Pay $100\n+ </Button>\n+ </Row>\n+\n+ {isAdminOrMod && (\n+ <Row className=\"gap-2\">\n+ <Button\n+ color=\"indigo-outline\"\n+ onClick={handleAdminFreeBoost}\n+ loading={loading === 'admin-free'}\n+ disabled={!!loading}\n+ className=\"flex-1\"\n+ >\n+ <BsRocketTakeoff className=\"mr-1 h-5 w-5\" />\n+ Free Admin Boost\n+ </Button>\n+ </Row>\n+ )}\n+\n+ {notEnoughFunds && (\n+ <div className=\"text-ink-600 flex items-center gap-2 text-sm\">\n+ <span className=\"text-error\">Insufficient balance</span>\n+ <Button\n+ size=\"xs\"\n+ color=\"gradient-pink\"\n+ onClick={() => setFundsModalOpen(true)}\n+ >\n+ Get mana\n+ </Button>\n+ </div>\n+ )}\n+ </Col>\n+ </Modal>\n+\n+ <AddFundsModal open={fundsModalOpen} setOpen={setFundsModalOpen} />\n+ </>\n+ )\n+}\n"
1221
- },
1222
- {
1223
- "path": "web/components/posts/post-row.tsx",
1224
- "status": "modified",
1225
- "diff": "Index: web/components/posts/post-row.tsx\n===================================================================\n--- web/components/posts/post-row.tsx\t21d26b6 (parent)\n+++ web/components/posts/post-row.tsx\td414e8e (commit)\n@@ -8,8 +8,9 @@\n import { UserIcon } from '@heroicons/react/solid'\n import { Tooltip } from '../widgets/tooltip'\n import { UserHovercard } from '../user/user-hovercard'\n import { EyeOffIcon } from '@heroicons/react/outline'\n+import { BoostedTooltip } from '../contract/boost-column'\n \n export function PostRow(props: {\n post: TopLevelPost\n highlighted?: boolean\n@@ -70,16 +71,23 @@\n {/* Hide normal action columns row on mobile when showing position row */}\n <Row\n className={clsx('w-full items-center justify-end gap-8 sm:w-fit')}\n >\n- <Tooltip\n- text={`${post.uniqueUsers} unique users commented or reacted`}\n- >\n- <Row className=\"text-ink-700 w-[4.62rem] items-center justify-start gap-0.5\">\n- <UserIcon className={'text-ink-400 h-4 w-4 shrink-0'} />\n- {post.uniqueUsers}\n- </Row>\n- </Tooltip>\n+ <Row>\n+ <BoostedTooltip\n+ boosted={post.boosted}\n+ placement={'top'}\n+ className=\"mr-3 w-4\"\n+ />\n+ <Tooltip\n+ text={`${post.uniqueUsers} unique users commented or reacted`}\n+ >\n+ <Row className=\"text-ink-700 w-[6.5rem] items-center justify-start gap-0.5 sm:w-[4.62rem]\">\n+ <UserIcon className={'text-ink-400 h-4 w-4 shrink-0'} />\n+ {post.uniqueUsers}\n+ </Row>\n+ </Tooltip>\n+ </Row>\n <div className=\"w-12 text-cyan-600\">POST</div>\n <div className=\"invisible w-12\"></div>\n </Row>\n </Col>\n"
1226
- },
1227
- {
1228
- "path": "web/pages/post/[slug]/index.tsx",
1229
- "status": "modified",
1230
- "diff": "Index: web/pages/post/[slug]/index.tsx\n===================================================================\n--- web/pages/post/[slug]/index.tsx\t21d26b6 (parent)\n+++ web/pages/post/[slug]/index.tsx\td414e8e (commit)\n@@ -39,8 +39,9 @@\n import { BackButton } from 'web/components/contract/back-button'\n import { report as reportContent } from 'web/lib/api/api'\n import { IoWarning } from 'react-icons/io5'\n import { FollowPostButton } from 'web/components/buttons/follow-post-button'\n+import { AddPostBoostButton } from 'web/components/posts/add-post-boost-button'\n \n export async function getStaticProps(props: { params: { slug: string } }) {\n const { slug } = props.params\n \n@@ -81,9 +82,8 @@\n const currentUser = useUser()\n useSaveReferral(currentUser, {\n defaultReferrerUsername: post?.creatorUsername,\n })\n-\n useEffect(() => {\n setPost(props.post)\n }, [props.post])\n \n@@ -273,8 +273,11 @@\n setEditing={setEditing}\n />\n </div>\n </div>\n+ <Row>\n+ <AddPostBoostButton post={post} />\n+ </Row>\n <Spacer h={4} />\n <PostCommentsActivity post={post} comments={comments} />\n </Col>\n </Page>\n"
1231
- }
1232
- ]
1233
- },
1234
- {
1235
- "id": "add-admin-free-boost",
1236
- "sha": "21d26b6620450a4f2cca836de07ba6e1e8c2b296",
1237
- "parentSha": "ddb266fa95162854d4de4dbd7ea4fee3d8ae25d5",
1238
- "spec": "Implement an admin/mod free boost option for contract boosts across schema, backend, and UI.\n\nScope:\n- API schema: Extend the purchase-contract-boost endpoint to accept a third method.\n- Backend handler: Authorize admin/mod usage and avoid charging mana for admin-free boosts.\n- Frontend UI: Show a Free Admin Boost button to admins/mods and call the updated API method.\n\nRequirements:\n1) Update API schema\n- File: common/src/api/schema.ts\n- Endpoint: 'purchase-contract-boost'\n- Change the props.method union from ['mana', 'cash'] to include 'admin-free'. The endpoint continues to return { success: boolean; checkoutUrl?: string }.\n\n2) Enforce admin/mod-only free boosts in backend\n- File: backend/api/src/purchase-contract-boost.ts\n- Import role utilities from common/src/envs/constants: isAdminId, isModId.\n- Introduce a boolean flag set when props.method === 'admin-free'.\n- If method is 'admin-free' and the authenticated user is neither admin nor mod, throw a 403 error with a clear message indicating only admins and mods can use free boosts.\n- Keep the existing cash flow unchanged (inserting funded=false, creating Stripe session).\n- In the mana transaction branch:\n - Always insert a new boost row with funded=true for the specified start and end times.\n - If method is NOT 'admin-free', perform the existing mana charge via runTxnInBetQueue with BOOST_COST_MANA from common/src/economy.ts; otherwise, skip creating/running the transaction so no user balance is deducted.\n- Preserve existing checks for active boosts and maximum concurrent boosts.\n- Preserve continue behavior: trigger trackPublicEvent and, if the start time is in the past or now and the payment is not via cash, immediately boost the contract via boostContractImmediately. The admin-free method should be treated as non-cash for immediate boosting behavior.\n\n3) Add admin/mod UI control for free boost\n- File: web/components/contract/add-boost-button.tsx\n- Import the role hook from web/hooks/use-admin: useAdminOrMod.\n- Within BoostPurchaseModal, derive a boolean isAdminOrMod using the hook.\n- Add a new handler that calls api('purchase-contract-boost', { contractId, startTime, method: 'admin-free' }) and displays a success toast, closing the modal on success; on error, log and display a toast with the error message.\n- Render a new \"Free Admin Boost\" button only when isAdminOrMod is true; style similarly to other buttons and include the rocket icon. It should not be disabled by insufficient mana balance and should show a loading state while the request is in flight.\n- Maintain existing behavior for the \"Pay {formatMoney(BOOST_COST_MANA)}\" and \"Pay $100\" options; do not alter analytics or other UI outside of adding this new option.\n\n4) Behavior to verify\n- Non-admin/mod users do not see the \"Free Admin Boost\" button in the UI and receive a 403 if they attempt to call the endpoint with method=admin-free.\n- Admins/mods see the additional button, can execute a free boost without deducting mana or requiring Stripe checkout.\n- Active boost checks and MAX_ACTIVE_BOOSTS limits remain enforced.\n- Immediate boosting logic for current start time still triggers for admin-free boosts.\n\nNo changes are needed to backend/api/src/routes.ts since the endpoint key remains 'purchase-contract-boost'.",
1239
- "prompt": "Add a free boost option for admins and mods when boosting a market. Extend the boost purchase API to accept a third method that allows authorized users to schedule a 24-hour boost without charging mana or going through cash checkout. Enforce that only admins/moderators can use this free method, and show a dedicated Free Admin Boost button in the boost modal for those users. The free boost should insert a funded boost window like the mana flow, skip any balance deductions, and trigger an immediate boost if the start time is now. Keep existing cash and mana behaviors, analytics, and validation intact.",
1240
- "supplementalFiles": [
1241
- "backend/api/src/routes.ts",
1242
- "web/hooks/use-admin.ts",
1243
- "common/src/envs/constants.ts",
1244
- "common/src/economy.ts",
1245
- "backend/shared/src/supabase/contracts.ts",
1246
- "backend/shared/src/txn/run-txn.ts",
1247
- "web/components/contract/boost-analytics.tsx"
1248
- ],
1249
- "fileDiffs": [
1250
- {
1251
- "path": "backend/api/src/purchase-contract-boost.ts",
1252
- "status": "modified",
1253
- "diff": "Index: backend/api/src/purchase-contract-boost.ts\n===================================================================\n--- backend/api/src/purchase-contract-boost.ts\tddb266f (parent)\n+++ backend/api/src/purchase-contract-boost.ts\t21d26b6 (commit)\n@@ -17,8 +17,9 @@\n import { trackPublicEvent } from 'shared/analytics'\n import Stripe from 'stripe'\n import { contractUrl } from 'common/contract'\n import { boostContractImmediately } from 'shared/supabase/contracts'\n+import { isAdminId, isModId } from 'common/envs/constants'\n \n const MAX_ACTIVE_BOOSTS = 5\n \n const initStripe = () => {\n@@ -41,9 +42,15 @@\n if (!contract) {\n throw new APIError(404, 'Contract not found')\n }\n const fundViaCash = method === 'cash'\n+ const freeAdminBoost = method === 'admin-free'\n \n+ // Check if user is admin/mod for free boost\n+ if (freeAdminBoost && !isAdminId(userId) && !isModId(userId)) {\n+ throw new APIError(403, 'Only admins and mods can use free boosts')\n+ }\n+\n // Check if there's already an active boost\n const activeBoost = await pg.manyOrNone<Row<'contract_boosts'>>(\n `select * from contract_boosts \n where millis_to_ts($1) between start_time and end_time\n@@ -109,32 +116,31 @@\n })\n },\n }\n } else {\n- // Start transaction\n+ // Start transaction for mana payment\n await pg.tx(async (tx) => {\n const boost = await tx.one(\n `insert into contract_boosts (contract_id, user_id, start_time, end_time, funded)\n values ($1, $2, millis_to_ts($3), millis_to_ts($4), true)\n returning id`,\n [contractId, userId, startTime, startTime + DAY_MS]\n )\n-\n- // Charge mana\n- const txnData: TxnData = {\n- category: 'CONTRACT_BOOST_PURCHASE',\n- fromType: 'USER',\n- toType: 'BANK',\n- token: 'M$',\n- data: { contractId, boostId: boost.id },\n- amount: BOOST_COST_MANA,\n- fromId: userId,\n- toId: isProd()\n- ? HOUSE_LIQUIDITY_PROVIDER_ID\n- : DEV_HOUSE_LIQUIDITY_PROVIDER_ID,\n- } as ContractBoostPurchaseTxn\n-\n- await runTxnInBetQueue(tx, txnData)\n+ if (!freeAdminBoost) {\n+ const txnData: TxnData = {\n+ category: 'CONTRACT_BOOST_PURCHASE',\n+ fromType: 'USER',\n+ toType: 'BANK',\n+ token: 'M$',\n+ data: { contractId, boostId: boost.id },\n+ amount: BOOST_COST_MANA,\n+ fromId: userId,\n+ toId: isProd()\n+ ? HOUSE_LIQUIDITY_PROVIDER_ID\n+ : DEV_HOUSE_LIQUIDITY_PROVIDER_ID,\n+ } as ContractBoostPurchaseTxn\n+ await runTxnInBetQueue(tx, txnData)\n+ }\n })\n }\n \n return {\n"
1254
- },
1255
- {
1256
- "path": "common/src/api/schema.ts",
1257
- "status": "modified",
1258
- "diff": "Index: common/src/api/schema.ts\n===================================================================\n--- common/src/api/schema.ts\tddb266f (parent)\n+++ common/src/api/schema.ts\t21d26b6 (commit)\n@@ -2081,9 +2081,9 @@\n props: z\n .object({\n contractId: z.string(),\n startTime: z.number().positive().finite().safe(),\n- method: z.enum(['mana', 'cash']),\n+ method: z.enum(['mana', 'cash', 'admin-free']),\n })\n .strict(),\n returns: {} as { success: boolean; checkoutUrl?: string },\n },\n"
1259
- },
1260
- {
1261
- "path": "web/components/contract/add-boost-button.tsx",
1262
- "status": "modified",
1263
- "diff": "Index: web/components/contract/add-boost-button.tsx\n===================================================================\n--- web/components/contract/add-boost-button.tsx\tddb266f (parent)\n+++ web/components/contract/add-boost-button.tsx\t21d26b6 (commit)\n@@ -14,8 +14,9 @@\n import { Input } from '../widgets/input'\n import { HOUR_MS } from 'common/util/time'\n import { formatMoney } from 'common/util/format'\n import { BoostAnalytics } from './boost-analytics'\n+import { useAdminOrMod } from 'web/hooks/use-admin'\n \n export function AddBoostButton(props: {\n contract: Contract\n className?: string\n@@ -86,8 +87,9 @@\n const [fundsModalOpen, setFundsModalOpen] = useState(false)\n const now = Date.now()\n const [startTime, setStartTime] = useState(now)\n const user = useUser()\n+ const isAdminOrMod = useAdminOrMod()\n \n if (!user) return null\n \n const notEnoughFunds = (user.balance ?? 0) < BOOST_COST_MANA\n@@ -116,8 +118,30 @@\n }\n setLoading(undefined)\n }\n \n+ const handleAdminFreeBoost = async () => {\n+ setLoading('admin-free')\n+ try {\n+ const result = (await api('purchase-contract-boost', {\n+ contractId: contract.id,\n+ startTime,\n+ method: 'admin-free',\n+ })) as { success: boolean }\n+\n+ if (result.success) {\n+ toast.success(\n+ 'Market boosted for free! It will be featured on the homepage for 24 hours.'\n+ )\n+ setOpen(false)\n+ }\n+ } catch (e) {\n+ console.error(e)\n+ toast.error(e instanceof Error ? e.message : 'Error applying free boost')\n+ }\n+ setLoading(undefined)\n+ }\n+\n return (\n <>\n <Modal open={open} setOpen={setOpen} size=\"sm\">\n <Col className=\"bg-canvas-0 gap-4 rounded-lg p-6\">\n@@ -175,8 +199,24 @@\n >\n Pay $100\n </Button>\n </Row>\n+\n+ {isAdminOrMod && (\n+ <Row className=\"gap-2\">\n+ <Button\n+ color=\"indigo-outline\"\n+ onClick={handleAdminFreeBoost}\n+ loading={loading === 'admin-free'}\n+ disabled={!!loading}\n+ className=\"flex-1\"\n+ >\n+ <BsRocketTakeoff className=\"mr-1 h-5 w-5\" />\n+ Free Admin Boost\n+ </Button>\n+ </Row>\n+ )}\n+\n {notEnoughFunds && (\n <div className=\"text-ink-600 flex items-center gap-2 text-sm\">\n <span className=\"text-error\">Insufficient balance</span>\n <Button\n"
1264
- }
1265
- ]
1266
- },
1267
- {
1268
- "id": "free-market-creation",
1269
- "sha": "ddb266fa95162854d4de4dbd7ea4fee3d8ae25d5",
1270
- "parentSha": "7173b73b777cc2871f29a10e958533e447c58435",
1271
- "spec": "Implement a 'free market' creation path for a specific user with house-funded ante and a daily limit.\n\nBackend:\n1) In backend/api/src/create-market.ts\n- Define a free-market condition: the creator is FREE_MARKET_USER_ID and totalMarketCost <= 100.\n- If free-market condition is true:\n - Skip enforcing that the user's balance must be at least totalMarketCost.\n - Enforce a daily creation limit: if the user has created 5 or more contracts in the past day, reject with 403 and a clear message.\n - Select the funding providerId as the house liquidity provider based on environment (production uses HOUSE_LIQUIDITY_PROVIDER_ID, non-prod uses DEV_HOUSE_LIQUIDITY_PROVIDER_ID). Otherwise, use the creator's userId as the providerId.\n - When creating the initial funding transaction (CREATE_CONTRACT_ANTE), use providerId (house for free markets; user for others) as fromId, with toId = contract.id and amount = ante.\n - Pass providerId into generateAntes so the initial liquidity is attributed to the chosen provider.\n- Ensure isProd is available for environment detection and that FREE_MARKET_USER_ID is imported from common/economy.\n\n2) In backend/shared/src/create-contract-helpers.ts\n- Change generateAntes signature to remove the outcomeType parameter: generateAntes(pg, providerId, contract, ante, totalMarketCost).\n- Keep its observable behavior intact: it should provision initial liquidity for the given contract and amount, attributing it to providerId.\n- Remove any import/types tied solely to the removed parameter.\n\n3) Update all call sites of generateAntes\n- In backend/api/src/create-market.ts, update to the new signature and pass providerId, contract, ante, totalMarketCost (without outcomeType).\n- Update any scripts or other usages (e.g., backend/scripts/reimburse-broken-markets.ts if present) to match the new signature.\n\n4) Add FREE_MARKET_USER_ID constant\n- In common/src/economy.ts export a constant FREE_MARKET_USER_ID for the user allowed to create free markets.\n\nFrontend:\n5) In web/components/new-contract/contract-params-form.tsx\n- Import FREE_MARKET_USER_ID.\n- Detect free-market condition on the client: creator.id === FREE_MARKET_USER_ID and ante <= 100.\n- Validation: Allow form submission even if ante > creator.balance when free-market condition is true. Otherwise, retain existing balance validation.\n- Display: When passing balance to CostSection, if free-market condition is true, pass 100 instead of the actual user balance so the UI reflects available free amount.\n\nConstraints/Behavioral expectations:\n- Free-market creation applies only to the whitelisted user ID and only when the ante/totalMarketCost does not exceed 100.\n- Free-market creations are limited to 5 per rolling 24-hour period per the server-side check.\n- In free-market cases, the ante is funded by the house liquidity provider (env-specific ID) rather than the user.\n- Non-free cases should remain unchanged: normal balance checks, user-funded ante, and normal call flows.\n- Client-side changes should reflect the ability to proceed despite low balance only for the whitelisted user and within the 100 free amount cap.",
1272
- "prompt": "Implement a mode where a specific whitelisted user can create markets for free up to a small daily budget. When this user creates a market under that limit, the initial ante should be funded by the house instead of the user, with environment-specific house IDs used appropriately, and the server must enforce a daily cap. Update the market creation form so this user isn’t blocked by their own balance for these free creations and the UI shows the effective free balance for the cost section. Also, adjust any helpers to support this behavior and update any impacted function signatures and usages accordingly.",
1273
- "supplementalFiles": [
1274
- "backend/shared/src/utils.ts",
1275
- "common/src/antes.ts",
1276
- "web/components/new-contract/cost-section.tsx",
1277
- "backend/scripts/reimburse-broken-markets.ts"
1278
- ],
1279
- "fileDiffs": [
1280
- {
1281
- "path": "backend/api/src/create-market.ts",
1282
- "status": "modified",
1283
- "diff": "Index: backend/api/src/create-market.ts\n===================================================================\n--- backend/api/src/create-market.ts\t7173b73 (parent)\n+++ backend/api/src/create-market.ts\tddb266f (commit)\n@@ -20,9 +20,9 @@\n nativeContractColumnsArray,\n NUMBER_CREATION_ENABLED,\n PollVoterVisibility,\n } from 'common/contract'\n-import { getAnte } from 'common/economy'\n+import { FREE_MARKET_USER_ID, getAnte } from 'common/economy'\n import { MAX_GROUPS_PER_MARKET } from 'common/group'\n import { getNewContract } from 'common/new-contract'\n import { getPseudoProbability } from 'common/pseudo-numeric'\n import { STONK_INITIAL_PROB } from 'common/stonk'\n@@ -44,10 +44,15 @@\n import {\n addGroupToContract,\n canUserAddGroupToMarket,\n } from 'shared/update-group-contracts-internal'\n-import { contractColumnsToSelect, htmlToRichText, log } from 'shared/utils'\n import {\n+ contractColumnsToSelect,\n+ htmlToRichText,\n+ isProd,\n+ log,\n+} from 'shared/utils'\n+import {\n broadcastNewAnswer,\n broadcastNewContract,\n } from 'shared/websockets/helpers'\n import { APIError, AuthedUser, type APIHandler } from './helpers/endpoint'\n@@ -60,8 +65,12 @@\n import { betsQueue } from 'shared/helpers/fn-queue'\n import { convertUser } from 'common/supabase/users'\n import { camelCase, first } from 'lodash'\n import { getMultiNumericAnswerBucketRangeNames } from 'common/number'\n+import {\n+ DEV_HOUSE_LIQUIDITY_PROVIDER_ID,\n+ HOUSE_LIQUIDITY_PROVIDER_ID,\n+} from 'common/antes'\n type Body = ValidatedAPIParams<'market'>\n \n export const createMarket: APIHandler<'market'> = async (body, auth) => {\n const pg = createSupabaseDirectClient()\n@@ -181,10 +190,22 @@\n const user = first(userAndSlugResult[0].map(convertUser))\n if (!user) throw new APIError(401, 'Your account was not found')\n if (user.isBannedFromPosting) throw new APIError(403, 'You are banned')\n \n- if (totalMarketCost > user.balance)\n+ const isFree = userId === FREE_MARKET_USER_ID && totalMarketCost <= 100\n+ if (!isFree && totalMarketCost > user.balance)\n throw new APIError(403, `Balance must be at least ${totalMarketCost}.`)\n+ if (isFree) {\n+ const contractsToday = await tx.oneOrNone(\n+ `select count(*) from contracts where creator_id = $1 and created_time > now() - interval '1 day'`,\n+ [userId]\n+ )\n+ if (contractsToday && contractsToday.count >= 5)\n+ throw new APIError(\n+ 403,\n+ 'Tumblingnomics has reached its breaking point. No more free markets today.'\n+ )\n+ }\n \n const slug = getSlug(!!first(userAndSlugResult[1]), proposedSlug)\n \n const contract = getNewContract(\n@@ -254,10 +275,14 @@\n \n if (result[1].length > 0 && contract.mechanism === 'cpmm-multi-1') {\n contract.answers = result[1].map(convertAnswer)\n }\n+ const house = isProd()\n+ ? HOUSE_LIQUIDITY_PROVIDER_ID\n+ : DEV_HOUSE_LIQUIDITY_PROVIDER_ID\n+ const providerId = isFree ? house : userId\n await runTxnOutsideBetQueue(tx, {\n- fromId: userId,\n+ fromId: providerId,\n fromType: 'USER',\n toId: contract.id,\n toType: 'CONTRACT',\n amount: ante,\n@@ -271,16 +296,9 @@\n question,\n ante: ante || 0,\n })\n \n- await generateAntes(\n- tx,\n- userId,\n- contract,\n- outcomeType,\n- ante,\n- totalMarketCost\n- )\n+ await generateAntes(tx, providerId, contract, ante, totalMarketCost)\n \n return { contract, user }\n })\n }, [userId])\n"
1284
- },
1285
- {
1286
- "path": "backend/shared/src/create-contract-helpers.ts",
1287
- "status": "modified",
1288
- "diff": "Index: backend/shared/src/create-contract-helpers.ts\n===================================================================\n--- backend/shared/src/create-contract-helpers.ts\t7173b73 (parent)\n+++ backend/shared/src/create-contract-helpers.ts\tddb266f (commit)\n@@ -1,12 +1,7 @@\n import { getNewLiquidityProvision } from 'common/add-liquidity'\n import { getCpmmInitialLiquidity } from 'common/antes'\n-import {\n- BinaryContract,\n- Contract,\n- CPMMMultiContract,\n- OutcomeType,\n-} from 'common/contract'\n+import { BinaryContract, Contract, CPMMMultiContract } from 'common/contract'\n import { updateContract } from './supabase/contracts'\n import { SupabaseDirectClient } from './supabase/init'\n import { insertLiquidity } from './supabase/liquidity'\n import { FieldVal } from './supabase/utils'\n@@ -15,9 +10,8 @@\n export async function generateAntes(\n pg: SupabaseDirectClient,\n providerId: string,\n contract: Contract,\n- outcomeType: OutcomeType,\n ante: number,\n totalMarketCost: number\n ) {\n if (\n"
1289
- },
1290
- {
1291
- "path": "common/src/economy.ts",
1292
- "status": "modified",
1293
- "diff": "Index: common/src/economy.ts\n===================================================================\n--- common/src/economy.ts\t7173b73 (parent)\n+++ common/src/economy.ts\tddb266f (commit)\n@@ -164,4 +164,5 @@\n export const PROFIT_FEE_FRACTION = 0.1\n export const BOOST_COST_MANA = 10000\n export const DEV_BOOST_STRIPE_PRICE_ID = 'price_1QuI5BGdoFKoCJW7lMjCIuKW'\n export const PROD_BOOST_STRIPE_PRICE_ID = 'price_1QuItEGdoFKoCJW7t9qtiGoD'\n+export const FREE_MARKET_USER_ID = '6hHpzvRG0pMq8PNJs7RZj2qlZGn2'\n"
1294
- },
1295
- {
1296
- "path": "web/components/new-contract/contract-params-form.tsx",
1297
- "status": "modified",
1298
- "diff": "Index: web/components/new-contract/contract-params-form.tsx\n===================================================================\n--- web/components/new-contract/contract-params-form.tsx\t7173b73 (parent)\n+++ web/components/new-contract/contract-params-form.tsx\tddb266f (commit)\n@@ -16,8 +16,9 @@\n PollVoterVisibility,\n contractPath,\n } from 'common/contract'\n import {\n+ FREE_MARKET_USER_ID,\n getAnte,\n getUniqueBettorBonusAmount,\n MINIMUM_BOUNTY,\n } from 'common/economy'\n@@ -433,12 +434,13 @@\n const midpointsError =\n outcomeType === 'MULTI_NUMERIC' || outcomeType === 'DATE'\n ? midpoints.length !== answers.length\n : false\n+ const isFree = creator.id === FREE_MARKET_USER_ID && ante <= 100\n \n const isValid =\n isValidQuestion &&\n- ante <= balance &&\n+ (isFree ? true : ante <= balance) &&\n isValidDate &&\n isValidTopics &&\n (outcomeType !== 'PSEUDO_NUMERIC' ||\n (initialValue !== undefined &&\n@@ -1086,9 +1088,9 @@\n }}\n />\n </Row>\n <CostSection\n- balance={balance}\n+ balance={isFree ? 100 : balance}\n numAnswers={numAnswers}\n outcomeType={outcomeType}\n liquidityTier={liquidityTier}\n setLiquidityTier={setLiquidityTier}\n"
1299
- }
1300
- ]
1301
- },
1302
- {
1303
- "id": "block-animated-avatars",
1304
- "sha": "7173b73b777cc2871f29a10e958533e447c58435",
1305
- "parentSha": "248642f6c2504111b1f9f818a63349133d0c8e00",
1306
- "spec": "Implement animated avatar prevention across upload and display.\n\nUpload validation:\n- In web/components/profile/edit-profile.tsx, update the avatar file selection handler to reject GIF and WebP files.\n - Add a guard that blocks when the MIME type is 'image/gif' or 'image/webp', or when the filename extension matches .gif or .webp (case-insensitive).\n - Update the alert text to: \"GIF and WebP files are not allowed for profile pictures as they may contain animations. Please select a JPG or PNG image.\"\n - Ensure the handler returns early without attempting upload when this condition matches.\n - Preserve existing checks (e.g., ensuring a file is selected and is an image) and the rest of the upload flow.\n\nAvatar rendering:\n- In web/components/widgets/avatar.tsx, modify the logic that determines whether to render the image or show the fallback icon.\n - Replace the GIF-only exclusion with detection for both .gif and .webp formats.\n - Detect these formats using a case-insensitive check on the avatarUrl that matches filename extensions and handles query strings (e.g., /\\.(gif|webp)(\\?|$)/ on a lowercased URL string).\n - Set shouldShowImage to false when a match is found so the component renders the fallback icon.\n - Do not change other behaviors (click-through to profile, fresh user ring, error fallback, sizing, etc.).\n\nBehavioral expectations:\n- Attempting to upload a GIF or WebP profile picture shows the new alert message and aborts the upload.\n- Existing users with avatarUrl pointing to GIF or WebP (including URLs with query params) see the default avatar icon instead of an animated image.\n- JPG and PNG uploads and display continue to work as before.",
1307
- "prompt": "Prevent animated profile pictures across the app. Update the profile image upload UI to disallow animated formats and adjust the avatar display so it never renders animated images. Specifically, block GIF and WebP avatar uploads with a clear message, and avoid rendering avatars that point to those formats (including URLs with query strings). Keep all other behaviors and supported formats unchanged.",
1308
- "supplementalFiles": [
1309
- "web/lib/firebase/storage.ts",
1310
- "common/src/user.ts",
1311
- "web/components/user/user-hovercard.tsx",
1312
- "web/components/chat/chat-message.tsx",
1313
- "web/components/multiple-or-single-avatars.tsx"
1314
- ],
1315
- "fileDiffs": [
1316
- {
1317
- "path": "web/components/profile/edit-profile.tsx",
1318
- "status": "modified",
1319
- "diff": "Index: web/components/profile/edit-profile.tsx\n===================================================================\n--- web/components/profile/edit-profile.tsx\t248642f (parent)\n+++ web/components/profile/edit-profile.tsx\t7173b73 (commit)\n@@ -110,12 +110,16 @@\n alert('Please select an image file.')\n return\n }\n \n- // Prevent GIF uploads\n- if (file.type === 'image/gif' || file.name.toLowerCase().endsWith('.gif')) {\n+ // Prevent GIF and WebP uploads (potentially animated)\n+ if (\n+ file.type === 'image/gif' ||\n+ file.type === 'image/webp' ||\n+ file.name.toLowerCase().match(/\\.(gif|webp)$/)\n+ ) {\n alert(\n- 'GIF files are not allowed for profile pictures. Please select a different image format.'\n+ 'GIF and WebP files are not allowed for profile pictures as they may contain animations. Please select a JPG or PNG image.'\n )\n return\n }\n \n"
1320
- },
1321
- {
1322
- "path": "web/components/widgets/avatar.tsx",
1323
- "status": "modified",
1324
- "diff": "Index: web/components/widgets/avatar.tsx\n===================================================================\n--- web/components/widgets/avatar.tsx\t248642f (parent)\n+++ web/components/widgets/avatar.tsx\t7173b73 (commit)\n@@ -39,11 +39,13 @@\n const sizeInPx = s * 4\n \n const isUserFresh = createdTime ? isFresh(createdTime) : false\n \n- // Check if the avatar URL is a GIF and exclude it\n- const isGif = avatarUrl?.toLowerCase().includes('.gif')\n- const shouldShowImage = avatarUrl && !isGif\n+ // Check if the avatar URL is a GIF or WebP (potentially animated) and exclude it\n+ const isAnimatedFormat = avatarUrl\n+ ?.toLowerCase()\n+ .match(/\\.(gif|webp)(\\?|$)/)\n+ const shouldShowImage = avatarUrl && !isAnimatedFormat\n \n const onClick = (e: MouseEvent) => {\n if (!noLink && username) {\n if (preventDefault) {\n"
1325
- }
1326
- ]
1327
- },
1328
- {
1329
- "id": "remove-play-mode",
1330
- "sha": "a3aeeb19676f966215b5eafb24ef52ac9db2fad1",
1331
- "parentSha": "5d7a0b8db6835cae42d0a07de4f38c853583ae12",
1332
- "spec": "Implement the removal of sweepstakes/play-mode and the play URL query across contract viewing flows and embeddings, and simplify to a single-contract rendering model.\n\nRequirements:\n1) Remove play-mode path helper\n- Delete the twombaContractPath export from common/src/contract.ts.\n- Replace all usages with contractPath:\n - web/components/buttons/share-embed-button.tsx: embedContractCode must build src using https://{DOMAIN}/embed + contractPath(contract).\n - web/components/contract/contract-seo.tsx: SEO url must use contractPath(contract).\n - web/components/new-contract/contract-params-form.tsx: after creating a new contract, router.push(contractPath(newContract)).\n - web/pages/embed/[username]/[contractSlug].tsx: external href should use https://{DOMAIN}{contractPath(contract)}.\n\n2) Eliminate dual contract (mana/cash) toggle and setIsPlay\n- Remove all isPlay/prefersPlay logic and router.query.play synchronization in web/components/contract/contract-page.tsx.\n- Do not fetch or pass a sibling cash contract to the page contents; remove cash from ContractParams and all references to props.cash.* in ContractPageContent.\n- Use a single liveContract = useLiveContract(props.contract) everywhere.\n- For ContractDescription on CASH contracts, replace the description/comments with a message linking to the parent MANA version at /{creatorUsername}/{slug with '--cash' removed}. For non-CASH contracts, render the normal ContractDescription.\n- Update top traders/metrics to use the props.topContractMetrics provided and remove useTopContractMetrics calls.\n\n3) Simplify ContractInfoDialog and related components to a single contract\n- web/components/contract/contract-info-dialog.tsx:\n - Change props to accept a single contract: { contract, user, open, setOpen }.\n - Remove setIsPlay from props and eliminate the SweepsToggle UI. For the statistics row \"Sweepstakes\", render a link that toggles between the CASH and non-CASH slug by adding or stripping \"--cash\".\n - Update all internal references to use the single contract.\n- web/components/contract/header-actions.tsx:\n - Change props to { contract, initialHideGraph, hideGraph, setHideGraph }.\n - Update all references from playContract/currentContract to contract.\n - Remove setIsPlay and related flow.\n - Pass the simplified props to ContractInfoDialog and other child components.\n\n4) Remove play handling and sibling contract usage from pages\n- web/pages/[username]/[contractSlug].tsx:\n - In getStaticProps, fetch only the requested contract by slug; do not redirect CASH to mana or vice versa and do not append ?play.\n - Do not fetch sibling contracts; remove picking/merging of cash params.\n - In NonPrivateContractPage, do not use useSweepstakes; render ContractSEO for the provided contract and use ContractEmbedPage only for iframe contexts with the provided contract and points.\n- web/pages/embed/[username]/[contractSlug].tsx:\n - Do not read router.query.play; remove isCash state.\n - Do not fetch sibling contracts; remove cashContract and cashPoints loading/props.\n - Always render ContractSEO and ContractSmolView for the provided contract and points.\n\n5) Update middleware to strip play query globally\n- web/middleware.ts:\n - If the request URL has a play query parameter, remove it and issue a 308 redirect to the same URL without the parameter.\n - Only apply API proxying to requests under /api/; preserve existing proxy behavior for /api/v0 while keeping the redirect logic intact.\n - Extend matcher to include:\n - '/api/v0/:path*' (API proxy)\n - '/([^/]+)/([^/]+)' (contract pages)\n - '/embed/([^/]+)/([^/]+)' (embed pages)\n This ensures the play query is stripped on contract and embed routes as well as on API routes.\n\n6) Update share/embed utilities\n- web/components/buttons/share-embed-button.tsx: ensure the embed iframe code uses the updated embed path computed via contractPath.\n\n7) Type and prop clean-up\n- Remove cash?: CashType from the ContractParams type in common/src/contract.ts (or equivalent shared type location) and update all consumers to no longer reference it.\n- Adjust all affected imports accordingly (e.g., remove imports of CASH_SUFFIX where no longer needed).\n\nObservable outcomes:\n- Navigating to any contract or embed page with ?play=* redirects to the same URL without the play parameter.\n- Contract pages and embeds no longer toggle between cash/mana via query params; they display the contract specified by the URL.\n- SEO tags and share/embed links use canonical contractPath URLs without play parameters.\n- Contract info dialog shows a Sweepstakes row as a link that navigates between the CASH and non-CASH slug variants, instead of an inline toggle.\n- No references to twombaContractPath, setIsPlay, or router.query.play remain in the codebase for these flows.",
1333
- "prompt": "Remove the sweepstakes/play-mode from contract and embed experiences and unify everything to a single contract view and canonical URL scheme.\n\nSpecifically:\n- Eliminate the special URL helper that appended play-mode and switch all share/SEO/embed links to use the standard contract path.\n- Strip the play query parameter at the edge for contract, embed, and API routes, redirecting to the same URL without it.\n- Remove dual contract logic (mana/cash) on contract pages, embeds, and dialogs. Stop reading play state from query or context. Work with just the contract referenced by the URL.\n- Where a CASH contract is shown, don’t duplicate description/comments; instead link back to the parent MANA question.\n- Simplify the contract info dialog and header actions to accept a single contract and remove any play-mode toggles.\n- Adjust page data fetching and props so sibling cash/mana contracts are not loaded or passed around.\n- Ensure SEO and share/embed URLs are consistent and no longer include any play parameter or cash/mana toggling logic.",
1334
- "supplementalFiles": [
1335
- "web/components/sweeps/sweeps-toggle.tsx",
1336
- "web/components/sweepstakes-provider.tsx",
1337
- "common/src/envs/constants.ts",
1338
- "web/hooks/use-saved-contract-metrics.ts",
1339
- "common/supabase/contracts.ts",
1340
- "web/components/SEO.tsx",
1341
- "common/src/util/share.ts"
1342
- ],
1343
- "fileDiffs": [
1344
- {
1345
- "path": "common/src/contract.ts",
1346
- "status": "modified",
1347
- "diff": "Index: common/src/contract.ts\n===================================================================\n--- common/src/contract.ts\t5d7a0b8 (parent)\n+++ common/src/contract.ts\ta3aeeb1 (commit)\n@@ -6,9 +6,9 @@\n import { Answer } from './answer'\n import { getLiquidity } from './calculate-cpmm'\n import { ContractComment } from './comment'\n import { ContractMetric } from './contract-metric'\n-import { CASH_SUFFIX, ENV_CONFIG } from './envs/constants'\n+import { ENV_CONFIG } from './envs/constants'\n import { Fees } from './fees'\n import { PollOption } from './poll-option'\n import { formatMoney, formatPercent } from './util/format'\n import { MultiBase64Points } from './chart'\n@@ -453,20 +453,8 @@\n }) {\n return `/${contract.creatorUsername}/${contract.slug}`\n }\n \n-export function twombaContractPath(contract: {\n- creatorUsername: string\n- slug: string\n- token?: ContractToken\n-}) {\n- const isCashContract = contract.token == 'CASH'\n- const cleanedSlug = contract.slug.replace(new RegExp(`${CASH_SUFFIX}$`), '')\n- return `/${contract.creatorUsername}/${cleanedSlug}${\n- isCashContract ? '?play=false' : '?play=true'\n- }`\n-}\n-\n export type CashType = {\n contract: Contract\n lastBetTime?: number\n pointsString: string\n@@ -488,9 +476,8 @@\n chartAnnotations: ChartAnnotation[]\n topics: Topic[]\n dashboards: { slug: string; title: string }[]\n pinnedComments: ContractComment[]\n- cash?: CashType\n }\n \n export type MaybeAuthedContractParams =\n | {\n"
1348
- },
1349
- {
1350
- "path": "web/components/buttons/share-embed-button.tsx",
1351
- "status": "modified",
1352
- "diff": "Index: web/components/buttons/share-embed-button.tsx\n===================================================================\n--- web/components/buttons/share-embed-button.tsx\t5d7a0b8 (parent)\n+++ web/components/buttons/share-embed-button.tsx\ta3aeeb1 (commit)\n@@ -1,17 +1,17 @@\n import { CodeIcon } from '@heroicons/react/outline'\n import toast from 'react-hot-toast'\n \n-import { Contract, contractPath, twombaContractPath } from 'common/contract'\n+import { Contract, contractPath } from 'common/contract'\n import { DOMAIN } from 'common/envs/constants'\n import { copyToClipboard } from 'web/lib/util/copy'\n import { track } from 'web/lib/service/analytics'\n import { Button } from './button'\n import clsx from 'clsx'\n \n export function embedContractCode(contract: Contract) {\n const title = contract.question\n- const src = `https://${DOMAIN}/embed${twombaContractPath(contract)}`\n+ const src = `https://${DOMAIN}/embed${contractPath(contract)}`\n return `<iframe src=\"${src}\" title=\"${title}\" frameborder=\"0\" style=\"position: relative; left:50%; transform: translateX(-50%); width:90%; height:18rem; max-width: 35rem;\"></iframe>`\n }\n \n export function ShareEmbedButton(props: {\n"
1353
- },
1354
- {
1355
- "path": "web/components/contract/contract-info-dialog.tsx",
1356
- "status": "modified",
1357
- "diff": "Index: web/components/contract/contract-info-dialog.tsx\n===================================================================\n--- web/components/contract/contract-info-dialog.tsx\t5d7a0b8 (parent)\n+++ web/components/contract/contract-info-dialog.tsx\ta3aeeb1 (commit)\n@@ -27,16 +27,16 @@\n import { InfoTooltip } from '../widgets/info-tooltip'\n import ShortToggle from '../widgets/short-toggle'\n import { Table } from '../widgets/table'\n import { ContractHistoryButton } from './contract-edit-history-button'\n-import { SweepsToggle } from '../sweeps/sweeps-toggle'\n import { useSweepstakes } from '../sweepstakes-provider'\n+import Link from 'next/link'\n+import { linkClass } from '../widgets/site-link'\n export const Stats = (props: {\n contract: Contract\n- setIsPlay: (isPlay: boolean) => void\n user?: User | null | undefined\n }) => {\n- const { contract, user, setIsPlay } = props\n+ const { contract, user } = props\n const { creatorId } = contract\n const shouldAnswersSumToOne =\n contract.mechanism === 'cpmm-multi-1'\n ? contract.shouldAnswersSumToOne\n@@ -337,26 +337,21 @@\n )}\n {sweepsEnabled && !isNonBetPollOrBountiedQuestion && (\n <tr>\n <td>Sweepstakes</td>\n- <td>\n- <SweepsToggle\n- sweepsEnabled={sweepsEnabled}\n- isPlay={isPlay}\n- onClick={() => {\n- if (prefersPlay && isPlay) {\n- setPrefersPlay(false)\n- setIsPlay(false)\n- } else if (!prefersPlay && !isPlay) {\n- setPrefersPlay(true)\n- setIsPlay(true)\n- } else if (prefersPlay && !isPlay) {\n- setIsPlay(true)\n- } else if (!prefersPlay && isPlay) {\n- setIsPlay(false)\n- }\n- }}\n- />\n+ <td className={linkClass}>\n+ <Link\n+ href={\n+ contract.token === 'CASH'\n+ ? `/${contract.creatorUsername}/${contract.slug.replace(\n+ '--cash',\n+ ''\n+ )}`\n+ : `/${contract.creatorUsername}/${contract.slug}--cash`\n+ }\n+ >\n+ {contract.token === 'CASH' ? 'True' : 'False'}\n+ </Link>\n </td>\n </tr>\n )}\n \n@@ -534,16 +529,14 @@\n )\n }\n \n export function ContractInfoDialog(props: {\n- playContract: Contract\n- statsContract: Contract\n+ contract: Contract\n user: User | null | undefined\n- setIsPlay: (isPlay: boolean) => void\n open: boolean\n setOpen: (open: boolean) => void\n }) {\n- const { playContract, statsContract, user, open, setOpen, setIsPlay } = props\n+ const { contract, user, open, setOpen } = props\n const isAdmin = useAdmin()\n const isTrusted = useTrusted()\n \n return (\n@@ -551,21 +544,21 @@\n open={open}\n setOpen={setOpen}\n className=\"bg-canvas-0 flex flex-col gap-4 rounded p-6\"\n >\n- <Stats contract={statsContract} user={user} setIsPlay={setIsPlay} />\n+ <Stats contract={contract} user={user} />\n \n {!!user && (\n <>\n <Row className=\"my-2 flex-wrap gap-2\">\n- <ContractHistoryButton contract={playContract} />\n- <ShareQRButton contract={playContract} />\n- <ShareIRLButton contract={playContract} />\n- <ShareEmbedButton contract={statsContract} />\n+ <ContractHistoryButton contract={contract} />\n+ <ShareQRButton contract={contract} />\n+ <ShareIRLButton contract={contract} />\n+ <ShareEmbedButton contract={contract} />\n </Row>\n <Row className=\"flex-wrap gap-2\">\n {isAdmin || isTrusted ? (\n- <SuperBanControl userId={playContract.creatorId} />\n+ <SuperBanControl userId={contract.creatorId} />\n ) : null}\n </Row>\n </>\n )}\n"
1358
- },
1359
- {
1360
- "path": "web/components/contract/contract-page.tsx",
1361
- "status": "modified",
1362
- "diff": "Index: web/components/contract/contract-page.tsx\n===================================================================\n--- web/components/contract/contract-page.tsx\t5d7a0b8 (parent)\n+++ web/components/contract/contract-page.tsx\ta3aeeb1 (commit)\n@@ -56,20 +56,15 @@\n import { useRelatedMarkets } from 'web/hooks/use-related-contracts'\n import { useReview } from 'web/hooks/use-review'\n import { useSaveCampaign } from 'web/hooks/use-save-campaign'\n import { useSaveContractVisitsLocally } from 'web/hooks/use-save-visits'\n-import {\n- useSavedContractMetrics,\n- useTopContractMetrics,\n-} from 'web/hooks/use-saved-contract-metrics'\n+import { useSavedContractMetrics } from 'web/hooks/use-saved-contract-metrics'\n import { useTracking } from 'web/hooks/use-tracking'\n import { usePrivateUser, useUser } from 'web/hooks/use-user'\n import { track } from 'web/lib/service/analytics'\n import { scrollIntoViewCentered } from 'web/lib/util/scroll'\n import { SpiceCoin } from 'web/public/custom-components/spiceCoin'\n import { YourTrades } from 'web/pages/[username]/[contractSlug]'\n-import { useSweepstakes } from '../sweepstakes-provider'\n-import { useRouter } from 'next/router'\n import { precacheAnswers } from 'web/hooks/use-answers'\n import { useIsPageVisible } from 'web/hooks/use-page-visible'\n import { api } from 'web/lib/api/api'\n import { shouldHideGraph } from 'common/contract-params'\n@@ -77,8 +72,9 @@\n import { FollowMarketButton } from '../buttons/follow-market-button'\n import { useSaveReferral } from 'web/hooks/use-save-referral'\n import { base64toPoints } from 'common/edge/og'\n import { useDisplayUserById } from 'web/hooks/use-user-supabase'\n+import Link from 'next/link'\n \n export function ContractPageContent(props: ContractParams) {\n const {\n comments,\n@@ -88,79 +84,20 @@\n chartAnnotations,\n topics,\n dashboards,\n pinnedComments,\n- cash,\n } = props\n \n- // sync query state with context\n- const { prefersPlay } = useSweepstakes()\n- const router = useRouter()\n- const livePlayContract = useLiveContract(props.contract)\n- const sweepsIsPossible = !!livePlayContract.siblingContractId\n- const [isPlay, setIsPlay] = useState<boolean | undefined>(prefersPlay)\n- const liveCashContract = props.cash\n- ? // eslint-disable-next-line react-hooks/rules-of-hooks\n- useLiveContract(props.cash.contract)\n- : null\n-\n- const liveContract =\n- !isPlay && liveCashContract ? liveCashContract : livePlayContract\n+ // Just use the contract that was navigated to directly\n+ const liveContract = useLiveContract(props.contract)\n const user = useUser()\n useSaveReferral(user, {\n defaultReferrerUsername: props.contract.creatorUsername,\n contractId: props.contract.id,\n })\n- // Read and set play state from the query\n- useEffect(() => {\n- if (!router.isReady) return\n- const playQuery = router.query.play\n- const queryIndicatesSweeps = playQuery === 'false'\n- const queryIndicatesPlay =\n- playQuery === 'true' ||\n- (playQuery === undefined &&\n- !sweepsIsPossible &&\n- prefersPlay === undefined)\n- if (queryIndicatesSweeps) {\n- if (sweepsIsPossible && isPlay) {\n- setIsPlay(false)\n- } else if (!sweepsIsPossible && !isPlay) {\n- setIsPlay(true)\n- }\n- } else if (queryIndicatesPlay && !isPlay) {\n- setIsPlay(true)\n- }\n- }, [isPlay, router.query, prefersPlay])\n \n- const setIsPlayAndQuery = (isPlay: boolean) => {\n- setIsPlay(isPlay)\n- setPlayStateInQuery(isPlay)\n- }\n-\n- const setPlayStateInQuery = (play: boolean) => {\n- const newQuery = { ...router.query, play: play.toString() }\n-\n- if (JSON.stringify(newQuery) !== JSON.stringify(router.query)) {\n- router.replace(\n- {\n- query: newQuery,\n- hash: router.asPath.split('#')[1],\n- },\n- undefined,\n- { shallow: true }\n- )\n- }\n- }\n-\n const myContractMetrics = useSavedContractMetrics(liveContract)\n- const topContractMetrics = useTopContractMetrics({\n- playContract: livePlayContract,\n- cashContract: liveCashContract,\n- prefersPlay: isPlay ?? false,\n- // TODO: do we really need this? leaderboards are below the fold. If we do, should add for cash as well\n- defaultTopManaTraders: props.topContractMetrics,\n- defaultTopCashTraders: [],\n- })\n+ const topContractMetrics = props.topContractMetrics\n \n const privateUser = usePrivateUser()\n const blockedUserIds = privateUser?.blockedUserIds ?? []\n \n@@ -181,41 +118,25 @@\n useEffect(() => {\n if ('answers' in props.contract) {\n precacheAnswers(props.contract.answers)\n }\n- if (props.cash?.contract && 'answers' in props.cash.contract) {\n- precacheAnswers(props.cash.contract.answers)\n- }\n }, [])\n \n- const playBetData = useBetData({\n- contractId: props.contract.id,\n- outcomeType: props.contract.outcomeType,\n+ const { bets, totalBets, yourNewBets, betPoints } = useBetData({\n+ contractId: liveContract.id,\n+ outcomeType: liveContract.outcomeType,\n userId: user?.id,\n lastBetTime: props.lastBetTime,\n totalBets: props.totalBets,\n- pointsString,\n- multiPointsString,\n+ pointsString: pointsString,\n+ multiPointsString: multiPointsString,\n })\n \n- const cashBetData = useBetData({\n- contractId: cash?.contract.id ?? '_',\n- outcomeType: cash?.contract.outcomeType,\n- userId: user?.id,\n- lastBetTime: cash?.lastBetTime,\n- totalBets: cash?.totalBets ?? 0,\n- pointsString: cash?.pointsString,\n- multiPointsString: cash?.multiPointsString,\n- })\n-\n- const { bets, totalBets, yourNewBets, betPoints } =\n- cash && !isPlay ? cashBetData : playBetData\n-\n const { isResolved, outcomeType, resolution, closeTime, creatorId } =\n liveContract\n- const { coverImageUrl } = livePlayContract\n+ const { coverImageUrl } = liveContract\n \n- const description = livePlayContract.description\n+ const description = liveContract.description\n \n const isAdmin = useAdmin()\n const isMod = useTrusted()\n const isCreator = creatorId === user?.id\n@@ -299,9 +220,9 @@\n }}\n priority\n />\n <ChangeBannerButton\n- contract={livePlayContract}\n+ contract={liveContract}\n className=\"absolute right-4 top-12\"\n />\n </div>\n )}\n@@ -336,11 +257,9 @@\n )}\n </Row>\n {(headerStuck || !coverImageUrl) && (\n <HeaderActions\n- setIsPlay={setIsPlayAndQuery}\n- playContract={livePlayContract}\n- currentContract={liveContract}\n+ contract={liveContract}\n initialHideGraph={initialHideGraph}\n hideGraph={hideGraph}\n setHideGraph={setHideGraph}\n />\n@@ -353,11 +272,9 @@\n <div>\n <BackButton className=\"pr-8\" />\n </div>\n <HeaderActions\n- setIsPlay={setIsPlayAndQuery}\n- playContract={livePlayContract}\n- currentContract={liveContract}\n+ contract={liveContract}\n initialHideGraph={initialHideGraph}\n hideGraph={hideGraph}\n setHideGraph={setHideGraph}\n />\n@@ -373,9 +290,9 @@\n isLarge\n className=\"mr-1\"\n />\n <EditableQuestionTitle\n- contract={livePlayContract}\n+ contract={liveContract}\n canEdit={isAdmin || isCreator || isMod}\n />\n </div>\n </Col>\n@@ -495,14 +412,27 @@\n {showResolver && <Spacer h={4} />}\n <CreatorSharePanel contract={liveContract} />\n </>\n )}\n- <ContractDescription\n- contractId={props.contract.id}\n- creatorId={props.contract.creatorId}\n- isSweeps={isCashContract}\n- description={description}\n- />\n+ {liveContract.token === 'CASH' ? (\n+ <span className=\"bg-canvas-50 rounded-md p-4\">\n+ See parent question for description and comments:{' '}\n+ <Link\n+ href={`/${\n+ liveContract.creatorUsername\n+ }/${liveContract.slug.replace('--cash', '')}`}\n+ >\n+ {liveContract.question}\n+ </Link>\n+ </span>\n+ ) : (\n+ <ContractDescription\n+ contractId={props.contract.id}\n+ creatorId={props.contract.creatorId}\n+ isSweeps={isCashContract}\n+ description={description}\n+ />\n+ )}\n <Row className=\"mb-4 items-center gap-2\">\n <MarketTopics\n contract={props.contract}\n dashboards={dashboards}\n@@ -552,11 +482,9 @@\n liveContract={liveContract}\n bets={bets}\n totalBets={totalBets}\n comments={comments}\n- totalPositions={\n- !isPlay && cash ? cash.totalPositions : props.totalPositions\n- }\n+ totalPositions={props.totalPositions}\n replyTo={replyTo}\n setReplyTo={setReplyTo}\n blockedUserIds={blockedUserIds}\n activeIndex={activeTabIndex}\n"
1363
- },
1364
- {
1365
- "path": "web/components/contract/contract-seo.tsx",
1366
- "status": "modified",
1367
- "diff": "Index: web/components/contract/contract-seo.tsx\n===================================================================\n--- web/components/contract/contract-seo.tsx\t5d7a0b8 (parent)\n+++ web/components/contract/contract-seo.tsx\ta3aeeb1 (commit)\n@@ -1,5 +1,5 @@\n-import { Contract, twombaContractPath } from 'common/contract'\n+import { Contract, contractPath } from 'common/contract'\n import { getSeoDescription, getContractOGProps } from 'common/contract-seo'\n import { removeUndefinedProps } from 'common/util/object'\n import { SEO } from '../SEO'\n \n@@ -20,9 +20,9 @@\n return (\n <SEO\n title={question}\n description={seoDesc}\n- url={twombaContractPath(contract)}\n+ url={contractPath(contract)}\n ogProps={{ props: ogCardProps, endpoint: 'market' }}\n shouldIgnore={contract.visibility !== 'public'}\n />\n )\n"
1368
- },
1369
- {
1370
- "path": "web/components/contract/header-actions.tsx",
1371
- "status": "modified",
1372
- "diff": "Index: web/components/contract/header-actions.tsx\n===================================================================\n--- web/components/contract/header-actions.tsx\t5d7a0b8 (parent)\n+++ web/components/contract/header-actions.tsx\ta3aeeb1 (commit)\n@@ -36,70 +36,60 @@\n import { ChangeBannerButton } from './change-banner-button'\n import { GoGraph } from 'react-icons/go'\n \n export function HeaderActions(props: {\n- playContract: Contract\n- setIsPlay: (isPlay: boolean) => void\n- currentContract: Contract\n+ contract: Contract\n initialHideGraph: boolean\n hideGraph: boolean\n setHideGraph: (hideGraph: boolean) => void\n }) {\n- const {\n- playContract,\n- currentContract,\n- initialHideGraph,\n- hideGraph,\n- setHideGraph,\n- setIsPlay,\n- } = props\n+ const { contract, initialHideGraph, hideGraph, setHideGraph } = props\n const user = useUser()\n const privateUser = usePrivateUser()\n- const isCreator = user?.id === playContract.creatorId\n+ const isCreator = user?.id === contract.creatorId\n \n const [detailsOpen, setDetailsOpen] = useState(false)\n const [repostOpen, setRepostOpen] = useState(false)\n const [reportOpen, setReportOpen] = useState(false)\n const [liquidityOpen, setLiquidityOpen] = useState(false)\n \n- const duplicateHref = duplicateContractHref(playContract)\n+ const duplicateHref = duplicateContractHref(contract)\n \n const isBlocked =\n- privateUser && privateUser.blockedContractIds?.includes(playContract.id)\n+ privateUser && privateUser.blockedContractIds?.includes(contract.id)\n \n const onBlock = async () => {\n await toast.promise(\n- api('market/:contractId/block', { contractId: playContract.id }),\n+ api('market/:contractId/block', { contractId: contract.id }),\n {\n loading: 'Blocking...',\n success: `You'll no longer see this question in your feed nor search.`,\n error: 'Error blocking user',\n }\n )\n }\n const onUnblock = async () => {\n- await api('market/:contractId/unblock', { contractId: playContract.id })\n+ await api('market/:contractId/unblock', { contractId: contract.id })\n }\n \n const markUninteresting = async () => {\n await updateUserDisinterestEmbedding({\n- contractId: playContract.id,\n- creatorId: playContract.creatorId,\n+ contractId: contract.id,\n+ creatorId: contract.creatorId,\n })\n toast(`We won't show you content like that again`, {\n icon: <TiVolumeMute className={'h-5 w-5 text-teal-500'} />,\n })\n }\n \n const contractOpenAndPublic =\n- !currentContract.isResolved &&\n- (currentContract.closeTime ?? Infinity) > Date.now() &&\n- currentContract.visibility == 'public'\n+ !contract.isResolved &&\n+ (contract.closeTime ?? Infinity) > Date.now() &&\n+ contract.visibility == 'public'\n \n const addLiquidityEnabled =\n user &&\n- (currentContract.mechanism == 'cpmm-1' ||\n- currentContract.mechanism == 'cpmm-multi-1') &&\n+ (contract.mechanism == 'cpmm-1' || contract.mechanism == 'cpmm-multi-1') &&\n contractOpenAndPublic\n \n const [following, setFollowing] = useState<boolean>()\n const [followingOpen, setFollowingOpen] = useState(false)\n@@ -107,9 +97,9 @@\n if (!user?.id) return\n db.from('contract_follows')\n .select('contract_id')\n .eq('follow_id', user.id)\n- .eq('contract_id', currentContract.id)\n+ .eq('contract_id', contract.id)\n .then((res) => {\n setFollowing((res.data?.length ?? 0) > 0)\n })\n }, [user?.id, followingOpen])\n@@ -131,12 +121,12 @@\n {\n name: following ? 'Unwatch' : 'Watch',\n onClick: async () => {\n if (following) {\n- await unfollowMarket(playContract.id, playContract.slug)\n+ await unfollowMarket(contract.id, contract.slug)\n setFollowing(false)\n } else {\n- await followMarket(playContract.id, playContract.slug)\n+ await followMarket(contract.id, contract.slug)\n setFollowing(true)\n }\n if (!user.hasSeenContractFollowModal) {\n await api('me/update', { hasSeenContractFollowModal: true })\n@@ -241,42 +231,37 @@\n ]\n \n return (\n <Row className=\"mr-4 shrink-0 items-center [&>*]:flex\">\n- {!playContract.coverImageUrl && isCreator && (\n- <ChangeBannerButton\n- contract={playContract}\n- className=\"ml-3 first:ml-0\"\n- />\n+ {!contract.coverImageUrl && isCreator && (\n+ <ChangeBannerButton contract={contract} className=\"ml-3 first:ml-0\" />\n )}\n- <FollowMarketIconButton contract={currentContract} user={user} />\n+ <FollowMarketIconButton contract={contract} user={user} />\n <CopyLinkOrShareButton\n- url={getShareUrl(currentContract, user?.username)}\n+ url={getShareUrl(contract, user?.username)}\n tooltip=\"Copy question share link\"\n className=\"text-ink-500 hover:text-ink-600\"\n size=\"xs\"\n eventTrackingName=\"copy market link\"\n- trackingInfo={{ contractId: currentContract.id }}\n+ trackingInfo={{ contractId: contract.id }}\n />\n <DropdownMenu items={dropdownItems} />\n <ContractInfoDialog\n- playContract={playContract}\n- statsContract={currentContract}\n+ contract={contract}\n user={user}\n- setIsPlay={setIsPlay}\n open={detailsOpen}\n setOpen={setDetailsOpen}\n />\n {repostOpen && (\n <RepostModal\n- playContract={playContract}\n+ playContract={contract}\n open={repostOpen}\n setOpen={setRepostOpen}\n />\n )}\n {addLiquidityEnabled && (\n <AddLiquidityModal\n- contract={currentContract}\n+ contract={contract}\n isOpen={liquidityOpen}\n setOpen={setLiquidityOpen}\n />\n )}\n@@ -284,11 +269,11 @@\n isModalOpen={reportOpen}\n label={'contract'}\n setIsModalOpen={setReportOpen}\n report={{\n- contentId: playContract.id,\n+ contentId: contract.id,\n contentType: 'contract',\n- contentOwnerId: playContract.creatorId,\n+ contentOwnerId: contract.creatorId,\n }}\n />\n <FollowMarketModal\n open={followingOpen}\n"
1373
- },
1374
- {
1375
- "path": "web/components/new-contract/contract-params-form.tsx",
1376
- "status": "modified",
1377
- "diff": "Index: web/components/new-contract/contract-params-form.tsx\n===================================================================\n--- web/components/new-contract/contract-params-form.tsx\t5d7a0b8 (parent)\n+++ web/components/new-contract/contract-params-form.tsx\ta3aeeb1 (commit)\n@@ -11,11 +11,11 @@\n MAX_DESCRIPTION_LENGTH,\n MAX_QUESTION_LENGTH,\n NUMBER_BUCKETS_MAX,\n NON_BETTING_OUTCOMES,\n- twombaContractPath,\n Visibility,\n PollVoterVisibility,\n+ contractPath,\n } from 'common/contract'\n import {\n getAnte,\n getUniqueBettorBonusAmount,\n@@ -650,9 +650,9 @@\n })\n \n // Await to clear form data from localstorage after navigate, since market is created.\n // Don't clear before navigate, because looks like a bug.\n- const path = twombaContractPath(newContract)\n+ const path = contractPath(newContract)\n await router.push(path)\n resetProperties()\n } catch (e) {\n console.error('error creating contract', e)\n"
1378
- },
1379
- {
1380
- "path": "web/middleware.ts",
1381
- "status": "modified",
1382
- "diff": "Index: web/middleware.ts\n===================================================================\n--- web/middleware.ts\t5d7a0b8 (parent)\n+++ web/middleware.ts\ta3aeeb1 (commit)\n@@ -1,24 +1,46 @@\n import { NextResponse, type NextRequest } from 'next/server'\n import { PROD_CONFIG } from 'common/envs/prod'\n \n export async function middleware(req: NextRequest) {\n- const path = req.nextUrl.pathname.replace('/api/', '')\n+ const url = req.nextUrl\n \n- if (pathsToSkip.includes(path)) {\n- return NextResponse.next()\n+ // Handle play parameter removal for all requests\n+ if (url.searchParams.has('play')) {\n+ url.searchParams.delete('play')\n+ return NextResponse.redirect(url, 308)\n }\n \n- return new Response('Permanent Redirect', {\n- status: 308,\n- headers: {\n- location: getProxiedRequestUrl(req, path),\n- },\n- })\n+ // Only run API proxy logic for API requests\n+ if (url.pathname.startsWith('/api/')) {\n+ const path = req.nextUrl.pathname.replace('/api/', '')\n+\n+ if (pathsToSkip.includes(path)) {\n+ return NextResponse.next()\n+ }\n+\n+ return new Response('Permanent Redirect', {\n+ status: 308,\n+ headers: {\n+ location: getProxiedRequestUrl(req, path),\n+ },\n+ })\n+ }\n+\n+ // For non-API requests, just continue normally\n+ return NextResponse.next()\n }\n \n export const config = {\n- matcher: ['/api/v0/:path*'],\n+ matcher: [\n+ // API proxy\n+ '/api/v0/:path*',\n+ // Contract pages - be specific about the format\n+ // This matches /username/contract-slug but not / or /browse etc\n+ '/([^/]+)/([^/]+)',\n+ // Embed pages\n+ '/embed/([^/]+)/([^/]+)',\n+ ],\n }\n \n const pathsToSkip = ['v0/deployment-id', 'v0/revalidate']\n \n"
1383
- },
1384
- {
1385
- "path": "web/pages/[username]/[contractSlug].tsx",
1386
- "status": "modified",
1387
- "diff": "Index: web/pages/[username]/[contractSlug].tsx\n===================================================================\n--- web/pages/[username]/[contractSlug].tsx\t5d7a0b8 (parent)\n+++ web/pages/[username]/[contractSlug].tsx\ta3aeeb1 (commit)\n@@ -7,19 +7,17 @@\n } from 'common/contract'\n import { ContractMetric } from 'common/contract-metric'\n import { getContractParams } from 'common/contract-params'\n import { base64toPoints } from 'common/edge/og'\n-import { CASH_SUFFIX } from 'common/envs/constants'\n-import { getContract, getContractFromSlug } from 'common/supabase/contracts'\n+import { getContractFromSlug } from 'common/supabase/contracts'\n import { removeUndefinedProps } from 'common/util/object'\n-import { pick, sortBy, uniqBy } from 'lodash'\n+import { sortBy, uniqBy } from 'lodash'\n import { ContractBetsTable } from 'web/components/bet/contract-bets-table'\n import { YourOrders } from 'web/components/bet/order-book'\n import { ContractPageContent } from 'web/components/contract/contract-page'\n import { ContractSEO } from 'web/components/contract/contract-seo'\n import { Col } from 'web/components/layout/col'\n import { Page } from 'web/components/layout/page'\n-import { useSweepstakes } from 'web/components/sweepstakes-provider'\n import { Title } from 'web/components/widgets/title'\n import { useIsIframe } from 'web/hooks/use-is-iframe'\n import { useIsPageVisible } from 'web/hooks/use-page-visible'\n import { useUser } from 'web/hooks/use-user'\n@@ -30,9 +28,9 @@\n \n export async function getStaticProps(ctx: {\n params: { username: string; contractSlug: string }\n }) {\n- const { username, contractSlug } = ctx.params\n+ const { contractSlug } = ctx.params\n const adminDb = await initSupabaseAdmin()\n const contract = await getContractFromSlug(adminDb, contractSlug)\n \n if (!contract) {\n@@ -50,44 +48,14 @@\n },\n }\n }\n \n- if (contract.token === 'CASH') {\n- const manaContract = contract.siblingContractId\n- ? await getContract(adminDb, contract.siblingContractId)\n- : null\n- const slug = manaContract?.slug ?? contractSlug.replace(CASH_SUFFIX, '')\n-\n- return {\n- redirect: {\n- destination: `/${username}/${slug}?play=false`,\n- permanent: false,\n- },\n- }\n- }\n-\n const props = await getContractParams(contract, adminDb)\n \n- // Fetch sibling contract if it exists\n- let cash = undefined\n- if (contract.siblingContractId) {\n- const cashContract = await getContract(adminDb, contract.siblingContractId)\n- if (cashContract) {\n- const params = await getContractParams(cashContract, adminDb)\n- cash = pick(params, [\n- 'contract',\n- 'lastBetTime',\n- 'pointsString',\n- 'multiPointsString',\n- 'totalPositions',\n- 'totalBets',\n- ])\n- }\n- }\n return {\n props: {\n state: 'authed',\n- params: removeUndefinedProps({ ...props, cash }),\n+ params: removeUndefinedProps(props),\n },\n }\n }\n \n@@ -109,39 +77,23 @@\n return <NonPrivateContractPage contractParams={props.params} />\n }\n \n function NonPrivateContractPage(props: { contractParams: ContractParams }) {\n- const { contract, pointsString, cash } = props.contractParams\n- const { prefersPlay } = useSweepstakes()\n+ const { contract, pointsString } = props.contractParams\n \n const points = pointsString ? base64toPoints(pointsString) : []\n- const cashPoints = cash\n- ? cash.pointsString\n- ? base64toPoints(cash.pointsString)\n- : []\n- : null\n \n const inIframe = useIsIframe()\n if (!contract) {\n return <Custom404 customText=\"Unable to fetch question\" />\n }\n if (inIframe) {\n- return (\n- <ContractEmbedPage\n- contract={contract}\n- points={points}\n- cashContract={cash ? cash.contract : null}\n- cashPoints={cashPoints}\n- />\n- )\n+ return <ContractEmbedPage contract={contract} points={points} />\n }\n \n return (\n <Page trackPageView={false} className=\"xl:col-span-10\">\n- <ContractSEO\n- contract={!prefersPlay && cash?.contract ? cash.contract : contract}\n- points={pointsString}\n- />\n+ <ContractSEO contract={contract} points={pointsString} />\n <ContractPageContent key={contract.id} {...props.contractParams} />\n </Page>\n )\n }\n"
1388
- },
1389
- {
1390
- "path": "web/pages/embed/[username]/[contractSlug].tsx",
1391
- "status": "modified",
1392
- "diff": "Index: web/pages/embed/[username]/[contractSlug].tsx\n===================================================================\n--- web/pages/embed/[username]/[contractSlug].tsx\t5d7a0b8 (parent)\n+++ web/pages/embed/[username]/[contractSlug].tsx\ta3aeeb1 (commit)\n@@ -7,15 +7,15 @@\n } from 'common/chart'\n import {\n CPMMMultiContract,\n Contract,\n+ contractPath,\n getMainBinaryMCAnswer,\n isBinaryMulti,\n- twombaContractPath,\n } from 'common/contract'\n import { getMultiBetPoints, getSingleBetPoints } from 'common/contract-params'\n import { DOMAIN, TRADE_TERM } from 'common/envs/constants'\n-import { getContract, getContractFromSlug } from 'common/supabase/contracts'\n+import { getContractFromSlug } from 'common/supabase/contracts'\n import { formatMoney } from 'common/util/format'\n import { getShareUrl } from 'common/util/share'\n import Image from 'next/image'\n import { useRouter } from 'next/router'\n@@ -82,16 +82,8 @@\n }\n \n const points = await getHistoryData(contract)\n \n- let cashContract = null\n- let cashPoints = null\n- if (contract.siblingContractId) {\n- cashContract = await getContract(db, contract.siblingContractId)\n- if (cashContract) {\n- cashPoints = await getHistoryData(cashContract)\n- }\n- }\n let multiPoints = null\n if (\n contract.outcomeType === 'MULTI_NUMERIC' ||\n contract.outcomeType === 'DATE'\n@@ -108,9 +100,9 @@\n pointsToBase64(v)\n )\n }\n return {\n- props: { contract, points, multiPoints, cashContract, cashPoints },\n+ props: { contract, points, multiPoints },\n }\n }\n \n export async function getStaticPaths() {\n@@ -120,34 +112,24 @@\n export default function ContractEmbedPage(props: {\n contract: Contract\n points: Points | null\n multiPoints?: MultiBase64Points | null\n- cashContract: Contract | null\n- cashPoints: Points | null\n }) {\n const [showQRCode, setShowQRCode] = useState(false)\n- const [isCash, setIsCash] = useState(false)\n- const { cashContract, points, cashPoints } = props\n+ const { points } = props\n const multiPoints = props.multiPoints\n ? unserializeBase64Multi(props.multiPoints)\n : null\n \n const contract = useLiveContract(props.contract)\n- const liveCashContract = cashContract\n- ? // eslint-disable-next-line react-hooks/rules-of-hooks\n- useLiveContract(cashContract)\n- : null\n \n const router = useRouter()\n \n useEffect(() => {\n if (router.isReady) {\n if (router.query.qr !== undefined) {\n setShowQRCode(true)\n }\n- if (router.query.play !== undefined) {\n- setIsCash(router.query.play === 'false')\n- }\n }\n }, [router.isReady, router.query])\n \n useEffect(() => {\n@@ -159,19 +141,19 @@\n hostname: window.location.hostname,\n })\n }, [contract?.creatorId, contract?.id, contract?.slug])\n \n- if (!contract || (isCash && !liveCashContract)) {\n+ if (!contract) {\n return <Custom404 />\n }\n \n return (\n <>\n <NoSEO />\n- <ContractSEO contract={isCash ? liveCashContract! : contract} />\n+ <ContractSEO contract={contract} />\n <ContractSmolView\n- contract={isCash ? liveCashContract! : contract}\n- points={isCash ? cashPoints : points}\n+ contract={contract}\n+ points={points}\n multiPoints={multiPoints}\n showQRCode={showQRCode}\n />\n </>\n@@ -286,9 +268,9 @@\n const isPoll = outcomeType === 'POLL'\n const isMultiNumeric = outcomeType === 'MULTI_NUMERIC'\n const isDate = outcomeType === 'DATE'\n \n- const href = `https://${DOMAIN}${twombaContractPath(contract)}`\n+ const href = `https://${DOMAIN}${contractPath(contract)}`\n const user = useUser()\n const shareUrl = getShareUrl(contract, user?.username)\n \n const showMultiChart = isMulti && !!props.multiPoints\n"
1393
- }
1394
- ]
1395
- },
1396
- {
1397
- "id": "fix-cancel-payouts",
1398
- "sha": "67f59be5ede4d0aa44c0fc8e09f26f06942d1e04",
1399
- "parentSha": "bb1bd578b325c28a34b3c3460357e99418890b7d",
1400
- "spec": "Implement the following changes to fix N/A (CANCEL) payouts and permit CANCEL on linked multiple-choice markets:\n\n1) Allow CANCEL on linked cpmm-multi-1 markets\n- File: backend/shared/src/resolve-market-helpers.ts\n- In resolveMarketHelper, remove the guard that blocks resolving linked multi-choice markets to CANCEL. Specifically, delete the isLinkedMC computed flag and the conditional that throws an APIError when isLinkedMC && outcome === 'CANCEL'. Keep the isIndieMC flag as-is because it is used for metrics updates. No other behavior changes are required in this file.\n\n2) Compute trader CANCEL payouts as net invested\n- File: common/src/payouts-fixed.ts\n- In getFixedCancelPayouts(contractMetrics, liquidities):\n - Update traderPayouts so that, for each ContractMetric, payout equals (totalAmountInvested ?? 0) - (totalAmountSold ?? 0), not the invested field. Preserve the userId mapping and keep liquidityPayouts unchanged (refund liquidity.amount). Leave the existing comment about creator fee clawback unchanged.\n\n3) Ensure CANCEL flow continues to use the fixed payout function\n- No code changes needed in common/src/payouts.ts; verify that the CANCEL branches for cpmm-1 and cpmm-multi-1 continue to return getFixedCancelPayouts(contractMetrics, liquidities). This ensures the updated payout logic is applied for both binary and multiple-choice CANCEL resolutions, including independent-answer CANCEL.\n\n4) Behavior to validate manually after the change\n- Resolving a cpmm-multi-1 market with shouldAnswersSumToOne to CANCEL should succeed (no 403 in the helper) and produce payouts that refund traders’ net invested and liquidity providers’ amounts.\n- For independent multi-answer CANCEL (answer-level), refunds should be computed using only the metrics for the specified answer (as already filtered by the caller), applying the same net invested logic.\n- Existing negative payout threshold handling and unique bettor bonus clawback on CANCEL should remain unchanged.",
1401
- "prompt": "Enable cancellation for linked multiple-choice markets and correct cancel payouts to refund net invested.\n\nSpecifically: allow cpmm-multi-1 markets where answers sum to one to be resolved as CANCEL (remove the server-side restriction that throws on this case); and change the cancel payout calculation so that trader payouts refund the net amount invested (amount bought minus amount sold) based on contract metrics. Ensure the cancel payout logic remains the same for liquidity providers and that existing resolution and notification flows continue to work.",
1402
- "supplementalFiles": [
1403
- "common/src/payouts.ts",
1404
- "common/src/contract-metric.ts",
1405
- "backend/api/src/resolve-market.ts",
1406
- "backend/api/src/unlist-and-cancel-user-contracts.ts"
1407
- ],
1408
- "fileDiffs": [
1409
- {
1410
- "path": "backend/shared/src/resolve-market-helpers.ts",
1411
- "status": "modified",
1412
- "diff": "Index: backend/shared/src/resolve-market-helpers.ts\n===================================================================\n--- backend/shared/src/resolve-market-helpers.ts\tbb1bd57 (parent)\n+++ backend/shared/src/resolve-market-helpers.ts\t67f59be (commit)\n@@ -89,15 +89,8 @@\n unresolvedContract,\n answerId\n )\n const isIndieMC = c.mechanism === 'cpmm-multi-1' && !c.shouldAnswersSumToOne\n- const isLinkedMC = c.mechanism === 'cpmm-multi-1' && c.shouldAnswersSumToOne\n- if (isLinkedMC && outcome === 'CANCEL') {\n- throw new APIError(\n- 403,\n- 'Resolving linked multi-choice markets to N/A is temporarily disabled.'\n- )\n- }\n \n unresolvedContract = c as MarketContract\n if (unresolvedContract.isResolved) {\n throw new APIError(403, 'Contract is already resolved')\n"
1413
- },
1414
- {
1415
- "path": "common/src/payouts-fixed.ts",
1416
- "status": "modified",
1417
- "diff": "Index: common/src/payouts-fixed.ts\n===================================================================\n--- common/src/payouts-fixed.ts\tbb1bd57 (parent)\n+++ common/src/payouts-fixed.ts\t67f59be (commit)\n@@ -9,12 +9,16 @@\n export const getFixedCancelPayouts = (\n contractMetrics: ContractMetric[],\n liquidities: LiquidityProvision[]\n ): PayoutInfo => {\n- const traderPayouts = contractMetrics.map((metric) => ({\n- userId: metric.userId,\n- payout: metric.invested,\n- }))\n+ const traderPayouts = contractMetrics.map((metric) => {\n+ const payout =\n+ (metric.totalAmountInvested ?? 0) - (metric.totalAmountSold ?? 0)\n+ return {\n+ userId: metric.userId,\n+ payout,\n+ }\n+ })\n \n const liquidityPayouts = liquidities.map((liquidity) => ({\n userId: liquidity.userId,\n payout: liquidity.amount,\n"
1418
- }
1419
- ]
1420
- },
1421
- {
1422
- "id": "restore-profit-unresolve",
1423
- "sha": "bb1bd578b325c28a34b3c3460357e99418890b7d",
1424
- "parentSha": "2cbe1fc8d770565f1d011ae61ecc66333c42c6ad",
1425
- "spec": "Implement profit restoration on unresolve by reverting each user's contract metric profit to the previously stored profit value and ensuring that value is tracked during normal metric calculation.\n\nMake the following changes:\n\n1) ContractMetric type update\n- In common/src/contract-metric.ts, add an optional numeric field previousProfit to the ContractMetric type so that historical profit can be persisted alongside current profit.\n\n2) Track and surface previousProfit during profit calculations\n- In common/src/calculate-metrics.ts, in the function that calculates profit/payout values for unresolved/cancel states (the one that maps from a user metric state to updated metrics), destructure and expose previousProfit from the input metric and set the returned metric's profit based on that previousProfit value. Ensure the returned metric includes previousProfit as a top-level field so it persists in storage.\n\n3) Helper for fetching contract metrics by contract/answer\n- In backend/shared/src/helpers/user-contract-metrics.ts, add a function getContractMetricsForContract(pg, contractId, answerIdsOrNull) that returns all user_contract_metrics rows for the given contract and optional subset of answers (when provided). Use the user_contract_metrics table's JSONB data field to map rows to ContractMetric objects.\n- Preserve or correct the import ordering for SupabaseDirectClient/createSupabaseDirectClient in this file if necessary, and keep bulk update helpers available.\n\n4) Profit reset during unresolve\n- In backend/api/src/unresolve.ts, import getContractMetricsForContract and bulkUpdateContractMetrics.\n- When unresolving a contract with no answerId (entire contract unresolve):\n - Fetch all contract metrics for the contract using getContractMetricsForContract with a null answer filter.\n - Create an update payload that sets each metric's profit field to metric.previousProfit when present; otherwise leave profit unchanged.\n - Call bulkUpdateContractMetrics with the updated metrics to persist the reverted profit in the DB.\n- When unresolving a single answer (answerId provided):\n - Fetch contract metrics filtered to the given answer using getContractMetricsForContract with [answerId].\n - Apply the same profit reset logic and bulk update.\n\n5) Multi-answer unresolve consistency\n- Ensure the new profit reset logic executes before multi-answer answer record updates and broadcasts so that downstream consumers see consistent metrics post-unresolve.\n\n6) Broadcasting and other flows\n- Keep existing broadcasting calls and answer update behavior intact. Do not modify transaction reversal or contract field resets beyond adding the metrics profit reset.\n\nAcceptance criteria:\n- Unresolving a fully resolved market resets each user's contract metric profit to the previously stored value and persists it in user_contract_metrics (data JSONB and any dependent columns updated by helpers).\n- Unresolving a single answer in a multi-answer market resets the profit only for metrics tied to that answer.\n- The ContractMetric type includes previousProfit, and calculate-metrics includes and returns this field so it can be used later.\n- No change to external APIs or client behavior beyond correct profit values after unresolve.\n",
1426
- "prompt": "Add support for restoring user contract profits when a market resolution is undone. Extend the contract metric type to store a prior profit value, ensure the metric calculation pipeline preserves that prior value, and modify the unresolve flow to bulk reset the current profit from the prior value. Provide a helper to fetch all metrics for a contract or a specific answer, and apply the profit reset for full or per-answer unresolve. Keep the rest of the unresolve and broadcast behavior unchanged.",
1427
- "supplementalFiles": [
1428
- "backend/shared/src/resolve-market-helpers.ts",
1429
- "backend/shared/src/update-contract-metrics-core.ts",
1430
- "backend/shared/src/update-user-metrics-with-bets.ts",
1431
- "common/src/supabase/contract-metrics.ts"
1432
- ],
1433
- "fileDiffs": [
1434
- {
1435
- "path": "backend/api/src/unresolve.ts",
1436
- "status": "modified",
1437
- "diff": "Index: backend/api/src/unresolve.ts\n===================================================================\n--- backend/api/src/unresolve.ts\t2cbe1fc (parent)\n+++ backend/api/src/unresolve.ts\tbb1bd57 (commit)\n@@ -28,8 +28,12 @@\n import { convertTxn } from 'common/supabase/txns'\n import { HOUSE_LIQUIDITY_PROVIDER_ID } from 'common/antes'\n import { getCpmmProbability } from 'common/calculate-cpmm'\n import { removeUndefinedProps } from 'common/util/object'\n+import {\n+ bulkUpdateContractMetrics,\n+ getContractMetricsForContract,\n+} from 'shared/helpers/user-contract-metrics'\n \n const TXNS_PR_MERGED_ON = 1675693800000 // #PR 1476\n \n export const unresolve: APIHandler<'unresolve'> = async (\n@@ -291,8 +295,20 @@\n })\n await updateContract(pg, contractId, updatedAttrs)\n await recordContractEdit(contract, userId, Object.keys(updatedAttrs))\n }\n+ if (!answerId) {\n+ const contractMetrics = await getContractMetricsForContract(\n+ pg,\n+ contract.id,\n+ null\n+ )\n+ const updateMetrics = contractMetrics.map((metric) => ({\n+ ...metric,\n+ profit: metric.previousProfit ?? metric.profit,\n+ }))\n+ await bulkUpdateContractMetrics(updateMetrics, pg)\n+ }\n if (contract.mechanism === 'cpmm-multi-1' && !answerId) {\n // remove resolutionTime and resolverId from all answers in the contract\n const newAnswers = await pg.map(\n `\n@@ -313,8 +329,18 @@\n convertAnswer\n )\n broadcastUpdatedAnswers(contractId, newAnswers)\n } else if (answerId) {\n+ const contractMetrics = await getContractMetricsForContract(\n+ pg,\n+ contract.id,\n+ [answerId]\n+ )\n+ const updateMetrics = contractMetrics.map((metric) => ({\n+ ...metric,\n+ profit: metric.previousProfit ?? metric.profit,\n+ }))\n+ await bulkUpdateContractMetrics(updateMetrics, pg)\n const answer = await pg.one(\n `\n update answers\n set\n"
1438
- },
1439
- {
1440
- "path": "backend/shared/src/helpers/user-contract-metrics.ts",
1441
- "status": "modified",
1442
- "diff": "Index: backend/shared/src/helpers/user-contract-metrics.ts\n===================================================================\n--- backend/shared/src/helpers/user-contract-metrics.ts\t2cbe1fc (parent)\n+++ backend/shared/src/helpers/user-contract-metrics.ts\tbb1bd57 (commit)\n@@ -4,10 +4,10 @@\n MarginalBet,\n } from 'common/calculate-metrics'\n import { bulkUpsert, bulkUpsertQuery } from 'shared/supabase/utils'\n import {\n- SupabaseDirectClient,\n createSupabaseDirectClient,\n+ SupabaseDirectClient,\n } from 'shared/supabase/init'\n import { ContractMetric } from 'common/contract-metric'\n import { Tables } from 'common/supabase/utils'\n import { log } from 'shared/utils'\n@@ -123,9 +123,9 @@\n contractId: string,\n answerIds: string[],\n includeNullAnswer: boolean\n ) => {\n- const metrics = await pg.map<ContractMetric>(\n+ return await pg.map<ContractMetric>(\n `select data from user_contract_metrics\n where contract_id = $1\n and user_id = any ($2)\n and ($3 is null or answer_id = any ($3) ${\n@@ -134,6 +134,19 @@\n `,\n [contractId, userIds, answerIds.length > 0 ? answerIds : null],\n (row) => row.data as ContractMetric\n )\n- return metrics\n }\n+export const getContractMetricsForContract = async (\n+ pg: SupabaseDirectClient,\n+ contractId: string,\n+ answerIds: string[] | null\n+) => {\n+ return await pg.map<ContractMetric>(\n+ `select data from user_contract_metrics\n+ where contract_id = $1\n+ and ($2 is null or answer_id = any ($2))\n+ `,\n+ [contractId, answerIds?.length ? answerIds : null],\n+ (row) => row.data as ContractMetric\n+ )\n+}\n"
1443
- },
1444
- {
1445
- "path": "common/src/calculate-metrics.ts",
1446
- "status": "modified",
1447
- "diff": "Index: common/src/calculate-metrics.ts\n===================================================================\n--- common/src/calculate-metrics.ts\t2cbe1fc (parent)\n+++ common/src/calculate-metrics.ts\tbb1bd57 (commit)\n@@ -334,8 +334,9 @@\n totalShares,\n hasNoShares,\n hasYesShares,\n invested,\n+ profit: previousProfit,\n } = um\n const soldOut = !hasNoShares && !hasYesShares\n const payout =\n newState === 'CANCEL'\n@@ -356,8 +357,9 @@\n ...um,\n payout,\n profit,\n profitPercent,\n+ previousProfit,\n }\n }\n \n export const calculateAnswerMetricsWithNewBetsOnly = (\n"
1448
- },
1449
- {
1450
- "path": "common/src/contract-metric.ts",
1451
- "status": "modified",
1452
- "diff": "Index: common/src/contract-metric.ts\n===================================================================\n--- common/src/contract-metric.ts\t2cbe1fc (parent)\n+++ common/src/contract-metric.ts\tbb1bd57 (commit)\n@@ -25,8 +25,9 @@\n totalAmountSold: number // This is the sum of all negative amounts/redemptions\n totalAmountInvested: number // This is the sum of all positive amounts\n profit: number\n profitPercent: number\n+ previousProfit?: number\n from:\n | {\n // Monthly is not updated atm bc it's not used\n [period: string]: {\n"
1453
- }
1454
- ]
1455
- },
1456
- {
1457
- "id": "add-liquidity-column",
1458
- "sha": "dd94ce33fc3eba2eb7d79e14bd90bf6698cb1a0a",
1459
- "parentSha": "b90b4457114e3974be14bf6330abb45d2d69f3b0",
1460
- "spec": "Implement a new Liquidity column in the contract tables and combined results views, and adjust a UI width in post rows.\n\nScope:\n1) Define a reusable Liquidity column alongside other column formats.\n2) Integrate the Liquidity column into the default columns of the contracts table and combined results.\n3) Adjust the width of the unique users display in post rows.\n\nDetails:\n- In web/components/contract/contract-table-col-formats.tsx\n - Export a new column object (liquidityColumn) following the existing ColumnFormat structure (header, content, width) and colocated with boostedColumn, traderColumn, probColumn, and actionColumn.\n - The header should read \"Liquidity\".\n - The content should:\n - Read the contract's totalLiquidity (treat as 0 if missing).\n - If the contract has answers (multi), determine the liquidity tier using a tier utility that takes liquidity and the number of answers; otherwise use a single-liquidity tier utility.\n - Render a tooltip showing the full formatted total liquidity amount.\n - Render a small row with a tier-based icon (empty, half, or full droplet), and on small screens show a short-formatted numeric value.\n - Ensure needed utilities are imported (tier helpers, money/short number formatters), along with Tooltip, Row, react-icons droplet icons, and clsx.\n - Set a responsive width similar to other compact columns (e.g., narrow width on small screens with a slightly wider width on larger screens), consistent with how other columns define width.\n\n- In web/components/contract/contracts-table.tsx\n - Add liquidityColumn to the default columns array for ContractsTable so that it appears between the probability and action columns (ordering should follow prob before liquidity, then action, matching the diff placement).\n - Ensure liquidityColumn is included in the imports from contract-table-col-formats.\n\n- In web/components/contract/combined-results.tsx\n - Include liquidityColumn when building the contractDisplayColumns array for ContractRow, positioned before the prob and action columns.\n - Ensure liquidityColumn is imported from contract-table-col-formats.\n\n- In web/components/posts/post-row.tsx\n - Update the width of the row showing unique users (the Row wrapping the user count) from a smaller fixed width to a larger fixed width to accommodate the updated layout, maintaining alignment and spacing with its icon.\n\nBehavioral expectations:\n- On pages using ContractsTable, the new Liquidity column is visible with an appropriate header and icon indicating liquidity tier. Hovering shows a tooltip with the full formatted liquidity amount.\n- On combined results views that include contracts, the Liquidity column is included in the constructed columns list.\n- The user count area in post rows has a wider width to improve spacing.\n- There should be no runtime or type errors; imports resolve to existing utilities and components.\n\nNon-goals:\n- Do not alter any API endpoints or server behavior.\n- Do not change calculation logic for liquidity; just display and appropriate tiering based on existing utilities.\n- Do not change other column orders or widths beyond the ones specified.",
1461
- "prompt": "Add a liquidity indicator column to the contract table and combined results views. The column should show a small droplet icon representing a tier based on the contract’s liquidity and, on small screens, a short-formatted number. Include a tooltip that displays the full formatted liquidity amount on hover. Make sure this new column is added to the default columns for the main contracts table and also appears in the combined results contract rows. Additionally, slightly increase the width of the unique users row in post rows to improve spacing. Follow existing patterns in the codebase for column definitions, tooltips, formatting, and tier utilities.",
1462
- "supplementalFiles": [
1463
- "common/src/tier.ts",
1464
- "common/util/format.ts",
1465
- "web/components/tiers/liquidity-tooltip.tsx",
1466
- "web/components/contract/contracts-table.tsx",
1467
- "web/components/contract/combined-results.tsx"
1468
- ],
1469
- "fileDiffs": [
1470
- {
1471
- "path": "web/components/contract/combined-results.tsx",
1472
- "status": "modified",
1473
- "diff": "Index: web/components/contract/combined-results.tsx\n===================================================================\n--- web/components/contract/combined-results.tsx\tb90b445 (parent)\n+++ web/components/contract/combined-results.tsx\tdd94ce3 (commit)\n@@ -10,8 +10,9 @@\n boostedColumn,\n traderColumn,\n probColumn,\n actionColumn,\n+ liquidityColumn,\n } from './contract-table-col-formats'\n import { buildArray } from 'common/util/array'\n \n type CombinedResultsProps = {\n@@ -62,8 +63,9 @@\n // Define columns for ContractRow, similar to how ContractsTable did\n const contractDisplayColumns = buildArray([\n !hasBets && boostedColumn,\n traderColumn,\n+ liquidityColumn,\n probColumn,\n !hideActions && actionColumn,\n ])\n \n"
1474
- },
1475
- {
1476
- "path": "web/components/contract/contract-table-col-formats.tsx",
1477
- "status": "modified",
1478
- "diff": "Index: web/components/contract/contract-table-col-formats.tsx\n===================================================================\n--- web/components/contract/contract-table-col-formats.tsx\tb90b445 (parent)\n+++ web/components/contract/contract-table-col-formats.tsx\tdd94ce3 (commit)\n@@ -7,8 +7,15 @@\n import { ContractStatusLabel } from './contracts-table'\n import { useHasContractMetrics } from 'web/hooks/use-saved-contract-metrics'\n import { Tooltip } from '../widgets/tooltip'\n import { BoostedTooltip } from './boost-column'\n+import {\n+ getTierIndexFromLiquidity,\n+ getTierIndexFromLiquidityAndAnswers,\n+} from 'common/src/tier'\n+import { formatMoney, shortFormatNumber } from 'common/util/format'\n+import { BsDroplet, BsDropletFill, BsDropletHalf } from 'react-icons/bs'\n+import clsx from 'clsx'\n \n export type ColumnFormat = {\n header: string\n content: (props: { contract: Contract }) => JSX.Element\n@@ -102,8 +109,48 @@\n },\n width: 'w-6',\n }\n \n+export const liquidityColumn = {\n+ header: 'Liquidity',\n+ content: (props: { contract: Contract }) => {\n+ const { contract } = props\n+\n+ // Check if contract has totalLiquidity field and it's above default tier\n+ const totalLiquidity =\n+ 'totalLiquidity' in contract ? contract.totalLiquidity : 0\n+ const liquidityTier =\n+ 'answers' in contract\n+ ? getTierIndexFromLiquidityAndAnswers(\n+ totalLiquidity,\n+ contract.answers.length\n+ )\n+ : getTierIndexFromLiquidity(totalLiquidity)\n+\n+ // if (liquidityTier < 2) {\n+ // return <div />\n+ // }\n+\n+ return (\n+ <Tooltip text={`Total liquidity: ${formatMoney(totalLiquidity)}`}>\n+ <Row className=\"text-ink-500 items-center justify-start gap-0.5\">\n+ {liquidityTier < 1 ? (\n+ <BsDroplet className={clsx('h-3.5 w-3.5')} />\n+ ) : liquidityTier < 2 ? (\n+ <BsDropletHalf className={clsx('h-3.5 w-3.5')} />\n+ ) : (\n+ <BsDropletFill className={clsx('h-3.5 w-3.5')} />\n+ )}\n+ <span className=\"text-ink-700 block sm:hidden\">\n+ {shortFormatNumber(totalLiquidity)}\n+ </span>\n+ </Row>\n+ </Tooltip>\n+ )\n+ },\n+ width: 'sm:w-[40px] w-[70px]',\n+}\n+\n export const actionColumn = {\n header: 'Action',\n content: (props: { contract: Contract }) => (\n <Action contract={props.contract} />\n"
1479
- },
1480
- {
1481
- "path": "web/components/contract/contracts-table.tsx",
1482
- "status": "modified",
1483
- "diff": "Index: web/components/contract/contracts-table.tsx\n===================================================================\n--- web/components/contract/contracts-table.tsx\tb90b445 (parent)\n+++ web/components/contract/contracts-table.tsx\tdd94ce3 (commit)\n@@ -28,8 +28,9 @@\n probColumn,\n traderColumn,\n ColumnFormat,\n boostedColumn,\n+ liquidityColumn,\n } from './contract-table-col-formats'\n import { UserHovercard } from '../user/user-hovercard'\n import { getFormattedNumberExpectedValue } from 'common/src/number'\n import { removeEmojis } from 'common/util/string'\n@@ -56,9 +57,15 @@\n const {\n contracts,\n onContractClick,\n highlightContractIds,\n- columns = [boostedColumn, traderColumn, probColumn, actionColumn],\n+ columns = [\n+ boostedColumn,\n+ traderColumn,\n+ probColumn,\n+ liquidityColumn,\n+ actionColumn,\n+ ],\n hideAvatar,\n contractAnswers,\n showPosition,\n } = props\n"
1484
- },
1485
- {
1486
- "path": "web/components/posts/post-row.tsx",
1487
- "status": "modified",
1488
- "diff": "Index: web/components/posts/post-row.tsx\n===================================================================\n--- web/components/posts/post-row.tsx\tb90b445 (parent)\n+++ web/components/posts/post-row.tsx\tdd94ce3 (commit)\n@@ -73,9 +73,9 @@\n >\n <Tooltip\n text={`${post.uniqueUsers} unique users commented or reacted`}\n >\n- <Row className=\"text-ink-700 w-[2.1rem] items-center justify-start gap-0.5\">\n+ <Row className=\"text-ink-700 w-[4.62rem] items-center justify-start gap-0.5\">\n <UserIcon className={'text-ink-400 h-4 w-4 shrink-0'} />\n {post.uniqueUsers}\n </Row>\n </Tooltip>\n"
1489
- }
1490
- ]
1491
- },
1492
- {
1493
- "id": "add-profit-ordering",
1494
- "sha": "15ee0e8f57080424cf1384f64f18323e7d85ea1d",
1495
- "parentSha": "2812fd8212ae8cec0761a0c45c0a22ca6d17db3f",
1496
- "spec": "Implement ordering and deprecate obsolete filtering for the get-user-contract-metrics-with-contracts API.\n\nRequired changes:\n\n1) Backend endpoint behavior\n- File: backend/api/src/get-user-contract-metrics-with-contracts.ts\n - Accept an optional `order` prop with allowed values: 'lastBetTime' (default) or 'profit'.\n - Determine the ORDER BY clause from `order`: when 'profit', order by aggregated profit descending; otherwise, order by most recent lastBetTime descending with NULLS LAST. Do not alter returned shape: still return { metricsByContract, contracts }.\n - Remove support for the deprecated `inMani` prop and delete its conditional SQL filter (which checked siblingContractId and has_shares). The visibility filter remains as-is via getContractPrivacyWhereSQLFilter.\n - Keep `perAnswer`, `limit`, and `offset` behavior intact.\n\n2) API schema updates\n- File: common/src/api/schema.ts\n - For the 'get-user-contract-metrics-with-contracts' endpoint:\n - Remove `inMani` from props.\n - Add optional `order` as an enum ['lastBetTime', 'profit'].\n - Change `limit` to z.coerce.number().gte(0).lte(10_000).default(100).\n - Ensure the props object is `.strict()` so extra keys are rejected.\n - Returns remain an object with metricsByContract: Dictionary<ContractMetric[]> and contracts: MarketContract[].\n\n3) Documentation\n- File: docs/docs/api.md\n - Add a dedicated section for GET /v0/get-user-contract-metrics-with-contracts.\n - Describe parameters: userId (required), limit (required or defaulted as per schema), offset (optional, default 0), order (optional, defaults to lastBetTime), perAnswer (optional).\n - Explain behavior: returns user's contract metrics and corresponding contracts; ordering can be by last bet time or profit; auth optional with private-visibility nuances.\n - Remove any mention of the removed `inMani` parameter. Ensure examples align with the new props.\n\n4) Client usage updates\n- Find any callers passing `inMani` for this endpoint (e.g., mani/components/profile/positions.tsx) and remove that prop. If a specific sort order is desired by the UI, use `order: 'profit'` or omit for default lastBetTime. Ensure types compile after schema changes.\n\nNon-changes/constraints:\n- Do not modify handler registration in backend/api/src/routes.ts other than import path updates if necessary (it should remain mapped to 'get-user-contract-metrics-with-contracts').\n- Do not change the response shape.\n- Preserve existing visibility filtering and per-answer aggregation behavior.",
1497
- "prompt": "Add support for ordering a user's contract metrics by profit as well as by most recent activity, and remove any obsolete filter flags. Update the backend endpoint that returns user contract metrics with their contracts to accept a new optional order parameter and default to ordering by recent activity, with profit ordering as an option. Remove the deprecated filter that restricted results to a particular app context. Tighten the input schema (including limit defaults and ranges) to match the new behavior and make it strict. Update the API documentation to describe this endpoint, its parameters, and its response, and adjust any client code that still sends the removed flag so everything compiles and runs cleanly.",
1498
- "supplementalFiles": [
1499
- "backend/api/src/routes.ts",
1500
- "mani/components/profile/positions.tsx",
1501
- "web/lib/api/api.ts",
1502
- "mani/lib/api.ts",
1503
- "common/src/contract-metric.ts"
1504
- ],
1505
- "fileDiffs": [
1506
- {
1507
- "path": "backend/api/src/get-user-contract-metrics-with-contracts.ts",
1508
- "status": "modified",
1509
- "diff": "Index: backend/api/src/get-user-contract-metrics-with-contracts.ts\n===================================================================\n--- backend/api/src/get-user-contract-metrics-with-contracts.ts\t2812fd8 (parent)\n+++ backend/api/src/get-user-contract-metrics-with-contracts.ts\t15ee0e8 (commit)\n@@ -10,11 +10,15 @@\n \n export const getUserContractMetricsWithContracts: APIHandler<\n 'get-user-contract-metrics-with-contracts'\n > = async (props, auth) => {\n- const { userId, limit, offset = 0, perAnswer = false, inMani } = props\n+ const { userId, limit, offset = 0, perAnswer = false, order } = props\n const visibilitySQL = getContractPrivacyWhereSQLFilter(auth?.uid, 'c.id')\n const pg = createSupabaseDirectClient()\n+ const orderBySQL =\n+ order === 'profit'\n+ ? `sum(ucm.profit) DESC`\n+ : `max((ucm.data->>'lastBetTime')::bigint) DESC NULLS LAST`\n const q = `\n SELECT \n (select row_to_json(t) from (select ${prefixedContractColumnsToSelect}) t) as contract,\n jsonb_agg(ucm.data) as metrics\n@@ -22,15 +26,10 @@\n JOIN user_contract_metrics ucm ON c.id = ucm.contract_id\n WHERE ${visibilitySQL}\n AND ucm.user_id = $1\n and case when c.mechanism = 'cpmm-multi-1' then ucm.answer_id is not null else true end\n- ${\n- inMani\n- ? \"and c.data->>'siblingContractId' is not null and ucm.has_shares = true\"\n- : ''\n- }\n GROUP BY c.id, ${prefixedContractColumnsToSelect}\n- ORDER BY max((ucm.data->>'lastBetTime')::bigint) DESC NULLS LAST\n+ ORDER BY ${orderBySQL}\n OFFSET $2 LIMIT $3\n `\n const results = await pg.map(q, [userId, offset, limit], (row) => ({\n contract: convertContract<MarketContract>(row.contract),\n"
1510
- },
1511
- {
1512
- "path": "common/src/api/schema.ts",
1513
- "status": "modified",
1514
- "diff": "Index: common/src/api/schema.ts\n===================================================================\n--- common/src/api/schema.ts\t2812fd8 (parent)\n+++ common/src/api/schema.ts\t15ee0e8 (commit)\n@@ -2036,15 +2036,17 @@\n returns: {} as {\n metricsByContract: Dictionary<ContractMetric[]>\n contracts: MarketContract[]\n },\n- props: z.object({\n- userId: z.string(),\n- limit: z.coerce.number(),\n- offset: z.coerce.number().gte(0).optional(),\n- perAnswer: coerceBoolean.optional(),\n- inMani: coerceBoolean.optional(),\n- }),\n+ props: z\n+ .object({\n+ userId: z.string(),\n+ limit: z.coerce.number().gte(0).lte(10_000).default(100),\n+ offset: z.coerce.number().gte(0).optional(),\n+ perAnswer: coerceBoolean.optional(),\n+ order: z.enum(['lastBetTime', 'profit']).optional(),\n+ })\n+ .strict(),\n },\n validateIap: {\n method: 'POST',\n visibility: 'undocumented',\n"
1515
- },
1516
- {
1517
- "path": "docs/docs/api.md",
1518
- "status": "modified",
1519
- "diff": "Index: docs/docs/api.md\n===================================================================\n--- docs/docs/api.md\t2812fd8 (parent)\n+++ docs/docs/api.md\t15ee0e8 (commit)\n@@ -473,8 +473,51 @@\n groupSlugs?: string[] // topics tagged in this market\n }\n ```\n \n+### `GET /v0/slug/[marketSlug]`\n+\n+Get information about a single market by slug (the portion of the URL path after the username).\n+\n+Requires no auth.\n+\n+Example request:\n+\n+```bash\n+curl \"https://api.manifold.markets/v0/slug/will-carrick-flynn-win-the-general\" -X GET\n+```\n+\n+Response type: A `FullMarket`\n+\n+### `GET /v0/search-markets`\n+\n+Search or filter markets, Similar to the [browse page](https://manifold.markets/browse).\n+\n+Requires no auth.\n+\n+Parameters:\n+\n+- `term`: The search query in question. Can be empty string.\n+- `sort`: Optional. One of `most-popular` (default), `newest`, `score`, `daily-score`, `freshness-score`, `24-hour-vol`, `liquidity`, `subsidy`, `last-updated`, `close-date`, `start-time`, `resolve-date`, `random`, `bounty-amount`, `prob-descending`, or `prob-ascending`.\n+- `filter`: Optional. Closing state. One of `all` (default), `open`, `closed`, `resolved`, `news`, `closing-90-days`, `closing-week`, `closing-month`, or `closing-day`.\n+- `contractType`: Optional. `ALL` (default), `BINARY` (yes/no), `MULTIPLE_CHOICE`, `BOUNTY`, `POLL`, or ... (see code)\n+- `topicSlug`: Optional. Only include questions with the topic tag with this slug.\n+- `creatorId`: Optional. Only include questions created by the user with this id.\n+- `limit`: Optional. Number of contracts to return from 0 to 1000. Default 100.\n+- `offset`: Optional. Number of contracts to skip. Use with limit to paginate the results.\n+- `liquidity`: Optional. Minimum liquidity per contract (or per answer according to tier map)\n+- `creatorId`: Optional. Only markets from creator id.\n+\n+Requires no auth.\n+\n+Example request:\n+\n+```bash\n+curl https://api.manifold.markets/v0/search-markets?term=biden&sort=liquidity&filter=resolved&contractType=BINARY&limit=2 -X GET\n+```\n+\n+Response type: Array of `LiteMarket`.\n+\n ### `GET /v0/market/[marketId]/prob`\n \n Get the current probability (or probabilities for multiple choice markets) for a market without caching.\n \n@@ -689,51 +732,168 @@\n lastBetTime: number\n }\n ```\n \n-### `GET /v0/slug/[marketSlug]`\n+### `GET /v0/get-user-contract-metrics-with-contracts`\n \n-Get information about a single market by slug (the portion of the URL path after the username).\n+Get a user's contract metrics and their corresponding contracts, ordered by profit or last bet time. This is useful for displaying a user's portfolio.\n \n-Requires no auth.\n+Requires no auth. When authenticated, the response may include metrics from private markets that are visible to you.\n \n-Example request:\n-\n-```bash\n-curl \"https://api.manifold.markets/v0/slug/will-carrick-flynn-win-the-general\" -X GET\n-```\n-\n-Response type: A `FullMarket`\n-\n-### `GET /v0/search-markets`\n-\n-Search or filter markets, Similar to the [browse page](https://manifold.markets/browse).\n-\n-Requires no auth.\n-\n Parameters:\n \n-- `term`: The search query in question. Can be empty string.\n-- `sort`: Optional. One of `most-popular` (default), `newest`, `score`, `daily-score`, `freshness-score`, `24-hour-vol`, `liquidity`, `subsidy`, `last-updated`, `close-date`, `start-time`, `resolve-date`, `random`, `bounty-amount`, `prob-descending`, or `prob-ascending`.\n-- `filter`: Optional. Closing state. One of `all` (default), `open`, `closed`, `resolved`, `news`, `closing-90-days`, `closing-week`, `closing-month`, or `closing-day`.\n-- `contractType`: Optional. `ALL` (default), `BINARY` (yes/no), `MULTIPLE_CHOICE`, `BOUNTY`, `POLL`, or ... (see code)\n-- `topicSlug`: Optional. Only include questions with the topic tag with this slug.\n-- `creatorId`: Optional. Only include questions created by the user with this id.\n-- `limit`: Optional. Number of contracts to return from 0 to 1000. Default 100.\n-- `offset`: Optional. Number of contracts to skip. Use with limit to paginate the results.\n-- `liquidity`: Optional. Minimum liquidity per contract (or per answer according to tier map)\n-- `creatorId`: Optional. Only markets from creator id.\n+- `userId`: Required. The ID of the user.\n+- `limit`: Required. The number of contracts to return.\n+- `offset`: Optional. The number of contracts to skip for pagination. Default 0.\n+- `order`: Optional. The sort order for the contracts. One of `lastBetTime` (default) or `profit`.\n+- `perAnswer`: Optional. If `true` for multiple choice markets, metrics will be returned for each answer. If `false` (default), only summary metrics for the market are returned.\n \n-Requires no auth.\n+Response type: an object with `metricsByContract` and `contracts`.\n \n-Example request:\n+- `metricsByContract`: A dictionary mapping a contract ID to an array of `ContractMetric` objects.\n+- `contracts`: An array of `MarketContract` objects.\n \n-```bash\n-curl https://api.manifold.markets/v0/search-markets?term=biden&sort=liquidity&filter=resolved&contractType=BINARY&limit=2 -X GET\n+Example response:\n+\n+```json\n+{\n+ \"metricsByContract\": {\n+ \"xv86CDBe0flxF2epvO3f\": [\n+ {\n+ \"from\": {\n+ \"day\": {\n+ \"value\": 132936.84161688015,\n+ \"profit\": 0,\n+ \"invested\": 132936.84161688015,\n+ \"prevValue\": 132936.84161688015,\n+ \"profitPercent\": 0\n+ },\n+ \"week\": {\n+ \"value\": 132936.84161688015,\n+ \"profit\": 0,\n+ \"invested\": 132936.84161688015,\n+ \"prevValue\": 132936.84161688015,\n+ \"profitPercent\": 0\n+ },\n+ \"month\": {\n+ \"value\": 132936.84161688015,\n+ \"profit\": 0,\n+ \"invested\": 132936.84161688015,\n+ \"prevValue\": 132936.84161688015,\n+ \"profitPercent\": 0\n+ }\n+ },\n+ \"loan\": 0,\n+ \"payout\": 132936.84161688015,\n+ \"profit\": 73386.56039227714,\n+ \"userId\": \"AJwLWoo3xue32XIiAVrL5SyR1WB2\",\n+ \"answerId\": null,\n+ \"invested\": 63625.999354329724,\n+ \"userName\": \"Ian Philips\",\n+ \"hasShares\": true,\n+ \"contractId\": \"xv86CDBe0flxF2epvO3f\",\n+ \"totalSpent\": {\n+ \"NO\": 63625.999354329724,\n+ \"YES\": 0\n+ },\n+ \"hasNoShares\": true,\n+ \"lastBetTime\": 1719868394000,\n+ \"totalShares\": {\n+ \"NO\": 132936.84161688015\n+ },\n+ \"hasYesShares\": false,\n+ \"userUsername\": \"ian\",\n+ \"profitPercent\": 109.48224425971169,\n+ \"userAvatarUrl\": \"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2Fian%2Fm6zfu88qLr.png?alt=media&token=126eab7e-fcba-49bd-b2e2-669b3fa9eaf0\",\n+ \"totalAmountSold\": 7480.277829512599,\n+ \"maxSharesOutcome\": \"NO\",\n+ \"totalAmountInvested\": 67030.55905411561\n+ }\n+ ]\n+ },\n+ \"contracts\": [\n+ {\n+ \"p\": 0.47784802346618205,\n+ \"id\": \"xv86CDBe0flxF2epvO3f\",\n+ \"pool\": {\n+ \"NO\": 11520.402666342714,\n+ \"YES\": 1043748.1561188638\n+ },\n+ \"prob\": 0,\n+ \"slug\": \"will-tesla-stock-reach-275-by-88-of-e836ca33649e\",\n+ \"volume\": 21510052.08761673,\n+ \"question\": \"Will TSLA reach >$ 275 before 8pm EST on 8/8?\",\n+ \"closeTime\": 1723166651449,\n+ \"creatorId\": \"nHX1qmzRItUHm3ifj7wNNR8hGf62\",\n+ \"mechanism\": \"cpmm-1\",\n+ \"viewCount\": 20625,\n+ \"dailyScore\": 0,\n+ \"elasticity\": 1.3259613990620638,\n+ \"groupSlugs\": [\n+ \"ev\",\n+ \"tsla\",\n+ \"tesla\",\n+ \"wall-street-bets\"\n+ ],\n+ \"isResolved\": true,\n+ \"marketTier\": \"crystal\",\n+ \"resolution\": \"NO\",\n+ \"resolverId\": \"nHX1qmzRItUHm3ifj7wNNR8hGf62\",\n+ \"visibility\": \"public\",\n+ \"createdTime\": 1715825810671,\n+ \"creatorName\": \"TSLABull\",\n+ \"description\": {\n+ \"type\": \"doc\",\n+ \"content\":\n+ [\n+ {\n+ \"type\": \"paragraph\",\n+ \"content\": [\n+ {\n+ \"text\": \"text removed\",\n+ \"type\": \"text\"\n+ }\n+ ]\n+ },\n+ ]\n+ }\n+ \"lastBetTime\": 1723187716841,\n+ \"outcomeType\": \"BINARY\",\n+ \"probChanges\": {\n+ \"day\": 0,\n+ \"week\": 0,\n+ \"month\": 0\n+ },\n+ \"subsidyPool\": 0,\n+ \"collectedFees\": {\n+ \"creatorFee\": 173884.84121027112,\n+ \"platformFee\": 172931.0356998528,\n+ \"liquidityFee\": 0\n+ },\n+ \"coverImageUrl\": \"https://storage.googleapis.com/mantic-markets.appspot.com/contract-images/PedroSobral/112df20121f7.jpg\",\n+ \"volume24Hours\": 0,\n+ \"freshnessScore\": 0,\n+ \"resolutionTime\": 1723166651449,\n+ \"totalLiquidity\": 100000,\n+ \"conversionScore\": 0.1337930700139641,\n+ \"creatorUsername\": \"MolbyDick\",\n+ \"importanceScore\": 0.0740977547170235,\n+ \"lastCommentTime\": 1741622281671,\n+ \"lastUpdatedTime\": 1741622283813,\n+ \"popularityScore\": 0,\n+ \"creatorAvatarUrl\": \"https://lh3.googleusercontent.com/a-/ALV-UjXY86BCu6RTvqG8yEFc5Y7D_S97tCf20jO2Z8tVPKaaUqG14DA9PLqiLMFEeMfNIZqYYE4tIzI_BrVccd4GjslXsd5xpRxNHEYntNGm2gIyorhNQXCFWaGveqVbopCKtvW8Hxom_b74OxRgIK95auugOa5P56HIijcmp8zUBmze5fHo3g0ex4yJjnK3v7cvgEOF25_-TG4N8zJWXs8h_RcZDoDOKDs__GbJIprrIRmH0xBPqyMPjM6JiRSy1sfS72DHbE53wywXJmkbjC8pw3Fv6tHNtztWvt2EKzgxFCFO5vBboyToBEm5ne-O_e2iPy2vA10-am1HyumXwhHQ8TmgD7hUTB7yokY1_OnpMRIYz_I26jS_8xrIRYiLTpn4tz1ehGr5MBXc52cHtHOFIr75TBvfRBucNqWMi55HJ3gMafiLCsRRufPbWV9W75ebNkpPQr-Fajv1VVUfjMrOmDhzwcB0uEArrgF0lQ1aSuwd2JN11ZJo7Y7p4gyPbmMDureFH9Ppk6xmGp53c3N5lIgeS_dH309hrZWL-N1_L1xoWufgpSeq0GaYVcpChDe_uWm5muiM39lzL5xdzCh5CLJn2HsflObP9H_Ei2mQXM_JQa0PHtPI11wk4w90HMPihYAIv8U4GECdlSV7nKemKVQ5C9asqy40yji5loeo3jof6fSSvWMYvFj206ohJ0r-NviW0y2A-Pp_VCPDMPg8cLpToFGPcmYguqw6Kw87iUow37UNBwG_vKFyI9_k0QWVT6dXpSSMYpts6Ui-KOv__LGb_ybFWH0dHGR_KA_WNNWZyoH8IDbwiGhUATlBS8qTwS7MDrON0E7K2MJCxCHBcVQdmXFNrBvz0zi2kwCtaPUcLR9teA9h_lcj2ybyXjPfdY_KVPz_smz9XzNssQ5Qaw5sR6n1aTX-wNaONG7j3nIvaOXIArN1glV07JsXIOQYPkPHFnWWq0DMoqOBJHBcna922FE=s96-c\",\n+ \"uniqueBettorCount\": 501,\n+ \"creatorCreatedTime\": 1713057686215,\n+ \"initialProbability\": 0.5,\n+ \"uniqueBettorCountDay\": 0,\n+ \"resolutionProbability\": 0.01,\n+ \"token\": \"MANA\",\n+ \"boosted\": false\n+ }\n+ ]\n+}\n ```\n \n-Response type: Array of `LiteMarket`.\n-\n ### `GET /v0/users`\n \n List all users, ordered by creation date descending.\n \n"
1520
- }
1521
- ]
1522
- },
1523
- {
1524
- "id": "add-comments-order",
1525
- "sha": "0c7b17ba470f563975d6192f0076faac2f8f7baf",
1526
- "parentSha": "66f248cd63ea445c34e578b85f93597fa66e71cd",
1527
- "spec": "Implement optional ordering for the GET /comments endpoint.\n\nScope\n- Add a new optional order parameter to GET /comments allowing sorting by likes, newest, or oldest.\n- Ensure the ordering is enforced in the DB query used to fetch comments.\n- Pass the order parameter through the API handler to the Supabase shared function.\n- Document the new parameter in API docs.\n\nRequirements\n1) API Schema\n- In common/src/api/schema.ts, in the comments endpoint schema, add an optional order property that accepts one of: likes, newest, oldest.\n- Keep existing parameters (contractId, contractSlug, userId, afterTime, limit, page, isPolitics) unchanged.\n\n2) Backend Supabase Query\n- In backend/shared/src/supabase/contract-comments.ts, update the function that fetches comments (getCommentsDirect) to accept an optional order parameter in its filters object.\n- Apply ordering according to the following:\n - likes: order by cc.likes DESC\n - newest (default): order by cc.created_time DESC\n - oldest: order by cc.created_time ASC\n- Ensure the SQL still filters for public contracts and supports all existing filters (contractId, userId, replyToCommentId, commentId, afterTime), as well as pagination (limit, offset).\n- Return the same shape as before for comments; including cc.data in the select. Including cc.likes in the select is acceptable but callers should continue to rely on the comment data structure as before.\n\n3) API Handler\n- In backend/api/src/get-comments.ts, update the getComments handler to accept the new order parameter from request props and forward it when calling getCommentsDirect.\n- No change is required to user-comments unless it needs to support this parameter; limit this change to the comments endpoint.\n\n4) API Documentation\n- In docs/docs/api.md, under GET /v0/comments, document the new optional order parameter with allowed values [likes, newest, oldest].\n- Note that order defaults to newest if unspecified.\n\nBehavioral Notes\n- If order is unspecified or invalid, default to newest.\n- Existing callers without the order parameter should see no change (newest ordering remains the default).\n- Confirm that filtering by contractId, userId, replyToCommentId, commentId, and afterTime continues to work in combination with the new ordering.\n",
1528
- "prompt": "Add support for an optional order parameter to the comments listing endpoint so clients can sort by likes, newest first, or oldest first. Update the API schema to validate the parameter and set a default, apply the ordering in the database query used to fetch comments, and pass the parameter through the API handler. Finally, update the API documentation for GET /v0/comments to describe the new parameter and its allowed values.",
1529
- "supplementalFiles": [
1530
- "backend/api/src/get-comments.ts",
1531
- "backend/api/src/routes.ts",
1532
- "web/hooks/use-comments.ts",
1533
- "web/lib/supabase/comments.ts",
1534
- "backend/shared/src/websockets/helpers.ts"
1535
- ],
1536
- "fileDiffs": [
1537
- {
1538
- "path": "backend/shared/src/supabase/contract-comments.ts",
1539
- "status": "modified",
1540
- "diff": "Index: backend/shared/src/supabase/contract-comments.ts\n===================================================================\n--- backend/shared/src/supabase/contract-comments.ts\t66f248c (parent)\n+++ backend/shared/src/supabase/contract-comments.ts\t0c7b17b (commit)\n@@ -36,8 +36,9 @@\n page?: number\n replyToCommentId?: string\n commentId?: string\n afterTime?: number\n+ order?: 'likes' | 'newest' | 'oldest'\n }\n ) {\n const {\n userId,\n@@ -46,8 +47,9 @@\n page = 0,\n replyToCommentId,\n commentId,\n afterTime,\n+ order = 'newest',\n } = filters\n \n const params: any[] = [\n limit,\n@@ -57,20 +59,26 @@\n replyToCommentId,\n commentId,\n afterTime ? millisToTs(afterTime) : null,\n ]\n+ const orderBy =\n+ order === 'likes'\n+ ? 'cc.likes desc'\n+ : !order || order === 'newest'\n+ ? 'cc.created_time desc'\n+ : 'cc.created_time asc'\n \n return await pg.map(\n `\n- select cc.data from contract_comments cc\n+ select cc.data, cc.likes from contract_comments cc\n join contracts c on cc.contract_id = c.id\n where c.visibility = 'public'\n- and cc.contract_id = $3 -- contractId (must be present here)\n- -- userId ($4) is ignored in this branch\n- and ($5 is null or cc.data->>'replyToCommentId' = $5) -- replyToCommentId\n- and ($6 is null or cc.comment_id = $6) -- commentId\n- and ($7 is null or cc.created_time > $7) -- afterTime\n- order by cc.created_time desc\n+ and ($3 is null or contract_id = $3)\n+ and ($4 is null or user_id = $4)\n+ and ($5 is null or cc.data->>'replyToCommentId' = $5) \n+ and ($6 is null or cc.comment_id = $6) \n+ and ($7 is null or cc.created_time > $7) \n+ order by ${orderBy}\n limit $1\n offset $2\n `,\n params,\n"
1541
- },
1542
- {
1543
- "path": "common/src/api/schema.ts",
1544
- "status": "modified",
1545
- "diff": "Index: common/src/api/schema.ts\n===================================================================\n--- common/src/api/schema.ts\t66f248c (parent)\n+++ common/src/api/schema.ts\t0c7b17b (commit)\n@@ -199,8 +199,9 @@\n afterTime: z.coerce.number().optional(),\n limit: z.coerce.number().gte(0).lte(1000).default(1000),\n page: z.coerce.number().gte(0).default(0),\n userId: z.string().optional(),\n+ order: z.enum(['likes', 'newest', 'oldest']).optional(),\n isPolitics: coerceBoolean.optional(),\n })\n .strict(),\n },\n"
1546
- },
1547
- {
1548
- "path": "docs/docs/api.md",
1549
- "status": "modified",
1550
- "diff": "Index: docs/docs/api.md\n===================================================================\n--- docs/docs/api.md\t66f248c (parent)\n+++ docs/docs/api.md\t0c7b17b (commit)\n@@ -1032,8 +1032,9 @@\n - `contractSlug`: Optional. The slug of the market to read comments of.\n - `limit`. Optional. How many comments to return. The default and maximum are both 1000.\n - `page`. Optional. For pagination with `limit`\n - `userId`: Optional. Get only comments created by this user.\n+- `order`: Optional. One of [`likes`, `newest`, `oldest`]\n \n Requires no auth.\n \n ### `GET /v0/bets`\n"
1551
- }
1552
- ]
1553
- },
1554
- {
1555
- "id": "refine-timeline",
1556
- "sha": "ec43d7b037e99418470294a31958fff4f1a9c2ec",
1557
- "parentSha": "309b47b01d19be3b06815b50b2a111648c21d785",
1558
- "spec": "Implement improved timeline layout, positioning accuracy, and visual connectors across the timeline components.\n\nScope:\n- web/components/timeline/timeline-item.tsx\n- web/components/timeline/timeline.tsx\n- Ensure wiring via TimelineRow and TimelineCard without changing external API besides the new optional prop on TimelineItem.\n\nRequirements:\n1) Add connecting line to Timeline items\n- In timeline-item.tsx, extend TimelineItemProps to include an optional string prop: lineColor?. Default it to the same color as the main timeline line ('bg-fuchsia-700 dark:bg-fuchsia-500').\n- Render a vertical 1px-wide line from the bottom of the item bubble to the timeline baseline. The line should:\n - Be positioned absolutely at left: 50% relative to the item; transform translateX(-50%).\n - Start just below the item content (a small top offset) and extend to reach the timeline baseline regardless of positive or negative verticalOffset; compute height as abs(verticalOffset) plus a small constant to ensure it always reaches the baseline.\n - Use the provided lineColor and slight opacity (e.g., opacity-80) and sit behind the item bubble (z-index 0) while the item remains above (z-index 1 or 2 when offset).\n- Ensure this ConnectingLine renders in both link-wrapped and non-link item render paths, immediately after the item content.\n\n2) Overlap the month ranges across two rows and place items accordingly\n- In timeline.tsx, split month markers into two rows such that the second row begins at the 6th month again (overlapping with the first row):\n - First row: months 0..5\n - Second row: months 5..11\n - Compute indices defensively based on available months; cap end indices at available length.\n- Filter items to rows with special handling for the overlapping 6th month:\n - First row includes items through the 5th month, and for the 6th month includes only items occurring in the first half of that month (dayOfMonth <= midpoint where midpoint is ceil(lastDayOfMonth/2)). Items after the 6th month should not appear in the first row.\n - Second row includes items from the 6th month occurring in the second half of that month (dayOfMonth > midpoint) and months thereafter.\n\n3) Day-accurate positioning with interpolation between month markers\n- Update getFirstRowPosition and getSecondRowPosition to compute the row end date as the last day of the last month for that row (not the first day of the next month). Include the full last month in range checks.\n- Compute a base percentage position by time proportion within the row, then improve accuracy by interpolating between adjacent month markers for the exact day:\n - Determine the index of the month the item date falls into within that row (handle the last month correctly by including dates through its final day).\n - Interpolate between the marker positions returned by getMonthMarkerPosition(currentIndex) and either the next month’s marker or 100 for the row end.\n - Month progress is the proportion of the date’s time between the monthStart and monthEnd (use the next month’s start or the last day of the month).\n - Clamp final positions between 5% and 95%.\n - If no month match is found, fall back to the base proportion.\n\n4) Keep month marker spacing consistent and pass through color\n- Keep getMonthMarkerPosition returning equally spaced marker positions: for months.length <= 1 return 0; else index/(months.length - 1) * 100.\n- In the TimelineRow’s render loop where TimelineItem is instantiated, pass the lineColor prop through to TimelineItem so the connector line matches the timeline’s color.\n\n5) Do not change external APIs beyond the optional lineColor on TimelineItem\n- TimelineCard should continue to accept lineColor and pass it to Timeline; Timeline should keep its existing props and defaults while ensuring TimelineItem receives lineColor.\n\nAcceptance criteria:\n- Items draw a thin vertical line from their bubble to the baseline, colored to match the timeline line by default, visible in both light/dark modes.\n- The second row starts at the same 6th month as the first row ends (overlap), and items in the 6th month are split between rows by month midpoint.\n- Item horizontal positions reflect their day within a month (not just the month index) by smoothly interpolating between month markers.\n- Positions are clamped to visible bounds (5–95%).\n- No type or runtime errors; TimelineCard and AI dashboard render without modification beyond the new visuals.",
1559
- "prompt": "Improve the timeline component to be more precise and visually informative. Display a two-row timeline for up to 12 months where the second row overlaps the first by including the sixth month again so items in that month can appear in either row. Place items based on their exact day within the month by interpolating between month markers rather than using only whole-month positions. Add a thin vertical connector line from each item bubble to the baseline using the same color as the timeline line, and ensure it renders behind the item and works for both linked and non-linked items. Keep month markers evenly spaced, clamp item positions within the visible bounds, and ensure existing cards that render the timeline continue to work with these enhancements.",
1560
- "supplementalFiles": [
1561
- "web/components/timeline/index.tsx",
1562
- "web/components/timeline/timeline-card.tsx",
1563
- "web/pages/ai/[[...slug]].tsx",
1564
- "web/components/ai-content.tsx"
1565
- ],
1566
- "fileDiffs": [
1567
- {
1568
- "path": "web/components/ai-forecast.tsx",
1569
- "status": "modified",
1570
- "diff": "Index: web/components/ai-forecast.tsx\n===================================================================\n--- web/components/ai-forecast.tsx\t309b47b (parent)\n+++ web/components/ai-forecast.tsx\tec43d7b (commit)\n@@ -214,9 +214,9 @@\n marketId: 'ssZ5lUgItL',\n type: 'benchmark',\n displayType: 'binary-odds',\n },\n- {\n+ {\n title: 'OpenAI Claims AGI',\n description: 'OAI claims AGI by EOY',\n marketId: '5SLp6d9yzy',\n type: 'benchmark',\n@@ -278,9 +278,9 @@\n type: 'long-term',\n displayType: 'binary-odds',\n },\n {\n- title: 'Fully AI-generated Movie',\n+ title: 'Fully AI-generated Movie', \n description: 'High quality AI-generated movie',\n marketId: 'A319ydGB1B7f4PMOROL3',\n type: 'long-term',\n displayType: 'binary-odds',\n"
1571
- },
1572
- {
1573
- "path": "web/components/timeline/timeline-item.tsx",
1574
- "status": "modified",
1575
- "diff": "Index: web/components/timeline/timeline-item.tsx\n===================================================================\n--- web/components/timeline/timeline-item.tsx\t309b47b (parent)\n+++ web/components/timeline/timeline-item.tsx\tec43d7b (commit)\n@@ -4,14 +4,16 @@\n interface TimelineItemProps {\n item: TimelineItemData\n position: number\n verticalOffset: number\n+ lineColor?: string\n }\n \n export const TimelineItem = ({\n item,\n position,\n verticalOffset,\n+ lineColor = 'bg-fuchsia-700 dark:bg-fuchsia-500',\n }: TimelineItemProps) => {\n // Ensure the position is within bounds (5-95% of container width)\n const safePosition = Math.max(5, Math.min(95, position))\n \n@@ -39,23 +41,47 @@\n const itemStyle = {\n left: `${safePosition}%`,\n transform: `translateX(-50%) translateY(${verticalOffset}px)`,\n transition: 'transform 0.2s ease-out',\n- zIndex: verticalOffset !== 0 ? 2 : 1, // Items that are offset get higher z-index\n+ zIndex: verticalOffset !== 0 ? 2 : 1,\n }\n \n+ // Create connecting line from item to timeline\n+ const ConnectingLine = () => {\n+ // Calculate the distance from the bottom of the item to the timeline\n+ const itemHeight = 28; // Approximate height of the timeline item\n+ const topOffset = itemHeight + 4; // Start below the item\n+ // Adding 10px to ensure the line reaches the timeline and lower items still get a line\n+ const lineHeight = Math.abs(verticalOffset) + 8; \n+ \n+ return (\n+ <div \n+ className={`absolute w-[1px] ${lineColor} opacity-80`} \n+ style={{\n+ left: '50%',\n+ top: `${topOffset}px`, // Position from the top of the item\n+ height: `${lineHeight}px`,\n+ transform: 'translateX(-50%)',\n+ zIndex: 0,\n+ }}\n+ />\n+ )\n+ }\n+\n // If path is provided, make it a link\n if (item.path) {\n return (\n <Link href={item.path} className=\"absolute\" style={itemStyle}>\n {itemContent}\n+ <ConnectingLine />\n </Link>\n )\n }\n \n // Otherwise render as a simple div\n return (\n <div className=\"absolute\" style={itemStyle}>\n {itemContent}\n+ <ConnectingLine />\n </div>\n )\n }\n"
1576
- },
1577
- {
1578
- "path": "web/components/timeline/timeline.tsx",
1579
- "status": "modified",
1580
- "diff": "Index: web/components/timeline/timeline.tsx\n===================================================================\n--- web/components/timeline/timeline.tsx\t309b47b (parent)\n+++ web/components/timeline/timeline.tsx\tec43d7b (commit)\n@@ -83,11 +83,12 @@\n \n // First row: first 6 months\n const firstHalfMonths = allMonthMarkers.slice(0, 6)\n \n- // Second row: next 6 months\n- const secondRowStartIndex = Math.min(6, allMonthMarkers.length)\n- const secondRowEndIndex = Math.min(12, allMonthMarkers.length)\n+ // Second row: overlapping with first row by including the 6th month again\n+ // This ensures the 6th month appears in both rows and items in that month can be displayed in either row\n+ const secondRowStartIndex = Math.min(5, allMonthMarkers.length - 1) // Start from the 6th month (index 5)\n+ const secondRowEndIndex = Math.min(11, allMonthMarkers.length) // Go up to the 12th month\n const secondHalfMonths = allMonthMarkers.slice(\n secondRowStartIndex,\n secondRowEndIndex\n )\n@@ -96,50 +97,202 @@\n const getFirstRowPosition = (date: Date) => {\n if (firstHalfMonths.length === 0) return -1\n \n const rowStartDate = firstHalfMonths[0]\n- const rowEndDate = new Date(firstHalfMonths[firstHalfMonths.length - 1])\n- rowEndDate.setMonth(rowEndDate.getMonth() + 1) // End of the last month\n-\n+ \n+ // Get the last month in this row\n+ const lastMonth = firstHalfMonths[firstHalfMonths.length - 1]\n+ \n+ // Create a date for the last day of the last month in this row\n+ const rowEndDate = new Date(lastMonth.getFullYear(), lastMonth.getMonth() + 1, 0)\n+ \n const timeRange = rowEndDate.getTime() - rowStartDate.getTime()\n if (timeRange === 0) return 0\n \n- // Check if date is in this row's range\n+ // Check if date is in this row's range - include the full last month\n if (date < rowStartDate || date > rowEndDate) return -1\n \n- // Calculate position as percentage\n- const position =\n- ((date.getTime() - rowStartDate.getTime()) / timeRange) * 100\n- return Math.max(5, Math.min(95, position)) // Clamp between 5% and 95%\n+ // For day-accurate positioning within the month spans\n+ // Calculate the position based on the proportion of time elapsed in the range\n+ const exactPosition = ((date.getTime() - rowStartDate.getTime()) / timeRange) * 100\n+\n+ // Interpolate position between month markers for more accurate day positioning\n+ // Find the month this date belongs to\n+ let monthIndex = -1;\n+ for (let i = 0; i < firstHalfMonths.length; i++) {\n+ const currentMonth = firstHalfMonths[i];\n+ const nextMonthStart = i < firstHalfMonths.length - 1 \n+ ? firstHalfMonths[i + 1] \n+ : new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1);\n+ \n+ if (date >= currentMonth && date < nextMonthStart) {\n+ monthIndex = i;\n+ break;\n+ }\n+ }\n+\n+ // If it's the last month in the row\n+ if (monthIndex === -1 && date >= firstHalfMonths[firstHalfMonths.length - 1]) {\n+ monthIndex = firstHalfMonths.length - 1;\n+ }\n+\n+ // If we found the month, calculate a more precise position\n+ if (monthIndex >= 0) {\n+ // Get positions of the current and next month markers\n+ const currentMonthPosition = getMonthMarkerPosition(monthIndex, firstHalfMonths);\n+ const nextMonthPosition = monthIndex < firstHalfMonths.length - 1 \n+ ? getMonthMarkerPosition(monthIndex + 1, firstHalfMonths)\n+ : 100;\n+ \n+ // Calculate start and end dates for interpolation\n+ const monthStart = firstHalfMonths[monthIndex];\n+ const monthEnd = monthIndex < firstHalfMonths.length - 1\n+ ? firstHalfMonths[monthIndex + 1]\n+ : new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0); // Last day of month\n+ \n+ // Calculate position within the month\n+ const monthProgress = (date.getTime() - monthStart.getTime()) / \n+ (monthEnd.getTime() - monthStart.getTime());\n+ \n+ // Interpolate between month markers\n+ const interpolatedPosition = currentMonthPosition + \n+ monthProgress * (nextMonthPosition - currentMonthPosition);\n+ \n+ return Math.max(5, Math.min(95, interpolatedPosition)); // Clamp between 5% and 95%\n+ }\n+\n+ // Fallback to original calculation\n+ return Math.max(5, Math.min(95, exactPosition)) // Clamp between 5% and 95%\n }\n \n // Calculate timeline position for an item (0-100%) for second row\n const getSecondRowPosition = (date: Date) => {\n if (secondHalfMonths.length === 0) return -1\n \n const rowStartDate = secondHalfMonths[0]\n- const rowEndDate = new Date(secondHalfMonths[secondHalfMonths.length - 1])\n- rowEndDate.setMonth(rowEndDate.getMonth() + 1) // End of the last month\n+ \n+ // Get the last month in this row\n+ const lastMonth = secondHalfMonths[secondHalfMonths.length - 1]\n+ \n+ // Create a date for the last day of the last month in this row\n+ const rowEndDate = new Date(lastMonth.getFullYear(), lastMonth.getMonth() + 1, 0)\n \n const timeRange = rowEndDate.getTime() - rowStartDate.getTime()\n if (timeRange === 0) return 0\n \n- // Check if date is in this row's range\n+ // Check if date is in this row's range - include the full last month\n if (date < rowStartDate || date > rowEndDate) return -1\n \n- // Calculate position as percentage\n- const position =\n- ((date.getTime() - rowStartDate.getTime()) / timeRange) * 100\n- return Math.max(5, Math.min(95, position)) // Clamp between 5% and 95%\n+ // For day-accurate positioning within the month spans\n+ // Calculate the position based on the proportion of time elapsed in the range\n+ const exactPosition = ((date.getTime() - rowStartDate.getTime()) / timeRange) * 100\n+\n+ // Interpolate position between month markers for more accurate day positioning\n+ // Find the month this date belongs to\n+ let monthIndex = -1;\n+ for (let i = 0; i < secondHalfMonths.length; i++) {\n+ const currentMonth = secondHalfMonths[i];\n+ const nextMonthStart = i < secondHalfMonths.length - 1 \n+ ? secondHalfMonths[i + 1] \n+ : new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1);\n+ \n+ if (date >= currentMonth && date < nextMonthStart) {\n+ monthIndex = i;\n+ break;\n+ }\n+ }\n+\n+ // If it's the last month in the row\n+ if (monthIndex === -1 && date >= secondHalfMonths[secondHalfMonths.length - 1]) {\n+ monthIndex = secondHalfMonths.length - 1;\n+ }\n+\n+ // If we found the month, calculate a more precise position\n+ if (monthIndex >= 0) {\n+ // Get positions of the current and next month markers\n+ const currentMonthPosition = getMonthMarkerPosition(monthIndex, secondHalfMonths);\n+ const nextMonthPosition = monthIndex < secondHalfMonths.length - 1 \n+ ? getMonthMarkerPosition(monthIndex + 1, secondHalfMonths)\n+ : 100;\n+ \n+ // Calculate start and end dates for interpolation\n+ const monthStart = secondHalfMonths[monthIndex];\n+ const monthEnd = monthIndex < secondHalfMonths.length - 1\n+ ? secondHalfMonths[monthIndex + 1]\n+ : new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0); // Last day of month\n+ \n+ // Calculate position within the month\n+ const monthProgress = (date.getTime() - monthStart.getTime()) / \n+ (monthEnd.getTime() - monthStart.getTime());\n+ \n+ // Interpolate between month markers\n+ const interpolatedPosition = currentMonthPosition + \n+ monthProgress * (nextMonthPosition - currentMonthPosition);\n+ \n+ return Math.max(5, Math.min(95, interpolatedPosition)); // Clamp between 5% and 95%\n+ }\n+\n+ // Fallback to original calculation\n+ return Math.max(5, Math.min(95, exactPosition)) // Clamp between 5% and 95%\n }\n \n- // Filter items for each row\n+ // Filter items for each row, with special handling for the overlapping month\n const firstRowItems = sortedItems.filter((item) => {\n- return getFirstRowPosition(item.releaseDate) >= 0\n+ // First row gets items for the first 5 months, plus items from the first half of the 6th month\n+ const position = getFirstRowPosition(item.releaseDate)\n+ if (position < 0) return false\n+ \n+ // For items in the 6th month, we need to decide if they go in the first or second row\n+ if (firstHalfMonths.length === 6 && // We have at least 6 months\n+ secondHalfMonths.length > 0 && // We have a second row\n+ item.releaseDate >= firstHalfMonths[5]) { // Item is in or after the 6th month\n+ \n+ // Check if this date is in the 6th month\n+ const sixthMonth = firstHalfMonths[5]\n+ const seventhMonthStart = new Date(sixthMonth.getFullYear(), sixthMonth.getMonth() + 1, 1)\n+ \n+ if (item.releaseDate >= sixthMonth && item.releaseDate < seventhMonthStart) {\n+ // If it's in the 6th month, determine if it belongs in first or second row\n+ // Items in the first half of the month go to the first row\n+ const dayOfMonth = item.releaseDate.getDate()\n+ const lastDayOfMonth = new Date(sixthMonth.getFullYear(), sixthMonth.getMonth() + 1, 0).getDate()\n+ const midpoint = Math.ceil(lastDayOfMonth / 2)\n+ \n+ // Items in the second half of the month go to the second row\n+ if (dayOfMonth > midpoint) return false\n+ } else if (item.releaseDate >= seventhMonthStart) {\n+ // Items after the 6th month always go to the second row\n+ return false\n+ }\n+ }\n+ \n+ return true\n })\n \n const secondRowItems = sortedItems.filter((item) => {\n- return getSecondRowPosition(item.releaseDate) >= 0\n+ // Check if the item belongs to the second row\n+ const position = getSecondRowPosition(item.releaseDate)\n+ if (position < 0) return false\n+ \n+ // For items in the 6th month (which is duplicated), we need to decide where they go\n+ if (secondHalfMonths.length > 0 && firstHalfMonths.length === 6) {\n+ const sixthMonth = firstHalfMonths[5] // This is the same as secondHalfMonths[0]\n+ const seventhMonthStart = secondHalfMonths.length > 1 \n+ ? secondHalfMonths[1] \n+ : new Date(sixthMonth.getFullYear(), sixthMonth.getMonth() + 1, 1)\n+ \n+ // If the item is in the 6th month, determine which row it belongs to\n+ if (item.releaseDate >= sixthMonth && item.releaseDate < seventhMonthStart) {\n+ const dayOfMonth = item.releaseDate.getDate()\n+ const lastDayOfMonth = new Date(sixthMonth.getFullYear(), sixthMonth.getMonth() + 1, 0).getDate()\n+ const midpoint = Math.ceil(lastDayOfMonth / 2)\n+ \n+ // Items in the first half of the month go to the first row, not the second\n+ if (dayOfMonth <= midpoint) return false\n+ }\n+ }\n+ \n+ return true\n })\n \n return (\n <div className={`${className}`}>\n@@ -164,9 +317,13 @@\n )\n }\n // Position month markers evenly from 0% to 100%\n const getMonthMarkerPosition = (index: number, months: Date[]) => {\n+ // Early return for empty or single-month arrays\n if (months.length <= 1) return 0\n+ \n+ // For consistent visual display, we maintain equal spacing between month markers\n+ // This approach ensures the timeline looks orderly, with months evenly spaced\n return (index / (months.length - 1)) * 100\n }\n \n // Create a timeline row component for reuse\n@@ -235,8 +392,9 @@\n key={`${item.title}-${item.releaseDate.getTime()}`}\n item={item}\n position={position}\n verticalOffset={verticalOffset}\n+ lineColor={lineColor}\n />\n ))\n })()}\n </div>\n"
1581
- }
1582
- ]
1583
- },
1584
- {
1585
- "id": "enable-creator-unfollow",
1586
- "sha": "eb2fc1492e3dd80f94c4b2554875a11689219d2b",
1587
- "parentSha": "d6424f375771691943a35b3d6bea460c1a33c9f5",
1588
- "spec": "Implement the following changes across the topic UI and hooks:\n\n1) Topic title follow toggle and tooltip (web/components/topics/questions-topic-title.tsx):\n- Replace the plain topic name text with the shared Tooltip component that shows the full name on hover.\n- Add a single Follow/Following toggle button beside the Share button that:\n - Renders when a user is logged in and the topic is followable (topic.id not in the disallowed list) regardless of current follow state or creator status.\n - Shows a filled bookmark icon and the label \"Following\" when the user is following; clicking unfollows the topic.\n - Shows an outline bookmark icon and the label \"Follow\" when the user is not following; clicking follows the topic.\n - Uses the existing follow/unfollow utilities and updates the local follow state accordingly.\n - Is disabled during loading and when follow state is undefined.\n - Preserves the existing conditional rendering for additional actions (e.g., add contract) to appear only when the user is following, not on mobile.\n\n2) Remove Unfollow from topic options menu (web/components/topics/topic-options.tsx):\n- Remove the \"Unfollow\" menu item entirely and any associated icon import(s).\n- Do not depend on creator checks for showing Unfollow; the Unfollow action should no longer be available in this menu.\n- Remove any now-unused variables (e.g., isCreator) and destructured props not used by the component.\n- Keep other menu items (edit, add description, block/unblock, delete if applicable) unchanged.\n\n3) Fix follow-state hook auth handling (web/hooks/use-group-supabase.ts):\n- Update the isAuthorized check so that the hook only sets follow state to false when isAuthorized is strictly false or the group slug is missing.\n- Do not set follow state to false when isAuthorized is undefined (auth still loading).\n\n4) Icons and tooltip imports:\n- Use the outline bookmark for not-following and the solid/filled bookmark for following states as icons for the toggle button.\n- Import and use the existing tooltip component for the topic name.\n\nExpected behavior:\n- Creators can unfollow their own topics using the main Follow/Following toggle on the title.\n- The Unfollow option is removed from the options dropdown, avoiding redundancy.\n- The follow state does not incorrectly reset during auth loading.\n- Long topic names show a tooltip with the full name.",
1589
- "prompt": "Improve the topic header to streamline following behavior and readability. Add a tooltip to the topic title that reveals the full name. Introduce a single Follow/Following toggle button next to the share button that works for any followable topic and is available to all users, including the topic creator. This button should reflect the user’s current state and allow toggling between follow and unfollow. Remove the Unfollow option from the three-dot options menu entirely. Also ensure that the follow state isn’t reset to false when the user’s auth status is still loading.",
1590
- "supplementalFiles": [
1591
- "web/components/topics/topics-button.tsx",
1592
- "web/components/topics/topic-dropdown.tsx",
1593
- "web/pages/topic/[topicSlug].tsx",
1594
- "web/components/widgets/tooltip.tsx",
1595
- "common/src/group.ts"
1596
- ],
1597
- "fileDiffs": [
1598
- {
1599
- "path": "web/components/topics/questions-topic-title.tsx",
1600
- "status": "modified",
1601
- "diff": "Index: web/components/topics/questions-topic-title.tsx\n===================================================================\n--- web/components/topics/questions-topic-title.tsx\td6424f3 (parent)\n+++ web/components/topics/questions-topic-title.tsx\teb2fc14 (commit)\n@@ -1,6 +1,7 @@\n import { Group } from 'common/group'\n import { BookmarkIcon, PlusCircleIcon } from '@heroicons/react/outline'\n+import { BookmarkIcon as FilledBookmark } from '@heroicons/react/solid'\n import { CopyLinkOrShareButton } from 'web/components/buttons/copy-link-button'\n import { Button } from 'web/components/buttons/button'\n import { AddContractToGroupModal } from 'web/components/topics/add-contract-to-group-modal'\n import {\n@@ -10,13 +11,13 @@\n import { TopicOptions } from 'web/components/topics/topic-options'\n import { Row } from 'web/components/layout/row'\n import { useIsFollowingTopic } from 'web/hooks/use-group-supabase'\n import { forwardRef, Ref, useState } from 'react'\n-// import { TopicDropdown } from 'web/components/topics/topic-dropdown'\n import { useIsMobile } from 'web/hooks/use-is-mobile'\n import { TOPIC_IDS_YOU_CANT_FOLLOW } from 'common/supabase/groups'\n import { getTopicShareUrl } from 'common/util/share'\n import { useUser } from 'web/hooks/use-user'\n+import { Tooltip } from '../widgets/tooltip'\n \n export const QuestionsTopicTitle = forwardRef(\n (props: { topic: Group; addAbout: () => void }, ref: Ref<HTMLDivElement>) => {\n const { topic, addAbout } = props\n@@ -33,9 +34,9 @@\n }\n ref={ref}\n >\n <h1 className=\"text-primary-700 self-center truncate text-2xl\">\n- {topic.name}\n+ <Tooltip text={topic.name}>{topic.name}</Tooltip>\n </h1>\n <Row>\n <CopyLinkOrShareButton\n url={getTopicShareUrl(topic.slug, user?.username)}\n@@ -44,8 +45,35 @@\n size={isMobile ? 'sm' : 'md'}\n >\n Share\n </CopyLinkOrShareButton>\n+ {!TOPIC_IDS_YOU_CANT_FOLLOW.includes(topic.id) && (\n+ <Button\n+ color={'gray-white'}\n+ className={'whitespace-nowrap'}\n+ loading={loading}\n+ disabled={loading || !user || isFollowing === undefined}\n+ size={isMobile ? 'sm' : 'md'}\n+ onClick={async () => {\n+ setLoading(true)\n+ if (isFollowing) {\n+ await internalUnfollowTopic(user, topic)\n+ setIsFollowing(false)\n+ } else {\n+ await internalFollowTopic(user, topic)\n+ setIsFollowing(true)\n+ }\n+ setLoading(false)\n+ }}\n+ >\n+ {loading ? null : isFollowing ? (\n+ <FilledBookmark className={'mr-1 h-5 w-5'} />\n+ ) : (\n+ <BookmarkIcon className={'mr-1 h-5 w-5'} />\n+ )}\n+ {isFollowing ? 'Following' : 'Follow'}\n+ </Button>\n+ )}\n {isFollowing && !isMobile && user ? (\n <>\n <Button\n color={'gray-white'}\n@@ -66,33 +94,9 @@\n user={user}\n />\n )}\n </>\n- ) : (\n- !isFollowing &&\n- !TOPIC_IDS_YOU_CANT_FOLLOW.includes(topic.id) &&\n- user && (\n- <Button\n- color={'gray-white'}\n- className={'whitespace-nowrap'}\n- loading={loading}\n- size={isMobile ? 'sm' : 'md'}\n- onClick={() => {\n- setLoading(true)\n- internalFollowTopic(user, topic)\n- .then(() => {\n- setIsFollowing(true)\n- })\n- .finally(() => {\n- setLoading(false)\n- })\n- }}\n- >\n- {!loading && <BookmarkIcon className={'mr-1 h-5 w-5'} />}\n- Follow\n- </Button>\n- )\n- )}\n+ ) : null}\n \n <TopicOptions\n group={topic}\n isMember={!!isFollowing}\n"
1602
- },
1603
- {
1604
- "path": "web/components/topics/topic-options.tsx",
1605
- "status": "modified",
1606
- "diff": "Index: web/components/topics/topic-options.tsx\n===================================================================\n--- web/components/topics/topic-options.tsx\td6424f3 (parent)\n+++ web/components/topics/topic-options.tsx\teb2fc14 (commit)\n@@ -21,9 +21,8 @@\n import {\n AddContractToGroupModal,\n AddContractToGroupPermissionType,\n } from 'web/components/topics/add-contract-to-group-modal'\n-import { BsFillPersonDashFill } from 'react-icons/bs'\n import { BiSolidVolumeMute } from 'react-icons/bi'\n import { usePrivateUser } from 'web/hooks/use-user'\n import { blockGroup, unBlockGroup } from 'web/components/topics/topic-dropdown'\n import { useIsMobile } from 'web/hooks/use-is-mobile'\n@@ -37,15 +36,14 @@\n unfollow: () => void\n addAbout: () => void\n className?: string\n }) {\n- const { group, user, isMember, unfollow, addAbout, className } = props\n+ const { group, user, isMember, addAbout, className } = props\n const privateUser = usePrivateUser()\n const [editingName, setEditingName] = useState(false)\n const [showAddContract, setShowAddContract] = useState(false)\n const [showDelete, setShowDelete] = useState(false)\n const userRole = useGroupRole(group.id, user)\n- const isCreator = group.creatorId == user?.id\n const isMobile = useIsMobile()\n \n const hasAbout = !!group.about && !JSONEmpty(group.about)\n \n@@ -66,14 +64,8 @@\n name: 'Add description',\n icon: <PlusIcon className=\"h-5 w-5\" />,\n onClick: addAbout,\n },\n- isMember &&\n- !isCreator && {\n- name: 'Unfollow',\n- icon: <BsFillPersonDashFill className=\"h-5 w-5\" />,\n- onClick: unfollow,\n- },\n !isMember &&\n privateUser && {\n name: privateUser.blockedGroupSlugs?.includes(group.slug)\n ? 'Unblock topic'\n"
1607
- },
1608
- {
1609
- "path": "web/hooks/use-group-supabase.ts",
1610
- "status": "modified",
1611
- "diff": "Index: web/hooks/use-group-supabase.ts\n===================================================================\n--- web/hooks/use-group-supabase.ts\td6424f3 (parent)\n+++ web/hooks/use-group-supabase.ts\teb2fc14 (commit)\n@@ -23,9 +23,9 @@\n boolean | undefined\n >(undefined, 'is-member-' + groupSlug)\n const isAuthorized = useIsAuthorized()\n useEffect(() => {\n- if (!isAuthorized || !groupSlug) {\n+ if (isAuthorized === false || !groupSlug) {\n setIsFollowing(false)\n } else {\n getUserIsFollowingTopic({ groupSlug }).then((result) => {\n setIsFollowing(result.isGroupMember)\n"
1612
- }
1613
- ]
1614
- },
1615
- {
1616
- "id": "restore-welcome-topics",
1617
- "sha": "63bfd3bc1a03c014473459ad8170ad460f51a1e7",
1618
- "parentSha": "3f8beb8921e4a3a898a8ae745256c0671cfc48d1",
1619
- "spec": "Implement the following changes to reintroduce topic selection in onboarding and update search to use the new topic mapping:\n\n1) Refactor topic constants (common/src/topics.ts)\n- Introduce a new export SEARCH_TOPICS_TO_SUBTOPICS which contains the same structure previously exported as TOPICS_TO_SUBTOPICS. Rename usages within this file accordingly.\n- Update getSubtopics(topic: string) to return triples derived from SEARCH_TOPICS_TO_SUBTOPICS[topic], preserving the [nameWithEmoji, nameWithoutEmoji, groupIds] structure.\n- Update ALL_TOPICS to be computed from SEARCH_TOPICS_TO_SUBTOPICS. Remove ALL_PARENT_TOPICS export from this file if it existed previously; no longer export ALL_PARENT_TOPICS from common.\n- Remove TOPICS_TO_HIDE_FROM_WELCOME_FLOW and the runtime validation against TOPICS_TO_SUBTOPICS. Do not export this value anymore.\n- Add a new export type WelcomeTopicInfo = { name: string; groupId: string } and a curated array export WELCOME_FLOW_TOPICS: WelcomeTopicInfo[] containing the provided list of topics and group IDs (politics, AI, technology, coding, etc.).\n- Preserve GROUP_SLUGS_TO_HIDE_FROM_WELCOME_FLOW and HIDE_FROM_NEW_USER_SLUGS behavior; keep GROUP_SLUGS_TO_HIDE_FROM_WELCOME_FLOW as-is.\n- Where necessary in the existing mapping, comment out duplicated/merged group IDs and annotate with TODO: MERGE for clarity (as shown in the diff for Culture, Movies & TV, Gaming).\n\n2) Restore and simplify topics in onboarding (web/components/onboarding/welcome.tsx)\n- Replace imports of getSubtopics, TOPICS_TO_SUBTOPICS, and TOPICS_TO_HIDE_FROM_WELCOME_FLOW with WELCOME_FLOW_TOPICS and GROUP_SLUGS_TO_HIDE_FROM_WELCOME_FLOW from common/topics.\n- Ensure SHOW_TOPICS is true to display topic selection in the welcome flow.\n- Compute hardCodedTopicIds using WELCOME_FLOW_TOPICS.map(t => t.groupId) instead of flattening over subtopics.\n- Modify follow logic to check for the single-topic group IDs established in WELCOME_FLOW_TOPICS for special follow actions (e.g., follow @ManifoldPolitics if selecting politics group ID; follow @ManifoldAI if selecting AI group ID). Keep any additional, specific follow logic intact and add clarifying comments where IDs are not part of WELCOME_FLOW_TOPICS (e.g., meme group).\n- Update the pill button component/signature to select a single groupId rather than an array. Selection should be based on selectedTopics.includes(groupId), and onSelect should selectTopic(groupId). Apply the updated visual styles for the pill buttons (larger padding/typography).\n- Update UI copy and layout: adjust headings, description text to \"We'll use this to customize your experience.\", and change the section label from \"Trending now\" to \"Trending\". Render a row of curated WELCOME_FLOW_TOPICS pills followed by a separate row of suggested/trending topics.\n- Remove the previous per-parent-topic sections that were derived via getSubtopics and TOPICS_TO_SUBTOPICS; the welcome flow should no longer iterate over parent topics/subtopics for onboarding.\n- Allow finishing the welcome flow without enforcing a minimum of three selected topics (remove the disabled condition on the Finish button). Keep the loading behavior. Also add console.error logging in the username update try/catch before fallback.\n\n3) Update search to consume the renamed constant (web/components/search.tsx)\n- Replace imports of TOPICS_TO_SUBTOPICS and ALL_PARENT_TOPICS with an import of SEARCH_TOPICS_TO_SUBTOPICS from common/topics.\n- Locally derive ALL_PARENT_TOPICS in this component as Object.keys(SEARCH_TOPICS_TO_SUBTOPICS) for determining the current parent topic.\n- Replace all internal references from TOPICS_TO_SUBTOPICS[...] to SEARCH_TOPICS_TO_SUBTOPICS[...] when computing selectedTopic, selectedSubTopic, \"All\" groupIds, and rendering subtopic carousels. Continue to respect hideFromSearch flags on subtopics.\n\nAcceptance criteria\n- Onboarding welcome flow displays a curated set of topic pills from WELCOME_FLOW_TOPICS above suggested/trending topics, supports selecting single group IDs per pill, and no longer shows parent-topic sections derived from subtopics. Finish button can be clicked without a topic-count requirement.\n- Search page topic carousel still functions, with topic selection and subtopic selection derived from SEARCH_TOPICS_TO_SUBTOPICS and the component’s locally computed ALL_PARENT_TOPICS. The “All” option aggregates all group IDs defined under the selected parent.\n- common/src/topics.ts no longer exports TOPICS_TO_SUBTOPICS, TOPICS_TO_HIDE_FROM_WELCOME_FLOW, or ALL_PARENT_TOPICS. It instead exports SEARCH_TOPICS_TO_SUBTOPICS, getSubtopics (using the renamed constant), ALL_TOPICS (using the renamed constant), WELCOME_FLOW_TOPICS, and existing group slug exclusion lists.\n- Build and type checks pass. No dead imports remain referencing the removed constants.",
1620
- "prompt": "Reintroduce topic selection into the onboarding welcome flow and modernize topic data usage across the app.\n\nGoals:\n- Restore a simple, curated topic selection experience in the welcome flow using a fixed list of high-signal topics.\n- Split topic data into two concerns: a search-focused parent/subtopic mapping and a curated onboarding list.\n- Update the search experience to use the renamed search topic mapping and continue supporting parent and subtopic selection, including an \"All\" option.\n- Make minor UX copy and style tweaks to the onboarding topic pills and labels.\n\nWhat to do:\n- Create a curated list of onboarding topics, each pointing to a single group.\n- Keep the more detailed parent/subtopic mapping for search, but rename it to make the usage clear and future-proof.\n- Update onboarding to use the curated list and select topics by single group ID per pill.\n- Update search to rely on the renamed mapping and derive its list of parent topics locally.\n- Remove any outdated validations and constants that were specific to the old onboarding logic.\n\nOutcomes:\n- New users see a straightforward topic chooser with large pills and helpful copy, can finish without a minimum number of topics, and their selections drive personalization and follows where appropriate.\n- Search retains parent/subtopic browsing powered by the renamed mapping, and continues to support selecting all subtopics under a parent.",
1621
- "supplementalFiles": [
1622
- "web/pages/home/index.tsx",
1623
- "web/lib/supabase/groups.ts",
1624
- "common/src/supabase/groups.ts",
1625
- "web/hooks/use-topic-from-router.ts",
1626
- "web/pages/topic/[topicSlug].tsx"
1627
- ],
1628
- "fileDiffs": [
1629
- {
1630
- "path": "common/src/topics.ts",
1631
- "status": "modified",
1632
- "diff": "Index: common/src/topics.ts\n===================================================================\n--- common/src/topics.ts\t3f8beb8 (parent)\n+++ common/src/topics.ts\t63bfd3b (commit)\n@@ -5,9 +5,9 @@\n import { removeEmojis } from './util/string'\n \n type TopicInfo = { name: string; groupIds: string[]; hideFromSearch?: boolean }\n \n-export const TOPICS_TO_SUBTOPICS: { [key: string]: TopicInfo[] } = {\n+export const SEARCH_TOPICS_TO_SUBTOPICS: { [key: string]: TopicInfo[] } = {\n '🗳️ Politics': [\n {\n name: '🇺🇸 USA',\n groupIds: [\n@@ -133,17 +133,17 @@\n '🎬 Culture': [\n {\n name: '🤩 Pop culture',\n groupIds: [\n- 'eJZecx6r22G2NriYYXcC', // Culture\n 'XU1fOYURSnb58lgsqaly', // Entertainment & Pop culture\n- '4QIcUOfCSSha0JZHAg9X', // celebrities\n+ // 'eJZecx6r22G2NriYYXcC', // Culture TODO: MERGE\n+ // '4QIcUOfCSSha0JZHAg9X', // celebrities TODO: MERGE\n ],\n },\n {\n name: '🍿 Movies & TV',\n groupIds: [\n- 'KSeNIu7AWgiBBM5FqVuB', // Movies\n+ // 'KSeNIu7AWgiBBM5FqVuB', // Movies TODO: MERGE\n 'EUSEngFk1dGGBfaMeAmh', // TV and Film\n ],\n },\n {\n@@ -153,9 +153,9 @@\n {\n name: '🎮 Gaming',\n groupIds: [\n '5FaFmmaNNFTSA5r0vTAi', // Gaming\n- '9FaZmHrfS8IcDJyu6pUD', // Video Games\n+ // '9FaZmHrfS8IcDJyu6pUD', // Video Games TODO: MERGE\n ],\n },\n {\n name: '🎮️ Destiny.gg',\n@@ -223,19 +223,28 @@\n },\n ],\n }\n \n-export const TOPICS_TO_HIDE_FROM_WELCOME_FLOW = [] as string[]\n-if (\n- !TOPICS_TO_HIDE_FROM_WELCOME_FLOW.every((topic) =>\n- Object.keys(TOPICS_TO_SUBTOPICS).includes(topic)\n- )\n-) {\n- throw new Error(\n- `${TOPICS_TO_HIDE_FROM_WELCOME_FLOW.join(', ')} contains invalid topics`\n- )\n-}\n+export type WelcomeTopicInfo = { name: string; groupId: string }\n \n+export const WELCOME_FLOW_TOPICS: WelcomeTopicInfo[] = [\n+ { name: 'Politics', groupId: 'UCnpxVUdLOZYgoMsDlHD' },\n+ { name: 'AI', groupId: 'yEWvvwFFIqzf8JklMewp' },\n+ { name: 'Technology', groupId: 'IlzY3moWwOcpsVZXCVej' },\n+ { name: 'Coding', groupId: 'PZJMbrLekgJBy7OOBKGT' },\n+ { name: 'Science', groupId: 'XMhZ5LbQoLMZiOpQJRnj' },\n+ { name: 'Sports', groupId: '2hGlgVhIyvVaFyQAREPi' },\n+ { name: 'Music', groupId: 'Xuc2UY8gGfjQqFXwxq5d' },\n+ { name: 'Movies & TV', groupId: 'EUSEngFk1dGGBfaMeAmh' },\n+ { name: 'Culture', groupId: 'XU1fOYURSnb58lgsqaly' },\n+ { name: 'Gaming', groupId: '5FaFmmaNNFTSA5r0vTAi' },\n+ { name: 'Finance', groupId: 'CgB83AAMkkOHSrTnzani' },\n+ { name: 'Business', groupId: 'pmK8sntWL1SDkMm53UBR' },\n+ { name: 'Economics', groupId: 'p88Ycq6yFd5ECKqq9PFO' },\n+ { name: 'Crypto', groupId: 'YuJw0M1xvUHrpiRRuKso' },\n+ { name: 'Sex & Love', groupId: '3syjPCC7PxE5KurTiTT3' },\n+]\n+\n export const GROUP_SLUGS_TO_HIDE_FROM_WELCOME_FLOW = [\n 'world-default',\n 'shortterm-markets',\n 'daily-markets',\n@@ -268,13 +277,11 @@\n ...HIDE_FROM_NEW_USER_SLUGS,\n ]\n \n export const getSubtopics = (topic: string) =>\n- TOPICS_TO_SUBTOPICS[topic].map(\n+ SEARCH_TOPICS_TO_SUBTOPICS[topic].map(\n (subtopic) =>\n [subtopic.name, removeEmojis(subtopic.name), subtopic.groupIds] as const\n )\n-export const ALL_TOPICS = Object.keys(TOPICS_TO_SUBTOPICS)\n+export const ALL_TOPICS = Object.keys(SEARCH_TOPICS_TO_SUBTOPICS)\n .map((topic) => getSubtopics(topic).map(([_, subtopic]) => subtopic))\n .flat()\n-\n-export const ALL_PARENT_TOPICS = Object.keys(TOPICS_TO_SUBTOPICS)\n"
1633
- },
1634
- {
1635
- "path": "web/components/onboarding/welcome.tsx",
1636
- "status": "modified",
1637
- "diff": "Index: web/components/onboarding/welcome.tsx\n===================================================================\n--- web/components/onboarding/welcome.tsx\t3f8beb8 (parent)\n+++ web/components/onboarding/welcome.tsx\t63bfd3b (commit)\n@@ -14,12 +14,10 @@\n import { run } from 'common/supabase/utils'\n import { db } from 'web/lib/supabase/db'\n import { Group } from 'common/group'\n import {\n- getSubtopics,\n GROUP_SLUGS_TO_HIDE_FROM_WELCOME_FLOW,\n- TOPICS_TO_HIDE_FROM_WELCOME_FLOW,\n- TOPICS_TO_SUBTOPICS,\n+ WELCOME_FLOW_TOPICS,\n } from 'common/topics'\n import { intersection, orderBy, uniq, uniqBy } from 'lodash'\n import { track } from 'web/lib/service/analytics'\n import { Input } from '../widgets/input'\n@@ -27,18 +25,18 @@\n import { api, updateUser, followTopic, followUser } from 'web/lib/api/api'\n import { randomString } from 'common/util/random'\n import { unfollowTopic } from 'web/lib/supabase/groups'\n import { PillButton } from 'web/components/buttons/pill-button'\n-import { removeEmojis } from 'common/util/string'\n import { unauthedApi } from 'common/util/api'\n import { getSavedContractVisitsLocally } from 'web/hooks/use-save-visits'\n import { capitalize } from 'lodash'\n import { TRADE_TERM } from 'common/envs/constants'\n import { convertGroup } from 'common/supabase/groups'\n import { setCachedReferralInfoForUser } from 'web/lib/firebase/users'\n+import { removeEmojis } from 'common/util/string'\n \n export const DEFAULT_FOR_YOU = false\n-const SHOW_TOPICS = false\n+const SHOW_TOPICS = true\n \n export function Welcome(props: { setFeedKey?: (key: string) => void }) {\n const { setFeedKey } = props\n \n@@ -96,12 +94,9 @@\n ])\n const showBottomButtons = page < 3\n \n const getTrendingAndUserCategories = async (userId: string) => {\n- const hardCodedTopicIds = Object.keys(TOPICS_TO_SUBTOPICS)\n- .map((topic) => getSubtopics(topic))\n- .flat()\n- .flatMap(([_, __, groupIds]) => groupIds)\n+ const hardCodedTopicIds = WELCOME_FLOW_TOPICS.map((topic) => topic.groupId)\n const [userInterestedTopicsRes, trendingTopicsRes] = await Promise.all([\n unauthedApi('get-interesting-groups-from-views', {\n userId,\n contractIds: getSavedContractVisitsLocally(),\n@@ -255,8 +250,9 @@\n let username = cleanUsername(newName)\n try {\n await updateUser({ username })\n } catch (e) {\n+ console.error(e)\n username += randomString(5)\n await updateUser({ username })\n }\n }\n@@ -337,12 +333,8 @@\n const [userSelectedTopics, setUserSelectedTopics] = useState<\n string[] | undefined\n >()\n \n- const topics = Object.keys(TOPICS_TO_SUBTOPICS).filter(\n- (topic) => !TOPICS_TO_HIDE_FROM_WELCOME_FLOW.includes(topic)\n- )\n-\n useEffect(() => {\n if (userBetInTopics.length > 0) {\n userBetInTopics.forEach((group) => selectTopic(group.id))\n } else if (userInterestedTopics.length > 0) {\n@@ -367,29 +359,25 @@\n \n // if user is following us politics\n if (\n intersection(selectedTopics, [\n- 'AjxQR8JMpNyDqtiqoA96',\n- 'pYwsGvORZFlcq7QrkI6n',\n- 'cEzcLXuitr6o4VPI01Q1',\n+ 'UCnpxVUdLOZYgoMsDlHD', // Politics\n ]).length > 0\n ) {\n await followUser('vuI5upWB8yU00rP7yxj95J2zd952') // follow @ManifoldPolitics\n }\n \n // if user is following AI topics\n if (\n intersection(selectedTopics, [\n- 'yEWvvwFFIqzf8JklMewp',\n- 'a3ikurqO9fT46Pv9ZGkY',\n- 'GbbX9U5pYnDeftX9lxUh',\n+ 'yEWvvwFFIqzf8JklMewp', // AI\n ]).length > 0\n ) {\n await followUser('8lZo8X5lewh4hnCoreI7iSc0GxK2') // follow @ManifoldAI\n }\n \n if (\n- intersection(selectedTopics, ['0d39aa2b-1447-4298-bc60-5ef67d9cea4f'])\n+ intersection(selectedTopics, ['0d39aa2b-1447-4298-bc60-5ef67d9cea4f']) // This ID is not in WELCOME_FLOW_TOPICS. Consider removing or updating if it's for a specific meme group.\n .length > 0\n ) {\n await followUser('fBFdG15kdfeBmjRVEajSMLayZ2y1') // follow @JasonTweenieMemes\n }\n@@ -400,15 +388,16 @@\n \n const pillButton = (\n topicWithEmoji: string,\n topicName: string,\n- groupIds: string[]\n+ groupId: string\n ) => (\n <PillButton\n+ className=\"!text-ink-900 !px-5 !py-5 !text-lg\"\n key={topicName}\n- selected={groupIds.every((g) => selectedTopics.includes(g))}\n+ selected={selectedTopics.includes(groupId)}\n onSelect={() => {\n- groupIds.map((g) => selectTopic(g))\n+ selectTopic(groupId)\n track('onboarding select topic', { name: topicName })\n }}\n >\n {topicWithEmoji}\n@@ -416,52 +405,42 @@\n )\n \n return (\n <Col>\n- <div className=\"text-primary-700 mb-6 text-center text-2xl font-normal\">\n+ <div className=\"text-primary-700 mb-2 text-2xl font-normal\">\n What interests you?\n </div>\n- <div className=\"mb-4 text-lg\">\n- Select 3 or more topics to personalize your experience.\n+ <div className=\"text-ink-800 mb-2 text-base\">\n+ We'll use this to customize your experience.\n </div>\n <Col className=\"h-[25rem] gap-2 overflow-y-auto sm:h-[32rem]\">\n+ <Col className=\"mb-3 gap-1\">\n+ <Row className=\"flex flex-wrap gap-x-3 gap-y-3\">\n+ {WELCOME_FLOW_TOPICS.map((topic) => {\n+ return pillButton(topic.name, topic.name, topic.groupId)\n+ })}\n+ </Row>\n+ </Col>\n <Col className={'gap-1'}>\n <div className=\"text-ink-700 text-sm\">\n {userInterestedTopics.length > 0 || userBetInTopics.length > 0\n ? 'Suggested'\n- : 'Trending now'}\n+ : 'Trending'}\n </div>\n- <Row className={'flex-wrap gap-1'}>\n+ <Row className=\"flex flex-wrap gap-x-3 gap-y-3\">\n {trendingTopics.map((group) => (\n <div className=\"\" key={group.id + '-section'}>\n- {pillButton(group.name, removeEmojis(group.name), [group.id])}\n+ {pillButton(group.name, removeEmojis(group.name), group.id)}\n </div>\n ))}\n </Row>\n </Col>\n-\n- {topics.map((topic) => (\n- <Col className=\"mb-3 gap-1\" key={topic + '-section'}>\n- <div className=\"text-ink-700 text-sm\">{topic.slice(3)}</div>\n- <Row className=\"flex flex-wrap gap-x-1 gap-y-1.5\">\n- {getSubtopics(topic)\n- .filter(([_, __, groupId]) => !!groupId)\n- .map(([subtopicWithEmoji, subtopic, groupIds]) => {\n- return pillButton(subtopicWithEmoji, subtopic, groupIds)\n- })}\n- </Row>\n- </Col>\n- ))}\n </Col>\n <Row className={'mt-4 justify-between'}>\n <Button onClick={goBack} color={'gray-white'}>\n Previous\n </Button>\n- <Button\n- onClick={closeDialog}\n- disabled={(userSelectedTopics ?? []).length <= 2}\n- loading={isLoading}\n- >\n+ <Button onClick={closeDialog} loading={isLoading}>\n Finish\n </Button>\n </Row>\n </Col>\n"
1638
- },
1639
- {
1640
- "path": "web/components/search.tsx",
1641
- "status": "modified",
1642
- "diff": "Index: web/components/search.tsx\n===================================================================\n--- web/components/search.tsx\t3f8beb8 (parent)\n+++ web/components/search.tsx\t63bfd3b (commit)\n@@ -28,9 +28,9 @@\n import { BinaryDigit } from 'common/tier'\n import { useIsMobile } from 'web/hooks/use-is-mobile'\n import { Spacer } from './layout/spacer'\n import { useSweepstakes } from './sweepstakes-provider'\n-import { ALL_PARENT_TOPICS, TOPICS_TO_SUBTOPICS } from 'common/topics'\n+import { SEARCH_TOPICS_TO_SUBTOPICS } from 'common/topics'\n import { Carousel } from './widgets/carousel'\n import { isEqual } from 'lodash'\n import { SearchInput } from './search/search-input'\n import { removeEmojis } from 'common/util/string'\n@@ -378,18 +378,19 @@\n </Row>\n )}\n </Col>\n ))\n+ const ALL_PARENT_TOPICS = Object.keys(SEARCH_TOPICS_TO_SUBTOPICS)\n \n const selectedTopic = groupIds\n ? ALL_PARENT_TOPICS.find((topic) =>\n- TOPICS_TO_SUBTOPICS[topic].some((subtopic) =>\n+ SEARCH_TOPICS_TO_SUBTOPICS[topic].some((subtopic) =>\n groupIds.split(',').some((id) => subtopic.groupIds.includes(id))\n )\n )\n : undefined\n const selectedSubTopic = selectedTopic\n- ? TOPICS_TO_SUBTOPICS[selectedTopic].find(\n+ ? SEARCH_TOPICS_TO_SUBTOPICS[selectedTopic].find(\n (subtopic) => groupIds === subtopic.groupIds.join(',')\n )\n : undefined\n const selectedAll = !selectedTopic && !selectedFollowed\n@@ -513,9 +514,9 @@\n onClick={() => {\n if (selectedTopic != topic) {\n track('select search topic', { topic })\n // Join all group IDs for this topic's subtopics\n- const allGroupIds = TOPICS_TO_SUBTOPICS[topic]\n+ const allGroupIds = SEARCH_TOPICS_TO_SUBTOPICS[topic]\n .map((subtopic) => subtopic.groupIds)\n .flat()\n const changes: Partial<SearchParams> = {\n [GROUP_IDS_KEY]: allGroupIds.join(','),\n@@ -550,16 +551,16 @@\n )}\n \n {/* Subtopics row */}\n {selectedTopic &&\n- Object.keys(TOPICS_TO_SUBTOPICS).some(\n+ Object.keys(SEARCH_TOPICS_TO_SUBTOPICS).some(\n (topic) => topic === selectedTopic\n ) && (\n <Carousel fadeEdges labelsParentClassName=\"gap-1 mt-3 mb-1.5 \">\n <button\n onClick={() => {\n onChange({\n- [GROUP_IDS_KEY]: TOPICS_TO_SUBTOPICS[selectedTopic]\n+ [GROUP_IDS_KEY]: SEARCH_TOPICS_TO_SUBTOPICS[selectedTopic]\n .map((subtopic) => subtopic.groupIds)\n .flat()\n .join(','),\n })\n@@ -571,9 +572,9 @@\n )}\n >\n All\n </button>\n- {TOPICS_TO_SUBTOPICS[selectedTopic]\n+ {SEARCH_TOPICS_TO_SUBTOPICS[selectedTopic]\n .filter(({ hideFromSearch }) => !hideFromSearch)\n .map(({ name, groupIds }) => (\n <button\n key={name}\n@@ -584,9 +585,11 @@\n )}\n onClick={() => {\n if (searchParams[GROUP_IDS_KEY] === groupIds.join(',')) {\n onChange({\n- [GROUP_IDS_KEY]: TOPICS_TO_SUBTOPICS[selectedTopic]\n+ [GROUP_IDS_KEY]: SEARCH_TOPICS_TO_SUBTOPICS[\n+ selectedTopic\n+ ]\n .map((subtopic) => subtopic.groupIds)\n .flat()\n .join(','),\n })\n"
1643
- }
1644
- ]
1645
- },
1646
- {
1647
- "id": "update-ai-forecast",
1648
- "sha": "303bea982077164b424352f82cee106a454c6109",
1649
- "parentSha": "e0e5f84f3f89f06f4458866ea53e8fd93b309fb2",
1650
- "spec": "Implement the following updates to the AI Forecast dashboard and timeline layout:\n\n1) AI Forecast market config updates (web/components/ai-forecast.tsx)\n- Update the DisplayType union comment to reflect the current options without the legacy rename note, keeping the union values: 'top-three-mcq', 'top-one-mcq', 'binary-odds', 'date', 'numeric'.\n- Remove the benchmark entry for the numeric market titled \"SWE Bench\" (Predicted top score). This includes removing its associated numeric description mapping so it no longer renders any SWE Bench-specific helper text under numeric displays.\n- Add a new benchmark market titled \"OpenAI Claims AGI\" with description \"OAI claims AGI by EOY\", marketId \"5SLp6d9yzy\", displayType 'binary-odds'. Ensure it appears among other benchmark entries.\n- Add three new long-term items, all displayType 'binary-odds':\n • \"Fully AI-generated Movie\" (description: \"High quality AI-generated movie\"), marketId \"A319ydGB1B7f4PMOROL3\".\n • \"Reliable Household Robot\" (description: \"Reliable household robot developed\"), marketId \"Q64BBTJSHWQfhovq5bnA\".\n • \"AI Politically Relevant\" (description: \"AI Discourse = Abortion Discourse\"), marketId \"nOXCmJFNsLx08PjOc4Qk\".\n- Update the conditional helper texts displayed below cards:\n • Change the IMO entry text from \"LLM gets IMO gold medal\" to \"LLM gets IMO gold\".\n • Add a benchmark helper text for titles including \"AGI\": \"OpenAI claims to have achieved AGI by the end of 2025\".\n • Update the misuse ASL-3 text to remove vendor reference so it reads: \"Model defined as ASL-3 released by end of 2025\".\n • Add long-term helper texts for titles including:\n - \"Movie\": \"AI generates a high-quality movie with a single prompt by 2028\".\n - \"Relevant\": \"AI as big as a political issue as abortion by 2028\".\n - \"Robot\": \"Reliable general household robot available by 2030\".\n • Keep existing mappings (e.g., Pokemon Master, Blackmail, Economic, Zero-shot, Self-play) unchanged.\n- Improve graph rendering by increasing the fetch size for bet points in the featured/binary graph: wherever getBetPoints(contract.id, { ... }) is called for the featured graph inside this component, increase the limit option from 1000 to 5000.\n\n2) Timeline spacing fix (web/components/timeline/timeline-card.tsx)\n- Increase the bottom margin of the title row container from 'mb-14' to 'mb-20' to prevent overlap and improve layout of the timeline under the header.\n\nBehavioral expectations:\n- The SWE Bench numeric card no longer appears in the AI Forecast dashboard (and no SWE Bench numeric helper text is shown).\n- The new \"OpenAI Claims AGI\" benchmark renders as a binary-odds card and includes the new benchmark helper text when applicable.\n- The new long-term markets (Movie, Robot, Politically Relevant) render as binary-odds cards with their respective helper texts.\n- Timeline section renders with increased spacing between title and timeline content, fixing previous crowding.\n- Featured/binary charts appear smoother/more detailed due to increased bet point sampling.\n\nScope limitations:\n- Do not change APIs or shared getBetPoints behavior; only adjust the local call site limit within ai-forecast.tsx.\n- Do not modify other timeline components beyond the margin class change in timeline-card.tsx.",
1651
- "prompt": "Update the AI Forecast dashboard to reflect recently curated markets and improve the visual layout and chart smoothness. Remove an obsolete numeric benchmark, add a new benchmark focused on AGI claims, and include several new long-term AI capability markets. Refresh the short helper texts beneath cards to match the updated set, including concise language for IMO gold, ASL-3, and new entries for AGI, a fully AI-generated movie, political relevance of AI, and a reliable household robot. Also, increase the density of data points used in the featured probability chart to improve rendering quality. Finally, adjust the timeline card’s header spacing so the title and timeline do not crowd each other. Ensure the dashboard reflects these changes cleanly without altering global APIs.",
1652
- "supplementalFiles": [
1653
- "common/src/bets.ts",
1654
- "web/components/timeline/timeline.tsx",
1655
- "web/components/timeline/timeline-item.tsx",
1656
- "web/components/charts/contract/binary.tsx",
1657
- "web/components/charts/generic-charts.tsx",
1658
- "web/components/charts/contract/number.tsx",
1659
- "web/pages/ai/[[...slug]].tsx"
1660
- ],
1661
- "fileDiffs": [
1662
- {
1663
- "path": "web/components/ai-forecast.tsx",
1664
- "status": "modified",
1665
- "diff": "Index: web/components/ai-forecast.tsx\n===================================================================\n--- web/components/ai-forecast.tsx\te0e5f84 (parent)\n+++ web/components/ai-forecast.tsx\t303bea9 (commit)\n@@ -94,9 +94,9 @@\n description: string\n marketId: string\n type: string\n displayType?:\n- | 'top-three-mcq' // renamed from top-two-mcq\n+ | 'top-three-mcq'\n | 'top-one-mcq'\n | 'binary-odds'\n | 'date'\n | 'numeric'\n@@ -187,15 +187,8 @@\n type: 'benchmark',\n displayType: 'binary-odds',\n },\n {\n- title: 'SWE Bench',\n- description: 'Top SWE Bench score by EOY',\n- marketId: 'nEhgsIE6U0',\n- type: 'benchmark',\n- displayType: 'numeric',\n- },\n- {\n title: \"Humanity's Last Exam\",\n description: \"Highest score on Humanity's last exam by EOY\",\n marketId: 'tzsZCn85RQ',\n type: 'benchmark',\n@@ -221,8 +214,15 @@\n marketId: 'ssZ5lUgItL',\n type: 'benchmark',\n displayType: 'binary-odds',\n },\n+ {\n+ title: 'OpenAI Claims AGI',\n+ description: 'OAI claims AGI by EOY',\n+ marketId: '5SLp6d9yzy',\n+ type: 'benchmark',\n+ displayType: 'binary-odds',\n+ },\n \n // Prizes\n {\n title: 'ARC-AGI Grand Prize before 2030',\n@@ -278,16 +278,37 @@\n type: 'long-term',\n displayType: 'binary-odds',\n },\n {\n+ title: 'Fully AI-generated Movie', \n+ description: 'High quality AI-generated movie',\n+ marketId: 'A319ydGB1B7f4PMOROL3',\n+ type: 'long-term',\n+ displayType: 'binary-odds',\n+ },\n+ {\n+ title: 'Reliable Household Robot',\n+ description: 'Reliable household robot developed',\n+ marketId: 'Q64BBTJSHWQfhovq5bnA',\n+ type: 'long-term',\n+ displayType: 'binary-odds',\n+ },\n+ {\n title: 'Discontinuous Change in Economic Variables',\n description:\n 'Visible break in trend line on US GDP, GDP per capita, unemployment, or productivity',\n marketId: 'zg7xJ5ZkJJ4wJPJDPjWO',\n type: 'long-term',\n displayType: 'binary-odds',\n },\n {\n+ title: 'AI Politically Relevant',\n+ description: 'AI Discourse = Abortion Discourse',\n+ marketId: 'nOXCmJFNsLx08PjOc4Qk',\n+ type: 'long-term',\n+ displayType: 'binary-odds',\n+ },\n+ {\n title: 'Zero-shot Human-level Game Performance',\n description: 'AI plays computer games at human level',\n marketId: 'barjfHPUpHGNKSfhBhJx',\n type: 'long-term',\n@@ -919,12 +940,15 @@\n type === 'long-term') && (\n <p className=\"text-ink-600 mt-1 w-full px-1 text-left text-xs sm:mt-3 sm:text-sm\">\n {type === 'benchmark' &&\n title.includes('IMO Gold') &&\n- 'LLM gets IMO gold medal'}\n+ 'LLM gets IMO gold'}\n {type === 'benchmark' &&\n title.includes('Pokemon') &&\n 'LLM becomes a Pokemon Master with minimal assistance'}\n+ {type === 'benchmark' &&\n+ title.includes('AGI') &&\n+ 'OpenAI claims to have achieved AGI by the end of 2025'}\n {type === 'prize' &&\n title.includes('Millennium') &&\n 'Chance of solving a million-dollar math problem'}\n {type === 'prize' &&\n@@ -937,24 +961,33 @@\n title.includes('Hacking') &&\n 'Probability of AI compromising systems by end of 2025'}\n {type === 'misuse' &&\n title.includes('ASL-3') &&\n- 'Model defined as ASL-3 by Anthropic released by end of 2025'}\n+ 'Model defined as ASL-3 released by end of 2025'}\n {type === 'long-term' &&\n title.includes('Romantic') &&\n 'At least 1/1000 Americans talks weekly with one by 2028'}\n {type === 'long-term' &&\n+ title.includes('Movie') &&\n+ 'AI generates a high-quality movie with a single prompt by 2028'}\n+ {type === 'long-term' &&\n title.includes('Blackmail') &&\n 'Risk of AI being used for automated blackmail by 2028'}\n {type === 'long-term' &&\n title.includes('Economic') &&\n 'Break in trend for GDP growth, GDP/capita, productivity, or unemployment by 2028'}\n {type === 'long-term' &&\n+ title.includes('Relevant') &&\n+ 'AI as big as a political issue as abortion by 2028'}\n+ {type === 'long-term' &&\n title.includes('Zero') &&\n 'AI plays a random computer game at human-level by 2028'}\n {type === 'long-term' &&\n title.includes('Self-play') &&\n 'AI plays a random computer game as well as a human after self-play by 2028'}\n+ {type === 'long-term' &&\n+ title.includes('Robot') &&\n+ 'Reliable general household robot available by 2030'}\n </p>\n )}\n </div>\n ) : displayType === 'date' || displayType === 'numeric' ? (\n@@ -974,11 +1007,8 @@\n {/* Brief descriptive text for numeric markets */}\n {displayType === 'numeric' && (\n <p className=\"text-ink-600 mt-1 w-full px-1 text-left text-xs sm:mt-3 sm:text-sm\">\n {type === 'benchmark' &&\n- title.includes('SWE Bench') &&\n- 'Predicted top score'}\n- {type === 'benchmark' &&\n title.includes('Frontier Math') &&\n 'Predicted top score'}\n {type === 'benchmark' &&\n title.includes('Last Exam') &&\n@@ -1137,9 +1167,9 @@\n useEffect(() => {\n if (contract) {\n // Get data points for the chart\n getBetPoints(contract.id, {\n- limit: 1000,\n+ limit: 5000,\n filterRedemptions: true,\n }).then((fetchedPoints) => {\n if (fetchedPoints?.length > 0) {\n setPoints(fetchedPoints)\n"
1666
- },
1667
- {
1668
- "path": "web/components/timeline/timeline-card.tsx",
1669
- "status": "modified",
1670
- "diff": "Index: web/components/timeline/timeline-card.tsx\n===================================================================\n--- web/components/timeline/timeline-card.tsx\te0e5f84 (parent)\n+++ web/components/timeline/timeline-card.tsx\t303bea9 (commit)\n@@ -18,9 +18,9 @@\n }: TimelineCardProps) {\n const cardContent = (\n <>\n {/* Title row */}\n- <div className=\"mb-14\">\n+ <div className=\"mb-20\">\n <div className=\"flex justify-between\">\n <div className=\"font-semibold text-gray-700 dark:text-gray-100 sm:text-lg\">\n Release Timelines\n </div>\n"
1671
- }
1672
- ]
1673
- }
1674
- ]
1675
- }