feat: recommendation detail dialog with evidence, ambiguity badges, transaction list

This commit is contained in:
null 2026-06-06 21:05:01 -05:00
parent b2f8f5ef66
commit 422d8550bb
3 changed files with 333 additions and 15 deletions

View File

@ -10,6 +10,8 @@ import {
ArrowDown,
ArrowUp,
GripVertical,
AlertTriangle,
Info,
Link2,
Loader2,
Pause,
@ -283,11 +285,12 @@ function TxResultRow({ tx, onTrack }) {
);
}
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, busy }) {
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, onDetails, busy }) {
const identity = recommendation.evidence?.identity;
const amount = recommendation.evidence?.amount;
const cadence = recommendation.evidence?.cadence;
const amountRange = recommendation.evidence?.amount_range;
const ambiguity = recommendation.evidence?.ambiguity;
return (
<div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4">
@ -347,6 +350,12 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
Recurring
</Badge>
)}
{ambiguity?.ambiguous && (
<Badge variant="outline" className="gap-1 border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-300">
<AlertTriangle className="h-3 w-3" />
Review
</Badge>
)}
{amountRange && amountRange.min !== amountRange.max && (
<span className="rounded-md border border-border/60 bg-background/60 px-2 py-1 text-[11px] font-medium text-muted-foreground">
Range {fmt(amountRange.min)}-{fmt(amountRange.max)}
@ -359,7 +368,18 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
))}
</div>
<div className="grid grid-cols-1 gap-2 min-[380px]:grid-cols-3">
<div className="grid grid-cols-2 gap-2 min-[520px]:grid-cols-4">
<Button
type="button"
size="sm"
variant="outline"
className="gap-1.5"
disabled={busy}
onClick={() => onDetails(recommendation)}
>
<Info className="h-3.5 w-3.5" />
Details
</Button>
<Button
type="button"
size="sm"
@ -398,6 +418,160 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
);
}
function EvidenceItem({ label, value, tone = 'default' }) {
if (!value) return null;
return (
<div className={cn(
'rounded-lg border px-3 py-2',
tone === 'warn'
? 'border-amber-500/25 bg-amber-500/10'
: 'border-border/60 bg-muted/20',
)}>
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-1 text-sm font-medium text-foreground">{value}</p>
</div>
);
}
function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose, onAccept, onDecline, onMatch, busy }) {
if (!recommendation) return null;
const identity = recommendation.evidence?.identity;
const amount = recommendation.evidence?.amount;
const cadence = recommendation.evidence?.cadence;
const amountRange = recommendation.evidence?.amount_range;
const ambiguity = recommendation.evidence?.ambiguity;
const transactions = recommendation.transactions || [];
const handleAccept = async () => {
await onAccept({ ...recommendation, category_id: categoryId });
onClose();
};
const handleDecline = async () => {
await onDecline(recommendation);
onClose();
};
const handleMatch = () => {
onMatch(recommendation);
onClose();
};
return (
<Dialog open={open} onOpenChange={value => { if (!value) onClose(); }}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex flex-wrap items-center gap-2">
<span>{recommendation.name}</span>
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary">
{recommendation.confidence}% match
</Badge>
{ambiguity?.ambiguous && (
<Badge variant="outline" className="gap-1 border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-300">
<AlertTriangle className="h-3 w-3" />
Review
</Badge>
)}
</DialogTitle>
<p className="text-sm text-muted-foreground">
{TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charge{recommendation.occurrence_count !== 1 ? 's' : ''} · last seen {fmtDate(recommendation.last_seen_date)}
</p>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
<EvidenceItem label="Expected" value={`${fmt(recommendation.expected_amount)} ${recommendation.cycle_type || 'monthly'}`} />
<EvidenceItem label="Monthly" value={`${fmt(recommendation.monthly_equivalent)} / mo`} />
<EvidenceItem label="Identity" value={identity?.label} />
<EvidenceItem label="Price" value={amount?.label} tone={amount?.match === 'unusual' ? 'warn' : 'default'} />
<EvidenceItem label="Cadence" value={cadence?.label} />
<EvidenceItem
label="Catalog"
value={recommendation.catalog_match
? [
recommendation.catalog_match.name,
recommendation.catalog_match.starting_monthly_usd ? `from ${fmt(recommendation.catalog_match.starting_monthly_usd)} / mo` : null,
].filter(Boolean).join(' · ')
: null}
/>
</div>
{amountRange && (
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Amount Range</p>
<p className="mt-1 text-sm font-medium text-foreground">
{fmt(amountRange.min)}-{fmt(amountRange.max)} across matching transactions
</p>
</div>
)}
{ambiguity?.ambiguous && (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2">
<p className="flex items-center gap-1.5 text-sm font-semibold text-amber-700 dark:text-amber-300">
<AlertTriangle className="h-4 w-4" />
{ambiguity.label || 'Review before tracking'}
</p>
{ambiguity.reasons?.length > 0 && (
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
{ambiguity.reasons.map(reason => (
<p key={reason}>{reason}</p>
))}
</div>
)}
</div>
)}
{recommendation.reasons?.length > 0 && (
<div className="flex flex-wrap gap-2">
{recommendation.reasons.map(reason => (
<span key={reason} className="rounded-md border border-border/60 bg-background/60 px-2 py-1 text-[11px] font-medium text-muted-foreground">
{reason}
</span>
))}
</div>
)}
{transactions.length > 0 && (
<div className="overflow-hidden rounded-lg border border-border/60">
<div className="border-b border-border/50 bg-muted/20 px-3 py-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Bank Transactions
</p>
</div>
<div className="divide-y divide-border/40">
{transactions.map(tx => (
<div key={tx.id} className="grid gap-1 px-3 py-2.5 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{tx.payee || tx.description || tx.memo || 'Transaction'}
</p>
<p className="truncate text-xs text-muted-foreground">
{fmtDate(tx.date)}{tx.account ? ` · ${tx.account}` : ''}
</p>
</div>
<p className="tracker-number text-sm font-semibold text-foreground">{fmt(tx.amount)}</p>
</div>
))}
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="ghost" onClick={handleDecline} disabled={busy} className="gap-1.5 text-muted-foreground hover:text-destructive">
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
Decline
</Button>
<Button variant="outline" onClick={handleMatch} disabled={busy} className="gap-1.5">
<Link2 className="h-3.5 w-3.5" />
Link to bill
</Button>
<Button onClick={handleAccept} disabled={busy} className="gap-2">
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
Track
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function SubscriptionsPage() {
const [data, setData] = useState({ summary: {}, subscriptions: [] });
const [recommendations, setRecommendations] = useState([]);
@ -408,6 +582,7 @@ export default function SubscriptionsPage() {
const [busyId, setBusyId] = useState(null);
const [modal, setModal] = useState(null);
const [matchTarget, setMatchTarget] = useState(null);
const [detailsTarget, setDetailsTarget] = useState(null);
const [recSearch, setRecSearch] = useState('');
const [draggingId, setDraggingId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
@ -817,6 +992,7 @@ export default function SubscriptionsPage() {
onAccept={acceptRecommendation}
onDecline={declineRecommendation}
onMatch={rec => setMatchTarget(rec)}
onDetails={setDetailsTarget}
/>
))
)}
@ -914,6 +1090,21 @@ export default function SubscriptionsPage() {
/>
)}
<RecommendationDetailsDialog
open={!!detailsTarget}
recommendation={detailsTarget}
categoryId={subscriptionCategoryId}
onClose={() => setDetailsTarget(null)}
onAccept={acceptRecommendation}
onDecline={declineRecommendation}
onMatch={rec => setMatchTarget(rec)}
busy={detailsTarget ? (
busyId === `rec-${detailsTarget.id}`
|| busyId === `dec-${detailsTarget.id}`
|| busyId === `match-${detailsTarget.id}`
) : false}
/>
<BillPickerDialog
open={!!matchTarget}
onClose={() => setMatchTarget(null)}

View File

@ -38,6 +38,18 @@ const TYPE_KEYWORDS = [
['security', ['nordvpn', 'expressvpn', '1password', 'dashlane', 'norton', 'mcafee', 'surfshark']],
];
const AMBIGUOUS_CATALOG_TERMS = new Map([
['amazon', 'Amazon charges may be retail orders, Prime, media, Kindle, or subscriptions.'],
['amzn', 'Amazon charges may be retail orders, Prime, media, Kindle, or subscriptions.'],
['apple', 'Apple charges may be app purchases, iCloud, Apple One, media, or hardware.'],
['google', 'Google charges may be storage, YouTube, app purchases, ads, or domains.'],
['microsoft', 'Microsoft charges may be software, Xbox, cloud, or marketplace purchases.'],
['walmart', 'Walmart charges may be retail orders, delivery, Walmart+, or pharmacy purchases.'],
['target', 'Target charges may be retail orders, Circle, delivery, or pharmacy purchases.'],
['disney', 'Disney charges may be streaming, parks, retail, or bundle charges.'],
['max', 'Max is a short descriptor and can be confused with unrelated merchant text.'],
]);
// ── Catalog ───────────────────────────────────────────────────────────────────
function loadCatalog(db, userId) {
@ -149,6 +161,9 @@ function lookupCatalogMatch(catalog, merchantText) {
for (const desc of (entry.bankDescs || [])) {
const value = desc.value || desc;
if (value.length >= 4 && (normalized.includes(value) || value.includes(normalized))) {
const containment = normalized.includes(value)
? 'transaction_contains_descriptor'
: 'descriptor_contains_transaction';
const score = (desc.source === 'user_bank' ? 2200 : 2000) + value.length;
if (score > bestScore) {
best = entry;
@ -157,6 +172,7 @@ function lookupCatalogMatch(catalog, merchantText) {
type: desc.source === 'user_bank' ? 'user_descriptor' : 'bank_descriptor',
label: desc.source === 'user_bank' ? 'custom bank descriptor' : 'known bank descriptor',
descriptor: value,
containment,
};
}
}
@ -267,7 +283,12 @@ function identityEvidence(match) {
slang: { score: 66, label: 'Matched a known alternate name' },
unknown: { score: 60, label: 'Matched the service catalog' },
};
return { type, descriptor: match?.descriptor || null, ...(table[type] || table.unknown) };
return {
type,
descriptor: match?.descriptor || null,
containment: match?.containment || null,
...(table[type] || table.unknown),
};
}
function priceClose(amount, expected) {
@ -353,12 +374,61 @@ function cadenceEvidence(sorted, cycleType, avgGap, maxDelta, averageAmount) {
};
}
function scoreKnownServiceRecommendation({ match, amountInfo, cadenceInfo }) {
function catalogTextForAmbiguity(merchant, catalogEntry, identityInfo) {
return normalizeMerchant([
merchant,
catalogEntry?.name,
catalogEntry?.domain,
catalogEntry?.website,
identityInfo?.descriptor,
].filter(Boolean).join(' '));
}
function ambiguityEvidence({ merchant, catalogEntry, identityInfo, amountInfo, cadenceInfo }) {
const identityType = identityInfo?.type || 'unknown';
const compactMerchant = normalizeMerchant(merchant).replace(/\s+/g, '');
const descriptorContainsShortTransaction = identityInfo?.containment === 'descriptor_contains_transaction'
&& compactMerchant.length > 0
&& compactMerchant.length <= 4;
const weakIdentity = !['user_descriptor', 'bank_descriptor'].includes(identityType)
|| descriptorContainsShortTransaction;
if (!weakIdentity) {
return { ambiguous: false, penalty: 0, label: null, reasons: [] };
}
const haystack = catalogTextForAmbiguity(merchant, catalogEntry, identityInfo);
const tokens = new Set(haystack.split(/\s+/).filter(Boolean));
const hit = [...AMBIGUOUS_CATALOG_TERMS.entries()].find(([term]) => tokens.has(term));
const compactDescriptor = String(identityInfo?.descriptor || '').replace(/\s+/g, '');
const shortDescriptor = compactDescriptor.length > 0 && compactDescriptor.length <= 4;
if (!hit && !shortDescriptor) {
return { ambiguous: false, penalty: 0, label: null, reasons: [] };
}
const recurringStrong = !!cadenceInfo?.recurring && amountInfo?.score >= 8;
const penalty = recurringStrong ? 6 : 16;
const reasons = [];
if (hit) reasons.push(hit[1]);
if (shortDescriptor) reasons.push('The matched service text is very short, so false positives are more likely.');
if (!cadenceInfo?.recurring) reasons.push('Only one bank transaction supports this recommendation.');
if (amountInfo?.match === 'unusual') reasons.push('The amount is outside the catalog price range.');
return {
ambiguous: true,
penalty,
label: 'Broad match - review before tracking',
reasons: Array.from(new Set(reasons)),
};
}
function scoreKnownServiceRecommendation({ match, merchant, catalogEntry, amountInfo, cadenceInfo }) {
const identity = identityEvidence(match);
const ambiguity = ambiguityEvidence({ merchant, catalogEntry, identityInfo: identity, amountInfo, cadenceInfo });
const confidence = Math.min(99, Math.max(0,
identity.score + amountInfo.score + cadenceInfo.score
identity.score + amountInfo.score + cadenceInfo.score - ambiguity.penalty
));
return { confidence, identity };
return { confidence, identity, ambiguity };
}
function monthlyEquivalent(amount, cycleType, billingCycle) {
@ -451,6 +521,13 @@ function dollarsFromTransactionAmount(amount) {
return Math.round((Math.abs(Number(amount || 0)) / 100) * 100) / 100;
}
function recommendationAccountLabel(item) {
const accountName = item.account_name || '';
const orgName = item.account_org_name || '';
if (accountName && orgName && accountName !== orgName) return `${orgName} · ${accountName}`;
return accountName || orgName || item.data_source_name || '';
}
// ── Decline store ─────────────────────────────────────────────────────────────
function getDeclinedKeys(db, userId) {
@ -553,13 +630,14 @@ function getSubscriptionRecommendations(db, userId) {
}
const cadenceInfo = cadenceEvidence(sorted, cycleType, 30, maxDelta, averageAmount);
const scored = scoreKnownServiceRecommendation({
match: catalogIdentityMatch, amountInfo, cadenceInfo, sorted,
match: catalogIdentityMatch, merchant, catalogEntry, amountInfo, cadenceInfo,
});
if (scored.confidence < 90) continue;
recommendations.push(buildRecommendation({
merchant, catalogEntry, sorted, averageAmount, maxDelta, last,
cycleType, avgGap: 30, confidence: scored.confidence, tier: 'known_service',
declineKey, catalogTypeMap, identityInfo: scored.identity, amountInfo, cadenceInfo,
ambiguityInfo: scored.ambiguity,
}));
continue;
}
@ -587,7 +665,7 @@ function getSubscriptionRecommendations(db, userId) {
const amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry);
const cadenceInfo = cadenceEvidence(sorted, cycleType, avgGap, maxDelta, averageAmount);
const scored = scoreKnownServiceRecommendation({
match: catalogIdentityMatch, amountInfo, cadenceInfo, sorted,
match: catalogIdentityMatch, merchant, catalogEntry, amountInfo, cadenceInfo,
});
if (scored.confidence < 90) continue;
@ -595,6 +673,7 @@ function getSubscriptionRecommendations(db, userId) {
merchant, catalogEntry, sorted, averageAmount, maxDelta, last,
cycleType, avgGap, confidence: scored.confidence, tier: 'confirmed',
declineKey, catalogTypeMap, identityInfo: scored.identity, amountInfo, cadenceInfo,
ambiguityInfo: scored.ambiguity,
}));
}
@ -612,16 +691,11 @@ function getSubscriptionRecommendations(db, userId) {
return deduped.slice(0, 20);
}
function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap, identityInfo = null, amountInfo = null, cadenceInfo = null }) {
function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap, identityInfo = null, amountInfo = null, cadenceInfo = null, ambiguityInfo = null }) {
const name = catalogEntry ? catalogEntry.name : titleCase(merchant);
const subscriptionType = inferType(merchant, catalogEntry, catalogTypeMap);
const accounts = Array.from(new Set(sorted
.map(item => {
const accountName = item.account_name || '';
const orgName = item.account_org_name || '';
if (accountName && orgName && accountName !== orgName) return `${orgName} · ${accountName}`;
return accountName || orgName || item.data_source_name || '';
})
.map(recommendationAccountLabel)
.filter(Boolean)));
const reasons = [];
@ -629,6 +703,7 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
if (identityInfo?.label) reasons.push(identityInfo.label);
if (amountInfo?.label) reasons.push(amountInfo.label);
if (cadenceInfo?.recurring && cadenceInfo.label) reasons.push(cadenceInfo.label);
if (ambiguityInfo?.ambiguous && ambiguityInfo.label) reasons.push(ambiguityInfo.label);
reasons.push(`${last.currency || 'USD'} ${averageAmount.toFixed(2)} average`);
return {
@ -663,8 +738,24 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
max: Math.max(...sorted.map(item => item.amount_dollars)),
max_delta: Math.round(maxDelta * 100) / 100,
} : null,
ambiguity: ambiguityInfo ? {
ambiguous: !!ambiguityInfo.ambiguous,
label: ambiguityInfo.label,
reasons: ambiguityInfo.reasons || [],
penalty: ambiguityInfo.penalty || 0,
} : { ambiguous: false, label: null, reasons: [], penalty: 0 },
},
transaction_ids: sorted.map(item => item.id),
transactions: sorted.map(item => ({
id: item.id,
date: item.tx_date,
amount: item.amount_dollars,
currency: item.currency || 'USD',
payee: item.payee || null,
description: item.description || null,
memo: item.memo || null,
account: recommendationAccountLabel(item) || null,
})),
merchant,
decline_key: declineKey,
source: last.data_source_name || 'Transaction history',

View File

@ -88,6 +88,42 @@ test('weak one-off known service names stay below the recommendation threshold',
assert.equal(recommendations.some(item => item.catalog_match?.name === 'Max'), false);
});
test('ambiguous recurring known service names are flagged for review', () => {
const db = getDb();
const userId = createUser(db, 'ambiguous-known');
const accountId = createAccount(db, userId, true);
createTransaction(db, userId, {
account_id: accountId,
description: 'MAX',
payee: 'MAX',
amount: -1099,
posted_date: '2026-01-08',
});
createTransaction(db, userId, {
account_id: accountId,
description: 'MAX',
payee: 'MAX',
amount: -1099,
posted_date: '2026-02-08',
});
createTransaction(db, userId, {
account_id: accountId,
description: 'MAX',
payee: 'MAX',
amount: -1099,
posted_date: '2026-03-08',
});
const recommendations = getSubscriptionRecommendations(db, userId);
const max = recommendations.find(item => item.catalog_match?.name === 'Max');
assert.ok(max, 'Recurring Max charges should still be recommended');
assert.equal(max.confidence >= 90, true);
assert.equal(max.evidence.ambiguity.ambiguous, true);
assert.equal(max.evidence.ambiguity.penalty > 0, true);
assert.equal(max.transactions.length, 3);
});
test('unknown recurring patterns do not appear as known-service recommendations', () => {
const db = getDb();
const userId = createUser(db, 'unknown-pattern');