feat: redesign day picker with expandable calendar, status markers & Playwright E2E testing

- Replace modal calendar with inline expandable calendar widget integrated into the day picker.
- Add status indicator dots (attended/absent/pending) to month calendar grid matching day picker.
- Disable and gray out weekend days in calendar when weekend schedule is turned off.
- Redesign extend bar with integrated handle that aligns with the palette.
- Remove start-to-end timing on Today screen's class time rail, showing only start time.
- Implement Playwright E2E and visual testing suite with multi-screen capture scripts.
- Add cascade DELETE endpoints for subjects and versioned recurring timetable entries.
This commit is contained in:
root
2026-09-05 08:58:51 +05:30
parent e6884a148b
commit 693cc1c0b6
39 changed files with 1350 additions and 129 deletions

View File

@@ -13,7 +13,7 @@ import {
radius,
spacing,
} from '@/components/ui';
import { dateFromKey, formatDayHeading, timeToMinutes } from '@/lib/date';
import { dateFromKey, formatDayHeading, formatWeekdayLong, timeToMinutes } from '@/lib/date';
import { subjectToneFor } from '@/lib/design';
import { collegeApi, type Subject } from '@/lib/api';
@@ -112,7 +112,7 @@ export function AddClassModal({ visible, onClose, onAdded, date, regular = false
const title = regular ? 'Add recurring class' : 'Add a class';
const context = regular
? `Repeats on ${new Intl.DateTimeFormat(undefined, { weekday: 'long' }).format(dateFromKey(date))}. Recurring changes begin in the future.`
? `Repeats on ${formatWeekdayLong(dateFromKey(date))}. Recurring changes begin in the future.`
: `One-off class for ${formatDayHeading(dateFromKey(date))}.`;
return <BottomSheet
@@ -167,7 +167,7 @@ const styles = StyleSheet.create({
loading: { minHeight: 180, alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
sectionLabel: { marginTop: spacing[6], marginBottom: spacing[3] },
subjects: { gap: spacing[2] },
subject: { minHeight: 58, flexDirection: 'row', alignItems: 'center', gap: spacing[3], paddingHorizontal: spacing[3], paddingVertical: spacing[2], borderRadius: radius.card, borderWidth: 1, borderColor: colors.neutral.border, backgroundColor: colors.neutral.surface },
subject: { minHeight: 58, flexDirection: 'row', alignItems: 'center', gap: spacing[3], paddingHorizontal: spacing[3], paddingVertical: spacing[2], borderRadius: radius.card, borderCurve: 'continuous', borderWidth: 1, borderColor: colors.neutral.border, backgroundColor: colors.neutral.surface },
pressed: { opacity: 0.76 },
subjectCopy: { flex: 1 },
radio: { width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: colors.neutral.border, alignItems: 'center', justifyContent: 'center' },

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { AppText } from './app-text';
import { colors, size, spacing } from './tokens';
import { colors, radius, size, spacing } from './tokens';
type Props = {
title: string;
@@ -9,33 +9,46 @@ type Props = {
subtitle?: string;
leading?: ReactNode;
trailing?: ReactNode;
avatar?: { initials: string; onPress: () => void };
compact?: boolean;
style?: StyleProp<ViewStyle>;
};
export function AppHeader({ title, context, subtitle, leading, trailing, compact = false, style }: Props) {
export function AppHeader({ title, context, subtitle, leading, trailing, avatar, compact = false, style }: Props) {
if (compact) return <View style={[styles.compact, style]}>
<View style={styles.side}>{leading}</View>
<AppText variant="title" numberOfLines={1} style={styles.compactTitle}>{title}</AppText>
<View style={[styles.side, styles.trailing]}>{trailing}</View>
</View>;
const resolvedTrailing = trailing ?? (avatar ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Open account profile"
onPress={avatar.onPress}
style={({ pressed }) => [styles.avatar, pressed && styles.avatarPressed]}>
<AppText variant="label" color={colors.brand.cobalt}>{avatar.initials || '?'}</AppText>
</Pressable>
) : null);
return <View style={[styles.root, style]}>
<View style={styles.copy}>
{context ? <AppText variant="bodySmall" color={colors.neutral.textMuted}>{context}</AppText> : null}
<AppText variant="heading1" style={context ? styles.titleWithContext : undefined}>{title}</AppText>
{subtitle ? <AppText variant="bodySmall" color={colors.neutral.textSecondary} style={styles.subtitle}>{subtitle}</AppText> : null}
</View>
{trailing ? <View style={styles.rootTrailing}>{trailing}</View> : null}
{resolvedTrailing ? <View style={styles.rootTrailing}>{resolvedTrailing}</View> : null}
</View>;
}
const styles = StyleSheet.create({
root: { minHeight: 76, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[5], paddingTop: spacing[6] },
root: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[5], paddingTop: spacing[4] },
copy: { flex: 1 },
titleWithContext: { marginTop: spacing[1] },
subtitle: { marginTop: spacing[1] },
rootTrailing: { alignSelf: 'flex-start', paddingTop: spacing[1] },
avatar: { width: 40, height: 40, borderRadius: radius.control, borderCurve: 'continuous', backgroundColor: colors.brand.cobaltSoft, alignItems: 'center', justifyContent: 'center' },
avatarPressed: { opacity: 0.78 },
compact: { height: 56, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
side: { width: size.touchTargetMin, minHeight: size.touchTargetMin, justifyContent: 'center' },
trailing: { alignItems: 'flex-end' },

View File

@@ -39,7 +39,7 @@ const styles = StyleSheet.create({
overlay: { flex: 1, justifyContent: 'flex-end', backgroundColor: colors.neutral.scrim },
backdrop: { ...StyleSheet.absoluteFillObject },
keyboard: { width: '100%', maxHeight: '92%' },
sheet: { borderTopLeftRadius: radius.sheet, borderTopRightRadius: radius.sheet, backgroundColor: colors.neutral.surface, paddingHorizontal: spacing[6], paddingTop: spacing[3], ...shadow.floating },
sheet: { borderTopLeftRadius: radius.sheet, borderTopRightRadius: radius.sheet, borderCurve: 'continuous', backgroundColor: colors.neutral.surface, paddingHorizontal: spacing[6], paddingTop: spacing[3], ...shadow.floating },
handle: { width: 36, height: 4, alignSelf: 'center', borderRadius: radius.pill, backgroundColor: colors.neutral.border },
header: { minHeight: 60, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[4] },
body: { paddingBottom: spacing[5] },

View File

@@ -47,10 +47,10 @@ export function BottomTabBar({ state, descriptors, navigation, insets }: BottomT
}
const styles = StyleSheet.create({
floatingArea: { position: 'absolute', left: 0, right: 0, bottom: 0, paddingHorizontal: spacing[5], paddingTop: spacing[3], backgroundColor: 'transparent' },
bar: { minHeight: size.touchTargetMin + spacing[3], flexDirection: 'row', alignItems: 'center', gap: spacing[1], padding: spacing[2], borderRadius: radius.sheet, borderWidth: 1, borderColor: colors.neutral.border, backgroundColor: colors.neutral.surface, ...shadow.floating },
tab: { minHeight: size.touchTargetMin, flex: 1, flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1, borderRadius: radius.pill, paddingHorizontal: spacing[2] },
tabActive: { flexDirection: 'row', flexGrow: 1.28, gap: spacing[2], backgroundColor: colors.brand.cobalt, paddingHorizontal: spacing[4] },
label: { textAlign: 'center' },
floatingArea: { position: 'absolute', left: 0, right: 0, bottom: 0, paddingHorizontal: spacing[4], paddingTop: spacing[3], backgroundColor: 'transparent' },
bar: { minHeight: size.touchTargetMin + spacing[3], flexDirection: 'row', alignItems: 'center', gap: 2, padding: spacing[2], borderRadius: radius.sheet, borderCurve: 'continuous', borderWidth: 1, borderColor: colors.neutral.border, backgroundColor: colors.neutral.surface, ...shadow.floating },
tab: { minHeight: size.touchTargetMin, flex: 1, flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 2, borderRadius: radius.pill, borderCurve: 'continuous', paddingHorizontal: spacing[1] },
tabActive: { flexDirection: 'row', flexGrow: 1.15, gap: spacing[1] + 2, backgroundColor: colors.brand.cobalt, paddingHorizontal: spacing[3] },
label: { textAlign: 'center', flexShrink: 0 },
pressed: { opacity: 0.76 },
});

View File

@@ -50,8 +50,8 @@ export function Button({ label, onPress, variant = 'primary', size: buttonSize =
}
const styles = StyleSheet.create({
base: { minHeight: size.control, paddingHorizontal: spacing[5], borderRadius: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing[2] },
compact: { minHeight: 40, paddingHorizontal: spacing[4], borderRadius: 12 },
base: { minHeight: size.control, paddingHorizontal: spacing[5], borderRadius: 14, borderCurve: 'continuous', flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing[2] },
compact: { minHeight: 40, paddingHorizontal: spacing[4], borderRadius: 12, borderCurve: 'continuous' },
fullWidth: { alignSelf: 'stretch' },
pressed: { transform: [{ scale: motion.pressedScale }] },
disabled: { backgroundColor: colors.neutral.surfaceSubtle, borderColor: colors.neutral.surfaceSubtle },

View File

@@ -19,5 +19,5 @@ export function Card({ children, tone = 'surface', padding = spacing[5], style,
}
const styles = StyleSheet.create({
base: { borderRadius: radius.card },
base: { borderRadius: radius.card, borderCurve: 'continuous' },
});

View File

@@ -35,7 +35,7 @@ export function FormField({ label, hint, error, containerStyle, editable = true,
const styles = StyleSheet.create({
label: { marginBottom: spacing[2] },
inputWrap: { minHeight: size.control, flexDirection: 'row', alignItems: 'center', borderRadius: radius.control, borderWidth: 1, borderColor: colors.neutral.border, backgroundColor: colors.neutral.surface, paddingHorizontal: spacing[4] },
inputWrap: { minHeight: size.control, flexDirection: 'row', alignItems: 'center', borderRadius: radius.control, borderCurve: 'continuous', borderWidth: 1, borderColor: colors.neutral.border, backgroundColor: colors.neutral.surface, paddingHorizontal: spacing[4] },
inputFocused: { borderColor: colors.brand.cobalt, borderWidth: 2, paddingHorizontal: spacing[3] + 1 },
inputError: { borderColor: colors.semantic.danger.solid, borderWidth: 2, paddingHorizontal: spacing[3] + 1 },
inputDisabled: { backgroundColor: colors.neutral.surfaceSubtle },

View File

@@ -29,7 +29,7 @@ export function IconButton({ icon, label, onPress, tone = 'soft', disabled = fal
}
const styles = StyleSheet.create({
base: { width: size.touchTargetMin, height: size.touchTargetMin, borderRadius: radius.control, alignItems: 'center', justifyContent: 'center' },
base: { width: size.touchTargetMin, height: size.touchTargetMin, borderRadius: radius.control, borderCurve: 'continuous', alignItems: 'center', justifyContent: 'center' },
disabled: { opacity: 0.48 },
pressed: { transform: [{ scale: motion.pressedScale }] },
});

View File

@@ -28,7 +28,7 @@ export function InlineBanner({ title, message, tone = 'neutral', action, style }
}
const styles = StyleSheet.create({
banner: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing[3], borderRadius: radius.card, padding: spacing[4] },
banner: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing[3], borderRadius: radius.card, borderCurve: 'continuous', padding: spacing[4] },
copy: { flex: 1 },
message: { marginTop: spacing[1] },
action: { alignSelf: 'center' },

View File

@@ -27,8 +27,8 @@ export function RecessCard({ timeRange, isNow = false, style }: Props) {
}
const styles = StyleSheet.create({
card: { minHeight: 96, flexDirection: 'row', alignItems: 'center', gap: spacing[4], borderRadius: radius.feature, padding: spacing[5], backgroundColor: colors.neutral.surfaceSubtle, borderWidth: 1, borderColor: colors.neutral.divider },
icon: { width: 40, height: 40, borderRadius: radius.control, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.neutral.surface },
card: { minHeight: 96, flexDirection: 'row', alignItems: 'center', gap: spacing[4], borderRadius: radius.feature, borderCurve: 'continuous', padding: spacing[5], backgroundColor: colors.neutral.surfaceSubtle, borderWidth: 1, borderColor: colors.neutral.divider },
icon: { width: 40, height: 40, borderRadius: radius.control, borderCurve: 'continuous', alignItems: 'center', justifyContent: 'center', backgroundColor: colors.neutral.surface },
copy: { flex: 1 },
heading: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[3] },
now: { borderRadius: radius.pill, backgroundColor: colors.brand.coral, paddingHorizontal: spacing[3], paddingVertical: 3 },

View File

@@ -26,6 +26,7 @@ export function ScheduleEventCard({ title, timeRange, subjectTone, room, kind, c
const subject = colors.subject[subjectTone];
const cancelled = state === 'cancelled';
const absent = state === 'absent';
const attended = state === 'attended';
const backgroundColor = cancelled ? colors.neutral.surfaceSubtle : absent ? colors.semantic.danger.soft : subject.surface;
const foreground = cancelled ? colors.neutral.textMuted : absent ? colors.semantic.danger.text : subject.accent;
const content = <>
@@ -42,7 +43,23 @@ export function ScheduleEventCard({ title, timeRange, subjectTone, room, kind, c
<Ionicons name="school-outline" size={14} color={foreground} />
<AppText variant="label" color={foreground}>{classType}</AppText>
</View>
{isNow ? <View style={styles.now}><AppText variant="caption" color={colors.brand.ink}>Now</AppText></View> : cancelled ? <AppText variant="label" color={colors.neutral.textMuted}>Cancelled</AppText> : <ParticipantStack accent={foreground} />}
{isNow ? (
<View style={styles.now}><AppText variant="caption" color={colors.brand.ink}>Now</AppText></View>
) : cancelled ? (
<AppText variant="label" color={colors.neutral.textMuted}>Cancelled</AppText>
) : attended ? (
<View style={styles.statusConfirmed}>
<Ionicons name="checkmark-circle" size={15} color={colors.semantic.success.solid} />
<AppText variant="label" color={colors.semantic.success.text}>Attended</AppText>
</View>
) : absent ? (
<View style={styles.statusConfirmed}>
<Ionicons name="close-circle" size={15} color={colors.semantic.danger.solid} />
<AppText variant="label" color={colors.semantic.danger.text}>Absent</AppText>
</View>
) : (
<ParticipantStack accent={foreground} />
)}
</View>
{footer ? <View style={styles.footer}>{footer}</View> : null}
</>;
@@ -62,7 +79,7 @@ function ParticipantStack({ accent }: { accent: string }) {
}
const styles = StyleSheet.create({
card: { minHeight: 116, overflow: 'hidden', borderRadius: radius.feature, padding: spacing[5] },
card: { minHeight: 116, overflow: 'hidden', borderRadius: radius.feature, borderCurve: 'continuous', padding: spacing[5] },
titleRow: { minHeight: 40, flexDirection: 'row', alignItems: 'flex-start', gap: spacing[2] },
title: { flex: 1, paddingTop: spacing[1] },
topAction: { marginRight: -spacing[3], marginTop: -spacing[3] },
@@ -72,6 +89,7 @@ const styles = StyleSheet.create({
bottomRow: { minHeight: 30, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[3], marginTop: spacing[3] },
typeRow: { flexDirection: 'row', alignItems: 'center', gap: spacing[2] },
now: { borderRadius: radius.pill, backgroundColor: colors.brand.coral, paddingHorizontal: spacing[3], paddingVertical: 3 },
statusConfirmed: { flexDirection: 'row', alignItems: 'center', gap: 4 },
participants: { flexDirection: 'row', alignItems: 'center' },
avatar: { width: 24, height: 24, alignItems: 'center', justifyContent: 'center', borderRadius: radius.pill, borderWidth: 2, borderColor: colors.neutral.surface },
more: { marginLeft: spacing[2] },

View File

@@ -20,6 +20,7 @@ export function Screen({ children, scroll = true, edges = ['top'], style, conten
<ScrollView
testID={testID}
style={styles.scroll}
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={[styles.content, { paddingHorizontal: responsiveGutter }, contentContainerStyle, { paddingBottom: size.tabBar + spacing[6] }]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled">

View File

@@ -13,7 +13,7 @@ export function SubjectBadge({ shortName, tone, size = 'medium', style }: Props)
}
const styles = StyleSheet.create({
base: { alignItems: 'center', justifyContent: 'center', borderRadius: radius.control },
base: { alignItems: 'center', justifyContent: 'center', borderRadius: radius.control, borderCurve: 'continuous' },
small: { minWidth: 32, height: 32, paddingHorizontal: 6 },
medium: { minWidth: 44, height: 44, paddingHorizontal: 8, borderRadius: 14 },
medium: { minWidth: 44, height: 44, paddingHorizontal: 8, borderRadius: 14, borderCurve: 'continuous' },
});

View File

@@ -2,6 +2,7 @@ import * as Haptics from 'expo-haptics';
import { Platform, Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { AppText } from './app-text';
import { colors, radius, spacing } from './tokens';
import { formatDayHeading, formatWeekdayShort } from '@/lib/date';
export type WeekDay = { date: Date; disabled?: boolean; marker?: 'none' | 'success' | 'warning' | 'danger' | 'neutral' };
type Props = {
@@ -23,8 +24,8 @@ export function WeekStrip({ days, selectedDateKey, onSelect, todayDateKey, style
const key = dateKey(date);
const selected = key === selectedDateKey;
const today = key === todayDateKey;
const weekday = new Intl.DateTimeFormat(undefined, { weekday: 'narrow' }).format(date);
const fullDate = new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'long', day: 'numeric' }).format(date);
const weekday = formatWeekdayShort(date).slice(0, 3).toLowerCase();
const fullDate = formatDayHeading(date);
return <Pressable
key={key}
accessibilityRole="button"
@@ -33,7 +34,7 @@ export function WeekStrip({ days, selectedDateKey, onSelect, todayDateKey, style
disabled={disabled}
onPress={() => { if (Platform.OS !== 'web') void Haptics.selectionAsync(); onSelect(date); }}
style={({ pressed }) => [styles.day, selected && styles.selected, disabled && styles.disabled, pressed && !disabled && styles.pressed]}>
<AppText variant="caption" color={selected ? colors.brand.ink : colors.neutral.textSecondary}>{weekday}</AppText>
<AppText variant="caption" color={selected ? colors.brand.ink : colors.neutral.textMuted} style={styles.weekday}>{weekday}</AppText>
<AppText variant="title" color={selected ? colors.brand.ink : colors.neutral.textPrimary} style={styles.number}>{date.getDate()}</AppText>
<View style={styles.indicatorRow}>
<View style={[styles.dot, today && !selected && styles.todayDot, marker !== 'none' && { backgroundColor: markerColor[marker] }]} />
@@ -44,13 +45,14 @@ export function WeekStrip({ days, selectedDateKey, onSelect, todayDateKey, style
}
const styles = StyleSheet.create({
strip: { flexDirection: 'row', gap: spacing[1], padding: spacing[2], borderRadius: radius.feature, backgroundColor: colors.brand.sky },
day: { flex: 1, minHeight: 60, alignItems: 'center', justifyContent: 'center', borderRadius: radius.control, paddingVertical: spacing[1] },
strip: { flexDirection: 'row', gap: spacing[1], padding: spacing[2], borderRadius: radius.feature, borderCurve: 'continuous', backgroundColor: colors.brand.skySoft },
day: { flex: 1, minHeight: 64, alignItems: 'center', justifyContent: 'center', borderRadius: radius.control, borderCurve: 'continuous', paddingVertical: spacing[2] },
selected: { backgroundColor: colors.brand.coral },
disabled: { opacity: 0.42 },
pressed: { opacity: 0.78 },
number: { marginTop: 1, fontVariant: ['tabular-nums'] },
indicatorRow: { height: 5, marginTop: 2, justifyContent: 'center' },
weekday: { fontSize: 11, lineHeight: 14, fontWeight: '600' },
number: { fontSize: 18, lineHeight: 22, fontWeight: '800', marginTop: 2, fontVariant: ['tabular-nums'] },
indicatorRow: { height: 6, marginTop: 3, justifyContent: 'center' },
dot: { width: 4, height: 4, borderRadius: 2, backgroundColor: 'transparent' },
todayDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.brand.cobalt },
todayDot: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: colors.brand.cobalt },
});