Skip to content

Commit

Permalink
feat: add possibility to specify customized bonus points logic for li…
Browse files Browse the repository at this point in the history
…ve quizzes (#4262)
  • Loading branch information
sjschlapbach authored Sep 17, 2024
1 parent 5e0be7e commit 9b5d199
Show file tree
Hide file tree
Showing 28 changed files with 480 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export interface LiveSessionFormValues extends CommonFormValues {
isConfusionFeedbackEnabled: boolean
isLiveQAEnabled: boolean
isModerationEnabled: boolean
maxBonusPoints: number
timeToZeroBonus: number
}

export interface MicroLearningFormValues extends CommonFormValues {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { faGears } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
LQ_DEFAULT_CORRECT_POINTS,
LQ_DEFAULT_POINTS,
LQ_MAX_BONUS_POINTS,
LQ_TIME_TO_ZERO_BONUS,
} from '@klicker-uzh/shared-components/src/constants'
import { Button, FormikNumberField, Modal } from '@uzh-bf/design-system'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import {
CartesianGrid,
Label,
Line,
LineChart,
Tooltip as RechartsTooltip,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts'

const SUMMED_CORRECT_PTS = LQ_DEFAULT_POINTS + LQ_DEFAULT_CORRECT_POINTS

function AdvancedLiveQuizSettings({
maxBonusValue,
timeToZeroValue,
}: {
maxBonusValue: string
timeToZeroValue: string
}) {
const t = useTranslations()
const [open, setOpen] = useState(false)

return (
<Modal
open={open}
onClose={() => setOpen(false)}
trigger={
<Button
basic
onClick={() => setOpen(true)}
data={{ cy: 'live-quiz-advanced-settings' }}
>
<FontAwesomeIcon icon={faGears} className="hover:text-primary-100" />
</Button>
}
title={t('manage.sessionForms.liveQuizAdvancedSettings')}
className={{ content: 'h-max min-h-max' }}
dataCloseButton={{ cy: 'live-quiz-advanced-settings-close' }}
>
<div className="flex flex-row">
<div className="mr-8 w-1/2">
<FormikNumberField
required
precision={0}
name="maxBonusPoints"
label={t('manage.sessionForms.liveQuizMaxBonusPoints')}
tooltip={t('manage.sessionForms.liveQuizMaxBonusPointsTooltip', {
defaultValue: LQ_MAX_BONUS_POINTS,
})}
data={{
cy: 'live-quiz-max-bonus-points',
}}
/>
<FormikNumberField
required
precision={0}
name="timeToZeroBonus"
label={t('manage.sessionForms.liveQuizTimeToZeroBonus')}
tooltip={t('manage.sessionForms.liveQuizTimeToZeroBonusTooltip', {
defaultValue: LQ_TIME_TO_ZERO_BONUS,
})}
data={{
cy: 'live-quiz-time-to-zero-bonus',
}}
/>
</div>
<div className="w-1/2">
<div className="mb-2 font-bold">
{t('manage.sessionForms.liveQuizTotalPointsCorrect')}
</div>
<ResponsiveContainer className="mb-4" height={150}>
<LineChart
data={[
{
time: 0,
points:
SUMMED_CORRECT_PTS + (parseInt(maxBonusValue, 10) || 0),
},
{
time: parseInt(timeToZeroValue, 10) || 0,
points: SUMMED_CORRECT_PTS,
},
{
time: 2 * (parseInt(timeToZeroValue, 10) || 0),
points: SUMMED_CORRECT_PTS,
},
]}
margin={{ top: 0, right: 20, left: -20, bottom: 13 }}
height={150}
>
<CartesianGrid strokeDasharray="6 6" />

<XAxis
dataKey="time"
domain={[0, 2 * parseInt(timeToZeroValue)]}
type="number"
>
<Label
value={t('manage.sessionForms.liveQuizTSinceFirstCorrect')}
offset={-10}
position="insideBottom"
/>
</XAxis>
<YAxis
dataKey="points"
domain={[0, parseInt(maxBonusValue) + SUMMED_CORRECT_PTS + 10]}
type="number"
/>

<Line type="linear" dataKey="points" stroke="#0028a5" />

<RechartsTooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const time = payload[0].payload.time
const points = payload[0].payload.points

return (
<div className="border-primary-100 rounded border border-solid bg-white p-2 text-gray-600">
<div>
{t('manage.sessionForms.liveQuizAnswerTime', {
answerTime: time,
})}{' '}
</div>
<div>
{t('manage.sessionForms.liveQuizTotalAwardedPoints', {
totalPoints: points,
})}
</div>
</div>
)
}

return null
}}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</Modal>
)
}

