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:
null 2026-07-05 16:00:32 -05:00
parent 0f24016bcf
commit 01030fe7d0
5 changed files with 37 additions and 6 deletions

View File

@ -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">

View File

@ -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));

View File

@ -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 031 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,
};

View File

@ -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);

View File

@ -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',