Add 7-day sync overlap buffer and improve empty investments state
SimpleFIN sync now looks back 7 days from last sync to catch retroactively posted transactions. Investments page shows account balance instead of $0 summary cards when no holdings exist. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -29,90 +29,103 @@
|
|||||||
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
|
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
|
||||||
|
|
||||||
<template v-if="performance && !loading">
|
<template v-if="performance && !loading">
|
||||||
<!-- Portfolio Summary -->
|
<!-- No holdings: show account balance + prompt -->
|
||||||
<v-row class="mb-4">
|
<template v-if="performance.holdings.length === 0">
|
||||||
<v-col cols="12" sm="6" md="3">
|
<v-card class="text-center pa-8">
|
||||||
<v-card variant="tonal">
|
<div class="text-h4 mb-2" :class="selectedAccountBalance >= 0 ? 'text-success' : 'text-error'">
|
||||||
<v-card-text class="text-center">
|
{{ formatCurrency(selectedAccountBalance) }}
|
||||||
<div class="text-caption">Market Value</div>
|
</div>
|
||||||
<div class="text-h6">{{ formatCurrency(performance.totalMarketValue) }}</div>
|
<div class="text-caption text-medium-emphasis mb-6">Account Balance</div>
|
||||||
</v-card-text>
|
<v-icon size="48" color="grey-lighten-1" class="mb-4">mdi-chart-pie</v-icon>
|
||||||
</v-card>
|
<p class="text-medium-emphasis">No individual holdings tracked for this account.</p>
|
||||||
</v-col>
|
<p class="text-medium-emphasis text-caption">Add holdings to see allocation, cost basis, and performance.</p>
|
||||||
<v-col cols="12" sm="6" md="3">
|
</v-card>
|
||||||
<v-card variant="tonal">
|
</template>
|
||||||
<v-card-text class="text-center">
|
|
||||||
<div class="text-caption">Cost Basis</div>
|
|
||||||
<div class="text-h6">{{ formatCurrency(performance.totalCostBasis) }}</div>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" sm="6" md="3">
|
|
||||||
<v-card variant="tonal" :color="performance.totalGainLoss >= 0 ? 'success' : 'error'">
|
|
||||||
<v-card-text class="text-center">
|
|
||||||
<div class="text-caption">Total Gain/Loss</div>
|
|
||||||
<div class="text-h6">{{ formatCurrency(performance.totalGainLoss) }}</div>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" sm="6" md="3">
|
|
||||||
<v-card variant="tonal" :color="performance.totalGainLossPercent >= 0 ? 'success' : 'error'">
|
|
||||||
<v-card-text class="text-center">
|
|
||||||
<div class="text-caption">Return</div>
|
|
||||||
<div class="text-h6">{{ formatPercent(performance.totalGainLossPercent) }}</div>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<!-- Allocation Chart + Holdings Table -->
|
<!-- Has holdings: show full portfolio view -->
|
||||||
<v-row>
|
<template v-else>
|
||||||
<v-col v-if="performance.holdings.length > 0" cols="12" md="4">
|
<!-- Portfolio Summary -->
|
||||||
<v-card>
|
<v-row class="mb-4">
|
||||||
<v-card-title>Allocation</v-card-title>
|
<v-col cols="12" sm="6" md="3">
|
||||||
<v-card-text>
|
<v-card variant="tonal">
|
||||||
<apexchart
|
<v-card-text class="text-center">
|
||||||
type="donut"
|
<div class="text-caption">Market Value</div>
|
||||||
height="300"
|
<div class="text-h6">{{ formatCurrency(performance.totalMarketValue) }}</div>
|
||||||
:options="allocationChartOptions"
|
</v-card-text>
|
||||||
:series="allocationChartSeries"
|
</v-card>
|
||||||
/>
|
</v-col>
|
||||||
</v-card-text>
|
<v-col cols="12" sm="6" md="3">
|
||||||
</v-card>
|
<v-card variant="tonal">
|
||||||
</v-col>
|
<v-card-text class="text-center">
|
||||||
<v-col cols="12" :md="performance.holdings.length > 0 ? 8 : 12">
|
<div class="text-caption">Cost Basis</div>
|
||||||
<v-card>
|
<div class="text-h6">{{ formatCurrency(performance.totalCostBasis) }}</div>
|
||||||
<v-card-title>Holdings</v-card-title>
|
</v-card-text>
|
||||||
<v-card-text v-if="performance.holdings.length > 0">
|
</v-card>
|
||||||
<v-data-table
|
</v-col>
|
||||||
:headers="holdingsHeaders"
|
<v-col cols="12" sm="6" md="3">
|
||||||
:items="performance.holdings"
|
<v-card variant="tonal" :color="performance.totalGainLoss >= 0 ? 'success' : 'error'">
|
||||||
density="compact"
|
<v-card-text class="text-center">
|
||||||
:items-per-page="-1"
|
<div class="text-caption">Total Gain/Loss</div>
|
||||||
hide-default-footer
|
<div class="text-h6">{{ formatCurrency(performance.totalGainLoss) }}</div>
|
||||||
>
|
</v-card-text>
|
||||||
<template #item.shares="{ item }">{{ item.shares.toFixed(4) }}</template>
|
</v-card>
|
||||||
<template #item.costBasis="{ item }">{{ formatCurrency(item.costBasis) }}</template>
|
</v-col>
|
||||||
<template #item.currentPrice="{ item }">{{ formatCurrency(item.currentPrice) }}</template>
|
<v-col cols="12" sm="6" md="3">
|
||||||
<template #item.marketValue="{ item }">{{ formatCurrency(item.marketValue) }}</template>
|
<v-card variant="tonal" :color="performance.totalGainLossPercent >= 0 ? 'success' : 'error'">
|
||||||
<template #item.gainLoss="{ item }">
|
<v-card-text class="text-center">
|
||||||
<span :class="item.gainLoss >= 0 ? 'text-success' : 'text-error'">
|
<div class="text-caption">Return</div>
|
||||||
{{ formatCurrency(item.gainLoss) }}
|
<div class="text-h6">{{ formatPercent(performance.totalGainLossPercent) }}</div>
|
||||||
</span>
|
</v-card-text>
|
||||||
</template>
|
</v-card>
|
||||||
<template #item.gainLossPercent="{ item }">
|
</v-col>
|
||||||
<span :class="item.gainLossPercent >= 0 ? 'text-success' : 'text-error'">
|
</v-row>
|
||||||
{{ formatPercent(item.gainLossPercent) }}
|
|
||||||
</span>
|
<!-- Allocation Chart + Holdings Table -->
|
||||||
</template>
|
<v-row>
|
||||||
</v-data-table>
|
<v-col cols="12" md="4">
|
||||||
</v-card-text>
|
<v-card>
|
||||||
<v-card-text v-else class="text-center pa-8 text-medium-emphasis">
|
<v-card-title>Allocation</v-card-title>
|
||||||
No holdings found for this account. Add investment holdings to track your portfolio.
|
<v-card-text>
|
||||||
</v-card-text>
|
<apexchart
|
||||||
</v-card>
|
type="donut"
|
||||||
</v-col>
|
height="300"
|
||||||
</v-row>
|
:options="allocationChartOptions"
|
||||||
|
:series="allocationChartSeries"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="8">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Holdings</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-data-table
|
||||||
|
:headers="holdingsHeaders"
|
||||||
|
:items="performance.holdings"
|
||||||
|
density="compact"
|
||||||
|
:items-per-page="-1"
|
||||||
|
hide-default-footer
|
||||||
|
>
|
||||||
|
<template #item.shares="{ item }">{{ item.shares.toFixed(4) }}</template>
|
||||||
|
<template #item.costBasis="{ item }">{{ formatCurrency(item.costBasis) }}</template>
|
||||||
|
<template #item.currentPrice="{ item }">{{ formatCurrency(item.currentPrice) }}</template>
|
||||||
|
<template #item.marketValue="{ item }">{{ formatCurrency(item.marketValue) }}</template>
|
||||||
|
<template #item.gainLoss="{ item }">
|
||||||
|
<span :class="item.gainLoss >= 0 ? 'text-success' : 'text-error'">
|
||||||
|
{{ formatCurrency(item.gainLoss) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #item.gainLossPercent="{ item }">
|
||||||
|
<span :class="item.gainLossPercent >= 0 ? 'text-success' : 'text-error'">
|
||||||
|
{{ formatPercent(item.gainLossPercent) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-card v-if="!loading && !performance && selectedAccountId" class="text-center pa-8">
|
<v-card v-if="!loading && !performance && selectedAccountId" class="text-center pa-8">
|
||||||
@@ -158,6 +171,11 @@ const investmentAccounts = computed(() =>
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const selectedAccountBalance = computed(() => {
|
||||||
|
const acct = investmentAccounts.value.find(a => a.id === selectedAccountId.value)
|
||||||
|
return acct?.balance ?? 0
|
||||||
|
})
|
||||||
|
|
||||||
// --- Allocation Chart ---
|
// --- Allocation Chart ---
|
||||||
const allocationChartSeries = computed(() =>
|
const allocationChartSeries = computed(() =>
|
||||||
performance.value?.holdings.map(h => h.marketValue) ?? []
|
performance.value?.holdings.map(h => h.marketValue) ?? []
|
||||||
|
|||||||
@@ -424,12 +424,15 @@ public class BankSyncService : IBankSyncService
|
|||||||
|
|
||||||
// For SimpleFIN, use the earliest start date across all accounts so the
|
// For SimpleFIN, use the earliest start date across all accounts so the
|
||||||
// single API call (cached) returns transactions covering every account.
|
// single API call (cached) returns transactions covering every account.
|
||||||
|
// Subtract a 7-day overlap buffer because banks may post transactions with
|
||||||
|
// dates earlier than when they appear in the SimpleFIN feed. Duplicate
|
||||||
|
// detection (FitId + fingerprint) ensures re-fetched transactions are skipped.
|
||||||
var endDate = DateTime.UtcNow;
|
var endDate = DateTime.UtcNow;
|
||||||
DateTime? simpleFinStartDate = null;
|
DateTime? simpleFinStartDate = null;
|
||||||
if (connection.Provider == SyncProvider.SimpleFIN)
|
if (connection.Provider == SyncProvider.SimpleFIN)
|
||||||
{
|
{
|
||||||
simpleFinStartDate = enabledAccounts
|
simpleFinStartDate = enabledAccounts
|
||||||
.Select(la => la.LastSyncAt ?? endDate.AddDays(-90))
|
.Select(la => la.LastSyncAt?.AddDays(-7) ?? endDate.AddDays(-90))
|
||||||
.Min();
|
.Min();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,7 +450,7 @@ public class BankSyncService : IBankSyncService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var startDate = simpleFinStartDate ?? linkedAccount.LastSyncAt ?? endDate.AddDays(-90);
|
var startDate = simpleFinStartDate ?? linkedAccount.LastSyncAt?.AddDays(-7) ?? endDate.AddDays(-90);
|
||||||
|
|
||||||
var provider = connection.Provider == SyncProvider.Plaid
|
var provider = connection.Provider == SyncProvider.Plaid
|
||||||
? (Purrse.Plugins.Abstractions.IAccountSyncProvider)_plaid
|
? (Purrse.Plugins.Abstractions.IAccountSyncProvider)_plaid
|
||||||
|
|||||||
Reference in New Issue
Block a user