Skip to content

Commit 46c683e

Browse files
Mutugiiidogi
authored andcommitted
manager: smoother survey rating scale (fixes #9068) (#9070)
1 parent 31ad9fa commit 46c683e

File tree

11 files changed

+123
-40
lines changed

11 files changed

+123
-40
lines changed

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"name": "planet",
33
"license": "AGPL-3.0",
4-
"version": "0.20.18",
4+
"version": "0.20.19",
55
"myplanet": {
6-
"latest": "v0.30.73",
7-
"min": "v0.29.73"
6+
"latest": "v0.30.87",
7+
"min": "v0.29.87"
88
},
99
"scripts": {
1010
"ng": "ng",

src/app/exams/exams-add.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
<button mat-menu-item type="button" (click)="addQuestion('textarea')" i18n>Text - Long answer</button>
2626
<button mat-menu-item type="button" (click)="addQuestion('select')" i18n>Multiple Choice - single answer</button>
2727
<button mat-menu-item type="button" (click)="addQuestion('selectMultiple')" i18n>Multiple Choice - multiple answer</button>
28+
<button *ngIf="examType !== 'exam'" mat-menu-item type="button" (click)="addQuestion('ratingScale')" i18n>Rating Scale - 1 to 9</button>
2829
</mat-menu>
2930
<mat-accordion class="exam-inputs" *ngIf="!isCourseContent">
3031
<mat-expansion-panel [expanded]="!isQuestionsActive">

src/app/exams/exams-question.component.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
<mat-option value="textarea" i18n>Text - Long answer</mat-option>
77
<mat-option value="select" i18n>Multiple Choice - single answer</mat-option>
88
<mat-option value="selectMultiple" i18n>Multiple Choice - multiple answer</mat-option>
9+
<mat-option *ngIf="examType !== 'exam'" value="ratingScale" i18n>Rating Scale - 1 to 9</mat-option>
910
</mat-select>
1011
</mat-form-field>
1112
</div>
@@ -20,7 +21,7 @@
2021
You must add a choice
2122
</span>
2223
</div>
23-
<div i18n class="mat-caption warn-text-color" *ngIf="!questionForm.controls.correctChoice.valid && questionForm.controls.correctChoice.touched">
24+
<div i18n class="mat-caption warn-text-color" *ngIf="!questionForm.controls.correctChoice.valid && questionForm.controls.correctChoice.touched && questionForm.controls.type.value !== 'ratingScale'">
2425
You must select the correct {questionForm.controls.type.value, select, select {choice} selectMultiple {choices}}
2526
</div>
2627
<div *ngFor="let choice of choices.controls; index as i; trackBy: trackByFn">

src/app/exams/exams-view.component.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@
7777
</div>
7878
</ng-container>
7979
</div>
80+
<div *ngSwitchCase="'ratingScale'" class="rating-scale-keypad">
81+
<div class="rating-scale-grid">
82+
<button type="button" mat-raised-button class="rating-scale-button" *ngFor="let num of [1,2,3,4,5,6,7,8,9]" [class.selected]="answer.value === num.toString()" (click)="setRatingScaleAnswer(num)">
83+
{{num}}
84+
</button>
85+
</div>
86+
</div>
8087
</ng-container>
8188
</ng-container>
8289
<ng-container *ngSwitchCase="'grade'">

src/app/exams/exams-view.component.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,11 @@ export class ExamsViewComponent implements OnInit, OnDestroy {
309309
this.checkboxState[option.id] = event.checked;
310310
}
311311

312+
setRatingScaleAnswer(number: number) {
313+
this.answer.setValue(number.toString());
314+
this.answer.updateValueAndValidity();
315+
}
316+
312317
calculateCorrect() {
313318
const value = this.answer.value;
314319
const answers = value instanceof Array ? value : [ value ];

src/app/exams/exams-view.scss

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@import '../variables';
2+
13
:host {
24

35
.view-container {
@@ -48,4 +50,40 @@
4850
outline: none;
4951
max-width: 50%;
5052
}
53+
54+
.rating-scale-keypad {
55+
display: flex;
56+
justify-content: flex-start;
57+
margin: 20px 0;
58+
}
59+
60+
.rating-scale-grid {
61+
display: grid;
62+
grid-template-columns: repeat(3, 1fr);
63+
gap: 10px;
64+
max-width: 100px;
65+
width: 100%;
66+
}
67+
68+
.rating-scale-button {
69+
height: 50px;
70+
font-size: 24px;
71+
font-weight: 500;
72+
border: 2px solid #ddd;
73+
border-radius: 8px;
74+
transition: all 0.2s ease;
75+
76+
&:hover {
77+
border-color: $primary;
78+
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
79+
}
80+
81+
&.selected {
82+
background-color: $primary;
83+
color: white;
84+
border-color: $primary;
85+
box-shadow: 0 4px 8px rgba(33, 150, 243, 0.3);
86+
}
87+
}
88+
5189
}

src/app/exams/exams.model.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export interface Exam {
1313

1414
export interface ExamQuestion {
1515
body: string;
16-
type: 'input' | 'textarea' | 'select' | 'selectMultiple';
16+
type: 'input' | 'textarea' | 'select' | 'selectMultiple' | 'ratingScale';
1717
correctChoice: string[];
1818
marks: number;
1919
choices: { text: string, id: string }[];

src/app/exams/exams.service.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,8 @@ export class ExamsService {
2929
}
3030

3131
choiceRequiredValidator(ac) {
32-
return ac.get('type').value === 'select' || ac.get('type').value === 'selectMultiple' ?
33-
Validators.required(ac.get('choices')) && { noChoices: true } :
34-
null;
32+
const questionType = ac.get('type').value;
33+
return questionType === 'select' || questionType === 'selectMultiple' ? Validators.required(ac.get('choices')) && { noChoices: true } : null;
3534
}
3635

3736
newQuestionChoice(newId, intialValue?) {

src/app/shared/ai-prompts.constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export const surveyAnalysisPrompt = (examType, examName, examDescription, payloa
44
Please generate a detailed AI Analysis for PDF export, organized into 4 sections:
55
66
1. INDIVIDUAL QUESTION ANALYSIS
7-
If the question is a **Closed-ended questions(type - select or selectMultiple):**
7+
If the question is a **Closed-ended questions(type - select or selectMultiple or rating scale [1-9 choices]):**
88
- List the top three answer choices with absolute counts and percentages.
99
- In addition to the top three, highlight any answer choice with fewer than 10% of responses and suggest why it might be under-selected
1010
- Create a hypothesis for the selections

src/app/submissions/submissions.service.ts

Lines changed: 62 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ export class SubmissionsService {
351351
this.setHeader(docContent, 'Charts');
352352
for (let i = 0; i < exam.questions.length; i++) {
353353
const question = exam.questions[i];
354-
if (question.type !== 'select' && question.type !== 'selectMultiple') { continue; }
354+
if (question.type !== 'select' && question.type !== 'selectMultiple' && question.type !== 'ratingScale') { continue; }
355355
question.index = i;
356356
docContent.push({ text: `Q${i + 1}: ${question.body}` });
357357
if (question.type === 'selectMultiple') {
@@ -389,6 +389,16 @@ export class SubmissionsService {
389389
],
390390
alignment: 'center'
391391
});
392+
} else if (question.type === 'ratingScale') {
393+
const ratingScaleAgg = this.aggregateQuestionResponses(question, updatedSubmissions, 'count');
394+
const ratingScaleImg = await this.generateChartImage(ratingScaleAgg);
395+
docContent.push({
396+
stack: [
397+
{ image: ratingScaleImg, width: 300, alignment: 'center', margin: [ 0, 10, 0, 10 ] },
398+
{ text: `Total respondents: ${updatedSubmissions.length}`, alignment: 'center' }
399+
],
400+
alignment: 'center'
401+
});
392402
} else {
393403
const pieAgg = this.aggregateQuestionResponses(question, updatedSubmissions, 'count');
394404
const pieImg = await this.generateChartImage(pieAgg);
@@ -552,16 +562,18 @@ export class SubmissionsService {
552562
canvas.width = 300;
553563
canvas.height = 400;
554564
const isBar = data.chartType === 'bar';
565+
const isRatingScale = data.isRatingScale || false;
555566
const ctx = canvas.getContext('2d');
556567

557568
return new Promise<string>((resolve) => {
569+
const maxCount = Math.max(...data.data);
558570
const chartConfig: ChartConfiguration<'bar' | 'doughnut'> = {
559571
type: isBar ? 'bar' : 'doughnut',
560572
data: {
561573
labels: data.labels,
562574
datasets: [ {
563575
data: data.data,
564-
label: isBar ? '% of responders/selection' : undefined,
576+
label: isRatingScale ? 'selection/choices(1-9)' : (isBar ? '% of responders/selection' : undefined),
565577
backgroundColor: [
566578
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40', '#C9CBCF', '#8DD4F2', '#A8E6CF', '#DCE775'
567579
],
@@ -571,25 +583,33 @@ export class SubmissionsService {
571583
responsive: false,
572584
maintainAspectRatio: false,
573585
indexAxis: 'x',
586+
plugins: {
587+
legend: {
588+
display: true,
589+
labels: {
590+
boxWidth: isBar ? 0 : 50,
591+
boxHeight: isBar ? 0 : 20
592+
}
593+
}
594+
},
574595
scales: isBar ? {
575596
y: {
576597
type: 'linear',
577598
beginAtZero: true,
578-
max: 100,
579-
ticks: { precision: 0 }
599+
max: isRatingScale ? maxCount > 0 ? Math.ceil(maxCount / 10) * 10 : 10 : 100,
600+
ticks: { precision: 0, stepSize: 2 }
580601
}
581602
} : {},
582603
animation: {
583604
onComplete: function() {
584605
if (isBar && data.userCounts) {
585606
this.getDatasetMeta(0).data.forEach((bar, index) => {
586-
const percentage = data.data[index];
587-
const userCount = data.userCounts[index];
588-
if (percentage > 0) {
589-
ctx.fillText(`${userCount}`, bar.x - 2.5 , bar.y);
607+
const count = data.userCounts[index];
608+
if (count > 0) {
609+
ctx.fillText(`${count}`, bar.x - 2.5 , bar.y);
590610
}
591611
});
592-
} else if (!isBar) {
612+
} else {
593613
const total = data.data.reduce((sum, val) => sum + val, 0);
594614
this.getDatasetMeta(0).data.forEach((element, index) => {
595615
const count = data.data[index];
@@ -609,37 +629,47 @@ export class SubmissionsService {
609629
});
610630
}
611631

612-
aggregateQuestionResponses(
613-
question,
614-
submissions,
615-
mode: 'percent' | 'count' = 'percent',
616-
calculationMode: 'users' | 'selections' = 'users'
617-
) {
632+
aggregateQuestionResponses(question, submissions, mode: 'percent' | 'count' = 'percent', calculationMode: 'users' | 'selections' = 'users') {
618633
const totalUsers = submissions.length;
619634
const counts: Record<string, Set<string>> = {};
620635

621-
question.choices.forEach(c => { counts[c.text] = new Set(); });
622-
if (question.hasOtherOption) {
623-
counts['Other'] = new Set();
636+
if (question.type === 'ratingScale') {
637+
for (let i = 1; i <= 9; i++) {
638+
counts[i.toString()] = new Set();
639+
}
640+
} else {
641+
question.choices.forEach(c => { counts[c.text] = new Set(); });
642+
if (question.hasOtherOption) {
643+
counts['Other'] = new Set();
644+
}
624645
}
625646

626647
submissions.forEach((sub, submissionIndex) => {
627648
const ans = sub.answers[question.index];
628649
if (!ans) { return; }
629650

630651
const userId = sub.user?._id || sub.user?.name || sub._id || `submission_${submissionIndex}`;
631-
const selections = question.type === 'selectMultiple' ? ans.value ?? [] : ans.value ? [ ans.value ] : [];
632-
selections.forEach(selection => {
633-
if (selection.isOther || selection.id === 'other') {
634-
counts['Other']?.add(userId);
635-
} else {
636-
const txt = selection.text ?? selection;
637-
counts[txt]?.add(userId);
652+
if (question.type === 'ratingScale') {
653+
if (ans.value) {
654+
const value = ans.value.toString();
655+
if (counts[value]) {
656+
counts[value].add(userId);
657+
}
638658
}
639-
});
659+
} else {
660+
const selections = question.type === 'selectMultiple' ? ans.value ?? [] : ans.value ? [ ans.value ] : [];
661+
selections.forEach(selection => {
662+
if (selection.isOther || selection.id === 'other') {
663+
counts['Other']?.add(userId);
664+
} else {
665+
const txt = selection.text ?? selection;
666+
counts[txt]?.add(userId);
667+
}
668+
});
669+
}
640670
});
641671

642-
const labels = Object.keys(counts);
672+
const labels = question.type === 'ratingScale' ? Array.from({length: 9}, (_, i) => (i + 1).toString()) : Object.keys(counts);
643673
const userCounts = labels.map(l => counts[l].size);
644674
const totalSelections = userCounts.reduce((sum, count) => sum + count, 0);
645675
let data: number[];
@@ -664,7 +694,8 @@ export class SubmissionsService {
664694
userCounts,
665695
totalUsers,
666696
totalSelections,
667-
chartType: question.type === 'selectMultiple' ? (mode === 'percent' ? 'bar' : 'pie') : 'pie'
697+
chartType: question.type === 'ratingScale' ? 'bar' : (question.type === 'selectMultiple' ? (mode === 'percent' ? 'bar' : 'pie') : 'pie'),
698+
isRatingScale: question.type === 'ratingScale'
668699
};
669700
}
670701

@@ -686,6 +717,9 @@ export class SubmissionsService {
686717
case 'selectMultiple':
687718
result = answer.value.map(item => item.text).join(', ');
688719
break;
720+
case 'ratingScale':
721+
result = `Rating: ${answer.value} (on 1-9 scale)`;
722+
break;
689723
default:
690724
result = answer.value;
691725
}

0 commit comments

Comments
 (0)