Use real timestamps on history chart x-axes.
Plot snapshots by export or import time instead of category indices so coins, level, skill, and inventory trends reflect actual elapsed time. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -338,7 +338,7 @@ def timeline(db_path: Path | str = DEFAULT_DB) -> list[dict]:
|
|||||||
init_db(conn)
|
init_db(conn)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT s.id, s.exported_at, s.coins, s.total_level, s.character_name, s.source_file
|
SELECT s.id, s.imported_at, s.exported_at, s.coins, s.total_level, s.character_name, s.source_file
|
||||||
FROM snapshots s ORDER BY s.exported_at ASC, s.id ASC
|
FROM snapshots s ORDER BY s.exported_at ASC, s.id ASC
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -352,7 +352,7 @@ def inventory_timeline(db_path: Path | str = DEFAULT_DB) -> dict[str, Any]:
|
|||||||
init_db(conn)
|
init_db(conn)
|
||||||
snap_rows = conn.execute(
|
snap_rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, exported_at FROM snapshots
|
SELECT id, imported_at, exported_at FROM snapshots
|
||||||
ORDER BY exported_at ASC, id ASC
|
ORDER BY exported_at ASC, id ASC
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -1047,7 +1047,7 @@ def skill_timeline(db_path: Path | str = DEFAULT_DB) -> dict[str, Any]:
|
|||||||
init_db(conn)
|
init_db(conn)
|
||||||
snap_rows = conn.execute(
|
snap_rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, exported_at FROM snapshots
|
SELECT id, imported_at, exported_at FROM snapshots
|
||||||
ORDER BY exported_at ASC, id ASC
|
ORDER BY exported_at ASC, id ASC
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|||||||
+102
-21
@@ -766,21 +766,20 @@ function openInventoryChartModal(itemKey, itemName) {
|
|||||||
document.body.classList.add("inv-chart-modal-open");
|
document.body.classList.add("inv-chart-modal-open");
|
||||||
|
|
||||||
destroyChart("inventoryModal");
|
destroyChart("inventoryModal");
|
||||||
const labels = tl.snapshots.map((s) => formatTs(s.exported_at));
|
const points = timelineChartPoints(tl.snapshots, values);
|
||||||
state.charts.inventoryModal = new Chart(document.getElementById("inv-chart-modal-canvas"), {
|
state.charts.inventoryModal = new Chart(document.getElementById("inv-chart-modal-canvas"), {
|
||||||
type: "line",
|
type: "line",
|
||||||
data: {
|
data: {
|
||||||
labels,
|
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: t("inventory.qty"),
|
label: t("inventory.qty"),
|
||||||
data: values,
|
data: points,
|
||||||
borderColor: "#6c8cff",
|
borderColor: "#6c8cff",
|
||||||
backgroundColor: "rgba(108, 140, 255, 0.12)",
|
backgroundColor: "rgba(108, 140, 255, 0.12)",
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
fill: true,
|
fill: true,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
options: chartOpts(),
|
options: chartOptsTime(tl.snapshots),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1627,30 +1626,41 @@ function renderTimelineCharts() {
|
|||||||
const tl = state.timeline;
|
const tl = state.timeline;
|
||||||
if (!tl.length) return;
|
if (!tl.length) return;
|
||||||
|
|
||||||
const labels = tl.map((s) => formatTs(s.exported_at));
|
|
||||||
const coins = tl.map((s) => s.coins);
|
|
||||||
const levels = tl.map((s) => s.total_level);
|
|
||||||
|
|
||||||
destroyChart("coins");
|
destroyChart("coins");
|
||||||
destroyChart("level");
|
destroyChart("level");
|
||||||
destroyChart("skills");
|
destroyChart("skills");
|
||||||
|
|
||||||
state.charts.coins = new Chart(document.getElementById("chart-coins"), {
|
state.charts.coins = new Chart(document.getElementById("chart-coins"), {
|
||||||
type: "line",
|
type: "line",
|
||||||
data: { labels, datasets: [{ label: t("kpi.coins"), data: coins, borderColor: "#6c8cff", tension: 0.3, fill: false }] },
|
data: {
|
||||||
options: chartOpts(),
|
datasets: [{
|
||||||
|
label: t("kpi.coins"),
|
||||||
|
data: timelineChartPoints(tl, tl.map((s) => s.coins)),
|
||||||
|
borderColor: "#6c8cff",
|
||||||
|
tension: 0.3,
|
||||||
|
fill: false,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: chartOptsTime(tl),
|
||||||
});
|
});
|
||||||
state.charts.level = new Chart(document.getElementById("chart-level"), {
|
state.charts.level = new Chart(document.getElementById("chart-level"), {
|
||||||
type: "line",
|
type: "line",
|
||||||
data: { labels, datasets: [{ label: t("kpi.totalLevel"), data: levels, borderColor: "#4ade80", tension: 0.3, fill: false }] },
|
data: {
|
||||||
options: chartOpts(),
|
datasets: [{
|
||||||
|
label: t("kpi.totalLevel"),
|
||||||
|
data: timelineChartPoints(tl, tl.map((s) => s.total_level)),
|
||||||
|
borderColor: "#4ade80",
|
||||||
|
tension: 0.3,
|
||||||
|
fill: false,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: chartOptsTime(tl),
|
||||||
});
|
});
|
||||||
|
|
||||||
const skillTl = state.skillTimeline;
|
const skillTl = state.skillTimeline;
|
||||||
const skillCanvas = document.getElementById("chart-skills");
|
const skillCanvas = document.getElementById("chart-skills");
|
||||||
if (!skillCanvas || !skillTl?.snapshots?.length) return;
|
if (!skillCanvas || !skillTl?.snapshots?.length) return;
|
||||||
|
|
||||||
const skillLabels = skillTl.snapshots.map((s) => formatTs(s.exported_at));
|
|
||||||
const skillEntries = Object.entries(skillTl.series || {})
|
const skillEntries = Object.entries(skillTl.series || {})
|
||||||
.map(([key, values]) => ({ key, values, latest: values[values.length - 1] || 0 }))
|
.map(([key, values]) => ({ key, values, latest: values[values.length - 1] || 0 }))
|
||||||
.sort((a, b) => b.latest - a.latest)
|
.sort((a, b) => b.latest - a.latest)
|
||||||
@@ -1665,26 +1675,96 @@ function renderTimelineCharts() {
|
|||||||
state.charts.skills = new Chart(skillCanvas, {
|
state.charts.skills = new Chart(skillCanvas, {
|
||||||
type: "line",
|
type: "line",
|
||||||
data: {
|
data: {
|
||||||
labels: skillLabels,
|
|
||||||
datasets: skillEntries.map((entry, idx) => ({
|
datasets: skillEntries.map((entry, idx) => ({
|
||||||
label: skillName(entry.key),
|
label: skillName(entry.key),
|
||||||
data: entry.values,
|
data: timelineChartPoints(skillTl.snapshots, entry.values),
|
||||||
borderColor: colors[idx % colors.length],
|
borderColor: colors[idx % colors.length],
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
fill: false,
|
fill: false,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
options: chartOpts(),
|
options: chartOptsTime(skillTl.snapshots),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function chartOpts() {
|
function normalizeTimeMs(ts) {
|
||||||
|
const n = Number(ts);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return null;
|
||||||
|
return n < 1e12 ? n * 1000 : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotTimeMs(snapshot) {
|
||||||
|
if (!snapshot) return null;
|
||||||
|
const exported = normalizeTimeMs(snapshot.exported_at);
|
||||||
|
if (exported != null) return exported;
|
||||||
|
if (snapshot.imported_at) {
|
||||||
|
const imported = Date.parse(snapshot.imported_at);
|
||||||
|
if (Number.isFinite(imported)) return imported;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineChartPoints(snapshots, values) {
|
||||||
|
return snapshots.map((snapshot, i) => {
|
||||||
|
const x = snapshotTimeMs(snapshot);
|
||||||
|
if (x == null) return null;
|
||||||
|
return { x, y: values[i] };
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartTimeRange(snapshots) {
|
||||||
|
const times = snapshots.map(snapshotTimeMs).filter((t) => t != null);
|
||||||
|
if (!times.length) return {};
|
||||||
|
const min = Math.min(...times);
|
||||||
|
const max = Math.max(...times);
|
||||||
|
if (min === max) {
|
||||||
|
const pad = 60 * 60 * 1000;
|
||||||
|
return { min: min - pad, max: max + pad };
|
||||||
|
}
|
||||||
|
return { min, max };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChartAxisTs(ms, spanMs = 0) {
|
||||||
|
if (!ms || !Number.isFinite(ms)) return "";
|
||||||
|
const d = new Date(ms);
|
||||||
|
const dayMs = 86400000;
|
||||||
|
if (spanMs > 14 * dayMs) {
|
||||||
|
return d.toLocaleString(I18n.localeTag(), { dateStyle: "short" });
|
||||||
|
}
|
||||||
|
if (spanMs > 2 * dayMs) {
|
||||||
|
return d.toLocaleString(I18n.localeTag(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
return d.toLocaleString(I18n.localeTag(), { dateStyle: "short", timeStyle: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartOptsTime(snapshots) {
|
||||||
|
const range = chartTimeRange(snapshots);
|
||||||
|
const spanMs = (range.max ?? 0) - (range.min ?? 0);
|
||||||
return {
|
return {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: { legend: { labels: { color: "#8b92a8" } } },
|
plugins: {
|
||||||
|
legend: { labels: { color: "#8b92a8" } },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
title: (items) => {
|
||||||
|
const x = items[0]?.parsed?.x;
|
||||||
|
return x != null ? formatTs(x) : "";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: { ticks: { color: "#8b92a8" }, grid: { color: "#2d3348" } },
|
x: {
|
||||||
|
type: "linear",
|
||||||
|
...range,
|
||||||
|
ticks: {
|
||||||
|
color: "#8b92a8",
|
||||||
|
maxTicksLimit: 8,
|
||||||
|
callback: (v) => formatChartAxisTs(v, spanMs),
|
||||||
|
},
|
||||||
|
grid: { color: "#2d3348" },
|
||||||
|
},
|
||||||
y: { ticks: { color: "#8b92a8" }, grid: { color: "#2d3348" } },
|
y: { ticks: { color: "#8b92a8" }, grid: { color: "#2d3348" } },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1762,8 +1842,9 @@ function fmt(n) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatTs(ts) {
|
function formatTs(ts) {
|
||||||
if (!ts) return "—";
|
const ms = normalizeTimeMs(ts);
|
||||||
const d = new Date(Number(ts));
|
if (ms == null) return "—";
|
||||||
|
const d = new Date(ms);
|
||||||
return d.toLocaleString(I18n.localeTag(), { dateStyle: "short", timeStyle: "short" });
|
return d.toLocaleString(I18n.localeTag(), { dateStyle: "short", timeStyle: "short" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user