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 ?