From c68700f0951f06bc17c503d5c9d7221226d74e84 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Mon, 26 Oct 2026 11:41:09 -0300 Subject: [PATCH 01/17] refactor!: keep timer state on the server Keep the elapsed time on the server, as the time banked from previous runs plus the instant the current run started, and make the web component a renderer of that state. The server pushes the elapsed time as a duration when the component is attached, so a timer that is detached and attached again (e.g. when switching tabs) keeps counting instead of resetting to its start time. If the timer reaches its limit while detached, TimerEndedEvent is fired when the component is attached again, with isRetroactive() set. Rewrite the web component as a plain custom element, removing the dependency on Polymer. Add setCountdown, setCountUp, getElapsed, getRemaining, setElapsed and addTimerEndListener, and deprecate the methods they replace. BREAKING CHANGE: start() no longer pauses a running timer; use pause(). getCurrentTime() returns the value computed on the server instead of the last value synchronized from the client. TimerEndedEvent is no longer a DomEvent. The web component no longer exposes the startTime, endTime, countUp and isRunning properties, nor the start, pause and ready functions. Close #39 --- .../addons/simpletimer/SimpleTimer.java | 662 ++++++++++++------ .../frontend/simple-timer/simple-timer.js | 326 ++++----- 2 files changed, 599 insertions(+), 389 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java index 29050f0..d843167 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java +++ b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java @@ -1,210 +1,452 @@ -/*- - * #%L - * Simple Timer Addon - * %% - * Copyright (C) 2019 - 2026 Flowing Code - * %% - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * #L% - */ -package com.flowingcode.vaadin.addons.simpletimer; - -import com.vaadin.flow.component.Component; -import com.vaadin.flow.component.ComponentEvent; -import com.vaadin.flow.component.ComponentEventListener; -import com.vaadin.flow.component.DomEvent; -import com.vaadin.flow.component.HasSize; -import com.vaadin.flow.component.HasStyle; -import com.vaadin.flow.component.Synchronize; -import com.vaadin.flow.component.Tag; -import com.vaadin.flow.component.dependency.JsModule; -import com.vaadin.flow.component.dependency.NpmPackage; -import com.vaadin.flow.dom.PropertyChangeListener; -import com.vaadin.flow.shared.Registration; -import java.io.Serializable; -import java.math.BigDecimal; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; - -/** @author Leonardo Scardanzan / Flowing Code */ -@Tag("simple-timer") -@NpmPackage(value = "@polymer/polymer", version = "3.5.2") -@JsModule("./simple-timer/simple-timer.js") -public class SimpleTimer extends Component implements HasSize, HasStyle, Serializable { - - private static final long serialVersionUID = 1L; - private static final String DISPLAY = "display"; - private static final String INLINE = "inline"; - private static final String CURRENT_TIME = "currentTime"; - - /** Creates a timer */ - public SimpleTimer() { - getElement().getStyle().set(DISPLAY, INLINE); - } - - /** - * Sets the start time, for countdown mode. - * - * @param startTime value in seconds for the start time - */ - public void setStartTime(final Number startTime) { - getElement().setProperty("countUp", false); - getElement().setProperty("startTime", startTime.doubleValue()); - getElement().setProperty(CURRENT_TIME, startTime.doubleValue()); - reset(); - } - - /** - * Sets the end time, for countup mode. - * - * @param endTime value in seconds for the end time - */ - public void setEndTime(final Number endTime) { - getElement().setProperty("countUp", true); - getElement().setProperty("endTime", endTime.doubleValue()); - reset(); - } - - /** - * Enables showing fractions of a second - * - * @param fractions - */ - public void setFractions(final boolean fractions) { - getElement().setProperty("fractions", fractions); - } - - /** - * Enables showing minutes - * - * @param minutes - */ - public void setMinutes(final boolean minutes) { - getElement().setProperty("minutes", minutes); - } - - /** - * Enables showing hours and minutes - * - * @param hours - */ - public void setHours(final boolean hours) { - getElement().setProperty("hours", hours); - } - - /** - * Use two digits for hours. - * - * @param doubleDigitHours true to format hours using two digits. - */ - public void setDoubleDigitHours(final boolean doubleDigitHours) { - getElement().setProperty("doubleDigitHours", doubleDigitHours); - } - - /** Starts or stops the timer if it is already started */ - public void start() { - getElement().callJsFunction("start"); - } - - /** Stops the timer, does nothing if already stopped */ - public void pause() { - getElement().callJsFunction("pause"); - } - - /** Resets the current value to the start time */ - public void reset() { - getElement().callJsFunction("ready"); - } - - /** - * Returns the status of the timer - * - * @return {@code true} if the timer is running, {@code false} otherwise. - */ - @Synchronize(property = "isRunning", value = "is-running-changed") - public boolean isRunning() { - return getElement().getProperty("isRunning", false); - } - - /** - * Returns the last known value of the timer. The value is updated when the - * CurrentTimeChangeListener executes. - * - * @return current value in seconds - */ - @Synchronize("is-running-changed") - public BigDecimal getCurrentTime() { - return BigDecimal.valueOf(getElement().getProperty(CURRENT_TIME, 0d)); - } - - /** - * Returns the current value of the timer. - * - * @return a pending result that completes after retrieving the timer value. - */ - public CompletableFuture getCurrentTimeAsync() { - return getElement() - .executeJs("return this.currentTime") - .toCompletableFuture(Double.class) - .thenApply(BigDecimal::valueOf); - } - - /** - * Adds a property change listener for the {@code currentTime} property - * - * @param listener the property change listener - * @param period the minimum period between listener invocations, or 0 to disable throttling - * @param periodUnit time duration of throttling period - * - * @return this registration, for chaining - */ - public Registration addCurrentTimeChangeListener( - PropertyChangeListener listener, long period, TimeUnit periodUnit) { - int millis = (int) Math.min(periodUnit.toMillis(period), Integer.MAX_VALUE); - if (listener == null) { - listener = ev -> {}; - } - return getElement() - .addPropertyChangeListener(CURRENT_TIME, "current-time-changed", listener) - .throttle(millis); - } - - /** Event that gets triggered when the timer reaches 0 */ - @DomEvent("simple-timer-end") - public static class TimerEndedEvent extends ComponentEvent { - - public TimerEndedEvent(final SimpleTimer source, final boolean fromClient) { - super(source, fromClient); - } - } - - /** - * Adds a timer ended listener that will be triggered when the timer reaches 0 - * - * @param listener - * @return a handle that can be used for removing the listener - */ - public Registration addTimerEndEvent(final ComponentEventListener listener) { - return addListener(TimerEndedEvent.class, listener); - } - - @Override - public boolean isVisible() { - return getStyle().get(DISPLAY).equals(INLINE); - } - - @Override - public void setVisible(boolean visible) { - getStyle().set(DISPLAY, visible ? INLINE : "none"); - } -} +/*- + * #%L + * Simple Timer Addon + * %% + * Copyright (C) 2019 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.simpletimer; + +import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.HasSize; +import com.vaadin.flow.component.HasStyle; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.dependency.JsModule; +import com.vaadin.flow.dom.PropertyChangeListener; +import com.vaadin.flow.shared.Registration; +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * A timer that counts down to zero or counts up to a limit. + * + *

