From e43fc21ff845288ed2d4c092dc4efe2510d4ae18 Mon Sep 17 00:00:00 2001 From: Sylvain <74110469+SylvainChevalier@users.noreply.github.com> Date: Tue, 2 Dec 2025 18:19:21 +0100 Subject: [PATCH 01/22] fix: show comments on medium screens in consumer views (#3848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: show comments on medium screens (640px-1024px) in consumer views Changed ResponsiveCommentFeed display breakpoint from lg:block to sm:block to ensure comments are visible for screen widths between 640px and 1024px. Previously, comments were hidden in this range because the tabbed version was hidden at sm (≥640px) and the feed version only appeared at lg (≥1024px). Fixes #3846 Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Sylvain --- .../question_layout/consumer_question_layout/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/index.tsx index 6d7bae19a..b9c86a2cf 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/index.tsx @@ -108,7 +108,7 @@ const ConsumerQuestionLayout: React.FC> = ({ /> -
+
From 9d52f493c61659de1032e51f317df4194078d9d2 Mon Sep 17 00:00:00 2001 From: Hlib Date: Tue, 2 Dec 2025 19:13:32 +0100 Subject: [PATCH 02/22] Post versions history (#3840) * S3 Posts history versioning * Small fix * Small adjustments * Fixed todo * Added smoke tests * Small fix * Connected admin layers * Added duplication prevention * Fixed formatting --- metaculus_web/settings.py | 6 +- posts/admin.py | 6 + posts/services/common.py | 16 +- posts/services/versioning.py | 225 ++++++++++++++++++ posts/tasks.py | 16 +- posts/views.py | 2 +- questions/admin.py | 14 ++ .../test_services/test_versioning.py | 95 ++++++++ utils/aws.py | 12 + 9 files changed, 384 insertions(+), 8 deletions(-) create mode 100644 posts/services/versioning.py create mode 100644 tests/unit/test_posts/test_services/test_versioning.py create mode 100644 utils/aws.py diff --git a/metaculus_web/settings.py b/metaculus_web/settings.py index 808c1b249..7b11a0ee6 100644 --- a/metaculus_web/settings.py +++ b/metaculus_web/settings.py @@ -311,7 +311,6 @@ "url": f"{REDIS_URL}/2?{REDIS_URL_CONFIG}", } - # Setting StubBroker broker for unit tests environment # Integration tests should run as the real env if IS_TEST_ENV: @@ -365,6 +364,11 @@ ) AWS_S3_FILE_OVERWRITE = False AWS_QUERYSTRING_AUTH = False +# S3 bucket to store posts’ version history. +# Version tracking will be disabled if this isn’t set. +AWS_STORAGE_BUCKET_POST_VERSION_HISTORY = os.environ.get( + "AWS_STORAGE_BUCKET_POST_VERSION_HISTORY" +) # Cloudflare captcha # https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ diff --git a/posts/admin.py b/posts/admin.py index 9a9f427ac..d77cc8b2f 100644 --- a/posts/admin.py +++ b/posts/admin.py @@ -9,6 +9,7 @@ from posts.models import Post, Notebook from posts.services.common import trigger_update_post_translations from posts.services.hotness import explain_post_hotness +from posts.tasks import run_post_generate_history_snapshot from questions.models import Question from questions.services.forecasts import build_question_forecasts from utils.csv_utils import export_all_data_for_questions @@ -169,6 +170,11 @@ def get_fields(self, request, obj=None): fields.insert(0, field) return fields + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + + run_post_generate_history_snapshot.send(obj.id, request.user.id) + @admin.register(Notebook) class NotebookAdmin(CustomTranslationAdmin): diff --git a/posts/services/common.py b/posts/services/common.py index 9b9f0aacd..0515d2d89 100644 --- a/posts/services/common.py +++ b/posts/services/common.py @@ -16,8 +16,11 @@ from projects.models import Project from projects.permissions import ObjectPermission from projects.services.cache import invalidate_projects_questions_count_cache -from projects.services.common import get_projects_staff_users, get_site_main_project -from projects.services.common import move_project_forecasting_end_date +from projects.services.common import ( + get_projects_staff_users, + get_site_main_project, + move_project_forecasting_end_date, +) from questions.models import Question from questions.services.common import ( create_conditional, @@ -42,7 +45,8 @@ update_translations_for_model, ) from .search import generate_post_content_for_embedding_vectorization -from ..tasks import run_post_indexing +from .versioning import PostVersionService +from ..tasks import run_post_indexing, run_post_generate_history_snapshot logger = logging.getLogger(__name__) @@ -236,6 +240,7 @@ def update_post( conditional: dict = None, group_of_questions: dict = None, notebook: dict = None, + updated_by: User = None, **kwargs, ): # We need to edit post & questions content in the original mode @@ -297,6 +302,11 @@ def update_post( ): run_post_indexing.send(post.id) + if PostVersionService.check_is_enabled(): + run_post_generate_history_snapshot( + post.id, updated_by.id if updated_by else None + ) + return post diff --git a/posts/services/versioning.py b/posts/services/versioning.py new file mode 100644 index 000000000..7fdbd2b3f --- /dev/null +++ b/posts/services/versioning.py @@ -0,0 +1,225 @@ +import json +import time + +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder +from django.utils import translation + +from questions.models import Question, GroupOfQuestions, Conditional +from users.models import User +from utils.aws import get_boto_client +from ..models import Post, Notebook + + +def drop_keys(obj: dict, drop: list): + """Recursively remove keys from nested dicts (in-place).""" + + if isinstance(obj, dict): + for key in list(obj.keys()): + if key in drop: + del obj[key] + else: + drop_keys(obj[key], drop) + + return obj + + +class PostVersionService: + @classmethod + def get_post_version_snapshot(cls, post: Post, updated_by: User = None) -> dict: + """ + Generates a dictionary snapshot of the post's current state. + """ + + # Ensure we are using the original language for extraction + with translation.override(settings.ORIGINAL_LANGUAGE_CODE): + snapshot = cls._extract_fields( + post, + [ + "id", + "title", + "short_title", + "content_original_lang", + "author_id", + "default_project_id", + "open_time", + "scheduled_close_time", + "scheduled_resolve_time", + "edited_at", + ], + ) + + snapshot["updated_by_user_id"] = updated_by.id if updated_by else None + + if post.question_id: + snapshot["question"] = cls._get_question_snapshot(post.question) + elif post.group_of_questions_id: + snapshot["group_of_questions"] = cls._get_group_snapshot( + post.group_of_questions + ) + elif post.conditional_id: + snapshot["conditional"] = cls._get_conditional_snapshot( + post.conditional + ) + elif post.notebook_id: + snapshot["conditional"] = cls._get_notebook_snapshot(post.notebook) + + return snapshot + + @classmethod + def _get_question_snapshot(cls, question: Question) -> dict: + common_fields = [ + "id", + "type", + "created_at", + "edited_at", + "open_time", + "scheduled_close_time", + "scheduled_resolve_time", + "actual_resolve_time", + "resolution_set_time", + "actual_close_time", + "cp_reveal_time", + "spot_scoring_time", + "resolution", + "include_bots_in_aggregates", + "question_weight", + "default_score_type", + "default_aggregation_method", + "title", + "description", + "resolution_criteria", + "fine_print", + "label", + "range_min", + "range_max", + "zero_point", + "open_upper_bound", + "open_lower_bound", + "inbound_outcome_count", + "unit", + "options", + "group_variable", + "group_rank", + ] + data = cls._extract_fields(question, common_fields) + + return data + + @classmethod + def _get_group_snapshot(cls, group: GroupOfQuestions) -> dict: + data = cls._extract_fields( + group, + [ + "id", + "graph_type", + "subquestions_order", + "group_variable", + "description", + "resolution_criteria", + "fine_print", + "edited_at", + ], + ) + + data["questions"] = [ + cls._get_question_snapshot(q) for q in group.questions.all().order_by("id") + ] + return data + + @classmethod + def _get_conditional_snapshot(cls, conditional: Conditional) -> dict: + data = cls._extract_fields( + conditional, ["id", "condition_id", "condition_child_id", "edited_at"] + ) + data["question_yes"] = cls._get_question_snapshot(conditional.question_yes) + data["question_no"] = cls._get_question_snapshot(conditional.question_no) + + return data + + @classmethod + def _get_notebook_snapshot(cls, notebook: Notebook) -> dict: + data = cls._extract_fields(notebook, ["id", "markdown", "markdown_summary"]) + data["image_url"] = str(notebook.image_url) if notebook.image_url else None + + return data + + @classmethod + def _extract_fields(cls, obj, fields: list[str]) -> dict: + """ + Helper to extract a list of fields from an object into a dictionary. + """ + return {field: getattr(obj, field) for field in fields if hasattr(obj, field)} + + @classmethod + def check_is_enabled(cls): + return bool(settings.AWS_STORAGE_BUCKET_POST_VERSION_HISTORY) + + @classmethod + def upload_snapshot_to_s3(cls, post: Post, snapshot: dict): + """ + Uploads the snapshot to S3 as a JSON file. + Path: post_versions/{post_id}/{timestamp}.json + """ + + s3 = get_boto_client("s3") + + key = f"post_versions/{post.id}/{round(time.time() * 1000)}.json" + + s3.put_object( + Bucket=settings.AWS_STORAGE_BUCKET_POST_VERSION_HISTORY, + Key=key, + Body=json.dumps(snapshot, cls=DjangoJSONEncoder), + ContentType="application/json", + ) + + @classmethod + def get_latest_snapshot_from_s3(cls, post: Post) -> dict | None: + s3 = get_boto_client("s3") + prefix = f"post_versions/{post.id}/" + + response = s3.list_objects_v2( + Bucket=settings.AWS_STORAGE_BUCKET_POST_VERSION_HISTORY, + Prefix=prefix, + ) + contents = response.get("Contents", []) + if not contents: + return None + + # Sort by Key (which contains timestamp) descending + latest_obj = sorted(contents, key=lambda x: x["Key"], reverse=True)[0] + + obj = s3.get_object( + Bucket=settings.AWS_STORAGE_BUCKET_POST_VERSION_HISTORY, + Key=latest_obj["Key"], + ) + return json.loads(obj["Body"].read().decode("utf-8")) + + @classmethod + def hash_obj(cls, obj: dict) -> str: + obj = obj.copy() + drop = ["edited_at", "updated_by_user_id"] + + return json.dumps(drop_keys(obj, drop), sort_keys=True, cls=DjangoJSONEncoder) + + @classmethod + def _snapshots_are_equal(cls, s1: dict, s2: dict) -> bool: + """ + Compares two snapshots, ignoring metadata fields that always change. + """ + # Create copies to avoid modifying originals + + return cls.hash_obj(s1) == cls.hash_obj(s2) + + @classmethod + def generate_and_upload(cls, post: Post, updated_by: User = None): + if not cls.check_is_enabled(): + return + + snapshot = cls.get_post_version_snapshot(post, updated_by) + latest_snapshot = cls.get_latest_snapshot_from_s3(post) + + if latest_snapshot and cls._snapshots_are_equal(snapshot, latest_snapshot): + return + + cls.upload_snapshot_to_s3(post, snapshot) diff --git a/posts/tasks.py b/posts/tasks.py index 5e25744a7..6fb9113c2 100644 --- a/posts/tasks.py +++ b/posts/tasks.py @@ -3,10 +3,12 @@ import dramatiq from misc.services.itn import generate_related_articles_for_post -from posts.models import Post -from posts.services.search import update_post_search_embedding_vector -from posts.services.subscriptions import notify_post_cp_change +from users.models import User from utils.dramatiq import concurrency_retries, task_concurrent_limit +from .models import Post +from .services.search import update_post_search_embedding_vector +from .services.subscriptions import notify_post_cp_change +from .services.versioning import PostVersionService logger = logging.getLogger(__name__) @@ -57,3 +59,11 @@ def run_post_indexing(post_id): generate_related_articles_for_post(post) except Post.DoesNotExist: logger.warning(f"Post {post_id} does not exist") + + +@dramatiq.actor(max_retries=1) +def run_post_generate_history_snapshot(post_id: int, updated_by_id: int): + updated_by = User.objects.get(pk=updated_by_id) if updated_by_id else None + post = Post.objects.get(pk=post_id) + + PostVersionService.generate_and_upload(post, updated_by=updated_by) diff --git a/posts/views.py b/posts/views.py index fe4ead9ed..773627a8a 100644 --- a/posts/views.py +++ b/posts/views.py @@ -311,7 +311,7 @@ def post_update_api_view(request, pk): ) serializer.is_valid(raise_exception=True) - post = update_post(post, **serializer.validated_data) + post = update_post(post, updated_by=request.user, **serializer.validated_data) should_delete = check_and_handle_post_spam(request.user, post) diff --git a/questions/admin.py b/questions/admin.py index dbefab525..66e662f75 100644 --- a/questions/admin.py +++ b/questions/admin.py @@ -7,6 +7,7 @@ from django_better_admin_arrayfield.admin.mixins import DynamicArrayMixin from posts.models import Post +from posts.tasks import run_post_generate_history_snapshot from questions.constants import UnsuccessfulResolutionType from questions.models import ( AggregateForecast, @@ -98,6 +99,13 @@ def get_fields(self, request, obj=None): fields.insert(0, field) return fields + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + post_id = obj.get_post_id() + + if post_id: + run_post_generate_history_snapshot.send(post_id, request.user.id) + def get_actions(self, request): actions = super().get_actions(request) if "delete_selected" in actions: @@ -254,6 +262,12 @@ def get_fields(self, request, obj=None): fields.insert(0, field) return fields + def save_model(self, request, obj: GroupOfQuestions, form, change): + super().save_model(request, obj, form, change) + + if obj.post_id: + run_post_generate_history_snapshot.send(obj.post_id, request.user.id) + @admin.register(Forecast) class ForecastAdmin(admin.ModelAdmin): diff --git a/tests/unit/test_posts/test_services/test_versioning.py b/tests/unit/test_posts/test_services/test_versioning.py new file mode 100644 index 000000000..6fcd02c01 --- /dev/null +++ b/tests/unit/test_posts/test_services/test_versioning.py @@ -0,0 +1,95 @@ +from unittest.mock import patch, MagicMock + +from django.test import override_settings + +from posts.services.versioning import PostVersionService +from questions.models import Question +from tests.unit.test_posts.factories import factory_post +from tests.unit.test_questions.factories import create_question + + +class TestPostVersionService: + def test_get_post_version_snapshot_question(self): + question = create_question( + question_type=Question.QuestionType.BINARY, title_original="Test Question" + ) + post = factory_post(question=question, title_original="Test Post") + + snapshot = PostVersionService.get_post_version_snapshot(post) + + assert snapshot["id"] == post.id + assert snapshot["title"] == "Test Post" + assert snapshot["question"]["id"] == question.id + assert snapshot["question"]["title"] == "Test Question" + assert snapshot["question"]["type"] == "binary" + + @override_settings(AWS_STORAGE_BUCKET_POST_VERSION_HISTORY="test-bucket") + @patch("posts.services.versioning.get_boto_client") + def test_generate_and_upload_enabled(self, mock_get_client): + mock_s3 = MagicMock() + mock_get_client.return_value = mock_s3 + # Mock list_objects_v2 to return empty so it proceeds to upload + mock_s3.list_objects_v2.return_value = {} + + question = create_question(question_type=Question.QuestionType.BINARY) + post = factory_post(question=question) + + PostVersionService.generate_and_upload(post) + + assert mock_s3.put_object.called + call_args = mock_s3.put_object.call_args[1] + assert call_args["Bucket"] == "test-bucket" + assert f"post_versions/{post.id}/" in call_args["Key"] + + @override_settings(AWS_STORAGE_BUCKET_POST_VERSION_HISTORY=None) + @patch("posts.services.versioning.get_boto_client") + def test_generate_and_upload_disabled(self, mock_get_client): + question = create_question(question_type=Question.QuestionType.BINARY) + post = factory_post(question=question) + + PostVersionService.generate_and_upload(post) + + assert not mock_get_client.called + + @override_settings(AWS_STORAGE_BUCKET_POST_VERSION_HISTORY="test-bucket") + @patch("posts.services.versioning.get_boto_client") + def test_generate_and_upload_deduplication(self, mock_get_client): + mock_s3 = MagicMock() + mock_get_client.return_value = mock_s3 + + question = create_question(question_type=Question.QuestionType.BINARY) + post = factory_post(question=question) + + # 1. First upload (no existing snapshot) + # Mock list_objects_v2 to return empty + mock_s3.list_objects_v2.return_value = {} + + PostVersionService.generate_and_upload(post) + assert mock_s3.put_object.call_count == 1 + + # 2. Second upload (same content) + # Mock list_objects_v2 to return the uploaded file + # And get_object to return the content of the first snapshot + + # Capture the uploaded content from the first call + call_args = mock_s3.put_object.call_args[1] + uploaded_body = call_args["Body"] # This is a JSON string + + mock_s3.list_objects_v2.return_value = {"Contents": [{"Key": call_args["Key"]}]} + mock_s3.get_object.return_value = { + "Body": MagicMock(read=lambda: uploaded_body.encode("utf-8")) + } + + # Reset put_object mock to verify it's NOT called + mock_s3.put_object.reset_mock() + + PostVersionService.generate_and_upload(post) + assert mock_s3.put_object.call_count == 0 + + # 3. Third upload (changed content) + # Change title + post.title = "New Title" + post.save() + + PostVersionService.generate_and_upload(post) + assert mock_s3.put_object.call_count == 1 diff --git a/utils/aws.py b/utils/aws.py new file mode 100644 index 000000000..e6bb9c70c --- /dev/null +++ b/utils/aws.py @@ -0,0 +1,12 @@ +import boto3 +from django.conf import settings + + +def get_boto_client(*args, **kwargs): + return boto3.client( + *args, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + region_name=settings.AWS_S3_REGION_NAME, + **kwargs, + ) From 0318effb083e116d9cf23bf465aa85e94bd715ff Mon Sep 17 00:00:00 2001 From: Hlib Date: Tue, 2 Dec 2025 19:35:03 +0100 Subject: [PATCH 03/22] Fixed Social Signup Last Login Date (#3839) --- authentication/social_pipeline.py | 11 +++++++++++ metaculus_web/settings.py | 1 + 2 files changed, 12 insertions(+) diff --git a/authentication/social_pipeline.py b/authentication/social_pipeline.py index 13fdf3dbd..2d07b60aa 100644 --- a/authentication/social_pipeline.py +++ b/authentication/social_pipeline.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.contrib.auth import user_logged_in from rest_framework.exceptions import ValidationError @@ -7,3 +8,13 @@ def check_signup_allowed(strategy, details, backend, user=None, *args, **kwargs) raise ValidationError("Signup is disabled") return {"user": user, **kwargs} + + +def send_user_logged_in(strategy, user=None, *args, **kwargs): + """ + Sends the user_logged_in signal when a user logs in via social auth. + """ + if user: + user_logged_in.send(sender=user.__class__, request=strategy.request, user=user) + + return {"user": user, **kwargs} diff --git a/metaculus_web/settings.py b/metaculus_web/settings.py index 7b11a0ee6..d35b35e01 100644 --- a/metaculus_web/settings.py +++ b/metaculus_web/settings.py @@ -208,6 +208,7 @@ "social_core.pipeline.social_auth.associate_user", "social_core.pipeline.social_auth.load_extra_data", "social_core.pipeline.user.user_details", + "authentication.social_pipeline.send_user_logged_in", ) SOCIAL_AUTH_FACEBOOK_KEY = os.environ.get("SOCIAL_AUTH_FACEBOOK_KEY") From 241242ccc474d3bc5a315ed7db6980d41eece320 Mon Sep 17 00:00:00 2001 From: Hlib Date: Tue, 2 Dec 2025 19:35:19 +0100 Subject: [PATCH 04/22] Fixed comments deletion counter (#3849) --- comments/services/common.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/comments/services/common.py b/comments/services/common.py index 7bd243e8b..47ff5aab5 100644 --- a/comments/services/common.py +++ b/comments/services/common.py @@ -197,18 +197,22 @@ def unpin_comment(comment: Comment): @transaction.atomic def soft_delete_comment(comment: Comment): - post = comment.on_post + if comment.is_soft_deleted: + return - # Decrement counter during comment deletion - post.snapshots.filter(viewed_at__gte=comment.created_at).update( - comments_count=F("comments_count") - 1 - ) + post = comment.on_post comment.is_soft_deleted = True comment.save(update_fields=["is_soft_deleted"]) post.update_comment_count() + if not comment.is_private: + # Decrement counter during comment deletion + post.snapshots.filter(viewed_at__gte=comment.created_at).update( + comments_count=F("comments_count") - 1 + ) + def compute_comment_score( comment_votes: int, From e49ad1b57ef56632c3e94f06d376e864fedefcb4 Mon Sep 17 00:00:00 2001 From: Hlib Date: Wed, 3 Dec 2025 15:54:57 +0000 Subject: [PATCH 05/22] Save initial version of post after the approval (#3855) Save initial version of post after approval --- posts/services/common.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/posts/services/common.py b/posts/services/common.py index 0515d2d89..8115a27e8 100644 --- a/posts/services/common.py +++ b/posts/services/common.py @@ -440,6 +440,10 @@ def approve_post( # Translate approved post trigger_update_post_translations(post, with_comments=False, force=False) + # Log initial post version + if PostVersionService.check_is_enabled(): + run_post_generate_history_snapshot(post.id, post.author_id) + @transaction.atomic def reject_post(post: Post): From 9e019b5ec651ae1ff8333dba9e49de0f53de88ed Mon Sep 17 00:00:00 2001 From: Hlib Date: Wed, 3 Dec 2025 15:56:19 +0000 Subject: [PATCH 06/22] Resolutions V2 Scores (#3817) * get_average_coverage_for_questions function * Small fix * Small fix * Participation block frontend * Score Card V1 * Small refactor * Small refactor * Small refactor * Small fix * Score card overlapping resolution * Info toggle container integration * Adjusted animation * Small fix * Small fix * Score Card Adjustment * Small fix * Adjusted animation * Refactoring * Small fix * Fixed dark mode * Small fix * Additional Scores table implementation * Small fix * Small fix * Group Table Resolution implementation * Extra fixes * Movile improvements * Added conditions support * Deprecated ScoreDisplay component * Small refactoring * Added translations * Fixed mobile consumer views tabs * Fixed font weight 800 * Small fixes * Small fix * Small fix * Hide resolution block for Annulled questions * Fix * Fixed score labels * Small fix * Extra spot scores text * Adjusted copy * Adjusted badges align * Normalize scaling * Fixed scores scaling * Fixed table margin * Bring back forecastMaker for continuous questions * Adjustments * Added participation summary question tiles * Small fix * Dynamic scaleScores GAP * Small fix * Small fix --- front_end/messages/cs.json | 32 +- front_end/messages/en.json | 32 +- front_end/messages/es.json | 30 + front_end/messages/pt.json | 32 +- front_end/messages/zh-TW.json | 32 +- front_end/messages/zh.json | 32 +- .../additional_scores_table.tsx | 182 ++++++ .../conditional_score_data.tsx | 49 ++ .../group_score_cell.tsx | 56 ++ .../group_resolution_score_data/index.tsx | 181 ++++++ .../[id]/components/post_score_data/index.tsx | 38 ++ .../post_score_data/participation_summary.tsx | 159 +++++ .../participation_summary_question_tile.tsx | 48 ++ .../resolution_score_cards.tsx | 88 +++ .../single_question_score_data.tsx | 53 ++ .../[id]/components/post_score_data/utils.ts | 41 ++ .../consumer_question_layout/index.tsx | 14 + .../question_layout/question_info.tsx | 11 + .../forecaster_question_view/index.tsx | 9 +- .../basic_consumer_post_card.tsx | 4 + .../forecast_maker_conditional_binary.tsx | 6 - .../forecast_maker_conditional_continuous.tsx | 2 - .../continuous_input_wrapper.tsx | 7 - .../forecast_maker_group_binary.tsx | 2 - .../forecast_maker_question/index.tsx | 2 - .../resolution/score_display.tsx | 181 ------ .../post_card/basic_post_card/index.tsx | 8 +- .../src/components/question/score_card.tsx | 542 ++++++++++++++++++ .../components/ui/info_toggle_container.tsx | 116 ++++ front_end/src/types/question.ts | 1 + front_end/src/utils/fonts.ts | 2 +- posts/views.py | 1 + 32 files changed, 1782 insertions(+), 211 deletions(-) create mode 100644 front_end/src/app/(main)/questions/[id]/components/post_score_data/additional_scores_table.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/post_score_data/conditional_score_data.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/post_score_data/group_resolution_score_data/group_score_cell.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/post_score_data/group_resolution_score_data/index.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/post_score_data/index.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary_question_tile.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/post_score_data/resolution_score_cards.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/post_score_data/single_question_score_data.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/post_score_data/utils.ts delete mode 100644 front_end/src/components/forecast_maker/resolution/score_display.tsx create mode 100644 front_end/src/components/question/score_card.tsx create mode 100644 front_end/src/components/ui/info_toggle_container.tsx diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 73f3b34f2..9ae3a26b6 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -843,7 +843,6 @@ "myBaselineScore": "Můj základní skór", "myPeerScore": "Můj skór kolegy", "mySpotBaselineScore": "Můj spot základní skór", - "mySpotPeerScore": "Můj spot skór", "myRelativeLegacyScore": "Můj relativní starší skór", "myRelativeLegacyArchivedScore": "Můj relativní starší archivovaný skór", "communityBaselineScore": "Základní skóre komunity", @@ -1636,5 +1635,36 @@ "aibShowMore": "Zobrazit více ({count})", "aibShowMoreAria": "Zobrazit více modelů ({count})", "aibSOTALinearTrend": "SOTA lineární trend", + "spotBaselineScore": "Základní spot skóre", + "resolutionScores": "Skóre výsledků", + "participationSummary": "Shrnutí účasti", + "participationSummaryPredictionNrStats": "Udělal(a) jste {userUpdates} {userUpdates, plural, =0 {aktualizací} =1 {aktualizaci} other {aktualizací} } vs. průměr komunity {communityUpdates} {communityUpdates, plural, =0 {aktualizací} =1 {aktualizaci} other {aktualizací} }.", + "participationSummaryCoverageBetterStats": "Vaše pokrytí bylo {userCoverage}%, což je lepší než průměrný předpovídač v této otázce ({averageCoverage}%).", + "participationSummaryCoverageWorseStats": "Vaše pokrytí bylo {userCoverage}%, což je horší než průměrný předpovídač v této otázce ({averageCoverage}%).", + "peer": "Vrstevník", + "bothPeerAndBaseline": "jak vrstevnické, tak základní", + "scores": "Skóre", + "additionalScores": "Další skóre", + "myScores": "Moje skóre", + "subquestion": "Podotázka", + "learnMoreAboutScores": "Zjistit více o skórech", + "here": "zde", + "averageOfPeers": "Průměr vrstevníků", + "mySpotPeerScore": "Moje skóre sportovce", + "spotPeerScore": "Skóre sportovce", + "participationSummaryScoreOutperformance": "Gratulujeme, překonali jste komunitní predikci v {scoreTypes}.", + "bothSpotPeerAndBaseline": "obě skóre Sport Peer a Spot Baseline", + "spotPeerScoreExplanation": "Spot Peer Skóre porovnává předpověď prognostika v jednom časovém bodě s předpověďmi ostatních lidí v té době. Pokud je kladné, předpověď byla (v průměru) lepší než ostatní, a naopak. Obvykle je čas hodnocení Spot chvíle, kdy byla odhalena Predikce komunity.", + "spotBaselineScoreExplanation": "Spot Základní Skóre porovnává předpověď prognostika v jednom časovém bodě s pevnou \"náhodnou\" základní hodnotou (50 % pro binární otázku atd.). Pokud je kladné, předpověď byla lepší než náhodně, a naopak. Obvykle je čas hodnocení Spot chvíle, kdy byla odhalena Predikce komunity.", + "whatIsBaselineScore": "Co je základní skóre?", + "baselineScoreExplanation": "Základní skóre porovnává předpověď prognostika s pevnou „náhodnou“ základnou (50 % pro binární otázku atd.). Pokud je kladné, předpověď byla lepší než náhoda, a naopak.", + "learnMoreAboutBaselineScore": "Zjistěte více o základním skóre", + "whatIsPeerScore": "Co je to Peer skóre?", + "peerScoreExplanation": "Peer skóre porovnává předpověď prognostika s předpověďmi ostatních. Pokud je kladné, předpověď byla (v průměru) lepší než ostatní, a naopak.", + "learnMoreAboutPeerScore": "Zjistěte více o Peer skóre", + "learnMoreAboutSpotScores": "Zjistěte více o Spot skórech", + "whatIsSpotPeerScore": "Co je Spot Peer skóre?", + "whatIsSpotBaselineScore": "Co je Spot základní skóre?", + "chanceBaseline": "Náhodná základní hodnota", "othersCount": "Ostatní ({count})" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 141b95634..8b65d61e8 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -134,7 +134,7 @@ "myBaselineScore": "My Baseline Score", "myPeerScore": "My Peer Score", "mySpotBaselineScore": "My Spot Baseline Score", - "mySpotPeerScore": "My Spot Score", + "mySpotPeerScore": "My Spot Peer Score", "myRelativeLegacyScore": "My Relative Legacy Score", "myRelativeLegacyArchivedScore": "My Relative Legacy Archived Score", "communityBaselineScore": "Community Baseline Score", @@ -439,9 +439,22 @@ "baselineScore": "Baseline Score", "baselineAccuracy": "Baseline Accuracy", "baselineAccuracyShort": "Baseline Accuracy", + "whatIsBaselineScore": "What is the Baseline Score?", + "baselineScoreExplanation": "The Baseline Score compares the forecaster's prediction to a fixed \"chance\" baseline (50% for a binary question, etc.). If it's positive, the prediction was better than chance, and vice versa.", + "learnMoreAboutBaselineScore": "Learn more about the Baseline Score", + "spotPeerScore": "Spot Peer Score", + "spotBaselineScore": "Spot Baseline Score", "peerScore": "Peer Score", "peerAccuracy": "Peer Accuracy", "peerAccuracyShort": "Peer Accuracy", + "whatIsPeerScore": "What is the Peer Score?", + "peerScoreExplanation": "The Peer Score compares the forecaster's prediction to everyone else's predictions. If it's positive, the prediction was (on average) better than others, and vice versa.", + "learnMoreAboutPeerScore": "Learn more about the Peer Score", + "learnMoreAboutSpotScores": "Learn more about the Spot Scores", + "whatIsSpotPeerScore": "What is the the Spot Peer Score?", + "spotPeerScoreExplanation": "The Spot Peer Score compares the forecaster's prediction at a single point in time to everyone else's predictions at that time. If it's positive, the prediction was (on average) better than others, and vice versa. The Spot Scoring Time is usually the time the Community Prediction was revealed.", + "whatIsSpotBaselineScore": "What is the the Spot Baseline Score?", + "spotBaselineScoreExplanation": "The Spot Baseline Score compares the forecaster's prediction at a single point in time to a fixed \"chance\" baseline (50% for a binary question, etc.). If it's positive, the prediction was better than chance, and vice versa. The Spot Scoring Time is usually the time the Community Prediction was revealed.", "insight": "Insight", "questionWriting": "Question Writing", "questionWritingShort": "Question Writing", @@ -792,6 +805,7 @@ "unfollowModalDescription": "You won't be notified about this question anymore", "best": "best", "cmmButton": "Changed my mind", + "resolutionScores": "Resolution Scores", "cmmUpdateButton": "Update", "cmmUpdatePredictionLink": "Update your prediction?", "updateYourPrediction": "Update your prediction", @@ -1399,6 +1413,14 @@ "tournamentSpotlightFeedbackAuthor": "((Bridgewater representative))", "visitTournamentPage": "Visit Tournament Page", "participations": "Participations", + "participationSummary": "Participation Summary", + "participationSummaryPredictionNrStats": "You made {userUpdates} {userUpdates, plural, =0 {updates} =1 {update} other {updates} } vs. a community average of {communityUpdates} {communityUpdates, plural, =0 {updates} =1 {update} other {updates} }.", + "participationSummaryCoverageBetterStats": "Your coverage was {userCoverage}%, better than the average forecaster on this question ({averageCoverage}%).", + "participationSummaryCoverageWorseStats": "Your coverage was {userCoverage}%, worse than the average forecaster on this question ({averageCoverage}%).", + "participationSummaryScoreOutperformance": "Congrats, you outperformed the Community Prediction in {scoreTypes}.", + "peer": "Peer", + "bothPeerAndBaseline": "both Peer and Baseline Scores", + "bothSpotPeerAndBaseline": "both Sport Peer and Spot Baseline Scores", "otherTournaments": "Other tournaments", "createPrivateInstance": "Create a Private Instance of Metaculus", "metaculusProForecasters": "Metaculus Pro Forecasters", @@ -1514,6 +1536,14 @@ "changes": "changes.", "currentValue": "Current Value", "baseline": "Baseline", + "scores": "Scores", + "additionalScores": "Additional Scores", + "myScores": "My Scores", + "subquestion": "Subquestion", + "learnMoreAboutScores": "Learn more about scores", + "here": "here", + "averageOfPeers": "Average of peers", + "chanceBaseline": "Chance baseline", "questionLinkCreated": "Question Link successfully created!", "questionLinkCreatedMultiple": "Question Links successfully created!", "returnToHomePage": "Return to Home page", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 53d61eb51..6f9f257b2 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1636,5 +1636,35 @@ "aibShowMore": "Mostrar más ({count})", "aibShowMoreAria": "Mostrar más modelos ({count})", "aibSOTALinearTrend": "Tendencia Lineal SOTA", + "spotBaselineScore": "Puntaje de Referencia Spot", + "resolutionScores": "Puntajes de Resolución", + "participationSummary": "Resumen de Participación", + "participationSummaryPredictionNrStats": "Hiciste {userUpdates} {userUpdates, plural, =0 {actualizaciones} =1 {actualización} other {actualizaciones} } versus un promedio comunitario de {communityUpdates} {communityUpdates, plural, =0 {actualizaciones} =1 {actualización} other {actualizaciones} }.", + "participationSummaryCoverageBetterStats": "Tu cobertura fue del {userCoverage}%, mejor que el promedio de pronosticadores en esta pregunta ({averageCoverage}%).", + "participationSummaryCoverageWorseStats": "Tu cobertura fue del {userCoverage}%, peor que el promedio de pronosticadores en esta pregunta ({averageCoverage}%).", + "peer": "Pares", + "bothPeerAndBaseline": "tanto en Pares como en Referencia", + "scores": "Puntuaciones", + "additionalScores": "Puntuaciones Adicionales", + "myScores": "Mis Puntuaciones", + "subquestion": "Subpregunta", + "learnMoreAboutScores": "Aprende más sobre las puntuaciones", + "here": "aquí", + "averageOfPeers": "Promedio de pares", + "spotPeerScore": "Puntuación de Par entre Puntos", + "participationSummaryScoreOutperformance": "Felicidades, superaste la Predicción de la Comunidad en {scoreTypes}.", + "bothSpotPeerAndBaseline": "tanto la Puntuación de Par como la Puntuación de Referencia de Puntos", + "spotPeerScoreExplanation": "El Spot Peer Score compara la predicción del pronosticador en un único momento con las predicciones de todos los demás en ese momento. Si es positivo, la predicción fue (en promedio) mejor que las otras, y viceversa. La hora de puntuación de Spot generalmente es cuando se reveló la Predicción de la Comunidad.", + "spotBaselineScoreExplanation": "El Spot Baseline Score compara la predicción del pronosticador en un único momento con una línea de base \"al azar\" (50% para una pregunta binaria, etc.). Si es positivo, la predicción fue mejor que al azar, y viceversa. La hora de puntuación de Spot generalmente es cuando se reveló la Predicción de la Comunidad.", + "whatIsBaselineScore": "¿Qué es la Puntuación Base?", + "baselineScoreExplanation": "La Puntuación Base compara la predicción del pronosticador con una línea base de \"azar\" fija (50% para una pregunta binaria, etc.). Si es positiva, la predicción fue mejor que el azar, y viceversa.", + "learnMoreAboutBaselineScore": "Más información sobre la Puntuación Base", + "whatIsPeerScore": "¿Qué es la Puntuación de Pares?", + "peerScoreExplanation": "La Puntuación de Pares compara la predicción del pronosticador con las predicciones de todos los demás. Si es positiva, la predicción fue (en promedio) mejor que las de otros, y viceversa.", + "learnMoreAboutPeerScore": "Más información sobre la Puntuación de Pares", + "learnMoreAboutSpotScores": "Más información sobre las Puntuaciones Puntuales", + "whatIsSpotPeerScore": "¿Qué es la Puntuación de Pares Puntual?", + "whatIsSpotBaselineScore": "¿Qué es la Puntuación Base Puntual?", + "chanceBaseline": "Línea base de azar", "othersCount": "Otros ({count})" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 2599b873d..74c953bbd 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -961,7 +961,6 @@ "myBaselineScore": "Minha Pontuação Básica", "myPeerScore": "Minha Pontuação de Pares", "mySpotBaselineScore": "Minha Pontuação Básica de Spot", - "mySpotPeerScore": "Minha Pontuação de Spot", "myRelativeLegacyScore": "Minha Pontuação Relativa de Legado", "myRelativeLegacyArchivedScore": "Minha Pontuação Relativa de Legado Arquivada", "communityBaselineScore": "Pontuação Básica da Comunidade", @@ -1634,5 +1633,36 @@ "aibShowMore": "Mostrar mais ({count})", "aibShowMoreAria": "Mostrar mais modelos ({count})", "aibSOTALinearTrend": "Tendência Linear SOTA", + "spotBaselineScore": "Pontuação de Referência Spot", + "resolutionScores": "Pontuações de Resolução", + "participationSummary": "Resumo de Participação", + "participationSummaryPredictionNrStats": "Você fez {userUpdates} {userUpdates, plural, =0 {atualizações} =1 {atualização} other {atualizações} } vs. uma média da comunidade de {communityUpdates} {communityUpdates, plural, =0 {atualizações} =1 {atualização} other {atualizações} }.", + "participationSummaryCoverageBetterStats": "Sua cobertura foi de {userCoverage}%, melhor que a do previsor médio nesta pergunta ({averageCoverage}%).", + "participationSummaryCoverageWorseStats": "Sua cobertura foi de {userCoverage}%, pior que a do previsor médio nesta pergunta ({averageCoverage}%).", + "peer": "Par", + "bothPeerAndBaseline": "tanto Par quanto Referência", + "scores": "Pontuações", + "additionalScores": "Pontuações Adicionais", + "myScores": "Minhas Pontuações", + "subquestion": "Subpergunta", + "learnMoreAboutScores": "Saiba mais sobre pontuações", + "here": "aqui", + "averageOfPeers": "Média dos pares", + "mySpotPeerScore": "Minha Pontuação de Par", + "spotPeerScore": "Pontuação de Par", + "participationSummaryScoreOutperformance": "Parabéns, você superou a Previsão da Comunidade em {scoreTypes}.", + "bothSpotPeerAndBaseline": "tanto Pontuação de Par quanto Pontuação de Referência", + "spotPeerScoreExplanation": "A Pontuação de Par de Spot compara a previsão do previsor em um único ponto no tempo com as previsões de todos os outros naquele momento. Se for positiva, a previsão foi (em média) melhor do que as outras, e vice-versa. O Tempo de Pontuação de Spot é geralmente o momento em que a Previsão da Comunidade foi revelada.", + "spotBaselineScoreExplanation": "A Pontuação de Referência de Spot compara a previsão do previsor em um único ponto no tempo com uma referência fixa de \"chance\" (50% para uma pergunta binária, etc.). Se for positiva, a previsão foi melhor do que a chance, e vice-versa. O Tempo de Pontuação de Spot é geralmente o momento em que a Previsão da Comunidade foi revelada.", + "whatIsBaselineScore": "O que é a Pontuação Básica?", + "baselineScoreExplanation": "A Pontuação Básica compara a previsão do previsor com um ponto de referência fixo de \"acaso\" (50% para uma pergunta binária, etc.). Se for positiva, a previsão foi melhor que o acaso, e vice-versa.", + "learnMoreAboutBaselineScore": "Saiba mais sobre a Pontuação Básica", + "whatIsPeerScore": "O que é a Pontuação de Pares?", + "peerScoreExplanation": "A Pontuação de Pares compara a previsão do previsor com as previsões de todos os outros. Se for positiva, a previsão foi (em média) melhor que as dos outros, e vice-versa.", + "learnMoreAboutPeerScore": "Saiba mais sobre a Pontuação de Pares", + "learnMoreAboutSpotScores": "Saiba mais sobre as Pontuações Pontuais", + "whatIsSpotPeerScore": "O que é a Pontuação Pontual de Pares?", + "whatIsSpotBaselineScore": "O que é a Pontuação Pontual Básica?", + "chanceBaseline": "Chance base", "othersCount": "Outros ({count})" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 63880a2bc..8e8beec7a 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -112,7 +112,6 @@ "myBaselineScore": "我的基準分數", "myPeerScore": "我的同儕分數", "mySpotBaselineScore": "我的即時基準分數", - "mySpotPeerScore": "我的即時得分", "myRelativeLegacyScore": "我的相對遺產分數", "myRelativeLegacyArchivedScore": "我的相對遺產存檔分數", "communityBaselineScore": "社群基準分數", @@ -1633,5 +1632,36 @@ "aibShowMore": "顯示更多({count})", "aibShowMoreAria": "顯示更多型號({count})", "aibSOTALinearTrend": "SOTA 線性趨勢", + "spotBaselineScore": "Spot 基準分數", + "resolutionScores": "決議分數", + "participationSummary": "參與總結", + "participationSummaryPredictionNrStats": "您做出了 {userUpdates} {userUpdates, plural, =0 {次更新} =1 {次更新} other {次更新} },而社群平均為 {communityUpdates} {communityUpdates, plural, =0 {次更新} =1 {次更新} other {次更新} }。", + "participationSummaryCoverageBetterStats": "您的涵蓋率是 {userCoverage}%,比該問題的平均預測者更好({averageCoverage}%)。", + "participationSummaryCoverageWorseStats": "您的涵蓋率是 {userCoverage}%,比該問題的平均預測者更差({averageCoverage}%)。", + "peer": "同行", + "bothPeerAndBaseline": "同行和基準", + "scores": "分數", + "additionalScores": "其他分數", + "myScores": "我的分數", + "subquestion": "子問題", + "learnMoreAboutScores": "了解更多關於分數", + "here": "這裡", + "averageOfPeers": "同行的平均值", + "mySpotPeerScore": "我的 Spot 同行評分", + "spotPeerScore": "Spot 同行評分", + "participationSummaryScoreOutperformance": "恭喜,您在{scoreTypes}方面超越了社群預測。", + "bothSpotPeerAndBaseline": "包含運動同行和 Spot 基準評分", + "spotPeerScoreExplanation": "即時同行分數將預測者在某個特定時間點的預測與其他人在同一時間點的預測進行比較。如果是正數,表示該預測比其他預測平均來說要好,反之亦然。即時評分時間通常是社群預測揭示的時間。", + "spotBaselineScoreExplanation": "即時基準分數將預測者在某個特定時間點的預測與固定的「機會」基準(例如,二元問題的50%)進行比較。如果是正數,表示該預測優於機會,反之亦然。即時評分時間通常是社群預測揭示的時間。", + "whatIsBaselineScore": "什麼是基線分數?", + "baselineScoreExplanation": "基線分數將預測者的預測與固定的「機會」基線進行比較(二元問題為50%等)。如果是正數,預測優於機會,反之亦然。", + "learnMoreAboutBaselineScore": "了解更多關於基線分數的信息", + "whatIsPeerScore": "什麼是同行分數?", + "peerScoreExplanation": "同行分數將預測者的預測與其他所有人的預測進行比較。如果是正數,預測平均優於其他人,反之亦然。", + "learnMoreAboutPeerScore": "了解更多關於同行分數的信息", + "learnMoreAboutSpotScores": "了解更多關於Spot分數的信息", + "whatIsSpotPeerScore": "什麼是Spot同行分數?", + "whatIsSpotBaselineScore": "什麼是Spot基線分數?", + "chanceBaseline": "機會基線", "withdrawAfterPercentSetting2": "問題總生命周期後撤回" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 416819cb1..91b752cbb 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -836,7 +836,6 @@ "myBaselineScore": "我的基线分数", "myPeerScore": "我的同伴分数", "mySpotBaselineScore": "我的即时基线分数", - "mySpotPeerScore": "我的即时分数", "myRelativeLegacyScore": "我的相对遗留分数", "myRelativeLegacyArchivedScore": "我的相对遗留存档分数", "communityBaselineScore": "社区基线分数", @@ -1638,5 +1637,36 @@ "aibShowMore": "显示更多 ({count})", "aibShowMoreAria": "显示更多模型 ({count})", "aibSOTALinearTrend": "SOTA线性趋势", + "spotBaselineScore": "现货基准分数", + "resolutionScores": "解答分数", + "participationSummary": "参与摘要", + "participationSummaryPredictionNrStats": "您做出了 {userUpdates} {userUpdates, plural, =0 {次更新} =1 {次更新} other {次更新} },社区平均为 {communityUpdates} {communityUpdates, plural, =0 {次更新} =1 {次更新} other {次更新} }。", + "participationSummaryCoverageBetterStats": "您的覆盖率为 {userCoverage}%,优于该问题上的平均预测者({averageCoverage}%)。", + "participationSummaryCoverageWorseStats": "您的覆盖率为 {userCoverage}%,逊于该问题上的平均预测者({averageCoverage}%)。", + "peer": "同行", + "bothPeerAndBaseline": "同行和基准", + "scores": "得分", + "additionalScores": "附加分数", + "myScores": "我的得分", + "subquestion": "子问题", + "learnMoreAboutScores": "了解更多关于得分的信息", + "here": "这里", + "averageOfPeers": "同行的平均值", + "mySpotPeerScore": "我的 Sport Peer 分数", + "spotPeerScore": "Sport Peer 分数", + "participationSummaryScoreOutperformance": "恭喜,你在{scoreTypes}中超越了社区预测。", + "bothSpotPeerAndBaseline": "同时 Sport Peer 和 Sport 基准分数", + "spotPeerScoreExplanation": "Spot同行评分将预测者在某一时刻的预测与其他人在同一时间的预测进行比较。如果是正数,说明预测(平均而言)比其他人好,反之亦然。Spot评分时间通常是社区预测揭晓的时间。", + "spotBaselineScoreExplanation": "Spot基线评分将预测者在某一时刻的预测与固定的“机会”基线(例如二元问题的50%)进行比较。如果是正数,说明预测比机会好,反之亦然。Spot评分时间通常是社区预测揭晓的时间。", + "whatIsBaselineScore": "什么是基准得分?", + "baselineScoreExplanation": "基准得分将预测者的预测与固定的“机会”基准进行比较(二元问题的基准是50%等)。如果是正值,预测优于机会,反之亦然。", + "learnMoreAboutBaselineScore": "了解有关基准得分的更多信息", + "whatIsPeerScore": "什么是同行得分?", + "peerScoreExplanation": "同行得分将预测者的预测与其他所有人的预测进行比较。如果是正值,预测(平均)优于其他人,反之亦然。", + "learnMoreAboutPeerScore": "了解有关同行得分的更多信息", + "learnMoreAboutSpotScores": "了解有关即时报分的更多信息", + "whatIsSpotPeerScore": "什么是即时报同行得分?", + "whatIsSpotBaselineScore": "什么是即时报基准得分?", + "chanceBaseline": "机会基准", "othersCount": "其他({count})" } diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/additional_scores_table.tsx b/front_end/src/app/(main)/questions/[id]/components/post_score_data/additional_scores_table.tsx new file mode 100644 index 000000000..088250095 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/additional_scores_table.tsx @@ -0,0 +1,182 @@ +import { isNil } from "lodash"; +import { useTranslations } from "next-intl"; +import React, { FC } from "react"; + +import SectionToggle from "@/components/ui/section_toggle"; +import { QuestionWithForecasts, ScoreData } from "@/types/question"; +import { TranslationKey } from "@/types/translations"; +import cn from "@/utils/core/cn"; + +type Variant = "auto" | "compact"; +type Props = { + question: QuestionWithForecasts; + separateCoverage?: boolean; + variant?: Variant; +}; + +const ScoreTable: FC<{ + rows: { label: string; value: string }[]; + className?: string; + variant?: Variant; +}> = ({ rows, className, variant = "auto" }) => ( +
+ {rows.map((row, index) => ( +
+ + {row.label} + + + {row.value} + +
+ ))} +
+); + +const getScore = (data: ScoreData | undefined, key: string) => { + const field = ( + key.includes("coverage") ? key : `${key}_score` + ) as keyof ScoreData; + return data?.[field]; +}; + +const mustHideCommunity = (key: string) => + key === "coverage" || key === "weighted_coverage"; + +const toCamel = (s: string) => + s.replace(/(^|_)(\w)/g, (_, __, c: string) => c.toUpperCase()); + +/** Builds translation key e.g. "myPeerScore", "communityBaselineScore" */ +const buildScoreLabelKey = ( + key: string, + forecaster: "user" | "community" +): TranslationKey => { + const prefix = forecaster === "user" ? "my" : "community"; + const suffix = key.includes("coverage") ? "" : "Score"; + + return (prefix + toCamel(key) + suffix) as TranslationKey; +}; + +export const AdditionalScoresTable: FC = ({ + question, + separateCoverage, + variant, +}) => { + const t = useTranslations(); + + if (!question) return null; + + const cpScores = + question.aggregations?.[question.default_aggregation_method]?.score_data; + const userScores = question.my_forecasts?.score_data; + + if (!cpScores && !userScores) return null; + + const spot = question.default_score_type.startsWith("spot"); + const peerKey = spot ? "spot_peer" : "peer"; + const baselineKey = spot ? "spot_baseline" : "baseline"; + + const scoreKeys = [ + "peer", + "baseline", + "spot_peer", + "spot_baseline", + "relative_legacy", + "relative_legacy_archived", + "coverage", + "weighted_coverage", + ]; + + const coverageRows: { label: string; value: string }[] = []; + const otherRows: { label: string; value: string }[] = []; + + for (const key of scoreKeys) { + if (key === peerKey || key === baselineKey) continue; + + const isCoverage = key.includes("coverage"); + const targetRows = isCoverage ? coverageRows : otherRows; + + const userVal = getScore(userScores, key); + if (!isNil(userVal)) { + const digits = key.includes("relative_legacy") ? 2 : 1; + const formattedValue = isCoverage + ? `${(userVal * 100).toFixed(digits)}%` + : userVal.toFixed(digits); + + targetRows.push({ + label: t(buildScoreLabelKey(key, "user")), + value: formattedValue, + }); + } + + if (!mustHideCommunity(key)) { + const cpVal = getScore(cpScores, key); + if (!isNil(cpVal)) { + const digits = key.includes("relative_legacy") ? 2 : 1; + const formattedValue = isCoverage + ? `${(cpVal * 100).toFixed(digits)}%` + : cpVal.toFixed(digits); + + targetRows.push({ + label: t(buildScoreLabelKey(key, "community")), + value: formattedValue, + }); + } + } + } + + if (coverageRows.length === 0 && otherRows.length === 0) return null; + + if (!separateCoverage) { + return ( + + ); + } + + return ( + <> + {coverageRows.length > 0 && ( + + )} + {otherRows.length > 0 && ( + + )} + + ); +}; + +const AdditionalScoresTableSection: FC = ({ question }) => { + const t = useTranslations(); + + const table = ; + + if (isNil(table)) return null; + + return ( + +
{table}
+
+ ); +}; + +export default AdditionalScoresTableSection; diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/conditional_score_data.tsx b/front_end/src/app/(main)/questions/[id]/components/post_score_data/conditional_score_data.tsx new file mode 100644 index 000000000..7a5df2e52 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/conditional_score_data.tsx @@ -0,0 +1,49 @@ +import React, { FC } from "react"; + +import { PostWithForecasts } from "@/types/post"; + +import SingleQuestionScoreData from "./single_question_score_data"; + +type Props = { + post: PostWithForecasts; + isConsumerView?: boolean; + noSectionWrapper?: boolean; +}; + +const ConditionalScoreData: FC = ({ + post, + isConsumerView, + noSectionWrapper, +}) => { + if (!post.conditional) return null; + + const { condition, question_yes, question_no } = post.conditional; + let effectivePost: PostWithForecasts | null = null; + + if (condition.resolution === "yes") { + effectivePost = { + ...post, + question: question_yes, + conditional: undefined, + } as unknown as PostWithForecasts; + } else if (condition.resolution === "no") { + effectivePost = { + ...post, + question: question_no, + conditional: undefined, + } as unknown as PostWithForecasts; + } + + if (!effectivePost) return null; + + return ( + + ); +}; + +export default ConditionalScoreData; diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/group_resolution_score_data/group_score_cell.tsx b/front_end/src/app/(main)/questions/[id]/components/post_score_data/group_resolution_score_data/group_score_cell.tsx new file mode 100644 index 000000000..3f72cf089 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/group_resolution_score_data/group_score_cell.tsx @@ -0,0 +1,56 @@ +import { isNil } from "lodash"; +import { useTranslations } from "next-intl"; +import React, { FC } from "react"; + +import cn from "@/utils/core/cn"; + +type Props = { + userScore: number | null | undefined; + communityScore: number | null | undefined; +}; + +const GroupScoreCell: FC = ({ userScore, communityScore }) => { + const t = useTranslations(); + + const formatScore = (score: number) => { + const sign = score > 0 ? "+" : ""; + return `${sign}${score.toFixed(1)}`; + }; + + const getScoreColor = (score: number) => { + if (score > 0) return "text-olive-800 dark:text-olive-800-dark"; + if (score < 0) return "text-salmon-800 dark:text-salmon-800-dark"; + return "text-gray-500 dark:text-gray-500-dark"; + }; + + return ( +
+ {/* User Score */} +
+ {!isNil(userScore) ? ( + + {formatScore(userScore)} + + ) : ( + + )} +
+ + {/* Community Score */} +
+ {!isNil(communityScore) ? ( + <> + {t("community")}:{" "} + + {formatScore(communityScore)} + + + ) : ( + + )} +
+
+ ); +}; + +export default GroupScoreCell; diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/group_resolution_score_data/index.tsx b/front_end/src/app/(main)/questions/[id]/components/post_score_data/group_resolution_score_data/index.tsx new file mode 100644 index 000000000..94f69d285 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/group_resolution_score_data/index.tsx @@ -0,0 +1,181 @@ +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { isNil } from "lodash"; +import { useTranslations } from "next-intl"; +import React, { FC } from "react"; + +import SectionToggle from "@/components/ui/section_toggle"; +import Tooltip from "@/components/ui/tooltip"; +import { PostWithForecasts } from "@/types/post"; +import { QuestionWithNumericForecasts, ScoreData } from "@/types/question"; + +import { AdditionalScoresTable } from "../additional_scores_table"; +import GroupScoreCell from "./group_score_cell"; + +type Props = { + post: PostWithForecasts; + isConsumerView?: boolean; +}; + +const getScore = (data: ScoreData | undefined, key: string) => { + const field = `${key}_score` as keyof ScoreData; + return data?.[field]; +}; + +type RowData = { + question: QuestionWithNumericForecasts; + cpBaselineScore: number | null | undefined; + userBaselineScore: number | null | undefined; + cpPeerScore: number | null | undefined; + userPeerScore: number | null | undefined; +}; + +const GroupResolutionScoreRow: FC = ({ + question, + cpBaselineScore, + userBaselineScore, + cpPeerScore, + userPeerScore, +}) => { + return ( +
+
+ + {question.label} + + + } + className="cursor-help text-blue-500 hover:text-blue-800 dark:text-blue-500-dark dark:hover:text-blue-800-dark" + tooltipClassName="p-0 border-none bg-transparent w-[320px]" + > + + +
+
+ +
+
+ +
+
+ ); +}; + +const GroupResolutionScores: FC = ({ post }) => { + const t = useTranslations(); + + if (!post.group_of_questions) return null; + + const rows = post.group_of_questions.questions.reduce((acc, q) => { + const spot = q.default_score_type.startsWith("spot"); + const peerKey = spot ? "spot_peer" : "peer"; + const baselineKey = spot ? "spot_baseline" : "baseline"; + + const cpScores = q.aggregations?.[q.default_aggregation_method]?.score_data; + const userScores = q.my_forecasts?.score_data; + const cpBaselineScore = getScore(cpScores, baselineKey); + + if (!isNil(cpBaselineScore)) { + acc.push({ + question: q, + cpBaselineScore, + userBaselineScore: getScore(userScores, baselineKey), + cpPeerScore: getScore(cpScores, peerKey), + userPeerScore: getScore(userScores, peerKey), + }); + } + return acc; + }, []); + + if (rows.length === 0) { + return null; + } + + const hasUserForecasts = rows.some((r) => r.question.my_forecasts?.latest); + + const renderHeader = (label: string, scoreLabel?: string) => ( +
+
{label}
+ {scoreLabel &&
{scoreLabel}
} + {!scoreLabel && ( + <> +
+ {t("baselineScore")} + {t("baseline")} +
+
+ {t("peerScore")} + {t("score")} +
+ + )} +
+ ); + + return ( + + {/* Mobile View: Baseline Table */} +
+ {renderHeader(t("subquestion"), t("baselineScore"))} + {rows.map((row) => ( +
+
+ {row.question.label} +
+
+ +
+
+ ))} +
+ + {/* Mobile View: Peer Table */} +
+ {renderHeader(t("subquestion"), t("peerScore"))} + {rows.map((row) => ( +
+
+ {row.question.label} +
+
+ +
+
+ ))} +
+ + {/* Desktop View */} +
+ {renderHeader(t("subquestion"))} + {rows.map((row) => ( + + ))} +
+
+ ); +}; + +export default GroupResolutionScores; diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/index.tsx b/front_end/src/app/(main)/questions/[id]/components/post_score_data/index.tsx new file mode 100644 index 000000000..811a6d46a --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/index.tsx @@ -0,0 +1,38 @@ +import React, { FC } from "react"; + +import { shouldPostShowScores } from "@/app/(main)/questions/[id]/components/post_score_data/utils"; +import { PostWithForecasts } from "@/types/post"; +import { + isConditionalPost, + isGroupOfQuestionsPost, +} from "@/utils/questions/helpers"; + +import ConditionalScoreData from "./conditional_score_data"; +import GroupResolutionScores from "./group_resolution_score_data"; +import SingleQuestionScoreData from "./single_question_score_data"; + +type Props = { + post: PostWithForecasts; + isConsumerView?: boolean; + noSectionWrapper?: boolean; +}; + +const PostScoreData: FC = (props) => { + const { post } = props; + + if (!shouldPostShowScores(post)) { + return null; + } + + if (isGroupOfQuestionsPost(post)) { + return ; + } + + if (isConditionalPost(post)) { + return ; + } + + return ; +}; + +export default PostScoreData; diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary.tsx b/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary.tsx new file mode 100644 index 000000000..c42c7db3a --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary.tsx @@ -0,0 +1,159 @@ +import { faClock } from "@fortawesome/free-regular-svg-icons"; +import { faFire, faRepeat } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { isNil } from "lodash"; +import { useTranslations } from "next-intl"; +import React, { PropsWithChildren, ReactNode } from "react"; + +import { QuestionWithForecasts } from "@/types/question"; +import cn from "@/utils/core/cn"; + +const ParticipationItem: React.FC< + PropsWithChildren<{ icon: ReactNode; className?: string }> +> = ({ icon, children, className }) => { + return ( +
+
+ {icon} +
+
{children}
+
+ ); +}; + +type Props = { + question: QuestionWithForecasts; + forecastsCount: number; + forecastersCount: number; + className?: string; + itemClassName?: string; +}; + +export const ParticipationSummary: React.FC = ({ + question, + forecastsCount, + forecastersCount, + className, + itemClassName, +}) => { + const t = useTranslations(); + + const userForecasts = question.my_forecasts?.history.length ?? 0; + const userScores = question.my_forecasts?.score_data; + + if (!userForecasts) { + return null; + } + + const communityScores = + question.aggregations[question.default_aggregation_method]?.score_data; + + const communityUpdates = + (forecastsCount - forecastersCount) / forecastersCount; + const userUpdates = Math.max(userForecasts - 1, 0); + + const userCoverage = userScores?.coverage ?? 0; + const averageCoverage = question.average_coverage ?? 0; + + const isSpot = question.default_score_type.includes("spot"); + const peerScoreKey = isSpot ? "spot_peer_score" : "peer_score"; + const baselineScoreKey = isSpot ? "spot_baseline_score" : "baseline_score"; + + const outperformedPeer = + !isNil(userScores?.[peerScoreKey]) && + !isNil(communityScores?.[peerScoreKey]) && + (userScores?.[peerScoreKey] ?? 0) > (communityScores?.[peerScoreKey] ?? 0); + const outperformedBaseline = + !isNil(userScores?.[baselineScoreKey]) && + !isNil(communityScores?.[baselineScoreKey]) && + (userScores?.[baselineScoreKey] ?? 0) > + (communityScores?.[baselineScoreKey] ?? 0); + + const getScoreTypes = () => { + if (outperformedPeer && outperformedBaseline) + return isSpot ? t("bothSpotPeerAndBaseline") : t("bothPeerAndBaseline"); + if (outperformedPeer) return t(isSpot ? "spotPeerScore" : "peerScore"); + return t(isSpot ? "spotBaselineScore" : "baselineScore"); + }; + + const richStrong = (chunk: ReactNode) => ( + + {chunk} + + ); + + return ( +
+ } + className={itemClassName} + > + {t.rich("participationSummaryPredictionNrStats", { + strong: richStrong, + communityUpdates: Math.round(communityUpdates * 10) / 10, + userUpdates: Math.round(userUpdates * 10) / 10, + })} + + } + className={itemClassName} + > + {t.rich( + userCoverage >= averageCoverage + ? "participationSummaryCoverageBetterStats" + : "participationSummaryCoverageWorseStats", + { + strong: richStrong, + userCoverage: Math.round(userCoverage * 100), + averageCoverage: Math.round(averageCoverage * 100), + } + )} + + {(outperformedPeer || outperformedBaseline) && ( + } + className={itemClassName} + > + {t.rich("participationSummaryScoreOutperformance", { + strong: richStrong, + scoreTypes: getScoreTypes(), + })} + + )} +
+ ); +}; + +const ParticipationSummarySection: React.FC = ({ + question, + forecastsCount, + forecastersCount, +}) => { + const t = useTranslations(); + + const userForecasts = question.my_forecasts?.history.length ?? 0; + + if (!userForecasts) { + return null; + } + + return ( +
+
+ {t("participationSummary")} +
+ +
+ ); +}; + +export default ParticipationSummarySection; diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary_question_tile.tsx b/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary_question_tile.tsx new file mode 100644 index 000000000..90d020aa1 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary_question_tile.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import React, { FC, PropsWithChildren } from "react"; + +import { ParticipationSummary } from "@/app/(main)/questions/[id]/components/post_score_data/participation_summary"; +import { shouldPostShowScores } from "@/app/(main)/questions/[id]/components/post_score_data/utils"; +import { PostWithForecasts } from "@/types/post"; +import { QuestionType } from "@/types/question"; +import { isQuestionPost } from "@/utils/questions/helpers"; + +type Props = { + post: PostWithForecasts; +}; + +const ParticipationSummaryQuestionTile: FC> = ({ + post, +}) => { + const t = useTranslations(); + + if ( + !isQuestionPost(post) || + post.question.type == QuestionType.MultipleChoice || + !shouldPostShowScores(post) + ) { + return null; + } + + const { question, nr_forecasters } = post; + + return ( +
+

+ {t("participationSummary")} +

+ + +
+ ); +}; + +export default ParticipationSummaryQuestionTile; diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/resolution_score_cards.tsx b/front_end/src/app/(main)/questions/[id]/components/post_score_data/resolution_score_cards.tsx new file mode 100644 index 000000000..6a09d8be2 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/resolution_score_cards.tsx @@ -0,0 +1,88 @@ +import { useTranslations } from "next-intl"; +import React, { FC } from "react"; + +import AdditionalScoresTableSection from "@/app/(main)/questions/[id]/components/post_score_data/additional_scores_table"; +import ScoreCard from "@/components/question/score_card"; +import SectionToggle from "@/components/ui/section_toggle"; +import { PostWithForecasts } from "@/types/post"; +import { ScoreData } from "@/types/question"; + +type Props = { + post: PostWithForecasts; + isConsumerView?: boolean; + noSectionWrapper?: boolean; +}; + +const getScore = (data: ScoreData | undefined, key: string) => { + const field = ( + key.includes("coverage") ? key : `${key}_score` + ) as keyof ScoreData; + return data?.[field]; +}; + +const ResolutionScoreCards: FC = ({ + post, + isConsumerView, + noSectionWrapper, +}) => { + const t = useTranslations(); + const { question } = post; + + if (!question) return null; + + const cpScores = + question.aggregations?.[question.default_aggregation_method]?.score_data; + const userScores = question.my_forecasts?.score_data; + + if (!cpScores && !userScores) return null; + + const spot = question.default_score_type.startsWith("spot"); + const peerKey = spot ? "spot_peer" : "peer"; + const baselineKey = spot ? "spot_baseline" : "baseline"; + + const renderPrimaryCards = () => ( +
+ + +
+ ); + + if (isConsumerView) { + if (noSectionWrapper) { + return renderPrimaryCards(); + } + + return ( + + {renderPrimaryCards()} + + ); + } + + return ( +
+ {renderPrimaryCards()} + +
+ ); +}; + +export default ResolutionScoreCards; diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/single_question_score_data.tsx b/front_end/src/app/(main)/questions/[id]/components/post_score_data/single_question_score_data.tsx new file mode 100644 index 000000000..60f18bd63 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/single_question_score_data.tsx @@ -0,0 +1,53 @@ +import React, { FC } from "react"; + +import ForecastMaker from "@/components/forecast_maker"; +import { PostWithForecasts } from "@/types/post"; +import { QuestionType } from "@/types/question"; +import { isContinuousQuestion } from "@/utils/questions/helpers"; + +import ParticipationSummarySection from "./participation_summary"; +import ResolutionScoreCards from "./resolution_score_cards"; + +type Props = { + post: PostWithForecasts; + isConsumerView?: boolean; + noSectionWrapper?: boolean; + hideParticipation?: boolean; +}; + +const SingleQuestionScoreData: FC = ({ + post, + isConsumerView, + noSectionWrapper, + hideParticipation, +}) => { + const { question, nr_forecasters } = post; + + if (!question) return null; + + if (isConsumerView) { + return ( + + ); + } + + return ( +
+ {!hideParticipation && question.type != QuestionType.MultipleChoice && ( + + )} + + {isContinuousQuestion(question) && } +
+ ); +}; + +export default SingleQuestionScoreData; diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/utils.ts b/front_end/src/app/(main)/questions/[id]/components/post_score_data/utils.ts new file mode 100644 index 000000000..3ae61719c --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/utils.ts @@ -0,0 +1,41 @@ +import { isNil } from "lodash"; + +import { PostWithForecasts } from "@/types/post"; +import { QuestionWithForecasts } from "@/types/question"; +import { + isConditionalPost, + isGroupOfQuestionsPost, + isQuestionPost, +} from "@/utils/questions/helpers"; +import { isUnsuccessfullyResolved } from "@/utils/questions/resolution"; + +export const shouldQuestionShowScores = (question: QuestionWithForecasts) => { + const cpScores = + question.aggregations?.[question.default_aggregation_method]?.score_data; + + return ( + !isNil(cpScores) && + Object.keys(cpScores).length > 0 && + !isUnsuccessfullyResolved(question.resolution) + ); +}; + +export function shouldPostShowScores(post: PostWithForecasts): boolean { + if (isGroupOfQuestionsPost(post)) { + return post.group_of_questions.questions.some(shouldQuestionShowScores); + } + + if (isConditionalPost(post)) { + const { condition, question_yes, question_no } = post.conditional; + + if (condition.resolution === "yes") { + return shouldQuestionShowScores(question_yes); + } else if (condition.resolution === "no") { + return shouldQuestionShowScores(question_no); + } + } + + if (isQuestionPost(post)) return shouldQuestionShowScores(post.question); + + return false; +} diff --git a/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/index.tsx index b9c86a2cf..3e18ba0c4 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/index.tsx @@ -2,6 +2,8 @@ import { useTranslations } from "next-intl"; import { PropsWithChildren, Suspense } from "react"; import KeyFactorsFeed from "@/app/(main)/questions/[id]/components/key_factors/key_factors_feed"; +import PostScoreData from "@/app/(main)/questions/[id]/components/post_score_data"; +import { shouldPostShowScores } from "@/app/(main)/questions/[id]/components/post_score_data/utils"; import ConsumerTabs from "@/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/consumer_tabs"; import DetailedGroupCard from "@/components/detailed_question_card/detailed_group_card"; import { TabsList, TabsSection, TabsTab } from "@/components/ui/tabs"; @@ -33,6 +35,8 @@ const ConsumerQuestionLayout: React.FC> = ({ postData.group_of_questions?.graph_type === GroupOfQuestionsGraphType.FanGraph; + const showScores = shouldPostShowScores(postData); + return (
@@ -47,6 +51,7 @@ const ConsumerQuestionLayout: React.FC> = ({ {t("inNews")} + {showScores && {t("scores")}} {t("keyFactors")} {t("info")} @@ -81,6 +86,15 @@ const ConsumerQuestionLayout: React.FC> = ({ + {showScores && ( + + + + )}
= ({ )} + } + consumer={ +
+ +
+ } + /> + {isConditionalPost(postData) && } diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/index.tsx index 204e7bd94..0e2786554 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/index.tsx @@ -3,7 +3,7 @@ import { Fragment } from "react"; import DetailedGroupCard from "@/components/detailed_question_card/detailed_group_card"; import DetailedQuestionCard from "@/components/detailed_question_card/detailed_question_card"; import ForecastMaker from "@/components/forecast_maker"; -import { PostWithForecasts } from "@/types/post"; +import { PostStatus, PostWithForecasts } from "@/types/post"; import { isGroupOfQuestionsPost, isQuestionPost, @@ -20,17 +20,20 @@ const ForecasterQuestionView: React.FC = ({ postData, preselectedGroupQuestionId, }) => { + const isResolved = postData.status === PostStatus.RESOLVED; + const isGroup = isGroupOfQuestionsPost(postData); + return ( {isQuestionPost(postData) && } - {isGroupOfQuestionsPost(postData) && ( + {isGroup && ( )} - + {(!isResolved || isGroup) && } ); }; diff --git a/front_end/src/components/consumer_post_card/basic_consumer_post_card.tsx b/front_end/src/components/consumer_post_card/basic_consumer_post_card.tsx index 73efd24af..c1385e97a 100644 --- a/front_end/src/components/consumer_post_card/basic_consumer_post_card.tsx +++ b/front_end/src/components/consumer_post_card/basic_consumer_post_card.tsx @@ -4,6 +4,7 @@ import { FC, PropsWithChildren } from "react"; import WeightBadge from "@/app/(main)/(tournaments)/tournament/components/index/index_weight_badge"; import KeyFactorsTileDisplay from "@/app/(main)/questions/[id]/components/key_factors/key_factors_tile_display"; +import ParticipationSummaryQuestionTile from "@/app/(main)/questions/[id]/components/post_score_data/participation_summary_question_tile"; import ForecastersCounter from "@/app/(main)/questions/components/forecaster_counter"; import CommentStatus from "@/components/post_card/basic_post_card/comment_status"; import CommunityDisclaimer from "@/components/post_card/community_disclaimer"; @@ -94,6 +95,9 @@ const BasicConsumerPostCard: FC> = ({ {isQuestionPost(post) && (post.key_factors?.length ?? 0) > 0 && ( )} + {isQuestionPost(post) && ( + + )}
); diff --git a/front_end/src/components/forecast_maker/forecast_maker_conditional/forecast_maker_conditional_binary.tsx b/front_end/src/components/forecast_maker/forecast_maker_conditional/forecast_maker_conditional_binary.tsx index 818013905..ce8c0f8ea 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_conditional/forecast_maker_conditional_binary.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_conditional/forecast_maker_conditional_binary.tsx @@ -40,7 +40,6 @@ import { useExpirationModalState, } from "../forecast_expiration"; import PredictButton from "../predict_button"; -import ScoreDisplay from "../resolution/score_display"; import WithdrawButton from "../withdraw/withdraw_button"; type Props = { @@ -103,10 +102,6 @@ const ForecastMakerConditionalBinary: FC = ({ const [activeTableOption, setActiveTableOption] = useState( question_yes.resolution === "annulled" ? questionNoId : questionYesId ); - const activeQuestion = useMemo( - () => [question_yes, question_no].find((q) => q.id === activeTableOption), - [activeTableOption, question_yes, question_no] - ); const questionYesDuration = new Date(question_yes.scheduled_close_time).getTime() - @@ -508,7 +503,6 @@ const ForecastMakerConditionalBinary: FC = ({ className="flex items-center justify-center" detached /> - {activeQuestion && } ); }; diff --git a/front_end/src/components/forecast_maker/forecast_maker_conditional/forecast_maker_conditional_continuous.tsx b/front_end/src/components/forecast_maker/forecast_maker_conditional/forecast_maker_conditional_continuous.tsx index 5231e9b03..946296330 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_conditional/forecast_maker_conditional_continuous.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_conditional/forecast_maker_conditional_continuous.tsx @@ -72,7 +72,6 @@ import { validateUserQuantileData, } from "../helpers"; import PredictButton from "../predict_button"; -import ScoreDisplay from "../resolution/score_display"; import WithdrawButton from "../withdraw/withdraw_button"; type Props = { @@ -919,7 +918,6 @@ const ForecastMakerConditionalContinuous: FC = ({ ); })} - {activeQuestion && } ); }; diff --git a/front_end/src/components/forecast_maker/forecast_maker_group/continuous_input_wrapper.tsx b/front_end/src/components/forecast_maker/forecast_maker_group/continuous_input_wrapper.tsx index 278314fd6..a6fb868aa 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_group/continuous_input_wrapper.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_group/continuous_input_wrapper.tsx @@ -47,7 +47,6 @@ import { validateUserQuantileData, } from "../helpers"; import PredictButton from "../predict_button"; -import ScoreDisplay from "../resolution/score_display"; import WithdrawButton from "../withdraw/withdraw_button"; type Props = { @@ -446,12 +445,6 @@ const ContinuousInputWrapper: FC> = ({

)} - - {!isNil(option.question.resolution) && ( -
- -
- )} ); }; diff --git a/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_binary.tsx b/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_binary.tsx index 9b30972dc..034fe62f4 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_binary.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_binary.tsx @@ -60,7 +60,6 @@ import { useExpirationModalState, } from "../forecast_expiration"; import PredictButton from "../predict_button"; -import ScoreDisplay from "../resolution/score_display"; import WithdrawButton from "../withdraw/withdraw_button"; type QuestionOption = { @@ -477,7 +476,6 @@ const ForecastMakerGroupBinary: FC = ({ )} )} - {highlightedQuestion && } ); }; diff --git a/front_end/src/components/forecast_maker/forecast_maker_question/index.tsx b/front_end/src/components/forecast_maker/forecast_maker_question/index.tsx index a41631701..729bf4fd7 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_question/index.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_question/index.tsx @@ -14,7 +14,6 @@ import ForecastMakerBinary from "./forecast_maker_binary"; import ForecastMakerContinuous from "./forecast_maker_continuous"; import ForecastMakerMultipleChoice from "./forecast_maker_multiple_choice"; import ForecastMakerContainer from "../container"; -import ScoreDisplay from "../resolution/score_display"; type Props = { post: PostWithForecasts; @@ -79,7 +78,6 @@ const QuestionForecastMaker: FC = ({ )} - ); }; diff --git a/front_end/src/components/forecast_maker/resolution/score_display.tsx b/front_end/src/components/forecast_maker/resolution/score_display.tsx deleted file mode 100644 index 615bf199c..000000000 --- a/front_end/src/components/forecast_maker/resolution/score_display.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { - faUsersLine, - faArrowsUpToLine, -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { isNil } from "lodash"; -import Link from "next/link"; -import { useTranslations } from "next-intl"; -import { FC } from "react"; - -import SectionToggle, { SectionVariant } from "@/components/ui/section_toggle"; -import { QuestionWithForecasts, ScoreData } from "@/types/question"; -import cn from "@/utils/core/cn"; -type Props = { - question: QuestionWithForecasts; - className?: string; - variant?: SectionVariant; -}; - -type ScoreBoxProps = { - label: string; - value: number; - icon?: typeof faUsersLine; - color: "orange" | "olive" | "gray"; - digits?: number; -}; - -const ScoreBox: FC = ({ - label, - value, - icon, - color, - digits = 1, -}) => { - const iconColorClass = - color === "orange" - ? "text-orange-700 dark:text-orange-700-dark" - : color === "olive" - ? "text-olive-700 dark:text-olive-700-dark" - : "text-gray-700 dark:text-gray-700-dark"; - - return ( -
- {icon && ( - - )} - {label} -
- {label.toLowerCase().includes("coverage") - ? (value * 100).toFixed(digits) + "%" - : value.toFixed(digits)} -
-
- ); -}; - -const ScoreDisplay: FC = ({ question, className, variant }) => { - const t = useTranslations(); - const cp_scores = - question.aggregations[question.default_aggregation_method].score_data; - const user_scores = question.my_forecasts?.score_data; - if (!cp_scores && !user_scores) return null; - - type KeyedScoreBox = { - key: string; - scoreBox: React.ReactNode | null; - }; - - function getScoreBox( - key: string, - forecaster: "user" | "community" - ): KeyedScoreBox { - const toCamelCase = (s: string): string => - s.replace(/(^|_)(\w)/g, (_, __, c: string) => c.toUpperCase()); - const sourceKey = (key + - (key.includes("coverage") ? "" : "_score")) as keyof ScoreData; - const value = (forecaster === "user" ? user_scores : cp_scores)?.[ - sourceKey - ]; - const labelKey = - (forecaster === "user" ? "my" : "community") + - toCamelCase(key) + - (key.includes("coverage") ? "" : "Score"); - // @ts-expect-error: ignore type error for dynamic translation key - const label = t(labelKey); - const icon = key.includes("peer") - ? faUsersLine - : key.includes("baseline") - ? faArrowsUpToLine - : undefined; - - return { - key, - scoreBox: isNil(value) ? null : ( - - ), - }; - } - const keyedScoreBoxes: KeyedScoreBox[] = [ - getScoreBox("peer", "user"), - getScoreBox("baseline", "user"), - getScoreBox("spot_peer", "user"), - getScoreBox("spot_baseline", "user"), - getScoreBox("relative_legacy", "user"), - getScoreBox("relative_legacy_archived", "user"), - getScoreBox("coverage", "user"), - getScoreBox("weighted_coverage", "user"), - getScoreBox("peer", "community"), - getScoreBox("baseline", "community"), - getScoreBox("spot_peer", "community"), - getScoreBox("spot_baseline", "community"), - getScoreBox("relative_legacy", "community"), - getScoreBox("relative_legacy_archived", "community"), - ]; - const primaryScoreBoxes = []; - const secondaryScoreBoxes = []; - const spotScores = question.default_score_type.startsWith("spot"); - for (const { key, scoreBox } of keyedScoreBoxes) { - if ( - (spotScores && (key === "spot_baseline" || key === "spot_peer")) || - (!spotScores && (key === "baseline" || key === "peer")) - ) { - primaryScoreBoxes.push(scoreBox); - } else { - secondaryScoreBoxes.push(scoreBox); - } - } - return ( - <> - {primaryScoreBoxes.length > 0 && ( -
- {primaryScoreBoxes} -
- )} - {secondaryScoreBoxes.length > 0 && question.resolution && ( - -
- {secondaryScoreBoxes} -
-
-
- Learn more about scores{" "} - - here - - . -
-
-
- )} - - ); -}; - -export default ScoreDisplay; diff --git a/front_end/src/components/post_card/basic_post_card/index.tsx b/front_end/src/components/post_card/basic_post_card/index.tsx index 36adf7f74..b76e1b35b 100644 --- a/front_end/src/components/post_card/basic_post_card/index.tsx +++ b/front_end/src/components/post_card/basic_post_card/index.tsx @@ -6,9 +6,10 @@ import { FC, PropsWithChildren } from "react"; import WeightBadge from "@/app/(main)/(tournaments)/tournament/components/index/index_weight_badge"; import KeyFactorsTileDisplay from "@/app/(main)/questions/[id]/components/key_factors/key_factors_tile_display"; +import ParticipationSummaryQuestionTile from "@/app/(main)/questions/[id]/components/post_score_data/participation_summary_question_tile"; import BasicPostControls from "@/components/post_card/basic_post_card/post_controls"; import CommunityDisclaimer from "@/components/post_card/community_disclaimer"; -import { Post } from "@/types/post"; +import { PostWithForecasts } from "@/types/post"; import { TournamentType } from "@/types/projects"; import cn from "@/utils/core/cn"; import { getPostLink } from "@/utils/navigation"; @@ -18,7 +19,7 @@ type BorderVariant = "regular" | "highlighted"; type BorderColor = "blue" | "purple"; type Props = { - post: Post; + post: PostWithForecasts; hideTitle?: boolean; borderVariant?: BorderVariant; borderColor?: BorderColor; @@ -78,6 +79,9 @@ const BasicPostCard: FC> = ({ {isQuestionPost(post) && (post.key_factors?.length ?? 0) > 0 && ( )} + {isQuestionPost(post) && ( + + )} ); diff --git a/front_end/src/components/question/score_card.tsx b/front_end/src/components/question/score_card.tsx new file mode 100644 index 000000000..5c9bfee03 --- /dev/null +++ b/front_end/src/components/question/score_card.tsx @@ -0,0 +1,542 @@ +"use client"; + +import { faUsers } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import React, { + forwardRef, + PropsWithChildren, + ReactNode, + useEffect, + useRef, + useState, +} from "react"; + +import { InfoToggleContainer } from "@/components/ui/info_toggle_container"; +import cn from "@/utils/core/cn"; + +const MIN_BADGE_GAP_PX = 12; + +interface BadgeProps { + label: string; + value: number; + pos: number; + variant: "user" | "community"; + align?: "left" | "center" | "right"; + xOffset?: number; +} + +const Badge = forwardRef( + ({ label, value, pos, variant, align = "center", xOffset = 0 }, ref) => ( +
+
+
+
+ {label} +
+
+
+
+ {value >= 0 ? "+" : ""} + {value.toFixed(1)} +
+
+
+
+
+
+
+
+ ) +); +Badge.displayName = "Badge"; + +const BaselineBadge = ({ + label, + pos, + icon, +}: { + label: ReactNode; + pos: number; + icon: ReactNode; +}) => ( +
+
+ +
+ {icon} +
+ +
+ {label} +
+
+); + +interface ScoreVisualizationProps { + userScore: number | null | undefined; + communityScore: number | null | undefined; + baselineLabel: ReactNode; + baselineIcon: ReactNode; +} + +const scaleScores = (user: number, community: number) => { + const max = Math.max(Math.abs(user), Math.abs(community)); + if (max === 0) return { user: 0.5, community: 0.5 }; + + // Safety margin for borders + // If the same sign and community label is on the left and the highest val -> do 17.5% instead + const GAP = + user * community > 0 && + Math.abs(community) > Math.abs(user) && + Math.abs(user) / Math.abs(community) > 0.7 + ? 0.175 + : 0.1; + + // Visual divider + const PAD = 0.25; + + // Linear Interpolation + const lerp = (norm: number, min: number, max: number) => + min + norm * (max - min); + + let getScore: (val: number) => number; + + if (user > 0 && community > 0) { + // Both Positive: Map 0..1 to [0.25, 0.95] + // 0% becomes 25% (PAD), 100% becomes 90% (1 - GAP) + getScore = (val) => lerp(val / max, PAD, 1 - GAP); + } else if (user < 0 && community < 0) { + // Both Negative: Map 0..1 to [0.1, 0.75] + // 0% (Most Negative) becomes 10% (GAP) + // 100% (Zero) becomes 75% (1 - PAD) + getScore = (val) => lerp(1 - Math.abs(val) / max, GAP, 1 - PAD); + } else { + // Mixed: Map 0..1 to [0.1, 0.9] + // Max becomes 10%, +Max becomes 90% + getScore = (val) => lerp((val + max) / (2 * max), GAP, 1 - GAP); + } + + return { + user: getScore(user), + community: getScore(community), + }; +}; + +const ScoreVisualization = ({ + userScore, + communityScore, + baselineLabel, + baselineIcon, +}: ScoreVisualizationProps) => { + const t = useTranslations(); + + const scores = [userScore, communityScore].filter( + (s): s is number => s != null + ); + const allPositive = scores.every((s) => s > 0); + const allNegative = scores.every((s) => s < 0); + + let baseline = 50; + + if (allPositive) { + baseline = 25; + } else if (allNegative) { + baseline = 75; + } + + const { user: initialUserPos, community: initialCommPos } = scaleScores( + userScore ?? 0, + communityScore ?? 0 + ); + + const [userPos, setUserPos] = useState(initialUserPos); + const [commPos, setCommPos] = useState(initialCommPos); + const [offsets, setOffsets] = useState<{ user: number; comm: number }>({ + user: 0, + comm: 0, + }); + const [align, setAlign] = useState<{ + user: "left" | "center" | "right"; + comm: "left" | "center" | "right"; + }>({ user: "center", comm: "center" }); + const userRef = useRef(null); + const commRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + if ( + !userScore || + !communityScore || + !userRef.current || + !commRef.current || + !containerRef.current + ) { + setUserPos(initialUserPos); + setCommPos(initialCommPos); + setOffsets({ user: 0, comm: 0 }); + setAlign({ user: "center", comm: "center" }); + return; + } + + const containerWidth = containerRef.current.offsetWidth; + const userPx = initialUserPos * containerWidth; + const commPx = initialCommPos * containerWidth; + + // Line gap calculation (center-to-center) + const LINE_GAP_PX = 4; + const lineGap = Math.abs(userPx - commPx); + + let adjustedUserPos = initialUserPos; + let adjustedCommPos = initialCommPos; + + if (lineGap < LINE_GAP_PX) { + const neededLineShift = LINE_GAP_PX - lineGap; + const shiftPct = neededLineShift / 2 / containerWidth; + + if (initialUserPos < initialCommPos) { + adjustedUserPos -= shiftPct; + adjustedCommPos += shiftPct; + } else { + adjustedUserPos += shiftPct; + adjustedCommPos -= shiftPct; + } + } + + setUserPos(adjustedUserPos); + setCommPos(adjustedCommPos); + + // Content gap calculation using ADJUSTED positions + const adjUserPx = adjustedUserPos * containerWidth; + const adjCommPx = adjustedCommPos * containerWidth; + + const userWidth = userRef.current.offsetWidth; + const commWidth = commRef.current.offsetWidth; + + const isUserLeft = adjustedUserPos < adjustedCommPos; + const leftPx = isUserLeft ? adjUserPx : adjCommPx; + const rightPx = isUserLeft ? adjCommPx : adjUserPx; + const leftWidth = isUserLeft ? userWidth : commWidth; + const rightWidth = isUserLeft ? commWidth : userWidth; + + const gap = rightPx - rightWidth / 2 - (leftPx + leftWidth / 2); + + if (gap >= MIN_BADGE_GAP_PX) { + setOffsets({ user: 0, comm: 0 }); + setAlign({ user: "center", comm: "center" }); + return; + } + + const SIDE_BY_SIDE_GAP_PX = 4; + const neededShift = Math.max(0, SIDE_BY_SIDE_GAP_PX - gap); + + // Distribute shift proportionally to widths to ensure flush alignment at max compression + const totalWidth = leftWidth + rightWidth; + const leftShiftMagnitude = neededShift * (leftWidth / totalWidth) - 0.5; + const rightShiftMagnitude = neededShift * (rightWidth / totalWidth) - 0.5; + + setOffsets( + isUserLeft + ? { user: -leftShiftMagnitude, comm: rightShiftMagnitude } + : { user: rightShiftMagnitude, comm: -leftShiftMagnitude } + ); + setAlign({ + user: isUserLeft ? "right" : "left", + comm: isUserLeft ? "left" : "right", + }); + }, [userScore, communityScore, initialUserPos, initialCommPos]); + + if (userScore == null && communityScore == null) return null; + + return ( +
+ {/* Badges */} +
+ {userScore != null && ( + + )} + + {communityScore != null && ( + + )} +
+ + {/* Gradient background */} +
+
+
+
+
+
+ + {/* Baseline indicator */} +
+ +
+
+ ); +}; + +const ScoreCardContainer = ({ + title, + children, + infoTitle, + infoContent, + className, +}: PropsWithChildren<{ + title: string; + infoTitle: ReactNode; + infoContent: ReactNode; + className?: string; +}>) => ( + + {children} + +); + +export const PeerScoreCard = ({ + userScore, + communityScore, + className, + title, + infoTitle, + infoContent, +}: { + userScore: number | null | undefined; + communityScore: number | null | undefined; + className?: string; + title: string; + infoTitle?: ReactNode; + infoContent?: ReactNode; +}) => { + const t = useTranslations(); + return ( + + ( +
{chunk}
+ ), + })} + baselineIcon={ + + } + /> +
+ ); +}; + +export const BaselineScoreCard = ({ + userScore, + communityScore, + className, + title, + infoTitle, + infoContent, +}: { + userScore: number | null | undefined; + communityScore: number | null | undefined; + className?: string; + title: string; + infoTitle?: ReactNode; + infoContent?: ReactNode; +}) => { + const t = useTranslations(); + return ( + + ( +
{chunk}
+ ), + })} + baselineIcon={
0
} + /> +
+ ); +}; + +export default function ScoreCard({ + type, + userScore, + communityScore, + className, +}: { + type: "peer" | "baseline" | "spot_peer" | "spot_baseline"; + userScore: number | null | undefined; + communityScore: number | null | undefined; + className?: string; +}) { + const t = useTranslations(); + const isSpot = type.includes("spot"); + + console.log("isSpot", isSpot, type); + + if (type.includes("peer")) { + return ( + +

+ {isSpot + ? t("spotPeerScoreExplanation") + : t("peerScoreExplanation")} +

+ + {isSpot + ? t("learnMoreAboutSpotScores") + : t("learnMoreAboutPeerScore")} + +
+ } + /> + ); + } + + return ( + +

+ {isSpot + ? t("spotBaselineScoreExplanation") + : t("baselineScoreExplanation")} +

+ + {isSpot + ? t("learnMoreAboutSpotScores") + : t("learnMoreAboutBaselineScore")} + +
+ } + /> + ); +} diff --git a/front_end/src/components/ui/info_toggle_container.tsx b/front_end/src/components/ui/info_toggle_container.tsx new file mode 100644 index 000000000..93b95f7b8 --- /dev/null +++ b/front_end/src/components/ui/info_toggle_container.tsx @@ -0,0 +1,116 @@ +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Transition } from "@headlessui/react"; +import { ReactNode, useState } from "react"; + +import cn from "@/utils/core/cn"; + +interface InfoToggleContainerProps { + title: string; + children: ReactNode; + infoTitle: ReactNode; + infoContent: ReactNode; + className?: string; +} + +export const InfoToggleContainer = ({ + title, + children, + infoTitle, + infoContent, + className, +}: InfoToggleContainerProps) => { + const [showInfo, setShowInfo] = useState(false); + + return ( +
setShowInfo(!showInfo)} + > +
+
+ + {title} + + + + {infoTitle} + +
+ + +
+ +
+ + {children} + + + + {infoContent} + +
+
+ ); +}; + +export default InfoToggleContainer; diff --git a/front_end/src/types/question.ts b/front_end/src/types/question.ts index d23645151..7efc0b980 100644 --- a/front_end/src/types/question.ts +++ b/front_end/src/types/question.ts @@ -267,6 +267,7 @@ export type Question = { lifetime_elapsed: number; movement: null | CPMovement; }; + average_coverage?: number | null; }; export enum MovementDirection { diff --git a/front_end/src/utils/fonts.ts b/front_end/src/utils/fonts.ts index 211ee7886..6e4731e92 100644 --- a/front_end/src/utils/fonts.ts +++ b/front_end/src/utils/fonts.ts @@ -70,7 +70,7 @@ export const interVariable = localFont({ src: [ { path: "../../public/fonts/inter_variable.ttf", - weight: "100 700", + weight: "100 800", style: "normal", }, ], diff --git a/posts/views.py b/posts/views.py index 773627a8a..0c47f7ecd 100644 --- a/posts/views.py +++ b/posts/views.py @@ -107,6 +107,7 @@ def posts_list_api_view(request): include_cp_history=include_cp_history, include_movements=include_movements, include_conditional_cps=include_conditional_cps, + include_average_scores=True, ) return paginator.get_paginated_response(data) From bc92269cce8f9585805a55bf67447cd75dac9795 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:25:26 +0000 Subject: [PATCH 07/22] build(deps): bump django from 5.1.14 to 5.1.15 (#3854) Bumps [django](https://github.com/django/django) from 5.1.14 to 5.1.15. - [Commits](https://github.com/django/django/compare/5.1.14...5.1.15) --- updated-dependencies: - dependency-name: django dependency-version: 5.1.15 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index e5fb3bae7..d81c21bbb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -850,14 +850,14 @@ typing_extensions = ">=3.10.0.0" [[package]] name = "django" -version = "5.1.14" +version = "5.1.15" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "django-5.1.14-py3-none-any.whl", hash = "sha256:2a4b9c20404fd1bf50aaaa5542a19d860594cba1354f688f642feb271b91df27"}, - {file = "django-5.1.14.tar.gz", hash = "sha256:b98409fb31fdd6e8c3a6ba2eef3415cc5c0020057b43b21ba7af6eff5f014831"}, + {file = "django-5.1.15-py3-none-any.whl", hash = "sha256:117871e58d6eda37f09870b7d73a3d66567b03aecd515b386b1751177c413432"}, + {file = "django-5.1.15.tar.gz", hash = "sha256:46a356b5ff867bece73fc6365e081f21c569973403ee7e9b9a0316f27d0eb947"}, ] [package.dependencies] @@ -4092,4 +4092,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "3.12.3" -content-hash = "5e7bcfdfe1f9d4b93cbdf08df8a0d490cdc267a22f9104ed7f2245db93d9d0a8" +content-hash = "c863b7ba1d3bbf8b8b0307f99e32f0d577789425db4e01167a2e9c46f693c6ff" diff --git a/pyproject.toml b/pyproject.toml index 7d900db8c..bdf6d8558 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ package-mode = false [tool.poetry.dependencies] python = "3.12.3" -django = "^5.1.14" +django = "^5.1.15" djangorestframework = "^3.15.1" rest-social-auth = "^8.3.0" dj-database-url = "^2.1.0" From 66412a6142405f668408abe4a684065ef7a192a4 Mon Sep 17 00:00:00 2001 From: Luke Sabor <32885230+lsabor@users.noreply.github.com> Date: Wed, 3 Dec 2025 08:43:59 -0800 Subject: [PATCH 08/22] fix/3815/reaffirm-single-question-in-group (#3843) closes #3815 --- .../forecast_maker_group/forecast_maker_group_continuous.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_continuous.tsx b/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_continuous.tsx index 6c1c9b2da..32383fe68 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_continuous.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_continuous.tsx @@ -351,8 +351,8 @@ const ForecastMakerGroupContinuous: FC = ({ const handleSingleQuestionSubmit = useCallback( async (questionId: number, forecastExpiration: ForecastExpirationValue) => { - const optionToSubmit = questionsToSubmit.find( - (opt) => opt.id === questionId + const optionToSubmit = groupOptions.find( + (opt) => opt.question.id === questionId ); if (!optionToSubmit) return; From 0ce414de2ab0a77807edbdbefe88d4a553dc6227 Mon Sep 17 00:00:00 2001 From: Luke Sabor <32885230+lsabor@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:31:13 -0800 Subject: [PATCH 09/22] fix/3823/my-predictions-feed-tweaks (#3844) closes #3823 changes movement calculation to default to 0.0 if question has resolved at least 7 days ago removes the "open" filter from movement feed --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Luke Sabor --- .../app/(main)/questions/components/feed_filters/main.tsx | 1 - questions/services/movement.py | 6 ++++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/front_end/src/app/(main)/questions/components/feed_filters/main.tsx b/front_end/src/app/(main)/questions/components/feed_filters/main.tsx index 24fa28d22..2b81c188a 100644 --- a/front_end/src/app/(main)/questions/components/feed_filters/main.tsx +++ b/front_end/src/app/(main)/questions/components/feed_filters/main.tsx @@ -165,7 +165,6 @@ const MainFeedFilters: FC = ({ if ( [ QuestionOrder.ActivityDesc, - QuestionOrder.WeeklyMovementDesc, QuestionOrder.PublishTimeDesc, QuestionOrder.CloseTimeAsc, QuestionOrder.NewsHotness, diff --git a/questions/services/movement.py b/questions/services/movement.py index 9606c3c20..c8f05e47d 100644 --- a/questions/services/movement.py +++ b/questions/services/movement.py @@ -29,6 +29,12 @@ def compute_question_movement(question: Question) -> float | None: if not cp_now: return + if question.resolution_set_time and question.resolution_set_time < now - timedelta( + days=7 + ): + # questions that have resolved at least 7 days ago have no movement + return 0.0 + cp_previous = get_aggregations_at_time( question, now - get_question_movement_period(question), From 95696eb156e5e4cb349245ad538b0b292bc3c6aa Mon Sep 17 00:00:00 2001 From: Nikita <93587872+ncarazon@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:47:48 +0200 Subject: [PATCH 10/22] fix: security vulnerability (#3859) * fix: security vulnerability in react * fix: integration test * fix: add user seeding step * revert: integration test changes --- front_end/package-lock.json | 80 ++++++++++++++++++------------------- front_end/package.json | 2 +- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/front_end/package-lock.json b/front_end/package-lock.json index 8179369e1..a389ba348 100644 --- a/front_end/package-lock.json +++ b/front_end/package-lock.json @@ -62,7 +62,7 @@ "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.1.0", "negotiator": "^0.6.3", - "next": "^15.5.6", + "next": "^15.5.7", "next-intl": "^3.26.4", "next-router-mock": "^1.0.2", "next-themes": "^0.4.6", @@ -4910,9 +4910,9 @@ "license": "MIT" }, "node_modules/@next/env": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.6.tgz", - "integrity": "sha512-3qBGRW+sCGzgbpc5TS1a0p7eNxnOarGVQhZxfvTdnV0gFI61lX7QNtQ4V1TSREctXzYn5NetbUsLvyqwLFJM6Q==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", + "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -4956,9 +4956,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.6.tgz", - "integrity": "sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", "cpu": [ "arm64" ], @@ -4972,9 +4972,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.6.tgz", - "integrity": "sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", "cpu": [ "x64" ], @@ -4988,9 +4988,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.6.tgz", - "integrity": "sha512-qvz4SVKQ0P3/Im9zcS2RmfFL/UCQnsJKJwQSkissbngnB/12c6bZTCB0gHTexz1s6d/mD0+egPKXAIRFVS7hQg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", "cpu": [ "arm64" ], @@ -5004,9 +5004,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.6.tgz", - "integrity": "sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", "cpu": [ "arm64" ], @@ -5020,9 +5020,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.6.tgz", - "integrity": "sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", "cpu": [ "x64" ], @@ -5036,9 +5036,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.6.tgz", - "integrity": "sha512-OsGX148sL+TqMK9YFaPFPoIaJKbFJJxFzkXZljIgA9hjMjdruKht6xDCEv1HLtlLNfkx3c5w2GLKhj7veBQizQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", "cpu": [ "x64" ], @@ -5052,9 +5052,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.6.tgz", - "integrity": "sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", "cpu": [ "arm64" ], @@ -5068,9 +5068,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.6.tgz", - "integrity": "sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", "cpu": [ "x64" ], @@ -18165,12 +18165,12 @@ } }, "node_modules/next": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.6.tgz", - "integrity": "sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", + "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", "license": "MIT", "dependencies": { - "@next/env": "15.5.6", + "@next/env": "15.5.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -18183,14 +18183,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.6", - "@next/swc-darwin-x64": "15.5.6", - "@next/swc-linux-arm64-gnu": "15.5.6", - "@next/swc-linux-arm64-musl": "15.5.6", - "@next/swc-linux-x64-gnu": "15.5.6", - "@next/swc-linux-x64-musl": "15.5.6", - "@next/swc-win32-arm64-msvc": "15.5.6", - "@next/swc-win32-x64-msvc": "15.5.6", + "@next/swc-darwin-arm64": "15.5.7", + "@next/swc-darwin-x64": "15.5.7", + "@next/swc-linux-arm64-gnu": "15.5.7", + "@next/swc-linux-arm64-musl": "15.5.7", + "@next/swc-linux-x64-gnu": "15.5.7", + "@next/swc-linux-x64-musl": "15.5.7", + "@next/swc-win32-arm64-msvc": "15.5.7", + "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { diff --git a/front_end/package.json b/front_end/package.json index 8e5c8056b..55de17144 100644 --- a/front_end/package.json +++ b/front_end/package.json @@ -72,7 +72,7 @@ "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.1.0", "negotiator": "^0.6.3", - "next": "^15.5.6", + "next": "^15.5.7", "next-intl": "^3.26.4", "next-router-mock": "^1.0.2", "next-themes": "^0.4.6", From 730d9d7729278211a136a1f812097584f9267884 Mon Sep 17 00:00:00 2001 From: Vasile Popescu Date: Wed, 3 Dec 2025 10:40:37 +0100 Subject: [PATCH 11/22] Add a minimalistic mode for PostCard This is intended to be used in the new Home page and it needs to have certain characteristics changed when the minimalistic mode is enabled --- .../post_card/basic_post_card/index.tsx | 22 ++++-- .../basic_post_card/post_controls.tsx | 8 +- .../group_of_questions_tile/index.tsx | 9 ++- front_end/src/components/post_card/index.tsx | 15 +++- .../post_card/multiple_choice_tile/index.tsx | 75 ++++++++++++------- .../post_card/question_tile/index.tsx | 3 + 6 files changed, 96 insertions(+), 36 deletions(-) diff --git a/front_end/src/components/post_card/basic_post_card/index.tsx b/front_end/src/components/post_card/basic_post_card/index.tsx index b76e1b35b..bbe3cab2b 100644 --- a/front_end/src/components/post_card/basic_post_card/index.tsx +++ b/front_end/src/components/post_card/basic_post_card/index.tsx @@ -25,6 +25,7 @@ type Props = { borderColor?: BorderColor; forCommunityFeed?: boolean; indexWeight?: number; + minimalistic?: boolean; }; const BasicPostCard: FC> = ({ @@ -35,6 +36,7 @@ const BasicPostCard: FC> = ({ children, forCommunityFeed, indexWeight, + minimalistic = false, }) => { const { title } = post; @@ -50,7 +52,7 @@ const BasicPostCard: FC> = ({ )}
> = ({ {!hideTitle && (
-

+

{title}

{typeof indexWeight === "number" && ( @@ -75,10 +82,13 @@ const BasicPostCard: FC> = ({ )} {children} - - {isQuestionPost(post) && (post.key_factors?.length ?? 0) > 0 && ( - - )} +
+ + {!minimalistic && + isQuestionPost(post) && + (post.key_factors?.length ?? 0) > 0 && ( + + )} {isQuestionPost(post) && ( )} diff --git a/front_end/src/components/post_card/basic_post_card/post_controls.tsx b/front_end/src/components/post_card/basic_post_card/post_controls.tsx index 68750057c..adc180997 100644 --- a/front_end/src/components/post_card/basic_post_card/post_controls.tsx +++ b/front_end/src/components/post_card/basic_post_card/post_controls.tsx @@ -14,9 +14,13 @@ import PostVoter from "./post_voter"; type Props = { post: Post; + withVoter?: boolean; }; -const BasicPostControls: FC> = ({ post }) => { +const BasicPostControls: FC> = ({ + post, + withVoter = true, +}) => { const resolutionData = extractPostResolution(post); const defaultProject = post.projects.default_project; @@ -30,7 +34,7 @@ const BasicPostControls: FC> = ({ post }) => { return (
- + {withVoter && } {/* CommentStatus - compact on small screens, full on large screens */} ; showChart?: boolean; + minimalistic?: boolean; }; -const GroupOfQuestionsTile: FC = ({ post, showChart }) => { +const GroupOfQuestionsTile: FC = ({ + post, + showChart, + minimalistic = false, +}) => { const t = useTranslations(); const { hideCP } = useHideCP(); @@ -72,6 +77,7 @@ const GroupOfQuestionsTile: FC = ({ post, showChart }) => { canPredict={canPredict} hideCP={hideCP} showChart={showChart} + minimalistic={minimalistic} optionsLimit={10} /> ); @@ -103,6 +109,7 @@ const GroupOfQuestionsTile: FC = ({ post, showChart }) => { canPredict={canPredict} hideCP={hideCP} showChart={showChart} + minimalistic={minimalistic} /> ); } diff --git a/front_end/src/components/post_card/index.tsx b/front_end/src/components/post_card/index.tsx index 0a78bfb12..17adefd34 100644 --- a/front_end/src/components/post_card/index.tsx +++ b/front_end/src/components/post_card/index.tsx @@ -24,9 +24,15 @@ type Props = { post: PostWithForecasts; forCommunityFeed?: boolean; indexWeight?: number; + minimalistic?: boolean; }; -const PostCard: FC = ({ post, forCommunityFeed, indexWeight }) => { +const PostCard: FC = ({ + post, + forCommunityFeed, + indexWeight, + minimalistic = false, +}) => { const { user } = useAuth(); const hideCP = user?.hide_community_prediction && @@ -49,6 +55,7 @@ const PostCard: FC = ({ post, forCommunityFeed, indexWeight }) => { borderColor={internalPost.notebook ? "purple" : "blue"} forCommunityFeed={forCommunityFeed} indexWeight={indexWeight} + minimalistic={minimalistic} > {isQuestionPost(internalPost) && ( @@ -58,10 +65,14 @@ const PostCard: FC = ({ post, forCommunityFeed, indexWeight }) => { curationStatus={post.status} hideCP={hideCP} canPredict={canPredict} + minimalistic={minimalistic} /> )} {isGroupOfQuestionsPost(internalPost) && ( - + )} {isConditionalPost(internalPost) && ( diff --git a/front_end/src/components/post_card/multiple_choice_tile/index.tsx b/front_end/src/components/post_card/multiple_choice_tile/index.tsx index f979a66c8..f37043525 100644 --- a/front_end/src/components/post_card/multiple_choice_tile/index.tsx +++ b/front_end/src/components/post_card/multiple_choice_tile/index.tsx @@ -40,6 +40,7 @@ type BaseProps = { chartHeight?: number; canPredict?: boolean; showChart?: boolean; + minimalistic?: boolean; optionsLimit?: number; }; @@ -90,6 +91,7 @@ export const MultipleChoiceTile: FC = ({ forecastAvailability, canPredict, showChart = true, + minimalistic = false, }) => { const { user } = useAuth(); const { onReaffirm } = useCardReaffirmContext(); @@ -122,29 +124,40 @@ export const MultipleChoiceTile: FC = ({ return (
-
+
{isResolvedView ? ( ) : ( - + !minimalistic && ( + + ) )}
{showChart && !isResolvedView && ( -
+
{isNil(group) ? ( = ({ groupType, canPredict, showChart = true, + minimalistic = false, optionsLimit, }) => { const { onReaffirm } = useCardReaffirmContext(); @@ -219,26 +233,37 @@ export const FanGraphTile: FC = ({ return (
-
- +
+ {!minimalistic && ( + + )}
{showChart && ( -
+
= ({ @@ -40,6 +41,7 @@ const QuestionTile: FC = ({ hideCP, canPredict, showChart, + minimalistic, }) => { const t = useTranslations(); const locale = useLocale(); @@ -109,6 +111,7 @@ const QuestionTile: FC = ({ forecastAvailability={forecastAvailability} canPredict={canPredict} showChart={showChart} + minimalistic={minimalistic} /> ); } From 62888dca29343a94ed3eefa337e2e926b715ae9e Mon Sep 17 00:00:00 2001 From: Hlib Date: Thu, 4 Dec 2025 14:02:50 +0000 Subject: [PATCH 12/22] Fixed KeyFactors ValidationError issue (#3863) --- comments/services/key_factors/suggestions.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/comments/services/key_factors/suggestions.py b/comments/services/key_factors/suggestions.py index 80ded5ece..563570f22 100644 --- a/comments/services/key_factors/suggestions.py +++ b/comments/services/key_factors/suggestions.py @@ -3,7 +3,13 @@ from typing import List, Optional from django.conf import settings -from pydantic import BaseModel, Field, ValidationError, model_validator +from pydantic import ( + BaseModel, + Field, + model_validator, + ValidationError as PydanticValidationError, +) +from rest_framework.exceptions import ValidationError from comments.models import KeyFactor, KeyFactorDriver from posts.models import Post @@ -257,7 +263,7 @@ def generate_keyfactors( # TODO: replace KeyFactorsResponse with plain list parsed = KeyFactorsResponse(**data) return parsed.key_factors - except (json.JSONDecodeError, ValidationError): + except (json.JSONDecodeError, PydanticValidationError): return [] From 1e61a7c6dc76159a528fadeb9c878038371ab598 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 14:03:16 +0000 Subject: [PATCH 13/22] build(deps): bump h11 from 0.14.0 to 0.16.0 in /screenshot (#3856) Bumps [h11](https://github.com/python-hyper/h11) from 0.14.0 to 0.16.0. - [Commits](https://github.com/python-hyper/h11/compare/v0.14.0...v0.16.0) --- updated-dependencies: - dependency-name: h11 dependency-version: 0.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Hlib --- screenshot/poetry.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/screenshot/poetry.lock b/screenshot/poetry.lock index 957001bce..5869c4b6d 100644 --- a/screenshot/poetry.lock +++ b/screenshot/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -609,14 +609,14 @@ test = ["objgraph", "psutil"] [[package]] name = "h11" -version = "0.14.0" +version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["main"] files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] [[package]] From d43c10d8498b6772d85de51d20d665c8cd90e9f1 Mon Sep 17 00:00:00 2001 From: Hlib Date: Thu, 4 Dec 2025 15:11:17 +0000 Subject: [PATCH 14/22] Fixed participation summary label for question tiles (#3866) Fixed participation summary label --- .../post_score_data/participation_summary_question_tile.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary_question_tile.tsx b/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary_question_tile.tsx index 90d020aa1..31f4d96bf 100644 --- a/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary_question_tile.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary_question_tile.tsx @@ -27,6 +27,11 @@ const ParticipationSummaryQuestionTile: FC> = ({ } const { question, nr_forecasters } = post; + const userForecasts = question.my_forecasts?.history.length ?? 0; + + if (!userForecasts) { + return null; + } return (
From 5d305e2a2cf189a00df2d61f89e484138ac394e2 Mon Sep 17 00:00:00 2001 From: Sylvain <74110469+SylvainChevalier@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:20:47 +0100 Subject: [PATCH 15/22] Fix z-index for continuous prediction slider center handle (#3861) Ensure the center slider (square handle) appears on top of the right slider (circular handle) when they overlap at the right edge of the range. Added z-10 to active slider and z-0 to inactive sliders. Fixes #3853 Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Sylvain Co-authored-by: Hlib --- front_end/src/components/sliders/primitives/thumb.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/front_end/src/components/sliders/primitives/thumb.tsx b/front_end/src/components/sliders/primitives/thumb.tsx index 73efb9e89..ae66978f1 100644 --- a/front_end/src/components/sliders/primitives/thumb.tsx +++ b/front_end/src/components/sliders/primitives/thumb.tsx @@ -33,6 +33,7 @@ const SliderThumb: FC = ({ {...props} className={cn( "absolute flex cursor-pointer touch-none items-center focus:outline-none", + active ? "z-10" : "z-0", className )} > From 57ba09ec4640254405760812fbc1ca64cc968d25 Mon Sep 17 00:00:00 2001 From: Hlib Date: Thu, 4 Dec 2025 18:16:47 +0000 Subject: [PATCH 16/22] Question Link Votes & Freshness implementation (#3869) * Question Link Votes & Freshness implementation * Small fix * Small fix --- .../0004_aggregatecoherencelinkvote.py | 62 +++++++++++++++++ coherence/models.py | 41 +++++++++++ coherence/serializers.py | 55 ++++++++++++++- coherence/services.py | 69 ++++++++++++++++++- coherence/urls.py | 29 +++++++- coherence/views.py | 42 +++++++++-- tests/unit/test_coherence/factories.py | 32 +++++++++ tests/unit/test_coherence/test_services.py | 41 +++++++++++ tests/unit/test_coherence/test_views.py | 35 ++++++++++ 9 files changed, 393 insertions(+), 13 deletions(-) create mode 100644 coherence/migrations/0004_aggregatecoherencelinkvote.py create mode 100644 tests/unit/test_coherence/factories.py create mode 100644 tests/unit/test_coherence/test_services.py create mode 100644 tests/unit/test_coherence/test_views.py diff --git a/coherence/migrations/0004_aggregatecoherencelinkvote.py b/coherence/migrations/0004_aggregatecoherencelinkvote.py new file mode 100644 index 000000000..55fd7cd87 --- /dev/null +++ b/coherence/migrations/0004_aggregatecoherencelinkvote.py @@ -0,0 +1,62 @@ +# Generated by Django 5.1.14 on 2025-12-04 16:00 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("coherence", "0003_alter_coherencelink_direction_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="AggregateCoherenceLinkVote", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField( + default=django.utils.timezone.now, editable=False + ), + ), + ("edited_at", models.DateTimeField(editable=False, null=True)), + ("score", models.SmallIntegerField(choices=[(1, "Up"), (-1, "Down")])), + ( + "aggregation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="votes", + to="coherence.aggregatecoherencelink", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("user_id", "aggregation_id"), + name="uq_aggregate_coherence_link_votes_unique_user", + ) + ], + }, + ), + ] diff --git a/coherence/models.py b/coherence/models.py index ea7adfe8c..a190ae882 100644 --- a/coherence/models.py +++ b/coherence/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.db.models import Subquery, OuterRef from django.db.models.functions import Least, Greatest from questions.models import Question @@ -38,6 +39,21 @@ class Meta: ] +class AggregateCoherenceLinkQuerySet(models.QuerySet): + def annotate_user_vote(self, user: User): + """ + Annotates queryset with the user's vote option + """ + + return self.annotate( + user_vote=Subquery( + AggregateCoherenceLinkVote.objects.filter( + user=user, aggregation=OuterRef("pk") + ).values("score")[:1] + ), + ) + + class AggregateCoherenceLink(TimeStampedModel): question1 = models.ForeignKey( Question, models.CASCADE, related_name="aggregate_coherence_links_as_q1" @@ -47,6 +63,11 @@ class AggregateCoherenceLink(TimeStampedModel): ) type = models.CharField(max_length=16, choices=LinkType.choices) + # Annotated fields + user_vote: int = None + + objects = models.Manager.from_queryset(AggregateCoherenceLinkQuerySet)() + class Meta: constraints = [ models.UniqueConstraint( @@ -58,3 +79,23 @@ class Meta: name="aggregate_different_questions", ), ] + + +class AggregateCoherenceLinkVote(TimeStampedModel): + class VoteDirection(models.IntegerChoices): + UP = 1 + DOWN = -1 + + user = models.ForeignKey(User, models.CASCADE) + aggregation = models.ForeignKey( + AggregateCoherenceLink, models.CASCADE, related_name="votes" + ) + score = models.SmallIntegerField(choices=VoteDirection.choices) + + class Meta: + constraints = [ + models.UniqueConstraint( + name="uq_aggregate_coherence_link_votes_unique_user", + fields=["user_id", "aggregation_id"], + ), + ] diff --git a/coherence/serializers.py b/coherence/serializers.py index 68e2024f0..42da08add 100644 --- a/coherence/serializers.py +++ b/coherence/serializers.py @@ -1,3 +1,4 @@ +from collections import Counter from typing import Iterable from django.db.models import Q @@ -7,7 +8,12 @@ from questions.models import Question from questions.serializers.common import serialize_question -from .models import CoherenceLink, AggregateCoherenceLink +from users.models import User +from .models import CoherenceLink, AggregateCoherenceLink, AggregateCoherenceLinkVote +from .services import ( + get_votes_for_aggregate_coherence_links, + calculate_freshness_aggregate_coherence_link, +) from .utils import ( get_aggregation_results, link_to_question_id_pair, @@ -38,6 +44,7 @@ class AggregateCoherenceLinkSerializer(serializers.ModelSerializer): class Meta: model = AggregateCoherenceLink fields = [ + "id", "question1_id", "question2_id", "type", @@ -92,9 +99,13 @@ def serialize_aggregate_coherence_link( question1: Question, question2: Question, matching_links: list[CoherenceLink], + votes: list[AggregateCoherenceLinkVote] = None, + user_vote: int = None, + current_question: Question = None, ): + votes = votes or [] + serialized_data = AggregateCoherenceLinkSerializer(link).data - serialized_data["id"] = link.id if question1: serialized_data["question1"] = serialize_question(question1) if question2: @@ -104,15 +115,32 @@ def serialize_aggregate_coherence_link( serialized_data["direction"] = direction serialized_data["strength"] = strength serialized_data["rsem"] = rsem if rsem else None + + serialized_data["votes"] = serialize_aggregate_coherence_link_vote( + votes, user_vote=user_vote + ) + + if current_question: + serialized_data["freshness"] = calculate_freshness_aggregate_coherence_link( + current_question, link, votes + ) + return serialized_data -def serialize_aggregate_coherence_link_many(links: Iterable[AggregateCoherenceLink]): +def serialize_aggregate_coherence_link_many( + links: Iterable[AggregateCoherenceLink], + current_user: User = None, + current_question: Question = None, +): ids = [link.pk for link in links] qs = AggregateCoherenceLink.objects.filter( pk__in=[c.pk for c in links] ).select_related("question1", "question2") + if current_user: + qs = qs.annotate_user_vote(current_user) + aggregate_links = list(qs.all()) aggregate_links.sort(key=lambda obj: ids.index(obj.id)) @@ -137,6 +165,9 @@ def serialize_aggregate_coherence_link_many(links: Iterable[AggregateCoherenceLi key = link_to_question_id_pair(link) matching_links_by_pair.add(key, link) + # Extract user votes + votes_map = get_votes_for_aggregate_coherence_links(aggregate_links) + return [ serialize_aggregate_coherence_link( link, @@ -145,6 +176,24 @@ def serialize_aggregate_coherence_link_many(links: Iterable[AggregateCoherenceLi matching_links=matching_links_by_pair.getall( link_to_question_id_pair(link), default=[] ), + votes=votes_map.get(link.id), + user_vote=link.user_vote, + current_question=current_question, ) for link in aggregate_links ] + + +def serialize_aggregate_coherence_link_vote( + vote_scores: list[AggregateCoherenceLinkVote], + user_vote: int = None, +): + pivot_votes = Counter([v.score for v in vote_scores]) + + return { + "aggregated_data": [ + {"score": score, "count": count} for score, count in pivot_votes.items() + ], + "user_vote": user_vote, + "count": len(vote_scores), + } diff --git a/coherence/services.py b/coherence/services.py index 43e941848..76b56052a 100644 --- a/coherence/services.py +++ b/coherence/services.py @@ -1,15 +1,20 @@ +from collections import defaultdict +from datetime import datetime, timedelta +from typing import Iterable + from django.db import transaction from django.db.models import Q +from django.utils import timezone from coherence.models import ( CoherenceLink, AggregateCoherenceLink, LinkType, + AggregateCoherenceLinkVote, ) from questions.models import Question from questions.services.forecasts import get_user_last_forecasts_map from users.models import User -from datetime import datetime def create_coherence_link( @@ -21,7 +26,6 @@ def create_coherence_link( strength: int = None, link_type: LinkType = None, ): - with transaction.atomic(): obj = CoherenceLink( user=user, @@ -90,3 +94,64 @@ def get_stale_linked_questions( if current_question.id != question.id and (not last_forecast or last_forecast.start_time < question_forecast_time) ] + + +@transaction.atomic +def aggregate_coherence_link_vote( + aggregation: AggregateCoherenceLink, + user: User, + vote: int = None, +): + # Deleting existing vote for this vote type + aggregation.votes.filter(user=user).delete() + + if vote is not None: + aggregation.votes.create(user=user, score=vote) + + +def get_votes_for_aggregate_coherence_links( + aggregations: Iterable[AggregateCoherenceLink], +) -> dict[int, list[AggregateCoherenceLink]]: + """ + Generates map of user votes for a set of KeyFactors + """ + + votes = AggregateCoherenceLinkVote.objects.filter(aggregation__in=aggregations) + votes_map = defaultdict(list) + + for vote in votes: + votes_map[vote.aggregation_id].append(vote) + + return votes_map + + +def calculate_freshness_aggregate_coherence_link( + question: Question, + aggregation: AggregateCoherenceLink, + votes: list[AggregateCoherenceLinkVote], +) -> float: + """ + Freshness doesn't decay over time + """ + + target_question = ( + aggregation.question1 + if aggregation.question1 != question + else aggregation.question2 + ) + + # If question resolved > 2w ago -> link does not make sense + if ( + target_question.actual_resolve_time + and timezone.now() - target_question.actual_resolve_time > timedelta(days=14) + ): + return 0.0 + + if not votes: + return 0.0 + + freshness = sum([x.score for x in votes]) + 2 * max(0, 3 - len(votes)) / max( + len(votes), 3 + ) + + return max(0.0, freshness) diff --git a/coherence/urls.py b/coherence/urls.py index 790583f40..c91bc3454 100644 --- a/coherence/urls.py +++ b/coherence/urls.py @@ -2,21 +2,30 @@ from . import views + urlpatterns = [ path( "coherence/links/create/", views.create_link_api_view, name="coherence-create-link", ), + # TODO: this is confusing because `/links/:id` represents the question ID, not the link ID. + # We should improve this in the future so that the URL always refers to the actual object ID. + # The question layer should be explicitly defined with a `/question/` prefix. path( "coherence/links//", views.get_links_for_question_api_view, - name="get-links-for-question", + name="get-links-for-question-old", ), path( "coherence/aggregate-links//", views.get_aggregate_links_for_question_api_view, - name="get-aggregate-links-for-question", + name="get-aggregate-links-for-question-old", + ), + path( + "coherence/aggregate-links//votes/", + views.aggregate_links_vote_view, + name="aggregate-links-votes", ), path( "coherence/links//delete/", @@ -26,6 +35,22 @@ path( "coherence/links//needs-update", views.get_questions_requiring_update, + name="needs-update-old", + ), + # Question-level links + path( + "coherence/question/links//needs-update/", + views.get_questions_requiring_update, name="needs-update", ), + path( + "coherence/question//links/", + views.get_links_for_question_api_view, + name="get-links-for-question", + ), + path( + "coherence/question//aggregate-links/", + views.get_aggregate_links_for_question_api_view, + name="get-aggregate-links-for-question", + ), ] diff --git a/coherence/views.py b/coherence/views.py index 8cd6c5559..3e6787651 100644 --- a/coherence/views.py +++ b/coherence/views.py @@ -1,23 +1,30 @@ from django.db.models import Q from django.shortcuts import get_object_or_404 -from rest_framework import status +from rest_framework import status, serializers from rest_framework.decorators import api_view, permission_classes from rest_framework.exceptions import PermissionDenied, NotFound -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.request import Request from rest_framework.response import Response -from coherence.models import CoherenceLink, AggregateCoherenceLink +from coherence.models import ( + CoherenceLink, + AggregateCoherenceLink, + AggregateCoherenceLinkVote, +) from coherence.serializers import ( CoherenceLinkSerializer, serialize_coherence_link, serialize_coherence_link_many, serialize_aggregate_coherence_link_many, NeedsUpdateQuerySerializer, + serialize_aggregate_coherence_link_vote, ) from coherence.services import ( create_coherence_link, get_stale_linked_questions, get_links_for_question, + aggregate_coherence_link_vote, ) from posts.services.common import get_post_permission_for_user from projects.permissions import ObjectPermission @@ -72,18 +79,41 @@ def get_links_for_question_api_view(request, pk): @api_view(["GET"]) -@permission_classes([IsAuthenticated]) -def get_aggregate_links_for_question_api_view(request, pk): +@permission_classes([AllowAny]) +def get_aggregate_links_for_question_api_view(request: Request, pk: int): question = get_object_or_404(Question, pk=pk) links = AggregateCoherenceLink.objects.filter( Q(question1=question) | Q(question2=question) ) - links_to_data = serialize_aggregate_coherence_link_many(links) + links_to_data = serialize_aggregate_coherence_link_many( + links, + current_user=request.user if request.user.is_authenticated else None, + current_question=question, + ) return Response({"data": links_to_data}) +@api_view(["POST"]) +def aggregate_links_vote_view(request: Request, pk: int): + aggregation = get_object_or_404(AggregateCoherenceLink, pk=pk) + + vote = serializers.ChoiceField( + required=False, + allow_null=True, + choices=AggregateCoherenceLinkVote.VoteDirection.choices, + ).run_validation(request.data.get("vote")) + + aggregate_coherence_link_vote(aggregation, user=request.user, vote=vote) + + return Response( + serialize_aggregate_coherence_link_vote( + list(aggregation.votes.all()), user_vote=vote + ) + ) + + @api_view(["DELETE"]) def delete_link_api_view(request, pk): link = get_object_or_404(CoherenceLink, pk=pk) diff --git a/tests/unit/test_coherence/factories.py b/tests/unit/test_coherence/factories.py new file mode 100644 index 000000000..b16f2ad9b --- /dev/null +++ b/tests/unit/test_coherence/factories.py @@ -0,0 +1,32 @@ +from django_dynamic_fixture import G + +from coherence.models import AggregateCoherenceLink, AggregateCoherenceLinkVote +from questions.models import Question +from users.models import User +from utils.dtypes import setdefaults_not_null + + +def factory_aggregate_coherence_link( + *, question1=Question, question2=Question, **kwargs +) -> AggregateCoherenceLink: + return G( + AggregateCoherenceLink, + **setdefaults_not_null( + kwargs, + question1=question1, + question2=question2, + ) + ) + + +def factory_agg_link_vote( + *, + aggregation: AggregateCoherenceLink = None, + score: int = None, + user: User = None, + **kwargs +) -> AggregateCoherenceLinkVote: + return G( + AggregateCoherenceLinkVote, + **setdefaults_not_null(kwargs, aggregation=aggregation, score=score, user=user) + ) diff --git a/tests/unit/test_coherence/test_services.py b/tests/unit/test_coherence/test_services.py new file mode 100644 index 000000000..aa90983f0 --- /dev/null +++ b/tests/unit/test_coherence/test_services.py @@ -0,0 +1,41 @@ +import pytest +from freezegun import freeze_time + +from coherence.services import ( + calculate_freshness_aggregate_coherence_link as calculate_freshness, +) +from tests.unit.test_questions.conftest import * # noqa +from .factories import factory_aggregate_coherence_link, factory_agg_link_vote +from ..utils import datetime_aware + + +@freeze_time("2025-05-01") +def test_calculate_freshness_aggregate_coherence_link( + question_binary, question_numeric, user1, user2 +): + aggregation = factory_aggregate_coherence_link( + question1=question_binary, question2=question_numeric + ) + + assert calculate_freshness(question_binary, aggregation, []) == 0 + + v1 = factory_agg_link_vote(aggregation=aggregation, user=user1, score=1) + v2 = factory_agg_link_vote(aggregation=aggregation, user=user2, score=-1) + + assert calculate_freshness(question_binary, aggregation, [v1]) == pytest.approx( + 2.33, rel=0.1 + ) + + assert calculate_freshness(question_binary, aggregation, [v1, v2]) == pytest.approx( + 0.66, rel=0.1 + ) + + # Resolved + question_numeric.actual_resolve_time = datetime_aware(2025, 4, 17) + assert calculate_freshness(question_binary, aggregation, [v1]) == pytest.approx( + 2.33, rel=0.1 + ) + + # Resolved + question_numeric.actual_resolve_time = datetime_aware(2025, 4, 15) + assert calculate_freshness(question_binary, aggregation, [v1]) == 0 diff --git a/tests/unit/test_coherence/test_views.py b/tests/unit/test_coherence/test_views.py new file mode 100644 index 000000000..a252b2078 --- /dev/null +++ b/tests/unit/test_coherence/test_views.py @@ -0,0 +1,35 @@ +import pytest +from django.urls import reverse + +from tests.unit.test_questions.conftest import * # noqa +from .factories import factory_aggregate_coherence_link + + +def test_aggregate_question_link_vote( + user1, user2_client, user1_client, question_binary, question_numeric +): + aggregation = factory_aggregate_coherence_link( + question1=question_binary, question2=question_numeric + ) + + url = reverse("aggregate-links-votes", kwargs={"pk": aggregation.pk}) + + # User2 votes with 1 + response = user2_client.post(url, data={"vote": 1}, format="json") + assert response.status_code == 200 + assert response.data["count"] == 1 + + # User1 votes with -1 + response = user1_client.post(url, data={"vote": -1}, format="json") + assert response.status_code == 200 + assert response.data["count"] == 2 + + # Check votes response + url = reverse( + "get-aggregate-links-for-question", kwargs={"pk": question_numeric.pk} + ) + response = user2_client.get(url) + + assert response.data["data"][0]["freshness"] == pytest.approx(0.66, rel=0.1) + votes_response = response.data["data"][0]["votes"] + assert {x["score"] for x in votes_response["aggregated_data"]} == {1, -1} From 4df08136f396b2ba08d791beaadc9e5e75f2b975 Mon Sep 17 00:00:00 2001 From: Hlib Date: Thu, 4 Dec 2025 22:31:16 +0000 Subject: [PATCH 17/22] Move Dramatiq rate limit redis db to the same db as queue (#3873) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "Revert "Moved Dramatiq rate limit redis db to the same db as queues (… (#3248)" This reverts commit d3d890dede2f95d315f56d4f92d3306c9c89de91. * Added prefixes to the mutex lock keys --- metaculus_web/settings.py | 5 +---- posts/tasks.py | 2 +- questions/tasks.py | 2 +- utils/dramatiq.py | 6 +++++- utils/tasks.py | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/metaculus_web/settings.py b/metaculus_web/settings.py index d35b35e01..94ae2674c 100644 --- a/metaculus_web/settings.py +++ b/metaculus_web/settings.py @@ -307,10 +307,7 @@ "django_dramatiq.middleware.DbConnectionsMiddleware", ], } -DRAMATIQ_RATE_LIMITER_BACKEND_OPTIONS = { - # Setting redis db to 1 for the MQ storage - "url": f"{REDIS_URL}/2?{REDIS_URL_CONFIG}", -} + # Setting StubBroker broker for unit tests environment # Integration tests should run as the real env diff --git a/posts/tasks.py b/posts/tasks.py index 6fb9113c2..3fc632fae 100644 --- a/posts/tasks.py +++ b/posts/tasks.py @@ -15,7 +15,7 @@ @dramatiq.actor(max_backoff=180_000, retry_when=concurrency_retries(max_retries=10)) @task_concurrent_limit( - lambda post_id: f"on-post-forecast-{post_id}", + lambda post_id: f"mutex:on-post-forecast-{post_id}", # We want only one task for the same post id be executed at the same time limit=1, # This task shouldn't take longer than 3m diff --git a/questions/tasks.py b/questions/tasks.py index cad2dcb76..988b7e0fb 100644 --- a/questions/tasks.py +++ b/questions/tasks.py @@ -30,7 +30,7 @@ @dramatiq.actor(max_backoff=10_000, retry_when=concurrency_retries(max_retries=20)) @task_concurrent_limit( - lambda question_id: f"build-question-forecasts-{question_id}", + lambda question_id: f"mutex:build-question-forecasts-{question_id}", # We want only one task for the same question id be executed at the same time # To ensure all forecasts will be included in the AggregatedForecasts model limit=1, diff --git a/utils/dramatiq.py b/utils/dramatiq.py index ecb4981f0..da555c42c 100644 --- a/utils/dramatiq.py +++ b/utils/dramatiq.py @@ -8,7 +8,11 @@ def get_redis_backend(): - return RedisBackend(**settings.DRAMATIQ_RATE_LIMITER_BACKEND_OPTIONS) + """ + ConcurrentRateLimiter uses the same Redis db index as redis queue + """ + + return RedisBackend(**settings.DRAMATIQ_BROKER["OPTIONS"]) def concurrency_retries(max_retries=20): diff --git a/utils/tasks.py b/utils/tasks.py index 7e8cce471..9be835f77 100644 --- a/utils/tasks.py +++ b/utils/tasks.py @@ -14,7 +14,7 @@ @dramatiq.actor(min_backoff=3_000, max_retries=3) @task_concurrent_limit( - lambda app_label, model_name, pk: f"update-translations-{app_label}.{model_name}/{pk}", + lambda app_label, model_name, pk: f"mutex:update-translations-{app_label}.{model_name}/{pk}", limit=1, # This task shouldn't take longer than 1m # So it's fine to set mutex lock timeout for this duration From 545cb4d7087e4ee26ab86fdb961ff50c1a343c6e Mon Sep 17 00:00:00 2001 From: lsabor Date: Sun, 23 Nov 2025 10:00:16 -0800 Subject: [PATCH 18/22] mc/3804/backend/updating add admin form for changing options add comment author and text to admin panel action and mc change methods --- questions/admin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/questions/admin.py b/questions/admin.py index aa0eac027..b7a9dd4f3 100644 --- a/questions/admin.py +++ b/questions/admin.py @@ -28,10 +28,9 @@ MultipleChoiceOptionsUpdateSerializer, get_all_options_from_history, multiple_choice_add_options, - multiple_choice_change_grace_period_end, multiple_choice_delete_options, multiple_choice_rename_option, - multiple_choice_reorder_options, + multiple_choice_change_grace_period_end, ) from utils.csv_utils import export_all_data_for_questions from utils.models import CustomTranslationAdmin From d96663a9eda2f9287a6625bc5545c48255838855 Mon Sep 17 00:00:00 2001 From: lsabor Date: Sun, 23 Nov 2025 14:28:10 -0800 Subject: [PATCH 19/22] mc/3805/frontend/graphing forecast only current option values aggregation explorer disclaimer to forecast during grace period add option reordering (should be in mc/3804) --- .../components/explorer.tsx | 8 +- .../question_header_cp_status.tsx | 10 +- .../charts/minified_continuous_area_chart.tsx | 5 +- .../forecast_maker_multiple_choice.tsx | 91 +++++++++++++------ front_end/src/types/question.ts | 1 + front_end/src/utils/questions/choices.ts | 8 +- front_end/src/utils/questions/helpers.ts | 16 ++++ questions/admin.py | 3 +- 8 files changed, 101 insertions(+), 41 deletions(-) diff --git a/front_end/src/app/(main)/aggregation-explorer/components/explorer.tsx b/front_end/src/app/(main)/aggregation-explorer/components/explorer.tsx index 5d20a5c6f..ddd8b23b3 100644 --- a/front_end/src/app/(main)/aggregation-explorer/components/explorer.tsx +++ b/front_end/src/app/(main)/aggregation-explorer/components/explorer.tsx @@ -23,7 +23,10 @@ import { SearchParams } from "@/types/navigation"; import { Post, PostWithForecasts } from "@/types/post"; import { QuestionType, QuestionWithForecasts } from "@/types/question"; import { logError } from "@/utils/core/errors"; -import { parseQuestionId } from "@/utils/questions/helpers"; +import { + getAllOptionsHistory, + parseQuestionId, +} from "@/utils/questions/helpers"; import { AggregationWrapper } from "./aggregation_wrapper"; import { AggregationExtraMethod } from "../types"; @@ -417,8 +420,9 @@ function parseSubQuestions( }, ]; } else if (data.question?.type === QuestionType.MultipleChoice) { + const allOptions = getAllOptionsHistory(data.question); return ( - data.question.options?.map((option) => ({ + allOptions?.map((option) => ({ value: option, label: option, })) || [] diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/question_header_cp_status.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/question_header_cp_status.tsx index 9d74d0a7f..ee7bca72a 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/question_header_cp_status.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/question_header_cp_status.tsx @@ -36,15 +36,17 @@ const QuestionHeaderCPStatus: FC = ({ const t = useTranslations(); const { hideCP } = useHideCP(); const forecastAvailability = getQuestionForecastAvailability(question); - const continuousAreaChartData = getContinuousAreaChartData({ - question, - isClosed: question.status === QuestionStatus.CLOSED, - }); const isContinuous = [ QuestionType.Numeric, QuestionType.Discrete, QuestionType.Date, ].includes(question.type); + const continuousAreaChartData = !isContinuous + ? null + : getContinuousAreaChartData({ + question, + isClosed: question.status === QuestionStatus.CLOSED, + }); if (question.status === QuestionStatus.RESOLVED && question.resolution) { // Resolved/Annulled/Ambiguous diff --git a/front_end/src/components/charts/minified_continuous_area_chart.tsx b/front_end/src/components/charts/minified_continuous_area_chart.tsx index ea80085b1..c6f4e96d5 100644 --- a/front_end/src/components/charts/minified_continuous_area_chart.tsx +++ b/front_end/src/components/charts/minified_continuous_area_chart.tsx @@ -56,7 +56,7 @@ const HORIZONTAL_PADDING = 10; type Props = { question: Question | GraphingQuestionProps; - data: ContinuousAreaGraphInput; + data: ContinuousAreaGraphInput | null; height?: number; width?: number; extraTheme?: VictoryThemeDefinition; @@ -81,6 +81,9 @@ const MinifiedContinuousAreaChart: FC = ({ forceTickCount, variant = "feed", }) => { + if (data === null) { + throw new Error("Data for MinifiedContinuousAreaChart is null"); + } const { ref: chartContainerRef, width: containerWidth } = useContainerSize(); const chartWidth = width || containerWidth; diff --git a/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_multiple_choice.tsx b/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_multiple_choice.tsx index 7e73c630f..8636df54e 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_multiple_choice.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_multiple_choice.tsx @@ -30,6 +30,7 @@ import { isForecastActive, isOpenQuestionPredicted, } from "@/utils/forecasts/helpers"; +import { getAllOptionsHistory } from "@/utils/questions/helpers"; import { BINARY_FORECAST_PRECISION, @@ -79,6 +80,8 @@ const ForecastMakerMultipleChoice: FC = ({ const { user } = useAuth(); const { hideCP } = useHideCP(); + const allOptions = getAllOptionsHistory(question); + const activeUserForecast = question.my_forecasts?.latest && isForecastActive(question.my_forecasts.latest) @@ -150,19 +153,31 @@ const ForecastMakerMultipleChoice: FC = ({ [choicesForecasts] ); const forecastsSum = useMemo( - () => (forecastHasValues ? sumForecasts(choicesForecasts) : null), - [choicesForecasts, forecastHasValues] + () => + forecastHasValues + ? sumForecasts( + choicesForecasts.filter((choice) => + question.options.includes(choice.name) + ) + ) + : null, + [question.options, choicesForecasts, forecastHasValues] ); const remainingSum = forecastsSum ? 100 - forecastsSum : null; const isForecastValid = forecastHasValues && forecastsSum === 100; const [submitError, setSubmitError] = useState(); + const showUserMustForecast = + !!activeUserForecast && + activeUserForecast.forecast_values.filter((value) => value !== null) + .length < question.options.length; + const resetForecasts = useCallback(() => { setIsDirty(false); setChoicesForecasts((prev) => - question.options.map((_, index) => { - // okay to do no-non-null-assertion, as choicesForecasts is mapped based on question.options + allOptions.map((_, index) => { + // okay to do no-non-null-assertion, as choicesForecasts is mapped based on allOptions // so there won't be a case where arrays are not of the same length // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const choiceOption = prev[index]!; @@ -177,7 +192,7 @@ const ForecastMakerMultipleChoice: FC = ({ }; }) ); - }, [question.options, question.my_forecasts?.latest?.forecast_values]); + }, [allOptions, question.my_forecasts?.latest?.forecast_values]); const handleForecastChange = useCallback( (choice: string, value: number) => { @@ -214,6 +229,9 @@ const ForecastMakerMultipleChoice: FC = ({ if (isNil(choice.forecast) || isNil(forecastsSum)) { return null; } + if (!question.options.includes(choice.name)) { + return 0.0; + } const value = round( Math.max(round((100 * choice.forecast) / forecastsSum, 1), 0.1), @@ -255,6 +273,9 @@ const ForecastMakerMultipleChoice: FC = ({ const forecastValue: Record = {}; choicesForecasts.forEach((el) => { + if (!question.options.includes(el.name)) { + return; // only submit forecasts for current options + } const forecast = el.forecast; if (!isNil(forecast)) { forecastValue[el.name] = round( @@ -360,28 +381,32 @@ const ForecastMakerMultipleChoice: FC = ({ - {choicesForecasts.map((choice) => ( - - ))} + {choicesForecasts.map((choice) => { + if (question.options.includes(choice.name)) { + return ( + + ); + } + })} {predictionMessage && ( @@ -394,6 +419,11 @@ const ForecastMakerMultipleChoice: FC = ({
+ {showUserMustForecast && ( +
+ PLACEHOLDER: User must forecast (a new option). +
+ )}
Total: {getForecastPctString(forecastsSum)} @@ -501,8 +531,9 @@ function generateChoiceOptions( userLastForecast: UserForecast | undefined ): ChoiceOption[] { const latest = aggregate.latest; + const allOptions = getAllOptionsHistory(question); - const choiceItems = question.options.map((option, index) => { + const choiceItems = allOptions.map((option, index) => { const communityForecastValue = latest?.forecast_values[index]; const userForecastValue = userLastForecast?.forecast_values[index]; @@ -518,8 +549,8 @@ function generateChoiceOptions( : null, }; }); - const resolutionIndex = question.options.findIndex( - (_, index) => question.options[index] === question.resolution + const resolutionIndex = allOptions.findIndex( + (_, index) => allOptions[index] === question.resolution ); if (resolutionIndex !== -1) { const [resolutionItem] = choiceItems.splice(resolutionIndex, 1); diff --git a/front_end/src/types/question.ts b/front_end/src/types/question.ts index 0caae0eb6..6547507c7 100644 --- a/front_end/src/types/question.ts +++ b/front_end/src/types/question.ts @@ -236,6 +236,7 @@ export type Question = { type: QuestionType; // Multiple-choice only options?: string[]; + options_history?: [number, string[]][]; group_variable?: string; group_rank?: number; // Continuous only diff --git a/front_end/src/utils/questions/choices.ts b/front_end/src/utils/questions/choices.ts index d1c818a44..985c9501f 100644 --- a/front_end/src/utils/questions/choices.ts +++ b/front_end/src/utils/questions/choices.ts @@ -14,6 +14,7 @@ import { formatResolution, } from "@/utils/formatters/resolution"; import { sortGroupPredictionOptions } from "@/utils/questions/groupOrdering"; +import { getAllOptionsHistory } from "@/utils/questions/helpers"; import { isUnsuccessfullyResolved } from "@/utils/questions/resolution"; export function generateChoiceItemsFromMultipleChoiceForecast( @@ -32,7 +33,8 @@ export function generateChoiceItemsFromMultipleChoiceForecast( const latest = question.aggregations[question.default_aggregation_method].latest; - const choiceOrdering: number[] = question.options?.map((_, i) => i) ?? []; + const allOptions = getAllOptionsHistory(question); + const choiceOrdering: number[] = allOptions?.map((_, i) => i) ?? []; if (!preserveOrder) { choiceOrdering.sort((a, b) => { const aCenter = latest?.forecast_values[a] ?? 0; @@ -41,7 +43,7 @@ export function generateChoiceItemsFromMultipleChoiceForecast( }); } - const labels = question.options ? question.options : []; + const labels = allOptions ? allOptions : []; const aggregationHistory = question.aggregations[question.default_aggregation_method].history; const userHistory = question.my_forecasts?.history; @@ -139,7 +141,7 @@ export function generateChoiceItemsFromMultipleChoiceForecast( const orderedChoiceItems = choiceOrdering.map((order) => choiceItems[order]); // move resolved choice to the front const resolutionIndex = choiceOrdering.findIndex( - (order) => question.options?.[order] === question.resolution + (order) => allOptions?.[order] === question.resolution ); if (resolutionIndex !== -1) { const [resolutionItem] = orderedChoiceItems.splice(resolutionIndex, 1); diff --git a/front_end/src/utils/questions/helpers.ts b/front_end/src/utils/questions/helpers.ts index f20b0f937..d87e7712a 100644 --- a/front_end/src/utils/questions/helpers.ts +++ b/front_end/src/utils/questions/helpers.ts @@ -203,3 +203,19 @@ export function inferEffectiveQuestionTypeFromPost( return null; } + +export function getAllOptionsHistory(question: Question): string[] { + const allOptions: string[] = []; + (question.options_history ?? []).map((entry) => { + entry[1].slice(0, -1).map((option) => { + if (!allOptions.includes(option)) { + allOptions.push(option); + } + }); + }); + const other = (question.options ?? []).at(-1); + if (other) { + allOptions.push(other); + } + return allOptions; +} diff --git a/questions/admin.py b/questions/admin.py index b7a9dd4f3..aa0eac027 100644 --- a/questions/admin.py +++ b/questions/admin.py @@ -28,9 +28,10 @@ MultipleChoiceOptionsUpdateSerializer, get_all_options_from_history, multiple_choice_add_options, + multiple_choice_change_grace_period_end, multiple_choice_delete_options, multiple_choice_rename_option, - multiple_choice_change_grace_period_end, + multiple_choice_reorder_options, ) from utils.csv_utils import export_all_data_for_questions from utils.models import CustomTranslationAdmin From f05d1b4241303da24df3752300547df6a203ade4 Mon Sep 17 00:00:00 2001 From: Hlib Date: Fri, 5 Dec 2025 14:21:21 +0000 Subject: [PATCH 20/22] Added LRU Cache for ConcurrentRateLimiter Redis Backend (#3874) --- utils/dramatiq.py | 1 + 1 file changed, 1 insertion(+) diff --git a/utils/dramatiq.py b/utils/dramatiq.py index da555c42c..690d4d67c 100644 --- a/utils/dramatiq.py +++ b/utils/dramatiq.py @@ -7,6 +7,7 @@ from dramatiq.rate_limits.backends import RedisBackend +@functools.lru_cache(maxsize=None) def get_redis_backend(): """ ConcurrentRateLimiter uses the same Redis db index as redis queue From ef96c28eecbeb7ccbf0da87d4928712f2acd55f3 Mon Sep 17 00:00:00 2001 From: Hlib Date: Fri, 5 Dec 2025 14:25:43 +0000 Subject: [PATCH 21/22] Deprecated old URL schema for coherence links (#3876) --- coherence/urls.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/coherence/urls.py b/coherence/urls.py index c91bc3454..81cbad2e3 100644 --- a/coherence/urls.py +++ b/coherence/urls.py @@ -9,19 +9,6 @@ views.create_link_api_view, name="coherence-create-link", ), - # TODO: this is confusing because `/links/:id` represents the question ID, not the link ID. - # We should improve this in the future so that the URL always refers to the actual object ID. - # The question layer should be explicitly defined with a `/question/` prefix. - path( - "coherence/links//", - views.get_links_for_question_api_view, - name="get-links-for-question-old", - ), - path( - "coherence/aggregate-links//", - views.get_aggregate_links_for_question_api_view, - name="get-aggregate-links-for-question-old", - ), path( "coherence/aggregate-links//votes/", views.aggregate_links_vote_view, @@ -32,14 +19,9 @@ views.delete_link_api_view, name="delete-link", ), - path( - "coherence/links//needs-update", - views.get_questions_requiring_update, - name="needs-update-old", - ), # Question-level links path( - "coherence/question/links//needs-update/", + "coherence/question//links/needs-update/", views.get_questions_requiring_update, name="needs-update", ), From 291e311fe3d9e3d0f4108d1b2620ed8eb0edaccb Mon Sep 17 00:00:00 2001 From: Hlib Date: Fri, 5 Dec 2025 17:25:13 +0000 Subject: [PATCH 22/22] Revert "Deprecated old URL schema for coherence links" (#3879) Revert "Deprecated old URL schema for coherence links (#3876)" This reverts commit ef96c28eecbeb7ccbf0da87d4928712f2acd55f3. --- coherence/urls.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/coherence/urls.py b/coherence/urls.py index 81cbad2e3..c91bc3454 100644 --- a/coherence/urls.py +++ b/coherence/urls.py @@ -9,6 +9,19 @@ views.create_link_api_view, name="coherence-create-link", ), + # TODO: this is confusing because `/links/:id` represents the question ID, not the link ID. + # We should improve this in the future so that the URL always refers to the actual object ID. + # The question layer should be explicitly defined with a `/question/` prefix. + path( + "coherence/links//", + views.get_links_for_question_api_view, + name="get-links-for-question-old", + ), + path( + "coherence/aggregate-links//", + views.get_aggregate_links_for_question_api_view, + name="get-aggregate-links-for-question-old", + ), path( "coherence/aggregate-links//votes/", views.aggregate_links_vote_view, @@ -19,9 +32,14 @@ views.delete_link_api_view, name="delete-link", ), + path( + "coherence/links//needs-update", + views.get_questions_requiring_update, + name="needs-update-old", + ), # Question-level links path( - "coherence/question//links/needs-update/", + "coherence/question/links//needs-update/", views.get_questions_requiring_update, name="needs-update", ),