Add global sync summary component for all providers (#588)

* Add shared sync statistics collection and provider sync summary UI

- Introduced `SyncStats::Collector` concern to centralize sync statistics logic, including account, transaction, holdings, and health stats collection.
- Added collapsible `ProviderSyncSummary` component for displaying sync summaries across providers.
- Updated syncers (e.g., `LunchflowItem::Syncer`) to use the shared collector methods for consistent stats calculation.
- Added rake tasks under `dev:sync_stats` for testing and development purposes, including fake stats generation with optional issues.
- Enhanced provider-specific views to include sync summaries using the new shared component.

* Refactor `ProviderSyncSummary` to improve maintainability

- Extracted `severity_color_class` to simplify severity-to-CSS mapping.
- Replaced `holdings_label` with `holdings_label_key` for streamlined localization.
- Updated locale file to separate `found` and `processed` translations for clarity.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
LPW
2026-01-09 13:26:37 -05:00
committed by GitHub
parent aaa336b091
commit 140ea78b0e
13 changed files with 998 additions and 130 deletions

View File

@@ -80,7 +80,20 @@
<div class="space-y-4 mt-4">
<% if coinstats_item.accounts.any? %>
<%= render "accounts/index/account_groups", accounts: coinstats_item.accounts %>
<% else %>
<% end %>
<%# Sync summary (collapsible) - using shared ProviderSyncSummary component %>
<% stats = if defined?(@coinstats_sync_stats_map) && @coinstats_sync_stats_map
@coinstats_sync_stats_map[coinstats_item.id] || {}
else
coinstats_item.syncs.ordered.first&.sync_stats || {}
end %>
<%= render ProviderSyncSummary.new(
stats: stats,
provider_item: coinstats_item
) %>
<% if coinstats_item.accounts.empty? %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".no_wallets_title") %></p>
<p class="text-secondary text-sm"><%= t(".no_wallets_message") %></p>

View File

@@ -85,6 +85,17 @@
<%= render "accounts/index/account_groups", accounts: enable_banking_item.accounts %>
<% end %>
<%# Sync summary (collapsible) - using shared ProviderSyncSummary component %>
<% stats = if defined?(@enable_banking_sync_stats_map) && @enable_banking_sync_stats_map
@enable_banking_sync_stats_map[enable_banking_item.id] || {}
else
enable_banking_item.syncs.ordered.first&.sync_stats || {}
end %>
<%= render ProviderSyncSummary.new(
stats: stats,
provider_item: enable_banking_item
) %>
<% if enable_banking_item.unlinked_accounts_count > 0 %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm">Setup needed</p>

View File

@@ -82,6 +82,18 @@
<%= render "accounts/index/account_groups", accounts: lunchflow_item.accounts %>
<% end %>
<%# Sync summary (collapsible) - using shared ProviderSyncSummary component %>
<% stats = if defined?(@lunchflow_sync_stats_map) && @lunchflow_sync_stats_map
@lunchflow_sync_stats_map[lunchflow_item.id] || {}
else
lunchflow_item.syncs.ordered.first&.sync_stats || {}
end %>
<%= render ProviderSyncSummary.new(
stats: stats,
provider_item: lunchflow_item,
institutions_count: lunchflow_item.connected_institutions.size
) %>
<%# Use model methods for consistent counts %>
<% unlinked_count = lunchflow_item.unlinked_accounts_count %>
<% linked_count = lunchflow_item.linked_accounts_count %>

View File

@@ -80,7 +80,20 @@
<div class="space-y-4 mt-4">
<% if plaid_item.accounts.any? %>
<%= render "accounts/index/account_groups", accounts: plaid_item.accounts %>
<% else %>
<% end %>
<%# Sync summary (collapsible) - using shared ProviderSyncSummary component %>
<% stats = if defined?(@plaid_sync_stats_map) && @plaid_sync_stats_map
@plaid_sync_stats_map[plaid_item.id] || {}
else
plaid_item.syncs.ordered.first&.sync_stats || {}
end %>
<%= render ProviderSyncSummary.new(
stats: stats,
provider_item: plaid_item
) %>
<% if plaid_item.accounts.empty? %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".no_accounts_title") %></p>
<p class="text-secondary text-sm"><%= t(".no_accounts_description") %></p>

