Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions packages/alea-frontend/components/CalculateLectureProgress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { FTML } from '@flexiformal/ftml';
import { LectureEntry } from '@alea/utils';
import { SecInfo } from '../types';
import dayjs from 'dayjs';

export function calculateLectureProgress(
entries: LectureEntry[],
secInfo: Record<FTML.DocumentUri, SecInfo>
) {
let totalLatestMinutes = 0;
let count = 0;
let lastFilledSectionUri: string | null = null;

for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i].sectionUri) {
lastFilledSectionUri = entries[i].sectionUri;
break;
}
}

for (const entry of entries) {
if (!entry.sectionUri) continue;

const section = secInfo[entry.sectionUri];
if (!section || section.latestDuration == null || section.averagePastDuration == null) continue;

const latestMins = Math.ceil(section.latestDuration / 60);
totalLatestMinutes += latestMins;
count++;

if (entry.sectionUri === lastFilledSectionUri) break;
}

if (lastFilledSectionUri && count > 0) {
const lastEntry = entries.find((e) => e.sectionUri === lastFilledSectionUri);
const targetUri = lastEntry?.targetSectionUri;
if (!targetUri) return 'Progress unknown';

const allSectionsOrdered = Object.values(secInfo).sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0)
);

const fromIdx = allSectionsOrdered.findIndex((s) => s.uri === lastFilledSectionUri);
const toIdx = allSectionsOrdered.findIndex((s) => s.uri === targetUri);
if (fromIdx === -1 || toIdx === -1) return 'Progress unknown';

const isBefore = fromIdx < toIdx;
const [start, end] = isBefore ? [fromIdx, toIdx] : [toIdx, fromIdx];
const inBetweenSections = allSectionsOrdered.slice(start + 1, end + 1);

const totalAvgInBetween = inBetweenSections.reduce((sum, sec) => {
return sum + (sec.averagePastDuration != null ? Math.ceil(sec.averagePastDuration / 60) : 0);
}, 0);

const adjustedExpected = totalAvgInBetween;
const lastSectionAvg = secInfo[lastFilledSectionUri]?.averagePastDuration ?? null;
const targetSectionAvg = secInfo[targetUri]?.averagePastDuration ?? null;

if (adjustedExpected === 0 || lastSectionAvg == null || targetSectionAvg == null) {
// Fall through to section-based logic
} else {
if (adjustedExpected === 0) return 'On track';
if (isBefore) return `behind by ${adjustedExpected} min`;
else return `ahead by ${Math.abs(adjustedExpected)} min`;
}
}

// SECTION-BASED FALLBACK
const sectionToIndex = new Map(Object.values(secInfo).map((s, i) => [s.uri, i]));
// This is not post order. I think its simply pre-order. I just added this to get rid of compil errors.
const targetSectionsWithIndices = entries
.map((entry) => {
const index = sectionToIndex.get(entry.targetSectionUri);
return index !== undefined ? { targetSectionName: entry.targetSectionUri, index } : null;
})
.filter(Boolean) as Array<{ targetSectionName: string; index: number }>;

let lastFilledSectionEntry: LectureEntry | null = null;
for (const entry of entries) {
if (entry.sectionUri) {
lastFilledSectionEntry = entry;
}
}

const lastFilledSectionIdx = sectionToIndex.get(lastFilledSectionEntry?.sectionUri ?? '') ?? -1;
const lastEligibleTargetSectionIdx =
sectionToIndex.get(lastFilledSectionEntry?.targetSectionUri ?? '') ?? -1;

let progressStatus = '';
if (lastEligibleTargetSectionIdx !== -1 && lastFilledSectionIdx !== -1) {
let progressCovered = 0;
let totalTarget = 0;

for (const s of targetSectionsWithIndices) {
if (s.index <= lastFilledSectionIdx) progressCovered++;
if (s.index <= lastEligibleTargetSectionIdx) totalTarget++;
}

const isLastSectionInTargets = targetSectionsWithIndices.some(
(s) => s.index === lastFilledSectionIdx
);
if (!isLastSectionInTargets) {
progressCovered += 0.5;
}

const difference = progressCovered - totalTarget;
const absDiff = Math.abs(difference);
const roundedBottom = Math.floor(absDiff);
const roundedUp = Math.ceil(absDiff);
const fractionalPart = absDiff - roundedBottom;

if (absDiff === 0) {
progressStatus = 'On track';
} else if (absDiff < 1) {
progressStatus = difference > 0 ? 'slightly ahead' : 'slightly behind';
} else if (fractionalPart < 0.9 && fractionalPart > 0) {
const lecturesCount = difference > 0 ? roundedBottom : roundedUp;
progressStatus = `Over ${lecturesCount} lecture${lecturesCount !== 1 ? 's' : ''} ${
difference > 0 ? 'ahead' : 'behind'
}`;
} else {
progressStatus = `${Math.round(absDiff)} lectures ${difference > 0 ? 'ahead' : 'behind'}`;
}
}

