fleetlens 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/app/apps/web/.next/BUILD_ID +1 -1
  2. package/app/apps/web/.next/app-path-routes-manifest.json +9 -0
  3. package/app/apps/web/.next/build-manifest.json +6 -6
  4. package/app/apps/web/.next/prerender-manifest.json +3 -3
  5. package/app/apps/web/.next/routes-manifest.json +62 -0
  6. package/app/apps/web/.next/server/app/_global-error/page/build-manifest.json +3 -3
  7. package/app/apps/web/.next/server/app/_global-error.html +1 -1
  8. package/app/apps/web/.next/server/app/_global-error.rsc +1 -1
  9. package/app/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  10. package/app/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/app/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  12. package/app/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  13. package/app/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/app/apps/web/.next/server/app/_not-found/page/build-manifest.json +3 -3
  15. package/app/apps/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  16. package/app/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/app/apps/web/.next/server/app/api/ask/route.js +3 -2
  18. package/app/apps/web/.next/server/app/api/ask/route.js.nft.json +1 -1
  19. package/app/apps/web/.next/server/app/api/events/route.js +1 -0
  20. package/app/apps/web/.next/server/app/api/events/route.js.nft.json +1 -1
  21. package/app/apps/web/.next/server/app/api/insights/months-index/route/app-paths-manifest.json +3 -0
  22. package/app/apps/web/.next/server/app/api/insights/months-index/route/build-manifest.json +9 -0
  23. package/app/apps/web/.next/server/app/api/insights/months-index/route/server-reference-manifest.json +4 -0
  24. package/app/apps/web/.next/server/app/api/insights/months-index/route.js +7 -0
  25. package/app/apps/web/.next/server/app/api/insights/months-index/route.js.map +5 -0
  26. package/app/apps/web/.next/server/app/api/insights/months-index/route.js.nft.json +1 -0
  27. package/app/apps/web/.next/server/app/api/insights/months-index/route_client-reference-manifest.js +3 -0
  28. package/app/apps/web/.next/server/app/api/insights/pdf/[key]/route/app-paths-manifest.json +3 -0
  29. package/app/apps/web/.next/server/app/api/insights/pdf/[key]/route/build-manifest.json +9 -0
  30. package/app/apps/web/.next/server/app/api/insights/pdf/[key]/route/server-reference-manifest.json +4 -0
  31. package/app/apps/web/.next/server/app/api/insights/pdf/[key]/route.js +6 -0
  32. package/app/apps/web/.next/server/app/api/insights/pdf/[key]/route.js.map +5 -0
  33. package/app/apps/web/.next/server/app/api/insights/pdf/[key]/route.js.nft.json +1 -0
  34. package/app/apps/web/.next/server/app/api/insights/pdf/[key]/route_client-reference-manifest.js +3 -0
  35. package/app/apps/web/.next/server/app/api/insights/route/app-paths-manifest.json +3 -0
  36. package/app/apps/web/.next/server/app/api/insights/route/build-manifest.json +9 -0
  37. package/app/apps/web/.next/server/app/api/insights/route/server-reference-manifest.json +4 -0
  38. package/app/apps/web/.next/server/app/api/insights/route.js +8 -0
  39. package/app/apps/web/.next/server/app/api/insights/route.js.map +5 -0
  40. package/app/apps/web/.next/server/app/api/insights/route.js.nft.json +1 -0
  41. package/app/apps/web/.next/server/app/api/insights/route_client-reference-manifest.js +3 -0
  42. package/app/apps/web/.next/server/app/api/insights/saved/[key]/route/app-paths-manifest.json +3 -0
  43. package/app/apps/web/.next/server/app/api/insights/saved/[key]/route/build-manifest.json +9 -0
  44. package/app/apps/web/.next/server/app/api/insights/saved/[key]/route/server-reference-manifest.json +4 -0
  45. package/app/apps/web/.next/server/app/api/insights/saved/[key]/route.js +6 -0
  46. package/app/apps/web/.next/server/app/api/insights/saved/[key]/route.js.map +5 -0
  47. package/app/apps/web/.next/server/app/api/insights/saved/[key]/route.js.nft.json +1 -0
  48. package/app/apps/web/.next/server/app/api/insights/saved/[key]/route_client-reference-manifest.js +3 -0
  49. package/app/apps/web/.next/server/app/api/insights/saved/route/app-paths-manifest.json +3 -0
  50. package/app/apps/web/.next/server/app/api/insights/saved/route/build-manifest.json +9 -0
  51. package/app/apps/web/.next/server/app/api/insights/saved/route/server-reference-manifest.json +4 -0
  52. package/app/apps/web/.next/server/app/api/insights/saved/route.js +6 -0
  53. package/app/apps/web/.next/server/app/api/insights/saved/route.js.map +5 -0
  54. package/app/apps/web/.next/server/app/api/insights/saved/route.js.nft.json +1 -0
  55. package/app/apps/web/.next/server/app/api/insights/saved/route_client-reference-manifest.js +3 -0
  56. package/app/apps/web/.next/server/app/api/insights/weeks-index/route/app-paths-manifest.json +3 -0
  57. package/app/apps/web/.next/server/app/api/insights/weeks-index/route/build-manifest.json +9 -0
  58. package/app/apps/web/.next/server/app/api/insights/weeks-index/route/server-reference-manifest.json +4 -0
  59. package/app/apps/web/.next/server/app/api/insights/weeks-index/route.js +7 -0
  60. package/app/apps/web/.next/server/app/api/insights/weeks-index/route.js.map +5 -0
  61. package/app/apps/web/.next/server/app/api/insights/weeks-index/route.js.nft.json +1 -0
  62. package/app/apps/web/.next/server/app/api/insights/weeks-index/route_client-reference-manifest.js +3 -0
  63. package/app/apps/web/.next/server/app/insights/[key]/page/app-paths-manifest.json +3 -0
  64. package/app/apps/web/.next/server/app/insights/[key]/page/build-manifest.json +17 -0
  65. package/app/apps/web/.next/server/app/insights/[key]/page/next-font-manifest.json +6 -0
  66. package/app/apps/web/.next/server/app/insights/[key]/page/react-loadable-manifest.json +1 -0
  67. package/app/apps/web/.next/server/app/insights/[key]/page/server-reference-manifest.json +4 -0
  68. package/app/apps/web/.next/server/app/insights/[key]/page.js +16 -0
  69. package/app/apps/web/.next/server/app/insights/[key]/page.js.map +5 -0
  70. package/app/apps/web/.next/server/app/insights/[key]/page.js.nft.json +1 -0
  71. package/app/apps/web/.next/server/app/insights/[key]/page_client-reference-manifest.js +3 -0
  72. package/app/apps/web/.next/server/app/insights/page/app-paths-manifest.json +3 -0
  73. package/app/apps/web/.next/server/app/insights/page/build-manifest.json +17 -0
  74. package/app/apps/web/.next/server/app/insights/page/next-font-manifest.json +6 -0
  75. package/app/apps/web/.next/server/app/insights/page/react-loadable-manifest.json +1 -0
  76. package/app/apps/web/.next/server/app/insights/page/server-reference-manifest.json +4 -0
  77. package/app/apps/web/.next/server/app/insights/page.js +16 -0
  78. package/app/apps/web/.next/server/app/insights/page.js.map +5 -0
  79. package/app/apps/web/.next/server/app/insights/page.js.nft.json +1 -0
  80. package/app/apps/web/.next/server/app/insights/page_client-reference-manifest.js +3 -0
  81. package/app/apps/web/.next/server/app/insights/print/[key]/page/app-paths-manifest.json +3 -0
  82. package/app/apps/web/.next/server/app/insights/print/[key]/page/build-manifest.json +17 -0
  83. package/app/apps/web/.next/server/app/insights/print/[key]/page/next-font-manifest.json +6 -0
  84. package/app/apps/web/.next/server/app/insights/print/[key]/page/react-loadable-manifest.json +1 -0
  85. package/app/apps/web/.next/server/app/insights/print/[key]/page/server-reference-manifest.json +4 -0
  86. package/app/apps/web/.next/server/app/insights/print/[key]/page.js +16 -0
  87. package/app/apps/web/.next/server/app/insights/print/[key]/page.js.map +5 -0
  88. package/app/apps/web/.next/server/app/insights/print/[key]/page.js.nft.json +1 -0
  89. package/app/apps/web/.next/server/app/insights/print/[key]/page_client-reference-manifest.js +3 -0
  90. package/app/apps/web/.next/server/app/page/build-manifest.json +3 -3
  91. package/app/apps/web/.next/server/app/page.js +1 -1
  92. package/app/apps/web/.next/server/app/page.js.nft.json +1 -1
  93. package/app/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  94. package/app/apps/web/.next/server/app/parallelism/page/build-manifest.json +3 -3
  95. package/app/apps/web/.next/server/app/parallelism/page.js.nft.json +1 -1
  96. package/app/apps/web/.next/server/app/parallelism/page_client-reference-manifest.js +1 -1
  97. package/app/apps/web/.next/server/app/projects/[slug]/page/build-manifest.json +3 -3
  98. package/app/apps/web/.next/server/app/projects/[slug]/page.js +1 -2
  99. package/app/apps/web/.next/server/app/projects/[slug]/page.js.nft.json +1 -1
  100. package/app/apps/web/.next/server/app/projects/[slug]/page_client-reference-manifest.js +1 -1
  101. package/app/apps/web/.next/server/app/projects/page/build-manifest.json +3 -3
  102. package/app/apps/web/.next/server/app/projects/page.js.nft.json +1 -1
  103. package/app/apps/web/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  104. package/app/apps/web/.next/server/app/sessions/[id]/page/build-manifest.json +3 -3
  105. package/app/apps/web/.next/server/app/sessions/[id]/page.js +1 -2
  106. package/app/apps/web/.next/server/app/sessions/[id]/page.js.nft.json +1 -1
  107. package/app/apps/web/.next/server/app/sessions/[id]/page_client-reference-manifest.js +1 -1
  108. package/app/apps/web/.next/server/app/sessions/page/build-manifest.json +3 -3
  109. package/app/apps/web/.next/server/app/sessions/page.js.nft.json +1 -1
  110. package/app/apps/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  111. package/app/apps/web/.next/server/app/usage/page/build-manifest.json +3 -3
  112. package/app/apps/web/.next/server/app/usage/page.js.nft.json +1 -1
  113. package/app/apps/web/.next/server/app/usage/page_client-reference-manifest.js +1 -1
  114. package/app/apps/web/.next/server/app-paths-manifest.json +9 -0
  115. package/app/apps/web/.next/server/chunks/02dz_web__next-internal_server_app_api_insights_months-index_route_actions_0~h~zci.js +3 -0
  116. package/app/apps/web/.next/server/chunks/02dz_web__next-internal_server_app_api_insights_saved_[key]_route_actions_0a5j9yw.js +3 -0
  117. package/app/apps/web/.next/server/chunks/02dz_web__next-internal_server_app_api_insights_weeks-index_route_actions_0hh-1c7.js +3 -0
  118. package/app/apps/web/.next/server/chunks/{[externals]__0rv9ct5._.js → [externals]__0_xztrf._.js} +2 -2
  119. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0.hmj0z._.js +3 -0
  120. package/app/apps/web/.next/server/chunks/[root-of-the-server]__01h_g-p._.js +3 -0
  121. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0nh-z10._.js +3 -0
  122. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0op9af~._.js +1 -1
  123. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0ri7q~r._.js +3 -0
  124. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0rr1~~x._.js +3 -0
  125. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0s0gv.0._.js +3 -0
  126. package/app/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_insights_pdf_[key]_route_actions_0lu-lnx.js +3 -0
  127. package/app/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_insights_route_actions_0h8l5lz.js +3 -0
  128. package/app/apps/web/.next/server/chunks/apps_web__next-internal_server_app_api_insights_saved_route_actions_13rlblw.js +3 -0
  129. package/app/apps/web/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_00t2xj6.js +68 -0
  130. package/app/apps/web/.next/server/chunks/{[root-of-the-server]__0sgyieh._.js → node_modules_next_dist_esm_build_templates_app-route_0ck.78s.js} +5 -5
  131. package/app/apps/web/.next/server/chunks/packages_parser_dist_fs_0bc7ptm.js +3 -0
  132. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__00z7zlm._.js +1 -1
  133. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__08.d5ia._.js +12 -0
  134. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0b323~k._.js +8 -0
  135. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0euu8sa._.js +3 -0
  136. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0gio_ck._.js +3 -0
  137. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0gx-4qa._.js +3 -0
  138. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0w52fbc._.js +2 -2
  139. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0z45d0g._.js +49 -0
  140. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__13itnvx._.js +3 -0
  141. package/app/apps/web/.next/server/chunks/ssr/_0r~__0v._.js +12 -0
  142. package/app/apps/web/.next/server/chunks/ssr/_0xies90._.js +1 -1
  143. package/app/apps/web/.next/server/chunks/ssr/apps_web__next-internal_server_app_insights_[key]_page_actions_0_z2~e_.js +3 -0
  144. package/app/apps/web/.next/server/chunks/ssr/apps_web__next-internal_server_app_insights_page_actions_0zjdm~n.js +3 -0
  145. package/app/apps/web/.next/server/chunks/ssr/apps_web__next-internal_server_app_insights_print_[key]_page_actions_0a853fw.js +3 -0
  146. package/app/apps/web/.next/server/chunks/ssr/apps_web_components_06j3c.0._.js +1 -1
  147. package/app/apps/web/.next/server/chunks/ssr/apps_web_components_insight-report_tsx_13q2a-c._.js +12 -0
  148. package/app/apps/web/.next/server/chunks/ssr/apps_web_components_insights-view_tsx_11730ta._.js +6 -0
  149. package/app/apps/web/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_icons_0-lmm4p._.js +3 -0
  150. package/app/apps/web/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_icons_0nqz8dw._.js +3 -0
  151. package/app/apps/web/.next/server/chunks/ssr/node_modules_next_dist_0t-hj0x._.js +3 -0
  152. package/app/apps/web/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0vz7_px.js +4 -0
  153. package/app/apps/web/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_10cdrh2.js +4 -0
  154. package/app/apps/web/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_12n1lw_.js +4 -0
  155. package/app/apps/web/.next/server/chunks/ssr/packages_parser_dist_0-jvt~a._.js +1 -1
  156. package/app/apps/web/.next/server/functions-config-manifest.json +7 -1
  157. package/app/apps/web/.next/server/middleware-build-manifest.js +6 -6
  158. package/app/apps/web/.next/server/pages/500.html +1 -1
  159. package/app/apps/web/.next/server/server-reference-manifest.js +1 -1
  160. package/app/apps/web/.next/server/server-reference-manifest.json +1 -1
  161. package/app/apps/web/.next/static/chunks/03_7xtpi9sv4g.js +4 -0
  162. package/app/apps/web/.next/static/chunks/03g5dw5eskotd.js +6 -0
  163. package/app/apps/web/.next/static/chunks/{0p1lpoh02zh8h.js → 063oimt30jeo9.js} +2 -2
  164. package/app/apps/web/.next/static/chunks/08c1pcrocqq5s.js +6 -0
  165. package/app/apps/web/.next/static/chunks/0e-wrhqh4h3r5.js +2 -0
  166. package/app/apps/web/.next/static/chunks/0i7er7_bfab36.js +10 -0
  167. package/app/apps/web/.next/static/chunks/0ipytyi01h0ta.js +1 -0
  168. package/app/apps/web/.next/static/chunks/137uw2-qbro07.js +31 -0
  169. package/app/apps/web/.next/static/chunks/{turbopack-17n9y8y0h4q9f.js → turbopack-169w-1._23ra_.js} +1 -1
  170. package/app/apps/web/app/api/insights/months-index/route.ts +80 -0
  171. package/app/apps/web/app/api/insights/pdf/[key]/route.ts +45 -0
  172. package/app/apps/web/app/api/insights/route.ts +508 -0
  173. package/app/apps/web/app/api/insights/saved/[key]/route.ts +11 -0
  174. package/app/apps/web/app/api/insights/saved/route.ts +9 -0
  175. package/app/apps/web/app/api/insights/weeks-index/route.ts +95 -0
  176. package/app/apps/web/app/insights/[key]/page.tsx +59 -0
  177. package/app/apps/web/app/insights/page.tsx +7 -0
  178. package/app/apps/web/app/insights/print/[key]/page.tsx +73 -0
  179. package/app/apps/web/components/insight-report.tsx +1031 -0
  180. package/app/apps/web/components/insights-view.tsx +647 -0
  181. package/app/apps/web/components/live-refresher.tsx +11 -2
  182. package/app/apps/web/components/sidebar.tsx +8 -0
  183. package/app/apps/web/eslint.config.mjs +2 -0
  184. package/app/apps/web/lib/ai/chrome-pdf.ts +64 -0
  185. package/app/apps/web/lib/ai/insights-prompt.ts +71 -0
  186. package/app/apps/web/lib/ai/saved-reports.ts +83 -0
  187. package/app/apps/web/lib/date-range.ts +11 -2
  188. package/app/apps/web/lib/use-live-events.ts +7 -2
  189. package/app/apps/web/package.json +3 -2
  190. package/app/apps/web/tsconfig.tsbuildinfo +1 -1
  191. package/dist/daemon-worker.js +2765 -11
  192. package/dist/index.js +2990 -254
  193. package/package.json +5 -3
  194. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__05lbtny._.js +0 -12
  195. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0ms7h0~._.js +0 -8
  196. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0p~g4m6._.js +0 -3
  197. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0wvzyl7._.js +0 -3
  198. package/app/apps/web/.next/server/chunks/ssr/_0ph19-k._.js +0 -12
  199. package/app/apps/web/.next/server/chunks/ssr/_0u29mdd._.js +0 -3
  200. package/app/apps/web/.next/server/chunks/ssr/node_modules_0i2xw~e._.js +0 -3
  201. package/app/apps/web/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_icons_0.knjn.._.js +0 -3
  202. package/app/apps/web/.next/static/chunks/0257pdz1-imal.js +0 -2
  203. package/app/apps/web/.next/static/chunks/08983lz4~hffx.js +0 -6
  204. package/app/apps/web/.next/static/chunks/0qmh881w772ef.js +0 -31
  205. package/app/apps/web/.next/static/chunks/0wdga7zbwvg70.js +0 -1
  206. package/app/apps/web/.next/static/chunks/14q3xsfpkvuef.js +0 -6
  207. /package/app/apps/web/.next/static/{0fPDRTdMQ-Y62h6fHNJXL → G_4ofKI9v9ARC_lb9JILZ}/_buildManifest.js +0 -0
  208. /package/app/apps/web/.next/static/{0fPDRTdMQ-Y62h6fHNJXL → G_4ofKI9v9ARC_lb9JILZ}/_clientMiddlewareManifest.js +0 -0
  209. /package/app/apps/web/.next/static/{0fPDRTdMQ-Y62h6fHNJXL → G_4ofKI9v9ARC_lb9JILZ}/_ssgManifest.js +0 -0
