rework noita objectives + deathcounter

This commit is contained in:
Loïc Gremaud 2026-05-15 01:50:34 +02:00
parent 754b0b0803
commit 7cfab20826
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
14 changed files with 481 additions and 167 deletions

View File

@ -1,5 +1,7 @@
from django.contrib import admin 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) @admin.register(LogfileSubmission)
@ -23,13 +25,28 @@ class LogfileSubmissionAdmin(admin.ModelAdmin):
("Processing", {"fields": ("processed",)}), ("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) @admin.register(Objectiv)
class ObjectivAdmin(admin.ModelAdmin): 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") list_filter = ("objectiv_id", "user")
search_fields = ("objectiv_id", "user__username") 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) @admin.register(ObjectivPoint)
@ -41,3 +58,15 @@ class ObjectivPointAdmin(admin.ModelAdmin):
("Objective Information", {"fields": ("objectiv_id", "display_string")}), ("Objective Information", {"fields": ("objectiv_id", "display_string")}),
("Scoring", {"fields": ("max_count", "point")}), ("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",)}),
)

View File

@ -5,18 +5,15 @@ from django.db.models import (
F, F,
Case, Case,
When, When,
Sum,
Count, Count,
IntegerField, IntegerField,
Subquery, Subquery,
OuterRef, OuterRef,
Window,
) )
from django.db.models.functions import Rank
from ninja import Router, File from ninja import Router, File
from ninja.files import UploadedFile 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 noita.services.objectives import parse_objectives_and_store
from .models import LogfileSubmission, Objectiv, ObjectivPoint from .models import LogfileSubmission, Objectiv, ObjectivPoint
@ -26,11 +23,6 @@ from .schemas import NoitaSubmissionOut
router = Router() 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) @router.get("results", response=ResultsOut)
def get_results(request: HttpRequest): def get_results(request: HttpRequest):
cache_key = f"api:noita:results:{request.user.id}" 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 Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective
Uses Django ORM annotate for efficient queryset computation. 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 # 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 # Get points per objective from ObjectivPoint
points_per_objectiv=Subquery( points_per_objectiv=Subquery(
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values( 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"), total_points=F("points_per_objectiv") * F("capped_count"),
) )
# Get total score # Annotate seed + first-seen-at
total_score_result = user_objectives.aggregate(Sum("total_points"))[ user_objectives = user_objectives.annotate(
"total_points__sum" seed=F("seed"),
] first_seen_at=F("first_seen_at"),
total_score = total_score_result or 0 )
# Build response with all objectives # Build response with all objectives and compute total score
objectives_with_points = [ 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, "objectiv_id": obj["objectiv_id"],
"count": obj.count, "count": obj["count"],
"points_per_objectiv": obj.points_per_objectiv or 0, "points_per_objectiv": obj["points_per_objectiv"] or 0,
"total_points": obj.total_points or 0, "total_points": points,
"first_seen_at": obj["first_seen_at"],
"seed": obj["seed"],
} }
for obj in user_objectives.order_by("-total_points") )
] total_score += points
data = { data = {
"total_score": total_score, "total_score": total_score,
@ -124,9 +129,11 @@ def get_leaderboard(request: HttpRequest):
User = get_user_model() User = get_user_model()
# Get all objectives with calculated points # Get all objectives with calculated points (grouped by objectiv_id and user)
all_objectives = ( all_objectives = (
Objectiv.objects.annotate( Objectiv.objects.values("user", "objectiv_id")
.annotate(count=Count("id"))
.annotate(
# Fetch points from ObjectivPoint using Subquery # Fetch points from ObjectivPoint using Subquery
points_per_objectiv=Subquery( points_per_objectiv=Subquery(
ObjectivPoint.objects.filter( ObjectivPoint.objects.filter(
@ -161,46 +168,47 @@ def get_leaderboard(request: HttpRequest):
) )
) )
# Get user totals using subquery # Build user totals by iterating through objectives
user_totals = ( user_totals_dict = {}
all_objectives.values("user") for obj in all_objectives:
.annotate(total_score=Sum("total_points")) user_id = obj["user"]
.values("user", "total_score") 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 # Get unique users and their scores, then apply ranking
leaderboard = ( users_with_scores = []
User.objects.filter(objectiv__isnull=False) 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() .distinct()
.annotate( .count()
total_score=Subquery(
user_totals.filter(user=OuterRef("id")).values("total_score")[:1],
output_field=IntegerField(),
) )
) users_with_scores.append(
.annotate(objectives_count=Count("objectiv", distinct=True)) {
.annotate( "user_id": user_id,
rank=Window( "username": user.username,
expression=Rank(), "total_score": total_score,
order_by=F("total_score").desc(), "objectives_count": objectives_count,
) }
)
.values("rank", "username", "total_score", "objectives_count")
.order_by("rank")
) )
data = { # Sort by score and add rank
"leaderboard": [ users_with_scores.sort(key=lambda x: x["total_score"], reverse=True)
leaderboard = [
{ {
"rank": entry["rank"], "rank": idx + 1,
"username": entry["username"], "username": entry["username"],
"total_score": entry["total_score"] or 0, "total_score": entry["total_score"],
"objectives_count": entry["objectives_count"], "objectives_count": entry["objectives_count"],
} }
for entry in leaderboard for idx, entry in enumerate(users_with_scores)
] ]
}
data = {"leaderboard": leaderboard}
cache.set("api:noita:leaderboard", data, 300) cache.set("api:noita:leaderboard", data, 300)
return data return data

View File

@ -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",
),
),
]

View 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,
),
]

View File

@ -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,
),
]

