# UI 重设计 P4：四个 Tab 页 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git add/commit**（用户要求，改动留工作区）。不创建额外任务清单。

**Goal:** 按 spec §7.1–7.4 重做四个 Tab 页：首页（问候 + 净资产白卡 + 待办条 + 最近交易）、交易页（搜索优先 + 日期分组时间线 + FilterSheet + 左滑操作）、报表页（单一 anchor 日期统一导航 + ⋯ 菜单 + 年报去重）、我的页（4 分组）。

**Architecture:** 纯逻辑（periodNav 周期导航 / transactionGroups 日期分组 / useSearch 金额区间）先 TDD 落地；共享组件（FilterSheet / 重刷 TransactionCard / SwipeableTransactionCard / TodoStrip）随后；四个页面逐页替换，每页替换后全量测试。

**Spec:** `docs/ui-redesign-design.md` §7。**与 spec 的偏差**：①「数据」组的备份恢复/导出入口并入现有「同步与备份」页（/settings/sync 已含备份 UI），不新建页面；②左滑用 gesture-handler 传统 `Swipeable`（不依赖 reanimated worklet 配置）；③报表周期 label 维持现有中文格式（P5 统一 i18n）。

**前置状态：** P1–P3 已完成（570 测试全绿）。BottomSheet/DatePickerField/CategoryIcon/StatCard/ScreenHeader 可用；gesture-handler 2.28 已安装；`_layout.tsx` **无** GestureHandlerRootView（T3 加）；`settings/` 现有 ai/preferences/sync 三个子页，Stack.Screen 需显式注册。

---

### Task 1: 纯逻辑失败测试

**Files:**
- Create: `tests/periodNav.test.ts`
- Create: `tests/transactionGroups.test.ts`
- Modify: 现有 search 测试文件（先 `ls tests/ | grep -i search` 找到它，追加金额区间用例）

- [ ] **Step 1: tests/periodNav.test.ts**

```typescript
import { describe, expect, it } from 'vitest';
import { shiftAnchor, periodRange, isoWeekNumber } from '../src/domain/periodNav';

describe('shiftAnchor 周期导航', () => {
  it('周：±7 天', () => {
    expect(shiftAnchor('2026-07-21', 'weekly', 1)).toBe('2026-07-28');
    expect(shiftAnchor('2026-07-21', 'weekly', -1)).toBe('2026-07-14');
  });

  it('月：月末回退钳制（1月31日 → 2月28日）', () => {
    expect(shiftAnchor('2026-01-31', 'monthly', 1)).toBe('2026-02-28');
    expect(shiftAnchor('2026-03-31', 'monthly', -1)).toBe('2026-02-28');
  });

  it('月：跨年', () => {
    expect(shiftAnchor('2026-12-15', 'monthly', 1)).toBe('2027-01-15');
    expect(shiftAnchor('2026-01-15', 'monthly', -1)).toBe('2025-12-15');
  });

  it('年：±1 年', () => {
    expect(shiftAnchor('2026-07-21', 'annual', 1)).toBe('2027-07-21');
    expect(shiftAnchor('2026-07-21', 'annual', -1)).toBe('2025-07-21');
  });

  it('多步 delta', () => {
    expect(shiftAnchor('2026-07-21', 'monthly', 3)).toBe('2026-10-21');
    expect(shiftAnchor('2026-07-21', 'weekly', -2)).toBe('2026-07-07');
  });
});

describe('periodRange 周期范围', () => {
  it('周：周一起周日止（周二 anchor）', () => {
    expect(periodRange('2026-07-21', 'weekly')).toEqual({ start: '2026-07-20', end: '2026-07-26' });
  });

  it('周：周日 anchor 属于本周（周一开头）', () => {
    expect(periodRange('2026-07-26', 'weekly')).toEqual({ start: '2026-07-20', end: '2026-07-26' });
  });

  it('月：整月', () => {
    expect(periodRange('2026-02-10', 'monthly')).toEqual({ start: '2026-02-01', end: '2026-02-28' });
  });

  it('年：整年', () => {
    expect(periodRange('2026-07-21', 'annual')).toEqual({ start: '2026-01-01', end: '2026-12-31' });
  });
});

describe('isoWeekNumber ISO 周数', () => {
  it('2026-01-01 是第 1 周', () => {
    expect(isoWeekNumber('2026-01-01')).toBe(1);
  });

  it('2026-07-20 是第 30 周', () => {
    expect(isoWeekNumber('2026-07-20')).toBe(30);
  });
});
```

- [ ] **Step 2: tests/transactionGroups.test.ts**

```typescript
import { describe, expect, it } from 'vitest';
import { groupTransactionsByDate } from '../src/components/transactionGroups';
import type { Transaction } from '../src/domain/types';

function tx(id: string, date: string, postings: { account: string; amount?: string }[]): Transaction {
  return {
    id, date, flag: '*', narration: id,
    postings: postings.map(p => ({ account: p.account, amount: p.amount, currency: 'CNY' })),
    tags: [], links: [], raw: '',
  } as Transaction;
}

describe('groupTransactionsByDate 日期分组', () => {
  it('同日合并并计算收支小计', () => {
    const groups = groupTransactionsByDate([
      tx('a', '2026-07-21', [{ account: 'Assets:招行', amount: '-35' }, { account: 'Expenses:餐饮', amount: '35' }]),
      tx('b', '2026-07-21', [{ account: 'Assets:招行', amount: '100' }, { account: 'Income:工资', amount: '-100' }]),
    ]);
    expect(groups).toHaveLength(1);
    expect(groups[0].date).toBe('2026-07-21');
    expect(groups[0].expense).toBe('35');
    expect(groups[0].income).toBe('100');
    expect(groups[0].items.map(t => t.id)).toEqual(['a', 'b']);
  });

  it('保持输入顺序（调用方已按日期倒序）', () => {
    const groups = groupTransactionsByDate([
      tx('a', '2026-07-21', [{ account: 'Expenses:餐饮', amount: '35' }]),
      tx('b', '2026-07-20', [{ account: 'Expenses:餐饮', amount: '20' }]),
    ]);
    expect(groups.map(g => g.date)).toEqual(['2026-07-21', '2026-07-20']);
  });

  it('转账日收支小计为零', () => {
    const groups = groupTransactionsByDate([
      tx('a', '2026-07-21', [{ account: 'Assets:A', amount: '-50' }, { account: 'Assets:B', amount: '50' }]),
    ]);
    expect(groups[0].income).toBe('0');
    expect(groups[0].expense).toBe('0');
  });

  it('空输入返回空数组', () => {
    expect(groupTransactionsByDate([])).toEqual([]);
  });
});
```

- [ ] **Step 3: search 测试追加金额区间用例**

找到现有 search 测试文件，在末尾追加（构造交易的辅助函数沿用该文件现有风格）：

```typescript
describe('金额区间筛选', () => {
  it('amountMin/amountMax 按主金额绝对值过滤', () => {
    // 三笔：支出 35、支出 200、收入 5000（收入主金额为负 → 绝对值 5000）
    // filters { amountMin: '30', amountMax: '300' } 应只剩支出 35 与支出 200
    // filters { amountMin: '1000' } 应只剩收入 5000
  });

  it('无金额 posting 的交易在设了下限时被排除', () => {
    // filters { amountMin: '1' } 排除 postings 全无 amount 的交易
  });
});
```

（实现者按现有测试文件的构造风格补全具体数据与断言。）

