Final push before system formatting

This commit is contained in:
Chris Sanden
2026-05-31 14:05:22 +02:00
commit 5ece589fbe
178 changed files with 164198 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { SessionType } from '@/lib/types';
const notificationKey = (aId: string) => `assignment_notification_${aId}`;
const setupSprintDemoKey = (userId: string) => `setup_sprint_demo_${userId}`;
const activeSprintKey = 'active_sprint';
const studyCycleKey = 'study_cycle';
export type ActiveSession = {
sessionId: string;
sessionType: SessionType;
taskId: string | null;
returnTaskId?: string | null;
durationSeconds: number;
endTime: number;
};
export type StudyCycle = {
taskId: string;
completedFocusSessions: number;
lastCompletedSessionType: SessionType;
lastCompletedAt: number;
};
export async function SaveAssignmentNotificationId(aId: string, notificationId: string) {
await AsyncStorage.setItem(notificationKey(aId), notificationId);
}
export async function GetAssignmentNotificationId(aId: string) {
return await AsyncStorage.getItem(notificationKey(aId));
}
export async function RemoveAssignmentNotificationId(aId: string) {
await AsyncStorage.removeItem(notificationKey(aId));
}
export async function SaveActiveSession(activeSession: ActiveSession) {
await AsyncStorage.setItem(activeSprintKey, JSON.stringify(activeSession));
}
export async function GetActiveSession() {
const activeSession = await AsyncStorage.getItem(activeSprintKey);
if (!activeSession) {
return null;
}
return JSON.parse(activeSession) as ActiveSession;
}
export async function RemoveActiveSession() {
await AsyncStorage.removeItem(activeSprintKey);
}
export async function SaveStudyCycle(studyCycle: StudyCycle) {
await AsyncStorage.setItem(studyCycleKey, JSON.stringify(studyCycle));
}
export async function GetStudyCycle() {
const studyCycle = await AsyncStorage.getItem(studyCycleKey);
if (!studyCycle) {
return null;
}
return JSON.parse(studyCycle) as StudyCycle;
}
export async function RemoveStudyCycle() {
await AsyncStorage.removeItem(studyCycleKey);
}
export async function GetSetupSprintDemoUsed(userId: string) {
const value = await AsyncStorage.getItem(setupSprintDemoKey(userId));
return value === 'true';
}
export async function SaveSetupSprintDemoUsed(userId: string) {
await AsyncStorage.setItem(setupSprintDemoKey(userId), 'true');
}

View File

@@ -0,0 +1,29 @@
export const formatDate = (value?: string | null) => {
if (!value) return 'No date';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
export const formatDateTime = (value?: string | null) => {
if (!value) return 'Unknown';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
};

View File

@@ -0,0 +1,39 @@
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
});
function HandleRegistrationError(errorMessage: string) {
alert(errorMessage);
throw new Error(errorMessage);
}
export async function RegisterForLocalNotificationsAsync() {
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX
});
}
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
HandleRegistrationError('Permission not granted for local notifications');
return;
}
}

View File

@@ -0,0 +1,15 @@
import { supabase } from '@/lib/supabase';
export async function CheckAssignmentCompletion(aId: string) {
const { data, error } = await supabase.from("tasks").select("tId, isCompleted").eq("aId", aId);
if (error) throw error;
const tasks = data ?? [];
const allCompleted = tasks.length > 0 && tasks.every((task) => task.isCompleted === true);
const { error: updateError } = await supabase.from("assignments").update({ isCompleted: allCompleted, lastChanged: new Date().toISOString()}).eq("aId", aId);
if (updateError) throw updateError;
}

View File

@@ -0,0 +1,5 @@
export const DEFAULT_FOCUS_DURATION_MINUTES = 25;
export const DEFAULT_SHORT_BREAK_DURATION_MINUTES = 5;
export const DEFAULT_LONG_BREAK_DURATION_MINUTES = 15;
export const FOCUS_SESSIONS_PER_LONG_BREAK = 4;
export const STUDY_CYCLE_IDLE_RESET_MINUTES = 120;

View File

@@ -0,0 +1,37 @@
import {
GetActiveSession,
RemoveActiveSession,
RemoveStudyCycle,
type ActiveSession,
} from '@/lib/asyncStorage';
import { supabase } from '@/lib/supabase';
export type FinalSessionStatus = 'completed' | 'cancelled' | 'expired';
export async function finalizeStoredSession(
finalStatus: FinalSessionStatus,
activeSessionOverride?: ActiveSession | null
) {
const activeSession = activeSessionOverride ?? await GetActiveSession();
if (!activeSession) {
return null;
}
await RemoveActiveSession();
if (finalStatus !== 'completed') {
await RemoveStudyCycle();
}
const { error } = await supabase.rpc('finalize_sprint_session', {
p_session_id: activeSession.sessionId,
p_final_status: finalStatus,
p_ended_at: new Date().toISOString(),
});
return {
activeSession,
error,
};
}

View File