View 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),
]

View 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,
),
]

View File

@ -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"
),
),
]

View File

@ -1,5 +1,6 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.utils import timezone
import uuid import uuid
@ -49,7 +50,17 @@ class Objectiv(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE) 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): class ObjectivPoint(models.Model):
@ -57,3 +68,17 @@ class ObjectivPoint(models.Model):
display_string = models.CharField(max_length=255) display_string = models.CharField(max_length=255)
max_count = models.IntegerField(default=1) max_count = models.IntegerField(default=1)
point = models.IntegerField(default=0) 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",
)
]

View File

@ -1,14 +1,6 @@
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
from ninja import Schema, ModelSchema from ninja import Schema
from noita.models import Objectiv
class ObjectivOut(ModelSchema):
class Meta:
model = Objectiv
fields = ["objectiv_id", "count"]
class NoitaSubmissionOut(Schema): class NoitaSubmissionOut(Schema):
@ -23,7 +15,8 @@ class NoitaSubmissionOut(Schema):
class ObjectivResultOut(Schema): class ObjectivResultOut(Schema):
objectiv_id: str objectiv_id: str
count: int first_seen_at: datetime
seed: str
points_per_objectiv: int points_per_objectiv: int
total_points: int total_points: int

View File

@ -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 noita.services.decode import parse_log, resolve
from collections import Counter def parse_objectives_from_logfile(
logfile: LogfileSubmission,
) -> list[tuple[str, str, str]]:
def parse_objectives_from_logfile(logfile: LogfileSubmission) -> Counter:
"""Parse a log file, and output a count for each ID.""" """Parse a log file, and output a count for each ID."""
file_data = logfile.file.read().decode() file_data = logfile.file.read().decode()
ids = [] entries: list[tuple[str, str, str]] = []
for entry in parse_log(file_data): for entry in parse_log(file_data):
idx, _seed = resolve(entry["hash"], entry["ts"]) idx, seed = resolve(entry["hash"], entry["ts"])
if idx: if idx and seed:
ids.append(idx) entries.append((idx, str(seed), entry["ts"]))
return Counter(ids) return entries
def parse_objectives_and_store(logfile: LogfileSubmission) -> None: def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
@ -25,16 +24,32 @@ def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
if not logfile.user: if not logfile.user:
return return
counter = parse_objectives_from_logfile(logfile) objectives = []
deaths = []
for idx, count in counter.items(): for idx, seed, ts in parse_objectives_from_logfile(logfile):
print(idx, seed, ts)
if idx in {"-", "DEBUG", "polylan-mod"}: if idx in {"-", "DEBUG", "polylan-mod"}:
continue continue
obj, created = Objectiv.objects.get_or_create( if idx == "DEATH":
deaths.append(DeathCounter(user=logfile.user, seed=seed, seen_at=ts))
continue
objectives.append(
Objectiv(
objectiv_id=idx, objectiv_id=idx,
user=logfile.user, user=logfile.user,
first_seen_at=ts,
seed=seed,
submission=logfile,
)
) )
obj.count += count Objectiv.objects.bulk_create(
obj.save(update_fields=["count"]) objectives,
update_conflicts=True,
update_fields=["seed", "submission"],
unique_fields=["objectiv_id", "user", "first_seen_at"],
)
DeathCounter.objects.bulk_create(deaths, ignore_conflicts=True)

View File

@ -13,6 +13,7 @@
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.0.0", "@vueuse/core": "^14.0.0",
"dayjs": "^1.11.20",
"install": "^0.13.0", "install": "^0.13.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.16",

View File