- [ ] **Step 4: 运行确认失败**

Run: `npx vitest run tests/periodNav.test.ts tests/transactionGroups.test.ts` → Expected: FAIL（模块不存在）。金额区间用例也应 FAIL。**若意外通过，报告 BLOCKED。**

---

### Task 2: periodNav + transactionGroups + useSearch 金额区间

**Files:**
- Create: `src/domain/periodNav.ts`
- Create: `src/components/transactionGroups.ts`
- Modify: `src/hooks/useSearch.ts`、`src/domain/index.ts`

- [ ] **Step 1: src/domain/periodNav.ts**

```typescript
/**
 * 报表周期导航（P4）：单一 anchor 日期 + 周期类型，替代周/月/年三套独立状态。
 * 全部本地时区字符串运算，避免 toISOString 的 UTC 偏移。
 */
import { toDateString } from './decimal';

export type ReportPeriod = 'weekly' | 'monthly' | 'annual';

function parse(dateStr: string): Date {
  const [y, m, d] = dateStr.split('-').map(Number);
  return new Date(y, m - 1, d);
}

/** 平移 anchor：周 ±7 天；月 ±N（月末日钳制）；年 ±N。 */
export function shiftAnchor(anchor: string, period: ReportPeriod, delta: number): string {
  const d = parse(anchor);
  if (period === 'weekly') {
    d.setDate(d.getDate() + 7 * delta);
  } else if (period === 'monthly') {
    const day = d.getDate();
    d.setDate(1);
    d.setMonth(d.getMonth() + delta);
    // 月末钳制：目标月天数不足时退到月末
    const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
    d.setDate(Math.min(day, lastDay));
  } else {
    d.setFullYear(d.getFullYear() + delta);
  }
  return toDateString(d);
}

/** anchor 所在周期的起止日期（含两端）。周 = 周一~周日。 */
export function periodRange(anchor: string, period: ReportPeriod): { start: string; end: string } {
  const d = parse(anchor);
  if (period === 'weekly') {
    const day = d.getDay(); // 0=周日
    const diffToMonday = day === 0 ? -6 : 1 - day;
    const monday = new Date(d);
    monday.setDate(d.getDate() + diffToMonday);
    const sunday = new Date(monday);
    sunday.setDate(monday.getDate() + 6);
    return { start: toDateString(monday), end: toDateString(sunday) };
  }
  if (period === 'monthly') {
    const start = new Date(d.getFullYear(), d.getMonth(), 1);
    const end = new Date(d.getFullYear(), d.getMonth() + 1, 0);
    return { start: toDateString(start), end: toDateString(end) };
  }
  return { start: `${d.getFullYear()}-01-01`, end: `${d.getFullYear()}-12-31` };
}

/** ISO-8601 周数（周一开头）。 */
export function isoWeekNumber(dateStr: string): number {
  const date = parse(dateStr);
  date.setDate(date.getDate() + 3 - ((date.getDay() + 6) % 7));
  const week1 = new Date(date.getFullYear(), 0, 4);
  return 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7);
}
```

- [ ] **Step 2: src/components/transactionGroups.ts**

```typescript
/**
 * 交易按日期分组（P4 时间线）：组内保持输入顺序，组头带当日收支小计。
 * 输入需已由调用方按日期倒序排列。
 */
import type { Transaction } from '../domain/types';
import { addDecimals, negateDecimal } from '../domain/decimal';

export interface DayGroup {
  date: string; // YYYY-MM-DD
  income: string;
  expense: string;
  items: Transaction[];
}

export function groupTransactionsByDate(txs: Transaction[]): DayGroup[] {
  const groups: DayGroup[] = [];
  const byDate = new Map<string, DayGroup>();
  for (const t of txs) {
    const date = t.date.slice(0, 10);
    let group = byDate.get(date);
    if (!group) {
      group = { date, income: '0', expense: '0', items: [] };
      byDate.set(date, group);
      groups.push(group);
    }
    group.items.push(t);
    const incomeAmounts: string[] = [];
    const expenseAmounts: string[] = [];
    for (const p of t.postings) {
      if (!p.amount) continue;
      if (p.account.startsWith('Income')) incomeAmounts.push(negateDecimal(p.amount));
      if (p.account.startsWith('Expenses')) expenseAmounts.push(p.amount);
    }
    if (incomeAmounts.length) group.income = addDecimals([group.income, ...incomeAmounts]);
    if (expenseAmounts.length) group.expense = addDecimals([group.expense, ...expenseAmounts]);
  }
  return groups;
}
```

- [ ] **Step 3: useSearch.ts 金额区间**

- `SearchFilters` 加 `amountMin?: string; amountMax?: string;`
- filter 链中（direction 判断之后）加：

```typescript
      if (debouncedFilters.amountMin || debouncedFilters.amountMax) {
        const raw = t.postings.find(p => p.amount)?.amount;
        const abs = raw?.replace(/^-/, '');
        if (debouncedFilters.amountMin && (!abs || compareDecimals(abs, debouncedFilters.amountMin) < 0)) return false;
        if (debouncedFilters.amountMax && abs && compareDecimals(abs, debouncedFilters.amountMax) > 0) return false;
      }
```

- 顶部 import `compareDecimals` from '../domain/decimal'。

- [ ] **Step 4: domain/index.ts 加 barrel**

```typescript
export * from './periodNav';
```

- [ ] **Step 5: 验证**

Run: `npx vitest run tests/periodNav.test.ts tests/transactionGroups.test.ts` + search 测试文件 → 全 PASS
Run: `npm run typecheck` → 无错误

---

### Task 3: 共享组件（BottomSheet scrollable / FilterSheet / TransactionCard 重刷 / SwipeableTransactionCard / 手势根）

**Files:**
- Modify: `src/components/BottomSheet.tsx`、`src/components/TransactionCard.tsx`、`src/app/_layout.tsx`
- Create: `src/components/FilterSheet.tsx`、`src/components/SwipeableTransactionCard.tsx`
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`

- [ ] **Step 1: BottomSheet 加 scrollable 选项（P2 遗留）**

props 加 `scrollable?: boolean`；为 true 时 children 包一层 `<ScrollView style={{ maxHeight: 480 }} keyboardShouldPersistTaps="handled">`（import ScrollView）。

- [ ] **Step 2: i18n 加键（zh/en 对等）**

zh：

```typescript
  'transactions.filter': '筛选',
  'transactions.amountMin': '最小金额',
  'transactions.amountMax': '最大金额',
  'transactions.today': '今天',
  'transactions.yesterday': '昨天',
  'transactions.daySummary': '收 {{income}} · 支 {{expense}}',
  'transactions.duplicate': '复制',
  'transactions.deleteTitle': '删除交易',
  'transactions.deleteMessage': '确定删除「{{name}}」吗？此操作会同时从 main.bean 移除。',
  'filter.title': '高级筛选',
  'filter.reset': '重置',
  'filter.apply': '完成',
```

en 对应：'Filter' / 'Min amount' / 'Max amount' / 'Today' / 'Yesterday' / '+{{income}} · -{{expense}}' / 'Duplicate' / 'Delete transaction' / 'Delete "{{name}}"? This also removes it from main.bean.' / 'Advanced filters' / 'Reset' / 'Done'。

（若 transactions.deleteTitle/deleteMessage 等键已存在则复用，先 grep 确认，勿重复添加；i18n 插值语法以现有键为准。）

- [ ] **Step 3: TransactionCard 重刷（spec §7.2）**

完整替换 `src/components/TransactionCard.tsx`：

```tsx
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { CategoryIcon } from './CategoryIcon';
import type { Transaction } from '../domain/types';

