Skip to content

Commit

Permalink
fix: Sort functionality and admin dashboard sort order (#29)
Browse files Browse the repository at this point in the history
Some various fixes to make sort functionality more flexible. Sort is now
handled by placing a 'sort' attribute on the element that should be
sorted, e.g. the player list ul element on the scoreboard page.

The sort attribute value is the attribute value on each child element
used to sort, e.g. for the player list each li element contains an
attribute score to use for sorting.

Finally a sortFn attribute can be specified to say which function to use
to compare sort values. In the case of stringly score numbers we need to
parse the numbers before comparing as regular string comparisons won't
work and attribute values have to be string.

Various other small fixes to the logic to clean up the chart.
HyperScript is used to update the player row li elements with new
score-attribute values to avoid having to replace the entire row. The
only other option would be handling this client side, or using something
like the morphdom plugin to htmx to allow merging the server rendered
(SSE) html data with the browser html data. Otherwise client side logic
can break.
  • Loading branch information
snorremd authored Aug 4, 2024
1 parent 84f2c95 commit b1b35c7
Show file tree
Hide file tree
Showing 8 changed files with 115 additions and 76 deletions.
Binary file modified bun.lockb
Binary file not shown.
11 changes: 7 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@
"build:tailwind": "tailwindcss -i ./src/tailwind.css -o ./public/tailwind.css",
"build:server": "bun build --target=bun ./src/index.tsx --outfile=dist/index.js",
"build:client": "bun build --target=bun ./src/client/client.ts --outdir ./public",
"build": "bun run --target=bun build:server && npm run build:tailwind",
"build": "bun run --target=bun build:server && bun run --target=bun build:tailwind && bun run --target=bun build:client",
"format:check": "biome format ./src",
"format": "biome format ./src --write",
"lint": "biome lint ./src",
"check": "biome check --write ./src",
"typecheck": "tsc --noEmit",
"benchmark": "bun run --target=bun tooling/benchmark.ts",
"simulate": "bun run --target=bun tooling/simulate.ts"
"simulate": "bun run build && bun run --target=bun tooling/simulate.ts"
},
"dependencies": {
"@elysiajs/static": "^1.0.3",
Expand All @@ -29,7 +29,10 @@
"chartjs-adapter-date-fns": "^3.0.0",
"elysia": "^1.0.24",
"html-escaper": "^3.0.3",
"htmx.org": "^1.9.12",
"htmx-ext-response-targets": "^2.0.0",
"htmx-ext-sse": "^2.2.1",
"htmx.org": "2.0.1",
"hyperscript.org": "^0.9.12",
"lit": "^3.1.4"
},
"devDependencies": {
Expand All @@ -38,7 +41,7 @@
"@total-typescript/shoehorn": "^0.1.2",
"@types/bun": "^1.1.5",
"@types/html-escaper": "^3.0.2",
"bun-types": "latest",
"bun-types": "1.1.21",
"concurrently": "^8.2.2",
"daisyui": "^4.12.8",
"mitata": "^0.1.11",
Expand Down
126 changes: 74 additions & 52 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,49 +5,70 @@ import {
type Point,
registerables,
} from "chart.js";
import * as htmx from "htmx.org";
import "chartjs-adapter-date-fns";
import { CmcCounter } from "./counter";
import { startConfetti } from "./confetti";

// First some type overrides
declare global {
interface Window {
htmx: typeof htmx;
}
}

// Then we register some chart plugins and web components
Chart.register(...registerables);
customElements.define("cmc-counter", CmcCounter);

// Then define some functions to sort the scoreboard and render the chart
function sortScoreboard() {
const list = document.getElementById("scoreboard-list");
if (!list) {
return;
}
/**
* Define a type for holding a map of sort functions.
* Each function accepts two strings and returns a number.
* The key is the name of the function.
*/
type SortFunctions = {
[key: string]: (a: string, b: string) => number;
};

/**
* A map of sort function implementations.
* Allows us to sort elements on different criteria.
* For example, we can sort elements by score or by localeCompare
* if values are nicknames. If values are stringly numbers we
* can sort them by the numeric value.
*/
const sortFunctions: SortFunctions = {
score: (a, b) => Number.parseInt(b) - Number.parseInt(a),
localeCompare: (a, b) => a.localeCompare(b),
};

/**
* Sorts all elements with the attribute "sort".
* The value of the attribute specifies the value in each child
* to use for sorting. The optional attribute "sortFn" specifies
* the name of the function to use for sorting.
*
* Fallbacks to "localeCompare" if the function is not found.
* We simply remove the elements in the list and re-append them
* in the sorted order.
*
* Works with auto-animate to animate the sorting.
*/
function sortSortable() {
// First find all elements with attribute "sort"
const sortableElements = Array.from(document.querySelectorAll("[sort]"));

// For each sortable element we sort the children by the attribute "sortkey"
for (const sortableElement of sortableElements) {
const sortAttr = sortableElement.getAttribute("sort") ?? "";
const fnName = sortableElement.getAttribute("sortFn") ?? "";
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
const sortFn = sortFunctions[fnName] ?? sortFunctions["localeCompare"];
const items = Array.from(sortableElement.children);

items.sort((a, b) => {
const sortValueA = a.getAttribute(sortAttr) ?? "";
const sortValueB = b.getAttribute(sortAttr) ?? "";
return sortFn(sortValueA, sortValueB);
});

const items = Array.from(list.children);

items.sort((a, b) => {
const scoreA = Number.parseInt(
a.querySelector("span")?.innerText ?? "0",
10,
);
const scoreB = Number.parseInt(
b.querySelector("span")?.innerText ?? "0",
10,
);

const randomNumberBetweenMinus1And1 = Math.random() * 2 - 1;
return scoreB - (scoreA + randomNumberBetweenMinus1And1); // Sort descending order (highest score first)
});

// Clear the list and re-append sorted items
list.innerHTML = "";
for (const item of items) {
list.appendChild(item);
// Clear the list and re-append sorted items
sortableElement.innerHTML = "";
for (const item of items) {
sortableElement.appendChild(item);
}
}
}

Expand All @@ -61,12 +82,12 @@ let chart: Chart<"line">;

function cleanChart() {
const now = new Date().getTime();
const fiveMinutesAgo = now - 5 * 60 * 1000;

for (const dataset of chart.data.datasets) {
dataset.data = dataset.data.filter((point) => {
const x = (point as Point).x;
console.log("X", now - x < 5 * 60 * 1000);
return now - x < 5 * 60 * 1000;
return x >= fiveMinutesAgo;
});
}
chart.update();
Expand Down Expand Up @@ -146,11 +167,12 @@ function renderChart(datasets: ChartConfiguration<"line">["data"]["datasets"]) {

window.renderChart = renderChart;

htmx.onLoad(() => {
function htmxOnLoad() {
// Find all elements with the auto-animate class and animate them
for (const element of Array.from(
document.querySelectorAll(".auto-animate"),
)) {
console.info("Auto animating", element);
autoAnimate(element as HTMLElement);
}

Expand All @@ -161,26 +183,18 @@ htmx.onLoad(() => {
if (confettiCanvas) {
startConfetti(confettiCanvas);
}
});
}

// On SSE messages do DOM-manipulation where necessary
htmx.on("htmx:sseMessage", (evt) => {
// If player score changes sort the scoreboard
if (
evt instanceof CustomEvent &&
evt.detail.type.startsWith("player-score-")
) {
// Sort the scoreboard when a player's score changes
sortScoreboard();
}
function htmxSSEMessage(evt: Event) {
// On SSE messages do DOM-manipulation where necessary
sortSortable();

// If the event is a player-score-chart event, update the chart with the new score data
if (
evt instanceof CustomEvent &&
evt.detail.type.startsWith("player-score-chart-")
evt.detail.type.startsWith("player-score-chart")
) {
const nick = evt.detail.type.replace("player-score-chart-", "");
const data = JSON.parse(evt.detail.data);
const { nick, ...data } = JSON.parse(evt.detail.data);
const dataset = chart.data.datasets.find((d) => d.label === nick);

if (dataset) {
Expand All @@ -205,4 +219,12 @@ htmx.on("htmx:sseMessage", (evt) => {
chart.update();
}
}
});
}

window.onload = () => {
console.info("Running client.ts window.onload setup function");
document.body.addEventListener("htmx:load", htmxOnLoad);
document.body.addEventListener("htmx:sseMessage", htmxSSEMessage);

htmxOnLoad();
};
2 changes: 1 addition & 1 deletion src/game/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ const newWorker = (player: Player) => {
const player = state.players.find((p) => p.uuid === uuid);
if (player) {
player.log.unshift(log);
player.score += log.score;
player.score = log.score;
}
// We need to notify all relevant listeners about the new log
// Multiple listeners can be listening for the same player
Expand Down
7 changes: 4 additions & 3 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ const app = new Elysia({
Bun.file("node_modules/htmx.org/dist/htmx.min.js"),
)
.get("/public/response-targets.js", () =>
Bun.file("node_modules/htmx.org/dist/ext/response-targets.js"),
Bun.file("node_modules/htmx-ext-response-targets/response-targets.js"),
)
.get("/public/sse.js", () =>
Bun.file("node_modules/htmx.org/dist/ext/sse.js"),
.get("/public/sse.js", () => Bun.file("node_modules/htmx-ext-sse/sse.js"))
.get("/public/hyperscript.js", () =>
Bun.file("node_modules/hyperscript.org/dist/_hyperscript.min.js"),
)
.use(adminPlugin)
.use(homePlugin)
Expand Down
1 change: 1 addition & 0 deletions src/layouts/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const HTMLLayout = ({ page, header, children }: LayoutProps) => {
<script src="/public/htmx.min.js" />
<script src="/public/response-targets.js" />
<script src="/public/sse.js" />
<script src="/public/hyperscript.js" />
<script src="/public/client.js" />
</head>
<body class="bg-base-100" hx-ext="response-targets" hx-boost="true">
Expand Down
5 changes: 3 additions & 2 deletions src/pages/admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ const Round = ({ state }: RoundProps) => {
const PlayerRow = (player: Player) => {
const rowId = `player-${player.uuid}`;
return (
<tr class="odd:bg-base-300" id={rowId}>
<tr class="odd:bg-base-300" id={rowId} attrs={{ nick: player.nick }}>
<td>
<a
class="link link-hover"
Expand Down Expand Up @@ -310,12 +310,13 @@ const Admin = ({ state }: AdminProps) => {
</tr>
</thead>
<tbody
attrs={{ sort: "nick" }} // Enable auto-sorting
class="auto-animate"
sse-swap="player-joined"
hx-swap="afterbegin"
>
{state.players
.toSorted((a, b) => a.score - b.score)
.toSorted((a, b) => a.nick.localeCompare(b.nick))
.map((player) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: Don't need it here
<PlayerRow {...player} />
Expand Down
39 changes: 25 additions & 14 deletions src/pages/scoreboard/scoreboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import { basePluginSetup } from "../../plugins";
const PlayerRow = ({ player }: { player: Player }) => {
return (
<li
attrs={{ score: player.score }}
class="flex flex-row justify-between bg-base-300 px-4 py-2 rounded-2xl bg-opacity-30 shadow-lg z-1"
id={`player-${player.nick}`}
sse-swap={`player-left-${player.nick}`}
hx-swap="delete"
// On htmx:sseMessage event, if event matches player-score-${player.nick} update attrs score with the new score
_={`on htmx:sseMessage(event) if event.detail.type === "player-score-${player.nick}" set @score to event.detail.data end`}
>
<h2 class={`text-xl ${player.color.class}`} safe>
<h2 safe class={`text-xl ${player.color.class}`}>
{player.nick}
</h2>
<span
Expand All @@ -23,10 +24,10 @@ const PlayerRow = ({ player }: { player: Player }) => {
>
{player.score}
</span>
<span // Use a hidden element to swap the chart data, don't actually swap json into the DOM
class="hidden"
sse-swap={`player-score-chart-${player.nick}`}
hx-swap="none"
<span // Use a hidden element to remove the player element from the score list
sse-swap={`player-left-${player.nick}`}
hx-swap="delete"
hx-target={`player-${player.nick}`}
/>
</li>
);
Expand All @@ -39,15 +40,18 @@ interface PlayerTableProps {
const PlayerList = ({ players }: PlayerTableProps) => {
return (
<ul
id="scoreboard-list"
// Sort here is a selector
attrs={{ sort: "score", sortFn: "score" }}
class="auto-animate flex flex-col gap-2 z-10 bg-opacity-80 backdrop-blur-sm drop-shadow-lg max-h-[80vh] overflow-y-scroll scrollbar-w-none"
sse-swap="player-joined"
hx-swap="afterbegin"
>
{players.map((player) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<PlayerRow player={player} />
))}
{players
.toSorted((a, b) => b.score - a.score)
.map((player) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<PlayerRow player={player} />
))}
</ul>
);
};
Expand Down Expand Up @@ -108,6 +112,11 @@ export const scoreboardPlugin = basePluginSetup()
<div class="chart-container min-h-screen min-w-screen max-h-screen max-w-screen absolute inset-0 pt-24">
<div class="epic-dark" />
<canvas id="score-board-chart" class="relative" />
<span // Use a hidden element to swap the chart data, don't actually swap json into the DOM
class="hidden"
sse-swap="player-score-chart"
hx-swap="none"
/>
</div>
<div class="min-h-screen min-w-screen max-h-screen max-w-screen absolute inset-0 pt-24 flex flex-col items-center text-center justify-center">
<GameStatus status={state.status} />
Expand Down Expand Up @@ -171,9 +180,10 @@ export const scoreboardPlugin = basePluginSetup()
data: `${event.log.score}`,
},
{
event: `player-score-chart-${event.nick}`,
event: "player-score-chart",
data: JSON.stringify({
x: new Date(event.log.date).toISOString(),
nick: event.nick,
x: event.log.date,
y: event.log.score,
}),
},
Expand Down Expand Up @@ -215,6 +225,7 @@ export const scoreboardPlugin = basePluginSetup()
return [
{
event: `player-left-${event.nick}`,
data: "",
},
{
event: "player-left-chart",
Expand Down

0 comments on commit b1b35c7

Please sign in to comment.