diff --git a/apps/client/app/(auth)/login.tsx b/apps/client/app/(auth)/login.tsx
index 91d56d0..71b5257 100644
--- a/apps/client/app/(auth)/login.tsx
+++ b/apps/client/app/(auth)/login.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect } from 'react';
import {
View,
Text,
@@ -7,19 +7,30 @@ import {
ScrollView,
ActivityIndicator,
} from 'react-native';
+import { router } from 'expo-router';
import { useAuth } from '@/contexts/AuthContext';
import { useGoogleAuth } from '@/contexts/GoogleAuthContext';
+import { useDevMode } from '@/contexts/DevModeContext';
+import { DEV_ACCOUNTS } from '@/constants/devAccounts';
import { useResponsive } from '@/hooks/useResponsive';
import { useAppTheme } from '@/hooks/useAppTheme';
import { AppPalette } from '@/constants/theme';
export default function LoginScreen() {
- const { isLoading, error } = useAuth();
+ const { isLoading, error, isAuthenticated } = useAuth();
const { googleSignIn, isLoading: isGoogleLoading } = useGoogleAuth();
+ const { isDevMode, signInAsDevUser } = useDevMode();
const { isMobile } = useResponsive();
const { colors, fontScale } = useAppTheme();
const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]);
+ // Covers both Google sign-in and dev-mode test accounts
+ useEffect(() => {
+ if (isAuthenticated) {
+ router.replace('/(tabs)');
+ }
+ }, [isAuthenticated]);
+
const handleGoogleSignIn = async () => {
await googleSignIn();
// On success, onAuthStateChange will set session; tabs layout redirects to /(tabs)
@@ -73,7 +84,32 @@ export default function LoginScreen() {
Sign up with Google
+
+ {/* Dev mode: CMU SSO becomes optional — sign in as a local test persona */}
+ {isDevMode && (
+
+
+
+ DEV MODE · TEST ACCOUNTS
+
+
+ {DEV_ACCOUNTS.map((account) => (
+ signInAsDevUser(account.user.id)}
+ >
+ {account.user.name}
+ {account.description}
+
+ ))}
+
+ )}
+
+ router.push('/dev')} style={styles.devLink}>
+ Developer mode
+
);
}
@@ -187,4 +223,50 @@ const createStyles = (colors: AppPalette, fontScale: number) =>
color: colors.primary,
fontWeight: '600',
},
+ devSection: {
+ marginTop: 24,
+ },
+ devDivider: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ marginBottom: 12,
+ },
+ devDividerLine: {
+ flex: 1,
+ height: 1,
+ backgroundColor: colors.border,
+ },
+ devDividerText: {
+ fontSize: 10 * fontScale,
+ fontWeight: '700',
+ letterSpacing: 1,
+ color: colors.textTertiary,
+ },
+ devAccountButton: {
+ borderWidth: 1,
+ borderColor: colors.border,
+ borderRadius: 10,
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ marginBottom: 8,
+ },
+ devAccountName: {
+ fontSize: 14 * fontScale,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ },
+ devAccountHint: {
+ fontSize: 12 * fontScale,
+ color: colors.textSecondary,
+ marginTop: 1,
+ },
+ devLink: {
+ marginTop: 20,
+ padding: 8,
+ },
+ devLinkText: {
+ fontSize: 12 * fontScale,
+ color: colors.textTertiary,
+ },
});
diff --git a/apps/client/app/_layout.tsx b/apps/client/app/_layout.tsx
index 1f16774..7b8f38c 100644
--- a/apps/client/app/_layout.tsx
+++ b/apps/client/app/_layout.tsx
@@ -4,6 +4,7 @@ import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated';
import { AuthProvider } from '@/contexts/AuthContext';
+import { DevModeProvider } from '@/contexts/DevModeContext';
import { GoogleAuthProvider } from '@/contexts/GoogleAuthContext';
import { GoogleCalendarProvider } from '@/contexts/GoogleCalendarContext';
import { EventsProvider } from '@/contexts/EventsContext';
@@ -25,6 +26,8 @@ function ThemedStack() {
+
+
@@ -34,18 +37,20 @@ function ThemedStack() {
export default function RootLayout() {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/client/app/dev.tsx b/apps/client/app/dev.tsx
new file mode 100644
index 0000000..e468975
--- /dev/null
+++ b/apps/client/app/dev.tsx
@@ -0,0 +1,683 @@
+import React, { useState } from 'react';
+import {
+ View,
+ Text,
+ ScrollView,
+ StyleSheet,
+ TouchableOpacity,
+ TextInput,
+ Platform,
+ Switch,
+} from 'react-native';
+import { router } from 'expo-router';
+import { Ionicons } from '@expo/vector-icons';
+import { useDevMode } from '@/contexts/DevModeContext';
+import { useAuth } from '@/contexts/AuthContext';
+import { useEvents } from '@/contexts/EventsContext';
+import { useSettings } from '@/contexts/SettingsContext';
+import { useAppTheme } from '@/hooks/useAppTheme';
+import { AppPalette, FontScales } from '@/constants/theme';
+import { DEV_ACCOUNTS } from '@/constants/devAccounts';
+import { generateDevEvents, DEV_EVENT_PREFIX } from '@/utils/devTools';
+import { isSupabaseConfigured } from '@/lib/supabase';
+import { storage } from '@/lib/storage';
+
+/**
+ * Dev-mode panel: password-gated testing tools.
+ *
+ * The gate is a client-side convenience latch (the password ships in the
+ * bundle) — everything behind it is local-only and can never touch real
+ * user data. See contexts/DevModeContext.tsx.
+ */
+export default function DevScreen() {
+ const { colors, fontScale } = useAppTheme();
+ const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]);
+ const {
+ isDevMode,
+ devUser,
+ enableDevMode,
+ disableDevMode,
+ signInAsDevUser,
+ signOutDevUser,
+ } = useDevMode();
+ const { currentUser } = useAuth();
+ const { events, addExternalEvents, removeExternalEvents, refreshEvents } = useEvents();
+ const { settings, updateSettings, resetSettings } = useSettings();
+
+ const [password, setPassword] = useState('');
+ const [gateError, setGateError] = useState(false);
+ const [statusMessage, setStatusMessage] = useState(null);
+
+ const flash = (message: string) => {
+ setStatusMessage(message);
+ setTimeout(() => setStatusMessage(null), 2500);
+ };
+
+ /* ── Locked state: password gate ── */
+ if (!isDevMode) {
+ return (
+
+
+
+
+
+ Developer Mode
+
+ Testing tools for the CMUnify team: test accounts that bypass CMU
+ sign-in, data seeding, and theme controls. Enter the dev password
+ to continue.
+
+ {
+ setPassword(text);
+ setGateError(false);
+ }}
+ placeholder="Dev password"
+ placeholderTextColor={colors.textTertiary}
+ secureTextEntry
+ autoCapitalize="none"
+ onSubmitEditing={() => {
+ if (!enableDevMode(password)) setGateError(true);
+ }}
+ />
+ {gateError && Wrong password.}
+ {
+ if (!enableDevMode(password)) setGateError(true);
+ }}
+ >
+ Unlock
+
+ router.back()}>
+ ← Back
+
+
+
+ );
+ }
+
+ /* ── Unlocked: the panel ── */
+ const seededCount = events.filter((e) => e.id.startsWith(DEV_EVENT_PREFIX)).length;
+
+ const clearLocalState = async () => {
+ // Everything except auth/dev-mode keys, which have their own controls
+ const keys = [
+ 'universify_settings',
+ 'universify_scheduled_events',
+ 'universify_google_events',
+ 'universify_google_events_last_sync',
+ 'universify_gcal_event_map',
+ 'universify_fired_reminders',
+ 'universify_slack_config',
+ 'universify_slack_events',
+ 'universify_slack_last_import',
+ ];
+ await Promise.all(keys.map((k) => storage.removeItem(k).catch(() => {})));
+ await resetSettings();
+ flash('Local app state cleared');
+ };
+
+ const testNotification = () => {
+ if (Platform.OS !== 'web' || typeof Notification === 'undefined') {
+ flash('Notifications are web-only');
+ return;
+ }
+ Notification.requestPermission().then((perm) => {
+ if (perm === 'granted') {
+ new Notification('CMUnify test notification', {
+ body: 'This is what an event reminder looks like.',
+ });
+ flash('Notification fired');
+ } else {
+ flash(`Notification permission: ${perm}`);
+ }
+ });
+ };
+
+ return (
+
+ {/* Header */}
+
+
+ Developer Mode
+ Local testing tools — nothing here touches real data
+
+ {
+ disableDevMode();
+ router.replace('/');
+ }}
+ >
+ Exit dev mode
+
+
+
+ {statusMessage && (
+
+ {statusMessage}
+
+ )}
+
+ {/* Test accounts */}
+
+
+ Sign in without CMU SSO. Personas are local-only: RSVPs, schedules,
+ and created events stay on this device.
+
+ {DEV_ACCOUNTS.map((account) => {
+ const active = devUser?.id === account.user.id;
+ return (
+ {
+ signInAsDevUser(account.user.id);
+ flash(`Signed in as ${account.user.name}`);
+ }}
+ >
+
+
+ {account.user.name.split(' ').map((p) => p[0]).join('')}
+
+
+
+ {account.user.name}
+ {account.description}
+
+ {active && }
+
+ );
+ })}
+
+ {devUser && (
+ <>
+ router.push('/(tabs)')}>
+ Open app as {devUser.name.split(' ')[0]}
+
+ {
+ signOutDevUser();
+ flash('Signed out of test account');
+ }}
+ >
+ Sign out
+
+ >
+ )}
+
+
+
+ {/* Data tools */}
+
+
+ Seeded events are pinned to the next 7 days (bundled mock data has
+ fixed dates), tagged dev-seed, and removable in one tap.
+
+
+ {
+ addExternalEvents(generateDevEvents(10));
+ flash('Seeded 10 events across this week');
+ }}
+ >
+ Seed 10 events this week
+
+ {
+ removeExternalEvents(DEV_EVENT_PREFIX);
+ flash('Removed seeded events');
+ }}
+ >
+ Clear seeded ({seededCount})
+
+
+
+ {
+ refreshEvents();
+ flash('Events reloaded');
+ }}
+ >
+ Reload events
+
+
+ Reset local app state
+
+
+
+
+ {/* Theme playground */}
+
+ Theme
+
+ {(['light', 'dark', 'system'] as const).map((theme) => (
+ updateSettings({ theme })}
+ >
+
+ {theme}
+
+
+ ))}
+
+ Font size
+
+ {(Object.keys(FontScales) as (keyof typeof FontScales)[]).map((size) => (
+ updateSettings({ fontSize: size })}
+ >
+
+ {size}
+
+
+ ))}
+
+
+ High contrast
+
+ updateSettings({
+ accessibility: { ...settings.accessibility, highContrast: value },
+ })
+ }
+ trackColor={{ false: colors.border, true: colors.primary }}
+ />
+
+
+ Reduce motion
+
+ updateSettings({
+ accessibility: { ...settings.accessibility, reduceMotion: value },
+ })
+ }
+ trackColor={{ false: colors.border, true: colors.primary }}
+ />
+
+
+
+ {/* Shortcuts */}
+
+
+ router.push('/(tabs)/calendar')}>
+ Calendar
+
+ router.push('/(tabs)/find')}>
+ Find
+
+ router.push('/(tabs)/create')}>
+ Create
+
+
+
+ router.push('/resources')}>
+ Freshman guide
+
+
+ Test notification
+
+
+
+
+ {/* Diagnostics */}
+
+
+ );
+}
+
+/* ─── Small pieces ──────────────────────────────────────────────────── */
+
+function Section({
+ styles,
+ colors,
+ title,
+ icon,
+ children,
+}: {
+ styles: ReturnType;
+ colors: AppPalette;
+ title: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+function DiagRow({
+ styles,
+ label,
+ value,
+}: {
+ styles: ReturnType;
+ label: string;
+ value: string;
+}) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+/* ─── Styles ────────────────────────────────────────────────────────── */
+
+const createStyles = (colors: AppPalette, fontScale: number) =>
+ StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ scrollContent: {
+ padding: 20,
+ paddingBottom: 48,
+ maxWidth: 720,
+ width: '100%',
+ alignSelf: 'center',
+ },
+
+ /* Gate */
+ gateContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 24,
+ backgroundColor: colors.background,
+ },
+ gateCard: {
+ width: '100%',
+ maxWidth: 420,
+ backgroundColor: colors.surface,
+ borderRadius: 16,
+ borderWidth: 1,
+ borderColor: colors.border,
+ padding: 28,
+ alignItems: 'center',
+ },
+ gateIcon: {
+ width: 44,
+ height: 44,
+ borderRadius: 12,
+ backgroundColor: colors.surfaceAlt,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginBottom: 16,
+ },
+ gateTitle: {
+ fontSize: 22 * fontScale,
+ fontWeight: '800',
+ letterSpacing: -0.5,
+ color: colors.textPrimary,
+ marginBottom: 8,
+ },
+ gateBody: {
+ fontSize: 14 * fontScale,
+ lineHeight: 21 * fontScale,
+ color: colors.textSecondary,
+ textAlign: 'center',
+ marginBottom: 20,
+ },
+ gateInput: {
+ width: '100%',
+ borderWidth: 1,
+ borderColor: colors.border,
+ borderRadius: 10,
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ fontSize: 15 * fontScale,
+ color: colors.textPrimary,
+ marginBottom: 8,
+ },
+ gateInputError: {
+ borderColor: colors.danger,
+ },
+ gateErrorText: {
+ fontSize: 13 * fontScale,
+ color: colors.danger,
+ alignSelf: 'flex-start',
+ marginBottom: 4,
+ },
+ gateButton: {
+ width: '100%',
+ backgroundColor: colors.primary,
+ borderRadius: 10,
+ paddingVertical: 12,
+ alignItems: 'center',
+ marginTop: 8,
+ marginBottom: 14,
+ },
+ gateButtonText: {
+ fontSize: 15 * fontScale,
+ fontWeight: '600',
+ color: colors.onPrimary,
+ },
+ gateBack: {
+ fontSize: 14 * fontScale,
+ color: colors.textSecondary,
+ },
+
+ /* Panel */
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'flex-start',
+ marginBottom: 16,
+ gap: 12,
+ flexWrap: 'wrap',
+ },
+ title: {
+ fontSize: 26 * fontScale,
+ fontWeight: '800',
+ letterSpacing: -0.5,
+ color: colors.textPrimary,
+ },
+ subtitle: {
+ fontSize: 13 * fontScale,
+ color: colors.textSecondary,
+ marginTop: 2,
+ },
+ exitButton: {
+ borderWidth: 1,
+ borderColor: colors.border,
+ borderRadius: 10,
+ paddingHorizontal: 14,
+ paddingVertical: 8,
+ },
+ exitButtonText: {
+ fontSize: 13 * fontScale,
+ fontWeight: '600',
+ color: colors.danger,
+ },
+ statusBanner: {
+ backgroundColor: colors.infoSoft,
+ borderRadius: 10,
+ padding: 12,
+ marginBottom: 12,
+ },
+ statusBannerText: {
+ fontSize: 14 * fontScale,
+ color: colors.infoText,
+ textAlign: 'center',
+ },
+ section: {
+ backgroundColor: colors.surface,
+ borderRadius: 14,
+ borderWidth: 1,
+ borderColor: colors.border,
+ padding: 18,
+ marginBottom: 14,
+ },
+ sectionHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ marginBottom: 12,
+ },
+ sectionTitle: {
+ fontSize: 16 * fontScale,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ },
+ sectionHint: {
+ fontSize: 13 * fontScale,
+ lineHeight: 19 * fontScale,
+ color: colors.textSecondary,
+ marginBottom: 12,
+ },
+ accountRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ padding: 12,
+ borderRadius: 10,
+ borderWidth: 1,
+ borderColor: colors.border,
+ marginBottom: 8,
+ },
+ accountRowActive: {
+ borderColor: colors.primary,
+ backgroundColor: colors.surfaceAlt,
+ },
+ accountAvatar: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ backgroundColor: colors.surfaceAlt,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ accountAvatarText: {
+ fontSize: 13 * fontScale,
+ fontWeight: '700',
+ color: colors.primary,
+ },
+ accountInfo: {
+ flex: 1,
+ },
+ accountName: {
+ fontSize: 15 * fontScale,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ },
+ accountDescription: {
+ fontSize: 12 * fontScale,
+ color: colors.textSecondary,
+ marginTop: 1,
+ },
+ buttonRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 8,
+ marginTop: 4,
+ },
+ actionButton: {
+ backgroundColor: colors.primary,
+ borderRadius: 10,
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ },
+ actionButtonText: {
+ fontSize: 13 * fontScale,
+ fontWeight: '600',
+ color: colors.onPrimary,
+ },
+ quietButton: {
+ borderWidth: 1,
+ borderColor: colors.border,
+ borderRadius: 10,
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ },
+ quietButtonText: {
+ fontSize: 13 * fontScale,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ },
+ rowLabel: {
+ fontSize: 14 * fontScale,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ marginBottom: 8,
+ },
+ pillRow: {
+ flexDirection: 'row',
+ gap: 8,
+ marginBottom: 14,
+ },
+ pill: {
+ paddingHorizontal: 14,
+ paddingVertical: 7,
+ borderRadius: 999,
+ borderWidth: 1,
+ borderColor: colors.border,
+ },
+ pillActive: {
+ backgroundColor: colors.primary,
+ borderColor: colors.primary,
+ },
+ pillText: {
+ fontSize: 13 * fontScale,
+ fontWeight: '600',
+ color: colors.textSecondary,
+ },
+ pillTextActive: {
+ color: colors.onPrimary,
+ },
+ switchRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingVertical: 6,
+ },
+ diagRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ paddingVertical: 6,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.border,
+ gap: 12,
+ },
+ diagLabel: {
+ fontSize: 13 * fontScale,
+ color: colors.textSecondary,
+ },
+ diagValue: {
+ fontSize: 13 * fontScale,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ flexShrink: 1,
+ textAlign: 'right',
+ },
+ });
diff --git a/apps/client/app/index.tsx b/apps/client/app/index.tsx
index dc7a0f9..5948ef1 100644
--- a/apps/client/app/index.tsx
+++ b/apps/client/app/index.tsx
@@ -67,12 +67,20 @@ export default function LandingPage() {
CMUnify
- router.push('/(auth)/login')}
- >
- Sign in
-
+
+ router.push('/resources')}
+ >
+ Freshman Guide
+
+ router.push('/(auth)/login')}
+ >
+ Sign in
+
+
{/* Hero */}
@@ -232,6 +240,9 @@ export default function LandingPage() {
{/* Footer */}
+ router.push('/resources')}>
+ New to CMU? Read the Freshman Guide →
+
Made with care at Carnegie Mellon · ScottyLabs Labrador · © 2026 CMUnify
@@ -380,6 +391,20 @@ const createStyles = (colors: AppPalette, fontScale: number) =>
wordmarkAccent: {
color: colors.primary,
},
+ topBarActions: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ topBarLink: {
+ paddingHorizontal: 10,
+ paddingVertical: 8,
+ },
+ topBarLinkText: {
+ fontSize: 14 * fontScale,
+ fontWeight: '600',
+ color: colors.textSecondary,
+ },
topBarButton: {
paddingHorizontal: 16,
paddingVertical: 8,
@@ -706,6 +731,12 @@ const createStyles = (colors: AppPalette, fontScale: number) =>
alignItems: 'center',
paddingHorizontal: 24,
paddingTop: 8,
+ gap: 10,
+ },
+ footerLink: {
+ fontSize: 14 * fontScale,
+ fontWeight: '600',
+ color: colors.primary,
},
footerText: {
fontSize: 13 * fontScale,
diff --git a/apps/client/app/resources.tsx b/apps/client/app/resources.tsx
new file mode 100644
index 0000000..97a6763
--- /dev/null
+++ b/apps/client/app/resources.tsx
@@ -0,0 +1,494 @@
+import React, { useMemo, useState } from 'react';
+import {
+ View,
+ Text,
+ ScrollView,
+ StyleSheet,
+ TouchableOpacity,
+ TextInput,
+ Linking,
+ Platform,
+} from 'react-native';
+import { router } from 'expo-router';
+import { Ionicons } from '@expo/vector-icons';
+import { useAppTheme } from '@/hooks/useAppTheme';
+import { AppPalette } from '@/constants/theme';
+import { useResponsive } from '@/hooks/useResponsive';
+
+/**
+ * The Freshman Guide: a one-stop link tree of everything a new CMU student
+ * needs — accounts, academics, food, health, safety, money, getting around,
+ * and getting involved. Public page, no sign-in required.
+ *
+ * Links point at canonical top-level CMU pages (most stable URLs). If one
+ * breaks, tell us and we'll fix it.
+ */
+
+interface ResourceLink {
+ title: string;
+ description: string;
+ url: string;
+}
+
+interface ResourceSection {
+ id: string;
+ title: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ blurb: string;
+ links: ResourceLink[];
+}
+
+const SECTIONS: ResourceSection[] = [
+ {
+ id: 'start',
+ title: 'Start here: your accounts',
+ icon: 'key-outline',
+ blurb: 'The logins everything else depends on.',
+ links: [
+ { title: 'Andrew Account & Email', description: 'Your CMU identity — email, WiFi, printing, everything uses it.', url: 'https://www.cmu.edu/computing/services/comm-collab/email-calendar/' },
+ { title: 'SIO (Student Information Online)', description: 'Register for classes, view grades, pay bills, update info.', url: 'https://www.cmu.edu/hub/sio/' },
+ { title: 'Canvas', description: 'Course materials, assignments, and grades for most classes.', url: 'https://canvas.cmu.edu' },
+ { title: 'Stellic', description: 'Degree audit — track requirements and plan future semesters.', url: 'https://cmu.stellic.com' },
+ { title: 'Workday', description: 'Campus job paperwork, payroll, and direct deposit.', url: 'https://www.cmu.edu/my-workday-toolkit/' },
+ { title: 'Duo Two-Factor (2fa)', description: 'Required for almost every CMU login — set it up first.', url: 'https://www.cmu.edu/computing/services/security/identity-access/authentication/' },
+ ],
+ },
+ {
+ id: 'academics',
+ title: 'Classes & academics',
+ icon: 'school-outline',
+ blurb: 'Registering, planning, and getting help when a course fights back.',
+ links: [
+ { title: 'Schedule of Classes', description: 'Every course offered, with times, rooms, and instructors.', url: 'https://enr-apps.as.cmu.edu/open/SOC/SOCServlet/search' },
+ { title: 'Academic Calendar', description: 'Semester dates, add/drop deadlines, breaks, and finals.', url: 'https://www.cmu.edu/hub/calendar/' },
+ { title: 'The HUB', description: 'Registration, financial aid, student accounts — the admin front door.', url: 'https://www.cmu.edu/hub/' },
+ { title: 'Student Academic Success Center', description: 'Free tutoring, academic coaching, and communication support.', url: 'https://www.cmu.edu/student-success/' },
+ { title: 'FCE (Course Evaluations)', description: 'What past students thought of a course before you take it.', url: 'https://www.cmu.edu/hub/fce/' },
+ { title: 'University Libraries', description: 'Study spaces, research help, and course reserves at Hunt & Sorrells.', url: 'https://www.library.cmu.edu' },
+ { title: 'ScottyLabs CMU Courses', description: 'Student-built course browser with FCE data and prereq maps.', url: 'https://cmucourses.com' },
+ { title: 'Undergraduate Catalog', description: 'Official degree requirements and university policies.', url: 'https://www.cmu.edu/academic-catalog/' },
+ ],
+ },
+ {
+ id: 'tech',
+ title: 'Tech & IT help',
+ icon: 'laptop-outline',
+ blurb: 'WiFi, printing, software, and who to call when none of it works.',
+ links: [
+ { title: 'Computing Services', description: 'The IT front door — help desk, guides, and status.', url: 'https://www.cmu.edu/computing/' },
+ { title: 'WiFi Setup (CMU-SECURE)', description: 'Get your laptop and phone on the campus network.', url: 'https://www.cmu.edu/computing/services/endpoint/network-access/wireless/' },
+ { title: 'Printing (andrew printing)', description: 'Print from anywhere; release at clusters around campus.', url: 'https://www.cmu.edu/computing/services/endpoint/printing/' },
+ { title: 'Free & Discounted Software', description: 'MATLAB, Office, Adobe, and more with your Andrew ID.', url: 'https://www.cmu.edu/computing/software/' },
+ { title: 'VPN Access', description: 'Reach campus-only resources from off campus.', url: 'https://www.cmu.edu/computing/services/endpoint/network-access/vpn/' },
+ { title: 'Computer Labs & Clusters', description: 'Public machines with specialized software.', url: 'https://www.cmu.edu/computing/services/endpoint/computer-labs/' },
+ ],
+ },
+ {
+ id: 'food',
+ title: 'Food & dining',
+ icon: 'restaurant-outline',
+ blurb: 'Where to eat and how meal blocks actually work.',
+ links: [
+ { title: 'Dining Services', description: 'Meal plans, locations, and how blocks + DineXtra work.', url: 'https://www.cmu.edu/dining/' },
+ { title: 'Dining Hours & Menus', description: 'What is open right now and what they are serving.', url: 'https://www.cmu.edu/dining/locations/' },
+ { title: 'CMUEats', description: 'Student-built live dashboard of what is open at a glance.', url: 'https://cmueats.com' },
+ ],
+ },
+ {
+ id: 'housing',
+ title: 'Housing & living',
+ icon: 'home-outline',
+ blurb: 'Your room, your mail, and the people paid to help you settle in.',
+ links: [
+ { title: 'Housing Services', description: 'Room assignments, fixes, moving, and next-year selection.', url: 'https://www.cmu.edu/housing/' },
+ { title: 'Residential Education', description: 'Your RA and Housefellow — community and support in the dorms.', url: 'https://www.cmu.edu/residential-education/' },
+ { title: 'Mail Services', description: 'Your SMC mailbox — where packages actually go.', url: 'https://www.cmu.edu/mail-services/' },
+ { title: 'FixIt (Maintenance Requests)', description: 'Broken heater? Leaky faucet? File it here.', url: 'https://www.cmu.edu/fmcs/service-requests/' },
+ ],
+ },
+ {
+ id: 'health',
+ title: 'Health & wellbeing',
+ icon: 'heart-outline',
+ blurb: 'Physical health, mental health, and everything that keeps you running.',
+ links: [
+ { title: 'University Health Services (UHS)', description: 'Doctor visits, immunizations, and pharmacy on campus.', url: 'https://www.cmu.edu/health-services/' },
+ { title: 'CaPS (Counseling & Psychological Services)', description: 'Free, confidential counseling for students.', url: 'https://www.cmu.edu/counseling/' },
+ { title: 'TimelyCare', description: '24/7 virtual medical and mental health care, free for students.', url: 'https://www.cmu.edu/wellbeing/resources/timely-care.html' },
+ { title: 'Cohon Fitness & GroupX', description: 'Gym, pool, climbing wall, and free group fitness classes.', url: 'https://athletics.cmu.edu/athletics/fitness/index' },
+ { title: 'Student Wellbeing', description: 'Wellness programs, mindfulness room, and self-care resources.', url: 'https://www.cmu.edu/wellbeing/' },
+ { title: 'Disability Resources', description: 'Academic accommodations and accessibility support.', url: 'https://www.cmu.edu/disability-resources/' },
+ { title: 'CMU Food Pantry', description: 'Free groceries for any student who needs them, no questions.', url: 'https://www.cmu.edu/student-affairs/resources/cmu-pantry/' },
+ ],
+ },
+ {
+ id: 'safety',
+ title: 'Safety & emergencies',
+ icon: 'shield-checkmark-outline',
+ blurb: 'Numbers to save in your phone tonight.',
+ links: [
+ { title: 'CMU Police (412-268-2323)', description: 'Campus emergencies — save this number, 911 works too.', url: 'https://www.cmu.edu/police/' },
+ { title: 'Safewalk & Shuttle/Escort', description: 'Free rides and walking escorts around campus at night.', url: 'https://www.cmu.edu/parking/transport/index.html' },
+ { title: 'CMU Alert', description: 'Emergency text alerts — make sure your number is registered.', url: 'https://www.cmu.edu/alert/' },
+ { title: 'Ethics & Compliance Reporting', description: 'Report concerns anonymously.', url: 'https://www.cmu.edu/hr/resources/ethics-reporting.html' },
+ ],
+ },
+ {
+ id: 'money',
+ title: 'Money & jobs',
+ icon: 'wallet-outline',
+ blurb: 'Bills, aid, and getting paid.',
+ links: [
+ { title: 'Student Financial Services', description: 'Tuition bills, payment plans, and financial aid questions.', url: 'https://www.cmu.edu/sfs/' },
+ { title: 'Handshake', description: 'Campus jobs, internships, and new-grad roles.', url: 'https://cmu.joinhandshake.com' },
+ { title: 'Career & Professional Development Center', description: 'Resume reviews, mock interviews, and career fairs.', url: 'https://www.cmu.edu/career/' },
+ { title: 'Emergency Support Funding', description: 'One-time help when something unexpected hits your wallet.', url: 'https://www.cmu.edu/student-affairs/dean/loans/' },
+ ],
+ },
+ {
+ id: 'transport',
+ title: 'Getting around',
+ icon: 'bus-outline',
+ blurb: 'Your ID is a bus pass. Use it.',
+ links: [
+ { title: 'Free PRT Transit (with CMU ID)', description: 'All Pittsburgh buses and the incline — free with your card.', url: 'https://www.cmu.edu/parking/transport/prt/index.html' },
+ { title: 'PRT Trip Planner', description: 'Routes and real-time arrivals for Pittsburgh transit.', url: 'https://www.rideprt.org' },
+ { title: 'Campus Shuttles', description: 'CMU shuttle routes and schedules, including grocery runs.', url: 'https://www.cmu.edu/parking/transport/index.html' },
+ { title: 'Interactive Campus Map', description: 'Find any building — and the bathrooms inside it.', url: 'https://www.cmu.edu/visit/map-interactive.html' },
+ ],
+ },
+ {
+ id: 'involvement',
+ title: 'Clubs & getting involved',
+ icon: 'people-outline',
+ blurb: 'The part of college you will actually remember.',
+ links: [
+ { title: 'TartanConnect', description: 'Every student org, their events, and how to join.', url: 'https://tartanconnect.cmu.edu' },
+ { title: 'SLICE', description: 'Student Leadership, Involvement, and Civic Engagement — org support and the activities fair.', url: 'https://www.cmu.edu/student-affairs/slice/' },
+ { title: 'Athletics & Club Sports', description: 'Varsity schedules, intramurals, and club teams.', url: 'https://athletics.cmu.edu' },
+ { title: 'Fraternity & Sorority Life', description: 'Greek chapters and recruitment.', url: 'https://www.cmu.edu/student-affairs/slice/fraternity-sorority-life/index.html' },
+ { title: 'Center for Student Diversity & Inclusion', description: 'Identity-based communities, programs, and spaces.', url: 'https://www.cmu.edu/student-diversity/' },
+ { title: 'The Tartan', description: 'Student newspaper — read it or join it.', url: 'https://thetartan.org' },
+ ],
+ },
+ {
+ id: 'help',
+ title: 'When you don’t know who to ask',
+ icon: 'help-buoy-outline',
+ blurb: 'Stuck, overwhelmed, or facing something weird? These offices exist for exactly that.',
+ links: [
+ { title: 'Dean of Students Office', description: 'The catch-all: personal emergencies, absences, or "I don’t know who handles this."', url: 'https://www.cmu.edu/student-affairs/dean/' },
+ { title: 'Your Academic Advisor', description: 'Course planning, requirements, and academic trouble — find yours in SIO.', url: 'https://www.cmu.edu/hub/sio/' },
+ { title: 'The Word (Student Handbook)', description: 'Every policy, tradition, and rule in one place.', url: 'https://www.cmu.edu/student-affairs/theword/' },
+ { title: 'Office of International Education', description: 'Visas, OPT/CPT, and support for international students.', url: 'https://www.cmu.edu/oie/' },
+ { title: 'Graduation & Enrollment Verification', description: 'Enrollment letters for insurance, leases, and visas.', url: 'https://www.cmu.edu/hub/registrar/' },
+ { title: 'First-Gen Community', description: 'Programs and mentorship for first-generation college students.', url: 'https://www.cmu.edu/student-success/programs/fgen.html' },
+ ],
+ },
+];
+
+export default function ResourcesScreen() {
+ const { colors, fontScale } = useAppTheme();
+ const { isDesktop } = useResponsive();
+ const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]);
+ const [query, setQuery] = useState('');
+
+ const filteredSections = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return SECTIONS;
+ return SECTIONS.map((section) => ({
+ ...section,
+ links: section.links.filter(
+ (link) =>
+ link.title.toLowerCase().includes(q) ||
+ link.description.toLowerCase().includes(q) ||
+ section.title.toLowerCase().includes(q)
+ ),
+ })).filter((section) => section.links.length > 0);
+ }, [query]);
+
+ const openLink = (url: string) => {
+ if (Platform.OS === 'web' && typeof window !== 'undefined') {
+ window.open(url, '_blank', 'noopener');
+ } else {
+ Linking.openURL(url).catch(() => {});
+ }
+ };
+
+ return (
+
+ {/* Top bar */}
+
+ router.back()} style={styles.backButton}>
+
+
+
+ CMUnify
+
+
+
+
+ {/* Header */}
+
+
+ THE FRESHMAN GUIDE
+
+ Everything you need,{'\n'}one page
+
+ Every account, office, and resource a new Tartan needs — so when you
+ don't know how to do something or who to talk to, you start here.
+
+
+ {/* Search */}
+
+
+
+ {query.length > 0 && (
+ setQuery('')}>
+
+
+ )}
+
+
+
+ {/* Sections */}
+ {filteredSections.length === 0 ? (
+
+ Nothing matched “{query}”
+
+ Try a broader word — or ask the Dean of Students Office, whose whole
+ job is questions that don't fit anywhere else.
+
+
+ ) : (
+ filteredSections.map((section) => (
+
+
+
+
+
+
+ {section.title}
+ {section.blurb}
+
+
+
+ {section.links.map((link) => (
+ openLink(link.url)}
+ activeOpacity={0.7}
+ >
+
+ {link.title}
+ {link.description}
+
+
+
+ ))}
+
+
+ ))
+ )}
+
+ {/* Footer note */}
+
+ Maintained by students. Spot a broken or missing link? Tell the CMUnify
+ team and we'll fix it.
+
+
+ );
+}
+
+const createStyles = (colors: AppPalette, fontScale: number) =>
+ StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ scrollContent: {
+ paddingBottom: 48,
+ maxWidth: 960,
+ width: '100%',
+ alignSelf: 'center',
+ },
+ topBar: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingHorizontal: 20,
+ paddingVertical: 16,
+ },
+ backButton: {
+ width: 36,
+ height: 36,
+ borderRadius: 10,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ wordmark: {
+ fontSize: 18 * fontScale,
+ fontWeight: '800',
+ letterSpacing: -0.5,
+ color: colors.textPrimary,
+ },
+ wordmarkAccent: {
+ color: colors.primary,
+ },
+ header: {
+ alignItems: 'center',
+ paddingHorizontal: 20,
+ paddingTop: 16,
+ paddingBottom: 28,
+ },
+ kickerPill: {
+ paddingHorizontal: 12,
+ paddingVertical: 5,
+ borderRadius: 999,
+ backgroundColor: colors.surfaceAlt,
+ borderWidth: 1,
+ borderColor: colors.border,
+ marginBottom: 16,
+ },
+ kickerText: {
+ fontSize: 11 * fontScale,
+ fontWeight: '700',
+ letterSpacing: 1.2,
+ color: colors.primary,
+ },
+ title: {
+ fontSize: 34 * fontScale,
+ lineHeight: 39 * fontScale,
+ fontWeight: '800',
+ letterSpacing: -1,
+ textAlign: 'center',
+ color: colors.textPrimary,
+ marginBottom: 12,
+ },
+ subtitle: {
+ fontSize: 15 * fontScale,
+ lineHeight: 23 * fontScale,
+ textAlign: 'center',
+ color: colors.textSecondary,
+ maxWidth: 560,
+ marginBottom: 24,
+ },
+ searchWrap: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ width: '100%',
+ maxWidth: 560,
+ backgroundColor: colors.surface,
+ borderWidth: 1,
+ borderColor: colors.border,
+ borderRadius: 12,
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ },
+ searchInput: {
+ flex: 1,
+ fontSize: 15 * fontScale,
+ color: colors.textPrimary,
+ },
+ emptyState: {
+ alignItems: 'center',
+ padding: 40,
+ },
+ emptyTitle: {
+ fontSize: 17 * fontScale,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ marginBottom: 6,
+ },
+ emptyBody: {
+ fontSize: 14 * fontScale,
+ lineHeight: 21 * fontScale,
+ color: colors.textSecondary,
+ textAlign: 'center',
+ maxWidth: 420,
+ },
+ section: {
+ paddingHorizontal: 20,
+ marginBottom: 28,
+ },
+ sectionHeader: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ gap: 12,
+ marginBottom: 12,
+ },
+ sectionIconWrap: {
+ width: 36,
+ height: 36,
+ borderRadius: 10,
+ backgroundColor: colors.surfaceAlt,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ sectionHeaderText: {
+ flex: 1,
+ },
+ sectionTitle: {
+ fontSize: 19 * fontScale,
+ fontWeight: '800',
+ letterSpacing: -0.3,
+ color: colors.textPrimary,
+ },
+ sectionBlurb: {
+ fontSize: 13 * fontScale,
+ color: colors.textSecondary,
+ marginTop: 1,
+ },
+ linkGrid: {
+ gap: 8,
+ },
+ linkGridDesktop: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ },
+ linkCard: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ backgroundColor: colors.surface,
+ borderWidth: 1,
+ borderColor: colors.border,
+ borderRadius: 12,
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ },
+ linkCardDesktop: {
+ flexBasis: '48%',
+ flexGrow: 1,
+ },
+ linkCardText: {
+ flex: 1,
+ },
+ linkTitle: {
+ fontSize: 15 * fontScale,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ marginBottom: 2,
+ },
+ linkDescription: {
+ fontSize: 13 * fontScale,
+ lineHeight: 18 * fontScale,
+ color: colors.textSecondary,
+ },
+ footerNote: {
+ fontSize: 12 * fontScale,
+ color: colors.textTertiary,
+ textAlign: 'center',
+ paddingHorizontal: 24,
+ marginTop: 8,
+ },
+ });
diff --git a/apps/client/constants/devAccounts.ts b/apps/client/constants/devAccounts.ts
new file mode 100644
index 0000000..a8b5d22
--- /dev/null
+++ b/apps/client/constants/devAccounts.ts
@@ -0,0 +1,103 @@
+import { User } from '@/types/user';
+
+/**
+ * Dev-mode test accounts.
+ *
+ * These personas exist ONLY on the client: their ids carry a "dev-" prefix,
+ * which the data layer uses to skip every Supabase write (they are not real
+ * auth.users rows). They exist so the app can be exercised end-to-end —
+ * tabs, calendar, RSVPs, recommendations — without CMU SSO.
+ *
+ * NOTE: dev mode is gated by a password that ships in the client bundle.
+ * That is a convenience latch for testers, NOT security. Nothing behind it
+ * may ever grant access to real data.
+ */
+
+const baseSettings: User['settings'] = {
+ theme: 'system',
+ defaultHomePage: 'calendar',
+ calendarViewDays: 7,
+ colorScheme: 'default',
+ fontSize: 'medium',
+ compactView: false,
+ accessibility: {
+ highContrast: false,
+ reduceMotion: false,
+ },
+};
+
+const basePreferences: User['preferences'] = {
+ categoryInterests: [],
+ eventTypePreferences: {
+ clubEvents: true,
+ socialEvents: true,
+ },
+ defaultRSVPVisibility: 'public',
+ notificationPreferences: {
+ email: false,
+ push: false,
+ eventReminders: true,
+ newEventsInCategories: false,
+ },
+ publicProfile: false,
+};
+
+function makeAccount(overrides: Partial & { id: string; name: string }): User {
+ return {
+ email: `${overrides.id}@example.invalid`,
+ university: 'Carnegie Mellon University',
+ preferences: basePreferences,
+ settings: baseSettings,
+ savedEvents: [],
+ createdEvents: [],
+ createdAt: '2026-01-01T00:00:00.000Z',
+ lastLogin: new Date().toISOString(),
+ ...overrides,
+ };
+}
+
+export interface DevAccount {
+ user: User;
+ /** Short blurb shown in the account picker */
+ description: string;
+}
+
+export const DEV_ACCOUNTS: DevAccount[] = [
+ {
+ user: makeAccount({
+ id: 'dev-freshman',
+ name: 'Fiona Freshman',
+ email: 'dev-freshman@example.invalid',
+ }),
+ description: 'Brand-new user — no interests, no history. Cold-start experience.',
+ },
+ {
+ user: makeAccount({
+ id: 'dev-power-user',
+ name: 'Petra Poweruser',
+ email: 'dev-power-user@example.invalid',
+ preferences: {
+ ...basePreferences,
+ categoryInterests: ['Tech', 'Social', 'Food'],
+ },
+ }),
+ description: 'Explicit category interests set — exercises the ranked Home feed.',
+ },
+ {
+ user: makeAccount({
+ id: 'dev-organizer',
+ name: 'Oscar Organizer',
+ email: 'dev-organizer@example.invalid',
+ preferences: {
+ ...basePreferences,
+ categoryInterests: ['Career', 'Academic'],
+ },
+ }),
+ description: 'Club-officer persona — use Create to add events, then find them under My Events.',
+ },
+];
+
+/** True when a user id belongs to a local dev persona (never hits Supabase). */
+export function isDevUserId(id: string | undefined | null): boolean {
+ return typeof id === 'string' && id.startsWith('dev-');
+}
diff --git a/apps/client/contexts/DevModeContext.tsx b/apps/client/contexts/DevModeContext.tsx
new file mode 100644
index 0000000..4b0cdd7
--- /dev/null
+++ b/apps/client/contexts/DevModeContext.tsx
@@ -0,0 +1,113 @@
+import React, {
+ createContext,
+ useContext,
+ useState,
+ useEffect,
+ useCallback,
+ ReactNode,
+} from 'react';
+import { User } from '@/types/user';
+import { DEV_ACCOUNTS } from '@/constants/devAccounts';
+import { storage } from '@/lib/storage';
+
+/**
+ * Dev mode: a password-gated set of testing tools.
+ *
+ * The password ships in the client bundle, so this is a convenience latch —
+ * it keeps casual visitors out of test features, nothing more. Everything
+ * dev mode unlocks is client-local: mock sign-in personas, theme toggles,
+ * local data seeding/reset. It can never touch real user data.
+ */
+
+const DEV_MODE_PASSWORD = 'dev50';
+const DEV_MODE_KEY = 'universify_dev_mode';
+const DEV_USER_KEY = 'universify_dev_user';
+
+interface DevModeContextType {
+ /** True once the password has been entered on this device */
+ isDevMode: boolean;
+ /** The currently signed-in test persona, if any */
+ devUser: User | null;
+ /** True until stored dev state has been loaded */
+ isHydrating: boolean;
+ enableDevMode: (password: string) => boolean;
+ disableDevMode: () => void;
+ signInAsDevUser: (accountId: string) => void;
+ signOutDevUser: () => void;
+}
+
+const DevModeContext = createContext(undefined);
+
+export const DevModeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
+ const [isDevMode, setIsDevMode] = useState(false);
+ const [devUser, setDevUser] = useState(null);
+ const [isHydrating, setIsHydrating] = useState(true);
+
+ useEffect(() => {
+ (async () => {
+ try {
+ const [mode, userId] = await Promise.all([
+ storage.getItem(DEV_MODE_KEY),
+ storage.getItem(DEV_USER_KEY),
+ ]);
+ if (mode === '1') {
+ setIsDevMode(true);
+ if (userId) {
+ const account = DEV_ACCOUNTS.find((a) => a.user.id === userId);
+ if (account) setDevUser(account.user);
+ }
+ }
+ } catch {
+ // Fresh state on storage failure
+ } finally {
+ setIsHydrating(false);
+ }
+ })();
+ }, []);
+
+ const enableDevMode = useCallback((password: string): boolean => {
+ if (password !== DEV_MODE_PASSWORD) return false;
+ setIsDevMode(true);
+ storage.setItem(DEV_MODE_KEY, '1').catch(() => {});
+ return true;
+ }, []);
+
+ const disableDevMode = useCallback(() => {
+ setIsDevMode(false);
+ setDevUser(null);
+ storage.removeItem(DEV_MODE_KEY).catch(() => {});
+ storage.removeItem(DEV_USER_KEY).catch(() => {});
+ }, []);
+
+ const signInAsDevUser = useCallback((accountId: string) => {
+ const account = DEV_ACCOUNTS.find((a) => a.user.id === accountId);
+ if (!account) return;
+ setDevUser(account.user);
+ storage.setItem(DEV_USER_KEY, accountId).catch(() => {});
+ }, []);
+
+ const signOutDevUser = useCallback(() => {
+ setDevUser(null);
+ storage.removeItem(DEV_USER_KEY).catch(() => {});
+ }, []);
+
+ const value: DevModeContextType = {
+ isDevMode,
+ devUser,
+ isHydrating,
+ enableDevMode,
+ disableDevMode,
+ signInAsDevUser,
+ signOutDevUser,
+ };
+
+ return {children};
+};
+
+export const useDevMode = (): DevModeContextType => {
+ const context = useContext(DevModeContext);
+ if (context === undefined) {
+ throw new Error('useDevMode must be used within a DevModeProvider');
+ }
+ return context;
+};
diff --git a/apps/client/contexts/EventsContext.tsx b/apps/client/contexts/EventsContext.tsx
index 5b3ede5..05e83f6 100644
--- a/apps/client/contexts/EventsContext.tsx
+++ b/apps/client/contexts/EventsContext.tsx
@@ -11,6 +11,7 @@ import {
} from '@/lib/api';
import { useAuth } from '@/contexts/AuthContext';
import { dedupeAgainst } from '@/utils/dedupe';
+import { isDevUserId } from '@/constants/devAccounts';
import allEventsData from '@/data/allEvents.json';
interface EventsContextType {
@@ -62,6 +63,39 @@ export const EventsProvider: React.FC<{ children: ReactNode }> = ({ children })
const createEvent = async (eventData: EventFormData, userId: string): Promise => {
const organizerName = 'Current User';
+
+ // Dev-mode personas create events in local state only (they are not
+ // real auth.users rows, so a Supabase insert would be rejected anyway)
+ if (isDevUserId(userId)) {
+ const now = new Date().toISOString();
+ const localEvent: Event = {
+ id: `dev-evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ title: eventData.title,
+ description: eventData.description,
+ startTime: new Date(`${eventData.startDate}T${eventData.startTime}:00`).toISOString(),
+ endTime: new Date(`${eventData.endDate}T${eventData.endTime}:00`).toISOString(),
+ location: eventData.location,
+ categories: eventData.categories,
+ organizer: { id: userId, name: organizerName, type: eventData.isClubEvent ? 'club' : 'individual' },
+ color: eventData.color,
+ rsvpEnabled: eventData.rsvpEnabled,
+ rsvpCounts: { going: 0, maybe: 0, notGoing: 0 },
+ attendees: [],
+ attendeeVisibility: eventData.attendeeVisibility,
+ isClubEvent: eventData.isClubEvent,
+ isSocialEvent: eventData.isSocialEvent,
+ capacity: eventData.capacity,
+ recurring: eventData.recurring,
+ tags: eventData.tags,
+ createdAt: now,
+ updatedAt: now,
+ imageUrl: eventData.imageUrl,
+ };
+ setEvents((prev) => [...prev, localEvent]);
+ addCreatedEvent(localEvent.id);
+ return localEvent;
+ }
+
const newEvent = await createEventAPI(eventData, userId, organizerName);
setEvents((prev) => [...prev, newEvent]);
addCreatedEvent(newEvent.id);
@@ -119,6 +153,9 @@ export const EventsProvider: React.FC<{ children: ReactNode }> = ({ children })
)
);
+ // Dev-mode personas keep RSVPs in local state only
+ if (isDevUserId(userId)) return;
+
try {
// Write the user's own RSVP row; a DB trigger recomputes the aggregates
await setRSVPAPI(eventId, userId, status);
diff --git a/apps/client/hooks/useAuth.ts b/apps/client/hooks/useAuth.ts
index 90952ec..7b8fa29 100644
--- a/apps/client/hooks/useAuth.ts
+++ b/apps/client/hooks/useAuth.ts
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { User, AuthCredentials, SignupData } from '@/types/user';
import { EventCategory } from '@/types/event';
import { useGoogleAuth } from '@/contexts/GoogleAuthContext';
+import { useDevMode } from '@/contexts/DevModeContext';
import { fetchUserProfile, upsertUserProfile, updateUserProfile, updateUserProfilePreferences } from '@/lib/userProfilesApi';
import { fetchCreatedEventIds } from '@/lib/api';
@@ -76,6 +77,11 @@ export const useAuth = () => {
error: googleError,
clearError: clearGoogleError,
} = useGoogleAuth();
+ // Dev mode can substitute a local test persona for real CMU SSO. Dev
+ // users never reach Supabase (their ids carry a "dev-" prefix that the
+ // data layer checks before every write).
+ const { devUser, signOutDevUser, isHydrating: isDevHydrating } = useDevMode();
+ const [devUserOverrides, setDevUserOverrides] = useState>({});
useEffect(() => {
if (!isGoogleAuthenticated || !googleSession?.user) {
@@ -132,19 +138,38 @@ export const useAuth = () => {
}, [isGoogleAuthenticated, googleSession]);
const logout = useCallback(async () => {
+ if (devUser) {
+ signOutDevUser();
+ setDevUserOverrides({});
+ return;
+ }
await googleSignOut();
setCurrentUser(null);
- }, [googleSignOut]);
+ }, [googleSignOut, devUser, signOutDevUser]);
const addCreatedEvent = useCallback((eventId: string) => {
+ if (devUser) {
+ setDevUserOverrides((prev) => {
+ const created = prev.createdEvents ?? devUser.createdEvents;
+ return created.includes(eventId)
+ ? prev
+ : { ...prev, createdEvents: [...created, eventId] };
+ });
+ return;
+ }
setCurrentUser((prev) =>
prev && !prev.createdEvents.includes(eventId)
? { ...prev, createdEvents: [...prev.createdEvents, eventId] }
: prev
);
- }, []);
+ }, [devUser]);
const updateUser = useCallback(async (updates: Partial) => {
+ if (devUser) {
+ // Dev personas live in memory only — never write to Supabase
+ setDevUserOverrides((prev) => ({ ...prev, ...updates }));
+ return;
+ }
if (!currentUser) return;
setCurrentUser((prev) => (prev ? { ...prev, ...updates } : null));
try {
@@ -164,12 +189,14 @@ export const useAuth = () => {
} catch (err) {
console.error('Failed to update user profile:', err);
}
- }, [currentUser]);
+ }, [currentUser, devUser]);
+
+ const effectiveDevUser: User | null = devUser ? { ...devUser, ...devUserOverrides } : null;
return {
- currentUser,
- isAuthenticated: isGoogleAuthenticated,
- isLoading: isGoogleLoading,
+ currentUser: effectiveDevUser ?? currentUser,
+ isAuthenticated: Boolean(effectiveDevUser) || isGoogleAuthenticated,
+ isLoading: isGoogleLoading || isDevHydrating,
error: googleError,
login: async (_credentials?: AuthCredentials) => {
await googleSignIn();
diff --git a/apps/client/hooks/useScheduledEvents.ts b/apps/client/hooks/useScheduledEvents.ts
index 8d95bab..5202326 100644
--- a/apps/client/hooks/useScheduledEvents.ts
+++ b/apps/client/hooks/useScheduledEvents.ts
@@ -12,8 +12,12 @@ import {
unscheduleEventInSupabase,
getAllScheduledEventIdsFromSupabase,
} from '@/lib/scheduledEventsApi';
+import { isDevUserId } from '@/constants/devAccounts';
-export function useScheduledEvents(userId: string | undefined, weekKey: string) {
+export function useScheduledEvents(rawUserId: string | undefined, weekKey: string) {
+ // Dev-mode personas are not real auth.users rows — route them through the
+ // local-storage path exactly like an unauthenticated visitor.
+ const userId = isDevUserId(rawUserId) ? undefined : rawUserId;
const [scheduledEventIds, setScheduledEventIds] = useState([]);
const [allScheduledIds, setAllScheduledIds] = useState([]);
const [isLoading, setIsLoading] = useState(true);
diff --git a/apps/client/utils/devTools.ts b/apps/client/utils/devTools.ts
new file mode 100644
index 0000000..116eb35
--- /dev/null
+++ b/apps/client/utils/devTools.ts
@@ -0,0 +1,72 @@
+import { Event, EventCategory } from '@/types/event';
+
+/**
+ * Dev-mode helpers: generate realistic test events pinned to the CURRENT
+ * week (the bundled mock data has fixed dates that drift into the past),
+ * so the calendar and feed always have something fresh to show.
+ */
+
+const SAMPLE_TITLES: { title: string; categories: EventCategory[]; location: string }[] = [
+ { title: 'Poker Night @ Wiegand', categories: ['Social', 'Fun'], location: 'Wiegand Gym Lounge' },
+ { title: 'Intro to Systems Study Session', categories: ['Academic', 'Tech'], location: 'Gates 4401' },
+ { title: 'ScottyLabs Demo Day', categories: ['Tech', 'Networking'], location: 'Rangos Ballroom' },
+ { title: 'Late Night Pancakes', categories: ['Food', 'Social'], location: 'Cohon Center Kitchen' },
+ { title: 'Climbing Wall Open Hours', categories: ['Sports', 'Wellness'], location: 'Cohon Fitness Center' },
+ { title: 'Resume Review Drop-in', categories: ['Career'], location: 'CPDC Office' },
+ { title: 'A Cappella Showcase', categories: ['Arts'], location: 'McConomy Auditorium' },
+ { title: 'Board Game Cafe', categories: ['Fun', 'Social'], location: 'Danforth Lounge' },
+ { title: 'Startup Pitch Practice', categories: ['Career', 'Networking'], location: 'Swartz Center' },
+ { title: 'Trivia Night', categories: ['Fun', 'Social'], location: 'Schatz Dining' },
+];
+
+const COLORS = ['#E11D48', '#8B5CF6', '#0EA5E9', '#059669', '#D97706', '#DB2777'];
+
+export const DEV_EVENT_PREFIX = 'dev-seed-';
+
+/**
+ * Generate `count` events spread over the next 7 days at plausible times.
+ * Ids carry DEV_EVENT_PREFIX so they can be cleared with one tap.
+ */
+export function generateDevEvents(count: number): Event[] {
+ const events: Event[] = [];
+ const now = new Date();
+
+ for (let i = 0; i < count; i++) {
+ const sample = SAMPLE_TITLES[i % SAMPLE_TITLES.length];
+ const dayOffset = i % 7;
+ const startHour = 10 + ((i * 3) % 11); // 10:00 .. 20:00
+
+ const start = new Date(now);
+ start.setDate(now.getDate() + dayOffset);
+ start.setHours(startHour, 0, 0, 0);
+ const end = new Date(start);
+ end.setHours(startHour + 1, 30, 0, 0);
+
+ const going = ((i * 17) % 80) + 5;
+ const iso = new Date().toISOString();
+
+ events.push({
+ id: `${DEV_EVENT_PREFIX}${i}-${start.toISOString().slice(0, 10)}`,
+ title: sample.title,
+ description: `Test event seeded by dev mode. ${sample.title} — come hang out!`,
+ startTime: start.toISOString(),
+ endTime: end.toISOString(),
+ location: sample.location,
+ categories: sample.categories,
+ organizer: { id: 'dev-organizer', name: 'Dev Mode', type: 'club' },
+ color: COLORS[i % COLORS.length],
+ rsvpEnabled: true,
+ rsvpCounts: { going, maybe: (i * 5) % 20, notGoing: (i * 3) % 10 },
+ attendees: [],
+ attendeeVisibility: 'public',
+ isClubEvent: i % 2 === 0,
+ isSocialEvent: i % 2 === 1,
+ capacity: i % 3 === 0 ? going + 20 : undefined,
+ tags: ['dev-seed'],
+ createdAt: iso,
+ updatedAt: iso,
+ });
+ }
+
+ return events;
+}