interface TransactionCardProps {
  transaction: Transaction;
  onPress?: (t: Transaction) => void;
  /** 命中分类 id（调用方按 linkedAccount 匹配）；缺省用方向兜底图标。 */
  categoryId?: string;
  /** 时间线分组下隐藏日期（默认 true 保持旧行为）。 */
  showDate?: boolean;
}

/** 交易卡片：CategoryIcon 圆底 + 摘要/账户小字 + 右侧等宽金额（支出黑、收入绿+、转账蓝）。 */
export const TransactionCard = React.memo(function TransactionCard({ transaction, onPress, categoryId, showDate = true }: TransactionCardProps) {
  const { theme } = useTheme();
  const t = useT();
  const isExpense = transaction.postings.some(p => p.account.startsWith('Expenses'));
  const isIncome = transaction.postings.some(p => p.account.startsWith('Income'));
  const direction = isExpense ? 'expense' : isIncome ? 'income' : 'transfer';
  const rawAmount = transaction.postings.find(p => p.amount)?.amount ?? '';
  const absAmount = rawAmount.replace(/^-/, '');
  const amountColor = direction === 'income' ? theme.colors.financial.income
    : direction === 'transfer' ? theme.colors.financial.transfer
    : theme.colors.fgPrimary;
  const amountText = direction === 'income' ? `+${absAmount}` : direction === 'expense' ? `-${absAmount}` : absAmount;
  // 账户小字：第一条资金腿的短名
  const assetAccount = transaction.postings.find(p => p.account.startsWith('Assets') || p.account.startsWith('Liabilities'))?.account;
  const accountShort = assetAccount?.split(':').pop();
  const subtitle = [
    showDate ? transaction.date.slice(0, 10) : '',
    accountShort ?? '',
  ].filter(Boolean).join(' · ');

  return (
    <Pressable
      accessibilityRole="button"
      accessibilityLabel={`${transaction.narration || transaction.payee || ''} ${amountText} ${transaction.date.slice(0, 10)}`}
      onPress={() => onPress?.(transaction)}
      style={({ pressed }) => [
        styles.card,
        {
          backgroundColor: theme.colors.bgSecondary,
          borderRadius: theme.radii.lg,
          padding: theme.spacing.md,
          borderWidth: StyleSheet.hairlineWidth,
          borderColor: theme.colors.border,
          opacity: pressed ? 0.8 : 1,
        },
      ]}
    >
      <CategoryIcon categoryId={categoryId ?? direction} size={36} />
      <View style={styles.body}>
        <Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]} numberOfLines={1}>
          {transaction.narration || transaction.payee || t('transaction.noSummary')}
        </Text>
        {subtitle ? (
          <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]} numberOfLines={1}>
            {subtitle}
          </Text>
        ) : null}
      </View>
      <Text style={[theme.typography.body, { color: amountColor, fontWeight: '700', fontSize: 15, fontVariant: ['tabular-nums'] }]}>
        {amountText}
      </Text>
      {transaction.tags.length > 0 && (
        <View style={styles.tags}>
          {transaction.tags.map(tag => (
            <Text key={tag} style={[styles.tag, { backgroundColor: theme.colors.accentLight, color: theme.colors.accent, fontWeight: '600' }]}>
              #{tag}
            </Text>
          ))}
        </View>
      )}
    </Pressable>
  );
});

const styles = StyleSheet.create({
  card: { marginBottom: 8, flexDirection: 'row', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
  body: { flex: 1 },
  tags: { flexDirection: 'row', gap: 4, marginLeft: 46, flexBasis: '100%', flexWrap: 'wrap' },
  tag: { fontSize: 11, paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, overflow: 'hidden' },
});
```

注意：tags 行布局改为 `flexBasis: '100%'` 换行（card 变 flexWrap 行容器）。金额 `fontFamily: 'monospace'` 改为 `fontVariant: ['tabular-nums']`（P1 决策）。

- [ ] **Step 4: SwipeableTransactionCard.tsx**

```tsx
/**
 * 可左滑的交易卡片（spec §7.2）：右滑出「复制 / 删除」快捷操作。
 * 用 gesture-handler 传统 Swipeable（不依赖 reanimated worklet）。
 */
import React, { useRef } from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { Swipeable } from 'react-native-gesture-handler';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { TransactionCard } from './TransactionCard';
import type { Transaction } from '../domain/types';

interface SwipeableTransactionCardProps {
  transaction: Transaction;
  categoryId?: string;
  showDate?: boolean;
  onPress: (t: Transaction) => void;
  /** 复制：跳转录入页预填（由调用方实现）。 */
  onDuplicate: (t: Transaction) => void;
  /** 删除：调用方负责 confirm + deleteTransaction。 */
  onDelete: (t: Transaction) => void;
}

export function SwipeableTransactionCard({ transaction, categoryId, showDate, onPress, onDuplicate, onDelete }: SwipeableTransactionCardProps) {
  const { theme } = useTheme();
  const t = useT();
  const swipeRef = useRef<Swipeable>(null);

  const renderRightActions = () => (
    <View style={styles.actions}>
      <Pressable
        onPress={() => { swipeRef.current?.close(); onDuplicate(transaction); }}
        style={[styles.actionBtn, { backgroundColor: theme.colors.bgTertiary, borderRadius: theme.radii.md }]}
        accessibilityRole="button"
        accessibilityLabel={t('transactions.duplicate')}
      >
        <Ionicons name="copy-outline" size={20} color={theme.colors.fgPrimary} />
        <Text style={[styles.actionText, { color: theme.colors.fgPrimary }]}>{t('transactions.duplicate')}</Text>
      </Pressable>
      <Pressable
        onPress={() => { swipeRef.current?.close(); onDelete(transaction); }}
        style={[styles.actionBtn, { backgroundColor: theme.colors.financial.expense, borderRadius: theme.radii.md }]}
        accessibilityRole="button"
        accessibilityLabel={t('common.delete')}
      >
        <Ionicons name="trash-outline" size={20} color={theme.colors.fgInverse} />
        <Text style={[styles.actionText, { color: theme.colors.fgInverse }]}>{t('common.delete')}</Text>
      </Pressable>
    </View>
  );

  return (
    <Swipeable ref={swipeRef} renderRightActions={renderRightActions} overshootRight={false}>
      <TransactionCard transaction={transaction} categoryId={categoryId} showDate={showDate} onPress={onPress} />
    </Swipeable>
  );
}

const styles = StyleSheet.create({
  actions: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingLeft: 8, marginBottom: 8 },
  actionBtn: { width: 64, alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: 10 },
  actionText: { fontSize: 11, fontWeight: '600' },
});
```

（Alert import 若未用可去；onDelete 的确认弹窗由页面实现。）

- [ ] **Step 5: FilterSheet.tsx**

```tsx
/**
 * 高级筛选底部弹层（spec §6）：账户 / 日期范围 / 金额区间。
 * 受控组件：值由父级持有，「完成」回调应用，「重置」清空。
 */
import React from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { useTheme, createCommonStyles } from '../theme';
import { useT } from '../i18n';
import { BottomSheet } from './BottomSheet';
import { DatePickerField } from './DatePickerField';
import { Button } from './Button';

