mirror of
https://github.com/Smile-QWQ/SubTracker.git
synced 2026-09-03 06:23:58 +08:00
perf: trim main statistics and ai summary inputs
- keep the existing overview DTO surface while reducing duplicated projected statistics work under the hood - shrink dashboard AI summary inputs to top and non-empty slices so generated copy stays lighter without changing the response contract - add regression coverage for overview field compatibility and the reduced AI summary payload shape
This commit is contained in:
@@ -92,6 +92,25 @@ function extractChatCompletionText(payload: ChatCompletionPayload) {
|
||||
}
|
||||
|
||||
function buildSummaryInput(overview: DashboardOverview) {
|
||||
const tagSpendTop = [...overview.tagSpend]
|
||||
.filter((item) => item.value > 0)
|
||||
.sort((a, b) => b.value - a.value || a.name.localeCompare(b.name, 'zh-CN'))
|
||||
.slice(0, 6)
|
||||
const renewalModeDistribution = overview.renewalModeDistribution.filter((item) => item.count > 0 || item.amount > 0)
|
||||
const currencyDistribution = overview.currencyDistribution
|
||||
.filter((item) => item.amount > 0)
|
||||
.sort((a, b) => b.amount - a.amount || a.currency.localeCompare(b.currency, 'en'))
|
||||
.slice(0, 8)
|
||||
const upcomingRenewalsTop = overview.upcomingRenewals.slice(0, 6)
|
||||
const topSubscriptionsByMonthlyCost = overview.topSubscriptionsByMonthlyCost.slice(0, 6)
|
||||
const upcomingByDayNonZero = overview.upcomingByDay
|
||||
.filter((item) => item.count > 0 || item.amount > 0)
|
||||
.slice(0, 10)
|
||||
const tagBudgetUsageTop = (overview.tagBudgetUsage ?? [])
|
||||
.filter((item) => item.ratio > 0 || item.spent > 0)
|
||||
.sort((a, b) => b.ratio - a.ratio || b.spent - a.spent)
|
||||
.slice(0, 6)
|
||||
|
||||
return {
|
||||
summary: {
|
||||
activeSubscriptions: overview.activeSubscriptions,
|
||||
@@ -105,21 +124,15 @@ function buildSummaryInput(overview: DashboardOverview) {
|
||||
yearlyBudgetUsageRatio: overview.yearlyBudgetUsageRatio ?? null
|
||||
},
|
||||
budgetSummary: overview.budgetSummary,
|
||||
tagSpendTop: [...overview.tagSpend]
|
||||
.sort((a, b) => b.value - a.value || a.name.localeCompare(b.name, 'zh-CN'))
|
||||
.slice(0, 8),
|
||||
tagSpendTop,
|
||||
statusDistribution: overview.statusDistribution,
|
||||
renewalModeDistribution: overview.renewalModeDistribution,
|
||||
currencyDistribution: overview.currencyDistribution,
|
||||
upcomingRenewalsTop: overview.upcomingRenewals.slice(0, 8),
|
||||
topSubscriptionsByMonthlyCost: overview.topSubscriptionsByMonthlyCost.slice(0, 8),
|
||||
upcomingByDayNonZero: overview.upcomingByDay.filter((item) => item.count > 0 || item.amount > 0).slice(0, 15),
|
||||
renewalModeDistribution,
|
||||
currencyDistribution,
|
||||
upcomingRenewalsTop,
|
||||
topSubscriptionsByMonthlyCost,
|
||||
upcomingByDayNonZero,
|
||||
tagBudgetSummary: overview.tagBudgetSummary ?? null,
|
||||
tagBudgetUsageTop:
|
||||
(overview.tagBudgetUsage ?? [])
|
||||
.filter((item) => item.ratio > 0)
|
||||
.sort((a, b) => b.ratio - a.ratio || b.spent - a.spent)
|
||||
.slice(0, 8) ?? []
|
||||
tagBudgetUsageTop
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ function buildBudgetEntry(spent: number, budget: number | null | undefined): Bud
|
||||
}
|
||||
}
|
||||
|
||||
function buildProjectedMonthlyTrend(
|
||||
function buildProjectedSeries(
|
||||
subscriptions: StatisticsSubscription[],
|
||||
baseCurrency: string,
|
||||
rates: Awaited<ReturnType<typeof ensureExchangeRates>>,
|
||||
@@ -111,15 +111,22 @@ function buildProjectedMonthlyTrend(
|
||||
) {
|
||||
const startMonth = toTimezonedDayjs(startOfMonthDateInTimezone(new Date(), timezone), timezone)
|
||||
const endMonth = startMonth.add(11, 'month').endOf('month')
|
||||
const startDay = toTimezonedDayjs(startOfDayDateInTimezone(new Date(), timezone), timezone)
|
||||
const endDay = startDay.add(89, 'day').endOf('day')
|
||||
const monthlyTrendMap = new Map<string, number>()
|
||||
const upcomingMap = new Map<string, { count: number; amount: number }>()
|
||||
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
monthlyTrendMap.set(startMonth.add(index, 'month').format('YYYY-MM'), 0)
|
||||
}
|
||||
|
||||
for (let index = 0; index < 90; index += 1) {
|
||||
upcomingMap.set(startDay.add(index, 'day').format('YYYY-MM-DD'), { count: 0, amount: 0 })
|
||||
}
|
||||
|
||||
const projectedEvents = projectRenewalEvents(subscriptions, {
|
||||
start: startMonth.toDate(),
|
||||
end: endMonth.toDate(),
|
||||
end: endDay.isAfter(endMonth) ? endDay.toDate() : endMonth.toDate(),
|
||||
statuses: ['active', 'expired'],
|
||||
timezone
|
||||
})
|
||||
@@ -128,49 +135,27 @@ function buildProjectedMonthlyTrend(
|
||||
const convertedAmount = convertAmount(event.amount, event.currency, baseCurrency, rates.baseCurrency, rates.rates)
|
||||
const key = monthKeyInTimezone(event.date, timezone)
|
||||
monthlyTrendMap.set(key, (monthlyTrendMap.get(key) ?? 0) + convertedAmount)
|
||||
|
||||
if (!startDay.isAfter(event.date) && !toTimezonedDayjs(event.date, timezone).isAfter(endDay)) {
|
||||
const dayKey = formatDateInTimezone(event.date, timezone)
|
||||
const current = upcomingMap.get(dayKey) ?? { count: 0, amount: 0 }
|
||||
current.count += 1
|
||||
current.amount += convertedAmount
|
||||
upcomingMap.set(dayKey, current)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(monthlyTrendMap.entries()).map(([month, amount]) => ({
|
||||
month,
|
||||
amount: Number(amount.toFixed(2))
|
||||
}))
|
||||
}
|
||||
|
||||
function buildUpcomingByDay(
|
||||
subscriptions: StatisticsSubscription[],
|
||||
baseCurrency: string,
|
||||
rates: Awaited<ReturnType<typeof ensureExchangeRates>>,
|
||||
timezone: string
|
||||
) {
|
||||
const startDay = toTimezonedDayjs(startOfDayDateInTimezone(new Date(), timezone), timezone)
|
||||
const endDay = startDay.add(89, 'day').endOf('day')
|
||||
const upcomingMap = new Map<string, { count: number; amount: number }>()
|
||||
|
||||
for (let index = 0; index < 90; index += 1) {
|
||||
upcomingMap.set(startDay.add(index, 'day').format('YYYY-MM-DD'), { count: 0, amount: 0 })
|
||||
return {
|
||||
monthlyTrend: Array.from(monthlyTrendMap.entries()).map(([month, amount]) => ({
|
||||
month,
|
||||
amount: Number(amount.toFixed(2))
|
||||
})),
|
||||
upcomingByDay: Array.from(upcomingMap.entries()).map(([date, value]) => ({
|
||||
date,
|
||||
count: value.count,
|
||||
amount: Number(value.amount.toFixed(2))
|
||||
}))
|
||||
}
|
||||
|
||||
const projectedEvents = projectRenewalEvents(subscriptions, {
|
||||
start: startDay.toDate(),
|
||||
end: endDay.toDate(),
|
||||
statuses: ['active', 'expired'],
|
||||
timezone
|
||||
})
|
||||
|
||||
for (const event of projectedEvents) {
|
||||
const convertedAmount = convertAmount(event.amount, event.currency, baseCurrency, rates.baseCurrency, rates.rates)
|
||||
const key = formatDateInTimezone(event.date, timezone)
|
||||
const current = upcomingMap.get(key) ?? { count: 0, amount: 0 }
|
||||
current.count += 1
|
||||
current.amount += convertedAmount
|
||||
upcomingMap.set(key, current)
|
||||
}
|
||||
|
||||
return Array.from(upcomingMap.entries()).map(([date, value]) => ({
|
||||
date,
|
||||
count: value.count,
|
||||
amount: Number(value.amount.toFixed(2))
|
||||
}))
|
||||
}
|
||||
|
||||
async function buildStatisticsState() {
|
||||
@@ -265,8 +250,7 @@ async function buildStatisticsState() {
|
||||
}
|
||||
}
|
||||
|
||||
const monthlyTrend = buildProjectedMonthlyTrend(projectedSubscriptions, baseCurrency, rates, timezone)
|
||||
const upcomingByDay = buildUpcomingByDay(projectedSubscriptions, baseCurrency, rates, timezone)
|
||||
const { monthlyTrend, upcomingByDay } = buildProjectedSeries(projectedSubscriptions, baseCurrency, rates, timezone)
|
||||
|
||||
const upcomingRenewals = projectedSubscriptions
|
||||
.filter((subscription) => {
|
||||
@@ -284,13 +268,11 @@ async function buildStatisticsState() {
|
||||
status: item.status
|
||||
}))
|
||||
|
||||
const tagLookup = new Map(tags.map((tag) => [tag.id, tag.name]))
|
||||
|
||||
const tagBudgetUsage = appSettings.enableTagBudgets
|
||||
? Object.entries(appSettings.tagBudgets)
|
||||
.flatMap<TagBudgetUsageEntry>(([tagId, budget]) => {
|
||||
const item = tagBudgetMap.get(tagId)
|
||||
const name = item?.name ?? tagLookup.get(tagId)
|
||||
const name = item?.name ?? tags.find((tag) => tag.id === tagId)?.name
|
||||
if (!name) return []
|
||||
|
||||
const spent = Number((item?.spent ?? 0).toFixed(2))
|
||||
@@ -319,21 +301,23 @@ async function buildStatisticsState() {
|
||||
yearly: buildBudgetEntry(yearlyEstimatedBase, appSettings.yearlyBudgetBase)
|
||||
}
|
||||
|
||||
const tagBudgetSummary = {
|
||||
configuredCount: tagBudgetUsage.length,
|
||||
warningCount: tagBudgetUsage.filter((item) => item.status === 'warning').length,
|
||||
overBudgetCount: tagBudgetUsage.filter((item) => item.status === 'over').length,
|
||||
topTags: tagBudgetUsage.slice(0, 3).map((item) => ({
|
||||
tagId: item.tagId,
|
||||
name: item.name,
|
||||
budget: item.budget,
|
||||
spent: item.spent,
|
||||
ratio: item.ratio,
|
||||
remaining: item.remaining,
|
||||
overBudget: item.overBudget,
|
||||
status: item.status
|
||||
}))
|
||||
}
|
||||
const tagBudgetSummary = appSettings.enableTagBudgets
|
||||
? {
|
||||
configuredCount: tagBudgetUsage.length,
|
||||
warningCount: tagBudgetUsage.filter((item) => item.status === 'warning').length,
|
||||
overBudgetCount: tagBudgetUsage.filter((item) => item.status === 'over').length,
|
||||
topTags: tagBudgetUsage.slice(0, 3).map((item) => ({
|
||||
tagId: item.tagId,
|
||||
name: item.name,
|
||||
budget: item.budget,
|
||||
spent: item.spent,
|
||||
ratio: item.ratio,
|
||||
remaining: item.remaining,
|
||||
overBudget: item.overBudget,
|
||||
status: item.status
|
||||
}))
|
||||
}
|
||||
: null
|
||||
|
||||
return {
|
||||
appSettings,
|
||||
|
||||
@@ -358,4 +358,74 @@ describe('ai summary service', () => {
|
||||
expect(requestBody.messages[0].content).toContain('专门输出统计摘要的助手')
|
||||
expect(requestBody.messages[0].content).not.toContain('只返回 JSON')
|
||||
})
|
||||
|
||||
it('shrinks large summary arrays before sending them to AI', async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
jsonResponse({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: '## 总览\n- 已缩减输入体积'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
aiSummaryMocks.getOverviewStatisticsMock.mockResolvedValue(
|
||||
buildOverview({
|
||||
tagSpend: Array.from({ length: 10 }, (_, index) => ({ name: `tag-${index}`, value: 10 - index })),
|
||||
renewalModeDistribution: [
|
||||
{ autoRenew: true, count: 4, amount: 120 },
|
||||
{ autoRenew: false, count: 0, amount: 0 }
|
||||
],
|
||||
currencyDistribution: Array.from({ length: 10 }, (_, index) => ({ currency: `C${index}`, amount: 20 - index })),
|
||||
topSubscriptionsByMonthlyCost: Array.from({ length: 10 }, (_, index) => ({
|
||||
id: `top-${index}`,
|
||||
name: `Top ${index}`,
|
||||
amount: index + 1,
|
||||
currency: 'CNY',
|
||||
monthlyAmountBase: index + 1,
|
||||
baseCurrency: 'CNY'
|
||||
})),
|
||||
upcomingRenewals: Array.from({ length: 10 }, (_, index) => ({
|
||||
id: `up-${index}`,
|
||||
name: `Upcoming ${index}`,
|
||||
nextRenewalDate: '2026-05-04',
|
||||
amount: index + 1,
|
||||
currency: 'CNY',
|
||||
convertedAmount: index + 1,
|
||||
status: 'active'
|
||||
})),
|
||||
upcomingByDay: [
|
||||
{ date: '2026-05-03', count: 0, amount: 0 },
|
||||
...Array.from({ length: 12 }, (_, index) => ({ date: `2026-05-${String(index + 4).padStart(2, '0')}`, count: 1, amount: index + 1 }))
|
||||
],
|
||||
tagBudgetUsage: Array.from({ length: 10 }, (_, index) => ({
|
||||
tagId: `tb-${index}`,
|
||||
name: `tag-budget-${index}`,
|
||||
budget: 100,
|
||||
spent: 90 - index,
|
||||
ratio: 0.9 - index * 0.01,
|
||||
remaining: 10 + index,
|
||||
overBudget: 0,
|
||||
status: 'warning' as const
|
||||
}))
|
||||
})
|
||||
)
|
||||
|
||||
await generateDashboardAiSummary()
|
||||
|
||||
const requestBody = JSON.parse(String((((fetchMock.mock.calls[0] as unknown) as [unknown, RequestInit])[1])?.body))
|
||||
const content = String(requestBody.messages[1].content)
|
||||
const payload = JSON.parse(content.slice(content.indexOf('{')))
|
||||
|
||||
expect(payload.tagSpendTop).toHaveLength(6)
|
||||
expect(payload.renewalModeDistribution).toHaveLength(1)
|
||||
expect(payload.currencyDistribution).toHaveLength(8)
|
||||
expect(payload.upcomingRenewalsTop).toHaveLength(6)
|
||||
expect(payload.topSubscriptionsByMonthlyCost).toHaveLength(6)
|
||||
expect(payload.upcomingByDayNonZero).toHaveLength(10)
|
||||
expect(payload.tagBudgetUsageTop).toHaveLength(6)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -200,4 +200,48 @@ describe('statistics service', () => {
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps overview dto fields for dashboard and statistics pages', async () => {
|
||||
statisticsSettings.enableTagBudgets = true
|
||||
statisticsSettings.tagBudgets = { tag_video: 100 }
|
||||
findManyTagsMock.mockResolvedValue([{ id: 'tag_video', name: 'Video' }])
|
||||
findManySubscriptionsMock.mockResolvedValue([
|
||||
createSubscription('active-video', {
|
||||
amount: 40,
|
||||
tags: [{ tag: { id: 'tag_video', name: 'Video', color: '#3b82f6', icon: 'apps-outline', sortOrder: 0 } }]
|
||||
})
|
||||
])
|
||||
|
||||
const result = await getOverviewStatistics()
|
||||
|
||||
expect(result).toMatchObject({
|
||||
activeSubscriptions: expect.any(Number),
|
||||
upcoming7Days: expect.any(Number),
|
||||
upcoming30Days: expect.any(Number),
|
||||
monthlyEstimatedBase: expect.any(Number),
|
||||
yearlyEstimatedBase: expect.any(Number),
|
||||
budgetSummary: {
|
||||
monthly: expect.any(Object),
|
||||
yearly: expect.any(Object)
|
||||
},
|
||||
statusDistribution: expect.any(Array),
|
||||
renewalModeDistribution: expect.any(Array),
|
||||
currencyDistribution: expect.any(Array),
|
||||
topSubscriptionsByMonthlyCost: expect.any(Array),
|
||||
upcomingRenewals: expect.any(Array)
|
||||
})
|
||||
expect(Array.isArray(result.monthlyTrend)).toBe(true)
|
||||
expect(Array.isArray(result.upcomingByDay)).toBe(true)
|
||||
expect(result.monthlyTrendMeta).toEqual({
|
||||
mode: 'projected',
|
||||
months: 12
|
||||
})
|
||||
expect(result.tagBudgetSummary).not.toBeNull()
|
||||
expect(result.tagBudgetUsage).toEqual([
|
||||
expect.objectContaining({
|
||||
tagId: 'tag_video',
|
||||
budget: 100
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user