From a0eb8ed0bb7b8b4eae8764785ea9778d05215ea4 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 2 Jan 2026 18:26:26 +0100 Subject: [PATCH 01/17] Comment update: include forecast retroactively --- comments/services/common.py | 15 +++++++++++++-- comments/views/common.py | 16 +++++++++++++++- posts/services/common.py | 6 +----- questions/models.py | 5 +++++ questions/services/common.py | 7 ++----- questions/services/forecasts.py | 5 +---- utils/the_math/aggregations.py | 4 +--- 7 files changed, 38 insertions(+), 20 deletions(-) diff --git a/comments/services/common.py b/comments/services/common.py index 49f7d2ca84..8013a905bf 100644 --- a/comments/services/common.py +++ b/comments/services/common.py @@ -130,7 +130,9 @@ def create_comment( return obj -def update_comment(comment: Comment, text: str = None): +def update_comment( + comment: Comment, text: str = None, included_forecast: Forecast = None +): differ = difflib.Differ() diff = list(differ.compare(comment.text.splitlines(), text.splitlines())) @@ -148,10 +150,19 @@ def update_comment(comment: Comment, text: str = None): comment.text = text comment.text_edited_at = timezone.now() + if included_forecast: + comment.included_forecast = included_forecast + should_soft_delete = check_and_handle_comment_spam(comment.author, comment) comment.save( - update_fields=["text", "edit_history", "text_edited_at", "is_soft_deleted"] + update_fields=[ + "text", + "edit_history", + "text_edited_at", + "is_soft_deleted", + "included_forecast", + ] ) if should_soft_delete: diff --git a/comments/views/common.py b/comments/views/common.py index 72a10a1e85..dbad2443a5 100644 --- a/comments/views/common.py +++ b/comments/views/common.py @@ -166,11 +166,25 @@ def comment_edit_api_view(request: Request, pk: int): # Small validation comment = get_object_or_404(Comment, pk=pk) text = serializers.CharField().run_validation(request.data.get("text")) + include_forecast = serializers.BooleanField( + required=False, allow_null=True + ).run_validation(request.data.get("include_forecast")) if not (comment.author == request.user): raise PermissionDenied("You do not have permission to edit this comment.") - update_comment(comment, text) + post = comment.on_post + forecast = None + + if include_forecast and not comment.included_forecast and post and post.question_id: + forecast = ( + post.question.user_forecasts.filter(author=comment.author) + .filter_active_at(comment.created_at) + .order_by("-start_time") + .first() + ) + + update_comment(comment, text, included_forecast=forecast) return Response({}, status=status.HTTP_200_OK) diff --git a/posts/services/common.py b/posts/services/common.py index 349924804c..d306e6346d 100644 --- a/posts/services/common.py +++ b/posts/services/common.py @@ -4,7 +4,6 @@ from django.conf import settings from django.db import transaction -from django.db.models import Q from django.db.utils import IntegrityError from django.utils import timezone from django.utils.translation import activate @@ -339,10 +338,7 @@ def compute_sorting_divergence(post: Post) -> dict[int, float]: if cp is None: continue - active_forecasts = question.user_forecasts.filter( - Q(end_time__isnull=True) | Q(end_time__gt=now), - start_time__lte=now, - ) + active_forecasts = question.user_forecasts.filter_active_at(now) for forecast in active_forecasts: difference = prediction_difference_for_sorting( forecast.get_prediction_values(), diff --git a/questions/models.py b/questions/models.py index 5111bd1563..d9c44ef721 100644 --- a/questions/models.py +++ b/questions/models.py @@ -454,6 +454,11 @@ def filter_within_question_period(self): ), ) + def filter_active_at(self, timestamp: datetime): + return self.filter(start_time__lte=timestamp).filter( + Q(end_time__gt=timestamp) | Q(end_time__isnull=True) + ) + def active(self): """ Returns active forecasts. diff --git a/questions/services/common.py b/questions/services/common.py index 8c10ec998f..a1cf43161d 100644 --- a/questions/services/common.py +++ b/questions/services/common.py @@ -3,7 +3,7 @@ from typing import Iterable import sentry_sdk -from django.db.models import F, Q +from django.db.models import F from django.utils import timezone from coherence.models import CoherenceLink @@ -291,10 +291,7 @@ def get_questions_cutoff( AggregateForecast.objects.filter( question__in=questions, method=F("question__default_aggregation_method") ) - .filter( - (Q(end_time__isnull=True) | Q(end_time__gt=timezone.now())), - start_time__lte=timezone.now(), - ) + .filter_active_at(timezone.now()) .order_by("question_id", "-start_time") .distinct("question_id") .values_list("question_id", "centers") diff --git a/questions/services/forecasts.py b/questions/services/forecasts.py index 15aba16fa3..4fd108c7e4 100644 --- a/questions/services/forecasts.py +++ b/questions/services/forecasts.py @@ -244,10 +244,7 @@ def get_last_aggregated_forecasts_for_questions( ): return ( aggregated_forecast_qs.filter(question__in=questions) - .filter( - (Q(end_time__isnull=True) | Q(end_time__gt=timezone.now())), - start_time__lte=timezone.now(), - ) + .filter_active_at(timezone.now()) .order_by("question_id", "method", "-start_time") .distinct("question_id", "method") ) diff --git a/utils/the_math/aggregations.py b/utils/the_math/aggregations.py index 1ff6a98c01..87f75ac842 100644 --- a/utils/the_math/aggregations.py +++ b/utils/the_math/aggregations.py @@ -690,9 +690,7 @@ def get_aggregations_at_time( """set include_stats to True if you want to include num_forecasters, q1s, medians, and q3s""" forecasts = ( - question.user_forecasts.filter( - Q(end_time__isnull=True) | Q(end_time__gt=time), start_time__lte=time - ) + question.user_forecasts.filter_active_at(time) .order_by("start_time") .select_related("author") ) From 7bcfe1df82e18fdff2b285952a63175fc39bb672 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 2 Jan 2026 18:30:40 +0100 Subject: [PATCH 02/17] Small fix --- questions/services/common.py | 7 +++++-- questions/services/forecasts.py | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/questions/services/common.py b/questions/services/common.py index a1cf43161d..8c10ec998f 100644 --- a/questions/services/common.py +++ b/questions/services/common.py @@ -3,7 +3,7 @@ from typing import Iterable import sentry_sdk -from django.db.models import F +from django.db.models import F, Q from django.utils import timezone from coherence.models import CoherenceLink @@ -291,7 +291,10 @@ def get_questions_cutoff( AggregateForecast.objects.filter( question__in=questions, method=F("question__default_aggregation_method") ) - .filter_active_at(timezone.now()) + .filter( + (Q(end_time__isnull=True) | Q(end_time__gt=timezone.now())), + start_time__lte=timezone.now(), + ) .order_by("question_id", "-start_time") .distinct("question_id") .values_list("question_id", "centers") diff --git a/questions/services/forecasts.py b/questions/services/forecasts.py index 4fd108c7e4..15aba16fa3 100644 --- a/questions/services/forecasts.py +++ b/questions/services/forecasts.py @@ -244,7 +244,10 @@ def get_last_aggregated_forecasts_for_questions( ): return ( aggregated_forecast_qs.filter(question__in=questions) - .filter_active_at(timezone.now()) + .filter( + (Q(end_time__isnull=True) | Q(end_time__gt=timezone.now())), + start_time__lte=timezone.now(), + ) .order_by("question_id", "method", "-start_time") .distinct("question_id", "method") ) From 4b4bba9552b5df806796bdac0af677d009d42e2c Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 2 Jan 2026 18:40:41 +0100 Subject: [PATCH 03/17] Added tests --- tests/unit/test_comments/test_views.py | 123 +++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/tests/unit/test_comments/test_views.py b/tests/unit/test_comments/test_views.py index cf13ee8770..15f95991fd 100644 --- a/tests/unit/test_comments/test_views.py +++ b/tests/unit/test_comments/test_views.py @@ -1,6 +1,10 @@ +from datetime import timedelta + import pytest # noqa from django.urls import reverse +from django.utils import timezone from django_dynamic_fixture import G +from freezegun import freeze_time from comments.models import ( Comment, @@ -674,3 +678,122 @@ def test_empty_comment(self, user1, post, user1_client): assert not KeyFactor.objects.filter(pk=kf2.pk).exists() # Last KF deleted - drop comment assert not Comment.objects.filter(pk=comment.pk).exists() + + +class TestCommentEdit: + def test_comment_edit_include_forecast(self, user1, user1_client, question_binary): + post = factory_post(author=user1, question=question_binary) + question = post.question + now = timezone.now() + + # 0. Forecast created and closed before comment creation + t_forecast_expired_start = now - timedelta(hours=4) + t_forecast_expired_end = now - timedelta(hours=3) + + with freeze_time(t_forecast_expired_start): + forecast_expired = create_forecast( + question=question, + user=user1, + probability_yes=0.2, + ) + + forecast_expired.end_time = t_forecast_expired_end + forecast_expired.save() + + # 1. Forecast active at comment creation + t_forecast_1 = now - timedelta(hours=2) + + with freeze_time(t_forecast_1): + forecast_1 = create_forecast( + question=question, + user=user1, + probability_yes=0.5, + ) + + # 2. Comment created later. + t_comment = now - timedelta(hours=1) + + with freeze_time(t_comment): + comment = factory_comment(author=user1, on_post=post) + + # Ensure timestamps are correct + assert forecast_1.start_time == t_forecast_1 + assert comment.created_at == t_comment + assert forecast_1.start_time < comment.created_at + + # 3. New forecast created after comment + t_forecast_2 = now - timedelta(minutes=30) + with freeze_time(t_forecast_2): + forecast_2 = create_forecast( + question=question, + user=user1, + probability_yes=0.8, + ) + + # 4. Edit comment to include forecast + url = reverse("comment-edit", kwargs={"pk": comment.pk}) + response = user1_client.post( + url, {"text": "Updated text", "include_forecast": True} + ) + + assert response.status_code == 200 + comment.refresh_from_db() + + # Should attach forecast_1 (active at creation), not forecast_2 (created later) + assert comment.included_forecast == forecast_1 + + # 5. Prevent overwrite if already set + with freeze_time(now): + create_forecast( + question=question, + user=user1, + probability_yes=0.9, + ) + + # Even if we pass include_forecast=True again, it shouldn't change + response = user1_client.post( + url, {"text": "Updated text 2", "include_forecast": True} + ) + assert response.status_code == 200 + comment.refresh_from_db() + assert comment.included_forecast == forecast_1 + + # 6. Test include_forecast=False (should do nothing if already set) + response = user1_client.post( + url, {"text": "Updated text 3", "include_forecast": False} + ) + assert response.status_code == 200 + comment.refresh_from_db() + assert comment.included_forecast == forecast_1 + + # 7. If we manually remove it, include_forecast=False should leave it removed. + comment.included_forecast = None + comment.save() + + response = user1_client.post( + url, {"text": "Updated text 4", "include_forecast": False} + ) + assert response.status_code == 200 + comment.refresh_from_db() + assert comment.included_forecast is None + + # 8. Test attaching when multiple forecasts exist before creation + t_forecast_0 = now - timedelta(hours=3) + with freeze_time(t_forecast_0): + forecast_0 = create_forecast( + question=question, + user=user1, + probability_yes=0.1, + ) + + # Close it before comment creation + forecast_0.end_time = t_forecast_1 + forecast_0.save() + + # So at t_comment, forecast_0 is closed. Forecast_1 is open. + response = user1_client.post( + url, {"text": "Updated text 5", "include_forecast": True} + ) + assert response.status_code == 200 + comment.refresh_from_db() + assert comment.included_forecast == forecast_1 From 2f08946f568c3ad726019a32b8c25d0299817e30 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Mon, 5 Jan 2026 20:57:21 +0000 Subject: [PATCH 04/17] Frontend changes --- front_end/messages/cs.json | 4 + front_end/messages/en.json | 1 + front_end/messages/es.json | 4 + front_end/messages/pt.json | 4 + front_end/messages/zh-TW.json | 4 + front_end/messages/zh.json | 4 + .../src/components/comment_feed/comment.tsx | 374 ++++++++++-------- .../services/api/comments/comments.server.ts | 4 +- .../services/api/comments/comments.shared.ts | 1 + 9 files changed, 241 insertions(+), 159 deletions(-) diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 7533eb9c94..14cf608f28 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1818,5 +1818,9 @@ "tournamentsTabArchived": "Archivováno", "tournamentTimelineClosed": "Čekání na vyřešení", "questionsPreviouslyPredicted": "{count, plural, =1 {# otázka} other {# otázek}} dříve předpovězených", + "includeMyForecastAtTheTime": "Zahrnout mou předpověď v daném čase", + "tournamentsInfoTitle": "Jsme nepredikční trh. Můžete se účastnit zdarma a vyhrát peněžní ceny za přesnost.", + "tournamentsInfoScoringLink": "Co jsou předpovídací skóre?", + "tournamentsInfoPrizesLink": "Jak jsou rozdělovány ceny?", "othersCount": "Ostatní ({count})" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 04bbbfc1d1..93cdda3302 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -714,6 +714,7 @@ "legacyPeerDisclaimer": "The Peer Accuracy leaderboards used slightly different math before 2024. See details here.", "namesPrediction": "{username}'s Prediction", "includeMyForecast": "include your current forecast in this comment", + "includeMyForecastAtTheTime": "Include my prediction at the time", "privateComment": "private comment", "groupVariable": "Group Variable", "questionUnit": "Question Unit", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index f887f8b541..123161ca5c 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1818,5 +1818,9 @@ "tournamentsTabArchived": "Archivado", "tournamentTimelineClosed": "Esperando resoluciones", "questionsPreviouslyPredicted": "{count, plural, =1 {# pregunta} other {# preguntas}} previamente previstas", + "includeMyForecastAtTheTime": "Incluir mi predicción en ese momento", + "tournamentsInfoTitle": "Nosotros no somos un mercado de predicciones. Puedes participar gratis y ganar premios en efectivo por ser preciso.", + "tournamentsInfoScoringLink": "¿Qué son las puntuaciones de predicción?", + "tournamentsInfoPrizesLink": "¿Cómo se distribuyen los premios?", "othersCount": "Otros ({count})" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 307309081c..c62c3b6e70 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -1816,5 +1816,9 @@ "tournamentsTabArchived": "Arquivado", "tournamentTimelineClosed": "Aguardando resoluções", "questionsPreviouslyPredicted": "{count, plural, =1 {# pergunta} other {# perguntas}} previamente previstas", + "includeMyForecastAtTheTime": "Incluir minha previsão no momento", + "tournamentsInfoTitle": "Nós não somos um mercado de previsões. Você pode participar gratuitamente e ganhar prêmios em dinheiro por ser preciso.", + "tournamentsInfoScoringLink": "O que são pontuações de previsão?", + "tournamentsInfoPrizesLink": "Como os prêmios são distribuídos?", "othersCount": "Outros ({count})" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index bb06306c2a..c743634702 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1815,5 +1815,9 @@ "tournamentsTabArchived": "已存檔", "tournamentTimelineClosed": "等待裁定", "questionsPreviouslyPredicted": "先前預測的{count, plural, =1 {# 個問題} other {# 個問題}}", + "includeMyForecastAtTheTime": "包括我當時的預測", + "tournamentsInfoTitle": "我們 不是預測市場。您可以免費參加並因精確的預測贏取現金獎勵。", + "tournamentsInfoScoringLink": "什麼是預測得分?", + "tournamentsInfoPrizesLink": "獎品如何分配?", "withdrawAfterPercentSetting2": "問題總生命周期後撤回" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 599c7fd3c3..f90118c89f 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1820,5 +1820,9 @@ "tournamentsTabArchived": "已归档", "tournamentTimelineClosed": "等待解决", "questionsPreviouslyPredicted": "之前预测的{count, plural, =1 {# 个问题} other {# 个问题}}", + "includeMyForecastAtTheTime": "包含我当时的预测", + "tournamentsInfoTitle": "我们不是一个预测市场。您可以免费参与,并因精准的预测赢得现金奖品。", + "tournamentsInfoScoringLink": "什么是预测分数?", + "tournamentsInfoPrizesLink": "奖品如何分配?", "othersCount": "其他({count})" } diff --git a/front_end/src/components/comment_feed/comment.tsx b/front_end/src/components/comment_feed/comment.tsx index aad166e2b9..6e4a55ca6b 100644 --- a/front_end/src/components/comment_feed/comment.tsx +++ b/front_end/src/components/comment_feed/comment.tsx @@ -30,6 +30,7 @@ import { Admin } from "@/components/icons/admin"; import { Moderator } from "@/components/icons/moderator"; import MarkdownEditor from "@/components/markdown_editor"; import Button from "@/components/ui/button"; +import Checkbox from "@/components/ui/checkbox"; import DropdownMenu, { MenuItemProps } from "@/components/ui/dropdown_menu"; import { userTagPattern } from "@/constants/comments"; import { useAuth } from "@/contexts/auth_context"; @@ -233,6 +234,10 @@ const Comment: FC = ({ const [errorMessage, setErrorMessage] = useState(); const [commentMarkdown, setCommentMarkdown] = useState(comment.text); const [tempCommentMarkdown, setTempCommentMarkdown] = useState(""); + const [includeEditForecast, setIncludeEditForecast] = useState(false); + const [includedForecast, setIncludedForecast] = useState( + comment.included_forecast + ); const [isReportModalOpen, setIsReportModalOpen] = useState(false); const { ref, width } = useContainerSize(); const { PUBLIC_MINIMAL_UI } = usePublicSettings(); @@ -242,6 +247,31 @@ const Comment: FC = ({ const userForecast = postData?.question?.my_forecasts?.latest?.forecast_values[1] ?? 0.5; const isCommentAuthor = comment.author.id === user?.id; + + // Check if user had a forecast active at comment creation time + const commentCreatedAt = new Date(comment.created_at).getTime() / 1000; + const hadForecastAtCommentCreation = useMemo(() => { + if (comment.author.id !== user?.id) { + return false; + } + + const forecasts = postData?.question?.my_forecasts?.history || []; + + return forecasts.find((forecast) => { + const startTime = forecast.start_time; + const endTime = forecast.end_time; + return ( + startTime <= commentCreatedAt && + (endTime === null || endTime > commentCreatedAt) + ); + }); + }, [ + comment.author.id, + user?.id, + postData?.question?.my_forecasts?.history, + commentCreatedAt, + ]); + const isCmmButtonVisible = !!postData?.question || !!postData?.group_of_questions || @@ -447,6 +477,7 @@ const Comment: FC = ({ id: comment.id, text: parsedMarkdown, author: user.id, + include_forecast: includeEditForecast, }); if (response && "errors" in response) { setErrorMessage(response.errors as ErrorResponse); @@ -460,6 +491,17 @@ const Comment: FC = ({ setTempCommentMarkdown(parsedMarkdown); setEditInitialMarkdown(parsedMarkdown); setIsEditing(false); + setIncludeEditForecast(false); + if ( + response && + "included_forecast" in response && + response.included_forecast + ) { + setIncludedForecast({ + ...response.included_forecast, + start_time: new Date(response.included_forecast.start_time), + }); + } deleteEditDraft(); } } finally { @@ -738,10 +780,10 @@ const Comment: FC = ({ */} {/* comment indexing is broken, since the comment feed loading happens async for the client*/} - {comment.included_forecast && !isCollapsed && ( + {includedForecast && !isCollapsed && !isEditing && ( )} @@ -760,173 +802,191 @@ const Comment: FC = ({ <>
{isEditing && ( - { - setCommentMarkdown(val); - saveEditDraftDebounced(val); - }} - withUgcLinks - withCodeBlocks - /> - )}{" "} - {!isEditing && !(isTextEmpty && commentKeyFactors.length > 0) && ( - +
+ { + setCommentMarkdown(val); + saveEditDraftDebounced(val); + }} + withUgcLinks + withCodeBlocks + /> + {!includedForecast && hadForecastAtCommentCreation && ( + setIncludeEditForecast(checked)} + label={t("includeMyForecastAtTheTime")} + className="mt-3 text-sm" + /> + )} +
+ {!!errorMessage && ( + )} - mode="read" - withUgcLinks - withTwitterPreview - withCodeBlocks - /> +
+ + +
+
)} - {!!errorMessage && isEditing && ( - - )} - {isEditing && ( - <> - - - - )} - - {commentKeyFactors.length > 0 && canListKeyFactors && postData && ( - - )} -
-
-
- + {!(isTextEmpty && commentKeyFactors.length > 0) && ( + - - {canShowAddKeyFactorsButton && ( -
- - - )} + <> +
+ +
+
+ + + {t("addKeyFactor")} + + {t("add")} +
+ + + )} - {!onProfile && - (isReplying ? ( - + ) : ( + + ))} + + {isCmmButtonVisible && ( + - {t("cancel")} - - ) : ( - - ))} - - {isCmmButtonVisible && ( - - )} -
+ )} +
-
0 && "pr-1.5 md:pr-2")}> - +
0 && "pr-1.5 md:pr-2")}> + +
+
- - + + )} )} diff --git a/front_end/src/services/api/comments/comments.server.ts b/front_end/src/services/api/comments/comments.server.ts index 0a950aacc1..7d63af6bb2 100644 --- a/front_end/src/services/api/comments/comments.server.ts +++ b/front_end/src/services/api/comments/comments.server.ts @@ -17,8 +17,8 @@ class ServerCommentsApiClass extends CommentsApi { return await this.post(`/comments/${id}/delete/`, null); } - async editComment(commentData: EditCommentParams): Promise { - return await this.post( + async editComment(commentData: EditCommentParams): Promise { + return await this.post( `/comments/${commentData.id}/edit/`, commentData ); diff --git a/front_end/src/services/api/comments/comments.shared.ts b/front_end/src/services/api/comments/comments.shared.ts index 2663230e14..4a5884cf3e 100644 --- a/front_end/src/services/api/comments/comments.shared.ts +++ b/front_end/src/services/api/comments/comments.shared.ts @@ -34,6 +34,7 @@ export type EditCommentParams = { id: number; text: string; author: number; + include_forecast?: boolean; }; export type VoteParams = { From 64fd44a053ddff8cd6f0cdd218de408c4c7aff3d Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Mon, 5 Jan 2026 21:11:16 +0000 Subject: [PATCH 05/17] Always display a checkbox --- front_end/src/components/comment_feed/comment.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/front_end/src/components/comment_feed/comment.tsx b/front_end/src/components/comment_feed/comment.tsx index 6e4a55ca6b..1e9db4bc61 100644 --- a/front_end/src/components/comment_feed/comment.tsx +++ b/front_end/src/components/comment_feed/comment.tsx @@ -816,12 +816,13 @@ const Comment: FC = ({ withUgcLinks withCodeBlocks /> - {!includedForecast && hadForecastAtCommentCreation && ( + {hadForecastAtCommentCreation && ( setIncludeEditForecast(checked)} label={t("includeMyForecastAtTheTime")} className="mt-3 text-sm" + disabled={!!includedForecast} /> )} From 8b57580e9b9db807ff7c3bb11f4ca50ec5956763 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Mon, 5 Jan 2026 21:17:06 +0000 Subject: [PATCH 06/17] Small fix --- tests/unit/test_comments/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_comments/test_views.py b/tests/unit/test_comments/test_views.py index 15f95991fd..7fba7d2d74 100644 --- a/tests/unit/test_comments/test_views.py +++ b/tests/unit/test_comments/test_views.py @@ -724,7 +724,7 @@ def test_comment_edit_include_forecast(self, user1, user1_client, question_binar # 3. New forecast created after comment t_forecast_2 = now - timedelta(minutes=30) with freeze_time(t_forecast_2): - forecast_2 = create_forecast( + create_forecast( question=question, user=user1, probability_yes=0.8, From 9bc40bf492ad52ff15bdba72fca3a91ee468a68b Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Tue, 6 Jan 2026 19:51:43 +0000 Subject: [PATCH 07/17] Small backend fix --- comments/views/common.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/comments/views/common.py b/comments/views/common.py index dbad2443a5..8fc4b704f4 100644 --- a/comments/views/common.py +++ b/comments/views/common.py @@ -185,8 +185,9 @@ def comment_edit_api_view(request: Request, pk: int): ) update_comment(comment, text, included_forecast=forecast) + comment.refresh_from_db() - return Response({}, status=status.HTTP_200_OK) + return Response(serialize_comment(comment), status=status.HTTP_200_OK) @api_view(["POST"]) From f232d2c7f3021761e6cf5662ebffd9796956ce93 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Tue, 6 Jan 2026 20:08:21 +0000 Subject: [PATCH 08/17] Added forecast value preview at the time --- front_end/messages/cs.json | 2 +- front_end/messages/en.json | 2 +- front_end/messages/es.json | 2 +- front_end/messages/pt.json | 2 +- front_end/messages/zh-TW.json | 2 +- front_end/messages/zh.json | 2 +- .../src/components/comment_feed/comment.tsx | 46 ++++- .../comment_feed/included_forecast.tsx | 168 ++++++++++++------ 8 files changed, 155 insertions(+), 71 deletions(-) diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 14cf608f28..dbbcae9639 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1818,7 +1818,7 @@ "tournamentsTabArchived": "Archivováno", "tournamentTimelineClosed": "Čekání na vyřešení", "questionsPreviouslyPredicted": "{count, plural, =1 {# otázka} other {# otázek}} dříve předpovězených", - "includeMyForecastAtTheTime": "Zahrnout mou předpověď v daném čase", + "includeMyForecastAtTheTime": "Zahrnout mou předpověď v daném čase ({forecast})", "tournamentsInfoTitle": "Jsme nepredikční trh. Můžete se účastnit zdarma a vyhrát peněžní ceny za přesnost.", "tournamentsInfoScoringLink": "Co jsou předpovídací skóre?", "tournamentsInfoPrizesLink": "Jak jsou rozdělovány ceny?", diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 93cdda3302..4414f70431 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -714,7 +714,7 @@ "legacyPeerDisclaimer": "The Peer Accuracy leaderboards used slightly different math before 2024. See details here.", "namesPrediction": "{username}'s Prediction", "includeMyForecast": "include your current forecast in this comment", - "includeMyForecastAtTheTime": "Include my prediction at the time", + "includeMyForecastAtTheTime": "Include my prediction at the time ({forecast})", "privateComment": "private comment", "groupVariable": "Group Variable", "questionUnit": "Question Unit", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 123161ca5c..b589fe6cd8 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1818,7 +1818,7 @@ "tournamentsTabArchived": "Archivado", "tournamentTimelineClosed": "Esperando resoluciones", "questionsPreviouslyPredicted": "{count, plural, =1 {# pregunta} other {# preguntas}} previamente previstas", - "includeMyForecastAtTheTime": "Incluir mi predicción en ese momento", + "includeMyForecastAtTheTime": "Incluir mi predicción en ese momento ({forecast})", "tournamentsInfoTitle": "Nosotros no somos un mercado de predicciones. Puedes participar gratis y ganar premios en efectivo por ser preciso.", "tournamentsInfoScoringLink": "¿Qué son las puntuaciones de predicción?", "tournamentsInfoPrizesLink": "¿Cómo se distribuyen los premios?", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index c62c3b6e70..ead0f99156 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -1816,7 +1816,7 @@ "tournamentsTabArchived": "Arquivado", "tournamentTimelineClosed": "Aguardando resoluções", "questionsPreviouslyPredicted": "{count, plural, =1 {# pergunta} other {# perguntas}} previamente previstas", - "includeMyForecastAtTheTime": "Incluir minha previsão no momento", + "includeMyForecastAtTheTime": "Incluir minha previsão no momento ({forecast})", "tournamentsInfoTitle": "Nós não somos um mercado de previsões. Você pode participar gratuitamente e ganhar prêmios em dinheiro por ser preciso.", "tournamentsInfoScoringLink": "O que são pontuações de previsão?", "tournamentsInfoPrizesLink": "Como os prêmios são distribuídos?", diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index c743634702..1307d3d7fa 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1815,7 +1815,7 @@ "tournamentsTabArchived": "已存檔", "tournamentTimelineClosed": "等待裁定", "questionsPreviouslyPredicted": "先前預測的{count, plural, =1 {# 個問題} other {# 個問題}}", - "includeMyForecastAtTheTime": "包括我當時的預測", + "includeMyForecastAtTheTime": "包括我當時的預測 ({forecast})", "tournamentsInfoTitle": "我們 不是預測市場。您可以免費參加並因精確的預測贏取現金獎勵。", "tournamentsInfoScoringLink": "什麼是預測得分?", "tournamentsInfoPrizesLink": "獎品如何分配?", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index f90118c89f..e43c7150d3 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1820,7 +1820,7 @@ "tournamentsTabArchived": "已归档", "tournamentTimelineClosed": "等待解决", "questionsPreviouslyPredicted": "之前预测的{count, plural, =1 {# 个问题} other {# 个问题}}", - "includeMyForecastAtTheTime": "包含我当时的预测", + "includeMyForecastAtTheTime": "包含我当时的预测 ({forecast})", "tournamentsInfoTitle": "我们不是一个预测市场。您可以免费参与,并因精准的预测赢得现金奖品。", "tournamentsInfoScoringLink": "什么是预测分数?", "tournamentsInfoPrizesLink": "奖品如何分配?", diff --git a/front_end/src/components/comment_feed/comment.tsx b/front_end/src/components/comment_feed/comment.tsx index 1e9db4bc61..4393b242a4 100644 --- a/front_end/src/components/comment_feed/comment.tsx +++ b/front_end/src/components/comment_feed/comment.tsx @@ -29,6 +29,7 @@ import CommentVoter from "@/components/comment_feed/comment_voter"; import { Admin } from "@/components/icons/admin"; import { Moderator } from "@/components/icons/moderator"; import MarkdownEditor from "@/components/markdown_editor"; +import RichText from "@/components/rich_text"; import Button from "@/components/ui/button"; import Checkbox from "@/components/ui/checkbox"; import DropdownMenu, { MenuItemProps } from "@/components/ui/dropdown_menu"; @@ -55,7 +56,10 @@ import { getMarkdownSummary } from "@/utils/markdown"; import { canPredictQuestion } from "@/utils/questions/predictions"; import { CmmOverlay, CmmToggleButton, useCmmContext } from "./comment_cmm"; -import IncludedForecast from "./included_forecast"; +import IncludedForecast, { + formatForecastValueText, + userForecastToForecastType, +} from "./included_forecast"; import { validateComment } from "./validate_comment"; import { FormErrorMessage } from "../ui/form_field"; import LoadingSpinner from "../ui/loading_spiner"; @@ -519,6 +523,7 @@ const Comment: FC = ({ setEditInitialMarkdown, setIsEditing, deleteEditDraft, + includeEditForecast, ]); // scroll to comment from URL hash useEffect(() => { @@ -816,14 +821,36 @@ const Comment: FC = ({ withUgcLinks withCodeBlocks /> - {hadForecastAtCommentCreation && ( - setIncludeEditForecast(checked)} - label={t("includeMyForecastAtTheTime")} - className="mt-3 text-sm" - disabled={!!includedForecast} - /> + {hadForecastAtCommentCreation && postData?.question && ( + + {(tags) => ( + + setIncludeEditForecast(checked) + } + label="includeMyForecastAtTheTime" + className="mt-2 text-sm" + disabled={!!includedForecast} + > + + {t.rich("includeMyForecastAtTheTime", { + ...tags, + forecast: formatForecastValueText( + userForecastToForecastType( + hadForecastAtCommentCreation, + postData.question + ) + ), + })} + + + )} + )} {!!errorMessage && ( @@ -849,6 +876,7 @@ const Comment: FC = ({ className="ml-2" onClick={() => { setCommentMarkdown(tempCommentMarkdown); + setIncludeEditForecast(false); setIsEditing(false); }} disabled={isLoading} diff --git a/front_end/src/components/comment_feed/included_forecast.tsx b/front_end/src/components/comment_feed/included_forecast.tsx index f64550dc83..0974ee63c7 100644 --- a/front_end/src/components/comment_feed/included_forecast.tsx +++ b/front_end/src/components/comment_feed/included_forecast.tsx @@ -8,11 +8,12 @@ import ChoiceIcon from "@/components/choice_icon"; import Button from "@/components/ui/button"; import { MULTIPLE_CHOICE_COLOR_SCALE } from "@/constants/colors"; import { ForecastType } from "@/types/comment"; -import { QuestionType } from "@/types/question"; +import { Question, QuestionType, UserForecast } from "@/types/question"; import cn from "@/utils/core/cn"; import { formatDate } from "@/utils/formatters/date"; import { abbreviatedNumber } from "@/utils/formatters/number"; import { getQuestionDateFormatString } from "@/utils/formatters/prediction"; +import { scaleInternalLocation } from "@/utils/math"; import { formatValueUnit } from "@/utils/questions/units"; type Props = { @@ -24,55 +25,63 @@ type ForecastValueProps = { forecast: ForecastType; }; -const ForecastValue: FC = ({ forecast }) => { - const t = useTranslations(); - const [showAll, setShowAll] = useState(false); +export function userForecastToForecastType( + userForecast: UserForecast, + question: Question +): ForecastType { + const scaling = question.scaling; + const questionType = question.type; - if (forecast.question_type == QuestionType.Binary) { - return ( -
- {`${Math.round(forecast.probability_yes * 1000) / 10}%`} -
- ); + let quartiles: [number, number, number] = [0, 0, 0]; + if ( + questionType !== QuestionType.Binary && + questionType !== QuestionType.MultipleChoice + ) { + const q1 = userForecast.interval_lower_bounds?.[0] ?? 0.25; + const q2 = userForecast.centers?.[0] ?? 0.5; + const q3 = userForecast.interval_upper_bounds?.[0] ?? 0.75; + quartiles = [ + scaleInternalLocation(q1, scaling), + scaleInternalLocation(q2, scaling), + scaleInternalLocation(q3, scaling), + ]; } - if (forecast.question_type == QuestionType.MultipleChoice) { + + return { + start_time: new Date(userForecast.start_time * 1000), + probability_yes: userForecast.forecast_values[1] ?? 0, + probability_yes_per_category: userForecast.forecast_values, + options: question.options ?? [], + continuous_cdf: userForecast.forecast_values, + quartiles, + scaling, + question_type: questionType, + question_unit: question.unit, + }; +} + +export function formatForecastValueText(forecast: ForecastType): string { + if (forecast.question_type === QuestionType.Binary) { + return `${Math.round(forecast.probability_yes * 1000) / 10}%`; + } + + if (forecast.question_type === QuestionType.MultipleChoice) { const choices = forecast.probability_yes_per_category .map((probability, index) => ({ - probability: probability, + probability, name: forecast.options[index], - color: MULTIPLE_CHOICE_COLOR_SCALE[index], })) .sort((a, b) => b.probability - a.probability); - return ( -
    - {choices.map((choice, index) => ( -
  1. 1, - })} - key={index} - > - {/* TODO: why does this generate a slightly different color than in ForecastChoiceOption ? */} - - {`${choice.name}: ${Math.round(choice.probability * 1000) / 10}%`} -
  2. - ))} - -
- ); + const top = choices[0]; + if (top) { + return `${top.name}: ${Math.round(top.probability * 1000) / 10}%`; + } + return ""; } - // continuous questions get customized formatting - if (forecast.quartiles.length !== 3) return null; + if (forecast.quartiles.length !== 3) return ""; const { range_min, range_max } = forecast.scaling; - if (isNil(range_min) || isNil(range_max)) return null; + if (isNil(range_min) || isNil(range_max)) return ""; const q1 = forecast.quartiles[0] <= range_min @@ -125,19 +134,20 @@ const ForecastValue: FC = ({ forecast }) => { format(new Date(forecast.quartiles[2] * 1000), dateFormatString), format(new Date(range_max * 1000), dateFormatString), ]; - let text: string = ""; + if (q1 === "below" && q2 === "below" && q3 === "below") { - text = + return ( probBelow + "% " + (forecast.question_type === QuestionType.Numeric || forecast.question_type === QuestionType.Discrete ? "below " : "before ") + - valueText[0]; + valueText[0] + ); } if (q1 === "below" && q2 === "below" && q3 === "inRange") { - text = + return ( probBelow + "% " + (forecast.question_type === QuestionType.Numeric || @@ -147,10 +157,11 @@ const ForecastValue: FC = ({ forecast }) => { valueText[0] + " (upper 75%=" + valueText[3] + - ")"; + ")" + ); } if (q1 === "below" && q2 === "inRange" && q3 === "inRange") { - text = + return ( valueText[2] + " (" + probBelow + @@ -160,13 +171,14 @@ const ForecastValue: FC = ({ forecast }) => { ? "below " : "before ") + valueText[0] + - ")"; + ")" + ); } if (q1 === "inRange" && q2 === "inRange" && q3 === "inRange") { - text = valueText[2] + " (" + valueText[1] + " - " + valueText[3] + ")"; + return valueText[2] + " (" + valueText[1] + " - " + valueText[3] + ")"; } if (q1 === "inRange" && q2 === "inRange" && q3 === "above") { - text = + return ( valueText[2] + " (" + probAbove + @@ -176,10 +188,11 @@ const ForecastValue: FC = ({ forecast }) => { ? "above " : "after ") + valueText[4] + - ")"; + ")" + ); } if (q1 === "inRange" && q2 === "above" && q3 === "above") { - text = + return ( probAbove + "% " + (forecast.question_type === QuestionType.Numeric || @@ -189,24 +202,67 @@ const ForecastValue: FC = ({ forecast }) => { valueText[4] + " (lower 25%=" + valueText[1] + - ")"; + ")" + ); } if (q1 === "above" && q2 === "above" && q3 === "above") { - text = + return ( probAbove + "% " + (forecast.question_type === QuestionType.Numeric || forecast.question_type === QuestionType.Discrete ? "above " : "after ") + - valueText[4]; + valueText[4] + ); } if (q1 === "below" && q2 === "inRange" && q3 === "above") { - text = valueText[2] + " (" + valueText[1] + " - " + valueText[3] + ")"; + return valueText[2] + " (" + valueText[1] + " - " + valueText[3] + ")"; } + return ""; +} + +const ForecastValue: FC = ({ forecast }) => { + const t = useTranslations(); + const [showAll, setShowAll] = useState(false); + + if (forecast.question_type === QuestionType.MultipleChoice) { + const choices = forecast.probability_yes_per_category + .map((probability, index) => ({ + probability: probability, + name: forecast.options[index], + color: MULTIPLE_CHOICE_COLOR_SCALE[index], + })) + .sort((a, b) => b.probability - a.probability); + return ( +
    + {choices.map((choice, index) => ( +
  1. 1, + })} + key={index} + > + {/* TODO: why does this generate a slightly different color than in ForecastChoiceOption ? */} + + {`${choice.name}: ${Math.round(choice.probability * 1000) / 10}%`} +
  2. + ))} + +
+ ); + } + return (
- {`${text}`} + {formatForecastValueText(forecast)}
); }; From 90ad58bf155be4d7bb998d16d2923f086710a389 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Tue, 6 Jan 2026 20:13:49 +0000 Subject: [PATCH 09/17] Preserve scroll position when clicking "edit" --- front_end/src/components/comment_feed/comment.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/front_end/src/components/comment_feed/comment.tsx b/front_end/src/components/comment_feed/comment.tsx index 4393b242a4..e81f14facc 100644 --- a/front_end/src/components/comment_feed/comment.tsx +++ b/front_end/src/components/comment_feed/comment.tsx @@ -423,12 +423,16 @@ const Comment: FC = ({ }; const openEdit = useCallback(() => { + const scrollY = window.scrollY; setTempCommentMarkdown(originalTextRef.current); setIsEditing(true); setEditorKey((k) => k + 1); setCommentMarkdown( editDraftReady ? editInitialMarkdown : originalTextRef.current ); + requestAnimationFrame(() => { + window.scrollTo(0, scrollY); + }); }, [editDraftReady, editInitialMarkdown]); const updateForecast = async (value: number) => { From 7a3822e3ceefa2962107ec4d0d9b079c83433850 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Tue, 6 Jan 2026 20:24:26 +0000 Subject: [PATCH 10/17] Adjusted spacing --- front_end/src/components/comment_feed/comment.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/front_end/src/components/comment_feed/comment.tsx b/front_end/src/components/comment_feed/comment.tsx index e81f14facc..9905f06ba3 100644 --- a/front_end/src/components/comment_feed/comment.tsx +++ b/front_end/src/components/comment_feed/comment.tsx @@ -834,7 +834,7 @@ const Comment: FC = ({ setIncludeEditForecast(checked) } label="includeMyForecastAtTheTime" - className="mt-2 text-sm" + className="ml-auto mt-2 w-fit text-sm" disabled={!!includedForecast} > = ({ containerClassName="text-balance text-center text-red-500 dark:text-red-500-dark" /> )} -
+
{!!errorMessage && ( Date: Wed, 7 Jan 2026 12:17:16 +0000 Subject: [PATCH 12/17] Enable forecast attachments in replies --- .../src/components/comment_feed/comment.tsx | 64 +++++++++---------- 1 file changed, 30 insertions(+), 34 deletions(-) diff --git a/front_end/src/components/comment_feed/comment.tsx b/front_end/src/components/comment_feed/comment.tsx index 2227168519..9905f06ba3 100644 --- a/front_end/src/components/comment_feed/comment.tsx +++ b/front_end/src/components/comment_feed/comment.tsx @@ -825,41 +825,37 @@ const Comment: FC = ({ withUgcLinks withCodeBlocks /> - {hadForecastAtCommentCreation && - postData?.question && - !comment.parent_id && ( - - {(tags) => ( - - setIncludeEditForecast(checked) - } - label="includeMyForecastAtTheTime" - className="ml-auto mt-2 w-fit text-sm" - disabled={!!includedForecast} + {hadForecastAtCommentCreation && postData?.question && ( + + {(tags) => ( + + setIncludeEditForecast(checked) + } + label="includeMyForecastAtTheTime" + className="ml-auto mt-2 w-fit text-sm" + disabled={!!includedForecast} + > + - - {t.rich("includeMyForecastAtTheTime", { - ...tags, - forecast: formatForecastValueText( - userForecastToForecastType( - hadForecastAtCommentCreation, - postData.question - ) - ), - })} - - - )} - - )} + {t.rich("includeMyForecastAtTheTime", { + ...tags, + forecast: formatForecastValueText( + userForecastToForecastType( + hadForecastAtCommentCreation, + postData.question + ) + ), + })} + + + )} + + )}
{!!errorMessage && ( Date: Wed, 7 Jan 2026 12:18:19 +0000 Subject: [PATCH 13/17] Removed todo --- front_end/src/components/comment_feed/comment_editor.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/front_end/src/components/comment_feed/comment_editor.tsx b/front_end/src/components/comment_feed/comment_editor.tsx index 9350fbdb6c..235f01f94f 100644 --- a/front_end/src/components/comment_feed/comment_editor.tsx +++ b/front_end/src/components/comment_feed/comment_editor.tsx @@ -210,7 +210,6 @@ const CommentEditor: FC = ({ )} - {/* TODO: this box can only be shown in create, not edit mode */} {shouldIncludeForecast && ( Date: Wed, 7 Jan 2026 12:29:19 +0000 Subject: [PATCH 14/17] Allow including forecast in replies --- .../src/components/comment_feed/comment.tsx | 9 +++++++ .../comment_feed/comment_editor.tsx | 24 ++++++++----------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/front_end/src/components/comment_feed/comment.tsx b/front_end/src/components/comment_feed/comment.tsx index 9905f06ba3..57ceaf8f14 100644 --- a/front_end/src/components/comment_feed/comment.tsx +++ b/front_end/src/components/comment_feed/comment.tsx @@ -51,6 +51,7 @@ import { sendAnalyticsEvent } from "@/utils/analytics"; import { parseUserMentions } from "@/utils/comments"; import cn from "@/utils/core/cn"; import { logError } from "@/utils/core/errors"; +import { isForecastActive } from "@/utils/forecasts/helpers"; import { formatUsername } from "@/utils/formatters/users"; import { getMarkdownSummary } from "@/utils/markdown"; import { canPredictQuestion } from "@/utils/questions/predictions"; @@ -282,6 +283,13 @@ const Comment: FC = ({ !!postData?.conditional; const isCmmButtonDisabled = !user || !userCanPredict || isCommentAuthor; + const canIncludeForecastInReply = useMemo(() => { + if (!postData?.question) return false; + if (postData.question.type === QuestionType.MultipleChoice) return false; + const latest = postData.question.my_forecasts?.latest; + return !!latest && isForecastActive(latest); + }, [postData]); + const { draftReady: editDraftReady, initialMarkdown: editInitialMarkdown, @@ -1033,6 +1041,7 @@ const Comment: FC = ({ setIsReplying(false); }} isReplying={isReplying} + shouldIncludeForecast={canIncludeForecastInReply} /> )} {isKeyfactorsFormOpen && postData && ( diff --git a/front_end/src/components/comment_feed/comment_editor.tsx b/front_end/src/components/comment_feed/comment_editor.tsx index 235f01f94f..e44e02277f 100644 --- a/front_end/src/components/comment_feed/comment_editor.tsx +++ b/front_end/src/components/comment_feed/comment_editor.tsx @@ -210,20 +210,6 @@ const CommentEditor: FC = ({ )} - {shouldIncludeForecast && ( - { - setHasIncludedForecast(checked); - }} - label={t("includeMyForecast")} - className="p-1 text-sm" - /> - )} - {/* TODO: display in preview mode only */} - {/*comment.included_forecast && ( - - )*/}
= ({ /> )}
+ {shouldIncludeForecast && ( + { + setHasIncludedForecast(checked); + }} + label={t("includeMyForecast")} + className="ml-auto mt-2 w-fit text-sm" + /> + )} {(isReplying || hasInteracted) && (
{!isReplying && isPrivateFeed && ( From 621c49aaa3ff8806db2d0fff00cba0d9e5a9b60a Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 7 Jan 2026 13:07:05 +0000 Subject: [PATCH 15/17] Fixed last forecast for closed questions --- comments/views/common.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/comments/views/common.py b/comments/views/common.py index 8fc4b704f4..572d64143d 100644 --- a/comments/views/common.py +++ b/comments/views/common.py @@ -1,6 +1,7 @@ from django.db import transaction from django.db.models import Q from django.shortcuts import get_object_or_404 +from django.utils import timezone from rest_framework import serializers, status from rest_framework.decorators import api_view, permission_classes from rest_framework.exceptions import PermissionDenied, ValidationError @@ -177,9 +178,16 @@ def comment_edit_api_view(request: Request, pk: int): forecast = None if include_forecast and not comment.included_forecast and post and post.question_id: + active_time = comment.created_at + question = post.question + + # If question was closed, take the forecast active on the date of closure + if question.actual_close_time and question.actual_close_time <= timezone.now(): + active_time = question.actual_close_time + forecast = ( - post.question.user_forecasts.filter(author=comment.author) - .filter_active_at(comment.created_at) + question.user_forecasts.filter(author=comment.author) + .filter_active_at(active_time) .order_by("-start_time") .first() ) From be0346d96eb54fefbaa1e8b0832b3266d0813cd3 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 7 Jan 2026 13:15:58 +0000 Subject: [PATCH 16/17] Added test --- tests/unit/test_comments/test_views.py | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/unit/test_comments/test_views.py b/tests/unit/test_comments/test_views.py index 7fba7d2d74..e28c47f12a 100644 --- a/tests/unit/test_comments/test_views.py +++ b/tests/unit/test_comments/test_views.py @@ -797,3 +797,45 @@ def test_comment_edit_include_forecast(self, user1, user1_client, question_binar assert response.status_code == 200 comment.refresh_from_db() assert comment.included_forecast == forecast_1 + + def test_comment_edit_include_forecast_closed_question( + self, user1, user1_client, question_binary + ): + """When question is closed, forecast should be taken from closure time, not comment creation time.""" + post = factory_post(author=user1, question=question_binary) + question = post.question + now = timezone.now() + + # Timeline: + t_forecast = now - timedelta(hours=3) + t_close = now - timedelta(hours=2) + + # Create forecast before question closure + with freeze_time(t_forecast): + forecast = create_forecast( + question=question, + user=user1, + probability_yes=0.6, + ) + + # Forecast end_time is after closure (still active at closure) + forecast.end_time = now - timedelta(hours=1) + forecast.save() + + # Close the question + question.actual_close_time = t_close + question.save() + + # Create comment after closure + comment = factory_comment(author=user1, on_post=post) + + # Edit comment to include forecast + url = reverse("comment-edit", kwargs={"pk": comment.pk}) + response = user1_client.post( + url, {"text": "Comment with forecast", "include_forecast": True} + ) + + assert response.status_code == 200 + comment.refresh_from_db() + # Should attach forecast active at closure time + assert comment.included_forecast == forecast \ No newline at end of file From ea1279d7030002b5df5ee82d0b97cafedbd867e1 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 7 Jan 2026 13:20:19 +0000 Subject: [PATCH 17/17] Small fix --- tests/unit/test_comments/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_comments/test_views.py b/tests/unit/test_comments/test_views.py index e28c47f12a..18293193e5 100644 --- a/tests/unit/test_comments/test_views.py +++ b/tests/unit/test_comments/test_views.py @@ -838,4 +838,4 @@ def test_comment_edit_include_forecast_closed_question( assert response.status_code == 200 comment.refresh_from_db() # Should attach forecast active at closure time - assert comment.included_forecast == forecast \ No newline at end of file + assert comment.included_forecast == forecast