rework noita objectives + deathcounter
This commit is contained in:
parent
754b0b0803
commit
7cfab20826
@ -1,5 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from .models import LogfileSubmission, Objectiv, ObjectivPoint
|
||||
|
||||
from noita.services.objectives import parse_objectives_from_logfile
|
||||
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||
|
||||
|
||||
@admin.register(LogfileSubmission)
|
||||
@ -23,13 +25,28 @@ class LogfileSubmissionAdmin(admin.ModelAdmin):
|
||||
("Processing", {"fields": ("processed",)}),
|
||||
)
|
||||
|
||||
actions = ["validate_submission"]
|
||||
|
||||
def validate_submission(self, request, queryset):
|
||||
for logfile in queryset:
|
||||
parse_objectives_from_logfile(logfile)
|
||||
|
||||
self.message_user(request, f"{queryset.count()} submissions validated.")
|
||||
|
||||
|
||||
@admin.register(Objectiv)
|
||||
class ObjectivAdmin(admin.ModelAdmin):
|
||||
list_display = ("objectiv_id", "user", "count")
|
||||
list_display = ("objectiv_id", "user", "first_seen_at", "get_user_objectiv_count")
|
||||
list_filter = ("objectiv_id", "user")
|
||||
search_fields = ("objectiv_id", "user__username")
|
||||
readonly_fields = ("user",)
|
||||
readonly_fields = ("user", "first_seen_at")
|
||||
|
||||
def get_user_objectiv_count(self, obj):
|
||||
return Objectiv.objects.filter(
|
||||
objectiv_id=obj.objectiv_id, user=obj.user
|
||||
).count()
|
||||
|
||||
get_user_objectiv_count.short_description = "Count"
|
||||
|
||||
|
||||
@admin.register(ObjectivPoint)
|
||||
@ -41,3 +58,15 @@ class ObjectivPointAdmin(admin.ModelAdmin):
|
||||
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
|
||||
("Scoring", {"fields": ("max_count", "point")}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(DeathCounter)
|
||||
class DeathCounterAdmin(admin.ModelAdmin):
|
||||
list_display = ("user_id", "seed", "seen_at")
|
||||
list_filter = ("user_id", "seed")
|
||||
search_fields = ("user_id", "seed")
|
||||
|
||||
fieldsets = (
|
||||
("User Information", {"fields": ("user_id",)}),
|
||||
("Scoring", {"fields": ("seed",)}),
|
||||
)
|
||||
|
||||
@ -5,18 +5,15 @@ from django.db.models import (
|
||||
F,
|
||||
Case,
|
||||
When,
|
||||
Sum,
|
||||
Count,
|
||||
IntegerField,
|
||||
Subquery,
|
||||
OuterRef,
|
||||
Window,
|
||||
)
|
||||
from django.db.models.functions import Rank
|
||||
from ninja import Router, File
|
||||
from ninja.files import UploadedFile
|
||||
|
||||
from noita.schemas import ObjectivOut, ResultsOut, LeaderboardOut
|
||||
from noita.schemas import ResultsOut, LeaderboardOut
|
||||
from noita.services.objectives import parse_objectives_and_store
|
||||
|
||||
from .models import LogfileSubmission, Objectiv, ObjectivPoint
|
||||
@ -26,11 +23,6 @@ from .schemas import NoitaSubmissionOut
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.get("objectives", response=list[ObjectivOut])
|
||||
def get_my_objectives(request: HttpRequest):
|
||||
return Objectiv.objects.order_by("-count").filter(user=request.user)
|
||||
|
||||
|
||||
@router.get("results", response=ResultsOut)
|
||||
def get_results(request: HttpRequest):
|
||||
cache_key = f"api:noita:results:{request.user.id}"
|
||||
@ -44,8 +36,15 @@ def get_results(request: HttpRequest):
|
||||
Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective
|
||||
Uses Django ORM annotate for efficient queryset computation.
|
||||
"""
|
||||
# Group objectives by objectiv_id and count occurrences
|
||||
user_objectives = (
|
||||
Objectiv.objects.filter(user=request.user)
|
||||
.values("objectiv_id")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
|
||||
# Fetch points from ObjectivPoint using Subquery
|
||||
user_objectives = Objectiv.objects.filter(user=request.user).annotate(
|
||||
user_objectives = user_objectives.annotate(
|
||||
# Get points per objective from ObjectivPoint
|
||||
points_per_objectiv=Subquery(
|
||||
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
|
||||
@ -81,22 +80,28 @@ def get_results(request: HttpRequest):
|
||||
total_points=F("points_per_objectiv") * F("capped_count"),
|
||||
)
|
||||
|
||||
# Get total score
|
||||
total_score_result = user_objectives.aggregate(Sum("total_points"))[
|
||||
"total_points__sum"
|
||||
]
|
||||
total_score = total_score_result or 0
|
||||
# Annotate seed + first-seen-at
|
||||
user_objectives = user_objectives.annotate(
|
||||
seed=F("seed"),
|
||||
first_seen_at=F("first_seen_at"),
|
||||
)
|
||||
|
||||
# Build response with all objectives
|
||||
objectives_with_points = [
|
||||
{
|
||||
"objectiv_id": obj.objectiv_id,
|
||||
"count": obj.count,
|
||||
"points_per_objectiv": obj.points_per_objectiv or 0,
|
||||
"total_points": obj.total_points or 0,
|
||||
}
|
||||
for obj in user_objectives.order_by("-total_points")
|
||||
]
|
||||
# Build response with all objectives and compute total score
|
||||
objectives_with_points = []
|
||||
total_score = 0
|
||||
for obj in user_objectives.order_by("-total_points"):
|
||||
points = obj["total_points"] or 0
|
||||
objectives_with_points.append(
|
||||
{
|
||||
"objectiv_id": obj["objectiv_id"],
|
||||
"count": obj["count"],
|
||||
"points_per_objectiv": obj["points_per_objectiv"] or 0,
|
||||
"total_points": points,
|
||||
"first_seen_at": obj["first_seen_at"],
|
||||
"seed": obj["seed"],
|
||||
}
|
||||
)
|
||||
total_score += points
|
||||
|
||||
data = {
|
||||
"total_score": total_score,
|
||||
@ -124,9 +129,11 @@ def get_leaderboard(request: HttpRequest):
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# Get all objectives with calculated points
|
||||
# Get all objectives with calculated points (grouped by objectiv_id and user)
|
||||
all_objectives = (
|
||||
Objectiv.objects.annotate(
|
||||
Objectiv.objects.values("user", "objectiv_id")
|
||||
.annotate(count=Count("id"))
|
||||
.annotate(
|
||||
# Fetch points from ObjectivPoint using Subquery
|
||||
points_per_objectiv=Subquery(
|
||||
ObjectivPoint.objects.filter(
|
||||
@ -161,46 +168,47 @@ def get_leaderboard(request: HttpRequest):
|
||||
)
|
||||
)
|
||||
|
||||
# Get user totals using subquery
|
||||
user_totals = (
|
||||
all_objectives.values("user")
|
||||
.annotate(total_score=Sum("total_points"))
|
||||
.values("user", "total_score")
|
||||
)
|
||||
# Build user totals by iterating through objectives
|
||||
user_totals_dict = {}
|
||||
for obj in all_objectives:
|
||||
user_id = obj["user"]
|
||||
points = obj["total_points"] or 0
|
||||
if user_id not in user_totals_dict:
|
||||
user_totals_dict[user_id] = 0
|
||||
user_totals_dict[user_id] += points
|
||||
|
||||
# Get unique users and their scores, then apply ranking
|
||||
leaderboard = (
|
||||
User.objects.filter(objectiv__isnull=False)
|
||||
.distinct()
|
||||
.annotate(
|
||||
total_score=Subquery(
|
||||
user_totals.filter(user=OuterRef("id")).values("total_score")[:1],
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
users_with_scores = []
|
||||
for user_id, total_score in user_totals_dict.items():
|
||||
user = User.objects.get(id=user_id)
|
||||
objectives_count = (
|
||||
Objectiv.objects.filter(user_id=user_id)
|
||||
.values("objectiv_id")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
.annotate(objectives_count=Count("objectiv", distinct=True))
|
||||
.annotate(
|
||||
rank=Window(
|
||||
expression=Rank(),
|
||||
order_by=F("total_score").desc(),
|
||||
)
|
||||
)
|
||||
.values("rank", "username", "total_score", "objectives_count")
|
||||
.order_by("rank")
|
||||
)
|
||||
|
||||
data = {
|
||||
"leaderboard": [
|
||||
users_with_scores.append(
|
||||
{
|
||||
"rank": entry["rank"],
|
||||
"username": entry["username"],
|
||||
"total_score": entry["total_score"] or 0,
|
||||
"objectives_count": entry["objectives_count"],
|
||||
"user_id": user_id,
|
||||
"username": user.username,
|
||||
"total_score": total_score,
|
||||
"objectives_count": objectives_count,
|
||||
}
|
||||
for entry in leaderboard
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by score and add rank
|
||||
users_with_scores.sort(key=lambda x: x["total_score"], reverse=True)
|
||||
leaderboard = [
|
||||
{
|
||||
"rank": idx + 1,
|
||||
"username": entry["username"],
|
||||
"total_score": entry["total_score"],
|
||||
"objectives_count": entry["objectives_count"],
|
||||
}
|
||||
for idx, entry in enumerate(users_with_scores)
|
||||
]
|
||||
|
||||
data = {"leaderboard": leaderboard}
|
||||
cache.set("api:noita:leaderboard", data, 300)
|
||||
return data
|
||||
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-11 08:20
|
||||
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0006_objectivpoint"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="objectiv",
|
||||
name="count",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="objectiv",
|
||||
name="first_seen_at",
|
||||
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="objectiv",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("objectiv_id", "user", "first_seen_at"),
|
||||
name="unique_objectiv_per_user_timestamp",
|
||||
),
|
||||
),
|
||||
]
|
||||
18
polylan_submitter/noita/migrations/0008_objectiv_seed.py
Normal file
18
polylan_submitter/noita/migrations/0008_objectiv_seed.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-11 08:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0007_remove_objectiv_count_objectiv_first_seen_at_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="objectiv",
|
||||
name="seed",
|
||||
field=models.CharField(default="", max_length=32),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-11 08:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0008_objectiv_seed"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="objectiv",
|
||||
name="submission",
|
||||
field=models.ForeignKey(
|
||||
default=1,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="noita.logfilesubmission",
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
34
polylan_submitter/noita/migrations/0010_deathcounter.py
Normal file
34
polylan_submitter/noita/migrations/0010_deathcounter.py
Normal file
@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-14 23:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def fw_func(apps, _schema_editor):
|
||||
Objectiv = apps.get_model("noita", "Objectiv")
|
||||
Objectiv.objects.filter(objectiv_id="DEATH").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0009_objectiv_submission"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="DeathCounter",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("seed", models.CharField(max_length=32)),
|
||||
("seen_at", models.DateTimeField()),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(fw_func, migrations.RunPython.noop),
|
||||
]
|
||||
25
polylan_submitter/noita/migrations/0011_deathcounter_user.py
Normal file
25
polylan_submitter/noita/migrations/0011_deathcounter_user.py
Normal file
@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-14 23:43
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0010_deathcounter"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="deathcounter",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
default=1,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-14 23:45
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0011_deathcounter_user"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name="deathcounter",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("user_id", "seen_at"), name="unique_death_per_seen_at"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,5 +1,6 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
import uuid
|
||||
|
||||
@ -49,7 +50,17 @@ class Objectiv(models.Model):
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
count = models.IntegerField(default=1)
|
||||
first_seen_at = models.DateTimeField(default=timezone.now)
|
||||
seed = models.CharField(max_length=32)
|
||||
submission = models.ForeignKey("LogfileSubmission", on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["objectiv_id", "user", "first_seen_at"],
|
||||
name="unique_objectiv_per_user_timestamp",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class ObjectivPoint(models.Model):
|
||||
@ -57,3 +68,17 @@ class ObjectivPoint(models.Model):
|
||||
display_string = models.CharField(max_length=255)
|
||||
max_count = models.IntegerField(default=1)
|
||||
point = models.IntegerField(default=0)
|
||||
|
||||
|
||||
class DeathCounter(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
seed = models.CharField(max_length=32)
|
||||
seen_at = models.DateTimeField()
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user_id", "seen_at"],
|
||||
name="unique_death_per_seen_at",
|
||||
)
|
||||
]
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from ninja import Schema, ModelSchema
|
||||
|
||||
from noita.models import Objectiv
|
||||
|
||||
|
||||
class ObjectivOut(ModelSchema):
|
||||
class Meta:
|
||||
model = Objectiv
|
||||
fields = ["objectiv_id", "count"]
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class NoitaSubmissionOut(Schema):
|
||||
@ -23,7 +15,8 @@ class NoitaSubmissionOut(Schema):
|
||||
|
||||
class ObjectivResultOut(Schema):
|
||||
objectiv_id: str
|
||||
count: int
|
||||
first_seen_at: datetime
|
||||
seed: str
|
||||
points_per_objectiv: int
|
||||
total_points: int
|
||||
|
||||
|
||||
@ -1,22 +1,21 @@
|
||||
from noita.models import LogfileSubmission, Objectiv
|
||||
from noita.models import LogfileSubmission, Objectiv, DeathCounter
|
||||
from noita.services.decode import parse_log, resolve
|
||||
|
||||
|
||||
from collections import Counter
|
||||
|
||||
|
||||
def parse_objectives_from_logfile(logfile: LogfileSubmission) -> Counter:
|
||||
def parse_objectives_from_logfile(
|
||||
logfile: LogfileSubmission,
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""Parse a log file, and output a count for each ID."""
|
||||
file_data = logfile.file.read().decode()
|
||||
|
||||
ids = []
|
||||
entries: list[tuple[str, str, str]] = []
|
||||
for entry in parse_log(file_data):
|
||||
idx, _seed = resolve(entry["hash"], entry["ts"])
|
||||
idx, seed = resolve(entry["hash"], entry["ts"])
|
||||
|
||||
if idx:
|
||||
ids.append(idx)
|
||||
if idx and seed:
|
||||
entries.append((idx, str(seed), entry["ts"]))
|
||||
|
||||
return Counter(ids)
|
||||
return entries
|
||||
|
||||
|
||||
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
||||
@ -25,16 +24,32 @@ def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
||||
if not logfile.user:
|
||||
return
|
||||
|
||||
counter = parse_objectives_from_logfile(logfile)
|
||||
|
||||
for idx, count in counter.items():
|
||||
objectives = []
|
||||
deaths = []
|
||||
for idx, seed, ts in parse_objectives_from_logfile(logfile):
|
||||
print(idx, seed, ts)
|
||||
if idx in {"-", "DEBUG", "polylan-mod"}:
|
||||
continue
|
||||
|
||||
obj, created = Objectiv.objects.get_or_create(
|
||||
objectiv_id=idx,
|
||||
user=logfile.user,
|
||||
if idx == "DEATH":
|
||||
deaths.append(DeathCounter(user=logfile.user, seed=seed, seen_at=ts))
|
||||
continue
|
||||
|
||||
objectives.append(
|
||||
Objectiv(
|
||||
objectiv_id=idx,
|
||||
user=logfile.user,
|
||||
first_seen_at=ts,
|
||||
seed=seed,
|
||||
submission=logfile,
|
||||
)
|
||||
)
|
||||
|
||||
obj.count += count
|
||||
obj.save(update_fields=["count"])
|
||||
Objectiv.objects.bulk_create(
|
||||
objectives,
|
||||
update_conflicts=True,
|
||||
update_fields=["seed", "submission"],
|
||||
unique_fields=["objectiv_id", "user", "first_seen_at"],
|
||||
)
|
||||
|
||||
DeathCounter.objects.bulk_create(deaths, ignore_conflicts=True)
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"install": "^0.13.0",
|
||||
"pinia": "^3.0.3",
|
||||
"tailwindcss": "^4.1.16",
|
||||
|
||||
@ -17,6 +17,9 @@ importers:
|
||||
'@vueuse/core':
|
||||
specifier: ^14.0.0
|
||||
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
|
||||
dayjs:
|
||||
specifier: ^1.11.20
|
||||
version: 1.11.20
|
||||
install:
|
||||
specifier: ^0.13.0
|
||||
version: 0.13.0
|
||||
@ -579,6 +582,9 @@ packages:
|
||||
daisyui@5.3.10:
|
||||
resolution: {integrity: sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==}
|
||||
|
||||
dayjs@1.11.20:
|
||||
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
@ -1287,6 +1293,8 @@ snapshots:
|
||||
|
||||
daisyui@5.3.10: {}
|
||||
|
||||
dayjs@1.11.20: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
enhanced-resolve@5.18.3:
|
||||
|
||||
@ -1,11 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
createColumnHelper,
|
||||
useVueTable,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
} from "@tanstack/vue-table";
|
||||
|
||||
interface Objective {
|
||||
objectiv_id: string;
|
||||
count: number;
|
||||
points_per_objectiv?: number;
|
||||
total_points?: number;
|
||||
first_seen_at: string;
|
||||
seed: string;
|
||||
points_per_objectiv: number;
|
||||
total_points: number;
|
||||
}
|
||||
|
||||
const userInfo = ref({
|
||||
@ -21,61 +32,91 @@ const isUploading = ref(false);
|
||||
const isDragover = ref(false);
|
||||
const objectives = ref<Objective[]>([]);
|
||||
const objectiveSearchQuery = ref("");
|
||||
const objectiveSortBy = ref<"id" | "count" | "points_per" | "total_points">("id");
|
||||
const objectiveSortDesc = ref(false);
|
||||
const isLoadingLeaderboard = ref(false);
|
||||
const leaderboard = ref<any[]>([]);
|
||||
const isLeaderboardModalOpen = ref(false);
|
||||
|
||||
const filteredObjectives = computed(() => {
|
||||
const query = objectiveSearchQuery.value.toLowerCase();
|
||||
const columnHelper = createColumnHelper<Objective>();
|
||||
const sorting = ref<SortingState>([]);
|
||||
const columnFilters = ref<ColumnFiltersState>([]);
|
||||
|
||||
let filtered = objectives.value;
|
||||
|
||||
if (query) {
|
||||
filtered = filtered.filter(
|
||||
(obj) =>
|
||||
obj.objectiv_id.toLowerCase().includes(query) ||
|
||||
obj.count.toString().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
let aValue: number | string;
|
||||
let bValue: number | string;
|
||||
|
||||
switch (objectiveSortBy.value) {
|
||||
case "points_per":
|
||||
aValue = a.points_per_objectiv || 0;
|
||||
bValue = b.points_per_objectiv || 0;
|
||||
break;
|
||||
case "total_points":
|
||||
aValue = a.total_points || 0;
|
||||
bValue = b.total_points || 0;
|
||||
break;
|
||||
case "id":
|
||||
default:
|
||||
aValue = a.objectiv_id.toLowerCase();
|
||||
bValue = b.objectiv_id.toLowerCase();
|
||||
}
|
||||
|
||||
if (aValue < bValue) return objectiveSortDesc.value ? 1 : -1;
|
||||
if (aValue > bValue) return objectiveSortDesc.value ? -1 : 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const toggleObjectiveSort = (column: "id" | "points_per" | "total_points") => {
|
||||
if (objectiveSortBy.value === column) {
|
||||
objectiveSortDesc.value = !objectiveSortDesc.value;
|
||||
} else {
|
||||
objectiveSortBy.value = column;
|
||||
objectiveSortDesc.value = false;
|
||||
}
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = dayjs(dateString);
|
||||
return date.format("MMM DD, YYYY HH:mm");
|
||||
};
|
||||
|
||||
const getDateTooltip = (dateString: string) => {
|
||||
const date = dayjs(dateString);
|
||||
return date.format("dddd, MMMM D, YYYY [at] h:mm A");
|
||||
};
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor("objectiv_id", {
|
||||
header: "Objective ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("total_points", {
|
||||
header: "Total Points",
|
||||
cell: (info) => info.getValue() || 0,
|
||||
}),
|
||||
columnHelper.accessor("first_seen_at", {
|
||||
header: "First seen",
|
||||
cell: (info) => formatDate(info.getValue()),
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const dateA = dayjs(rowA.original.first_seen_at);
|
||||
const dateB = dayjs(rowB.original.first_seen_at);
|
||||
return dateA.isBefore(dateB) ? -1 : dateA.isAfter(dateB) ? 1 : 0;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("seed", {
|
||||
header: "Seed",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
];
|
||||
|
||||
const table = computed(() =>
|
||||
useVueTable({
|
||||
get data() {
|
||||
return objectives.value;
|
||||
},
|
||||
columns,
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value;
|
||||
},
|
||||
get columnFilters() {
|
||||
return columnFilters.value;
|
||||
},
|
||||
},
|
||||
onSortingChange: (updater) => {
|
||||
sorting.value =
|
||||
typeof updater === "function" ? updater(sorting.value) : updater;
|
||||
},
|
||||
onColumnFiltersChange: (updater) => {
|
||||
columnFilters.value =
|
||||
typeof updater === "function" ? updater(columnFilters.value) : updater;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
filterFns: {
|
||||
fuzzy: (row, columnId, value) => {
|
||||
const itemData = row.getValue(columnId);
|
||||
const searchValue = value.toLowerCase();
|
||||
if (columnId === "first_seen_at") {
|
||||
const dateStr = itemData as string;
|
||||
const formatted = dayjs(dateStr).format("MMM DD, YYYY HH:mm");
|
||||
return formatted.toLowerCase().includes(searchValue);
|
||||
}
|
||||
return String(itemData).toLowerCase().includes(searchValue);
|
||||
},
|
||||
},
|
||||
globalFilterFn: "fuzzy",
|
||||
})
|
||||
);
|
||||
|
||||
const filteredObjectives = computed(() => table.value.getRowModel().rows);
|
||||
|
||||
const handleFileUpload = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files) {
|
||||
@ -387,8 +428,18 @@ onMounted(() => {
|
||||
|
||||
<div v-if="objectives.length > 0" class="space-y-4">
|
||||
<!-- Search Input -->
|
||||
<input v-model="objectiveSearchQuery" type="text" placeholder="Search objectives..."
|
||||
class="input input-bordered w-full" />
|
||||
<input
|
||||
:value="columnFilters.find((f) => f.id === 'objectiv_id')?.value ?? ''"
|
||||
@input="
|
||||
(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
table.getColumn('objectiv_id')?.setFilterValue(target.value);
|
||||
}
|
||||
"
|
||||
type="text"
|
||||
placeholder="Search objectives..."
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
|
||||
<!-- Results Summary -->
|
||||
<div class="text-sm text-base-content/70">
|
||||
@ -400,31 +451,64 @@ onMounted(() => {
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cursor-pointer hover:bg-base-300" @click="toggleObjectiveSort('id')">
|
||||
Objective ID
|
||||
<i v-if="objectiveSortBy === 'id'"
|
||||
:class="['mdi ml-2', objectiveSortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up']"></i>
|
||||
</th>
|
||||
<th class="text-right cursor-pointer hover:bg-base-300"
|
||||
@click="toggleObjectiveSort('total_points')">
|
||||
Total Points
|
||||
<i v-if="objectiveSortBy === 'total_points'"
|
||||
:class="['mdi ml-2', objectiveSortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up']"></i>
|
||||
<th
|
||||
v-for="header in table.getHeaderGroups()[0]?.headers"
|
||||
:key="header.id"
|
||||
:class="[
|
||||
'cursor-pointer hover:bg-base-300',
|
||||
header.column.columnDef.id === 'objectiv_id' ? 'text-left' : 'text-right',
|
||||
]"
|
||||
@click="header.column.toggleSorting()"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span v-if="header.column.columnDef.id === 'objectiv_id'">
|
||||
{{ header.isPlaceholder ? null : header.column.columnDef.header }}
|
||||
</span>
|
||||
<span v-else class="ml-auto">
|
||||
{{ header.isPlaceholder ? null : header.column.columnDef.header }}
|
||||
</span>
|
||||
<i
|
||||
v-if="header.column.getIsSorted()"
|
||||
:class="[
|
||||
'mdi ml-2',
|
||||
header.column.getIsSorted() === 'desc'
|
||||
? 'mdi-arrow-down'
|
||||
: 'mdi-arrow-up',
|
||||
]"
|
||||
></i>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="obj in filteredObjectives" :key="obj.objectiv_id">
|
||||
<td class="font-medium">
|
||||
<a :href="`https://noita.wiki.gg/wiki/${obj.objectiv_id}`" target="_blank">
|
||||
{{ obj.objectiv_id }}
|
||||
<i class="mdi mdi-open-in-new"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-right font-bold text-success">
|
||||
{{ obj.total_points || 0 }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<tr v-for="row in filteredObjectives" :key="row.id">
|
||||
<td
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:class="[
|
||||
cell.column.id === 'objectiv_id'
|
||||
? 'font-medium'
|
||||
: 'text-right',
|
||||
cell.column.id === 'total_points' ? 'font-bold text-primary' : '',
|
||||
]"
|
||||
>
|
||||
<template v-if="cell.column.id === 'objectiv_id'">
|
||||
<a
|
||||
:href="`https://noita.wiki.gg/wiki/${row.original.objectiv_id}`"
|
||||
target="_blank"
|
||||
>
|
||||
{{ row.original.objectiv_id }}
|
||||
<i class="mdi mdi-open-in-new"></i>
|
||||
</a>
|
||||
</template>
|
||||
<template v-else-if="cell.column.id === 'first_seen_at'">
|
||||
<span :title="getDateTooltip(row.original.first_seen_at)">
|
||||
{{ formatDate(row.original.first_seen_at) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ cell.renderValue() }}
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user