Skip to content

Commit 5004b44

Browse files
committed
feat(assistant): F3 睡前复习气泡拉起 5 卡迷你复习 + T4 孵化呼吸引导
1 parent a7d69de commit 5004b44

8 files changed

Lines changed: 375 additions & 9 deletions

File tree

‎client/src/features/assistant/AssistantRoot.tsx‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* 偏好 enabled=false 时整体不渲染(零开销)。
66
* 设计原则:可逆 > 不可逆——用户可随时关闭助手。
77
*/
8+
import { useState } from 'react';
89
import { AnimatePresence } from 'framer-motion';
910
import { useAssistantStore } from './store/useAssistantStore';
1011
import { useChat } from './hooks/useChat';
@@ -13,6 +14,8 @@ import { useAssistantAudio } from './hooks/useAssistantAudio';
1314
import { useBehaviorSignals } from './hooks/useBehaviorSignals';
1415
import { useBedtimeReminder } from './hooks/useBedtimeReminder';
1516
import { useIntentionCoach } from './hooks/useIntentionCoach';
17+
import { findTopDueDeck } from './lib/bedtimeReview';
18+
import { IncubationBreathing } from './components/IncubationBreathing';
1619
import { CreatureAvatar } from './components/CreatureAvatar';
1720
import { ConversationPanel } from './components/ConversationPanel';
1821

