Restock - adding inline graph

This commit is contained in:
dgtlmoon
2026-06-21 22:14:57 +02:00
parent 5d3dc80acf
commit fb238eefef
11 changed files with 739 additions and 8 deletions
+30
View File
@@ -332,6 +332,36 @@ def construct_blueprint(datastore: ChangeDetectionStore):
datastore.set_last_viewed(uuid, int(time.time()))
return jsonify({'summary': summary, 'error': None, 'cached': False})
@diff_blueprint.route("/diff/<uuid_str:uuid>/processor-data", methods=['GET'])
@login_optionally_required
def diff_history_page_processor_data(uuid):
"""
Return processor-specific JSON data for the history/diff page (e.g. the restock
price/stock timeline that the graph JS fetches).
Processor-aware: delegates to processors/{type}/difference.py::get_data(), so the
heavy data stays out of the rendered HTML (same rationale as the preview asset route).
Works for built-in and plugin processors via get_processor_submodule(). Returns 404
if the watch's processor doesn't implement get_data().
"""
from flask import jsonify, abort
if uuid == 'first':
uuid = list(datastore.data['watching'].keys()).pop()
try:
watch = datastore.data['watching'][uuid]
except KeyError:
return jsonify({'error': 'Watch not found'}), 404
processor_name = watch.get('processor', 'text_json_diff')
from changedetectionio.processors import get_processor_submodule
processor_module = get_processor_submodule(processor_name, 'difference')
if processor_module and hasattr(processor_module, 'get_data'):
return jsonify(processor_module.get_data(watch=watch, datastore=datastore, request=request))
abort(404, description=f"Processor '{processor_name}' does not provide difference data")
@diff_blueprint.route("/diff/<uuid_str:uuid>/extract", methods=['GET'])
@login_optionally_required
def diff_history_page_extract_GET(uuid):
@@ -7,6 +7,7 @@
{%- from '_helpers.html' import render_simple_field, render_field, render_nolabel_field, sort_by_title -%}
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='restock-price-graph.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='modal.js')}}"></script>
<script>let nowtimeserver={{ now_time_server }};</script>
<script>let favicon_baseURL="{{ url_for('static_content', group='favicon', filename="PLACEHOLDER")}}";</script>
@@ -126,7 +127,14 @@ document.addEventListener('DOMContentLoaded', function() {
<script>
window.watchOverviewI18n = {
generatingSummary: {{ _('Generating change summary…')|tojson }},
gotoHistory: {{ _('Goto full history')|tojson }}
gotoHistory: {{ _('Goto full history')|tojson }},
loadingPriceHistory: {{ _('Loading price history…')|tojson }},
inStock: {{ _('In stock')|tojson }},
outOfStock: {{ _('Out of stock')|tojson }},
noPriceData: {{ _('No price data available to graph yet.')|tojson }},
priceHistoryError: {{ _('Could not load price history.')|tojson }},
changes: {{ _('Number of changes')|tojson }},
avgPrice: {{ _('average price')|tojson }}
};
</script>
{% endif %}
@@ -489,7 +497,7 @@ window.watchOverviewI18n = {
<a href="" class="already-in-queue-button recheck cdio-btn cdio-btn--primary cdio-btn--sm" style="display: none;" disabled="disabled"><i data-feather="clock"></i>{{ _('Queued') }}</a>
<a href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" data-op='recheck' class="ajax-op recheck cdio-btn cdio-btn--primary cdio-btn--sm"><i data-feather="refresh-cw"></i>{{ _('Recheck') }}</a>
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="cdio-btn cdio-btn--primary cdio-btn--sm">{{ _('Edit') }}</a>
<a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="cdio-btn cdio-btn--primary cdio-btn--sm history-link ai-history-btn" style="display: none;" data-uuid="{{ watch.uuid }}" data-summary-url="{{ url_for('ui.ui_diff.diff_llm_summary', uuid=watch.uuid) }}"><span class="btn-label-history">{{ _('History') }}</span><span class="btn-label-summary">&#x2728; {{ _('Summary') }}</span></a>
<a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="cdio-btn cdio-btn--primary cdio-btn--sm history-link ai-history-btn" style="display: none;" data-uuid="{{ watch.uuid }}" data-summary-url="{{ url_for('ui.ui_diff.diff_llm_summary', uuid=watch.uuid) }}" data-processor-data-url="{{ url_for('ui.ui_diff.diff_history_page_processor_data', uuid=watch.uuid) }}"><span class="btn-label-history">{{ _('History') }}</span><span class="btn-label-summary">&#x2728; {{ _('Summary') }}</span></a>
<a href="{{ url_for('ui.ui_preview.preview_page', uuid=watch.uuid)}}" {{target_attr}} class="cdio-btn cdio-btn--primary cdio-btn--sm preview-link" style="display: none;">{{ _('Preview') }}</a>
</div>
</td>
@@ -0,0 +1,109 @@
"""
History/diff rendering for the restock_diff (price / stock availability) processor.
A text diff is meaningless for restock watches - each history snapshot is just a short
string like "In Stock: True - Price: 12.34". So instead of a diff, this renders the whole
timeline as a simple smoothed line graph of price over time, coloured green where the item
was in stock and red where it was out of stock.
Conforms to processors.difference_base.DifferenceRenderer (module-level render()), and also
provides an optional get_data() hook served as JSON by /diff/<uuid>/processor-data so the
(potentially long) timeline stays out of the rendered HTML - same rationale as the preview
asset endpoint.
"""
import re
import time
from flask_babel import gettext
from loguru import logger
# Snapshot format is written by processor.py: f"In Stock: {in_stock} - Price: {price}"
_RE_PRICE = re.compile(r"Price:\s*([\d.]+)", re.IGNORECASE)
_RE_INSTOCK = re.compile(r"In Stock:\s*(True|False)", re.IGNORECASE)
def _parse_restock_snapshot(text):
"""Parse a snapshot string into (price: float|None, in_stock: bool|None)."""
price = None
in_stock = None
if text:
m = _RE_PRICE.search(text)
if m:
try:
price = float(m.group(1))
except (TypeError, ValueError):
price = None
mi = _RE_INSTOCK.search(text)
if mi:
in_stock = mi.group(1).lower() == 'true'
return price, in_stock
def _currency(watch):
try:
return (watch.get('restock') or {}).get('currency') or ''
except Exception:
return ''
def _build_series(watch):
"""Read the full history into a [{timestamp, price, in_stock}] timeline (oldest -> newest)."""
series = []
for ts in list(watch.history.keys()):
try:
snapshot = watch.get_history_snapshot(timestamp=ts)
except Exception as e:
logger.error(f"Restock diff: unable to read snapshot {ts} for {watch.get('uuid')}: {e}")
continue
price, in_stock = _parse_restock_snapshot(snapshot)
series.append({'timestamp': int(ts), 'price': price, 'in_stock': in_stock})
return series
def get_data(watch, datastore, request):
"""JSON payload for the price/stock graph, fetched via /diff/<uuid>/processor-data.
Keeps the full timeline out of the HTML page."""
series = _build_series(watch)
priced = sum(1 for p in series if p['price'] is not None)
logger.info(f"Restock diff get_data for {watch.get('uuid')}: {len(series)} snapshots, {priced} with a price")
return {
'series': series,
'currency': _currency(watch),
}
def render(watch, datastore, request, url_for, render_template, flash, redirect, extract_form=None):
"""Render the price/stock timeline page (shell + summary). The graph data is loaded
asynchronously from get_data() so this stays light regardless of history length."""
uuid = watch.get('uuid')
dates = list(watch.history.keys())
# Light render: read only the most recent snapshot for the summary badge/price.
latest = None
if dates:
try:
price, in_stock = _parse_restock_snapshot(watch.get_history_snapshot(timestamp=dates[-1]))
latest = {'timestamp': int(dates[-1]), 'price': price, 'in_stock': in_stock}
except Exception as e:
logger.error(f"Restock diff: unable to read latest snapshot for {uuid}: {e}")
# Opening the history page counts as viewing it (mirrors the text diff page).
datastore.set_last_viewed(uuid, time.time())
return render_template(
'restock_diff/difference.html',
uuid=uuid,
watch=watch,
current_diff_url=watch['url'],
extra_title=f" - {watch.label} - {gettext('Price history')}",
last_error=watch['last_error'],
last_error_screenshot=watch.get_error_snapshot(),
last_error_text=watch.get_error_text(),
versions=dates,
from_version=str(dates[-2]) if len(dates) >= 2 else (str(dates[-1]) if dates else ''),
to_version=str(dates[-1]) if dates else '',
restock_latest=latest,
restock_currency=_currency(watch),
has_enough_history=len(dates) >= 2,
processor_data_url=url_for('ui.ui_diff.diff_history_page_processor_data', uuid=uuid),
)
@@ -0,0 +1,63 @@
{% extends 'base.html' %}
{% block content %}
<div class="box" id="restock-diff">
<div id="restock-diff-header">
<h3 style="margin: 0 0 0.25rem;">{{ _('Price & stock history') }}</h3>
<a href="{{ current_diff_url }}" target="_blank" rel="noopener" class="current-diff-url">{{ current_diff_url }}</a>
{% if restock_latest %}
<div id="restock-diff-summary">
{% if restock_latest.in_stock is not none %}
<span class="restock-badge {{ 'in-stock' if restock_latest.in_stock else 'out-of-stock' }}">
{{ _('In stock') if restock_latest.in_stock else _('Out of stock') }}
</span>
{% endif %}
{% if restock_latest.price is not none %}
<span class="restock-latest-price">{{ restock_currency }}{{ restock_latest.price }}</span>
{% endif %}
</div>
{% endif %}
</div>
{% if has_enough_history %}
<div id="restock-graph">
<p class="pure-form-message-inline" id="restock-graph-loading">{{ _('Loading price history…') }}</p>
</div>
{# Accessible data table - filled in by the graph JS from the same fetched data #}
<details id="restock-history-table-wrap" style="margin-top: 1rem;">
<summary>{{ _('Show data table') }}</summary>
<table class="pure-table" id="restock-history-table">
<thead>
<tr>
<th>{{ _('When') }}</th>
<th>{{ _('Stock') }}</th>
<th>{{ _('Price') }}</th>
</tr>
</thead>
<tbody></tbody>
</table>
</details>
{% else %}
<p class="pure-form-message-inline" style="margin-top: 1rem;">
{{ _('Not enough price data points yet to draw a graph - keep monitoring and the price history will appear here.') }}
</p>
{% endif %}
</div>
<script>
// The graph data (full timeline) is fetched from this endpoint to keep it out of the HTML.
window.restock_data_url = {{ processor_data_url|tojson }};
// i18n strings for the JS-built table + states.
window.restock_i18n = {
in_stock: {{ _('In stock')|tojson }},
out_of_stock: {{ _('Out of stock')|tojson }},
no_data: {{ _('No price data available to graph yet.')|tojson }},
load_error: {{ _('Could not load price history.')|tojson }},
changes: {{ _('Number of changes')|tojson }},
avg_price: {{ _('average price')|tojson }}
};
</script>
<script src="{{ url_for('static_content', group='js', filename='restock-price-graph.js') }}" defer></script>
{% endblock %}
@@ -0,0 +1,258 @@
// Restock price/stock timeline - a hand-rolled smoothed SVG line graph.
// Line segments are green where the item was in stock and red where it was out of stock.
//
// Two consumers:
// * the restock diff page (#restock-graph), which fetches window.restock_data_url and also
// builds the data table below it; and
// * the watchlist inline roll-down, which calls window.renderRestockGraph(el, series, currency, i18n).
//
// Series shape (oldest -> newest): [{timestamp: <epoch sec>, price: <float|null>, in_stock: <bool|null>}]
(function ($) {
'use strict';
const SVG_NS = 'http://www.w3.org/2000/svg';
const COLOR_IN = '#1fa463'; // in stock = green
const COLOR_OUT = '#e74c3c'; // out of stock = red
const COLOR_UNKNOWN = '#999'; // stock state not known
const HEIGHT = 320;
const PAD = { top: 20, right: 16, bottom: 30, left: 52 };
const MAX_X_LABELS = 6; // keep the date axis uncluttered
const SMOOTHING = 0.28; // curve tension (Catmull-Rom default ~0.167; higher = curvier)
const DEFAULT_I18N = { in_stock: 'In stock', out_of_stock: 'Out of stock',
no_data: 'No price data available to graph yet.', load_error: 'Could not load price history.',
changes: 'Number of changes', avg_price: 'average price' };
function el(name, attrs) {
const e = document.createElementNS(SVG_NS, name);
for (const k in attrs) e.setAttribute(k, attrs[k]);
return e;
}
function stockColor(inStock) {
if (inStock === true) return COLOR_IN;
if (inStock === false) return COLOR_OUT;
return COLOR_UNKNOWN;
}
// X-axis label: day + short month + short year, e.g. "21 Jun '25".
function fmtAxisDate(epoch) {
const d = new Date(epoch * 1000);
const dm = d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
return `${dm} '${String(d.getFullYear()).slice(-2)}`;
}
function fmtDateTime(epoch) {
// undefined locale => the browser's locale; explicit options for a clean, complete format.
return new Date(epoch * 1000).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
}
function fmtPrice(v, currency) {
const n = Math.round(v * 100) / 100;
return (currency || '') + (Number.isInteger(n) ? n : n.toFixed(2));
}
// Catmull-Rom control points for segment p[i] -> p[i+1] -> "C c1x c1y c2x c2y x y".
function smoothSegment(pts, i) {
const p0 = pts[i - 1] || pts[i];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[i + 2] || pts[i + 1];
const c1x = p1.x + (p2.x - p0.x) * SMOOTHING, c1y = p1.y + (p2.y - p0.y) * SMOOTHING;
const c2x = p2.x - (p3.x - p1.x) * SMOOTHING, c2y = p2.y - (p3.y - p1.y) * SMOOTHING;
return `C ${c1x.toFixed(1)} ${c1y.toFixed(1)} ${c2x.toFixed(1)} ${c2y.toFixed(1)} ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`;
}
function draw($container, series, currency, i18n) {
// Only points that actually have a price can sit on the line.
const data = (series || []).filter(p => p.price !== null && p.price !== undefined);
if (data.length < 2) {
$container.html($('<p class="pure-form-message-inline"></p>').text(i18n.no_data));
return;
}
const width = Math.max($container.width() || 0, 320);
const innerH = HEIGHT - PAD.top - PAD.bottom;
const prices = data.map(p => p.price);
let dataMin = Math.min.apply(null, prices), dataMax = Math.max.apply(null, prices);
if (dataMin === dataMax) { dataMin -= 1; dataMax += 1; }
// Pad the value domain ~10% top & bottom so the (smoothed, possibly overshooting) line
// and the min/max labels sit inside the plot area instead of being clipped at the edges.
const vpad = (dataMax - dataMin) * 0.1;
const domMin = dataMin - vpad, domMax = dataMax + vpad, range = domMax - domMin;
const ticks = [dataMax, (dataMin + dataMax) / 2, dataMin];
// Widen the left gutter to fit the price labels (e.g. "CZK12.34") so a long currency
// prefix isn't clipped at the SVG's left edge. ~7px/char + padding.
const maxLabelChars = Math.max.apply(null, ticks.map(v => fmtPrice(v, currency).length));
const padLeft = Math.max(PAD.left, maxLabelChars * 7 + 14);
const innerW = width - padLeft - PAD.right;
const x = i => padLeft + (i * innerW) / (data.length - 1);
const y = price => PAD.top + (1 - (price - domMin) / range) * innerH;
const pts = data.map((p, i) => ({ x: x(i), y: y(p.price), d: p }));
// Size the SVG in real pixels at the measured width (no viewBox => no CSS rescaling).
const svg = el('svg', { width: width, height: HEIGHT, role: 'img' });
// Y axis: max / mid / min gridlines + price labels.
ticks.forEach(price => {
const yy = y(price);
svg.appendChild(el('line', { class: 'rg-axis', x1: padLeft, y1: yy, x2: width - PAD.right, y2: yy, opacity: price === dataMin ? 0.6 : 0.15 }));
const t = el('text', { class: 'rg-label', x: padLeft - 8, y: yy + 4, 'text-anchor': 'end' });
t.textContent = fmtPrice(price, currency);
svg.appendChild(t);
});
// Line: one <path> per segment so each carries its own colour. A section is coloured by
// the stock state at the START of that interval (the state that held until the next check).
for (let i = 0; i < pts.length - 1; i++) {
const d = `M ${pts[i].x.toFixed(1)} ${pts[i].y.toFixed(1)} ${smoothSegment(pts, i)}`;
svg.appendChild(el('path', {
d: d, fill: 'none', stroke: stockColor(pts[i].d.in_stock),
'stroke-width': 2.5, 'stroke-linecap': 'round', 'stroke-linejoin': 'round'
}));
}
// Dots, coloured by each point's own stock state.
pts.forEach(p => {
svg.appendChild(el('circle', { cx: p.x.toFixed(1), cy: p.y.toFixed(1), r: 3, fill: stockColor(p.d.in_stock) }));
});
// Sparse x-axis date labels: up to MAX_X_LABELS, evenly spaced and always including both
// endpoints (computed by even fractions, so the last label can't overlap the previous one).
const count = Math.min(MAX_X_LABELS, pts.length);
const labelIdx = Array.from(new Set(
Array.from({ length: count }, (_, i) => Math.round((i * (pts.length - 1)) / (count - 1)))
));
labelIdx.forEach(i => {
const anchor = i === 0 ? 'start' : (i === pts.length - 1 ? 'end' : 'middle');
const t = el('text', { class: 'rg-label', x: pts[i].x.toFixed(1), y: HEIGHT - 10, 'text-anchor': anchor });
t.textContent = fmtAxisDate(pts[i].d.timestamp);
svg.appendChild(t);
});
$container.empty().append(svg);
// --- Hover tooltip: a generous transparent hit-circle per point shows price + date ---
const $tip = $('<div class="rg-tooltip"></div>').appendTo($container);
pts.forEach(p => {
const state = p.d.in_stock === null ? '' :
(p.d.in_stock ? ' &middot; ' + i18n.in_stock : ' &middot; ' + i18n.out_of_stock);
const hit = el('circle', { cx: p.x.toFixed(1), cy: p.y.toFixed(1), r: 14, fill: 'transparent', style: 'cursor: pointer;' });
svg.appendChild(hit);
$(hit).on('mouseenter mousemove', function (e) {
const r = $container[0].getBoundingClientRect();
const cx = e.clientX - r.left, cy = e.clientY - r.top;
$tip.html('<strong>' + fmtPrice(p.d.price, currency) + '</strong>' + state + '<br>' + fmtDateTime(p.d.timestamp)).show();
const tipW = $tip.outerWidth(), GAP = 14;
// Default to the right of the cursor; flip left if it would overflow the right edge.
let left = cx + GAP;
if (left + tipW > $container.width()) left = cx - GAP - tipW;
if (left < 0) left = 0;
$tip.css({ left: left + 'px', top: cy + 'px' });
}).on('mouseleave', function () {
$tip.hide();
});
});
// Footer stats: total recorded changes + average of the prices shown.
const avgPrice = prices.reduce((a, b) => a + b, 0) / prices.length;
$('<div class="rg-stats"></div>')
.text((i18n.changes || 'Number of changes') + ' ' + (series ? series.length : data.length) +
', ' + (i18n.avg_price || 'average price') + ' ' + fmtPrice(avgPrice, currency))
.appendTo($container);
}
function buildTable($table, series, currency, i18n) {
const $tbody = $table.find('tbody');
if (!$tbody.length) return;
$tbody.empty();
series.slice().reverse().forEach(p => {
let stock = '-';
if (p.in_stock === true) stock = $('<span class="restock-badge in-stock"></span>').text(i18n.in_stock);
else if (p.in_stock === false) stock = $('<span class="restock-badge out-of-stock"></span>').text(i18n.out_of_stock);
const price = (p.price !== null && p.price !== undefined) ? fmtPrice(p.price, currency) : '-';
$('<tr></tr>')
.append($('<td></td>').text(fmtDateTime(p.timestamp)))
.append($('<td></td>').append(stock))
.append($('<td></td>').text(price))
.appendTo($tbody);
});
}
// Redraw a graph only if its container width actually changed (skips redundant redraws and
// prevents ResizeObserver feedback loops).
function redraw($c) {
const ctx = $c.data('rg');
if (!ctx) return;
const w = Math.round($c.width());
if (w === ctx.lastWidth) return;
ctx.lastWidth = w;
draw($c, ctx.series, ctx.currency, ctx.i18n);
}
// Watch the container's own size, not just the window: the content area also changes width
// when the action sidebar expands on hover (no window 'resize' event fires for that).
const hasRO = typeof ResizeObserver !== 'undefined';
let ro = null;
if (hasRO) {
let roTimer;
const pending = new Set();
ro = new ResizeObserver(function (entries) {
entries.forEach(e => pending.add(e.target));
clearTimeout(roTimer);
roTimer = setTimeout(function () {
pending.forEach(t => redraw($(t)));
pending.clear();
}, 120);
});
} else {
// Fallback for browsers without ResizeObserver: window resize only.
let resizeTimer;
$(window).on('resize.restockgraph', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
$('.js-restock-graph').each(function () { redraw($(this)); });
}, 150);
});
}
// Public reusable renderer. Stores its context on the element and observes the element so
// it redraws on any size change; usable from anywhere (e.g. the watchlist inline roll-down).
window.renderRestockGraph = function (container, series, currency, i18n) {
const $c = $(container);
if (!$c.length) return;
const merged = $.extend({}, DEFAULT_I18N, i18n || {});
// Seed lastWidth to the current width so the immediate ResizeObserver callback (which
// fires once on observe()) doesn't trigger a redundant redraw.
$c.addClass('js-restock-graph').data('rg', {
series: series || [], currency: currency || '', i18n: merged, lastWidth: Math.round($c.width())
});
draw($c, series || [], currency || '', merged);
if (ro) {
try { ro.unobserve($c[0]); } catch (e) {}
ro.observe($c[0]);
}
};
// Diff page bootstrap: fetch the timeline, render the graph + the data table.
$(function () {
const $container = $('#restock-graph');
if (!$container.length || !window.restock_data_url) return;
const i18n = $.extend({}, DEFAULT_I18N, window.restock_i18n || {});
$.getJSON(window.restock_data_url).done(function (data) {
const series = (data && data.series) || [];
window.renderRestockGraph($container, series, (data && data.currency) || '', i18n);
buildTable($('#restock-history-table'), series, (data && data.currency) || '', i18n);
}).fail(function () {
$container.html($('<p class="pure-form-message-inline"></p>').text(i18n.load_error));
});
});
})(jQuery);
@@ -72,6 +72,74 @@ $(function () {
$(this).closest('tr').removeClass('unviewed');
});
// Inline restock price/stock graph: for restock_diff rows the History button rolls down a
// price/stock graph (no LLM needed), styled like the AI summary roll-down. Registered before
// the LLM handler and stops propagation so AI mode never turns these into LLM summaries.
$(document).on('click', 'tr.processor-restock_diff .ai-history-btn', function (e) {
e.preventDefault();
e.stopImmediatePropagation();
var i18n = window.watchOverviewI18n || {};
var $btn = $(this);
var uuid = $btn.data('uuid');
var dataUrl = $btn.data('processor-data-url');
var historyUrl = $btn.attr('href');
var $row = $btn.closest('tr');
var rowId = 'restock-graph-row-' + uuid;
var cols = $row.find('td').length;
var $tbody = $row.closest('tbody');
$row.removeClass('unviewed');
// Toggle off if already open
if ($('#' + rowId).length) {
$('#' + rowId).remove();
$tbody.find('tr:not(.restock-inline-row) td').css('background-color', '');
return;
}
// Freeze row backgrounds so inserting a <tr> doesn't shift nth-child striping
var $dataRows = $tbody.find('tr:not(.restock-inline-row)');
var bgMap = [];
$dataRows.each(function () { bgMap.push($(this).find('td:first').css('background-color')); });
var $r = $(
'<tr class="restock-inline-row" id="' + rowId + '">' +
'<td colspan="' + cols + '">' +
'<div class="restock-inline-graph"></div>' +
'<a class="restock-inline-history-link"></a>' +
'</td></tr>'
);
$r.find('.restock-inline-graph').text(i18n.loadingPriceHistory || 'Loading price history…');
$r.find('.restock-inline-history-link').attr('href', historyUrl).text(i18n.gotoHistory || 'Goto full history');
$row.after($r);
// Re-apply frozen backgrounds so the nth-child parity shift is invisible
$dataRows.each(function (i) { $(this).find('td').css('background-color', bgMap[i]); });
function showError() {
$r.find('.restock-inline-graph').html(
'<span class="restock-inline-error">' +
$('<span>').text(i18n.priceHistoryError || 'Could not load price history.').html() +
'</span>'
);
}
if (!dataUrl) { showError(); return; }
$.getJSON(dataUrl).done(function (data) {
var series = (data && data.series) || [];
var graphI18n = {
in_stock: i18n.inStock, out_of_stock: i18n.outOfStock,
no_data: i18n.noPriceData, load_error: i18n.priceHistoryError,
changes: i18n.changes, avg_price: i18n.avgPrice
};
if (window.renderRestockGraph) {
window.renderRestockGraph($r.find('.restock-inline-graph')[0], series, (data && data.currency) || '', graphI18n);
}
}).fail(showError);
});
$('.with-share-link > *').click(function () {
$("#copied-clipboard").remove();
@@ -330,6 +398,8 @@ $(function () {
// Inline AI summary — clicking the Summary button inserts a row below with AJAX content
$(document).on('click', '.ai-history-btn', function (e) {
// restock_diff rows have their own graph roll-down handler (above) - never LLM here.
if ($(this).closest('tr').hasClass('processor-restock_diff')) return;
if ($('html').attr('data-ai-mode') !== 'true') return; // normal navigation when AI mode is off
e.preventDefault();
@@ -0,0 +1,117 @@
// Restock price/stock history graph + inline watchlist roll-down.
// Used by the diff page (#restock-graph) and the watchlist inline graph (.restock-inline-graph);
// both containers get the .js-restock-graph class from restock-price-graph.js.
#restock-diff { width: 100%; box-sizing: border-box; }
#restock-diff-summary {
margin-top: 0.6rem;
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.restock-latest-price {
font-size: 1.4rem;
font-weight: 700;
}
.restock-badge {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 1rem;
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
&.in-stock { background: rgba(31, 164, 99, 0.15); color: #1fa463; }
&.out-of-stock { background: rgba(231, 76, 60, 0.15); color: #e74c3c; }
}
#restock-graph { margin-top: 1rem; }
.js-restock-graph {
position: relative;
width: 100%;
// The SVG is drawn at a measured pixel width; on shrink it would otherwise overflow and
// cause horizontal page scroll until the (debounced) resize redraw. max-width:100% caps it
// to the container so it never pushes the viewport wider (brief squish during an active
// resize, then the redraw restores the correct width).
svg { display: block; max-width: 100%; }
.rg-axis { stroke: var(--color-border-notification, rgba(127, 127, 127, 0.4)); stroke-width: 1; }
.rg-label { fill: currentColor; opacity: 0.7; font-size: 12px; }
.rg-stats {
margin-top: 0.35rem;
font-size: 0.8rem;
opacity: 0.7;
text-align: center;
}
}
.rg-tooltip {
position: absolute;
display: none;
pointer-events: none;
z-index: 5;
transform: translateY(-50%);
white-space: nowrap;
background: var(--color-background, #fff);
color: var(--color-text, #222);
border: 1px solid var(--color-border-notification, rgba(127, 127, 127, 0.4));
border-radius: 5px;
padding: 4px 8px;
font-size: 12px;
line-height: 1.4;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
#restock-history-table td,
#restock-history-table th { text-align: left; }
// ── Watchlist: restock rows keep the "History" button even in AI mode ──
// (AI mode swaps History->Summary for LLM watches; restock has its own graph roll-down instead.)
html[data-ai-mode="true"] body.llm-configured tr.processor-restock_diff {
.btn-label-history { display: inline; }
.btn-label-summary { display: none; }
}
// ── Watchlist: inline restock graph roll-down (mirrors .ai-inline-summary-row, teal/green) ──
.restock-inline-row {
td {
white-space: normal !important;
word-break: break-word;
padding: 0.6rem 1rem 0.8rem 1.4rem !important;
background: linear-gradient(135deg, #e6fbf0, #e8f6ff) !important;
border-top: 1px solid #9fe3c2 !important;
border-left: 3px solid #1fa463 !important;
color: #06281a !important;
line-height: 1.5;
}
html[data-darkmode="true"] & td {
background: linear-gradient(135deg, #0c2a1c, #0d2230) !important;
border-top: 1px solid #1f5e40 !important;
border-left-color: #1fa463 !important;
color: #d6ffe9 !important;
}
.restock-inline-graph {
width: 100%;
min-height: 40px;
}
.restock-inline-history-link {
display: inline-block;
margin-top: 0.5rem;
font-size: 0.78rem;
font-weight: 700;
opacity: 0.75;
white-space: nowrap;
&:hover { opacity: 1; }
}
.restock-inline-error { color: #c0392b; }
}
@@ -35,6 +35,7 @@
@use "parts/llm";
@use "parts/notification_bubble";
@use "parts/notifications";
@use "parts/restock_graph";
@use "parts/toast";
@use "parts/login_form";
@use "parts/tabs";
File diff suppressed because one or more lines are too long
@@ -23,13 +23,15 @@
{# Processor check only applies in watch-edit context (llm_group_overrides present). #}
{# In tag/group edit context the AI section is always visible. #}
{# Processors whose edit form carries the AI Intent / Change Summary fields (i.e. those whose
form inherits processor_text_json_diff_form). text_json_diff + restock_diff today. #}
{% if llm_group_overrides is defined %}
{% set is_text_json_diff = not watch.get('processor') or watch.get('processor') == 'text_json_diff' %}
{% set show_ai_section = not watch.get('processor') or watch.get('processor') in ['text_json_diff', 'restock_diff'] %}
{% else %}
{% set is_text_json_diff = true %}
{% set show_ai_section = true %}
{% endif %}
{% if is_text_json_diff %}
{% if show_ai_section %}
{# ── Configured: show the intent + summary fields ────────────────── #}
{% if llm_configured %}
@@ -123,4 +125,4 @@
</div>
{% endif %}
{% endif %}{# is_text_json_diff #}
{% endif %}{# show_ai_section #}
@@ -521,4 +521,77 @@ def test_itemprop_as_str(client, live_server, measure_memory_usage, datastore_pa
wait_for_all_checks(client)
res = client.get(url_for("watchlist.index"))
assert b'767.55' in res.data
assert b'767.55' in res.data
def test_restock_diff_price_data_ajax(client, live_server, measure_memory_usage, datastore_path):
"""The restock history graph fetches its timeline from the processor-data callback
(restock_diff/difference.py::get_data, served at /diff/<uuid>/processor-data)."""
import json
test_url = url_for('test_endpoint', _external=True)
# First snapshot - in stock @ 190.95
set_original_response(props_markup=instock_props[0], price="190.95", datastore_path=datastore_path)
client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": test_url, "tags": '', 'processor': 'restock_diff'},
follow_redirects=True
)
wait_for_all_checks(client)
uuid = extract_UUID_from_client(client)
# Second snapshot - price changes to 180.45 (still in stock) -> a new history point
set_original_response(props_markup=instock_props[0], price='180.45', datastore_path=datastore_path)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
# The AJAX/plugin data callback
res = client.get(url_for("ui.ui_diff.diff_history_page_processor_data", uuid=uuid))
assert res.status_code == 200
data = json.loads(res.data)
assert 'series' in data
assert 'currency' in data
series = data['series']
assert len(series) >= 2
# Every point exposes the parsed timestamp + price + stock state
for point in series:
assert 'timestamp' in point
assert 'price' in point
assert 'in_stock' in point
prices = [p['price'] for p in series]
assert 190.95 in prices
assert 180.45 in prices
# These snapshots were all "in stock"
assert series[-1]['in_stock'] is True
delete_all_watches(client)
def test_restock_edit_has_ai_llm_section(client, live_server, measure_memory_usage, datastore_path):
"""restock_diff watches must expose the AI / LLM tab content on the edit page, the same as
text_json_diff. Regression: the AI section used to be gated to text_json_diff only, so the
#ai-llm tab rendered empty for restock watches."""
test_url = url_for('test_endpoint', _external=True)
set_original_response(props_markup=instock_props[0], datastore_path=datastore_path)
client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": test_url, "tags": '', 'processor': 'restock_diff'},
follow_redirects=True
)
wait_for_all_checks(client)
uuid = extract_UUID_from_client(client)
res = client.get(url_for("ui.ui_edit.edit_page", uuid=uuid))
assert res.status_code == 200
# The AI / LLM tab link exists...
assert b'#ai-llm' in res.data
# ...and crucially its section now renders for restock_diff (either the configured fields
# block or the "configure a provider" disabled block — both carry this id). Before the fix
# this was absent for restock watches.
assert b'llm-intent-section' in res.data
delete_all_watches(client)