export interface AdvancedFilterValues {
  account: string;
  dateFrom: string;
  dateTo: string;
  amountMin: string;
  amountMax: string;
}

interface FilterSheetProps {
  visible: boolean;
  onClose: () => void;
  values: AdvancedFilterValues;
  onChange: (values: AdvancedFilterValues) => void;
  onReset: () => void;
}

export function FilterSheet({ visible, onClose, values, onChange, onReset }: FilterSheetProps) {
  const { theme } = useTheme();
  const commonStyles = createCommonStyles(theme);
  const t = useT();
  const set = (patch: Partial<AdvancedFilterValues>) => onChange({ ...values, ...patch });

  return (
    <BottomSheet visible={visible} onClose={onClose} title={t('filter.title')} scrollable>
      <Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
        {t('transactions.accountFilterPlaceholder')}
      </Text>
      <TextInput
        value={values.account}
        onChangeText={v => set({ account: v })}
        placeholder={t('transactions.accountFilterPlaceholder')}
        placeholderTextColor={theme.colors.fgSecondary}
        style={commonStyles.input}
      />

      <Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
        {t('transactions.dateFrom')} ~ {t('transactions.dateTo')}
      </Text>
      <View style={styles.row}>
        <View style={styles.flex1}><DatePickerField value={values.dateFrom} onChange={v => set({ dateFrom: v })} placeholder={t('transactions.dateFrom')} /></View>
        <View style={styles.flex1}><DatePickerField value={values.dateTo} onChange={v => set({ dateTo: v })} placeholder={t('transactions.dateTo')} /></View>
      </View>

      <Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
        {t('transactions.amountMin')} ~ {t('transactions.amountMax')}
      </Text>
      <View style={styles.row}>
        <TextInput
          value={values.amountMin}
          onChangeText={v => set({ amountMin: v })}
          placeholder={t('transactions.amountMin')}
          placeholderTextColor={theme.colors.fgSecondary}
          keyboardType="decimal-pad"
          style={[commonStyles.input, styles.flex1]}
        />
        <TextInput
          value={values.amountMax}
          onChangeText={v => set({ amountMax: v })}
          placeholder={t('transactions.amountMax')}
          placeholderTextColor={theme.colors.fgSecondary}
          keyboardType="decimal-pad"
          style={[commonStyles.input, styles.flex1]}
        />
      </View>

      <View style={[styles.row, styles.actions]}>
        <View style={styles.flex1}>
          <Button label={t('filter.reset')} variant="secondary" onPress={onReset} />
        </View>
        <View style={styles.flex1}>
          <Button label={t('filter.apply')} onPress={onClose} />
        </View>
      </View>
    </BottomSheet>
  );
}

const styles = StyleSheet.create({
  label: { marginTop: 10, marginBottom: 4 },
  row: { flexDirection: 'row', gap: 8 },
  flex1: { flex: 1 },
  actions: { marginTop: 16 },
});
```

- [ ] **Step 6: _layout.tsx 加 GestureHandlerRootView**

`import { GestureHandlerRootView } from 'react-native-gesture-handler';`，RootLayout 最外层包裹：`<GestureHandlerRootView style={{ flex: 1 }}><ThemeProvider>…</ThemeProvider></GestureHandlerRootView>`。

- [ ] **Step 7: 验证**

Run: `npm run typecheck && npm test` → 全绿

---

### Task 4: 首页重写 + TodoStrip

**Files:**
- Create: `src/components/TodoStrip.tsx`
- Modify: `src/app/(tabs)/index.tsx`（完整重写）、`src/i18n/zh.ts`、`src/i18n/en.ts`

- [ ] **Step 1: i18n 加键（zh/en 对等）**

zh：

```typescript
  'home.greetingMorning': '早上好',
  'home.greetingAfternoon': '下午好',
  'home.greetingEvening': '晚上好',
  'home.monthExpense': '本月支出',
  'home.monthIncome': '本月收入',
  'home.budgetRemaining': '预算剩余',
  'home.recentTransactions': '最近交易',
  'home.viewAll': '查看全部',
  'todo.title': '待办',
  'todo.dueRecurring': '周期记账「{{name}}」到期',
  'todo.creditCardDue': '{{name}} 还款日 {{date}}',
  'todo.pendingDrafts': '{{count}} 条自动账单待确认',
  'todo.confirm': '入账',
```

en 对应：'Good morning' / 'Good afternoon' / 'Good evening' / 'Spent this month' / 'Earned this month' / 'Budget left' / 'Recent transactions' / 'View all' / 'To-do' / 'Recurring "{{name}}" is due' / '{{name}} payment due {{date}}' / '{{count}} auto bills to confirm' / 'Post'。

- [ ] **Step 2: src/components/TodoStrip.tsx**

```tsx
/**
 * 首页待办条（spec §7.1）：周期记账到期 / 信用卡还款提醒 / 未确认自动账单。
 * 三类都无待办时不渲染。
 */
import React, { useMemo } from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { Card } from './Card';
import { useLedgerStore } from '../store/ledgerStore';
import { useMetadataStore } from '../store/metadataStore';
import { useAutomationStore } from '../store/automationStore';
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../domain/recurring';
import { calculateBillingPeriod } from '../domain/creditCards';
import { toDateString } from '../domain/decimal';
import { shiftAnchor } from '../domain/periodNav';