@@ -21,6 +24,8 @@ export function AssistantRoot() {
2124
const panelState = useAssistantStore(s => s.panelState);
2225
const setPanelState = useAssistantStore(s => s.setPanelState);
2326
const setCreatureState = useAssistantStore(s => s.setCreatureState);
27+
// T4 孵化呼吸引导浮层可见性(stuck-incubation 气泡点击触发)
28+
const [incubationOpen, setIncubationOpen] = useState(false);
2429

2530
const { sendMessage, retryLastMessage, dismissError } = useChat();
2631
const { playSound } = useAssistantAudio();
@@ -43,6 +48,26 @@ export function AssistantRoot() {
4348
const handleBubbleClick = () => {
4449
reportBubbleResponded();
4550
playSound('ack');
51+
// T4 孵化休息引导:卡壳救援气泡点击 → 打开 3 分钟呼吸引导浮层
52+
if (useAssistantStore.getState().bubbleTriggerId === 'stuck-incubation') {
53+
useAssistantStore.getState().hideBubble();
54+
setIncubationOpen(true);
55+
return;
56+
}
57+
// F3 闭环:睡前复习气泡点击直接拉起 5 卡迷你复习会话
58+
// (到期卡最多的牌组,?mini=5 限制卡数;hash 路由在 Router 外导航)
59+
if (useAssistantStore.getState().bubbleTriggerId === 'bedtime-review') {
60+
void findTopDueDeck().then((deckId) => {
61+
if (deckId) {
62+
useAssistantStore.getState().hideBubble();
63+
window.location.hash = `#/flashcards/${deckId}/study?mini=5`;
64+
return;
65+
}
66+
setPanelState('expanded');
67+
setCreatureState('listening');
68+
});
69+
return;
70+
}
4671
setPanelState('expanded');
4772
setCreatureState('listening');
4873
};
@@ -64,6 +89,8 @@ export function AssistantRoot() {
6489
onBubbleClick={handleBubbleClick}
6590
onBubbleDismiss={handleBubbleDismiss}
6691
/>
92+
{/* T4 孵化呼吸引导(卡壳救援气泡的落地动作) */}
93+
<IncubationBreathing open={incubationOpen} onClose={() => setIncubationOpen(false)} />
6794
<AnimatePresence>
6895
{panelState === 'expanded' && (
6996
<ConversationPanel onSend={sendMessage} onClose={handleClosePanel} onRetry={retryLastMessage} onDismiss={dismissError} />
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* IncubationBreathing 组件测试 / Tests for the T4 incubation overlay
3+
*
4+
* @ai-context: 覆盖 T4 孵化休息契约——3 分钟倒计时、提前关闭、完成态提示;
5+
* 复用呼吸组件的降级模式(reduced-motion)渲染,jsdom 内 stub matchMedia
6+
* 与 RAF,避免真实动画循环。
7+
* @ai-context: Covers the 3-minute countdown, early close and finished state;
8+
* uses the degraded breathing mode to stay deterministic in jsdom.
9+
*/
10+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11+
import { render, screen, cleanup, act } from '@testing-library/react';
12+
import { IncubationBreathing } from './IncubationBreathing';
13+
14+
function stubMatchMedia(reduced: boolean) {
15+
vi.stubGlobal('matchMedia', (query: string) => ({
16+
matches: reduced && query.includes('reduced-motion'),
17+
media: query,
18+
addEventListener: vi.fn(),
19+
removeEventListener: vi.fn(),
20+
addListener: vi.fn(),
21+
removeListener: vi.fn(),
22+
dispatchEvent: vi.fn(),
23+
onchange: null,
24+
}));
25+
}
26+
27+
beforeEach(() => {
28+
vi.stubGlobal('requestAnimationFrame', () => 1);
29+
vi.stubGlobal('cancelAnimationFrame', () => {});
30+
stubMatchMedia(true); // 降级模式:静态圆环 + 1Hz 倒计时,测试确定性
31+
vi.useFakeTimers();
32+
});
33+
34+
afterEach(() => {
35+
cleanup();
36+
vi.unstubAllGlobals();
37+
vi.useRealTimers();
38+
});
39+
40+
describe('IncubationBreathing (T4)', () => {
41+
it('should render nothing when closed', () => {
42+
render(<IncubationBreathing open={false} onClose={() => {}} />);
43+
expect(screen.queryByText(/孵化时刻/)).not.toBeInTheDocument();
44+
});
45+
46+
it('should show 3-minute countdown when opened', () => {
47+
render(<IncubationBreathing open onClose={() => {}} />);
48+
expect(screen.getByText(/孵化时刻/)).toBeInTheDocument();
49+
expect(screen.getByText(/3:00/)).toBeInTheDocument();
50+
expect(screen.getByText(/随时可提前回到任务/)).toBeInTheDocument();
51+
});
52+
53+
it('should count down every second and finish after 3 minutes', () => {
54+
render(<IncubationBreathing open onClose={() => {}} />);
55+
// React 渲染与 fake timers 互锁:逐秒推进 + act flush,链式重注册
56+
for (let i = 0; i < 60; i++) {
57+
act(() => { vi.advanceTimersByTime(1000); });
58+
}
59+
expect(screen.getByText(/2:00/)).toBeInTheDocument();
60+
61+
for (let i = 0; i < 120; i++) {
62+
act(() => { vi.advanceTimersByTime(1000); });
63+
}
64+
expect(screen.getByText(/放松完成/)).toBeInTheDocument();
65+
expect(screen.queryByText(/2:00/)).not.toBeInTheDocument();
66+
});
67+
68+
it('should call onClose when early-close button clicked', () => {
69+
const onClose = vi.fn();
70+
render(<IncubationBreathing open onClose={onClose} />);
71+
act(() => {
72+
screen.getByRole('button', { name: /回到任务/ }).click();
73+
});
74+
expect(onClose).toHaveBeenCalledTimes(1);
75+
});
76+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* T4 孵化呼吸引导浮层 — 卡壳后的 3 分钟轻体力放松
3+
* Incubation breathing overlay — 3-minute micro-break after getting stuck
4+
*
5+
* @ai-context: 复用仪式呼吸组件(BreathingProvider + RitualStepBreathing),
6+
* 不重造 RAF 循环。3 分钟自动倒计时(可提前关闭,可逆原则);完成时
7+
* 静默提示"随时回到任务"。降级模式(reduced-motion/低帧)由 Provider
8+
* 内部处理为 1Hz 倒计时圆环。
9+
* @ai-context: Reuses the ritual breathing stack (single RAF source); a
10+
* 3-minute countdown with early-close keeps the break reversible.
11+
*/
12+
import { useEffect, useState } from 'react';
13+
import { Wind, X, Check } from 'lucide-react';
14+
import { BreathingProvider } from '@/features/dashboard/components/ritual/BreathingProvider';
15+
import { RitualStepBreathing } from '@/features/dashboard/components/ritual/RitualStepBreathing';
16+
import { Button } from '@/components/ui';
17+
18+
/** 孵化休息时长(秒):3 分钟轻体力引导 */
19+
const INCUBATION_SECONDS = 180;
20+
21+
interface Props {
22+
open: boolean;
23+
onClose: () => void;
24+
}
25+
26+
export function IncubationBreathing({ open, onClose }: Props) {
27+
const [secondsLeft, setSecondsLeft] = useState(INCUBATION_SECONDS);
28+
const [finished, setFinished] = useState(false);
29+
30+
// 打开时重置倒计时
31+
useEffect(() => {
32+
if (open) {
33+
setSecondsLeft(INCUBATION_SECONDS);
34+
setFinished(false);
35+
}
36+
}, [open]);
37+
38+
// 3 分钟倒计时(挂起/后台时自然减速,不强求精确)
39+
useEffect(() => {
40+
if (!open || finished) return;
41+
const timer = setTimeout(() => {
42+
setSecondsLeft((s) => {
43+
if (s <= 1) {
44+
setFinished(true);
45+
return 0;
46+
}
47+
return s - 1;
48+
});
49+
}, 1000);
50+
return () => clearTimeout(timer);
51+
}, [open, finished, secondsLeft]);
52+
53+
if (!open) return null;
54+
55+
const minutes = Math.floor(secondsLeft / 60);
56+
const seconds = secondsLeft % 60;
57+
58+
return (
59+
<div className="bg-bg-primary/95 fixed inset-0 z-[90] flex flex-col items-center justify-center gap-6 p-6 backdrop-blur-2xl">
60+
<div className="flex flex-col items-center gap-4">
61+
<div className="flex items-center gap-2 text-focus">
62+
<Wind className="w-5 h-5" strokeWidth={1.5} />
63+
<span className="text-b1 font-semibold text-text-primary">孵化时刻</span>
64+
</div>
65+
<p className="max-w-xs text-center text-c1 text-text-secondary">
66+
卡壳不是停滞,是潜意识在孵化。用 3 分钟呼吸放松,让思路自己浮上来。
67+
</p>
68+
</div>
69+
70+
<BreathingProvider>
71+
<RitualStepBreathing onFirstCycleComplete={() => { /* 首圈完成仅点亮视觉,无需额外动作 */ }} />
72+
</BreathingProvider>
73+
74+
{!finished ? (
75+
<div className="flex flex-col items-center gap-3">
76+
{/* 倒计时条 */}
77+
<div className="h-1 w-64 overflow-hidden rounded-full bg-bg-tertiary">
78+
<div
79+
className="h-full rounded-full bg-gradient-to-r from-accent-500 to-brand-500 transition-all duration-1000"
80+
style={{ width: `${(secondsLeft / INCUBATION_SECONDS) * 100}%` }}
81+
/>
82+
</div>
83+
<span className="text-c1 text-text-tertiary tabular-nums">
84+
{minutes}:{String(seconds).padStart(2, '0')} · 随时可提前回到任务
85+
</span>
86+
<Button variant="ghost" size="sm" icon={<X className="w-3.5 h-3.5" />} onClick={onClose}>
87+
回到任务
88+
</Button>
89+
</div>
90+
) : (
91+
<div className="flex flex-col items-center gap-3">
92+
<div className="flex items-center gap-2 text-emerald-400">
93+
<Check className="w-4 h-4" />
94+
<span className="text-b2 font-medium">放松完成——现在回到任务,思路会更清晰</span>
95+
</div>
96+
<Button variant="primary" size="md" onClick={onClose}>
97+
回到任务
98+
</Button>
99+
</div>
100+
)}
101+
</div>
102+
);
103+
}

‎client/src/features/assistant/hooks/useBedtimeReminder.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
* 可选增强设计:任何检测异常静默跳过,绝不打扰用户。
77
*/
88
import { useEffect } from 'react';
9-
import { flashcardStore, flashcardReviewStore } from '@/lib/storage';
9+
import { flashcardReviewStore } from '@/lib/storage';
1010
import { assistantEventBus } from '../lib/eventBus';
11+
import { findDueCards, findTopDueDeck } from '../lib/bedtimeReview';
1112

1213
/** 睡前推荐窗口起点(21:30,以分钟计) */
1314
const BEDTIME_START_MIN = 21 * 60 + 30;
@@ -33,15 +34,16 @@ export function useBedtimeReminder(): void {
3334
if (reviewsToday > 0) return;
3435

3536
// 到期卡计数(dueDate 索引范围查询,避免全表加载)
36-
const dueCount = await flashcardStore.getTable()
37-
.where('dueDate').belowOrEqual(now)
38-
.and((c) => c.repetitions > 0)
39-
.count();
40-
if (dueCount < MIN_DUE_CARDS) return;
37+
const dueCards = await findDueCards(now);
38+
if (dueCards.length < MIN_DUE_CARDS) return;
39+
40+
// F3 闭环:到期卡最多的牌组作为迷你复习目标(无牌组则纯提醒)
41+
const topDeckId = await findTopDueDeck(now);
4142

4243
assistantEventBus.emit('review:bedtime', {
4344
currentHour: now.getHours(),
44-
dueCardCount: dueCount,
45+
dueCardCount: dueCards.length,
46+
topDeckId,
4547
});
4648
} catch {
4749
// 可选增强:检测失败静默跳过
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* Bedtime review shared logic tests
3+
* B2 (F3): due-card stats + mini-review target selection
4+
* @ai-context 睡前复习共享查询测试:到期卡过滤、牌组目标选择、失败静默降级;
5+
* 存储层全 Mock,绝不触碰真实 IndexedDB。
6+
*/
7+
import { describe, it, expect, vi, beforeEach } from 'vitest';
8+
9+
const mocks = vi.hoisted(() => ({
10+
flashcardStore: { getTable: vi.fn() },
11+
flashcardDeckStore: { getTable: vi.fn() },
12+
}));
13+
14+
vi.mock('@/lib/storage', () => ({
15+
flashcardStore: mocks.flashcardStore,
16+
flashcardDeckStore: mocks.flashcardDeckStore,
17+
}));
18+
19+
import { findDueCards, findTopDueDeck } from './bedtimeReview';
20+
21+
interface FakeCard {
22+
id: string;
23+
deckId: string;
24+
dueDate: Date;
25+
repetitions: number;
26+
}
27+
28+
/** 保留 Dexie where/and/toArray 语义的极简内存表 mock */
29+
function makeCardTable(rows: FakeCard[]) {
30+
return {
31+
where: (field: string) => ({
32+
belowOrEqual: (bound: Date) => ({
33+
and: (pred: (r: FakeCard) => boolean) => ({
34+
toArray: async () =>
35+
rows.filter((r) => field !== 'dueDate' || new Date(r.dueDate) <= bound).filter(pred),
36+
}),
37+
}),
38+
}),
39+
toArray: async () => rows,
40+
};
41+
}
42+
43+
const DUE_SOON = new Date('2026-08-03T10:00:00Z');
44+
const DUE_LATER = new Date('2026-08-10T10:00:00Z');
45+
const NOW = new Date('2026-08-03T12:00:00Z');
46+
47+
beforeEach(() => {
48+
vi.clearAllMocks();
49+
mocks.flashcardStore.getTable.mockReset();
50+
mocks.flashcardDeckStore.getTable.mockReset();
51+
});
52+
53+
describe('findDueCards', () => {
54+
it('should return due cards (dueDate <= now, repetitions > 0)', async () => {
55+
const cards: FakeCard[] = [
56+
{ id: 'c1', deckId: 'd1', dueDate: DUE_SOON, repetitions: 2 },
57+
{ id: 'c2', deckId: 'd1', dueDate: DUE_LATER, repetitions: 3 }, // 未到期
58+
{ id: 'c3', deckId: 'd2', dueDate: DUE_SOON, repetitions: 0 }, // 新卡
59+
];
60+
mocks.flashcardStore.getTable.mockReturnValue(makeCardTable(cards));
61+
62+
const result = await findDueCards(NOW);
63+
expect(result.map((c) => c.id)).toEqual(['c1']);
64+
});
65+
66+
it('should return empty array when no due cards', async () => {
67+
mocks.flashcardStore.getTable.mockReturnValue(makeCardTable([{ id: 'c2', deckId: 'd1', dueDate: DUE_LATER, repetitions: 3 }]));
68+
expect(await findDueCards(NOW)).toEqual([]);
69+
});
70+
});
71+
72+
describe('findTopDueDeck', () => {
73+
it('should return the deck with most due cards', async () => {
74+
const cards: FakeCard[] = [
75+
{ id: 'c1', deckId: 'd1', dueDate: DUE_SOON, repetitions: 1 },
76+
{ id: 'c2', deckId: 'd1', dueDate: DUE_SOON, repetitions: 1 },
77+
{ id: 'c3', deckId: 'd2', dueDate: DUE_SOON, repetitions: 1 },
78+
{ id: 'c4', deckId: 'd3', dueDate: DUE_LATER, repetitions: 1 },
79+
];
80+
mocks.flashcardStore.getTable.mockReturnValue(makeCardTable(cards));
81+
mocks.flashcardDeckStore.getTable.mockReturnValue(makeCardTable([
82+
{ id: 'd1', deckId: 'd1', dueDate: DUE_LATER, repetitions: 0 },
83+
{ id: 'd2', deckId: 'd2', dueDate: DUE_LATER, repetitions: 0 },
84+
]));
85+
86+
expect(await findTopDueDeck(NOW)).toBe('d1');
87+
});
88+
89+
it('should return undefined when no decks exist', async () => {
90+
mocks.flashcardStore.getTable.mockReturnValue(makeCardTable([
91+
{ id: 'c1', deckId: 'd1', dueDate: DUE_SOON, repetitions: 1 },
92+
]));
93+
mocks.flashcardDeckStore.getTable.mockReturnValue(makeCardTable([]));
94+
expect(await findTopDueDeck(NOW)).toBeUndefined();
95+
});
96+
97+
it('should return undefined when no due cards at all', async () => {
98+
mocks.flashcardStore.getTable.mockReturnValue(makeCardTable([
99+
{ id: 'c4', deckId: 'd3', dueDate: DUE_LATER, repetitions: 1 },
100+
]));
101+
mocks.flashcardDeckStore.getTable.mockReturnValue(makeCardTable([
102+
{ id: 'd3', deckId: 'd3', dueDate: DUE_LATER, repetitions: 0 },
103+
]));
104+
expect(await findTopDueDeck(NOW)).toBeUndefined();
105+
});
106+
107+
it('should silently return undefined when storage query throws', async () => {
108+
// deck 表保持正常,确保 Promise.all 正常成立,卡片表拒绝被统一捕获
109+
mocks.flashcardDeckStore.getTable.mockReturnValue(makeCardTable([]));
110+
mocks.flashcardStore.getTable.mockImplementation(() => {
111+
throw new Error('storage unavailable');
112+
});
113+
expect(await findTopDueDeck(NOW)).toBeUndefined();
114+
});
115+
});

0 commit comments

Comments
 (0)