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:
2026-09-05 09:56:55 +05:30
parent def12005de
commit 87b07728b5
5 changed files with 175 additions and 17 deletions

View File

@@ -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 ? 'Todays 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] },

View File

@@ -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,
},
});

View File

@@ -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=Todays 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
View 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);

View 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);