@@ -0,0 +1,647 @@
1
+ "use client";
2
+
3
+ import React, { useCallback, useEffect, useRef, useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import {
6
+ CheckCircle2, ChevronRight,
7
+ Circle, Loader2, Square,
8
+ } from "lucide-react";
9
+ import { priorCalendarWeek } from "@claude-lens/parser";
10
+ import { type ReportData } from "@/components/insight-report";
11
+ import type { SavedReportMeta } from "@/lib/ai/saved-reports";
12
+
13
+ type RangeChoice = {
14
+ /** Stable id used for the generating-state label. For picker rows, use `week-YYYY-MM-DD`. */
15
+ id: string;
16
+ label: string;
17
+ /** Body posted to /api/insights. */
18
+ body: { range_type: "prior_week" | "4weeks_completed" | "week" | "4weeks" | "custom"; since?: string; until?: string };
19
+ };
20
+
21
+ type WeekRow = {
22
+ iso_week: number;
23
+ start: string;
24
+ end: string;
25
+ label: string;
26
+ sessions: number;
27
+ in_progress: boolean;
28
+ saved_key: string | null;
29
+ archetype_label?: string;
30
+ sessions_used?: number;
31
+ prs?: number;
32
+ };
33
+
34
+ type PhaseId = "data" | "analyst" | "compose";
35
+ type Phase = { id: PhaseId; label: string; status: "pending" | "running" | "done"; steps: string[] };
36
+
37
+ type InsightsEvent =
38
+ | { type: "status"; phase: PhaseId; text: string }
39
+ | { type: "report"; report: ReportData }
40
+ | { type: "saved"; key: string; saved_at: string }
41
+ | { type: "done" }
42
+ | { type: "error"; message: string };
43
+
44
+ type View =
45
+ | { kind: "history" }
46
+ | { kind: "generating"; rangeId: string; label: string; phases: Phase[]; elapsedMs: number }
47
+ | { kind: "error"; message: string };
48
+
49
+ const INITIAL_PHASES: Phase[] = [
50
+ { id: "data", label: "Gather data", status: "pending", steps: [] },
51
+ { id: "analyst", label: "Claude analyst", status: "pending", steps: [] },
52
+ { id: "compose", label: "Compose report", status: "pending", steps: [] },
53
+ ];
54
+
55
+ export function InsightsView() {
56
+ const router = useRouter();
57
+ const [view, setView] = useState<View>({ kind: "history" });
58
+ const abortRef = useRef<AbortController | null>(null);
59
+ const startTimerRef = useRef<number | null>(null);
60
+
61
+ const openSaved = useCallback((key: string) => {
62
+ router.push(`/insights/${key}`);
63
+ }, [router]);
64
+
65
+ const generate = useCallback(async (range: RangeChoice) => {
66
+ const phases: Phase[] = INITIAL_PHASES.map((p) => ({ ...p, steps: [] }));
67
+ phases[0]!.status = "running";
68
+ const startMs = Date.now();
69
+ // Tick only when the whole-second reading changes — avoids re-rendering
70
+ // the phase tree twice a second when nothing visible moved.
71
+ startTimerRef.current = window.setInterval(() => {
72
+ setView((v) => {
73
+ if (v.kind !== "generating") return v;
74
+ const next = Date.now() - startMs;
75
+ if (Math.floor(next / 1000) === Math.floor(v.elapsedMs / 1000)) return v;
76
+ return { ...v, elapsedMs: next };
77
+ });
78
+ }, 500);
79
+ setView({ kind: "generating", rangeId: range.id, label: range.label, phases, elapsedMs: 0 });
80
+
81
+ const ctrl = new AbortController();
82
+ abortRef.current = ctrl;
83
+
84
+ try {
85
+ const res = await fetch("/api/insights", {
86
+ method: "POST",
87
+ headers: { "Content-Type": "application/json" },
88
+ body: JSON.stringify(range.body),
89
+ signal: ctrl.signal,
90
+ });
91
+ if (!res.ok || !res.body) {
92
+ setView({ kind: "error", message: await res.text() || `HTTP ${res.status}` });
93
+ return;
94
+ }
95
+ const reader = res.body.getReader();
96
+ const decoder = new TextDecoder();
97
+ let buffer = "";
98
+ let done = false;
99
+ let savedKey: string | null = null;
100
+ let report: ReportData | null = null;
101
+ while (!done) {
102
+ const chunk = await reader.read();
103
+ if (chunk.done) break;
104
+ buffer += decoder.decode(chunk.value, { stream: true });
105
+ const lines = buffer.split("\n");
106
+ buffer = lines.pop()!;
107
+ for (const line of lines) {
108
+ if (!line.startsWith("data: ")) continue;
109
+ const raw = line.slice(6).trim();
110
+ if (!raw) continue;
111
+ let data: InsightsEvent;
112
+ try { data = JSON.parse(raw); } catch { continue; }
113
+
114
+ if (data.type === "status") {
115
+ const { phase: ph, text: txt } = data;
116
+ setView((v) => {
117
+ if (v.kind !== "generating") return v;
118
+ const next = { ...v, phases: v.phases.map((p) => p.id === ph ? { ...p, steps: [...p.steps] } : p) };
119
+ for (const phase of next.phases) {
120
+ if (phase.id === ph) {
121
+ phase.status = "running";
122
+ const last = phase.steps[phase.steps.length - 1];
123
+ if (last && isSupersedingUpdate(last, txt)) {
124
+ phase.steps[phase.steps.length - 1] = txt;
125
+ } else {
126
+ phase.steps.push(txt);
127
+ }
128
+ } else if (phaseOrder(phase.id) < phaseOrder(ph)) {
129
+ phase.status = "done";
130
+ }
131
+ }
132
+ return next;
133
+ });
134
+ } else if (data.type === "report") {
135
+ report = data.report;
136
+ } else if (data.type === "saved") {
137
+ savedKey = data.key;
138
+ } else if (data.type === "done") {
139
+ done = true;
140
+ setView((v) => v.kind === "generating"
141
+ ? { ...v, phases: v.phases.map((p) => ({ ...p, status: "done" as const })) }
142
+ : v);
143
+ } else if (data.type === "error") {
144
+ setView({ kind: "error", message: data.message });
145
+ return;
146
+ }
147
+ }
148
+ }
149
+ // Successful generation of a completed period lands on the report URL.
150
+ // Successful generation of a completed period lands on its saved URL.
151
+ // In-progress (unsaved) generations flip back to history — the picker
152
+ // will re-fetch its row counts on mount.
153
+ if (report && savedKey) {
154
+ router.push(`/insights/${savedKey}`);
155
+ } else {
156
+ setView({ kind: "history" });
157
+ }
158
+ } catch (err) {
159
+ if ((err as Error).name !== "AbortError") {
160
+ setView({ kind: "error", message: (err as Error).message });
161
+ }
162
+ } finally {
163
+ if (startTimerRef.current) { window.clearInterval(startTimerRef.current); startTimerRef.current = null; }
164
+ abortRef.current = null;
165
+ }
166
+ }, [router]);
167
+
168
+ const stop = () => abortRef.current?.abort();
169
+
170
+ // ── Render ─────────────────────────────────────────────────────
171
+ if (view.kind === "error") {
172
+ return (
173
+ <div style={errorStyle}>
174
+ <strong>Error:</strong> {view.message}
175
+ <button type="button" onClick={() => setView({ kind: "history" })} style={{ ...primaryBtn, marginLeft: 12 }}>Back</button>
176
+ </div>
177
+ );
178
+ }
179
+
180
+ if (view.kind === "generating") {
181
+ return <GeneratingView view={view} onStop={stop} />;
182
+ }
183
+
184
+ return <HistoryView onGenerate={generate} onOpen={openSaved} />;
185
+ }
186
+
187
+ // ──────────────────────────────────────────────────────────────────
188
+ // HISTORY VIEW
189
+ // ──────────────────────────────────────────────────────────────────
190
+
191
+ function HistoryView({
192
+ onGenerate, onOpen,
193
+ }: {
194
+ onGenerate: (r: RangeChoice) => void;
195
+ onOpen: (key: string) => void;
196
+ }) {
197
+ const [tab, setTab] = useState<"weeks" | "months">("weeks");
198
+ const [weeks, setWeeks] = useState<WeekRow[] | null>(null);
199
+ const [includeInProgress, setIncludeInProgress] = useState(false);
200
+
201
+ useEffect(() => {
202
+ void fetch("/api/insights/weeks-index?count=12")
203
+ .then((r) => r.json())
204
+ .then((j: { weeks: WeekRow[] }) => setWeeks(j.weeks))
205
+ .catch(() => setWeeks([]));
206
+ }, []);
207
+
208
+ return (
209
+ <div style={{ maxWidth: 820, margin: "0 auto", padding: "32px 40px 64px", display: "flex", flexDirection: "column", gap: 24 }}>
210
+ <header style={{ display: "flex", flexDirection: "column", gap: 6 }}>
211
+ <h1 style={{ fontSize: 26, fontWeight: 700, letterSpacing: "-0.02em", margin: 0 }}>Insights</h1>
212
+ <p style={{ fontSize: 13, color: "var(--af-text-secondary)", margin: 0, lineHeight: 1.55 }}>
213
+ Pick a calendar week or a 4-week rollup. If a report already exists we'll open it instantly; otherwise
214
+ Claude runs the pipeline (~1–3 min) and saves the result locally.
215
+ </p>
216
+ </header>
217
+
218
+ {/* Tabs */}
219
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
220
+ <div style={tabGroup}>
221
+ {(["weeks", "months"] as const).map((t) => (
222
+ <button key={t} type="button" onClick={() => setTab(t)}
223
+ style={{
224
+ ...tabBtn,
225
+ background: tab === t ? "var(--af-accent)" : "transparent",
226
+ color: tab === t ? "white" : "var(--af-text-secondary)",
227
+ }}>
228
+ {t === "weeks" ? "Weeks" : "Months"}
229
+ </button>
230
+ ))}
231
+ </div>
232
+ <label style={inProgressToggle}>
233
+ <input type="checkbox" checked={includeInProgress} onChange={(e) => setIncludeInProgress(e.target.checked)} />
234
+ <span>Include current {tab === "weeks" ? "week" : "month"}</span>
235
+ </label>
236
+ </div>
237
+
238
+ {/* Weeks list or month rollup */}
239
+ {tab === "weeks" ? (
240
+ weeks === null ? (
241
+ <div style={emptyHistoryStyle}>Loading weeks…</div>
242
+ ) : (
243
+ <ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 4 }}>
244
+ {weeks
245
+ .filter((w) => includeInProgress || !w.in_progress)
246
+ .map((w) => (
247
+ <WeekPickerRow
248
+ key={w.start}
249
+ w={w}
250
+ onView={() => w.saved_key && onOpen(w.saved_key)}
251
+ onGenerate={() => onGenerate({
252
+ id: `week-${w.start}`,
253
+ label: `Week of ${w.label}`,
254
+ body: { range_type: "custom", since: w.start, until: w.end },
255
+ })}
256
+ />
257
+ ))}
258
+ </ul>
259
+ )
260
+ ) : (
261
+ <MonthsView onGenerate={onGenerate} onOpen={onOpen} includeInProgress={includeInProgress} />
262
+ )}
263
+ <Spin />
264
+ </div>
265
+ );
266
+ }
267
+
268
+ // ──────────────────────────────────────────────────────────────────
269
+ // PICKER ROWS
270
+ // ──────────────────────────────────────────────────────────────────
271
+
272
+ function WeekPickerRow({
273
+ w, onView, onGenerate,
274
+ }: {
275
+ w: WeekRow;
276
+ onView: () => void;
277
+ onGenerate: () => void;
278
+ }) {
279
+ const saved = !!w.saved_key;
280
+ const empty = w.sessions === 0 && !w.in_progress;
281
+ return (
282
+ <li style={pickerRow(saved)}>
283
+ <div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0, flex: 1 }}>
284
+ <div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
285
+ <span style={{ fontSize: 13, fontWeight: 600, color: "var(--af-text)", letterSpacing: "-0.01em" }}>
286
+ W{String(w.iso_week).padStart(2, "0")} · {w.label}
287
+ </span>
288
+ {w.in_progress && <span style={inProgressTag}>in progress</span>}
289
+ </div>
290
+ <div style={{ fontSize: 11, color: "var(--af-text-tertiary)", fontFamily: "var(--font-mono)", display: "flex", gap: 8, flexWrap: "wrap" }}>
291
+ <span>{w.sessions} sess</span>
292
+ {saved && w.archetype_label && (
293
+ <>
294
+ <span>·</span>
295
+ <span style={{ color: "var(--af-accent)" }}>{w.archetype_label}</span>
296
+ <span>·</span>
297
+ <span>{w.sessions_used} used · {w.prs} PR</span>
298
+ </>
299
+ )}
300
+ {empty && <><span>·</span><span>no data</span></>}
301
+ </div>
302
+ </div>
303
+ {saved ? (
304
+ <button type="button" onClick={onView} style={primaryBtn}>
305
+ View report <ChevronRight size={13} />
306
+ </button>
307
+ ) : empty ? (
308
+ <span style={disabledCTA}>no data</span>
309
+ ) : w.in_progress ? (
310
+ <button type="button" onClick={onGenerate} style={secondaryBtn} title="Current week is partial and won't be auto-saved">
311
+ Generate anyway
312
+ </button>
313
+ ) : (
314
+ <button type="button" onClick={onGenerate} style={secondaryBtn}>
315
+ Generate
316
+ </button>
317
+ )}
318
+ </li>
319
+ );
320
+ }
321
+
322
+ type MonthRow = {
323
+ year: number;
324
+ month: number;
325
+ start: string;
326
+ end: string;
327
+ label: string;
328
+ sessions: number;
329
+ in_progress: boolean;
330
+ saved_key: string | null;
331
+ archetype_label?: string;
332
+ sessions_used?: number;
333
+ prs?: number;
334
+ };
335
+
336
+ function MonthsView({
337
+ onGenerate, onOpen, includeInProgress,
338
+ }: {
339
+ onGenerate: (r: RangeChoice) => void;
340
+ onOpen: (key: string) => void;
341
+ includeInProgress: boolean;
342
+ }) {
343
+ const [months, setMonths] = useState<MonthRow[] | null>(null);
344
+
345
+ useEffect(() => {
346
+ void fetch("/api/insights/months-index?count=6")
347
+ .then((r) => r.json())
348
+ .then((j: { months: MonthRow[] }) => setMonths(j.months))
349
+ .catch(() => setMonths([]));
350
+ }, []);
351
+
352
+ if (months === null) return <div style={emptyHistoryStyle}>Loading months…</div>;
353
+
354
+ return (
355
+ <ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 4 }}>
356
+ {months
357
+ .filter((m) => includeInProgress || !m.in_progress)
358
+ .map((m) => <MonthPickerRow key={m.start} m={m}
359
+ onView={() => m.saved_key && onOpen(m.saved_key)}
360
+ onGenerate={() => onGenerate({
361
+ id: `month-${m.start}`,
362
+ label: m.label,
363
+ body: { range_type: "custom", since: m.start, until: m.end },
364
+ })}
365
+ />)}
366
+ </ul>
367
+ );
368
+ }
369
+
370
+ function MonthPickerRow({
371
+ m, onView, onGenerate,
372
+ }: {
373
+ m: MonthRow;
374
+ onView: () => void;
375
+ onGenerate: () => void;
376
+ }) {
377
+ const saved = !!m.saved_key;
378
+ const empty = m.sessions === 0 && !m.in_progress;
379
+ return (
380
+ <li style={pickerRow(saved)}>
381
+ <div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0, flex: 1 }}>
382
+ <div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
383
+ <span style={{ fontSize: 13, fontWeight: 600, color: "var(--af-text)", letterSpacing: "-0.01em" }}>
384
+ {m.label}
385
+ </span>
386
+ {m.in_progress && <span style={inProgressTag}>in progress</span>}
387
+ </div>
388
+ <div style={{ fontSize: 11, color: "var(--af-text-tertiary)", fontFamily: "var(--font-mono)", display: "flex", gap: 8, flexWrap: "wrap" }}>
389
+ <span>{m.sessions} sess</span>
390
+ {saved && m.archetype_label && (
391
+ <>
392
+ <span>·</span>
393
+ <span style={{ color: "var(--af-accent)" }}>{m.archetype_label}</span>
394
+ <span>·</span>
395
+ <span>{m.sessions_used} used · {m.prs} PR</span>
396
+ </>
397
+ )}
398
+ {empty && <><span>·</span><span>no data</span></>}
399
+ </div>
400
+ </div>
401
+ {saved ? (
402
+ <button type="button" onClick={onView} style={primaryBtn}>
403
+ View report <ChevronRight size={13} />
404
+ </button>
405
+ ) : empty ? (
406
+ <span style={disabledCTA}>no data</span>
407
+ ) : m.in_progress ? (
408
+ <button type="button" onClick={onGenerate} style={secondaryBtn} title="Current month is partial and won't be auto-saved">
409
+ Generate anyway
410
+ </button>
411
+ ) : (
412
+ <button type="button" onClick={onGenerate} style={secondaryBtn}>
413
+ Generate
414
+ </button>
415
+ )}
416
+ </li>
417
+ );
418
+ }
419
+
420
+ // ──────────────────────────────────────────────────────────────────
421
+ // GENERATING VIEW
422
+ // ──────────────────────────────────────────────────────────────────
423
+
424
+ function GeneratingView({
425
+ view, onStop,
426
+ }: {
427
+ view: Extract<View, { kind: "generating" }>;
428
+ onStop: () => void;
429
+ }) {
430
+ const elapsedS = Math.floor(view.elapsedMs / 1000);
431
+ return (
432
+ <div style={{ maxWidth: 720, margin: "0 auto", padding: "60px 40px", display: "flex", flexDirection: "column", gap: 24 }}>
433
+ <header style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
434
+ <div>
435
+ <div style={{ fontSize: 11, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--af-text-tertiary)", fontWeight: 600 }}>
436
+ Generating
437
+ </div>
438
+ <h1 style={{ fontSize: 22, fontWeight: 700, letterSpacing: "-0.02em", margin: "2px 0 0" }}>{view.label}</h1>
439
+ </div>
440
+ <button type="button" onClick={onStop} style={secondaryBtn}>
441
+ <Square size={12} /> Stop
442
+ </button>
443
+ </header>
444
+
445
+ {/* Phase summary strip */}
446
+ <div style={phaseStripStyle}>
447
+ {view.phases.map((p, i) => (
448
+ <div key={p.id} style={phaseChipStyle(p.status)}>
449
+ <PhaseIcon status={p.status} />
450
+ <span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.02em" }}>
451
+ {i + 1}. {p.label}
452
+ </span>
453
+ </div>
454
+ ))}
455
+ <div style={{ marginLeft: "auto", fontSize: 11, color: "var(--af-text-tertiary)", fontFamily: "var(--font-mono)", display: "inline-flex", alignItems: "center", gap: 6 }}>
456
+ <Loader2 size={11} className="af-spin" /> {elapsedS}s elapsed
457
+ </div>
458
+ </div>
459
+
460
+ {/* Active phase detail */}
461
+ {view.phases.map((p) => p.steps.length > 0 ? (
462
+ <div key={p.id} style={phaseDetailStyle(p.status)}>
463
+ <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
464
+ <PhaseIcon status={p.status} />
465
+ <span style={{ fontSize: 12, fontWeight: 600, color: p.status === "done" ? "var(--af-text-secondary)" : "var(--af-text)" }}>
466
+ {p.label}
467
+ </span>
468
+ </div>
469
+ <ol style={stepsListStyle}>
470
+ {p.steps.map((s, i) => (
471
+ <li key={i} style={{
472
+ ...stepItemStyle,
473
+ opacity: i === p.steps.length - 1 && p.status === "running" ? 1 : 0.65,
474
+ }}>
475
+ <span style={{ color: "var(--af-accent)", fontSize: 10, width: 10 }}>
476
+ {i === p.steps.length - 1 && p.status === "running" ? "•" : "✓"}
477
+ </span>
478
+ {s}
479
+ </li>
480
+ ))}
481
+ </ol>
482
+ </div>
483
+ ) : null)}
484
+
485
+ <Spin />
486
+ </div>
487
+ );
488
+ }
489
+
490
+ // ──────────────────────────────────────────────────────────────────
491
+ // Helpers
492
+ // ──────────────────────────────────────────────────────────────────
493
+
494
+ function phaseOrder(p: PhaseId): number {
495
+ return { data: 0, analyst: 1, compose: 2 }[p];
496
+ }
497
+
498
+ function isSupersedingUpdate(prev: string, next: string): boolean {
499
+ // e.g. "Built 7/26 capsules" → "Built 14/26 capsules" supersedes
500
+ // "Claude composing… 500 chars" → "Claude composing… 1500 chars" supersedes
501
+ const prefixes = ["Built ", "Claude composing", "Aggregating ", "Sending "];
502
+ return prefixes.some((p) => prev.startsWith(p) && next.startsWith(p));
503
+ }
504
+
505
+ function isoDay(d: Date): string {
506
+ const y = d.getFullYear();
507
+ const m = String(d.getMonth() + 1).padStart(2, "0");
508
+ const day = String(d.getDate()).padStart(2, "0");
509
+ return `${y}-${m}-${day}`;
510
+ }
511
+
512
+ function fmtDate(d: Date): string {
513
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
514
+ }
515
+
516
+ // ──────────────────────────────────────────────────────────────────
517
+ // Sub-components
518
+ // ──────────────────────────────────────────────────────────────────
519
+
520
+ function PhaseIcon({ status }: { status: Phase["status"] }) {
521
+ if (status === "done") return <CheckCircle2 size={14} color="var(--af-accent)" />;
522
+ if (status === "running") return <Loader2 size={14} color="var(--af-accent)" className="af-spin" />;
523
+ return <Circle size={14} color="var(--af-text-tertiary)" />;
524
+ }
525
+
526
+ function Spin() {
527
+ return (
528
+ <style>{`
529
+ @keyframes af-spin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
530
+ .af-spin { animation: af-spin 0.9s linear infinite; }
531
+ `}</style>
532
+ );
533
+ }
534
+
535
+ // ──────────────────────────────────────────────────────────────────
536
+ // Styles
537
+ // ──────────────────────────────────────────────────────────────────
538
+
539
+ const centerStyle: React.CSSProperties = {
540
+ padding: "80px 40px", textAlign: "center",
541
+ fontSize: 13, color: "var(--af-text-secondary)",
542
+ display: "inline-flex", alignItems: "center", gap: 8, justifyContent: "center", width: "100%",
543
+ };
544
+
545
+ const errorStyle: React.CSSProperties = {
546
+ maxWidth: 720, margin: "60px auto", padding: "14px 18px", borderRadius: 10,
547
+ border: "1px solid var(--af-border)", background: "var(--af-surface)",
548
+ fontSize: 13, color: "var(--af-text)",
549
+ };
550
+
551
+ const primaryBtn: React.CSSProperties = {
552
+ display: "inline-flex", alignItems: "center", gap: 6, padding: "8px 14px",
553
+ border: "1px solid var(--af-accent)", borderRadius: 8,
554
+ background: "var(--af-accent)", color: "white",
555
+ fontSize: 12, fontWeight: 600, cursor: "pointer",
556
+ };
557
+
558
+ const secondaryBtn: React.CSSProperties = {
559
+ display: "inline-flex", alignItems: "center", gap: 6, padding: "7px 12px",
560
+ border: "1px solid var(--af-border)", borderRadius: 8,
561
+ background: "var(--af-surface)", color: "var(--af-text)",
562
+ fontSize: 12, fontWeight: 500, cursor: "pointer",
563
+ };
564
+
565
+ const tabGroup: React.CSSProperties = {
566
+ display: "inline-flex", gap: 3, border: "1px solid var(--af-border)", borderRadius: 8, padding: 2,
567
+ };
568
+
569
+ const tabBtn: React.CSSProperties = {
570
+ padding: "5px 14px", fontSize: 12, fontWeight: 600,
571
+ border: "none", borderRadius: 6, cursor: "pointer",
572
+ };
573
+
574
+ const inProgressToggle: React.CSSProperties = {
575
+ display: "inline-flex", alignItems: "center", gap: 6,
576
+ fontSize: 11.5, color: "var(--af-text-secondary)",
577
+ cursor: "pointer", marginLeft: "auto",
578
+ };
579
+
580
+ function pickerRow(saved: boolean): React.CSSProperties {
581
+ return {
582
+ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14,
583
+ padding: "12px 16px", borderRadius: 10,
584
+ border: `1px solid ${saved ? "color-mix(in srgb, var(--af-accent) 24%, var(--af-border))" : "var(--af-border-subtle)"}`,
585
+ background: saved ? "color-mix(in srgb, var(--af-accent) 6%, var(--af-surface))" : "var(--af-surface)",
586
+ };
587
+ }
588
+
589
+ const disabledCTA: React.CSSProperties = {
590
+ fontSize: 11.5, color: "var(--af-text-tertiary)", fontFamily: "var(--font-mono)",
591
+ padding: "7px 12px",
592
+ };
593
+
594
+ const topNavStyle: React.CSSProperties = {
595
+ maxWidth: 980, margin: "0 auto", padding: "16px 44px 0",
596
+ display: "flex", alignItems: "center", gap: 12,
597
+ };
598
+
599
+ const inProgressTag: React.CSSProperties = {
600
+ fontSize: 10, fontWeight: 500, padding: "2px 6px", borderRadius: 4,
601
+ background: "color-mix(in srgb, #f5b445 18%, transparent)", color: "#c08a1f",
602
+ marginLeft: 8, letterSpacing: "0.02em",
603
+ };
604
+
605
+ const emptyHistoryStyle: React.CSSProperties = {
606
+ padding: "20px 18px", borderRadius: 10,
607
+ border: "1px dashed var(--af-border)", background: "var(--af-surface)",
608
+ fontSize: 12.5, color: "var(--af-text-tertiary)", lineHeight: 1.55,
609
+ };
610
+
611
+ const phaseStripStyle: React.CSSProperties = {
612
+ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap",
613
+ padding: "10px 14px", borderRadius: 10,
614
+ border: "1px solid var(--af-border-subtle)", background: "var(--af-surface)",
615
+ };
616
+
617
+ function phaseChipStyle(status: Phase["status"]): React.CSSProperties {
618
+ return {
619
+ display: "inline-flex", alignItems: "center", gap: 6,
620
+ padding: "4px 10px", borderRadius: 999,
621
+ background: status === "running"
622
+ ? "color-mix(in srgb, var(--af-accent) 16%, transparent)"
623
+ : status === "done"
624
+ ? "color-mix(in srgb, var(--af-accent) 9%, transparent)"
625
+ : "var(--af-surface-raised)",
626
+ color: status === "pending" ? "var(--af-text-tertiary)" : "var(--af-text)",
627
+ };
628
+ }
629
+
630
+ function phaseDetailStyle(status: Phase["status"]): React.CSSProperties {
631
+ return {
632
+ padding: "14px 18px", borderRadius: 10,
633
+ border: "1px solid var(--af-border-subtle)",
634
+ background: status === "running" ? "var(--af-surface)" : "transparent",
635
+ opacity: status === "pending" ? 0.6 : 1,
636
+ };
637
+ }
638
+
639
+ const stepsListStyle: React.CSSProperties = {
640
+ listStyle: "none", padding: 0, margin: 0,
641
+ display: "flex", flexDirection: "column", gap: 4,
642
+ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--af-text-secondary)",
643
+ };
644
+
645
+ const stepItemStyle: React.CSSProperties = {
646
+ display: "flex", alignItems: "baseline", gap: 8,
647
+ };
@@ -13,7 +13,7 @@
13
13
  * read.
