From 8fabadb71adbb687748d247d93ff51d191974824 Mon Sep 17 00:00:00 2001 From: Aaron Parisi Date: Tue, 17 Mar 2026 03:02:02 +0000 Subject: [PATCH] fix: enforce minimum time constraints for group and private chats correctly --- .../components/stages/group_chat_editor.ts | 25 ++++++++++ .../stages/group_chat_participant_view.ts | 33 +++++++++++-- .../components/stages/private_chat_editor.ts | 25 ++++++++++ .../stages/private_chat_participant_view.ts | 47 +++++++++++++++++-- functions/src/chat/chat.utils.ts | 31 ++++++++++++ functions/src/stages/chat.time.ts | 8 ++++ utils/src/stages/chat_stage.ts | 2 + utils/src/stages/private_chat_stage.ts | 3 ++ 8 files changed, 166 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/stages/group_chat_editor.ts b/frontend/src/components/stages/group_chat_editor.ts index 6db48cdf5..c1f297231 100644 --- a/frontend/src/components/stages/group_chat_editor.ts +++ b/frontend/src/components/stages/group_chat_editor.ts @@ -146,6 +146,31 @@ export class ChatEditor extends MobxLitElement { ` : nothing} + ${timeLimit !== null + ? html` +
+ + { + const val = Number((e.target as HTMLInputElement).value); + if (!this.stage) return; + this.experimentEditor.updateStage({ + ...this.stage, + timeMinimumInMinutes: val > 0 ? val : null, + }); + }} + /> +
+ ` + : 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..27ebaad2b 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.isMinimumTimeNotMet) return; this.readyToEndDiscussionLoading = true; try { @@ -183,13 +184,16 @@ export class GroupChatView extends MobxLitElement { this.participantService.isReadyToEndChatDiscussion( this.stage.id, currentDiscussionId, - ); + ) || + this.isMinimumTimeNotMet; return html` { if (!this.stage?.progress.showParticipantProgress) { return nothing; @@ -306,6 +312,23 @@ export class GroupChatView extends MobxLitElement { `; } + + get isMinimumTimeNotMet() { + if (!this.stage || !this.stage.timeMinimumInMinutes) return false; + + const publicStageData = this.cohortService.stagePublicDataMap[ + this.stage.id + ] as ChatStagePublicData; + + if (!publicStageData?.discussionStartTimestamp) { + return true; + } + + return ( + getTimeElapsed(publicStageData.discussionStartTimestamp, 'm') < + this.stage.timeMinimumInMinutes + ); + } } declare global { diff --git a/frontend/src/components/stages/private_chat_editor.ts b/frontend/src/components/stages/private_chat_editor.ts index 6d9557d34..bb1526d6f 100644 --- a/frontend/src/components/stages/private_chat_editor.ts +++ b/frontend/src/components/stages/private_chat_editor.ts @@ -117,6 +117,31 @@ export class ChatEditor extends MobxLitElement { ` : nothing} + ${timeLimit !== null + ? html` +
+ + { + const val = Number((e.target as HTMLInputElement).value); + if (!this.stage) return; + this.experimentEditor.updateStage({ + ...this.stage, + timeMinimumInMinutes: val > 0 ? val : null, + }); + }} + /> +
+ ` + : nothing} `; } diff --git a/frontend/src/components/stages/private_chat_participant_view.ts b/frontend/src/components/stages/private_chat_participant_view.ts index c415f7a76..50b10151e 100644 --- a/frontend/src/components/stages/private_chat_participant_view.ts +++ b/frontend/src/components/stages/private_chat_participant_view.ts @@ -14,6 +14,7 @@ import { ChatMessage, PrivateChatStageConfig, UserType, + getTimeElapsed, } from '@deliberation-lab/utils'; import {getHashBasedColor} from '../../shared/utils'; import {ResponseTimeoutTracker} from '../../shared/response_timeout'; @@ -86,8 +87,19 @@ export class PrivateChatView extends MobxLitElement { participantMessageCount >= this.stage.maxNumberOfTurns && !isWaitingForResponse; - // Check if conversation has ended (max turns reached and not waiting for response) - const isConversationOver = maxTurnsReached; + const discussionStartTimestamp = + chatMessages.length > 0 ? chatMessages[0].timestamp : null; + const elapsedMinutes = discussionStartTimestamp + ? getTimeElapsed(discussionStartTimestamp, 'm') + : 0; + + const maxTimeReached = + this.stage.timeLimitInMinutes !== null && + this.stage.timeLimitInMinutes > 0 && + elapsedMinutes >= this.stage.timeLimitInMinutes; + + // Check if conversation has ended + const isConversationOver = maxTurnsReached || maxTimeReached; // Disable input if turn-taking is set and latest message // is from participant OR if conversation is over @@ -111,6 +123,23 @@ export class PrivateChatView extends MobxLitElement { !isWaitingForResponse : participantMessageCount >= this.stage.minNumberOfTurns; + // Check if minimum time is met + let minTimeMet = true; + if ( + this.stage.timeMinimumInMinutes !== null && + this.stage.timeMinimumInMinutes > 0 + ) { + if (!discussionStartTimestamp) { + minTimeMet = false; + } else { + if (elapsedMinutes < this.stage.timeMinimumInMinutes) { + minTimeMet = false; + } + } + } + + const isNextDisabled = !minTurnsMet || !minTimeMet; + return html` ${chatMessages.map((message) => this.renderChatMessage(message))} @@ -119,13 +148,16 @@ export class PrivateChatView extends MobxLitElement { : nothing} ${isConversationOver ? this.renderConversationEndedMessage() : nothing} - + ${this.stage.progress.showParticipantProgress ? html`` : nothing} ${!minTurnsMet && !isConversationOver ? this.renderMinTurnsMessage(participantMessageCount) : nothing} + ${!minTimeMet && minTurnsMet && !isConversationOver + ? this.renderMinTimeMessage() + : nothing} `; } @@ -198,6 +230,15 @@ export class PrivateChatView extends MobxLitElement { `; } + + private renderMinTimeMessage() { + return html` +
+ You must wait until ${this.stage?.timeMinimumInMinutes} minutes have + passed. +
+ `; + } } declare global { diff --git a/functions/src/chat/chat.utils.ts b/functions/src/chat/chat.utils.ts index ca5128c0f..cd19a4fff 100644 --- a/functions/src/chat/chat.utils.ts +++ b/functions/src/chat/chat.utils.ts @@ -19,6 +19,7 @@ import { ChatStagePublicData, ChatStageParticipantAnswer, createChatStageParticipantAnswer, + getTimeElapsed, } from '@deliberation-lab/utils'; import {updateParticipantNextStage} from '../participant.utils'; @@ -71,6 +72,35 @@ export async function updateParticipantReadyToEndChat( ); if (!publicStageData) return; + let startTimestamp = (publicStageData as ChatStagePublicData) + .discussionStartTimestamp; + + if (stage.kind === 'privateChat' && !startTimestamp) { + const chatRef = app + .firestore() + .collection('experiments') + .doc(experimentId) + .collection('participants') + .doc(participantId) + .collection('stageData') + .doc(stage.id) + .collection('privateChats') + .orderBy('timestamp', 'asc') + .limit(1); + + const chatSnap = await chatRef.get(); + if (!chatSnap.empty) { + startTimestamp = chatSnap.docs[0].data().timestamp as Timestamp; + } + } + + const minTime = (stage as ChatStageConfig).timeMinimumInMinutes; + if (minTime !== undefined && minTime !== null && minTime > 0) { + if (!startTimestamp) return; + const elapsedMinutes = getTimeElapsed(startTimestamp, 'm'); + if (elapsedMinutes < minTime) return; + } + const participantAnswerDoc = getFirestoreParticipantAnswerRef( experimentId, participantId, @@ -111,6 +141,7 @@ export async function updateParticipantReadyToEndChat( } else { // Otherwise, move to next stage const experiment = await getFirestoreExperiment(experimentId); + if (!experiment) return; await updateParticipantNextStage( experimentId, participant, diff --git a/functions/src/stages/chat.time.ts b/functions/src/stages/chat.time.ts index 2ecc198a3..fbe674026 100644 --- a/functions/src/stages/chat.time.ts +++ b/functions/src/stages/chat.time.ts @@ -63,6 +63,14 @@ export async function updateTimeElapsed( await handleTimeElapsed(experimentId, cohortId, stageId); return; } + const isUnderMinimum = + stage.timeMinimumInMinutes !== null && + elapsedMinutes < stage.timeMinimumInMinutes; + + if (remainingTime <= 0 && !isUnderMinimum) { + await handleTimeElapsed(experimentId, cohortId, stageId); + return; + } // Otherwise, continue to wait const maxWaitTimeInMinutes = 5; diff --git a/utils/src/stages/chat_stage.ts b/utils/src/stages/chat_stage.ts index ae89949a7..c1448b8f6 100644 --- a/utils/src/stages/chat_stage.ts +++ b/utils/src/stages/chat_stage.ts @@ -29,6 +29,7 @@ export interface ChatStageConfig extends BaseStageConfig { kind: StageKind.CHAT; discussions: ChatDiscussion[]; // ordered list of discussions timeLimitInMinutes: number | null; // How long remaining in the chat. + timeMinimumInMinutes: number | null; // Minimum amount of time participants must spend in chat. requireFullTime: boolean; // Require participants to stay in chat until time limit is up } @@ -118,6 +119,7 @@ export function createChatStage( createStageProgressConfig({waitForAllParticipants: true}), discussions: config.discussions ?? [], timeLimitInMinutes: config.timeLimitInMinutes ?? null, + timeMinimumInMinutes: config.timeMinimumInMinutes ?? null, requireFullTime: config.requireFullTime ?? false, }; } diff --git a/utils/src/stages/private_chat_stage.ts b/utils/src/stages/private_chat_stage.ts index bb3836695..3b9999ee3 100644 --- a/utils/src/stages/private_chat_stage.ts +++ b/utils/src/stages/private_chat_stage.ts @@ -26,6 +26,8 @@ export interface PrivateChatStageConfig extends BaseStageConfig { // If defined, ends chat after specified time limit // (starting from when the first message is sent) timeLimitInMinutes: number | null; + // Minimum amount of time a participant must spend in chat + timeMinimumInMinutes: number | null; // Require participants to stay in chat until time limit is up requireFullTime: boolean; // If true, requires participant to go back and forth with mediator(s) @@ -58,6 +60,7 @@ export function createPrivateChatStage( config.progress ?? createStageProgressConfig({waitForAllParticipants: true}), timeLimitInMinutes: config.timeLimitInMinutes ?? null, + timeMinimumInMinutes: config.timeMinimumInMinutes ?? null, requireFullTime: config.requireFullTime ?? false, isTurnBasedChat: config.isTurnBasedChat ?? true, minNumberOfTurns: config.minNumberOfTurns ?? 0,