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. 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..841747a 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,650 @@ -/*- - * #%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.DetachEvent; +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.LinkedHashMap; +import java.util.Map; +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); + + /** 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 final Map displays = new LinkedHashMap<>(); + 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"); + } + // checked against the current limit, before it is replaced + endIfDue(!isAttached()); + 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() { + pausedByDetach = false; + if (startRun()) { + sync(true); + } + } + + private boolean startRun() { + if (startedAt != null || rawElapsed().compareTo(limit) >= 0) { + return false; + } + startedAt = clock.instant(); + endNotified = false; + return true; + } + + /** 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; + } + pauseRun(); + sync(false); + } + + private void pauseRun() { + baseElapsed = getElapsed(); + startedAt = null; + } + + /** 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"); + // the run being replaced may have reached its limit without being notified yet + endIfDue(!isAttached()); + 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); + } + + /** + * 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 + * @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( + "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}. + * + * @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; + } + + /** + * 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. + * + *

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 + */ + 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 (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()); + } + } + + private void bindDisplay(Component display) { + if (isAttached() && display.isAttached()) { + getElement().callJsFunction("_bindDisplay", display.getElement()); + } + } + + @Override + protected void onDetach(DetachEvent detachEvent) { + super.onDetach(detachEvent); + if (detachPolicy == DetachPolicy.PAUSE && isRunning()) { + // no sync: the client element is being removed, and onAttach pushes the state + pauseRun(); + pausedByDetach = true; + } + } + + @Override + protected void onAttach(AttachEvent attachEvent) { + super.onAttach(attachEvent); + if (pausedByDetach) { + pausedByDetach = false; + startRun(); + } + endIfDue(true); + sync(true); + displays.keySet().forEach(this::bindDisplay); + } + + private void onClientEnd() { + if (startedAt == null || rawElapsed().plus(END_TOLERANCE).compareTo(limit) < 0) { + return; + } + end(false, true); + } + + /** + * 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, false); + return true; + } + + /** + * 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, fromClient, 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 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 + */ + @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; + } + } + + /** 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 ---- + + /** + * 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 Use {@link #addTickListener(ComponentEventListener, Duration)} instead. The value + * itself is 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..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 @@ -21,183 +21,219 @@ 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']; + +// 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. +const displayNodes = new WeakMap(); + +/** + * 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._endTimeout = 0; + this._frame = this._frame.bind(this); + this._displays = new Set(); + + 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(); + this._scheduleEnd(); + } + + disconnectedCallback() { + cancelAnimationFrame(this._raf); + this._raf = 0; + this._scheduleEnd(); + } + + /** 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(); + this._scheduleEnd(); + if (!running) return; + this._loop(); + 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(); + } + + /** + * 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(); + if (target) displayNodes.delete(target); + } + + _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); + } + + /** + * 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.min(Math.max(remaining, 0), MAX_TIMEOUT)); + } + + /** 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 || this._endIfDue()) return; + 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(); + } + + _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; + 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; + } + }); + return 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); 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); + } } 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..473f9bc --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerClientCallsTest.java @@ -0,0 +1,124 @@ +/*- + * #%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.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; +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.time.Duration; +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")); + } + + @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")); + } +} 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..0a0efdb --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/simpletimer/SimpleTimerTest.java @@ -0,0 +1,366 @@ +/*- + * #%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.DetachPolicy; +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()); + 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 + 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(ended.get(0).isFromClient()); + assertFalse(timer.isRunning()); + 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)); + timer.start(); + clock.advance(s(90)); + + ComponentUtil.onComponentAttach(timer, false); + ComponentUtil.onComponentAttach(timer, false); + + 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)); + timer.start(); + clock.advance(s(30)); + + ComponentUtil.onComponentAttach(timer, false); + + assertTrue(ended.isEmpty()); + 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() { + 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()); + } +} 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); } } } 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); + } +}