Skip to content

Commit

Permalink
Display last time grades were successfully synced (#6721)
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya authored Oct 7, 2024
1 parent b58d7f7 commit 518ebaf
Show file tree
Hide file tree
Showing 11 changed files with 390 additions and 126 deletions.
43 changes: 38 additions & 5 deletions lms/static/scripts/frontend_apps/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,13 @@ export type YouTubeVideoInfo = {
* Dashboard-related API types
*/

/**
* Represents dates exposed by the backend as string, in ISO 8601 format
*/
export type ISODateTime = string;

export type AnnotationMetrics = {
last_activity: string | null;
last_activity: ISODateTime | null;
annotations: number;
replies: number;
};
Expand All @@ -163,7 +168,7 @@ export type Course = {

export type CourseMetrics = {
assignments: number;
last_launched: string | null;
last_launched: ISODateTime | null;
};

export type CourseWithMetrics = Course & {
Expand All @@ -181,7 +186,7 @@ export type Assignment = {
id: number;
title: string;
/** Date in which the assignment was created, in ISO format */
created: string;
created: ISODateTime;
};

export type Student = {
Expand All @@ -196,7 +201,7 @@ export type AutoGradingGrade = {
/** Grade that was last synced, if any */
last_grade: number | null;
/** When did the last grade sync happen, if any */
last_grade_date: number | null;
last_grade_date: ISODateTime | null;
};

export type StudentWithMetrics = Student & {
Expand Down Expand Up @@ -302,9 +307,37 @@ export type StudentsResponse = {
pagination: Pagination;
};

export type StudentGradingSyncStatus = 'in_progress' | 'finished' | 'failed';

export type StudentGradingSync = {
h_userid: string;
status: StudentGradingSyncStatus;
};

export type GradingSyncStatus = 'scheduled' | StudentGradingSyncStatus;

/**
* Response for `/api/dashboard/assignments/{assignment_id}/grading/sync`
* That endpoint returns a 404 when an assignment has never been synced.
*/
export type GradingSync = {
status: 'scheduled' | 'in_progress' | 'finished' | 'failed';
/**
* Global sync status.
* If at least one student grade is syncing, this will be `in_progress`.
* If at least one student grade failed, this will be `failed`.
* If all student grades finished successfully, this will be `finished`.
*/
status: GradingSyncStatus;

/**
* The date and time when syncing grades finished.
* It is null as long as status is `scheduled` or `in_progress`.
*/
finish_date: ISODateTime | null;

/**
* Grading status for every individual student that was scheduled as part of
* this sync.
*/
grades: StudentGradingSync[];
};
38 changes: 38 additions & 0 deletions lms/static/scripts/frontend_apps/components/RelativeTime.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
decayingInterval,
formatRelativeDate,
formatDateTime,
} from '@hypothesis/frontend-shared';
import { useEffect, useMemo, useState } from 'preact/hooks';

export type RelativeTimeProps = {
/** The reference date-time, in ISO format */
dateTime: string;
};

/**
* Displays a date as a time relative to `now`, making sure it is updated at
* appropriate intervals
*/
export default function RelativeTime({ dateTime }: RelativeTimeProps) {
const [now, setNow] = useState(() => new Date());
const absoluteDate = useMemo(
() => formatDateTime(dateTime, { includeWeekday: true }),
[dateTime],
);
const relativeDate = useMemo(
() => formatRelativeDate(new Date(dateTime), now),
[dateTime, now],
);

// Refresh relative timestamp, at a frequency appropriate for the age.
useEffect(() => {
return decayingInterval(dateTime, () => setNow(new Date()));
}, [dateTime]);

return (
<time dateTime={dateTime} title={absoluteDate}>
{relativeDate}
</time>
);
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { ClockIcon } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import { useCallback, useMemo } from 'preact/hooks';
import { useCallback, useMemo, useState } from 'preact/hooks';
import { useLocation, useParams, useSearch } from 'wouter-preact';

import type {
AssignmentDetails,
GradingSync,
StudentsMetricsResponse,
} from '../../api-types';
import { useConfig } from '../../config';
import { useAPIFetch } from '../../utils/api';
import { useAPIFetch, usePolledAPIFetch } from '../../utils/api';
import { useDashboardFilters } from '../../utils/dashboard/hooks';
import { courseURL } from '../../utils/dashboard/navigation';
import { useDocumentTitle } from '../../utils/hooks';
import { replaceURLParams } from '../../utils/url';
import { type QueryParams, replaceURLParams } from '../../utils/url';
import RelativeTime from '../RelativeTime';
import type {
DashboardActivityFiltersProps,
SegmentsType,
Expand Down Expand Up @@ -129,27 +132,49 @@ export default function AssignmentActivity() {
grade: auto_grading_grade?.current_grade ?? 0,
}));
}, [isAutoGradingAssignment, students.data]);
const onSyncScheduled = useCallback(

const syncURL = useMemo(
() =>
students.mutate({
students: (students.data?.students ?? []).map(
({ auto_grading_grade, ...rest }) =>
!auto_grading_grade
? rest
: {
...rest,
auto_grading_grade: {
...auto_grading_grade,
// Once a sync has been scheduled, update last_grade with
// current_grade value, so that students are no longer
// labelled as "New"
last_grade: auto_grading_grade.current_grade,
},
},
),
}),
[students],
isAutoGradingAssignment
? replaceURLParams(routes.assignment_grades_sync, {
assignment_id: assignmentId,
})
: null,
[assignmentId, isAutoGradingAssignment, routes.assignment_grades_sync],
);
const [lastSyncParams, setLastSyncParams] = useState<QueryParams>({});
const lastSync = usePolledAPIFetch<GradingSync>({
path: syncURL,
params: lastSyncParams,
// Keep polling as long as sync is in progress
shouldRefresh: result =>
!!result.data &&
['scheduled', 'in_progress'].includes(result.data.status),
});

const onSyncScheduled = useCallback(() => {
// Once the request succeeds, we update the params so that polling the
// status is triggered again
setLastSyncParams({ t: `${Date.now()}` });

students.mutate({
students: (students.data?.students ?? []).map(
({ auto_grading_grade, ...rest }) =>
!auto_grading_grade
? rest
: {
...rest,
auto_grading_grade: {
...auto_grading_grade,
// Once a sync has been scheduled, update last_grade with
// current_grade value, so that students are no longer
// labelled as "New"
last_grade: auto_grading_grade.current_grade,
},
},
),
});
}, [students]);

const rows: StudentsTableRow[] = useMemo(
() =>
Expand Down Expand Up @@ -204,7 +229,7 @@ export default function AssignmentActivity() {
<div className="flex flex-col gap-y-5">
<div>
{assignment.data && (
<div className="mb-3 mt-1 w-full">
<div className="mb-3 mt-1 w-full flex items-center">
<DashboardBreadcrumbs
allCoursesLink={urlWithFilters({ studentIds }, { path: '' })}
links={[
Expand All @@ -217,6 +242,20 @@ export default function AssignmentActivity() {
},
]}
/>
{lastSync.data && (
<div
className="flex gap-x-1 items-center text-color-text-light"
data-testid="last-sync-date"
>
<ClockIcon />
Grades last synced:{' '}
{lastSync.data.finish_date ? (
<RelativeTime dateTime={lastSync.data.finish_date} />
) : (
'syncing…'
)}
</div>
)}
</div>
)}
<h2 className="text-lg text-brand font-semibold" data-testid="title">
Expand Down Expand Up @@ -265,6 +304,7 @@ export default function AssignmentActivity() {
{isAutoGradingAssignment && auto_grading_sync_enabled && (
<SyncGradesButton
studentsToSync={studentsToSync}
lastSync={lastSync}
onSyncScheduled={onSyncScheduled}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export default function DashboardBreadcrumbs({

return (
<div
className="flex flex-row gap-0.5 w-full font-semibold"
className="flex flex-row gap-0.5 grow font-semibold"
data-testid="breadcrumbs-container"
>
{linksWithHome.map(({ title, href }, index) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import {
LeaveIcon,
SpinnerCircleIcon,
} from '@hypothesis/frontend-shared';
import { useCallback, useMemo, useState } from 'preact/hooks';
import { useCallback, useMemo } from 'preact/hooks';
import { useParams } from 'wouter-preact';

import type { GradingSync } from '../../api-types';
import type { GradingSync, GradingSyncStatus } from '../../api-types';
import { useConfig } from '../../config';
import { APIError } from '../../errors';
import { apiCall, usePolledAPIFetch } from '../../utils/api';
import type { QueryParams } from '../../utils/url';
import { apiCall } from '../../utils/api';
import type { FetchResult } from '../../utils/fetch';
import { replaceURLParams } from '../../utils/url';

export type SyncGradesButtonProps = {
Expand All @@ -27,11 +27,15 @@ export type SyncGradesButtonProps = {
* properly scheduled.
*/
onSyncScheduled: () => void;

/** Result of fetching the status of last sync */
lastSync: FetchResult<GradingSync>;
};

export default function SyncGradesButton({
studentsToSync,
onSyncScheduled,
lastSync,
}: SyncGradesButtonProps) {
const { assignmentId } = useParams<{ assignmentId: string }>();
const { dashboard, api } = useConfig(['dashboard', 'api']);
Expand All @@ -44,15 +48,15 @@ export default function SyncGradesButton({
}),
[assignmentId, routes.assignment_grades_sync],
);
const [lastSyncParams, setLastSyncParams] = useState<QueryParams>({});
const lastSync = usePolledAPIFetch<GradingSync>({
path: syncURL,
params: lastSyncParams,
// Keep polling as long as sync is in progress
shouldRefresh: result =>
!!result.data &&
['scheduled', 'in_progress'].includes(result.data.status),
});
const updateSyncStatus = useCallback(
(status: GradingSyncStatus) =>
lastSync.data &&
lastSync.mutate({
...lastSync.data,
status,
}),
[lastSync],
);

const buttonContent = useMemo(() => {
if (!studentsToSync || (lastSync.isLoading && !lastSync.data)) {
Expand Down Expand Up @@ -109,7 +113,7 @@ export default function SyncGradesButton({
studentsToSync.length === 0;

const syncGrades = useCallback(async () => {
lastSync.mutate({ status: 'scheduled' });
updateSyncStatus('scheduled');

apiCall({
authToken: api.authToken,
Expand All @@ -119,14 +123,15 @@ export default function SyncGradesButton({
grades: studentsToSync,
},
})
.then(() => {
// Once the request succeeds, we update the params so that polling the
// status is triggered again
setLastSyncParams({ t: `${Date.now()}` });
onSyncScheduled();
})
.catch(() => lastSync.mutate({ status: 'failed' }));
}, [api.authToken, lastSync, onSyncScheduled, studentsToSync, syncURL]);
.then(() => onSyncScheduled())
.catch(() => updateSyncStatus('failed'));
}, [
api.authToken,
onSyncScheduled,
studentsToSync,
syncURL,
updateSyncStatus,
]);

return (
<Button variant="primary" onClick={syncGrades} disabled={buttonDisabled}>
Expand Down
Loading

0 comments on commit 518ebaf

Please sign in to comment.