@burdenoff/microfe-subscriberbot 2026.1030.3 → 2026.1030.5

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 (40) hide show
  1. package/dist/subscriberbot/SubscriberBotRoutes.d.ts.map +1 -1
  2. package/dist/subscriberbot/SubscriberBotRoutes.js +12 -8
  3. package/dist/subscriberbot/SubscriberBotRoutes.js.map +1 -1
  4. package/dist/subscriberbot/components/decisions/DecisionCard.d.ts.map +1 -1
  5. package/dist/subscriberbot/components/decisions/DecisionCard.js +1 -0
  6. package/dist/subscriberbot/components/decisions/DecisionCard.js.map +1 -1
  7. package/dist/subscriberbot/components/decisions/decisionModel.d.ts +3 -2
  8. package/dist/subscriberbot/components/decisions/decisionModel.d.ts.map +1 -1
  9. package/dist/subscriberbot/components/decisions/decisionModel.js +18 -5
  10. package/dist/subscriberbot/components/decisions/decisionModel.js.map +1 -1
  11. package/dist/subscriberbot/components/savings/SavingsCards.d.ts +11 -0
  12. package/dist/subscriberbot/components/savings/SavingsCards.d.ts.map +1 -0
  13. package/dist/subscriberbot/components/savings/SavingsCards.js +448 -0
  14. package/dist/subscriberbot/components/savings/SavingsCards.js.map +1 -0
  15. package/dist/subscriberbot/components/savings/index.d.ts +8 -0
  16. package/dist/subscriberbot/components/savings/index.d.ts.map +1 -0
  17. package/dist/subscriberbot/components/savings/index.js +2 -0
  18. package/dist/subscriberbot/components/savings/savingsModel.d.ts +53 -0
  19. package/dist/subscriberbot/components/savings/savingsModel.d.ts.map +1 -0
  20. package/dist/subscriberbot/components/savings/savingsModel.js +105 -0
  21. package/dist/subscriberbot/components/savings/savingsModel.js.map +1 -0
  22. package/dist/subscriberbot/hooks/index.d.ts +1 -1
  23. package/dist/subscriberbot/hooks/index.d.ts.map +1 -1
  24. package/dist/subscriberbot/hooks/useSubscriberBotApi.d.ts +8 -1
  25. package/dist/subscriberbot/hooks/useSubscriberBotApi.d.ts.map +1 -1
  26. package/dist/subscriberbot/hooks/useSubscriberBotApi.js +45 -15
  27. package/dist/subscriberbot/hooks/useSubscriberBotApi.js.map +1 -1
  28. package/dist/subscriberbot/pages/InsightsPage.d.ts.map +1 -1
  29. package/dist/subscriberbot/pages/InsightsPage.js +121 -110
  30. package/dist/subscriberbot/pages/InsightsPage.js.map +1 -1
  31. package/dist/subscriberbot/pages/SavingsPage.d.ts +4 -0
  32. package/dist/subscriberbot/pages/SavingsPage.d.ts.map +1 -0
  33. package/dist/subscriberbot/pages/SavingsPage.js +205 -0
  34. package/dist/subscriberbot/pages/SavingsPage.js.map +1 -0
  35. package/dist/subscriberbot/pages/relationships/RelationshipDetailPage.d.ts.map +1 -1
  36. package/dist/subscriberbot/pages/relationships/RelationshipDetailPage.js +292 -301
  37. package/dist/subscriberbot/pages/relationships/RelationshipDetailPage.js.map +1 -1
  38. package/dist/subscriberbot/types/index.d.ts +62 -2
  39. package/dist/subscriberbot/types/index.d.ts.map +1 -1
  40. package/package.json +1 -1
@@ -1,19 +1,20 @@
1
- import { cn as e } from "../utils/cn.js";
2
- import { PageLayout as t } from "../components/PageLayout.js";
3
- import { MetricsCard as n } from "../components/MetricsCard.js";
4
- import { formatCurrency as r, formatEnumLabel as i } from "../utils/formatters.js";
5
- import { EmptyState as a } from "../components/EmptyState.js";
6
- import { useTr as o } from "../hooks/useTr.js";
7
- import { ErrorView as s, LoadingView as c, Section as l } from "../components/StateViews.js";
8
- import { BarList as u, Donut as d, Legend as f, MiniProgress as p } from "../components/viz.js";
1
+ import { useSubscriberBot as e } from "../context/SubscriberBotContext.js";
2
+ import { cn as t } from "../utils/cn.js";
3
+ import { PageLayout as n } from "../components/PageLayout.js";
4
+ import { MetricsCard as r } from "../components/MetricsCard.js";
5
+ import { formatCurrency as i, formatEnumLabel as a } from "../utils/formatters.js";
6
+ import { EmptyState as o } from "../components/EmptyState.js";
7
+ import { useTr as s } from "../hooks/useTr.js";
8
+ import { ErrorView as c, LoadingView as l, Section as u } from "../components/StateViews.js";
9
+ import { BarList as d, Donut as f, Legend as p, MiniProgress as m } from "../components/viz.js";
9
10
  import "../components/index.js";
10
- import { useRelationships as m } from "../hooks/useSubscriberBotApi.js";
11
- import { useMemo as h } from "react";
12
- import { CalendarClock as g, HeartPulse as _, Layers as v, TrendingUp as y, Wallet as b } from "lucide-react";
13
- import { Fragment as x, jsx as S, jsxs as C } from "react/jsx-runtime";
14
- import { GlassCard as w, PagePurpose as T } from "@burdenoff/fe-libs/ui";
11
+ import { useRelationships as h } from "../hooks/useSubscriberBotApi.js";
12
+ import { useMemo as g } from "react";
13
+ import { CalendarClock as _, HeartPulse as v, Layers as y, PiggyBank as b, TrendingUp as x, Wallet as S } from "lucide-react";
14
+ import { Fragment as C, jsx as w, jsxs as T } from "react/jsx-runtime";
15
+ import { GlassCard as E, PagePurpose as D } from "@burdenoff/fe-libs/ui";
15
16
  //#region src/subscriberbot/pages/InsightsPage.tsx
