diff --git a/parts/designing-surveys/designing-surveys.pdf b/parts/designing-surveys/designing-surveys.pdf
index 26226f0..41e3751 100644
Binary files a/parts/designing-surveys/designing-surveys.pdf and b/parts/designing-surveys/designing-surveys.pdf differ
diff --git a/parts/designing-surveys/figs/unnamed-chunk-24-1.png b/parts/designing-surveys/figs/unnamed-chunk-24-1.png
index 4efaf69..36ff2bd 100644
Binary files a/parts/designing-surveys/figs/unnamed-chunk-24-1.png and b/parts/designing-surveys/figs/unnamed-chunk-24-1.png differ
diff --git a/parts/designing-surveys/figs/unnamed-chunk-26-1.png b/parts/designing-surveys/figs/unnamed-chunk-26-1.png
index 1f67554..77d998d 100644
Binary files a/parts/designing-surveys/figs/unnamed-chunk-26-1.png and b/parts/designing-surveys/figs/unnamed-chunk-26-1.png differ
diff --git a/parts/designing-surveys/index.Rmd b/parts/designing-surveys/index.Rmd
index 7822ab4..98160e0 100755
--- a/parts/designing-surveys/index.Rmd
+++ b/parts/designing-surveys/index.Rmd
@@ -720,6 +720,19 @@ background-size: contain
class: inverse
+```{r}
+#| echo: false
+
+countdown::countdown(
+ minutes = 10,
+ warn_when = 30,
+ update_every = 1,
+ top = 0,
+ right = 0,
+ font_size = '2em'
+)
+```
+
# Your turn
- Be sure to have downloaded and unzipped the [practice code](https://jhelvy.github.io/2023-qux-conf-conjoint/practice/2023-qux-conf-conjoint.zip).
diff --git a/parts/designing-surveys/index.html b/parts/designing-surveys/index.html
index d689122..45c5a1e 100644
--- a/parts/designing-surveys/index.html
+++ b/parts/designing-surveys/index.html
@@ -29,6 +29,8 @@
+
+
@@ -304,12 +306,12 @@
```
#> profileID respID qID altID obsID price type freshness
-#> 1 55 1 1 1 1 3.5 Gala Excellent
-#> 2 15 1 1 2 1 1.0 Honeycrisp Poor
-#> 3 16 1 1 3 1 1.5 Honeycrisp Poor
-#> 4 6 1 2 1 2 3.5 Fuji Poor
-#> 5 35 1 2 2 2 4.0 Gala Average
-#> 6 57 1 2 3 2 1.0 Honeycrisp Excellent
+#> 1 53 1 1 1 1 2.5 Gala Excellent
+#> 2 45 1 1 2 1 2.0 Fuji Excellent
+#> 3 33 1 1 3 1 3.0 Gala Average
+#> 4 19 1 2 1 2 3.0 Honeycrisp Poor
+#> 5 14 1 2 2 2 4.0 Gala Poor
+#> 6 28 1 2 3 2 4.0 Fuji Average
```
---
@@ -336,12 +338,12 @@
```
#> profileID respID qID altID obsID price type_Fuji type_Gala type_Honeycrisp freshness_Poor freshness_Average freshness_Excellent no_choice
-#> 1 33 1 1 1 1 3.0 0 1 0 0 1 0 0
-#> 2 62 1 1 2 1 3.5 0 0 1 0 0 1 0
-#> 3 48 1 1 3 1 3.5 1 0 0 0 0 1 0
+#> 1 6 1 1 1 1 3.5 1 0 0 1 0 0 0
+#> 2 1 1 1 2 1 1.0 1 0 0 1 0 0 0
+#> 3 27 1 1 3 1 3.5 1 0 0 0 1 0 0
#> 4 0 1 1 4 1 0.0 0 0 0 0 0 0 1
-#> 5 15 1 2 1 2 1.0 0 0 1 1 0 0 0
-#> 6 23 1 2 2 2 1.5 1 0 0 0 1 0 0
+#> 5 48 1 2 1 2 3.5 1 0 0 0 0 1 0
+#> 6 1 1 2 2 2 1.0 1 0 0 1 0 0 0
```
---
@@ -370,12 +372,12 @@
```
#> profileID respID qID altID obsID price type freshness
-#> 1 26 1 1 1 1 3 Fuji Average
-#> 2 31 1 1 2 1 2 Gala Average
-#> 3 19 1 1 3 1 3 Honeycrisp Poor
-#> 4 1 1 2 1 2 1 Fuji Poor
-#> 5 8 1 2 2 2 1 Gala Poor
-#> 6 57 1 2 3 2 1 Honeycrisp Excellent
+#> 1 22 1 1 1 1 1.0 Fuji Average
+#> 2 55 1 1 2 1 3.5 Gala Excellent
+#> 3 63 1 1 3 1 4.0 Honeycrisp Excellent
+#> 4 28 1 2 1 2 4.0 Fuji Average
+#> 5 54 1 2 2 2 3.0 Gala Excellent
+#> 6 57 1 2 3 2 1.0 Honeycrisp Excellent
```
---
@@ -421,12 +423,12 @@
```
#> respID qID altID obsID price type freshness
-#> 1 1 1 1 1 3 Fuji Average
-#> 2 1 1 2 1 2 Gala Average
-#> 3 1 1 3 1 3 Honeycrisp Poor
-#> 4 1 2 1 2 1 Fuji Poor
-#> 5 1 2 2 2 1 Gala Poor
-#> 6 1 2 3 2 1 Honeycrisp Excellent
+#> 1 1 1 1 1 1.0 Fuji Average
+#> 2 1 1 2 1 3.5 Gala Excellent
+#> 3 1 1 3 1 4.0 Honeycrisp Excellent
+#> 4 1 2 1 2 4.0 Fuji Average
+#> 5 1 2 2 2 3.0 Gala Excellent
+#> 6 1 2 3 2 1.0 Honeycrisp Excellent
```
---
@@ -549,12 +551,12 @@
```
#> profileID respID qID altID obsID price type freshness choice
-#> 1 26 1 1 1 1 3 Fuji Average 0
-#> 2 31 1 1 2 1 2 Gala Average 1
-#> 3 19 1 1 3 1 3 Honeycrisp Poor 0
-#> 4 1 1 2 1 2 1 Fuji Poor 0
-#> 5 8 1 2 2 2 1 Gala Poor 0
-#> 6 57 1 2 3 2 1 Honeycrisp Excellent 1
+#> 1 22 1 1 1 1 1.0 Fuji Average 0
+#> 2 55 1 1 2 1 3.5 Gala Excellent 0
+#> 3 63 1 1 3 1 4.0 Honeycrisp Excellent 1
+#> 4 28 1 2 1 2 4.0 Fuji Average 1
+#> 5 54 1 2 2 2 3.0 Gala Excellent 0
+#> 6 57 1 2 3 2 1.0 Honeycrisp Excellent 0
```
---
@@ -705,13 +707,13 @@
```
```
-#> sampleSize coef est se
-#> 1 30 price -0.0395841 0.09664295
-#> 2 30 typeGala 0.3198033 0.18958184
-#> 3 30 typeHoneycrisp 0.1728158 0.19532138
-#> 4 30 freshnessAverage 0.6564963 0.22311590
-#> 5 30 freshnessExcellent -0.1699275 0.24271214
-#> 6 60 price -0.1187266 0.06587854
+#> sampleSize coef est se
+#> 1 30 price -0.18969925 0.09519838
+#> 2 30 typeGala -0.03074145 0.19389450
+#> 3 30 typeHoneycrisp 0.19956629 0.18165533
+#> 4 30 freshnessAverage 0.46713033 0.23479744
+#> 5 30 freshnessExcellent 0.47712173 0.22886577
+#> 6 60 price -0.13185249 0.06576991
```
]
@@ -724,13 +726,13 @@
```
```
-#> sampleSize coef est se
-#> 45 270 freshnessExcellent -0.1221717 0.07561697
-#> 46 300 price -0.1388063 0.02949868
-#> 47 300 typeGala 0.1638605 0.06015893
-#> 48 300 typeHoneycrisp 0.2682089 0.05873098
-#> 49 300 freshnessAverage 0.1894352 0.06988738
-#> 50 300 freshnessExcellent -0.1025510 0.07192388
+#> sampleSize coef est se
+#> 45 270 freshnessExcellent -0.14791440 0.07498391
+#> 46 300 price -0.11983143 0.02896963
+#> 47 300 typeGala 0.08577075 0.05984688
+#> 48 300 typeHoneycrisp 0.22142284 0.05810356
+#> 49 300 freshnessAverage 0.17092085 0.07083180
+#> 50 300 freshnessExcellent -0.11784026 0.07093520
```
]
@@ -805,6 +807,11 @@
class: inverse
+
+
# Your turn
- Be sure to have downloaded and unzipped the [practice code](https://jhelvy.github.io/2023-qux-conf-conjoint/practice/2023-qux-conf-conjoint.zip).
diff --git a/parts/designing-surveys/libs/countdown/countdown.css b/parts/designing-surveys/libs/countdown/countdown.css
new file mode 100644
index 0000000..b438be5
--- /dev/null
+++ b/parts/designing-surveys/libs/countdown/countdown.css
@@ -0,0 +1,144 @@
+.countdown {
+ background: inherit;
+ position: absolute;
+ cursor: pointer;
+ font-size: 2em;
+ line-height: 1;
+ border-color: #ddd;
+ border-width: 3px;
+ border-style: solid;
+ border-radius: 15px;
+ box-shadow: 0px 4px 10px 0px rgba(50, 50, 50, 0.4);
+ -webkit-box-shadow: 0px 4px 10px 0px rgba(50, 50, 50, 0.4);
+ margin: 0.6em;
+ padding: 10px 15px;
+ text-align: center;
+ z-index: 10;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+.countdown {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.countdown .countdown-time {
+ background: none;
+ font-size: 100%;
+ padding: 0;
+}
+.countdown-digits {
+ color: inherit;
+}
+.countdown.running {
+ border-color: #2A9B59FF;
+ background-color: #43AC6A;
+}
+.countdown.running .countdown-digits {
+ color: #002F14FF;
+}
+.countdown.finished {
+ border-color: #DE3000FF;
+ background-color: #F04124;
+}
+.countdown.finished .countdown-digits {
+ color: #4A0900FF;
+}
+.countdown.running.warning {
+ border-color: #CEAC04FF;
+ background-color: #E6C229;
+}
+.countdown.running.warning .countdown-digits {
+ color: #3A2F02FF;
+}
+
+.countdown.running.blink-colon .countdown-digits.colon {
+ opacity: 0.1;
+}
+
+/* ------ Controls ------ */
+.countdown:not(.running) .countdown-controls {
+ display: none;
+}
+
+.countdown-controls {
+ position: absolute;
+ top: -0.5rem;
+ right: -0.5rem;
+ left: -0.5rem;
+ display: flex;
+ justify-content: space-between;
+ margin: 0;
+ padding: 0;
+}
+
+.countdown-controls > button {
+ font-size: 1.5rem;
+ width: 1rem;
+ height: 1rem;
+ display: inline-block;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-family: monospace;
+ padding: 10px;
+ margin: 0;
+ background: inherit;
+ border: 2px solid;
+ border-radius: 100%;
+ transition: 50ms transform ease-in-out, 150ms opacity ease-in;
+ --countdown-transition-distance: 10px;
+}
+
+.countdown .countdown-controls > button:last-child {
+ transform: translate(calc(-1 * var(--countdown-transition-distance)), var(--countdown-transition-distance));
+ opacity: 0;
+ color: #002F14FF;
+ background-color: #43AC6A;
+ border-color: #2A9B59FF;
+}
+
+.countdown .countdown-controls > button:first-child {
+ transform: translate(var(--countdown-transition-distance), var(--countdown-transition-distance));
+ opacity: 0;
+ color: #4A0900FF;
+ background-color: #F04124;
+ border-color: #DE3000FF;
+}
+
+.countdown.running:hover .countdown-controls > button,
+.countdown.running:focus-within .countdown-controls > button{
+ transform: translate(0, 0);
+ opacity: 1;
+}
+
+.countdown.running:hover .countdown-controls > button:hover,
+.countdown.running:focus-within .countdown-controls > button:hover{
+ transform: translate(0, calc(var(--countdown-transition-distance) / -2));
+ box-shadow: 0px 2px 5px 0px rgba(50, 50, 50, 0.4);
+ -webkit-box-shadow: 0px 2px 5px 0px rgba(50, 50, 50, 0.4);
+}
+
+.countdown.running:hover .countdown-controls > button:active,
+.countdown.running:focus-within .countdown-controls > button:active{
+ transform: translate(0, calc(var(--coutndown-transition-distance) / -5));
+}
+
+/* ----- Fullscreen ----- */
+.countdown.countdown-fullscreen {
+ z-index: 0;
+}
+
+.countdown-fullscreen.running .countdown-controls {
+ top: 1rem;
+ left: 0;
+ right: 0;
+ justify-content: center;
+}
+
+.countdown-fullscreen.running .countdown-controls > button + button {
+ margin-left: 1rem;
+}
diff --git a/parts/designing-surveys/libs/countdown/countdown.js b/parts/designing-surveys/libs/countdown/countdown.js
new file mode 100644
index 0000000..a058ad8
--- /dev/null
+++ b/parts/designing-surveys/libs/countdown/countdown.js
@@ -0,0 +1,478 @@
+/* globals Shiny,Audio */
+class CountdownTimer {
+ constructor (el, opts) {
+ if (typeof el === 'string' || el instanceof String) {
+ el = document.querySelector(el)
+ }
+
+ if (el.counter) {
+ return el.counter
+ }
+
+ const minutes = parseInt(el.querySelector('.minutes').innerText || '0')
+ const seconds = parseInt(el.querySelector('.seconds').innerText || '0')
+ const duration = minutes * 60 + seconds
+
+ function attrIsTrue (x) {
+ if (x === true) return true
+ return !!(x === 'true' || x === '' || x === '1')
+ }
+
+ this.element = el
+ this.duration = duration
+ this.end = null
+ this.is_running = false
+ this.warn_when = parseInt(el.dataset.warnWhen) || -1
+ this.update_every = parseInt(el.dataset.updateEvery) || 1
+ this.play_sound = attrIsTrue(el.dataset.playSound)
+ this.blink_colon = attrIsTrue(el.dataset.blinkColon)
+ this.startImmediately = attrIsTrue(el.dataset.startImmediately)
+ this.timeout = null
+ this.display = { minutes, seconds }
+
+ if (opts.src_location) {
+ this.src_location = opts.src_location
+ }
+
+ this.addEventListeners()
+ }
+
+ addEventListeners () {
+ const self = this
+
+ if (this.startImmediately) {
+ if (window.remark && window.slideshow) {
+ // Remark (xaringan) support
+ const isOnVisibleSlide = () => {
+ return document.querySelector('.remark-visible').contains(self.element)
+ }
+ if (isOnVisibleSlide()) {
+ self.start()
+ } else {
+ let started_once = 0
+ window.slideshow.on('afterShowSlide', function () {
+ if (started_once > 0) return
+ if (isOnVisibleSlide()) {
+ self.start()
+ started_once = 1
+ }
+ })
+ }
+ } else if (window.Reveal) {
+ // Revealjs (quarto) support
+ const isOnVisibleSlide = () => {
+ const currentSlide = document.querySelector('.reveal .slide.present')
+ return currentSlide ? currentSlide.contains(self.element) : false
+ }
+ if (isOnVisibleSlide()) {
+ self.start()
+ } else {
+ const revealStartTimer = () => {
+ if (isOnVisibleSlide()) {
+ self.start()
+ window.Reveal.off('slidechanged', revealStartTimer)
+ }
+ }
+ window.Reveal.on('slidechanged', revealStartTimer)
+ }
+ } else if (window.IntersectionObserver) {
+ // All other situtations use IntersectionObserver
+ const onVisible = (element, callback) => {
+ new window.IntersectionObserver((entries, observer) => {
+ entries.forEach(entry => {
+ if (entry.intersectionRatio > 0) {
+ callback(element)
+ observer.disconnect()
+ }
+ })
+ }).observe(element)
+ }
+ onVisible(this.element, el => el.countdown.start())
+ } else {
+ // or just start the timer as soon as it's initialized
+ this.start()
+ }
+ }
+
+ function haltEvent (ev) {
+ ev.preventDefault()
+ ev.stopPropagation()
+ }
+ function isSpaceOrEnter (ev) {
+ return ev.code === 'Space' || ev.code === 'Enter'
+ }
+ function isArrowUpOrDown (ev) {
+ return ev.code === 'ArrowUp' || ev.code === 'ArrowDown'
+ }
+
+ ;['click', 'touchend'].forEach(function (eventType) {
+ self.element.addEventListener(eventType, function (ev) {
+ haltEvent(ev)
+ self.is_running ? self.stop() : self.start()
+ })
+ })
+ this.element.addEventListener('keydown', function (ev) {
+ if (ev.code === "Escape") {
+ self.reset()
+ haltEvent(ev)
+ }
+ if (!isSpaceOrEnter(ev) && !isArrowUpOrDown(ev)) return
+ haltEvent(ev)
+ if (isSpaceOrEnter(ev)) {
+ self.is_running ? self.stop() : self.start()
+ return
+ }
+
+ if (!self.is_running) return
+
+ if (ev.code === 'ArrowUp') {
+ self.bumpUp()
+ } else if (ev.code === 'ArrowDown') {
+ self.bumpDown()
+ }
+ })
+ this.element.addEventListener('dblclick', function (ev) {
+ haltEvent(ev)
+ if (self.is_running) self.reset()
+ })
+ this.element.addEventListener('touchmove', haltEvent)
+
+ const btnBumpDown = this.element.querySelector('.countdown-bump-down')
+ ;['click', 'touchend'].forEach(function (eventType) {
+ btnBumpDown.addEventListener(eventType, function (ev) {
+ haltEvent(ev)
+ if (self.is_running) self.bumpDown()
+ })
+ })
+ btnBumpDown.addEventListener('keydown', function (ev) {
+ if (!isSpaceOrEnter(ev) || !self.is_running) return
+ haltEvent(ev)
+ self.bumpDown()
+ })
+
+ const btnBumpUp = this.element.querySelector('.countdown-bump-up')
+ ;['click', 'touchend'].forEach(function (eventType) {
+ btnBumpUp.addEventListener(eventType, function (ev) {
+ haltEvent(ev)
+ if (self.is_running) self.bumpUp()
+ })
+ })
+ btnBumpUp.addEventListener('keydown', function (ev) {
+ if (!isSpaceOrEnter(ev) || !self.is_running) return
+ haltEvent(ev)
+ self.bumpUp()
+ })
+ this.element.querySelector('.countdown-controls').addEventListener('dblclick', function (ev) {
+ haltEvent(ev)
+ })
+ }
+
+ remainingTime () {
+ const remaining = this.is_running
+ ? (this.end - Date.now()) / 1000
+ : this.remaining || this.duration
+
+ let minutes = Math.floor(remaining / 60)
+ let seconds = Math.ceil(remaining - minutes * 60)
+
+ if (seconds > 59) {
+ minutes = minutes + 1
+ seconds = seconds - 60
+ }
+
+ return { remaining, minutes, seconds }
+ }
+
+ start () {
+ if (this.is_running) return
+
+ this.is_running = true
+
+ if (this.remaining) {
+ // Having a static remaining time indicates timer was paused
+ this.end = Date.now() + this.remaining * 1000
+ this.remaining = null
+ } else {
+ this.end = Date.now() + this.duration * 1000
+ }
+
+ this.reportStateToShiny('start')
+
+ this.element.classList.remove('finished')
+ this.element.classList.add('running')
+ this.update(true)
+ this.tick()
+ }
+
+ tick (run_again) {
+ if (typeof run_again === 'undefined') {
+ run_again = true
+ }
+
+ if (!this.is_running) return
+
+ const { seconds: secondsWas } = this.display
+ this.update()
+
+ if (run_again) {
+ const delay = (this.end - Date.now() > 10000) ? 1000 : 250
+ this.blinkColon(secondsWas)
+ this.timeout = setTimeout(this.tick.bind(this), delay)
+ }
+ }
+
+ blinkColon (secondsWas) {
+ // don't blink unless option is set
+ if (!this.blink_colon) return
+ // warn_when always updates the seconds
+ if (this.warn_when > 0 && Date.now() + this.warn_when > this.end) {
+ this.element.classList.remove('blink-colon')
+ return
+ }
+ const { seconds: secondsIs } = this.display
+ if (secondsIs > 10 || secondsWas !== secondsIs) {
+ this.element.classList.toggle('blink-colon')
+ }
+ }
+
+ update (force) {
+ if (typeof force === 'undefined') {
+ force = false
+ }
+
+ const { remaining, minutes, seconds } = this.remainingTime()
+
+ const setRemainingTime = (selector, time) => {
+ const timeContainer = this.element.querySelector(selector)
+ if (!timeContainer) return
+ time = Math.max(time, 0)
+ timeContainer.innerText = String(time).padStart(2, 0)
+ }
+
+ if (this.is_running && remaining < 0.25) {
+ this.stop()
+ setRemainingTime('.minutes', 0)
+ setRemainingTime('.seconds', 0)
+ this.playSound()
+ return
+ }
+
+ const should_update = force ||
+ Math.round(remaining) < this.warn_when ||
+ Math.round(remaining) % this.update_every === 0
+
+ if (should_update) {
+ this.element.classList.toggle('warning', remaining <= this.warn_when)
+ this.display = { minutes, seconds }
+ setRemainingTime('.minutes', minutes)
+ setRemainingTime('.seconds', seconds)
+ }
+ }
+
+ stop () {
+ const { remaining } = this.remainingTime()
+ if (remaining > 1) {
+ this.remaining = remaining
+ }
+ this.element.classList.remove('running')
+ this.element.classList.remove('warning')
+ this.element.classList.remove('blink-colon')
+ this.element.classList.add('finished')
+ this.is_running = false
+ this.end = null
+ this.reportStateToShiny('stop')
+ this.timeout = clearTimeout(this.timeout)
+ }
+
+ reset () {
+ this.stop()
+ this.remaining = null
+ this.update(true)
+ this.reportStateToShiny('reset')
+ this.element.classList.remove('finished')
+ this.element.classList.remove('warning')
+ }
+
+ setValues (opts) {
+ if (typeof opts.warn_when !== 'undefined') {
+ this.warn_when = opts.warn_when
+ }
+ if (typeof opts.update_every !== 'undefined') {
+ this.update_every = opts.update_every
+ }
+ if (typeof opts.blink_colon !== 'undefined') {
+ this.blink_colon = opts.blink_colon
+ if (!opts.blink_colon) {
+ this.element.classList.remove('blink-colon')
+ }
+ }
+ if (typeof opts.play_sound !== 'undefined') {
+ this.play_sound = opts.play_sound
+ }
+ if (typeof opts.duration !== 'undefined') {
+ this.duration = opts.duration
+ if (this.is_running) {
+ this.reset()
+ this.start()
+ }
+ }
+ this.reportStateToShiny('update')
+ this.update(true)
+ }
+
+ bumpTimer (val, round) {
+ round = typeof round === 'boolean' ? round : true
+ const { remaining } = this.remainingTime()
+ let newRemaining = remaining + val
+ if (newRemaining <= 0) {
+ this.setRemaining(0)
+ this.stop()
+ return
+ }
+ if (round && newRemaining > 10) {
+ newRemaining = Math.round(newRemaining / 5) * 5
+ }
+ this.setRemaining(newRemaining)
+ this.reportStateToShiny(val > 0 ? 'bumpUp' : 'bumpDown')
+ this.update(true)
+ }
+
+ bumpUp (val) {
+ if (!this.is_running) {
+ console.error('timer is not running')
+ return
+ }
+ this.bumpTimer(
+ val || this.bumpIncrementValue(),
+ typeof val === 'undefined'
+ )
+ }
+
+ bumpDown (val) {
+ if (!this.is_running) {
+ console.error('timer is not running')
+ return
+ }
+ this.bumpTimer(
+ val || -1 * this.bumpIncrementValue(),
+ typeof val === 'undefined'
+ )
+ }
+
+ setRemaining (val) {
+ if (!this.is_running) {
+ console.error('timer is not running')
+ return
+ }
+ this.end = Date.now() + val * 1000
+ this.update(true)
+ }
+
+ playSound () {
+ let url = this.play_sound
+ if (!url) return
+ if (typeof url === 'boolean') {
+ const src = this.src_location
+ ? this.src_location.replace('/countdown.js', '')
+ : 'libs/countdown'
+ url = src + '/smb_stage_clear.mp3'
+ }
+ const sound = new Audio(url)
+ sound.play()
+ }
+
+ bumpIncrementValue (val) {
+ val = val || this.remainingTime().remaining
+ if (val <= 30) {
+ return 5
+ } else if (val <= 300) {
+ return 15
+ } else if (val <= 3000) {
+ return 30
+ } else {
+ return 60
+ }
+ }
+
+ reportStateToShiny (action) {
+ if (!window.Shiny) return
+
+ const inputId = this.element.id
+ const data = {
+ event: {
+ action,
+ time: new Date().toISOString()
+ },
+ timer: {
+ is_running: this.is_running,
+ end: this.end ? new Date(this.end).toISOString() : null,
+ remaining: this.remainingTime()
+ }
+ }
+
+ function shinySetInputValue () {
+ if (!window.Shiny.setInputValue) {
+ setTimeout(shinySetInputValue, 100)
+ return
+ }
+ window.Shiny.setInputValue(inputId, data)
+ }
+
+ shinySetInputValue()
+ }
+}
+
+(function () {
+ const CURRENT_SCRIPT = document.currentScript.getAttribute('src')
+
+ document.addEventListener('DOMContentLoaded', function () {
+ const els = document.querySelectorAll('.countdown')
+ if (!els || !els.length) {
+ return
+ }
+ els.forEach(function (el) {
+ el.countdown = new CountdownTimer(el, { src_location: CURRENT_SCRIPT })
+ })
+
+ if (window.Shiny) {
+ Shiny.addCustomMessageHandler('countdown:update', function (x) {
+ if (!x.id) {
+ console.error('No `id` provided, cannot update countdown')
+ return
+ }
+ const el = document.getElementById(x.id)
+ el.countdown.setValues(x)
+ })
+
+ Shiny.addCustomMessageHandler('countdown:start', function (id) {
+ const el = document.getElementById(id)
+ if (!el) return
+ el.countdown.start()
+ })
+
+ Shiny.addCustomMessageHandler('countdown:stop', function (id) {
+ const el = document.getElementById(id)
+ if (!el) return
+ el.countdown.stop()
+ })
+
+ Shiny.addCustomMessageHandler('countdown:reset', function (id) {
+ const el = document.getElementById(id)
+ if (!el) return
+ el.countdown.reset()
+ })
+
+ Shiny.addCustomMessageHandler('countdown:bumpUp', function (id) {
+ const el = document.getElementById(id)
+ if (!el) return
+ el.countdown.bumpUp()
+ })
+
+ Shiny.addCustomMessageHandler('countdown:bumpDown', function (id) {
+ const el = document.getElementById(id)
+ if (!el) return
+ el.countdown.bumpDown()
+ })
+ }
+ })
+})()
diff --git a/parts/designing-surveys/libs/countdown/smb_stage_clear.mp3 b/parts/designing-surveys/libs/countdown/smb_stage_clear.mp3
new file mode 100644
index 0000000..da2ddc2
Binary files /dev/null and b/parts/designing-surveys/libs/countdown/smb_stage_clear.mp3 differ
diff --git a/parts/estimating-models/estimating-models.pdf b/parts/estimating-models/estimating-models.pdf
index fb8e812..1049902 100644
Binary files a/parts/estimating-models/estimating-models.pdf and b/parts/estimating-models/estimating-models.pdf differ
diff --git a/parts/estimating-models/index.Rmd b/parts/estimating-models/index.Rmd
index 7cf5f81..8b6ab91 100755
--- a/parts/estimating-models/index.Rmd
+++ b/parts/estimating-models/index.Rmd
@@ -890,6 +890,19 @@ predict(
class: inverse
+```{r}
+#| echo: false
+
+countdown::countdown(
+ minutes = 10,
+ warn_when = 30,
+ update_every = 1,
+ top = 0,
+ right = 0,
+ font_size = '2em'
+)
+```
+
# Your turn
- Be sure to have downloaded and unzipped the [practice code](https://jhelvy.github.io/2023-qux-conf-conjoint/practice/2023-qux-conf-conjoint.zip).
diff --git a/parts/estimating-models/index.html b/parts/estimating-models/index.html
index 531f521..0436188 100644
--- a/parts/estimating-models/index.html
+++ b/parts/estimating-models/index.html
@@ -29,6 +29,8 @@
+
+
@@ -269,9 +271,9 @@
```
#> Estimate Std. Error z-value Pr(>|z|)
-#> brandhiland -8.01982 0.45951 -17.4529 < 2.2e-16 ***
-#> brandyoplait 3.72173 0.15882 23.4330 < 2.2e-16 ***
-#> branddannon 1.65734 0.16723 9.9105 < 2.2e-16 ***
+#> brandhiland -8.01982 0.46096 -17.3980 < 2.2e-16 ***
+#> brandyoplait 3.72173 0.15890 23.4214 < 2.2e-16 ***
+#> branddannon 1.65734 0.16832 9.8463 < 2.2e-16 ***
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
```
@@ -465,10 +467,10 @@
```
#> Estimate Std. Error z-value Pr(>|z|)
-#> scalePar 0.388626 0.024254 16.0230 < 2.2e-16 ***
-#> brandhiland -8.019815 0.459511 -17.4529 < 2.2e-16 ***
-#> brandyoplait 3.721731 0.158824 23.4330 < 2.2e-16 ***
-#> branddannon 1.657345 0.167231 9.9105 < 2.2e-16 ***
+#> scalePar 0.388626 0.024399 15.9280 < 2.2e-16 ***
+#> brandhiland -8.019815 0.460961 -17.3980 < 2.2e-16 ***
+#> brandyoplait 3.721731 0.158903 23.4214 < 2.2e-16 ***
+#> branddannon 1.657345 0.168321 9.8463 < 2.2e-16 ***
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
```
@@ -996,6 +998,11 @@
class: inverse
+
+
# Your turn
- Be sure to have downloaded and unzipped the [practice code](https://jhelvy.github.io/2023-qux-conf-conjoint/practice/2023-qux-conf-conjoint.zip).
diff --git a/parts/estimating-models/libs/countdown/countdown.css b/parts/estimating-models/libs/countdown/countdown.css
new file mode 100644
index 0000000..b438be5
--- /dev/null
+++ b/parts/estimating-models/libs/countdown/countdown.css
@@ -0,0 +1,144 @@
+.countdown {
+ background: inherit;
+ position: absolute;
+ cursor: pointer;
+ font-size: 2em;
+ line-height: 1;
+ border-color: #ddd;
+ border-width: 3px;
+ border-style: solid;
+ border-radius: 15px;
+ box-shadow: 0px 4px 10px 0px rgba(50, 50, 50, 0.4);
+ -webkit-box-shadow: 0px 4px 10px 0px rgba(50, 50, 50, 0.4);
+ margin: 0.6em;
+ padding: 10px 15px;
+ text-align: center;
+ z-index: 10;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+.countdown {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.countdown .countdown-time {
+ background: none;
+ font-size: 100%;
+ padding: 0;
+}
+.countdown-digits {
+ color: inherit;
+}
+.countdown.running {
+ border-color: #2A9B59FF;
+ background-color: #43AC6A;
+}
+.countdown.running .countdown-digits {
+ color: #002F14FF;
+}
+.countdown.finished {
+ border-color: #DE3000FF;
+ background-color: #F04124;
+}
+.countdown.finished .countdown-digits {
+ color: #4A0900FF;
+}
+.countdown.running.warning {
+ border-color: #CEAC04FF;
+ background-color: #E6C229;
+}
+.countdown.running.warning .countdown-digits {
+ color: #3A2F02FF;
+}
+
+.countdown.running.blink-colon .countdown-digits.colon {
+ opacity: 0.1;
+}
+
+/* ------ Controls ------ */
+.countdown:not(.running) .countdown-controls {
+ display: none;
+}
+
+.countdown-controls {
+ position: absolute;
+ top: -0.5rem;
+ right: -0.5rem;
+ left: -0.5rem;
+ display: flex;
+ justify-content: space-between;
+ margin: 0;
+ padding: 0;
+}
+
+.countdown-controls > button {
+ font-size: 1.5rem;
+ width: 1rem;
+ height: 1rem;
+ display: inline-block;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-family: monospace;
+ padding: 10px;
+ margin: 0;
+ background: inherit;
+ border: 2px solid;
+ border-radius: 100%;
+ transition: 50ms transform ease-in-out, 150ms opacity ease-in;
+ --countdown-transition-distance: 10px;
+}
+
+.countdown .countdown-controls > button:last-child {
+ transform: translate(calc(-1 * var(--countdown-transition-distance)), var(--countdown-transition-distance));
+ opacity: 0;
+ color: #002F14FF;
+ background-color: #43AC6A;
+ border-color: #2A9B59FF;
+}
+
+.countdown .countdown-controls > button:first-child {
+ transform: translate(var(--countdown-transition-distance), var(--countdown-transition-distance));
+ opacity: 0;
+ color: #4A0900FF;
+ background-color: #F04124;
+ border-color: #DE3000FF;
+}
+
+.countdown.running:hover .countdown-controls > button,
+.countdown.running:focus-within .countdown-controls > button{
+ transform: translate(0, 0);
+ opacity: 1;
+}
+
+.countdown.running:hover .countdown-controls > button:hover,
+.countdown.running:focus-within .countdown-controls > button:hover{
+ transform: translate(0, calc(var(--countdown-transition-distance) / -2));
+ box-shadow: 0px 2px 5px 0px rgba(50, 50, 50, 0.4);
+ -webkit-box-shadow: 0px 2px 5px 0px rgba(50, 50, 50, 0.4);
+}
+
+.countdown.running:hover .countdown-controls > button:active,
+.countdown.running:focus-within .countdown-controls > button:active{
+ transform: translate(0, calc(var(--coutndown-transition-distance) / -5));
+}
+
+/* ----- Fullscreen ----- */
+.countdown.countdown-fullscreen {
+ z-index: 0;
+}
+
+.countdown-fullscreen.running .countdown-controls {
+ top: 1rem;
+ left: 0;
+ right: 0;
+ justify-content: center;
+}
+
+.countdown-fullscreen.running .countdown-controls > button + button {
+ margin-left: 1rem;
+}
diff --git a/parts/estimating-models/libs/countdown/countdown.js b/parts/estimating-models/libs/countdown/countdown.js
new file mode 100644
index 0000000..a058ad8
--- /dev/null
+++ b/parts/estimating-models/libs/countdown/countdown.js
@@ -0,0 +1,478 @@
+/* globals Shiny,Audio */
+class CountdownTimer {
+ constructor (el, opts) {
+ if (typeof el === 'string' || el instanceof String) {
+ el = document.querySelector(el)
+ }
+
+ if (el.counter) {
+ return el.counter
+ }
+
+ const minutes = parseInt(el.querySelector('.minutes').innerText || '0')
+ const seconds = parseInt(el.querySelector('.seconds').innerText || '0')
+ const duration = minutes * 60 + seconds
+
+ function attrIsTrue (x) {
+ if (x === true) return true
+ return !!(x === 'true' || x === '' || x === '1')
+ }
+
+ this.element = el
+ this.duration = duration
+ this.end = null
+ this.is_running = false
+ this.warn_when = parseInt(el.dataset.warnWhen) || -1
+ this.update_every = parseInt(el.dataset.updateEvery) || 1
+ this.play_sound = attrIsTrue(el.dataset.playSound)
+ this.blink_colon = attrIsTrue(el.dataset.blinkColon)
+ this.startImmediately = attrIsTrue(el.dataset.startImmediately)
+ this.timeout = null
+ this.display = { minutes, seconds }
+
+ if (opts.src_location) {
+ this.src_location = opts.src_location
+ }
+
+ this.addEventListeners()
+ }
+
+ addEventListeners () {
+ const self = this
+
+ if (this.startImmediately) {
+ if (window.remark && window.slideshow) {
+ // Remark (xaringan) support
+ const isOnVisibleSlide = () => {
+ return document.querySelector('.remark-visible').contains(self.element)
+ }
+ if (isOnVisibleSlide()) {
+ self.start()
+ } else {
+ let started_once = 0
+ window.slideshow.on('afterShowSlide', function () {
+ if (started_once > 0) return
+ if (isOnVisibleSlide()) {
+ self.start()
+ started_once = 1
+ }
+ })
+ }
+ } else if (window.Reveal) {
+ // Revealjs (quarto) support
+ const isOnVisibleSlide = () => {
+ const currentSlide = document.querySelector('.reveal .slide.present')
+ return currentSlide ? currentSlide.contains(self.element) : false
+ }
+ if (isOnVisibleSlide()) {
+ self.start()
+ } else {
+ const revealStartTimer = () => {
+ if (isOnVisibleSlide()) {
+ self.start()
+ window.Reveal.off('slidechanged', revealStartTimer)
+ }
+ }
+ window.Reveal.on('slidechanged', revealStartTimer)
+ }
+ } else if (window.IntersectionObserver) {
+ // All other situtations use IntersectionObserver
+ const onVisible = (element, callback) => {
+ new window.IntersectionObserver((entries, observer) => {
+ entries.forEach(entry => {
+ if (entry.intersectionRatio > 0) {
+ callback(element)
+ observer.disconnect()
+ }
+ })
+ }).observe(element)
+ }
+ onVisible(this.element, el => el.countdown.start())
+ } else {
+ // or just start the timer as soon as it's initialized
+ this.start()
+ }
+ }
+
+ function haltEvent (ev) {
+ ev.preventDefault()
+ ev.stopPropagation()
+ }
+ function isSpaceOrEnter (ev) {
+ return ev.code === 'Space' || ev.code === 'Enter'
+ }
+ function isArrowUpOrDown (ev) {
+ return ev.code === 'ArrowUp' || ev.code === 'ArrowDown'
+ }
+
+ ;['click', 'touchend'].forEach(function (eventType) {
+ self.element.addEventListener(eventType, function (ev) {
+ haltEvent(ev)
+ self.is_running ? self.stop() : self.start()
+ })
+ })
+ this.element.addEventListener('keydown', function (ev) {
+ if (ev.code === "Escape") {
+ self.reset()
+ haltEvent(ev)
+ }
+ if (!isSpaceOrEnter(ev) && !isArrowUpOrDown(ev)) return
+ haltEvent(ev)
+ if (isSpaceOrEnter(ev)) {
+ self.is_running ? self.stop() : self.start()
+ return
+ }
+
+ if (!self.is_running) return
+
+ if (ev.code === 'ArrowUp') {
+ self.bumpUp()
+ } else if (ev.code === 'ArrowDown') {
+ self.bumpDown()
+ }
+ })
+ this.element.addEventListener('dblclick', function (ev) {
+ haltEvent(ev)
+ if (self.is_running) self.reset()
+ })
+ this.element.addEventListener('touchmove', haltEvent)
+
+ const btnBumpDown = this.element.querySelector('.countdown-bump-down')
+ ;['click', 'touchend'].forEach(function (eventType) {
+ btnBumpDown.addEventListener(eventType, function (ev) {
+ haltEvent(ev)
+ if (self.is_running) self.bumpDown()
+ })
+ })
+ btnBumpDown.addEventListener('keydown', function (ev) {
+ if (!isSpaceOrEnter(ev) || !self.is_running) return
+ haltEvent(ev)
+ self.bumpDown()
+ })
+
+ const btnBumpUp = this.element.querySelector('.countdown-bump-up')
+ ;['click', 'touchend'].forEach(function (eventType) {
+ btnBumpUp.addEventListener(eventType, function (ev) {
+ haltEvent(ev)
+ if (self.is_running) self.bumpUp()
+ })
+ })
+ btnBumpUp.addEventListener('keydown', function (ev) {
+ if (!isSpaceOrEnter(ev) || !self.is_running) return
+ haltEvent(ev)
+ self.bumpUp()
+ })
+ this.element.querySelector('.countdown-controls').addEventListener('dblclick', function (ev) {
+ haltEvent(ev)
+ })
+ }
+
+ remainingTime () {
+ const remaining = this.is_running
+ ? (this.end - Date.now()) / 1000
+ : this.remaining || this.duration
+
+ let minutes = Math.floor(remaining / 60)
+ let seconds = Math.ceil(remaining - minutes * 60)
+
+ if (seconds > 59) {
+ minutes = minutes + 1
+ seconds = seconds - 60
+ }
+
+ return { remaining, minutes, seconds }
+ }
+
+ start () {
+ if (this.is_running) return
+
+ this.is_running = true
+
+ if (this.remaining) {
+ // Having a static remaining time indicates timer was paused
+ this.end = Date.now() + this.remaining * 1000
+ this.remaining = null
+ } else {
+ this.end = Date.now() + this.duration * 1000
+ }
+
+ this.reportStateToShiny('start')
+
+ this.element.classList.remove('finished')
+ this.element.classList.add('running')
+ this.update(true)
+ this.tick()
+ }
+
+ tick (run_again) {
+ if (typeof run_again === 'undefined') {
+ run_again = true
+ }
+
+ if (!this.is_running) return
+
+ const { seconds: secondsWas } = this.display
+ this.update()
+
+ if (run_again) {
+ const delay = (this.end - Date.now() > 10000) ? 1000 : 250
+ this.blinkColon(secondsWas)
+ this.timeout = setTimeout(this.tick.bind(this), delay)
+ }
+ }
+
+ blinkColon (secondsWas) {
+ // don't blink unless option is set
+ if (!this.blink_colon) return
+ // warn_when always updates the seconds
+ if (this.warn_when > 0 && Date.now() + this.warn_when > this.end) {
+ this.element.classList.remove('blink-colon')
+ return
+ }
+ const { seconds: secondsIs } = this.display
+ if (secondsIs > 10 || secondsWas !== secondsIs) {
+ this.element.classList.toggle('blink-colon')
+ }
+ }
+
+ update (force) {
+ if (typeof force === 'undefined') {
+ force = false
+ }
+
+ const { remaining, minutes, seconds } = this.remainingTime()
+
+ const setRemainingTime = (selector, time) => {
+ const timeContainer = this.element.querySelector(selector)
+ if (!timeContainer) return
+ time = Math.max(time, 0)
+ timeContainer.innerText = String(time).padStart(2, 0)
+ }
+
+ if (this.is_running && remaining < 0.25) {
+ this.stop()
+ setRemainingTime('.minutes', 0)
+ setRemainingTime('.seconds', 0)
+ this.playSound()
+ return
+ }
+
+ const should_update = force ||
+ Math.round(remaining) < this.warn_when ||
+ Math.round(remaining) % this.update_every === 0
+
+ if (should_update) {
+ this.element.classList.toggle('warning', remaining <= this.warn_when)
+ this.display = { minutes, seconds }
+ setRemainingTime('.minutes', minutes)
+ setRemainingTime('.seconds', seconds)
+ }
+ }
+
+ stop () {
+ const { remaining } = this.remainingTime()
+ if (remaining > 1) {
+ this.remaining = remaining
+ }
+ this.element.classList.remove('running')
+ this.element.classList.remove('warning')
+ this.element.classList.remove('blink-colon')
+ this.element.classList.add('finished')
+ this.is_running = false
+ this.end = null
+ this.reportStateToShiny('stop')
+ this.timeout = clearTimeout(this.timeout)
+ }
+
+ reset () {
+ this.stop()
+ this.remaining = null
+ this.update(true)
+ this.reportStateToShiny('reset')
+ this.element.classList.remove('finished')
+ this.element.classList.remove('warning')
+ }
+
+ setValues (opts) {
+ if (typeof opts.warn_when !== 'undefined') {
+ this.warn_when = opts.warn_when
+ }
+ if (typeof opts.update_every !== 'undefined') {
+ this.update_every = opts.update_every
+ }
+ if (typeof opts.blink_colon !== 'undefined') {
+ this.blink_colon = opts.blink_colon
+ if (!opts.blink_colon) {
+ this.element.classList.remove('blink-colon')
+ }
+ }
+ if (typeof opts.play_sound !== 'undefined') {
+ this.play_sound = opts.play_sound
+ }
+ if (typeof opts.duration !== 'undefined') {
+ this.duration = opts.duration
+ if (this.is_running) {
+ this.reset()
+ this.start()
+ }
+ }
+ this.reportStateToShiny('update')
+ this.update(true)
+ }
+
+ bumpTimer (val, round) {
+ round = typeof round === 'boolean' ? round : true
+ const { remaining } = this.remainingTime()
+ let newRemaining = remaining + val
+ if (newRemaining <= 0) {
+ this.setRemaining(0)
+ this.stop()
+ return
+ }
+ if (round && newRemaining > 10) {
+ newRemaining = Math.round(newRemaining / 5) * 5
+ }
+ this.setRemaining(newRemaining)
+ this.reportStateToShiny(val > 0 ? 'bumpUp' : 'bumpDown')
+ this.update(true)
+ }
+
+ bumpUp (val) {
+ if (!this.is_running) {
+ console.error('timer is not running')
+ return
+ }
+ this.bumpTimer(
+ val || this.bumpIncrementValue(),
+ typeof val === 'undefined'
+ )
+ }
+
+ bumpDown (val) {
+ if (!this.is_running) {
+ console.error('timer is not running')
+ return
+ }
+ this.bumpTimer(
+ val || -1 * this.bumpIncrementValue(),
+ typeof val === 'undefined'
+ )
+ }
+
+ setRemaining (val) {
+ if (!this.is_running) {
+ console.error('timer is not running')
+ return
+ }
+ this.end = Date.now() + val * 1000
+ this.update(true)
+ }
+
+ playSound () {
+ let url = this.play_sound
+ if (!url) return
+ if (typeof url === 'boolean') {
+ const src = this.src_location
+ ? this.src_location.replace('/countdown.js', '')
+ : 'libs/countdown'
+ url = src + '/smb_stage_clear.mp3'
+ }
+ const sound = new Audio(url)
+ sound.play()
+ }
+
+ bumpIncrementValue (val) {
+ val = val || this.remainingTime().remaining
+ if (val <= 30) {
+ return 5
+ } else if (val <= 300) {
+ return 15
+ } else if (val <= 3000) {
+ return 30
+ } else {
+ return 60
+ }
+ }
+
+ reportStateToShiny (action) {
+ if (!window.Shiny) return
+
+ const inputId = this.element.id
+ const data = {
+ event: {
+ action,
+ time: new Date().toISOString()
+ },
+ timer: {
+ is_running: this.is_running,
+ end: this.end ? new Date(this.end).toISOString() : null,
+ remaining: this.remainingTime()
+ }
+ }
+
+ function shinySetInputValue () {
+ if (!window.Shiny.setInputValue) {
+ setTimeout(shinySetInputValue, 100)
+ return
+ }
+ window.Shiny.setInputValue(inputId, data)
+ }
+
+ shinySetInputValue()
+ }
+}
+
+(function () {
+ const CURRENT_SCRIPT = document.currentScript.getAttribute('src')
+
+ document.addEventListener('DOMContentLoaded', function () {
+ const els = document.querySelectorAll('.countdown')
+ if (!els || !els.length) {
+ return
+ }
+ els.forEach(function (el) {
+ el.countdown = new CountdownTimer(el, { src_location: CURRENT_SCRIPT })
+ })
+
+ if (window.Shiny) {
+ Shiny.addCustomMessageHandler('countdown:update', function (x) {
+ if (!x.id) {
+ console.error('No `id` provided, cannot update countdown')
+ return
+ }
+ const el = document.getElementById(x.id)
+ el.countdown.setValues(x)
+ })
+
+ Shiny.addCustomMessageHandler('countdown:start', function (id) {
+ const el = document.getElementById(id)
+ if (!el) return
+ el.countdown.start()
+ })
+
+ Shiny.addCustomMessageHandler('countdown:stop', function (id) {
+ const el = document.getElementById(id)
+ if (!el) return
+ el.countdown.stop()
+ })
+
+ Shiny.addCustomMessageHandler('countdown:reset', function (id) {
+ const el = document.getElementById(id)
+ if (!el) return
+ el.countdown.reset()
+ })
+
+ Shiny.addCustomMessageHandler('countdown:bumpUp', function (id) {
+ const el = document.getElementById(id)
+ if (!el) return
+ el.countdown.bumpUp()
+ })
+
+ Shiny.addCustomMessageHandler('countdown:bumpDown', function (id) {
+ const el = document.getElementById(id)
+ if (!el) return
+ el.countdown.bumpDown()
+ })
+ }
+ })
+})()
diff --git a/parts/estimating-models/libs/countdown/smb_stage_clear.mp3 b/parts/estimating-models/libs/countdown/smb_stage_clear.mp3
new file mode 100644
index 0000000..da2ddc2
Binary files /dev/null and b/parts/estimating-models/libs/countdown/smb_stage_clear.mp3 differ