@ -17,6 +17,9 @@ importers:
'@vueuse/core': '@vueuse/core':
specifier: ^14.0.0 specifier: ^14.0.0
version: 14.0.0(vue@3.5.22(typescript@5.9.3)) version: 14.0.0(vue@3.5.22(typescript@5.9.3))
dayjs:
specifier: ^1.11.20
version: 1.11.20
install: install:
specifier: ^0.13.0 specifier: ^0.13.0
version: 0.13.0 version: 0.13.0
@ -579,6 +582,9 @@ packages:
daisyui@5.3.10: daisyui@5.3.10:
resolution: {integrity: sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==} resolution: {integrity: sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==}
dayjs@1.11.20:
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1287,6 +1293,8 @@ snapshots:
daisyui@5.3.10: {} daisyui@5.3.10: {}
dayjs@1.11.20: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
enhanced-resolve@5.18.3: enhanced-resolve@5.18.3:

View File

@ -1,11 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; 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 { interface Objective {
objectiv_id: string; objectiv_id: string;
count: number; first_seen_at: string;
points_per_objectiv?: number; seed: string;
total_points?: number; points_per_objectiv: number;
total_points: number;
} }
const userInfo = ref({ const userInfo = ref({
@ -21,61 +32,91 @@ const isUploading = ref(false);
const isDragover = ref(false); const isDragover = ref(false);
const objectives = ref<Objective[]>([]); const objectives = ref<Objective[]>([]);
const objectiveSearchQuery = ref(""); const objectiveSearchQuery = ref("");
const objectiveSortBy = ref<"id" | "count" | "points_per" | "total_points">("id");
const objectiveSortDesc = ref(false);
const isLoadingLeaderboard = ref(false); const isLoadingLeaderboard = ref(false);
const leaderboard = ref<any[]>([]); const leaderboard = ref<any[]>([]);
const isLeaderboardModalOpen = ref(false); const isLeaderboardModalOpen = ref(false);
const filteredObjectives = computed(() => { const columnHelper = createColumnHelper<Objective>();
const query = objectiveSearchQuery.value.toLowerCase(); const sorting = ref<SortingState>([]);
const columnFilters = ref<ColumnFiltersState>([]);
let filtered = objectives.value; const formatDate = (dateString: string) => {
const date = dayjs(dateString);
if (query) { return date.format("MMM DD, YYYY HH:mm");
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 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 handleFileUpload = (event: Event) => {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
if (input.files) { if (input.files) {
@ -387,8 +428,18 @@ onMounted(() => {
<div v-if="objectives.length > 0" class="space-y-4"> <div v-if="objectives.length > 0" class="space-y-4">
<!-- Search Input --> <!-- Search Input -->
<input v-model="objectiveSearchQuery" type="text" placeholder="Search objectives..." <input
class="input input-bordered w-full" /> :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 --> <!-- Results Summary -->
<div class="text-sm text-base-content/70"> <div class="text-sm text-base-content/70">
@ -400,31 +451,64 @@ onMounted(() => {
<table class="table table-zebra w-full"> <table class="table table-zebra w-full">
<thead> <thead>
<tr> <tr>
<th class="cursor-pointer hover:bg-base-300" @click="toggleObjectiveSort('id')"> <th
Objective ID v-for="header in table.getHeaderGroups()[0]?.headers"
<i v-if="objectiveSortBy === 'id'" :key="header.id"
:class="['mdi ml-2', objectiveSortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up']"></i> :class="[
</th> 'cursor-pointer hover:bg-base-300',
<th class="text-right cursor-pointer hover:bg-base-300" header.column.columnDef.id === 'objectiv_id' ? 'text-left' : 'text-right',
@click="toggleObjectiveSort('total_points')"> ]"
Total Points @click="header.column.toggleSorting()"
<i v-if="objectiveSortBy === 'total_points'" >
:class="['mdi ml-2', objectiveSortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up']"></i> <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> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="obj in filteredObjectives" :key="obj.objectiv_id"> <tr v-for="row in filteredObjectives" :key="row.id">
<td class="font-medium"> <td
<a :href="`https://noita.wiki.gg/wiki/${obj.objectiv_id}`" target="_blank"> v-for="cell in row.getVisibleCells()"
{{ obj.objectiv_id }} :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> <i class="mdi mdi-open-in-new"></i>
</a> </a>
</td> </template>
<td class="text-right font-bold text-success"> <template v-else-if="cell.column.id === 'first_seen_at'">
{{ obj.total_points || 0 }} <span :title="getDateTooltip(row.original.first_seen_at)">
</td> {{ formatDate(row.original.first_seen_at) }}
<td class="text-right"> </span>
</template>
<template v-else>
{{ cell.renderValue() }}
</template>
</td> </td>
</tr> </tr>
</tbody> </tbody>