diff --git a/db.py b/db.py index 33e7cac..c59c992 100644 --- a/db.py +++ b/db.py @@ -1072,6 +1072,14 @@ def skill_timeline(db_path: Path | str = DEFAULT_DB) -> dict[str, Any]: if idx is not None: series[key][idx] = row["level"] + for key in series: + last = 0 + for i in range(n): + if series[key][i] > 0: + last = series[key][i] + elif last > 0: + series[key][i] = last + return {"snapshots": snapshots, "series": series} diff --git a/static/app.js b/static/app.js index 280bfed..5e23098 100644 --- a/static/app.js +++ b/static/app.js @@ -748,6 +748,7 @@ function skillLevelSeries(skillKey) { if (!tl?.series) return null; const values = tl.series[skillKey]; if (!values || values.length < 2) return null; + if (values.filter((v) => v > 0).length < 2) return null; return values; } @@ -813,7 +814,7 @@ function setupTrendChartModal() { }); } -function openTrendChartModal(title, snapshots, values, datasetLabel, color = "#4ade80") { +function openTrendChartModal(title, snapshots, values, datasetLabel, color = "#4ade80", skillSeries = false) { setupTrendChartModal(); const modal = document.getElementById("inv-chart-modal"); document.getElementById("inv-chart-modal-title").textContent = title; @@ -822,16 +823,20 @@ function openTrendChartModal(title, snapshots, values, datasetLabel, color = "#4 destroyChart("trendModal"); const bg = color === "#6c8cff" ? "rgba(108, 140, 255, 0.12)" : "rgba(74, 222, 128, 0.12)"; + const points = skillSeries + ? skillTimelineChartPoints(snapshots, values) + : timelineChartPoints(snapshots, values); state.charts.trendModal = new Chart(document.getElementById("inv-chart-modal-canvas"), { type: "line", data: { datasets: [{ label: datasetLabel, - data: timelineChartPoints(snapshots, values), + data: points, borderColor: color, backgroundColor: bg, - tension: 0.3, + tension: skillSeries ? 0 : 0.3, fill: true, + spanGaps: false, }], }, options: chartOptsTime(snapshots), @@ -849,7 +854,7 @@ function openSkillChartModal(skillKey, skillName) { const values = skillLevelSeries(skillKey); const tl = state.skillTimeline; if (!values || !tl) return; - openTrendChartModal(skillName, tl.snapshots, values, t("skills.level")); + openTrendChartModal(skillName, tl.snapshots, values, t("skills.level"), "#4ade80", true); } function closeTrendChartModal() { @@ -1736,12 +1741,10 @@ function renderTimelineCharts() { const skillCanvas = document.getElementById("chart-skills"); if (!skillCanvas || !skillTl?.snapshots?.length) return; - const skillEntries = Object.entries(skillTl.series || {}) - .map(([key, values]) => ({ key, values, latest: values[values.length - 1] || 0 })) - .sort((a, b) => b.latest - a.latest) - .slice(0, 5); + const skillEntries = pickTopSkillEntries(skillTl.series, 5); const colors = ["#6c8cff", "#4ade80", "#fbbf24", "#f87171", "#a78bfa"]; + const dashPatterns = [[], [6, 4], [2, 3], [8, 4, 2, 4], [4, 2]]; const skillName = (key) => { const sk = state.data?.skills?.find((s) => s.key === key); return sk?.name || key.replace(/_/g, " "); @@ -1752,10 +1755,15 @@ function renderTimelineCharts() { data: { datasets: skillEntries.map((entry, idx) => ({ label: skillName(entry.key), - data: timelineChartPoints(skillTl.snapshots, entry.values), + data: skillTimelineChartPoints(skillTl.snapshots, entry.values), borderColor: colors[idx % colors.length], - tension: 0.3, + borderWidth: 2, + borderDash: dashPatterns[idx % dashPatterns.length], + pointRadius: 4, + pointHoverRadius: 6, + tension: 0, fill: false, + spanGaps: false, })), }, options: chartOptsTime(skillTl.snapshots), @@ -1779,6 +1787,45 @@ function snapshotTimeMs(snapshot) { return null; } +function skillLevelDelta(values) { + const valid = values.filter((v) => v > 0); + if (valid.length < 2) return 0; + return Math.max(...valid) - Math.min(...valid); +} + +function pickTopSkillEntries(series, limit = 5) { + const ranked = Object.entries(series || {}) + .map(([key, values]) => ({ + key, + values, + latest: values[values.length - 1] || 0, + delta: skillLevelDelta(values), + })) + .filter((e) => e.delta > 0) + .sort((a, b) => b.delta - a.delta || b.latest - a.latest); + + const seen = new Set(); + const picked = []; + for (const entry of ranked) { + const sig = entry.values.join(","); + if (seen.has(sig)) continue; + seen.add(sig); + picked.push(entry); + if (picked.length >= limit) break; + } + return picked; +} + +function skillTimelineChartPoints(snapshots, values) { + return snapshots.map((snapshot, i) => { + const x = snapshotTimeMs(snapshot); + if (x == null) return null; + const y = values[i]; + if (y == null || y <= 0) return null; + return { x, y }; + }).filter(Boolean); +} + function timelineChartPoints(snapshots, values) { return snapshots.map((snapshot, i) => { const x = snapshotTimeMs(snapshot);