export function TodoStrip() {
  const { theme } = useTheme();
  const t = useT();
  const router = useRouter();
  const addTransaction = useLedgerStore(s => s.addTransaction);
  const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
  const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction);
  const creditCards = useMetadataStore(s => s.creditCards);
  const draftsCount = useAutomationStore(s => s.drafts.length);

  const todayStr = useMemo(() => toDateString(new Date()), []);
  const weekLater = useMemo(() => shiftAnchor(todayStr, 'weekly', 1), [todayStr]);

  // autoConfirm 的由首页自动处理，待办条只列需手动确认的
  const dueRecurring = useMemo(
    () => getDueRecurring(recurringTransactions, todayStr).filter(r => !r.autoConfirm),
    [recurringTransactions, todayStr],
  );
  const dueCards = useMemo(
    () => creditCards.filter(card => {
      const period = calculateBillingPeriod(card, new Date());
      return period.dueDate >= todayStr && period.dueDate <= weekLater;
    }),
    [creditCards, todayStr, weekLater],
  );

  if (dueRecurring.length === 0 && dueCards.length === 0 && draftsCount === 0) return null;

  const handleConfirmRecurring = async (rec: (typeof dueRecurring)[number]) => {
    try {
      const draft = instantiateRecurring(rec);
      await addTransaction(draft);
      const nextDueDate = calculateNextDueDate(rec.frequency, rec.interval, rec.nextDueDate);
      updateRecurringTransaction(rec.id, { nextDueDate });
      Alert.alert(t('home.recurringSuccess'), t('home.recurringSuccessDesc', { name: rec.name }));
    } catch (e) {
      Alert.alert(t('home.recurringFail'), String(e));
    }
  };

  const renderRow = (icon: keyof typeof Ionicons.glyphMap, text: string, action?: { label: string; onPress: () => void }, key: string) => (
    <View key={key} style={[styles.row, { borderBottomColor: theme.colors.divider }]}>
      <Ionicons name={icon} size={18} color={theme.colors.accent} />
      <Text style={[theme.typography.bodySmall, styles.rowText, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
        {text}
      </Text>
      {action ? (
        <Pressable
          onPress={action.onPress}
          style={[styles.actionBtn, { backgroundColor: theme.colors.accent, borderRadius: theme.radii.full }]}
          accessibilityRole="button"
          accessibilityLabel={action.label}
        >
          <Text style={[styles.actionText, { color: theme.colors.fgInverse }]}>{action.label}</Text>
        </Pressable>
      ) : (
        <Ionicons name="chevron-forward" size={16} color={theme.colors.fgSecondary} />
      )}
    </View>
  );

  return (
    <Card title={t('todo.title')}>
      {dueRecurring.map(rec =>
        renderRow('repeat-outline', t('todo.dueRecurring', { name: rec.name }), { label: t('todo.confirm'), onPress: () => handleConfirmRecurring(rec) }, `rec-${rec.id}`),
      )}
      {dueCards.map(card =>
        renderRow('card-outline', t('todo.creditCardDue', { name: card.name, date: calculateBillingPeriod(card, new Date()).dueDate.slice(5) }), { label: t('common.confirm'), onPress: () => router.push('/credit-card' as any) }, `card-${card.id}`),
      )}
      {draftsCount > 0 &&
        renderRow('flash-outline', t('todo.pendingDrafts', { count: draftsCount }), { label: t('common.confirm'), onPress: () => router.push('/automation' as any) }, 'drafts')}
    </Card>
  );
}

const styles = StyleSheet.create({
  row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth },
  rowText: { flex: 1 },
  actionBtn: { paddingHorizontal: 12, paddingVertical: 5 },
  actionText: { fontSize: 12, fontWeight: '700' },
});
```

（`router.push('/credit-card' as any)`：若 typedRoutes 接受字面量则去 `as any`——参考现有 settings.tsx 的 `as Href` 写法，与之一致即可。）

- [ ] **Step 3: 完整重写 src/app/(tabs)/index.tsx**

结构（保留现有数据计算与 autoConfirm effect，删除 AccountTree/Bento 双格/Hero accent 卡）：

```tsx
/**
 * 首页（spec §7.1）：今日视角。
 * 问候 header → 净资产白卡（本月收支/预算剩余小字）→ 待办条 → 最近 5 条 → 月度趋势。
 * 账户树已下沉到「我的」页（/account）。
 */
import React, { useEffect, useMemo, useRef } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { TransactionCard } from '../../components/TransactionCard';
import { TodoStrip } from '../../components/TodoStrip';
import { TrendLine } from '../../components/charts/TrendLine';
import { calculateNetWorth } from '../../domain/netWorth';
import { groupByMonth } from '../../domain/chartStats';
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/recurring';
import { calculateBudgetProgress } from '../../domain/budgets';
import { toDateString } from '../../domain/decimal';
import type { Transaction } from '../../domain/types';

export default function HomeScreen() {
  const { theme } = useTheme();
  const t = useT();
  const router = useRouter();

  const ledger = useLedgerStore(s => s.ledger);
  const addTransaction = useLedgerStore(s => s.addTransaction);
  const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
  const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction);
  const budgets = useMetadataStore(s => s.budgets);
  const categories = useMetadataStore(s => s.categories);

  const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
  const netWorth = useMemo(() => calculateNetWorth(transactions), [transactions]);
  const monthlyData = useMemo(() => groupByMonth(transactions), [transactions]);
  const currentMonth = useMemo(() => monthlyData.length ? monthlyData[monthlyData.length - 1] : null, [monthlyData]);

  const todayStr = useMemo(() => toDateString(new Date()), []);
  const dueRecurring = useMemo(() => getDueRecurring(recurringTransactions, todayStr), [recurringTransactions, todayStr]);

  // 周期记账自动触发（autoConfirm 项启动时自动入账）——逻辑与旧版一致
  const autoConfirmed = useRef(false);
  useEffect(() => { /* …原样保留旧实现… */ }, [dueRecurring, addTransaction, updateRecurringTransaction]);

  // 月度预算剩余合计（无月度预算则不显示）
  const budgetRemaining = useMemo(() => {
    const monthlyBudgets = budgets.filter(b => b.period === 'monthly');
    if (monthlyBudgets.length === 0) return null;
    const remainings = monthlyBudgets.map(b => calculateBudgetProgress(b, transactions, todayStr).remaining);
    return remainings.reduce((a, b) => addDecimals([a, b]), '0');
  }, [budgets, transactions, todayStr]);

  // 最近 5 条（按日期+序号倒序）
  const recentTxs = useMemo(() =>
    transactions.map((tx, idx) => ({ tx, idx }))
      .sort((a, b) => b.tx.date.localeCompare(a.tx.date) || b.idx - a.idx)
      .slice(0, 5)
      .map(item => item.tx),
  [transactions]);

  // 分类匹配（TransactionCard 图标）
  const categoryIdFor = (tx: Transaction): string | undefined => {
    const target = tx.postings.find(p => p.account.startsWith('Expenses') || p.account.startsWith('Income'))?.account;
    return target ? categories.find(c => c.linkedAccount === target)?.id : undefined;
  };

  // 问候语 + 日期行
  const hour = new Date().getHours();
  const greeting = hour < 12 ? t('home.greetingMorning') : hour < 18 ? t('home.greetingAfternoon') : t('home.greetingEvening');
  const dateLine = new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' });

  const fmtAmount = (s: string) => { /* …原样保留旧实现… */ };

  return (
    <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
      <View style={styles.header}>
        <Text style={[theme.typography.h2, { color: theme.colors.fgPrimary }]}>{greeting}</Text>
        <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>{dateLine}</Text>
      </View>
      <ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
        {/* 净资产白卡 */}
        <Card>
          <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.netWorthTitle')}</Text>
          <Text style={[theme.typography.display, { color: theme.colors.fgPrimary, marginVertical: 6, fontVariant: ['tabular-nums'] }]}>
            {fmtAmount(netWorth.netWorth)}
          </Text>
          <View style={[styles.heroFooter, { borderTopColor: theme.colors.divider }]}>
            <View style={styles.heroStat}>
              <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthExpense')}</Text>
              <Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
                -{fmtAmount(currentMonth?.expense.toString() ?? '0')}
              </Text>
            </View>
            <View style={styles.heroStat}>
              <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthIncome')}</Text>
              <Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
                +{fmtAmount(currentMonth?.income.toString() ?? '0')}
              </Text>
            </View>
            {budgetRemaining !== null && (
              <View style={styles.heroStat}>
                <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.budgetRemaining')}</Text>
                <Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
                  {fmtAmount(budgetRemaining)}
                </Text>
              </View>
            )}
          </View>
        </Card>

        <TodoStrip />

        {/* 最近 5 条交易 */}
        {recentTxs.length > 0 && (
          <Card title={t('home.recentTransactions')}>
            {recentTxs.map(tx => (
              <TransactionCard key={tx.id} transaction={tx} categoryId={categoryIdFor(tx)} onPress={() => router.push(`/transaction/${tx.id}`)} />
            ))}
            <Pressable onPress={() => router.push('/(tabs)/transactions' as any)} style={styles.viewAll} accessibilityRole="button" accessibilityLabel={t('home.viewAll')}>
              <Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700' }]}>{t('home.viewAll')} →</Text>
            </Pressable>
          </Card>
        )}

        {/* 月度趋势 */}
        {monthlyData.length > 0 && (
          <Card title={t('home.monthlyTrend')}>
            <TrendLine transactions={transactions} />
          </Card>
        )}
      </ScrollView>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  page: { flex: 1 },
  header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
  content: { padding: 16, paddingBottom: 40 },
  heroFooter: { flexDirection: 'row', gap: 16, borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 10, marginTop: 4 },
  heroStat: { gap: 2 },
  viewAll: { alignItems: 'center', paddingVertical: 6 },
});
```

要点：①autoConfirm effect 与 fmtAmount 从旧文件原样搬运；②`addDecimals` 需 import；③`router.push('/(tabs)/transactions' as any)` 若 typedRoutes 不接受，用 `router.navigate('/(tabs)/transactions')` 或相对段——以实现时 typecheck 为准并报告；④旧 imports（AccountTree/buildAccountTree/handleConfirmRecurring/bento 样式）全部删除。

- [ ] **Step 4: 验证**

Run: `npm run typecheck && npm test` → 全绿
Run: `grep -n "AccountTree" "src/app/(tabs)/index.tsx"` → 无输出（账户树已下沉）

---

### Task 5: 交易页重写（时间线 + FilterSheet + 左滑）

**Files:**
- Modify: `src/app/(tabs)/transactions.tsx`（完整重写）

- [ ] **Step 1: 完整重写**

```tsx
/**
 * 交易页（spec §7.2）：搜索优先 + 日期分组时间线。
 * 搜索框常驻；方向 chip；高级筛选收进 FilterSheet（账户/日期/金额区间）；
 * 列表按日期分组（今天/昨天/具体日期 + 当日收支小计）；左滑复制/删除。
 * 解析诊断已移至 我的 → 数据 → 解析诊断（/settings/diagnostics）。
 */
