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" }) => (
+