@@ -0,0 +1,84 @@
import { supabase } from '@/lib/supabase';
export type SetupStepKey = 'subject' | 'assignment' | 'task' | 'sprint';
export type SetupStatus = {
subjectId: string | null;
assignmentId: string | null;
taskId: string | null;
completedFocusSessions: number;
currentStep: SetupStepKey;
isSetupComplete: boolean;
};
export async function getSetupStatus(userId: string): Promise<SetupStatus> {
const [subjectResult, assignmentResult, taskResult, focusSessionResult] = await Promise.all([
supabase
.from('subjects')
.select('sId')
.eq('uId', userId)
.order('lastChanged', { ascending: false })
.limit(1)
.maybeSingle(),
supabase
.from('assignments')
.select('aId')
.eq('uId', userId)
.order('lastChanged', { ascending: false })
.limit(1)
.maybeSingle(),
supabase
.from('tasks')
.select('tId')
.eq('uId', userId)
.order('lastChanged', { ascending: false })
.limit(1)
.maybeSingle(),
supabase
.from('sprint_sessions')
.select('sessionId', { count: 'exact', head: true })
.eq('userId', userId)
.eq('sessionType', 'focus')
.eq('status', 'completed'),
]);
if (subjectResult.error) {
throw subjectResult.error;
}
if (assignmentResult.error) {
throw assignmentResult.error;
}
if (taskResult.error) {
throw taskResult.error;
}
if (focusSessionResult.error) {
throw focusSessionResult.error;
}
const subjectId = subjectResult.data?.sId ?? null;
const assignmentId = assignmentResult.data?.aId ?? null;
const taskId = taskResult.data?.tId ?? null;
const completedFocusSessions = focusSessionResult.count ?? 0;
let currentStep: SetupStepKey = 'sprint';
if (!subjectId) {
currentStep = 'subject';
} else if (!assignmentId) {
currentStep = 'assignment';
} else if (!taskId) {
currentStep = 'task';
}
return {
subjectId,
assignmentId,
taskId,
completedFocusSessions,
currentStep,
isSetupComplete: taskId !== null && completedFocusSessions > 0,
};
}

View File

@@ -0,0 +1,58 @@
export type SubjectColor =
| 'blue'
| 'emerald'
| 'amber'
| 'violet'
| 'cyan'
| 'rose'
| 'slate';
export const SUBJECT_COLORS: Record<
SubjectColor,
{ soft: string; strong: string; label: string }
> = {
blue: {
soft: '#DCEFF5',
strong: '#2F6F88',
label: 'Blue',
},
emerald: {
soft: '#DDEFE5',
strong: '#2F7D55',
label: 'Emerald',
},
amber: {
soft: '#F6E8C6',
strong: '#9A6A16',
label: 'Amber',
},
violet: {
soft: '#E9E2F5',
strong: '#6D4BA3',
label: 'Violet',
},
cyan: {
soft: '#DDF0EF',
strong: '#287C7A',
label: 'Cyan',
},
rose: {
soft: '#F4E1DF',
strong: '#9B4A43',
label: 'Rose',
},
slate: {
soft: '#E8E4DA',
strong: '#52616B',
label: 'Slate',
},
};
export const SUBJECT_COLOR_KEYS = Object.keys(
SUBJECT_COLORS
) as SubjectColor[];
export const getSubjectColorSet = (color?: SubjectColor) => {
const colorKey: SubjectColor = color ?? 'slate';
return SUBJECT_COLORS[colorKey];
};

View File

@@ -0,0 +1,36 @@
import { createClient } from '@supabase/supabase-js';
import * as SecureStore from 'expo-secure-store';
import 'react-native-url-polyfill/auto';
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!
const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
if (!supabaseUrl) {
throw new Error('Missing EXPO_PUBLIC_SUPABASE_URL');
}
if (!supabaseKey) {
throw new Error('Missing EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY');
}
const SecureStoreAdapter = {
getItem: async (key: string) => {
return await SecureStore.getItemAsync(key);
},
setItem: async (key: string, value: string) => {
await SecureStore.setItemAsync(key, value);
},
removeItem: async (key: string) => {
await SecureStore.deleteItemAsync(key);
},
};
export const supabase = createClient(supabaseUrl, supabaseKey, {
auth: {
storage: SecureStoreAdapter,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
})

View File

@@ -0,0 +1,35 @@
import type { SubjectColor } from '@/lib/subjectColors';
export type SessionType = 'focus' | 'short_break' | 'long_break';
export type Task = {
tId: string;
title: string;
description: string;
isCompleted: boolean;
lastChanged: string;
uId: string;
aId: string;
totalTimeInSeconds: number;
};
export type Assignment = {
aId: string;
title: string;
description: string;
deadline: string;
isCompleted: boolean;
lastChanged: string;
uId: string;
sId: string;
};
export type Subject = {
sId: string;
title: string;
description: string;
isActive: boolean;
lastChanged: string;
uId: string;
color?: SubjectColor;
};