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/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/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,
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 []
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/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",
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/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..31f4d96bf
--- /dev/null
+++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary_question_tile.tsx
@@ -0,0 +1,53 @@
+"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;
+ const userForecasts = question.my_forecasts?.history.length ?? 0;
+
+ if (!userForecasts) {
+ return null;
+ }
+
+ 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 6d7bae19a..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 && (
+
+
+
+ )}
> = ({
/>
-
diff --git a/front_end/src/app/(main)/questions/[id]/components/question_layout/question_info.tsx b/front_end/src/app/(main)/questions/[id]/components/question_layout/question_info.tsx
index 87a964662..707076890 100644
--- a/front_end/src/app/(main)/questions/[id]/components/question_layout/question_info.tsx
+++ b/front_end/src/app/(main)/questions/[id]/components/question_layout/question_info.tsx
@@ -1,5 +1,6 @@
import { useTranslations } from "next-intl";
+import PostScoreData from "@/app/(main)/questions/[id]/components/post_score_data";
import { CoherenceLinks } from "@/app/(main)/questions/components/coherence_links/coherence_links";
import ConditionalTimeline from "@/components/conditional_timeline";
import DetailedGroupCard from "@/components/detailed_question_card/detailed_group_card";
@@ -43,6 +44,16 @@ const QuestionInfo: React.FC
= ({
)}
+ }
+ 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/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/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/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/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 823418092..abc7415b3 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 = {
@@ -929,7 +928,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 7af9cf8af..3e5804fca 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 = {
@@ -456,12 +455,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_group/forecast_maker_group_continuous.tsx b/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_continuous.tsx
index dd7fb3cbd..bfd686e69 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;
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/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..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
@@ -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,12 +19,13 @@ type BorderVariant = "regular" | "highlighted";
type BorderColor = "blue" | "purple";
type Props = {
- post: Post;
+ post: PostWithForecasts;
hideTitle?: boolean;
borderVariant?: BorderVariant;
borderColor?: BorderColor;
forCommunityFeed?: boolean;
indexWeight?: number;
+ minimalistic?: boolean;
};
const BasicPostCard: FC> = ({
@@ -34,6 +36,7 @@ const BasicPostCard: FC> = ({
children,
forCommunityFeed,
indexWeight,
+ minimalistic = false,
}) => {
const { title } = post;
@@ -49,7 +52,7 @@ const BasicPostCard: FC> = ({
)}
> = ({
{!hideTitle && (
-
+
{title}
{typeof indexWeight === "number" && (
@@ -74,9 +82,15 @@ 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 6260a3e2a..ef4a910de 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}
/>
);
}
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) => (
+
+
+
+
+
+ {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/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
)}
>
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 0caae0eb6..39c6e2f02 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
@@ -267,6 +268,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/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/metaculus_web/settings.py b/metaculus_web/settings.py
index 808c1b249..94ae2674c 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")
@@ -306,10 +307,6 @@
"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
@@ -365,6 +362,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/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/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..8115a27e8 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
@@ -430,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):
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..3fc632fae 100644
--- a/posts/tasks.py
+++ b/posts/tasks.py
@@ -3,17 +3,19 @@
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__)
@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
@@ -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..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)
@@ -311,7 +312,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/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"
diff --git a/questions/admin.py b/questions/admin.py
index aa0eac027..37f20baae 100644
--- a/questions/admin.py
+++ b/questions/admin.py
@@ -14,6 +14,7 @@
from rest_framework.exceptions import ValidationError as DRFValidationError
from posts.models import Post
+from posts.tasks import run_post_generate_history_snapshot
from questions.constants import UnsuccessfulResolutionType
from questions.models import (
AggregateForecast,
@@ -541,6 +542,13 @@ def insert_after(target_field: str, new_field: str):
insert_after("options_history", "update_mc_options")
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:
@@ -813,6 +821,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/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),
diff --git a/questions/tasks.py b/questions/tasks.py
index 2df66d78a..9dbf6bae6 100644
--- a/questions/tasks.py
+++ b/questions/tasks.py
@@ -32,7 +32,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/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]]
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}
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,
+ )
diff --git a/utils/dramatiq.py b/utils/dramatiq.py
index ecb4981f0..690d4d67c 100644
--- a/utils/dramatiq.py
+++ b/utils/dramatiq.py
@@ -7,8 +7,13 @@
from dramatiq.rate_limits.backends import RedisBackend
+@functools.lru_cache(maxsize=None)
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