From 87b07728b5c5d3d8961d483dc1bac3746e24765f Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Sat, 5 Sep 2026 09:56:55 +0530 Subject: [PATCH] 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. --- app/(tabs)/index.tsx | 52 +++++++++++++++-- components/ui/bottom-tab-bar.tsx | 77 +++++++++++++++++++++++--- e2e/app.spec.ts | 6 +- scripts/test-docker.mjs | 34 ++++++++++++ scripts/test-today-nav-and-metrics.mjs | 23 ++++++++ 5 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 scripts/test-docker.mjs create mode 100644 scripts/test-today-nav-and-metrics.mjs diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 9fa2bfd..7b990dc 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -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 @@ -189,6 +190,29 @@ export default function TodayScreen() { ) : null} + + + + + {summary.total ? `${summary.percentage}% overall` : 'No attendance yet'} + + {summary.total ? `${summary.attended} of ${summary.total} classes attended` : 'Mark a class to begin tracking.'} + + + + + Target + 75% + + + {calendarExpanded ? ( - {summary.total > 0 ? : null} - {isToday ? 'Today’s classes' : `${formatDayHeading(activeDate).split(',')[0]}’s classes`} @@ -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] }, diff --git a/components/ui/bottom-tab-bar.tsx b/components/ui/bottom-tab-bar.tsx index 7673093..d2b3a95 100644 --- a/components/ui/bottom-tab-bar.tsx +++ b/components/ui/bottom-tab-bar.tsx @@ -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]}> - - {label} + + + + + {label} + ; })} @@ -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, + }, }); diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 88aacfc..d05ee36 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -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 }) => { diff --git a/scripts/test-docker.mjs b/scripts/test-docker.mjs new file mode 100644 index 0000000..db6b283 --- /dev/null +++ b/scripts/test-docker.mjs @@ -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); diff --git a/scripts/test-today-nav-and-metrics.mjs b/scripts/test-today-nav-and-metrics.mjs new file mode 100644 index 0000000..7b67e13 --- /dev/null +++ b/scripts/test-today-nav-and-metrics.mjs @@ -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);