Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add dcc.Timer #2352

Closed
wants to merge 7 commits into from
Closed

Add dcc.Timer #2352

wants to merge 7 commits into from

Conversation

AnnMarieW
Copy link
Contributor

@AnnMarieW AnnMarieW commented Dec 5, 2022

Contributor Checklist

  • update pretty-ms to v8
  • I have run the tests locally and they passed. (refer to testing section in contributing)
  • I have added tests, or extended existing tests, to cover any new features or bugs fixed in this PR
  • I have added entry in the CHANGELOG.md

dcc.Timer()

The new Timer component is a response to the discussion in #857.

image

This PR is moved here from dash-core-components PR# 961



dcc.Timer is based on the dcc.Interval component. It has all the functionality of dcc.Interval plus these new features:

  • Operate the timer in either countdown or stopwatch (count up) modes.
  • Specify custom messages to display at certain times.
  • Automatically convert milliseconds into human readable times. For example, 1337000000ms can be display as:
    '15d 11h 23m 20s' See other available formats in the timer_format prop.
  • Specify certain times to trigger a callback. (see Limitations sections)
  • Improve load and performance times because updates can happen clientside. This makes it unnecessary to fire a callback
    frequently (ie every interval) just to update a countdown/stopwatch message.

Component Properties

Prop name Type & Default value Description Example values
id string; optional id of component used to identify dash components in callbacks
interval number; default 1000 This component will increment the counter n_intervals every interval milliseconds
disabled boolean; optional If True, the n_interval counter and the timer no longer updates. This pauses the timer.
n_intervals number; default 0 Number of times the interval has passed (read-only)
max_intervals number; default -1 Number of times the interval will be fired. If -1, then the interval has no limit and if 0 then the interval stops running.
timer number; default 0 When in countdown mode, the timer will count down to zero from the starting duration and will show the number of milliseconds remaining. When in stopwatch mode, the timer will count up from zero and show the number of milliseconds elapsed. (read only)
mode 'stopwatch' or 'countdown'; default 'countdown' The timer will count down to zero in countdown mode and count up from zero in stopwatch mode
duration number; default -1 Sets the number of milliseconds the timer will run. If -1 the timer will not be limited by the duration and if 0 then the timer stops running and may be reset.
reset boolean; default True This will start the timer at the beginning with the given prop settings.
fire_times list; optional A list of the time(s) in milliseconds at which to fire a callback. This can be used to start a task at a given time rather than using the timer. Since the timer is typically set at a small interval like one second, using fire_times can reduce the number of times a callback is fired and can increase app performance. The time(s) must be a multiple of the interval.
at_fire_time number This is updated when the timer reaches one of the times in the fire_times list. Using at_fire_time in a callback will trigger the callback at the time(s) in fire_times (Read only)
rerun boolean; default False When True, the timer repeats once the timer has run for the number of milliseconds set in the duration.
messages dict; optional Timer messages to be displayed by the component rather than showing the timer. It is a dictionary in the form of: { integer: string} where integer is the time in milliseconds of when the string message is to be displayed. Note: timer_format will override messages. {10000 : "updating in 10 seconds"} will display the message "updating in 10 seconds" once the timer equals 10000.
timer_format string; optional. One of: If a timer is displayed, it will override timer messages. This formats the timer (milliseconds) into human readable formats.
'none'; default No timer will be displayed. Timer messages will be shown.
'display' Display timer in default format. For example, 1337000000 milliseconds will display as: '15d 11h 23m 20s' '15d 11h 23m 20s'
'compact' Shows a compact timer display. It will only show the first unit 1h 10m → 1h
'verbose' Verbose will display full-length units. 5h 1m 45s → 5 hours 1 minute 45 seconds
'colonNotation' Use this when you want to display time without the time units, similar to a digital watch. It will always show at least minutes: 1s → 0:01 5h 1m 45s → 5:01:45


dcc.Timer demo

🎉 No callbacks required in the first 6 examples! 🎉

Countdown to time of day

Note that the layout is a function so that the timer is updated when the browser is refreshed.

update-at-time

from dash import Dash, html, dcc
import datetime as dt

app = Dash(__name__)


def layout():
    today = dt.datetime.now()
    difference = dt.datetime(today.year, today.month, today.day, 14, 0, 0) - today
    duration = difference.seconds * 1000
    timer = dcc.Timer(
        id="timer", timer_format="colons", duration=duration, mode="countdown"
    )

    return html.Div(
        [
            html.H4("Data updates daily at 2pm"),
            html.Span(
                ["Checking for updates in ", timer],
                style={"backgroundColor": "red", "color": "white", "padding": 5},
            ),
        ]
    )


app.layout = layout


if __name__ == "__main__":
    app.run(debug=True)

Countdown to date

countdown-to-new-years

from dash import Dash, html, dcc
import datetime as dt

app = Dash(__name__)


