diff --git a/admin/src/Models/Quiz.ts b/admin/src/Models/Quiz.ts index f2865edfb..7a743f8d2 100644 --- a/admin/src/Models/Quiz.ts +++ b/admin/src/Models/Quiz.ts @@ -3,6 +3,7 @@ import Base from "./Base"; export default class Quiz extends Base { name!: string; description!: string; + required_pass_rate!: number; created_at!: string; updated_at!: string; deleted_at!: string | null; @@ -13,6 +14,7 @@ export default class Quiz extends Base { attributes: { name: "", description: "", + required_pass_rate: 0, deleted_at: null, }, }; diff --git a/admin/src/Quiz/QuizEditForm.tsx b/admin/src/Quiz/QuizEditForm.tsx index 98787886e..82cd2e73a 100644 --- a/admin/src/Quiz/QuizEditForm.tsx +++ b/admin/src/Quiz/QuizEditForm.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import { useEffect, useState } from "react"; import Icon from "../Components/icons"; import Textarea from "../Components/Textarea"; import TextInput from "../Components/TextInput"; @@ -13,9 +13,17 @@ interface Props { export default (props: Props) => { const { quiz } = props; const { onSave, onDelete } = props; + const [version, setVersion] = useState(0); if (quiz == null) return null; + useEffect(() => { + const unsub = quiz.subscribe(() => setVersion((v) => v + 1)); + return () => { + unsub(); + }; + }, [quiz]); + return (
{ Du kan använda markdown eller html för att lägga extra funktionalitet och bilder + + + Quizzet mislyckas om medlemmen svarar fel på + fler än {100 - 100 * quiz.required_pass_rate}% + av frågorna. + )} diff --git a/api/src/dispatch_emails.py b/api/src/dispatch_emails.py index 7da2d5670..0d7f9446e 100755 --- a/api/src/dispatch_emails.py +++ b/api/src/dispatch_emails.py @@ -722,7 +722,7 @@ def handle_signal(signum: int, frame: Any) -> None: domain = config.get("MAILGUN_DOMAIN") sender = config.get("MAILGUN_FROM") to_override = config.get("MAILGUN_TO_OVERRIDE") - last_reminder_check = 0.0 + last_reminder_check = -10000 # Don't send emails immediately, to avoid clobbering up the logs exit.wait(2) diff --git a/api/src/migrations/0046_quiz_required_pass_rate.sql b/api/src/migrations/0046_quiz_required_pass_rate.sql new file mode 100644 index 000000000..f67aaad2f --- /dev/null +++ b/api/src/migrations/0046_quiz_required_pass_rate.sql @@ -0,0 +1,5 @@ +-- Add optional required_pass_rate column to quiz_quizzes +-- This allows quizzes to have a minimum pass rate requirement. +-- If a member has more than (1 - required_pass_rate) * total_questions incorrect answers, they fail. + +ALTER TABLE quiz_quizzes ADD COLUMN required_pass_rate FLOAT DEFAULT 0.8; diff --git a/api/src/quiz/models.py b/api/src/quiz/models.py index dccf874a1..8abc06251 100644 --- a/api/src/quiz/models.py +++ b/api/src/quiz/models.py @@ -11,6 +11,9 @@ class Quiz(Base): id = Column(Integer, primary_key=True, nullable=False, autoincrement=True) name = Column(Text, nullable=False) description = Column(Text, nullable=False) + # Optional required pass rate (0.0 to 1.0). If set, a member fails the quiz if they have + # more than (1 - required_pass_rate) * total_questions incorrect answers. + required_pass_rate = Column(Numeric, nullable=False) created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now()) deleted_at = Column(DateTime) diff --git a/api/src/quiz/views.py b/api/src/quiz/views.py index 85d62fdd2..8c4a64a42 100644 --- a/api/src/quiz/views.py +++ b/api/src/quiz/views.py @@ -90,6 +90,48 @@ def get_or_create_current_attempt(member_id: int, quiz_id: int) -> QuizAttempt: return attempt +def check_attempt_failed(attempt_id: int, quiz: Quiz) -> tuple[bool, int, int]: + """ + Check if an attempt has failed based on the quiz's required_pass_rate. + + Returns (failed, incorrect_count, max_allowed_incorrect) + """ + if quiz.required_pass_rate is None: + return (False, 0, 0) + + # Count total questions in the quiz + total_questions = ( + db_session.query(func.count(QuizQuestion.id)) + .filter(QuizQuestion.quiz_id == quiz.id, QuizQuestion.deleted_at == None) + .scalar() + or 0 + ) + + if total_questions == 0: + return (False, 0, 0) + + # Calculate max allowed incorrect answers + # required_pass_rate is stored as a fraction (0.0 to 1.0) + # If required_pass_rate is 0.8, member can have at most 20% incorrect answers + max_allowed_incorrect = int((1 - float(quiz.required_pass_rate)) * total_questions) + + # Count distinct questions answered incorrectly in this attempt + # Multiple incorrect answers for the same question count multiple times + incorrect_count = ( + db_session.query(func.count(QuizAnswer.id)) + .filter( + QuizAnswer.attempt_id == attempt_id, + QuizAnswer.correct == False, + QuizAnswer.deleted_at == None, + ) + .scalar() + or 0 + ) + + failed = incorrect_count > max_allowed_incorrect + return (failed, incorrect_count, max_allowed_incorrect) + + @service.route("/quiz//attempt", method=GET, permission=USER) def get_current_attempt(quiz_id: int): """Get the current attempt for the logged-in user on a quiz.""" @@ -98,6 +140,8 @@ def get_current_attempt(quiz_id: int): if attempt is None: return None + quiz = db_session.get(Quiz, quiz_id) + # Get the timestamp of the last answer in this attempt last_answer = ( db_session.query(QuizAnswer) @@ -109,11 +153,17 @@ def get_current_attempt(quiz_id: int): .first() ) + # Check if this attempt has failed + failed = False + if quiz and quiz.required_pass_rate is not None: + failed, _, _ = check_attempt_failed(attempt.id, quiz) + return { "id": attempt.id, "quiz_id": attempt.quiz_id, "created_at": attempt.created_at.isoformat() if attempt.created_at else None, "last_answer_at": last_answer.created_at.isoformat() if last_answer and last_answer.created_at else None, + "failed": failed, } @@ -155,6 +205,10 @@ def answer_question(question_id): if question is None: return (400, f"Question id {question_id} not found") + quiz = db_session.get(Quiz, question.quiz_id) + if quiz is None: + return (400, f"Quiz not found for question id {question_id}") + attempt = get_or_create_current_attempt(g.user_id, int(question.quiz_id)) db_session.add( @@ -179,6 +233,14 @@ def answer_question(question_id): option = quiz_question_option_entity.to_obj(option) json["options"].append(option) + # Check if the member has failed the quiz based on required_pass_rate + if quiz.required_pass_rate is not None: + failed, incorrect_count, max_allowed = check_attempt_failed(attempt.id, quiz) + if failed: + json["quiz_failed"] = True + json["incorrect_count"] = incorrect_count + json["max_allowed_incorrect"] = max_allowed + return json @@ -234,12 +296,18 @@ def mapify(rows): @dataclass class MemberQuizStatistic(DataClassJsonMixin): quiz: Any + # Current number of questions in the quiz total_questions_in_quiz: int + # Current number of correctly answered questions, across all attempts, of the currently existing questions correctly_answered_questions: int # Maximum pass rate ever achieved (percentage from 0-100) max_pass_rate: float + # Pass rate of the last attempt (percentage from 0-100), which could be in progress. 0 if no attempts. + last_pass_rate: float # Whether the member has ever fully completed the quiz ever_completed: bool + # Whether the last attempt has failed + last_attempt_failed: bool def calculate_max_pass_rates_cached(member_ids: list[int], quiz_id: int) -> list[tuple[float, bool]]: @@ -315,50 +383,58 @@ def calculate_max_pass_rate(member_id: int, quiz_id: int) -> tuple[float, bool]: ever_completed = False for attempt in attempts: - # Get all correct answers from this attempt - correct_answers = ( - db_session.query(QuizAnswer) - .join(QuizAnswer.question) - .filter( - QuizAnswer.attempt_id == attempt.id, - QuizAnswer.correct == True, - QuizAnswer.deleted_at == None, - ) - .order_by(QuizAnswer.created_at) - .all() - ) + pass_rate, completed = calculate_pass_rate_for_attempt(attempt.id, quiz_id, questions) - if not correct_answers: - continue + if pass_rate > max_pass_rate: + max_pass_rate = pass_rate - # Find the timestamp of the last answer in this attempt - last_answer_time = max(a.created_at for a in correct_answers) + if completed: + ever_completed = True - # Count questions that existed at the time of the last answer - questions_at_time = [ - q - for q in questions - if q.created_at <= last_answer_time and (q.deleted_at is None or q.deleted_at > last_answer_time) - ] - if not questions_at_time: - continue + return (max_pass_rate, ever_completed) - # Count correctly answered questions in this attempt - correctly_answered_question_ids = set(a.question_id for a in correct_answers) - question_ids_at_time = set(q.id for q in questions_at_time) - correct_count = len(correctly_answered_question_ids & question_ids_at_time) - total_count = len(question_ids_at_time) +def calculate_pass_rate_for_attempt(attempt_id: int, quiz: Quiz, questions: list[QuizQuestion]) -> tuple[float, bool]: + # Get all correct answers from this attempt + correct_answers = ( + db_session.query(QuizAnswer) + .join(QuizAnswer.question) + .filter( + QuizAnswer.attempt_id == attempt_id, + QuizAnswer.correct == True, + QuizAnswer.deleted_at == None, + ) + .order_by(QuizAnswer.created_at) + .all() + ) - pass_rate = (correct_count / total_count) * 100 + if not correct_answers: + return (0.0, False) - if pass_rate > max_pass_rate: - max_pass_rate = pass_rate + # Find the timestamp of the last answer in this attempt + last_answer_time = max(a.created_at for a in correct_answers) - if correct_count >= total_count: - ever_completed = True + # Count questions that existed at the time of the last answer + questions_at_time = [ + q + for q in questions + if q.created_at <= last_answer_time and (q.deleted_at is None or q.deleted_at > last_answer_time) + ] + if not questions_at_time: + return (0.0, False) - return (max_pass_rate, ever_completed) + # Count correctly answered questions in this attempt + correctly_answered_question_ids = set(a.question_id for a in correct_answers) + question_ids_at_time = set(q.id for q in questions_at_time) + + correct_count = len(correctly_answered_question_ids & question_ids_at_time) + total_count = len(question_ids_at_time) + + pass_rate = (correct_count / total_count) * 100 + + completed = correct_count >= total_count + + return pass_rate, completed def member_quiz_statistics(member_id: int) -> list[MemberQuizStatistic]: @@ -394,13 +470,31 @@ def member_quiz_statistics(member_id: int) -> list[MemberQuizStatistic]: result = [] for quiz in quizzes: max_pass_rate, ever_completed = calculate_max_pass_rate(member_id, quiz.id) + last_pass_rate = 0.0 + + last_attempt = _find_current_attempt(member_id, quiz.id) + failed = False + if last_attempt: + # Get all questions for this quiz, ordered by creation time + questions = ( + db_session.query(QuizQuestion) + .filter(QuizQuestion.quiz_id == quiz.id) + .order_by(QuizQuestion.created_at) + .all() + ) + + failed, _incorrect_count, _max_allowed_incorrect = check_attempt_failed(last_attempt.id, quiz) + last_pass_rate, _ = calculate_pass_rate_for_attempt(last_attempt.id, quiz, questions) + result.append( MemberQuizStatistic( quiz=quiz_entity.to_obj(quiz), total_questions_in_quiz=total_questions_in_quiz.get(quiz.id, 0), correctly_answered_questions=answered_questions_per_quiz.get(quiz.id, 0), max_pass_rate=max_pass_rate, + last_pass_rate=last_pass_rate, ever_completed=ever_completed, + last_attempt_failed=failed, ) ) diff --git a/config/locales/en/member_portal/courses.json5 b/config/locales/en/member_portal/courses.json5 index b00cc4dfd..1ef121c5c 100644 --- a/config/locales/en/member_portal/courses.json5 +++ b/config/locales/en/member_portal/courses.json5 @@ -6,4 +6,8 @@ course_completed: "Completed", course_continue: "Continue", course_take: "Take course", + course_retake: "Retake course", + course_retry: "Retry course", + course_new_version: "New version available", + course_new_version_description: "This course has been updated since you completed it. Please retake it to stay current.", } diff --git a/config/locales/sv/member_portal/courses.json5 b/config/locales/sv/member_portal/courses.json5 index 41da0e37a..d18d150f8 100644 --- a/config/locales/sv/member_portal/courses.json5 +++ b/config/locales/sv/member_portal/courses.json5 @@ -4,4 +4,8 @@ course_completed: "Genomförd", course_continue: "Fortsätt", course_take: "Ta kursen", + course_retake: "Gör om kursen", + course_retry: "Försök igen", + course_new_version: "Ny version tillgänglig", + course_new_version_description: "Kursen har uppdaterats sedan du slutförde den. Vänligen gör om den för att hålla dig uppdaterad.", } diff --git a/public/scss/style.scss b/public/scss/style.scss index 327d972e2..b71cc11d3 100644 --- a/public/scss/style.scss +++ b/public/scss/style.scss @@ -1230,6 +1230,61 @@ $palette-red: $palette4; } } + &.quiz-failed { + background-color: $palette4; + color: white; + + h1 { + color: white; + } + + .question-text { + color: rgba(255, 255, 255, 0.9); + } + + .question-answer-info-incorrect { + color: white; + font-weight: bold; + background: rgba(0, 0, 0, 0.2); + padding: 15px; + border-radius: 8px; + margin: 20px 0; + } + + .question-answer-description { + color: rgba(255, 255, 255, 0.9); + } + + .question-options li { + background-color: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); + color: white; + + &.question-option-correct { + background: $palette-green; + border-color: $palette-green; + } + + &.question-option-selected.question-option-incorrect { + background: #c0392b; + border-color: #c0392b; + } + + &.question-option-incorrect:not(.question-option-selected) { + opacity: 0.5; + } + } + + .uk-button-danger { + background-color: #c0392b; + border: none; + + &:hover { + background-color: #a93226; + } + } + } + h1 { font-family: "Bitter", serif; margin-top: 0px; @@ -1449,6 +1504,31 @@ $palette-red: $palette4; display: flex; flex-direction: column; + .course-item-wrapper { + margin-bottom: 30px; + } + + .course-item-wrapper-new-version { + .course-item { + margin-bottom: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } + + .course-new-version-message { + background: #ffba4abf; + color: #805107; + padding: 6px 12px; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + font-size: 0.7em; + box-shadow: + 0px 0.9px 2.2px rgba(0, 0, 0, 0.072), + 0px 1.7px 4.2px rgba(0, 0, 0, 0.086), + 0px 4px 10px rgba(0, 0, 0, 0.12); + } + .course-item { display: flex; flex-direction: row; @@ -1482,11 +1562,14 @@ $palette-red: $palette4; } .course-not-completed, - .course-completed { + .course-completed, + .course-new-version, + .course-failed { + text-wrap: nowrap; flex-grow: 0; flex-shrink: 0; align-self: stretch; - width: 150px; + width: 160px; display: flex; flex-direction: row; justify-content: space-between; @@ -1514,6 +1597,18 @@ $palette-red: $palette4; transition: 100ms background-color; } + .course-new-version { + background: #f5a623; + color: white; + transition: 100ms background-color; + } + + .course-failed { + background: #e2364a; + color: white; + transition: 100ms background-color; + } + &:hover { background: color.adjust(#fff, $lightness: -2%); } @@ -1525,8 +1620,16 @@ $palette-red: $palette4; ); } + &:hover .course-new-version { + background: color.adjust( + color.adjust(#f5a623, $lightness: -5%), + $saturation: 12% + ); + } + .course-not-completed > div, - .course-completed > div { + .course-completed > div, + .course-new-version > div { position: relative; top: -10px; left: -10px; diff --git a/public/ts/courses.tsx b/public/ts/courses.tsx index 9f8053b46..c8870c973 100644 --- a/public/ts/courses.tsx +++ b/public/ts/courses.tsx @@ -11,29 +11,45 @@ interface QuizInfo { quiz: Quiz; total_questions_in_quiz: number; correctly_answered_questions: number; + max_pass_rate: number; + last_pass_rate: number; + ever_completed: boolean; + last_attempt_failed: boolean; } const CourseButton = ({ quizInfo }: { quizInfo: QuizInfo }) => { const { t } = useTranslation("courses"); - const completed = + const currentlyComplete = quizInfo.correctly_answered_questions >= quizInfo.total_questions_in_quiz; + const hasNewVersion = quizInfo.ever_completed && !currentlyComplete; let actionBtn; - if (completed) { + if (currentlyComplete) { actionBtn = (
{t("course_completed")}{" "}
); - } else if (quizInfo.correctly_answered_questions > 0) { - const completed_fraction = - quizInfo.correctly_answered_questions / - quizInfo.total_questions_in_quiz; + } else if (quizInfo.ever_completed) { + actionBtn = ( +
+ {t("course_retake")}{" "} + +
+ ); + } else if (quizInfo.last_attempt_failed) { + actionBtn = ( +
+ {t("course_retry")}{" "} + +
+ ); + } else if (quizInfo.last_pass_rate > 0) { actionBtn = (
- {t("course_continue")} ({Math.round(completed_fraction * 100)}%){" "} + {t("course_continue")} ({Math.round(quizInfo.last_pass_rate)}%){" "}
); @@ -45,11 +61,29 @@ const CourseButton = ({ quizInfo }: { quizInfo: QuizInfo }) => {
); } + + if (hasNewVersion) { + return ( +
+ + {quizInfo.quiz.name} + {actionBtn} + +
+ {t("course_new_version_description")} +
+
+ ); + } + return ( {quizInfo.quiz.name} diff --git a/public/ts/generated_locales/en.json b/public/ts/generated_locales/en.json index 4f2386071..f64e301de 100644 --- a/public/ts/generated_locales/en.json +++ b/public/ts/generated_locales/en.json @@ -36,6 +36,10 @@ "courses": { "course_completed": "Completed", "course_continue": "Continue", + "course_new_version": "New version available", + "course_new_version_description": "This course has been updated since you completed it. Please retake it to stay current.", + "course_retake": "Retake course", + "course_retry": "Retry course", "course_take": "Take course", "description": "Here you can find all online courses that $t(brand:makerspace_name) provides. Most courses are held physically and will instead be announced in our Slack group #events. But some have a digital variant that you can find here, and take whenever you want.", "title": "Courses" diff --git a/public/ts/generated_locales/sv.json b/public/ts/generated_locales/sv.json index 6d776b223..e8c1d1895 100644 --- a/public/ts/generated_locales/sv.json +++ b/public/ts/generated_locales/sv.json @@ -36,6 +36,10 @@ "courses": { "course_completed": "Genomförd", "course_continue": "Fortsätt", + "course_new_version": "Ny version tillgänglig", + "course_new_version_description": "Kursen har uppdaterats sedan du slutförde den. Vänligen gör om den för att hålla dig uppdaterad.", + "course_retake": "Gör om kursen", + "course_retry": "Försök igen", "course_take": "Ta kursen", "description": "Här hittar du alla digitala kurser som $t(brand:makerspace_name) har. De flesta kurser sker på plats och annonseras i facebook-gruppen och på slack. Men vissa har en digital variant som du kan ta när du vill.", "title": "Kurser" diff --git a/public/ts/locales.ts b/public/ts/locales.ts index 274329013..2e3fbb294 100644 --- a/public/ts/locales.ts +++ b/public/ts/locales.ts @@ -21,7 +21,7 @@ import sv from "./generated_locales/sv.json"; ((key: "and" | "back" | "cancel" | "continue" | "logOut" | "priceUnit") => string) & ((key: "unit.day" | "unit.month" | "unit.piece" | "unit.year", args: { "count": string | number}) => string) "courses": -((key: "course_completed" | "course_continue" | "course_take" | "description" | "title") => string) +((key: "course_completed" | "course_continue" | "course_new_version" | "course_new_version_description" | "course_retake" | "course_retry" | "course_take" | "description" | "title") => string) "history": ((key: "receipt" | "receipt_has_been_sent" | "receipt_not_found" | "receipt_sum" | "thank_you_for_your_purchase" | "title") => string) "labels": @@ -75,7 +75,7 @@ export type LOCALE_SCHEMA_GLOBAL = ( & ((key: "change_phone:errors.generic" | "change_phone:errors.incorrect_code" | "change_phone:new_number_prompt" | "change_phone:validatePhone" | "change_phone:validation_code_prompt") => string) & ((key: "common:and" | "common:back" | "common:cancel" | "common:continue" | "common:logOut" | "common:priceUnit") => string) & ((key: "common:unit.day" | "common:unit.month" | "common:unit.piece" | "common:unit.year", args: { "count": string | number}) => string) - & ((key: "courses:course_completed" | "courses:course_continue" | "courses:course_take" | "courses:description" | "courses:title") => string) + & ((key: "courses:course_completed" | "courses:course_continue" | "courses:course_new_version" | "courses:course_new_version_description" | "courses:course_retake" | "courses:course_retry" | "courses:course_take" | "courses:description" | "courses:title") => string) & ((key: "history:receipt" | "history:receipt_has_been_sent" | "history:receipt_not_found" | "history:receipt_sum" | "history:thank_you_for_your_purchase" | "history:title") => string) & ((key: "labels:actions.add_photo" | "labels:actions.can_terminate_after_observation" | "labels:actions.cancel" | "labels:actions.change_photo" | "labels:actions.failed_to_submit" | "labels:actions.report" | "labels:actions.report_label" | "labels:actions.submit" | "labels:actions.submitted_successfully" | "labels:actions.terminate" | "labels:actions.terminate_label" | "labels:actions.title" | "labels:actions.write_a_message" | "labels:active_labels" | "labels:delete_failed" | "labels:delete_label" | "labels:delete_label_confirm" | "labels:expired" | "labels:expired_labels" | "labels:expires" | "labels:expiring_soon" | "labels:label_type.BoxLabel" | "labels:label_type.DryingLabel" | "labels:label_type.FireSafetyLabel" | "labels:label_type.MeetupNameTag" | "labels:label_type.NameTag" | "labels:label_type.Printer3DLabel" | "labels:label_type.RotatingStorageLabel" | "labels:label_type.TemporaryStorageLabel" | "labels:label_type.WarningLabel" | "labels:messages.status.failed" | "labels:messages.status.queued" | "labels:messages.status.sent" | "labels:messages.template.memberbooth_box_cleaned_away" | "labels:messages.template.memberbooth_box_cleaned_away_sms" | "labels:messages.template.memberbooth_box_expired" | "labels:messages.template.memberbooth_box_expiring_soon" | "labels:messages.template.memberbooth_label_cleaned_away" | "labels:messages.template.memberbooth_label_cleaned_away_sms" | "labels:messages.template.memberbooth_label_expired" | "labels:messages.template.memberbooth_label_expiring_soon" | "labels:messages.template.memberbooth_label_report" | "labels:messages.template.memberbooth_label_report_sms" | "labels:messages.title" | "labels:no_labels_uploaded" | "labels:page_title" | "labels:relative_drying.now" | "labels:relative_expiry.now" | "labels:relative_generic.now") => string) & ((key: "labels:relative_drying.days_ago" | "labels:relative_drying.hours_ago" | "labels:relative_drying.in_days" | "labels:relative_drying.in_hours" | "labels:relative_drying.in_minutes" | "labels:relative_drying.minutes_ago" | "labels:relative_expiry.days_ago" | "labels:relative_expiry.hours_ago" | "labels:relative_expiry.in_days" | "labels:relative_expiry.in_hours" | "labels:relative_expiry.in_minutes" | "labels:relative_expiry.minutes_ago" | "labels:relative_generic.days_ago" | "labels:relative_generic.hours_ago" | "labels:relative_generic.in_days" | "labels:relative_generic.in_hours" | "labels:relative_generic.in_minutes" | "labels:relative_generic.minutes_ago", args: { "count": string | number}) => string) diff --git a/public/ts/quiz.tsx b/public/ts/quiz.tsx index 2a69258e0..799ee4e28 100644 --- a/public/ts/quiz.tsx +++ b/public/ts/quiz.tsx @@ -22,6 +22,9 @@ interface Question { answer_description?: string; correct?: boolean; }[]; + quiz_failed?: boolean; + incorrect_count?: number; + max_allowed_incorrect?: number; } export interface Quiz { @@ -36,6 +39,7 @@ interface Attempt { quiz_id: number; created_at: string | null; last_answer_at: string | null; + failed: boolean; } interface State { @@ -167,14 +171,25 @@ class QuizManager extends Component { if (!attempt) { return "Start quiz"; } + if (attempt.failed) { + return "Retake quiz"; + } if (this.isAttemptStale()) { return "Start new quiz"; } return "Continue quiz"; } + shouldStartNewAttempt(): boolean { + const attempt = this.state.currentAttempt; + if (!attempt) { + return false; + } + return attempt.failed || this.isAttemptStale(); + } + async handleStartClick() { - if (this.isAttemptStale()) { + if (this.shouldStartNewAttempt()) { await this.startNewAttempt(); } else { await this.start(); @@ -296,8 +311,12 @@ class QuizManager extends Component { ); } else { + const quizFailed = this.state.question.quiz_failed; return ( -