[Feature] Surface transfer link status: filter and row indicator for linked/unlinked transfers

Author: JosanshuoCreated Sep 10, 2026Updated Sep 17, 2026

Summary

Whether a transfer is linked to its counterpart is already computed for every row, but it is never displayed. The only way to find out is to open a row's ... menu and see whether it offers "Link transfer" or "Unlink transfer" -- one row at a time. There is no badge, no column, and no filter.

The value already exists

apps/frontend/src/features/spending/lib/transactions-helpers.ts:93

typescript
export function getTransferLinkStatus(activity: CashActivity): TransferLinkStatus | null {
  if (!isTransferCashActivity(activity)) {
    return null;
  }
  return activity.transferLinkStatus ?? (activity.sourceGroupId ? "linked" : "unlinked");
}

It is on the row type (features/spending/types/cash-activity.ts:83) and is called on every render in both views -- transaction-row.tsx:98 and transaction-card.tsx:86.

And then it is used for exactly one purpose. Every consumer is a menu-item conditional:

  • transaction-row.tsx:293 -- transferLinkStatus === "linked" ? <Unlink item> : <Link item>
  • transaction-card.tsx:288 / :297 -- same

The only Icons.Link / Icons.Unlink occurrences in either file are inside those DropdownMenuItems. Nothing renders in the row itself.

What is missing

No filter. The existing "Status" filter is categorization status, not link status:

typescript
// features/spending/types/cash-activity.ts:44
export type CashActivityStatusFilter = "all" | "needs_review" | "uncategorized" | "categorized";

The filter bar offers Status, Date, Account, Type, Amount, Category, Subcategory and Event. None of them is "linked / unlinked".

No indicator. A linked transfer and an unlinked one are visually identical in the list.

Health Center is not a substitute. invalid_transfer_group reports unmatched transfers at severity ERROR, but as a flat text blob ("Transfer on Oct 24, 2024 needs review: Transfer In - 230.95 CAD - Scotiabank"). You cannot sort it, filter it, act on it, or see those rows highlighted in the transaction list where the Link action actually lives. It also only surfaces rows the check considers invalid, not the general linked/unlinked state.

Why it matters

Link state is not cosmetic. In classify_activity, an unlinked TRANSFER_IN on a CASH account classifies as Income and an unlinked TRANSFER_OUT as Expense; only a shared source_group_id makes the pair neutral (InternalTransfer), or Saving when it crosses out of the spending set.

So an unlinked pair inflates reported income and reported spending by the full amount, while net worth stays correct. It is invisible in every total that would normally catch a mistake -- and it is also invisible in the transaction list.

Concretely: I have ~54 transfer pairs pending across five accounts. To audit which are already linked I have to open 54 dropdown menus. There is no way to answer "show me every unlinked transfer" or even "how many are there".

Suggested

In rough order of value per unit of work:

  1. A filter option -- "Transfer link: all / linked / unlinked" alongside the existing filter chips. This alone turns a 54-menu audit into one click.
  2. A row indicator -- a small chain / broken-chain icon next to transfer rows. The icons (Icons.Link, Icons.Unlink) and the value are both already there; it is a render, not a computation.
  3. A count in the toolbar -- the summary bar already shows "Filtered net ... 50/1660 transactions"; "12 unlinked transfers" would fit naturally.

Only (1) requires any new plumbing, and the predicate is a one-liner over a value the row already carries.

Related

  • #1698 -- expose link_transfer_activities as an agent tool (an agent can compute the pairings but not apply them; this issue is the same gap for a human -- the state is knowable but not visible)
  • #1608 -- Unscrollable eligible transfers on Link Transfer
  • #1696 -- Sync toast covers the menu that opens Link Transfer

Happy to test against a dataset with ~54 pending pairs.