View File

@@ -171,7 +171,7 @@
<%= render "accounts/index/account_groups", accounts: simplefin_item.accounts %>
<% end %>
<%# Sync summary (collapsible)
<%# Sync summary (collapsible) - using shared ProviderSyncSummary component
Prefer controller-provided map; fallback to latest sync stats so Turbo broadcasts
can render the summary without requiring a full page refresh. %>
<% stats = if defined?(@simplefin_sync_stats_map) && @simplefin_sync_stats_map
@@ -180,69 +180,11 @@
# `latest_sync` is private on Syncable; access via association for broadcast renders.
simplefin_item.syncs.ordered.first&.sync_stats || {}
end %>
<% if stats.present? %>
<details class="group bg-surface rounded-lg border border-surface-inset/50">
<summary class="flex items-center justify-between gap-2 p-3 cursor-pointer">
<div class="flex items-center gap-2">
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
<span class="text-sm text-primary font-medium">Sync summary</span>
</div>
<div class="flex items-center gap-2 text-xs text-secondary">
<% if simplefin_item.last_synced_at %>
<span>Last sync: <%= time_ago_in_words(simplefin_item.last_synced_at) %> ago</span>
<% end %>
</div>
</summary>
<div class="p-3 text-sm text-secondary grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<h4 class="text-primary font-medium mb-1">Accounts</h4>
<div class="flex items-center gap-3">
<span>Total: <%= stats["total_accounts"].to_i %></span>
<span>Linked: <%= stats["linked_accounts"].to_i %></span>
<span>Unlinked: <%= stats["unlinked_accounts"].to_i %></span>
<% institutions = simplefin_item.connected_institutions %>
<span>Institutions: <%= institutions.size %></span>
</div>
</div>
<div>
<h4 class="text-primary font-medium mb-1">Transactions</h4>
<div class="flex items-center gap-3">
<span>Seen: <%= stats["tx_seen"].to_i %></span>
<span>Imported: <%= stats["tx_imported"].to_i %></span>
<span>Updated: <%= stats["tx_updated"].to_i %></span>
<span>Skipped: <%= stats["tx_skipped"].to_i %></span>
</div>
</div>
<div>
<h4 class="text-primary font-medium mb-1">Holdings</h4>
<div class="flex items-center gap-3">
<span>Found: <%= stats["holdings_found"].to_i %></span>
</div>
</div>
<div>
<h4 class="text-primary font-medium mb-1">Health</h4>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-3">
<% if stats["rate_limited"].present? || stats["rate_limited_at"].present? %>
<% ts = stats["rate_limited_at"] %>
<% ago = (ts.present? ? (begin; time_ago_in_words(Time.parse(ts)); rescue StandardError; nil; end) : nil) %>
<span class="text-warning">Rate limited <%= ago ? "(#{ago} ago)" : "recently" %></span>
<% end %>
<% total_errors = stats["total_errors"].to_i %>
<% import_started = stats["import_started"].present? %>
<% if total_errors > 0 %>
<span class="text-destructive">Errors: <%= total_errors %></span>
<% elsif import_started %>
<span class="text-success">Errors: 0</span>
<% else %>
<span>Errors: 0</span>
<% end %>
</div>
</div>
</div>
</div>
</details>
<% end %>
<%= render ProviderSyncSummary.new(
stats: stats,
provider_item: simplefin_item,
institutions_count: simplefin_item.connected_institutions.size
) %>
<%# Compute unlinked SimpleFin accounts (no legacy account and no AccountProvider link)
# Prefer controller-provided map; fallback to a local query so the card stays accurate after Turbo broadcasts %>