Task ID: task_e_682fdf1124448323b622d7df3ad030e0
Configuring language runtimes...
+ rm -- /tmp/sxY6vj-setup_script.sh
+ source ./codex_setup.sh
++ uv sync --frozen
Using CPython 3.12.3 interpreter at: /usr/bin/python3.12
Creating virtual environment at: .venv
Building scubaduck @ file:///workspace/scubaduck
Downloading duckdb (19.3MiB)
Downloading playwright (43.1MiB)
Downloading ruff (11.0MiB)
Downloading pyright (5.3MiB)
Downloading ruff
Built scubaduck @ file:///workspace/scubaduck
Downloading duckdb
Downloading playwright
Downloading pyright
Prepared 33 packages in 2.63s
Installed 33 packages in 102ms
+ blinker==1.9.0
+ certifi==2025.4.26
+ charset-normalizer==3.4.2
+ click==8.2.0
+ duckdb==1.2.2
+ execnet==2.1.1
+ flask==3.1.1
+ greenlet==3.2.2
+ idna==3.10
+ iniconfig==2.1.0
+ itsdangerous==2.2.0
+ jinja2==3.1.6
+ markupsafe==3.0.2
+ nodeenv==1.9.1
+ packaging==25.0
+ playwright==1.52.0
+ pluggy==1.6.0
+ pyee==13.0.0
+ pyright==1.1.400
+ pytest==8.3.5
+ pytest-base-url==2.1.0
+ pytest-playwright==0.7.0
+ pytest-xdist==3.6.1
+ python-dateutil==2.9.0.post0
+ python-slugify==8.0.4
+ requests==2.32.3
+ ruff==0.11.10
+ scubaduck==0.1.0 (from file:///workspace/scubaduck)
+ six==1.17.0
+ text-unidecode==1.3
+ typing-extensions==4.13.2
+ urllib3==2.4.0
+ werkzeug==3.1.3
++ source .venv/bin/activate
+++ '[' -z '' ']'
+++ '[' -n x ']'
+++ SCRIPT_PATH=.venv/bin/activate
+++ '[' .venv/bin/activate = /tmp/sxY6vj-setup_script.sh ']'
+++ deactivate nondestructive
+++ unset -f pydoc
+++ '[' -z '' ']'
+++ '[' -z '' ']'
+++ hash -r
+++ '[' -z '' ']'
+++ unset VIRTUAL_ENV
+++ unset VIRTUAL_ENV_PROMPT
+++ '[' '!' nondestructive = nondestructive ']'
+++ VIRTUAL_ENV=/workspace/scubaduck/.venv
+++ '[' linux-gnu = cygwin ']'
+++ '[' linux-gnu = msys ']'
+++ export VIRTUAL_ENV
+++ '[' -z '' ']'
+++ unset SCRIPT_PATH
+++ _OLD_VIRTUAL_PATH=/root/.cargo/bin:/root/.rbenv/shims:/root/.rbenv/bin:/root/.rbenv/shims:/root/.local/share/swiftly/bin:/root/.bun/bin:/root/.nvm/versions/node/v22.16.0/bin:/root/.pyenv/shims:3441PYENV_ROOT/shims:/root/.pyenv/bin:/usr/local/go/bin:/root/go/bin:/root/.rbenv/bin:/root/.rbenv/shims:/root/.bun/bin:/root/.local/bin:/root/.pyenv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+++ PATH=/workspace/scubaduck/.venv/bin:/root/.cargo/bin:/root/.rbenv/shims:/root/.rbenv/bin:/root/.rbenv/shims:/root/.local/share/swiftly/bin:/root/.bun/bin:/root/.nvm/versions/node/v22.16.0/bin:/root/.pyenv/shims:3441PYENV_ROOT/shims:/root/.pyenv/bin:/usr/local/go/bin:/root/go/bin:/root/.rbenv/bin:/root/.rbenv/shims:/root/.bun/bin:/root/.local/bin:/root/.pyenv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+++ export PATH
+++ '[' xscubaduck '!=' x ']'
+++ VIRTUAL_ENV_PROMPT=scubaduck
+++ export VIRTUAL_ENV_PROMPT
+++ '[' -z '' ']'
+++ '[' -z '' ']'
+++ _OLD_VIRTUAL_PS1=
+++ PS1='(scubaduck) '
+++ export PS1
+++ alias pydoc
+++ true
+++ hash -r
++ python -c 'import os; import duckdb; con = duckdb.connect(); con.execute(f"SET http_proxy = '\''{os.getenv("HTTP_PROXY")}'\''"); con.execute("INSTALL '\''sqlite'\'';")'
++ playwright install chromium
Downloading Chromium 136.0.7103.25 (playwright build v1169) from https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1169/chromium-linux.zip
167.7 MiB [] 0% 0.0s167.7 MiB [] 0% 29.3s167.7 MiB [] 0% 28.9s167.7 MiB [] 0% 21.1s167.7 MiB [] 0% 12.9s167.7 MiB [] 0% 8.4s167.7 MiB [] 1% 6.6s167.7 MiB [] 2% 5.6s167.7 MiB [] 2% 4.8s167.7 MiB [] 3% 3.8s167.7 MiB [] 4% 3.3s167.7 MiB [] 5% 3.2s167.7 MiB [] 5% 3.1s167.7 MiB [] 6% 2.9s167.7 MiB [] 7% 2.9s167.7 MiB [] 7% 3.0s167.7 MiB [] 7% 3.6s167.7 MiB [] 8% 3.8s167.7 MiB [] 8% 4.0s167.7 MiB [] 8% 3.9s167.7 MiB [] 9% 3.8s167.7 MiB [] 10% 3.6s167.7 MiB [] 11% 3.2s167.7 MiB [] 13% 2.9s167.7 MiB [] 14% 2.8s167.7 MiB [] 15% 2.7s167.7 MiB [] 16% 2.6s167.7 MiB [] 18% 2.3s167.7 MiB [] 19% 2.2s167.7 MiB [] 20% 2.1s167.7 MiB [] 21% 2.0s167.7 MiB [] 22% 2.0s167.7 MiB [] 24% 1.9s167.7 MiB [] 25% 1.9s167.7 MiB [] 26% 1.9s167.7 MiB [] 27% 1.9s167.7 MiB [] 28% 1.8s167.7 MiB [] 29% 1.8s167.7 MiB [] 30% 1.8s167.7 MiB [] 30% 1.9s167.7 MiB [] 31% 1.8s167.7 MiB [] 32% 1.8s167.7 MiB [] 33% 1.8s167.7 MiB [] 34% 1.7s167.7 MiB [] 34% 1.8s167.7 MiB [] 34% 1.9s167.7 MiB [] 35% 1.9s167.7 MiB [] 35% 2.0s167.7 MiB [] 36% 1.9s167.7 MiB [] 36% 2.1s167.7 MiB [] 37% 2.1s167.7 MiB [] 37% 2.2s167.7 MiB [] 37% 2.3s167.7 MiB [] 38% 2.3s167.7 MiB [] 39% 2.3s167.7 MiB [] 40% 2.2s167.7 MiB [] 41% 2.2s167.7 MiB [] 42% 2.2s167.7 MiB [] 42% 2.3s167.7 MiB [] 43% 2.3s167.7 MiB [] 44% 2.3s167.7 MiB [] 44% 2.2s167.7 MiB [] 46% 2.1s167.7 MiB [] 47% 2.1s167.7 MiB [] 48% 2.0s167.7 MiB [] 49% 2.0s167.7 MiB [] 49% 1.9s167.7 MiB [] 51% 1.8s167.7 MiB [] 52% 1.8s167.7 MiB [] 53% 1.7s167.7 MiB [] 55% 1.6s167.7 MiB [] 56% 1.5s167.7 MiB [] 57% 1.5s167.7 MiB [] 58% 1.5s167.7 MiB [] 59% 1.5s167.7 MiB [] 60% 1.4s167.7 MiB [] 61% 1.4s167.7 MiB [] 62% 1.4s167.7 MiB [] 62% 1.3s167.7 MiB [] 62% 1.4s167.7 MiB [] 63% 1.4s167.7 MiB [] 64% 1.3s167.7 MiB [] 65% 1.2s167.7 MiB [] 66% 1.2s167.7 MiB [] 67% 1.2s167.7 MiB [] 68% 1.1s167.7 MiB [] 70% 1.0s167.7 MiB [] 72% 1.0s167.7 MiB [] 73% 0.9s167.7 MiB [] 74% 0.9s167.7 MiB [] 76% 0.8s167.7 MiB [] 77% 0.8s167.7 MiB [] 78% 0.7s167.7 MiB [] 80% 0.6s167.7 MiB [] 81% 0.6s167.7 MiB [] 82% 0.5s167.7 MiB [] 83% 0.5s167.7 MiB [] 84% 0.5s167.7 MiB [] 86% 0.4s167.7 MiB [] 87% 0.4s167.7 MiB [] 89% 0.3s167.7 MiB [] 90% 0.3s167.7 MiB [] 91% 0.3s167.7 MiB [] 91% 0.2s167.7 MiB [] 93% 0.2s167.7 MiB [] 94% 0.2s167.7 MiB [] 95% 0.1s167.7 MiB [] 96% 0.1s167.7 MiB [] 97% 0.1s167.7 MiB [] 99% 0.0s167.7 MiB [] 100% 0.0s
Chromium 136.0.7103.25 (playwright build v1169) downloaded to /root/.cache/ms-playwright/chromium-1169
Downloading FFMPEG playwright build v1011 from https://cdn.playwright.dev/dbazure/download/playwright/builds/ffmpeg/1011/ffmpeg-linux.zip
2.3 MiB [] 0% 0.0s2.3 MiB [] 3% 0.5s2.3 MiB [] 4% 0.7s2.3 MiB [] 11% 0.4s2.3 MiB [] 24% 0.2s2.3 MiB [] 42% 0.1s2.3 MiB [] 75% 0.0s2.3 MiB [] 100% 0.0s
FFMPEG playwright build v1011 downloaded to /root/.cache/ms-playwright/ffmpeg-1011
Downloading Chromium Headless Shell 136.0.7103.25 (playwright build v1169) from https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1169/chromium-headless-shell-linux.zip
101.4 MiB [] 0% 0.0s101.4 MiB [] 0% 20.4s101.4 MiB [] 0% 14.9s101.4 MiB [] 0% 9.9s101.4 MiB [] 1% 5.9s101.4 MiB [] 2% 3.5s101.4 MiB [] 3% 3.1s101.4 MiB [] 4% 2.7s101.4 MiB [] 5% 2.1s101.4 MiB [] 7% 1.9s101.4 MiB [] 8% 1.9s101.4 MiB [] 9% 1.7s101.4 MiB [] 10% 1.7s101.4 MiB [] 12% 1.5s101.4 MiB [] 13% 1.4s101.4 MiB [] 15% 1.3s101.4 MiB [] 17% 1.3s101.4 MiB [] 18% 1.2s101.4 MiB [] 20% 1.1s101.4 MiB [] 23% 1.1s101.4 MiB [] 24% 1.0s101.4 MiB [] 26% 1.0s101.4 MiB [] 29% 0.9s101.4 MiB [] 30% 0.9s101.4 MiB [] 31% 0.9s101.4 MiB [] 31% 1.0s101.4 MiB [] 31% 1.1s101.4 MiB [] 32% 1.1s101.4 MiB [] 33% 1.1s101.4 MiB [] 35% 1.0s101.4 MiB [] 36% 1.0s101.4 MiB [] 39% 0.9s101.4 MiB [] 40% 0.9s101.4 MiB [] 43% 0.8s101.4 MiB [] 44% 0.8s101.4 MiB [] 45% 0.8s101.4 MiB [] 49% 0.7s101.4 MiB [] 51% 0.7s101.4 MiB [] 54% 0.6s101.4 MiB [] 56% 0.6s101.4 MiB [] 58% 0.6s101.4 MiB [] 60% 0.5s101.4 MiB [] 61% 0.5s101.4 MiB [] 62% 0.5s101.4 MiB [] 63% 0.5s101.4 MiB [] 65% 0.5s101.4 MiB [] 67% 0.5s101.4 MiB [] 68% 0.4s101.4 MiB [] 70% 0.4s101.4 MiB [] 72% 0.4s101.4 MiB [] 75% 0.3s101.4 MiB [] 78% 0.3s101.4 MiB [] 80% 0.2s101.4 MiB [] 82% 0.2s101.4 MiB [] 84% 0.2s101.4 MiB [] 85% 0.2s101.4 MiB [] 86% 0.2s101.4 MiB [] 88% 0.2s101.4 MiB [] 90% 0.1s101.4 MiB [] 92% 0.1s101.4 MiB [] 95% 0.1s101.4 MiB [] 97% 0.0s101.4 MiB [] 98% 0.0s101.4 MiB [] 99% 0.0s101.4 MiB [] 100% 0.0s
Chromium Headless Shell 136.0.7103.25 (playwright build v1169) downloaded to /root/.cache/ms-playwright/chromium_headless_shell-1169
++ echo 'source .venv/bin/activate'
I'm checking the repo root to find AGENTS.md for relevant instructions.
bash: .venv/bin/activate: No such file or directory
root@4682ac416816:/workspace# ls
scubaduck
root@4682ac416816:/workspace# cd scubaduck
root@4682ac416816:/workspace/scubaduck# ls
AGENTS.md codex_setup.sh scubaduck tests
README.md pyproject.toml stubs uv.lock
Let me open AGENTS.md to check instructions for the next task.
root@4682ac416816:/workspace/scubaduck# cat AGENTS.md
- Use "source .venv/bin/activate" to activate the virtual environment before
running any commands
- ONLY use non-interactive commands like cat, sed, apply_patch to do edits.
Do NOT use interactive editors.
- Do NOT attempt to install packages. Only the packages specified in
pyproject.toml are available. You cannot add new packages. If you
desperately want another package, make a note of it in the final PR
description.
- Use conventional commits to format PR title
- There are no nested AGENTS.md files, this is the only agents file
- When using Playwright, ONLY use chromium browser. This browser is already
installed.
- Use "ruff check" to check lint, "ruff format" to autoformat files and
"pyright" to typecheck.
- When writing the PR description, include the original prompt supplied by the
user.
- When you add functionality to the server, add server tests to
tests/test_server.py. When you add features to the frontend, add frontend
tests to tests/test_web.py
I see I need to run ruff check, format, and pyright before committing. Let's inspect.
root@4682ac416816:/workspace/scubaduck# ls
AGENTS.md codex_setup.sh scubaduck tests
README.md pyproject.toml stubs uv.lock
I'll check out the contents inside the scubaduck directory now.
root@4682ac416816:/workspace/scubaduck# ls scubaduck
__init__.py sample.csv server.py static
root@4682ac416816:/workspace/scubaduck# ls scubaduck/static
index.html js
root@4682ac416816:/workspace/scubaduck# ls scubaduck/static/js
chip_input.js table_view.js timeseries_chart.js
sidebar.js time_column.js view_settings.js
I think I'll check out the timeseries_chart.js file.
root@4682ac416816:/workspace/scubaduck# sed -n '1,160p' scubaduck/static/js/time
series_chart.js
let resizeObserver = null;
let currentChart = null;
function showTimeSeries(data) {
function parseTs(s) {
if (s.match(/GMT/) || s.endsWith('Z') || /\+\d{2}:?\d{2}$/.test(s)) {
return new Date(s).getTime();
}
return new Date(s + 'Z').getTime();
}
const view = document.getElementById('view');
if (data.rows.length === 0) {
view.innerHTML = '<p id="empty-message">Empty data provided to table</p>';
return;
}
const height = 600;
view.innerHTML =
'<div id="ts-container"><div id="legend"></div><div id="chart-wrapper"><svg
id="chart" height="' +
height +
'"></svg></div></div>';
const svg = document.getElementById('chart');
const legend = document.getElementById('legend');
const crosshairLine = document.createElementNS('http://www.w3.org/2000/svg', '
line');
crosshairLine.id = 'crosshair_line';
crosshairLine.setAttribute('stroke', '#555');
crosshairLine.style.display = 'none';
const crosshairDots = document.createElementNS('http://www.w3.org/2000/svg', '
g');
crosshairDots.id = 'crosshair_dots';
crosshairDots.style.display = 'none';
const groups = groupBy.chips || [];
const hasHits = document.getElementById('show_hits').checked ? 1 : 0;
const fill = document.getElementById('fill').value;
const bucketMs = (data.bucket_size || 3600) * 1000;
const start = data.start ? parseTs(data.start) : null;
const end = data.end ? parseTs(data.end) : null;
const startIdx = 1 + groups.length + hasHits;
const valueCols = selectedColumns.slice(groups.length + hasHits);
const series = {};
data.rows.forEach(r => {
const ts = parseTs(r[0]);
const groupKey = groups.map((_, i) => r[1 + i]).join(':') || 'all';
valueCols.forEach((name, i) => {
const val = Number(r[startIdx + i]);
const key = groupKey === 'all' ? name : groupKey + ':' + name;
if (!series[key]) series[key] = {};
series[key][ts] = val;
});
});
const buckets = [];
let minX = start !== null ? start : Infinity;
let maxX = end !== null ? end : -Infinity;
if (start !== null && end !== null) {
for (let t = start; t <= end; t += bucketMs) {
buckets.push(t);
}
} else {
Object.keys(series).forEach(k => {
const s = series[k];
Object.keys(s).forEach(t => {
const n = Number(t);
if (n < minX) minX = n;
if (n > maxX) maxX = n;
});
});
for (let t = minX; t <= maxX; t += bucketMs) {
buckets.push(t);
}
}
let minY = Infinity,
maxY = -Infinity;
Object.keys(series).forEach(key => {
const vals = series[key];
buckets.forEach(b => {
const v = vals[b];
const val = v === undefined && fill === '0' ? 0 : v;
if (val === undefined) return;
if (val < minY) minY = val;
if (val > maxY) maxY = val;
});
});
if (fill === '0') {
if (minY > 0) minY = 0;
if (maxY < 0) maxY = 0;
}
const colors = [
'#1f77b4',
'#ff7f0e',
'#2ca02c',
'#d62728',
'#9467bd',
'#8c564b',
'#e377c2'
];
currentChart = {
svg,
legend,
series,
buckets,
minX,
maxX,
minY,
maxY,
fill,
colors,
height,
crosshairLine,
crosshairDots,
seriesEls: {},
bucketPixels: [],
xScale: null,
yScale: null,
selected: null,
frozen: false
};
const intervals = [
{unit: 'second', step: 1, ms: 1000},
{unit: 'second', step: 2, ms: 2000},
{unit: 'second', step: 5, ms: 5000},
{unit: 'second', step: 10, ms: 10000},
{unit: 'second', step: 15, ms: 15000},
{unit: 'second', step: 30, ms: 30000},
{unit: 'minute', step: 1, ms: 60000},
{unit: 'minute', step: 2, ms: 120000},
{unit: 'minute', step: 5, ms: 300000},
{unit: 'minute', step: 10, ms: 600000},
{unit: 'minute', step: 15, ms: 900000},
{unit: 'minute', step: 30, ms: 1800000},
{unit: 'hour', step: 1, ms: 3600000},
{unit: 'hour', step: 2, ms: 7200000},
{unit: 'hour', step: 3, ms: 10800000},
{unit: 'hour', step: 4, ms: 14400000},
{unit: 'hour', step: 6, ms: 21600000},
{unit: 'hour', step: 12, ms: 43200000},
{unit: 'day', step: 1, ms: 86400000},
{unit: 'day', step: 2, ms: 172800000},
{unit: 'week', step: 1, ms: 604800000},
{unit: 'week', step: 2, ms: 1209600000},
{unit: 'month', step: 1},
{unit: 'month', step: 3},
{unit: 'month', step: 6},
{unit: 'year', step: 1},
{unit: 'year', step: 2},
{unit: 'year', step: 5},
{unit: 'year', step: 10}
];
function chooseInterval(start, end) {
const span = end - start;
function approxMs(i) {
if (i.ms) return i.ms;
if (i.unit === 'month') return i.step * 2629800000;
if (i.unit === 'year') return i.step * 31557600000;
return 1000;
}
root@4682ac416816:/workspace/scubaduck# sed -n '160,320p' scubaduck/static/js/ti
meseries_chart.js
}
let best = intervals[0];
let bestScore = Infinity;
intervals.forEach(i => {
const count = span / approxMs(i);
const score = Math.abs(count - 15);
if (score < bestScore) {
best = i;
bestScore = score;
}
});
return best;
}
function generateTicks(start, end, intv) {
const ticks = [];
if (intv.unit === 'month' || intv.unit === 'year') {
let d = new Date(start);
d.setUTCDate(1);
if (intv.unit === 'year') d.setUTCMonth(0);
let unitVal =
intv.unit === 'month'
? d.getUTCFullYear() * 12 + d.getUTCMonth()
: d.getUTCFullYear();
unitVal = Math.ceil(unitVal / intv.step) * intv.step;
while (true) {
const year =
intv.unit === 'month' ? Math.floor(unitVal / 12) : unitVal;
const month = intv.unit === 'month' ? unitVal % 12 : 0;
const t = Date.UTC(year, month, 1);
if (t > end) break;
if (t >= start) ticks.push(t);
unitVal += intv.step;
}
} else {
const step = intv.ms * intv.step;
let t = Math.ceil(start / step) * step;
if (intv.unit === 'week') {
const d = new Date(t);
const adj = (d.getUTCDay() + 6) % 7;
t = d.getTime() - adj * 86400000;
t = Math.ceil(t / step) * step;
}
if (t === start) t += step;
for (; t <= end; t += step) ticks.push(t);
}
return ticks;
}
function labelUnit(intv) {
if (intv.unit === 'year') return 'year';
if (intv.unit === 'month') return 'month';
if (intv.unit === 'day' || intv.unit === 'week') return 'day';
if (intv.unit === 'hour') return 'hour';
return 'minute';
}
function fmt(date, unit) {
const pad = n => String(n).padStart(2, '0');
const mon = date.toLocaleString('en-US', {month: 'short'});
switch (unit) {
case 'year':
return String(date.getFullYear());
case 'month':
if (date.getMonth() === 0) return String(date.getFullYear());
return `${mon} ${date.getFullYear()}`;
case 'day':
if (date.getDate() === 1) return `${mon} ${date.getFullYear()}`;
return `${date.getDate()} ${mon}`;
case 'hour':
if (date.getHours() === 0 && date.getMinutes() === 0)
return `${date.getDate()} ${mon}`;
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
default:
if (date.getMinutes() === 0 && date.getSeconds() === 0)
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
return `${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
}
function niceNum(range, round) {
const exponent = Math.floor(Math.log10(range));
const fraction = range / Math.pow(10, exponent);
let niceFraction;
if (round) {
if (fraction < 1.5) niceFraction = 1;
else if (fraction < 3) niceFraction = 2;
else if (fraction < 7) niceFraction = 5;
else niceFraction = 10;
} else {
if (fraction <= 1) niceFraction = 1;
else if (fraction <= 2) niceFraction = 2;
else if (fraction <= 5) niceFraction = 5;
else niceFraction = 10;
}
return niceFraction * Math.pow(10, exponent);
}
function niceTicks(min, max, count) {
const range = niceNum(max - min || 1, false);
const step = niceNum(range / Math.max(count - 1, 1), true);
const start = Math.floor(min / step) * step;
const end = Math.ceil(max / step) * step;
const ticks = [];
for (let v = start; v <= end; v += step) ticks.push(v);
return ticks;
}
function render() {
const style = getComputedStyle(svg.parentElement);
const width =
svg.parentElement.clientWidth -
parseFloat(style.paddingLeft) -
parseFloat(style.paddingRight);
svg.setAttribute('width', width);
svg.innerHTML = '';
legend.innerHTML = '';
let colorIndex = 0;
const xRange = maxX - minX || 1;
const yRange = maxY - minY || 1;
const xScale = x => ((x - minX) / xRange) * (width - 60) + 50;
const yScale = y => height - 30 - ((y - minY) / yRange) * (height - 60);
const grid = document.createElementNS('http://www.w3.org/2000/svg', 'g');
svg.appendChild(grid);
const yAxis = document.createElementNS('http://www.w3.org/2000/svg', 'g');
const yTicks = niceTicks(minY, maxY, 10);
yTicks.forEach(v => {
const y = yScale(v);
const gLine = document.createElementNS('http://www.w3.org/2000/svg', 'line
');
gLine.setAttribute('x1', xScale(minX));
gLine.setAttribute('x2', xScale(maxX));
gLine.setAttribute('y1', y);
gLine.setAttribute('y2', y);
gLine.setAttribute('class', 'grid');
grid.appendChild(gLine);
const tick = document.createElementNS('http://www.w3.org/2000/svg', 'line'
);
tick.setAttribute('x1', xScale(minX));
tick.setAttribute('x2', xScale(minX) - 5);
tick.setAttribute('y1', y);
tick.setAttribute('y2', y);
tick.setAttribute('stroke', '#000');
yAxis.appendChild(tick);
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'
);
text.setAttribute('x', xScale(minX) - 8);
text.setAttribute('y', y + 3);
text.setAttribute('text-anchor', 'end');
text.setAttribute('class', 'y-tick-label');
text.textContent = formatNumber(v);
yAxis.appendChild(text);
});
const seriesEls = {};
const agg = document.getElementById('aggregate').value.toLowerCase();
const groups = {};
Object.keys(series).forEach(key => {
const vals = series[key];
const color = colors[colorIndex++ % colors.length];
let path = '';
let drawing = false;
buckets.forEach(b => {
root@4682ac416816:/workspace/scubaduck# sed -n '320,640p' scubaduck/static/js/ti
meseries_chart.js
buckets.forEach(b => {
const v = vals[b];
if (v === undefined) {
if (fill === '0') {
const x = xScale(b);
const y = yScale(0);
path += (drawing ? 'L' : 'M') + x + ' ' + y + ' ';
drawing = true;
} else if (fill === 'blank') {
drawing = false;
}
// connect: do nothing
} else {
const x = xScale(b);
const y = yScale(v);
path += (drawing ? 'L' : 'M') + x + ' ' + y + ' ';
drawing = true;
}
});
const el = document.createElementNS('http://www.w3.org/2000/svg', 'path');
el.setAttribute('d', path.trim());
el.setAttribute('fill', 'none');
el.setAttribute('stroke', color);
el.setAttribute('stroke-width', '1.3');
svg.appendChild(el);
const idx = key.lastIndexOf(':');
const groupKey = idx === -1 ? 'all' : key.slice(0, idx);
const name = idx === -1 ? key : key.slice(idx + 1);
let group = groups[groupKey];
if (!group) {
const gEl = document.createElement('div');
gEl.className = 'legend-group';
const header = document.createElement('div');
header.className = 'legend-header';
header.textContent =
groupKey === 'all' ? agg : `${groupKey} ${agg}`;
gEl.appendChild(header);
const items = document.createElement('div');
items.className = 'legend-items';
gEl.appendChild(items);
legend.appendChild(gEl);
group = {items};
groups[groupKey] = group;
}
const item = document.createElement('div');
item.className = 'legend-item';
const label = document.createElement('span');
label.textContent = name;
label.style.color = color;
const valueSpan = document.createElement('span');
valueSpan.className = 'legend-value';
item.appendChild(label);
item.appendChild(valueSpan);
group.items.appendChild(item);
function highlight(on) {
el.setAttribute('stroke-width', on ? '2.5' : '1.3');
item.classList.toggle('highlight', on);
}
el.addEventListener('mouseenter', () => highlight(true));
el.addEventListener('mouseleave', () => highlight(false));
item.addEventListener('mouseenter', () => highlight(true));
item.addEventListener('mouseleave', () => highlight(false));
seriesEls[key] = { path: el, item, highlight, color, valueEl: valueSpan };
});
currentChart.seriesEls = seriesEls;
currentChart.xScale = xScale;
currentChart.yScale = yScale;
currentChart.bucketPixels = buckets.map(xScale);
svg.appendChild(crosshairLine);
svg.appendChild(crosshairDots);
const intv = chooseInterval(minX, maxX);
const ticks = generateTicks(minX, maxX, intv);
const lu = labelUnit(intv);
const rotate = ticks.length > 0 && (width - 60) / ticks.length < 60;
const axis = document.createElementNS('http://www.w3.org/2000/svg', 'g');
const axisLine = document.createElementNS('http://www.w3.org/2000/svg', 'lin
e');
axisLine.setAttribute('x1', xScale(minX));
axisLine.setAttribute('x2', xScale(maxX));
axisLine.setAttribute('y1', height - 30);
axisLine.setAttribute('y2', height - 30);
axisLine.setAttribute('stroke', '#888');
axis.appendChild(axisLine);
ticks.forEach(t => {
const x = xScale(t);
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'
);
line.setAttribute('x1', x);
line.setAttribute('y1', height - 30);
line.setAttribute('x2', x);
line.setAttribute('y2', height - 25);
line.setAttribute('stroke', '#888');
axis.appendChild(line);
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'
);
text.setAttribute('x', x);
text.setAttribute('y', height - 10);
text.setAttribute('text-anchor', 'middle');
text.setAttribute('class', 'tick-label' + (rotate ? ' rotated' : ''));
if (rotate) text.setAttribute('transform', `rotate(-45 ${x} ${height - 10}
)`);
text.textContent = fmt(new Date(t), lu);
axis.appendChild(text);
});
svg.appendChild(axis);
svg.appendChild(yAxis);
const helper = document.createElement('div');
helper.className = 'drill-links';
const heading = document.createElement('h4');
helper.appendChild(heading);
if ((groupBy.chips || []).length) {
heading.textContent = 'Drill up';
const link = document.createElement('a');
link.href = '#';
link.textContent = 'Aggregate';
link.addEventListener('click', e => {
e.preventDefault();
groupBy.chips = [];
groupBy.renderChips();
dive();
});
helper.appendChild(link);
} else {
heading.textContent = 'Group by';
(allColumns || []).forEach(col => {
const link = document.createElement('a');
link.href = '#';
link.textContent = col;
link.addEventListener('click', e => {
e.preventDefault();
groupBy.addChip(col);
groupBy.renderChips();
dive();
});
helper.appendChild(link);
});
}
legend.appendChild(helper);
}
render();
function hideCrosshair() {
if (currentChart.frozen) return;
crosshairLine.style.display = 'none';
crosshairDots.style.display = 'none';
crosshairDots.innerHTML = '';
Object.values(currentChart.seriesEls).forEach(el => {
el.valueEl.textContent = '';
});
if (currentChart.selected) {
currentChart.seriesEls[currentChart.selected].highlight(false);
currentChart.selected = null;
}
}
function updateCrosshair(e) {
const rect = svg.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const pixels = currentChart.bucketPixels;
if (!pixels.length) return;
let idx = 0;
let dist = Math.abs(pixels[0] - x);
for (let i = 1; i < pixels.length; i++) {
const d = Math.abs(pixels[i] - x);
if (d < dist) {
dist = d;
idx = i;
}
}
const bucket = currentChart.buckets[idx];
const xPix = pixels[idx];
crosshairLine.setAttribute('x1', xPix);
crosshairLine.setAttribute('x2', xPix);
crosshairLine.setAttribute('y1', currentChart.yScale(currentChart.maxY));
crosshairLine.setAttribute('y2', currentChart.yScale(currentChart.minY));
crosshairLine.style.display = 'block';
crosshairDots.style.display = 'block';
crosshairDots.innerHTML = '';
const options = [];
Object.keys(currentChart.series).forEach(key => {
const vals = currentChart.series[key];
let v = vals[bucket];
if (v === undefined && currentChart.fill !== '0') {
currentChart.seriesEls[key].valueEl.textContent = '';
return;
}
if (v === undefined) v = 0;
currentChart.seriesEls[key].valueEl.textContent = formatNumber(v);
const yPix = currentChart.yScale(v);
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle
');
dot.setAttribute('cx', xPix);
dot.setAttribute('cy', yPix);
dot.setAttribute('r', '3');
dot.setAttribute('fill', currentChart.seriesEls[key].color);
crosshairDots.appendChild(dot);
options.push({ key, y: yPix });
});
if (options.length) {
let best = options[0];
let bestDist = Math.abs(best.y - y);
for (let i = 1; i < options.length; i++) {
const d = Math.abs(options[i].y - y);
if (d < bestDist) {
best = options[i];
bestDist = d;
}
}
if (currentChart.selected && currentChart.selected !== best.key) {
currentChart.seriesEls[currentChart.selected].highlight(false);
}
currentChart.seriesEls[best.key].highlight(true);
currentChart.selected = best.key;
}
}
svg.addEventListener('mouseleave', hideCrosshair);
svg.addEventListener('mousemove', e => {
if (currentChart.frozen) return;
updateCrosshair(e);
});
svg.addEventListener('click', e => {
if (currentChart.frozen) {
currentChart.frozen = false;
hideCrosshair();
} else {
updateCrosshair(e);
currentChart.frozen = true;
}
});
if (resizeObserver) resizeObserver.disconnect();
resizeObserver = new ResizeObserver(render);
resizeObserver.observe(svg.parentElement);
}
Yup, the x-axis labels should be adjusted to avoid truncation—let's increase the bottom margin and tweak the margins as needed.
root@4682ac416816:/workspace/scubaduck# grep -n rotated -n scubaduck/static/js -
r
scubaduck/static/js/timeseries_chart.js:418: text.setAttribute('class', 'ti
ck-label' + (rotate ? ' rotated' : ''));
I’ll open index.html to check out the CSS and see what’s going on.
root@4682ac416816:/workspace/scubaduck# sed -n '1,240p' scubaduck/static/index.h
tml
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>ScubaDuck</title>
<style>
body { margin: 0; font-family: sans-serif; height: 100vh; display: flex; fle
x-direction: column; }
#header { padding: 10px; font-weight: bold; border-bottom: 1px solid #ccc; }
#content { flex: 1; display: flex; height: calc(100vh - 42px); overflow: hid
den; }
#sidebar { width: 450px; padding: 10px; border-right: 3px solid #ccc; overfl
ow-y: auto; display: flex; flex-direction: column; box-sizing: border-box; }
#sidebar-resizer { width: 5px; cursor: col-resize; background: #ccc; }
#view { flex: 1; padding: 10px; overflow-y: auto; overflow-x: auto; }
.field { display: flex; align-items: center; margin-bottom: 10px; }
.field label { width: 80px; text-align: right; margin-right: 5px; }
.help { margin-left: 4px; cursor: help; }
.rel-btn { margin-left: 4px; }
#tabs { display: flex; align-items: center; margin-bottom: 10px; }
#tabs .tab { margin-right: 5px; background: none; border: 1px solid #ccc; pa
dding: 4px 8px; cursor: pointer; width: 120px; text-align: center; box-sizing: b
order-box; }
#tabs .tab.active { background: #eee; font-weight: bold; }
#dive { margin-left: auto; background: green; color: white; border: none; pa
dding: 5px 10px; cursor: pointer; }
.tab-content { display: none; }
.tab-content.active { display: block; }
#filter_list { display: flex; flex-direction: column; }
#filters .filter {
border: 1px solid #ccc;
padding: 5px;
margin-bottom: 5px;
position: relative;
display: flex;
flex-direction: column;
}
#derived_columns .derived {
border: 1px solid #ccc;
padding: 5px;
margin-bottom: 5px;
display: flex;
flex-direction: column;
}
#derived_columns .derived-row {
display: flex;
margin-bottom: 5px;
}
#derived_columns .derived-row input[type="text"] {
margin-left: 5px;
flex: 1;
}
#derived_columns .derived-row button.remove {
margin-left: 5px;
width: 20px;
flex: 0 0 auto;
padding: 0;
text-align: center;
line-height: 1;
}
#derived_columns textarea {
width: 100%;
box-sizing: border-box;
}
#filters .filter-row { display: flex; margin-bottom: 5px; }
#filters .filter-row .f-col { flex: 1; }
#filters .filter-row .f-op {
margin-left: 5px;
width: fit-content;
flex: 0 0 auto;
}
.chip-input input {
border: none;
flex: 1;
min-width: 60px;
margin: 2px;
outline: none;
}
.chip-box { position: relative; }
.chip-input { display: flex; flex-wrap: wrap; border: 1px solid #ccc; paddin
g: 2px; min-height: 24px; }
.chip { background: #eee; border: 1px solid #999; padding: 2px 4px; margin:
2px; border-radius: 3px; display: flex; align-items: center; }
.chip .x { margin-left: 4px; cursor: pointer; }
.chip-copy { margin-left: 4px; cursor: pointer; background: none; border: no
ne; }
.chip-dropdown { position: absolute; left: 0; right: 0; top: 100%; backgroun
d: white; border: 1px solid #ccc; max-height: 120px; overflow-y: auto; z-index:
10; display: none; }
.chip-dropdown div { padding: 2px 4px; cursor: pointer; }
.chip-dropdown div.highlight { background: #bde4ff; }
.rel-box { position: relative; display: flex; }
.rel-dropdown { position: absolute; left: 0; right: 0; top: 100%; background
: white; border: 1px solid #ccc; z-index: 10; display: none; }
.rel-dropdown div { padding: 2px 4px; cursor: pointer; }
.rel-dropdown div:hover { background: #bde4ff; }
.dropdown { position: relative; display: inline-block; }
.dropdown-display {
border: 1px solid #ccc;
padding: 2px 18px 2px 4px;
cursor: pointer;
min-width: 80px;
position: relative;
}
.dropdown-display::after {
content: '\25BC';
position: absolute;
right: 4px;
pointer-events: none;
}
.dropdown-menu { position: absolute; left: 0; right: 0; top: 100%; backgroun
d: white; border: 1px solid #ccc; z-index: 10; max-height: 160px; overflow-y: au
to; display: none; }
.dropdown-menu input { width: 100%; box-sizing: border-box; padding: 2px 4px
; border: none; border-bottom: 1px solid #ccc; }
.dropdown-menu div { padding: 2px 4px; cursor: pointer; }
.dropdown-menu div.selected { background: #bde4ff; }
.dropdown-menu .option:hover { background: #eee; }
.dropdown-menu input::placeholder { color: #999; }
#filters .filter button.remove {
margin-left: 5px;
width: 20px;
...
margin-bottom: 5px;
}
#column_actions a {
margin-left: 5px;
}
.col-group-header {
overflow: hidden;
}
.col-group-header .links {
float: right;
}
.col-group-header .links a {
margin-left: 5px;
}
#ts-container {
display: flex;
}
#legend {
width: 150px;
flex: 0 0 150px;
margin-right: 10px;
overflow-y: auto;
}
.legend-group {
margin-bottom: 4px;
}
.legend-header {
font-weight: normal;
}
.legend-item {
display: flex;
justify-content: space-between;
padding-left: 8px;
}
.legend-value {
margin-left: 4px;
}
#chart-wrapper {
flex: 1;
}
.legend-item.highlight {
background: #ddd;
}
#legend .drill-links h4 {
margin: 10px 0 4px 0;
}
#legend .drill-links a {
display: block;
margin-left: 8px;
}
#chart text.tick-label {
font-size: 10px;
user-select: none;
}
#chart text.tick-label.rotated {
text-anchor: end;
}
#chart text.y-tick-label {
font-size: 10px;
user-select: none;
}
#chart line.grid {
stroke: #ccc;
}
/* Column resizer removed */
</style>
</head>
<body>
<div id="header">sample.csv - <select id="table"></select> <select id="graph_t
ype"><option value="samples">Samples</option><option value="table">Table</option
><option value="timeseries">Time Series</option></select></div>
<div id="content">
<div id="sidebar">
<div id="tabs">
<button class="tab active" data-tab="settings">View Settings</button>
<button id="columns_tab" class="tab" data-tab="columns">Columns</button>
<button id="dive" onclick="dive()">Dive</button>
</div>
<div id="settings" class="tab-content active">
<div class="field">
<label>Time Column</label>
<select id="time_column"></select>
<select id="time_unit" style="margin-left:4px">
<option value="s">s</option>
<option value="ms">ms</option>
<option value="us">us</option>
<option value="ns">ns</option>
</select>
</div>
<div class="field">
<label>Start<span class="help" title="Sets the start/end of the time r
ange to query. Can be any kind of datetime string. For example: 'April 23, 2014'
or 'yesterday'.">[?]</span></label>
<div class="rel-box">
<input id="start" type="text" />
<button type="button" class="rel-btn" data-target="start-select">	
660;</button>
<div id="start-select" class="rel-dropdown" data-input="start">
<div data-value="-1 hour">-1 hour</div>
<div data-value="-3 hours">-3 hours</div>
<div data-value="-12 hours">-12 hours</div>
<div data-value="-1 day">-1 day</div>
<div data-value="-3 days">-3 days</div>
<div data-value="-1 week">-1 week</div>
<div data-value="-1 fortnight">-1 fortnight</div>
<div data-value="-30 days">-30 days</div>
<div data-value="-90 days">-90 days</div>
</div>
</div>
</div>
<div class="field">
<label>End<span class="help" title="Sets the start/end of the time ran
ge to query. Can be any kind of datetime string. For example: 'April 23, 2014' o
r 'yesterday'.">[?]</span></label>
<div class="rel-box">
<input id="end" type="text" />
<button type="button" class="rel-btn" data-target="end-select">φ
0;</button>
<div id="end-select" class="rel-dropdown" data-input="end">
<div data-value="now">now</div>
<div data-value="-1 hour">-1 hour</div>
<div data-value="-3 hours">-3 hours</div>
<div data-value="-12 hours">-12 hours</div>
<div data-value="-1 day">-1 day</div>
root@4682ac416816:/workspace/scubaduck# grep -n 'tick-label' -n scubaduck/static
/index.html
175: #chart text.tick-label {
179: #chart text.tick-label.rotated {
182: #chart text.y-tick-label {
I’ll focus on the lines around 175.
root@4682ac416816:/workspace/scubaduck# sed -n '170,190p' scubaduck/static/index
.html
}
#legend .drill-links a {
display: block;
margin-left: 8px;
}
#chart text.tick-label {
font-size: 10px;
user-select: none;
}
#chart text.tick-label.rotated {
text-anchor: end;
}
#chart text.y-tick-label {
font-size: 10px;
user-select: none;
}
#chart line.grid {
stroke: #ccc;
}
/* Column resizer removed */
</style>
I need to increase the bottom margin or adjust the y-coordinate for rotated ticks.
To prevent cropping, I’ll adjust the y-coordinate of rotated tick labels by 5-10px.
I’ll set baseline to height - 15 or 20 and shift axis to avoid cropping.
To avoid cropping, I’ll increase axis bottom margin and adjust label positions accordingly.
I'll shift label y coordinate to height - 15 or 20 when rotated to avoid truncation.
I’ll set y = height - 15 to provide more margin for rotated labels.
I’ll set label y = height - 15 for rotated labels and check the test files.
root@4682ac416816:/workspace/scubaduck# ls tests
conftest.py test_server_time.py test_web_graph.py
test_multi_table_web.py test_server_timeseries.py test_web_table.py
test_server.py test_web.py test_web_timeseries.py
test_server_basic.py test_web_basics.py web_utils.py
test_server_db_types.py test_web_chips.py
test_server_errors.py test_web_columns.py
I'll check test_web_timeseries for existing tests related to timeseries chart features.
from __future__ import annotations
from typing import Any
from tests.web_utils import select_value
def test_timeseries_default_query(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
data = page.evaluate("window.lastResults")
assert "error" not in data
assert page.is_visible("#chart")
page.click("text=Columns")
assert not page.is_checked("#column_groups input[value='timestamp']")
def test_timeseries_single_bucket(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-01 00:00:00")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
path = page.get_attribute("#chart path", "d")
assert path is not None and "NaN" not in path
def test_timeseries_fill_options(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-02 03:00:00")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
select_value(page, "#granularity", "1 hour")
select_value(page, "#fill", "0")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
path_zero = page.get_attribute("#chart path", "d")
assert path_zero is not None and path_zero.count("L") > 20
select_value(page, "#fill", "connect")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
path_conn = page.get_attribute("#chart path", "d")
assert path_conn is not None and path_conn.count("M") == 1
select_value(page, "#fill", "blank")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
path_blank = page.get_attribute("#chart path", "d")
assert path_blank is not None and path_blank.count("M") > 1
def test_timeseries_hover_highlight(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
page.wait_for_selector("#chart path", state="attached")
path_el = page.query_selector("#chart path")
assert path_el
page.evaluate(
"el => el.dispatchEvent(new MouseEvent('mouseenter', {bubbles: true}))",
path_el,
)
width = page.evaluate(
"getComputedStyle(document.querySelector('#chart path')).strokeWidth"
)
assert "2.5" in width
color = page.evaluate(
"getComputedStyle(document.querySelector('#legend .legend-item')).backgr
oundColor"
)
assert "221, 221, 221" in color
def test_timeseries_crosshair(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
page.wait_for_selector("#chart path", state="attached")
page.eval_on_selector(
"#chart",
"el => { const r = el.getBoundingClientRect(); el.dispatchEvent(new Mous
eEvent('mousemove', {clientX: r.left + r.width/2, clientY: r.top + r.height/2, b
ubbles: true})); }",
)
line_display = page.evaluate(
"document.getElementById('crosshair_line').style.display"
)
assert line_display != "none"
count = page.eval_on_selector_all("#crosshair_dots circle", "els => els.leng
th")
assert count > 0
page.eval_on_selector(
"#chart",
"el => el.dispatchEvent(new MouseEvent('mouseleave', {bubbles: true}))",
)
line_display = page.evaluate(
"document.getElementById('crosshair_line').style.display"
)
assert line_display == "none"
def test_timeseries_crosshair_freeze(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
page.wait_for_selector("#chart path", state="attached")
page.eval_on_selector(
"#chart",
"el => { const r = el.getBoundingClientRect(); el.dispatchEvent(new Mous
eEvent('mousemove', {clientX: r.left + r.width/2, clientY: r.top + r.height/2, b
ubbles: true})); }",
)
page.eval_on_selector(
"#chart",
"el => { const r = el.getBoundingClientRect(); el.dispatchEvent(new Mous
eEvent('click', {clientX: r.left + r.width/2, clientY: r.top + r.height/2, bubbl
es: true})); }",
)
line_display = page.evaluate(
"document.getElementById('crosshair_line').style.display"
)
assert line_display != "none"
pos1 = page.evaluate("document.getElementById('crosshair_line').getAttribute
('x1')")
page.eval_on_selector(
"#chart",
"el => { const r = el.getBoundingClientRect(); el.dispatchEvent(new Mous
eEvent('mousemove', {clientX: r.left + r.width/4, clientY: r.top + r.height/2, b
ubbles: true})); }",
)
pos2 = page.evaluate("document.getElementById('crosshair_line').getAttribute
('x1')")
assert pos1 == pos2
page.eval_on_selector(
"#chart",
"el => el.dispatchEvent(new MouseEvent('mouseleave', {bubbles: true}))",
)
line_display = page.evaluate(
"document.getElementById('crosshair_line').style.display"
)
assert line_display != "none"
page.eval_on_selector(
"#chart",
"el => { const r = el.getBoundingClientRect(); el.dispatchEvent(new Mous
eEvent('click', {clientX: r.left + r.width/2, clientY: r.top + r.height/2, bubbl
es: true})); }",
)
line_display = page.evaluate(
"document.getElementById('crosshair_line').style.display"
)
assert line_display == "none"
def test_timeseries_auto_timezone(browser: Any, server_url: str) -> None:
context = browser.new_context(timezone_id="America/New_York")
page = context.new_page()
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
path = page.get_attribute("#chart path", "d")
context.close()
assert path is not None
coords = [float(p.split(" ")[1]) for p in path.replace("M", "L").split("L")[
1:]]
assert max(coords) > min(coords)
def test_timeseries_multi_series(page: Any, server_url: str) -> None:
page.goto(server_url)
I'm continuing to scroll to capture the remaining details I need.
expr = page.query_selector("#derived_list .derived textarea")
assert expr
name_inp = page.query_selector("#derived_list .derived .d-name")
assert name_inp
name_inp.fill("value_2")
expr.fill("value * 2")
page.click("text=View Settings")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-03 00:00:00")
select_value(page, "#granularity", "1 hour")
select_value(page, "#aggregate", "Avg")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
count = page.eval_on_selector_all("#chart path", "els => els.length")
assert count == 2
def test_timeseries_resize(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
page.wait_for_selector("#chart path", state="attached")
def chart_info() -> dict[str, float]:
return page.evaluate(
"() => {const p=document.querySelector('#chart path'); const nums=p.
getAttribute('d').match(/[-0-9.]+/g).map(parseFloat); return {width: parseFloat(
document.getElementById('chart').getAttribute('width')), last: nums[nums.length-
2]};}"
)
before = chart_info()
legend_width = page.evaluate(
"parseFloat(getComputedStyle(document.getElementById('legend')).width)"
)
assert page.evaluate(
"() => document.getElementById('legend').getBoundingClientRect().right <
= document.getElementById('chart').getBoundingClientRect().left"
)
page.evaluate("document.getElementById('sidebar').style.width='200px'")
page.wait_for_function(
"width => document.getElementById('chart').getAttribute('width') != widt
h",
arg=before["width"],
)
after = chart_info()
legend_width_after = page.evaluate(
"parseFloat(getComputedStyle(document.getElementById('legend')).width)"
)
assert after["width"] > before["width"]
assert after["last"] > before["last"]
assert legend_width_after == legend_width
def test_timeseries_no_overflow(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
overflow = page.evaluate(
"var v=document.getElementById('view'); v.scrollWidth > v.clientWidth"
)
assert not overflow
def test_timeseries_axis_ticks(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
page.wait_for_selector("#chart text.tick-label", state="attached")
count = page.eval_on_selector_all("#chart text.tick-label", "els => els.leng
th")
assert count > 2
def test_timeseries_y_axis_labels(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
page.wait_for_selector("#chart text.y-tick-label", state="attached")
count = page.eval_on_selector_all("#chart text.y-tick-label", "els => els.le
ngth")
grid_count = page.eval_on_selector_all("#chart line.grid", "els => els.lengt
h")
assert count > 0 and count == grid_count
def test_timeseries_interval_offset(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-03 12:00:00")
select_value(page, "#granularity", "1 hour")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
page.wait_for_selector("#chart text.tick-label", state="attached")
labels = page.eval_on_selector_all(
"#chart text.tick-label", "els => els.map(e => e.textContent)"
)
assert labels
assert all(lbl != "00:00" for lbl in labels)
times = [lbl for lbl in labels if ":" in lbl]
assert times
for t in times:
h = int(t.split(":")[0])
assert h % 4 == 0
def test_timeseries_legend_values(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("g => { groupBy.chips = g; groupBy.renderChips(); }", ["user"]
)
select_value(page, "#aggregate", "Avg")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
headers = page.evaluate(
"() => Array.from(document.querySelectorAll('#legend .legend-header')).m
ap(e => e.textContent)"
)
assert any(h.startswith("alice") for h in headers)
page.wait_for_selector("#chart path", state="attached")
page.eval_on_selector(
"#chart",
"el => { const r=el.getBoundingClientRect(); el.dispatchEvent(new MouseE
vent('mousemove', {clientX:r.left+r.width/2, clientY:r.top+r.height/2, bubbles:t
rue})); }",
)
value = page.evaluate("document.querySelector('#legend .legend-value').textC
ontent")
assert value != ""
def test_timeseries_group_links(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-02 03:00:00")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
assert page.text_content("#legend .drill-links h4") == "Group by"
page.click("#legend .drill-links a:text('user')")
page.wait_for_function("window.lastResults !== undefined")
chips = page.evaluate("groupBy.chips")
assert chips == ["user"]
assert page.text_content("#legend .drill-links h4") == "Drill up"
assert page.is_visible("#legend .drill-links a:text('Aggregate')")
I'm checking further down to ensure I see all the tests.
root@4682ac416816:/workspace/scubaduck# sed -n '400,800p' tests/test_web_timeser
ies.py
The file looks done. I'll check if existing tests cover the truncation/fixing request.
root@4682ac416816:/workspace/scubaduck# grep -n "truncated" -r tests | head
I'll search for tests related to "rotated" to check if label truncation is verified.
I'll add a test to ensure chart labels don't overflow or get truncated, using bounding boxes.
root@4682ac416816:/workspace/scubaduck# grep -n "bounding" -r tests | head
I'll check out tests/test_web_graph.py to explore further.
from typing import Any
from tests.web_utils import select_value
def test_graph_type_table_fields(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "table")
assert page.is_visible("#group_by_field")
assert page.is_visible("#aggregate_field")
assert page.is_visible("#show_hits_field")
page.click("text=Columns")
assert not page.is_visible("text=Strings:")
def test_graph_type_timeseries_fields(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
assert page.is_visible("#group_by_field")
assert page.is_visible("#aggregate_field")
assert page.is_visible("#x_axis_field")
assert page.is_visible("#granularity_field")
assert page.is_visible("#fill_field")
def test_limit_persists_per_chart_type(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
assert page.input_value("#limit") == "100"
select_value(page, "#graph_type", "timeseries")
assert page.input_value("#limit") == "7"
select_value(page, "#graph_type", "samples")
assert page.input_value("#limit") == "100"
def test_columns_persist_per_chart_type(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
page.click("text=Columns")
page.wait_for_selector("#column_groups input", state="attached")
page.uncheck("#column_groups input[value='value']")
select_value(page, "#graph_type", "timeseries")
count = page.evaluate(
"document.querySelectorAll('#column_groups input:checked').length"
)
assert count == 0
select_value(page, "#graph_type", "samples")
count = page.evaluate(
"document.querySelectorAll('#column_groups input:checked').length"
)
assert count == 3
I need to configure the date range and interval to trigger 45° rotation for labels.
I'll confirm data availability for 2024 by inspecting the first lines of sample.csv.
root@4682ac416816:/workspace/scubaduck# head scubaduck/sample.csv
timestamp,event,value,user
2024-01-01 00:00:00,login,10,alice
2024-01-01 01:00:00,logout,20,bob
2024-01-02 00:00:00,login,30,alice
2024-01-02 03:00:00,login,40,charlie
I’m expanding the view to include more lines for better context.
timestamp,event,value,user
2024-01-01 00:00:00,login,10,alice
2024-01-01 01:00:00,logout,20,bob
2024-01-02 00:00:00,login,30,alice
2024-01-02 03:00:00,login,40,charlie
I think the file might be smaller than expected—let’s open the whole thing.
root@4682ac416816:/workspace/scubaduck# wc -l scubaduck/sample.csv
5 scubaduck/sample.csv
I see only 4 rows for the timeseries, so I'll check server.py for generation details.
from datetime import datetime, timedelta, timezone
import time
from pathlib import Path
import os
import traceback
import math
import duckdb
from dateutil import parser as dtparser
from dateutil.relativedelta import relativedelta
from flask import Flask, jsonify, request, send_from_directory
def _quote(ident: str) -> str:
"""Return identifier quoted for SQL."""
return f'"{ident.replace('"', '""')}"'
@dataclass
class Filter:
column: str
op: str
value: str | int | float | list[str] | None
@dataclass
class QueryParams:
start: str | None = None
end: str | None = None
order_by: str | None = None
order_dir: str = "ASC"
limit: int | None = None
columns: list[str] = field(default_factory=lambda: [])
filters: list[Filter] = field(default_factory=lambda: [])
derived_columns: dict[str, str] = field(default_factory=lambda: {})
graph_type: str = "samples"
group_by: list[str] = field(default_factory=lambda: [])
aggregate: str | None = None
show_hits: bool = False
x_axis: str | None = None
granularity: str = "Auto"
fill: str = "0"
table: str = "events"
time_column: str | None = "timestamp"
time_unit: str = "s"
def _load_database(path: Path) -> duckdb.DuckDBPyConnection:
if not path.exists():
raise FileNotFoundError(path)
ext = path.suffix.lower()
if ext == ".csv":
con = duckdb.connect()
con.execute(
f"CREATE TABLE events AS SELECT * FROM read_csv_auto('{path.as_posix
()}')"
)
elif ext in {".db", ".sqlite"}:
con = duckdb.connect()
con.execute("LOAD sqlite")
con.execute(f"ATTACH '{path.as_posix()}' AS db (TYPE SQLITE)")
tables = [
r[0]
for r in con.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
]
for t in tables:
con.execute(f'CREATE VIEW "{t}" AS SELECT * FROM db."{t}"')
else:
con = duckdb.connect(path)
return con
def _create_test_database() -> duckdb.DuckDBPyConnection:
"""Return a DuckDB connection with a small multi-table dataset."""
con = duckdb.connect()
con.execute(
"CREATE TABLE events (id INTEGER PRIMARY KEY, ts INTEGER, val REAL, name
TEXT, flag BOOLEAN)"
)
con.execute("INSERT INTO events VALUES (1, 1704067200, 1.5, 'alice', 1)")
con.execute("INSERT INTO events VALUES (2, 1704070800, 2.0, 'bob', 0)")
con.execute('CREATE TABLE extra (ts INTEGER, "desc" TEXT, num INTEGER)')
con.execute("INSERT INTO extra VALUES (1704067200, 'x', 1)")
con.execute("INSERT INTO extra VALUES (1704070800, 'y', 2)")
return con
_REL_RE = re.compile(
r"([+-]?\d+(?:\.\d*)?)\s*(hour|hours|day|days|week|weeks|fortnight|fortnight
s|month|months|year|years)",
re.IGNORECASE,
)
def parse_time(val: str | None) -> str | None:
"""Parse an absolute or relative time string into ``YYYY-MM-DD HH:MM:SS``.""
"
if val is None or val == "":
return None
s = val.strip()
if s.lower() == "now":
dt = datetime.now(timezone.utc)
return dt.replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S")
m = _REL_RE.fullmatch(s)
if m:
qty = float(m.group(1))
unit = m.group(2).lower()
now = datetime.now(timezone.utc)
dt: datetime
if unit.startswith("hour"):
dt = now + timedelta(hours=qty)
elif unit.startswith("day"):
dt = now + timedelta(days=qty)
elif unit.startswith("week"):
dt = now + timedelta(weeks=qty)
elif unit.startswith("fortnight"):
dt = now + timedelta(weeks=2 * qty)
elif unit.startswith("month"):
if qty.is_integer():
dt = now + relativedelta(months=int(qty))
else:
dt = now + timedelta(days=30 * qty)
elif unit.startswith("year"):
if qty.is_integer():
dt = now + relativedelta(years=int(qty))
else:
dt = now + timedelta(days=365 * qty)
else: # pragma: no cover - defensive
raise ValueError(f"Unsupported unit: {unit}")
return dt.replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S")
dt = dtparser.parse(s)
return dt.replace(microsecond=0, tzinfo=None).strftime("%Y-%m-%d %H:%M:%S")
def _numeric_to_datetime(value: int | float, unit: str) -> datetime:
"""Convert a numeric timestamp ``value`` with unit ``unit`` to ``datetime``.
Heuristically fall back to seconds when the converted value is before 1990
but the seconds interpretation is in a reasonable range. This handles
integer columns stored in seconds even when ``unit`` is mistakenly set to a
finer granularity.
"""
divisor = {
"s": 1,
"ms": 1000,
"us": 1_000_000,
"ns": 1_000_000_000,
}.get(unit, 1)
dt = datetime.fromtimestamp(int(value) / divisor, tz=timezone.utc)
if unit != "s" and dt.year < 1990:
I'm continuing to scroll to check around the handle_timeseries
section.
.py
252: if params.graph_type == "timeseries":
256: raise ValueError("x_axis required for timeseries")
274: group_cols[1:] if params.graph_type == "timeseries" else group_c
ols
339: elif params.graph_type == "timeseries":
393: elif params.graph_type == "timeseries":
535: if params.graph_type not in {"table", "timeseries"} and (
541: "error": "group_by, aggregate and show_hits are only
valid for table or timeseries view"
550: if params.graph_type == "timeseries":
580: if params.group_by or params.graph_type == "timeseries":
681: if params.graph_type == "timeseries":
root@4682ac416816:/workspace/scubaduck# sed -n '252,312p' scubaduck/server.py
if params.graph_type == "timeseries":
sec = _granularity_seconds(params.granularity, params.start, params.end)
x_axis = params.x_axis or params.time_column
if x_axis is None:
raise ValueError("x_axis required for timeseries")
xexpr = _time_expr(x_axis, column_types, params.time_unit)
if params.start:
bucket_expr = (
f"TIMESTAMP '{params.start}' + INTERVAL '{sec} second' * "
f"CAST(floor((epoch({xexpr}) - epoch(TIMESTAMP '{params.start}')
)/{sec}) AS BIGINT)"
)
else:
bucket_expr = (
f"TIMESTAMP 'epoch' + INTERVAL '{sec} second' * "
f"CAST(floor(epoch({xexpr})/{sec}) AS BIGINT)"
)
select_parts.append(f"{bucket_expr} AS bucket")
group_cols = ["bucket"] + group_cols
selected_for_order.add("bucket")
has_agg = bool(group_cols) or params.aggregate is not None
if has_agg:
select_cols = (
group_cols[1:] if params.graph_type == "timeseries" else group_cols
)
select_parts.extend(_quote(c) for c in select_cols)
agg = (params.aggregate or "count").lower()
selected_for_order.update(group_cols)
def agg_expr(col: str) -> str:
expr = _quote(col)
ctype = column_types.get(col, "").upper() if column_types else ""
if "BOOL" in ctype:
expr = f"CAST({_quote(col)} AS BIGINT)"
if agg.startswith("p"):
quant = float(agg[1:]) / 100
return f"quantile({expr}, {quant})"
if agg == "count distinct":
return f"count(DISTINCT {expr})"
if agg == "avg" and column_types is not None:
if "TIMESTAMP" in ctype or "DATE" in ctype or "TIME" in ctype:
return (
"TIMESTAMP 'epoch' + INTERVAL '1 second' * "
f"CAST(avg(epoch({_quote(col)})) AS BIGINT)"
)
return f"{agg}({expr})"
if agg == "count":
if params.graph_type != "table":
select_parts.append("count(*) AS Count")
selected_for_order.add("Count")
else:
for col in params.columns:
if col in group_cols:
continue
select_parts.append(f"{agg_expr(col)} AS {_quote(col)}")
selected_for_order.add(col)
select_parts.insert(len(group_cols), "count(*) AS Hits")
selected_for_order.add("Hits")
else:
select_parts.extend(_quote(c) for c in params.columns)
selected_for_order.update(params.columns)
I'll scroll through to inspect the test_server_timeseries
dataset.
from scubaduck import server
def test_group_by_table() -> None:
app = server.app
client = app.test_client()
payload: dict[str, Any] = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "table",
"order_by": "user",
"limit": 10,
"columns": ["value"],
"group_by": ["user"],
"aggregate": "Sum",
"show_hits": True,
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
rows = rv.get_json()["rows"]
assert rows[0][0] == "alice"
assert rows[0][1] == 2
assert rows[0][2] == 40
def test_table_avg_with_timestamp() -> None:
app = server.app
client = app.test_client()
payload: dict[str, Any] = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "table",
"order_by": "user",
"limit": 100,
"columns": ["user", "timestamp", "value"],
"group_by": ["user"],
"aggregate": "Avg",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert "error" not in data
rows = data["rows"]
assert rows[0][0] == "alice"
from dateutil import parser
ts = parser.parse(rows[0][2]).replace(tzinfo=None)
assert ts == parser.parse("2024-01-01 12:00:00")
def test_timeseries_basic() -> None:
app = server.app
client = app.test_client()
payload: dict[str, Any] = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "timeseries",
"limit": 100,
"group_by": ["user"],
"aggregate": "Count",
"columns": ["value"],
"x_axis": "timestamp",
"granularity": "1 day",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert len(data["rows"]) == 4
def test_timeseries_orders_by_xaxis() -> None:
app = server.app
client = app.test_client()
payload: dict[str, Any] = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "timeseries",
"limit": 100,
"columns": ["value"],
"x_axis": "timestamp",
"granularity": "1 day",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
rows = data["rows"]
from dateutil import parser
timestamps = [parser.parse(r[0]).replace(tzinfo=None) for r in rows]
assert timestamps == sorted(timestamps)
def test_timeseries_count_no_columns() -> None:
app = server.app
client = app.test_client()
payload: dict[str, Any] = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "timeseries",
"granularity": "1 day",
"columns": [],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
rows = data["rows"]
assert len(rows) == 2
assert rows[0][1] == 2
assert rows[1][1] == 2
assert len(rows[0]) == 3
def test_timeseries_limit_applies_to_series() -> None:
app = server.app
client = app.test_client()
payload: dict[str, Any] = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "timeseries",
"limit": 1,
"order_by": "user",
"group_by": ["user"],
"aggregate": "Count",
"columns": ["value"],
"x_axis": "timestamp",
"granularity": "1 day",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert len(data["rows"]) == 2
assert all(r[1] == "alice" for r in data["rows"])
def test_timeseries_auto_and_fine_buckets() -> None:
app = server.app
client = app.test_client()
"graph_type": "timeseries",
"columns": ["value"],
"x_axis": "timestamp",
"granularity": gran,
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/js
on"
)
data = rv.get_json()
assert rv.status_code == 200
from dateutil import parser
start = parser.parse(cast(str, payload["start"])).replace(tzinfo=None)
buckets = [
parser.parse(cast(str, r[0])).replace(tzinfo=None) for r in data["ro
ws"]
]
assert buckets[0] == start
if len(buckets) > 1:
step = (buckets[1] - buckets[0]).total_seconds()
assert step % data["bucket_size"] == 0
assert any(r[1] != 0 for r in data["rows"])
run("Auto")
run("Fine")
def test_timeseries_string_column_error() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "timeseries",
"limit": 7,
"columns": ["timestamp", "event", "value", "user"],
"x_axis": "timestamp",
"granularity": "1 hour",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
assert rv.status_code == 200
def test_derived_column_basic() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"limit": 10,
"columns": ["timestamp"],
"derived_columns": {"val2": "value * 2"},
"filters": [],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert data["rows"][0][1] == 20
def test_timeseries_derived_column() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "timeseries",
"granularity": "1 hour",
"limit": 7,
"columns": ["value"],
"derived_columns": {"derived_1": "value * 2"},
"aggregate": "Avg",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
rows = data["rows"]
assert all(r[3] == r[2] * 2 for r in rows)
def test_reserved_word_column() -> None:
app = server.create_app("TEST")
client = app.test_client()
payload = {
"table": "extra",
"columns": ["ts", "desc"],
"order_by": "ts",
"time_column": "",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert len(data["rows"]) == 2
assert data["rows"][0][1] == "x"
def test_table_count_group_by_extra() -> None:
app = server.create_app("TEST")
client = app.test_client()
payload = {
"table": "extra",
"graph_type": "table",
"time_column": "",
"group_by": ["num"],
"aggregate": "Count",
"columns": [],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
rows = sorted(data["rows"])
assert rows == [[1, 1], [2, 1]]
assert all(len(r) == 2 for r in rows)
def test_order_by_samples_table() -> None:
app = server.app
client = app.test_client()
payload: dict[str, Any] = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "table",
"order_by": "Samples",
"order_dir": "DESC",
"limit": 10,
"columns": [],
"group_by": ["user"],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert 'ORDER BY "Hits" DESC' in data["sql"]
def test_order_by_samples_timeseries() -> None:
app = server.app
client = app.test_client()
payload: dict[str, Any] = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
I’m just paging down to finish checking the rest.
series.py
"end": "2024-01-03 00:00:00",
"graph_type": "timeseries",
"order_by": "Samples",
"order_dir": "DESC",
"x_axis": "timestamp",
"granularity": "1 day",
"columns": [],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert 'ORDER BY "Hits" DESC' in data["sql"]
def test_show_hits_client_side() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "table",
"columns": ["value"],
"group_by": ["user"],
"aggregate": "Sum",
"show_hits": False,
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert "count(*) AS Hits" in data["sql"]
The dataset ends on 2024-01-02, but a 31-day range gives us 15 ticks.
I'll adjust the test to check that rotated labels are fully visible and not cut off.
uv sync --frozen
source .venv/bin/activate
python -c "import os; import duckdb; con = duckdb.connect(); con.execute(f\"SET
http_proxy = '{os.getenv(\"HTTP_PROXY\")}'\"); con.execute(\"INSTALL 'sqlite';\"
)"
playwright install chromium
echo "source .venv/bin/activate" >> ~/.bashrc
I’ll take a peek at the latest commit to verify the message style.
root@4682ac416816:/workspace/scubaduck# git log -1 --stat
commit a8074f6ccca3f3c7a48bdc2aa762ea90177b1b87 (HEAD -> work, main)
Author: Edward Z. Yang <ezyang@mit.edu>
Date: Thu May 22 22:28:01 2025 -0400
fix(server): drop duplicate count column in table view (#147)
scubaduck/server.py | 5 +++--
tests/test_server_errors.py | 2 +-
tests/test_server_timeseries.py | 21 +++++++++++++++++++++
3 files changed, 25 insertions(+), 3 deletions(-)
I need to review the commit message style and confirm the ruff config in pyproject.toml
.
root@4682ac416816:/workspace/scubaduck# cat pyproject.toml
[project]
name = "scubaduck"
version = "0.1.0"
description = "Scuba on DuckDB"
readme = "README.md"
requires-python = ">=3.12"
license = {text = "MIT"}
dependencies = [
"duckdb",
"flask",
"python-dateutil>=2.9.0.post0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
addopts = "-n auto --tb=native"
[tool.pyright]
# Pyright configuration with strict settings
include = ["scubaduck", "tests"]
exclude = ["**/__pycache__", "dist"]
stubPath = "stubs"
venvPath = "."
venv = ".venv"
reportMissingImports = true
reportMissingTypeStubs = true
pythonVersion = "3.12"
pythonPlatform = "All"
typeCheckingMode = "strict"
reportUnknownMemberType = true
reportUnknownParameterType = true
reportUnknownVariableType = true
reportUnknownArgumentType = true
reportPrivateImportUsage = true
reportUntypedFunctionDecorator = true
reportFunctionMemberAccess = true
reportIncompatibleMethodOverride = true
[tool.ruff]
target-version = "py312"
[tool.uv]
dev-dependencies = [
"pytest>=8.3.5",
"ruff>=0.11.2",
"pytest-xdist>=3.6.1",
"playwright>=1.52.0",
"pyright>=1.1.350",
"pytest-playwright>=0.7.0",
]
I’ll add the test right after test_timeseries_no_overflow
, checking lines 136-170 for context.
)
line_display = page.evaluate(
"document.getElementById('crosshair_line').style.display"
)
assert line_display != "none"
pos1 = page.evaluate("document.getElementById('crosshair_line').getAttribute
('x1')")
page.eval_on_selector(
"#chart",
"el => { const r = el.getBoundingClientRect(); el.dispatchEvent(new Mous
eEvent('mousemove', {clientX: r.left + r.width/4, clientY: r.top + r.height/2, b
ubbles: true})); }",
)
pos2 = page.evaluate("document.getElementById('crosshair_line').getAttribute
('x1')")
assert pos1 == pos2
page.eval_on_selector(
"#chart",
"el => el.dispatchEvent(new MouseEvent('mouseleave', {bubbles: true}))",
)
line_display = page.evaluate(
"document.getElementById('crosshair_line').style.display"
)
assert line_display != "none"
page.eval_on_selector(
"#chart",
"el => { const r = el.getBoundingClientRect(); el.dispatchEvent(new Mous
eEvent('click', {clientX: r.left + r.width/2, clientY: r.top + r.height/2, bubbl
es: true})); }",
)
line_display = page.evaluate(
"document.getElementById('crosshair_line').style.display"
)
assert line_display == "none"
def test_timeseries_auto_timezone(browser: Any, server_url: str) -> None:
context = browser.new_context(timezone_id="America/New_York")
page = context.new_page()
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
path = page.get_attribute("#chart path", "d")
context.close()
assert path is not None
coords = [float(p.split(" ")[1]) for p in path.replace("M", "L").split("L")[
1:]]
assert max(coords) > min(coords)
def test_timeseries_multi_series(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=Add Derived")
expr = page.query_selector("#derived_list .derived textarea")
assert expr
name_inp = page.query_selector("#derived_list .derived .d-name")
assert name_inp
name_inp.fill("value_2")
expr.fill("value * 2")
page.click("text=View Settings")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-03 00:00:00")
select_value(page, "#granularity", "1 hour")
select_value(page, "#aggregate", "Avg")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
count = page.eval_on_selector_all("#chart path", "els => els.length")
assert count == 2
def test_timeseries_resize(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
I’m scrolling further to find the right spot to insert the new test.
page.wait_for_selector("#chart path", state="attached")
def chart_info() -> dict[str, float]:
return page.evaluate(
"() => {const p=document.querySelector('#chart path'); const nums=p.
getAttribute('d').match(/[-0-9.]+/g).map(parseFloat); return {width: parseFloat(
document.getElementById('chart').getAttribute('width')), last: nums[nums.length-
2]};}"
)
before = chart_info()
legend_width = page.evaluate(
"parseFloat(getComputedStyle(document.getElementById('legend')).width)"
)
assert page.evaluate(
"() => document.getElementById('legend').getBoundingClientRect().right <
= document.getElementById('chart').getBoundingClientRect().left"
)
page.evaluate("document.getElementById('sidebar').style.width='200px'")
page.wait_for_function(
"width => document.getElementById('chart').getAttribute('width') != widt
h",
arg=before["width"],
)
after = chart_info()
legend_width_after = page.evaluate(
"parseFloat(getComputedStyle(document.getElementById('legend')).width)"
)
assert after["width"] > before["width"]
assert after["last"] > before["last"]
assert legend_width_after == legend_width
def test_timeseries_no_overflow(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
overflow = page.evaluate(
"var v=document.getElementById('view'); v.scrollWidth > v.clientWidth"
)
assert not overflow
def test_timeseries_axis_ticks(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
page.wait_for_selector("#chart text.tick-label", state="attached")
count = page.eval_on_selector_all("#chart text.tick-label", "els => els.leng
th")
assert count > 2
def test_timeseries_y_axis_labels(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
page.wait_for_selector("#chart text.y-tick-label", state="attached")
count = page.eval_on_selector_all("#chart text.y-tick-label", "els => els.le
ngth")
grid_count = page.eval_on_selector_all("#chart line.grid", "els => els.lengt
h")
assert count > 0 and count == grid_count
def test_timeseries_interval_offset(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-03 12:00:00")
select_value(page, "#granularity", "1 hour")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
page.wait_for_selector("#chart text.tick-label", state="attached")
labels = page.eval_on_selector_all(
"#chart text.tick-label", "els => els.map(e => e.textContent)"
)
assert labels
assert all(lbl != "00:00" for lbl in labels)
times = [lbl for lbl in labels if ":" in lbl]
assert times
for t in times:
h = int(t.split(":")[0])
assert h % 4 == 0
def test_timeseries_legend_values(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("g => { groupBy.chips = g; groupBy.renderChips(); }", ["user"]
)
select_value(page, "#aggregate", "Avg")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
headers = page.evaluate(
"() => Array.from(document.querySelectorAll('#legend .legend-header')).m
ap(e => e.textContent)"
)
assert any(h.startswith("alice") for h in headers)
page.wait_for_selector("#chart path", state="attached")
page.eval_on_selector(
"#chart",
"el => { const r=el.getBoundingClientRect(); el.dispatchEvent(new MouseE
vent('mousemove', {clientX:r.left+r.width/2, clientY:r.top+r.height/2, bubbles:t
rue})); }",
)
value = page.evaluate("document.querySelector('#legend .legend-value').textC
ontent")
assert value != ""
def test_timeseries_group_links(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-02 03:00:00")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
assert page.text_content("#legend .drill-links h4") == "Group by"
page.click("#legend .drill-links a:text('user')")
page.wait_for_function("window.lastResults !== undefined")
chips = page.evaluate("groupBy.chips")
assert chips == ["user"]
assert page.text_content("#legend .drill-links h4") == "Drill up"
assert page.is_visible("#legend .drill-links a:text('Aggregate')")
I’m moving past the previous test results to review the next bits.
root@4682ac416816:/workspace/scubaduck# sed -n '400,800p' tests/test_web_timeser
ies.py
I’ll open the last 20 lines near group_links to find where to add the test.
def test_timeseries_group_links(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-02 03:00:00")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
assert page.text_content("#legend .drill-links h4") == "Group by"
page.click("#legend .drill-links a:text('user')")
page.wait_for_function("window.lastResults !== undefined")
chips = page.evaluate("groupBy.chips")
assert chips == ["user"]
assert page.text_content("#legend .drill-links h4") == "Drill up"
assert page.is_visible("#legend .drill-links a:text('Aggregate')")
root@4682ac416816:/workspace/scubaduck# grep -n "eval_on_selector(" -n tests/tes
t_web_timeseries.py | head
112: page.eval_on_selector(
122: page.eval_on_selector(
143: page.eval_on_selector(
147: page.eval_on_selector(
156: page.eval_on_selector(
162: page.eval_on_selector(
170: page.eval_on_selector(
352: page.eval_on_selector(
root@4682ac416816:/workspace/scubaduck# sed -n '344,368p' tests/test_web_timeser
ies.py
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
headers = page.evaluate(
"() => Array.from(document.querySelectorAll('#legend .legend-header')).m
ap(e => e.textContent)"
)
assert any(h.startswith("alice") for h in headers)
page.wait_for_selector("#chart path", state="attached")
page.eval_on_selector(
"#chart",
"el => { const r=el.getBoundingClientRect(); el.dispatchEvent(new MouseE
vent('mousemove', {clientX:r.left+r.width/2, clientY:r.top+r.height/2, bubbles:t
rue})); }",
)
value = page.evaluate("document.querySelector('#legend .legend-value').textC
ontent")
assert value != ""
def test_timeseries_group_links(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-02 03:00:00")
I’ll inspect around 100 lines to understand the multi-line arrow functions and the eval_on_selector
.
ies.py
def test_timeseries_crosshair(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
page.wait_for_selector("#chart path", state="attached")
page.eval_on_selector(
"#chart",
"el => { const r = el.getBoundingClientRect(); el.dispatchEvent(new Mous
eEvent('mousemove', {clientX: r.left + r.width/2, clientY: r.top + r.height/2, b
ubbles: true})); }",
root@4682ac416816:/workspace/scubaduck# sed -n '114,140p' tests/test_web_timeser
ies.py
"el => { const r = el.getBoundingClientRect(); el.dispatchEvent(new Mous
eEvent('mousemove', {clientX: r.left + r.width/2, clientY: r.top + r.height/2, b
ubbles: true})); }",
)
line_display = page.evaluate(
"document.getElementById('crosshair_line').style.display"
)
assert line_display != "none"
count = page.eval_on_selector_all("#crosshair_dots circle", "els => els.leng
th")
assert count > 0
page.eval_on_selector(
"#chart",
"el => el.dispatchEvent(new MouseEvent('mouseleave', {bubbles: true}))",
)
line_display = page.evaluate(
"document.getElementById('crosshair_line').style.display"
)
assert line_display == "none"
def test_timeseries_crosshair_freeze(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
I'll implement the test at the bottom of tests/test_web_timeseries.py and check .venv.
root@4682ac416816:/workspace/scubaduck# source .venv/bin/activate
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'll check if ruff passes before making any changes.
(scubaduck) root@4682ac416816:/workspace/scubaduck# ruff check >/tmp/ruff.log &&
tail -n 20 /tmp/ruff.log
All checks passed!
(scubaduck) root@4682ac416816:/workspace/scubaduck# ruff format >/tmp/ruff.log &
& tail -n 20 /tmp/ruff.log
19 files left unchanged
(scubaduck) root@4682ac416816:/workspace/scubaduck# pyright >/tmp/pyright.log &&
tail -n 20 /tmp/pyright.log
0 errors, 0 warnings, 0 informations
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'm adding a test to tests/test_web_timeseries.py based on the described changes.
> *** Update File: tests/test_web_timeseries.py
> @@
> def test_timeseries_group_links(page: Any, server_url: str) -> None:
> @@
> assert page.is_visible("#legend .drill-links a:text('Aggregate')")
> +
> +
> +def test_timeseries_rotated_day_labels_padding(page: Any, server_url: str) ->
None:
> + page.goto(server_url)
> + page.wait_for_selector("#graph_type", state="attached")
> + select_value(page, "#graph_type", "timeseries")
> + page.click("text=Columns")
> + page.check("#column_groups input[value='value']")
> + page.click("text=View Settings")
> + page.fill("#start", "2024-01-01 00:00:00")
> + page.fill("#end", "2024-02-01 00:00:00")
> + select_value(page, "#granularity", "1 day")
> + page.evaluate("window.lastResults = undefined")
> + page.click("text=Dive")
> + page.wait_for_function("window.lastResults !== undefined")
> + page.wait_for_selector("#chart text.tick-label", state="attached")
> + assert page.eval_on_selector_all("#chart text.tick-label.rotated", "els =
> els.length")
> + overflow = page.eval_on_selector(
> + "#chart",
> + "el => {const r=el.getBoundingClientRect(); return Array.from(el.quer
ySelectorAll('text.tick-label')).some(t => t.getBoundingClientRect().bottom > r.
bottom);}",
> + )
> + assert not overflow
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck#
The patch went through. Now, I’ll check to make sure it’s added correctly.
meseries.py
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-02 03:00:00")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
assert page.text_content("#legend .drill-links h4") == "Group by"
page.click("#legend .drill-links a:text('user')")
page.wait_for_function("window.lastResults !== undefined")
chips = page.evaluate("groupBy.chips")
assert chips == ["user"]
assert page.text_content("#legend .drill-links h4") == "Drill up"
assert page.is_visible("#legend .drill-links a:text('Aggregate')")
def test_timeseries_rotated_day_labels_padding(page: Any, server_url: str) -> No
ne:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-02-01 00:00:00")
select_value(page, "#granularity", "1 day")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
page.wait_for_selector("#chart text.tick-label", state="attached")
assert page.eval_on_selector_all("#chart text.tick-label.rotated", "els => e
ls.length")
overflow = page.eval_on_selector(
"#chart",
"el => {const r=el.getBoundingClientRect(); return Array.from(el.querySe
lectorAll('text.tick-label')).some(t => t.getBoundingClientRect().bottom > r.bot
tom);}",
)
assert not overflow
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'll patch the line in timeseries_chart.js, changing 'height - 10' to 15.
(scubaduck) root@4682ac416816:/workspace/scubaduck# grep -n "height - 10" -n scu
baduck/static/js/timeseries_chart.js
416: text.setAttribute('y', height - 10);
419: if (rotate) text.setAttribute('transform', `rotate(-45 ${x} ${height -
10})`);
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'll take a look around line 414.
(scubaduck) root@4682ac416816:/workspace/scubaduck# sed -n '412,421p' scubaduck/
static/js/timeseries_chart.js
line.setAttribute('stroke', '#888');
axis.appendChild(line);
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'
);
text.setAttribute('x', x);
text.setAttribute('y', height - 10);
text.setAttribute('text-anchor', 'middle');
text.setAttribute('class', 'tick-label' + (rotate ? ' rotated' : ''));
if (rotate) text.setAttribute('transform', `rotate(-45 ${x} ${height - 10}
)`);
text.textContent = fmt(new Date(t), lu);
axis.appendChild(text);
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I think I'll set the bottom margin bigger for rotated labels, adjusting with height - 15
.
(scubaduck) root@4682ac416816:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/js/timeseries_chart.js
> @@
> - const text = document.createElementNS('http://www.w3.org/2000/svg', 'te
xt');
> - text.setAttribute('x', x);
> - text.setAttribute('y', height - 10);
> - text.setAttribute('text-anchor', 'middle');
> - text.setAttribute('class', 'tick-label' + (rotate ? ' rotated' : ''));
> - if (rotate) text.setAttribute('transform', `rotate(-45 ${x} ${height -
10})`);
> + const text = document.createElementNS('http://www.w3.org/2000/svg', 'te
xt');
> + const labelY = rotate ? height - 15 : height - 10;
> + text.setAttribute('x', x);
> + text.setAttribute('y', labelY);
> + text.setAttribute('text-anchor', 'middle');
> + text.setAttribute('class', 'tick-label' + (rotate ? ' rotated' : ''));
> + if (rotate) text.setAttribute('transform', `rotate(-45 ${x} ${labelY})`
);
> text.textContent = fmt(new Date(t), lu);
> axis.appendChild(text);
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/js/timeseries_chart.js
(scubaduck) root@4682ac416816:/workspace/scubaduck#
Applying ruff format to clean up and standardize the code.
(scubaduck) root@4682ac416816:/workspace/scubaduck# ruff format scubaduck/static
/js/timeseries_chart.js tests/test_web_timeseries.py >/tmp/ruff.log && tail -n 2
0 /tmp/ruff.log
error: Failed to parse scubaduck/static/js/timeseries_chart.js:1:5: Simple state
ments must be separated by newlines or semicolons
(scubaduck) root@4682ac416816:/workspace/scubaduck#
Ruff format only works for Python, so I'll apply it to test_web_timeseries.py.
(scubaduck) root@4682ac416816:/workspace/scubaduck# ruff format tests/test_web_t
imeseries.py >/tmp/ruff.log && tail -n 20 /tmp/ruff.log
1 file left unchanged
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'll run 'ruff format' for Python files and then follow up with 'ruff check'.
(scubaduck) root@4682ac416816:/workspace/scubaduck# ruff check >/tmp/ruff.log &&
tail -n 20 /tmp/ruff.log
All checks passed!
(scubaduck) root@4682ac416816:/workspace/scubaduck# pyright >/tmp/pyright.log &&
tail -n 20 /tmp/pyright.log
0 errors, 0 warnings, 0 informations
(scubaduck) root@4682ac416816:/workspace/scubaduck#
Time to run pytest and see if everything is working as expected!
(scubaduck) root@4682ac416816:/workspace/scubaduck# pytest -q >/tmp/pytest.log &
& tail -n 20 /tmp/pytest.log
(scubaduck) root@4682ac416816:/workspace/scubaduck# tail -n 20 /tmp/pytest.log
File "/workspace/scubaduck/tests/test_web_timeseries.py", line 395, in test_ti
meseries_rotated_day_labels_padding
assert page.eval_on_selector_all(
AssertionError: assert 0
+ where 0 = eval_on_selector_all('#chart text.tick-label.rotated', 'els => els
.length')
+ where eval_on_selector_all = <Page url='http://127.0.0.1:37041/?table=even
ts&time_column=timestamp&time_unit=s&start=2024-01-01+00%3A00%3A00&end=20...%22%
3A%22%3D%22%2C%22value%22%3Anull%7D%5D&graph_type=timeseries&aggregate=Count&sho
w_hits=1&granularity=1+day&fill=0'>.eval_on_selector_all
------------------------------ Captured log call -------------------------------
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:39:54] "GET / HT
TP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:39:54] "GET /js/
chip_input.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:39:54] "GET /js/
timeseries_chart.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:39:54] "GET /js/
time_column.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:39:54] "GET /js/
sidebar.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:39:54] "GET /js/
table_view.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:39:54] "GET /js/
view_settings.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:39:54] "GET /api
/tables HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:39:54] "GET /api
/columns?table=events HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:39:54] "POST /ap
i/query HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:39:55] "POST /ap
i/query HTTP/1.1" 200 -
=========================== short test summary info ============================
FAILED tests/test_web_timeseries.py::test_timeseries_rotated_day_labels_padding[
chromium]
1 failed, 121 passed in 17.41s
(scubaduck) root@4682ac416816:/workspace/scubaduck#
The test fails because rotation isn't triggered; adjusting the date range should help.
_web_timeseries.py
assert page.is_visible("#legend .drill-links a:text('Aggregate')")
def test_timeseries_rotated_day_labels_padding(page: Any, server_url: str) -> No
ne:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-02-01 00:00:00")
select_value(page, "#granularity", "1 day")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
page.wait_for_selector("#chart text.tick-label", state="attached")
assert page.eval_on_selector_all(
"#chart text.tick-label.rotated", "els => els.length"
)
overflow = page.eval_on_selector(
"#chart",
"el => {const r=el.getBoundingClientRect(); return Array.from(el.querySe
lectorAll('text.tick-label')).some(t => t.getBoundingClientRect().bottom > r.bot
tom);}",
)
assert not overflow
(scubaduck) root@4682ac416816:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: tests/test_web_timeseries.py
> @@
> - page.fill("#start", "2024-01-01 00:00:00")
> - page.fill("#end", "2024-02-01 00:00:00")
> + page.fill("#start", "2024-01-01 00:00:00")
> + page.fill("#end", "2024-03-01 00:00:00")
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'll try running ruff format again to make sure everything's properly linted!
(scubaduck) root@4682ac416816:/workspace/scubaduck# ruff format tests/test_web_t
imeseries.py >/tmp/ruff.log && tail -n 20 /tmp/ruff.log
1 file left unchanged
(scubaduck) root@4682ac416816:/workspace/scubaduck# ruff check >/tmp/ruff.log &&
tail -n 20 /tmp/ruff.log
All checks passed!
(scubaduck) root@4682ac416816:/workspace/scubaduck# pyright >/tmp/pyright.log &&
tail -n 20 /tmp/pyright.log
0 errors, 0 warnings, 0 informations
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'll try running pytest one more time. Fingers crossed!
(scubaduck) root@4682ac416816:/workspace/scubaduck# pytest -q >/tmp/pytest.log &
& tail -n 20 /tmp/pytest.log
(scubaduck) root@4682ac416816:/workspace/scubaduck# tail -n 20 /tmp/pytest.log
File "/workspace/scubaduck/tests/test_web_timeseries.py", line 395, in test_ti
meseries_rotated_day_labels_padding
assert page.eval_on_selector_all(
AssertionError: assert 0
+ where 0 = eval_on_selector_all('#chart text.tick-label.rotated', 'els => els
.length')
+ where eval_on_selector_all = <Page url='http://127.0.0.1:43417/?table=even
ts&time_column=timestamp&time_unit=s&start=2024-01-01+00%3A00%3A00&end=20...%22%
3A%22%3D%22%2C%22value%22%3Anull%7D%5D&graph_type=timeseries&aggregate=Count&sho
w_hits=1&granularity=1+day&fill=0'>.eval_on_selector_all
------------------------------ Captured log call -------------------------------
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:40:27] "GET / HT
TP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:40:27] "GET /js/
chip_input.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:40:27] "GET /js/
timeseries_chart.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:40:27] "GET /js/
time_column.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:40:27] "GET /js/
sidebar.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:40:27] "GET /js/
table_view.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:40:27] "GET /js/
view_settings.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:40:27] "GET /api
/tables HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:40:27] "GET /api
/columns?table=events HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:40:27] "POST /ap
i/query HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:40:27] "POST /ap
i/query HTTP/1.1" 200 -
=========================== short test summary info ============================
FAILED tests/test_web_timeseries.py::test_timeseries_rotated_day_labels_padding[
chromium]
1 failed, 121 passed in 16.60s
(scubaduck) root@4682ac416816:/workspace/scubaduck#
The ticks might not rotate because the interval is too wide. I'll check the dimensions.
(scubaduck) root@4682ac416816:/workspace/scubaduck# python - <<'PY'
> from math import log10
> intervals = [
> {'unit':'second','step':1,'ms':1000},
> {'unit':'second','step':2,'ms':2000},
> {'unit':'second','step':5,'ms':5000},
> {'unit':'second','step':10,'ms':10000},
> {'unit':'second','step':15,'ms':15000},
> {'unit':'second','step':30,'ms':30000},
> {'unit':'minute','step':1,'ms':60000},
> {'unit':'minute','step':2,'ms':120000},
> {'unit':'minute','step':5,'ms':300000},
> {'unit':'minute','step':10,'ms':600000},
> {'unit':'minute','step':15,'ms':900000},
> {'unit':'minute','step':30,'ms':1800000},
> {'unit':'hour','step':1,'ms':3600000},
> {'unit':'hour','step':2,'ms':7200000},
> {'unit':'hour','step':3,'ms':10800000},
> {'unit':'hour','step':4,'ms':14400000},
> {'unit':'hour','step':6,'ms':21600000},
> {'unit':'hour','step':12,'ms':43200000},
> {'unit':'day','step':1,'ms':86400000},
> {'unit':'day','step':2,'ms':172800000},
> {'unit':'week','step':1,'ms':604800000},
> {'unit':'week','step':2,'ms':1209600000},
> {'unit':'month','step':1},
> {'unit':'month','step':3},
> {'unit':'month','step':6},
> {'unit':'year','step':1},
> {'unit':'year','step':2},
> {'unit':'year','step':5},
> {'unit':'year','step':10},
> ]
>
> def approx_ms(i):
> if 'ms' in i: return i['ms']
> if i['unit']=='month': return i['step']*2629800000
> if i['unit']=='year': return i['step']*31557600000
> return 1000
>
> def choose(start,end):
> span=end-start
> best=None
> bestScore=1e9
> for i in intervals:
> cnt=span/approx_ms(i)
> score=abs(cnt-15)
> if score<bestScore:
> best=i;bestScore=score
> return best
>
> span= (60*60*24*59) * 1000
> start=0; end=start+span
> best=choose(start,end)
> print(best)
> PY
{'unit': 'week', 'step': 1, 'ms': 604800000}
(scubaduck) root@4682ac416816:/workspace/scubaduck#
To ensure rotation, I'll set a time range of 10-20 days for 15 ticks.
> from math import ceil
> intervals = [
> {'unit':'second','step':1,'ms':1000},
> {'unit':'second','step':2,'ms':2000},
> {'unit':'second','step':5,'ms':5000},
> {'unit':'second','step':10,'ms':10000},
> {'unit':'second','step':15,'ms':15000},
> {'unit':'second','step':30,'ms':30000},
> {'unit':'minute','step':1,'ms':60000},
> {'unit':'minute','step':2,'ms':120000},
> {'unit':'minute','step':5,'ms':300000},
> {'unit':'minute','step':10,'ms':600000},
> {'unit':'minute','step':15,'ms':900000},
> {'unit':'minute','step':30,'ms':1800000},
> {'unit':'hour','step':1,'ms':3600000},
> {'unit':'hour','step':2,'ms':7200000},
> {'unit':'hour','step':3,'ms':10800000},
> {'unit':'hour','step':4,'ms':14400000},
> {'unit':'hour','step':6,'ms':21600000},
> {'unit':'hour','step':12,'ms':43200000},
> {'unit':'day','step':1,'ms':86400000},
> {'unit':'day','step':2,'ms':172800000},
> {'unit':'week','step':1,'ms':604800000},
> {'unit':'week','step':2,'ms':1209600000},
> {'unit':'month','step':1},
> {'unit':'month','step':3},
> {'unit':'month','step':6},
> {'unit':'year','step':1},
> {'unit':'year','step':2},
> {'unit':'year','step':5},
> {'unit':'year','step':10},
> ]
>
>
> def approx_ms(i):
> if 'ms' in i: return i['ms']
> if i['unit']=='month': return i['step']*2629800000
> if i['unit']=='year': return i['step']*31557600000
> return 1000
>
> def choose(span):
> best=None; bestScore=1e9
> for i in intervals:
> cnt=span/approx_ms(i)
> score=abs(cnt-15)
> if score<bestScore:
> best=i; bestScore=score
> return best
>
> for days in range(10,25):
> span=days*86400*1000
> best=choose(span)
> print(days,best)
> PY
10 {'unit': 'hour', 'step': 12, 'ms': 43200000}
11 {'unit': 'day', 'step': 1, 'ms': 86400000}
12 {'unit': 'day', 'step': 1, 'ms': 86400000}
13 {'unit': 'day', 'step': 1, 'ms': 86400000}
14 {'unit': 'day', 'step': 1, 'ms': 86400000}
15 {'unit': 'day', 'step': 1, 'ms': 86400000}
16 {'unit': 'day', 'step': 1, 'ms': 86400000}
17 {'unit': 'day', 'step': 1, 'ms': 86400000}
18 {'unit': 'day', 'step': 1, 'ms': 86400000}
19 {'unit': 'day', 'step': 1, 'ms': 86400000}
20 {'unit': 'day', 'step': 1, 'ms': 86400000}
21 {'unit': 'day', 'step': 2, 'ms': 172800000}
22 {'unit': 'day', 'step': 2, 'ms': 172800000}
23 {'unit': 'day', 'step': 2, 'ms': 172800000}
24 {'unit': 'day', 'step': 2, 'ms': 172800000}
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'll set the date range from 2024-01-01 to 2024-01-16 to get 15 ticks.
(scubaduck) root@4682ac416816:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: tests/test_web_timeseries.py
> @@
> - page.fill("#end", "2024-03-01 00:00:00")
> + page.fill("#end", "2024-01-16 00:00:00")
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck# ruff format tests/test_web_t
imeseries.py >/tmp/ruff.log && tail -n 20 /tmp/ruff.log
1 file left unchanged
(scubaduck) root@4682ac416816:/workspace/scubaduck# ruff check >/tmp/ruff.log &&
tail -n 20 /tmp/ruff.log
All checks passed!
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'll give pyright another go to see if the issues persist.
(scubaduck) root@4682ac416816:/workspace/scubaduck# pyright >/tmp/pyright.log &&
tail -n 20 /tmp/pyright.log
0 errors, 0 warnings, 0 informations
(scubaduck) root@4682ac416816:/workspace/scubaduck#
Time to run pytest again to see if everything passed this time!
(scubaduck) root@4682ac416816:/workspace/scubaduck# pytest -q >/tmp/pytest.log &
& tail -n 20 /tmp/pytest.log
(scubaduck) root@4682ac416816:/workspace/scubaduck# tail -n 20 /tmp/pytest.log
result = testfunction(**testargs)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/tests/test_web_timeseries.py", line 402, in test_ti
meseries_rotated_day_labels_padding
assert not overflow
AssertionError: assert not True
------------------------------ Captured log call -------------------------------
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:41:17] "GET / HT
TP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:41:17] "GET /js/
chip_input.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:41:17] "GET /js/
timeseries_chart.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:41:17] "GET /js/
time_column.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:41:17] "GET /js/
sidebar.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:41:17] "GET /js/
table_view.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:41:17] "GET /js/
view_settings.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:41:17] "GET /api
/tables HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:41:17] "GET /api
/columns?table=events HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:41:17] "POST /ap
i/query HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:41:17] "POST /ap
i/query HTTP/1.1" 200 -
=========================== short test summary info ============================
FAILED tests/test_web_timeseries.py::test_timeseries_rotated_day_labels_padding[
chromium]
1 failed, 121 passed in 17.01s
(scubaduck) root@4682ac416816:/workspace/scubaduck#
The test fails due to overflow. Labels are still exceeding the chart bottom. Something’s off!
(scubaduck) root@4682ac416816:/workspace/scubaduck# sed -n '382,408p' tests/test
_web_timeseries.py
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-16 00:00:00")
select_value(page, "#granularity", "1 day")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
page.wait_for_selector("#chart text.tick-label", state="attached")
assert page.eval_on_selector_all(
"#chart text.tick-label.rotated", "els => els.length"
)
overflow = page.eval_on_selector(
"#chart",
"el => {const r=el.getBoundingClientRect(); return Array.from(el.querySe
lectorAll('text.tick-label')).some(t => t.getBoundingClientRect().bottom > r.bot
tom);}",
)
assert not overflow
(scubaduck) root@4682ac416816:/workspace/scubaduck#
The rotation assertion passed, but the label still overflowed, indicating a need for adjustment.
I'll set labelY to ensure 25px margin, allowing room for labels without overflow.
(scubaduck) root@4682ac416816:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/js/timeseries_chart.js
> @@
> - const labelY = rotate ? height - 15 : height - 10;
> + const labelY = rotate ? height - 25 : height - 10;
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/js/timeseries_chart.js
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'll run ruff to ensure Python tests aren't affected, but skip JS formatting.
(scubaduck) root@4682ac416816:/workspace/scubaduck# ruff check >/tmp/ruff.log &&
tail -n 20 /tmp/ruff.log
All checks passed!
(scubaduck) root@4682ac416816:/workspace/scubaduck# pyright >/tmp/pyright.log &&
tail -n 20 /tmp/pyright.log
0 errors, 0 warnings, 0 informations
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'll go ahead and re-run pytest to check everything again.
(scubaduck) root@4682ac416816:/workspace/scubaduck# pytest -q >/tmp/pytest.log &
& tail -n 20 /tmp/pytest.log
bringing up nodes...
bringing up nodes...
........................................................................ [ 59%]
.................................................. [100%]
122 passed in 16.97s
(scubaduck) root@4682ac416816:/workspace/scubaduck#
Alright, let's check the status of the repo now that the tests passed.
(scubaduck) root@4682ac416816:/workspace/scubaduck# git status --short
M scubaduck/static/js/timeseries_chart.js
M tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck#
Time to stage the changes before I commit them.
(scubaduck) root@4682ac416816:/workspace/scubaduck# git add scubaduck/static/js/
timeseries_chart.js tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck# git status --short
M scubaduck/static/js/timeseries_chart.js
M tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I might have missed staging some files—checking paths and re-adding them now.
(scubaduck) root@4682ac416816:/workspace/scubaduck# git add scubaduck/static/js/
timeseries_chart.js tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck# git status --short
M scubaduck/static/js/timeseries_chart.js
M tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I think the issue might be that I didn't run 'git add'. Let me stage everything.
(scubaduck) root@4682ac416816:/workspace/scubaduck# git add -A scubaduck/static/
js/timeseries_chart.js tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck# git status --short
M scubaduck/static/js/timeseries_chart.js
M tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck#
Hmm, I believe the issue might be with the .gitattributes or index settings. Let's verify.
duck/static/js/timeseries_chart.js
H scubaduck/static/js/timeseries_chart.js
(scubaduck) root@4682ac416816:/workspace/scubaduck# git ls-files -v | grep tests
/test_web_timeseries.py
H tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck#
Looks like 'H' means 'skip-worktree', preventing staging. I'll run the fix to enable editing.
(scubaduck) root@4682ac416816:/workspace/scubaduck# git update-index --no-skip-w
orktree scubaduck/static/js/timeseries_chart.js tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck# git status --short
M scubaduck/static/js/timeseries_chart.js
M tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I think I need to double-check if skip-worktree has been removed yet.
(scubaduck) root@4682ac416816:/workspace/scubaduck# git ls-files -v | grep scuba
duck/static/js/timeseries_chart.js
H scubaduck/static/js/timeseries_chart.js
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'm looking into the 'H' status—might be related to skip-worktree or assume-unchanged.
(scubaduck) root@4682ac416816:/workspace/scubaduck# git ls-files -v --help | sed
-n '100,160p'
(scubaduck) root@4682ac416816:/workspace/scubaduck# man git-ls-files | head -n 2
0
This system has been minimized by removing packages and content that are
not required on a system that users do not log into.
To restore this content, including manpages, you can run the 'unminimize'
command. You will still need to ensure the 'man-db' package is installed.
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I suspect sparse-checkout might be in use, so I'll run git config core.sparseCheckout
.
(scubaduck) root@4682ac416816:/workspace/scubaduck# git config --get core.sparse
Checkout
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I tried removing the "skip-worktree" flag, but now I'll run "no-assume-unchanged" on the file.
(scubaduck) root@4682ac416816:/workspace/scubaduck# git update-index --no-assume
-unchanged scubaduck/static/js/timeseries_chart.js tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck# git ls-files -v | grep scuba
duck/static/js/timeseries_chart.js
H scubaduck/static/js/timeseries_chart.js
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'll run git update-index --no-skip-worktree --no-assume-unchanged
again individually.
(scubaduck) root@4682ac416816:/workspace/scubaduck# git update-index --no-skip-w
orktree --no-assume-unchanged scubaduck/static/js/timeseries_chart.js tests/test
_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck# git ls-files -v | grep scuba
duck/static/js/timeseries_chart.js
H scubaduck/static/js/timeseries_chart.js
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'm curious if 'H' means "unchanged" or "unmerged." Let's check with git ls-files -v
.
(scubaduck) root@4682ac416816:/workspace/scubaduck# git ls-files -v | head
H .github/workflows/lint.yml
H .github/workflows/test.yml
H .gitignore
H AGENTS.md
H README.md
H codex_setup.sh
H pyproject.toml
H scubaduck/__init__.py
H scubaduck/sample.csv
H scubaduck/server.py
(scubaduck) root@4682ac416816:/workspace/scubaduck#
The 'H' stands for 'assume unchanged,' not 'skip-worktree.' I'll just stage and commit.
(scubaduck) root@4682ac416816:/workspace/scubaduck# git add scubaduck/static/js/
timeseries_chart.js tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck# git status --short
M scubaduck/static/js/timeseries_chart.js
M tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I’ll run git diff --name-only --staged
to see if anything is staged.
(scubaduck) root@4682ac416816:/workspace/scubaduck# git diff --name-only --stage
d
scubaduck/static/js/timeseries_chart.js
tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck#
Hmm, there are staged changes, but also some un-staged modifications—let’s check the diff.
(scubaduck) root@4682ac416816:/workspace/scubaduck# git diff --stat --staged
scubaduck/static/js/timeseries_chart.js | 5 +++--
tests/test_web_timeseries.py | 24 ++++++++++++++++++++++++
2 files changed, 27 insertions(+), 2 deletions(-)
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I see staged changes, but the 'M' status might point to unstaged line endings. Let's investigate.
(scubaduck) root@4682ac416816:/workspace/scubaduck# git diff scubaduck/static/js
/timeseries_chart.js | head
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'll check the status with git status --porcelain
to investigate further.
(scubaduck) root@4682ac416816:/workspace/scubaduck# git status --porcelain | cat
M scubaduck/static/js/timeseries_chart.js
M tests/test_web_timeseries.py
(scubaduck) root@4682ac416816:/workspace/scubaduck#
The "M " means the index is modified, but the working tree is unchanged. I’m good to commit!
(scubaduck) root@4682ac416816:/workspace/scubaduck# git commit -m "fix(frontend)
: prevent clipped rotated time labels" && git status --short
[work dbaacb5] fix(frontend): prevent clipped rotated time labels
2 files changed, 27 insertions(+), 2 deletions(-)
(scubaduck) root@4682ac416816:/workspace/scubaduck# git status --short
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I need to gather the final patch lines, focusing on modifications in test_web_timeseries.py.
(scubaduck) root@4682ac416816:/workspace/scubaduck# nl -ba tests/test_web_timese
ries.py | sed -n '380,410p'
380
381 def test_timeseries_rotated_day_labels_padding(page: Any, server_url: st
r) -> None:
382 page.goto(server_url)
383 page.wait_for_selector("#graph_type", state="attached")
384 select_value(page, "#graph_type", "timeseries")
385 page.click("text=Columns")
386 page.check("#column_groups input[value='value']")
387 page.click("text=View Settings")
388 page.fill("#start", "2024-01-01 00:00:00")
389 page.fill("#end", "2024-01-16 00:00:00")
390 select_value(page, "#granularity", "1 day")
391 page.evaluate("window.lastResults = undefined")
392 page.click("text=Dive")
393 page.wait_for_function("window.lastResults !== undefined")
394 page.wait_for_selector("#chart text.tick-label", state="attached")
395 assert page.eval_on_selector_all(
396 "#chart text.tick-label.rotated", "els => els.length"
397 )
398 overflow = page.eval_on_selector(
399 "#chart",
400 "el => {const r=el.getBoundingClientRect(); return Array.from(el
.querySelectorAll('text.tick-label')).some(t => t.getBoundingClientRect().bottom
> r.bottom);}",
401 )
402 assert not overflow
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'll grab the line ranges and note the update to timeseries_chart.js for labelY.
(scubaduck) root@4682ac416816:/workspace/scubaduck# nl -ba scubaduck/static/js/t
imeseries_chart.js | sed -n '412,421p'
412 line.setAttribute('stroke', '#888');
413 axis.appendChild(line);
414 const text = document.createElementNS('http://www.w3.org/2000/svg'
, 'text');
415 const labelY = rotate ? height - 25 : height - 10;
416 text.setAttribute('x', x);
417 text.setAttribute('y', labelY);
418 text.setAttribute('text-anchor', 'middle');
419 text.setAttribute('class', 'tick-label' + (rotate ? ' rotated' : '
'));
420 if (rotate) text.setAttribute('transform', `rotate(-45 ${x} ${labe
lY})`);
421 text.textContent = fmt(new Date(t), lu);
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'll highlight the necessary lines and include the pytest output with test results.
(scubaduck) root@4682ac416816:/workspace/scubaduck# cat /tmp/pytest.log | tail -
n 5
bringing up nodes...
........................................................................ [ 59%]
.................................................. [100%]
122 passed in 16.97s
(scubaduck) root@4682ac416816:/workspace/scubaduck#
I'll prepare the PR, including details from the user prompt and a patch reference.