mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-07-08 08:21:14 +00:00
Restock - adding inline graph
This commit is contained in:
@@ -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">✨ {{ _('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">✨ {{ _('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 ? ' · ' + i18n.in_stock : ' · ' + 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)
|
||||
Reference in New Issue
Block a user