diff --git a/frontend/src/features/schedule/presentation/LocationScheduleDetailSheet.tsx b/frontend/src/features/schedule/presentation/LocationScheduleDetailSheet.tsx index 29da8cef..343f4186 100644 --- a/frontend/src/features/schedule/presentation/LocationScheduleDetailSheet.tsx +++ b/frontend/src/features/schedule/presentation/LocationScheduleDetailSheet.tsx @@ -34,12 +34,8 @@ export function LocationScheduleDetailSheet({ const categoryLabel = scheduleCategoryLabel(detailSchedule.category); return ( - + + {categoryLabel ? : null} {location ? : null} {reminder ? ( void; }) { const locationLabel = item.locationName ?? '地点触发'; - const categoryLabel = scheduleCategoryLabel(item.category); return ( - - {categoryLabel ? ( - - {categoryLabel} - - ) : null} - - 位置日程 - - {item.title} @@ -62,18 +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' }, - badges: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs }, - categoryBadge: { backgroundColor: colors.input }, - categoryBadgeText: { color: colors.mutedText, 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 9bbaad79..079abd23 100644 --- a/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx +++ b/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx @@ -9,6 +9,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'; @@ -56,6 +57,8 @@ export function ScheduleCalendarScreen({ const selectedLocation = calendar.locationSchedules.find((item) => item.scheduleId === selectedLocationId) ?? 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() ?? '用'; @@ -69,7 +72,9 @@ export function ScheduleCalendarScreen({ - 我的日程 + + {selectedLabel} + @@ -104,9 +109,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) => ( setSelectedOccurrenceKey(occurrenceKey(item))} /> @@ -162,10 +164,7 @@ export function ScheduleCalendarScreen({ {calendar.locationSchedules.length > 0 ? ( - - 位置触发 - 地点提醒 - + 地点提醒 {calendar.locationSchedules.length} 项 {calendar.locationSchedules.map((item) => ( @@ -215,11 +214,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, }, @@ -265,7 +264,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, @@ -276,6 +274,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', justifyContent: 'space-between', minWidth: 0, + width: '100%', }, locationSection: { borderTopColor: colors.border, @@ -293,7 +292,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', @@ -314,7 +312,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 6d1c0f54..1b0496d6 100644 --- a/frontend/src/features/schedule/presentation/ScheduleOccurrenceDetailSheet.tsx +++ b/frontend/src/features/schedule/presentation/ScheduleOccurrenceDetailSheet.tsx @@ -42,16 +42,9 @@ export function ScheduleOccurrenceDetailSheet({ detailOccurrence.timezone, ); const categoryLabel = scheduleCategoryLabel(detailOccurrence.category); - const badges = [ - ...(categoryLabel ? [categoryLabel] : []), - detailOccurrence.scheduleCategory === 'time' ? '时间日程' : '地点日程', - detailOccurrence.recurrenceMode === 'recurring' ? '周期日程' : '一次性', - ...(detailOccurrence.isAllDay ? ['全天'] : []), - ]; return ( )} + {categoryLabel ? : null} {location ? : null} {reminder ? ( 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 categoryLabel = scheduleCategoryLabel(item.category); + 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} - {categoryLabel || isRecurring ? ( - - {categoryLabel ? ( - - {categoryLabel} - - ) : null} - {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, + height: CARD_HEIGHT, + justifyContent: 'center', + minWidth: 0, + overflow: 'hidden', + 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' }, - badges: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs }, - categoryBadge: { backgroundColor: colors.input }, - categoryBadgeText: { color: colors.mutedText, 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', + overflow: 'visible', + 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, + left: 6, + position: 'absolute', + top: 12, + width: 2, + }, + railLineFollow: { bottom: -ROW_FOLLOW_GAP }, + 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, + overflow: 'visible', + }, + rowFollow: { paddingBottom: ROW_FOLLOW_GAP }, + 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', + includeFontPadding: false, + lineHeight: 22, }, - startTime: { color: colors.text, fontSize: 15, fontWeight: '800' }, - timeColumn: { paddingTop: 1, width: 58 }, - title: { color: colors.text, fontSize: 16, fontWeight: '700', lineHeight: 21 }, }); diff --git a/frontend/src/features/schedule/presentation/scheduleDisplay.ts b/frontend/src/features/schedule/presentation/scheduleDisplay.ts index a5418271..13464340 100644 --- a/frontend/src/features/schedule/presentation/scheduleDisplay.ts +++ b/frontend/src/features/schedule/presentation/scheduleDisplay.ts @@ -24,6 +24,29 @@ 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 dbf97536..ab99107d 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx @@ -2,17 +2,45 @@ 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 createService(): ScheduleCalendarReadService { +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', + category: null, + 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( + occurrences: readonly ScheduleOccurrenceView[] = [], +): ScheduleCalendarReadService { return { getSchedulesByDay: jest .fn() .mockResolvedValue([]), getSchedulesByRange: jest .fn() - .mockResolvedValue([]), + .mockResolvedValue(occurrences), getLocationSchedules: jest .fn() .mockResolvedValue([ @@ -91,7 +119,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(); @@ -104,7 +132,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); @@ -123,10 +151,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; @@ -154,7 +201,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(); @@ -212,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: '关闭详情' })); @@ -255,7 +303,36 @@ 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(); }); + + 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([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/ScheduleDetailSheet.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx index 365d8d02..e13ca8f7 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx @@ -45,8 +45,9 @@ describe('schedule detail sheets', () => { expect(screen.getByText(timedOccurrence.title)).toBeTruthy(); expect(screen.getByText('工作')).toBeTruthy(); - expect(screen.getByText('时间日程')).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(); @@ -63,8 +64,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(); expect(screen.queryByText('工作')).toBeNull(); @@ -115,8 +116,10 @@ describe('schedule detail sheets', () => { }; render(); - expect(screen.getByText('地点日程')).toBeTruthy(); + expect(screen.getByText('地点触发日程')).toBeTruthy(); 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 26d5b297..c4c660ec 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 { @@ -31,7 +33,46 @@ 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, + height: 88, + }); + }); + + 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).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', () => { @@ -40,15 +81,61 @@ describe('ScheduleOccurrenceRow', () => { expect(screen.getByText('重复')).toBeTruthy(); }); - it('shows localized work and study category badges', () => { - const view = render(); + it('extends the timeline rail when another occurrence follows', () => { + render(); - expect(screen.getByText('工作')).toBeTruthy(); - view.rerender(); - expect(screen.getByText('学习')).toBeTruthy(); + 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('does not show a category badge while category is null', () => { + 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', () => { + 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('does not show category on the list even when category is set', () => { + render(); + + 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', () => { render(); expect(screen.queryByText('工作')).toBeNull(); @@ -56,7 +143,7 @@ describe('ScheduleOccurrenceRow', () => { expect(screen.queryByText('未分类')).toBeNull(); }); - it('shows only the recurring badge for an unclassified recurring schedule', () => { + it('shows only repeating for an unclassified recurring schedule', () => { render( , ); @@ -78,6 +165,19 @@ 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, + height: 88, + }); }); }); @@ -96,8 +196,8 @@ describe('LocationScheduleRow', () => { render(); - expect(screen.getByText('位置日程')).toBeTruthy(); - expect(screen.getByText('工作')).toBeTruthy(); + expect(screen.queryByText('位置日程')).toBeNull(); + expect(screen.queryByText('工作')).toBeNull(); expect(screen.getByText('到公司提醒我打卡')).toBeTruthy(); expect(screen.getByText('公司')).toBeTruthy(); expect(screen.queryByText('location')).toBeNull(); @@ -120,7 +220,7 @@ describe('LocationScheduleRow', () => { render(); - 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/scheduleDisplay.test.ts b/frontend/tests/unit/features/schedule/presentation/scheduleDisplay.test.ts new file mode 100644 index 00000000..c531faf8 --- /dev/null +++ b/frontend/tests/unit/features/schedule/presentation/scheduleDisplay.test.ts @@ -0,0 +1,41 @@ +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, + }); + }); +});