From e22a5fccb1ea1fb74e1fcaea4feabbe64ba46160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=9B=BA=E6=89=A7=E7=9A=84=E9=B1=BC?= <1504947133@qq.com> Date: Wed, 19 Aug 2026 14:32:50 +0800 Subject: [PATCH 1/7] feat(schedule): simplify calendar home and detail chrome Keep the selected date beside account controls, connect occurrence cards with a timeline rail, and drop type badges from the agenda and detail sheets. --- .../LocationScheduleDetailSheet.tsx | 1 - .../presentation/LocationScheduleRow.tsx | 15 +- .../presentation/ScheduleCalendarScreen.tsx | 45 +++--- .../presentation/ScheduleDetailSheet.tsx | 32 ++-- .../ScheduleOccurrenceDetailSheet.tsx | 17 ++- .../presentation/ScheduleOccurrenceRow.tsx | 143 +++++++++++++----- .../schedule/presentation/scheduleDisplay.ts | 20 +++ .../ScheduleCalendarScreen.test.tsx | 25 ++- .../presentation/ScheduleDetailSheet.test.tsx | 11 +- .../presentation/ScheduleRows.test.tsx | 42 ++++- .../presentation/scheduleDisplay.test.ts | 38 +++++ 11 files changed, 280 insertions(+), 109 deletions(-) create mode 100644 frontend/tests/unit/features/schedule/presentation/scheduleDisplay.test.ts diff --git a/frontend/src/features/schedule/presentation/LocationScheduleDetailSheet.tsx b/frontend/src/features/schedule/presentation/LocationScheduleDetailSheet.tsx index 908b43f5..4dd14cad 100644 --- a/frontend/src/features/schedule/presentation/LocationScheduleDetailSheet.tsx +++ b/frontend/src/features/schedule/presentation/LocationScheduleDetailSheet.tsx @@ -33,7 +33,6 @@ export function LocationScheduleDetailSheet({ return ( - - 位置日程 - {item.title} @@ -53,15 +50,7 @@ function LocationPinIcon() { } const styles = StyleSheet.create({ - badge: { - alignSelf: 'flex-start', - backgroundColor: colors.accent, - borderRadius: 999, - paddingHorizontal: spacing.sm, - paddingVertical: 2, - }, - badgeText: { color: colors.text, fontSize: 11, fontWeight: '700' }, - copy: { flex: 1, gap: 6, minWidth: 0 }, + copy: { flex: 1, gap: 4, minWidth: 0 }, iconWrap: { alignItems: 'center', backgroundColor: colors.accent, diff --git a/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx b/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx index 84f81a24..b573b0e5 100644 --- a/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx +++ b/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx @@ -13,6 +13,7 @@ import { LocationScheduleRow } from './LocationScheduleRow'; import { MonthCalendar } from './MonthCalendar'; import { ScheduleOccurrenceDetailSheet } from './ScheduleOccurrenceDetailSheet'; import { ScheduleOccurrenceRow } from './ScheduleOccurrenceRow'; +import { emptyAgendaMessage, formatAgendaSectionTitle } from './scheduleDisplay'; import { useScheduleCalendar } from './useScheduleCalendar'; import type { CalendarFocusTarget } from './calendarFocus'; @@ -55,6 +56,8 @@ export function ScheduleCalendarScreen({ const [selectedOccurrence, setSelectedOccurrence] = useState(null); const [selectedLocation, setSelectedLocation] = useState(null); const selectedLabel = SELECTED_DATE_FORMATTER.format(calendar.selectedDate); + const agendaTitle = formatAgendaSectionTitle(calendar.selectedDate); + const emptyAgenda = emptyAgendaMessage(calendar.selectedDate); const displayUsername = username.trim() || '用户'; const avatarInitial = Array.from(displayUsername)[0]?.toLocaleUpperCase() ?? '用'; @@ -68,7 +71,9 @@ export function ScheduleCalendarScreen({ - 我的日程 + + {selectedLabel} + @@ -103,9 +108,6 @@ export function ScheduleCalendarScreen({ - - {selectedLabel} - - - 当日安排 - 日程 - + {agendaTitle} {calendar.selectedOccurrences.length} 项 {calendar.selectedOccurrences.length === 0 ? ( - 这一天暂时没有日程 - 留一点时间给自己,或用语音助手添加安排。 + {emptyAgenda.title} + {emptyAgenda.detail ? ( + {emptyAgenda.detail} + ) : null} ) : ( - calendar.selectedOccurrences.map((item) => ( + calendar.selectedOccurrences.map((item, index) => ( setSelectedOccurrence(item)} /> @@ -161,10 +163,7 @@ export function ScheduleCalendarScreen({ {calendar.locationSchedules.length > 0 ? ( - - 位置触发 - 地点提醒 - + 地点提醒 {calendar.locationSchedules.length} 项 {calendar.locationSchedules.map((item) => ( @@ -210,11 +209,11 @@ function LogoutIcon() { const styles = StyleSheet.create({ accountActions: { alignItems: 'center', - flex: 1, flexDirection: 'row', + flexShrink: 1, gap: spacing.sm, justifyContent: 'flex-end', - marginLeft: spacing.sm, + marginLeft: 'auto', maxWidth: 240, minWidth: 0, }, @@ -260,7 +259,6 @@ const styles = StyleSheet.create({ }, emptyTitle: { color: colors.text, fontSize: 15, fontWeight: '700' }, error: { color: colors.error, fontSize: 15, textAlign: 'center' }, - eyebrow: { color: colors.mutedText, fontSize: 13, fontWeight: '700' }, header: { paddingBottom: spacing.lg, paddingHorizontal: spacing.lg, @@ -271,6 +269,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', justifyContent: 'space-between', minWidth: 0, + width: '100%', }, locationSection: { borderTopColor: colors.border, @@ -288,7 +287,6 @@ const styles = StyleSheet.create({ screen: { backgroundColor: colors.background, flex: 1 }, scrollContent: { paddingBottom: spacing.lg }, sectionCount: { color: colors.mutedText, fontSize: 12, fontWeight: '600' }, - sectionEyebrow: { color: colors.mutedText, fontSize: 12, fontWeight: '600', marginBottom: 3 }, sectionHeader: { alignItems: 'flex-end', flexDirection: 'row', @@ -309,7 +307,14 @@ const styles = StyleSheet.create({ }, signOutButtonPressed: { opacity: 0.62 }, stateText: { color: colors.mutedText }, - title: { color: colors.text, fontSize: 28, fontWeight: '800', lineHeight: 34, marginTop: 4 }, + title: { + color: colors.text, + flex: 1, + fontSize: 28, + fontWeight: '800', + lineHeight: 34, + minWidth: 0, + }, userPill: { alignItems: 'center', backgroundColor: colors.surface, diff --git a/frontend/src/features/schedule/presentation/ScheduleDetailSheet.tsx b/frontend/src/features/schedule/presentation/ScheduleDetailSheet.tsx index bb468c9f..997233ec 100644 --- a/frontend/src/features/schedule/presentation/ScheduleDetailSheet.tsx +++ b/frontend/src/features/schedule/presentation/ScheduleDetailSheet.tsx @@ -4,13 +4,11 @@ import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-nati import { colors, spacing } from '../../../shared/ui/theme'; export function ScheduleDetailSheet({ - badges, children, onClose, title, visible, }: { - badges: readonly string[]; children: ReactNode; onClose: () => void; title: string; @@ -36,15 +34,6 @@ export function ScheduleDetailSheet({ {title} - - {badges.map((badge, index) => ( - - - {badge} - - - ))} - {children} @@ -121,14 +110,6 @@ const styles = StyleSheet.create({ flex: 1, justifyContent: 'flex-end', }, - badge: { - backgroundColor: colors.input, - borderRadius: 999, - paddingHorizontal: 11, - paddingVertical: 6, - }, - badgeText: { color: colors.mutedText, fontSize: 12, fontWeight: '700' }, - badges: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm }, closeButton: { alignItems: 'center', backgroundColor: colors.input, @@ -170,8 +151,6 @@ const styles = StyleSheet.create({ metaIcon: { color: colors.mutedText, fontSize: 15 }, metaText: { color: colors.mutedText, flex: 1, fontSize: 12 }, pressed: { opacity: 0.62 }, - primaryBadge: { backgroundColor: colors.accent }, - primaryBadgeText: { color: colors.text }, section: { alignItems: 'flex-start', backgroundColor: colors.background, @@ -189,9 +168,18 @@ const styles = StyleSheet.create({ borderRadius: 12, height: 38, justifyContent: 'center', + overflow: 'hidden', width: 38, }, - sectionIconText: { color: colors.text, fontSize: 18, fontWeight: '700' }, + sectionIconText: { + color: colors.text, + fontSize: 18, + height: 18, + includeFontPadding: false, + lineHeight: 18, + textAlign: 'center', + textAlignVertical: 'center', + }, sectionLabel: { color: colors.mutedText, fontSize: 12, fontWeight: '700', marginBottom: 4 }, sectionPrimary: { color: colors.text, fontSize: 16, fontWeight: '700', lineHeight: 22 }, sectionSecondary: { color: colors.mutedText, fontSize: 13, lineHeight: 19, marginTop: 3 }, diff --git a/frontend/src/features/schedule/presentation/ScheduleOccurrenceDetailSheet.tsx b/frontend/src/features/schedule/presentation/ScheduleOccurrenceDetailSheet.tsx index 162d47d9..fc771306 100644 --- a/frontend/src/features/schedule/presentation/ScheduleOccurrenceDetailSheet.tsx +++ b/frontend/src/features/schedule/presentation/ScheduleOccurrenceDetailSheet.tsx @@ -41,15 +41,9 @@ export function ScheduleOccurrenceDetailSheet({ detailOccurrence.occurrenceEnd, detailOccurrence.timezone, ); - const badges = [ - detailOccurrence.scheduleCategory === 'time' ? '时间日程' : '地点日程', - detailOccurrence.recurrenceMode === 'recurring' ? '周期日程' : '一次性', - ...(detailOccurrence.isAllDay ? ['全天'] : []), - ]; return ( void; }) { const startLabel = item.isAllDay ? '全天' : formatTime(item.occurrenceStart, item.timezone); + const endLabel = + !item.isAllDay && item.occurrenceEnd ? formatTime(item.occurrenceEnd, item.timezone) : null; const isRecurring = item.recurrenceMode !== 'once'; + const hasMeta = Boolean(item.locationName) || isRecurring; return ( [styles.row, pressed && styles.pressed]} + style={({ pressed }) => [styles.row, !isLast && styles.rowFollow, pressed && styles.pressed]} + testID="schedule-occurrence-row" > - - + {startLabel} - {!item.isAllDay && item.occurrenceEnd ? ( + {endLabel ? ( - 至 {formatTime(item.occurrenceEnd, item.timezone)} + {endLabel} ) : null} - + + + + + + + {item.title} - {isRecurring ? ( - - 重复 + {hasMeta ? ( + + {item.locationName ? ( + + {item.locationName} + + ) : null} + {isRecurring ? 重复 : null} ) : null} - {item.locationName ? ( - - {item.locationName} - - ) : null} ); } const styles = StyleSheet.create({ - allDayIndicator: { backgroundColor: colors.accent }, - badge: { - alignSelf: 'flex-start', - backgroundColor: colors.accent, - borderRadius: 999, - paddingHorizontal: spacing.sm, - paddingVertical: 2, + card: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 14, + borderWidth: 1, + flex: 1, + gap: 4, + justifyContent: 'center', + minHeight: CARD_MIN_HEIGHT, + minWidth: 0, + paddingHorizontal: 14, + paddingVertical: 12, + }, + endTime: { + color: colors.mutedText, + fontSize: 12, + fontVariant: ['tabular-nums'], + fontWeight: '500', + letterSpacing: 0.2, + lineHeight: 16, + marginTop: 2, + }, + meta: { + alignItems: 'center', + flexDirection: 'row', + flexWrap: 'nowrap', + gap: spacing.sm, + minWidth: 0, + }, + metaText: { + color: colors.mutedText, + flexShrink: 1, + fontSize: 12, + fontWeight: '500', + lineHeight: 16, + minWidth: 0, }, - badgeText: { color: colors.text, fontSize: 11, fontWeight: '700' }, - copy: { flex: 1, gap: 6, minWidth: 0 }, - endTime: { color: colors.mutedText, fontSize: 11, marginTop: 3 }, - indicator: { + pressed: { opacity: 0.72, transform: [{ scale: 0.995 }] }, + rail: { + alignItems: 'center', alignSelf: 'stretch', + width: 14, + }, + railDot: { backgroundColor: colors.focus, + borderColor: colors.background, borderRadius: 999, - width: 4, + borderWidth: 3, + height: 10, + marginTop: 8, + width: 10, + zIndex: 1, + }, + railDotAllDay: { backgroundColor: colors.text }, + railLine: { + backgroundColor: colors.focus, + bottom: 0, + left: 6, + position: 'absolute', + top: 12, + width: 2, + }, + railLineLast: { bottom: 18 }, + repeat: { + color: colors.mutedText, + flexShrink: 0, + fontSize: 12, + fontWeight: '600', + lineHeight: 16, }, - location: { color: colors.mutedText, fontSize: 13 }, - pressed: { opacity: 0.72, transform: [{ scale: 0.995 }] }, row: { alignItems: 'stretch', - backgroundColor: colors.surface, - borderColor: colors.border, - borderRadius: 16, - borderWidth: 1, flexDirection: 'row', gap: 12, - marginBottom: 10, - padding: 14, }, - startTime: { color: colors.text, fontSize: 15, fontWeight: '800' }, - timeColumn: { paddingTop: 1, width: 58 }, - title: { color: colors.text, fontSize: 16, fontWeight: '700', lineHeight: 21 }, + rowFollow: { paddingBottom: 10 }, + startTime: { + color: colors.text, + fontSize: 15, + fontVariant: ['tabular-nums'], + fontWeight: '700', + letterSpacing: 0.2, + lineHeight: 20, + }, + timeColumn: { paddingTop: 6, width: 52 }, + title: { color: colors.text, fontSize: 16, fontWeight: '700', lineHeight: 22 }, }); diff --git a/frontend/src/features/schedule/presentation/scheduleDisplay.ts b/frontend/src/features/schedule/presentation/scheduleDisplay.ts index 94356173..b9325530 100644 --- a/frontend/src/features/schedule/presentation/scheduleDisplay.ts +++ b/frontend/src/features/schedule/presentation/scheduleDisplay.ts @@ -8,6 +8,26 @@ export function dateKey(date: Date): string { ).padStart(2, '0')}`; } +export function formatAgendaSectionTitle(selectedDate: Date, today: Date = new Date()): string { + if (dateKey(selectedDate) === dateKey(today)) { + return '今日安排'; + } + return `${selectedDate.getMonth() + 1}月${selectedDate.getDate()}日的安排`; +} + +export function emptyAgendaMessage(selectedDate: Date, today: Date = new Date()): { + title: string; + detail: string | null; +} { + if (dateKey(selectedDate) < dateKey(today)) { + return { title: '这一天是属于你的', detail: null }; + } + return { + title: '这一天暂时没有日程', + detail: '留一点时间给自己,或用语音助手添加安排。', + }; +} + export function dateKeyInTimezone(instant: string, timezone: string): string | null { const date = new Date(instant); if (Number.isNaN(date.getTime())) return null; diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx index 7c2f6980..3c4e85b1 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx @@ -90,7 +90,7 @@ describe('ScheduleCalendarScreen location schedules', () => { ); await waitFor(() => expect(service.getSchedulesByRange).toHaveBeenCalled()); - expect(screen.getByText('我的日程')).toBeTruthy(); + expect(screen.queryByText('我的日程')).toBeNull(); expect(screen.getByText('Z')).toBeTruthy(); expect(screen.getByText(username)).toBeTruthy(); expect(screen.queryByText(/账号:/)).toBeNull(); @@ -103,7 +103,7 @@ describe('ScheduleCalendarScreen location schedules', () => { ).toMatchObject({ flexShrink: 1, minWidth: 0 }); expect( StyleSheet.flatten(screen.getByTestId('schedule-account-actions').props.style), - ).toMatchObject({ maxWidth: 240, minWidth: 0 }); + ).toMatchObject({ marginLeft: 'auto', maxWidth: 240, minWidth: 0 }); fireEvent.press(screen.getByRole('button', { name: '退出登录' })); expect(onSignOut).toHaveBeenCalledTimes(1); @@ -122,10 +122,29 @@ describe('ScheduleCalendarScreen location schedules', () => { ); await waitFor(() => expect(screen.getByText('地点提醒')).toBeTruthy()); + expect(screen.queryByText('位置触发')).toBeNull(); + expect(screen.getByText('今日安排')).toBeTruthy(); + expect(screen.queryByText('当日安排')).toBeNull(); + expect(screen.getByText('留一点时间给自己,或用语音助手添加安排。')).toBeTruthy(); expect(screen.getByText('到公司提醒我打卡')).toBeTruthy(); const dateButton = screen.getByLabelText(/月13日$/); fireEvent.press(dateButton); + const today = new Date(); + const selectedIsPast = 13 < today.getDate(); + if (today.getDate() === 13) { + expect(screen.getByText('今日安排')).toBeTruthy(); + expect(screen.getByText('留一点时间给自己,或用语音助手添加安排。')).toBeTruthy(); + } else { + expect(screen.getByText(`${today.getMonth() + 1}月13日的安排`)).toBeTruthy(); + expect(screen.queryByText('今日安排')).toBeNull(); + if (selectedIsPast) { + expect(screen.queryByText('留一点时间给自己,或用语音助手添加安排。')).toBeNull(); + expect(screen.getByText('这一天是属于你的')).toBeTruthy(); + } else { + expect(screen.getByText('留一点时间给自己,或用语音助手添加安排。')).toBeTruthy(); + } + } expect(screen.getByText('地点提醒')).toBeTruthy(); const rangeCallsBeforeMonthChange = (service.getSchedulesByRange as jest.Mock).mock.calls .length; @@ -153,7 +172,7 @@ describe('ScheduleCalendarScreen location schedules', () => { await waitFor(() => expect(screen.getByLabelText('公司 到公司提醒我打卡')).toBeTruthy()); fireEvent.press(screen.getByLabelText('公司 到公司提醒我打卡')); - expect(screen.getByText('地点日程')).toBeTruthy(); + expect(screen.queryByText('地点日程')).toBeNull(); expect(screen.getAllByText('公司')).toHaveLength(2); expect(screen.getByText('时区 · Asia/Shanghai')).toBeTruthy(); expect(screen.getByText('到达地点时')).toBeTruthy(); diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx index ad2f6a2e..a83852af 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx @@ -42,8 +42,8 @@ describe('schedule detail sheets', () => { render( {}} />); expect(screen.getByText(timedOccurrence.title)).toBeTruthy(); - expect(screen.getByText('时间日程')).toBeTruthy(); - expect(screen.getByText('周期日程')).toBeTruthy(); + expect(screen.queryByText('时间日程')).toBeNull(); + expect(screen.queryByText('周期日程')).toBeNull(); expect(screen.getByText('2026年8月13日')).toBeTruthy(); expect(screen.getByText('星期四')).toBeTruthy(); expect(screen.getByText('09:30')).toBeTruthy(); @@ -60,8 +60,8 @@ describe('schedule detail sheets', () => { render( {}} />); expect(screen.getByText('2026年8月17日')).toBeTruthy(); - expect(screen.getByText('一次性')).toBeTruthy(); - expect(screen.getAllByText('全天')).toHaveLength(2); + expect(screen.queryByText('一次性')).toBeNull(); + expect(screen.getByText('全天')).toBeTruthy(); expect(screen.queryByText('地点')).toBeNull(); expect(screen.queryByText('提醒')).toBeNull(); }); @@ -109,7 +109,8 @@ describe('schedule detail sheets', () => { }; render(); - expect(screen.getByText('地点日程')).toBeTruthy(); + expect(screen.getByText('地点触发日程')).toBeTruthy(); + expect(screen.queryByText('地点日程')).toBeNull(); expect(screen.queryByText('未命名地点')).toBeNull(); expect(screen.queryByText('未配置')).toBeNull(); expect(screen.queryByText('地点')).toBeNull(); diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleRows.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleRows.test.tsx index 285007d0..79c14d16 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleRows.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleRows.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react-native'; import { describe, expect, it } from '@jest/globals'; +import { StyleSheet } from 'react-native'; import type { LocationScheduleView, @@ -7,6 +8,7 @@ import type { } from '../../../../../src/features/schedule/application'; import { LocationScheduleRow } from '../../../../../src/features/schedule/presentation/LocationScheduleRow'; import { ScheduleOccurrenceRow } from '../../../../../src/features/schedule/presentation/ScheduleOccurrenceRow'; +import { colors } from '../../../../../src/shared/ui/theme'; function occurrence(overrides: Partial = {}): ScheduleOccurrenceView { return { @@ -30,7 +32,36 @@ describe('ScheduleOccurrenceRow', () => { render(); expect(screen.getByText('团队周会')).toBeTruthy(); + expect(screen.getByText('14:00')).toBeTruthy(); + expect(screen.getByText('15:00')).toBeTruthy(); + expect(screen.queryByText(/至 /)).toBeNull(); expect(screen.queryByText('重复')).toBeNull(); + expect(StyleSheet.flatten(screen.getByTestId('schedule-occurrence-indicator').props.style)).toMatchObject({ + backgroundColor: colors.focus, + height: 10, + width: 10, + }); + expect(StyleSheet.flatten(screen.getByTestId('schedule-occurrence-card').props.style)).toMatchObject({ + backgroundColor: colors.surface, + minHeight: 72, + }); + }); + + it('keeps the same card height for different durations', () => { + const short = render( + , + ); + const long = render( + , + ); + + expect(StyleSheet.flatten(short.getByTestId('schedule-occurrence-card').props.style).minHeight).toBe( + StyleSheet.flatten(long.getByTestId('schedule-occurrence-card').props.style).minHeight, + ); }); it('shows a recurrence badge for a recurring schedule', () => { @@ -52,6 +83,15 @@ describe('ScheduleOccurrenceRow', () => { ); expect(screen.getByText('全天')).toBeTruthy(); + expect(StyleSheet.flatten(screen.getByTestId('schedule-occurrence-indicator').props.style)).toMatchObject({ + backgroundColor: colors.text, + height: 10, + width: 10, + }); + expect(StyleSheet.flatten(screen.getByTestId('schedule-occurrence-card').props.style)).toMatchObject({ + backgroundColor: colors.surface, + minHeight: 72, + }); }); }); @@ -69,7 +109,7 @@ describe('LocationScheduleRow', () => { render(); - expect(screen.getByText('位置日程')).toBeTruthy(); + expect(screen.queryByText('位置日程')).toBeNull(); expect(screen.getByText('到公司提醒我打卡')).toBeTruthy(); expect(screen.getByText('公司')).toBeTruthy(); expect(screen.queryByText('location')).toBeNull(); diff --git a/frontend/tests/unit/features/schedule/presentation/scheduleDisplay.test.ts b/frontend/tests/unit/features/schedule/presentation/scheduleDisplay.test.ts new file mode 100644 index 00000000..90be1140 --- /dev/null +++ b/frontend/tests/unit/features/schedule/presentation/scheduleDisplay.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from '@jest/globals'; + +import { emptyAgendaMessage, formatAgendaSectionTitle } from '../../../../../src/features/schedule/presentation/scheduleDisplay'; + +describe('formatAgendaSectionTitle', () => { + const today = new Date(2026, 7, 19); + + it('keeps 今日安排 for the selected day', () => { + expect(formatAgendaSectionTitle(new Date(2026, 7, 19), today)).toBe('今日安排'); + }); + + it('uses the calendar date for other days', () => { + expect(formatAgendaSectionTitle(new Date(2026, 7, 13), today)).toBe('8月13日的安排'); + }); +}); + +describe('emptyAgendaMessage', () => { + const today = new Date(2026, 7, 19); + const addPrompt = '留一点时间给自己,或用语音助手添加安排。'; + + it('keeps the add prompt for today and future days', () => { + expect(emptyAgendaMessage(new Date(2026, 7, 19), today)).toEqual({ + title: '这一天暂时没有日程', + detail: addPrompt, + }); + expect(emptyAgendaMessage(new Date(2026, 7, 20), today)).toEqual({ + title: '这一天暂时没有日程', + detail: addPrompt, + }); + }); + + it('omits the add prompt for past days', () => { + expect(emptyAgendaMessage(new Date(2026, 7, 13), today)).toEqual({ + title: '这一天是属于你的', + detail: null, + }); + }); +}); From ad3bf4a5d81a28c81597854bbdd407beeec25c1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=9B=BA=E6=89=A7=E7=9A=84=E9=B1=BC?= <1504947133@qq.com> Date: Wed, 19 Aug 2026 14:35:45 +0800 Subject: [PATCH 2/7] style(frontend): format schedule home UI with Prettier --- .../LocationScheduleDetailSheet.tsx | 6 +--- .../schedule/presentation/scheduleDisplay.ts | 5 +++- .../presentation/ScheduleRows.test.tsx | 30 +++++++++++-------- .../presentation/scheduleDisplay.test.ts | 5 +++- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/frontend/src/features/schedule/presentation/LocationScheduleDetailSheet.tsx b/frontend/src/features/schedule/presentation/LocationScheduleDetailSheet.tsx index 4dd14cad..ab371ad2 100644 --- a/frontend/src/features/schedule/presentation/LocationScheduleDetailSheet.tsx +++ b/frontend/src/features/schedule/presentation/LocationScheduleDetailSheet.tsx @@ -32,11 +32,7 @@ export function LocationScheduleDetailSheet({ ); return ( - + {location ? : null} {reminder ? ( { expect(screen.getByText('15:00')).toBeTruthy(); expect(screen.queryByText(/至 /)).toBeNull(); expect(screen.queryByText('重复')).toBeNull(); - expect(StyleSheet.flatten(screen.getByTestId('schedule-occurrence-indicator').props.style)).toMatchObject({ + expect( + StyleSheet.flatten(screen.getByTestId('schedule-occurrence-indicator').props.style), + ).toMatchObject({ backgroundColor: colors.focus, height: 10, width: 10, }); - expect(StyleSheet.flatten(screen.getByTestId('schedule-occurrence-card').props.style)).toMatchObject({ + expect( + StyleSheet.flatten(screen.getByTestId('schedule-occurrence-card').props.style), + ).toMatchObject({ backgroundColor: colors.surface, minHeight: 72, }); @@ -49,19 +53,15 @@ describe('ScheduleOccurrenceRow', () => { it('keeps the same card height for different durations', () => { const short = render( - , + , ); const long = render( - , + , ); - expect(StyleSheet.flatten(short.getByTestId('schedule-occurrence-card').props.style).minHeight).toBe( - StyleSheet.flatten(long.getByTestId('schedule-occurrence-card').props.style).minHeight, - ); + expect( + StyleSheet.flatten(short.getByTestId('schedule-occurrence-card').props.style).minHeight, + ).toBe(StyleSheet.flatten(long.getByTestId('schedule-occurrence-card').props.style).minHeight); }); it('shows a recurrence badge for a recurring schedule', () => { @@ -83,12 +83,16 @@ describe('ScheduleOccurrenceRow', () => { ); expect(screen.getByText('全天')).toBeTruthy(); - expect(StyleSheet.flatten(screen.getByTestId('schedule-occurrence-indicator').props.style)).toMatchObject({ + expect( + StyleSheet.flatten(screen.getByTestId('schedule-occurrence-indicator').props.style), + ).toMatchObject({ backgroundColor: colors.text, height: 10, width: 10, }); - expect(StyleSheet.flatten(screen.getByTestId('schedule-occurrence-card').props.style)).toMatchObject({ + expect( + StyleSheet.flatten(screen.getByTestId('schedule-occurrence-card').props.style), + ).toMatchObject({ backgroundColor: colors.surface, minHeight: 72, }); diff --git a/frontend/tests/unit/features/schedule/presentation/scheduleDisplay.test.ts b/frontend/tests/unit/features/schedule/presentation/scheduleDisplay.test.ts index 90be1140..c531faf8 100644 --- a/frontend/tests/unit/features/schedule/presentation/scheduleDisplay.test.ts +++ b/frontend/tests/unit/features/schedule/presentation/scheduleDisplay.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from '@jest/globals'; -import { emptyAgendaMessage, formatAgendaSectionTitle } from '../../../../../src/features/schedule/presentation/scheduleDisplay'; +import { + emptyAgendaMessage, + formatAgendaSectionTitle, +} from '../../../../../src/features/schedule/presentation/scheduleDisplay'; describe('formatAgendaSectionTitle', () => { const today = new Date(2026, 7, 19); From ede39a6f74452639912f01fead6f4163feafb0f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=9B=BA=E6=89=A7=E7=9A=84=E9=B1=BC?= <1504947133@qq.com> Date: Wed, 19 Aug 2026 14:44:18 +0800 Subject: [PATCH 3/7] test(schedule): cover timeline rows and multi-item agenda --- .../ScheduleCalendarScreen.test.tsx | 56 ++++++++++++++++++- .../presentation/ScheduleRows.test.tsx | 33 +++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx index 3c4e85b1..ea5d0277 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx @@ -2,9 +2,34 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react-nativ import { describe, expect, it, jest } from '@jest/globals'; import { StyleSheet } from 'react-native'; -import type { ScheduleCalendarReadService } from '../../../../../src/features/schedule/application'; +import type { + ScheduleCalendarReadService, + ScheduleOccurrenceView, +} from '../../../../../src/features/schedule/application'; import { ScheduleCalendarScreen } from '../../../../../src/features/schedule/presentation/ScheduleCalendarScreen'; +function occurrenceOnSelectedDay( + hourUtc: number, + overrides: Partial = {}, +): ScheduleOccurrenceView { + const now = new Date(); + const start = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), hourUtc, 0, 0)); + return { + scheduleId: 'schedule-a', + scheduleCategory: 'time', + recurrenceMode: 'once', + title: '项目例会', + isAllDay: false, + timezone: 'Asia/Shanghai', + locationName: null, + reminderType: 'before_start', + reminderStrength: 'medium', + occurrenceStart: start.toISOString(), + occurrenceEnd: new Date(start.getTime() + 60 * 60 * 1000).toISOString(), + ...overrides, + }; +} + function createService(): ScheduleCalendarReadService { return { getSchedulesByDay: jest @@ -180,4 +205,33 @@ describe('ScheduleCalendarScreen location schedules', () => { expect(screen.queryByText('编辑')).toBeNull(); expect(screen.queryByText('删除')).toBeNull(); }); + + it('connects multiple timed occurrences on a timeline and opens detail', async () => { + const first = occurrenceOnSelectedDay(1, { scheduleId: 'schedule-a', title: '项目例会' }); + const second = occurrenceOnSelectedDay(4, { scheduleId: 'schedule-b', title: '方案讨论' }); + const service = createService(); + (service.getSchedulesByRange as jest.Mock).mockResolvedValue([first, second]); + + render( + {}} + service={service} + timezone="Asia/Shanghai" + username="Sarah" + />, + ); + + await waitFor(() => expect(screen.getByText('项目例会')).toBeTruthy()); + expect(screen.getByText('方案讨论')).toBeTruthy(); + expect(screen.getByText('2 项')).toBeTruthy(); + expect( + StyleSheet.flatten(screen.getAllByTestId('schedule-occurrence-row')[0]?.props.style), + ).toMatchObject({ paddingBottom: 10 }); + + fireEvent.press(screen.getByLabelText(/项目例会$/)); + expect(screen.getByText('时区 · Asia/Shanghai')).toBeTruthy(); + fireEvent.press(screen.getByLabelText('关闭详情')); + await waitFor(() => expect(screen.queryByText('时区 · Asia/Shanghai')).toBeNull()); + }); }); diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleRows.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleRows.test.tsx index 05c8df6f..a807f13e 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleRows.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleRows.test.tsx @@ -70,6 +70,39 @@ describe('ScheduleOccurrenceRow', () => { expect(screen.getByText('重复')).toBeTruthy(); }); + it('extends the timeline rail when another occurrence follows', () => { + render(); + + expect( + StyleSheet.flatten(screen.getByTestId('schedule-occurrence-row').props.style), + ).toMatchObject({ paddingBottom: 10 }); + }); + + it('omits meta when a one-time schedule has no location', () => { + render(); + + expect(screen.queryByText('上海科技馆')).toBeNull(); + expect(screen.queryByText('重复')).toBeNull(); + }); + + it('shows repeating without a location line', () => { + render( + , + ); + + expect(screen.getByText('重复')).toBeTruthy(); + expect(screen.queryByText('上海科技馆')).toBeNull(); + }); + + it('hides the end time when a timed occurrence has no end', () => { + render(); + + expect(screen.getByText('14:00')).toBeTruthy(); + expect(screen.queryByText('15:00')).toBeNull(); + }); + it('shows an explicit all-day label for an all-day schedule', () => { render( Date: Wed, 19 Aug 2026 14:46:43 +0800 Subject: [PATCH 4/7] fix(test): type the calendar occurrence list mock --- .../presentation/ScheduleCalendarScreen.test.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx index ea5d0277..df5bd6b3 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx @@ -30,14 +30,16 @@ function occurrenceOnSelectedDay( }; } -function createService(): ScheduleCalendarReadService { +function createService( + occurrences: readonly ScheduleOccurrenceView[] = [], +): ScheduleCalendarReadService { return { getSchedulesByDay: jest .fn() .mockResolvedValue([]), getSchedulesByRange: jest .fn() - .mockResolvedValue([]), + .mockResolvedValue(occurrences), getLocationSchedules: jest .fn() .mockResolvedValue([ @@ -209,8 +211,7 @@ describe('ScheduleCalendarScreen location schedules', () => { it('connects multiple timed occurrences on a timeline and opens detail', async () => { const first = occurrenceOnSelectedDay(1, { scheduleId: 'schedule-a', title: '项目例会' }); const second = occurrenceOnSelectedDay(4, { scheduleId: 'schedule-b', title: '方案讨论' }); - const service = createService(); - (service.getSchedulesByRange as jest.Mock).mockResolvedValue([first, second]); + const service = createService([first, second]); render( Date: Wed, 19 Aug 2026 14:53:15 +0800 Subject: [PATCH 5/7] fix(schedule): keep the agenda rail continuous and cards equal height Extend the timeline through row gaps and give occurrence cards a fixed height so wrapping titles do not change the list rhythm. --- .../presentation/ScheduleOccurrenceRow.tsx | 25 +++++++++---- .../presentation/ScheduleRows.test.tsx | 35 +++++++++++++++---- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/frontend/src/features/schedule/presentation/ScheduleOccurrenceRow.tsx b/frontend/src/features/schedule/presentation/ScheduleOccurrenceRow.tsx index 58fc346a..204808f0 100644 --- a/frontend/src/features/schedule/presentation/ScheduleOccurrenceRow.tsx +++ b/frontend/src/features/schedule/presentation/ScheduleOccurrenceRow.tsx @@ -4,7 +4,8 @@ import type { ScheduleOccurrenceView } from '../application'; import { colors, spacing } from '../../../shared/ui/theme'; import { formatRange, formatTime } from './scheduleDisplay'; -const CARD_MIN_HEIGHT = 72; +const CARD_HEIGHT = 88; +const ROW_FOLLOW_GAP = 10; export function ScheduleOccurrenceRow({ item, @@ -41,7 +42,10 @@ export function ScheduleOccurrenceRow({ - + { StyleSheet.flatten(screen.getByTestId('schedule-occurrence-card').props.style), ).toMatchObject({ backgroundColor: colors.surface, - minHeight: 72, + height: 88, }); }); - it('keeps the same card height for different durations', () => { + it('keeps the same card height for different durations and content', () => { const short = render( - , + , ); const long = render( - , + , ); expect( - StyleSheet.flatten(short.getByTestId('schedule-occurrence-card').props.style).minHeight, - ).toBe(StyleSheet.flatten(long.getByTestId('schedule-occurrence-card').props.style).minHeight); + StyleSheet.flatten(short.getByTestId('schedule-occurrence-card').props.style).height, + ).toBe(88); + expect( + StyleSheet.flatten(long.getByTestId('schedule-occurrence-card').props.style).height, + ).toBe(StyleSheet.flatten(short.getByTestId('schedule-occurrence-card').props.style).height); }); it('shows a recurrence badge for a recurring schedule', () => { @@ -76,6 +86,17 @@ describe('ScheduleOccurrenceRow', () => { expect( StyleSheet.flatten(screen.getByTestId('schedule-occurrence-row').props.style), ).toMatchObject({ paddingBottom: 10 }); + expect( + StyleSheet.flatten(screen.getByTestId('schedule-occurrence-rail-line').props.style), + ).toMatchObject({ bottom: -10, top: 12 }); + }); + + it('stops the timeline rail at the last occurrence', () => { + render(); + + expect( + StyleSheet.flatten(screen.getByTestId('schedule-occurrence-rail-line').props.style), + ).toMatchObject({ bottom: 18 }); }); it('omits meta when a one-time schedule has no location', () => { @@ -127,7 +148,7 @@ describe('ScheduleOccurrenceRow', () => { StyleSheet.flatten(screen.getByTestId('schedule-occurrence-card').props.style), ).toMatchObject({ backgroundColor: colors.surface, - minHeight: 72, + height: 88, }); }); }); From eee992ad599a44d40cbc2808af47f60d8e13b45b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=9B=BA=E6=89=A7=E7=9A=84=E9=B1=BC?= <1504947133@qq.com> Date: Thu, 20 Aug 2026 16:03:59 +0800 Subject: [PATCH 6/7] fix(test): include required category on calendar occurrence fixtures Merging main made ScheduleOccurrenceView.category required, so the calendar screen factory no longer typechecked. --- .../schedule/presentation/ScheduleCalendarScreen.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx index 1665768a..2632e1d8 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx @@ -17,6 +17,7 @@ function occurrenceOnSelectedDay( return { scheduleId: 'schedule-a', scheduleCategory: 'time', + category: null, recurrenceMode: 'once', title: '项目例会', isAllDay: false, From 7bce85354a8266dfd71682f9b3f3a7da99ae7c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=9B=BA=E6=89=A7=E7=9A=84=E9=B1=BC?= <1504947133@qq.com> Date: Thu, 20 Aug 2026 16:17:45 +0800 Subject: [PATCH 7/7] fix(schedule): keep category off the agenda list Category belongs in the detail sheet as copy, not next to the list location. --- .../schedule/presentation/LocationScheduleRow.tsx | 7 ------- .../presentation/ScheduleOccurrenceRow.tsx | 10 ++-------- .../presentation/ScheduleCalendarScreen.test.tsx | 6 ++++-- .../schedule/presentation/ScheduleRows.test.tsx | 14 ++++++++------ 4 files changed, 14 insertions(+), 23 deletions(-) diff --git a/frontend/src/features/schedule/presentation/LocationScheduleRow.tsx b/frontend/src/features/schedule/presentation/LocationScheduleRow.tsx index 650ce645..01b513da 100644 --- a/frontend/src/features/schedule/presentation/LocationScheduleRow.tsx +++ b/frontend/src/features/schedule/presentation/LocationScheduleRow.tsx @@ -3,7 +3,6 @@ import Svg, { Circle, Path } from 'react-native-svg'; import type { LocationScheduleView } from '../application'; import { colors } from '../../../shared/ui/theme'; -import { scheduleCategoryLabel } from './scheduleDisplay'; export function LocationScheduleRow({ item, @@ -13,7 +12,6 @@ export function LocationScheduleRow({ onPress?: () => void; }) { const locationLabel = item.locationName ?? '地点触发'; - const categoryLabel = scheduleCategoryLabel(item.category); return ( {locationLabel} - {categoryLabel ? ( - - {categoryLabel} - - ) : null} ); diff --git a/frontend/src/features/schedule/presentation/ScheduleOccurrenceRow.tsx b/frontend/src/features/schedule/presentation/ScheduleOccurrenceRow.tsx index ee74ff0f..204808f0 100644 --- a/frontend/src/features/schedule/presentation/ScheduleOccurrenceRow.tsx +++ b/frontend/src/features/schedule/presentation/ScheduleOccurrenceRow.tsx @@ -2,7 +2,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'; import type { ScheduleOccurrenceView } from '../application'; import { colors, spacing } from '../../../shared/ui/theme'; -import { formatRange, formatTime, scheduleCategoryLabel } from './scheduleDisplay'; +import { formatRange, formatTime } from './scheduleDisplay'; const CARD_HEIGHT = 88; const ROW_FOLLOW_GAP = 10; @@ -20,8 +20,7 @@ export function ScheduleOccurrenceRow({ const endLabel = !item.isAllDay && item.occurrenceEnd ? formatTime(item.occurrenceEnd, item.timezone) : null; const isRecurring = item.recurrenceMode !== 'once'; - const categoryLabel = scheduleCategoryLabel(item.category); - const hasMeta = Boolean(item.locationName) || isRecurring || Boolean(categoryLabel); + const hasMeta = Boolean(item.locationName) || isRecurring; return ( ) : null} - {categoryLabel ? ( - - {categoryLabel} - - ) : null} {isRecurring ? 重复 : null} ) : null} diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx index 2632e1d8..ab99107d 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx @@ -259,7 +259,8 @@ describe('ScheduleCalendarScreen location schedules', () => { view.rerender(); await waitFor(() => expect(getSchedulesByRange).toHaveBeenCalledTimes(2)); - await waitFor(() => expect(screen.getAllByText('工作')).toHaveLength(2)); + await waitFor(() => expect(screen.getAllByText('工作')).toHaveLength(1)); + expect(screen.getByText('分类')).toBeTruthy(); expect(screen.getByText('日程详情')).toBeTruthy(); fireEvent.press(screen.getByRole('button', { name: '关闭详情' })); @@ -302,7 +303,8 @@ describe('ScheduleCalendarScreen location schedules', () => { view.rerender(); await waitFor(() => expect(getLocationSchedules).toHaveBeenCalledTimes(2)); - await waitFor(() => expect(screen.getAllByText('学习')).toHaveLength(2)); + await waitFor(() => expect(screen.getAllByText('学习')).toHaveLength(1)); + expect(screen.getByText('分类')).toBeTruthy(); expect(screen.getByText('日程详情')).toBeTruthy(); }); diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleRows.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleRows.test.tsx index 15cd6208..c4c660ec 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleRows.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleRows.test.tsx @@ -125,12 +125,14 @@ describe('ScheduleOccurrenceRow', () => { expect(screen.queryByText('15:00')).toBeNull(); }); - it('shows localized work and study category labels', () => { - const view = render(); + it('does not show category on the list even when category is set', () => { + render(); - expect(screen.getByText('工作')).toBeTruthy(); - view.rerender(); - expect(screen.getByText('学习')).toBeTruthy(); + expect(screen.getByText('团队周会')).toBeTruthy(); + expect(screen.getByText('上海科技馆')).toBeTruthy(); + expect(screen.queryByText('工作')).toBeNull(); + expect(screen.queryByText('学习')).toBeNull(); + expect(screen.queryByText('未分类')).toBeNull(); }); it('does not show a category label while category is null', () => { @@ -195,7 +197,7 @@ describe('LocationScheduleRow', () => { render(); expect(screen.queryByText('位置日程')).toBeNull(); - expect(screen.getByText('工作')).toBeTruthy(); + expect(screen.queryByText('工作')).toBeNull(); expect(screen.getByText('到公司提醒我打卡')).toBeTruthy(); expect(screen.getByText('公司')).toBeTruthy(); expect(screen.queryByText('location')).toBeNull();