export default AdvancedLiveQuizSettings
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { twMerge } from 'tailwind-merge'
import CreationFormValidator from '../CreationFormValidator'
import MultiplierSelector from '../MultiplierSelector'
import WizardNavigation from '../WizardNavigation'
import AdvancedLiveQuizSettings from './AdvancedLiveQuizSettings'
import LiveQuizCourseMonitor from './LiveQuizCourseMonitor'
import { LiveQuizWizardStepProps } from './LiveSessionWizard'

Expand Down Expand Up @@ -66,11 +67,22 @@ function LiveQuizSettingsStep({
values.isGamificationEnabled && 'border-orange-400'
)}
>
<div className="flex flex-row items-center justify-center gap-2">
<FontAwesomeIcon icon={faCrown} className="text-orange-400" />
<div className="text-lg font-bold">
{t('shared.generic.gamification')}
<div className="grid grid-cols-9">
<div className="col-span-7 col-start-2 flex flex-row items-center justify-center gap-2">
<FontAwesomeIcon
icon={faCrown}
className="text-orange-400"
/>
<div className="text-lg font-bold">
{t('shared.generic.gamification')}
</div>
</div>
{values.isGamificationEnabled && (
<AdvancedLiveQuizSettings
maxBonusValue={String(values.maxBonusPoints)}
timeToZeroValue={String(values.timeToZeroBonus)}
/>
)}
</div>
<FormikSelectField
name="courseId"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
Session,
StartSessionDocument,
} from '@klicker-uzh/graphql/dist/ops'
import {
LQ_MAX_BONUS_POINTS,
LQ_TIME_TO_ZERO_BONUS,
} from '@klicker-uzh/shared-components/src/constants'
import useCoursesGamificationSplit from '@lib/hooks/useCoursesGamificationSplit'
import { Button } from '@uzh-bf/design-system'
import { FormikProps } from 'formik'
Expand Down Expand Up @@ -97,6 +101,14 @@ function LiveSessionWizard({
isGamificationEnabled: yup
.boolean()
.required(t('manage.sessionForms.liveQuizGamified')),
maxBonusPoints: yup
.number()
.required(t('manage.sessionForms.liveQuizMaxBonusPointsReq'))
.min(0, t('manage.sessionForms.liveQuizMaxBonusPointsMin')),
timeToZeroBonus: yup
.number()
.required(t('manage.sessionForms.liveQuizTimeToZeroBonusReq'))
.min(1, t('manage.sessionForms.liveQuizTimeToZeroBonusMin')),
})

