From 4d8b81348fc734d3a4a5e978ad97ea0e02f4cfbf Mon Sep 17 00:00:00 2001
From: adi-herwana-nus
Date: Mon, 1 Dec 2025 12:51:13 +0800
Subject: [PATCH 1/2] fix(downloads): standardize rich text to csv conversion
---
app/helpers/application_formatters_helper.rb | 15 +++
.../assessment/answer/forum_post_response.rb | 3 +-
.../assessment/answer/multiple_response.rb | 2 +-
.../answer/rubric_based_response.rb | 2 +-
.../course/assessment/answer/text_response.rb | 2 +-
.../course/survey/survey_download_service.rb | 3 +-
.../application_formatters_helper_spec.rb | 100 ++++++++++++++++++
7 files changed, 121 insertions(+), 6 deletions(-)
diff --git a/app/helpers/application_formatters_helper.rb b/app/helpers/application_formatters_helper.rb
index 7415cb4d388..b9a2a70cfc7 100644
--- a/app/helpers/application_formatters_helper.rb
+++ b/app/helpers/application_formatters_helper.rb
@@ -82,4 +82,19 @@ def format_duration(total_seconds)
hours = total_seconds / (60 * 60)
format('%02dH%02dM%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 , &, 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('
', "\n").gsub('
', "
\n")
+ HTMLEntities.new.decode(ActionController::Base.helpers.strip_tags(cleaned_text)).strip
+ end
end
diff --git a/app/models/course/assessment/answer/forum_post_response.rb b/app/models/course/assessment/answer/forum_post_response.rb
index 1921cb2ce6e..25474184efd 100644
--- a/app/models/course/assessment/answer/forum_post_response.rb
+++ b/app/models/course/assessment/answer/forum_post_response.rb
@@ -77,8 +77,7 @@ def stripped_answer_to_array
def readable_string_of(text)
return nil unless text
- cleaned_text = text.gsub('', "\n").gsub('
', "
\n").squish
- ActionController::Base.helpers.strip_tags(cleaned_text)
+ ApplicationController.helpers.format_rich_text_for_csv(text).squish
end
def destroy_previous_selection
diff --git a/app/models/course/assessment/answer/multiple_response.rb b/app/models/course/assessment/answer/multiple_response.rb
index c0758e9ae68..975d69686e9 100644
--- a/app/models/course/assessment/answer/multiple_response.rb
+++ b/app/models/course/assessment/answer/multiple_response.rb
@@ -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
diff --git a/app/models/course/assessment/answer/rubric_based_response.rb b/app/models/course/assessment/answer/rubric_based_response.rb
index 5354ec5a74a..dcb4b17d38b 100644
--- a/app/models/course/assessment/answer/rubric_based_response.rb
+++ b/app/models/course/assessment/answer/rubric_based_response.rb
@@ -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)
diff --git a/app/models/course/assessment/answer/text_response.rb b/app/models/course/assessment/answer/text_response.rb
index 29a8c70242b..e0c84b232f7 100644
--- a/app/models/course/assessment/answer/text_response.rb
+++ b/app/models/course/assessment/answer/text_response.rb
@@ -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)
diff --git a/app/services/course/survey/survey_download_service.rb b/app/services/course/survey/survey_download_service.rb
index f6545c57215..a3f8c83d6b9 100644
--- a/app/services/course/survey/survey_download_service.rb
+++ b/app/services/course/survey/survey_download_service.rb
@@ -3,6 +3,7 @@
class Course::Survey::SurveyDownloadService
include TmpCleanupHelper
+ include ApplicationFormattersHelper
def initialize(survey)
@survey = survey
@@ -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)
diff --git a/spec/helpers/application_formatters_helper_spec.rb b/spec/helpers/application_formatters_helper_spec.rb
index d86191a0cac..b9506cd46f8 100644
--- a/spec/helpers/application_formatters_helper_spec.rb
+++ b/spec/helpers/application_formatters_helper_spec.rb
@@ -314,4 +314,104 @@ def hello:
end
end
end
+
+ describe '#format_rich_text_for_csv' do
+ context 'when text is nil' do
+ it 'returns empty string' do
+ expect(helper.format_rich_text_for_csv(nil)).to eq('')
+ end
+ end
+
+ context 'when text is empty' do
+ it 'returns empty string' do
+ expect(helper.format_rich_text_for_csv('')).to eq('')
+ end
+ end
+
+ context 'when text contains HTML tags' do
+ it 'strips all HTML tags' do
+ html = 'Hello World
'
+ expect(helper.format_rich_text_for_csv(html)).to eq('Hello World')
+ end
+
+ it 'strips nested HTML tags' do
+ html = ''
+ expect(helper.format_rich_text_for_csv(html)).to eq('Test')
+ end
+ end
+
+ context 'when text contains HTML entities' do
+ it 'decodes to space' do
+ html = 'Hello World
'
+ expect(helper.format_rich_text_for_csv(html)).to eq('Hello World')
+ end
+
+ it 'decodes & to &' do
+ html = 'Test&Example
'
+ expect(helper.format_rich_text_for_csv(html)).to eq('Test&Example')
+ end
+
+ it 'decodes multiple entities' do
+ html = 'Hello World&Test<Example>
'
+ expect(helper.format_rich_text_for_csv(html)).to eq('Hello World&Test')
+ end
+
+ it 'decodes numeric entities' do
+ html = '"Quote"
'
+ expect(helper.format_rich_text_for_csv(html)).to eq('"Quote"')
+ end
+ end
+
+ context 'when text contains paragraph and line breaks' do
+ it 'preserves paragraph breaks as newlines' do
+ html = 'First paragraph
Second paragraph
'
+ result = helper.format_rich_text_for_csv(html)
+ expect(result).to eq("First paragraph\nSecond paragraph")
+ end
+
+ it 'preserves line breaks as newlines' do
+ html = 'Line 1
Line 2
'
+ result = helper.format_rich_text_for_csv(html)
+ expect(result).to eq("Line 1\nLine 2")
+ end
+
+ it 'handles complex HTML with mixed content' do
+ html = 'First
Second
Third
Fourth
'
+ result = helper.format_rich_text_for_csv(html)
+ expect(result).to eq("First\nSecond\nThird\nFourth")
+ end
+ end
+
+ context 'edge cases' do
+ it 'handles malformed HTML gracefully' do
+ html = 'Unclosed paragraph'
+ result = helper.format_rich_text_for_csv(html)
+ expect(result).to eq('Unclosed paragraph')
+ end
+
+ it 'handles text without any HTML' do
+ text = 'Plain text'
+ result = helper.format_rich_text_for_csv(text)
+ expect(result).to eq('Plain text')
+ end
+
+ it 'handles mixed plain text and HTML' do
+ html = 'Plain bold text'
+ result = helper.format_rich_text_for_csv(html)
+ expect(result).to eq('Plain bold text')
+ end
+
+ it 'handles script tags (should be stripped)' do
+ html = '
Safe text
'
+ result = helper.format_rich_text_for_csv(html)
+ expect(result).to eq("Safe text\nalert(\"xss\")")
+ end
+
+ it 'handles literal & character' do
+ html = 'Peanut Butter & Jelly'
+ result = helper.format_rich_text_for_csv(html)
+ expect(result).to eq('Peanut Butter & Jelly')
+ end
+ end
+ end
end
From d5cc7091b79f0da0fb2d04dbbdbdafb608886a48 Mon Sep 17 00:00:00 2001
From: adi-herwana-nus
Date: Mon, 1 Dec 2025 16:33:21 +0800
Subject: [PATCH 2/2] fix(moment): remove server-side duration formatting on
stats page
---
.../course/statistics/aggregate_helper.rb | 14 -------
.../aggregate/all_assessments.json.jbuilder | 4 +-
.../aggregate/all_staff.json.jbuilder | 4 +-
.../LiveFeedbackMessageHistory.tsx | 6 +--
.../reducers/liveFeedbackChats/index.ts | 12 +++---
.../DestinationCourseSelector/index.jsx | 4 +-
.../LessonPlanItem/Details/Chips.jsx | 12 ++----
.../AssessmentsStatisticsTable.tsx | 6 +--
.../staff/StaffStatisticsTable.tsx | 5 ++-
client/app/bundles/course/statistics/types.ts | 8 ++--
client/app/lib/moment.ts | 41 +++++++++++++++++--
config/locales/en/time.yml | 1 -
config/locales/ko/time.yml | 1 -
config/locales/zh/time.yml | 1 -
spec/features/course/staff_statistics_spec.rb | 12 +++---
15 files changed, 71 insertions(+), 60 deletions(-)
delete mode 100644 app/helpers/course/statistics/aggregate_helper.rb
diff --git a/app/helpers/course/statistics/aggregate_helper.rb b/app/helpers/course/statistics/aggregate_helper.rb
deleted file mode 100644
index 20f14fc20b5..00000000000
--- a/app/helpers/course/statistics/aggregate_helper.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# frozen_string_literal: true
-module Course::Statistics::AggregateHelper
- # Convert time in seconds to HH:MM:SS format.
- def seconds_to_str(total_seconds)
- return '--:--:--' unless total_seconds
-
- seconds = total_seconds % 60
- minutes = (total_seconds / 60) % 60
- hours = (total_seconds / (60 * 60)) % 24
- days = total_seconds / (60 * 60 * 24)
-
- format("%2d #{t('time.day')} %02d:%02d:%02d", days, hours, minutes, seconds)
- end
-end
diff --git a/app/views/course/statistics/aggregate/all_assessments.json.jbuilder b/app/views/course/statistics/aggregate/all_assessments.json.jbuilder
index 7356b08fa4b..9cf70793fa5 100644
--- a/app/views/course/statistics/aggregate/all_assessments.json.jbuilder
+++ b/app/views/course/statistics/aggregate/all_assessments.json.jbuilder
@@ -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
diff --git a/app/views/course/statistics/aggregate/all_staff.json.jbuilder b/app/views/course/statistics/aggregate/all_staff.json.jbuilder
index d863a04e577..35e4f2d4ef5 100644
--- a/app/views/course/statistics/aggregate/all_staff.json.jbuilder
+++ b/app/views/course/statistics/aggregate/all_staff.json.jbuilder
@@ -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
diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackMessageHistory.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackMessageHistory.tsx
index 3f36c1b3225..ad3d7e0df71 100644
--- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackMessageHistory.tsx
+++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackMessageHistory.tsx
@@ -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[];
@@ -110,9 +110,7 @@ const LiveFeedbackMessageHistory: FC = (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 (
{
- const createdAt = moment(new Date(message.createdAt)).format(
- SHORT_TIME_FORMAT,
- );
+ const createdAt = formatShortTime(new Date(message.createdAt));
return {
sender:
message.creatorId === 0
@@ -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
= {
@@ -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,
},
]
@@ -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,
};
diff --git a/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/index.jsx b/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/index.jsx
index 157726cae87..a3ea5d0649b 100644
--- a/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/index.jsx
+++ b/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/index.jsx
@@ -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';
@@ -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,
diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/Details/Chips.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/Details/Chips.jsx
index 399b62846cc..64f69595180 100644
--- a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/Details/Chips.jsx
+++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/Details/Chips.jsx
@@ -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: {
@@ -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 {
diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx
index e288b0bcb7c..07d1a144fc5 100644
--- a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx
+++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx
@@ -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';
@@ -226,14 +226,14 @@ const AssessmentsStatisticsTable: FC = (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,
},
];
diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/staff/StaffStatisticsTable.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/staff/StaffStatisticsTable.tsx
index 0e63f04e89f..64ac9ea868c 100644
--- a/client/app/bundles/course/statistics/pages/StatisticsIndex/staff/StaffStatisticsTable.tsx
+++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/staff/StaffStatisticsTable.tsx
@@ -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: {
@@ -82,14 +83,14 @@ const StaffStatisticsTable: FC = (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),
},
];
diff --git a/client/app/bundles/course/statistics/types.ts b/client/app/bundles/course/statistics/types.ts
index 253710ead8a..627587339f4 100644
--- a/client/app/bundles/course/statistics/types.ts
+++ b/client/app/bundles/course/statistics/types.ts
@@ -63,8 +63,8 @@ export interface Staff {
name: string;
numGraded: number;
numStudents: number;
- averageMarkingTime: string;
- stddev: string;
+ averageMarkingTime: number;
+ stddev: number;
}
export interface StaffStatistics {
@@ -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;
diff --git a/client/app/lib/moment.ts b/client/app/lib/moment.ts
index d63b9cd3c3f..734189f44b2 100644
--- a/client/app/lib/moment.ts
+++ b/client/app/lib/moment.ts
@@ -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 =>
@@ -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);
@@ -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();
+};
diff --git a/config/locales/en/time.yml b/config/locales/en/time.yml
index c88b42b72ea..58e43d52980 100644
--- a/config/locales/en/time.yml
+++ b/config/locales/en/time.yml
@@ -1,5 +1,4 @@
en:
time:
- day: 'day'
formats:
default: '%a, %d %b %Y %I:%M:%S %p'
diff --git a/config/locales/ko/time.yml b/config/locales/ko/time.yml
index d6b2fd3d5dc..1ee43cc0eb2 100644
--- a/config/locales/ko/time.yml
+++ b/config/locales/ko/time.yml
@@ -1,5 +1,4 @@
ko:
time:
- day: '일'
formats:
default: '%a, %Y %b %d %p %I:%M:%S'
diff --git a/config/locales/zh/time.yml b/config/locales/zh/time.yml
index 5881711a853..a3d222966aa 100644
--- a/config/locales/zh/time.yml
+++ b/config/locales/zh/time.yml
@@ -1,5 +1,4 @@
zh:
time:
- day: '日期'
formats:
default: '%a, %Y %b %d %H:%M:%S'
diff --git a/spec/features/course/staff_statistics_spec.rb b/spec/features/course/staff_statistics_spec.rb
index c9e380f7b14..a2b1bba5374 100644
--- a/spec/features/course/staff_statistics_spec.rb
+++ b/spec/features/course/staff_statistics_spec.rb
@@ -24,7 +24,7 @@
# Create submissions for tutors, with given submitted at and published_at
let!(:tutor1_submissions) do
submitted_at = 1.day.ago
- published_at = submitted_at + 1.day + 1.hour + 1.minute + 1.second
+ published_at = submitted_at + 1.hour
assessment = create(:assessment, :with_mcq_question, course: course)
submission = create(:submission, :published,
assessment: assessment, course: course, publisher: tutor1.user,
@@ -36,7 +36,7 @@
let!(:tutor2_submissions) do
submitted_at = 2.days.ago
- published_at = submitted_at + 2.days
+ published_at = submitted_at + 1.day + 1.hour + 1.minute + 1.second
assessment = create(:assessment, :with_mcq_question, course: course)
submission = create(:submission, :published,
assessment: assessment, course: course, publisher: tutor2.user,
@@ -48,7 +48,7 @@
let!(:tutor3_submissions) do
submitted_at = 2.days.ago
- published_at = submitted_at + 3.days
+ published_at = submitted_at + 2.days
assessment = create(:assessment, :with_mcq_question, course: course)
staff_submission = create(:submission, :published,
assessment: assessment, course: course, publisher: tutor3.user,
@@ -85,14 +85,14 @@
expect(row).to have_selector('td', text: '1') # S/N
expect(row).to have_selector('td', text: tutor1.name)
expect(row).to have_selector('td', text: tutor1_submissions.size)
- expect(row).to have_selector('td', text: "1 #{I18n.t('time.day')} 01:01:01")
+ expect(row).to have_selector('td', text: '01:00:00')
end
within find('tr', text: tutor2.name) do |row|
expect(row).to have_selector('td', text: '2')
expect(row).to have_selector('td', text: tutor2.name)
expect(row).to have_selector('td', text: tutor2_submissions.size)
- expect(row).to have_selector('td', text: "2 #{I18n.t('time.day')} 00:00:00")
+ expect(row).to have_selector('td', text: '1 day 01:01:01')
end
# Do not reflect staff submissions as part of staff statistics.
@@ -100,7 +100,7 @@
expect(row).to have_selector('td', text: '3')
expect(row).to have_selector('td', text: tutor3.name)
expect(row).to have_selector('td', text: '1')
- expect(row).to have_selector('td', text: "3 #{I18n.t('time.day')} 00:00:00")
+ expect(row).to have_selector('td', text: '2 days 00:00:00')
end
expect(page).not_to have_text(tutor4.name)