The state of the timer is kept on the server, as the time banked from previous runs plus the + * instant the current run started. The browser only renders it. When the component is detached and + * attached again, the elapsed time is pushed to the new client element, so the timer keeps + * counting while it is not in the DOM. + * + * @author Leonardo Scardanzan / Flowing Code + */ +@Tag("simple-timer") +@JsModule("./simple-timer/simple-timer.js") +public class SimpleTimer extends Component implements HasSize, HasStyle, Serializable { + + private static final long serialVersionUID = 2L; + private static final String DISPLAY = "display"; + private static final String INLINE = "inline"; + private static final String NONE = "none"; + private static final String CURRENT_TIME = "currentTime"; + + /** + * How much earlier than the server computes it a client may report the end. The client renders + * slightly behind the server, so an end reported much earlier belongs to a previous run. + */ + private static final Duration END_TOLERANCE = Duration.ofSeconds(1); + + private Duration limit = Duration.ZERO; + private boolean countUp; + private Duration baseElapsed = Duration.ZERO; + private Instant startedAt; + private boolean endNotified; + private Clock clock = Clock.systemUTC(); + + /** Creates a timer. */ + public SimpleTimer() { + getElement().getStyle().set(DISPLAY, INLINE); + getElement().addEventListener("simple-timer-end", ev -> onClientEnd()); + getElement().addEventListener("simple-timer-sync-request", ev -> sync(false)); + } + + /** + * Sets the clock used to measure elapsed time. Intended for tests. + * + * @param clock the clock to use + */ + void setClock(Clock clock) { + this.clock = Objects.requireNonNull(clock); + } + + /** + * Configures the timer to count down from the given duration to zero, and resets it. A running + * timer keeps running from the new start. + * + * @param duration the duration to count down from + * @throws IllegalArgumentException if {@code duration} is negative + */ + public void setCountdown(Duration duration) { + setLimit(duration, false); + } + + /** + * Configures the timer to count up from zero to the given limit, and resets it. A running timer + * keeps running from zero. + * + * @param limit the duration at which the timer stops + * @throws IllegalArgumentException if {@code limit} is negative + */ + public void setCountUp(Duration limit) { + setLimit(limit, true); + } + + private void setLimit(Duration limit, boolean countUp) { + Objects.requireNonNull(limit, "limit"); + if (limit.isNegative()) { + throw new IllegalArgumentException("limit must not be negative"); + } + boolean running = isRunning(); + this.limit = limit; + this.countUp = countUp; + restart(Duration.ZERO, running); + } + + /** + * Returns whether the timer counts up. + * + * @return {@code true} if the timer counts up to its limit, {@code false} if it counts down + */ + public boolean isCountUp() { + return countUp; + } + + /** + * Returns the duration the timer counts down from, or counts up to. + * + * @return the limit of the timer + */ + public Duration getLimit() { + return limit; + } + + /** Starts the timer. Does nothing if it is already running or has reached its limit. */ + public void start() { + if (startedAt != null || rawElapsed().compareTo(limit) >= 0) { + return; + } + startedAt = clock.instant(); + endNotified = false; + sync(true); + } + + /** Pauses the timer, keeping the elapsed time. Does nothing if it is not running. */ + public void pause() { + if (startedAt == null) { + return; + } + baseElapsed = getElapsed(); + startedAt = null; + sync(false); + } + + /** Resets the elapsed time to zero. A running timer keeps running from the start. */ + public void reset() { + setElapsed(Duration.ZERO); + } + + /** + * Returns whether the timer is running. + * + * @return {@code true} if the timer has been started and has not reached its limit + */ + public boolean isRunning() { + return startedAt != null && rawElapsed().compareTo(limit) < 0; + } + + /** + * Returns the time the timer has been running, up to its limit. The value is computed on the + * server and does not need a round trip. + * + * @return the elapsed time + */ + public Duration getElapsed() { + Duration elapsed = rawElapsed(); + return elapsed.compareTo(limit) > 0 ? limit : elapsed; + } + + /** + * Returns the time left until the timer reaches its limit. + * + * @return the remaining time + */ + public Duration getRemaining() { + return limit.minus(getElapsed()); + } + + /** + * Sets the elapsed time, for instance to restore a timer whose state was saved elsewhere. A + * running timer keeps running from the given point. + * + * @param elapsed the elapsed time, clamped between zero and the limit + */ + public void setElapsed(Duration elapsed) { + Objects.requireNonNull(elapsed, "elapsed"); + restart(elapsed, isRunning()); + } + + private void restart(Duration elapsed, boolean running) { + baseElapsed = elapsed.isNegative() ? Duration.ZERO : elapsed; + baseElapsed = baseElapsed.compareTo(limit) > 0 ? limit : baseElapsed; + startedAt = running && baseElapsed.compareTo(limit) < 0 ? clock.instant() : null; + endNotified = false; + sync(running); + } + + private Duration rawElapsed() { + if (startedAt == null) { + return baseElapsed; + } + Duration run = Duration.between(startedAt, clock.instant()); + // a backwards step of the server clock must not reduce the banked time + return run.isNegative() ? baseElapsed : baseElapsed.plus(run); + } + + /** + * Enables showing fractions of a second. + * + * @param fractions whether to show hundredths of a second + */ + public void setFractions(final boolean fractions) { + getElement().setProperty("fractions", fractions); + } + + /** + * Enables showing minutes. + * + * @param minutes whether to show minutes + */ + public void setMinutes(final boolean minutes) { + getElement().setProperty("minutes", minutes); + } + + /** + * Enables showing hours and minutes. + * + * @param hours whether to show hours and minutes + */ + public void setHours(final boolean hours) { + getElement().setProperty("hours", hours); + } + + /** + * Use two digits for hours. + * + * @param doubleDigitHours true to format hours using two digits. + */ + public void setDoubleDigitHours(final boolean doubleDigitHours) { + getElement().setProperty("doubleDigitHours", doubleDigitHours); + } + + /** + * Adds a listener that is notified when the timer reaches its limit. + * + *

If the timer reaches its limit while it is detached, the listener is notified when the + * component is attached again, and {@link TimerEndedEvent#isRetroactive()} returns {@code true}. + * + * @param listener the listener to add + * @return a handle that can be used for removing the listener + */ + public Registration addTimerEndListener(ComponentEventListener listener) { + return addListener(TimerEndedEvent.class, listener); + } + + @Override + protected void onAttach(AttachEvent attachEvent) { + super.onAttach(attachEvent); + if (startedAt != null && rawElapsed().compareTo(limit) >= 0) { + end(true); + } + sync(true); + } + + private void onClientEnd() { + if (startedAt == null || rawElapsed().plus(END_TOLERANCE).compareTo(limit) < 0) { + return; + } + end(false); + } + + private void end(boolean retroactive) { + baseElapsed = limit; + startedAt = null; + if (endNotified) { + return; + } + endNotified = true; + fireEvent(new TimerEndedEvent(this, false, retroactive)); + } + + private void sync(boolean pull) { + if (!isAttached()) { + // pushed in onAttach + return; + } + getElement() + .callJsFunction( + "_sync", + (double) getElapsed().toMillis(), + isRunning(), + (double) limit.toMillis(), + countUp, + pull); + } + + @Override + public boolean isVisible() { + return !NONE.equals(getStyle().get(DISPLAY)); + } + + /** + * Shows or hides the timer with CSS, so that it keeps communicating with the server while hidden. + * + * @param visible whether to show the timer + */ + @Override + public void setVisible(boolean visible) { + getStyle().set(DISPLAY, visible ? INLINE : NONE); + } + + /** Event fired when the timer reaches its limit. */ + public static class TimerEndedEvent extends ComponentEvent { + + private final boolean retroactive; + + /** + * Creates a new event. + * + * @param source the timer that reached its limit + * @param fromClient whether the event originated from the client + */ + public TimerEndedEvent(final SimpleTimer source, final boolean fromClient) { + this(source, fromClient, false); + } + + /** + * Creates a new event. + * + * @param source the timer that reached its limit + * @param fromClient whether the event originated from the client + * @param retroactive whether the timer ended while it was detached + */ + public TimerEndedEvent( + final SimpleTimer source, final boolean fromClient, final boolean retroactive) { + super(source, fromClient); + this.retroactive = retroactive; + } + + /** + * Returns whether the timer reached its limit while it was detached, in which case the event is + * fired late, when the component is attached again. + * + * @return {@code true} if the event is fired after the fact + */ + public boolean isRetroactive() { + return retroactive; + } + } + + // ---- deprecated API ---- + + /** + * Sets the start time, for countdown mode. + * + * @param startTime value in seconds for the start time + * @deprecated Use {@link #setCountdown(Duration)} instead. + */ + @Deprecated + public void setStartTime(final Number startTime) { + setCountdown(seconds(startTime)); + } + + /** + * Sets the end time, for countup mode. + * + * @param endTime value in seconds for the end time + * @deprecated Use {@link #setCountUp(Duration)} instead. + */ + @Deprecated + public void setEndTime(final Number endTime) { + setCountUp(seconds(endTime)); + } + + private static Duration seconds(Number seconds) { + return Duration.ofNanos(Math.round(seconds.doubleValue() * 1_000_000_000d)); + } + + /** + * Returns the displayed value of the timer: the remaining time when counting down, or the elapsed + * time when counting up. + * + * @return current value in seconds + * @deprecated Use {@link #getElapsed()} or {@link #getRemaining()} instead. + */ + @Deprecated + public BigDecimal getCurrentTime() { + Duration displayed = countUp ? getElapsed() : getRemaining(); + return BigDecimal.valueOf(displayed.toMillis(), 3); + } + + /** + * Returns the displayed value of the timer. The value is computed on the server, so the returned + * future is already completed. + * + * @return a completed future with the value of {@link #getCurrentTime()} + * @deprecated Use {@link #getElapsed()} or {@link #getRemaining()} instead. + */ + @Deprecated + public CompletableFuture getCurrentTimeAsync() { + return CompletableFuture.completedFuture(getCurrentTime()); + } + + /** + * Adds a property change listener for the {@code currentTime} property. + * + * @param listener the property change listener + * @param period the minimum period between listener invocations, or 0 to disable throttling + * @param periodUnit time duration of throttling period + * @return this registration, for chaining + * @deprecated The value is now available on the server through {@link #getElapsed()}. + */ + @Deprecated + public Registration addCurrentTimeChangeListener( + PropertyChangeListener listener, final long period, final TimeUnit periodUnit) { + final int millis = (int) Math.min(periodUnit.toMillis(period), Integer.MAX_VALUE); + if (listener == null) { + listener = ev -> {}; + } + return getElement() + .addPropertyChangeListener(CURRENT_TIME, "current-time-changed", listener) + .throttle(millis); + } + + /** + * Adds a timer ended listener that will be triggered when the timer reaches its limit. + * + * @param listener the listener to add + * @return a handle that can be used for removing the listener + * @deprecated Use {@link #addTimerEndListener(ComponentEventListener)} instead. + */ + @Deprecated + public Registration addTimerEndEvent(final ComponentEventListener listener) { + return addTimerEndListener(listener); + } +} diff --git a/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js b/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js index 4965118..767a798 100644 --- a/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js +++ b/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js @@ -21,183 +21,151 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -//import '@polymer/polymer/polymer-legacy.js'; - -import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js'; -import {html} from '@polymer/polymer/lib/utils/html-tag.js'; - -Polymer({ - _template: html` - - {{_formattedTime}} - `, - - is: 'simple-timer', - - properties: { - /** - * Start time for the timer in seconds - */ - startTime: { - type: Number, - value: 0 - }, - /** - * End time for the timer in seconds - */ - endTime: { - type: Number, - value: 0 - }, - /** - * Current time of the timer, in seconds - */ - currentTime: { - type: Number, - notify: true - }, - /** - * True if the timer is currently running - * @default false - */ - isRunning: { - type: Boolean, - reflectToAttribute: true, - notify: true, - value: false - }, - /** - * Set to true to have timer count up - * @default false - */ - countUp: { - type: Boolean, - value: false - }, - /** - * Set to false to hide fractional seconds - * @default true - */ - fractions: { - type: Boolean, - value: true - }, - /** - * Set to true to show minutes - * @default false - */ - minutes: { - type: Boolean, - value: false - }, - /** - * Set to true to show hours, and minutes - * @default false - */ - hours: { - type: Boolean, - value: false - }, - /** - * Use two digits for hours - * @default false - */ - doubleDigitHours: { - type: Boolean, - value: false - }, - /** - * Time the timer has spent running since it was started - */ - _elapsedTime: { - type: Number, - value: 0 - }, - - _formattedTime: { - type: String, - value: '0' - } - }, - - ready: function() { - if (this.countUp) { - this.set('currentTime', 0); - } else { - this.set('currentTime', this.startTime); - } - this.set('_formattedTime', this._formatTime(this.currentTime.toString())); - }, - - start: function() { - if ((this.currentTime <= 0 && !this.countUp) - || (this.currentTime >= this.endTime && this.countUp) ) { - // timer is over - this.currentTime = this.countUp ? this.endTime : 0; - this._formattedTime = this._formatTime(this.currentTime); - } - - if (this.countUp && !this.endTime || !this.countUp && !this.startTime || this.isRunning) { - this.pause(); - return; - } - this._elapsed = performance.now()/1000; - this.isRunning = true; - window.requestAnimationFrame(this._decreaseTimer.bind(this)); - }, - - pause: function() { - this.isRunning = false; - }, - - _decreaseTimer: function(timestamp) { - if (!this.isRunning) { - return; - } - if ((this.currentTime <= 0 && !this.countUp) - || (this.currentTime >= this.endTime && this.countUp) ) { - // timer is over - this.currentTime = this.countUp ? this.endTime : 0; - this._formattedTime = this._formatTime(this.currentTime); - this.pause(); - this.fire('simple-timer-end'); - return; - } - - var now = timestamp/1000; - // Compute the relative progress based on the time spent running - var progress = now - this._elapsed; - this.currentTime = this.countUp ? this.currentTime + progress : this.currentTime - progress; - this._formattedTime = this._formatTime(this.currentTime); - this._elapsed = now; - window.requestAnimationFrame(this._decreaseTimer.bind(this)); - }, - - _formatTime: function(time) { - var timeString = time.toString().split('.'); - if (timeString[0].indexOf('-') === 0) { - return 0; - } - if (timeString.length==1) { - timeString.push("00"); - } - var seconds = timeString[0]; - var minutes = seconds / 60 | 0; - var hours = minutes / 60 | 0; - - minutes = this.hours ? minutes % 60 : minutes; - minutes = minutes < 10 ? '0' + minutes : minutes; - - seconds = this.minutes || this.hours ? seconds % 60 : seconds; - seconds = seconds < 10 ? '0' + seconds : seconds; - - if(this.hours && this.doubleDigitHours) { - hours = hours.toString().padStart(2, '0'); - } - return (this.hours ? hours + ':' : '') + (this.minutes || this.hours ? minutes + ':' : '') + seconds + (this.fractions ? ('.' + timeString[1].substring(0,2)) : '') + +const pad = n => (n < 10 ? '0' : '') + n; + +/** + * Formats a time value given in milliseconds, e.g. "1:02:03.45". + * Negative values are shown as zero. Fractions are truncated to hundredths. + */ +export function formatTime(millis, {fractions, minutes, hours, doubleDigitHours}) { + const ms = millis > 0 ? Math.floor(millis) : 0; + const showMinutes = minutes || hours; + let s = Math.floor(ms / 1000); + let m = Math.floor(s / 60); + const h = Math.floor(m / 60); + if (hours) m %= 60; + if (showMinutes) s %= 60; + + let text = ''; + if (hours) text += (doubleDigitHours ? pad(h) : h) + ':'; + if (showMinutes) text += pad(m) + ':'; + text += pad(s); + if (fractions) text += '.' + pad(Math.floor((ms % 1000) / 10)); + return text; +} + +const FORMAT_PROPERTIES = ['fractions', 'minutes', 'hours', 'doubleDigitHours']; + +/** + * Renders a timer whose state is owned by the server. + * + * The server pushes the elapsed time as a duration, never as an instant, because the server and + * browser clocks are not the same clock. The element anchors that duration to its own + * performance.now() when it arrives, and derives the current value on every frame. + */ +class SimpleTimer extends HTMLElement { + + constructor() { + super(); + this._fractions = true; + this._minutes = false; + this._hours = false; + this._doubleDigitHours = false; + + this._base = 0; // elapsed milliseconds at _origin + this._origin = 0; // performance.now() when _base was received + this._running = false; + this._limit = 0; + this._countUp = false; + this._raf = 0; + this._frame = this._frame.bind(this); + + this.attachShadow({mode: 'open'}).innerHTML = ''; + this._value = this.shadowRoot.firstChild; + } + + connectedCallback() { + // Flow may set properties before the element is upgraded; re-apply them through the setters. + FORMAT_PROPERTIES.forEach(name => { + if (Object.prototype.hasOwnProperty.call(this, name)) { + const value = this[name]; + delete this[name]; + this[name] = value; } - }); \ No newline at end of file + }); + this._render(); + if (this._running) this._loop(); + } + + disconnectedCallback() { + cancelAnimationFrame(this._raf); + this._raf = 0; + } + + /** The displayed value in seconds. Kept for the deprecated currentTime property listener. */ + get currentTime() { + return this._displayed() / 1000; + } + + /** + * Applies the clock state pushed by the server. + * + * When pull is set and the timer is running, the element asks for the state again. A push can be + * deferred while the element is hidden, which would leave a stale origin; the answer to the pull + * is computed when it is sent, so it always lands on the right time. + */ + _sync(elapsed, running, limit, countUp, pull) { + this._base = elapsed; + this._origin = performance.now(); + this._limit = limit; + this._countUp = countUp; + this._running = running; + this._render(); + if (!running) return; + this._loop(); + if (pull) this.dispatchEvent(new CustomEvent('simple-timer-sync-request')); + } + + _elapsed() { + const elapsed = this._base + (this._running ? performance.now() - this._origin : 0); + return Math.min(elapsed, this._limit); + } + + _displayed() { + const elapsed = this._elapsed(); + return this._countUp ? elapsed : this._limit - elapsed; + } + + _loop() { + if (!this._raf && this.isConnected) this._raf = requestAnimationFrame(this._frame); + } + + _frame() { + this._raf = 0; + if (!this._running) return; + if (this._elapsed() >= this._limit) { + this._base = this._limit; + this._running = false; + this._render(); + this._fire('simple-timer-end'); + return; + } + this._render(); + this.dispatchEvent(new CustomEvent('current-time-changed')); + this._loop(); + } + + _fire(type, detail) { + this.dispatchEvent(new CustomEvent(type, {bubbles: true, composed: true, detail})); + } + + _render() { + const text = formatTime(this._displayed(), this); + if (this._value.textContent !== text) this._value.textContent = text; + } +} + +FORMAT_PROPERTIES.forEach(name => { + Object.defineProperty(SimpleTimer.prototype, name, { + get() { + return this['_' + name]; + }, + set(value) { + this['_' + name] = !!value; + this._render(); + }, + }); +}); + +customElements.define('simple-timer', SimpleTimer); From 495db7daa768fd90b1ec60c7028a72fa0a8cdece Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Mon, 26 Oct 2026 11:41:13 -0300 Subject: [PATCH 02/17] test: add unit tests for server-side timer state --- .../addons/simpletimer/SimpleTimerTest.java | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java diff --git a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java new file mode 100644 index 0000000..23c44d4 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java @@ -0,0 +1,247 @@ +/*- + * #%L + * Simple Timer Addon + * %% + * Copyright (C) 2019 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.simpletimer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.flowingcode.vaadin.addons.simpletimer.SimpleTimer.TimerEndedEvent; +import com.vaadin.flow.component.ComponentUtil; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import org.junit.Before; +import org.junit.Test; + +public class SimpleTimerTest { + + private static class MutableClock extends Clock { + + private Instant now = Instant.parse("2026-01-01T00:00:00Z"); + + void advance(Duration duration) { + now = now.plus(duration); + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now; + } + } + + private final MutableClock clock = new MutableClock(); + private final List ended = new ArrayList<>(); + private SimpleTimer timer; + + @Before + public void setUp() { + timer = new SimpleTimer(); + timer.setClock(clock); + timer.addTimerEndListener(ended::add); + } + + private static Duration s(long seconds) { + return Duration.ofSeconds(seconds); + } + + @Test + public void countsWhileRunning() { + timer.setCountdown(s(60)); + timer.start(); + clock.advance(s(10)); + + assertTrue(timer.isRunning()); + assertEquals(s(10), timer.getElapsed()); + assertEquals(s(50), timer.getRemaining()); + } + + @Test + public void pauseBanksElapsedTime() { + timer.setCountdown(s(60)); + timer.start(); + clock.advance(s(10)); + timer.pause(); + clock.advance(s(100)); + + assertFalse(timer.isRunning()); + assertEquals(s(10), timer.getElapsed()); + + timer.start(); + clock.advance(s(5)); + assertEquals(s(15), timer.getElapsed()); + } + + @Test + public void stopsAtLimit() { + timer.setCountUp(s(30)); + timer.start(); + clock.advance(s(100)); + + assertFalse(timer.isRunning()); + assertEquals(s(30), timer.getElapsed()); + assertEquals(Duration.ZERO, timer.getRemaining()); + } + + @Test + public void startDoesNotToggle() { + timer.setCountdown(s(60)); + timer.start(); + clock.advance(s(10)); + timer.start(); + clock.advance(s(10)); + + assertTrue(timer.isRunning()); + assertEquals(s(20), timer.getElapsed()); + } + + @Test + public void startDoesNothingWithoutLimit() { + timer.start(); + assertFalse(timer.isRunning()); + } + + @Test + public void resetKeepsRunningTimerRunning() { + timer.setCountdown(s(60)); + timer.start(); + clock.advance(s(10)); + timer.reset(); + clock.advance(s(3)); + + assertTrue(timer.isRunning()); + assertEquals(s(3), timer.getElapsed()); + } + + @Test + public void resetKeepsPausedTimerPaused() { + timer.setCountdown(s(60)); + timer.start(); + clock.advance(s(10)); + timer.pause(); + timer.reset(); + clock.advance(s(3)); + + assertFalse(timer.isRunning()); + assertEquals(Duration.ZERO, timer.getElapsed()); + } + + @Test + public void shorterLimitKeepsRunningTimerRunning() { + timer.setCountdown(s(60)); + timer.start(); + clock.advance(s(20)); + timer.setCountdown(s(10)); + clock.advance(s(4)); + + assertTrue(timer.isRunning()); + assertEquals(s(4), timer.getElapsed()); + } + + @Test + public void clockSteppingBackwardsKeepsBankedTime() { + timer.setCountdown(s(60)); + timer.start(); + clock.advance(s(10)); + timer.pause(); + timer.start(); + clock.advance(s(-30)); + + assertEquals(s(10), timer.getElapsed()); + } + + @Test + public void setElapsedIsClampedToLimit() { + timer.setCountdown(s(60)); + timer.setElapsed(s(90)); + assertEquals(s(60), timer.getElapsed()); + + timer.setElapsed(s(-5)); + assertEquals(Duration.ZERO, timer.getElapsed()); + } + + @Test + public void endWhileDetachedFiresRetroactivelyOnAttach() { + timer.setCountdown(s(60)); + timer.start(); + ComponentUtil.onComponentDetach(timer); + clock.advance(s(90)); + assertTrue(ended.isEmpty()); + + ComponentUtil.onComponentAttach(timer, false); + + assertEquals(1, ended.size()); + assertTrue(ended.get(0).isRetroactive()); + assertFalse(timer.isRunning()); + assertEquals(s(60), timer.getElapsed()); + } + + @Test + public void endFiresOnlyOnce() { + timer.setCountdown(s(60)); + timer.start(); + clock.advance(s(90)); + + ComponentUtil.onComponentAttach(timer, false); + ComponentUtil.onComponentAttach(timer, false); + + assertEquals(1, ended.size()); + } + + @Test + public void attachBeforeLimitDoesNotEnd() { + timer.setCountdown(s(60)); + timer.start(); + clock.advance(s(30)); + + ComponentUtil.onComponentAttach(timer, false); + + assertTrue(ended.isEmpty()); + assertTrue(timer.isRunning()); + } + + @Test + @SuppressWarnings("deprecation") + public void deprecatedCurrentTimeShowsDisplayedValue() { + timer.setStartTime(60); + timer.start(); + clock.advance(Duration.ofMillis(10_500)); + assertEquals(new BigDecimal("49.500"), timer.getCurrentTime()); + + timer.setEndTime(new BigDecimal("30")); + clock.advance(Duration.ofMillis(2_250)); + assertEquals(new BigDecimal("2.250"), timer.getCurrentTime()); + } +} From 526ce9f85badba521784bca72a363a3321a117c0 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Mon, 26 Oct 2026 11:42:08 -0300 Subject: [PATCH 03/17] feat: add detach policy Add setDetachPolicy to choose whether a detached timer keeps counting (CONTINUE, the default) or pauses until it is attached again (PAUSE). An explicit start() or pause() while detached takes precedence over the policy. --- .../addons/simpletimer/SimpleTimer.java | 43 ++++++++++++++ .../addons/simpletimer/SimpleTimerTest.java | 57 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java index d843167..3cea71d 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java +++ b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java @@ -23,6 +23,7 @@ import com.vaadin.flow.component.Component; import com.vaadin.flow.component.ComponentEvent; import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.DetachEvent; import com.vaadin.flow.component.HasSize; import com.vaadin.flow.component.HasStyle; import com.vaadin.flow.component.Tag; @@ -64,11 +65,21 @@ public class SimpleTimer extends Component implements HasSize, HasStyle, Seriali */ private static final Duration END_TOLERANCE = Duration.ofSeconds(1); + /** What the timer does while it is detached. */ + public enum DetachPolicy { + /** The timer keeps counting while detached. */ + CONTINUE, + /** The timer pauses when detached, and resumes when attached again. */ + PAUSE + } + private Duration limit = Duration.ZERO; private boolean countUp; private Duration baseElapsed = Duration.ZERO; private Instant startedAt; private boolean endNotified; + private DetachPolicy detachPolicy = DetachPolicy.CONTINUE; + private boolean pausedByDetach; private Clock clock = Clock.systemUTC(); /** Creates a timer. */ @@ -140,6 +151,7 @@ public Duration getLimit() { /** Starts the timer. Does nothing if it is already running or has reached its limit. */ public void start() { + pausedByDetach = false; if (startedAt != null || rawElapsed().compareTo(limit) >= 0) { return; } @@ -150,6 +162,7 @@ public void start() { /** Pauses the timer, keeping the elapsed time. Does nothing if it is not running. */ public void pause() { + pausedByDetach = false; if (startedAt == null) { return; } @@ -269,9 +282,39 @@ public Registration addTimerEndListener(ComponentEventListener return addListener(TimerEndedEvent.class, listener); } + /** + * Sets what the timer does while it is detached. The default is {@link DetachPolicy#CONTINUE}. + * + * @param detachPolicy the policy to apply when the timer is detached + */ + public void setDetachPolicy(DetachPolicy detachPolicy) { + this.detachPolicy = Objects.requireNonNull(detachPolicy, "detachPolicy"); + } + + /** + * Returns what the timer does while it is detached. + * + * @return the detach policy + */ + public DetachPolicy getDetachPolicy() { + return detachPolicy; + } + + @Override + protected void onDetach(DetachEvent detachEvent) { + super.onDetach(detachEvent); + if (detachPolicy == DetachPolicy.PAUSE && isRunning()) { + pause(); + pausedByDetach = true; + } + } + @Override protected void onAttach(AttachEvent attachEvent) { super.onAttach(attachEvent); + if (pausedByDetach) { + start(); + } if (startedAt != null && rawElapsed().compareTo(limit) >= 0) { end(true); } diff --git a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java index 23c44d4..fd72c87 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java @@ -23,6 +23,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import com.flowingcode.vaadin.addons.simpletimer.SimpleTimer.DetachPolicy; import com.flowingcode.vaadin.addons.simpletimer.SimpleTimer.TimerEndedEvent; import com.vaadin.flow.component.ComponentUtil; import java.math.BigDecimal; @@ -232,6 +233,62 @@ public void attachBeforeLimitDoesNotEnd() { assertTrue(timer.isRunning()); } + @Test + public void continuePolicyCountsWhileDetached() { + timer.setCountdown(s(60)); + timer.start(); + clock.advance(s(10)); + ComponentUtil.onComponentDetach(timer); + clock.advance(s(20)); + ComponentUtil.onComponentAttach(timer, false); + + assertTrue(timer.isRunning()); + assertEquals(s(30), timer.getElapsed()); + } + + @Test + public void pausePolicyPausesWhileDetached() { + timer.setDetachPolicy(DetachPolicy.PAUSE); + timer.setCountdown(s(60)); + timer.start(); + clock.advance(s(10)); + ComponentUtil.onComponentDetach(timer); + clock.advance(s(100)); + + assertFalse(timer.isRunning()); + assertEquals(s(10), timer.getElapsed()); + + ComponentUtil.onComponentAttach(timer, false); + clock.advance(s(5)); + + assertTrue(timer.isRunning()); + assertEquals(s(15), timer.getElapsed()); + } + + @Test + public void pausePolicyDoesNotResumeExplicitlyPausedTimer() { + timer.setDetachPolicy(DetachPolicy.PAUSE); + timer.setCountdown(s(60)); + timer.start(); + clock.advance(s(10)); + ComponentUtil.onComponentDetach(timer); + timer.pause(); + ComponentUtil.onComponentAttach(timer, false); + + assertFalse(timer.isRunning()); + assertEquals(s(10), timer.getElapsed()); + } + + @Test + public void pausePolicyDoesNotResumeTimerThatWasNotRunning() { + timer.setDetachPolicy(DetachPolicy.PAUSE); + timer.setCountdown(s(60)); + ComponentUtil.onComponentDetach(timer); + ComponentUtil.onComponentAttach(timer, false); + + assertFalse(timer.isRunning()); + } + @Test @SuppressWarnings("deprecation") public void deprecatedCurrentTimeShowsDisplayedValue() { From fdc8754465fb1f006db9647f813c84d389130326 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Mon, 26 Oct 2026 11:46:27 -0300 Subject: [PATCH 04/17] feat: show the timer value inside other components Add addDisplay to show the value of the timer inside other components. The browser updates the value on every frame, writing to a text node the timer appends to the component, so the content the component already has is kept and no round trips are needed. The binding is established again whenever the display or the timer is attached, so a display can be moved in and out of view (e.g. inside tabs) while the timer stays attached elsewhere. Combined with setVisible(false), which hides the timer with CSS and keeps it running, the value can be shown only inside other components. --- .../addons/simpletimer/SimpleTimer.java | 49 ++++++++++++++++++- .../frontend/simple-timer/simple-timer.js | 36 ++++++++++++++ .../simpletimer/test/SerializationTest.java | 12 +++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java index 3cea71d..03d05fe 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java +++ b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java @@ -35,6 +35,8 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -80,6 +82,7 @@ public enum DetachPolicy { private boolean endNotified; private DetachPolicy detachPolicy = DetachPolicy.CONTINUE; private boolean pausedByDetach; + private final Map displays = new LinkedHashMap<>(); private Clock clock = Clock.systemUTC(); /** Creates a timer. */ @@ -300,6 +303,47 @@ public DetachPolicy getDetachPolicy() { return detachPolicy; } + /** + * Shows the value of the timer inside another component as well. The browser updates the value + * on every frame, without round trips to the server. + * + *

The timer appends a text node to the component and only updates that node, so the content + * the component already has is kept. To show the value only inside other components, hide the + * timer with {@link #setVisible(boolean)}: it keeps running while hidden. + * + *

The value is only updated while the timer is attached. To keep a display updated while it + * is moved in and out of view, for instance inside tabs, attach the timer somewhere it stays + * attached and add only the display to the tab. + * + * @param display the component in which to show the value + * @return a handle that can be used for removing the display + */ + public Registration addDisplay(Component display) { + Objects.requireNonNull(display, "display"); + if (!displays.containsKey(display)) { + displays.put(display, display.addAttachListener(ev -> bindDisplay(display))); + bindDisplay(display); + } + return () -> removeDisplay(display); + } + + private void removeDisplay(Component display) { + Registration attachRegistration = displays.remove(display); + if (attachRegistration == null) { + return; + } + attachRegistration.remove(); + if (isAttached() && display.isAttached()) { + getElement().callJsFunction("_unbindDisplay", display.getElement()); + } + } + + private void bindDisplay(Component display) { + if (isAttached() && display.isAttached()) { + getElement().callJsFunction("_bindDisplay", display.getElement()); + } + } + @Override protected void onDetach(DetachEvent detachEvent) { super.onDetach(detachEvent); @@ -319,6 +363,7 @@ protected void onAttach(AttachEvent attachEvent) { end(true); } sync(true); + displays.keySet().forEach(this::bindDisplay); } private void onClientEnd() { @@ -359,7 +404,9 @@ public boolean isVisible() { } /** - * Shows or hides the timer with CSS, so that it keeps communicating with the server while hidden. + * Shows or hides the timer with CSS, so that it keeps running and communicating with the server + * while hidden. A hidden timer still updates the components added with {@link + * #addDisplay(Component)}. * * @param visible whether to show the timer */ diff --git a/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js b/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js index 767a798..1ab9eb3 100644 --- a/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js +++ b/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js @@ -47,6 +47,11 @@ export function formatTime(millis, {fractions, minutes, hours, doubleDigitHours} const FORMAT_PROPERTIES = ['fractions', 'minutes', 'hours', 'doubleDigitHours']; +// Text node written by a timer inside each display element. Flow owns the display's other nodes, so +// the timer only touches its own. Shared across timer instances, so that a timer element created +// again after a detach reuses the node instead of appending a second one. +const displayNodes = new WeakMap(); + /** * Renders a timer whose state is owned by the server. * @@ -70,6 +75,7 @@ class SimpleTimer extends HTMLElement { this._countUp = false; this._raf = 0; this._frame = this._frame.bind(this); + this._displays = new Set(); this.attachShadow({mode: 'open'}).innerHTML = ''; this._value = this.shadowRoot.firstChild; @@ -117,6 +123,27 @@ class SimpleTimer extends HTMLElement { if (pull) this.dispatchEvent(new CustomEvent('simple-timer-sync-request')); } + /** Starts writing the value into the given element. */ + _bindDisplay(target) { + if (!target) return; + let node = displayNodes.get(target); + if (!node || node.parentNode !== target) { + node = document.createTextNode(''); + target.appendChild(node); + displayNodes.set(target, node); + } + this._displays.add(target); + this._render(); + } + + /** Stops writing the value into the given element, and removes the text written so far. */ + _unbindDisplay(target) { + if (!target || !this._displays.delete(target)) return; + const node = displayNodes.get(target); + if (node) node.remove(); + displayNodes.delete(target); + } + _elapsed() { const elapsed = this._base + (this._running ? performance.now() - this._origin : 0); return Math.min(elapsed, this._limit); @@ -153,6 +180,15 @@ class SimpleTimer extends HTMLElement { _render() { const text = formatTime(this._displayed(), this); if (this._value.textContent !== text) this._value.textContent = text; + this._displays.forEach(target => { + const node = displayNodes.get(target); + if (!node || !target.isConnected) { + // Flow replaced or removed the element; the server binds the new one when it attaches + this._displays.delete(target); + } else if (node.data !== text) { + node.data = text; + } + }); } } diff --git a/src/test/java/com/flowingcode/addons/simpletimer/test/SerializationTest.java b/src/test/java/com/flowingcode/addons/simpletimer/test/SerializationTest.java index a757b97..f60ebd6 100644 --- a/src/test/java/com/flowingcode/addons/simpletimer/test/SerializationTest.java +++ b/src/test/java/com/flowingcode/addons/simpletimer/test/SerializationTest.java @@ -20,11 +20,13 @@ package com.flowingcode.addons.simpletimer.test; import com.flowingcode.vaadin.addons.simpletimer.SimpleTimer; +import com.vaadin.flow.component.html.Span; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; +import java.time.Duration; import org.junit.Test; public class SerializationTest { @@ -44,4 +46,14 @@ private void testSerializationOf(Object obj) throws IOException, ClassNotFoundEx public void testSerialization() throws ClassNotFoundException, IOException { testSerializationOf(new SimpleTimer()); } + + @Test + public void testSerializationOfRunningTimerWithDisplay() + throws ClassNotFoundException, IOException { + SimpleTimer timer = new SimpleTimer(); + timer.setCountdown(Duration.ofSeconds(60)); + timer.addDisplay(new Span()); + timer.start(); + testSerializationOf(timer); + } } From 15d8a20fdbbdd20984359327c006debfe620f4d0 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Mon, 26 Oct 2026 11:48:48 -0300 Subject: [PATCH 05/17] feat: add throttled tick listener Add addTickListener, notified while the timer is running at most once per the given period, and deprecate addCurrentTimeChangeListener in its favor. The event exposes the elapsed and remaining time as computed on the server. The web component also dispatches a composed simple-timer-tick event on every frame, with the elapsed, remaining and formatted time, for client-side subscribers. --- .../addons/simpletimer/SimpleTimer.java | 56 ++++++++++++++++++- .../frontend/simple-timer/simple-timer.js | 5 +- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java index 03d05fe..4973565 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java +++ b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java @@ -285,6 +285,27 @@ public Registration addTimerEndListener(ComponentEventListener return addListener(TimerEndedEvent.class, listener); } + /** + * Adds a listener that is notified while the timer is running, at most once per period. + * + *

Each notification is a round trip from the browser, so use a period that matches what the + * listener needs. To show the value in another component, use {@link #addDisplay(Component)} + * instead, which needs no round trips. + * + * @param listener the listener to add + * @param period the minimum time between notifications + * @return a handle that can be used for removing the listener + */ + public Registration addTickListener( + ComponentEventListener listener, Duration period) { + Objects.requireNonNull(listener, "listener"); + int millis = (int) Math.min(period.toMillis(), Integer.MAX_VALUE); + return getElement() + .addEventListener( + "simple-timer-tick", ev -> listener.onComponentEvent(new TimerTickEvent(this, true))) + .throttle(millis); + } + /** * Sets what the timer does while it is detached. The default is {@link DetachPolicy#CONTINUE}. * @@ -454,6 +475,38 @@ public boolean isRetroactive() { } } + /** Event fired periodically while the timer is running. */ + public static class TimerTickEvent extends ComponentEvent { + + /** + * Creates a new event. + * + * @param source the running timer + * @param fromClient whether the event originated from the client + */ + public TimerTickEvent(final SimpleTimer source, final boolean fromClient) { + super(source, fromClient); + } + + /** + * Returns the elapsed time, as computed on the server when this method is called. + * + * @return the value of {@link SimpleTimer#getElapsed()} + */ + public Duration getElapsed() { + return getSource().getElapsed(); + } + + /** + * Returns the remaining time, as computed on the server when this method is called. + * + * @return the value of {@link SimpleTimer#getRemaining()} + */ + public Duration getRemaining() { + return getSource().getRemaining(); + } + } + // ---- deprecated API ---- /** @@ -514,7 +567,8 @@ public CompletableFuture getCurrentTimeAsync() { * @param period the minimum period between listener invocations, or 0 to disable throttling * @param periodUnit time duration of throttling period * @return this registration, for chaining - * @deprecated The value is now available on the server through {@link #getElapsed()}. + * @deprecated Use {@link #addTickListener(ComponentEventListener, Duration)} instead. The value + * itself is available on the server through {@link #getElapsed()}. */ @Deprecated public Registration addCurrentTimeChangeListener( diff --git a/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js b/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js index 1ab9eb3..d130ea9 100644 --- a/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js +++ b/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js @@ -168,7 +168,9 @@ class SimpleTimer extends HTMLElement { this._fire('simple-timer-end'); return; } - this._render(); + const elapsed = this._elapsed(); + const formatted = this._render(); + this._fire('simple-timer-tick', {elapsed, remaining: this._limit - elapsed, formatted}); this.dispatchEvent(new CustomEvent('current-time-changed')); this._loop(); } @@ -189,6 +191,7 @@ class SimpleTimer extends HTMLElement { node.data = text; } }); + return text; } } From 0bc43111f83a43bcc7fd59df45d71c3189672f58 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Mon, 26 Oct 2026 11:49:36 -0300 Subject: [PATCH 06/17] style: apply google-java-format to SimpleTimer --- .../vaadin/addons/simpletimer/SimpleTimer.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java index 4973565..4037322 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java +++ b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java @@ -46,8 +46,8 @@ * *

The state of the timer is kept on the server, as the time banked from previous runs plus the * instant the current run started. The browser only renders it. When the component is detached and - * attached again, the elapsed time is pushed to the new client element, so the timer keeps - * counting while it is not in the DOM. + * attached again, the elapsed time is pushed to the new client element, so the timer keeps counting + * while it is not in the DOM. * * @author Leonardo Scardanzan / Flowing Code */ @@ -325,15 +325,15 @@ public DetachPolicy getDetachPolicy() { } /** - * Shows the value of the timer inside another component as well. The browser updates the value - * on every frame, without round trips to the server. + * Shows the value of the timer inside another component as well. The browser updates the value on + * every frame, without round trips to the server. * *

The timer appends a text node to the component and only updates that node, so the content * the component already has is kept. To show the value only inside other components, hide the * timer with {@link #setVisible(boolean)}: it keeps running while hidden. * - *

The value is only updated while the timer is attached. To keep a display updated while it - * is moved in and out of view, for instance inside tabs, attach the timer somewhere it stays + *

The value is only updated while the timer is attached. To keep a display updated while it is + * moved in and out of view, for instance inside tabs, attach the timer somewhere it stays * attached and add only the display to the tab. * * @param display the component in which to show the value From a3f47c0e4e74f981bc5dd4d36287265cc10a456f Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Mon, 26 Oct 2026 11:49:45 -0300 Subject: [PATCH 07/17] chore(demo): migrate demo to the new timer API Replace the deprecated methods, split the Start/Stop button into Start and Pause now that start() no longer toggles, show the elapsed time reported by a tick listener, and fix the initial title, which said "Count Up" for a countdown. --- .../addons/simpletimer/SimpletimerDemo.java | 71 ++++++++++--------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpletimerDemo.java b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpletimerDemo.java index d2d8bd6..9439e83 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpletimerDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpletimerDemo.java @@ -31,7 +31,8 @@ import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; -import java.math.BigDecimal; +import java.time.Duration; +import java.util.Locale; @SuppressWarnings("serial") @PageTitle("Simple Timer Demo") @@ -42,26 +43,28 @@ public class SimpletimerDemo extends Div { private final SimpleTimer timer = new SimpleTimer(); private boolean countUpMode; - private BigDecimal time = new BigDecimal(60); + private Duration time = Duration.ofSeconds(60); public SimpletimerDemo() { setSizeFull(); timer.setWidth("100px"); timer.setHeight("50px"); timer.getStyle().set("font-size", "40px"); - timer.setStartTime(60); + timer.setCountdown(time); - final Span timerTitle = new Span("Simple Count Up Timer"); + final Span timerTitle = new Span("Simple Countdown Timer"); final TextField startTime = - new TextField("Start Time", e -> { - try { - time = new BigDecimal(e.getValue()); - update(); - } catch (final Exception ex) { - Notification.show("Please enter a number"); - } - }); + new TextField( + "Start Time", + e -> { + try { + time = Duration.ofMillis(Math.round(Double.parseDouble(e.getValue()) * 1000)); + update(); + } catch (final Exception ex) { + Notification.show("Please enter a positive number"); + } + }); final Checkbox countUp = new Checkbox("Count Up", false); countUp.addValueChangeListener( e -> { @@ -75,25 +78,16 @@ public SimpletimerDemo() { } update(); }); - final Button start = new Button("Start/Stop", e -> timer.start()); - final Button stop = new Button("Stop", e -> timer.pause()); - final Button reset = - new Button( - "Reset", - e -> { - timer.reset(); - }); - final Button running = + final Button start = new Button("Start", e -> timer.start()); + final Button pause = new Button("Pause", e -> timer.pause()); + final Button reset = new Button("Reset", e -> timer.reset()); + final Button current = new Button( "Current Time", e -> - timer - .getCurrentTimeAsync() - .thenAccept( - time -> - Notification.show( - time.toPlainString() - + (timer.isRunning() ? "" : " (Not Running)")))); + Notification.show( + seconds(countUpMode ? timer.getElapsed() : timer.getRemaining()) + + (timer.isRunning() ? "" : " (Not Running)"))); final Checkbox fractions = new Checkbox("Fractions", true); fractions.addValueChangeListener(e -> timer.setFractions(e.getValue())); final Checkbox minutes = new Checkbox("Minutes", e -> timer.setMinutes(e.getValue())); @@ -110,7 +104,14 @@ public SimpletimerDemo() { }); visible.setValue(true); - timer.addTimerEndEvent(e -> Notification.show("Timer Ended")); + final Span serverValue = new Span(); + timer.addTickListener( + e -> + serverValue.setText( + "Elapsed on the server, once per second: " + seconds(e.getElapsed())), + Duration.ofSeconds(1)); + + timer.addTimerEndListener(e -> Notification.show("Timer Ended")); final HorizontalLayout topLayout = new HorizontalLayout(timerTitle, timer); topLayout.setAlignItems(Alignment.CENTER); @@ -120,17 +121,21 @@ public SimpletimerDemo() { options.setAlignItems(Alignment.CENTER); options.getStyle().set("flex-wrap", "wrap"); - final HorizontalLayout bottomLayout = new HorizontalLayout(start, stop, reset, running); + final HorizontalLayout bottomLayout = new HorizontalLayout(start, pause, reset, current); bottomLayout.setAlignItems(Alignment.BASELINE); - add(new VerticalLayout(topLayout, startTime, options, bottomLayout)); + add(new VerticalLayout(topLayout, startTime, options, bottomLayout, serverValue)); + } + + private static String seconds(Duration duration) { + return String.format(Locale.ROOT, "%.2f s", duration.toMillis() / 1000d); } private void update() { if (countUpMode) { - timer.setEndTime(time); + timer.setCountUp(time); } else { - timer.setStartTime(time); + timer.setCountdown(time); } } } From 0ebb37717065580ac563ee2f8e0628cc1bcb6f40 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Mon, 26 Oct 2026 11:49:45 -0300 Subject: [PATCH 08/17] chore(demo): add tabs and external display demo Show a countdown inside a tab that keeps counting while another tab is selected (issue #39), with an option to pause it instead, and a hidden timer attached outside the tabs whose value is shown in the header and inside a tab. --- .../simpletimer/SimpletimerDemoView.java | 1 + .../simpletimer/SimpletimerTabsDemo.java | 110 ++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpletimerTabsDemo.java diff --git a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpletimerDemoView.java b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpletimerDemoView.java index 94a4b7d..9bcc088 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpletimerDemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpletimerDemoView.java @@ -33,6 +33,7 @@ public class SimpletimerDemoView extends TabbedDemo { public SimpletimerDemoView() { addDemo(SimpletimerDemo.class); + addDemo(SimpletimerTabsDemo.class); setSizeFull(); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpletimerTabsDemo.java b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpletimerTabsDemo.java new file mode 100644 index 0000000..5150823 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpletimerTabsDemo.java @@ -0,0 +1,110 @@ +/*- + * #%L + * Simple Timer Addon + * %% + * Copyright (C) 2019 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.simpletimer; + +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.flowingcode.vaadin.addons.simpletimer.SimpleTimer.DetachPolicy; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.tabs.Tab; +import com.vaadin.flow.component.tabs.Tabs; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import java.time.Duration; + +@SuppressWarnings("serial") +@PageTitle("Tabs and External Displays") +@DemoSource +@Route(value = "simple-timer/tabs", layout = SimpletimerDemoView.class) +public class SimpletimerTabsDemo extends Div { + + public SimpletimerTabsDemo() { + // A timer inside a tab is detached when another tab is selected, and keeps counting + SimpleTimer countdown = new SimpleTimer(); + countdown.setCountdown(Duration.ofSeconds(30)); + countdown.getStyle().set("font-size", "40px"); + countdown.addTimerEndListener( + ev -> + Notification.show( + ev.isRetroactive() + ? "The countdown ended while its tab was not selected" + : "The countdown ended")); + + Checkbox pauseWhileHidden = + new Checkbox( + "Pause while another tab is selected", + ev -> + countdown.setDetachPolicy( + ev.getValue() ? DetachPolicy.PAUSE : DetachPolicy.CONTINUE)); + + VerticalLayout countdownPage = + new VerticalLayout( + countdown, + new HorizontalLayout( + new Button("Start", ev -> countdown.start()), + new Button("Pause", ev -> countdown.pause()), + new Button("Reset", ev -> countdown.reset())), + pauseWhileHidden); + + // A hidden timer attached outside the tabs, shown in the header and inside a tab + SimpleTimer clock = new SimpleTimer(); + clock.setCountUp(Duration.ofHours(1)); + clock.setMinutes(true); + clock.setFractions(false); + clock.setVisible(false); + clock.start(); + + Span headerDisplay = new Span(); + Span tabDisplay = new Span(); + tabDisplay.getStyle().set("font-size", "40px"); + clock.addDisplay(headerDisplay); + clock.addDisplay(tabDisplay); + + VerticalLayout displayPage = + new VerticalLayout( + new Span("This tab only holds a display. The timer is attached outside the tabs."), + tabDisplay, + new HorizontalLayout( + new Button("Start", ev -> clock.start()), + new Button("Pause", ev -> clock.pause()), + new Button("Reset", ev -> clock.reset()))); + + Tab countdownTab = new Tab("Countdown"); + Tab displayTab = new Tab("External display"); + Tabs tabs = new Tabs(countdownTab, displayTab); + Div content = new Div(countdownPage); + tabs.addSelectedChangeListener( + ev -> { + content.removeAll(); + content.add(tabs.getSelectedTab() == countdownTab ? countdownPage : displayPage); + }); + + HorizontalLayout header = new HorizontalLayout(new Span("Time on this page:"), headerDisplay); + header.setDefaultVerticalComponentAlignment(Alignment.BASELINE); + + add(header, clock, tabs, content); + } +} From 4ab7b633423275baf3e4af9314b442795ea6cf93 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Mon, 26 Oct 2026 11:49:45 -0300 Subject: [PATCH 09/17] docs(readme): document 3.0 API and migration from 2.x --- README.md | 54 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 449ca65..2e0b144 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ To see the demo, navigate to http://localhost:8080/ - **Version 1.0.0** Supports Vaadin 10-13 and Vaadin 14 in compatibility mode - **Version 2.0.0** Supports Vaadin 14 in npm mode +- **Version 3.0.0** Keeps the timer state on the server, so the timer keeps counting while detached (e.g. inside tabs). Adds external displays and a detach policy. Removes the Polymer dependency. See [Migrating from 2.x](#migrating-from-2x). ## Issue tracking @@ -67,16 +68,51 @@ Simple Timer Addon is written by Flowing Code S.A. ## Using the component - Basic usage +```java +SimpleTimer timer = new SimpleTimer(); +timer.setCountdown(Duration.ofSeconds(30)); // or timer.setCountUp(Duration.ofMinutes(5)) +timer.start(); +timer.pause(); +timer.reset(); +timer.isRunning(); +timer.getElapsed(); +timer.getRemaining(); ``` - SimpleTimer timer = new SimpleTimer(new BigDecimal("30")); - timer.start(); - timer.pause(); - timer.reset(); - timer.setCountUp(true); - timer.isRunning(); - + +- Timer ended listener +```java +timer.addTimerEndListener(ev -> Notification.show("Timer ended")); ``` -- Timer Ended listener + +- Showing the value inside other components + + The browser updates each display on every frame, without round trips to the server. To show the value only in other components, hide the timer: it keeps running while hidden. +```java +timer.setVisible(false); +timer.addDisplay(headerBadge); +timer.addDisplay(tabContent); ``` - timer.addTimerEndEvent(ev-> Notification.show("Timer ended")); + +- Server-side notifications while running + + Each notification is a round trip, so pick the period the listener needs. To show the value, use `addDisplay` instead. +```java +timer.addTickListener(ev -> log(ev.getElapsed()), Duration.ofSeconds(1)); ``` + +## Detached timers + +The state of the timer is kept on the server, so a timer that is detached and attached again (for instance when it is inside a tab) keeps counting. To pause it while it is detached instead, use `timer.setDetachPolicy(DetachPolicy.PAUSE)`. + +If the timer reaches its limit while detached, the end listener is notified when the timer is attached again, and `isRetroactive()` returns `true`. A display added with `addDisplay` is only updated while the timer is attached, so keep the timer somewhere it stays attached and move only the display. + +A browser reload creates a new UI and a new timer. To keep the time across reloads, save `getElapsed()` and restore it with `setElapsed`, or use `@PreserveOnRefresh`. + +## Migrating from 2.x + +- `start()` no longer pauses a running timer. Use `pause()`. +- `setStartTime(Number)` and `setEndTime(Number)` are deprecated in favor of `setCountdown(Duration)` and `setCountUp(Duration)`. +- `getCurrentTime()` and `getCurrentTimeAsync()` are deprecated in favor of `getElapsed()` and `getRemaining()`, which are computed on the server without a round trip. +- `addTimerEndEvent` is deprecated in favor of `addTimerEndListener`. +- `addCurrentTimeChangeListener` is deprecated in favor of `addTickListener`. +- The web component no longer depends on Polymer, and no longer exposes the `startTime`, `endTime`, `countUp` and `isRunning` properties, nor the `start`, `pause` and `ready` functions. From 65b0f8d87c38b6df6a6a9065d1c3aaae59f5fd7e Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Mon, 26 Oct 2026 12:30:16 -0300 Subject: [PATCH 10/17] fix: fire end event while the browser tab is in the background Browsers stop animation frames in background tabs, and the end of the timer was only detected on a frame, so TimerEndedEvent was not fired until the user returned to the tab. Arm a timeout for the moment the timer reaches its limit as well: timeouts keep firing in background tabs, with a precision of about a second. Whichever notices the end first fires the event, and the timeout is cleared when the timer is paused, synchronized again, ends or is disconnected. --- .../frontend/simple-timer/simple-timer.js | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js b/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js index d130ea9..95fa1b4 100644 --- a/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js +++ b/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js @@ -74,6 +74,7 @@ class SimpleTimer extends HTMLElement { this._limit = 0; this._countUp = false; this._raf = 0; + this._endTimeout = 0; this._frame = this._frame.bind(this); this._displays = new Set(); @@ -92,11 +93,13 @@ class SimpleTimer extends HTMLElement { }); this._render(); if (this._running) this._loop(); + this._scheduleEnd(); } disconnectedCallback() { cancelAnimationFrame(this._raf); this._raf = 0; + this._scheduleEnd(); } /** The displayed value in seconds. Kept for the deprecated currentTime property listener. */ @@ -118,6 +121,7 @@ class SimpleTimer extends HTMLElement { this._countUp = countUp; this._running = running; this._render(); + this._scheduleEnd(); if (!running) return; this._loop(); if (pull) this.dispatchEvent(new CustomEvent('simple-timer-sync-request')); @@ -158,16 +162,36 @@ class SimpleTimer extends HTMLElement { if (!this._raf && this.isConnected) this._raf = requestAnimationFrame(this._frame); } + /** + * Arms a timeout for the moment the timer reaches its limit. Frames stop in background tabs, so + * without it the end would only be noticed when the user returns; timeouts keep firing there, + * with a precision of about a second. + */ + _scheduleEnd() { + clearTimeout(this._endTimeout); + this._endTimeout = 0; + if (!this._running || !this.isConnected) return; + const remaining = Math.ceil(this._limit - this._elapsed()); + this._endTimeout = setTimeout(() => { + this._endTimeout = 0; + if (!this._endIfDue()) this._scheduleEnd(); + }, Math.max(remaining, 0)); + } + + /** Ends the timer if it has reached its limit, and returns whether it did. */ + _endIfDue() { + if (!this._running || this._elapsed() < this._limit) return false; + this._base = this._limit; + this._running = false; + this._scheduleEnd(); + this._render(); + this._fire('simple-timer-end'); + return true; + } + _frame() { this._raf = 0; - if (!this._running) return; - if (this._elapsed() >= this._limit) { - this._base = this._limit; - this._running = false; - this._render(); - this._fire('simple-timer-end'); - return; - } + if (!this._running || this._endIfDue()) return; const elapsed = this._elapsed(); const formatted = this._render(); this._fire('simple-timer-tick', {elapsed, remaining: this._limit - elapsed, formatted}); From 3e00d47b4d780eed2eee238b3fee4c57f26793a5 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Mon, 26 Oct 2026 13:13:50 -0300 Subject: [PATCH 11/17] fix: fire end event when an expired run is paused or reset The server only ends a run when the client reports it or the component is attached. If pause(), reset(), setElapsed(), setCountdown() or setCountUp() was called after the run had reached its limit but before that happened (e.g. while detached), the run was replaced without ever firing TimerEndedEvent. End such a run first, against the limit it was running with, so that every run that reaches its limit fires exactly one end event. --- .../addons/simpletimer/SimpleTimer.java | 28 +++++++++-- .../addons/simpletimer/SimpleTimerTest.java | 46 +++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java index 4037322..63ce12b 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java +++ b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java @@ -128,6 +128,8 @@ private void setLimit(Duration limit, boolean countUp) { if (limit.isNegative()) { throw new IllegalArgumentException("limit must not be negative"); } + // checked against the current limit, before it is replaced + endIfDue(!isAttached()); boolean running = isRunning(); this.limit = limit; this.countUp = countUp; @@ -166,6 +168,10 @@ public void start() { /** Pauses the timer, keeping the elapsed time. Does nothing if it is not running. */ public void pause() { pausedByDetach = false; + if (endIfDue(!isAttached())) { + sync(false); + return; + } if (startedAt == null) { return; } @@ -216,6 +222,8 @@ public Duration getRemaining() { */ public void setElapsed(Duration elapsed) { Objects.requireNonNull(elapsed, "elapsed"); + // the run being replaced may have reached its limit without being notified yet + endIfDue(!isAttached()); restart(elapsed, isRunning()); } @@ -380,9 +388,7 @@ protected void onAttach(AttachEvent attachEvent) { if (pausedByDetach) { start(); } - if (startedAt != null && rawElapsed().compareTo(limit) >= 0) { - end(true); - } + endIfDue(true); sync(true); displays.keySet().forEach(this::bindDisplay); } @@ -394,6 +400,22 @@ private void onClientEnd() { end(false); } + /** + * Ends the current run if it has reached its limit, so that a run is never left without its end + * event. The server does not end a run on its own: the client reports it, or the timer notices it + * when it is attached or its state is changed. + * + * @param retroactive whether the timer was detached when the run reached its limit + * @return {@code true} if the run was ended + */ + private boolean endIfDue(boolean retroactive) { + if (startedAt == null || rawElapsed().compareTo(limit) < 0) { + return false; + } + end(retroactive); + return true; + } + private void end(boolean retroactive) { baseElapsed = limit; startedAt = null; diff --git a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java index fd72c87..619b14f 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java @@ -169,6 +169,20 @@ public void shorterLimitKeepsRunningTimerRunning() { assertTrue(timer.isRunning()); assertEquals(s(4), timer.getElapsed()); + assertTrue(ended.isEmpty()); + } + + @Test + public void longerLimitAfterLimitFiresEndOfPreviousRun() { + timer.setCountdown(s(60)); + timer.start(); + ComponentUtil.onComponentDetach(timer); + clock.advance(s(90)); + timer.setCountdown(s(120)); + + assertEquals(1, ended.size()); + assertFalse(timer.isRunning()); + assertEquals(Duration.ZERO, timer.getElapsed()); } @Test @@ -221,6 +235,38 @@ public void endFiresOnlyOnce() { assertEquals(1, ended.size()); } + @Test + public void pauseAfterLimitWhileDetachedFiresEnd() { + timer.setCountdown(s(60)); + timer.start(); + ComponentUtil.onComponentDetach(timer); + clock.advance(s(90)); + timer.pause(); + + assertEquals(1, ended.size()); + assertTrue(ended.get(0).isRetroactive()); + assertEquals(s(60), timer.getElapsed()); + + ComponentUtil.onComponentAttach(timer, false); + assertEquals(1, ended.size()); + } + + @Test + public void resetAfterLimitWhileDetachedFiresEndOnce() { + timer.setCountdown(s(60)); + timer.start(); + ComponentUtil.onComponentDetach(timer); + clock.advance(s(90)); + timer.reset(); + + assertEquals(1, ended.size()); + assertFalse(timer.isRunning()); + assertEquals(Duration.ZERO, timer.getElapsed()); + + ComponentUtil.onComponentAttach(timer, false); + assertEquals(1, ended.size()); + } + @Test public void attachBeforeLimitDoesNotEnd() { timer.setCountdown(s(60)); From b0bd0337a24d0aa008c90e0f3a0857568b9737dc Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Mon, 26 Oct 2026 13:15:13 -0300 Subject: [PATCH 12/17] fix: clamp the end timeout to the maximum browser delay Browsers store timeout delays as signed 32-bit integers, so a delay longer than about 24.8 days fires immediately. For a timer longer than that, the end timeout fired early and re-armed itself in a loop, about 200 times per second. Clamp the delay; the existing re-arm handles the rest of the time. --- .../resources/META-INF/frontend/simple-timer/simple-timer.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js b/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js index 95fa1b4..421f73a 100644 --- a/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js +++ b/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js @@ -47,6 +47,9 @@ export function formatTime(millis, {fractions, minutes, hours, doubleDigitHours} const FORMAT_PROPERTIES = ['fractions', 'minutes', 'hours', 'doubleDigitHours']; +// Browsers store timeout delays as signed 32-bit integers; a longer delay fires immediately. +const MAX_TIMEOUT = 2147483647; + // Text node written by a timer inside each display element. Flow owns the display's other nodes, so // the timer only touches its own. Shared across timer instances, so that a timer element created // again after a detach reuses the node instead of appending a second one. @@ -175,7 +178,7 @@ class SimpleTimer extends HTMLElement { this._endTimeout = setTimeout(() => { this._endTimeout = 0; if (!this._endIfDue()) this._scheduleEnd(); - }, Math.max(remaining, 0)); + }, Math.min(Math.max(remaining, 0), MAX_TIMEOUT)); } /** Ends the timer if it has reached its limit, and returns whether it did. */ From 390650521a51081d3a5ab64cb89e3427885bf0aa Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Wed, 28 Oct 2026 12:21:29 -0300 Subject: [PATCH 13/17] fix: remove display value when the timer is detached Removing a display while the timer was detached (e.g. inside a tab that is not selected) skipped the cleanup, so the display kept showing the last value. Remove the value from the display element, which does not depend on the timer being attached. A timer that is still attached stops writing to the display on its next render. --- .../addons/simpletimer/SimpleTimer.java | 9 +- .../frontend/simple-timer/simple-timer.js | 12 +- .../SimpleTimerClientCallsTest.java | 108 ++++++++++++++++++ 3 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerClientCallsTest.java diff --git a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java index 63ce12b..8f77086 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java +++ b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java @@ -362,8 +362,13 @@ private void removeDisplay(Component display) { return; } attachRegistration.remove(); - if (isAttached() && display.isAttached()) { - getElement().callJsFunction("_unbindDisplay", display.getElement()); + if (display.isAttached()) { + // run from the display, so that the value is removed even if the timer is detached + display + .getElement() + .executeJs( + "const timer = customElements.get('simple-timer'); timer && timer._unbindDisplay($0)", + display.getElement()); } } diff --git a/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js b/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js index 421f73a..7791cfc 100644 --- a/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js +++ b/src/main/resources/META-INF/frontend/simple-timer/simple-timer.js @@ -143,12 +143,14 @@ class SimpleTimer extends HTMLElement { this._render(); } - /** Stops writing the value into the given element, and removes the text written so far. */ - _unbindDisplay(target) { - if (!target || !this._displays.delete(target)) return; - const node = displayNodes.get(target); + /** + * Removes the text written into the given element. Static, so that it works while no timer element + * exists; a timer still bound to the element drops it on its next render. + */ + static _unbindDisplay(target) { + const node = target && displayNodes.get(target); if (node) node.remove(); - displayNodes.delete(target); + if (target) displayNodes.delete(target); } _elapsed() { diff --git a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerClientCallsTest.java b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerClientCallsTest.java new file mode 100644 index 0000000..6749945 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerClientCallsTest.java @@ -0,0 +1,108 @@ +/*- + * #%L + * Simple Timer Addon + * %% + * Copyright (C) 2019 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.simpletimer; + +import static org.junit.Assert.assertEquals; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.internal.PendingJavaScriptInvocation; +import com.vaadin.flow.function.DeploymentConfiguration; +import com.vaadin.flow.server.VaadinServletService; +import com.vaadin.flow.server.VaadinSession; +import com.vaadin.flow.shared.Registration; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.List; +import org.junit.Before; +import org.junit.Test; + +/** Checks the JavaScript calls the timer queues for the browser. */ +public class SimpleTimerClientCallsTest { + + private UI ui; + private SimpleTimer timer; + + /** + * Queuing a JavaScript call requires a session holding the lock, and since Vaadin 25 the session + * needs a service with a deployment configuration. The configuration is a proxy, so that it + * implements whatever methods the interface has in each supported version: it returns the default + * value passed to the method if there is one, and a neutral value otherwise. + */ + @Before + public void setUp() { + DeploymentConfiguration configuration = + (DeploymentConfiguration) + Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[] {DeploymentConfiguration.class}, + (proxy, method, args) -> defaultValue(method, args)); + VaadinSession session = + new VaadinSession(new VaadinServletService(null, configuration)) { + @Override + public boolean hasLock() { + return true; + } + }; + ui = new UI(); + ui.getInternals().setSession(session); + timer = new SimpleTimer(); + } + + private static Object defaultValue(Method method, Object[] args) { + Class type = method.getReturnType(); + if (args != null && args.length > 0) { + Class last = method.getParameterTypes()[args.length - 1]; + if (type.isAssignableFrom(last) || (type.isPrimitive() && last == type)) { + return args[args.length - 1]; + } + } + if (type == boolean.class) { + return false; + } + if (type == int.class || type == long.class) { + return type == int.class ? (Object) 0 : (Object) 0L; + } + return null; + } + + private List flush() { + ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); + return ui.getInternals().dumpPendingJavaScriptInvocations(); + } + + private static long count(List calls, String function) { + return calls.stream() + .filter(call -> call.getInvocation().getExpression().contains(function)) + .count(); + } + + @Test + public void removingDisplayWhileTimerIsDetachedClearsIt() { + Span display = new Span(); + ui.add(display); + Registration registration = timer.addDisplay(display); + flush(); + + registration.remove(); + + assertEquals(1, count(flush(), "_unbindDisplay")); + } +} From c88239387ef466aab6bfe16e61bdf26e1d704a1e Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Wed, 28 Oct 2026 12:23:14 -0300 Subject: [PATCH 14/17] perf: sync once when a paused timer resumes on attach With DetachPolicy.PAUSE, re-attaching a timer pushed its state three times: the pause queued on detach (delivered to the new client element), the resume, and the sync of onAttach, each running sync requesting another round trip. Bank the time on detach without syncing, since the client element is being removed, and resume on attach without syncing, so that onAttach pushes the state once. --- .../addons/simpletimer/SimpleTimer.java | 22 ++++++++++++++----- .../SimpleTimerClientCallsTest.java | 16 ++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java index 8f77086..941c57e 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java +++ b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java @@ -157,12 +157,18 @@ public Duration getLimit() { /** Starts the timer. Does nothing if it is already running or has reached its limit. */ public void start() { pausedByDetach = false; + if (startRun()) { + sync(true); + } + } + + private boolean startRun() { if (startedAt != null || rawElapsed().compareTo(limit) >= 0) { - return; + return false; } startedAt = clock.instant(); endNotified = false; - sync(true); + return true; } /** Pauses the timer, keeping the elapsed time. Does nothing if it is not running. */ @@ -175,9 +181,13 @@ public void pause() { if (startedAt == null) { return; } + pauseRun(); + sync(false); + } + + private void pauseRun() { baseElapsed = getElapsed(); startedAt = null; - sync(false); } /** Resets the elapsed time to zero. A running timer keeps running from the start. */ @@ -382,7 +392,8 @@ private void bindDisplay(Component display) { protected void onDetach(DetachEvent detachEvent) { super.onDetach(detachEvent); if (detachPolicy == DetachPolicy.PAUSE && isRunning()) { - pause(); + // no sync: the client element is being removed, and onAttach pushes the state + pauseRun(); pausedByDetach = true; } } @@ -391,7 +402,8 @@ protected void onDetach(DetachEvent detachEvent) { protected void onAttach(AttachEvent attachEvent) { super.onAttach(attachEvent); if (pausedByDetach) { - start(); + pausedByDetach = false; + startRun(); } endIfDue(true); sync(true); diff --git a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerClientCallsTest.java b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerClientCallsTest.java index 6749945..473f9bc 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerClientCallsTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerClientCallsTest.java @@ -21,6 +21,7 @@ import static org.junit.Assert.assertEquals; +import com.flowingcode.vaadin.addons.simpletimer.SimpleTimer.DetachPolicy; import com.vaadin.flow.component.UI; import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.internal.PendingJavaScriptInvocation; @@ -30,6 +31,7 @@ import com.vaadin.flow.shared.Registration; import java.lang.reflect.Method; import java.lang.reflect.Proxy; +import java.time.Duration; import java.util.List; import org.junit.Before; import org.junit.Test; @@ -105,4 +107,18 @@ public void removingDisplayWhileTimerIsDetachedClearsIt() { assertEquals(1, count(flush(), "_unbindDisplay")); } + + @Test + public void resumingOnAttachSyncsOnce() { + timer.setDetachPolicy(DetachPolicy.PAUSE); + timer.setCountdown(Duration.ofSeconds(60)); + ui.add(timer); + timer.start(); + flush(); + + ui.remove(timer); + ui.add(timer); + + assertEquals(1, count(flush(), "_sync")); + } } From 6cd4277b2b27e944c146e5829c910fff2fdce04b Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Wed, 28 Oct 2026 12:23:14 -0300 Subject: [PATCH 15/17] fix: report a client-detected end as coming from the client In 2.x TimerEndedEvent was a DomEvent, so isFromClient() returned true. Since it is fired from the server, it returned false even when the client reported the end. Pass fromClient=true when the client reports the end; ends detected on the server keep fromClient=false. --- .../vaadin/addons/simpletimer/SimpleTimer.java | 14 ++++++++++---- .../vaadin/addons/simpletimer/SimpleTimerTest.java | 1 + 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java index 941c57e..6cea8ed 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java +++ b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java @@ -414,7 +414,7 @@ private void onClientEnd() { if (startedAt == null || rawElapsed().plus(END_TOLERANCE).compareTo(limit) < 0) { return; } - end(false); + end(false, true); } /** @@ -429,18 +429,24 @@ private boolean endIfDue(boolean retroactive) { if (startedAt == null || rawElapsed().compareTo(limit) < 0) { return false; } - end(retroactive); + end(retroactive, false); return true; } - private void end(boolean retroactive) { + /** + * Ends the current run. + * + * @param retroactive whether the timer was detached when the run reached its limit + * @param fromClient whether the client reported the end, as it always did before 3.0 + */ + private void end(boolean retroactive, boolean fromClient) { baseElapsed = limit; startedAt = null; if (endNotified) { return; } endNotified = true; - fireEvent(new TimerEndedEvent(this, false, retroactive)); + fireEvent(new TimerEndedEvent(this, fromClient, retroactive)); } private void sync(boolean pull) { diff --git a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java index 619b14f..1cea937 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java @@ -219,6 +219,7 @@ public void endWhileDetachedFiresRetroactivelyOnAttach() { assertEquals(1, ended.size()); assertTrue(ended.get(0).isRetroactive()); + assertFalse(ended.get(0).isFromClient()); assertFalse(timer.isRunning()); assertEquals(s(60), timer.getElapsed()); } From ab17c74482eb303e15a3738ea39c318a95b5a9c7 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Wed, 28 Oct 2026 12:23:15 -0300 Subject: [PATCH 16/17] fix: validate the tick listener period A null period failed with an NPE without a message, and a zero or negative period disabled the throttle, so the client sent a tick on every animation frame. Reject them. --- .../vaadin/addons/simpletimer/SimpleTimer.java | 5 +++++ .../addons/simpletimer/SimpleTimerTest.java | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java index 6cea8ed..d04b018 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java +++ b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java @@ -313,10 +313,15 @@ public Registration addTimerEndListener(ComponentEventListener * @param listener the listener to add * @param period the minimum time between notifications * @return a handle that can be used for removing the listener + * @throws IllegalArgumentException if {@code period} is zero or negative */ public Registration addTickListener( ComponentEventListener listener, Duration period) { Objects.requireNonNull(listener, "listener"); + Objects.requireNonNull(period, "period"); + if (period.isZero() || period.isNegative()) { + throw new IllegalArgumentException("period must be positive"); + } int millis = (int) Math.min(period.toMillis(), Integer.MAX_VALUE); return getElement() .addEventListener( diff --git a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java index 1cea937..0a0efdb 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java @@ -224,6 +224,21 @@ public void endWhileDetachedFiresRetroactivelyOnAttach() { assertEquals(s(60), timer.getElapsed()); } + @Test(expected = IllegalArgumentException.class) + public void tickListenerRejectsZeroPeriod() { + timer.addTickListener(ev -> {}, Duration.ZERO); + } + + @Test(expected = IllegalArgumentException.class) + public void tickListenerRejectsNegativePeriod() { + timer.addTickListener(ev -> {}, s(-1)); + } + + @Test(expected = NullPointerException.class) + public void tickListenerRejectsNullPeriod() { + timer.addTickListener(ev -> {}, null); + } + @Test public void endFiresOnlyOnce() { timer.setCountdown(s(60)); From 03329df451d460a95fa11ada40462b95f18808ef Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Wed, 28 Oct 2026 12:23:15 -0300 Subject: [PATCH 17/17] docs: document sharing and retention of display components A component shows the value of one timer at a time, and the timer keeps a reference to it until the display is removed. --- .../flowingcode/vaadin/addons/simpletimer/SimpleTimer.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java index d04b018..841747a 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java +++ b/src/main/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimer.java @@ -359,6 +359,10 @@ public DetachPolicy getDetachPolicy() { * moved in and out of view, for instance inside tabs, attach the timer somewhere it stays * attached and add only the display to the tab. * + *

A component shows the value of one timer at a time: if it is added as a display to two + * timers, they overwrite each other's value. The timer keeps a reference to the component until + * the display is removed with the returned handle. + * * @param display the component in which to show the value * @return a handle that can be used for removing the display */