import React, { useMemo, useState } from 'react';
import { Alert, Pressable, SectionList, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { SearchBar } from '../../components/SearchBar';
import { FilterSheet, type AdvancedFilterValues } from '../../components/FilterSheet';
import { SwipeableTransactionCard } from '../../components/SwipeableTransactionCard';
import { groupTransactionsByDate } from '../../components/transactionGroups';
import { useSearch, type SearchFilters } from '../../hooks/useSearch';
import { toDateString } from '../../domain/decimal';
import type { Transaction } from '../../domain/types';

type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer';

const EMPTY_FILTERS: AdvancedFilterValues = { account: '', dateFrom: '', dateTo: '', amountMin: '', amountMax: '' };

export default function TransactionsScreen() {
  const { theme } = useTheme();
  const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
  const t = useT();
  const router = useRouter();
  const ledger = useLedgerStore(s => s.ledger);
  const deleteTransaction = useLedgerStore(s => s.deleteTransaction);
  const categories = useMetadataStore(s => s.categories);

  const [keyword, setKeyword] = useState('');
  const [direction, setDirection] = useState<DirectionFilter>('all');
  const [filterSheetOpen, setFilterSheetOpen] = useState(false);
  const [advanced, setAdvanced] = useState<AdvancedFilterValues>(EMPTY_FILTERS);

  const allTransactions = useMemo(() => {
    if (!ledger?.transactions) return [];
    return ledger.transactions
      .map((tx, idx) => ({ tx, idx }))
      .sort((a, b) => b.tx.date.localeCompare(a.tx.date) || b.idx - a.idx)
      .map(item => item.tx);
  }, [ledger]);

  const filters: SearchFilters = useMemo(() => ({
    keyword: keyword || undefined,
    direction: direction === 'all' ? undefined : direction,
    account: advanced.account || undefined,
    dateFrom: advanced.dateFrom || undefined,
    dateTo: advanced.dateTo || undefined,
    amountMin: advanced.amountMin || undefined,
    amountMax: advanced.amountMax || undefined,
  }), [keyword, direction, advanced]);

  const filtered = useSearch(allTransactions, filters);
  const sections = useMemo(() =>
    groupTransactionsByDate(filtered).map(g => ({ ...g, data: g.items })),
  [filtered]);

  const advancedActive = advanced.account !== '' || advanced.dateFrom !== '' || advanced.dateTo !== '' || advanced.amountMin !== '' || advanced.amountMax !== '';

  const todayStr = toDateString(new Date());
  const yesterdayStr = toDateString(new Date(Date.now() - 86400000));
  const dateLabel = (date: string) =>
    date === todayStr ? t('transactions.today') : date === yesterdayStr ? t('transactions.yesterday') : date;

  const categoryIdFor = (tx: Transaction): string | undefined => {
    const target = tx.postings.find(p => p.account.startsWith('Expenses') || p.account.startsWith('Income'))?.account;
    return target ? categories.find(c => c.linkedAccount === target)?.id : undefined;
  };

  // 左滑：复制（预填今天的录入页）
  const handleDuplicate = (tx: Transaction) => {
    const draftJson = JSON.stringify({
      date: todayStr,
      payee: tx.payee,
      narration: tx.narration,
      postings: tx.postings.map(p => ({ account: p.account, amount: p.amount, currency: p.currency })),
    });
    router.push({ pathname: '/transaction/new', params: { draftJson } });
  };

  // 左滑：删除（确认后从 main.bean 移除）
  const handleDelete = (tx: Transaction) => {
    Alert.alert(
      t('transactions.deleteTitle'),
      t('transactions.deleteMessage', { name: tx.narration || tx.payee || tx.date.slice(0, 10) }),
      [
        { text: t('common.cancel'), style: 'cancel' },
        { text: t('common.delete'), style: 'destructive', onPress: () => { deleteTransaction(tx.raw).catch(e => Alert.alert(t('common.error'), String(e))); } },
      ],
    );
  };

  const directionTabs: { key: DirectionFilter; label: string }[] = [
    { key: 'all', label: t('transactions.filterAll') },
    { key: 'expense', label: t('transactions.filterExpense') },
    { key: 'income', label: t('transactions.filterIncome') },
    { key: 'transfer', label: t('transactions.filterTransfer') },
  ];

  return (
    <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
      <View style={styles.header}>
        <Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.transactions')}</Text>
      </View>

      <SearchBar value={keyword} onChangeText={setKeyword} placeholder={t('transactions.searchPlaceholder')} />

      {/* 方向筛选 + 高级筛选入口 */}
      <View style={styles.filterRow}>
        {directionTabs.map(tab => (
          <Pressable key={tab.key} onPress={() => setDirection(tab.key)}
            style={[commonStyles.chip, direction === tab.key && commonStyles.chipActive]}>
            <Text style={[commonStyles.chipText, direction === tab.key && commonStyles.chipTextActive]}>{tab.label}</Text>
          </Pressable>
        ))}
        <View style={styles.flex1} />
        <Pressable
          onPress={() => setFilterSheetOpen(true)}
          style={[commonStyles.chip, advancedActive && commonStyles.chipActive, styles.filterBtn]}
          accessibilityRole="button"
          accessibilityLabel={t('transactions.filter')}
        >
          <Ionicons name="options-outline" size={14} color={advancedActive ? theme.colors.fgInverse : theme.colors.fgSecondary} />
          <Text style={[commonStyles.chipText, advancedActive && commonStyles.chipTextActive]}>{t('transactions.filter')}</Text>
        </Pressable>
      </View>

      <SectionList
        sections={sections}
        keyExtractor={(item) => item.id}
        renderItem={({ item: tx }) => (
          <SwipeableTransactionCard
            transaction={tx}
            categoryId={categoryIdFor(tx)}
            showDate={false}
            onPress={() => router.push(`/transaction/${tx.id}`)}
            onDuplicate={handleDuplicate}
            onDelete={handleDelete}
          />
        )}
        renderSectionHeader={({ section }) => (
          <View style={styles.sectionHeader}>
            <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
              {dateLabel(section.date)}
            </Text>
            <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontVariant: ['tabular-nums'] }]}>
              {t('transactions.daySummary', { income: `+${section.income}`, expense: `-${section.expense}` })}
            </Text>
          </View>
        )}
        ListHeaderComponent={
          <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingBottom: 8 }]}>
            {filtered.length === allTransactions.length
              ? t('transactions.countTotal', { count: filtered.length })
              : t('transactions.countFiltered', { shown: filtered.length, total: allTransactions.length })}
          </Text>
        }
        ListEmptyComponent={
          <Card title={t('transactions.noMatchTitle')}>
            <Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
              {allTransactions.length === 0 ? t('transactions.noMatchEmpty') : t('transactions.noMatchFiltered')}
            </Text>
          </Card>
        }
        contentContainerStyle={styles.content}
        stickySectionHeadersEnabled={false}
        initialNumToRender={10}
        maxToRenderPerBatch={10}
        windowSize={5}
      />

      <FilterSheet
        visible={filterSheetOpen}
        onClose={() => setFilterSheetOpen(false)}
        values={advanced}
        onChange={setAdvanced}
        onReset={() => setAdvanced(EMPTY_FILTERS)}
      />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  page: { flex: 1 },
  header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
  filterRow: { flexDirection: 'row', gap: 6, paddingHorizontal: 16, paddingBottom: 8, alignItems: 'center' },
  filterBtn: { flexDirection: 'row', alignItems: 'center', gap: 4 },
  flex1: { flex: 1 },
  content: { padding: 16, paddingTop: 8 },
  sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8 },
});
```

要点：①删除旧的 showAdvancedFilters/showDiagnostics 全部代码与 `Ionicons` 诊断 UI；②`common.error` 键若不存在改用现有错误键（grep 确认）；③daySummary 当收支均为 0 时仍显示——可接受；④SectionList section 类型由 `{...g, data}` 推导。

- [ ] **Step 2: 验证**

Run: `npm run typecheck && npm test` → 全绿
Run: `grep -n "diagnostics\|showAdvancedFilters" "src/app/(tabs)/transactions.tsx"` → 无输出

---

### Task 6: 报表页重写（anchor 统一 + ⋯ 菜单 + 年报去重）

**Files:**
- Modify: `src/app/(tabs)/report.tsx`（重写状态与导航；看板渲染保留并适配）
- Modify: `src/components/charts/AnnualReport.tsx`（去 MonthlyReport，加月度节奏迷你图）
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`