def layout():
    today = dt.datetime.now()
    future_date = dt.datetime((today.year + 1), 1, 1, 0, 0, 0)
    difference = future_date - today
    duration = int(difference.total_seconds()) * 1000

    timer = dcc.Timer(
        id="timer", timer_format="verbose", duration=duration, mode="countdown"
    )

    return html.H4(["Countdown to New Years:  ", timer])


app.layout = layout


if __name__ == "__main__":
    app.run(debug=True)


Countdown timer - repeats every 30 seconds

(partial gif)
timer1

from dash import Dash, dcc, html

app = Dash(__name__)

timer = dcc.Timer(
    mode="countdown",
    duration=30000,
    timer_format="verbose",
    rerun=True,    
)

app.layout = html.H4(["Updating in ",timer])


if __name__ == "__main__":
    app.run_server(debug=True)


Countdown timer - to target time

timer2

from dash import Dash, dcc
import dash_bootstrap_components as dbc

app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])

timer = dcc.Timer(mode="countdown", duration=1000000, timer_format="default")

app.layout = dbc.Container(
    [
        "Hurry! Special offer ends in ",
        dbc.Badge(timer, color="danger")
    ],className="h4"
)

if __name__ == "__main__":
    app.run_server(debug=True)


Stopwatch - repeats every 30 seconds

(partial gif)
timer3

from dash import Dash, dcc, html

app = Dash(__name__)

timer = dcc.Timer(
    mode="stopwatch",
    duration=30000,
    timer_format="verbose",
    rerun=True,    
)

app.layout = html.H4(
    [
        "Loading data. This will take about 30 seconds.",
        html.Div(["It has been ", timer])
    ]
)


if __name__ == "__main__":
    app.run_server(debug=True)


"Stopwatch - with custom messages",

(partial gif- just shows first 30 seconds)
timer4

from dash import Dash, dcc, html

app = Dash(__name__)

timer = dcc.Timer(
    mode="stopwatch",
    duration=421000,
    messages={
        5000: "Task submitted! This will take around five minutes.",
        10000: "Task submitted! This will take around five minutes. It has been 10 seconds.",
        30000: "Task submitted! This will take around five minutes. It has been 30 seconds.",
        60000: "Task submitted! This will take around five minutes. It has been one minute.",
        120000: "Task submitted! This will take around five minutes. It has been two minutes.",
        180000: "Task submitted! This will take around five minutes. It has been three minutes.",
        240000: "Task submitted! This will take around five minutes. It has been four minutes.",
        300000: "Task submitted! It has been five minutes - it should be done momentarily!",
        330000: "Task submitted! This will take around five minutes. It has been five minutes and 30 seconds.\
                 It's taking a little longer than expected, hang tight!",
        360000: "Task submitted! This should have taken around five minutes. It has been six minutes.\
                 Something might've gone wrong. Reach out to eli@acme.corp.",
        420000: "Task submitted! This should have taken around five minutes. It is taking much longer \
                  than expected. Something might've gone wrong. Reach out to eli@acme.corp.",
    },
)

app.layout = html.Div(timer)


if __name__ == "__main__":
    app.run_server(debug=True)


Timing a callback

This example displays how long the callback has been running. When the start button is clicked, one callback starts the timer. A second callback pauses the timer when the callback finishes.
(Note that this only works if you stay on the page while the callback runs)

timer-callback

import dash
from dash import Dash, html, dcc, Input, Output
import time

app = Dash(__name__, suppress_callback_exceptions=True)


app.layout = html.Div(
    [html.Button("Start Job", id="start", n_clicks=0), html.Div(id="timer-display")]
)


@app.callback(
    Output("timer-display", "children"),
    Input("start", "n_clicks"),
)
def display_timer(n):
    if n > 0:
        return html.Div(
            [
                "Callback running time: ",
                dcc.Timer(
                    id="callback-timer",
                    mode="stopwatch",
                    timer_format="sub_ms",
                    interval=100,
                ),
            ]
        )
    return dash.no_update


@app.callback(
    Output("callback-timer", "disabled"),
    Input("start", "n_clicks"),
)
def stop_timer(n):
    if n > 0:
        # simulate a long running callback job
        time.sleep(4.2)
        return True


if __name__ == "__main__":
    app.run(debug=True)


Timing background callbacks

import time
import os

import dash
from dash import dcc, DiskcacheManager, CeleryManager, Input, Output, html

if 'REDIS_URL' in os.environ:
    # Use Redis & Celery if REDIS_URL set as an env variable
    from celery import Celery
    celery_app = Celery(__name__, broker=os.environ['REDIS_URL'], backend=os.environ['REDIS_URL'])
    background_callback_manager = CeleryManager(celery_app)

else:
    # Diskcache for non-production apps when developing locally
    import diskcache
    cache = diskcache.Cache("./cache")
    background_callback_manager = DiskcacheManager(cache)

