diff --git a/docs/assets/api/schemas.json b/docs/assets/api/schemas.json
index 3746375a7..594820029 100644
--- a/docs/assets/api/schemas.json
+++ b/docs/assets/api/schemas.json
@@ -13,26 +13,12 @@
],
"properties": {
"minParticipantsPerCohort": {
- "anyOf": [
- {
- "type": "null"
- },
- {
- "minimum": 0,
- "type": "integer"
- }
- ]
+ "minimum": 0,
+ "type": ["null", "integer"]
},
"maxParticipantsPerCohort": {
- "anyOf": [
- {
- "type": "null"
- },
- {
- "minimum": 1,
- "type": "integer"
- }
- ]
+ "minimum": 1,
+ "type": ["null", "integer"]
},
"includeAllParticipantsInCohortCount": {
"type": "boolean"
@@ -674,7 +660,7 @@
"descriptions",
"progress",
"timeLimitInMinutes",
- "requireFullTime",
+ "timeMinimumInMinutes",
"discussions"
],
"properties": {
@@ -697,24 +683,12 @@
"$ref": "#/$defs/StageProgressConfig"
},
"timeLimitInMinutes": {
- "anyOf": [
- {
- "type": "number"
- },
- {
- "type": "null"
- }
- ]
+ "minimum": 1,
+ "type": ["integer", "null"]
},
- "requireFullTime": {
- "anyOf": [
- {
- "type": "boolean"
- },
- {
- "type": "null"
- }
- ]
+ "timeMinimumInMinutes": {
+ "minimum": 1,
+ "type": ["integer", "null"]
},
"discussions": {
"type": "array",
@@ -1308,27 +1282,13 @@
"type": "number"
},
"rankingStageId": {
- "anyOf": [
- {
- "type": "null"
- },
- {
- "type": "string"
- }
- ]
+ "type": ["null", "string"]
},
"questionMap": {
"type": "object",
"patternProperties": {
"^(.*)$": {
- "anyOf": [
- {
- "type": "number"
- },
- {
- "type": "null"
- }
- ]
+ "type": ["number", "null"]
}
},
"title": "QuestionMap"
@@ -1345,7 +1305,8 @@
"name",
"descriptions",
"progress",
- "timeLimitInMinutes"
+ "timeLimitInMinutes",
+ "timeMinimumInMinutes"
],
"properties": {
"id": {
@@ -1367,17 +1328,12 @@
"$ref": "#/$defs/StageProgressConfig"
},
"timeLimitInMinutes": {
- "anyOf": [
- {
- "type": "number"
- },
- {
- "type": "null"
- }
- ]
+ "minimum": 1,
+ "type": ["integer", "null"]
},
- "requireFullTime": {
- "type": "boolean"
+ "timeMinimumInMinutes": {
+ "minimum": 1,
+ "type": ["integer", "null"]
},
"isTurnBasedChat": {
"type": "boolean"
@@ -1386,14 +1342,7 @@
"type": "number"
},
"maxNumberOfTurns": {
- "anyOf": [
- {
- "type": "number"
- },
- {
- "type": "null"
- }
- ]
+ "type": ["number", "null"]
},
"preventCancellation": {
"type": "boolean"
@@ -1819,14 +1768,7 @@
"type": "integer"
},
"maxParticipants": {
- "anyOf": [
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ]
+ "type": ["integer", "null"]
}
},
"title": "Role"
@@ -2101,17 +2043,7 @@
]
},
"value": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "number"
- },
- {
- "type": "boolean"
- }
- ]
+ "type": ["string", "number", "boolean"]
}
}
},
@@ -2273,14 +2205,7 @@
}
},
"correctAnswerId": {
- "anyOf": [
- {
- "type": "null"
- },
- {
- "type": "string"
- }
- ]
+ "type": ["null", "string"]
},
"displayType": {
"title": "MultipleChoiceDisplayType",
@@ -2798,34 +2723,13 @@
"required": ["pronouns", "avatar", "name"],
"properties": {
"pronouns": {
- "anyOf": [
- {
- "type": "null"
- },
- {
- "type": "string"
- }
- ]
+ "type": ["null", "string"]
},
"avatar": {
- "anyOf": [
- {
- "type": "null"
- },
- {
- "type": "string"
- }
- ]
+ "type": ["null", "string"]
},
"name": {
- "anyOf": [
- {
- "type": "null"
- },
- {
- "type": "string"
- }
- ]
+ "type": ["null", "string"]
}
}
},
@@ -3135,14 +3039,7 @@
]
},
"reasoningBudget": {
- "anyOf": [
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ]
+ "type": ["integer", "null"]
},
"includeReasoning": {
"type": "boolean"
@@ -3784,14 +3681,7 @@
],
"properties": {
"wordsPerMinute": {
- "anyOf": [
- {
- "type": "number"
- },
- {
- "type": "null"
- }
- ]
+ "type": ["number", "null"]
},
"minMessagesBeforeResponding": {
"type": "integer"
@@ -3800,14 +3690,7 @@
"type": "boolean"
},
"maxResponses": {
- "anyOf": [
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ]
+ "type": ["integer", "null"]
},
"initialMessage": {
"type": "string"
diff --git a/frontend/src/components/chat/chat_info_panel.ts b/frontend/src/components/chat/chat_info_panel.ts
index 62104de2f..a7d3b90a8 100644
--- a/frontend/src/components/chat/chat_info_panel.ts
+++ b/frontend/src/components/chat/chat_info_panel.ts
@@ -18,14 +18,11 @@ import {
ChatStageConfig,
ChatStagePublicData,
MediatorProfile,
- MediatorStatus,
ParticipantProfile,
convertUnifiedTimestampToTime,
+ getTimeElapsed,
} from '@deliberation-lab/utils';
-import {
- getChatStartTimestamp,
- getChatTimeRemainingInSeconds,
-} from '../../shared/stage.utils';
+import {getChatStartTimestamp} from '../../shared/stage.utils';
import {getHashBasedColor} from '../../shared/utils';
import {styles} from './chat_info_panel.scss';
@@ -57,12 +54,12 @@ export class ChatPanel extends MobxLitElement {
return html`
- ${this.renderTimer(true)} ${this.renderParticipantList()}
+ ${this.renderTimer()} ${this.renderParticipantList()}
`;
}
- private renderTimer(showDivider = false) {
+ private renderTimer() {
if (!this.stage) return nothing;
const publicStageData = this.cohortService.stagePublicDataMap[
@@ -93,8 +90,21 @@ export class ChatPanel extends MobxLitElement {
class=${`countdown ${publicStageData.discussionEndTimestamp ? 'ended' : ''}`}
>
ā±ļø Timer: ${this.stage.timeLimitInMinutes} minutes ${renderStatus()}
- ${this.stage.requireFullTime && !publicStageData.discussionEndTimestamp
- ? 'You must stay on this chat for the full time.'
+ ${this.stage.timeMinimumInMinutes &&
+ !publicStageData.discussionEndTimestamp &&
+ publicStageData.discussionStartTimestamp &&
+ getTimeElapsed(publicStageData.discussionStartTimestamp, 'm') <
+ this.stage.timeMinimumInMinutes
+ ? (() => {
+ const remaining = Math.ceil(
+ this.stage.timeMinimumInMinutes -
+ getTimeElapsed(
+ publicStageData.discussionStartTimestamp!,
+ 'm',
+ ),
+ );
+ return `You must stay in this chat for at least ${remaining} more minute${remaining !== 1 ? 's' : ''}.`;
+ })()
: ''}
${this.topLayout ? nothing : html`
`}
diff --git a/frontend/src/components/stages/group_chat_editor.ts b/frontend/src/components/stages/group_chat_editor.ts
index 6db48cdf5..08c8b3752 100644
--- a/frontend/src/components/stages/group_chat_editor.ts
+++ b/frontend/src/components/stages/group_chat_editor.ts
@@ -70,38 +70,35 @@ export class ChatEditor extends MobxLitElement {
}
private renderTimeLimit() {
- const timeLimit = this.stage?.timeLimitInMinutes ?? null;
- const requireFullTime = this.stage?.requireFullTime ?? false;
+ const timeLimit = this.stage?.timeLimitInMinutes;
const updateCheck = () => {
- if (!this.stage) return;
- if (this.stage.timeLimitInMinutes) {
- this.experimentEditor.updateStage({
- ...this.stage,
- timeLimitInMinutes: null,
- });
- } else {
- this.experimentEditor.updateStage({
- ...this.stage,
- timeLimitInMinutes: 20, // Default to 20 if checked
- });
- }
+ const isSet = this.stage?.timeLimitInMinutes != null;
+ this.experimentEditor.updateStage({
+ ...this.stage!,
+ timeLimitInMinutes: isSet ? null : 20,
+ timeMinimumInMinutes: null,
+ });
};
- const updateNum = (e: InputEvent) => {
- if (!this.stage) return;
- const timeLimit = Number((e.target as HTMLTextAreaElement).value);
+ const updateMaxTime = (e: InputEvent) => {
+ const val = (e.target as HTMLInputElement).valueAsNumber;
+ const timeLimitInMinutes = val > 0 ? Math.floor(val) : null;
this.experimentEditor.updateStage({
- ...this.stage,
- timeLimitInMinutes: timeLimit,
+ ...this.stage!,
+ timeLimitInMinutes,
});
};
- const updateRequireFullTime = (e: Event) => {
- if (!this.stage) return;
+ const updateMinTime = (e: InputEvent) => {
+ const val = (e.target as HTMLInputElement).valueAsNumber;
+ const minTime = val > 0 ? Math.floor(val) : null;
+ const max = this.stage?.timeLimitInMinutes;
+ const timeMinimumInMinutes =
+ minTime != null && max != null ? Math.min(minTime, max) : minTime;
this.experimentEditor.updateStage({
- ...this.stage,
- requireFullTime: (e.target as HTMLInputElement).checked,
+ ...this.stage!,
+ timeMinimumInMinutes,
});
};
@@ -110,39 +107,49 @@ export class ChatEditor extends MobxLitElement {
Disable conversation after a fixed amount of time
- ${timeLimit !== null
+ ${timeLimit != null
? html`
- Elapsed time from first message to conversation close (in
- minutes)
+ Maximum time in minutes (starting at first message).
+ Participant will remain in chat until minimum messages
+ requirement is met, even if maximum time has passed.
-
-
+
+ Minimum time participants must stay (in minutes). Takes
+ precedence over maximum number of messages.
+
+
-
-
Require participants to stay until time elapses
+ @input=${updateMinTime}
+ />
`
: nothing}
diff --git a/frontend/src/components/stages/group_chat_participant_view.ts b/frontend/src/components/stages/group_chat_participant_view.ts
index 0d975a4da..80c6289b3 100644
--- a/frontend/src/components/stages/group_chat_participant_view.ts
+++ b/frontend/src/components/stages/group_chat_participant_view.ts
@@ -26,6 +26,7 @@ import {
ChatStageConfig,
DiscussionItem,
StageKind,
+ getTimeElapsed,
} from '@deliberation-lab/utils';
import {styles} from './group_chat_participant_view.scss';
@@ -164,7 +165,7 @@ export class GroupChatView extends MobxLitElement {
}
const onClick = async () => {
- if (!this.stage) return;
+ if (!this.stage || !this.isMinimumTimeMet) return;
this.readyToEndDiscussionLoading = true;
try {
@@ -183,13 +184,16 @@ export class GroupChatView extends MobxLitElement {
this.participantService.isReadyToEndChatDiscussion(
this.stage.id,
currentDiscussionId,
- );
+ ) ||
+ !this.isMinimumTimeMet;
return html`
{
if (!this.stage?.progress.showParticipantProgress) {
@@ -306,6 +293,41 @@ export class GroupChatView extends MobxLitElement {
`;
}
+
+ get isMinimumTimeMet() {
+ if (!this.stage || !this.stage.timeMinimumInMinutes) return true;
+
+ const publicStageData = this.cohortService.stagePublicDataMap[
+ this.stage.id
+ ] as ChatStagePublicData;
+
+ if (!publicStageData?.discussionStartTimestamp) {
+ return false;
+ }
+
+ return (
+ getTimeElapsed(publicStageData.discussionStartTimestamp, 'm') >=
+ this.stage.timeMinimumInMinutes
+ );
+ }
+
+ get minutesRemainingUntilMinimum(): number {
+ if (!this.stage?.timeMinimumInMinutes) return 0;
+
+ const publicStageData = this.cohortService.stagePublicDataMap[
+ this.stage.id
+ ] as ChatStagePublicData;
+
+ if (!publicStageData?.discussionStartTimestamp) {
+ return this.stage.timeMinimumInMinutes;
+ }
+
+ const elapsed = getTimeElapsed(
+ publicStageData.discussionStartTimestamp,
+ 'm',
+ );
+ return Math.ceil(this.stage.timeMinimumInMinutes - elapsed);
+ }
}
declare global {
diff --git a/frontend/src/components/stages/private_chat_editor.ts b/frontend/src/components/stages/private_chat_editor.ts
index 6d9557d34..7020d615c 100644
--- a/frontend/src/components/stages/private_chat_editor.ts
+++ b/frontend/src/components/stages/private_chat_editor.ts
@@ -41,38 +41,35 @@ export class ChatEditor extends MobxLitElement {
}
private renderTimeLimit() {
- const timeLimit = this.stage?.timeLimitInMinutes ?? null;
- const requireFullTime = this.stage?.requireFullTime ?? false;
+ const timeLimit = this.stage?.timeLimitInMinutes;
const updateCheck = () => {
- if (!this.stage) return;
- if (this.stage.timeLimitInMinutes) {
- this.experimentEditor.updateStage({
- ...this.stage,
- timeLimitInMinutes: null,
- });
- } else {
- this.experimentEditor.updateStage({
- ...this.stage,
- timeLimitInMinutes: 20, // Default to 20 if checked
- });
- }
+ const isSet = this.stage?.timeLimitInMinutes != null;
+ this.experimentEditor.updateStage({
+ ...this.stage!,
+ timeLimitInMinutes: isSet ? null : 20,
+ timeMinimumInMinutes: null,
+ });
};
- const updateNum = (e: InputEvent) => {
- if (!this.stage) return;
- const timeLimit = Number((e.target as HTMLTextAreaElement).value);
+ const updateMaxTime = (e: InputEvent) => {
+ const val = (e.target as HTMLInputElement).valueAsNumber;
+ const timeLimitInMinutes = val > 0 ? Math.floor(val) : null;
this.experimentEditor.updateStage({
- ...this.stage,
- timeLimitInMinutes: timeLimit,
+ ...this.stage!,
+ timeLimitInMinutes,
});
};
- const updateRequireFullTime = (e: Event) => {
- if (!this.stage) return;
+ const updateMinTime = (e: InputEvent) => {
+ const val = (e.target as HTMLInputElement).valueAsNumber;
+ const minTime = val > 0 ? Math.floor(val) : null;
+ const max = this.stage?.timeLimitInMinutes;
+ const timeMinimumInMinutes =
+ minTime != null && max != null ? Math.min(minTime, max) : minTime;
this.experimentEditor.updateStage({
- ...this.stage,
- requireFullTime: (e.target as HTMLInputElement).checked,
+ ...this.stage!,
+ timeMinimumInMinutes,
});
};
@@ -81,39 +78,49 @@ export class ChatEditor extends MobxLitElement {
Disable conversation after a fixed amount of time
- ${timeLimit !== null
+ ${timeLimit != null
? html`
- Elapsed time from first message to conversation close (in
- minutes)
+ Maximum time in minutes (starting at first message).
+ Participant will remain in chat until minimum messages
+ requirement is met, even if maximum time has passed.
-
-
+
+ Minimum time participants must stay (in minutes). Takes
+ precedence over maximum number of messages.
+
+
-
-
Require participants to stay until time elapses
+ @input=${updateMinTime}
+ />
`
: nothing}
@@ -183,12 +190,17 @@ export class ChatEditor extends MobxLitElement {
private renderMinNumberOfTurns() {
const minNumberOfTurns = this.stage?.minNumberOfTurns ?? 0;
+ const maxNumberOfTurns = this.stage?.maxNumberOfTurns ?? null;
+
const updateNum = (e: InputEvent) => {
if (!this.stage) return;
- const value = Number((e.target as HTMLInputElement).value);
+ let value = Math.max(0, Number((e.target as HTMLInputElement).value));
+ if (maxNumberOfTurns !== null) {
+ value = Math.min(value, maxNumberOfTurns);
+ }
this.experimentEditor.updateStage({
...this.stage,
- minNumberOfTurns: Math.max(0, value),
+ minNumberOfTurns: value,
});
};
@@ -196,13 +208,15 @@ export class ChatEditor extends MobxLitElement {