- [ ] **Step 1: i18n 加键**

zh：`'report.menu': '更多操作'`、`'report.exportImage': '导出图片'`、`'report.monthlyRhythm': '月度节奏'`；en：'More actions' / 'Export image' / 'Monthly rhythm'。（report.aiSummary 已存在。）

- [ ] **Step 2: report.tsx 状态合并为单一 anchor**

- 删除 `viewYear/viewMonth/viewWeekDate` 三个 state 及 prevMonth/nextMonth/changeWeek，替换为：

```typescript
  const [period, setPeriod] = useState<ReportPeriod>('monthly');
  const [anchor, setAnchor] = useState(() => toDateString(new Date()));
  const [menuOpen, setMenuOpen] = useState(false);

  const range = useMemo(() => periodRange(anchor, period), [anchor, period]);
  const rangeTxs = useMemo(
    () => transactions.filter(tx => tx.date >= range.start && tx.date <= range.end),
    [transactions, range],
  );
  const rangeStats = useMemo(() => {
    // 与旧 weeklyStats/monthlyStats 相同算法，作用于 rangeTxs
  }, [rangeTxs]);

  const shift = (delta: number) => {
    setSelectedReportDate(null);
    setAnchor(a => shiftAnchor(a, period, delta));
  };
```

- 周期 label（统一渲染一个月切换器，替代三个）：

```typescript
  const periodLabel = useMemo(() => {
    if (period === 'weekly') {
      const monday = range.start; const sunday = range.end;
      return `${anchor.slice(0, 4)}年第${isoWeekNumber(anchor)}周 (${Number(monday.slice(5,7))}/${Number(monday.slice(8))} ~ ${Number(sunday.slice(5,7))}/${Number(sunday.slice(8))})`;
    }
    if (period === 'monthly') return anchor.slice(0, 7);
    return `${anchor.slice(0, 4)}年`;
  }, [period, anchor, range]);
```

- `monthlyTxs`→`rangeTxs`（monthly 时等价）；`annualTxs` 删除（年报直接用 transactions + year）；`netWorthDates` 由 anchor 派生（`new Date(Number(anchor.slice(0,4)), Number(anchor.slice(5,7)) - 1 - i, 1)`）；`handleMonthlySummary` 的 viewYear/viewMonth 参数改从 anchor 派生（`Number(anchor.slice(0,4))`、`Number(anchor.slice(5,7))`）。
- Tab 切换 `setActiveTab` → `setPeriod`，三个 switcher 合并为一个：`‹ {periodLabel} ›`（onPress `shift(-1)`/`shift(1)`）。
- 收支看板卡：weekly/monthly 两个分支合并为一个（activeTab 不为 annual 时渲染），数据用 rangeStats + rangeTxs；weekly/monthly 文案键按 period 动态取（`t(period === 'weekly' ? 'report.weeklyIncome' : 'report.monthlyIncome')` 等）。
- 月 Tab 专属：CategoryPie(rangeTxs) + CalendarView（year/month 从 anchor 派生）+ 选中日期明细 + NetWorthChart 保留；周 Tab：看板 + CategoryPie(rangeTxs)；年 Tab：AnnualReport(transactions, year=Number(anchor.slice(0,4)))。
- 空值判断统一为 `rangeTxs.length === 0`（年报也用它）。

- [ ] **Step 3: AI/导出入口收进 ⋯ 菜单（文字标签）**

header 右侧两个图标按钮替换为单个 `ellipsis-horizontal` 图标 → 打开 BottomSheet：

```tsx
<BottomSheet visible={menuOpen} onClose={() => setMenuOpen(false)} title={t('report.menu')}>
  <Pressable style={menuRow} onPress={() => { setMenuOpen(false); handleMonthlySummary(); }}>
    <Ionicons name="sparkles-outline" size={20} color={theme.colors.accent} />
    <Text body>{t('report.aiSummary')}</Text>
  </Pressable>
  <Pressable style={menuRow} onPress={() => { setMenuOpen(false); handleExportImage(); }}>
    <Ionicons name="share-outline" size={20} color={theme.colors.accent} />
    <Text body>{t('report.exportImage')}</Text>
  </Pressable>
</BottomSheet>
```

- [ ] **Step 4: AnnualReport.tsx 去重 + 月度节奏迷你图**

- 删除 `import { MonthlyReport }` 与 `<MonthlyReport …/>` 用法。
- 末尾加月度节奏卡（12 根迷你柱，高度 = expense/maxExpense）：

