@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.
- package/dist/subscriberbot/SubscriberBotRoutes.d.ts.map +1 -1
- package/dist/subscriberbot/SubscriberBotRoutes.js +12 -8
- package/dist/subscriberbot/SubscriberBotRoutes.js.map +1 -1
- package/dist/subscriberbot/components/decisions/DecisionCard.d.ts.map +1 -1
- package/dist/subscriberbot/components/decisions/DecisionCard.js +1 -0
- package/dist/subscriberbot/components/decisions/DecisionCard.js.map +1 -1
- package/dist/subscriberbot/components/decisions/decisionModel.d.ts +3 -2
- package/dist/subscriberbot/components/decisions/decisionModel.d.ts.map +1 -1
- package/dist/subscriberbot/components/decisions/decisionModel.js +18 -5
- package/dist/subscriberbot/components/decisions/decisionModel.js.map +1 -1
- package/dist/subscriberbot/components/savings/SavingsCards.d.ts +11 -0
- package/dist/subscriberbot/components/savings/SavingsCards.d.ts.map +1 -0
- package/dist/subscriberbot/components/savings/SavingsCards.js +448 -0
- package/dist/subscriberbot/components/savings/SavingsCards.js.map +1 -0
- package/dist/subscriberbot/components/savings/index.d.ts +8 -0
- package/dist/subscriberbot/components/savings/index.d.ts.map +1 -0
- package/dist/subscriberbot/components/savings/index.js +2 -0
- package/dist/subscriberbot/components/savings/savingsModel.d.ts +53 -0
- package/dist/subscriberbot/components/savings/savingsModel.d.ts.map +1 -0
- package/dist/subscriberbot/components/savings/savingsModel.js +105 -0
- package/dist/subscriberbot/components/savings/savingsModel.js.map +1 -0
- package/dist/subscriberbot/hooks/index.d.ts +1 -1
- package/dist/subscriberbot/hooks/index.d.ts.map +1 -1
- package/dist/subscriberbot/hooks/useSubscriberBotApi.d.ts +8 -1
- package/dist/subscriberbot/hooks/useSubscriberBotApi.d.ts.map +1 -1
- package/dist/subscriberbot/hooks/useSubscriberBotApi.js +45 -15
- package/dist/subscriberbot/hooks/useSubscriberBotApi.js.map +1 -1
- package/dist/subscriberbot/pages/InsightsPage.d.ts.map +1 -1
- package/dist/subscriberbot/pages/InsightsPage.js +121 -110
- package/dist/subscriberbot/pages/InsightsPage.js.map +1 -1
- package/dist/subscriberbot/pages/SavingsPage.d.ts +4 -0
- package/dist/subscriberbot/pages/SavingsPage.d.ts.map +1 -0
- package/dist/subscriberbot/pages/SavingsPage.js +205 -0
- package/dist/subscriberbot/pages/SavingsPage.js.map +1 -0
- package/dist/subscriberbot/pages/relationships/RelationshipDetailPage.d.ts.map +1 -1
- package/dist/subscriberbot/pages/relationships/RelationshipDetailPage.js +292 -301
- package/dist/subscriberbot/pages/relationships/RelationshipDetailPage.js.map +1 -1
- package/dist/subscriberbot/types/index.d.ts +62 -2
- package/dist/subscriberbot/types/index.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
|
11
|
-
import { useMemo as
|
|
12
|
-
import { CalendarClock as
|
|
13
|
-
import { Fragment as
|
|
14
|
-
import { GlassCard as
|
|
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
|
|
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
|
-
},
|
|
26
|
-
let
|
|
27
|
-
let e =
|
|
28
|
-
for (let e of
|
|
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) +
|
|
31
|
-
let c = [...e].sort((e, t) =>
|
|
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 =
|
|
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 =
|
|
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:
|
|
55
|
+
avgHealth: i,
|
|
55
56
|
renewals30: f,
|
|
56
57
|
statusData: [...o.entries()].map(([e, t]) => ({
|
|
57
|
-
label:
|
|
58
|
+
label: a(e),
|
|
58
59
|
value: t
|
|
59
60
|
})),
|
|
60
61
|
kindData: [...s.entries()].map(([e, t]) => ({
|
|
61
|
-
label:
|
|
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:
|
|
68
|
+
total: N.length
|
|
68
69
|
};
|
|
69
|
-
}, [
|
|
70
|
-
if (
|
|
71
|
-
if (
|
|
72
|
-
message:
|
|
73
|
-
onRetry: () => void
|
|
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
|
|
76
|
-
return /* @__PURE__ */
|
|
77
|
-
title:
|
|
78
|
-
description:
|
|
79
|
-
|
|
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:
|
|
82
|
-
}),
|
|
83
|
-
icon: /* @__PURE__ */
|
|
84
|
-
title:
|
|
85
|
-
description:
|
|
86
|
-
}) : /* @__PURE__ */ C
|
|
87
|
-
/* @__PURE__ */
|
|
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__ */
|
|
91
|
-
icon: /* @__PURE__ */ S
|
|
92
|
-
value:
|
|
93
|
-
label:
|
|
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__ */
|
|
97
|
-
icon: /* @__PURE__ */
|
|
98
|
-
value:
|
|
99
|
-
label:
|
|
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__ */
|
|
102
|
-
icon: /* @__PURE__ */
|
|
103
|
-
value:
|
|
104
|
-
label:
|
|
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__ */
|
|
107
|
-
icon: /* @__PURE__ */
|
|
108
|
-
value:
|
|
109
|
-
label:
|
|
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__ */
|
|
124
|
+
/* @__PURE__ */ T("div", {
|
|
114
125
|
className: "grid grid-cols-1 gap-4 lg:grid-cols-3",
|
|
115
126
|
children: [
|
|
116
|
-
/* @__PURE__ */
|
|
127
|
+
/* @__PURE__ */ T(E, {
|
|
117
128
|
treatment: "plain",
|
|
118
129
|
className: "p-6",
|
|
119
|
-
children: [/* @__PURE__ */
|
|
130
|
+
children: [/* @__PURE__ */ w("h3", {
|
|
120
131
|
className: "mb-4 text-sm font-semibold text-text-primary",
|
|
121
|
-
children:
|
|
122
|
-
}), /* @__PURE__ */
|
|
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__ */
|
|
125
|
-
data:
|
|
126
|
-
centerLabel: String(
|
|
127
|
-
centerSub:
|
|
128
|
-
}), /* @__PURE__ */
|
|
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:
|
|
141
|
+
data: P.statusData
|
|
131
142
|
})]
|
|
132
143
|
})]
|
|
133
144
|
}),
|
|
134
|
-
/* @__PURE__ */
|
|
145
|
+
/* @__PURE__ */ T(E, {
|
|
135
146
|
treatment: "plain",
|
|
136
147
|
className: "p-6",
|
|
137
|
-
children: [/* @__PURE__ */
|
|
148
|
+
children: [/* @__PURE__ */ w("h3", {
|
|
138
149
|
className: "mb-4 text-sm font-semibold text-text-primary",
|
|
139
|
-
children:
|
|
140
|
-
}),
|
|
141
|
-
data:
|
|
142
|
-
valueFormat:
|
|
143
|
-
}) : /* @__PURE__ */
|
|
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:
|
|
156
|
+
children: j("insights.noActiveSpend", "No active spend.")
|
|
146
157
|
})]
|
|
147
158
|
}),
|
|
148
|
-
/* @__PURE__ */
|
|
159
|
+
/* @__PURE__ */ T(E, {
|
|
149
160
|
treatment: "plain",
|
|
150
161
|
className: "p-6",
|
|
151
|
-
children: [/* @__PURE__ */
|
|
162
|
+
children: [/* @__PURE__ */ w("h3", {
|
|
152
163
|
className: "mb-4 text-sm font-semibold text-text-primary",
|
|
153
|
-
children:
|
|
154
|
-
}),
|
|
155
|
-
data:
|
|
156
|
-
valueFormat:
|
|
157
|
-
}) : /* @__PURE__ */
|
|
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:
|
|
170
|
+
children: j("insights.noRenewals", "No dated renewals in the next 6 months.")
|
|
160
171
|
})]
|
|
161
172
|
})
|
|
162
173
|
]
|
|
163
174
|
}),
|
|
164
|
-
/* @__PURE__ */
|
|
175
|
+
/* @__PURE__ */ T("div", {
|
|
165
176
|
className: "grid grid-cols-1 gap-4 lg:grid-cols-2",
|
|
166
|
-
children: [/* @__PURE__ */
|
|
167
|
-
title:
|
|
168
|
-
children: /* @__PURE__ */
|
|
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:
|
|
182
|
+
children: P.topCosts.map((e) => /* @__PURE__ */ T("div", {
|
|
172
183
|
className: "flex items-center justify-between p-4",
|
|
173
|
-
children: [/* @__PURE__ */
|
|
184
|
+
children: [/* @__PURE__ */ w("span", {
|
|
174
185
|
className: "truncate text-sm text-text-primary",
|
|
175
186
|
children: e.label
|
|
176
|
-
}), /* @__PURE__ */
|
|
187
|
+
}), /* @__PURE__ */ T("span", {
|
|
177
188
|
className: "ml-3 shrink-0 text-sm font-semibold tabular-nums text-text-primary",
|
|
178
|
-
children: [
|
|
189
|
+
children: [F(k(e)), /* @__PURE__ */ w("span", {
|
|
179
190
|
className: "text-xs font-normal text-text-secondary",
|
|
180
|
-
children:
|
|
191
|
+
children: j("insights.perMonth", "/mo")
|
|
181
192
|
})]
|
|
182
193
|
})]
|
|
183
194
|
}, e.id))
|
|
184
195
|
})
|
|
185
|
-
}), /* @__PURE__ */
|
|
186
|
-
title:
|
|
187
|
-
children:
|
|
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:
|
|
201
|
+
children: P.watchlist.map((e) => /* @__PURE__ */ T("div", {
|
|
191
202
|
className: "flex items-center gap-3 p-4",
|
|
192
203
|
children: [
|
|
193
|
-
/* @__PURE__ */
|
|
204
|
+
/* @__PURE__ */ w("span", {
|
|
194
205
|
className: "w-32 shrink-0 truncate text-sm text-text-primary",
|
|
195
|
-
children:
|
|
206
|
+
children: e.label
|
|
196
207
|
}),
|
|
197
|
-
/* @__PURE__ */
|
|
208
|
+
/* @__PURE__ */ w(m, {
|
|
198
209
|
className: "flex-1",
|
|
199
|
-
value:
|
|
210
|
+
value: e.healthScore ?? 0,
|
|
200
211
|
tone: "negative"
|
|
201
212
|
}),
|
|
202
|
-
/* @__PURE__ */
|
|
203
|
-
className:
|
|
204
|
-
children:
|
|
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
|
-
},
|
|
208
|
-
}) : /* @__PURE__ */
|
|
218
|
+
}, e.id))
|
|
219
|
+
}) : /* @__PURE__ */ w(E, {
|
|
209
220
|
treatment: "plain",
|
|
210
221
|
className: "p-6 text-sm text-text-secondary",
|
|
211
|
-
children:
|
|
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 {
|
|
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 @@
|
|
|
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"}
|