16
- var E = {
17
+ var O = {
17
18
  DAILY: 30,
18
19
  WEEKLY: 4.345,
19
20
  MONTHLY: 1,
@@ -22,15 +23,15 @@ var E = {
22
23
  LIFETIME: 0,
23
24
  FREE: 0,
24
25
  USAGE_BASED: 1
25
- }, D = (e) => e.amount * (E[e.interval] ?? 1), O = (e) => e.status === "ACTIVE", k = () => {
26
- let E = m({ pagination: { limit: 100 } }), k = o(), A = h(() => E.data?.items ?? [], [E.data]), j = h(() => {
27
- let e = A.filter(O), t = e[0]?.currency ?? A[0]?.currency ?? "USD", n = e.reduce((e, t) => e + D(t), 0), r = A.filter((e) => e.healthScore != null), a = r.length ? r.reduce((e, t) => e + (t.healthScore ?? 0), 0) / r.length : null, o = /* @__PURE__ */ new Map();
28
- for (let e of A) o.set(e.status, (o.get(e.status) ?? 0) + 1);
26
+ }, k = (e) => e.amount * (O[e.interval] ?? 1), A = (e) => e.status === "ACTIVE", j = () => {
27
+ let O = h({ pagination: { limit: 100 } }), j = s(), { navigateTo: M } = e(), N = g(() => O.data?.items ?? [], [O.data]), P = g(() => {
28
+ let e = N.filter(A), t = e[0]?.currency ?? N[0]?.currency ?? "USD", n = e.reduce((e, t) => e + k(t), 0), r = N.filter((e) => e.healthScore != null), i = r.length ? r.reduce((e, t) => e + (t.healthScore ?? 0), 0) / r.length : null, o = /* @__PURE__ */ new Map();
29
+ for (let e of N) o.set(e.status, (o.get(e.status) ?? 0) + 1);
29
30
  let s = /* @__PURE__ */ new Map();
30
- for (let t of e) s.set(t.kind, (s.get(t.kind) ?? 0) + D(t));
31
- let c = [...e].sort((e, t) => D(t) - D(e)).slice(0, 6), l = [], u = /* @__PURE__ */ new Date();
31
+ for (let t of e) s.set(t.kind, (s.get(t.kind) ?? 0) + k(t));
32
+ let c = [...e].sort((e, t) => k(t) - k(e)).slice(0, 6), l = [], u = /* @__PURE__ */ new Date();
32
33
  for (let e = 0; e < 6; e += 1) {
33
- let t = new Date(u.getFullYear(), u.getMonth() + e, 1), n = `${t.getFullYear()}-${t.getMonth()}`, r = t.toLocaleString(void 0, { month: "short" }), i = A.reduce((e, t) => {
34
+ let t = new Date(u.getFullYear(), u.getMonth() + e, 1), n = `${t.getFullYear()}-${t.getMonth()}`, r = t.toLocaleString(void 0, { month: "short" }), i = N.reduce((e, t) => {
34
35
  if (!t.renewsAt) return e;
35
36
  let r = new Date(t.renewsAt);
36
37
  return Number.isNaN(r.getTime()) ? e : `${r.getFullYear()}-${r.getMonth()}` === n ? e + t.amount : e;
@@ -40,7 +41,7 @@ var E = {
40
41
  value: i
41
42
  });
42
43
  }
43
- let d = A.filter((e) => e.healthScore != null && e.healthScore < 40).sort((e, t) => (e.healthScore ?? 0) - (t.healthScore ?? 0)).slice(0, 6), f = A.filter((e) => {
44
+ let d = N.filter((e) => e.healthScore != null && e.healthScore < 40).sort((e, t) => (e.healthScore ?? 0) - (t.healthScore ?? 0)).slice(0, 6), f = N.filter((e) => {
44
45
  if (!e.renewsAt) return !1;
45
46
  let t = new Date(e.renewsAt).getTime();
46
47
  if (Number.isNaN(t)) return !1;
@@ -51,164 +52,174 @@ var E = {
51
52
  currency: t,
52
53
  monthlySpend: n,
53
54
  activeCount: e.length,
54
- avgHealth: a,
55
+ avgHealth: i,
55
56
  renewals30: f,
56
57
  statusData: [...o.entries()].map(([e, t]) => ({
57
- label: i(e),
58
+ label: a(e),
58
59
  value: t
59
60
  })),
60
61
  kindData: [...s.entries()].map(([e, t]) => ({
61
- label: i(e),
62
+ label: a(e),
62
63
  value: t
63
64
  })).sort((e, t) => t.value - e.value),
64
65
  topCosts: c,
65
66
  forecast: l,
66
67
  watchlist: d,
67
- total: A.length
68
+ total: N.length
68
69
  };
69
- }, [A]);
70
- if (E.isLoading) return /* @__PURE__ */ S(c, { label: k("insights.loading", "Crunching your spend insights…") });
71
- if (E.isError) return /* @__PURE__ */ S(s, {
72
- message: k("insights.error", "Could not load insights."),
73
- onRetry: () => void E.refetch()
70
+ }, [N]);
71
+ if (O.isLoading) return /* @__PURE__ */ w(l, { label: j("insights.loading", "Crunching your spend insights…") });
72
+ if (O.isError) return /* @__PURE__ */ w(c, {
73
+ message: j("insights.error", "Could not load insights."),
74
+ onRetry: () => void O.refetch()
74
75
  });
75
- let M = (e) => r(e, j.currency);
76
- return /* @__PURE__ */ C(t, {
77
- title: k("insights.title", "Insights"),
78
- description: k("insights.description", "Where your recurring money goes, what renews next, and what to review — computed live from your portfolio."),
79
- children: [/* @__PURE__ */ S(T, {
76
+ let F = (e) => i(e, P.currency);
77
+ return /* @__PURE__ */ T(n, {
78
+ title: j("insights.title", "Insights"),
79
+ description: j("insights.description", "Where your recurring money goes, what renews next, and what to review — computed live from your portfolio."),
80
+ actions: /* @__PURE__ */ T("button", {
81
+ type: "button",
82
+ onClick: () => M("/savings"),
83
+ "data-testid": "insights-open-savings",
84
+ className: "inline-flex items-center gap-2 rounded-lg bg-action-primary-bg px-3 py-2 text-sm font-medium text-action-primary-text hover:opacity-90",
85
+ children: [/* @__PURE__ */ w(b, {
86
+ className: "h-4 w-4",
87
+ "aria-hidden": !0
88
+ }), j("insights.openSavings", "See how to save")]
89
+ }),
90
+ children: [/* @__PURE__ */ w(D, {
80
91
  className: "mb-2",
81
- children: k("insights.purpose", "Understand and optimize your recurring spend. Every figure here is aggregated from your real subscriptions — normalized to a monthly equivalent so plans on different billing cycles are comparable.")
82
- }), j.total === 0 ? /* @__PURE__ */ S(a, {
83
- icon: /* @__PURE__ */ S(y, { className: "h-8 w-8" }),
84
- title: k("insights.emptyTitle", "No data to analyze yet"),
85
- description: k("insights.emptyDescription", "Add a few subscriptions and your spend breakdown, renewal forecast and health watchlist will appear here.")
86
- }) : /* @__PURE__ */ C(x, { children: [
87
- /* @__PURE__ */ C("div", {
92
+ children: j("insights.purpose", "Understand and optimize your recurring spend. Every figure here is aggregated from your real subscriptions — normalized to a monthly equivalent so plans on different billing cycles are comparable.")
93
+ }), P.total === 0 ? /* @__PURE__ */ w(o, {
94
+ icon: /* @__PURE__ */ w(x, { className: "h-8 w-8" }),
95
+ title: j("insights.emptyTitle", "No data to analyze yet"),
96
+ description: j("insights.emptyDescription", "Add a few subscriptions and your spend breakdown, renewal forecast and health watchlist will appear here.")
97
+ }) : /* @__PURE__ */ T(C, { children: [
98
+ /* @__PURE__ */ T("div", {
88
99
  className: "grid grid-cols-2 gap-4 lg:grid-cols-4",
89
100
  children: [
90
- /* @__PURE__ */ S(n, {
91
- icon: /* @__PURE__ */ S(b, { className: "h-5 w-5" }),
92
- value: M(j.monthlySpend),
93
- label: k("insights.metrics.recurringSpend", "Recurring spend / month"),
101
+ /* @__PURE__ */ w(r, {
102
+ icon: /* @__PURE__ */ w(S, { className: "h-5 w-5" }),
103
+ value: F(P.monthlySpend),
104
+ label: j("insights.metrics.recurringSpend", "Recurring spend / month"),
94
105
  featured: !0
95
106
  }),
96
- /* @__PURE__ */ S(n, {
97
- icon: /* @__PURE__ */ S(v, { className: "h-5 w-5" }),
98
- value: j.activeCount,
99
- label: k("insights.metrics.activeSubscriptions", "Active subscriptions")
107
+ /* @__PURE__ */ w(r, {
108
+ icon: /* @__PURE__ */ w(y, { className: "h-5 w-5" }),
109
+ value: P.activeCount,
110
+ label: j("insights.metrics.activeSubscriptions", "Active subscriptions")
100
111
  }),
101
- /* @__PURE__ */ S(n, {
102
- icon: /* @__PURE__ */ S(g, { className: "h-5 w-5" }),
103
- value: j.renewals30,
104
- label: k("insights.metrics.renewing30Days", "Renewing in 30 days")
112
+ /* @__PURE__ */ w(r, {
113
+ icon: /* @__PURE__ */ w(_, { className: "h-5 w-5" }),
114
+ value: P.renewals30,
115
+ label: j("insights.metrics.renewing30Days", "Renewing in 30 days")
105
116
  }),
106
- /* @__PURE__ */ S(n, {
107
- icon: /* @__PURE__ */ S(_, { className: "h-5 w-5" }),
108
- value: j.avgHealth == null ? k("insights.metrics.noScore", "—") : Math.round(j.avgHealth),
109
- label: k("insights.metrics.avgHealthScore", "Avg. health score")
117
+ /* @__PURE__ */ w(r, {
118
+ icon: /* @__PURE__ */ w(v, { className: "h-5 w-5" }),
119
+ value: P.avgHealth == null ? j("insights.metrics.noScore", "—") : Math.round(P.avgHealth),
120
+ label: j("insights.metrics.avgHealthScore", "Avg. health score")
110
121
  })
111
122
  ]
112
123
  }),
113
- /* @__PURE__ */ C("div", {
124
+ /* @__PURE__ */ T("div", {
114
125
  className: "grid grid-cols-1 gap-4 lg:grid-cols-3",
115
126
  children: [
116
- /* @__PURE__ */ C(w, {
127
+ /* @__PURE__ */ T(E, {
117
128
  treatment: "plain",
118
129
  className: "p-6",
119
- children: [/* @__PURE__ */ S("h3", {
130
+ children: [/* @__PURE__ */ w("h3", {
120
131
  className: "mb-4 text-sm font-semibold text-text-primary",
121
- children: k("insights.portfolioByStatus", "Portfolio by status")
122
- }), /* @__PURE__ */ C("div", {
132
+ children: j("insights.portfolioByStatus", "Portfolio by status")
133
+ }), /* @__PURE__ */ T("div", {
123
134
  className: "flex flex-col items-center gap-6 sm:flex-row",
124
- children: [/* @__PURE__ */ S(d, {
125
- data: j.statusData,
126
- centerLabel: String(j.total),
127
- centerSub: k("insights.subscriptions", "subscriptions")
128
- }), /* @__PURE__ */ S(f, {
135
+ children: [/* @__PURE__ */ w(f, {
136
+ data: P.statusData,
137
+ centerLabel: String(P.total),
138
+ centerSub: j("insights.subscriptions", "subscriptions")
139
+ }), /* @__PURE__ */ w(p, {
129
140
  className: "w-full sm:flex-1",
130
- data: j.statusData
141
+ data: P.statusData
131
142
  })]
132
143
  })]
133
144
  }),
134
- /* @__PURE__ */ C(w, {
145
+ /* @__PURE__ */ T(E, {
135
146
  treatment: "plain",
136
147
  className: "p-6",
137
- children: [/* @__PURE__ */ S("h3", {
148
+ children: [/* @__PURE__ */ w("h3", {
138
149
  className: "mb-4 text-sm font-semibold text-text-primary",
139
- children: k("insights.spendByType", "Monthly spend by type")
140
- }), j.kindData.length ? /* @__PURE__ */ S(u, {
141
- data: j.kindData,
142
- valueFormat: M
143
- }) : /* @__PURE__ */ S("p", {
150
+ children: j("insights.spendByType", "Monthly spend by type")
151
+ }), P.kindData.length ? /* @__PURE__ */ w(d, {
152
+ data: P.kindData,
153
+ valueFormat: F
154
+ }) : /* @__PURE__ */ w("p", {
144
155
  className: "text-sm text-text-secondary",
145
- children: k("insights.noActiveSpend", "No active spend.")
156
+ children: j("insights.noActiveSpend", "No active spend.")
146
157
  })]
147
158
  }),
148
- /* @__PURE__ */ C(w, {
159
+ /* @__PURE__ */ T(E, {
149
160
  treatment: "plain",
150
161
  className: "p-6",
151
- children: [/* @__PURE__ */ S("h3", {
162
+ children: [/* @__PURE__ */ w("h3", {
152
163
  className: "mb-4 text-sm font-semibold text-text-primary",
153
- children: k("insights.renewalForecast", "Renewal forecast (6 mo)")
154
- }), j.forecast.some((e) => e.value > 0) ? /* @__PURE__ */ S(u, {
155
- data: j.forecast,
156
- valueFormat: M
157
- }) : /* @__PURE__ */ S("p", {
164
+ children: j("insights.renewalForecast", "Renewal forecast (6 mo)")
165
+ }), P.forecast.some((e) => e.value > 0) ? /* @__PURE__ */ w(d, {
166
+ data: P.forecast,
167
+ valueFormat: F
168
+ }) : /* @__PURE__ */ w("p", {
158
169
  className: "text-sm text-text-secondary",
159
- children: k("insights.noRenewals", "No dated renewals in the next 6 months.")
170
+ children: j("insights.noRenewals", "No dated renewals in the next 6 months.")
160
171
  })]
161
172
  })
162
173
  ]
163
174
  }),
164
- /* @__PURE__ */ C("div", {
175
+ /* @__PURE__ */ T("div", {
165
176
  className: "grid grid-cols-1 gap-4 lg:grid-cols-2",
166
- children: [/* @__PURE__ */ S(l, {
167
- title: k("insights.topCosts", "Top monthly costs"),
168
- children: /* @__PURE__ */ S(w, {
177
+ children: [/* @__PURE__ */ w(u, {
178
+ title: j("insights.topCosts", "Top monthly costs"),
179
+ children: /* @__PURE__ */ w(E, {
169
180
  treatment: "plain",
170
181
  className: "divide-y divide-border-subtle",
171
- children: j.topCosts.map((e) => /* @__PURE__ */ C("div", {
182
+ children: P.topCosts.map((e) => /* @__PURE__ */ T("div", {
172
183
  className: "flex items-center justify-between p-4",
173
- children: [/* @__PURE__ */ S("span", {
184
+ children: [/* @__PURE__ */ w("span", {
174
185
  className: "truncate text-sm text-text-primary",
175
186
  children: e.label
176
- }), /* @__PURE__ */ C("span", {
187
+ }), /* @__PURE__ */ T("span", {
177
188
  className: "ml-3 shrink-0 text-sm font-semibold tabular-nums text-text-primary",
178
- children: [M(D(e)), /* @__PURE__ */ S("span", {
189
+ children: [F(k(e)), /* @__PURE__ */ w("span", {
179
190
  className: "text-xs font-normal text-text-secondary",
180
- children: k("insights.perMonth", "/mo")
191
+ children: j("insights.perMonth", "/mo")
181
192
  })]
182
193
  })]
183
194
  }, e.id))
184
195
  })
185
- }), /* @__PURE__ */ S(l, {
186
- title: k("insights.healthWatchlist", "Health watchlist"),
187
- children: j.watchlist.length ? /* @__PURE__ */ S(w, {
196
+ }), /* @__PURE__ */ w(u, {
197
+ title: j("insights.healthWatchlist", "Health watchlist"),
198
+ children: P.watchlist.length ? /* @__PURE__ */ w(E, {
188
199
  treatment: "plain",
189
200
  className: "divide-y divide-border-subtle",
190
- children: j.watchlist.map((t) => /* @__PURE__ */ C("div", {
201
+ children: P.watchlist.map((e) => /* @__PURE__ */ T("div", {
191
202
  className: "flex items-center gap-3 p-4",
192
203
  children: [
193
- /* @__PURE__ */ S("span", {
204
+ /* @__PURE__ */ w("span", {
194
205
  className: "w-32 shrink-0 truncate text-sm text-text-primary",
195
- children: t.label
206
+ children: e.label
196
207
  }),
197
- /* @__PURE__ */ S(p, {
208
+ /* @__PURE__ */ w(m, {
198
209
  className: "flex-1",
199
- value: t.healthScore ?? 0,
210
+ value: e.healthScore ?? 0,
200
211
  tone: "negative"
201
212
  }),
202
- /* @__PURE__ */ S("span", {
203
- className: e("w-8 shrink-0 text-right text-sm font-medium tabular-nums text-status-warning-text"),
204
- children: t.healthScore
213
+ /* @__PURE__ */ w("span", {
214
+ className: t("w-8 shrink-0 text-right text-sm font-medium tabular-nums text-status-warning-text"),
215
+ children: e.healthScore
205
216
  })
206
217
  ]
207
- }, t.id))
208
- }) : /* @__PURE__ */ S(w, {
218
+ }, e.id))
219
+ }) : /* @__PURE__ */ w(E, {
209
220
  treatment: "plain",
210
221
  className: "p-6 text-sm text-text-secondary",
211
- children: k("insights.allHealthy", "Nothing needs review — all scored subscriptions look healthy.")
222
+ children: j("insights.allHealthy", "Nothing needs review — all scored subscriptions look healthy.")
212
223
  })
213
224
  })]
214
225
  })
@@ -216,6 +227,6 @@ var E = {
216
227
  });
217
228
  };
218
229
  //#endregion
219
- export { k as InsightsPage };
230
+ export { j as InsightsPage };
220
231
 
221
232
  //# sourceMappingURL=InsightsPage.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"InsightsPage.js","names":[],"sources":["../../../src/subscriberbot/pages/InsightsPage.tsx"],"sourcesContent":["/**\n * Insights — spend analytics + renewal forecast, aggregated entirely from the\n * real subscription portfolio (`useRelationships`). Nothing is mocked: every\n * figure is computed from live relationship data (amount normalized to a monthly\n * equivalent by billing interval). Covers the concept mocks for cost insight,\n * renewal cashflow forecast, and spend management.\n * @module subscriberbot/pages\n */\nimport { useMemo, type FC } from 'react';\nimport { TrendingUp, Wallet, CalendarClock, HeartPulse, Layers } from 'lucide-react';\nimport { GlassCard, PagePurpose } from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n MetricsCard,\n EmptyState,\n LoadingView,\n ErrorView,\n Section,\n Donut,\n Legend,\n BarList,\n MiniProgress,\n} from '../components';\nimport { useRelationships } from '../hooks/useSubscriberBotApi';\nimport { useTr } from '../hooks/useTr';\nimport { formatCurrency, formatEnumLabel } from '../utils/formatters';\nimport type { SubBillingInterval, SubRelationship } from '../types';\nimport { cn } from '../utils/cn';\n\nconst MONTHLY_FACTOR: Record<SubBillingInterval, number> = {\n DAILY: 30,\n WEEKLY: 4.345,\n MONTHLY: 1,\n QUARTERLY: 1 / 3,\n YEARLY: 1 / 12,\n LIFETIME: 0,\n FREE: 0,\n USAGE_BASED: 1,\n};\n\nconst monthly = (r: SubRelationship): number => r.amount * (MONTHLY_FACTOR[r.interval] ?? 1);\nconst isActive = (r: SubRelationship): boolean => r.status === 'ACTIVE';\n\nexport const InsightsPage: FC = () => {\n const query = useRelationships({ pagination: { limit: 100 } });\n const tr = useTr();\n const rels = useMemo(() => query.data?.items ?? [], [query.data]);\n\n const stats = useMemo(() => {\n const active = rels.filter(isActive);\n const currency = active[0]?.currency ?? rels[0]?.currency ?? 'USD';\n const monthlySpend = active.reduce((s, r) => s + monthly(r), 0);\n const scored = rels.filter((r) => r.healthScore != null);\n const avgHealth = scored.length\n ? scored.reduce((s, r) => s + (r.healthScore ?? 0), 0) / scored.length\n : null;\n\n // by status (donut)\n const byStatus = new Map<string, number>();\n for (const r of rels) byStatus.set(r.status, (byStatus.get(r.status) ?? 0) + 1);\n\n // spend by kind (bar)\n const byKind = new Map<string, number>();\n for (const r of active) byKind.set(r.kind, (byKind.get(r.kind) ?? 0) + monthly(r));\n\n // top monthly costs\n const topCosts = [...active].sort((a, b) => monthly(b) - monthly(a)).slice(0, 6);\n\n // renewal forecast — next 6 calendar months by renewsAt\n const buckets: { label: string; value: number }[] = [];\n const now = new Date();\n for (let i = 0; i < 6; i += 1) {\n const d = new Date(now.getFullYear(), now.getMonth() + i, 1);\n const key = `${d.getFullYear()}-${d.getMonth()}`;\n const label = d.toLocaleString(undefined, { month: 'short' });\n const value = rels.reduce((s, r) => {\n if (!r.renewsAt) return s;\n const rd = new Date(r.renewsAt);\n if (Number.isNaN(rd.getTime())) return s;\n return `${rd.getFullYear()}-${rd.getMonth()}` === key ? s + r.amount : s;\n }, 0);\n buckets.push({ label, value });\n }\n\n // low-health watchlist\n const watchlist = rels\n .filter((r) => r.healthScore != null && (r.healthScore as number) < 40)\n .sort((a, b) => (a.healthScore ?? 0) - (b.healthScore ?? 0))\n .slice(0, 6);\n\n const renewals30 = rels.filter((r) => {\n if (!r.renewsAt) return false;\n const t = new Date(r.renewsAt).getTime();\n if (Number.isNaN(t)) return false;\n const days = (t - Date.now()) / 86_400_000;\n return days >= 0 && days <= 30;\n }).length;\n\n return {\n currency,\n monthlySpend,\n activeCount: active.length,\n avgHealth,\n renewals30,\n statusData: [...byStatus.entries()].map(([label, value]) => ({\n label: formatEnumLabel(label),\n value,\n })),\n kindData: [...byKind.entries()]\n .map(([label, value]) => ({ label: formatEnumLabel(label), value }))\n .sort((a, b) => b.value - a.value),\n topCosts,\n forecast: buckets,\n watchlist,\n total: rels.length,\n };\n }, [rels]);\n\n if (query.isLoading)\n return <LoadingView label={tr('insights.loading', 'Crunching your spend insights…')} />;\n if (query.isError)\n return (\n <ErrorView\n message={tr('insights.error', 'Could not load insights.')}\n onRetry={() => void query.refetch()}\n />\n );\n\n const fmt = (n: number) => formatCurrency(n, stats.currency);\n\n return (\n <PageLayout\n title={tr('insights.title', 'Insights')}\n description={tr(\n 'insights.description',\n 'Where your recurring money goes, what renews next, and what to review — computed live from your portfolio.'\n )}\n >\n <PagePurpose className=\"mb-2\">\n {tr(\n 'insights.purpose',\n 'Understand and optimize your recurring spend. Every figure here is aggregated from your real subscriptions — normalized to a monthly equivalent so plans on different billing cycles are comparable.'\n )}\n </PagePurpose>\n\n {stats.total === 0 ? (\n <EmptyState\n icon={<TrendingUp className=\"h-8 w-8\" />}\n title={tr('insights.emptyTitle', 'No data to analyze yet')}\n description={tr(\n 'insights.emptyDescription',\n 'Add a few subscriptions and your spend breakdown, renewal forecast and health watchlist will appear here.'\n )}\n />\n ) : (\n <>\n <div className=\"grid grid-cols-2 gap-4 lg:grid-cols-4\">\n <MetricsCard\n icon={<Wallet className=\"h-5 w-5\" />}\n value={fmt(stats.monthlySpend)}\n label={tr('insights.metrics.recurringSpend', 'Recurring spend / month')}\n featured\n />\n <MetricsCard\n icon={<Layers className=\"h-5 w-5\" />}\n value={stats.activeCount}\n label={tr('insights.metrics.activeSubscriptions', 'Active subscriptions')}\n />\n <MetricsCard\n icon={<CalendarClock className=\"h-5 w-5\" />}\n value={stats.renewals30}\n label={tr('insights.metrics.renewing30Days', 'Renewing in 30 days')}\n />\n <MetricsCard\n icon={<HeartPulse className=\"h-5 w-5\" />}\n value={\n stats.avgHealth != null\n ? Math.round(stats.avgHealth)\n : tr('insights.metrics.noScore', '—')\n }\n label={tr('insights.metrics.avgHealthScore', 'Avg. health score')}\n />\n </div>\n\n <div className=\"grid grid-cols-1 gap-4 lg:grid-cols-3\">\n <GlassCard treatment=\"plain\" className=\"p-6\">\n <h3 className=\"mb-4 text-sm font-semibold text-text-primary\">\n {tr('insights.portfolioByStatus', 'Portfolio by status')}\n </h3>\n <div className=\"flex flex-col items-center gap-6 sm:flex-row\">\n <Donut\n data={stats.statusData}\n centerLabel={String(stats.total)}\n centerSub={tr('insights.subscriptions', 'subscriptions')}\n />\n <Legend className=\"w-full sm:flex-1\" data={stats.statusData} />\n </div>\n </GlassCard>\n\n <GlassCard treatment=\"plain\" className=\"p-6\">\n <h3 className=\"mb-4 text-sm font-semibold text-text-primary\">\n {tr('insights.spendByType', 'Monthly spend by type')}\n </h3>\n {stats.kindData.length ? (\n <BarList data={stats.kindData} valueFormat={fmt} />\n ) : (\n <p className=\"text-sm text-text-secondary\">\n {tr('insights.noActiveSpend', 'No active spend.')}\n </p>\n )}\n </GlassCard>\n\n <GlassCard treatment=\"plain\" className=\"p-6\">\n <h3 className=\"mb-4 text-sm font-semibold text-text-primary\">\n {tr('insights.renewalForecast', 'Renewal forecast (6 mo)')}\n </h3>\n {stats.forecast.some((f) => f.value > 0) ? (\n <BarList data={stats.forecast} valueFormat={fmt} />\n ) : (\n <p className=\"text-sm text-text-secondary\">\n {tr('insights.noRenewals', 'No dated renewals in the next 6 months.')}\n </p>\n )}\n </GlassCard>\n </div>\n\n <div className=\"grid grid-cols-1 gap-4 lg:grid-cols-2\">\n <Section title={tr('insights.topCosts', 'Top monthly costs')}>\n <GlassCard treatment=\"plain\" className=\"divide-y divide-border-subtle\">\n {stats.topCosts.map((r) => (\n <div key={r.id} className=\"flex items-center justify-between p-4\">\n <span className=\"truncate text-sm text-text-primary\">{r.label}</span>\n <span className=\"ml-3 shrink-0 text-sm font-semibold tabular-nums text-text-primary\">\n {fmt(monthly(r))}\n <span className=\"text-xs font-normal text-text-secondary\">\n {tr('insights.perMonth', '/mo')}\n </span>\n </span>\n </div>\n ))}\n </GlassCard>\n </Section>\n\n <Section title={tr('insights.healthWatchlist', 'Health watchlist')}>\n {stats.watchlist.length ? (\n <GlassCard treatment=\"plain\" className=\"divide-y divide-border-subtle\">\n {stats.watchlist.map((r) => (\n <div key={r.id} className=\"flex items-center gap-3 p-4\">\n <span className=\"w-32 shrink-0 truncate text-sm text-text-primary\">\n {r.label}\n </span>\n <MiniProgress className=\"flex-1\" value={r.healthScore ?? 0} tone=\"negative\" />\n <span\n className={cn(\n 'w-8 shrink-0 text-right text-sm font-medium tabular-nums text-status-warning-text'\n )}\n >\n {r.healthScore}\n </span>\n </div>\n ))}\n </GlassCard>\n ) : (\n <GlassCard treatment=\"plain\" className=\"p-6 text-sm text-text-secondary\">\n {tr(\n 'insights.allHealthy',\n 'Nothing needs review — all scored subscriptions look healthy.'\n )}\n </GlassCard>\n )}\n </Section>\n </div>\n </>\n )}\n </PageLayout>\n );\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"InsightsPage.js","names":[],"sources":["../../../src/subscriberbot/pages/InsightsPage.tsx"],"sourcesContent":["/**\n * Insights — spend analytics + renewal forecast, aggregated entirely from the\n * real subscription portfolio (`useRelationships`). Nothing is mocked: every\n * figure is computed from live relationship data (amount normalized to a monthly\n * equivalent by billing interval). Covers the concept mocks for cost insight,\n * renewal cashflow forecast, and spend management.\n * @module subscriberbot/pages\n */\nimport { useMemo, type FC } from 'react';\nimport { TrendingUp, Wallet, CalendarClock, HeartPulse, Layers, PiggyBank } from 'lucide-react';\nimport { GlassCard, PagePurpose } from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n MetricsCard,\n EmptyState,\n LoadingView,\n ErrorView,\n Section,\n Donut,\n Legend,\n BarList,\n MiniProgress,\n} from '../components';\nimport { useRelationships } from '../hooks/useSubscriberBotApi';\nimport { useSubscriberBot } from '../context/SubscriberBotContext';\nimport { useTr } from '../hooks/useTr';\nimport { formatCurrency, formatEnumLabel } from '../utils/formatters';\nimport type { SubBillingInterval, SubRelationship } from '../types';\nimport { cn } from '../utils/cn';\n\nconst MONTHLY_FACTOR: Record<SubBillingInterval, number> = {\n DAILY: 30,\n WEEKLY: 4.345,\n MONTHLY: 1,\n QUARTERLY: 1 / 3,\n YEARLY: 1 / 12,\n LIFETIME: 0,\n FREE: 0,\n USAGE_BASED: 1,\n};\n\nconst monthly = (r: SubRelationship): number => r.amount * (MONTHLY_FACTOR[r.interval] ?? 1);\nconst isActive = (r: SubRelationship): boolean => r.status === 'ACTIVE';\n\nexport const InsightsPage: FC = () => {\n const query = useRelationships({ pagination: { limit: 100 } });\n const tr = useTr();\n const { navigateTo } = useSubscriberBot();\n const rels = useMemo(() => query.data?.items ?? [], [query.data]);\n\n const stats = useMemo(() => {\n const active = rels.filter(isActive);\n const currency = active[0]?.currency ?? rels[0]?.currency ?? 'USD';\n const monthlySpend = active.reduce((s, r) => s + monthly(r), 0);\n const scored = rels.filter((r) => r.healthScore != null);\n const avgHealth = scored.length\n ? scored.reduce((s, r) => s + (r.healthScore ?? 0), 0) / scored.length\n : null;\n\n // by status (donut)\n const byStatus = new Map<string, number>();\n for (const r of rels) byStatus.set(r.status, (byStatus.get(r.status) ?? 0) + 1);\n\n // spend by kind (bar)\n const byKind = new Map<string, number>();\n for (const r of active) byKind.set(r.kind, (byKind.get(r.kind) ?? 0) + monthly(r));\n\n // top monthly costs\n const topCosts = [...active].sort((a, b) => monthly(b) - monthly(a)).slice(0, 6);\n\n // renewal forecast — next 6 calendar months by renewsAt\n const buckets: { label: string; value: number }[] = [];\n const now = new Date();\n for (let i = 0; i < 6; i += 1) {\n const d = new Date(now.getFullYear(), now.getMonth() + i, 1);\n const key = `${d.getFullYear()}-${d.getMonth()}`;\n const label = d.toLocaleString(undefined, { month: 'short' });\n const value = rels.reduce((s, r) => {\n if (!r.renewsAt) return s;\n const rd = new Date(r.renewsAt);\n if (Number.isNaN(rd.getTime())) return s;\n return `${rd.getFullYear()}-${rd.getMonth()}` === key ? s + r.amount : s;\n }, 0);\n buckets.push({ label, value });\n }\n\n // low-health watchlist\n const watchlist = rels\n .filter((r) => r.healthScore != null && (r.healthScore as number) < 40)\n .sort((a, b) => (a.healthScore ?? 0) - (b.healthScore ?? 0))\n .slice(0, 6);\n\n const renewals30 = rels.filter((r) => {\n if (!r.renewsAt) return false;\n const t = new Date(r.renewsAt).getTime();\n if (Number.isNaN(t)) return false;\n const days = (t - Date.now()) / 86_400_000;\n return days >= 0 && days <= 30;\n }).length;\n\n return {\n currency,\n monthlySpend,\n activeCount: active.length,\n avgHealth,\n renewals30,\n statusData: [...byStatus.entries()].map(([label, value]) => ({\n label: formatEnumLabel(label),\n value,\n })),\n kindData: [...byKind.entries()]\n .map(([label, value]) => ({ label: formatEnumLabel(label), value }))\n .sort((a, b) => b.value - a.value),\n topCosts,\n forecast: buckets,\n watchlist,\n total: rels.length,\n };\n }, [rels]);\n\n if (query.isLoading)\n return <LoadingView label={tr('insights.loading', 'Crunching your spend insights…')} />;\n if (query.isError)\n return (\n <ErrorView\n message={tr('insights.error', 'Could not load insights.')}\n onRetry={() => void query.refetch()}\n />\n );\n\n const fmt = (n: number) => formatCurrency(n, stats.currency);\n\n return (\n <PageLayout\n title={tr('insights.title', 'Insights')}\n description={tr(\n 'insights.description',\n 'Where your recurring money goes, what renews next, and what to review — computed live from your portfolio.'\n )}\n actions={\n <button\n type=\"button\"\n onClick={() => navigateTo('/savings')}\n data-testid=\"insights-open-savings\"\n className=\"inline-flex items-center gap-2 rounded-lg bg-action-primary-bg px-3 py-2 text-sm font-medium text-action-primary-text hover:opacity-90\"\n >\n <PiggyBank className=\"h-4 w-4\" aria-hidden />\n {tr('insights.openSavings', 'See how to save')}\n </button>\n }\n >\n <PagePurpose className=\"mb-2\">\n {tr(\n 'insights.purpose',\n 'Understand and optimize your recurring spend. Every figure here is aggregated from your real subscriptions — normalized to a monthly equivalent so plans on different billing cycles are comparable.'\n )}\n </PagePurpose>\n\n {stats.total === 0 ? (\n <EmptyState\n icon={<TrendingUp className=\"h-8 w-8\" />}\n title={tr('insights.emptyTitle', 'No data to analyze yet')}\n description={tr(\n 'insights.emptyDescription',\n 'Add a few subscriptions and your spend breakdown, renewal forecast and health watchlist will appear here.'\n )}\n />\n ) : (\n <>\n <div className=\"grid grid-cols-2 gap-4 lg:grid-cols-4\">\n <MetricsCard\n icon={<Wallet className=\"h-5 w-5\" />}\n value={fmt(stats.monthlySpend)}\n label={tr('insights.metrics.recurringSpend', 'Recurring spend / month')}\n featured\n />\n <MetricsCard\n icon={<Layers className=\"h-5 w-5\" />}\n value={stats.activeCount}\n label={tr('insights.metrics.activeSubscriptions', 'Active subscriptions')}\n />\n <MetricsCard\n icon={<CalendarClock className=\"h-5 w-5\" />}\n value={stats.renewals30}\n label={tr('insights.metrics.renewing30Days', 'Renewing in 30 days')}\n />\n <MetricsCard\n icon={<HeartPulse className=\"h-5 w-5\" />}\n value={\n stats.avgHealth != null\n ? Math.round(stats.avgHealth)\n : tr('insights.metrics.noScore', '—')\n }\n label={tr('insights.metrics.avgHealthScore', 'Avg. health score')}\n />\n </div>\n\n <div className=\"grid grid-cols-1 gap-4 lg:grid-cols-3\">\n <GlassCard treatment=\"plain\" className=\"p-6\">\n <h3 className=\"mb-4 text-sm font-semibold text-text-primary\">\n {tr('insights.portfolioByStatus', 'Portfolio by status')}\n </h3>\n <div className=\"flex flex-col items-center gap-6 sm:flex-row\">\n <Donut\n data={stats.statusData}\n centerLabel={String(stats.total)}\n centerSub={tr('insights.subscriptions', 'subscriptions')}\n />\n <Legend className=\"w-full sm:flex-1\" data={stats.statusData} />\n </div>\n </GlassCard>\n\n <GlassCard treatment=\"plain\" className=\"p-6\">\n <h3 className=\"mb-4 text-sm font-semibold text-text-primary\">\n {tr('insights.spendByType', 'Monthly spend by type')}\n </h3>\n {stats.kindData.length ? (\n <BarList data={stats.kindData} valueFormat={fmt} />\n ) : (\n <p className=\"text-sm text-text-secondary\">\n {tr('insights.noActiveSpend', 'No active spend.')}\n </p>\n )}\n </GlassCard>\n\n <GlassCard treatment=\"plain\" className=\"p-6\">\n <h3 className=\"mb-4 text-sm font-semibold text-text-primary\">\n {tr('insights.renewalForecast', 'Renewal forecast (6 mo)')}\n </h3>\n {stats.forecast.some((f) => f.value > 0) ? (\n <BarList data={stats.forecast} valueFormat={fmt} />\n ) : (\n <p className=\"text-sm text-text-secondary\">\n {tr('insights.noRenewals', 'No dated renewals in the next 6 months.')}\n </p>\n )}\n </GlassCard>\n </div>\n\n <div className=\"grid grid-cols-1 gap-4 lg:grid-cols-2\">\n <Section title={tr('insights.topCosts', 'Top monthly costs')}>\n <GlassCard treatment=\"plain\" className=\"divide-y divide-border-subtle\">\n {stats.topCosts.map((r) => (\n <div key={r.id} className=\"flex items-center justify-between p-4\">\n <span className=\"truncate text-sm text-text-primary\">{r.label}</span>\n <span className=\"ml-3 shrink-0 text-sm font-semibold tabular-nums text-text-primary\">\n {fmt(monthly(r))}\n <span className=\"text-xs font-normal text-text-secondary\">\n {tr('insights.perMonth', '/mo')}\n </span>\n </span>\n </div>\n ))}\n </GlassCard>\n </Section>\n\n <Section title={tr('insights.healthWatchlist', 'Health watchlist')}>\n {stats.watchlist.length ? (\n <GlassCard treatment=\"plain\" className=\"divide-y divide-border-subtle\">\n {stats.watchlist.map((r) => (\n <div key={r.id} className=\"flex items-center gap-3 p-4\">\n <span className=\"w-32 shrink-0 truncate text-sm text-text-primary\">\n {r.label}\n </span>\n <MiniProgress className=\"flex-1\" value={r.healthScore ?? 0} tone=\"negative\" />\n <span\n className={cn(\n 'w-8 shrink-0 text-right text-sm font-medium tabular-nums text-status-warning-text'\n )}\n >\n {r.healthScore}\n </span>\n </div>\n ))}\n </GlassCard>\n ) : (\n <GlassCard treatment=\"plain\" className=\"p-6 text-sm text-text-secondary\">\n {tr(\n 'insights.allHealthy',\n 'Nothing needs review — all scored subscriptions look healthy.'\n )}\n </GlassCard>\n )}\n </Section>\n </div>\n </>\n )}\n </PageLayout>\n );\n};\n"],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ import { FC } from 'react';
2
+
3
+ export declare const SavingsPage: FC;
4
+ //# sourceMappingURL=SavingsPage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SavingsPage.d.ts","sourceRoot":"","sources":["../../../src/subscriberbot/pages/SavingsPage.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAqB,KAAK,EAAE,EAAE,MAAM,OAAO,CAAC;AAuBnD,eAAO,MAAM,WAAW,EAAE,EAgQzB,CAAC"}