```tsx
      <View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}>
        <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.monthlyRhythm')}</Text>
        <View style={styles.rhythmRow}>
          {report.monthlyTrend.map(m => {
            const ratio = maxExpense > 0 ? Math.min(parseFloat(m.expense) / maxExpense, 1) : 0;
            return (
              <View key={m.month} style={styles.rhythmCol}>
                <View style={[styles.rhythmTrack, { backgroundColor: theme.colors.bgTertiary }]}>
                  <View style={[styles.rhythmFill, { height: `${Math.max(ratio * 100, 2)}%`, backgroundColor: theme.colors.financial.expense }]} />
                </View>
                <Text style={[styles.rhythmLabel, { color: theme.colors.fgSecondary }]}>{Number(m.month.slice(5))}</Text>
              </View>
            );
          })}
        </View>
      </View>
```

其中 `const maxExpense = Math.max(...report.monthlyTrend.map(m => parseFloat(m.expense) || 0));`（展示层比率计算，允许 parseFloat——非记账金额）。样式：`rhythmRow: {flexDirection:'row', gap:4, height:64, marginTop:8}`、`rhythmCol: {flex:1, alignItems:'center', gap:4}`、`rhythmTrack: {flex:1, width:'100%', borderRadius:4, justifyContent:'flex-end', overflow:'hidden'}`、`rhythmFill: {width:'100%', borderRadius:4}`、`rhythmLabel: {fontSize:9}`。
- 检查 MonthlyReport 是否还有其他引用方（grep）；若无引用，**保留文件不删**（P5 图表重刷时统一处理），仅在报告里注明。

- [ ] **Step 5: 验证**

Run: `npm run typecheck && npm test` → 全绿
Run: `npx vitest run tests/periodNav.test.ts` → PASS
Run: `grep -n "viewWeekDate\|MonthlyReport" "src/app/(tabs)/report.tsx"` → 无输出

---

### Task 7: 我的页 4 分组 + 解析诊断页

**Files:**
- Modify: `src/app/(tabs)/settings.tsx`（完整重写）
- Create: `src/app/settings/diagnostics.tsx`
- Modify: `src/app/_layout.tsx`（注册新页）、`src/i18n/zh.ts`、`src/i18n/en.ts`

- [ ] **Step 1: i18n 加键**

zh：

```typescript
  'tab.settings': '我的',   // 改值（原'设置'）；key 不变
  'settings.groupAccount': '账户与分类',
  'settings.groupAutomation': '记账自动化',
  'settings.groupData': '数据',
  'settings.groupPreferences': '偏好',
  'settings.diagnostics': '解析诊断',
  'settings.syncBackup': '同步与备份',
  'settings.preferencesEntry': '偏好设置',
  'settings.import': '导入账单',
```

en：'tab.settings': 'Me'；'Accounts & categories' / 'Automation' / 'Data' / 'Preferences' / 'Parse diagnostics' / 'Sync & backup' / 'Preferences' / 'Import bills'。

- [ ] **Step 2: 完整重写 src/app/(tabs)/settings.tsx**

保留 renderNavLink 模式（删 `fontFamily` 引用），4 张分组卡 + 关于卡：

```typescript
// 账户与分类（wallet-outline 组）
/account（账户树，含余额）→ t('account.title')
/category → t('settings.categories')
/tag → t('settings.tags')
/budget → t('settings.budgets')
/credit-card → t('settings.creditCards')
/remark-template → t('remark.title')

// 记账自动化（flash-outline 组）
/rules → t('tab.rules')
/recurring → t('settings.recurringTitle')
/automation → t('automation.title')
/import → t('settings.import')

// 数据（cloud-outline 组）
/settings/sync → t('settings.syncBackup')
/settings/diagnostics → t('settings.diagnostics')

// 偏好（cog-outline 组）
/settings/preferences → t('settings.preferencesEntry')
/settings/ai → t('settings.aiSettingsTitle')
/ai/chat → t('ai.chatTitle')
```

组卡用 `Card title={t('settings.groupXxx')}`；关于卡原样保留。页面 header 标题改 `t('tab.settings')`（我的）。

- [ ] **Step 3: 新建 src/app/settings/diagnostics.tsx**（内容从旧交易页 footer 迁移）

```tsx
/** 解析诊断（自交易页迁入，spec §7.2/§7.4 数据组）。 */
import React from 'react';
import { ScrollView, StyleSheet, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLedgerStore } from '../../store/ledgerStore';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { ScreenHeader } from '../../components/ScreenHeader';

export default function DiagnosticsScreen() {
  const { theme } = useTheme();
  const t = useT();
  const ledger = useLedgerStore(s => s.ledger);

  return (
    <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
      <ScreenHeader title={t('settings.diagnostics')} />
      <ScrollView contentContainerStyle={styles.content}>
        <Card title={t('diagnostics.title')}>
          <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
            {t('diagnostics.syntaxErrors', { count: ledger?.diagnostics.length ?? 0 })}
          </Text>
          <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
            {t('diagnostics.unsupported', { count: ledger?.unsupported.length ?? 0 })}
          </Text>
          <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
            {t('diagnostics.balanceAssertions', { count: ledger?.balances.length ?? 0 })}
          </Text>
          <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
            {t('diagnostics.hint')}
          </Text>
        </Card>
      </ScrollView>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  page: { flex: 1 },
  content: { padding: 16 },
});
```

（ScreenHeader props 先 Read 确认——title/右侧 slot；若需要显式返回处理按其现有用法。）

- [ ] **Step 4: _layout.tsx 注册**

在 settings/preferences 行后加：

```tsx
<Stack.Screen name="settings/diagnostics" options={{ headerShown: false }} />
```

- [ ] **Step 5: 验证**

Run: `npm run typecheck && npm test` → 全绿（i18n 对等校验会覆盖 tab.settings 改值）

---

### Task 8: P4 全量验收

- [ ] **Step 1: 全量测试 + typecheck**

Run: `npm test` → 全绿（含 periodNav/transactionGroups/search 新测试）
Run: `npm run typecheck` → 无错误

- [ ] **Step 2: 审计**

Run: `grep -rn "fontFamily" "src/app/(tabs)"` → 无输出（P1 遗留清理）
Run: `grep -n "rgba(255,255,255" "src/app/(tabs)/index.tsx"` → 无输出（Hero 硬编码白字已消除）
Run: `grep -rn "MonthlyReport" src/` → 仅 components/charts/MonthlyReport.tsx 自身定义（report.tsx 已无引用）

- [ ] **Step 3: 手工走查（需设备/模拟器）**

Run: `npm run android`
走查清单（浅/暗双主题）：
1. 首页：问候+日期、净资产白卡大数字、本月收支/预算剩余小字、待办条（制造一个到期周期记账验证「入账」按钮）、最近 5 条、查看全部跳交易页
2. 交易页：搜索、方向 chip、筛选弹层（账户/日期/金额区间 + 重置）、日期分组（今天/昨天）、组头收支小计、左滑复制（预填今天）/删除（确认弹窗）、点击进详情
3. 报表页：周/月/年 Tab 切换 anchor 不跳变、左右箭头统一、⋯ 菜单两个文字入口、年报无内嵌月报且有月度节奏迷你图、月 Tab 日历点选出明细
4. 我的：4 分组条目齐全、每个入口可达、解析诊断页显示计数、Tab 标签为「我的」
5. ＋面板在四个 Tab 上均可唤起（全局开关开/关两条路径）

---

## 后续计划（不在本文件）

- **P5 管理页+收尾**：8 页套 ManagementScreen；图表重刷 token；emoji 清零；硬编码色 grep 审计；无障碍补全；`numpadGlobalEntry` 默认翻 true；MonthlyReport 等死文件清理；DatePickerField WEEKDAYS i18n；间距 token 化审计
