From 95856ca11a4f936bb103b47538de48b7b6e6ddd7 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 19 May 2026 11:11:58 +0800 Subject: [PATCH] feat(app-expo): show memoir chapter draft progress on list cards Explain invite vs. generating state using MemoirState slots, surface remaining characters to the display gate, refresh memoir-state on pull, and sync i18n. Co-authored-by: Cursor --- app-expo/src/app/(tabs)/memoir.tsx | 91 ++++++++++++++++--- .../src/features/memoir/draft-progress.ts | 53 +++++++++++ .../features/memoir/framework-chapter-keys.ts | 12 +++ app-expo/src/i18n/generated/resources.ts | 3 + app-expo/src/i18n/locales/en/memoir.json | 3 + app-expo/src/i18n/locales/zh/memoir.json | 3 + .../features/memoir/draft-progress.test.ts | 49 ++++++++++ 7 files changed, 201 insertions(+), 13 deletions(-) create mode 100644 app-expo/src/features/memoir/draft-progress.ts create mode 100644 app-expo/tests/features/memoir/draft-progress.test.ts diff --git a/app-expo/src/app/(tabs)/memoir.tsx b/app-expo/src/app/(tabs)/memoir.tsx index 816bdc9..1b6045e 100644 --- a/app-expo/src/app/(tabs)/memoir.tsx +++ b/app-expo/src/app/(tabs)/memoir.tsx @@ -28,7 +28,16 @@ import { buildFrameworkChapterPlaceholders, mergeFrameworkChaptersWithFetched, } from '@/features/memoir/framework-chapter-keys'; -import { useChapters, useCheckCoverGeneration } from '@/features/memoir/hooks'; +import { + memoirDraftCharsRemaining, + memoirDraftHasStarted, + resolvedChapterCategory, +} from '@/features/memoir/draft-progress'; +import { + useChapters, + useCheckCoverGeneration, + useMemoirState, +} from '@/features/memoir/hooks'; import type { ChapterViewModel } from '@/features/memoir/types'; type ChapterVariant = 'completed' | 'drafting'; @@ -69,11 +78,15 @@ function ChapterCard({ variant, t, onReadPress, + draftStarted = false, + draftRemainingChars = 0, }: { item: ChapterViewModel; variant: ChapterVariant; t: (key: string) => string; onReadPress: () => void; + draftStarted?: boolean; + draftRemainingChars?: number; }) { const typography = useTypography(); const { width } = useWindowDimensions(); @@ -225,7 +238,7 @@ function ChapterCard({ {item.title} - + {t('statusDrafting')} + {!draftStarted ? ( + + {t('draftingInviteChat')} + + ) : ( + + + {t('draftingGenerating')} + + {draftRemainingChars > 0 ? ( + + {t('draftingWordsRemaining').replace( + '{{count}}', + String(draftRemainingChars), + )} + + ) : null} + + )} @@ -267,6 +312,7 @@ function MemoirLoadError({ onRetry }: { onRetry: () => void }) { export default function MemoirScreen() { const { t } = useTranslation('memoir'); const { viewModels: chapters, isLoading, isError, refetch } = useChapters(); + const { data: memoirState, refetch: refetchMemoirState } = useMemoirState(); const checkCover = useCheckCoverGeneration(); const [refreshing, setRefreshing] = useState(false); const didRunInitialCoverCheckRef = useRef(false); @@ -291,11 +337,11 @@ export default function MemoirScreen() { setRefreshing(true); try { await checkCover.mutateAsync(undefined); - await refetch(); + await Promise.all([refetch(), refetchMemoirState()]); } finally { setRefreshing(false); } - }, [checkCover, refetch]); + }, [checkCover, refetch, refetchMemoirState]); const handleReadChapter = useCallback((chapterId: string) => { router.push(`/(main)/chapter/${chapterId}`); @@ -329,15 +375,34 @@ export default function MemoirScreen() { ) : isError ? ( void refetch()} /> ) : ( - displayChapters.map((item) => ( - string} - onReadPress={() => handleReadChapter(item.id)} - /> - )) + displayChapters.map((item) => { + const variant = getChapterVariant(item); + const cat = resolvedChapterCategory(item); + const started = + variant === 'drafting' + ? memoirDraftHasStarted( + memoirState?.slots, + cat, + item.wordCount, + ) + : false; + const remaining = + variant === 'drafting' + ? memoirDraftCharsRemaining(item.wordCount) + : 0; + + return ( + string} + onReadPress={() => handleReadChapter(item.id)} + /> + ); + }) )} diff --git a/app-expo/src/features/memoir/draft-progress.ts b/app-expo/src/features/memoir/draft-progress.ts new file mode 100644 index 0000000..34d04ff --- /dev/null +++ b/app-expo/src/features/memoir/draft-progress.ts @@ -0,0 +1,53 @@ +import type { MemoirState } from './types'; + +import { CHAPTER_CATEGORY_BY_ORDER_INDEX } from './framework-chapter-keys'; + +/** 与后端 `reading_segment_materialize.MIN_STORY_CHARS_IN_CHAPTER` 一致:章节可读成稿阈值 */ +export const MIN_CHAPTER_DISPLAY_CHARS = 300; + +/** + * Chat 口述槽的阶段键(MemoirState.slots);多章 career_* 共用 `career`,beliefs/summary 与 `belief` 槽对齐。 + */ +export function chapterCategoryToInterviewStage(category: string): string { + const c = (category ?? '').trim(); + if (!c) return 'childhood'; + if (c.startsWith('career_')) return 'career'; + if (c === 'beliefs' || c === 'summary') return 'belief'; + return c; +} + +export function resolvedChapterCategory(vm: { + category: string; + orderIndex: number; +}): string { + const raw = vm.category?.trim(); + if (raw) return raw; + return CHAPTER_CATEGORY_BY_ORDER_INDEX[vm.orderIndex] ?? 'childhood'; +} + +export function interviewStageHasSnippetMaterial( + slots: MemoirState['slots'] | undefined, + stage: string, +): boolean { + if (!slots) return false; + const block = slots[stage]; + if (!block) return false; + return Object.values(block).some( + (cell) => (cell?.snippet ?? '').trim().length > 0, + ); +} + +export function memoirDraftHasStarted( + slots: MemoirState['slots'] | undefined, + chapterCategory: string, + chapterWordCount: number, +): boolean { + const stage = chapterCategoryToInterviewStage(chapterCategory); + if (chapterWordCount > 0) return true; + return interviewStageHasSnippetMaterial(slots, stage); +} + +export function memoirDraftCharsRemaining(chapterWordCount: number): number { + const n = typeof chapterWordCount === 'number' ? chapterWordCount : 0; + return Math.max(0, MIN_CHAPTER_DISPLAY_CHARS - n); +} diff --git a/app-expo/src/features/memoir/framework-chapter-keys.ts b/app-expo/src/features/memoir/framework-chapter-keys.ts index 798dc8b..e1835a0 100644 --- a/app-expo/src/features/memoir/framework-chapter-keys.ts +++ b/app-expo/src/features/memoir/framework-chapter-keys.ts @@ -16,6 +16,18 @@ export const FRAMEWORK_CHAPTER_KEYS = [ export type FrameworkChapterKey = (typeof FRAMEWORK_CHAPTER_KEYS)[number]; +/** 与后端 `CHAPTER_ORDER` 一致(用于占位章节缺省 category) */ +export const CHAPTER_CATEGORY_BY_ORDER_INDEX = [ + 'childhood', + 'education', + 'career_early', + 'career_achievement', + 'career_challenge', + 'family', + 'beliefs', + 'summary', +] as const; + export function buildFrameworkChapterPlaceholders( tr: (key: string) => string, ): ChapterViewModel[] { diff --git a/app-expo/src/i18n/generated/resources.ts b/app-expo/src/i18n/generated/resources.ts index 06a321b..a1481f9 100644 --- a/app-expo/src/i18n/generated/resources.ts +++ b/app-expo/src/i18n/generated/resources.ts @@ -150,6 +150,9 @@ interface Resources { "settings": "Settings", "typography": "Typography" }, + "draftingGenerating": "Writing your memoir…", + "draftingInviteChat": "Chat about this chapter to get started", + "draftingWordsRemaining": "{{count}} characters to go", "emptySubtitle": "Chat with your companion to record your stories", "emptyTitle": "No memoir yet", "frameworkChapters": { diff --git a/app-expo/src/i18n/locales/en/memoir.json b/app-expo/src/i18n/locales/en/memoir.json index 7c072ec..ede9f08 100644 --- a/app-expo/src/i18n/locales/en/memoir.json +++ b/app-expo/src/i18n/locales/en/memoir.json @@ -40,5 +40,8 @@ "statusDrafting": "Drafting", "statusLocked": "Locked", "statusPending": "Pending", + "draftingInviteChat": "Chat about this chapter to get started", + "draftingGenerating": "Writing your memoir…", + "draftingWordsRemaining": "{{count}} characters to go", "wordsCount": "{{count}} words" } diff --git a/app-expo/src/i18n/locales/zh/memoir.json b/app-expo/src/i18n/locales/zh/memoir.json index b69c8f6..382d72c 100644 --- a/app-expo/src/i18n/locales/zh/memoir.json +++ b/app-expo/src/i18n/locales/zh/memoir.json @@ -40,5 +40,8 @@ "statusDrafting": "撰写中", "statusLocked": "已锁定", "statusPending": "待解锁", + "draftingInviteChat": "聊聊这部分内容吧", + "draftingGenerating": "正在生成回忆录…", + "draftingWordsRemaining": "还差 {{count}} 字", "wordsCount": "{{count}} 字" } diff --git a/app-expo/tests/features/memoir/draft-progress.test.ts b/app-expo/tests/features/memoir/draft-progress.test.ts new file mode 100644 index 0000000..7916f79 --- /dev/null +++ b/app-expo/tests/features/memoir/draft-progress.test.ts @@ -0,0 +1,49 @@ +import { + chapterCategoryToInterviewStage, + memoirDraftCharsRemaining, + memoirDraftHasStarted, + MIN_CHAPTER_DISPLAY_CHARS, + resolvedChapterCategory, +} from '@/features/memoir/draft-progress'; + +describe('draft-progress', () => { + test('chapterCategoryToInterviewStage maps career chapters to career', () => { + expect(chapterCategoryToInterviewStage('career_early')).toBe('career'); + expect(chapterCategoryToInterviewStage('career_achievement')).toBe('career'); + }); + + test('chapterCategoryToInterviewStage maps beliefs and summary to belief', () => { + expect(chapterCategoryToInterviewStage('beliefs')).toBe('belief'); + expect(chapterCategoryToInterviewStage('summary')).toBe('belief'); + }); + + test('resolvedChapterCategory falls back to order index', () => { + expect( + resolvedChapterCategory({ category: '', orderIndex: 2 }), + ).toBe('career_early'); + }); + + test('memoirDraftHasStarted when interview slots have snippet', () => { + const slots = { + childhood: { place: { snippet: '老家在小城', segment_ids: [] } }, + }; + expect( + memoirDraftHasStarted(slots, 'childhood', 0), + ).toBe(true); + }); + + test('memoirDraftHasStarted when word count positive', () => { + expect(memoirDraftHasStarted({}, 'childhood', 12)).toBe(true); + }); + + test('memoirDraftCharsRemaining caps at zero', () => { + expect(memoirDraftCharsRemaining(MIN_CHAPTER_DISPLAY_CHARS)).toBe(0); + expect(memoirDraftCharsRemaining(MIN_CHAPTER_DISPLAY_CHARS + 50)).toBe(0); + }); + + test('memoirDraftCharsRemaining subtracts from threshold', () => { + expect(memoirDraftCharsRemaining(100)).toBe( + MIN_CHAPTER_DISPLAY_CHARS - 100, + ); + }); +});