feat(settings): configurable "Due soon" window (plan Tier 4)
- statusService: resolveDueSoonDays (clamp 0–31, default 3) replaces the hardcoded 3-day threshold in calculateStatus; threaded via options.dueSoonDays through rowOptions in trackerService (×2) and routes/calendar. - New per-user 'due_soon_days' setting registered in the whitelist + defaults. - Settings: numeric "Due soon window" row next to Grace period, with min/max + clamp guard. Tooltips added to Grace period, Due-soon, and Price-change sensitivity explaining each. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0f24016bcf
commit
01030fe7d0
|
|
@ -316,6 +316,7 @@ interface SettingsState {
|
|||
date_format: string;
|
||||
week_start: string;
|
||||
grace_period_days: number | string;
|
||||
due_soon_days: number | string;
|
||||
drift_threshold_pct: string;
|
||||
tracker_show_bank_projection_banner: string;
|
||||
tracker_bank_projection_banner_snoozed_until: string;
|
||||
|
|
@ -335,6 +336,7 @@ export default function SettingsPage() {
|
|||
date_format: 'MM/DD/YYYY',
|
||||
week_start: '0',
|
||||
grace_period_days: 5,
|
||||
due_soon_days: 3,
|
||||
drift_threshold_pct: '5',
|
||||
tracker_show_bank_projection_banner: 'true',
|
||||
tracker_bank_projection_banner_snoozed_until: '',
|
||||
|
|
@ -367,6 +369,7 @@ export default function SettingsPage() {
|
|||
date_format: s.date_format,
|
||||
week_start: s.week_start,
|
||||
grace_period_days: s.grace_period_days,
|
||||
due_soon_days: s.due_soon_days,
|
||||
drift_threshold_pct: s.drift_threshold_pct,
|
||||
tracker_show_bank_projection_banner: s.tracker_show_bank_projection_banner,
|
||||
tracker_bank_projection_banner_snoozed_until: s.tracker_bank_projection_banner_snoozed_until || '',
|
||||
|
|
@ -561,7 +564,7 @@ export default function SettingsPage() {
|
|||
<SectionCard title="Billing Behavior">
|
||||
<LinkImportToggle />
|
||||
<SettingRow
|
||||
label="Grace period"
|
||||
label={<span className="inline-flex items-center">Grace period<SettingHelp label="Grace period" text="How many days after the due date a bill still counts as on-time before it's marked late." /></span>}
|
||||
description="Days after the due date before a bill is marked overdue."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -577,7 +580,23 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Price change sensitivity"
|
||||
label={<span className="inline-flex items-center">Due soon window<SettingHelp label="Due soon window" text="Bills due within this many days show as “Due soon”." /></span>}
|
||||
description="How many days ahead a bill is flagged as due soon."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={31}
|
||||
value={settings.due_soon_days}
|
||||
onChange={(e) => setTyped('due_soon_days', Math.min(Math.max(parseInt(e.target.value, 10) || 0, 0), 31))}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">days</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={<span className="inline-flex items-center">Price change sensitivity<SettingHelp label="Price change sensitivity" text="How big a change (%) between recent payments and the expected amount before a bill is flagged as drifted." /></span>}
|
||||
description="Flag a bill when recent payments differ from the expected amount by at least this percentage."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ router.get('/', (req, res) => {
|
|||
|
||||
const today = localDateString(now);
|
||||
const userSettings = getUserSettings(req.user.id);
|
||||
const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
|
||||
const rowOptions = { gracePeriodDays: userSettings.grace_period_days, dueSoonDays: userSettings.due_soon_days };
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
const { start, end } = getCycleRange(year, month);
|
||||
const days = Array.from({ length: daysInMonth }, (_, index) => emptyDay(year, month, index + 1));
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ function resolveGracePeriodDays(value) {
|
|||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 5;
|
||||
}
|
||||
|
||||
// How many days before the due date a bill is flagged "due soon". Configurable
|
||||
// like the grace period; clamped to a sane 0–31 with a fallback of 3.
|
||||
function resolveDueSoonDays(value) {
|
||||
const parsed = parseInt(value ?? getSetting('due_soon_days') ?? '3', 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) return 3;
|
||||
return Math.min(parsed, 31);
|
||||
}
|
||||
|
||||
function pad(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
|
@ -201,6 +209,7 @@ function calculateStatus(bill, payments, dueDate, today, options = {}) {
|
|||
if (!dueDate) return 'inactive_cycle';
|
||||
|
||||
const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays);
|
||||
const dueSoonDays = resolveDueSoonDays(options.dueSoonDays);
|
||||
const safePayments = Array.isArray(payments) ? payments : [];
|
||||
const expectedAmount = Number(bill.expected_amount) || 0;
|
||||
const totalPaid = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
|
||||
|
|
@ -215,7 +224,7 @@ function calculateStatus(bill, payments, dueDate, today, options = {}) {
|
|||
const todayDate = new Date(today + 'T00:00:00');
|
||||
const diffDays = Math.floor((due - todayDate) / 86400000);
|
||||
|
||||
if (diffDays > 3) return 'upcoming';
|
||||
if (diffDays > dueSoonDays) return 'upcoming';
|
||||
if (diffDays >= 0) return 'due_soon';
|
||||
if (Math.abs(diffDays) <= gracePeriodDays) return 'late';
|
||||
return 'missed';
|
||||
|
|
@ -296,4 +305,5 @@ module.exports = {
|
|||
resolveBucket,
|
||||
resolveDueDate,
|
||||
resolveGracePeriodDays,
|
||||
resolveDueSoonDays,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -547,7 +547,7 @@ function getTracker(userId, query = {}, now = new Date()) {
|
|||
const { year, month } = parsed;
|
||||
const todayStr = localDateString(now);
|
||||
const userSettings = getUserSettings(userId);
|
||||
const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
|
||||
const rowOptions = { gracePeriodDays: userSettings.grace_period_days, dueSoonDays: userSettings.due_soon_days };
|
||||
const { start, end } = getCycleRange(year, month);
|
||||
const previousMonth = previousMonthFor(year, month);
|
||||
const prevMonthRange = getCycleRange(previousMonth.year, previousMonth.month);
|
||||
|
|
@ -767,7 +767,7 @@ function getUpcomingBills(userId, query = {}, now = new Date()) {
|
|||
const days = Math.max(1, Math.min(parseInt(query.days || '30', 10) || 30, 365));
|
||||
const todayStr = localDateString(now);
|
||||
const userSettings = getUserSettings(userId);
|
||||
const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
|
||||
const rowOptions = { gracePeriodDays: userSettings.grace_period_days, dueSoonDays: userSettings.due_soon_days };
|
||||
const bills = fetchActiveBills(db, userId, 'id');
|
||||
|
||||
const cutoff = new Date(now);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const USER_SETTING_KEYS = [
|
|||
'date_format',
|
||||
'week_start',
|
||||
'grace_period_days',
|
||||
'due_soon_days',
|
||||
'notify_days_before',
|
||||
'drift_threshold_pct',
|
||||
'bank_tracking_enabled',
|
||||
|
|
@ -31,6 +32,7 @@ const USER_SETTING_KEYS = [
|
|||
|
||||
const USER_SETTING_DEFAULTS = {
|
||||
week_start: '0',
|
||||
due_soon_days: '3',
|
||||
search_bars_collapsed: 'false',
|
||||
bank_auto_categorize_merchants: 'true',
|
||||
tracker_show_bank_projection_banner: 'true',
|
||||
|
|
|
|||
Loading…
Reference in New Issue