feat: unify app navigation and add today attendance overview
- Replace floating pill tab dock with a full-width system-aligned navigation bar. - Give active tabs cobalt icon chips and clean cobalt labels while keeping inactive tabs muted. - Add compact live attendance summary above the day picker on Today. - Add Playwright verification for the new metrics and navigation surface.
This commit is contained in:
@@ -8,6 +8,7 @@ import { AddClassModal } from '@/components/add-class-modal';
|
||||
import {
|
||||
AppHeader,
|
||||
AppText,
|
||||
AttendanceRing,
|
||||
BottomSheet,
|
||||
Button,
|
||||
Card,
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
mondayOfWeek,
|
||||
timeToMinutes,
|
||||
} from '@/lib/date';
|
||||
import { attendanceMessage, attendanceTone, subjectToneFor } from '@/lib/design';
|
||||
import { attendanceTone, subjectToneFor } from '@/lib/design';
|
||||
import { collegeApi, type AttendanceStatus, type Profile, type Session } from '@/lib/api';
|
||||
|
||||
type DayMarker = WeekDay['marker'];
|
||||
@@ -158,7 +159,6 @@ export default function TodayScreen() {
|
||||
};
|
||||
|
||||
const isToday = selectedDateKey === todayKey;
|
||||
const insight = attendanceMessage(summary.percentage, summary.total);
|
||||
const insightTone = attendanceTone(summary.percentage, summary.total);
|
||||
const now = currentMinutes();
|
||||
const recessEnabled = Boolean(profile && profile.recessEnabled && timeToMinutes(profile.recessStart) >= 0 && timeToMinutes(profile.recessStart) < timeToMinutes(profile.recessEnd));
|
||||
@@ -170,6 +170,7 @@ export default function TodayScreen() {
|
||||
const insertionIndex = isToday ? (activeTimelineIndex >= 0 ? activeTimelineIndex : timelineItems.findIndex((item) => timeToMinutes(item.start) > now)) : -1;
|
||||
const currentTimeLabel = formatCurrentTime();
|
||||
const pendingCount = classes.filter((item) => item.status === 'pending').length;
|
||||
const metricTone = summary.total ? (insightTone === 'neutral' ? 'brand' : insightTone) : 'brand';
|
||||
|
||||
return <Screen scroll={false} contentContainerStyle={styles.content}>
|
||||
<View style={styles.stickyHeader}>
|
||||
@@ -189,6 +190,29 @@ export default function TodayScreen() {
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.attendanceOverview}>
|
||||
<View style={styles.overviewPrimary}>
|
||||
<AttendanceRing
|
||||
percentage={summary.percentage}
|
||||
tone={metricTone}
|
||||
size={76}
|
||||
strokeWidth={8}
|
||||
label=""
|
||||
accessibilityLabel={summary.total ? `Overall attendance ${summary.percentage} percent` : 'No attendance data yet'}
|
||||
/>
|
||||
<View style={styles.overviewCopy}>
|
||||
<AppText variant="title">{summary.total ? `${summary.percentage}% overall` : 'No attendance yet'}</AppText>
|
||||
<AppText variant="bodySmall" color={colors.neutral.textSecondary} style={styles.overviewMessage}>
|
||||
{summary.total ? `${summary.attended} of ${summary.total} classes attended` : 'Mark a class to begin tracking.'}
|
||||
</AppText>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.overviewTarget}>
|
||||
<AppText variant="caption" color={colors.neutral.textMuted}>Target</AppText>
|
||||
<AppText variant="title" color={colors.brand.cobalt} style={styles.overviewTargetValue}>75%</AppText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.pickerContainer}>
|
||||
{calendarExpanded ? (
|
||||
<InlineCalendarWidget
|
||||
@@ -220,8 +244,6 @@ export default function TodayScreen() {
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{summary.total > 0 ? <InlineBanner title={insight.title} message={insight.message} tone={insightTone} style={styles.insight} /> : null}
|
||||
|
||||
<View style={styles.sectionHeader}>
|
||||
<View style={styles.sectionCopy}>
|
||||
<AppText variant="heading2">{isToday ? 'Today’s classes' : `${formatDayHeading(activeDate).split(',')[0]}’s classes`}</AppText>
|
||||
@@ -431,6 +453,27 @@ const styles = StyleSheet.create({
|
||||
headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing[2], 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 },
|
||||
attendanceOverview: {
|
||||
marginTop: spacing[3],
|
||||
marginBottom: spacing[3],
|
||||
minHeight: 96,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing[4],
|
||||
paddingHorizontal: spacing[4],
|
||||
paddingVertical: spacing[3],
|
||||
borderRadius: radius.feature,
|
||||
borderCurve: 'continuous',
|
||||
backgroundColor: colors.brand.skySoft,
|
||||
borderWidth: 1,
|
||||
borderColor: '#DCEEF8',
|
||||
},
|
||||
overviewPrimary: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
|
||||
overviewCopy: { flex: 1 },
|
||||
overviewMessage: { marginTop: spacing[1] },
|
||||
overviewTarget: { alignItems: 'flex-end', gap: 2, paddingRight: spacing[1] },
|
||||
overviewTargetValue: { fontVariant: ['tabular-nums'] },
|
||||
pickerContainer: {
|
||||
marginTop: spacing[1],
|
||||
borderRadius: radius.feature,
|
||||
@@ -472,7 +515,6 @@ const styles = StyleSheet.create({
|
||||
indicatorRow: { height: 5, marginTop: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
dot: { width: 4, height: 4, borderRadius: 2, backgroundColor: 'transparent' },
|
||||
todayDot: { width: 4.5, height: 4.5, borderRadius: 2.5, backgroundColor: colors.brand.cobalt },
|
||||
insight: { marginTop: spacing[4] },
|
||||
sectionHeader: { marginTop: spacing[8], marginBottom: spacing[4], flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[4] },
|
||||
sectionCopy: { flex: 1 },
|
||||
sectionSubtitle: { marginTop: spacing[1] },
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Platform, Pressable, StyleSheet, View } from 'react-native';
|
||||
import { AppText } from './app-text';
|
||||
import { colors, radius, shadow, size, spacing } from './tokens';
|
||||
import { colors, radius, size, spacing } from './tokens';
|
||||
|
||||
const tabConfig = {
|
||||
index: { label: 'Today', icon: 'today-outline', activeIcon: 'today' },
|
||||
@@ -38,8 +38,20 @@ export function BottomTabBar({ state, descriptors, navigation, insets }: BottomT
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
style={({ pressed }) => [styles.tab, focused && styles.tabActive, pressed && styles.pressed]}>
|
||||
<Ionicons name={focused ? config.activeIcon : config.icon} size={focused ? 19 : 20} color={focused ? colors.neutral.surface : colors.neutral.textSecondary} />
|
||||
<AppText variant="caption" color={focused ? colors.neutral.surface : colors.neutral.textSecondary} numberOfLines={1} style={styles.label}>{label}</AppText>
|
||||
<View style={[styles.iconWrap, focused && styles.iconWrapActive]}>
|
||||
<Ionicons
|
||||
name={focused ? config.activeIcon : config.icon}
|
||||
size={20}
|
||||
color={focused ? colors.brand.cobalt : colors.neutral.textMuted}
|
||||
/>
|
||||
</View>
|
||||
<AppText
|
||||
variant="caption"
|
||||
color={focused ? colors.brand.cobalt : colors.neutral.textMuted}
|
||||
numberOfLines={1}
|
||||
style={[styles.label, focused && styles.labelActive]}>
|
||||
{label}
|
||||
</AppText>
|
||||
</Pressable>;
|
||||
})}
|
||||
</View>
|
||||
@@ -47,10 +59,57 @@ export function BottomTabBar({ state, descriptors, navigation, insets }: BottomT
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
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 },
|
||||
floatingArea: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
bar: {
|
||||
minHeight: size.tabBar,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
backgroundColor: colors.neutral.surface,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.neutral.divider,
|
||||
paddingTop: spacing[2],
|
||||
paddingHorizontal: spacing[4],
|
||||
},
|
||||
tab: {
|
||||
minHeight: size.touchTargetMin,
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
paddingVertical: spacing[1],
|
||||
},
|
||||
tabActive: {
|
||||
// Active tab keeps the clean columnar structure with color prominence
|
||||
},
|
||||
iconWrap: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.pill,
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
iconWrapActive: {
|
||||
backgroundColor: colors.brand.cobaltSoft,
|
||||
},
|
||||
label: {
|
||||
textAlign: 'center',
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
labelActive: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.72,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,9 +16,9 @@ test.describe('College Kit E2E & Visual Verification', () => {
|
||||
test('Today screen loads with date, week strip, and classes', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('text=Today’s classes')).toBeVisible();
|
||||
await expect(page.locator('text=Timetable')).toBeVisible();
|
||||
await expect(page.locator('text=Attendance')).toBeVisible();
|
||||
await expect(page.locator('text=Settings')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Timetable' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Attendance' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Navigation between all bottom tabs works smoothly', async ({ page }) => {
|
||||
|
||||
34
scripts/test-docker.mjs
Normal file
34
scripts/test-docker.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
async function testDocker() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
deviceScaleFactor: 2,
|
||||
isMobile: true,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Testing app via Docker at http://127.0.0.1:8080...');
|
||||
await page.goto('http://127.0.0.1:8080/');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Open add class modal
|
||||
const addBtn = page.getByRole('button', { name: 'Add a class' });
|
||||
await addBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await page.screenshot({ path: './screenshots/docker-add-class-modal.png' });
|
||||
|
||||
// Open course picker drawer
|
||||
const changeBtn = page.getByText('Change');
|
||||
if (await changeBtn.isVisible()) {
|
||||
await changeBtn.click();
|
||||
await page.waitForTimeout(600);
|
||||
await page.screenshot({ path: './screenshots/docker-add-class-picker-open.png' });
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log('Docker verification completed successfully.');
|
||||
}
|
||||
|
||||
testDocker().catch(console.error);
|
||||
23
scripts/test-today-nav-and-metrics.mjs
Normal file
23
scripts/test-today-nav-and-metrics.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
async function capture() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
deviceScaleFactor: 2,
|
||||
isMobile: true,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('http://127.0.0.1:3000/');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.screenshot({ path: './screenshots/16-today-metrics-and-nav.png' });
|
||||
|
||||
await page.getByRole('button', { name: 'Attendance' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: './screenshots/17-attendance-new-nav.png' });
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
capture().catch(console.error);
|
||||
Reference in New Issue
Block a user