14
14
  */
15
15
 
16
- import { useRouter } from "next/navigation";
16
+ import { useRouter, usePathname } from "next/navigation";
17
17
  import { useCallback, useEffect, useRef } from "react";
18
18
  import { useLiveEvents, type LiveUpdate } from "@/lib/use-live-events";
19
19
 
@@ -21,8 +21,17 @@ import { useLiveEvents, type LiveUpdate } from "@/lib/use-live-events";
21
21
  * the RSC loader. 400ms covers the end of a tool result flush. */
22
22
  const REFRESH_DEBOUNCE_MS = 400;
23
23
 
24
+ /** Print pages are headless-Chrome render targets — they must never
25
+ * open long-lived SSE connections or Chrome waits forever for the
26
+ * page to "settle" before printing. */
27
+ function useIsPrintRoute(): boolean {
28
+ const pathname = usePathname();
29
+ return pathname?.includes("/insights/print/") ?? false;
30
+ }
31
+
24
32
  export function LiveRefresher() {
25
33
  const router = useRouter();
34
+ const isPrint = useIsPrintRoute();
26
35
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
27
36
  // Track latest mtime per source so session updates don't starve
28
37
  // usage updates (and vice versa) — different files have different clocks.
@@ -41,7 +50,7 @@ export function LiveRefresher() {
41
50
  [router],
42
51
  );
43
52
 
44
- useLiveEvents(onUpdate);
53
+ useLiveEvents(onUpdate, { enabled: !isPrint });
45
54
 
46
55
  useEffect(() => {
47
56
  return () => {