Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
bda5eb3
add slider for range selection
tunikakeks Feb 12, 2026
f2b0c0c
imrpove ui/ux in general
tunikakeks Feb 12, 2026
7297e88
Add row selection, fix positioning of backend selection buttons, add …
PleaseInsertNameHere Feb 12, 2026
d1fb98f
add .next/ to gitignore
PleaseInsertNameHere Feb 12, 2026
4b9b376
merge conflicts
tunikakeks Feb 12, 2026
21d4749
small prediction change
tunikakeks Feb 12, 2026
373d834
add loading skeleton
tunikakeks Feb 12, 2026
723cd68
Changes
tunikakeks Feb 12, 2026
6d4edb7
fix preferences
tunikakeks Feb 12, 2026
8a25249
Improve prediction chart zoom and styling
tunikakeks Feb 12, 2026
5571961
change prediction ch
tunikakeks Feb 12, 2026
3d144a9
Address PR feedback on stats and UI
tunikakeks Feb 12, 2026
74ce68c
change prediction chart to be more smooth
tunikakeks Feb 12, 2026
13bcee6
fix y axis problems
tunikakeks Feb 12, 2026
c8d40c4
fix chart coloring
tunikakeks Feb 12, 2026
7e9ee77
comment fixes
tunikakeks Feb 12, 2026
f41ef23
Add experimental Android snapshot workflow
tunikakeks Feb 12, 2026
b4ec1dd
Limit snapshot workflow triggers and use Java 21
tunikakeks Feb 12, 2026
2e61f0d
add cursor gradient, prevent right click and selection
PleaseInsertNameHere Feb 12, 2026
ae60238
Merge branch 'dev' of https://github.com/RedstoneCloud/RedTrack into dev
PleaseInsertNameHere Feb 12, 2026
0a71e0e
make cursor gradient smaller and faster
PleaseInsertNameHere Feb 12, 2026
c4dbfc7
Add nightly dev Android snapshot release
tunikakeks Feb 12, 2026
911bb8a
Merge branch 'dev' of https://github.com/RedstoneCloud/RedTrack into dev
PleaseInsertNameHere Feb 12, 2026
9ba35cb
Split dev nightly Android snapshot workflow
tunikakeks Feb 12, 2026
b545be0
Run dev snapshot workflow on dev pushes only
tunikakeks Feb 12, 2026
a7b6f5b
Update backend/src/jobs/PingServerJob.ts
tunikakeks Feb 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(pnpm add:*)",
"Bash(node -e:*)",
"Bash(npx tsc:*)",
"Bash(pnpm run build:*)",
"Bash(pnpm exec tsc:*)",
"Bash(git add:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nAdd interactive first-time setup wizard for backend\n\nAdds an interactive CLI setup that runs automatically when no .env file\nexists, guiding through MongoDB URI, port, ping interval, HTTPS, and\nadmin account creation with input validation, colored output, and\npassword masking. Supports --skip-setup flag for CI/Docker environments.\n\nCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(npx next build:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nRedesign chart: smooth lines, move legend to table, fix Now button zoom reset\n\n- Add tension \\(0.3\\) to chart lines for smoother curves\n- Remove built-in Chart.js legend, replace with clickable color dots\n in the server table''s new \"Chart\" column to toggle server visibility\n- Sort table rows by player count descending\n- Fix \"Now\" button to also reset chart zoom via forwardRef/useImperativeHandle\n- Remove redundant \"Reset to live\" button\n\nCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>\nEOF\n\\)\")"
]
Comment thread
tunikakeks marked this conversation as resolved.
}
}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.next/
63 changes: 57 additions & 6 deletions app/components/charts/OnlinePlayersChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,41 @@ export const OnlinePlayersChart = forwardRef<OnlinePlayersChartHandle, { data: a
if (!data || !data.data) return;

const datasets: any[] = [];
const toQuarterMinuteAverages = (pings: any[]) => {
const buckets = new Map<number, { sum: number; count: number }>();
for (const point of pings) {
const timestamp = Number(point.timestamp);
const count = Number(point.count);
if (!Number.isFinite(timestamp) || !Number.isFinite(count)) continue;
const bucketKey = Math.floor(timestamp / 15000);
const bucket = buckets.get(bucketKey);
if (bucket) {
bucket.sum += count;
bucket.count += 1;
} else {
buckets.set(bucketKey, { sum: count, count: 1 });
}
}
return Array.from(buckets.entries())
.sort((a, b) => a[0] - b[0])
.map(([bucketKey, bucket]) => ({
x: bucketKey * 15000,
y: Math.round(bucket.sum / bucket.count),
}));
};
Comment thread
tunikakeks marked this conversation as resolved.
for (const server in data.data) {
const serverData = data.data[server];
const label = serverData.name || server;

datasets.push({
label,
data: serverData.pings.map((point: any) => ({
x: point.timestamp,
y: point.count
})),
data: toQuarterMinuteAverages(serverData.pings || []),
fill: false,
pointRadius: 0,
pointHoverRadius: 0,
backgroundColor: serverData.color,
borderColor: serverData.color,
borderWidth: 2,
borderWidth: 3,
tension: 0.3,
hidden: hiddenServers ? hiddenServers.has(label) : false,
});
Expand Down Expand Up @@ -118,6 +137,38 @@ export const OnlinePlayersChart = forwardRef<OnlinePlayersChartHandle, { data: a
titleColor: "white",
bodyColor: "white",
footerColor: "white",
titleFont: { weight: "700" },
bodyFont: { weight: "400" },
callbacks: {
title: (items: any[]) => {
const first = items?.[0];
if (!first) return "";
const timestamp = first.parsed?.x;
if (!timestamp) return "";
const date = new Date(timestamp);
return date.toLocaleString();
},
label: (item: any) => `${item.dataset?.label || "Server"}: ${item.parsed?.y ?? 0}`,
labelTextColor: (item: any) => {
const chartTooltip = item?.chart?.tooltip;
const caretY = chartTooltip?.caretY ?? null;
if (caretY == null) return "rgba(255,255,255,0.75)";
let nearest = item;
let nearestDist = Number.POSITIVE_INFINITY;
for (const candidate of chartTooltip?.dataPoints || []) {
const y = candidate?.element?.y;
if (!Number.isFinite(y)) continue;
const dist = Math.abs(y - caretY);
if (dist < nearestDist) {
nearest = candidate;
nearestDist = dist;
}
}
return item.dataset?.label === nearest?.dataset?.label
? "#ffffff"
: "rgba(255,255,255,0.75)";
},
},
itemSort: (a: any, b: any) => b.parsed.y - a.parsed.y,
},
zoom: {
Expand All @@ -142,7 +193,7 @@ export const OnlinePlayersChart = forwardRef<OnlinePlayersChartHandle, { data: a
},
hover: {
mode: "nearest",
intersect: true,
intersect: false,
},
scales: {
y: {
Expand Down
234 changes: 234 additions & 0 deletions app/components/charts/PredictionChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import Chart from "chart.js/auto";
import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm";
import React, { useEffect, useRef } from "react";

type PredictionPoint = {
timestamp: number;
predictedPlayers: number;
};

type PredictionTheme = {
grid: string;
axis: string;
tooltipBg: string;
tooltipBorder: string;
tooltipText: string;
};

export function PredictionChart({
points,
lineColor,
theme,
}: {
points: PredictionPoint[];
lineColor: string;
theme: PredictionTheme;
}) {
const chartRef = useRef<Chart | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const pluginRegisteredRef = useRef(false);
const hasManualViewportRef = useRef(false);

useEffect(() => {
if (pluginRegisteredRef.current || typeof window === "undefined") return;
Chart.register(require("chartjs-plugin-zoom").default);
pluginRegisteredRef.current = true;
}, []);
Comment thread
tunikakeks marked this conversation as resolved.

useEffect(() => {
if (!points || points.length === 0) return;

const dataPoints = points
.filter((point) => Number.isFinite(point.timestamp) && Number.isFinite(point.predictedPlayers))
.map((point) => ({ x: point.timestamp, y: point.predictedPlayers }));

if (dataPoints.length === 0) return;

const minX = Math.min(...dataPoints.map((point) => point.x));
const maxX = Math.max(...dataPoints.map((point) => point.x));

const stepSize = 1;
const maxYValue = Math.max(...dataPoints.map((point) => point.y), 0);
const yMax = Math.max(1, Math.ceil(maxYValue * 1.2));

if (chartRef.current) {
const chart = chartRef.current;
chart.data.datasets = [
{
label: "Predicted players",
data: dataPoints,
borderColor: lineColor,
backgroundColor: lineColor,
borderWidth: 3,
pointRadius: 0,
pointHoverRadius: 4,
},
];

if (chart.options?.scales?.x && !hasManualViewportRef.current) {
const xScale = chart.options.scales.x as any;
xScale.min = minX;
xScale.max = maxX;
}

if (chart.options?.scales?.y) {
const yScale = chart.options.scales.y as any;
yScale.min = 0;
yScale.max = yMax;
yScale.ticks.stepSize = stepSize;
yScale.ticks.callback = (value: any) => Math.ceil(Number(value));
}
Comment thread
tunikakeks marked this conversation as resolved.

chart.update("none");
return;
}

if (!canvasRef.current) return;

chartRef.current = new Chart(canvasRef.current, {
type: "line",
data: {
datasets: [
{
label: "Predicted players",
data: dataPoints,
borderColor: lineColor,
backgroundColor: lineColor,
borderWidth: 3,
pointRadius: 0,
pointHoverRadius: 4,
},
],
},
options: {
animation: false,
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: theme.tooltipBg,
borderColor: theme.tooltipBorder,
borderWidth: 1,
titleColor: theme.tooltipText,
bodyColor: theme.tooltipText,
callbacks: {
title: (items: any[]) => {
const first = items?.[0];
if (!first) return "";
const timestamp = first.parsed?.x;
if (!timestamp) return "";
return new Date(timestamp).toLocaleString();
},
label: (item: any) => {
const value = Number.isFinite(item.parsed?.y) ? item.parsed.y.toFixed(2) : "0.00";
return `Predicted players: ${value}`;
},
},
},
zoom: {
zoom: {
drag: {
enabled: true,
threshold: 8,
},
mode: "x",
onZoomComplete: () => {
hasManualViewportRef.current = true;
},
},
},
},
interaction: {
mode: "nearest",
intersect: false,
},
scales: {
y: {
min: 0,
max: yMax,
ticks: {
color: theme.axis,
beginAtZero: true,
stepSize,
callback: (value: any) => {
const rounded = Math.ceil(Number(value));
return rounded < 0 ? 0 : rounded;
},
},
grid: {
borderDash: [3],
borderDashOffset: 3,
drawBorder: false,
color: theme.grid,
},
},
x: {
type: "time",
time: {
unit: "hour",
displayFormats: {
hour: "HH:mm",
},
tooltipFormat: "ll HH:mm",
},
min: minX,
max: maxX,
ticks: {
autoSkip: true,
color: theme.axis,
callback: (value: any) => {
const date = new Date(value);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
},
},
grid: {
display: false,
},
},
},
},
} as any);
}, [points, lineColor, theme]);

useEffect(() => {
return () => {
if (chartRef.current) {
chartRef.current.destroy();
chartRef.current = null;
}
};
}, []);

const handleResetZoom = () => {
const chart = chartRef.current;
if (!chart) return;
const dataset = chart.data.datasets?.[0] as any;
const data = Array.isArray(dataset?.data) ? dataset.data : [];
if (data.length === 0) return;
const minX = Math.min(...data.map((point: any) => point.x));
const maxX = Math.max(...data.map((point: any) => point.x));
hasManualViewportRef.current = false;
if (chart.options?.scales?.x) {
const xScale = chart.options.scales.x as any;
xScale.min = minX;
xScale.max = maxX;
}
if (chart.options?.scales?.y) {
const yScale = chart.options.scales.y as any;
yScale.min = undefined;
yScale.max = undefined;
}
chart.update();
};

return (
<canvas
ref={canvasRef}
className="block h-full w-full p-2"
onDoubleClick={handleResetZoom}
/>
);
}
Loading