app = dash.Dash(__name__, background_callback_manager=background_callback_manager)

timer = dcc.Timer(id="callback-timer", mode="stopwatch", timer_format="sub_ms", interval=100)

app.layout = html.Div(
    [
        html.Div([html.P(id="paragraph_id", children=["Button not clicked"])]),
        html.Button(id="button_id", children="Run Job!", n_clicks=0),
        html.Button(id="cancel_button_id", children="Cancel Running Job!"),
        html.Div(["Callback running time: ", timer], id="timer-div")
    ]
)

@dash.callback(
    output=Output("paragraph_id", "children"),
    inputs=Input("button_id", "n_clicks"),
    background=True,
    running=[
        (Output("button_id", "disabled"), True, False),
        (Output("cancel_button_id", "disabled"), False, True),
        (Output("callback-timer", "disabled"), False, True),
        (Output("callback-timer", "reset"), False, True)
    ],
    cancel=[Input("cancel_button_id", "n_clicks")],
)
def update_clicks(n_clicks):
    if n_clicks>0:
        time.sleep(2.0)
        return [f"Clicked {n_clicks} times"]


if __name__ == "__main__":
    app.run_server(debug=True)


Space shuttle launch 🚀

This example demos:

  • triggering a callback at a specified time with the at_fire_time prop
  • using the messages prop to display a message at a certain time (without using a callback)
  • configuring the dcc.Timer s in a callback based on user input (in this case, a button to start the countdown sequence)
  • the timer callback firing only one time when the countdown timer reaches 0.
from dash import Dash, dcc, html, Input, Output, no_update
import dash_bootstrap_components as dbc

app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])


shuttle = (
    "https://cdn.pixabay.com/photo/2012/11/28/10/33/rocket-launch-67641_960_720.jpg"
)

app.layout = dbc.Container(
    [
        dbc.Button("Restart Countdown", id="start", color="danger"),
        html.H1("Space Shuttle Endeavour Countdown"),
        html.H3(
            [
                dcc.Timer(
                    id="clock",
                    duration=51000,
                    mode="countdown",
                    timer_format="colons",
                    disabled=True,
                    className="bg-dark text-white p-2",
                    style={"width": 100},
                ),
                dcc.Timer(
                    id="shuttle_countdown",
                    mode="countdown",
                    disabled=True,
                    duration=51000,
                    fire_times=[0],
                    messages={
                        50000: "(T-50 seconds) Orbiter transfers from ground to internal power",
                        31000: "(T-31 seconds) Ground Launch Sequencer is go for auto sequence start",
                        16000: "(T-16 seconds) Activate launch pad sound suppression system",
                        10000: "(T-10 seconds) Activate main engine hydrogen burnoff system",
                        6000: "(T-6 seconds) Main engine start",
                        5000: "",
                        0: "Solid Rocket Booster ignition and LIFTOFF!",
                    },
                ),
            ]
        ),
        dbc.Modal(
            dbc.ModalBody(html.Img(src=shuttle, className="w-100")),
            id="modal",
            is_open=False,
        ),
    ],
    fluid=True,
    className="p-4",
)


@app.callback(
    Output("shuttle_countdown", "disabled"),
    Output("shuttle_countdown", "reset"),
    Output("clock", "disabled"),
    Output("clock", "reset"),
    Input("start", "n_clicks"),
)
def start(btn_clicks):
    if btn_clicks and btn_clicks >= 0:
        return False, True, False, True
    return no_update


@app.callback(
    Output("modal", "is_open"),
    Input("shuttle_countdown", "at_fire_time"),
)
def blastoff(at_fire_time):
    return at_fire_time == 0


if __name__ == "__main__":
    app.run_server(debug=True)

timer-shuttle



Limitations

When using with multi-page apps, if the timer is on a certain page rather than in app.py, the timer will restart when navigating to that page. This could be confusing in cases where the timer is set for a fixed duration. You might expect the timer to keep running when you switch pages (it does not). Timers set for a certain datetime will work as expected on any page of a multi-page app.

It's possible to use the at_fire_time prop to trigger a callback. However, this is not recommended for scheduling tasks because the user’s tab need to stay open until the specified time in order for the callback to be triggered. In a multi-page app, the callback will not fire unless page with the timer is open.

@AnnMarieW AnnMarieW marked this pull request as draft December 11, 2022 21:45
@AnnMarieW
Copy link
Contributor Author

I converted this PR to a draft because I'm concerned about the limitations as outlined above.

@ndrezn
Copy link
Member

ndrezn commented Dec 15, 2022

Love this. Have built this manually several times and the mess it adds to the callback graph is silly! Having this as a component that you can just drop in will be fantastic.

@AnnMarieW
Copy link
Contributor Author

I think it's best to close this now, given the concerns I listed earlier. If it turns out that it's a useful component, it could be published as a community component rather than integrated with Dash.

@AnnMarieW AnnMarieW closed this May 24, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants