Add job celebration features with animations and notifications for completed tasks

This commit is contained in:
Brenda Wallace committed 2026-10-22 18:11:35 +13:00
1 parent 871c7ff9ce
commit 51923aadd8
16 files changed
+576 -7

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
// The fuss made when a job is crossed off: flowers blooming out of the tick box,
// and the sticky note and Undo toast. A streak of jobs gets more flowers, a
// bigger message, and from three in a row a row of flowers growing along the
// bottom of the page. See job_streak.js.
import {flowerCount, levelFor, sproutCount} from './job_streak';
const PETALS = ['#ffa500', '#e4a0a0', '#fff3a8', '#c9a0dc', '#ff8a65', '#ffffff'];
const SHOW_FOR_MS = 7000;
let timer = null;
const reducedMotion = function () {
return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
};
const fill = function (template, values) {
return template.replace(/%\{(\w+)\}/g, function (_, key) { return values[key]; });
};
// A flower: five petals round a centre. Only our own colours go in the markup.
const flower = function (petals, size) {
const el = document.createElement('span');
el.setAttribute('aria-hidden', 'true');
el.style.width = size + 'px';
el.style.height = size + 'px';
el.innerHTML = '<svg viewBox="-10 -10 20 20" width="100%" height="100%"><g fill="' + petals +
'" stroke="#413f3b" stroke-opacity=".25" stroke-width=".4">' +
[0, 72, 144, 216, 288].map(function (angle) {
return '<circle cx="0" cy="-5.2" r="3.9" transform="rotate(' + angle + ')"/>';
}).join('') +
'</g><circle r="2.7" fill="#c9a800"/></svg>';
return el;
};
const pick = function (list) {
return list[Math.floor(Math.random() * list.length)];
};
// Flowers blooming out of the tick box, drifting up and out as they fade.
const bloom = function (origin, streak) {
if (reducedMotion()) return;
const spread = 100 + Math.min(streak, 6) * 30;
for (let i = 0; i < flowerCount(streak); i++) {
const el = flower(pick(PETALS), 18 + Math.random() * 18);
el.className = 'notepad-flower';
el.style.left = origin.x + 'px';
el.style.top = origin.y + 'px';
el.style.setProperty('--dx', (Math.random() * 2 - 1) * spread + 'px');
el.style.setProperty('--dy', -(50 + Math.random() * spread) + 'px');
el.style.setProperty('--rot', (Math.random() * 2 - 1) * 200 + 'deg');
el.style.animationDelay = Math.random() * 0.25 + 's';
el.addEventListener('animationend', function () { el.remove(); });
document.body.appendChild(el);
}
};
// Flowers growing up from the bottom of the page, one more for each job in a
// row. They stay until the note goes.
const grow = function (streak) {
document.querySelectorAll('.notepad-sprout').forEach(function (el) { el.remove(); });
if (reducedMotion()) return;
for (let i = 0; i < sproutCount(streak); i++) {
const sprout = document.createElement('span');
sprout.className = 'notepad-sprout';
sprout.setAttribute('aria-hidden', 'true');
sprout.style.left = 4 + Math.random() * 92 + '%';
sprout.style.setProperty('--height', 50 + Math.random() * 90 + 'px');
sprout.style.animationDelay = i * 0.08 + 's';
sprout.appendChild(flower(pick(PETALS), 30));
document.body.appendChild(sprout);
}
};
// The note and toast, in a live region so they are read out as well as seen.
const panel = function () {
let el = document.getElementById('notepad-celebration');
if (el) return el;
el = document.createElement('div');
el.id = 'notepad-celebration';
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
document.body.appendChild(el);
return el;
};
const sticky = function (level, done) {
const note = document.createElement('div');
note.className = 'notepad-celebration-note';
const tape = document.createElement('span');
tape.className = 'tape';
note.appendChild(tape);
const lead = document.createElement('div');
lead.className = 'notepad-celebration-lead';
lead.textContent = level.lead + ' ' + done;
note.appendChild(lead);
const sub = document.createElement('div');
sub.className = 'notepad-celebration-sub';
sub.textContent = level.note;
note.appendChild(sub);
return note;
};
const toast = function (text, undoText, onUndo) {
const el = document.createElement('div');
el.className = 'notepad-toast';
el.appendChild(document.createTextNode(text));
const undo = document.createElement('button');
undo.type = 'button';
undo.textContent = undoText;
undo.addEventListener('click', function () {
hide();
onUndo();
});
el.appendChild(undo);
return el;
};
export function hide() {
clearTimeout(timer);
const el = document.getElementById('notepad-celebration');
if (el) el.innerHTML = '';
document.querySelectorAll('.notepad-sprout').forEach(function (sprout) { sprout.remove(); });
}
// words: the strings from the page. name: the job. done: how many are done this
// week. streak: how many in a row. origin: where the tick box is. onUndo: puts
// the job back.
export function celebrate({words, name, done, streak, origin, onUndo}) {
bloom(origin, streak);
grow(streak);
const el = panel();
el.innerHTML = '';
const count = fill(Number(done) === 1 ? words.done.one : words.done.other, {count: done});
el.appendChild(sticky(words.levels[levelFor(streak)], count));
el.appendChild(toast(fill(words.crossed_off, {name: name}), words.undo, onUndo));
clearTimeout(timer);
timer = setTimeout(hide, SHOW_FOR_MS);
}
+59 -5
View File
@@ -3,6 +3,9 @@
// the notepad's groups after each change. If anything goes wrong the page is
// loaded the ordinary way, so nothing is lost.
import {celebrate, hide} from './job_celebration';
import {nextStreak, undoStreak} from './job_streak';
document.addEventListener('DOMContentLoaded', function () {
const form = document.querySelector('.notepad-add');
const groups = document.getElementById('notepad-groups');
@@ -12,7 +15,8 @@ document.addEventListener('DOMContentLoaded', function () {
const token = document.querySelector('meta[name="csrf-token"]');
const kinds = JSON.parse(form.dataset.kinds);
// Sends a change to the server and shows the groups it sends back.
// Sends a change to the server and shows the groups it sends back. Resolves
// with how many jobs are done this week.
const send = function (url, body) {
return fetch(url, {
method: 'POST',
@@ -25,13 +29,63 @@ document.addEventListener('DOMContentLoaded', function () {
})
.then(function (response) {
if (!response.ok) throw new Error(response.status);
return response.text();
})
.then(function (html) {
groups.innerHTML = html;
const done = response.headers.get('X-Jobs-Done-This-Week');
return response.text().then(function (html) {
groups.innerHTML = html;
return Number(done);
});
});
};
// ---- Crossing a job off, or putting it back
const words = JSON.parse(groups.dataset.celebrations);
let streak = 0;
let lastAt = null;
const setFinished = function (url, finished) {
const body = new FormData();
body.append('_method', 'patch');
body.append('job[finished]', finished ? '1' : '0');
return send(url, body);
};
groups.addEventListener('submit', function (event) {
const tick = event.target.closest('.notepad-tick');
if (!tick) return;
event.preventDefault();
const finishing = tick.querySelector('input[name="job[finished]"]').value === '1';
const name = tick.closest('.notepad-job').querySelector('.notepad-name').textContent;
const box = tick.getBoundingClientRect();
const url = tick.action;
send(url, new FormData(tick))
.then(function (done) {
if (!finishing) {
streak = undoStreak(streak);
return hide();
}
streak = nextStreak(streak, lastAt, Date.now());
lastAt = Date.now();
celebrate({
words: words,
name: name,
done: done,
streak: streak,
origin: {x: box.left + box.width / 2, y: box.top + box.height / 2},
onUndo: function () {
streak = undoStreak(streak);
setFinished(url, false).catch(function () { window.location.reload(); });
},
});
})
.catch(function () {
tick.submit();
});
});
// ---- Adding a job
const link = document.getElementById('notepad-link');
+37
View File
@@ -0,0 +1,37 @@
// How jobs crossed off one after another are counted, and how much fuss each
// one gets. Kept apart from the page so it can be tested.
// Jobs crossed off within this long of the last one are in a row.
export const STREAK_WINDOW_MS = 90 * 1000;
// The streak after another job is crossed off: one more if it follows closely on
// the last, otherwise a new streak.
export function nextStreak(streak, lastAt, now) {
if (streak > 0 && lastAt !== null && now - lastAt <= STREAK_WINDOW_MS) return streak + 1;
return 1;
}
// The streak after a job is put back, which takes it off the count.
export function undoStreak(streak) {
return Math.max(0, streak - 1);
}
// Which message a streak gets: the plain one, then bigger ones.
export function levelFor(streak) {
if (streak >= 5) return 'many';
if (streak >= 3) return 'three';
if (streak === 2) return 'two';
return 'one';
}
// How many flowers to throw from the tick box: a few, then more with each job in
// a row.
export function flowerCount(streak) {
return Math.min(6 + (Math.max(streak, 1) - 1) * 5, 40);
}
// How many flowers to grow along the bottom of the page: none until three jobs
// in a row, then one more with each.
export function sproutCount(streak) {
return streak >= 3 ? Math.min(streak - 2, 12) : 0;
}
+63
View File
@@ -0,0 +1,63 @@
import {describe, expect, it} from 'vitest';
import {flowerCount, levelFor, nextStreak, sproutCount, STREAK_WINDOW_MS, undoStreak} from './job_streak';
describe('nextStreak', () => {
it('starts a streak with the first job', () => {
expect(nextStreak(0, null, 1000)).toBe(1);
});
it('adds a job that follows closely on the last', () => {
expect(nextStreak(2, 1000, 1000 + STREAK_WINDOW_MS)).toBe(3);
});
it('starts again after a gap', () => {
expect(nextStreak(4, 1000, 1001 + STREAK_WINDOW_MS)).toBe(1);
});
});
describe('undoStreak', () => {
it('takes one off', () => {
expect(undoStreak(3)).toBe(2);
});
it('stops at none', () => {
expect(undoStreak(0)).toBe(0);
});
});
describe('levelFor', () => {
it.each([[1, 'one'], [2, 'two'], [3, 'three'], [4, 'three'], [5, 'many'], [12, 'many']])(
'gives a streak of %i the %s message', (streak, level) => {
expect(levelFor(streak)).toBe(level);
},
);
});
describe('flowerCount', () => {
it('throws more flowers with each job in a row', () => {
expect(flowerCount(3)).toBeGreaterThan(flowerCount(2));
});
it('stops at a limit', () => {
expect(flowerCount(100)).toBe(40);
});
});
describe('sproutCount', () => {
it('grows nothing for the first two jobs', () => {
expect(sproutCount(2)).toBe(0);
});
it('grows a flower at three in a row', () => {
expect(sproutCount(3)).toBe(1);
});
it('grows one more with each job', () => {
expect(sproutCount(6)).toBe(4);
});
it('stops at a limit', () => {
expect(sproutCount(100)).toBe(12);
});
});