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

Feature stopwatch #741

Merged
merged 15 commits into from
Jul 13, 2024
Merged
117 changes: 117 additions & 0 deletions common-theme/assets/scripts/stop-watch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
class StopWatch {
constructor() {
if (StopWatch.instance) {
return StopWatch.instance;
}
this.attachListener();
StopWatch.instance = this;
}

attachListener() {
const timeElements = document.querySelectorAll(".c-block__time");

timeElements.forEach((timeElement) => {
timeElement.addEventListener("keydown", (event) => {
if (
event.key === "Enter" &&
!timeElement.classList.contains("is-active")
) {
this.startTimer(timeElement);
}
});
timeElement.addEventListener("click", () => {
if (!timeElement.classList.contains("is-active")) {
this.startTimer(timeElement);
}
});
});
}

startTimer(timeElement) {
const datetime = timeElement.getAttribute("datetime");
const duration = this.parseDuration(datetime);

if (!this.isValidDuration(duration)) {
console.error("The datetime attribute is not valid. Please fix it.");
return;
}

let startTime = Date.now();
timeElement.classList.add("is-active");

const timerInterval = setInterval(() => {
const remainingTime = this.updateTime(timeElement, startTime, duration);

if (remainingTime <= 0) {
clearInterval(timerInterval);
timeElement.classList.remove("is-active");
this.playAlarm(2, 200, 200);
}
}, 1000);
}

playAlarm(repetitions, delayBetweenRepetitions, delayBetweenSounds) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
const audioCtx = new AudioContext();

const playSound = async () => {
const oscillator = audioCtx.createOscillator();
oscillator.type = "triangle";
oscillator.frequency.setValueAtTime(1000, audioCtx.currentTime);
oscillator.connect(audioCtx.destination);
oscillator.start();

await new Promise((resolve) => setTimeout(resolve, delayBetweenSounds));
oscillator.stop();
};

const playNextRepetition = async (repetitionIndex) => {
if (repetitionIndex <= repetitions) {
await playSound();
await new Promise((resolve) =>
setTimeout(resolve, delayBetweenRepetitions)
);
await playNextRepetition(repetitionIndex + 1);
} else {
audioCtx.close();
}
};

playNextRepetition(0);
}

parseDuration(datetime) {
const match = datetime.match(/P(\d+)M/);
if (!match) {
return NaN;
}
const minutes = parseInt(match[1]);
return minutes * 60 * 1000; // Convert minutes to milliseconds
}

isValidDuration(duration) {
return !isNaN(duration) && duration > 0;
}

updateTime(element, startTime, duration) {
const currentTime = Date.now();
const elapsedTime = currentTime - startTime;
const remainingTime = duration - elapsedTime;

if (remainingTime <= 0) {
element.textContent = "Countdown finished!";
return 0;
}

const minutes = Math.floor(remainingTime / 60000);
const seconds = Math.floor((remainingTime % 60000) / 1000);

element.textContent = `${minutes} minutes ${
seconds < 10 ? "0" : ""
}${seconds} seconds ⏱`;
return remainingTime;
}
}

const stopwatchInstance = new StopWatch();
export default stopwatchInstance;
8 changes: 8 additions & 0 deletions common-theme/assets/styles/04-components/block.scss
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,12 @@
transform: translate(0%, 100%);
position: absolute;
}

&__time {
cursor: pointer;
color: var(--theme-color--ink);
@include on-event(false) {
color: var(--theme-color--pop);
}
}
}
24 changes: 21 additions & 3 deletions common-theme/layouts/partials/time.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,26 @@

The point of setting time is to help trainees and volunteers understand
how long to spend on activities.

At the moment this is always a countdown timer web component.
You can double click to set it going.
TODO: make this opt in for a simple time block if that comes up.
*/}}
{{ $blockData := .Page.Scratch.Get "blockData" }}
{{ $block := .Page.Site.GetPage $blockData.api }}
{{ $time := $blockData.time | default $block.Params.time | default 60 }}
<time class="c-block__time" datetime="P{{ $time }}M">{{ $time }} minutes</time>
{{ $localBlock := .Page.Site.GetPage $blockData.api }}
{{ if $localBlock }}
{{ $localBlock = $localBlock.Params.time }}
{{ end }}
{{ $time := $blockData.time | default $localBlock | default 60 }}
<time
class="c-block__time"
tabindex="0"
role="timer"
aria-label="{{ $time }} minutes countdown"
datetime="P{{ $time }}M"
>{{ $time }} minutes ⏱</time
>
{{ $stopwatch := resources.GetMatch "/scripts/stop-watch.js" | resources.Minify }}
<script type="module" defer>
import StopWatch from "{{ $stopwatch.RelPermalink }}";
</script>
13 changes: 13 additions & 0 deletions common-theme/layouts/shortcodes/timer.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{{/* this is similar to the youtube shortcode in that we are just pas
the inner content to the existing partial
in this case instead of passing through blockData
we make a mini scratch block and then just send to the existing
because we're not creating a block, just a time element to go on a block
*/}}

{{ .Scratch.Set "blockData" (dict) }}
{{ .Page.Scratch.SetInMap "blockData" "type" "time" }}
{{ .Page.Scratch.SetInMap "blockData" "api" "" }}
{{ .Page.Scratch.SetInMap "blockData" "time" .Inner }}
{{ .Page.Scratch.SetInMap "blockData" "isShortcode" true }}
{{ partial "time.html" . | safeHTML }}
Loading