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;
|
date_format: string;
|
||||||
week_start: string;
|
week_start: string;
|
||||||
grace_period_days: number | string;
|
grace_period_days: number | string;
|
||||||
|
due_soon_days: number | string;
|
||||||
drift_threshold_pct: string;
|
drift_threshold_pct: string;
|
||||||
tracker_show_bank_projection_banner: string;
|
tracker_show_bank_projection_banner: string;
|
||||||
tracker_bank_projection_banner_snoozed_until: string;
|
tracker_bank_projection_banner_snoozed_until: string;
|
||||||
|
|
@ -335,6 +336,7 @@ export default function SettingsPage() {
|
||||||
date_format: 'MM/DD/YYYY',
|
date_format: 'MM/DD/YYYY',
|
||||||
week_start: '0',
|
week_start: '0',
|
||||||
grace_period_days: 5,
|
grace_period_days: 5,
|
||||||
|
due_soon_days: 3,
|
||||||
drift_threshold_pct: '5',
|
drift_threshold_pct: '5',
|
||||||
tracker_show_bank_projection_banner: 'true',
|
tracker_show_bank_projection_banner: 'true',
|
||||||
tracker_bank_projection_banner_snoozed_until: '',
|
tracker_bank_projection_banner_snoozed_until: '',
|
||||||
|
|
@ -367,6 +369,7 @@ export default function SettingsPage() {
|
||||||
date_format: s.date_format,
|
date_format: s.date_format,
|
||||||
week_start: s.week_start,
|
week_start: s.week_start,
|
||||||
grace_period_days: s.grace_period_days,
|
grace_period_days: s.grace_period_days,
|
||||||
|
due_soon_days: s.due_soon_days,
|
||||||
drift_threshold_pct: s.drift_threshold_pct,
|
drift_threshold_pct: s.drift_threshold_pct,
|
||||||
tracker_show_bank_projection_banner: s.tracker_show_bank_projection_banner,
|
tracker_show_bank_projection_banner: s.tracker_show_bank_projection_banner,
|
||||||
tracker_bank_projection_banner_snoozed_until: s.tracker_bank_projection_banner_snoozed_until || '',
|
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">
|
<SectionCard title="Billing Behavior">
|
||||||
<LinkImportToggle />
|
<LinkImportToggle />
|
||||||
<SettingRow
|
<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."
|
description="Days after the due date before a bill is marked overdue."
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -577,7 +580,23 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<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."
|
description="Flag a bill when recent payments differ from the expected amount by at least this percentage."
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ router.get('/', (req, res) => {
|
||||||
|
|
||||||
const today = localDateString(now);
|
const today = localDateString(now);
|
||||||
const userSettings = getUserSettings(req.user.id);
|
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 daysInMonth = new Date(year, month, 0).getDate();
|
||||||
const { start, end } = getCycleRange(year, month);
|
const { start, end } = getCycleRange(year, month);
|
||||||
const days = Array.from({ length: daysInMonth }, (_, index) => emptyDay(year, month, index + 1));
|
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;
|
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) {
|
function pad(value) {
|
||||||
return String(value).padStart(2, '0');
|
return String(value).padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
@ -201,6 +209,7 @@ function calculateStatus(bill, payments, dueDate, today, options = {}) {
|
||||||
if (!dueDate) return 'inactive_cycle';
|
if (!dueDate) return 'inactive_cycle';
|
||||||
|
|
||||||
const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays);
|
const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays);
|
||||||
|
const dueSoonDays = resolveDueSoonDays(options.dueSoonDays);
|
||||||
const safePayments = Array.isArray(payments) ? payments : [];
|
const safePayments = Array.isArray(payments) ? payments : [];
|
||||||
const expectedAmount = Number(bill.expected_amount) || 0;
|
const expectedAmount = Number(bill.expected_amount) || 0;
|
||||||
const totalPaid = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 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 todayDate = new Date(today + 'T00:00:00');
|
||||||
const diffDays = Math.floor((due - todayDate) / 86400000);
|
const diffDays = Math.floor((due - todayDate) / 86400000);
|
||||||
|
|
||||||
if (diffDays > 3) return 'upcoming';
|
if (diffDays > dueSoonDays) return 'upcoming';
|
||||||
if (diffDays >= 0) return 'due_soon';
|
if (diffDays >= 0) return 'due_soon';
|
||||||
if (Math.abs(diffDays) <= gracePeriodDays) return 'late';
|
if (Math.abs(diffDays) <= gracePeriodDays) return 'late';
|
||||||
return 'missed';
|
return 'missed';
|
||||||
|
|
@ -296,4 +305,5 @@ module.exports = {
|
||||||
resolveBucket,
|
resolveBucket,
|
||||||
resolveDueDate,
|
resolveDueDate,
|
||||||
resolveGracePeriodDays,
|
resolveGracePeriodDays,
|
||||||
|
resolveDueSoonDays,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -547,7 +547,7 @@ function getTracker(userId, query = {}, now = new Date()) {
|
||||||
const { year, month } = parsed;
|
const { year, month } = parsed;
|
||||||
const todayStr = localDateString(now);
|
const todayStr = localDateString(now);
|
||||||
const userSettings = getUserSettings(userId);
|
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 { start, end } = getCycleRange(year, month);
|
||||||
const previousMonth = previousMonthFor(year, month);
|
const previousMonth = previousMonthFor(year, month);
|
||||||
const prevMonthRange = getCycleRange(previousMonth.year, previousMonth.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 days = Math.max(1, Math.min(parseInt(query.days || '30', 10) || 30, 365));
|
||||||
const todayStr = localDateString(now);
|
const todayStr = localDateString(now);
|
||||||
const userSettings = getUserSettings(userId);
|
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 bills = fetchActiveBills(db, userId, 'id');
|
||||||
|
|
||||||
const cutoff = new Date(now);
|
const cutoff = new Date(now);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const USER_SETTING_KEYS = [
|
||||||
'date_format',
|
'date_format',
|
||||||
'week_start',
|
'week_start',
|
||||||
'grace_period_days',
|
'grace_period_days',
|
||||||
|
'due_soon_days',
|
||||||
'notify_days_before',
|
'notify_days_before',
|
||||||
'drift_threshold_pct',
|
'drift_threshold_pct',
|
||||||
'bank_tracking_enabled',
|
'bank_tracking_enabled',
|
||||||
|
|
@ -31,6 +32,7 @@ const USER_SETTING_KEYS = [
|
||||||
|
|
||||||
const USER_SETTING_DEFAULTS = {
|
const USER_SETTING_DEFAULTS = {
|
||||||
week_start: '0',
|
week_start: '0',
|
||||||
|
due_soon_days: '3',
|
||||||
search_bars_collapsed: 'false',
|
search_bars_collapsed: 'false',
|
||||||
bank_auto_categorize_merchants: 'true',
|
bank_auto_categorize_merchants: 'true',
|
||||||
tracker_show_bank_projection_banner: 'true',
|
tracker_show_bank_projection_banner: 'true',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue