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
15 changes: 15 additions & 0 deletions app/helpers/application_formatters_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,19 @@ def format_duration(total_seconds)
hours = total_seconds / (60 * 60)
format('%<hours>02dH%<minutes>02dM%<seconds>02dS', hours: hours, minutes: minutes, seconds: seconds)
end

# Formats rich text fields for CSV export by stripping HTML tags and decoding HTML entities.
# Rich text fields are saved as HTML in the database (from WYSIWYG editors), so this helper
# converts them to plain text suitable for CSV files by removing HTML markup and decoding
# entities like &nbsp;, &amp;, etc.
#
# @param [String] text The rich text (HTML) to format
# @param [Boolean] preserve_newlines Whether to preserve paragraph/line breaks (default: true)
# @return [String] Plain text with HTML tags removed and entities decoded
def format_rich_text_for_csv(text)
return '' unless text

cleaned_text = text.gsub('</p>', "</p>\n").gsub('<br />', "<br />\n")
HTMLEntities.new.decode(ActionController::Base.helpers.strip_tags(cleaned_text)).strip
end
end
14 changes: 0 additions & 14 deletions app/helpers/course/statistics/aggregate_helper.rb

This file was deleted.

3 changes: 1 addition & 2 deletions app/models/course/assessment/answer/forum_post_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,7 @@ def stripped_answer_to_array
def readable_string_of(text)
return nil unless text

cleaned_text = text.gsub('</p>', "</p>\n").gsub('<br />', "<br />\n").squish
ActionController::Base.helpers.strip_tags(cleaned_text)
ApplicationController.helpers.format_rich_text_for_csv(text).squish
end

def destroy_previous_selection
Expand Down
2 changes: 1 addition & 1 deletion app/models/course/assessment/answer/multiple_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,6 @@ def compare_answer(other_answer)
end

def csv_download
ActionController::Base.helpers.strip_tags(options.map(&:option).join(';'))
ApplicationController.helpers.format_rich_text_for_csv(options.map(&:option).join(';'))
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def grade_inline?
end

def csv_download
ActionController::Base.helpers.strip_tags(answer_text)
ApplicationController.helpers.format_rich_text_for_csv(answer_text)
end

def compare_answer(other_answer)
Expand Down
2 changes: 1 addition & 1 deletion app/models/course/assessment/answer/text_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def download(dir)
end

def csv_download
ActionController::Base.helpers.strip_tags(answer_text)
ApplicationController.helpers.format_rich_text_for_csv(answer_text)
end

def download_answer(dir)
Expand Down
3 changes: 2 additions & 1 deletion app/services/course/survey/survey_download_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

class Course::Survey::SurveyDownloadService
include TmpCleanupHelper
include ApplicationFormattersHelper

def initialize(survey)
@survey = survey
Expand Down Expand Up @@ -57,7 +58,7 @@ def generate_header(questions)
I18n.t('course.surveys.survey_download_service.course_user_id'),
I18n.t('course.surveys.survey_download_service.name'),
I18n.t('course.surveys.survey_download_service.role')
] + questions.map(&:description)
] + questions.map { |q| format_rich_text_for_csv(q.description) }
end

def generate_row(response, questions)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ json.assessments @assessments do |assessment|
json.averageGrade grade_stats ? grade_stats[0] : 0
json.stdevGrade grade_stats ? grade_stats[1] : 0

json.averageTimeTaken seconds_to_str(duration_stats[0])
json.stdevTimeTaken seconds_to_str(duration_stats[1])
json.averageTimeTaken duration_stats[0]
json.stdevTimeTaken duration_stats[1]

json.numSubmitted @num_submitted_students_hash[assessment.id] || 0
json.numAttempted @num_attempted_students_hash[assessment.id] || 0
Expand Down
4 changes: 2 additions & 2 deletions app/views/course/statistics/aggregate/all_staff.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ json.staff graded_staff do |staff|
json.name staff.name
json.numGraded staff.published_submissions.size
json.numStudents staff.my_students.count
json.averageMarkingTime seconds_to_str(staff.average_marking_time)
json.stddev seconds_to_str(staff.marking_time_stddev)
json.averageMarkingTime staff.average_marking_time
json.stddev staff.marking_time_stddev
end
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from 'course/assessment/submission/components/GetHelpChatPage/utils';
import MarkdownText from 'course/assessment/submission/components/MarkdownText';
import useTranslation from 'lib/hooks/useTranslation';
import moment, { SHORT_DATE_TIME_FORMAT } from 'lib/moment';
import { formatShortDateTime } from 'lib/moment';

interface Props {
messages: LiveFeedbackChatMessage[];
Expand Down Expand Up @@ -110,9 +110,7 @@ const LiveFeedbackMessageHistory: FC<Props> = (props) => {
const message = messages[messageIndex];
const isStudent = message.creatorId !== 0;
const isError = message.isError;
const createdAt = moment(new Date(message.createdAt)).format(
SHORT_DATE_TIME_FORMAT,
);
const createdAt = formatShortDateTime(message.createdAt);

return (
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from '@reduxjs/toolkit';
import shuffle from 'lodash-es/shuffle';

import moment, { SHORT_TIME_FORMAT } from 'lib/moment';
import moment, { formatShortTime } from 'lib/moment';

import {
getLocalStorageValue,
Expand Down Expand Up @@ -138,9 +138,7 @@ export const liveFeedbackChatSlice = createSlice({
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
)
.map((message) => {
const createdAt = moment(new Date(message.createdAt)).format(
SHORT_TIME_FORMAT,
);
const createdAt = formatShortTime(new Date(message.createdAt));
return {
sender:
message.creatorId === 0
Expand Down Expand Up @@ -267,7 +265,7 @@ export const liveFeedbackChatSlice = createSlice({
const { answerId, message } = action.payload;
const liveFeedbackChats =
state.liveFeedbackChatPerAnswer.entities[answerId];
const currentTime = moment(new Date()).format(SHORT_TIME_FORMAT);
const currentTime = formatShortTime(new Date());

if (liveFeedbackChats) {
const changes: Partial<LiveFeedbackChatData> = {
Expand Down Expand Up @@ -342,7 +340,7 @@ export const liveFeedbackChatSlice = createSlice({
{
sender: ChatSender.codaveri,
message: overallContent,
createdAt: moment(new Date()).format(SHORT_TIME_FORMAT),
createdAt: formatShortTime(new Date()),
isError: false,
},
]
Expand Down Expand Up @@ -384,7 +382,7 @@ export const liveFeedbackChatSlice = createSlice({
const newChat: ChatShape = {
sender: ChatSender.codaveri,
message: errorMessage,
createdAt: moment(new Date()).format(SHORT_TIME_FORMAT),
createdAt: formatShortTime(new Date()),
isError: true,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { duplicationModes } from 'course/duplication/constants';
import { duplicateCourse } from 'course/duplication/operations';
import { courseShape, sourceCourseShape } from 'course/duplication/propTypes';
import { actions } from 'course/duplication/store';
import moment, { SHORT_DATE_TIME_FORMAT } from 'lib/moment';
import moment, { formatShortDateTime } from 'lib/moment';

import NewCourseForm from './NewCourseForm';

Expand Down Expand Up @@ -85,7 +85,7 @@ class DestinationCourseSelector extends Component {
date: tomorrow.date(),
});

const timeNow = moment().format(SHORT_DATE_TIME_FORMAT);
const timeNow = formatShortDateTime(new Date());
const newTitleValues = { title: sourceCourse.title, timestamp: timeNow };
const initialValues = {
destination_instance_id: currentInstanceId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Avatar, Chip } from '@mui/material';
import { red } from '@mui/material/colors';
import PropTypes from 'prop-types';

import moment, { SHORT_DATE_TIME_FORMAT, SHORT_TIME_FORMAT } from 'lib/moment';
import moment, { formatShortDateTime, formatShortTime } from 'lib/moment';

const translations = defineMessages({
notPublished: {
Expand Down Expand Up @@ -58,17 +58,13 @@ export const formatDateRange = (startAt, endAt) => {

const end = moment(endAt);
if (!end.isValid()) {
return start.format(SHORT_DATE_TIME_FORMAT);
return formatShortDateTime(start);
}

if (end.isSame(start, 'day')) {
return `${start.format(SHORT_DATE_TIME_FORMAT)} - ${end.format(
SHORT_TIME_FORMAT,
)}`;
return `${formatShortDateTime(start)} - ${formatShortTime(end)}`;
}
return `${start.format(SHORT_DATE_TIME_FORMAT)} - ${end.format(
SHORT_DATE_TIME_FORMAT,
)}`;
return `${formatShortDateTime(start)} - ${formatShortDateTime(end)}`;
};

class Chips extends Component {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from 'lib/helpers/url-builders';
import { getCourseId } from 'lib/helpers/url-helpers';
import useTranslation from 'lib/hooks/useTranslation';
import { formatMiniDateTime } from 'lib/moment';
import { formatMiniDateTime, formatSecondsDuration } from 'lib/moment';

import AssessmentsScoreSummaryDownload from './AssessmentsScoreSummaryDownload';

Expand Down Expand Up @@ -226,14 +226,14 @@ const AssessmentsStatisticsTable: FC<Props> = (props) => {
of: 'averageTimeTaken',
title: t(translations.averageTimeTaken),
sortable: true,
cell: (assessment) => assessment.averageTimeTaken,
cell: (assessment) => formatSecondsDuration(assessment.averageTimeTaken),
csvDownloadable: true,
},
{
of: 'stdevTimeTaken',
title: t(translations.stdevTimeTaken),
sortable: true,
cell: (assessment) => assessment.stdevTimeTaken,
cell: (assessment) => formatSecondsDuration(assessment.stdevTimeTaken),
csvDownloadable: true,
},
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { processStaff } from 'course/statistics/utils/parseStaffResponse';
import Table, { ColumnTemplate } from 'lib/components/table';
import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants';
import useTranslation from 'lib/hooks/useTranslation';
import { formatSecondsDuration } from 'lib/moment';

const translations = defineMessages({
name: {
Expand Down Expand Up @@ -82,14 +83,14 @@ const StaffStatisticsTable: FC<Props> = (props) => {
title: t(translations.averageMarkingTime),
sortable: true,
csvDownloadable: true,
cell: (staff) => staff.averageMarkingTime,
cell: (staff) => formatSecondsDuration(staff.averageMarkingTime),
},
{
of: 'stddev',
title: t(translations.stddev),
sortable: true,
csvDownloadable: true,
cell: (staff) => staff.stddev,
cell: (staff) => formatSecondsDuration(staff.stddev),
},
];

Expand Down
8 changes: 4 additions & 4 deletions client/app/bundles/course/statistics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ export interface Staff {
name: string;
numGraded: number;
numStudents: number;
averageMarkingTime: string;
stddev: string;
averageMarkingTime: number;
stddev: number;
}

export interface StaffStatistics {
Expand Down Expand Up @@ -125,8 +125,8 @@ export interface CourseAssessment {
maximumGrade: number;
averageGrade: number;
stdevGrade: number;
averageTimeTaken: string;
stdevTimeTaken: string;
averageTimeTaken: number;
stdevTimeTaken: number;
numSubmitted: number;
numAttempted: number;
numLate: number;
Expand Down
41 changes: 38 additions & 3 deletions client/app/lib/moment.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,41 @@
import moment from 'moment-timezone';

moment.updateLocale('en', {
relativeTime: {
m: '1 minute',
h: '1 hour',
d: '1 day',
w: '1 week',
M: '1 month',
y: '1 year',
},
});

const LONG_DATE_FORMAT = 'DD MMM YYYY' as const;
const LONG_TIME_FORMAT = 'h:mma' as const;
const LONG_DATE_TIME_FORMAT =
`${LONG_DATE_FORMAT}, ${LONG_TIME_FORMAT}` as const;

const SHORT_DATE_FORMAT = 'DD-MM-YYYY' as const;
const PRECISE_SECONDS_TIME_FORMAT = 'HH:mm:ss' as const;
const PRECISE_TIME_FORMAT = 'HH:mm:ss.SS' as const;
const PRECISE_DATE_TIME_FORMAT = `DD-MM-YY ${PRECISE_TIME_FORMAT}` as const;

// TODO: Do not export these and remove all of their imports
export const SHORT_TIME_FORMAT = 'HH:mm' as const;
export const SHORT_DATE_TIME_FORMAT =
const SHORT_TIME_FORMAT = 'HH:mm' as const;
const SHORT_DATE_TIME_FORMAT =
`${SHORT_DATE_FORMAT} ${SHORT_TIME_FORMAT}` as const;

const FULL_DATE_TIME_FORMAT = 'dddd, MMMM D YYYY, HH:mm' as const;
const MINI_DATE_TIME_FORMAT = 'D MMM YYYY HH:mm' as const;
const MINI_DATE_TIME_YEARLESS_FORMAT = 'D MMM HH:mm' as const;

const SECONDS_IN_A_DAY = 86400 as const;

// TODO: Do not export moment and create the helpers here
export default moment;

type DateTimeFormatter = (input?: string | Date | null | number) => string;
type DurationFormatter = (input?: string | null | number) => string;

const formatterWith =
(format: string): DateTimeFormatter =>
Expand All @@ -36,6 +50,7 @@ export const formatLongDate = formatterWith(LONG_DATE_FORMAT);
export const formatLongDateTime = formatterWith(LONG_DATE_TIME_FORMAT);
export const formatShortDateTime = formatterWith(SHORT_DATE_TIME_FORMAT);
export const formatFullDateTime = formatterWith(FULL_DATE_TIME_FORMAT);
export const formatShortTime = formatterWith(SHORT_TIME_FORMAT);
export const formatPreciseTime = formatterWith(PRECISE_TIME_FORMAT);
export const formatPreciseDateTime = formatterWith(PRECISE_DATE_TIME_FORMAT);

Expand All @@ -48,3 +63,23 @@ export const formatMiniDateTime: DateTimeFormatter = (input) => {

return dateTime.format(MINI_DATE_TIME_FORMAT);
};

export const formatSecondsDuration: DurationFormatter = (input) => {
if (!input) return '--:--:--';
if (typeof input === 'string') {
input = parseInt(input, 10);
}

const durationDays =
input >= SECONDS_IN_A_DAY
? // Always display days for greater precision, otherwise "40 days" gets rounded to "a month"
moment
.duration(Math.floor(input / SECONDS_IN_A_DAY), 'd')
.humanize(false, { d: 1_000_000_000 })
: '';

const durationTime = moment
.utc((input % SECONDS_IN_A_DAY) * 1000)
.format(PRECISE_SECONDS_TIME_FORMAT);
return [durationDays, durationTime].join(' ').trim();
};
1 change: 0 additions & 1 deletion config/locales/en/time.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
en:
time:
day: 'day'
formats:
default: '%a, %d %b %Y %I:%M:%S %p'
1 change: 0 additions & 1 deletion config/locales/ko/time.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
ko:
time:
day: '일'
formats:
default: '%a, %Y %b %d %p %I:%M:%S'
1 change: 0 additions & 1 deletion config/locales/zh/time.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
zh:
time:
day: '日期'
formats:
default: '%a, %Y %b %d %H:%M:%S'
Loading