const questionsValidationSchema = yup.object().shape({
Expand Down Expand Up @@ -134,6 +146,8 @@ function LiveSessionWizard({
blocks: [{ questionIds: [], titles: [], types: [], timeLimit: undefined }],
courseId: '',
multiplier: '1',
maxBonusPoints: LQ_MAX_BONUS_POINTS,
timeToZeroBonus: LQ_TIME_TO_ZERO_BONUS,
isGamificationEnabled: false,
isConfusionFeedbackEnabled: true,
isLiveQAEnabled: false,
Expand Down Expand Up @@ -186,6 +200,10 @@ function LiveSessionWizard({
multiplier: initialValues?.pointsMultiplier
? String(initialValues?.pointsMultiplier)
: formDefaultValues.multiplier,
maxBonusPoints:
initialValues?.maxBonusPoints ?? formDefaultValues.maxBonusPoints,
timeToZeroBonus:
initialValues?.timeToZeroBonus ?? formDefaultValues.timeToZeroBonus,
isGamificationEnabled:
initialValues?.isGamificationEnabled ??
formDefaultValues.isGamificationEnabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ async function submitLiveSessionForm({
blocks: blockQuestions,
courseId: values.courseId,
multiplier: values.courseId !== '' ? parseInt(values.multiplier) : 1,
maxBonusPoints: parseInt(String(values.maxBonusPoints)),
timeToZeroBonus: parseInt(String(values.timeToZeroBonus)),
isGamificationEnabled:
values.courseId !== '' && values.isGamificationEnabled,
isConfusionFeedbackEnabled: values.isConfusionFeedbackEnabled,
Expand Down Expand Up @@ -77,6 +79,8 @@ async function submitLiveSessionForm({
blocks: blockQuestions,
courseId: values.courseId,
multiplier: parseInt(values.multiplier),
maxBonusPoints: parseInt(String(values.maxBonusPoints)),
timeToZeroBonus: parseInt(String(values.timeToZeroBonus)),
isGamificationEnabled:
values.courseId !== '' && values.isGamificationEnabled,
isConfusionFeedbackEnabled: values.isConfusionFeedbackEnabled,
Expand Down
18 changes: 12 additions & 6 deletions apps/func-response-processor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,12 @@ const serviceBusTrigger = async function (
pointsAwarded = computeAwardedPoints({
firstResponseReceivedAt,
responseTimestamp,
maxBonus: MAX_BONUS_POINTS,
timeToZeroBonus: TIME_TO_ZERO_BONUS,
maxBonus: isNaN(parseInt(instanceInfo.maxBonusPoints, 10))
? MAX_BONUS_POINTS
: parseInt(instanceInfo.maxBonusPoints, 10),
timeToZeroBonus: isNaN(parseInt(instanceInfo.timeToZeroBonus, 10))
? TIME_TO_ZERO_BONUS
: parseInt(instanceInfo.timeToZeroBonus, 10),
defaultPoints: DEFAULT_POINTS,
defaultCorrectPoints: DEFAULT_CORRECT_POINTS,
pointsPercentage,
Expand Down Expand Up @@ -247,9 +251,10 @@ const serviceBusTrigger = async function (
pointsAwarded = computeAwardedPoints({
firstResponseReceivedAt,
responseTimestamp,
maxBonus: MAX_BONUS_POINTS,
getsMaxPoints: parsedSolutions && answerCorrect === 1,
timeToZeroBonus: TIME_TO_ZERO_BONUS,
maxBonus: parseInt(instanceInfo.maxBonusPoints) ?? MAX_BONUS_POINTS,
timeToZeroBonus:
parseInt(instanceInfo.timeToZeroBonus) ?? TIME_TO_ZERO_BONUS,
defaultPoints: DEFAULT_POINTS,
defaultCorrectPoints: DEFAULT_CORRECT_POINTS,
pointsMultiplier,
Expand Down Expand Up @@ -307,9 +312,10 @@ const serviceBusTrigger = async function (
pointsAwarded = computeAwardedPoints({
firstResponseReceivedAt,
responseTimestamp,
maxBonus: MAX_BONUS_POINTS,
getsMaxPoints: Boolean(answerCorrect),
timeToZeroBonus: TIME_TO_ZERO_BONUS,
maxBonus: parseInt(instanceInfo.maxBonusPoints) ?? MAX_BONUS_POINTS,
timeToZeroBonus:
parseInt(instanceInfo.timeToZeroBonus) ?? TIME_TO_ZERO_BONUS,
defaultPoints: DEFAULT_POINTS,
defaultCorrectPoints: DEFAULT_CORRECT_POINTS,
pointsMultiplier,
Expand Down
34 changes: 31 additions & 3 deletions cypress/cypress/e2e/F-live-quiz-workflow.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ describe('Different live-quiz workflows', () => {
const courseGamified = 'Testkurs'
const courseNonGamified = 'Non-Gamified Course'

const maxBonusPoints = 200
const timeToZeroBonus = 100

cy.get('[data-cy="create-question"]').click()
cy.get('[data-cy="insert-question-title"]').type(questionTitle1)
cy.get('[data-cy="insert-question-text"]').click().type(question1)
Expand Down Expand Up @@ -68,6 +71,7 @@ describe('Different live-quiz workflows', () => {
.should('exist')
.contains(messages.manage.sessionForms.liveQuizNoCourse)
cy.get('[data-cy="select-multiplier"]').should('not.exist')
cy.get('[data-cy="live-quiz-advanced-settings"]').should('not.exist')
cy.get('[data-cy="select-course"]').click()
cy.get(`[data-cy="select-course-${courseGamified}"]`).click()
cy.get('[data-cy="select-course"]').contains(courseGamified)
Expand All @@ -79,6 +83,18 @@ describe('Different live-quiz workflows', () => {
cy.get('[data-cy="select-course"]').click()
cy.get(`[data-cy="select-course-${courseGamified}"]`).click()
cy.get('[data-cy="select-course"]').contains(courseGamified)

cy.get('[data-cy="live-quiz-advanced-settings"]').should('exist').click()
cy.get('[data-cy="live-quiz-max-bonus-points"]')
.click()
.clear()
.type(String(maxBonusPoints))
cy.get('[data-cy="live-quiz-time-to-zero-bonus"]')
.click()
.clear()
.type(String(timeToZeroBonus))
cy.get('[data-cy="live-quiz-advanced-settings-close"]').click()

cy.get('[data-cy="select-multiplier"]').should('exist')
cy.get('[data-cy="select-multiplier"]')
.should('exist')
Expand Down Expand Up @@ -261,6 +277,16 @@ describe('Different live-quiz workflows', () => {

// check settings and modify them
cy.get('[data-cy="select-course"]').contains(courseGamified)
cy.get('[data-cy="live-quiz-advanced-settings"]').should('exist').click()
cy.get('[data-cy="live-quiz-max-bonus-points"]').should(
'have.value',
maxBonusPoints
)
cy.get('[data-cy="live-quiz-time-to-zero-bonus"]').should(
'have.value',
timeToZeroBonus
)
cy.get('[data-cy="live-quiz-advanced-settings-close"]').click()
cy.get('[data-cy="select-multiplier"]').contains(
messages.manage.sessionForms.multiplier2
)
Expand Down Expand Up @@ -904,9 +930,11 @@ describe('Different live-quiz workflows', () => {
cy.get(`[data-cy="session-cockpit-${sessionName}"]`).click()
cy.get(`[data-cy="open-feedback-${feedback1}"]`).should('exist').click()
cy.get(`[data-cy="pin-feedback-${feedback1}"]`).click()
cy.get(`[data-cy="open-lecturer-overview-session-${sessionName}"]`).click()
cy.findByText(feedback1).should('exist')
cy.findByText(feedback2).should('not.exist')

// is now added in separate tab and therefore not visible in test
// cy.get(`[data-cy="open-lecturer-overview-session-${sessionName}"]`).click()
// cy.findByText(feedback1).should('exist')
// cy.findByText(feedback2).should('not.exist')

// delete feedback response
cy.visit(Cypress.env('URL_MANAGE'))
Expand Down
4 changes: 4 additions & 0 deletions packages/graphql/src/graphql/ops/MCreateSession.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ mutation CreateSession(
$blocks: [BlockInput!]!
$courseId: String
$multiplier: Int!
$maxBonusPoints: Int!
$timeToZeroBonus: Int!
$isGamificationEnabled: Boolean!
$isConfusionFeedbackEnabled: Boolean!
$isLiveQAEnabled: Boolean!
Expand All @@ -17,6 +19,8 @@ mutation CreateSession(
blocks: $blocks
courseId: $courseId
multiplier: $multiplier
maxBonusPoints: $maxBonusPoints
timeToZeroBonus: $timeToZeroBonus
isGamificationEnabled: $isGamificationEnabled
isConfusionFeedbackEnabled: $isConfusionFeedbackEnabled
isLiveQAEnabled: $isLiveQAEnabled
Expand Down
Loading

0 comments on commit 9b5d199

Please sign in to comment.