return progressStatus || 'Progress unknown';
}

export function isTargetSectionUsed(entries: LectureEntry[]): boolean {
return entries.some((entry) => entry.targetSectionUri);
}

export function countMissingTargetsInFuture(entries: LectureEntry[]): number {
const now = dayjs();
return entries.filter(
(entry) => dayjs(entry.timestamp_ms).isAfter(now, 'day') && !entry.targetSectionUri
).length;
}

export const getProgressStatusColor = (status: string) => {
if (status.includes('ahead')) return 'success.main';
if (status.includes('behind')) return 'error.main';
if (status.includes('on track')) return 'success.main';
return 'info.main';
};
39 changes: 25 additions & 14 deletions packages/alea-frontend/components/CoverageForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
MenuItem,
Select,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import { LectureEntry } from '@alea/utils';
Expand All @@ -25,6 +26,7 @@ import React, { Dispatch, SetStateAction, useEffect, useRef, useState } from 're
import { SecInfo } from '../types';
import { isLeafSectionId, SlidePicker } from './SlideSelector';
import { getSlides } from '@alea/spec';
import { InfoOutlined } from '@mui/icons-material';

export type FormData = LectureEntry & {
sectionName: string;
Expand Down Expand Up @@ -298,24 +300,33 @@ export function CoverageForm({
<em>None</em>
</MenuItem>
{Object.values(secInfo).map((section) => {
const duration = section.duration || 0;
const roundedMinutes = Math.ceil(duration / 60);

let displayTime = '';
if (roundedMinutes >= 60) {
const hours = Math.floor(roundedMinutes / 60);
const minutes = roundedMinutes % 60;
displayTime = ` (${hours} hr${hours > 1 ? 's' : ''}${
minutes ? ` ${minutes} min${minutes > 1 ? 's' : ''}` : ''
})`;
} else if (roundedMinutes > 0) {
displayTime = ` (${roundedMinutes} min${roundedMinutes > 1 ? 's' : ''})`;
const latest = section.latestDuration ? Math.ceil(section.latestDuration / 60) : null;
const avgPast = section.averagePastDuration
? Math.ceil(section.averagePastDuration / 60)
: null;

let displayDurations = '';
if (latest != null && avgPast != null) {
displayDurations = `Latest: ${latest} min, Avg Past: ${avgPast} min`;
}

return (
<MenuItem key={section.uri} value={section.uri}>
{section.title}
{displayTime}
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<span>{section.title}</span>
{displayDurations && (
<Tooltip title={displayDurations} placement="right">
<InfoOutlined
sx={{
ml: 1,
fontSize: 16,
color: 'text.secondary',
cursor: 'help',
}}
/>
</Tooltip>
)}
</Box>
</MenuItem>
);
})}
Expand Down
84 changes: 6 additions & 78 deletions packages/alea-frontend/components/CoverageTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ import { AutoDetectedTooltipContent } from './AutoDetectedComponent';
import { getSectionNameForUri } from './CoverageUpdater';
import QuizHandler from './QuizHandler';
import { getSectionHierarchy, getSlideTitle } from './SlideSelector';
import {
calculateLectureProgress,
countMissingTargetsInFuture,
getProgressStatusColor,
isTargetSectionUsed,
} from './CalculateLectureProgress';

interface QuizMatchMap {
[timestamp_ms: number]: QuizWithStatus | null;
Expand Down Expand Up @@ -373,84 +379,6 @@ function CoverageRow({
);
}

export function calculateLectureProgress(
entries: LectureEntry[],
secInfo: Record<FTML.DocumentUri, SecInfo>
) {
const sectionToIndex = new Map(Object.values(secInfo).map((s, i) => [s.uri, i]));
// This is not post order. I think its simply pre-order. I just added this to get rid of compil errors.
const targetSectionsWithIndices = entries
.map((entry) => {
const index = sectionToIndex.get(entry.targetSectionUri);
return index !== undefined ? { targetSectionName: entry.targetSectionUri, index } : null;
})
.filter(Boolean) as Array<{ targetSectionName: string; index: number }>;
let lastFilledSectionEntry: LectureEntry | null = null;
for (const entry of entries) {
if (entry.sectionUri) {
lastFilledSectionEntry = entry;
}
}
const lastFilledSectionIdx = sectionToIndex.get(lastFilledSectionEntry?.sectionUri) ?? -1;

const lastEligibleTargetSectionIdx =
sectionToIndex.get(lastFilledSectionEntry?.targetSectionUri) ?? -1;
let progressStatus = '';
if (lastEligibleTargetSectionIdx !== -1 && lastFilledSectionIdx !== -1) {
let progressCovered = 0;
let totalTarget = 0;
for (const s of targetSectionsWithIndices) {
if (s.index <= lastFilledSectionIdx) progressCovered++;
if (s.index <= lastEligibleTargetSectionIdx) totalTarget++;
}
const isLastSectionInTargets = targetSectionsWithIndices.some(
(s) => s.index === lastFilledSectionIdx
);
if (!isLastSectionInTargets) {
progressCovered += 0.5;
}
const difference = progressCovered - totalTarget;
const absDiff = Math.abs(difference);
let description = '';
const roundedBottom = Math.floor(absDiff);
const roundedUp = Math.ceil(absDiff);

const fractionalPart = absDiff - roundedBottom;
if (absDiff === 0) {
description = 'On track';
} else if (absDiff < 1) {
description = difference > 0 ? 'slightly ahead' : 'slightly behind';
} else if (fractionalPart < 0.9 && fractionalPart > 0) {
const lecturesCount = difference > 0 ? roundedBottom : roundedUp;
description = `Over ${lecturesCount} lecture${lecturesCount !== 1 ? 's' : ''} ${
difference > 0 ? 'ahead' : 'behind'
} `;
} else {
description = ` ${Math.round(absDiff)} lectures ${difference > 0 ? 'ahead' : 'behind'}`;
}

progressStatus = description;
}
return progressStatus || 'Progress unknown';
}

function isTargetSectionUsed(entries: LectureEntry[]): boolean {
return entries.some((entry) => entry.targetSectionUri);
}

function countMissingTargetsInFuture(entries: LectureEntry[]): number {
const now = dayjs();
return entries.filter(
(entry) => dayjs(entry.timestamp_ms).isAfter(now, 'day') && !entry.targetSectionUri
).length;
}

export const getProgressStatusColor = (status: string) => {
if (status.includes('ahead')) return 'success.main';
if (status.includes('behind')) return 'error.main';
if (status.includes('on track')) return 'success.main';
return 'info.main';
};

const getProgressIcon = (status: string) => {
if (status.includes('ahead')) return '🚀';
Expand Down
2 changes: 1 addition & 1 deletion packages/alea-frontend/components/WelcomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import { BannerSection, CourseCard, VollKiInfoSection } from '../pages';
import { CourseThumb } from '../pages/u/[institution]';
import { SecInfo } from '../types';
import { getSecInfo } from './coverage-update';
import { calculateLectureProgress } from './CoverageTable';
import { calculateLectureProgress } from './CalculateLectureProgress';
import SystemAlertBanner from './SystemAlertBanner';

interface ColorInfo {
Expand Down
35 changes: 33 additions & 2 deletions packages/alea-frontend/components/coverage-update.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,41 @@ const CoverageUpdateTab = () => {

for (const uri in semDurations.sectionDurations) {
const duration = semDurations.sectionDurations[uri];
baseSecInfo[uri] = baseSecInfo[uri] || ({} as SecInfo);
baseSecInfo[uri].duration = (baseSecInfo[uri].duration || 0) + duration;
if (!baseSecInfo[uri]) continue;

if (!baseSecInfo[uri].durations) {
baseSecInfo[uri].durations = {};
}

baseSecInfo[uri].durations[semKey] = duration;
}
}

const semesterKeys = Object.keys(durationData).sort();
const latestSemester = semesterKeys[semesterKeys.length - 1];
const normalizedLatest = latestSemester.trim().toLowerCase();

for (const uri in baseSecInfo) {
const durations = baseSecInfo[uri].durations;
if (!durations) continue;

const previousSemesters = Object.entries(durations).filter(
([key]) => key.trim().toLowerCase() !== normalizedLatest
);

const average =
previousSemesters.length > 0
? previousSemesters.reduce((sum, [_, val]) => sum + val, 0) /
previousSemesters.length
: null;

const latestDuration = durations[latestSemester] ?? null;

baseSecInfo[uri].averagePastDuration = average;
baseSecInfo[uri].latestDuration = latestDuration;

const sectionTitle = baseSecInfo[uri].title || uri;
}
} catch (durationError) {
console.warn('Could not fetch durations:', durationError);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/alea-frontend/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@ export interface SecInfo {
id: string;
title: string;
uri: FTML.DocumentUri;
duration?: number;
order?: number;
durations?: Record<string, number>;
averagePastDuration?: number;
latestDuration?: number;
}