mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-28 16:26:42 +00:00
* Keep history diff title and controls visible while scrolling The watch label, version selectors, diff options and tabs move into a sticky #diff-header, and the top menu above it (watch URL, EDIT, theme and GitHub links) becomes sticky too, so the whole header stays on screen while the diff scrolls. The minimap and anchor jumps offset by the measured height of both bars instead of a fixed value. Heights are measured on load and kept current with a ResizeObserver where it is available, falling back to the window resize and hashchange events as the restock graph and queue sparkline already do, plus one deferred measure at load - the tab switch hides #settings and so resizes the header without a window resize, and opening a link already at #screenshot does the same with no hashchange to follow. The app header is stored before the diff header is measured, since the latter's cap is derived from the former. A sticky bar must be opaque to the content scrolling beneath it, but the page's own backdrop is a fixed full-viewport gradient layer over the page colour, so a flat --color-background panel reads as a white band cutting across it. Instead each bar repaints that same backdrop via a shared page-surface-gradient mixin: the three gradient stops pre-composited against --color-background-page with color-mix() at the layer's 0.91 opacity, with background-attachment: fixed so they stay registered with body::after. Children of #diff-header are centred with a flex column, mirroring the align-items: center that section.content applies to the non-sticky siblings, so the controls panel and tabs keep their intrinsic width and standard colours. The top menu is made sticky only under body.difference-page. Upstream already wants this globally (see the @todo in parts/_top_menu.scss) but held off because the bar has no background of its own; scoping it here keeps that decision separate. Sticky makes .header a stacking context, so its mobile drawer is capped at the bar's z-index of 30 - high enough to cover #diff-header, low enough that the action rail's hover flyout and toast notifications still paint over the bar as before. The activity strip is fixed on body in the root stacking context, so it would have painted through the capped drawer; on this page it drops just below the bar rather than raising the bar past the rail. Dropping the 40px section.content padding and tightening the title's own padding closes the gap between the two bars, and #diff-header's half-viewport cap now excludes the top menu so the sticky stack stays within the same budget on short viewports. .app-main's 0.55rem gap goes too on this page: it sits between the two sticky bars, so the diff header would otherwise start 8.8px below the top menu and slide up to meet it over the first 8.8px of scroll. That gap cannot survive sticking - it is outside both bars, so the diff would scroll through it - and the bars are flush at every scroll position instead. Co-authored-by: Engineer <engineer@agents.matrixsi.com> Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Condense the history diff header into a compact toolbar The sticky bar the previous commit introduced takes 297px of a 1000px desktop viewport and 422px of a 390x844 phone - at that width its own 50dvh cap is already clipping it, so the shipped page scrolls a header inside itself before the diff has moved at all. Compact it to three rows and that becomes 154px and 228px, without hiding any control. The watch title takes the full width of the bar on its own row above the controls, centred, 0.9rem, one line with an ellipsis. It truncates only when it is wider than the whole viewport - a 184-character title shows in full at 1440px and ellipsizes at 768 and 390 - and the bar keeps exactly the same height either way, which a two-line clamp would not. The full text goes in the title attribute so hover reveals what the ellipsis hides, the same bargain the watch URL above it already makes. The From/To labels shrink to chips rather than hiding. display: none takes a <label> out of the accessibility tree: verified through Chromium's accessibility tree, the selects report "From" and "To" with the chips and "" without them. Sighted users need them too, since two identically formatted datetime selects side by side have only their order to tell them apart, and on a phone they stack so even that stops helping. The chips cost horizontal space and no height at all. Dropping the shipped width: 4rem sizes them to their text, and "From" is wider than "To", so a min-width floor keeps the stacked selects flush. The seven diff options move behind a Filters button. The fieldset stays inline, inside the form, until diff-overview.js swaps it for the popover, so with scripting off the options are exactly as reachable as they are today. The panel is position: fixed, not absolute: #diff-header carries overflow: auto to enforce its 50dvh cap, which clips an absolutely positioned descendant at the bar's bottom edge. The bar is sticky with no transform, filter or contain, so it is not a containing block for fixed and the panel escapes the clip; the script parks it under the button and keeps it inside the viewport, with aria-expanded, Escape, outside-click and a reposition on resize. The keyboard-nav prompt and the two button words are hidden, leaving the arrows; both links carry the full wording as aria-label and title so the accessible name survives the CSS. The tabs' vertical padding halves on this page, 11px off every scroll position, leaving their horizontal padding and so their widths alone. Narrow viewports need one more thing, and font-size alone cannot give it: once the chip takes inline width the select clips its own displayed value, and two snapshots from the same day differ only by the meridiem, so they read identically while closed. #settings, #diff-form, .diff-fieldset and the span are all sized to max-content, which is the select's text, so the container shrinks by exactly as much as the text does - headroom measured 0.0px at every size from 0.9rem to 0.7rem. The chain is pinned to the row first and only then does 0.75rem buy room: +19.3px on the demo pair and +6.3px against the longest realistic English value, which still clips at 0.8rem. All of it hangs off the one 700px breakpoint, and every selector in it carries an ID because the rules it overrides are written with one and a class-only override would lose silently. Co-authored-by: Architect <architect@agents.matrixsi.com> Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Rebuild translation catalog for the new diff header strings The compact header adds three msgids - Filters, Previous version and Next version - and the lint-translations job re-runs extract_messages, update_catalog and compile_catalog and fails on any resulting diff, so the catalog has to travel with the strings that created it. It failed on297bd365and took the four test-application jobs down with it, since they depend on it. Purely mechanical: the extract adds exactly those three entries to messages.pot and to each of the seventeen catalogs, all with an empty msgstr, and changes nothing else. The .mo files are unchanged, because an empty msgstr is not written to the compiled catalog. POT-Creation-Date is deliberately left at its committed value rather than restamped. The job filters that line out of the diff it checks, so restamping buys nothing, and 0.60.4 rewrites the same header lines - a restamp here would conflict with the release commit over a timestamp. Kept out of the header commit so the change being reviewed stays the template, the stylesheet and the script. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Make the sticky diff header full-bleed and pin its cap to svh Two iOS-only faults in the sticky bar, both reported from a real iPhone and neither reproducible in Chromium, emulated or not. Both are properties of43b07d5brather than of the compact toolbar on top of it. section.content insets its children by $common-gap so they clear the left rail, while the top menu above stays flush against it, so the bar left an 8.8px strip of real page background down each side. That strip is invisible only while the bar's gradient stays aligned with the page's, which is what background-attachment: fixed buys - and iOS Safari does not honour it. There each box samples its own origin, so the bar paints the start of the ramp (#5ad8f7) while the strips paint whatever the document shows at that scroll offset (#9150bf at depth), and they read as mismatched borders that hold still while the diff scrolls past. Cancelling the inset removes the thing that has to stay matched, on every engine, instead of blending across it. The width has to come from align-self: stretch, not width: 100%. .content-main centres its children, so an item is fit-content unless it stretches, and a percentage resolves against the parent without the negative margins - it would leave the bar 17.6px short. Measured flush against the top menu at 1440, 768 and 390: 1358.4/1358.4, 768/768, 390/390. Note the desktop figure is not the 1440px viewport, because the rail takes 81.6px; the invariant is the top menu's box, not the window's. The height cap moves from dvh to svh. dvh tracks the dynamic viewport, which on iOS grows and shrinks as Safari's toolbar collapses and expands during a scroll: the cap breathed mid-scroll, changing the header's height and shifting the content below it, and each transition also fired resize and re-ran the sticky measurement behind it. svh is defined as the toolbar-expanded minimum and holds still for the life of the page. It is the smaller of the two, which is the safe side to be wrong on for a cap, and the plain vh line stays as the fallback for engines without the new units. This one has no Chromium instrument - there is no dynamic toolbar to simulate - so it is verified on the device, not in the harness. No control moves and no height changes: the bar grows outwards by exactly the margin it takes back, and the sticky stack still measures 153.9px at 1440, 230.7px at 768 and 227.5px at 390, the same as before this commit. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Stop the tab wrap verdict depending on which tab is selected base.html decides body.wrapped-tabs - the vertical tab stack - by flipping the tab list to flex-wrap and checking whether any tab lands on a second row. It re-runs on load, on resize and on every tab switch. Both sides of that comparison move, so in a band of widths the answer changes per tab: a phone showed the three tabs in a row in one screenshot and stacked in a video of the same page. The container moves because _tabs.scss says, in upstream's own comment, that .tabs must take the full width of the centred column "so the wrap-detector JS has the real available space to measure against" - and the two properties that would do it sit commented out directly beneath it. Inside the compact header, which centres its children, .tabs is therefore sized to its own content and the detector measures against a container that follows the thing it is judging. align-self: stretch here fixes that, scoped to this page; un-commenting the global lines would change every page in the app and this branch has no business doing that. The tab row is re-centred on the ul, which is a grid, so the visual is unchanged. The content moves because the active tab's label is bold. At 355px the Text and Extract Data states measure 332.9 and 330.5 against a 339.0 container and stay on one row, while Current screenshot - the widest label - goes over and wraps to three. Removing only the bold rule collapses all three states to 330.5 and the flipping stops, which is what identifies the weight rather than the container as the second variable. Dropping the bold on this page is the fix: the active tab already carries its own background-colour and its own text colour from _tabs.scss, so it loses the third of three signals and nothing else. Reserving room for the bold instead - a hidden bold copy behind every label - stabilises it too, but widens the row ~14px in every state, which pushes 360px from one row into the stack. Measured stable across 320, 344, 350, 355, 358, 360, 375, 390 and 412px, with the one-row layout now holding down to 350px where before it was unstable from 350 to 358. iOS put the same band at the phone's own width because its fonts are wider. Nothing here is engine-specific - the instability is arithmetic, and Chromium reproduces it once you look at the widths where the numbers cross. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Keep the Filters popover reachable and anchored on a small screen Two defects in the same placement code, both outside the viewport range the desktop frames covered. The panel is position: fixed, because #diff-header carries overflow: auto for its 50svh cap and would clip an absolutely positioned descendant. Fixed also means nothing the document does can scroll the panel: whatever hangs off the bottom edge at placement time stays unreachable. It was parked below the button at full height regardless, so in a landscape phone viewport - 844x390 - it spanned y=150.4 to 431.6 and the last three filters sat past the 390px edge. Replaced, at y=396.6..423.6, could not be clicked at all. place() now measures the room below the button and caps max-height to it, and the panel scrolls inside that cap. Room below is always the roomier side, so there is no flip to decide: the bar's own cap keeps its bottom edge inside 50svh, so below the button there is always at least half the viewport. The harness asserts that premise rather than trusting it. The cap is handed to max-height less the panel's own padding and borders, since the panel is content-box and switching it to border-box would fold the padding into min-width and narrow it by 24px. The bar is also its own scroll container, which the code did not account for - its comment claimed only a resize could move the button out from under the panel. On a short viewport the 50svh cap engages and #diff-header scrolls independently: 16px of scroll at 390x390 moved the button 16px while the fixed panel stayed at y=171, and 30px at 320x480. The popover visibly comes away from its trigger and can end up over the wrong control. It now re-places on the bar's scroll, and closes instead once the button has scrolled out of the bar altogether - reachable below 280x300, where the bar has 120px of travel. Verified in Chromium at 844x390, 740x360, 390x390, 320x480, 280x300, 390x844 and 1440x1000: the panel stays inside the viewport at every one, the last filter is clicked for real rather than asserted on by rect, and the gap between button and panel holds at 4px across the bar's whole scroll range. All seven fail on the tree without this change - twelve failures, the click timing out on the three short viewports. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Keep the Filters popover's height budget current with the visual viewport place() already budgets against the smaller of documentElement.clientHeight and visualViewport.height, because a fixed element is positioned against the layout viewport while iOS shrinks the visual one behind its chrome. But the open panel was re-placed only on window resize and on #diff-header scroll, and neither of those fires when the visual viewport alone changes - an expanding toolbar or the on-screen keyboard does exactly that. The cap then stays at the height it was computed with and the bottom filters go back behind the chrome, which is the failure the cap was added to prevent. That half of place() was recorded as unverifiable, on the grounds that Chromium has no dynamic toolbar and the two viewports are always equal here. They are equal only at page scale 1. CDP Emulation.setPageScaleFactor is pinch-zoom: it shrinks the visual viewport against a layout viewport that does not move, and fires VisualViewport resize - the same split iOS produces. So both halves are measurable, and the harness now measures them at 390x844@2, 844x390@1.5 and 390x390@1.5: * placing while the visual viewport is already the smaller one caps the panel to it - 85.6px against a layout-budget 215.6px at 844x390 - which is what shows the Math.min binds rather than sitting inert; * on the tree without this commit, shrinking the viewport under an open panel left the cap untouched and the panel overhung the visible area by 122px at both short viewports and 46.8px at 390x844. Three failures, one per viewport, and none anywhere else in the harness. The probe asserts its own instrument before its result: unless the scale really splits the two heights and the page really receives the resize event, it fails rather than passes. It also fails if no viewport in the set caps more tightly than the layout height would have, since then the visual-viewport budget is untested, and if a viewport's panel already fitted before the shrink, since then it cannot detect a stale cap. The re-placed cap lands on the same value as placing fresh at that viewport (210.4, 85.6, 65.0px), and the panel's width is unchanged by the re-placement. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Stop the sticky bars' backdrop depending on background-attachment: fixed Reported from an iPhone: on the diff page the sticky bar sweeps cyan to purple as you scroll while the page behind it holds still, and snaps when you scroll back up a little. The gutters beside it are correct throughout, so the page's own backdrop - body::after, a real position: fixed layer - is fine; it is the bars' copy of it that moves. page-surface-gradient asked for that copy with background-attachment: fixed, which makes the viewport the positioning area. iOS Safari does not honour it and anchors the layer to the document instead, so each bar shows whatever slice of the ramp its document position lands on. Chromium does honour the property, which is why every emulated check passed and only a device caught it. Derive the alignment from geometry instead, and ask for no engine feature at all. body::after is a viewport-wide, 100vh-tall box pinned to the viewport's top-left corner. Each bar keeps a constant viewport offset - .header at the top, #diff-header at --app-header-height, which the ResizeObserver already keeps current - and both are flush with the viewport's right edge at every width. So size the copy to the viewport, anchor it to that right edge, and push it up by the bar's own offset: the bar then paints body::after's own slice by construction, on any engine. Three details that are load-bearing: * 100vw, not 100%. The two bars are as wide as the viewport only on a narrow screen; at 1440 the action rail insets them by 81.6px (and widens further on hover), so a percentage would compress the whole ramp into what the rail leaves. Nothing moves their right edge, which is why that is the anchor. * background-origin: border-box. #diff-header carries 1rem of side padding and 0.25rem on top, and the default padding-box origin would offset the copy by exactly that. * longhands, with the solid-colour fallback shorthand first and no shorthand after them - a later `background:` line silently resets size, position and origin to their initial values. The Filters popover has no constant viewport offset of its own, so diff-overview.js hands it the one it just computed, in the same place it sets top and left. Measured in the harness against the page itself: shot B hides .app with visibility: hidden, which leaves body::before/body::after painting alone with layout and scroll untouched, so "seamless" is a pixel comparison rather than a judgement. Worst per-channel distance from that backdrop, over 40 samples at 1440x900, 768x1024, 390x844, 844x390 and 320x600, each at three scroll offsets and with the popover placed twice: bars popover before, attachment honoured 3 6 before, attachment denied 24 112 after, attachment honoured 3 6 after, attachment denied 3 6 "Denied" injects background-attachment: scroll !important - it takes away the one property iOS ignores. The before/after rows differ only in whether the engine grants it, and the after rows are identical to each other: the code path no longer has an opinion about it. That denial is not a full iOS emulation - WebKit anchors the layer to the document, which also produces the sweep, and nothing in Chromium reproduces that - so the device pass is still the gate on the sweep itself. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Give the diff page's screenshot tab the short label the extract page uses On a phone the three tabs render as a full-width vertical stack. base.html's wrap detector flips the page into body.wrapped-tabs when the tab row does not fit its container, and iOS font metrics put this row over the line at 390px - an iPhone's width.01420ac7stopped the verdict flip-flopping between tabs; it did not give the row room to fit. The extract page's tab for the same pane is called "Screenshot". It renders on one line on the same phone at the same width, which is the evidence this change rests on: the two pages then carry the identical three labels, the diff page's row asks for no more width than the extract page's, and #diff-header offers it slightly more room. Measured at 390px: diff page, this change row 275.4px offered 374.0px extract page row 280.8px offered 372.4px diff page, before row 330.6px offered 374.0px So the phone's own extract-page row settles the diff page, with no iOS font model in the argument. In Chromium the narrowest viewport that still fits on one line moves from 347px to 292px. "Screenshot" is also what translations/README.md asks for - the shortest suitable wording - and it makes the two sibling pages agree, which they should have done anyway. The catalogs are regenerated for the moved msgid reference; the empty msgstrs this exposes are filled in the commit that follows. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Keep the tab row inside the sticky bar when the bar runs out of room Reported from an iPhone held sideways: the diff page's tabs are not there. The bar's height budget is max-height: calc(50svh - the top menu) with overflow: auto, and in landscape its contents do not fit inside that - iOS gives the two datetime <select>s enough intrinsic width that From and To each take their own row, so the bar wants title + From + To + Filters + tabs where it has room for about three of them. What scrolls out of the visible box is the last child: the tab row, which is the page's navigation, with no scrollbar on a touch screen to say it is there. This one does not need the device. Chromium overflows the same bar on its own at 736x414, 667x375, 780x360, 844x330 and 844x300, and at 844x390 with From/To forced onto their own rows - measured as the tab row's rect falling outside So spend the budget on the row that can afford to give. #settings is the only child allowed to shrink and the only one that scrolls; #diff-watch-title and .tabs are flex: none and are therefore inside the bar at any viewport height. The shrink needs min-height: 0 on #settings - a flex item's automatic minimum size is its content, which is exactly what was pushing the tabs out. diff-overview.js follows the Filters button while the bar scrolls under the fixed panel, and the element that scrolls is now a descendant rather than the bar itself. Scroll events do not bubble, so that listener moves to capture; it then catches #settings and #diff-header's own last-resort scroll alike, and the close-when-the-button-leaves-the-bar test is unchanged because it already compares against #diff-header's rect. Verified: the tab row is inside the bar at all nine viewports above, and with the pre-change diff.css and diff-overview.js served back over the wire in the same run, six of the nine fail - the check is not watching an empty room. The popover drift probe in the harness was retargeted at whatever inside the bar actually scrolls, and proved red first: with the capture flag removed the panel drifts 3.0px off its button at 390x390 and 12.0px at 280x300. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Close the seam between the active tab and the diff card Reported from the device: a gap between the tabs and the content on Text and Screenshot, and none on Extract Data. It is #diff-header's own 0.25rem of bottom padding. The tab row is the bar's last child, and the active tab and #diff-ui below it are both var(--color-background) - so those 4px paint the page gradient across what is otherwise one continuous white surface, and the tab stops reading as the front edge of the card it belongs to. Extract Data looks right because its tabs are not in a bar at all: the same measurement on that page is 0. Measured at 1440x900, 768x1024, 390x844, 844x390 and 320x600, on both the Text and Screenshot tabs: 3.2-4.0px before, and -0.8 to 0.0 after, with the tab and the card confirmed to be the same computed colour in every frame. The check fails only on a positive gap: a sub-pixel overlap of white on white has nothing to show. The top padding stays - nothing sits above the title. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Land at the top of a tab's pane when the switch resizes the page under it Reported from the device: the Screenshot tab starts scrolled down a bit. The recording shows it plainly - for about a second after the tap the tip's first line sits behind the sticky bar, then Safari settles it. Order of events. The browser performs the fragment jump to #screenshot first. Only then does this file's own hashchange handler hide #settings, and the outgoing #text pane stop being :target - which takes the document from ~8000px to a fraction of a screen. The engine is left holding a scroll offset for a page that no longer exists, and clamps it to the new maximum rather than re-running the jump it has already done. On iOS that maximum is never zero: styles.scss floors the shell at min-height: 100vh, and 100vh there is the toolbar-collapsed viewport, so with the toolbar showing the document outruns the visual viewport by the toolbar's own height and the stale offset has somewhere to survive. So re-run the jump once the layout has stopped moving. scrollIntoView rather than scrollTo(0, 0): .tab-pane-inner already declares the offset the sticky stack needs as scroll-margin-top, and this asks for the same alignment the browser was asked for, against the layout that actually resulted. setTimeout(0) rather than requestAnimationFrame, because that scroll-margin-top is written in terms of --diff-header-height and diff-render.js's ResizeObserver updates it during the rendering update, after animation frame callbacks have run - and hiding #settings is precisely what changes it. Honest limit: Chromium is not the engine that gets this wrong. With the document floored above the viewport it lands 1px off before this change and 0 after, at 390x844 and 390x500 - the right direction, but a pixel is not the line the phone showed. The mechanism above is read off the recording and the code, not reproduced here, and the device is what settles it. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Drop the sticky design on a short viewport rather than compensate for it A phone held sideways is the one place the sticky bar cannot pay for itself. 50svh of a 390px-tall viewport is 195px, the top menu takes 55 of that, and what is left has to hold From, To, Filters and the tab row - three of those on their own lines, because iOS gives the two datetime selects enough width that From and To cannot share one. The previous two commits kept the tab row inside the bar by shrinking #settings into a scroller, then took the title and some padding back to widen the margin. Both were right about the budget and neither made the bar usable: the reporter's answer was still "it's only showing the From line and I have to scroll", and that is reproducible here - at 780x360 and 844x330 the To selector, the Filters button and both version arrows are clipped out of So below 500px of viewport height, give up the budget instead of spending it. Both bars become ordinary static blocks: no height cap, no inner scroller, title and every control present at once, and the page scrolls as one document. Neither bar keeps its pre-composited copy of the page gradient, which is not optional - that copy is aligned by pushing it up by the bar's own *constant* viewport offset, and a bar that scrolls has no constant offset, so it would slide out of phase with body::after's fixed layer. With nothing scrolling underneath, the real fixed backdrop shows through and is aligned by construction. The trade, plainly: in landscape and in a short desktop window the controls and the tab row scroll away with the diff, so switching tabs means scrolling back up. That is the right side to be wrong on when the alternative is controls you cannot see. Portrait and the desktop are untouched and still sticky - asserted, not assumed. Three things this turned up that the plan did not anticipate, each of which the change itself breaks and so belongs here: * The minimap is sticky inside the diff card at an offset equal to the sticky stack's height. With that stack gone it would pin itself 150-227px down the viewport under nothing - past halfway on a 390px-tall one. Pinned to the viewport instead, which is now free. * The Filters panel is position: fixed and followed its button only through a capture-phase listener on the bar, because the bar held still and #settings moved inside it. Static mode is the mirror image - nothing moves inside the bar and the bar itself moves with the document - so the panel drifted by the full scroll distance: 200px at 844x390, 926x428 and 1440x480. The same follow/close rule now runs from the window's scroll as well, and carries both closing tests; each is inert in the mode it was not written for. * place() had no flip-above branch, and compact_verify.py carried the reason as an assertion: the bar's own 50svh cap kept its bottom edge inside the top half of the viewport, so below the button was the roomier side by construction. Removing the cap removes the premise, and that assertion is what caught it - at 280x300, 147.6px above the button against 104.2px below. place() picks the roomier side again. The load-path scroll fix is the fourth, and it is the one worth reading twice. upstream's tabs.js rewrites an empty hash to the first tab's, and the browser performs that jump the moment the diff is built - before diff-render.js has measured the bar, so scroll-margin-top is still resolving against its --diff-header-height: 0 fallback. Traced at 844x390: the jump lands at scrollY 231 against a 16px margin at t=132ms, and the margin becomes its real 202px at t=167ms with the offset left where it was. Under a sticky bar that is invisible, because the chrome is pinned whatever the offset is; with the bar in flow the same 231px put every control above the top of the page and a landscape phone opened on a bare diff. So the alignment re-runs when the quantity it was wrong about settles - the bar's own size - guarded on the reader still being where the last alignment left them, so that rotating the phone mid-read cannot throw someone back to the top of the pane. Portrait benefits too: the page used to open 224px into the text diff and now opens at 1. .tab-pane-inner's scroll-margin-top is deliberately NOT dropped to 1rem here. It reads as an allowance for pinned chrome and therefore like dead weight once nothing is pinned, but the same number is right in both modes for two different reasons: sticky, it clears chrome painted over the pane; static, it keeps chrome that sits above the pane on screen. The comment says so, next to the measurement. 20d75707, which hid the watch title in landscape to buy budget room, is rebased out rather than left in and undone - there is no budget to buy any more, and the question of whether hiding it was acceptable goes away with it. Verified in four browser runs that vary the two changed files independently, because the popover check needs the new stylesheet with the old script: serving both old puts the page back in sticky mode, where it passes for the wrong reason. Every run reads the served diff.css and diff-overview.js back over the wire, hashes them, and fails if they are not the pair it meant to serve. On the branch: PASS, no failures. Reachability went red at 10 of 14 short viewports on the old pair, the gradient copy at 7, the minimap at 7, the load landing at 5, and the popover drift at 3 on the new-CSS/old-JS pair. The full branch harness and the previous round's probe both re-run green. One instrument repair, and it had been green while measuring nothing. The popover drift check scrolls whatever inside the bar can move and re-reads the gap - but page.click() scrolls the toggle into view to click it, which leaves that scroller already parked at its maximum, so "scroll to the maximum" was a no-op and the check compared two identical readings. It now drives the scroller to whichever end it is not at, and proves the point by comparing the two readings rather than by testing for non-zero. Re-proved red on the real code: with the capture flag removed the panel drifts 45px off its button. Nothing inside the bar overflows in this engine at any viewport any more - that was always iOS's wider metrics - so the condition is forced with an explicit max-height rather than hunted for. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Give the tab-to-card seam a whole pixel of overlap instead of none The thin blue line under the active tab is the page gradient, not a border - nothing at that boundary has one, the active tab and #diff-ui are both var(--color-background). It can only be a device-pixel row where neither white paints, and whether such a row exists comes down to a single number: the bar's bottom edge minus the card's top edge. Measured across 14 viewports at device pixel ratios 1, 2 and 3, on both the Text and Screenshot tabs, that number is 0.813px, 0.203px or exactly 0.000 - 84 readings, not one of them a whole pixel, and 0.000 at 390x844 on the Screenshot tab, which is the exact case reported. At zero the outcome is not a decision the stylesheet makes; it belongs to whatever the engine does with two edges on the same coordinate, which is why a DPR 3 phone can show a line a DPR 1 desktop does not. Chromium rounds the same way on both sides and paints no sliver at DPR 3 either - walking the bar's height in thirds of a pixel did not produce one. So this is the margin being fixed, not a pixel being chased: after the change the worst of the same 84 readings is exactly 1.000px. The alternative - a white skirt under the active tab, box-shadow: 0 1px 0 - would have been more precisely scoped and does not work. The shadow hangs below the bar's last child and #diff-header's overflow: auto, which is what enforces its height cap, clips it. Measured with the card held 3px away so the skirt and the card are separable: 0 device rows of the 3 it needs at 844x390 and 926x428, and 0 to 2 in the sticky modes. The negative margin is what survives the clip, because it moves the card rather than painting past an edge. Scoped to body.difference-page, which matters more than it looks. diff.css is not diff-page-only: preview.py and processors/extract.py serve it too, both pages have a #diff-ui of their own, and neither has a bar above it to absorb the pixel. Asserted rather than reasoned - the preview page's computed margin-top is 0px with this stylesheet and with the previous one, while the diff page's moves to -1px. The comment on the activity-strip rule above claimed diff.css was loaded only by the diff page; that was wrong and is corrected, since the scope it describes is what actually does the work. What the overlap costs, stated rather than left to be found: where the bar is sticky, nothing. A device-pixel diff of the seam band at 390x844 and DPR 3 comes back with zero differing pixels, because the bar is opaque and paints over the card. Below the short-viewport breakpoint the bar has no background of its own, so the card's white also reaches the bottom pixel of the inactive tabs and the 5px gaps between them - 4374 device pixels at 844x390, gradient turning white, and side-by-side crops of the two are indistinguishable. The tab row ends up sitting flush on the card instead of floating a pixel above it, which is how tabs normally meet their panel and is the same continuity the active tab was asked for. Full branch harness, the previous round's probe and this round's all re-run green against the change. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Reuse upstream's Previous and Next for the version arrows' labels297bd365gave the version arrows `title` and `aria-label` of their own, "Previous version" and "Next version". Those are two msgids this branch introduced and no catalog has ever carried, so on a German or Japanese page the arrows' accessible name is English. The upstream msgids `Previous` and `Next` say the same thing in this context - the arrows live inside the version bar, between the From and To selectors - and are non-empty in 15 of 17 catalogs; the two empty ones are en_GB and en_US, where the English msgid is the correct rendering anyway. The visible span on each arrow already uses these msgids, so the accessible name and the (header-hidden) label now agree instead of differing by a word. The cost is that the accessible name drops "version": a screen reader says "Previous" rather than "Previous version". On a control that sits between two datetime selectors that is enough, and it is the price of not asking fifteen translators for a string they can already spell. The catalogs are regenerated: both msgids disappear from the .pot and from all 17 .po files. No .mo file changes, because neither msgid had a msgstr anywhere to compile. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Correct the French translation of "Previous" fr renders the msgid `Previous` as "Aperçu", which is French for *preview*. That is a wrong word, not a style choice: the same catalog gives "Aperçu" as the msgstr for `Preview` and for `Activate preview`, and it evidently leaked from there. The msgid renders in exactly two places, both of them prev/next pairs - this page's version arrows and preview.html's own pager - and fr already translates `Next` as "Suivant", so "Précédent"/"Suivant" is the pair the catalog is otherwise using. On the preview page the current string produces an "Aperçu" button on the page called Aperçu. Left alone deliberately: de renders `Previous` as "Zurück" (*back*). That is defensible on a back arrow and is a translator's call, not an error. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Put the watch's name on the top line and drop the diff page's second title row The diff page printed the same thing twice. The menu's top line has always shown the watch URL, and297bd365added a sticky <h1> under it showing watch.label - which for any watch without a custom title *is* that URL. Two rows of chrome, 30px of the sticky budget, and on the common case the identical string on both. dgtlmoon's read (#4412): put it all on the top line, use the title if it is set, and take back the left inset while we are there. That is what this does, and it removes code rather than adding any. The top line's <a> is unchanged as a link - same href, same fade-out mask, same flex-shrink that keeps a long value off the menu icons. Only its text changes: current_diff_label (watch.label - title, then the fetched page_title, then the URL) with the URL as the fallback, so an untitled watch reads exactly as it does today. The five views that already pass current_diff_url pass the label beside it. The hover text now carries both, label then URL on its own line, and only when they differ. A title covers the URL that used to be printed here, and the URL is also where the link goes - so it has to stay readable without following it. That also keeps the long-title-on-long-press property the <h1> had. The heart hides under $desktop-wide-breakpoint on this page only. That is the width where the hamburger appears and the row is at its tightest, so it is where the name needs the space; on a desktop diff page and on every other page the heart is untouched. It is a media query rather than a {% if %} because the heart's reason to exist is that it is always there - folding it away with the rest of the narrow row is a different thing from taking it off the page. Left-justification: the line's text started $common-gap twice in - the menu row's own padding plus the link's own left margin. On the row's first item the second one only repeats the first, so it goes and the row's padding stays. 8.8px back, and the line still shares a left edge with the diff below it rather than sitting hard against the viewport. The right side of both is untouched. No new or removed translatable strings, so no catalog churn. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Fill the empty msgstr cells this page's own strings left behind 30 cells across 15 catalogs. No new msgids: extract_messages produces a byte-identical .pot (bar POT-Creation-Date) and update_catalog leaves every .po unchanged, which is the control that nothing here is a source change wearing a translation's clothes. Filters - empty in all 15 non-English catalogs since the diff page's options popover was added. Each value is the plural form that catalog already uses for "Global Filters" and "Filters & Triggers", not a fresh translation: Filtry, Filter, Filtros, Filtres, Filter, Filtri, フィルタ, 필터, Filtry, Filtros, Фильтры, Filtreler, Фільтри, 过滤器, 過濾器. Screenshot - this is a regression the diff-header work introduced and owes. The tab used to read "Current screenshot", which every catalog translates; shortening it to "Screenshot" moved the tab onto a msgid that nine of them (cs, de, es, fr, it, pt_BR, tr, uk, zh) had never filled, so those users got an English tab where they had a translated one. Each value is that catalog's own "Current screenshot" with the qualifier dropped - Snímek obrazovky, Captura de pantalla, Capture d'écran, Ekran görüntüsü, Скріншот, 截图 - and for de, it and pt_BR the word those catalogs already use is literally "Screenshot" (Aktueller Screenshot / Screenshot corrente / Screenshot atual), which is what dennis W302 flags and CI excludes. Italian's five pre-existing gaps on this page, deliberately in scope: the guide's multi-language rule is to fix every affected catalog in one session rather than fix one language and move on, and an Italian diff page was showing untranslated From, To, Ignore Whitespace, Keyboard:, Extract Data and Jump to next difference next to strings that were translated. Ignora spazi and Estrai dati come from the same catalog's "Ignore whitespace" and "Extract text" / "Extract as CSV"; successiva from its "Next" -> "Successivo" and differenza from its "Pixel Difference Sensitivity". Compiled with setup.py compile_catalog. en_GB and en_US recompile byte-identical, so the 15 changed .mo files are exactly the 15 changed .po files. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Let the watch title be dragged when it is too long to fit Asked for on the PR: "does the title at the top accept overflow scroll hidden so it can be scrolled with your finger/touch/drag?" It could not - .current-diff-url was overflow: hidden with a fixed right-edge fade, so the end of a long title was reachable only by hovering for the title attribute, which a phone does not have. It is now a horizontal scroll container with its scrollbar hidden in both engines. A tap still follows the link; browsers distinguish a pan from a click, so the anchor keeps its navigation. overflow-y stays hidden because the content is one non-wrapping line and an axis that can move by a rounding error lets a diagonal swipe jog the text. The fade has to follow the scroll, or the ending someone just dragged over to read is the part that is dimmed. static/js/scrollable-title.js maintains fade-left / fade-right against scrollLeft and adds js-fades to say it is doing so; under that class the CSS drops the static mask and paints only the edge that is actually cut off, or a two-ended gradient when both are, or nothing at all when the title fits. The static right-hand fade stays as the no-JS default, which is correct there because without a handler the line can only ever be cut on the right. In base.html rather than diff-overview.js: restock_diff/difference.py and image_ssim_diff/preview.py both render this line and neither loads that file. The script guards on the element existing, so it is inert on every other page. Accepted tradeoff: with the anchor focused, ArrowLeft/Right still runs version navigation on diff pages via the existing window keydown handler rather than scrolling the line. Keyboard users keep the hover title attribute, as today. styles.css is spliced, not rebuilt. parts/_top_menu.scss compiles into styles.css, whose committed bytes do not match a fresh compile of its own scss - an older sass folded rgba(255,255,255,X) to hsla(0,0%,100%,X) and hoisted nested declarations in a different order (#heartpath, .button-tag, .tab-pane-inner, .watch-table img.favicon, #browser_steps li and more). That drift is upstream's and predates this branch. So rather than commit a full rebuild and carry all of it, the scss was compiled twice - at the parent commit and with this change - and only the span that differs between those two compiles was transplanted into the committed file. The drift is identical in both compiles and cancels. The result differs from the parent by 795 bytes, all inside the .current-diff-url rules, and is byte-identical everywhere else; diff.css is untouched. A reviewer who runs npm run build will see a dirty styles.css - that mismatch is the pre-existing drift, not this commit. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Cut diff-overview.js back from a slab to a file Raised on the PR as "a slab of javascript... why cant this be a few lines of jquery". Measured before arguing: upstream's file is 190 lines (144 code, 20 comment) and this branch had grown it to 476 (284 code, 142 comment). Half the growth was comment, written at review density rather than this project's, and that is what makes it read huge. 476 -> 370 (256 code, 72 comment). Every long forensic comment is cut to the constraint it was protecting - the measurement that motivated it survives as a clause, the war story does not. Nothing load-bearing was deleted: the capture-phase scroll listener still says why capture, the visual-viewport listener still says why it exists separately from resize, and place() still says why offsetHeight is read after the cap. Converted to jQuery idiom where that is genuinely shorter, so the file reads in one voice: element lookup, class toggling, open/close, and the event bindings that need no listener options. The geometry stays native, because there is no jQuery shorthand for it - getBoundingClientRect, getComputedStyle, offsetHeight, visualViewport and ResizeObserver are the added code, and popover placement is what they are for. Two registrations deliberately stay on addEventListener and say so in place: the bar's scroll listener needs capture (scroll does not bubble and the element that scrolls is a descendant) and both scroll listeners need passive, neither of which jQuery's .on() can pass. The one structural change is that the four copies of "step both selects one position and rebuild the query string" collapse into diffStepHref(direction), called by the buttons at setup and by the arrow keys at press time. The guards are unchanged: the buttons are only touched when both selects have a selection, the arrow handler still requires both a live step and a button href, and it still returns early while an input, textarea or select has focus. Not done here, and offered instead as a follow-up: moving the popover to the native Popover API. It would delete the dismissal wiring, but placement stays manual without CSS anchor positioning, which is not yet safe cross-browser - and it would churn code that is tested and green. No new translatable strings, so the catalogs are untouched. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> * Point the top-menu comment at the rule it describes The previous commit inserted the fade variable and mixin between this block and .current-diff-url, which left the comment describing the declaration above it rather than the rule it was written for. It also still said the line "trims" its text, which is now only half true - it scrolls, and the trim is what the fade stands in for. Comment only: sass emits byte-identical css, so styles.css is unchanged. Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> --------- Signed-off-by: Jeff Hedlund <jhedlund@gmail.com> Co-authored-by: Surveyor <surveyor@agents.matrixsi.com> Co-authored-by: Engineer <engineer@agents.matrixsi.com> Co-authored-by: Architect <architect@agents.matrixsi.com> Co-authored-by: dgtlmoon <dgtlmoon@gmail.com>
209 lines
7.9 KiB
Python
209 lines
7.9 KiB
Python
"""
|
|
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 statistics
|
|
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 compute_price_summary(series):
|
|
"""Compact price stats for the graph overlays + (future) watchlist deal-score badge.
|
|
|
|
This is the single source of truth for "is the current price low/typical/high". It works
|
|
off an already-built series (cheap, no extra I/O) so the same function can be reused at
|
|
check time to cache the result on the watch and avoid scanning history at render time.
|
|
|
|
Returns None when there are no prices.
|
|
"""
|
|
prices = [p['price'] for p in series if p.get('price') is not None]
|
|
if not prices:
|
|
return None
|
|
|
|
count = len(prices)
|
|
current = prices[-1] # series is oldest -> newest, so last priced value
|
|
ordered = sorted(prices)
|
|
mn, mx = ordered[0], ordered[-1]
|
|
avg = sum(prices) / count
|
|
|
|
if count >= 2:
|
|
try:
|
|
q = statistics.quantiles(prices, n=4, method='inclusive') # [p25, p50, p75]
|
|
p25, median, p75 = q[0], q[1], q[2]
|
|
except statistics.StatisticsError:
|
|
median, p25, p75 = statistics.median(prices), mn, mx
|
|
else:
|
|
median = p25 = p75 = current
|
|
|
|
# Two directional shares so the UI can phrase it naturally per status:
|
|
# cheaper_than_pct = share of records MORE expensive than now (good when price is low)
|
|
# pricier_than_pct = share of records CHEAPER than now (relevant when price is high)
|
|
cheaper_than_pct = round(100.0 * sum(1 for x in prices if x > current) / count)
|
|
pricier_than_pct = round(100.0 * sum(1 for x in prices if x < current) / count)
|
|
|
|
if current <= p25:
|
|
status = 'low'
|
|
elif current >= p75:
|
|
status = 'high'
|
|
else:
|
|
status = 'typical'
|
|
|
|
r2 = lambda v: round(v, 2)
|
|
return {
|
|
'count': count,
|
|
'min': r2(mn), 'max': r2(mx), 'avg': r2(avg), 'median': r2(median),
|
|
'p25': r2(p25), 'p75': r2(p75),
|
|
'current': r2(current),
|
|
'cheaper_than_pct': cheaper_than_pct,
|
|
'pricier_than_pct': pricier_than_pct,
|
|
'status': status,
|
|
'all_time_low': current <= mn,
|
|
}
|
|
|
|
|
|
def export_xlsx(watch, datastore):
|
|
"""Build an .xlsx of the full price/stock history. Returns (bytes, filename).
|
|
|
|
xlsx (not csv) so prices stay real numbers and dates stay real dates in the user's
|
|
spreadsheet, regardless of locale number formatting. Columns: Date, Stock status, Price,
|
|
Currency. Served by /diff/<uuid>/processor-export.xlsx.
|
|
"""
|
|
import datetime
|
|
from io import BytesIO
|
|
from openpyxl import Workbook
|
|
|
|
series = _build_series(watch)
|
|
currency = _currency(watch)
|
|
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = 'Price history'
|
|
ws.append([gettext('Date'), gettext('Stock status'), gettext('Price'), gettext('Currency')])
|
|
|
|
for p in series:
|
|
when = datetime.datetime.fromtimestamp(p['timestamp'])
|
|
if p['in_stock'] is None:
|
|
stock = ''
|
|
else:
|
|
stock = gettext('In stock') if p['in_stock'] else gettext('Out of stock')
|
|
cell_date = ws.cell(row=ws.max_row + 1, column=1, value=when)
|
|
cell_date.number_format = 'YYYY-MM-DD HH:MM:SS'
|
|
ws.cell(row=ws.max_row, column=2, value=stock)
|
|
# Real numeric price (None -> blank cell) so spreadsheet number formats apply.
|
|
ws.cell(row=ws.max_row, column=3, value=p['price'])
|
|
ws.cell(row=ws.max_row, column=4, value=currency)
|
|
|
|
# Sensible column widths.
|
|
for col, width in {'A': 20, 'B': 14, 'C': 12, 'D': 10}.items():
|
|
ws.column_dimensions[col].width = width
|
|
|
|
out = BytesIO()
|
|
wb.save(out)
|
|
return out.getvalue(), f"price-history-{watch.get('uuid')}.xlsx"
|
|
|
|
|
|
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),
|
|
'summary': compute_price_summary(series),
|
|
}
|
|
|
|
|
|
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_label=watch.label,
|
|
current_diff_url=watch.open_link,
|
|
extra_title=f" - {watch.label} - {gettext('Price history')}",
|
|
last_error=watch['last_error'],
|
|
screenshot=watch.get_screenshot(),
|
|
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),
|
|
)
|