diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..32c7d26
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,12 @@
+node_modules
+.expo
+dist
+web-build
+.git
+.gitignore
+.vscode
+.claude
+backend/data
+*.log
+npm-debug.log
+README.md
diff --git a/.gitignore b/.gitignore
index f8c6c2e..54b4985 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,13 +25,16 @@ expo-env.d.ts
npm-debug.*
yarn-debug.*
yarn-error.*
+debug_screenshots/
# macOS
.DS_Store
*.pem
# local env files
-.env*.local
+.env
+.env.*
+!.env.example
# typescript
*.tsbuildinfo
@@ -41,3 +44,6 @@ app-example
# generated native folders
/ios
/android
+
+# Local College Kit API database
+backend/data/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..6ba5bfd
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,15 @@
+# Build the Expo Router web bundle. Expo replaces EXPO_PUBLIC_* values at export time.
+FROM node:22-bookworm-slim AS web-build
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+COPY . .
+ARG EXPO_PUBLIC_API_URL=/api/v1
+ENV EXPO_PUBLIC_API_URL=$EXPO_PUBLIC_API_URL
+RUN npx expo export --platform web
+
+# Serve the static bundle and proxy same-origin API traffic.
+FROM nginx:1.27-alpine AS web
+COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=web-build /app/dist /usr/share/nginx/html
+EXPOSE 80
diff --git a/README.md b/README.md
index 48dd63f..0c0286d 100644
--- a/README.md
+++ b/README.md
@@ -1,50 +1,44 @@
-# Welcome to your Expo app đź‘‹
+# College Kit
-This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
+A personal college organiser for timetables, flexible daily classes, and attendance tracking.
-## Get started
+## Run with Docker
-1. Install dependencies
+The complete production-style stack consists of an Expo Router web bundle served by Nginx and a Node + SQLite API. Nginx proxies the API at the same origin, so no browser CORS or API URL configuration is needed.
- ```bash
- npm install
- ```
-
-2. Start the app
-
- ```bash
- npx expo start
- ```
-
-In the output, you'll find options to open the app in a
-
-- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
-- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
-- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
-- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
-
-You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
-
-## Get a fresh project
-
-When you're ready, run:
-
-```bash
-npm run reset-project
+```sh
+docker compose up --build
```
-This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
+Open [http://localhost:8080](http://localhost:8080). The API is available through the web container at `http://localhost:8080/api/v1/*`, and its health check is at `http://localhost:8080/health`.
-## Learn more
+Stop the stack without deleting attendance data:
-To learn more about developing your project with Expo, look at the following resources:
+```sh
+docker compose down
+```
-- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
-- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
+The SQLite database persists in the named `college-kit-data` Docker volume. To completely reset the seeded application data:
-## Join the community
+```sh
+docker compose down -v
+```
-Join our community of developers creating universal apps.
+## Local development
-- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
-- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
+Start the API and Expo development server in separate terminals:
+
+```sh
+npm run api
+npm start
+```
+
+The local web app uses `http://localhost:4000/api/v1` by default. To use a physical device, point Expo at your computer's LAN IP:
+
+```sh
+EXPO_PUBLIC_API_URL=http://192.168.1.20:4000/api/v1 npm start
+```
+
+## Backend
+
+The API and its data model are documented in [`backend/README.md`](backend/README.md). It provides persistent recurring timetable rules, dated class overrides, attendance statuses, subjects, and attendance summaries.
diff --git a/app.json b/app.json
index 29bd9a6..a7a4895 100644
--- a/app.json
+++ b/app.json
@@ -6,7 +6,7 @@
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "clgkit",
- "userInterfaceStyle": "automatic",
+ "userInterfaceStyle": "light",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
@@ -33,10 +33,7 @@
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
- "backgroundColor": "#ffffff",
- "dark": {
- "backgroundColor": "#000000"
- }
+ "backgroundColor": "#F7F9FC"
}
]
],
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 54e11d0..87ed7ec 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -1,35 +1,18 @@
import { Tabs } from 'expo-router';
-import React from 'react';
-import { HapticTab } from '@/components/haptic-tab';
-import { IconSymbol } from '@/components/ui/icon-symbol';
-import { Colors } from '@/constants/theme';
-import { useColorScheme } from '@/hooks/use-color-scheme';
+import { BottomTabBar } from '@/components/ui/bottom-tab-bar';
export default function TabLayout() {
- const colorScheme = useColorScheme();
-
- return (
-
- ,
- }}
- />
- ,
- }}
- />
-
- );
+ return }
+ screenOptions={{
+ headerShown: false,
+ // The visible dock is independently absolute; this removes React Navigation's reserved tab-bar scene area.
+ tabBarStyle: { position: 'absolute', height: 0, backgroundColor: 'transparent', borderTopWidth: 0, elevation: 0 },
+ }}>
+
+
+
+
+ ;
}
diff --git a/app/(tabs)/attendance.tsx b/app/(tabs)/attendance.tsx
new file mode 100644
index 0000000..24f39eb
--- /dev/null
+++ b/app/(tabs)/attendance.tsx
@@ -0,0 +1,163 @@
+import { Ionicons } from '@expo/vector-icons';
+import { useFocusEffect } from '@react-navigation/native';
+import { useRouter } from 'expo-router';
+import { useCallback, useState } from 'react';
+import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
+
+import {
+ AppHeader,
+ AppText,
+ AttendanceRing,
+ Button,
+ Card,
+ EmptyState,
+ InlineBanner,
+ ProgressBar,
+ Screen,
+ StatusPill,
+ SubjectBadge,
+ colors,
+ spacing,
+} from '@/components/ui';
+import { attendanceMessage, attendanceTone, subjectToneFor } from '@/lib/design';
+import { collegeApi } from '@/lib/api';
+
+type Subject = Awaited>['subjects'][number];
+
+export default function AttendanceScreen() {
+ const router = useRouter();
+ const [subjects, setSubjects] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState('');
+
+ const load = useCallback(() => {
+ setLoading(true);
+ setLoadError('');
+ collegeApi.attendanceSummary()
+ .then(({ subjects: result }) => setSubjects(result))
+ .catch((error: Error) => { setSubjects([]); setLoadError(error.message); })
+ .finally(() => setLoading(false));
+ }, []);
+
+ useFocusEffect(useCallback(() => { load(); }, [load]));
+
+ const totalHeld = subjects.reduce((total, subject) => total + subject.total, 0);
+ const totalAttended = subjects.reduce((total, subject) => total + subject.attended, 0);
+ const totalMissed = subjects.reduce((total, subject) => total + subject.absent, 0);
+ const overall = totalHeld ? Math.round(totalAttended / totalHeld * 100) : 0;
+ const overallTone = attendanceTone(overall, totalHeld);
+ const insight = attendanceMessage(overall, totalHeld);
+ const sortedSubjects = [...subjects].sort((left, right) => {
+ if (!left.total && right.total) return 1;
+ if (left.total && !right.total) return -1;
+ return left.percentage - right.percentage || left.name.localeCompare(right.name);
+ });
+
+ return
+
+
+ {loadError ? } style={styles.firstBlock} /> : null}
+ {loading ? Calculating attendance… : null}
+
+ {!loading && !loadError ? <>
+
+
+
+ {insight.title}
+ {insight.message}
+ {totalHeld ? : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ By subject
+ Subjects needing attention appear first
+
+
+
+ {subjects.length === 0 ? :
+ {sortedSubjects.map((subject, index) => {
+ const tone = subjectToneFor(subject.id, subject.color);
+ const palette = colors.subject[tone];
+ const state = attendanceTone(subject.percentage, subject.total);
+ const atRisk = subject.total > 0 && state !== 'success';
+ return router.push(`/subjects/${subject.id}` as never)}
+ style={({ pressed }) => [styles.subject, pressed && styles.subjectPressed]}>
+
+
+
+
+ {subject.name}
+ {subject.code} · {subject.attended}/{subject.total} attended
+
+ {subject.total ? `${subject.percentage}%` : '—'}
+
+
+ {atRisk ? : null}
+
+
+ {index < sortedSubjects.length - 1 ? : null}
+ ;
+ })}
+ }
+ > : null}
+ ;
+}
+
+function Stat({ value, label }: { value: number; label: string }) {
+ return
+ {value}
+ {label}
+ ;
+}
+
+const styles = StyleSheet.create({
+ content: { paddingTop: spacing[1], paddingBottom: spacing[9] },
+ firstBlock: { marginTop: spacing[6] },
+ loading: { minHeight: 144, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
+ hero: { marginTop: spacing[6], flexDirection: 'row', alignItems: 'center', gap: spacing[6], borderRadius: 20 },
+ heroCopy: { flex: 1 },
+ heroMessage: { marginTop: spacing[2] },
+ heroStatus: { marginTop: spacing[4] },
+ stats: { minHeight: 92, marginTop: spacing[3], flexDirection: 'row', alignItems: 'center' },
+ stat: { flex: 1, alignItems: 'center', paddingVertical: spacing[5] },
+ statDivider: { width: 1, height: 36, backgroundColor: colors.neutral.divider },
+ statLabel: { marginTop: spacing[1], textAlign: 'center' },
+ tabular: { fontVariant: ['tabular-nums'] },
+ sectionHeader: { marginTop: spacing[8], marginBottom: spacing[4] },
+ sectionSub: { marginTop: spacing[1] },
+ subjectList: { overflow: 'hidden' },
+ subject: { minHeight: 100, flexDirection: 'row', alignItems: 'center', gap: spacing[3], padding: spacing[4] },
+ subjectPressed: { backgroundColor: colors.brand.skySoft },
+ subjectBody: { flex: 1 },
+ subjectTop: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing[3] },
+ subjectTitleWrap: { flex: 1 },
+ subjectMeta: { marginTop: spacing[1] },
+ percentage: { fontVariant: ['tabular-nums'] },
+ progress: { marginTop: spacing[3] },
+ subjectStatus: { marginTop: spacing[3] },
+ subjectDivider: { position: 'absolute', left: 72, right: spacing[4], bottom: 0, height: 1, backgroundColor: colors.neutral.divider },
+});
diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx
deleted file mode 100644
index 71518f9..0000000
--- a/app/(tabs)/explore.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import { Image } from 'expo-image';
-import { Platform, StyleSheet } from 'react-native';
-
-import { Collapsible } from '@/components/ui/collapsible';
-import { ExternalLink } from '@/components/external-link';
-import ParallaxScrollView from '@/components/parallax-scroll-view';
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-import { IconSymbol } from '@/components/ui/icon-symbol';
-import { Fonts } from '@/constants/theme';
-
-export default function TabTwoScreen() {
- return (
-
- }>
-
-
- Explore
-
-
- This app includes example code to help you get started.
-
-
- This app has two screens:{' '}
- app/(tabs)/index.tsx and{' '}
- app/(tabs)/explore.tsx
-
-
- The layout file in app/(tabs)/_layout.tsx{' '}
- sets up the tab navigator.
-
-
- Learn more
-
-
-
-
- You can open this project on Android, iOS, and the web. To open the web version, press{' '}
- w in the terminal running this project.
-
-
-
-
- For static images, you can use the @2x and{' '}
- @3x suffixes to provide files for
- different screen densities
-
-
-
- Learn more
-
-
-
-
- This template has light and dark mode support. The{' '}
- useColorScheme() hook lets you inspect
- what the user's current color scheme is, and so you can adjust UI colors accordingly.
-
-
- Learn more
-
-
-
-
- This template includes an example of an animated component. The{' '}
- components/HelloWave.tsx component uses
- the powerful{' '}
-
- react-native-reanimated
- {' '}
- library to create a waving hand animation.
-
- {Platform.select({
- ios: (
-
- The components/ParallaxScrollView.tsx{' '}
- component provides a parallax effect for the header image.
-
- ),
- })}
-
-
- );
-}
-
-const styles = StyleSheet.create({
- headerImage: {
- color: '#808080',
- bottom: -90,
- left: -35,
- position: 'absolute',
- },
- titleContainer: {
- flexDirection: 'row',
- gap: 8,
- },
-});
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 786b736..fd12242 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,98 +1,313 @@
-import { Image } from 'expo-image';
-import { Platform, StyleSheet } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import * as Haptics from 'expo-haptics';
+import { useFocusEffect, useRouter } from 'expo-router';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native';
-import { HelloWave } from '@/components/hello-wave';
-import ParallaxScrollView from '@/components/parallax-scroll-view';
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-import { Link } from 'expo-router';
+import { AddClassModal } from '@/components/add-class-modal';
+import {
+ AppHeader,
+ AppText,
+ BottomSheet,
+ Button,
+ Card,
+ CurrentTimeIndicator,
+ EmptyState,
+ IconButton,
+ InlineBanner,
+ RecessCard,
+ ScheduleEventCard,
+ Screen,
+ WeekStrip,
+ colors,
+ radius,
+ size,
+ spacing,
+ type WeekDay,
+} from '@/components/ui';
+import { addDays, currentMinutes, dateKey, formatDayHeading, mondayOfWeek, timeToMinutes } from '@/lib/date';
+import { attendanceMessage, attendanceTone, subjectToneFor } from '@/lib/design';
+import { collegeApi, type AttendanceStatus, type Profile, type Session } from '@/lib/api';
-export default function HomeScreen() {
- return (
-
- }>
-
- Welcome!
-
-
-
- Step 1: Try it
-
- Edit app/(tabs)/index.tsx to see changes.
- Press{' '}
-
- {Platform.select({
- ios: 'cmd + d',
- android: 'cmd + m',
- web: 'F12',
- })}
- {' '}
- to open developer tools.
-
-
-
-
-
- Step 2: Explore
-
-
-
- alert('Action pressed')} />
- alert('Share pressed')}
- />
-
- alert('Delete pressed')}
- />
-
-
-
+type DayMarker = WeekDay['marker'];
-
- {`Tap the Explore tab to learn more about what's included in this starter app.`}
-
-
-
- Step 3: Get a fresh start
-
- {`When you're ready, run `}
- npm run reset-project to get a fresh{' '}
- app directory. This will move the current{' '}
- app to{' '}
- app-example.
-
-
-
- );
+type AttendanceSummary = { total: number; attended: number; percentage: number };
+
+const emptySummary: AttendanceSummary = { total: 0, attended: 0, percentage: 0 };
+const startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1);
+const daysInMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
+
+export default function TodayScreen() {
+ const router = useRouter();
+ const [activeDate, setActiveDate] = useState(() => new Date());
+ const [calendarMonth, setCalendarMonth] = useState(() => startOfMonth(new Date()));
+ const [classes, setClasses] = useState([]);
+ const [profile, setProfile] = useState(null);
+ const [summary, setSummary] = useState(emptySummary);
+ const [weekendSchedule, setWeekendSchedule] = useState(false);
+ const [weekMarkers, setWeekMarkers] = useState>({});
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState('');
+ const [refresh, setRefresh] = useState(0);
+ const [calendarOpen, setCalendarOpen] = useState(false);
+ const [addClassOpen, setAddClassOpen] = useState(false);
+ const [selectedClass, setSelectedClass] = useState(null);
+
+ const today = new Date();
+ const todayKey = dateKey(today);
+ const selectedDateKey = dateKey(activeDate);
+ const weekStart = useMemo(() => mondayOfWeek(activeDate), [activeDate]);
+ const weekDates = useMemo(() => Array.from({ length: 7 }, (_, index) => addDays(weekStart, index)), [weekStart]);
+
+ useFocusEffect(useCallback(() => {
+ let active = true;
+ collegeApi.profile().then((result) => {
+ if (!active) return;
+ setProfile(result);
+ setWeekendSchedule(Boolean(result.weekendSchedule));
+ if (!result.weekendSchedule) setActiveDate((current) => current.getDay() === 0 || current.getDay() === 6 ? mondayOfWeek(current) : current);
+ }).catch(() => undefined);
+ return () => { active = false; };
+ }, []));
+
+ const loadSchedule = useCallback(() => {
+ setLoading(true);
+ setLoadError('');
+ collegeApi.schedule(selectedDateKey)
+ .then(({ sessions }) => setClasses(sessions))
+ .catch((error: Error) => { setClasses([]); setLoadError(error.message); })
+ .finally(() => setLoading(false));
+ }, [selectedDateKey]);
+
+ useEffect(() => { loadSchedule(); }, [loadSchedule, refresh]);
+
+ useEffect(() => {
+ collegeApi.attendanceSummary().then(({ subjects }) => {
+ const total = subjects.reduce((value, subject) => value + subject.total, 0);
+ const attended = subjects.reduce((value, subject) => value + subject.attended, 0);
+ setSummary({ total, attended, percentage: total ? Math.round(attended / total * 100) : 0 });
+ }).catch(() => setSummary(emptySummary));
+ }, [refresh]);
+
+ useEffect(() => {
+ Promise.all(weekDates.map((date) => collegeApi.schedule(dateKey(date)).then(({ sessions }) => ({ date, sessions })).catch(() => ({ date, sessions: [] as Session[] }))))
+ .then((results) => {
+ const markers: Record = {};
+ results.forEach(({ date, sessions }) => {
+ const key = dateKey(date);
+ if (key > todayKey) return;
+ const held = sessions.filter((session) => session.status !== 'cancelled');
+ if (!held.length) return;
+ if (held.every((session) => session.status === 'attended')) markers[key] = 'success';
+ else if (held.some((session) => session.status === 'absent')) markers[key] = 'danger';
+ else if (held.some((session) => session.status === 'pending')) markers[key] = 'warning';
+ else markers[key] = 'neutral';
+ });
+ setWeekMarkers(markers);
+ });
+ }, [refresh, todayKey, weekDates]);
+
+ const visibleDays: WeekDay[] = weekDates
+ .filter((date) => weekendSchedule || (date.getDay() !== 0 && date.getDay() !== 6))
+ .map((date) => ({ date, marker: weekMarkers[dateKey(date)] ?? 'none' }));
+
+ const updateStatus = (session: Session, status: AttendanceStatus) => {
+ const previous = classes;
+ setClasses((items) => items.map((item) => item.id === session.id ? { ...item, status } : item));
+ collegeApi.markAttendance(session.id, status)
+ .then(() => {
+ if (Platform.OS !== 'web') void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ })
+ .catch((error: Error) => {
+ setClasses(previous);
+ Alert.alert('Couldn’t save attendance', error.message);
+ });
+ };
+
+ const removeClass = (session: Session) => {
+ Alert.alert('Remove this class?', 'This removes only this occurrence. Your recurring timetable will not change.', [
+ { text: 'Keep class', style: 'cancel' },
+ { text: 'Remove', style: 'destructive', onPress: () => {
+ setSelectedClass(null);
+ setClasses((items) => items.filter((item) => item.id !== session.id));
+ collegeApi.removeClass(session.id).then(() => setRefresh((value) => value + 1)).catch((error: Error) => {
+ Alert.alert('Couldn’t remove class', error.message);
+ setRefresh((value) => value + 1);
+ });
+ } },
+ ]);
+ };
+
+ 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));
+ const timelineItems = [
+ ...classes.map((item) => ({ type: 'class' as const, start: item.time, end: item.endTime, item })),
+ ...(recessEnabled ? [{ type: 'recess' as const, start: profile!.recessStart, end: profile!.recessEnd }] : []),
+ ].sort((left, right) => left.start.localeCompare(right.start) || (left.type === 'recess' ? -1 : 1));
+ const activeTimelineIndex = isToday ? timelineItems.findIndex((item) => timeToMinutes(item.start) <= now && now < timeToMinutes(item.end)) : -1;
+ const insertionIndex = isToday ? (activeTimelineIndex >= 0 ? activeTimelineIndex : timelineItems.findIndex((item) => timeToMinutes(item.start) > now)) : -1;
+ const currentTimeLabel = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(new Date());
+ const pendingCount = classes.filter((item) => item.status === 'pending').length;
+
+ return
+
+
+
+
+ {!isToday ?
+
+
+ {summary.total > 0 ? : null}
+
+
+
+ {isToday ? 'Today’s classes' : `${new Intl.DateTimeFormat(undefined, { weekday: 'long' }).format(activeDate)}’s classes`}
+
+ {classes.length ? `${classes.length} ${classes.length === 1 ? 'class' : 'classes'}${pendingCount ? ` · ${pendingCount} to mark` : ''}` : 'Your agenda for this date'}
+
+
+ setAddClassOpen(true)} />
+
+
+
+
+
+ {loadError ? } /> : null}
+ {loading ? Loading your classes… : null}
+ {!loading && !loadError && classes.length === 0 && !recessEnabled ? setAddClassOpen(true)} leading={} />} /> : null}
+
+ {!loading && !loadError && timelineItems.length > 0 ?
+ {timelineItems.map((entry, index) => {
+ const isNow = index === activeTimelineIndex;
+ const key = entry.type === 'class' ? `class-${entry.item.id}` : `recess-${entry.start}`;
+ return
+ {index === insertionIndex ? : null}
+
+
+ {entry.start}
+ {entry.end}
+ {index < timelineItems.length - 1 ? : null}
+
+ {entry.type === 'recess' ? : setSelectedClass(entry.item)} />}
+ footer={entry.item.status !== 'cancelled' ? updateStatus(entry.item, 'attended')} onAbsent={() => updateStatus(entry.item, 'absent')} /> : null}
+ />}
+
+ ;
+ })}
+ {isToday && insertionIndex === -1 && activeTimelineIndex === -1 ? : null}
+ : null}
+
+
+ setAddClassOpen(false)} onAdded={() => setRefresh((value) => value + 1)} />
+
+ setSelectedClass(null)}>
+ {selectedClass ? <>
+
+ {selectedClass.title}
+ {selectedClass.code} · {selectedClass.time}–{selectedClass.endTime} · {selectedClass.room}
+
+
+
+ > : null}
+
+
+ setCalendarOpen(false)}
+ onSelect={(date) => { setActiveDate(date); setCalendarOpen(false); }}
+ />
+ ;
+}
+
+function AttendanceActions({ tone, onAttended, onAbsent }: { tone: keyof typeof colors.subject; onAttended: () => void; onAbsent: () => void }) {
+ const palette = colors.subject[tone];
+ return
+ [styles.attendanceButton, { borderColor: palette.accent }, pressed && styles.actionPressed]}>
+
+
+ [styles.attendanceButton, { borderColor: palette.accent }, pressed && styles.actionPressed]}>
+
+
+ ;
+}
+
+function CalendarSheet({ visible, month, selectedDateKey, todayKey, onMonthChange, onClose, onSelect }: { visible: boolean; month: Date; selectedDateKey: string; todayKey: string; onMonthChange: (date: Date) => void; onClose: () => void; onSelect: (date: Date) => void }) {
+ const offset = (month.getDay() + 6) % 7;
+ const dates = Array.from({ length: offset + daysInMonth(month) }, (_, index) => index < offset ? null : index - offset + 1);
+ const weekdayLabels = Array.from({ length: 7 }, (_, index) => new Intl.DateTimeFormat(undefined, { weekday: 'narrow' }).format(addDays(mondayOfWeek(new Date()), index)));
+ return
+
+ onMonthChange(new Date(month.getFullYear(), month.getMonth() - 1, 1))} />
+ {new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' }).format(month)}
+ onMonthChange(new Date(month.getFullYear(), month.getMonth() + 1, 1))} />
+
+ {weekdayLabels.map((label, index) => {label})}
+ {dates.map((day, index) => {
+ if (!day) return ;
+ const date = new Date(month.getFullYear(), month.getMonth(), day);
+ const key = dateKey(date);
+ const selected = key === selectedDateKey;
+ const isToday = key === todayKey;
+ return onSelect(date)} style={[styles.calendarCell, selected && styles.calendarSelected, isToday && !selected && styles.calendarToday]}>
+ {day}
+ ;
+ })}
+ onSelect(new Date())} leading={} style={styles.calendarButton} />
+ ;
}
const styles = StyleSheet.create({
- titleContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 8,
- },
- stepContainer: {
- gap: 8,
- marginBottom: 8,
- },
- reactLogo: {
- height: 178,
- width: 290,
- bottom: 0,
- left: 0,
- position: 'absolute',
- },
+ // Bottom padding belongs to the scrollable class content, not this fixed shell.
+ content: { paddingTop: spacing[1], paddingBottom: 0 },
+
+ stickyHeader: { flexShrink: 0 },
+ dateActions: { minHeight: 44, flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: spacing[1], marginBottom: spacing[3] },
+ insight: { marginTop: spacing[5] },
+ sectionHeader: { marginTop: spacing[8], marginBottom: spacing[4], flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[4] },
+ sectionCopy: { flex: 1 },
+ sectionSubtitle: { marginTop: spacing[1] },
+ loading: { minHeight: 112, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
+ timeline: { gap: spacing[2] },
+ classRow: { flexDirection: 'row', alignItems: 'stretch' },
+ timeRail: { width: 54, alignItems: 'flex-start', paddingTop: spacing[4], position: 'relative' },
+ timeText: { fontVariant: ['tabular-nums'] },
+ railLine: { position: 'absolute', top: 58, bottom: -10, left: 4, width: 1, backgroundColor: colors.neutral.divider },
+ classList: { flex: 1, minHeight: 0 },
+ classListContent: { paddingTop: spacing[4], paddingBottom: size.tabBar + spacing[6] },
+ event: { flex: 1, marginBottom: spacing[2] },
+ attendanceActions: { flexDirection: 'row', alignItems: 'center', gap: spacing[2] },
+ attendanceButton: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.pill, borderWidth: 1, backgroundColor: 'rgba(255, 255, 255, 0.62)' },
+ actionPressed: { opacity: 0.72 },
+ sheetButtons: { gap: spacing[2], marginTop: spacing[5] },
+ selectedClassSummary: { marginTop: spacing[2] },
+ monthNavigation: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: spacing[5] },
+ calendarWeekdays: { flexDirection: 'row', marginBottom: spacing[2] },
+ calendarWeekday: { width: '14.2857%', textAlign: 'center' },
+ calendarGrid: { flexDirection: 'row', flexWrap: 'wrap' },
+ calendarCell: { width: '14.2857%', aspectRatio: 1, alignItems: 'center', justifyContent: 'center', borderRadius: radius.control },
+ calendarSelected: { backgroundColor: colors.brand.coral },
+ calendarToday: { borderWidth: 2, borderColor: colors.brand.cobalt },
+ calendarButton: { marginTop: spacing[5] },
});
diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx
new file mode 100644
index 0000000..badd3ff
--- /dev/null
+++ b/app/(tabs)/settings.tsx
@@ -0,0 +1 @@
+export { default } from '../settings';
diff --git a/app/(tabs)/timetable.tsx b/app/(tabs)/timetable.tsx
new file mode 100644
index 0000000..e8c29e8
--- /dev/null
+++ b/app/(tabs)/timetable.tsx
@@ -0,0 +1,161 @@
+import { Ionicons } from '@expo/vector-icons';
+import { useFocusEffect, useRouter } from 'expo-router';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { ActivityIndicator, StyleSheet, View } from 'react-native';
+
+import { AddClassModal } from '@/components/add-class-modal';
+import {
+ AppHeader,
+ AppText,
+ Button,
+ Card,
+ EmptyState,
+ IconButton,
+ InlineBanner,
+ RecessCard,
+ ScheduleEventCard,
+ Screen,
+ WeekStrip,
+ colors,
+ spacing,
+ type WeekDay,
+} from '@/components/ui';
+import { addDays, dateKey, formatDayHeading, mondayOfWeek } from '@/lib/date';
+import { subjectToneFor } from '@/lib/design';
+import { collegeApi } from '@/lib/api';
+
+type TimetableClass = Awaited>['classes'][number];
+
+export default function TimetableScreen() {
+ const router = useRouter();
+ const [activeDate, setActiveDate] = useState(() => {
+ const today = new Date();
+ return today.getDay() === 0 || today.getDay() === 6 ? mondayOfWeek(today) : today;
+ });
+ const [schedule, setSchedule] = useState([]);
+ const [weekendSchedule, setWeekendSchedule] = useState(false);
+ const [recess, setRecess] = useState<{ enabled: boolean; start: string; end: string }>({ enabled: false, start: '13:00', end: '14:00' });
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState('');
+ const [addClassOpen, setAddClassOpen] = useState(false);
+ const [refresh, setRefresh] = useState(0);
+
+ const selectedDateKey = dateKey(activeDate);
+ const todayKey = dateKey(new Date());
+ const weekStart = useMemo(() => mondayOfWeek(activeDate), [activeDate]);
+ const weekDays: WeekDay[] = useMemo(() => Array.from({ length: 7 }, (_, index) => addDays(weekStart, index))
+ .filter((date) => weekendSchedule || (date.getDay() !== 0 && date.getDay() !== 6))
+ .map((date) => ({ date })), [weekStart, weekendSchedule]);
+
+ useFocusEffect(useCallback(() => {
+ let active = true;
+ collegeApi.profile().then((profile) => {
+ if (!active) return;
+ setWeekendSchedule(Boolean(profile.weekendSchedule));
+ setRecess({ enabled: Boolean(profile.recessEnabled), start: profile.recessStart, end: profile.recessEnd });
+ }).catch(() => undefined);
+ return () => { active = false; };
+ }, []));
+
+ const load = useCallback(() => {
+ setLoading(true);
+ setLoadError('');
+ collegeApi.timetable(selectedDateKey)
+ .then(({ classes }) => setSchedule(classes))
+ .catch((error: Error) => { setSchedule([]); setLoadError(error.message); })
+ .finally(() => setLoading(false));
+ }, [selectedDateKey]);
+
+ useEffect(() => { load(); }, [load, refresh]);
+
+ const moveWeek = (amount: number) => setActiveDate(addDays(activeDate, amount * 7));
+ const weekday = new Intl.DateTimeFormat(undefined, { weekday: 'long' }).format(activeDate);
+ const showRecess = recess.enabled && recess.start < recess.end;
+ const timelineItems = [
+ ...schedule.map((item) => ({ type: 'class' as const, start: item.startTime, end: item.endTime, item })),
+ ...(showRecess ? [{ type: 'recess' as const, start: recess.start, end: recess.end }] : []),
+ ].sort((left, right) => left.start.localeCompare(right.start) || (left.type === 'recess' ? -1 : 1));
+
+ return
+
+
+
+ moveWeek(-1)} />
+
+ Week of {new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(weekStart)}
+ {new Intl.DateTimeFormat(undefined, { year: 'numeric' }).format(activeDate)}
+
+
+ {todayKey !== selectedDateKey ? setActiveDate(new Date())} /> : null}
+ moveWeek(1)} />
+
+
+
+
+
+
+ {weekday}
+ {formatDayHeading(activeDate)} · {schedule.length} {schedule.length === 1 ? 'class' : 'classes'}
+
+ setAddClassOpen(true)} />
+
+
+ {loadError ? } /> : null}
+ {loading ? Loading timetable… : null}
+ {!loading && !loadError && schedule.length === 0 && !showRecess ? setAddClassOpen(true)} leading={} />} /> : null}
+
+ {!loading && !loadError && timelineItems.length > 0 ?
+ {timelineItems.map((entry, index) =>
+
+ {entry.start}
+ {entry.end}
+ {index < timelineItems.length - 1 ? : null}
+
+ {entry.type === 'recess' ? : router.push(`/subjects/${entry.item.subjectId}` as never)}
+ />}
+ )}
+ : null}
+
+
+
+ setAddClassOpen(false)}
+ onAdded={() => setRefresh((value) => value + 1)}
+ />
+ ;
+}
+
+const styles = StyleSheet.create({
+ content: { paddingTop: spacing[1], paddingBottom: spacing[9] },
+ weekToolbar: { marginTop: spacing[6], marginBottom: spacing[3], flexDirection: 'row', alignItems: 'center', gap: spacing[2] },
+ weekCopy: { flex: 1, alignItems: 'center' },
+ toolbarActions: { flexDirection: 'row', alignItems: 'center' },
+ sectionHeader: { marginTop: spacing[8], marginBottom: spacing[4], flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[4] },
+ sectionCopy: { flex: 1 },
+ sectionSub: { marginTop: spacing[1] },
+ loading: { minHeight: 112, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
+ timeline: { gap: spacing[2] },
+ classRow: { flexDirection: 'row', alignItems: 'stretch' },
+ timeRail: { width: 54, alignItems: 'flex-start', paddingTop: spacing[4], position: 'relative' },
+ tabular: { fontVariant: ['tabular-nums'] },
+ railLine: { position: 'absolute', top: 58, bottom: -10, left: 4, width: 1, backgroundColor: colors.neutral.divider },
+ event: { flex: 1, marginBottom: spacing[2] },
+ note: { marginTop: spacing[6] },
+});
diff --git a/app/_layout.tsx b/app/_layout.tsx
index f518c9b..5aceaa2 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,24 +1,73 @@
-import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
-import { Stack } from 'expo-router';
+import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
+import { useFonts } from 'expo-font';
+import { Stack, useRouter, useSegments } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
+import * as SplashScreen from 'expo-splash-screen';
+import {
+ Manrope_400Regular,
+ Manrope_500Medium,
+ Manrope_600SemiBold,
+ Manrope_700Bold,
+ Manrope_800ExtraBold,
+} from '@expo-google-fonts/manrope';
+import { useEffect } from 'react';
import 'react-native-reanimated';
-import { useColorScheme } from '@/hooks/use-color-scheme';
+import { colors } from '@/components/ui';
+import { collegeApi } from '@/lib/api';
+
+void SplashScreen.preventAutoHideAsync();
+
+const navigationTheme = {
+ ...DefaultTheme,
+ colors: {
+ ...DefaultTheme.colors,
+ primary: colors.brand.cobalt,
+ background: colors.neutral.canvas,
+ card: colors.neutral.surface,
+ text: colors.neutral.textPrimary,
+ border: colors.neutral.divider,
+ notification: colors.brand.coral,
+ },
+};
export const unstable_settings = {
anchor: '(tabs)',
};
export default function RootLayout() {
- const colorScheme = useColorScheme();
+ const router = useRouter();
+ const segments = useSegments();
+ const [fontsLoaded, fontError] = useFonts({
+ Manrope_400Regular,
+ Manrope_500Medium,
+ Manrope_600SemiBold,
+ Manrope_700Bold,
+ Manrope_800ExtraBold,
+ });
+
+ useEffect(() => {
+ if (fontsLoaded || fontError) void SplashScreen.hideAsync();
+ }, [fontError, fontsLoaded]);
+
+ useEffect(() => {
+ collegeApi.profile().then((profile) => {
+ if ((!profile.name || !profile.college) && String(segments[0]) !== 'onboarding') router.replace('/onboarding' as never);
+ }).catch(() => undefined);
+ }, [router, segments]);
+
+ if (!fontsLoaded && !fontError) return null;
return (
-
+
-
+
+
+
+
-
+
);
}
diff --git a/app/account.tsx b/app/account.tsx
new file mode 100644
index 0000000..8ff743e
--- /dev/null
+++ b/app/account.tsx
@@ -0,0 +1,142 @@
+import { Ionicons } from '@expo/vector-icons';
+import { router } from 'expo-router';
+import { useCallback, useEffect, useState } from 'react';
+import { ActivityIndicator, StyleSheet, View } from 'react-native';
+
+import {
+ AppHeader,
+ AppText,
+ Button,
+ Card,
+ FormField,
+ IconButton,
+ InlineBanner,
+ Screen,
+ colors,
+ radius,
+ spacing,
+} from '@/components/ui';
+import { collegeApi, type Profile } from '@/lib/api';
+
+export default function AccountScreen() {
+ const [profile, setProfile] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState('');
+ const [editing, setEditing] = useState(false);
+ const [name, setName] = useState('');
+ const [college, setCollege] = useState('');
+ const [programme, setProgramme] = useState('');
+ const [semester, setSemester] = useState('');
+ const [saving, setSaving] = useState(false);
+ const [formError, setFormError] = useState('');
+
+ const load = useCallback(() => {
+ setLoading(true);
+ setLoadError('');
+ collegeApi.profile()
+ .then((result) => {
+ setProfile(result);
+ setName(result.name);
+ setCollege(result.college);
+ setProgramme(result.programme);
+ setSemester(result.semester);
+ })
+ .catch((error: Error) => setLoadError(error.message))
+ .finally(() => setLoading(false));
+ }, []);
+
+ useEffect(() => { load(); }, [load]);
+
+ const save = async () => {
+ if (!name.trim() || !college.trim()) {
+ setFormError('Your name and college are required.');
+ return;
+ }
+ setSaving(true);
+ setFormError('');
+ try {
+ const updated = await collegeApi.updateProfile({ name, college, programme, semester });
+ setProfile(updated);
+ setEditing(false);
+ } catch (error) {
+ setFormError(error instanceof Error ? error.message : 'Please try again.');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return
+ router.back()} />}
+ trailing={profile ? { setEditing((value) => !value); setFormError(''); }} /> : undefined}
+ />
+
+ {loading ? Loading profile… : null}
+ {loadError ? } style={styles.error} /> : null}
+
+ {profile ? <>
+
+
+ {profile.initials || '?'}
+
+
+ {profile.name}
+ {profile.college}
+
+
+ {editing ?
+ Profile details
+ {formError ? : null}
+
+
+
+
+
+
+
+ : <>
+ College details
+
+
+
+
+
+
+
+ >}
+ > : null}
+ ;
+}
+
+function Info({ icon, label, value }: { icon: keyof typeof Ionicons.glyphMap; label: string; value: string }) {
+ return
+
+
+ {label}
+ {value}
+
+ ;
+}
+
+const styles = StyleSheet.create({
+ content: { paddingBottom: spacing[9] },
+ loading: { minHeight: 220, alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
+ error: { marginTop: spacing[6] },
+ profileHero: { marginTop: spacing[5], alignItems: 'center', borderRadius: radius.feature, backgroundColor: colors.brand.sky, padding: spacing[7] },
+ avatar: { width: 82, height: 82, borderRadius: 27, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.neutral.surface },
+ avatarDot: { position: 'absolute', right: 2, top: 2, width: 14, height: 14, borderRadius: 7, backgroundColor: colors.brand.coral, borderWidth: 3, borderColor: colors.neutral.surface },
+ name: { marginTop: spacing[4], textAlign: 'center' },
+ college: { marginTop: spacing[1], textAlign: 'center' },
+ editCard: { marginTop: spacing[4] },
+ formError: { marginTop: spacing[4] },
+ fields: { gap: spacing[4], marginVertical: spacing[5] },
+ sectionTitle: { marginTop: spacing[8], marginBottom: spacing[3] },
+ details: { overflow: 'hidden' },
+ info: { minHeight: 78, flexDirection: 'row', alignItems: 'center', gap: spacing[3], paddingHorizontal: spacing[4], paddingVertical: spacing[3] },
+ infoIcon: { width: 40, height: 40, borderRadius: radius.control, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.brand.cobaltSoft },
+ infoCopy: { flex: 1 },
+ infoValue: { marginTop: spacing[1] },
+ divider: { height: 1, marginLeft: 68, marginRight: spacing[4], backgroundColor: colors.neutral.divider },
+});
diff --git a/app/modal.tsx b/app/modal.tsx
deleted file mode 100644
index 6dfbc1a..0000000
--- a/app/modal.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Link } from 'expo-router';
-import { StyleSheet } from 'react-native';
-
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-
-export default function ModalScreen() {
- return (
-
- This is a modal
-
- Go to home screen
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- padding: 20,
- },
- link: {
- marginTop: 15,
- paddingVertical: 15,
- },
-});
diff --git a/app/onboarding.tsx b/app/onboarding.tsx
new file mode 100644
index 0000000..4846645
--- /dev/null
+++ b/app/onboarding.tsx
@@ -0,0 +1,165 @@
+import { Ionicons } from '@expo/vector-icons';
+import { router } from 'expo-router';
+import { useState } from 'react';
+import { Pressable, StyleSheet, View } from 'react-native';
+
+import {
+ AppText,
+ Button,
+ Card,
+ FormField,
+ InlineBanner,
+ Screen,
+ SubjectBadge,
+ colors,
+ radius,
+ spacing,
+ type SubjectTone,
+} from '@/components/ui';
+import { collegeApi } from '@/lib/api';
+
+type SubjectDraft = { name: string; code: string; shortName: string; tone: SubjectTone };
+const tones: SubjectTone[] = ['ocean', 'aqua', 'lilac', 'sun', 'mint', 'peach'];
+const makeShortName = (value: string) => value.trim().split(/\s+/).filter(Boolean).map((word) => word[0]).join('').toUpperCase().slice(0, 6);
+
+export default function OnboardingScreen() {
+ const [step, setStep] = useState(1);
+ const [name, setName] = useState('');
+ const [college, setCollege] = useState('');
+ const [programme, setProgramme] = useState('');
+ const [semester, setSemester] = useState('');
+ const [subjectName, setSubjectName] = useState('');
+ const [subjectCode, setSubjectCode] = useState('');
+ const [subjects, setSubjects] = useState([]);
+ const [formError, setFormError] = useState('');
+ const [saving, setSaving] = useState(false);
+
+ const continueToSubjects = () => {
+ if (!name.trim() || !college.trim()) {
+ setFormError('Your name and college are required.');
+ return;
+ }
+ setFormError('');
+ setStep(2);
+ };
+
+ const addSubject = () => {
+ const code = subjectCode.trim().toUpperCase();
+ if (!subjectName.trim() || !code) {
+ setFormError('Add both a subject name and code.');
+ return;
+ }
+ if (subjects.some((subject) => subject.code === code)) {
+ setFormError('That subject code is already in your list.');
+ return;
+ }
+ setSubjects((items) => [...items, { name: subjectName.trim(), code, shortName: makeShortName(subjectName), tone: tones[items.length % tones.length] }]);
+ setSubjectName('');
+ setSubjectCode('');
+ setFormError('');
+ };
+
+ const finish = async () => {
+ setSaving(true);
+ setFormError('');
+ try {
+ await collegeApi.updateProfile({ name, college, programme, semester });
+ for (const subject of subjects) await collegeApi.createSubject({ name: subject.name, code: subject.code, shortName: subject.shortName, color: colors.subject[subject.tone].accent });
+ router.replace('/');
+ } catch (error) {
+ setFormError(error instanceof Error ? error.message : 'Please try again.');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return
+
+
+ CLG Kit
+
+
+
+
+
+
+
+ {step === 1 ? <>
+
+ Build your college day.
+ Start with the essentials. We’ll use them to organize your timetable and attendance.
+
+
+ {formError ? : null}
+
+
+
+
+
+
+ } />
+ > : <>
+ { setStep(1); setFormError(''); }} style={styles.back}>
+
+ College details
+
+
+
+ Add your subjects.
+ They’ll keep the same color throughout your timetable and attendance views.
+
+
+ {formError ? : null}
+
+
+
+ setSubjectCode(value.toUpperCase())} placeholder="e.g. CS201" autoCapitalize="characters" />
+
+ } />
+
+
+ {subjects.length > 0 ?
+ Your subjects
+
+ {subjects.map((subject, index) =>
+
+
+ {subject.name}
+ {subject.code} · {subject.shortName}
+
+ setSubjects((items) => items.filter((_, itemIndex) => itemIndex !== index))} style={styles.remove}>
+
+
+ )}
+
+ : null}
+
+ } style={styles.finish} />
+ >}
+ ;
+}
+
+const styles = StyleSheet.create({
+ content: { paddingTop: spacing[5], paddingBottom: spacing[9] },
+ brand: { flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
+ logo: { width: 40, height: 40, borderRadius: radius.control, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.brand.cobalt },
+ progress: { flexDirection: 'row', gap: spacing[2], marginTop: spacing[8] },
+ progressSegment: { flex: 1, height: 5, borderRadius: radius.pill, backgroundColor: colors.neutral.divider },
+ progressActive: { backgroundColor: colors.brand.cobalt },
+ progressCurrent: { backgroundColor: colors.brand.coral },
+ intro: { marginTop: spacing[9] },
+ introSubjects: { marginTop: spacing[7] },
+ introCopy: { marginTop: spacing[3], maxWidth: 440 },
+ error: { marginTop: spacing[5] },
+ fields: { gap: spacing[5], marginVertical: spacing[7] },
+ fieldsCompact: { gap: spacing[4], marginBottom: spacing[5] },
+ back: { minHeight: 44, marginTop: spacing[5], flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', gap: spacing[2] },
+ subjectForm: { marginTop: spacing[7] },
+ subjectSection: { marginTop: spacing[7] },
+ subjects: { gap: spacing[2], marginTop: spacing[3] },
+ subjectRow: { minHeight: 68, flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
+ subjectCopy: { flex: 1 },
+ subjectMeta: { marginTop: spacing[1] },
+ remove: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' },
+ finish: { marginTop: spacing[7] },
+});
diff --git a/app/settings.tsx b/app/settings.tsx
new file mode 100644
index 0000000..ccb54e8
--- /dev/null
+++ b/app/settings.tsx
@@ -0,0 +1,184 @@
+import { Ionicons } from '@expo/vector-icons';
+import { useCallback, useEffect, useState } from 'react';
+import { ActivityIndicator, StyleSheet, Switch, View } from 'react-native';
+
+import {
+ AppHeader,
+ AppText,
+ Button,
+ Card,
+ FormField,
+ InlineBanner,
+ Screen,
+ colors,
+ radius,
+ spacing,
+} from '@/components/ui';
+import { collegeApi } from '@/lib/api';
+
+export default function SettingsScreen() {
+ const [name, setName] = useState('');
+ const [college, setCollege] = useState('');
+ const [programme, setProgramme] = useState('');
+ const [semester, setSemester] = useState('');
+ const [minutes, setMinutes] = useState('60');
+ const [recessEnabled, setRecessEnabled] = useState(true);
+ const [recessStart, setRecessStart] = useState('13:00');
+ const [recessEnd, setRecessEnd] = useState('14:00');
+ const [weekend, setWeekend] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [loadError, setLoadError] = useState('');
+ const [formError, setFormError] = useState('');
+ const [saved, setSaved] = useState(false);
+
+ const load = useCallback(() => {
+ setLoading(true);
+ setLoadError('');
+ collegeApi.profile()
+ .then((profile) => {
+ setName(profile.name);
+ setCollege(profile.college);
+ setProgramme(profile.programme);
+ setSemester(profile.semester);
+ setMinutes(String(profile.lectureMinutes ?? 60));
+ setRecessEnabled(Boolean(profile.recessEnabled));
+ setRecessStart(profile.recessStart ?? '13:00');
+ setRecessEnd(profile.recessEnd ?? '14:00');
+ setWeekend(Boolean(profile.weekendSchedule));
+ })
+ .catch((error: Error) => setLoadError(error.message))
+ .finally(() => setLoading(false));
+ }, []);
+
+ useEffect(() => { load(); }, [load]);
+
+ const save = async () => {
+ const duration = Number(minutes);
+ if (!name.trim() || !college.trim() || !Number.isInteger(duration) || duration < 15 || duration > 360 || !/^\d{2}:\d{2}$/.test(recessStart) || !/^\d{2}:\d{2}$/.test(recessEnd) || (recessEnabled && recessStart >= recessEnd)) {
+ setFormError('Add your name and college, then use a valid class length and recess time range.');
+ return;
+ }
+ setSaving(true);
+ setFormError('');
+ setSaved(false);
+ try {
+ await Promise.all([
+ collegeApi.updateProfile({ name, college, programme, semester }),
+ collegeApi.updateSettings({ lectureMinutes: duration, recessEnabled, recessStart, recessEnd, weekendSchedule: weekend }),
+ ]);
+ setSaved(true);
+ } catch (error) {
+ setFormError(error instanceof Error ? error.message : 'Please try again.');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return
+
+
+ {loading ? Loading settings… : null}
+ {loadError ? } style={styles.firstBlock} /> : null}
+
+ {!loading && !loadError ? <>
+
+
+
+ Shape your class day
+ These defaults make adding classes faster. You can still adjust individual times.
+
+
+
+ {saved ? : null}
+ {formError ? : null}
+
+ Profile
+
+
+ { setName(value); setSaved(false); }} placeholder="Your name" autoCapitalize="words" />
+ { setCollege(value); setSaved(false); }} placeholder="College or university" autoCapitalize="words" />
+ { setProgramme(value); setSaved(false); }} placeholder="e.g. B.Tech Computer Science" autoCapitalize="words" />
+ { setSemester(value); setSaved(false); }} placeholder="e.g. Semester 3" autoCapitalize="words" />
+
+
+
+ Class timing
+
+
+
+
+ Default class length
+ Used to suggest an end time.
+
+
+ { setMinutes(value); setSaved(false); }} keyboardType="number-pad" placeholder="60" hint="Between 15 and 360 minutes." containerStyle={styles.nestedField} />
+
+
+
+
+
+
+ Recess or lunch
+ Show this break in your daily timeline.
+
+ { setRecessEnabled(value); setSaved(false); }}
+ trackColor={{ false: colors.neutral.border, true: colors.brand.cobaltSoft }}
+ thumbColor={recessEnabled ? colors.brand.cobalt : colors.neutral.surface}
+ />
+
+
+ { setRecessStart(value); setSaved(false); }} placeholder="13:00" hint="HH:MM" containerStyle={styles.timeField} />
+ { setRecessEnd(value); setSaved(false); }} placeholder="14:00" hint="HH:MM" containerStyle={styles.timeField} />
+
+
+
+ Schedule
+
+
+
+
+ Weekend schedule
+ Show Saturday and Sunday in week selectors.
+
+ { setWeekend(value); setSaved(false); }}
+ trackColor={{ false: colors.neutral.border, true: colors.brand.cobaltSoft }}
+ thumbColor={weekend ? colors.brand.cobalt : colors.neutral.surface}
+ />
+
+
+
+
+ > : null}
+ ;
+}
+
+const styles = StyleSheet.create({
+ content: { paddingBottom: spacing[9] },
+ loading: { minHeight: 240, alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
+ firstBlock: { marginTop: spacing[6] },
+ intro: { marginTop: spacing[5], flexDirection: 'row', alignItems: 'center', gap: spacing[4] },
+ introIcon: { width: 48, height: 48, borderRadius: radius.card, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.neutral.surface },
+ introCopy: { flex: 1 },
+ introText: { marginTop: spacing[1] },
+ feedback: { marginTop: spacing[4] },
+ sectionTitle: { marginTop: spacing[8], marginBottom: spacing[3] },
+ card: { padding: spacing[5] },
+ fields: { gap: spacing[4] },
+ settingHeading: { flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
+ settingIcon: { width: 40, height: 40, borderRadius: radius.control, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.brand.cobaltSoft },
+ settingCopy: { flex: 1 },
+ settingSub: { marginTop: spacing[1] },
+ nestedField: { marginTop: spacing[5], marginLeft: 52 },
+ divider: { height: 1, backgroundColor: colors.neutral.divider, marginVertical: spacing[6] },
+ timeFields: { marginTop: spacing[5], marginLeft: 52, flexDirection: 'row', gap: spacing[3] },
+ timeField: { flex: 1 },
+ switchRow: { minHeight: 64, flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
+ save: { marginTop: spacing[7] },
+});
diff --git a/app/subjects/[id].tsx b/app/subjects/[id].tsx
new file mode 100644
index 0000000..e6f54fb
--- /dev/null
+++ b/app/subjects/[id].tsx
@@ -0,0 +1,213 @@
+import { Ionicons } from '@expo/vector-icons';
+import { router, useLocalSearchParams } from 'expo-router';
+import { useCallback, useEffect, useState } from 'react';
+import { ActivityIndicator, StyleSheet, View } from 'react-native';
+
+import {
+ AppHeader,
+ AppText,
+ Button,
+ Card,
+ EmptyState,
+ FormField,
+ IconButton,
+ InlineBanner,
+ ProgressBar,
+ Screen,
+ StatusPill,
+ SubjectBadge,
+ colors,
+ radius,
+ spacing,
+ type SemanticTone,
+} from '@/components/ui';
+import { formatSessionDate } from '@/lib/date';
+import { attendanceMessage, attendanceTone, subjectToneFor } from '@/lib/design';
+import { collegeApi, type AttendanceStatus } from '@/lib/api';
+
+type Details = Awaited>;
+const initials = (value: string) => value.trim().split(/\s+/).filter(Boolean).map((word) => word[0]).join('').toUpperCase().slice(0, 6);
+const statusConfig: Record = {
+ pending: { label: 'Not marked', tone: 'warning', icon: 'time' },
+ attended: { label: 'Attended', tone: 'success', icon: 'checkmark-circle' },
+ absent: { label: 'Absent', tone: 'danger', icon: 'close-circle' },
+ cancelled: { label: 'Cancelled', tone: 'neutral', icon: 'remove-circle' },
+};
+
+export default function SubjectDetailsScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const subjectId = Number(id);
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState('');
+ const [editing, setEditing] = useState(false);
+ const [name, setName] = useState('');
+ const [code, setCode] = useState('');
+ const [shortName, setShortName] = useState('');
+ const [classType, setClassType] = useState('Lecture');
+ const [defaultRoom, setDefaultRoom] = useState('');
+ const [shortEdited, setShortEdited] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [formError, setFormError] = useState('');
+
+ const load = useCallback(() => {
+ if (!Number.isFinite(subjectId)) return;
+ setLoading(true);
+ setLoadError('');
+ collegeApi.subject(subjectId)
+ .then((subject) => {
+ setData(subject);
+ setName(subject.name);
+ setCode(subject.code);
+ setShortName(subject.shortName || initials(subject.name));
+ setClassType(subject.classType || 'Lecture');
+ setDefaultRoom(subject.defaultRoom || '');
+ setShortEdited(Boolean(subject.shortName));
+ })
+ .catch((error: Error) => setLoadError(error.message))
+ .finally(() => setLoading(false));
+ }, [subjectId]);
+
+ useEffect(() => { load(); }, [load]);
+
+ const changeName = (value: string) => {
+ setName(value);
+ if (!shortEdited) setShortName(initials(value));
+ };
+
+ const save = async () => {
+ if (!name.trim() || !code.trim() || !shortName.trim() || !classType.trim()) {
+ setFormError('Name, subject code, short name, and class type are required.');
+ return;
+ }
+ setSaving(true);
+ setFormError('');
+ try {
+ const tone = subjectToneFor(subjectId, data?.color);
+ await collegeApi.updateSubject(subjectId, { name, code, shortName, classType, defaultRoom, color: colors.subject[tone].accent });
+ setEditing(false);
+ load();
+ } catch (error) {
+ setFormError(error instanceof Error ? error.message : 'Please try again.');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ if (loading && !data) return Loading subject…;
+ if (!data) return
+ router.back()} />} />
+ } style={styles.error} />
+ ;
+
+ const tone = subjectToneFor(data.id, data.color);
+ const palette = colors.subject[tone];
+ const summaryTone = attendanceTone(data.summary.percentage, data.summary.total);
+ const summaryCopy = attendanceMessage(data.summary.percentage, data.summary.total);
+
+ return
+ router.back()} />}
+ trailing={ { setEditing((value) => !value); setFormError(''); }} />}
+ />
+
+
+
+
+
+ {data.name}
+ {data.code} · {data.shortName || initials(data.name)}
+
+
+
+
+ {data.summary.total ? `${data.summary.percentage}%` : '—'}
+ Attendance
+
+
+
+
+
+ {editing ?
+ Edit subject
+ {formError ? : null}
+
+
+ setCode(value.toUpperCase())} placeholder="e.g. CS201" autoCapitalize="characters" />
+ { setShortEdited(true); setShortName(value.toUpperCase().slice(0, 6)); }} placeholder="e.g. DS" hint="Up to 6 characters; used in compact schedule views." autoCapitalize="characters" />
+
+
+
+
+ : <>
+
+
+
+
+
+
+
+
+
+
+ Recent classes
+ Attendance history for this subject
+
+
+ {data.sessions.length === 0 ? :
+ {data.sessions.map((session, index) => {
+ const status = statusConfig[session.status];
+ return
+
+
+ {formatSessionDate(session.date)}
+ {session.time}–{session.endTime} · {session.room}
+
+
+ {index < data.sessions.length - 1 ? : null}
+ ;
+ })}
+ }
+ >}
+ ;
+}
+
+function Stat({ value, label }: { value: number; label: string }) {
+ return
+ {value}
+ {label}
+ ;
+}
+
+const styles = StyleSheet.create({
+ content: { paddingBottom: spacing[9] },
+ center: { alignItems: 'center', justifyContent: 'center' },
+ loadingText: { marginTop: spacing[3] },
+ error: { marginTop: spacing[6] },
+ hero: { marginTop: spacing[5], borderRadius: radius.feature, padding: spacing[6] },
+ heroIdentity: { flexDirection: 'row', alignItems: 'center', gap: spacing[4] },
+ badge: { width: 52, height: 52, borderRadius: 17 },
+ heroCopy: { flex: 1 },
+ heroMeta: { marginTop: spacing[1] },
+ heroProgress: { marginTop: spacing[6], flexDirection: 'row', alignItems: 'flex-end', gap: spacing[5] },
+ progressWrap: { flex: 1, paddingBottom: spacing[2] },
+ tabular: { fontVariant: ['tabular-nums'] },
+ editCard: { marginTop: spacing[4] },
+ formError: { marginTop: spacing[4] },
+ fields: { gap: spacing[4], marginVertical: spacing[5] },
+ insight: { marginTop: spacing[4] },
+ stats: { minHeight: 92, marginTop: spacing[3], flexDirection: 'row', alignItems: 'center' },
+ stat: { flex: 1, alignItems: 'center', paddingVertical: spacing[5] },
+ statDivider: { width: 1, height: 36, backgroundColor: colors.neutral.divider },
+ statLabel: { marginTop: spacing[1], textAlign: 'center' },
+ sectionHeader: { marginTop: spacing[8], marginBottom: spacing[4] },
+ sectionSub: { marginTop: spacing[1] },
+ history: { overflow: 'hidden' },
+ session: { minHeight: 76, flexDirection: 'row', alignItems: 'center', gap: spacing[3], paddingHorizontal: spacing[4], paddingVertical: spacing[3] },
+ statusDot: { width: 9, height: 9, borderRadius: 5 },
+ sessionCopy: { flex: 1 },
+ sessionMeta: { marginTop: spacing[1], fontVariant: ['tabular-nums'] },
+ divider: { position: 'absolute', left: 36, right: spacing[4], bottom: 0, height: 1, backgroundColor: colors.neutral.divider },
+});
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..7be82fb
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,9 @@
+FROM node:22-bookworm-slim
+WORKDIR /app
+COPY backend/server.mjs ./backend/server.mjs
+RUN mkdir -p /app/backend/data && chown -R node:node /app
+USER node
+ENV NODE_ENV=production
+ENV PORT=4000
+EXPOSE 4000
+CMD ["node", "backend/server.mjs"]
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 0000000..3ec2085
--- /dev/null
+++ b/backend/README.md
@@ -0,0 +1,50 @@
+# College Kit API
+
+A zero-dependency Node HTTP API backed by a persistent SQLite database. The database is created at `backend/data/college-kit.db` on first start with no subjects, timetable rules, sessions, or attendance records.
+
+## Run
+
+```sh
+npm run api
+```
+
+The API listens on `http://localhost:4000`. In a second terminal, run the Expo app:
+
+```sh
+npm start
+```
+
+For a physical device, expose the API on your development machine's LAN IP:
+
+```sh
+EXPO_PUBLIC_API_URL=http://192.168.1.20:4000/api/v1 npm start
+```
+
+## Data model
+
+- `users` — currently a seeded local user; this is the seam for authentication.
+- `subjects` — course name, code, and display colour.
+- `timetable_classes` — recurring weekly timetable rules.
+- `class_sessions` — dated generated classes plus one-off overrides.
+- `attendance` — one status per session: `pending`, `attended`, `absent`, or `cancelled`.
+
+Sessions are generated from the weekly timetable when a date is requested. One-off sessions and edits are stored independently, so daily changes never alter the regular timetable. Cancelled sessions are excluded from attendance totals.
+
+Recurring timetable edits are **versioned**: `PATCH /timetable/classes/:id` closes the existing rule and creates a replacement rule effective tomorrow (or a later supplied date). It only removes future, auto-generated sessions for the old rule. Past sessions, their times, and every attendance record are retained unchanged.
+
+## API
+
+| Method | Route | Purpose |
+| --- | --- | --- |
+| GET | `/health` | Service check |
+| GET | `/api/v1/profile` | Current user profile |
+| GET | `/api/v1/schedule?date=YYYY-MM-DD` | Get/create dated sessions |
+| POST | `/api/v1/schedule` | Add a one-off class |
+| PATCH / DELETE | `/api/v1/schedule/:id` | Edit/remove a dated class |
+| PUT | `/api/v1/schedule/:id/attendance` | Set `{ "status": "attended" }` |
+| GET / POST | `/api/v1/subjects` | List/create subjects |
+| GET / POST | `/api/v1/timetable/classes?date=YYYY-MM-DD` | List/create recurring classes active for a date |
+| PATCH | `/api/v1/timetable/classes/:id` | Version a recurring class change for tomorrow or later |
+| GET | `/api/v1/attendance/summary` | Per-subject attendance totals |
+
+Timetable image OCR is deliberately not performed by this service. A mobile client should upload/select an image, run OCR with a provider of choice, let the student review the extracted classes, then send confirmed subjects and timetable classes through the endpoints above. This avoids silently creating an inaccurate timetable from an image.
diff --git a/backend/server.mjs b/backend/server.mjs
new file mode 100644
index 0000000..3e9a76d
--- /dev/null
+++ b/backend/server.mjs
@@ -0,0 +1,250 @@
+import { createServer } from 'node:http';
+import { mkdirSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { DatabaseSync } from 'node:sqlite';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const dataDir = join(__dirname, 'data');
+mkdirSync(dataDir, { recursive: true });
+const db = new DatabaseSync(join(dataDir, 'college-kit.db'));
+db.exec('PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;');
+
+const PORT = Number(process.env.PORT || 4000);
+const STUDENT_ID = 1; // Replace with the authenticated user's id when auth is added.
+const validStatuses = new Set(['attended', 'absent', 'cancelled', 'pending']);
+
+function migrate() {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY, name TEXT NOT NULL, initials TEXT NOT NULL,
+ college TEXT NOT NULL DEFAULT '', programme TEXT NOT NULL DEFAULT '', semester TEXT NOT NULL DEFAULT '',
+ lecture_minutes INTEGER NOT NULL DEFAULT 60, recess_enabled INTEGER NOT NULL DEFAULT 1, recess_start TEXT NOT NULL DEFAULT '13:00', recess_end TEXT NOT NULL DEFAULT '14:00', weekend_schedule INTEGER NOT NULL DEFAULT 0
+ );
+ CREATE TABLE IF NOT EXISTS subjects (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id),
+ name TEXT NOT NULL, code TEXT NOT NULL, short_name TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT '#0559FA',
+ class_type TEXT NOT NULL DEFAULT 'Lecture', default_room TEXT NOT NULL DEFAULT '',
+ UNIQUE(user_id, code)
+ );
+ CREATE TABLE IF NOT EXISTS timetable_classes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id),
+ subject_id INTEGER NOT NULL REFERENCES subjects(id), weekday INTEGER NOT NULL CHECK(weekday BETWEEN 0 AND 6),
+ start_time TEXT NOT NULL, end_time TEXT NOT NULL, room TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1,
+ effective_from TEXT NOT NULL DEFAULT '1970-01-01', effective_to TEXT,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE IF NOT EXISTS class_sessions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id),
+ subject_id INTEGER NOT NULL REFERENCES subjects(id), timetable_class_id INTEGER REFERENCES timetable_classes(id),
+ class_date TEXT NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, room TEXT NOT NULL,
+ is_override INTEGER NOT NULL DEFAULT 0, is_removed INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(user_id, timetable_class_id, class_date)
+ );
+ CREATE TABLE IF NOT EXISTS attendance (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL UNIQUE REFERENCES class_sessions(id) ON DELETE CASCADE,
+ status TEXT NOT NULL CHECK(status IN ('pending', 'attended', 'absent', 'cancelled')) DEFAULT 'pending',
+ marked_at TEXT, note TEXT
+ );
+ `);
+ const subjectColumns = db.prepare('PRAGMA table_info(subjects)').all().map((column) => column.name);
+ if (!subjectColumns.includes('short_name')) db.exec("ALTER TABLE subjects ADD COLUMN short_name TEXT NOT NULL DEFAULT ''");
+ if (!subjectColumns.includes('class_type')) db.exec("ALTER TABLE subjects ADD COLUMN class_type TEXT NOT NULL DEFAULT 'Lecture'");
+ if (!subjectColumns.includes('default_room')) db.exec("ALTER TABLE subjects ADD COLUMN default_room TEXT NOT NULL DEFAULT ''");
+ const userColumns = db.prepare('PRAGMA table_info(users)').all().map((column) => column.name);
+ if (!userColumns.includes('college')) db.exec("ALTER TABLE users ADD COLUMN college TEXT NOT NULL DEFAULT ''");
+ if (!userColumns.includes('programme')) db.exec("ALTER TABLE users ADD COLUMN programme TEXT NOT NULL DEFAULT ''");
+ if (!userColumns.includes('semester')) db.exec("ALTER TABLE users ADD COLUMN semester TEXT NOT NULL DEFAULT ''");
+ if (!userColumns.includes('lecture_minutes')) db.exec('ALTER TABLE users ADD COLUMN lecture_minutes INTEGER NOT NULL DEFAULT 60');
+ if (!userColumns.includes('recess_enabled')) db.exec('ALTER TABLE users ADD COLUMN recess_enabled INTEGER NOT NULL DEFAULT 1');
+ if (!userColumns.includes('recess_start')) db.exec("ALTER TABLE users ADD COLUMN recess_start TEXT NOT NULL DEFAULT '13:00'");
+ if (!userColumns.includes('recess_end')) db.exec("ALTER TABLE users ADD COLUMN recess_end TEXT NOT NULL DEFAULT '14:00'");
+ if (!userColumns.includes('weekend_schedule')) db.exec('ALTER TABLE users ADD COLUMN weekend_schedule INTEGER NOT NULL DEFAULT 0');
+ const sessionColumns = db.prepare('PRAGMA table_info(class_sessions)').all().map((column) => column.name);
+ if (!sessionColumns.includes('is_removed')) db.exec('ALTER TABLE class_sessions ADD COLUMN is_removed INTEGER NOT NULL DEFAULT 0');
+ const templateColumns = db.prepare('PRAGMA table_info(timetable_classes)').all().map((column) => column.name);
+ if (!templateColumns.includes('effective_from')) db.exec("ALTER TABLE timetable_classes ADD COLUMN effective_from TEXT NOT NULL DEFAULT '1970-01-01'");
+ if (!templateColumns.includes('effective_to')) db.exec('ALTER TABLE timetable_classes ADD COLUMN effective_to TEXT');
+ const user = db.prepare('SELECT id FROM users WHERE id = ?').get(STUDENT_ID);
+ // Keep only an empty local account record for the unauthenticated prototype.
+ // Courses, timetable rules, sessions, and attendance are never seeded.
+ if (!user) db.prepare('INSERT INTO users (id, name, initials) VALUES (?, ?, ?)').run(STUDENT_ID, '', '');
+}
+
+function json(res, status, body) {
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Allow-Methods': 'GET,POST,PATCH,PUT,DELETE,OPTIONS' });
+ res.end(JSON.stringify(body));
+}
+function error(res, status, message) { json(res, status, { error: message }); }
+function dateIsValid(value) { return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(new Date(`${value}T12:00:00`).valueOf()); }
+function weekdayFor(date) { return new Date(`${date}T12:00:00`).getDay(); }
+function localDateKey(date = new Date()) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; }
+function nextDateKey(date) { const next = new Date(`${date}T12:00:00`); next.setDate(next.getDate() + 1); return localDateKey(next); }
+function previousDateKey(date) { const previous = new Date(`${date}T12:00:00`); previous.setDate(previous.getDate() - 1); return localDateKey(previous); }
+function readBody(req) { return new Promise((resolve, reject) => { let raw = ''; req.on('data', c => { raw += c; if (raw.length > 1_000_000) reject(new Error('Request body is too large')); }); req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch { reject(new Error('Body must be valid JSON')); } }); req.on('error', reject); }); }
+function sessionRows(date) {
+ return db.prepare(`SELECT cs.id, cs.class_date AS date, cs.start_time AS time, cs.end_time AS endTime, cs.room, cs.is_override AS isOverride,
+ s.id AS subjectId, s.name AS title, s.code, s.color, s.class_type AS classType, COALESCE(a.status, 'pending') AS status, a.note
+ FROM class_sessions cs JOIN subjects s ON s.id = cs.subject_id LEFT JOIN attendance a ON a.session_id = cs.id
+ WHERE cs.user_id = ? AND cs.class_date = ? AND cs.is_removed = 0 ORDER BY cs.start_time`).all(STUDENT_ID, date);
+}
+function ensureSessions(date) {
+ const templates = db.prepare(`SELECT * FROM timetable_classes
+ WHERE user_id = ? AND weekday = ? AND active = 1 AND effective_from <= ?
+ AND (effective_to IS NULL OR effective_to >= ?)`).all(STUDENT_ID, weekdayFor(date), date, date);
+ const create = db.prepare('INSERT OR IGNORE INTO class_sessions (user_id, subject_id, timetable_class_id, class_date, start_time, end_time, room) VALUES (?, ?, ?, ?, ?, ?, ?)');
+ for (const item of templates) create.run(STUDENT_ID, item.subject_id, item.id, date, item.start_time, item.end_time, item.room);
+}
+function getSubject(id) { return db.prepare('SELECT id FROM subjects WHERE id = ? AND user_id = ?').get(id, STUDENT_ID); }
+
+migrate();
+const server = createServer(async (req, res) => {
+ if (req.method === 'OPTIONS') return json(res, 204, {});
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
+ const path = url.pathname;
+ try {
+ if (req.method === 'GET' && path === '/health') return json(res, 200, { ok: true });
+ if (req.method === 'GET' && path === '/api/v1/profile') return json(res, 200, db.prepare('SELECT id, name, initials, college, programme, semester, lecture_minutes AS lectureMinutes, recess_enabled AS recessEnabled, recess_start AS recessStart, recess_end AS recessEnd, weekend_schedule AS weekendSchedule FROM users WHERE id = ?').get(STUDENT_ID));
+ if (req.method === 'PATCH' && path === '/api/v1/settings') {
+ const body = await readBody(req); const minutes = Number(body.lectureMinutes); const recessEnabled = body.recessEnabled === false ? 0 : 1; const weekendSchedule = body.weekendSchedule ? 1 : 0;
+ const validRecess = /^\d\d:\d\d$/.test(body.recessStart) && /^\d\d:\d\d$/.test(body.recessEnd) && (!recessEnabled || body.recessStart < body.recessEnd);
+ if (!Number.isInteger(minutes) || minutes < 15 || minutes > 360 || !validRecess) return error(res, 400, 'Use a lecture length between 15 and 360 minutes and valid recess times');
+ db.prepare('UPDATE users SET lecture_minutes = ?, recess_enabled = ?, recess_start = ?, recess_end = ?, weekend_schedule = ? WHERE id = ?').run(minutes, recessEnabled, body.recessStart, body.recessEnd, weekendSchedule, STUDENT_ID);
+ return json(res, 200, { lectureMinutes: minutes, recessEnabled: Boolean(recessEnabled), recessStart: body.recessStart, recessEnd: body.recessEnd, weekendSchedule: Boolean(weekendSchedule) });
+ }
+ if (req.method === 'PUT' && path === '/api/v1/profile') {
+ const body = await readBody(req);
+ if (!body.name?.trim() || !body.college?.trim()) return error(res, 400, 'name and college are required');
+ const initials = body.name.trim().split(/\s+/).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
+ db.prepare('UPDATE users SET name = ?, initials = ?, college = ?, programme = ?, semester = ? WHERE id = ?').run(body.name.trim(), initials, body.college.trim(), body.programme?.trim() || '', body.semester?.trim() || '', STUDENT_ID);
+ return json(res, 200, db.prepare('SELECT id, name, initials, college, programme, semester, lecture_minutes AS lectureMinutes, recess_enabled AS recessEnabled, recess_start AS recessStart, recess_end AS recessEnd, weekend_schedule AS weekendSchedule FROM users WHERE id = ?').get(STUDENT_ID));
+ }
+
+ if (req.method === 'GET' && path === '/api/v1/schedule') {
+ const date = url.searchParams.get('date');
+ if (!dateIsValid(date)) return error(res, 400, 'date must be YYYY-MM-DD');
+ ensureSessions(date);
+ return json(res, 200, { date, sessions: sessionRows(date) });
+ }
+ if (req.method === 'POST' && path === '/api/v1/schedule') {
+ const body = await readBody(req);
+ const { subjectId, date, startTime, endTime, room } = body;
+ if (!getSubject(subjectId) || !dateIsValid(date) || !/^\d\d:\d\d$/.test(startTime) || !/^\d\d:\d\d$/.test(endTime) || !room?.trim()) return error(res, 400, 'subjectId, date, startTime, endTime and room are required');
+ const record = db.prepare('INSERT INTO class_sessions (user_id, subject_id, class_date, start_time, end_time, room, is_override) VALUES (?, ?, ?, ?, ?, ?, 1)').run(STUDENT_ID, subjectId, date, startTime, endTime, room.trim());
+ return json(res, 201, db.prepare('SELECT id FROM class_sessions WHERE id = ?').get(record.lastInsertRowid));
+ }
+ const scheduleMatch = path.match(/^\/api\/v1\/schedule\/(\d+)$/);
+ if (req.method === 'DELETE' && scheduleMatch) {
+ const id = Number(scheduleMatch[1]);
+ const session = db.prepare('SELECT timetable_class_id AS timetableClassId FROM class_sessions WHERE id = ? AND user_id = ?').get(id, STUDENT_ID);
+ if (!session) return error(res, 404, 'Class session not found');
+ // Recurring classes are hidden only for this date, so the weekly rule remains intact.
+ if (session.timetableClassId) db.prepare('UPDATE class_sessions SET is_removed = 1, is_override = 1 WHERE id = ?').run(id);
+ else db.prepare('DELETE FROM class_sessions WHERE id = ? AND user_id = ?').run(id, STUDENT_ID);
+ return json(res, 200, { id, deleted: true });
+ }
+ if (req.method === 'PATCH' && scheduleMatch) {
+ const body = await readBody(req); const id = Number(scheduleMatch[1]);
+ const existing = db.prepare('SELECT id FROM class_sessions WHERE id = ? AND user_id = ?').get(id, STUDENT_ID);
+ if (!existing) return error(res, 404, 'Class session not found');
+ const fields = []; const values = [];
+ for (const [key, column] of Object.entries({ startTime: 'start_time', endTime: 'end_time', room: 'room' })) if (body[key] !== undefined) { fields.push(`${column} = ?`); values.push(typeof body[key] === 'string' ? body[key].trim() : body[key]); }
+ if (!fields.length) return error(res, 400, 'No editable fields supplied');
+ db.prepare(`UPDATE class_sessions SET ${fields.join(', ')}, is_override = 1 WHERE id = ?`).run(...values, id);
+ return json(res, 200, { id });
+ }
+ const attendanceMatch = path.match(/^\/api\/v1\/schedule\/(\d+)\/attendance$/);
+ if (req.method === 'PUT' && attendanceMatch) {
+ const body = await readBody(req); const id = Number(attendanceMatch[1]);
+ if (!validStatuses.has(body.status)) return error(res, 400, 'status must be pending, attended, absent, or cancelled');
+ const session = db.prepare('SELECT id FROM class_sessions WHERE id = ? AND user_id = ?').get(id, STUDENT_ID);
+ if (!session) return error(res, 404, 'Class session not found');
+ db.prepare(`INSERT INTO attendance (session_id, status, marked_at, note) VALUES (?, ?, CURRENT_TIMESTAMP, ?)
+ ON CONFLICT(session_id) DO UPDATE SET status = excluded.status, marked_at = CURRENT_TIMESTAMP, note = excluded.note`).run(id, body.status, body.note?.trim() || null);
+ return json(res, 200, { id, status: body.status });
+ }
+ if (req.method === 'GET' && path === '/api/v1/subjects') {
+ return json(res, 200, db.prepare('SELECT id, name, code, short_name AS shortName, color, class_type AS classType, default_room AS defaultRoom FROM subjects WHERE user_id = ? ORDER BY name').all(STUDENT_ID));
+ }
+ if (req.method === 'POST' && path === '/api/v1/subjects') {
+ const body = await readBody(req);
+ if (!body.name?.trim() || !body.code?.trim()) return error(res, 400, 'name and code are required');
+ try {
+ const result = db.prepare('INSERT INTO subjects (user_id, name, code, short_name, color, class_type, default_room) VALUES (?, ?, ?, ?, ?, ?, ?)').run(STUDENT_ID, body.name.trim(), body.code.trim().toUpperCase(), body.shortName?.trim() || body.code.trim().toUpperCase(), body.color || '#0559FA', body.classType?.trim() || 'Lecture', body.defaultRoom?.trim() || '');
+ return json(res, 201, { id: Number(result.lastInsertRowid) });
+ } catch { return error(res, 409, 'A subject with that code already exists'); }
+ }
+ if (req.method === 'GET' && path === '/api/v1/timetable/classes') {
+ const date = url.searchParams.get('date') || localDateKey();
+ if (!dateIsValid(date)) return error(res, 400, 'date must be YYYY-MM-DD');
+ const rows = db.prepare(`SELECT tc.id, tc.weekday, tc.start_time AS startTime, tc.end_time AS endTime, tc.room, tc.active,
+ tc.effective_from AS effectiveFrom, tc.effective_to AS effectiveTo, s.id AS subjectId, s.name AS subjectName, s.code, s.color, s.class_type AS classType
+ FROM timetable_classes tc JOIN subjects s ON s.id = tc.subject_id
+ WHERE tc.user_id = ? AND tc.weekday = ? AND tc.active = 1 AND tc.effective_from <= ?
+ AND (tc.effective_to IS NULL OR tc.effective_to >= ?) ORDER BY tc.start_time`).all(STUDENT_ID, weekdayFor(date), date, date);
+ return json(res, 200, { date, classes: rows });
+ }
+ if (req.method === 'POST' && path === '/api/v1/timetable/classes') {
+ const body = await readBody(req);
+ // A newly added recurring class must also begin in the future; past timetable history is immutable.
+ const earliestFutureDate = nextDateKey(localDateKey());
+ const effectiveFrom = body.effectiveFrom && dateIsValid(body.effectiveFrom) && body.effectiveFrom > localDateKey() ? body.effectiveFrom : earliestFutureDate;
+ if (!getSubject(body.subjectId) || !Number.isInteger(body.weekday) || body.weekday < 0 || body.weekday > 6 || !dateIsValid(effectiveFrom) || !/^\d\d:\d\d$/.test(body.startTime) || !/^\d\d:\d\d$/.test(body.endTime) || !body.room?.trim()) return error(res, 400, 'subjectId, weekday, startTime, endTime, room and a valid effectiveFrom date are required');
+ const result = db.prepare('INSERT INTO timetable_classes (user_id, subject_id, weekday, start_time, end_time, room, effective_from) VALUES (?, ?, ?, ?, ?, ?, ?)').run(STUDENT_ID, body.subjectId, body.weekday, body.startTime, body.endTime, body.room.trim(), effectiveFrom);
+ return json(res, 201, { id: Number(result.lastInsertRowid), effectiveFrom });
+ }
+ const timetableMatch = path.match(/^\/api\/v1\/timetable\/classes\/(\d+)$/);
+ if (req.method === 'PATCH' && timetableMatch) {
+ const body = await readBody(req); const id = Number(timetableMatch[1]);
+ const existing = db.prepare('SELECT * FROM timetable_classes WHERE id = ? AND user_id = ? AND active = 1').get(id, STUDENT_ID);
+ if (!existing) return error(res, 404, 'Timetable class not found');
+ // A recurring timetable edit is versioned. It can only start tomorrow or later,
+ // and past sessions are never updated or deleted.
+ const earliestFutureDate = nextDateKey(localDateKey());
+ const effectiveFrom = body.effectiveFrom && dateIsValid(body.effectiveFrom) && body.effectiveFrom > earliestFutureDate ? body.effectiveFrom : earliestFutureDate;
+ const nextSubjectId = body.subjectId === undefined ? existing.subject_id : body.subjectId;
+ const nextWeekday = body.weekday === undefined ? existing.weekday : body.weekday;
+ const nextStart = body.startTime === undefined ? existing.start_time : body.startTime;
+ const nextEnd = body.endTime === undefined ? existing.end_time : body.endTime;
+ const nextRoom = body.room === undefined ? existing.room : body.room.trim();
+ if (!getSubject(nextSubjectId) || !Number.isInteger(nextWeekday) || nextWeekday < 0 || nextWeekday > 6 || !/^\d\d:\d\d$/.test(nextStart) || !/^\d\d:\d\d$/.test(nextEnd) || !nextRoom) return error(res, 400, 'Invalid timetable change');
+ db.prepare('UPDATE timetable_classes SET effective_to = ? WHERE id = ?').run(previousDateKey(effectiveFrom), id);
+ // Remove only generated, unmodified FUTURE sessions. Historical sessions and daily overrides stay intact.
+ db.prepare('DELETE FROM class_sessions WHERE timetable_class_id = ? AND class_date >= ? AND is_override = 0').run(id, effectiveFrom);
+ const result = db.prepare('INSERT INTO timetable_classes (user_id, subject_id, weekday, start_time, end_time, room, effective_from) VALUES (?, ?, ?, ?, ?, ?, ?)').run(STUDENT_ID, nextSubjectId, nextWeekday, nextStart, nextEnd, nextRoom, effectiveFrom);
+ return json(res, 201, { id: Number(result.lastInsertRowid), replacesId: id, effectiveFrom });
+ }
+ const subjectMatch = path.match(/^\/api\/v1\/subjects\/(\d+)$/);
+ if (req.method === 'GET' && subjectMatch) {
+ const id = Number(subjectMatch[1]);
+ const subject = db.prepare('SELECT id, name, code, short_name AS shortName, color, class_type AS classType, default_room AS defaultRoom FROM subjects WHERE id = ? AND user_id = ?').get(id, STUDENT_ID);
+ if (!subject) return error(res, 404, 'Subject not found');
+ const sessions = db.prepare(`SELECT cs.id, cs.class_date AS date, cs.start_time AS time, cs.end_time AS endTime, cs.room,
+ COALESCE(a.status, 'pending') AS status FROM class_sessions cs LEFT JOIN attendance a ON a.session_id = cs.id
+ WHERE cs.subject_id = ? AND cs.user_id = ? ORDER BY cs.class_date DESC, cs.start_time DESC`).all(id, STUDENT_ID);
+ const eligible = sessions.filter((session) => session.status !== 'cancelled');
+ const attended = eligible.filter((session) => session.status === 'attended').length;
+ return json(res, 200, { ...subject, summary: { total: eligible.length, attended, absent: eligible.filter((session) => session.status === 'absent').length, percentage: eligible.length ? Math.round(attended / eligible.length * 100) : 0 }, sessions });
+ }
+ if (req.method === 'PATCH' && subjectMatch) {
+ const id = Number(subjectMatch[1]); const body = await readBody(req);
+ const existing = getSubject(id); if (!existing) return error(res, 404, 'Subject not found');
+ if (!body.name?.trim() || !body.code?.trim()) return error(res, 400, 'name and code are required');
+ try {
+ db.prepare('UPDATE subjects SET name = ?, code = ?, short_name = ?, color = ?, class_type = ?, default_room = ? WHERE id = ? AND user_id = ?').run(body.name.trim(), body.code.trim().toUpperCase(), body.shortName?.trim() || body.code.trim().toUpperCase(), body.color || '#0559FA', body.classType?.trim() || 'Lecture', body.defaultRoom?.trim() || '', id, STUDENT_ID);
+ return json(res, 200, { id });
+ } catch { return error(res, 409, 'A subject with that code already exists'); }
+ }
+ if (req.method === 'GET' && path === '/api/v1/attendance/summary') {
+ const rows = db.prepare(`SELECT s.id, s.name, s.code, s.color,
+ COUNT(cs.id) AS total, SUM(CASE WHEN a.status = 'attended' THEN 1 ELSE 0 END) AS attended,
+ SUM(CASE WHEN a.status = 'absent' THEN 1 ELSE 0 END) AS absent,
+ SUM(CASE WHEN a.status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled
+ FROM subjects s LEFT JOIN class_sessions cs ON cs.subject_id = s.id LEFT JOIN attendance a ON a.session_id = cs.id
+ WHERE s.user_id = ? GROUP BY s.id ORDER BY s.name`).all(STUDENT_ID);
+ return json(res, 200, { subjects: rows.map(r => ({ ...r, total: Number(r.total) - Number(r.cancelled), percentage: Number(r.total) - Number(r.cancelled) ? Math.round(Number(r.attended) / (Number(r.total) - Number(r.cancelled)) * 100) : 0 })) });
+ }
+ return error(res, 404, 'Route not found');
+ } catch (err) { console.error(err); return error(res, 400, err.message || 'Bad request'); }
+});
+server.listen(PORT, '0.0.0.0', () => console.log(`College Kit API listening on http://localhost:${PORT}`));
diff --git a/components/add-class-modal.tsx b/components/add-class-modal.tsx
new file mode 100644
index 0000000..4b8b747
--- /dev/null
+++ b/components/add-class-modal.tsx
@@ -0,0 +1,179 @@
+import { Ionicons } from '@expo/vector-icons';
+import { useEffect, useState } from 'react';
+import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
+
+import {
+ AppText,
+ BottomSheet,
+ Button,
+ FormField,
+ InlineBanner,
+ SubjectBadge,
+ colors,
+ radius,
+ spacing,
+} from '@/components/ui';
+import { dateFromKey, formatDayHeading, timeToMinutes } from '@/lib/date';
+import { subjectToneFor } from '@/lib/design';
+import { collegeApi, type Subject } from '@/lib/api';
+
+type Props = { visible: boolean; onClose: () => void; onAdded: () => void; date: string; regular?: boolean; weekday?: number };
+type TimeRange = { start: string; end: string };
+
+const subjectShortName = (subject: Subject) => subject.shortName || subject.name.trim().split(/\s+/).filter(Boolean).map((word) => word[0]).join('').toUpperCase().slice(0, 6);
+const addMinutes = (time: string, minutes: number) => {
+ const [hours, mins] = time.split(':').map(Number);
+ const total = ((hours * 60 + mins + minutes) % (24 * 60) + 24 * 60) % (24 * 60);
+ return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
+};
+const skipRecess = (time: string, recessStart: string, recessEnd: string) => time >= recessStart && time < recessEnd ? recessEnd : time;
+const validTime = (value: string) => /^([01]\d|2[0-3]):[0-5]\d$/.test(value);
+
+export function AddClassModal({ visible, onClose, onAdded, date, regular = false, weekday }: Props) {
+ const [subjects, setSubjects] = useState([]);
+ const [subjectId, setSubjectId] = useState(null);
+ const [lectureMinutes, setLectureMinutes] = useState(60);
+ const [recessEnabled, setRecessEnabled] = useState(true);
+ const [recess, setRecess] = useState('13:00–14:00');
+ const [startTime, setStartTime] = useState('09:00');
+ const [endTime, setEndTime] = useState('10:00');
+ const [room, setRoom] = useState('');
+ const [existingRanges, setExistingRanges] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ if (!visible) return;
+ setLoading(true);
+ setError('');
+ const existing = regular
+ ? collegeApi.timetable(date).then(({ classes }) => classes.map((item) => ({ start: item.startTime, end: item.endTime })))
+ : collegeApi.schedule(date).then(({ sessions }) => sessions.filter((item) => item.status !== 'cancelled').map((item) => ({ start: item.time, end: item.endTime })));
+
+ Promise.all([collegeApi.subjects(), collegeApi.profile(), existing])
+ .then(([items, profile, ranges]) => {
+ setSubjects(items);
+ setSubjectId(items[0]?.id ?? null);
+ setRoom(items[0]?.defaultRoom ?? '');
+ setExistingRanges(ranges);
+ const minutes = profile.lectureMinutes ?? 60;
+ const recessIsEnabled = Boolean(profile.recessEnabled);
+ const recessStart = profile.recessStart ?? '13:00';
+ const recessEnd = profile.recessEnd ?? '14:00';
+ const lastEnd = [...ranges].sort((left, right) => left.end.localeCompare(right.end)).at(-1)?.end;
+ const nextStart = recessIsEnabled ? skipRecess(lastEnd || '09:00', recessStart, recessEnd) : (lastEnd || '09:00');
+ setLectureMinutes(minutes);
+ setRecessEnabled(recessIsEnabled);
+ setRecess(`${recessStart}–${recessEnd}`);
+ setStartTime(nextStart);
+ setEndTime(addMinutes(nextStart, minutes));
+ })
+ .catch((loadError: Error) => setError(loadError.message))
+ .finally(() => setLoading(false));
+ }, [visible, date, regular]);
+
+ const updateStart = (value: string) => {
+ setStartTime(value);
+ setError('');
+ if (validTime(value)) setEndTime(addMinutes(value, lectureMinutes));
+ };
+
+ const save = async () => {
+ if (!subjectId) {
+ setError('Choose a subject before adding this class.');
+ return;
+ }
+ if (!validTime(startTime) || !validTime(endTime) || timeToMinutes(startTime) >= timeToMinutes(endTime)) {
+ setError('Use valid times and make the end time later than the start time.');
+ return;
+ }
+ const overlaps = existingRanges.some((range) => timeToMinutes(startTime) < timeToMinutes(range.end) && timeToMinutes(endTime) > timeToMinutes(range.start));
+ if (overlaps) {
+ setError('This time overlaps another class. Choose a different time range.');
+ return;
+ }
+
+ setSaving(true);
+ setError('');
+ try {
+ const location = room.trim() || 'TBA';
+ if (regular) await collegeApi.addTimetableClass({ subjectId, weekday: weekday ?? dateFromKey(date).getDay(), startTime, endTime, room: location, effectiveFrom: date });
+ else await collegeApi.addOneOffClass({ subjectId, date, startTime, endTime, room: location });
+ onAdded();
+ onClose();
+ setRoom('');
+ } catch (saveError) {
+ setError(saveError instanceof Error ? saveError.message : 'Please try again.');
+ } finally {
+ setSaving(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.`
+ : `One-off class for ${formatDayHeading(dateFromKey(date))}.`;
+
+ return } />}>
+ {context}
+
+ {error ? : null}
+ {loading ? Loading subjects and times… : null}
+
+ {!loading ? <>
+ Subject
+ {subjects.length === 0 ? :
+ {subjects.map((subject) => {
+ const tone = subjectToneFor(subject.id, subject.color);
+ const selected = subject.id === subjectId;
+ return { setSubjectId(subject.id); setRoom(subject.defaultRoom ?? ''); setError(''); }}
+ style={({ pressed }) => [styles.subject, selected && { backgroundColor: colors.subject[tone].surface, borderColor: colors.subject[tone].accent }, pressed && styles.pressed]}>
+
+
+ {subject.name}
+ {subject.code}
+
+ {selected ? : null}
+ ;
+ })}
+ }
+
+
+ Time
+ {lectureMinutes} min default · {recessEnabled ? `recess ${recess}` : 'recess off'}
+
+
+
+ { setEndTime(value); setError(''); }} placeholder="10:00" hint="HH:MM" containerStyle={styles.timeField} />
+
+
+
+ > : null}
+ ;
+}
+
+const styles = StyleSheet.create({
+ feedback: { marginTop: spacing[4] },
+ 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 },
+ pressed: { opacity: 0.76 },
+ subjectCopy: { flex: 1 },
+ radio: { width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: colors.neutral.border, alignItems: 'center', justifyContent: 'center' },
+ radioDot: { width: 10, height: 10, borderRadius: 5 },
+ timeHeading: { marginTop: spacing[6], marginBottom: spacing[3], flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-between', gap: spacing[3] },
+ timeFields: { flexDirection: 'row', gap: spacing[3] },
+ timeField: { flex: 1 },
+ roomField: { marginTop: spacing[5], marginBottom: spacing[2] },
+});
diff --git a/components/themed-text.tsx b/components/themed-text.tsx
index d79d0a1..baa4c24 100644
--- a/components/themed-text.tsx
+++ b/components/themed-text.tsx
@@ -35,26 +35,30 @@ export function ThemedText({
const styles = StyleSheet.create({
default: {
- fontSize: 16,
- lineHeight: 24,
+ fontFamily: 'Manrope_400Regular',
+ fontSize: 15,
+ lineHeight: 22,
},
defaultSemiBold: {
- fontSize: 16,
- lineHeight: 24,
- fontWeight: '600',
+ fontFamily: 'Manrope_600SemiBold',
+ fontSize: 15,
+ lineHeight: 22,
},
title: {
+ fontFamily: 'Manrope_800ExtraBold',
fontSize: 32,
- fontWeight: 'bold',
- lineHeight: 32,
+ lineHeight: 38,
+ letterSpacing: -0.8,
},
subtitle: {
+ fontFamily: 'Manrope_700Bold',
fontSize: 20,
- fontWeight: 'bold',
+ lineHeight: 26,
},
link: {
- lineHeight: 30,
- fontSize: 16,
- color: '#0a7ea4',
+ fontFamily: 'Manrope_600SemiBold',
+ lineHeight: 22,
+ fontSize: 15,
+ color: '#0559FA',
},
});
diff --git a/components/ui/app-header.tsx b/components/ui/app-header.tsx
new file mode 100644
index 0000000..8069768
--- /dev/null
+++ b/components/ui/app-header.tsx
@@ -0,0 +1,43 @@
+import type { ReactNode } from 'react';
+import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
+import { AppText } from './app-text';
+import { colors, size, spacing } from './tokens';
+
+type Props = {
+ title: string;
+ context?: string;
+ subtitle?: string;
+ leading?: ReactNode;
+ trailing?: ReactNode;
+ compact?: boolean;
+ style?: StyleProp;
+};
+
+export function AppHeader({ title, context, subtitle, leading, trailing, compact = false, style }: Props) {
+ if (compact) return
+ {leading}
+ {title}
+ {trailing}
+ ;
+
+ return
+
+ {context ? {context} : null}
+ {title}
+ {subtitle ? {subtitle} : null}
+
+ {trailing ? {trailing} : null}
+ ;
+}
+
+const styles = StyleSheet.create({
+ root: { minHeight: 76, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[5], paddingTop: spacing[6] },
+ copy: { flex: 1 },
+ titleWithContext: { marginTop: spacing[1] },
+ subtitle: { marginTop: spacing[1] },
+ rootTrailing: { alignSelf: 'flex-start', paddingTop: spacing[1] },
+ compact: { height: 56, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
+ side: { width: size.touchTargetMin, minHeight: size.touchTargetMin, justifyContent: 'center' },
+ trailing: { alignItems: 'flex-end' },
+ compactTitle: { flex: 1, textAlign: 'center', paddingHorizontal: spacing[3] },
+});
diff --git a/components/ui/app-text.tsx b/components/ui/app-text.tsx
new file mode 100644
index 0000000..789b5cc
--- /dev/null
+++ b/components/ui/app-text.tsx
@@ -0,0 +1,12 @@
+import { Text, type TextProps, type TextStyle } from 'react-native';
+import { colors, type, type TypeVariant } from './tokens';
+
+type Props = TextProps & {
+ variant?: TypeVariant;
+ color?: string;
+};
+
+/** A semantic Manrope text primitive with accessible defaults. */
+export function AppText({ variant = 'body', color = colors.neutral.textPrimary, style, ...props }: Props) {
+ return ;
+}
diff --git a/components/ui/attendance-ring.tsx b/components/ui/attendance-ring.tsx
new file mode 100644
index 0000000..8f5add5
--- /dev/null
+++ b/components/ui/attendance-ring.tsx
@@ -0,0 +1,58 @@
+import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
+import Svg, { Circle } from 'react-native-svg';
+import { AppText } from './app-text';
+import { colors, type SemanticTone } from './tokens';
+
+type Props = {
+ percentage: number;
+ tone?: Extract | 'brand';
+ size?: number;
+ strokeWidth?: number;
+ label?: string;
+ accessibilityLabel: string;
+ style?: StyleProp;
+};
+
+/** An accessible determinate progress ring for real attendance values. */
+export function AttendanceRing({ percentage, tone = 'brand', size = 112, strokeWidth = 10, label = 'Attendance', accessibilityLabel, style }: Props) {
+ const value = Math.max(0, Math.min(100, Math.round(percentage)));
+ const center = size / 2;
+ const ringRadius = (size - strokeWidth) / 2;
+ const circumference = 2 * Math.PI * ringRadius;
+ const dashOffset = circumference * (1 - value / 100);
+ const accent = tone === 'brand' ? colors.brand.cobalt : colors.semantic[tone].solid;
+
+ return
+
+
+ {value}%
+ {label}
+
+ ;
+}
+
+const styles = StyleSheet.create({
+ container: { alignItems: 'center', justifyContent: 'center' },
+ copy: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12 },
+ value: { fontVariant: ['tabular-nums'] },
+});
diff --git a/components/ui/bottom-sheet.tsx b/components/ui/bottom-sheet.tsx
new file mode 100644
index 0000000..4dacecb
--- /dev/null
+++ b/components/ui/bottom-sheet.tsx
@@ -0,0 +1,47 @@
+import type { ReactNode } from 'react';
+import { KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { AppText } from './app-text';
+import { IconButton } from './icon-button';
+import { colors, radius, shadow, spacing } from './tokens';
+
+type Props = {
+ visible: boolean;
+ title: string;
+ onClose: () => void;
+ children: ReactNode;
+ footer?: ReactNode;
+ style?: StyleProp;
+ testID?: string;
+};
+
+/** Shared modal sheet with keyboard-safe padding and a single accessible close control. */
+export function BottomSheet({ visible, title, onClose, children, footer, style, testID }: Props) {
+ return
+
+
+
+
+
+
+ {title}
+
+
+ {children}
+ {footer ? {footer} : null}
+
+
+
+ ;
+}
+
+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 },
+ 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] },
+ footer: { paddingTop: spacing[3], paddingBottom: spacing[3], borderTopWidth: 1, borderTopColor: colors.neutral.divider },
+});
diff --git a/components/ui/bottom-tab-bar.tsx b/components/ui/bottom-tab-bar.tsx
new file mode 100644
index 0000000..3d40430
--- /dev/null
+++ b/components/ui/bottom-tab-bar.tsx
@@ -0,0 +1,56 @@
+import { Ionicons } from '@expo/vector-icons';
+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';
+
+const tabConfig = {
+ index: { label: 'Today', icon: 'today-outline', activeIcon: 'today' },
+ timetable: { label: 'Timetable', icon: 'calendar-outline', activeIcon: 'calendar' },
+ attendance: { label: 'Attendance', icon: 'pie-chart-outline', activeIcon: 'pie-chart' },
+ settings: { label: 'Settings', icon: 'settings-outline', activeIcon: 'settings' },
+} as const;
+
+/** Floating, label-forward dock that keeps the active destination prominent without covering the page in a solid bar. */
+export function BottomTabBar({ state, descriptors, navigation, insets }: BottomTabBarProps) {
+ return
+
+ {state.routes.map((route, index) => {
+ const focused = state.index === index;
+ const config = tabConfig[route.name as keyof typeof tabConfig];
+ if (!config) return null;
+ const options = descriptors[route.key].options;
+ const label = options.tabBarLabel === undefined || typeof options.tabBarLabel === 'string' ? options.tabBarLabel ?? config.label : config.label;
+ const onPress = () => {
+ const event = navigation.emit({ type: 'tabPress', target: route.key, canPreventDefault: true });
+ if (!focused && !event.defaultPrevented) {
+ if (Platform.OS !== 'web') void Haptics.selectionAsync();
+ navigation.navigate(route.name);
+ }
+ };
+ const onLongPress = () => navigation.emit({ type: 'tabLongPress', target: route.key });
+ return [styles.tab, focused && styles.tabActive, pressed && styles.pressed]}>
+
+ {label}
+ ;
+ })}
+
+ ;
+}
+
+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' },
+ pressed: { opacity: 0.76 },
+});
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 0000000..1697b60
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,65 @@
+import type { ReactNode } from 'react';
+import * as Haptics from 'expo-haptics';
+import { ActivityIndicator, Platform, Pressable, StyleSheet, View, type GestureResponderEvent, type StyleProp, type ViewStyle } from 'react-native';
+import { AppText } from './app-text';
+import { colors, motion, size, spacing } from './tokens';
+
+type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
+type ButtonSize = 'regular' | 'compact';
+type HapticFeedback = 'none' | 'selection' | 'success' | 'light';
+type Props = {
+ label: string;
+ onPress: (event: GestureResponderEvent) => void;
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ leading?: ReactNode;
+ trailing?: ReactNode;
+ disabled?: boolean;
+ loading?: boolean;
+ fullWidth?: boolean;
+ haptic?: HapticFeedback;
+ accessibilityHint?: string;
+ style?: StyleProp;
+ testID?: string;
+};
+
+const feedback = (type: HapticFeedback) => {
+ if (type === 'none' || Platform.OS === 'web') return;
+ if (type === 'selection') void Haptics.selectionAsync();
+ if (type === 'success') void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ if (type === 'light') void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+};
+
+export function Button({ label, onPress, variant = 'primary', size: buttonSize = 'regular', leading, trailing, disabled = false, loading = false, fullWidth = true, haptic = 'none', accessibilityHint, style, testID }: Props) {
+ const unavailable = disabled || loading;
+ const activeLabelColor = variant === 'primary' || variant === 'danger' ? colors.neutral.surface : colors.brand.cobalt;
+ const labelColor = unavailable ? colors.neutral.textDisabled : activeLabelColor;
+ const spinnerColor = unavailable ? colors.neutral.textDisabled : activeLabelColor;
+
+ return { feedback(haptic); onPress(event); }}
+ style={({ pressed }) => [styles.base, buttonSize === 'compact' && styles.compact, variantStyles[variant], fullWidth && styles.fullWidth, unavailable && styles.disabled, pressed && !unavailable && styles.pressed, style]}>
+ {loading ? : <>{leading}{label}{trailing}>}
+ ;
+}
+
+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 },
+ fullWidth: { alignSelf: 'stretch' },
+ pressed: { transform: [{ scale: motion.pressedScale }] },
+ disabled: { backgroundColor: colors.neutral.surfaceSubtle, borderColor: colors.neutral.surfaceSubtle },
+ icon: { minWidth: 0, alignItems: 'center', justifyContent: 'center' },
+});
+const variantStyles = StyleSheet.create({
+ primary: { backgroundColor: colors.brand.cobalt },
+ secondary: { backgroundColor: colors.brand.cobaltSoft },
+ ghost: { backgroundColor: 'transparent' },
+ danger: { backgroundColor: colors.semantic.danger.solid },
+});
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000..32ef341
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,23 @@
+import type { ReactNode } from 'react';
+import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
+import { colors, radius, spacing } from './tokens';
+
+type CardTone = 'surface' | 'sky' | 'skySoft' | 'cobaltSoft' | 'coralSoft';
+type Props = { children: ReactNode; tone?: CardTone; style?: StyleProp; padding?: number; testID?: string };
+
+const toneStyle = {
+ surface: { backgroundColor: colors.neutral.surface, borderWidth: 1, borderColor: colors.neutral.border },
+ sky: { backgroundColor: colors.brand.sky },
+ skySoft: { backgroundColor: colors.brand.skySoft },
+ cobaltSoft: { backgroundColor: colors.brand.cobaltSoft },
+ coralSoft: { backgroundColor: colors.brand.coralSoft },
+} as const;
+
+/** Flat grouped surface. Floating elevation is intentionally reserved for overlays. */
+export function Card({ children, tone = 'surface', padding = spacing[5], style, testID }: Props) {
+ return {children};
+}
+
+const styles = StyleSheet.create({
+ base: { borderRadius: radius.card },
+});
diff --git a/components/ui/current-time-indicator.tsx b/components/ui/current-time-indicator.tsx
new file mode 100644
index 0000000..0988a85
--- /dev/null
+++ b/components/ui/current-time-indicator.tsx
@@ -0,0 +1,20 @@
+import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
+import { AppText } from './app-text';
+import { colors, spacing } from './tokens';
+
+type Props = { time: string; style?: StyleProp };
+
+export function CurrentTimeIndicator({ time, style }: Props) {
+ return
+ {time}
+
+
+ ;
+}
+
+const styles = StyleSheet.create({
+ row: { height: 20, flexDirection: 'row', alignItems: 'center', marginVertical: spacing[1] },
+ time: { width: 46, fontVariant: ['tabular-nums'] },
+ triangle: { width: 0, height: 0, borderTopWidth: 5, borderBottomWidth: 5, borderLeftWidth: 7, borderTopColor: 'transparent', borderBottomColor: 'transparent', borderLeftColor: colors.brand.coral },
+ line: { flex: 1, height: 2, backgroundColor: colors.brand.coral },
+});
diff --git a/components/ui/empty-state.tsx b/components/ui/empty-state.tsx
new file mode 100644
index 0000000..ba0e3d7
--- /dev/null
+++ b/components/ui/empty-state.tsx
@@ -0,0 +1,30 @@
+import { Ionicons } from '@expo/vector-icons';
+import type { ReactNode } from 'react';
+import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
+import { AppText } from './app-text';
+import { Card } from './card';
+import { colors, radius, spacing } from './tokens';
+
+type Props = {
+ icon: keyof typeof Ionicons.glyphMap;
+ title: string;
+ message: string;
+ action?: ReactNode;
+ style?: StyleProp;
+};
+
+export function EmptyState({ icon, title, message, action, style }: Props) {
+ return
+
+ {title}
+ {message}
+ {action ? {action} : null}
+ ;
+}
+
+const styles = StyleSheet.create({
+ icon: { width: 48, height: 48, borderRadius: radius.control, backgroundColor: colors.brand.sky, alignItems: 'center', justifyContent: 'center', alignSelf: 'center' },
+ title: { marginTop: spacing[4], textAlign: 'center' },
+ message: { marginTop: spacing[2], textAlign: 'center' },
+ action: { marginTop: spacing[5], alignSelf: 'stretch' },
+});
diff --git a/components/ui/form-field.tsx b/components/ui/form-field.tsx
new file mode 100644
index 0000000..f7845c7
--- /dev/null
+++ b/components/ui/form-field.tsx
@@ -0,0 +1,44 @@
+import { Ionicons } from '@expo/vector-icons';
+import { useState } from 'react';
+import { StyleSheet, TextInput, View, type StyleProp, type TextInputProps, type ViewStyle } from 'react-native';
+import { AppText } from './app-text';
+import { colors, radius, size, spacing } from './tokens';
+
+type Props = TextInputProps & {
+ label: string;
+ hint?: string;
+ error?: string;
+ containerStyle?: StyleProp;
+};
+
+/** Labeled input with visible instructions and a consistent focus/error treatment. */
+export function FormField({ label, hint, error, containerStyle, editable = true, style, accessibilityLabel, accessibilityHint, onFocus, onBlur, ...inputProps }: Props) {
+ const [focused, setFocused] = useState(false);
+ return
+ {label}
+
+ { setFocused(true); onFocus?.(event); }}
+ onBlur={(event) => { setFocused(false); onBlur?.(event); }}
+ style={[styles.input, style]}
+ />
+ {error ? : null}
+
+ {error ? {error} : hint ? {hint} : null}
+ ;
+}
+
+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] },
+ 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 },
+ input: { flex: 1, minHeight: size.control - 2, paddingVertical: 0, color: colors.neutral.textPrimary, fontFamily: 'Manrope_400Regular', fontSize: 15, lineHeight: 22 },
+ support: { marginTop: spacing[1] },
+});
diff --git a/components/ui/icon-button.tsx b/components/ui/icon-button.tsx
new file mode 100644
index 0000000..8605869
--- /dev/null
+++ b/components/ui/icon-button.tsx
@@ -0,0 +1,41 @@
+import { Ionicons } from '@expo/vector-icons';
+import { Pressable, StyleSheet, type GestureResponderEvent, type StyleProp, type ViewStyle } from 'react-native';
+import { colors, motion, radius, size } from './tokens';
+
+type Tone = 'soft' | 'sky' | 'ghost' | 'danger';
+type Props = {
+ icon: keyof typeof Ionicons.glyphMap;
+ label: string;
+ onPress: (event: GestureResponderEvent) => void;
+ tone?: Tone;
+ disabled?: boolean;
+ style?: StyleProp;
+ testID?: string;
+};
+
+export function IconButton({ icon, label, onPress, tone = 'soft', disabled = false, style, testID }: Props) {
+ const iconColor = tone === 'danger' ? colors.semantic.danger.text : tone === 'ghost' ? colors.neutral.textPrimary : colors.brand.cobalt;
+ return [styles.base, toneStyles[tone], disabled && styles.disabled, pressed && !disabled && styles.pressed, style]}>
+
+ ;
+}
+
+const styles = StyleSheet.create({
+ base: { width: size.touchTargetMin, height: size.touchTargetMin, borderRadius: radius.control, alignItems: 'center', justifyContent: 'center' },
+ disabled: { opacity: 0.48 },
+ pressed: { transform: [{ scale: motion.pressedScale }] },
+});
+const toneStyles = StyleSheet.create({
+ soft: { backgroundColor: colors.brand.cobaltSoft },
+ sky: { backgroundColor: colors.brand.sky },
+ ghost: { backgroundColor: 'transparent' },
+ danger: { backgroundColor: colors.semantic.danger.soft },
+});
diff --git a/components/ui/index.ts b/components/ui/index.ts
new file mode 100644
index 0000000..a7995a0
--- /dev/null
+++ b/components/ui/index.ts
@@ -0,0 +1,22 @@
+export { AppHeader } from './app-header';
+export { AppText } from './app-text';
+export { AttendanceRing } from './attendance-ring';
+export { BottomSheet } from './bottom-sheet';
+export { Button } from './button';
+export { Card } from './card';
+export { CurrentTimeIndicator } from './current-time-indicator';
+export { EmptyState } from './empty-state';
+export { FormField } from './form-field';
+export { IconButton } from './icon-button';
+export { InlineBanner } from './inline-banner';
+export { ProgressBar } from './progress-bar';
+export { RecessCard } from './recess-card';
+export { ScheduleEventCard } from './schedule-event-card';
+export type { ClassAttendanceState } from './schedule-event-card';
+export { Screen } from './screen';
+export { StatusPill } from './status-pill';
+export { SubjectBadge } from './subject-badge';
+export { WeekStrip } from './week-strip';
+export type { WeekDay } from './week-strip';
+export { colors, motion, radius, shadow, size, spacing, type } from './tokens';
+export type { SemanticTone, SubjectTone, TypeVariant } from './tokens';
diff --git a/components/ui/inline-banner.tsx b/components/ui/inline-banner.tsx
new file mode 100644
index 0000000..bbf04e7
--- /dev/null
+++ b/components/ui/inline-banner.tsx
@@ -0,0 +1,35 @@
+import { Ionicons } from '@expo/vector-icons';
+import type { ReactNode } from 'react';
+import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
+import { AppText } from './app-text';
+import { colors, radius, spacing, type SemanticTone } from './tokens';
+
+type Props = {
+ title: string;
+ message?: string;
+ tone?: SemanticTone;
+ action?: ReactNode;
+ style?: StyleProp;
+};
+
+const icon = { success: 'checkmark-circle', warning: 'alert-circle', danger: 'alert-circle', neutral: 'information-circle' } as const;
+
+/** Concise, data-backed feedback for save states and attendance insights. */
+export function InlineBanner({ title, message, tone = 'neutral', action, style }: Props) {
+ const palette = colors.semantic[tone];
+ return
+
+
+ {title}
+ {message ? {message} : null}
+
+ {action ? {action} : null}
+ ;
+}
+
+const styles = StyleSheet.create({
+ banner: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing[3], borderRadius: radius.card, padding: spacing[4] },
+ copy: { flex: 1 },
+ message: { marginTop: spacing[1] },
+ action: { alignSelf: 'center' },
+});
diff --git a/components/ui/progress-bar.tsx b/components/ui/progress-bar.tsx
new file mode 100644
index 0000000..7b07ca7
--- /dev/null
+++ b/components/ui/progress-bar.tsx
@@ -0,0 +1,27 @@
+import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
+import { colors } from './tokens';
+
+type Props = {
+ value: number;
+ color?: string;
+ height?: number;
+ accessibilityLabel: string;
+ style?: StyleProp;
+};
+
+export function ProgressBar({ value, color = colors.brand.cobalt, height = 8, accessibilityLabel, style }: Props) {
+ const percentage = Math.max(0, Math.min(100, Math.round(value)));
+ return
+ 0 ? Math.min(3, height) : 0, backgroundColor: color, borderRadius: height / 2 }]} />
+ ;
+}
+
+const styles = StyleSheet.create({
+ track: { overflow: 'hidden', backgroundColor: colors.neutral.surfaceSubtle },
+ fill: { height: '100%' },
+});
diff --git a/components/ui/recess-card.tsx b/components/ui/recess-card.tsx
new file mode 100644
index 0000000..18affcd
--- /dev/null
+++ b/components/ui/recess-card.tsx
@@ -0,0 +1,36 @@
+import { Ionicons } from '@expo/vector-icons';
+import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
+import { AppText } from './app-text';
+import { colors, radius, spacing } from './tokens';
+
+type Props = {
+ timeRange: string;
+ isNow?: boolean;
+ style?: StyleProp;
+};
+
+/** A quiet timeline block for a configured recess or lunch period. */
+export function RecessCard({ timeRange, isNow = false, style }: Props) {
+ return
+
+
+
+
+
+ Recess
+ {isNow ? Now : null}
+
+ Time to recharge
+ {timeRange}
+
+ ;
+}
+
+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 },
+ 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 },
+ range: { marginTop: spacing[2], fontVariant: ['tabular-nums'] },
+});
diff --git a/components/ui/schedule-event-card.tsx b/components/ui/schedule-event-card.tsx
new file mode 100644
index 0000000..db17cfc
--- /dev/null
+++ b/components/ui/schedule-event-card.tsx
@@ -0,0 +1,81 @@
+import { Ionicons } from '@expo/vector-icons';
+import type { ReactNode } from 'react';
+import { Pressable, StyleSheet, View, type GestureResponderEvent, type StyleProp, type ViewStyle } from 'react-native';
+import { AppText } from './app-text';
+import { colors, radius, spacing, type SubjectTone } from './tokens';
+
+export type ClassAttendanceState = 'pending' | 'attended' | 'absent' | 'cancelled';
+type Props = {
+ title: string;
+ timeRange: string;
+ subjectTone: SubjectTone;
+ room?: string;
+ kind?: string;
+ classType?: string;
+ state?: ClassAttendanceState;
+ isNow?: boolean;
+ topAction?: ReactNode;
+ footer?: ReactNode;
+ onPress?: (event: GestureResponderEvent) => void;
+ style?: StyleProp;
+ testID?: string;
+};
+
+/** Editorial calendar block: subject color leads; attendance changes only the card state. */
+export function ScheduleEventCard({ title, timeRange, subjectTone, room, kind, classType = 'Lecture', state = 'pending', isNow = false, topAction, footer, onPress, style, testID }: Props) {
+ const subject = colors.subject[subjectTone];
+ const cancelled = state === 'cancelled';
+ const absent = state === 'absent';
+ 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 = <>
+
+ {title}
+ {topAction ? {topAction} : null}
+
+
+ {kind || 'Class'}
+ {room ? {room} : null}
+
+
+
+
+ {classType}
+
+ {isNow ? Now : cancelled ? Cancelled : }
+
+ {footer ? {footer} : null}
+ >;
+
+ const cardStyle = [styles.card, { backgroundColor }, cancelled && styles.cancelled, style];
+ if (!onPress) return {content};
+ return [cardStyle, pressed && styles.pressed]}>{content};
+}
+
+function ParticipantStack({ accent }: { accent: string }) {
+ return
+ {['A', 'K', 'S'].map((initial, index) =>
+ {initial}
+ )}
+ +8
+ ;
+}
+
+const styles = StyleSheet.create({
+ card: { minHeight: 116, overflow: 'hidden', borderRadius: radius.feature, 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] },
+ meta: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[3], marginTop: spacing[1] },
+ code: { flexShrink: 1 },
+ room: { maxWidth: '52%', alignItems: 'flex-end' },
+ 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 },
+ 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] },
+ footer: { marginTop: spacing[3] },
+ cancelled: { opacity: 0.72 },
+ pressed: { opacity: 0.78 },
+});
diff --git a/components/ui/screen.tsx b/components/ui/screen.tsx
new file mode 100644
index 0000000..f87ad85
--- /dev/null
+++ b/components/ui/screen.tsx
@@ -0,0 +1,40 @@
+import type { ReactNode } from 'react';
+import { ScrollView, StyleSheet, useWindowDimensions, View, type StyleProp, type ViewStyle } from 'react-native';
+import { SafeAreaView, type Edge } from 'react-native-safe-area-context';
+import { colors, size, spacing } from './tokens';
+
+type Props = {
+ children: ReactNode;
+ scroll?: boolean;
+ edges?: Edge[];
+ style?: StyleProp;
+ contentContainerStyle?: StyleProp;
+ testID?: string;
+};
+
+/** Consistent canvas, safe-area behavior, horizontal gutter, and readable content width. */
+export function Screen({ children, scroll = true, edges = ['top'], style, contentContainerStyle, testID }: Props) {
+ const { width } = useWindowDimensions();
+ const responsiveGutter = width >= 430 ? size.screenGutterWide : size.screenGutter;
+ const content = scroll ? (
+
+ {children}
+
+ ) : (
+ {children}
+ );
+
+ return {content};
+}
+
+const styles = StyleSheet.create({
+ safe: { flex: 1, backgroundColor: colors.neutral.canvas },
+ scroll: { flex: 1 },
+ content: { width: '100%', maxWidth: size.contentMaxWidth, alignSelf: 'center', paddingTop: spacing[5], paddingBottom: spacing[8] },
+ fill: { flex: 1 },
+});
diff --git a/components/ui/status-pill.tsx b/components/ui/status-pill.tsx
new file mode 100644
index 0000000..7156b28
--- /dev/null
+++ b/components/ui/status-pill.tsx
@@ -0,0 +1,23 @@
+import { Ionicons } from '@expo/vector-icons';
+import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
+import { AppText } from './app-text';
+import { colors, radius, spacing, type SemanticTone } from './tokens';
+
+type Props = {
+ label: string;
+ tone?: SemanticTone;
+ icon?: keyof typeof Ionicons.glyphMap;
+ style?: StyleProp;
+};
+
+export function StatusPill({ label, tone = 'neutral', icon, style }: Props) {
+ const palette = colors.semantic[tone];
+ return
+ {icon && }
+ {label}
+ ;
+}
+
+const styles = StyleSheet.create({
+ pill: { minHeight: 28, alignSelf: 'flex-start', alignItems: 'center', flexDirection: 'row', gap: spacing[1], borderRadius: radius.pill, paddingHorizontal: spacing[3], paddingVertical: spacing[1] },
+});
diff --git a/components/ui/subject-badge.tsx b/components/ui/subject-badge.tsx
new file mode 100644
index 0000000..7fab3df
--- /dev/null
+++ b/components/ui/subject-badge.tsx
@@ -0,0 +1,19 @@
+import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
+import { AppText } from './app-text';
+import { colors, radius, type SubjectTone } from './tokens';
+
+type Props = { shortName: string; tone: SubjectTone; size?: 'small' | 'medium'; style?: StyleProp };
+
+/** Stable subject identifier. Use the same tone for a subject everywhere in the product. */
+export function SubjectBadge({ shortName, tone, size = 'medium', style }: Props) {
+ const subject = colors.subject[tone];
+ return
+ {shortName}
+ ;
+}
+
+const styles = StyleSheet.create({
+ base: { alignItems: 'center', justifyContent: 'center', borderRadius: radius.control },
+ small: { minWidth: 32, height: 32, paddingHorizontal: 6 },
+ medium: { minWidth: 44, height: 44, paddingHorizontal: 8, borderRadius: 14 },
+});
diff --git a/components/ui/tokens.ts b/components/ui/tokens.ts
new file mode 100644
index 0000000..2a220da
--- /dev/null
+++ b/components/ui/tokens.ts
@@ -0,0 +1,87 @@
+import { Platform } from 'react-native';
+
+/**
+ * Runtime token mirror for designs/design-tokens.json.
+ * Components must consume this module rather than introduce screen-local values.
+ */
+export const colors = {
+ brand: {
+ ink: '#061430',
+ cobalt: '#0559FA',
+ cobaltPressed: '#0047D5',
+ cobaltSoft: '#E7F0FF',
+ coral: '#FF7A4F',
+ coralSoft: '#FFE9E1',
+ sky: '#D5F2FF',
+ skySoft: '#F0FAFF',
+ },
+ neutral: {
+ canvas: '#F7F9FC',
+ surface: '#FFFFFF',
+ surfaceSubtle: '#F0F4F8',
+ textPrimary: '#061430',
+ textSecondary: '#4E6078',
+ textMuted: '#68788F',
+ textDisabled: '#9AA8B8',
+ border: '#DDE5EE',
+ divider: '#E9EEF4',
+ scrim: 'rgba(6, 20, 48, 0.46)',
+ },
+ semantic: {
+ success: { solid: '#168A63', text: '#117A57', soft: '#DDF5EC' },
+ warning: { solid: '#F0B44D', text: '#8A5900', soft: '#FFF4CE' },
+ danger: { solid: '#CF4038', text: '#A82E2A', soft: '#FFE9E7' },
+ neutral: { solid: '#68788F', text: '#4E6078', soft: '#EDF1F5' },
+ },
+ subject: {
+ ocean: { surface: '#BFD5FF', accent: '#0559FA', text: '#061430' },
+ aqua: { surface: '#DDF7FA', accent: '#167F96', text: '#061430' },
+ lilac: { surface: '#FDF0FF', accent: '#9B4BA4', text: '#061430' },
+ sun: { surface: '#FFF3C4', accent: '#8A6400', text: '#061430' },
+ mint: { surface: '#DDF5EC', accent: '#117A57', text: '#061430' },
+ peach: { surface: '#FFE5D9', accent: '#B95230', text: '#061430' },
+ },
+} as const;
+
+export const spacing = { 0: 0, 1: 2, 2: 4, 3: 8, 4: 12, 5: 16, 6: 20, 7: 24, 8: 32, 9: 40, 10: 48, 11: 64 } as const;
+export const radius = { small: 8, control: 12, card: 16, feature: 20, sheet: 28, pill: 999 } as const;
+export const size = { touchTargetMin: 44, control: 52, controlCompact: 40, iconSmall: 16, icon: 20, iconLarge: 24, iconContainer: 40, screenGutter: 20, screenGutterWide: 24, contentMaxWidth: 560, tabBar: 68 } as const;
+
+const fontFaces = {
+ regular: 'Manrope_400Regular',
+ medium: 'Manrope_500Medium',
+ semibold: 'Manrope_600SemiBold',
+ bold: 'Manrope_700Bold',
+ extrabold: 'Manrope_800ExtraBold',
+} as const;
+
+const systemFont = Platform.select({ ios: 'System', android: 'sans-serif', default: 'system-ui' });
+const font = (face: keyof typeof fontFaces) => ({ fontFamily: fontFaces[face] || systemFont });
+
+export const type = {
+ display: { ...font('extrabold'), fontSize: 40, lineHeight: 44, letterSpacing: -1.2 },
+ heading1: { ...font('extrabold'), fontSize: 32, lineHeight: 38, letterSpacing: -0.8 },
+ heading2: { ...font('bold'), fontSize: 24, lineHeight: 30, letterSpacing: -0.4 },
+ heading3: { ...font('bold'), fontSize: 20, lineHeight: 26, letterSpacing: -0.2 },
+ title: { ...font('bold'), fontSize: 17, lineHeight: 23, letterSpacing: 0 },
+ body: { ...font('regular'), fontSize: 15, lineHeight: 22, letterSpacing: 0 },
+ bodySmall: { ...font('regular'), fontSize: 13, lineHeight: 19, letterSpacing: 0 },
+ label: { ...font('semibold'), fontSize: 12, lineHeight: 16, letterSpacing: 0.1 },
+ caption: { ...font('medium'), fontSize: 11, lineHeight: 15, letterSpacing: 0.1 },
+} as const;
+
+export type TypeVariant = keyof typeof type;
+export type SubjectTone = keyof typeof colors.subject;
+export type SemanticTone = keyof typeof colors.semantic;
+
+export const shadow = {
+ floating: {
+ shadowColor: colors.brand.ink,
+ shadowOpacity: 0.12,
+ shadowRadius: 18,
+ shadowOffset: { width: 0, height: 8 },
+ elevation: 8,
+ },
+} as const;
+
+export const motion = { instant: 100, quick: 160, standard: 220, emphasized: 320, pressedScale: 0.98 } as const;
diff --git a/components/ui/week-strip.tsx b/components/ui/week-strip.tsx
new file mode 100644
index 0000000..004d835
--- /dev/null
+++ b/components/ui/week-strip.tsx
@@ -0,0 +1,56 @@
+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';
+
+export type WeekDay = { date: Date; disabled?: boolean; marker?: 'none' | 'success' | 'warning' | 'danger' | 'neutral' };
+type Props = {
+ days: WeekDay[];
+ selectedDateKey: string;
+ onSelect: (date: Date) => void;
+ todayDateKey?: string;
+ style?: StyleProp;
+ accessibilityLabel?: string;
+};
+
+const dateKey = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
+const markerColor = { none: 'transparent', success: colors.semantic.success.solid, warning: colors.semantic.warning.solid, danger: colors.semantic.danger.solid, neutral: colors.neutral.textMuted } as const;
+
+/** The shared selected-date control: coral selection, cobalt today signal, semantic status dot. */
+export function WeekStrip({ days, selectedDateKey, onSelect, todayDateKey, style, accessibilityLabel = 'Choose a day' }: Props) {
+ return
+ {days.map(({ date, disabled = false, marker = 'none' }) => {
+ 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);
+ return { if (Platform.OS !== 'web') void Haptics.selectionAsync(); onSelect(date); }}
+ style={({ pressed }) => [styles.day, selected && styles.selected, disabled && styles.disabled, pressed && !disabled && styles.pressed]}>
+ {weekday}
+ {date.getDate()}
+
+
+
+ ;
+ })}
+ ;
+}
+
+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] },
+ 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' },
+ dot: { width: 4, height: 4, borderRadius: 2, backgroundColor: 'transparent' },
+ todayDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.brand.cobalt },
+});
diff --git a/constants/theme.ts b/constants/theme.ts
index f06facd..51574a9 100644
--- a/constants/theme.ts
+++ b/constants/theme.ts
@@ -1,53 +1,33 @@
-/**
- * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
- * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
- */
-
import { Platform } from 'react-native';
-const tintColorLight = '#0a7ea4';
-const tintColorDark = '#fff';
+const cobalt = '#0559FA';
+const ink = '#061430';
+const canvas = '#F7F9FC';
+const muted = '#68788F';
+/** Legacy-compatible theme exports backed by the CLG Kit light design system. */
export const Colors = {
light: {
- text: '#11181C',
- background: '#fff',
- tint: tintColorLight,
- icon: '#687076',
- tabIconDefault: '#687076',
- tabIconSelected: tintColorLight,
+ text: ink,
+ background: canvas,
+ tint: cobalt,
+ icon: muted,
+ tabIconDefault: muted,
+ tabIconSelected: cobalt,
},
dark: {
- text: '#ECEDEE',
- background: '#151718',
- tint: tintColorDark,
- icon: '#9BA1A6',
- tabIconDefault: '#9BA1A6',
- tabIconSelected: tintColorDark,
+ text: ink,
+ background: canvas,
+ tint: cobalt,
+ icon: muted,
+ tabIconDefault: muted,
+ tabIconSelected: cobalt,
},
};
export const Fonts = Platform.select({
- ios: {
- /** iOS `UIFontDescriptorSystemDesignDefault` */
- sans: 'system-ui',
- /** iOS `UIFontDescriptorSystemDesignSerif` */
- serif: 'ui-serif',
- /** iOS `UIFontDescriptorSystemDesignRounded` */
- rounded: 'ui-rounded',
- /** iOS `UIFontDescriptorSystemDesignMonospaced` */
- mono: 'ui-monospace',
- },
- default: {
- sans: 'normal',
- serif: 'serif',
- rounded: 'normal',
- mono: 'monospace',
- },
- web: {
- sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
- serif: "Georgia, 'Times New Roman', serif",
- rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
- mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
- },
+ ios: { sans: 'Manrope_400Regular', serif: 'ui-serif', rounded: 'Manrope_600SemiBold', mono: 'ui-monospace' },
+ android: { sans: 'Manrope_400Regular', serif: 'serif', rounded: 'Manrope_600SemiBold', mono: 'monospace' },
+ default: { sans: 'Manrope_400Regular', serif: 'serif', rounded: 'Manrope_600SemiBold', mono: 'monospace' },
+ web: { sans: "Manrope_400Regular, system-ui, sans-serif", serif: "Georgia, 'Times New Roman', serif", rounded: "Manrope_600SemiBold, system-ui, sans-serif", mono: "SFMono-Regular, Menlo, Monaco, Consolas, monospace" },
});
diff --git a/designs/37400450.png b/designs/37400450.png
new file mode 100644
index 0000000..6bf98da
Binary files /dev/null and b/designs/37400450.png differ
diff --git a/designs/37400451.png b/designs/37400451.png
new file mode 100644
index 0000000..de03723
Binary files /dev/null and b/designs/37400451.png differ
diff --git a/designs/DESIGN_SYSTEM.md b/designs/DESIGN_SYSTEM.md
new file mode 100644
index 0000000..25fa96c
--- /dev/null
+++ b/designs/DESIGN_SYSTEM.md
@@ -0,0 +1,1003 @@
+# CLG Kit Design System
+
+**Status:** Authoritative design direction for the polish pass
+**Version:** 1.0
+**Reference inputs:** `designs/37400450.png`, `designs/37400451.png`
+**Companion tokens:** `designs/design-tokens.json`
+
+This document translates the reference artwork into a coherent product system for CLG Kit. It is not a screen-copying brief. The references contain a fictional brand, fictional data, a side drawer, chat, and marketing-only compositions; those are not requirements. What matters is the underlying visual grammar and how it improves CLG Kit’s real timetable and attendance workflows.
+
+When this document conflicts with one-off styles in the current app, this document wins.
+
+---
+
+## 1. Executive direction
+
+CLG Kit should feel like a **bright, editorial student utility**: calm enough for everyday planning, energetic enough to feel contemporary, and direct enough to make schedule and attendance decisions at a glance.
+
+The visual identity has five anchors:
+
+1. **Deep navy typography** instead of black or purple-gray.
+2. **Cobalt blue** for brand structure, primary actions, selected navigation, and key analytics.
+3. **Coral orange** for temporal focus: the selected date, “now,” deadlines, and high-attention moments that are not destructive.
+4. **Pale sky and subject pastels** for grouping dense information without heavy borders or shadows.
+5. **Large geometric type, rounded blocks, and disciplined whitespace** to make schedules feel editorial rather than spreadsheet-like.
+
+The app must not look like a generic lavender productivity template. The current purple palette, small uppercase-heavy hierarchy, and repeated floating white cards should be replaced by this clearer cobalt/coral/sky system.
+
+### One-sentence design thesis
+
+> CLG Kit turns dense college logistics into a bright, chronological, glanceable daily plan.
+
+---
+
+## 2. What the reference designs are actually doing
+
+### 2.1 Composition and hierarchy
+
+The references use large headings, oversized feature areas, and deliberately uneven card proportions. Small data cards sit next to one dominant analytical card. The result is lively without becoming chaotic because every region has a clear visual owner.
+
+**Translation for CLG Kit:**
+
+- Every screen gets one dominant headline or feature region, not a wall of equal-weight cards.
+- Primary information is shown at large scale: date, next class, attendance percentage, or subject name.
+- Secondary information is quieter and grouped using pale surfaces.
+- Tertiary metadata uses smaller type and icons, but never becomes illegible.
+- Prefer one strong module plus supporting content over four identical dashboard tiles.
+
+### 2.2 Color behavior
+
+The references use color by role, not as decoration:
+
+- Cobalt holds primary analytical or brand content.
+- Coral marks the selected date, progress, or urgent/high-energy regions.
+- Pale sky creates context zones such as calendars and navigation.
+- Periwinkle, aqua, and lilac distinguish schedule blocks.
+- White creates breathing room.
+- Navy unifies all text.
+
+The gray-blue visible behind the rotated phones (`#E6EBF4`) is presentation artwork, not the main in-app background.
+
+### 2.3 Typography
+
+The typography is a modern geometric grotesk with compact bold headings, clean lowercase forms, and strong numerals. Headings are large and confident; body text is simple and unstyled. The design rarely needs ornamental typography because scale and weight create the character.
+
+### 2.4 Shape and depth
+
+- Corners are soft and generous, usually in the 16–28 range.
+- Depth comes primarily from color adjacency and overlap, not shadow.
+- Full-color cards and pale blocks are mostly borderless.
+- Shadows appear only where an element truly floats, such as a menu or sheet.
+
+### 2.5 Schedule-specific patterns
+
+The schedule is treated as a chronological canvas:
+
+- Time remains on a stable rail.
+- Classes appear as large tinted blocks.
+- Class type, room, title, and time have obvious visual order.
+- A coral line and marker communicate the current time.
+- Pastel colors identify subjects; they do not communicate attendance state.
+
+### 2.6 What must not be copied
+
+Do not reproduce:
+
+- The “studly.” identity or wordmark.
+- The reference names, course names, percentages, avatars, or copy.
+- The side drawer simply because it appears in the artwork.
+- Chat or tasks unless they become real CLG Kit product features.
+- Rotated devices, clipped mockups, or the presentation backdrop inside the app.
+- Charts that are not backed by real data.
+- Decorative diagonal hatching without a defined meaning.
+
+---
+
+## 3. Product personality
+
+### Desired qualities
+
+- Bright
+- Capable
+- Direct
+- Youthful, not childish
+- Organized, not clinical
+- Confident, not loud
+- Friendly, not cute
+
+### Avoid
+
+- Generic purple SaaS styling
+- Glassmorphism
+- Gradients as decoration
+- Thick shadows on every card
+- Excessive uppercase labels
+- Neon status colors
+- Tiny dashboard text
+- Cartoon mascots
+- Fake analytics
+- Dead controls that only open placeholder alerts
+
+---
+
+## 4. Non-negotiable design rules
+
+1. Use the semantic tokens in `designs/design-tokens.json`; do not add screen-local hex colors without extending the token set.
+2. Use **Manrope** throughout the product.
+3. Use navy (`#061430`) for primary text; do not use pure black.
+4. Use cobalt for primary interaction. Coral is not the default CTA color.
+5. Use coral for temporal focus and emphasis, not for destructive actions.
+6. Use the dedicated danger red for errors, deletion, and absence warnings.
+7. Default cards are flat. Add shadows only to floating menus, popovers, and modal sheets.
+8. Use one dominant accent per component.
+9. Subject color identifies a subject and must remain stable across screens.
+10. Attendance state must use a label or icon in addition to color.
+11. Minimum interactive target is 44 Ă— 44.
+12. Body text must not be smaller than 13; 11 is reserved for short captions.
+13. Use sentence case for user-facing labels and headings.
+14. Do not display an insight such as “On track” or “Looking good” unless it is calculated from real data.
+15. Do not expose a feature as a polished primary affordance until the flow is implemented.
+
+---
+
+## 5. Color system
+
+### 5.1 Brand anchors
+
+These anchors are sampled or closely adapted from the reference images.
+
+| Token | Hex | Role |
+|---|---:|---|
+| Ink | `#061430` | Primary text, icons, dark identity |
+| Cobalt | `#0559FA` | Primary actions, active navigation, core analytics |
+| Cobalt pressed | `#0047D5` | Pressed/active cobalt state |
+| Cobalt soft | `#E7F0FF` | Tonal button, selected supporting surface |
+| Coral | `#FF7A4F` | Selected date, current-time marker, deadlines, energetic emphasis |
+| Coral soft | `#FFE9E1` | Coral chip or contextual highlight |
+| Sky | `#D5F2FF` | Calendar context, overview panel, navigation grouping |
+| Sky soft | `#F0FAFF` | Subtle schedule surface, information panel |
+
+### 5.2 Neutral colors
+
+| Token | Hex | Role |
+|---|---:|---|
+| Canvas | `#F7F9FC` | Default screen background |
+| Surface | `#FFFFFF` | Primary cards, sheets, input surfaces |
+| Surface subtle | `#F0F4F8` | Quiet control tracks and grouped list regions |
+| Text primary | `#061430` | Headings and primary values |
+| Text secondary | `#4E6078` | Body copy and secondary values |
+| Text muted | `#68788F` | Metadata and captions; minimum AA on white |
+| Text disabled | `#9AA8B8` | Disabled-only text; never for necessary information |
+| Border | `#DDE5EE` | Inputs and white cards on canvas |
+| Divider | `#E9EEF4` | Internal list separation |
+| Scrim | `rgba(6,20,48,0.46)` | Modal backdrop |
+
+### 5.3 Semantic colors
+
+Coral and danger are deliberately different. Coral means **focus/now/priority**. Danger means **error/destructive/attendance risk**.
+
+| State | Solid | Text | Soft surface |
+|---|---:|---:|---:|
+| Success | `#168A63` | `#117A57` | `#DDF5EC` |
+| Warning | `#F0B44D` | `#8A5900` | `#FFF4CE` |
+| Danger | `#CF4038` | `#A82E2A` | `#FFE9E7` |
+| Neutral | `#68788F` | `#4E6078` | `#EDF1F5` |
+
+### 5.4 Subject palette
+
+A subject owns one stable pair: a pale surface and a darker accent. The surface fills schedule blocks; the accent is used for a small bar, icon, initials, or progress segment.
+
+| Name | Surface | Accent |
+|---|---:|---:|
+| Ocean | `#BFD5FF` | `#0559FA` |
+| Aqua | `#DDF7FA` | `#167F96` |
+| Lilac | `#FDF0FF` | `#9B4BA4` |
+| Sun | `#FFF3C4` | `#8A6400` |
+| Mint | `#DDF5EC` | `#117A57` |
+| Peach | `#FFE5D9` | `#B95230` |
+
+Assignment must be deterministic and persisted. A blue Mathematics class must remain blue on Today, Timetable, Attendance, and Subject Details. Never change its subject color because the user was absent; add a danger status indicator instead.
+
+### 5.5 Color usage ratio
+
+A typical screen should be approximately:
+
+- 60–70% canvas and white
+- 15–25% pale sky or subject pastels
+- 8–12% cobalt
+- 3–6% coral
+- Semantic colors only when the state requires them
+
+This preserves the reference’s brightness without turning every region into a competing color block.
+
+### 5.6 Contrast rules
+
+- White on cobalt has sufficient contrast and is the standard primary-button pairing.
+- Navy on coral is preferred. White on coral is insufficient for normal-sized text.
+- Navy is used on sky and all subject pastels.
+- Do not place cobalt text on cobalt or subject accent text on a similarly saturated fill.
+- Muted text must use at least `#68788F` on white for normal-sized copy.
+- Charts and status indicators must include text, a pattern, or an icon; color alone is insufficient.
+
+---
+
+## 6. Typography
+
+### 6.1 Font decision: Manrope
+
+**Manrope** is the sole product font family.
+
+Why it fits:
+
+- It captures the reference’s geometric, modern, student-friendly tone.
+- Its bold weights create expressive editorial headings.
+- Its numerals are clear for dates, times, attendance percentages, and room numbers.
+- It remains readable at mobile body sizes.
+- It is open and practical to ship across iOS, Android, and web.
+
+Use real font files for weights 400, 500, 600, 700, and 800. Do not rely on synthetic bolding. Platform fallback is `system-ui` only while fonts are loading or in exceptional environments.
+
+### 6.2 Type scale
+
+| Style | Size / line | Weight | Letter spacing | Typical use |
+|---|---:|---:|---:|---|
+| Display | 40 / 44 | 800 | -1.2 | Rare feature statement or onboarding hero |
+| Heading 1 | 32 / 38 | 800 | -0.8 | Main screen title or greeting |
+| Heading 2 | 24 / 30 | 700 | -0.4 | Major section or attendance value context |
+| Heading 3 | 20 / 26 | 700 | -0.2 | Section heading, event title |
+| Title | 17 / 23 | 700 | 0 | Card title, app-bar title |
+| Body | 15 / 22 | 400 | 0 | Main copy, form values |
+| Body small | 13 / 19 | 400 | 0 | Supporting copy and metadata |
+| Label | 12 / 16 | 600 | 0.1 | Button, chip, compact control |
+| Caption | 11 / 15 | 500 | 0.1 | Short secondary metadata only |
+
+### 6.3 Typography rules
+
+- Main screen titles use Heading 1 and should not be preceded by a redundant uppercase kicker on every screen.
+- Use one line of Heading 1 whenever possible; allow two lines when the content is the hero.
+- Use Heading 3 for class names in spacious event blocks; Title in compact lists.
+- Use tabular numerals for time columns, percentages, dates, and counters.
+- Keep body lines to roughly 40–65 characters on tablet/web.
+- Do not put long paragraphs in centered text.
+- Use 800 for major values, 700 for headings, 600 for controls, and 400/500 for reading text.
+- Avoid 8–10 point labels from the artwork. The marketing render is larger than a real device and those sizes do not meet production readability needs.
+- Uppercase is allowed only for very short metadata labels when it improves scanning. It is not the default voice.
+
+---
+
+## 7. Layout and spacing
+
+### 7.1 Base grid
+
+Use a 4-point base grid, with 8-point rhythm for most layout.
+
+Approved spacing values:
+
+`2, 4, 8, 12, 16, 20, 24, 32, 40, 48, 64`
+
+Avoid arbitrary values unless needed for optical alignment.
+
+### 7.2 Screen frame
+
+- Standard phone gutter: 20.
+- Large phone/tablet gutter: 24.
+- Maximum readable content width: 560.
+- Center the main column on tablet and web.
+- Respect safe areas; visual backgrounds may bleed, content may not.
+- Standard section gap: 32.
+- Heading-to-content gap: 12–16.
+- Card-to-card gap: 12.
+- Bottom scroll padding must clear the tab bar or home indicator by at least 24.
+
+### 7.3 Density
+
+The reference is spacious but data-rich. Preserve both qualities:
+
+- Use large containers for primary schedule events.
+- Use compact rows for history and settings.
+- Do not turn every piece of metadata into a separate chip.
+- Keep no more than three hierarchy levels inside one card.
+- Give the most urgent or time-sensitive information the most area, not merely the brightest color.
+
+### 7.4 Responsive behavior
+
+- **Narrow phones below 360:** preserve 20-point gutters where possible; allow metric groups to wrap; keep touch targets unchanged.
+- **Standard phones:** one main column. Two-column layouts are allowed only for compact metrics.
+- **Large phones/foldables:** increase gutters, not type indiscriminately.
+- **Tablet/web:** center a maximum 560-point daily-flow column. A secondary detail pane may be added only when it provides real utility.
+- Never stretch schedule cards edge-to-edge across a desktop viewport.
+
+---
+
+## 8. Shape, borders, and elevation
+
+### 8.1 Corner radii
+
+| Token | Radius | Use |
+|---|---:|---|
+| Small | 8 | Badges, tiny chips |
+| Control | 12 | Inputs, icon buttons, compact controls |
+| Card | 16 | Standard cards and list groups |
+| Feature | 20 | Hero cards, large schedule events |
+| Sheet | 28 | Bottom sheets and large modal surfaces |
+| Pill | 999 | Status pills only |
+
+Avoid mixing three different radii within one compact component.
+
+### 8.2 Borders
+
+- Inputs and white cards on canvas: 1-point `border`.
+- Internal list separation: 1-point `divider`.
+- Keyboard/focus state: 2-point cobalt.
+- Subject accent bar: 4 points.
+- Tinted cards generally need no border.
+- Dashed borders are reserved for explicit “add new” drop zones or placeholders, not normal buttons.
+
+### 8.3 Shadows
+
+The default shadow is none.
+
+Use the floating shadow only for:
+
+- Menus and popovers
+- Date pickers that float above content
+- Modal sheets when separation from the scrim needs reinforcement
+- Dragged items
+
+Standard content cards must use surface contrast or a hairline border, not elevation. This is a major change from the current app’s repeated card shadows.
+
+---
+
+## 9. Iconography
+
+Continue with Ionicons for consistency and implementation efficiency.
+
+- Default icon size: 20.
+- Compact icon: 16.
+- Prominent icon: 24.
+- Standard icon button: 44 Ă— 44.
+- Standard icon container: 40 Ă— 40 with 12 radius.
+- Use outline icons for inactive/navigation/supporting actions.
+- Filled icons are acceptable for selected bottom-tab items and strong state confirmation.
+- Keep icons navy, cobalt, or semantic. Avoid arbitrary multicolor iconography.
+- Every icon-only control needs an accessibility label.
+- Do not mix Ionicons with unrelated icon families on the same screen.
+
+---
+
+## 10. Navigation and app shell
+
+### 10.1 Navigation model
+
+The reference side drawer is not appropriate for CLG Kit’s three high-frequency destinations. Retain bottom navigation for:
+
+1. Today
+2. Timetable
+3. Attendance
+
+Account and Settings remain secondary destinations behind the profile/avatar entry point.
+
+### 10.2 Bottom tab bar
+
+- White surface with a subtle top divider.
+- Height: 68 plus safe area.
+- Active icon/label: cobalt.
+- Inactive icon/label: muted navy.
+- Icon: 22; label: 11/15 medium or semibold.
+- No heavy shadow.
+- Keep labels visible; icons alone reduce clarity.
+- Avoid a floating oversized center action because all three destinations have equal structural importance.
+
+### 10.3 Top app bar
+
+- Minimum height: 56 excluding safe area.
+- Back/action targets: 44 Ă— 44.
+- Screen title: Heading 1 for root tabs, Title for pushed detail screens.
+- Root screen headers may pair a small date/context line with a Heading 1 greeting or title.
+- Use at most two right-side actions. Overflow belongs in a menu.
+- Avatar is 40–44 with 14–16 radius or circular if a real photo is used.
+
+---
+
+## 11. Core component specifications
+
+### 11.1 Buttons
+
+#### Primary
+
+- Cobalt fill, white label.
+- Height: 52.
+- Radius: 14.
+- Horizontal padding: 18.
+- Label: 14–15 semibold/bold.
+- Optional leading or trailing 18-point icon.
+- Pressed: cobalt pressed plus 0.98 scale.
+- Disabled: soft neutral fill with disabled text; no opacity-only state.
+
+#### Secondary tonal
+
+- Cobalt soft or sky fill.
+- Navy or cobalt label depending on contrast.
+- Same geometry as primary.
+- Use for non-primary actions such as date selection or edit.
+
+#### Ghost
+
+- Transparent background.
+- Cobalt or navy label.
+- Use for back, cancel, and tertiary actions.
+
+#### Destructive
+
+- Danger solid only for final destructive confirmation.
+- Danger soft for the initial delete/remove action.
+- Never use coral for delete.
+
+#### Icon button
+
+- 44 Ă— 44 touch target.
+- Visible background may be 40 Ă— 40.
+- 12 radius.
+- Soft sky/cobalt surface for prominent actions; transparent for low-priority toolbar actions.
+
+### 11.2 Cards
+
+#### Standard card
+
+- White surface.
+- Radius: 16.
+- Padding: 16.
+- Border: 1 point when placed on canvas.
+- No shadow.
+
+#### Tinted card
+
+- Sky, cobalt soft, coral soft, or subject surface.
+- Radius: 16–20.
+- Padding: 16–20.
+- No border and no shadow.
+- Navy text unless the card is cobalt.
+
+#### Feature card
+
+- Cobalt or sky.
+- Radius: 20.
+- Padding: 20.
+- One prominent value or action.
+- Do not fill it with multiple unrelated metrics.
+
+### 11.3 Week/date selector
+
+This is a signature CLG Kit component.
+
+- Place the week in a sky context panel or a clearly bounded sky strip.
+- Each day target is at least 44 wide and 56–64 tall.
+- Weekday caption: 11–12 medium.
+- Date number: 18–20 bold.
+- Selected day: coral fill with navy text.
+- Today when not selected: cobalt outline or cobalt dot.
+- Selected and today may show both coral fill and a small cobalt dot.
+- Disabled/out-of-range dates use muted text but remain legible.
+- “Selected date,” “today,” and “has attendance issue” must be visually distinct states.
+- Do not color every day by attendance result; that overloads the date selector. Put attendance detail in the agenda or a small status marker with a legend.
+
+### 11.4 Schedule timeline
+
+- Fixed time rail: 48–56 wide.
+- Time: 12–13 semibold with tabular numerals.
+- Event block: subject pastel surface, 16–20 radius, minimum height 88.
+- Event padding: 16.
+- Class type or subject code: 11–12 medium.
+- Class name: 18–20 bold.
+- Time range and room: 12–13 secondary.
+- Room uses a 16-point location icon only when space allows.
+- Event blocks do not need a shadow.
+- Use the subject accent as a 4-point edge, small icon, or metadata accent if stronger identification is needed.
+- A current-time indicator uses coral: a 2-point line plus a small triangle/dot and an explicit time label.
+- Overlapping events must render side by side or show a conflict indicator; never silently overlap text.
+
+#### Class state overlays
+
+Subject color remains the base identity. State is layered on top:
+
+- **Upcoming / not marked:** normal subject card plus “Not marked” action.
+- **In progress:** coral “Now” badge and current-time line.
+- **Attended:** success icon + `Attended`; optionally reduce saturation after the day has passed.
+- **Absent:** danger icon + `Absent`; do not turn the whole subject card red.
+- **Cancelled:** neutral label, reduced contrast, and optional meaningful diagonal pattern. If diagonal hatching is used anywhere, it must mean cancelled/tentative consistently.
+
+### 11.5 Attendance summary
+
+- Use one dominant attendance hero, not several equal cards.
+- Percentage: 32–40 extrabold with tabular numerals.
+- Ring: 96–120 with an 8–10 stroke.
+- Always show a textual interpretation and the underlying attended/held count.
+- Ring color may follow risk state:
+ - On/above threshold: cobalt ring plus success status chip.
+ - Near threshold: warning ring/status.
+ - Below threshold: danger ring/status.
+- Do not use green as the entire hero background; green is a status, not the brand.
+- “On track” must be derived from the actual configured threshold and current totals.
+- With zero classes, show `No attendance yet`, not `0%` with a negative status.
+
+### 11.6 Subject list item
+
+- Height is content-driven; target 72–88.
+- Use a subject-color initials block or 4-point accent.
+- Show name, code, attended/held count, and percentage.
+- Progress track: 6–8 high.
+- Percentage is text, not color-only.
+- At-risk subjects may add a danger label; their stable subject color remains visible.
+- Use dividers in a grouped white list or individual tinted rows. Do not put a shadow on every item.
+
+### 11.7 Progress bars and charts
+
+- Track: neutral subtle or low-opacity white.
+- Bar height: 6–8 for rows; 10–12 for feature analytics.
+- Minimum visible segment: 3 points, but preserve true numeric labels.
+- No gradients, 3D, unexplained animation, or unlabeled axes.
+- Cobalt is the default data series.
+- Coral represents a highlighted day, missed target, or current focus only when the legend explains it.
+- Subject comparisons may use subject accent colors if labels are present.
+
+### 11.8 Inputs
+
+- Height: 52.
+- Radius: 12.
+- White fill.
+- 1-point border.
+- Horizontal padding: 14–16.
+- Input text: Body.
+- Label: 12 semibold in sentence case, 8 points above input.
+- Placeholder: muted, but optional instructions must not exist only as placeholder text.
+- Focus: 2-point cobalt.
+- Error: danger border, error icon where useful, and explanatory text below.
+- Disabled/read-only states must look distinct.
+- Use the correct keyboard and locale-aware time/date pickers instead of requiring raw `HH:mm` text when practical.
+
+### 11.9 Chips and status pills
+
+- Minimum height: 32; selection chips should be 36–40.
+- Radius: 8–999 depending on compactness.
+- Horizontal padding: 10–12.
+- Label: 12 semibold.
+- Selection chip: cobalt fill/white text.
+- Status chip: semantic soft fill + semantic text + optional icon.
+- Avoid more than five visible chips in one row; use wrapping or a picker.
+
+### 11.10 Bottom sheets
+
+- Top radii: 28.
+- Surface: white or canvas, not translucent glass.
+- Scrim: navy at 46%.
+- Handle: 36 Ă— 4.
+- Padding: 20, plus safe-area bottom.
+- Title: 20/26 bold.
+- Primary action: full-width at bottom.
+- Keyboard must not cover active fields or the CTA.
+- Swipe-to-dismiss must not discard entered data without warning when meaningful work is present.
+
+### 11.11 Empty, loading, and error states
+
+#### Empty
+
+- Compact, contextual, and actionable.
+- Use one 40–48 icon container, a 17-point title, and one short body sentence.
+- Show one relevant action at most.
+- Example: `No classes today` / `Enjoy the break, or add a one-off class.`
+
+#### Loading
+
+- Preserve layout with skeletons where possible.
+- Use a spinner only for a focused blocking action.
+- Do not flash an empty state while data is loading.
+
+#### Error
+
+- Explain what failed and what the user can do.
+- Keep entered form data after save failures.
+- Offer retry for recoverable fetch failures.
+- Use an inline banner or toast before resorting to a blocking alert.
+
+### 11.12 Toasts and undo
+
+Attendance marking is frequent and easy to tap incorrectly. After marking attended/absent/cancelled:
+
+- Update optimistically.
+- Show a short confirmation toast.
+- Provide `Undo` for several seconds.
+- If persistence fails, restore the prior state and explain the failure.
+
+---
+
+## 12. Screen-level UX blueprints
+
+These are hierarchy guides, not pixel-for-pixel wireframes.
+
+### 12.1 Today
+
+**Primary question:** What do I need to do next?
+
+Recommended order:
+
+1. Current date/context and personalized Heading 1.
+2. Signature week selector.
+3. Conditional attendance insight only when real and useful.
+4. Agenda heading with class count and one clear Add action.
+5. Chronological timeline.
+6. Empty or end-of-day message.
+
+Guidelines:
+
+- The next/in-progress class gets the strongest emphasis.
+- Past classes remain visible but visually quieter.
+- Attendance actions stay on the relevant class, not in a detached global control.
+- Do not permanently show a generic “attendance is looking good” banner.
+- Preserve the selected date when returning from subject details.
+- `Today` is a shortcut, not a competing primary button when the selected date is already today.
+
+### 12.2 Timetable
+
+**Primary question:** What is my recurring schedule?
+
+Recommended order:
+
+1. Heading 1 and edit action.
+2. Week/day navigation.
+3. Selected day and recurring schedule.
+4. Add recurring class.
+5. Import/scan as a secondary tool.
+6. Brief explanation of effective-date behavior where relevant.
+
+Guidelines:
+
+- Do not place a non-functional scan card above the actual timetable.
+- Clearly separate recurring timetable edits from one-off day changes.
+- Show start and end times, not start time alone.
+- Make conflicts and recess gaps explicit.
+- If timetable import is unavailable, hide it or label it as coming soon; do not simulate it with a placeholder alert.
+
+### 12.3 Attendance
+
+**Primary question:** Am I safe, and which subject needs attention?
+
+Recommended order:
+
+1. Heading 1.
+2. Overall attendance hero.
+3. Held / attended / missed totals.
+4. Subject breakdown, with at-risk subjects first or clearly marked.
+5. Attendance history entry point when implemented.
+
+Guidelines:
+
+- Show `attended / held` next to percentages.
+- Cancelled classes are excluded and this rule should be available through concise help.
+- Zero data is not a failing 0% state.
+- Avoid celebratory language if the percentage is merely above threshold by a fragile margin.
+- Do not expose “View attendance history” as a finished control if it only opens an informational alert.
+
+### 12.4 Subject details
+
+**Primary question:** What is happening in this subject?
+
+Recommended order:
+
+1. Compact app bar.
+2. Subject-tinted hero with name, code, short name, and percentage.
+3. Attendance decision summary and totals.
+4. Recent classes/history.
+5. Edit action through a sheet or focused edit state.
+
+Guidelines:
+
+- Use the subject’s stable pastel surface, not an alpha-generated color that may be muddy or inaccessible.
+- Attendance status uses semantic indicators layered over subject identity.
+- Long subject names may wrap to two lines.
+- Show dates in a human, locale-aware format rather than raw API strings.
+
+### 12.5 Add class
+
+**Primary question:** Can I add the correct class quickly and confidently?
+
+Recommended order:
+
+1. Title and one-line explanation of recurring vs one-off.
+2. Subject picker.
+3. Date/day context.
+4. Start and end time.
+5. Room.
+6. Full-width confirmation.
+
+Guidelines:
+
+- Prefer subject rows or accessible chips with both short name and enough identifying information.
+- Auto-calculate end time from default duration but keep it editable.
+- Use a time picker where possible.
+- Warn about overlap before saving.
+- Preserve values after validation errors.
+
+### 12.6 Onboarding
+
+**Primary question:** What minimum information gets me to a useful schedule?
+
+- Keep the two-step structure.
+- Use the Display or Heading 1 style once per step.
+- Explain why each requested field matters.
+- Required and optional fields must be explicit.
+- Let users add subjects in a compact repeatable list.
+- Keep Skip visible when subjects are optional.
+- Progress indicators should indicate `1 of 2` in accessible text, not bars alone.
+- Do not front-load features or fake examples beyond helpful placeholders.
+
+### 12.7 Account and settings
+
+- Use compact grouped lists instead of a collection of elevated cards.
+- Show actual saved profile values; use clear empty labels such as `Not added`.
+- Editing profile should be a real flow before the button is emphasized.
+- Settings changes should save predictably: either auto-save with feedback or use one explicit Save pattern, not both without explanation.
+- Use native switches for binary settings and time/duration pickers for values.
+
+---
+
+## 13. Interaction states
+
+Every interactive component needs:
+
+- Default
+- Pressed
+- Focused, including keyboard/web focus
+- Disabled
+- Loading where applicable
+- Error where applicable
+
+### Pressed behavior
+
+- Buttons/cards: scale to 0.98 over 100–160 ms.
+- Use color change in addition to scale.
+- Do not animate layout for a simple press.
+
+### Selection behavior
+
+- Selected navigation: cobalt.
+- Selected date: coral.
+- Selected form choice: cobalt.
+- Checked/completed state: success.
+
+Selection colors differ by context on purpose. Do not flatten every selection into one visual treatment.
+
+### Destructive behavior
+
+- Require confirmation for deleting a subject or recurring timetable item.
+- Removing a one-off class may use Undo instead of a confirmation dialog.
+- State the consequence in concrete language.
+
+---
+
+## 14. Motion and haptics
+
+Motion should make state changes understandable, not decorate the interface.
+
+### Durations
+
+- Micro feedback: 100 ms.
+- Press and simple transitions: 160 ms.
+- Standard content transition: 220 ms.
+- Sheet/large emphasized transition: 320 ms.
+
+### Recommended motion
+
+- Bottom sheet slides with a fading scrim.
+- Attendance ring animates only when first loaded or materially changed.
+- Agenda updates cross-fade or use a short layout transition.
+- Current-time line moves only on minute updates without attention-grabbing animation.
+- Respect reduced-motion settings; replace transforms with fades or immediate changes.
+
+### Haptics
+
+Use sparingly:
+
+- Light selection haptic for date selection.
+- Success haptic after attendance is saved.
+- Warning haptic before a destructive confirmation.
+- No haptic on every scroll, tab render, or decorative animation.
+
+---
+
+## 15. Content design
+
+### 15.1 Terminology
+
+Use these terms consistently:
+
+- **Subject:** an academic unit such as Data Structures.
+- **Class:** one scheduled occurrence of a subject.
+- **Timetable:** the recurring weekly schedule.
+- **Attendance:** the attended/absent record for held classes.
+- **Programme:** the student’s overall degree/programme.
+- **Session:** internal/API language only; avoid in product copy.
+
+Attendance labels:
+
+- Not marked
+- Attended
+- Absent
+- Cancelled
+
+### 15.2 Voice
+
+- Direct and calm.
+- Short sentences.
+- Action before explanation.
+- Specific rather than promotional.
+- Encouraging only when supported by data.
+
+Good:
+
+- `No classes today`
+- `You need to attend the next 2 classes to reach 75%.`
+- `This change starts tomorrow. Past attendance will not change.`
+- `Couldn’t save attendance. Try again.`
+
+Avoid:
+
+- `Awesome! You’re crushing it!`
+- `Your attendance is looking good` when no calculation supports it.
+- `An unexpected error occurred.`
+- Long policy text embedded in a yellow card on every visit.
+
+### 15.3 Formatting
+
+- Use sentence case.
+- Format dates and times according to locale.
+- Use an en dash for ranges: `9:00–10:00`.
+- Use centered dots for compact metadata: `CS201 · Room B204`.
+- Do not manually uppercase user content such as subject names.
+- Truncate codes and rooms before class names; the class name is the meaningful content.
+
+---
+
+## 16. Accessibility
+
+The reference aesthetic must be adapted, not reproduced at the expense of usability.
+
+- Normal text contrast: at least 4.5:1.
+- Large text contrast: at least 3:1.
+- Interactive target: at least 44 Ă— 44.
+- Support font scaling without clipping essential content.
+- Do not disable font scaling for timetable metadata.
+- At 200% text size, cards may grow vertically; do not fix heights around text.
+- Every status uses text or an icon as well as color.
+- Charts expose a textual summary to screen readers.
+- Icon-only controls have accessible names and state.
+- Weekday buttons announce full day, date, selected state, and today state.
+- Attendance controls announce the subject/class and resulting state.
+- Focus order follows visual order.
+- Scrims trap focus within modal content on web.
+- Respect reduced motion and increased contrast where available.
+- Avoid horizontal-only swipe interactions without visible buttons.
+
+---
+
+## 17. Localization and data resilience
+
+- Expect long subject names, room names, college names, and translated labels.
+- Avoid fixed-width text containers except the time rail.
+- Use locale-aware date/time formatters.
+- Support both 12-hour and 24-hour time.
+- Do not derive initials from Latin-only assumptions without a fallback.
+- Never use a percentage without guarding against zero held classes.
+- Loading, empty, offline, partial-data, and API-error states must be distinct.
+- Cache or preserve the last useful schedule where product logic allows; do not replace useful content with a blank screen during a transient error.
+
+---
+
+## 18. Implementation guidance for the polish pass
+
+The next implementation pass should establish shared primitives before restyling individual screens.
+
+Recommended primitive inventory:
+
+- `Screen`
+- `AppText`
+- `AppHeader`
+- `Button`
+- `IconButton`
+- `Card`
+- `Badge` / `StatusPill`
+- `WeekStrip`
+- `ScheduleEventCard`
+- `CurrentTimeIndicator`
+- `AttendanceRing`
+- `ProgressBar`
+- `SubjectBadge`
+- `FormField`
+- `EmptyState`
+- `InlineBanner`
+- `BottomSheet`
+- `Toast`
+
+Engineering rules:
+
+1. Centralize tokens; screen files must not carry their own color systems.
+2. Load Manrope once at the root and map named text variants.
+3. Replace ad-hoc `fontWeight`/`fontSize` pairs with typography variants.
+4. Replace arbitrary alpha-string subject colors with explicit accessible subject surface/accent pairs.
+5. Keep semantic state colors independent of subject colors.
+6. Avoid fixed-height containers around user text.
+7. Use one shared shadow only for truly floating elements.
+8. Use shared pressed, disabled, focus, and loading states.
+9. Add visual regression screenshots for Today, Timetable, Attendance, Subject Details, onboarding, empty states, and a populated schedule.
+10. Do not implement partial dark mode. The first polish pass is a coherent light theme. Add dark mode only as a separately specified token layer.
+
+---
+
+## 19. Design QA checklist
+
+### Foundations
+
+- [ ] Manrope is loaded and no screen uses an unrelated font.
+- [ ] There are no legacy purple primary colors.
+- [ ] Primary text is navy, not black or purple-gray.
+- [ ] Cobalt is the primary action color.
+- [ ] Coral is used for time/focus, not delete/error.
+- [ ] Subject colors are stable across screens.
+- [ ] No necessary normal text uses a low-contrast muted color.
+
+### Layout
+
+- [ ] Phone gutters are consistent.
+- [ ] Section rhythm uses the approved spacing scale.
+- [ ] Text can grow without clipping.
+- [ ] Tablet/web content is width-constrained.
+- [ ] Bottom content clears navigation and safe areas.
+
+### Components
+
+- [ ] Touch targets are at least 44 Ă— 44.
+- [ ] Default cards are flat.
+- [ ] Only floating surfaces use shadow.
+- [ ] Inputs have focus/error/disabled states.
+- [ ] Buttons have pressed/loading/disabled states.
+- [ ] Empty, loading, and error states are distinct.
+
+### Timetable and attendance
+
+- [ ] Today, selected date, and attendance issue states are distinguishable.
+- [ ] Schedule blocks show start/end time and room where available.
+- [ ] Current time uses the coral indicator.
+- [ ] Subject identity does not change with attendance status.
+- [ ] Attendance status includes text/icon, not color alone.
+- [ ] Zero attendance data is not shown as a failing 0%.
+- [ ] Insights are calculated from real data.
+
+### UX integrity
+
+- [ ] No primary affordance ends in placeholder-only content.
+- [ ] Destructive actions confirm or offer Undo.
+- [ ] Save failures preserve user input.
+- [ ] Terminology uses subject/class/timetable consistently.
+- [ ] Dates and times are locale-aware.
+- [ ] Screen-reader labels describe icon-only controls and selection state.
+
+---
+
+## 20. Final decision register
+
+| Decision | Chosen direction |
+|---|---|
+| Font | Manrope, weights 400–800 |
+| Primary text | Navy `#061430` |
+| Primary brand/action | Cobalt `#0559FA` |
+| Temporal focus | Coral `#FF7A4F` |
+| Context surface | Sky `#D5F2FF` |
+| Main background | Cool canvas `#F7F9FC` with white surfaces |
+| Subject treatment | Stable pastel surface + accessible accent pair |
+| Corner language | 12 controls, 16 cards, 20 feature cards, 28 sheets |
+| Elevation | Flat by default; floating overlays only |
+| Grid | 4-point base, 8-point rhythm |
+| Navigation | Three labeled bottom tabs; profile for secondary destinations |
+| Theme scope | Coherent light theme first; no partial automatic dark mode |
+| Voice | Direct, calm, sentence case, data-grounded |
+
+This system preserves the reference artwork’s strongest qualities—confidence, color clarity, editorial scale, and schedule-first organization—while making them appropriate, accessible, and specific to CLG Kit.
diff --git a/designs/design-tokens.json b/designs/design-tokens.json
new file mode 100644
index 0000000..03e6740
--- /dev/null
+++ b/designs/design-tokens.json
@@ -0,0 +1,223 @@
+{
+ "meta": {
+ "name": "CLG Kit Design Tokens",
+ "version": "1.0.0",
+ "fontFamily": "Manrope",
+ "units": {
+ "dimension": "dp",
+ "type": "sp",
+ "motion": "ms"
+ }
+ },
+ "color": {
+ "brand": {
+ "ink": "#061430",
+ "cobalt": "#0559FA",
+ "cobaltPressed": "#0047D5",
+ "cobaltSoft": "#E7F0FF",
+ "coral": "#FF7A4F",
+ "coralSoft": "#FFE9E1",
+ "sky": "#D5F2FF",
+ "skySoft": "#F0FAFF"
+ },
+ "neutral": {
+ "canvas": "#F7F9FC",
+ "surface": "#FFFFFF",
+ "surfaceSubtle": "#F0F4F8",
+ "presentationBackdrop": "#E6EBF4",
+ "textPrimary": "#061430",
+ "textSecondary": "#4E6078",
+ "textMuted": "#68788F",
+ "textDisabled": "#9AA8B8",
+ "border": "#DDE5EE",
+ "divider": "#E9EEF4",
+ "scrim": "rgba(6, 20, 48, 0.46)"
+ },
+ "semantic": {
+ "success": {
+ "solid": "#168A63",
+ "text": "#117A57",
+ "soft": "#DDF5EC"
+ },
+ "warning": {
+ "solid": "#F0B44D",
+ "text": "#8A5900",
+ "soft": "#FFF4CE"
+ },
+ "danger": {
+ "solid": "#CF4038",
+ "text": "#A82E2A",
+ "soft": "#FFE9E7"
+ },
+ "neutral": {
+ "solid": "#68788F",
+ "text": "#4E6078",
+ "soft": "#EDF1F5"
+ }
+ },
+ "subject": {
+ "ocean": {
+ "surface": "#BFD5FF",
+ "accent": "#0559FA",
+ "text": "#061430"
+ },
+ "aqua": {
+ "surface": "#DDF7FA",
+ "accent": "#167F96",
+ "text": "#061430"
+ },
+ "lilac": {
+ "surface": "#FDF0FF",
+ "accent": "#9B4BA4",
+ "text": "#061430"
+ },
+ "sun": {
+ "surface": "#FFF3C4",
+ "accent": "#8A6400",
+ "text": "#061430"
+ },
+ "mint": {
+ "surface": "#DDF5EC",
+ "accent": "#117A57",
+ "text": "#061430"
+ },
+ "peach": {
+ "surface": "#FFE5D9",
+ "accent": "#B95230",
+ "text": "#061430"
+ }
+ }
+ },
+ "typography": {
+ "family": {
+ "primary": "Manrope",
+ "fallback": "system-ui"
+ },
+ "weight": {
+ "regular": 400,
+ "medium": 500,
+ "semibold": 600,
+ "bold": 700,
+ "extrabold": 800
+ },
+ "style": {
+ "display": {
+ "fontSize": 40,
+ "lineHeight": 44,
+ "fontWeight": 800,
+ "letterSpacing": -1.2
+ },
+ "heading1": {
+ "fontSize": 32,
+ "lineHeight": 38,
+ "fontWeight": 800,
+ "letterSpacing": -0.8
+ },
+ "heading2": {
+ "fontSize": 24,
+ "lineHeight": 30,
+ "fontWeight": 700,
+ "letterSpacing": -0.4
+ },
+ "heading3": {
+ "fontSize": 20,
+ "lineHeight": 26,
+ "fontWeight": 700,
+ "letterSpacing": -0.2
+ },
+ "title": {
+ "fontSize": 17,
+ "lineHeight": 23,
+ "fontWeight": 700,
+ "letterSpacing": 0
+ },
+ "body": {
+ "fontSize": 15,
+ "lineHeight": 22,
+ "fontWeight": 400,
+ "letterSpacing": 0
+ },
+ "bodySmall": {
+ "fontSize": 13,
+ "lineHeight": 19,
+ "fontWeight": 400,
+ "letterSpacing": 0
+ },
+ "label": {
+ "fontSize": 12,
+ "lineHeight": 16,
+ "fontWeight": 600,
+ "letterSpacing": 0.1
+ },
+ "caption": {
+ "fontSize": 11,
+ "lineHeight": 15,
+ "fontWeight": 500,
+ "letterSpacing": 0.1
+ }
+ }
+ },
+ "spacing": {
+ "0": 0,
+ "1": 2,
+ "2": 4,
+ "3": 8,
+ "4": 12,
+ "5": 16,
+ "6": 20,
+ "7": 24,
+ "8": 32,
+ "9": 40,
+ "10": 48,
+ "11": 64
+ },
+ "radius": {
+ "small": 8,
+ "control": 12,
+ "card": 16,
+ "feature": 20,
+ "sheet": 28,
+ "pill": 999
+ },
+ "size": {
+ "touchTargetMin": 44,
+ "control": 52,
+ "controlCompact": 40,
+ "iconSmall": 16,
+ "icon": 20,
+ "iconLarge": 24,
+ "iconContainer": 40,
+ "screenGutter": 20,
+ "screenGutterWide": 24,
+ "contentMaxWidth": 560,
+ "tabBar": 68
+ },
+ "border": {
+ "hairline": 1,
+ "focus": 2,
+ "accent": 4
+ },
+ "shadow": {
+ "none": {
+ "color": "#061430",
+ "opacity": 0,
+ "radius": 0,
+ "offsetY": 0,
+ "elevation": 0
+ },
+ "floating": {
+ "color": "#061430",
+ "opacity": 0.12,
+ "radius": 18,
+ "offsetY": 8,
+ "elevation": 8
+ }
+ },
+ "motion": {
+ "instant": 100,
+ "quick": 160,
+ "standard": 220,
+ "emphasized": 320,
+ "pressedScale": 0.98
+ }
+}
diff --git a/designs/timeline.png b/designs/timeline.png
new file mode 100644
index 0000000..37ad289
Binary files /dev/null and b/designs/timeline.png differ
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..f3287fc
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,33 @@
+services:
+ api:
+ build:
+ context: .
+ dockerfile: backend/Dockerfile
+ restart: unless-stopped
+ environment:
+ PORT: 4000
+ volumes:
+ - college-kit-data:/app/backend/data
+ healthcheck:
+ test: ["CMD", "node", "-e", "fetch('http://localhost:4000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
+ interval: 10s
+ timeout: 3s
+ retries: 5
+ start_period: 5s
+
+ web:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ args:
+ EXPO_PUBLIC_API_URL: /api/v1
+ restart: unless-stopped
+ depends_on:
+ api:
+ condition: service_healthy
+ ports:
+ - "8080:80"
+
+volumes:
+ college-kit-data:
+ name: college-kit-data
diff --git a/docker/nginx.conf b/docker/nginx.conf
new file mode 100644
index 0000000..b90b40e
--- /dev/null
+++ b/docker/nginx.conf
@@ -0,0 +1,26 @@
+server {
+ listen 80;
+ listen [::]:80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # The web bundle calls /api/v1 so it stays same-origin in Docker.
+ location /api/ {
+ proxy_pass http://api:4000/api/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location = /health {
+ proxy_pass http://api:4000/health;
+ }
+
+ # Expo Router static output and client-side routes.
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/lib/api.ts b/lib/api.ts
new file mode 100644
index 0000000..c4bd841
--- /dev/null
+++ b/lib/api.ts
@@ -0,0 +1,36 @@
+export type AttendanceStatus = 'pending' | 'attended' | 'absent' | 'cancelled';
+export type Session = { id: number; subjectId: number; date: string; time: string; endTime: string; title: string; code: string; room: string; color: string; classType: string; status: AttendanceStatus };
+
+// Set EXPO_PUBLIC_API_URL to your computer's LAN address when testing on a phone,
+// e.g. EXPO_PUBLIC_API_URL=http://192.168.1.20:4000/api/v1
+const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1';
+
+async function request(path: string, options?: RequestInit): Promise {
+ const response = await fetch(`${API_URL}${path}`, { headers: { 'Content-Type': 'application/json', ...(options?.headers ?? {}) }, ...options });
+ if (!response.ok) {
+ const body = await response.json().catch(() => ({}));
+ throw new Error(body.error ?? 'Unable to reach College Kit');
+ }
+ return response.json() as Promise;
+}
+
+export type Profile = { id: number; name: string; initials: string; college: string; programme: string; semester: string; lectureMinutes: number; recessEnabled: boolean; recessStart: string; recessEnd: string; weekendSchedule: boolean };
+export type Subject = { id: number; name: string; code: string; shortName: string; color: string; classType: string; defaultRoom: string };
+
+export const collegeApi = {
+ profile() { return request('/profile'); },
+ updateProfile(data: { name: string; college: string; programme?: string; semester?: string }) { return request('/profile', { method: 'PUT', body: JSON.stringify(data) }); },
+ updateSettings(data: { lectureMinutes: number; recessEnabled: boolean; recessStart: string; recessEnd: string; weekendSchedule: boolean }) { return request<{ lectureMinutes: number; recessEnabled: boolean; recessStart: string; recessEnd: string; weekendSchedule: boolean }>('/settings', { method: 'PATCH', body: JSON.stringify(data) }); },
+ subjects() { return request('/subjects'); },
+ createSubject(data: { name: string; code: string; shortName?: string; color?: string; classType?: string; defaultRoom?: string }) { return request<{ id: number }>('/subjects', { method: 'POST', body: JSON.stringify(data) }); },
+ subject(id: number) { return request<{ id: number; name: string; code: string; shortName: string; color: string; classType: string; defaultRoom: string; summary: { total: number; attended: number; absent: number; percentage: number }; sessions: Array<{ id: number; date: string; time: string; endTime: string; room: string; status: AttendanceStatus }> }>(`/subjects/${id}`); },
+ updateSubject(id: number, data: { name: string; code: string; shortName: string; color?: string; classType?: string; defaultRoom?: string }) { return request<{ id: number }>(`/subjects/${id}`, { method: 'PATCH', body: JSON.stringify(data) }); },
+ schedule(date: string) { return request<{ date: string; sessions: Session[] }>(`/schedule?date=${date}`); },
+ markAttendance(id: number, status: AttendanceStatus) { return request<{ id: number; status: AttendanceStatus }>(`/schedule/${id}/attendance`, { method: 'PUT', body: JSON.stringify({ status }) }); },
+ removeClass(id: number) { return request<{ id: number; deleted: boolean }>(`/schedule/${id}`, { method: 'DELETE' }); },
+ addOneOffClass(data: { subjectId: number; date: string; startTime: string; endTime: string; room: string }) { return request<{ id: number }>('/schedule', { method: 'POST', body: JSON.stringify(data) }); },
+ attendanceSummary() { return request<{ subjects: Array<{ id: number; name: string; code: string; shortName?: string; color: string; total: number; attended: number; absent: number; cancelled: number; percentage: number }> }>('/attendance/summary'); },
+ timetable(date: string) { return request<{ date: string; classes: Array<{ id: number; weekday: number; startTime: string; endTime: string; room: string; subjectId: number; subjectName: string; code: string; color: string; classType: string; effectiveFrom: string; effectiveTo: string | null }> }>(`/timetable/classes?date=${date}`); },
+ updateTimetableClass(id: number, data: { subjectId?: number; weekday?: number; startTime?: string; endTime?: string; room?: string; effectiveFrom?: string }) { return request<{ id: number; replacesId: number; effectiveFrom: string }>(`/timetable/classes/${id}`, { method: 'PATCH', body: JSON.stringify(data) }); },
+ addTimetableClass(data: { subjectId: number; weekday: number; startTime: string; endTime: string; room: string; effectiveFrom?: string }) { return request<{ id: number }>('/timetable/classes', { method: 'POST', body: JSON.stringify(data) }); },
+};
diff --git a/lib/date.ts b/lib/date.ts
new file mode 100644
index 0000000..51ee5e8
--- /dev/null
+++ b/lib/date.ts
@@ -0,0 +1,34 @@
+export const dateKey = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
+
+export const addDays = (date: Date, amount: number) => {
+ const next = new Date(date);
+ next.setDate(next.getDate() + amount);
+ return next;
+};
+
+export const mondayOfWeek = (date: Date) => addDays(date, -((date.getDay() + 6) % 7));
+
+export const dateFromKey = (value: string) => new Date(`${value}T12:00:00`);
+
+export const formatDayHeading = (date: Date) => new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'long', day: 'numeric' }).format(date);
+
+export const formatMonthDay = (date: Date) => new Intl.DateTimeFormat(undefined, { month: 'long', day: 'numeric' }).format(date);
+
+export const formatSessionDate = (value: string) => new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(dateFromKey(value));
+
+export const currentMinutes = () => {
+ const now = new Date();
+ return now.getHours() * 60 + now.getMinutes();
+};
+
+export const timeToMinutes = (value: string) => {
+ const [hours, minutes] = value.split(':').map(Number);
+ return Number.isFinite(hours) && Number.isFinite(minutes) ? hours * 60 + minutes : -1;
+};
+
+export const greetingFor = (date = new Date()) => {
+ const hour = date.getHours();
+ if (hour < 12) return 'Good morning';
+ if (hour < 18) return 'Good afternoon';
+ return 'Good evening';
+};
diff --git a/lib/design.ts b/lib/design.ts
new file mode 100644
index 0000000..abde45d
--- /dev/null
+++ b/lib/design.ts
@@ -0,0 +1,31 @@
+import { colors, type SemanticTone, type SubjectTone } from '@/components/ui';
+
+const subjectTones = Object.keys(colors.subject) as SubjectTone[];
+
+const hash = (value: string | number) => {
+ const input = String(value);
+ let result = 0;
+ for (let index = 0; index < input.length; index += 1) result = ((result << 5) - result + input.charCodeAt(index)) | 0;
+ return Math.abs(result);
+};
+
+/** Maps persisted subjects to one stable accessible surface/accent pair. */
+export function subjectToneFor(identity: string | number, savedColor?: string): SubjectTone {
+ const normalized = savedColor?.toUpperCase();
+ const matched = subjectTones.find((tone) => colors.subject[tone].accent.toUpperCase() === normalized || colors.subject[tone].surface.toUpperCase() === normalized);
+ return matched ?? subjectTones[hash(identity) % subjectTones.length];
+}
+
+export function attendanceTone(percentage: number, total: number, threshold = 75): SemanticTone {
+ if (!total) return 'neutral';
+ if (percentage >= threshold) return 'success';
+ if (percentage >= threshold - 10) return 'warning';
+ return 'danger';
+}
+
+export function attendanceMessage(percentage: number, total: number, threshold = 75) {
+ if (!total) return { title: 'No attendance yet', message: 'Mark your first class to start tracking progress.' };
+ if (percentage >= threshold + 5) return { title: 'You’re on track', message: `${percentage}% overall · Keep it above ${threshold}%.` };
+ if (percentage >= threshold) return { title: 'You’re just above the target', message: `${percentage}% overall · Your target is ${threshold}%.` };
+ return { title: 'Attendance needs attention', message: `${percentage}% overall · Your target is ${threshold}%.` };
+}
diff --git a/package-lock.json b/package-lock.json
index 2aff42c..a43bfd6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "clg-kit",
"version": "1.0.0",
"dependencies": {
+ "@expo-google-fonts/manrope": "^0.4.2",
"@expo/vector-icons": "^15.0.3",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
@@ -31,6 +32,7 @@
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
+ "react-native-svg": "15.12.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1"
},
@@ -1626,6 +1628,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@expo-google-fonts/manrope": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@expo-google-fonts/manrope/-/manrope-0.4.2.tgz",
+ "integrity": "sha512-BZsKe8d9BJrVnIQIZcTS7/Kac0TbXnqs/+8EBSiQxrmK6GoCO6eTkmr50D1weIk/EoF20pTmAkpWICnovATr/g==",
+ "license": "MIT AND OFL-1.1"
+ },
"node_modules/@expo/code-signing-certificates": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
@@ -4273,6 +4281,12 @@
"node": ">=0.6"
}
},
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "license": "ISC"
+ },
"node_modules/bplist-creator": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@@ -4755,6 +4769,56 @@
"hyphenate-style-name": "^1.0.3"
}
},
+ "node_modules/css-select": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+ "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
+ "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.0.14",
+ "source-map": "^0.6.1"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/css-tree/node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
+ "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -4953,6 +5017,61 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -5015,6 +5134,18 @@
"node": ">= 0.8"
}
},
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/env-editor": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@@ -8396,6 +8527,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.0.14",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
+ "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
+ "license": "CC0-1.0"
+ },
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -8917,6 +9054,18 @@
"node": ">=10"
}
},
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
"node_modules/nullthrows": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@@ -9797,6 +9946,21 @@
"react-native": "*"
}
},
+ "node_modules/react-native-svg": {
+ "version": "15.12.1",
+ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz",
+ "integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==",
+ "license": "MIT",
+ "dependencies": {
+ "css-select": "^5.1.0",
+ "css-tree": "^1.1.3",
+ "warn-once": "0.1.1"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/react-native-web": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
diff --git a/package.json b/package.json
index 8f4d14d..56d89dd 100644
--- a/package.json
+++ b/package.json
@@ -8,9 +8,11 @@
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
- "lint": "expo lint"
+ "lint": "expo lint",
+ "api": "node backend/server.mjs"
},
"dependencies": {
+ "@expo-google-fonts/manrope": "^0.4.2",
"@expo/vector-icons": "^15.0.3",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
@@ -31,17 +33,18 @@
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
- "react-native-worklets": "0.5.1",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
- "react-native-web": "~0.21.0"
+ "react-native-svg": "15.12.1",
+ "react-native-web": "~0.21.0",
+ "react-native-worklets": "0.5.1"
},
"devDependencies": {
"@types/react": "~19.1.0",
- "typescript": "~5.9.2",
"eslint": "^9.25.0",
- "eslint-config-expo": "~10.0.0"
+ "eslint-config-expo": "~10.0.0",
+ "typescript": "~5.9.2"
},
"private": true
}