Skip to content

Commit 7162c3d

Browse files
committed
refactor(manage): share escape room config fields, i18n, order lock and validation bounds
- Extract EscapeRoomSettingsFields shared component (Checkbox + time limit + hint penalty) used by practice quiz, microlearning, group activity settings steps (M6) - Extract useEscapeRoomYupFields hook with i18n-backed validation bounds: time limit integer/positive/max 1440min, hint penalty integer/min 0/max 3600s (M4) - Force sequential order and disable the order selector when escape mode is enabled in practice quiz (M5) - Add 10 escape room i18n keys to en + de (labels, tooltips, validation messages) - Sync statsAggregatedAt to apps/analytics prisma schema (prisma:sync drift from B4)
1 parent 8fd46c8 commit 7162c3d

11 files changed

Lines changed: 140 additions & 179 deletions

File tree

apps/analytics/prisma/schema/quiz.prisma

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,11 @@ model EscapeRoomAttempt {
510510
completedAt DateTime?
511511
lockoutUntil DateTime?
512512
513+
// Set by the prune job once this attempt has been rolled into instance
514+
// statistics. Guards against double-counting across repeated prune runs;
515+
// completed/expired attempts are retained as the integrity record.
516+
statsAggregatedAt DateTime?
517+
513518
// Individual Play (PracticeQuiz / MicroLearning / LiveQuiz block)
514519
participant Participant? @relation(fields: [participantId], references: [id], onDelete: Cascade)
515520
participantId String? @db.Uuid
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Checkbox, FormikNumberField } from '@uzh-bf/design-system'
2+
import { useTranslations } from 'next-intl'
3+
4+
interface EscapeRoomSettingsFieldsProps {
5+
isEscapeRoom: boolean
6+
onToggle: (nextEnabled: boolean) => void
7+
}
8+
9+
function EscapeRoomSettingsFields({
10+
isEscapeRoom,
11+
onToggle,
12+
}: EscapeRoomSettingsFieldsProps) {
13+
const t = useTranslations()
14+
15+
return (
16+
<div className="mt-2 flex flex-col gap-2 border-t border-solid border-gray-200 pt-2">
17+
<Checkbox
18+
label={t('manage.activityWizard.escapeRoomMode')}
19+
checked={isEscapeRoom}
20+
onCheck={() => onToggle(!isEscapeRoom)}
21+
className={{
22+
indicator: 'text-xs',
23+
root: 'w-4.5 h-4.5',
24+
}}
25+
data={{ cy: 'toggle-escape-room' }}
26+
/>
27+
{isEscapeRoom && (
28+
<>
29+
<FormikNumberField
30+
name="escapeRoomTimeLimit"
31+
label={t('manage.activityWizard.escapeRoomTimeLimit')}
32+
required
33+
className={{ root: 'w-full', field: 'w-full' }}
34+
data={{ cy: 'escape-room-time-limit' }}
35+
/>
36+
<FormikNumberField
37+
name="escapeRoomHintPenalty"
38+
label={t('manage.activityWizard.escapeRoomHintPenalty')}
39+
required
40+
className={{ root: 'w-full', field: 'w-full' }}
41+
data={{ cy: 'escape-room-hint-penalty' }}
42+
/>
43+
</>
44+
)}
45+
</div>
46+
)
47+
}
48+
49+
export default EscapeRoomSettingsFields
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useTranslations } from 'next-intl'
2+
import * as yup from 'yup'
3+
4+
// note: named with a `use` prefix (and called as a hook) rather than taking
5+
// `t` as a parameter, since typing the parameter explicitly as
6+
// `ReturnType<typeof useTranslations>` blows up tsc with TS2590 ("union type
7+
// too complex") given how large this repo's generated Messages type already is
8+
export function useEscapeRoomYupFields() {
9+
const t = useTranslations()
10+
11+
return {
12+
isEscapeRoom: yup.boolean(),
13+
escapeRoomTimeLimit: yup.number().when('isEscapeRoom', {
14+
is: true,
15+
then: (schema) =>
16+
schema
17+
.required(t('manage.activityWizard.escapeRoomTimeLimitRequired'))
18+
.integer(t('manage.activityWizard.escapeRoomInteger'))
19+
.positive(t('manage.activityWizard.escapeRoomTimeLimitPositive'))
20+
.max(1440, t('manage.activityWizard.escapeRoomTimeLimitMax')),
21+
otherwise: (schema) => schema.notRequired(),
22+
}),
23+
escapeRoomHintPenalty: yup.number().when('isEscapeRoom', {
24+
is: true,
25+
then: (schema) =>
26+
schema
27+
.required(t('manage.activityWizard.escapeRoomHintPenaltyRequired'))
28+
.integer(t('manage.activityWizard.escapeRoomInteger'))
29+
.min(0, t('manage.activityWizard.escapeRoomHintPenaltyMin'))
30+
.max(3600, t('manage.activityWizard.escapeRoomHintPenaltyMax')),
31+
otherwise: (schema) => schema.notRequired(),
32+
}),
33+
}
34+
}

apps/frontend-manage/src/components/activities/creation/groupActivity/GroupActivitySettingsStep.tsx

Lines changed: 5 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import { faClock, faCrown } from '@fortawesome/free-solid-svg-icons'
22
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
33
import {
4-
Checkbox,
54
FormikDatetimePicker,
6-
FormikNumberField,
75
FormikSelectField,
86
UserNotification,
97
} from '@uzh-bf/design-system'
@@ -14,6 +12,7 @@ import useGroupActivityCourseGrouping from '../../../../lib/hooks/useGroupActivi
1412
import CourseChangeMonitor from '../CourseChangeMonitor'
1513
import CreationFormValidator from '../CreationFormValidator'
1614
import DateChangeMonitor from '../DateChangeMonitor'
15+
import EscapeRoomSettingsFields from '../EscapeRoomSettingsFields'
1716
import MultiplierSelector from '../MultiplierSelector'
1817
import WizardNavigation from '../WizardNavigation'
1918
import { GroupActivityWizardStepProps } from './GroupActivityWizard'
@@ -150,44 +149,10 @@ function GroupActivitySettingsStep({
150149
dataHours={{ cy: 'select-end-date-hours' }}
151150
dataMinutes={{ cy: 'select-end-date-minutes' }}
152151
/>
153-
<div className="mt-2 flex flex-col gap-2 border-t border-solid border-gray-200 pt-2">
154-
<Checkbox
155-
label="Escape Room Mode"
156-
checked={!!values.isEscapeRoom}
157-
onCheck={() =>
158-
setFieldValue('isEscapeRoom', !values.isEscapeRoom)
159-
}
160-
className={{
161-
indicator: 'text-xs',
162-
root: 'w-4.5 h-4.5',
163-
}}
164-
data={{ cy: 'toggle-escape-room' }}
165-
/>
166-
{values.isEscapeRoom && (
167-
<>
168-
<FormikNumberField
169-
name="escapeRoomTimeLimit"
170-
label="Time Limit (minutes)"
171-
required
172-
className={{
173-
root: 'w-full',
174-
field: 'w-full',
175-
}}
176-
data={{ cy: 'escape-room-time-limit' }}
177-
/>
178-
<FormikNumberField
179-
name="escapeRoomHintPenalty"
180-
label="Hint Penalty (seconds)"
181-
required
182-
className={{
183-
root: 'w-full',
184-
field: 'w-full',
185-
}}
186-
data={{ cy: 'escape-room-hint-penalty' }}
187-
/>
188-
</>
189-
)}
190-
</div>
152+
<EscapeRoomSettingsFields
153+
isEscapeRoom={!!values.isEscapeRoom}
154+
onToggle={(next) => setFieldValue('isEscapeRoom', next)}
155+
/>
191156
</div>
192157
</div>
193158
</div>

apps/frontend-manage/src/components/activities/creation/groupActivity/GroupActivityWizard.tsx

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import * as yup from 'yup'
1717
import useCoursesGroupActivitySplit from '../../../../lib/hooks/useCoursesGroupActivitySplit'
1818
import { ElementSelectCourse } from '../../ActivityCreation'
1919
import CompletionStep from '../CompletionStep'
20+
import { useEscapeRoomYupFields } from '../escapeRoomValidation'
2021
import WizardLayout, {
2122
GroupActivityClueFormValues,
2223
GroupActivityFormValues,
@@ -92,6 +93,7 @@ function GroupActivityWizard({
9293
} = useCoursesGroupActivitySplit({
9394
courseSelection: courses,
9495
})
96+
const escapeRoomYupFields = useEscapeRoomYupFields()
9597

9698
const nameValidationSchema = yup.object().shape({
9799
name: yup
@@ -146,25 +148,7 @@ function GroupActivityWizard({
146148
courseId: yup
147149
.string()
148150
.required(t('manage.activityWizard.groupActivityCourse')),
149-
isEscapeRoom: yup.boolean(),
150-
escapeRoomTimeLimit: yup.number().when('isEscapeRoom', {
151-
is: true,
152-
then: (schema) =>
153-
schema
154-
.required('Time limit is required')
155-
.integer('Must be an integer')
156-
.positive('Must be a positive number of minutes'),
157-
otherwise: (schema) => schema.notRequired(),
158-
}),
159-
escapeRoomHintPenalty: yup.number().when('isEscapeRoom', {
160-
is: true,
161-
then: (schema) =>
162-
schema
163-
.required('Hint penalty is required')
164-
.integer('Must be an integer')
165-
.min(0, 'Must be a non-negative number of seconds'),
166-
otherwise: (schema) => schema.notRequired(),
167-
}),
151+
...escapeRoomYupFields,
168152
})
169153

170154
const stackCluesValiationSchema = yup.object().shape({

apps/frontend-manage/src/components/activities/creation/microLearning/MicroLearningSettingsStep.tsx

Lines changed: 5 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@ import { faCrown } from '@fortawesome/free-solid-svg-icons'
33
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
44
import useGamifiedCourseGrouping from '@lib/hooks/useGamifiedCourseGrouping'
55
import {
6-
Checkbox,
76
FormikDatetimePicker,
8-
FormikNumberField,
97
FormikSelectField,
108
UserNotification,
119
} from '@uzh-bf/design-system'
@@ -16,6 +14,7 @@ import { twMerge } from 'tailwind-merge'
1614
import CourseSelectionMonitorMicrolearning from '../CourseSelectionMonitorMicrolearning'
1715
import CreationFormValidator from '../CreationFormValidator'
1816
import DateChangeMonitor from '../DateChangeMonitor'
17+
import EscapeRoomSettingsFields from '../EscapeRoomSettingsFields'
1918
import MultiplierSelector from '../MultiplierSelector'
2019
import WizardNavigation from '../WizardNavigation'
2120
import { MicroLearningWizardStepProps } from './MicroLearningWizard'
@@ -162,44 +161,10 @@ function MicroLearningSettingsStep({
162161
dataHours={{ cy: 'select-end-date-hours' }}
163162
dataMinutes={{ cy: 'select-end-date-minutes' }}
164163
/>
165-
<div className="mt-2 flex flex-col gap-2 border-t border-solid border-gray-200 pt-2">
166-
<Checkbox
167-
label="Escape Room Mode"
168-
checked={!!values.isEscapeRoom}
169-
onCheck={() =>
170-
setFieldValue('isEscapeRoom', !values.isEscapeRoom)
171-
}
172-
className={{
173-
indicator: 'text-xs',
174-
root: 'w-4.5 h-4.5',
175-
}}
176-
data={{ cy: 'toggle-escape-room' }}
177-
/>
178-
{values.isEscapeRoom && (
179-
<>
180-
<FormikNumberField
181-
name="escapeRoomTimeLimit"
182-
label="Time Limit (minutes)"
183-
required
184-
className={{
185-
root: 'w-full',
186-
field: 'w-full',
187-
}}
188-
data={{ cy: 'escape-room-time-limit' }}
189-
/>
190-
<FormikNumberField
191-
name="escapeRoomHintPenalty"
192-
label="Hint Penalty (seconds)"
193-
required
194-
className={{
195-
root: 'w-full',
196-
field: 'w-full',
197-
}}
198-
data={{ cy: 'escape-room-hint-penalty' }}
199-
/>
200-
</>
201-
)}
202-
</div>
164+
<EscapeRoomSettingsFields
165+
isEscapeRoom={!!values.isEscapeRoom}
166+
onToggle={(next) => setFieldValue('isEscapeRoom', next)}
167+
/>
203168
</div>
204169
</div>
205170
</div>

apps/frontend-manage/src/components/activities/creation/microLearning/MicroLearningWizard.tsx

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Dispatch, SetStateAction, useCallback, useRef, useState } from 'react'
1616
import * as yup from 'yup'
1717
import { ElementSelectCourse } from '../../ActivityCreation'
1818
import CompletionStep from '../CompletionStep'
19+
import { useEscapeRoomYupFields } from '../escapeRoomValidation'
1920
import StackCreationStep from '../StackCreationStep'
2021
import WizardLayout, { MicroLearningFormValues } from '../WizardLayout'
2122
import MicroLearningDescriptionStep from './MicroLearningDescriptionStep'
@@ -86,6 +87,7 @@ function MicroLearningWizard({
8687
useCoursesGamificationSplit({
8788
courseSelection: courses,
8889
})
90+
const escapeRoomYupFields = useEscapeRoomYupFields()
8991

9092
const nameValidationSchema = yup.object().shape({
9193
name: yup.string().required(t('manage.activityWizard.activityName')),
@@ -137,25 +139,7 @@ function MicroLearningWizard({
137139
courseId: yup
138140
.string()
139141
.required(t('manage.activityWizard.microlearningCourse')),
140-
isEscapeRoom: yup.boolean(),
141-
escapeRoomTimeLimit: yup.number().when('isEscapeRoom', {
142-
is: true,
143-
then: (schema) =>
144-
schema
145-
.required('Time limit is required')
146-
.integer('Must be an integer')
147-
.positive('Must be a positive number of minutes'),
148-
otherwise: (schema) => schema.notRequired(),
149-
}),
150-
escapeRoomHintPenalty: yup.number().when('isEscapeRoom', {
151-
is: true,
152-
then: (schema) =>
153-
schema
154-
.required('Hint penalty is required')
155-
.integer('Must be an integer')
156-
.min(0, 'Must be a non-negative number of seconds'),
157-
otherwise: (schema) => schema.notRequired(),
158-
}),
142+
...escapeRoomYupFields,
159143
})
160144

161145
const stackValiationSchema = yup.object().shape({

apps/frontend-manage/src/components/activities/creation/practiceQuiz/PracticeQuizSettingsStep.tsx

Lines changed: 11 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
33
import { ElementOrderType } from '@klicker-uzh/graphql/dist/ops'
44
import useGamifiedCourseGrouping from '@lib/hooks/useGamifiedCourseGrouping'
55
import {
6-
Checkbox,
76
FormikNumberField,
87
FormikSelectField,
98
UserNotification,
@@ -14,6 +13,7 @@ import { useState } from 'react'
1413
import { twMerge } from 'tailwind-merge'
1514
import CourseSelectionMonitorPracticeQuiz from '../CourseSelectionMonitorPracticeQuiz'
1615
import CreationFormValidator from '../CreationFormValidator'
16+
import EscapeRoomSettingsFields from '../EscapeRoomSettingsFields'
1717
import MultiplierSelector from '../MultiplierSelector'
1818
import WizardNavigation from '../WizardNavigation'
1919
import { PracticeQuizWizardStepProps } from './PracticeQuizWizard'
@@ -157,53 +157,22 @@ function PracticeQuizSettingsStep({
157157
}
158158
})}
159159
required
160+
disabled={!!values.isEscapeRoom}
160161
data={{ cy: 'select-order' }}
161162
className={{
162163
root: 'w-full',
163164
tooltip: 'z-20',
164165
}}
165166
/>
166-
<div className="mt-2 flex flex-col gap-2 border-t border-solid border-gray-200 pt-2">
167-
<Checkbox
168-
label="Escape Room Mode"
169-
checked={!!values.isEscapeRoom}
170-
onCheck={() => {
171-
setFieldValue('isEscapeRoom', !values.isEscapeRoom)
172-
if (!values.isEscapeRoom) {
173-
setFieldValue('order', ElementOrderType.Sequential)
174-
}
175-
}}
176-
className={{
177-
indicator: 'text-xs',
178-
root: 'w-4.5 h-4.5',
179-
}}
180-
data={{ cy: 'toggle-escape-room' }}
181-
/>
182-
{values.isEscapeRoom && (
183-
<>
184-
<FormikNumberField
185-
name="escapeRoomTimeLimit"
186-
label="Time Limit (minutes)"
187-
required
188-
className={{
189-
root: 'w-full',
190-
field: 'w-full',
191-
}}
192-
data={{ cy: 'escape-room-time-limit' }}
193-
/>
194-
<FormikNumberField
195-
name="escapeRoomHintPenalty"
196-
label="Hint Penalty (seconds)"
197-
required
198-
className={{
199-
root: 'w-full',
200-
field: 'w-full',
201-
}}
202-
data={{ cy: 'escape-room-hint-penalty' }}
203-
/>
204-
</>
205-
)}
206-
</div>
167+
<EscapeRoomSettingsFields
168+
isEscapeRoom={!!values.isEscapeRoom}
169+
onToggle={(next) => {
170+
setFieldValue('isEscapeRoom', next)
171+
// practice-quiz ONLY: force sequential order when enabling
172+
if (next)
173+
setFieldValue('order', ElementOrderType.Sequential)
174+
}}
175+
/>
207176
</div>
208177
</div>
209178
</div>

0 commit comments

Comments
 (0)