From 3a61b7952bb70cf243d3ba6640fdf87e59621544 Mon Sep 17 00:00:00 2001 From: yuming Date: Sat, 15 Aug 2026 17:43:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=AE=A2=E9=98=85=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=E9=A3=8E=E9=99=A9=E9=A2=84=E8=AD=A6=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/atRisk.js | 66 ++++++++++++++++++++++++++++++++ server/test/atRisk.test.js | 78 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 server/src/atRisk.js create mode 100644 server/test/atRisk.test.js diff --git a/server/src/atRisk.js b/server/src/atRisk.js new file mode 100644 index 0000000..3a87040 --- /dev/null +++ b/server/src/atRisk.js @@ -0,0 +1,66 @@ +/** + * 风险预警计算:算出未来一段时间内,有哪些提醒会因额度不足发不出去 + * + * 首页拿这个结果给用户看「谁的提醒有风险」,而不是「还剩几次额度」—— + * 用户理解不了额度这种抽象概念,但看得懂人名。 + */ + +const { getNextOccurrence, daysBetween } = require('./occurrence') + +// 预警窗口。太短则提示不及时,太长会拿几个月后的事惊扰用户,两个月是折中。 +const LOOKAHEAD_DAYS = 60 + +/** + * 把一条纪念日展开成未来窗口内的提醒事件 + * 每条纪念日最多产生两个事件:提前 N 天一个、当天一个 + */ +function expandEvents(anniv, today = new Date()) { + const next = getNextOccurrence(anniv, today) + const onDayIn = daysBetween(next, today) + const remindDays = anniv.remindDays || 0 + + const events = [{ + anniversaryId: anniv.id, + personName: anniv.personName, + kind: 'onDay', + fireInDays: onDayIn, + importance: anniv.importance + }] + + if (remindDays > 0) { + events.push({ + anniversaryId: anniv.id, + personName: anniv.personName, + kind: 'ahead', + fireInDays: onDayIn - remindDays, + importance: anniv.importance + }) + } + + // 已经过去的(如提前提醒的时点早已过)和超出窗口的都不算 + return events.filter(e => e.fireInDays >= 0 && e.fireInDays <= LOOKAHEAD_DAYS) +} + +/** + * 算出有风险的提醒 + * 排序用「时间先后」而非重要程度——跨天的额度就是先到先消耗。 + * 重要程度只在同一天内比较(那部分逻辑在 reminder.js)。 + */ +function computeAtRisk(anniversaries, balance, today = new Date()) { + const events = (anniversaries || []) + .filter(a => a && a.remindEnabled) + .flatMap(a => expandEvents(a, today)) + .sort((x, y) => x.fireInDays - y.fireInDays) + + const safeCount = Math.max(0, balance) + const atRisk = events.slice(safeCount) + + const names = [] + for (const e of atRisk) { + if (e.personName && !names.includes(e.personName)) names.push(e.personName) + } + + return { atRiskCount: atRisk.length, atRiskNames: names } +} + +module.exports = { LOOKAHEAD_DAYS, expandEvents, computeAtRisk } diff --git a/server/test/atRisk.test.js b/server/test/atRisk.test.js new file mode 100644 index 0000000..5f38309 --- /dev/null +++ b/server/test/atRisk.test.js @@ -0,0 +1,78 @@ +const test = require('node:test') +const assert = require('node:assert') +const { expandEvents, computeAtRisk, LOOKAHEAD_DAYS } = require('../src/atRisk') + +const TODAY = new Date(2026, 7, 15) // 2026-08-15 + +// 距今 10 天的公历纪念日,提前 3 天提醒 +const near = { + id: 'a1', personName: '张三', remindEnabled: 1, importance: 'high', + isLunar: false, solarMonth: 8, solarDay: 25, remindDays: 3 +} +// 距今 40 天 +const mid = { + id: 'a2', personName: '李四', remindEnabled: 1, importance: 'low', + isLunar: false, solarMonth: 9, solarDay: 24, remindDays: 7 +} + +test('LOOKAHEAD_DAYS 为 60', () => { + assert.strictEqual(LOOKAHEAD_DAYS, 60) +}) + +test('一条纪念日展开出「当天」和「提前」两个事件', () => { + const events = expandEvents(near, TODAY) + assert.strictEqual(events.length, 2) + const onDay = events.find(e => e.kind === 'onDay') + const ahead = events.find(e => e.kind === 'ahead') + assert.strictEqual(onDay.fireInDays, 10) + assert.strictEqual(ahead.fireInDays, 7) +}) + +test('remindDays 为 0 时只有当天事件', () => { + const events = expandEvents({ ...near, remindDays: 0 }, TODAY) + assert.strictEqual(events.length, 1) + assert.strictEqual(events[0].kind, 'onDay') +}) + +test('超出 60 天窗口的事件被过滤掉', () => { + const far = { ...near, solarMonth: 12, solarDay: 25, remindDays: 3 } + assert.strictEqual(expandEvents(far, TODAY).length, 0) +}) + +test('提前日已过、当天未到时,只剩当天事件', () => { + // 距今 2 天,但提前 7 天的时点早已过去 + const soon = { ...near, solarMonth: 8, solarDay: 17, remindDays: 7 } + const events = expandEvents(soon, TODAY) + assert.strictEqual(events.length, 1) + assert.strictEqual(events[0].kind, 'onDay') +}) + +test('余额足够时没有风险', () => { + const r = computeAtRisk([near, mid], 10, TODAY) + assert.strictEqual(r.atRiskCount, 0) + assert.deepStrictEqual(r.atRiskNames, []) +}) + +test('余额为 0 时全部有风险', () => { + const r = computeAtRisk([near, mid], 0, TODAY) + assert.strictEqual(r.atRiskCount, 4) + assert.deepStrictEqual(r.atRiskNames, ['张三', '李四']) +}) + +test('按时间先后消耗额度,后面的才有风险', () => { + // 张三的两个事件在第 7、10 天,李四的在第 33、40 天 + const r = computeAtRisk([near, mid], 2, TODAY) + assert.strictEqual(r.atRiskCount, 2) + assert.deepStrictEqual(r.atRiskNames, ['李四']) +}) + +test('人名去重,同一人占两条只出现一次', () => { + const r = computeAtRisk([near], 0, TODAY) + assert.strictEqual(r.atRiskCount, 2) + assert.deepStrictEqual(r.atRiskNames, ['张三']) +}) + +test('未开启提醒的纪念日不参与计算', () => { + const r = computeAtRisk([{ ...near, remindEnabled: 0 }], 0, TODAY) + assert.strictEqual(r.atRiskCount, 0) +})