diff --git a/app/assets/stylesheets/_jobs.scss b/app/assets/stylesheets/_jobs.scss index 1e83a6bcc..de5d235e6 100644 --- a/app/assets/stylesheets/_jobs.scss +++ b/app/assets/stylesheets/_jobs.scss @@ -318,3 +318,139 @@ line-height: 1; } } + +// Crossing a job off: flowers blooming out of the tick box, a row of them +// growing along the bottom of the page for a streak, and a sticky note with an +// Undo toast in the corner. They sit outside the notepad, on the page. +.notepad-flower { + position: fixed; + z-index: 1080; + margin: -12px 0 0 -12px; + pointer-events: none; + animation: notepad-bloom 1.4s ease-out forwards; +} + +@keyframes notepad-bloom { + 0% { + opacity: 0; + transform: translate(0, 0) scale(0.2) rotate(0); + } + + 15% { + opacity: 1; + } + + 70% { + opacity: 1; + } + + 100% { + opacity: 0; + transform: translate(var(--dx), var(--dy)) scale(1) rotate(var(--rot)); + } +} + +// A stem with a flower on top, growing up from the bottom edge. +.notepad-sprout { + position: fixed; + z-index: 1079; + bottom: 0; + width: 3px; + height: var(--height); + background: #57803c; + border-radius: 2px 2px 0 0; + pointer-events: none; + transform-origin: bottom; + animation: notepad-sprout 0.9s ease-out backwards; + + > span { + position: absolute; + top: -15px; + left: -13.5px; + } +} + +@keyframes notepad-sprout { + from { + transform: scaleY(0); + } + + to { + transform: scaleY(1); + } +} + +#notepad-celebration { + position: fixed; + z-index: 1080; + left: 18px; + bottom: 18px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + max-width: calc(100vw - 36px); +} + +.notepad-celebration-note { + position: relative; + min-width: 300px; + padding: 14px 20px 12px; + background: #fff3a8; + box-shadow: 0 6px 10px rgba(0, 0, 0, 0.2); + color: #3b3a36; + transform: rotate(-2deg); + + .tape { + position: absolute; + top: -12px; + left: 50%; + width: 84px; + height: 26px; + margin-left: -42px; + background: rgba(255, 165, 0, 0.45); + transform: rotate(-3deg); + } +} + +.notepad-celebration-lead { + color: #3f5e2b; + font-size: 1.25rem; + font-weight: 600; + line-height: 1.2; +} + +.notepad-celebration-sub { + color: #6b5a00; + font-size: 0.9rem; + font-weight: 500; +} + +.notepad-toast { + display: flex; + align-items: center; + gap: 14px; + min-height: 48px; + padding: 6px 8px 6px 16px; + background: $brown; + border-radius: $card-radius; + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3); + color: $white; + + button { + min-width: 44px; + min-height: 44px; + padding: 0 10px; + border: 0; + background: transparent; + color: $orange; + font-weight: 700; + } +} + +@media (prefers-reduced-motion: reduce) { + .notepad-flower, + .notepad-sprout { + display: none; + } +} diff --git a/app/controllers/jobs_controller.rb b/app/controllers/jobs_controller.rb index a5b818359..98d190e06 100644 --- a/app/controllers/jobs_controller.rb +++ b/app/controllers/jobs_controller.rb @@ -77,6 +77,7 @@ class JobsController < DataController def render_notepad(status) @owner = current_member load_notepad + response.set_header('X-Jobs-Done-This-Week', current_member.jobs.finished_this_week.count.to_s) render partial: 'jobs/notepad_groups', locals: { groups: @job_groups }, status: end diff --git a/app/helpers/jobs_helper.rb b/app/helpers/jobs_helper.rb index f85a302b9..48cb7be17 100644 --- a/app/helpers/jobs_helper.rb +++ b/app/helpers/jobs_helper.rb @@ -17,6 +17,21 @@ module JobsHelper end end + # What the notepad's script says when a job is crossed off. The count is filled + # in by the script, which knows it only once the server has answered. + def job_celebrations + levels = %w(one two three many).index_with do |level| + { lead: t("jobs.celebrate.#{level}.lead"), note: t("jobs.celebrate.#{level}.note") } + end + + # The name and count are left as tokens for the script to fill in. + name = '%{name}' # rubocop:disable Style/FormatStringToken + count = '%{count}' # rubocop:disable Style/FormatStringToken + + { levels:, crossed_off: t('jobs.celebrate.crossed_off', name:), undo: t('jobs.celebrate.undo'), + done: { one: t('jobs.celebrate.done.one'), other: t('jobs.celebrate.done.other', count:) } } + end + def job_overdue?(job, today = Time.zone.today) job.due_date.present? && job.due_date < today && !job.finished end diff --git a/app/javascript/site/job_celebration.js b/app/javascript/site/job_celebration.js new file mode 100644 index 000000000..e531d98c9 --- /dev/null +++ b/app/javascript/site/job_celebration.js @@ -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 = '' + + [0, 72, 144, 216, 288].map(function (angle) { + return ''; + }).join('') + + ''; + 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); +} diff --git a/app/javascript/site/job_notepad.js b/app/javascript/site/job_notepad.js index c6899b9b0..de76fa728 100644 --- a/app/javascript/site/job_notepad.js +++ b/app/javascript/site/job_notepad.js @@ -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'); diff --git a/app/javascript/site/job_streak.js b/app/javascript/site/job_streak.js new file mode 100644 index 000000000..057353f40 --- /dev/null +++ b/app/javascript/site/job_streak.js @@ -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; +} diff --git a/app/javascript/site/job_streak.test.js b/app/javascript/site/job_streak.test.js new file mode 100644 index 000000000..99ed007be --- /dev/null +++ b/app/javascript/site/job_streak.test.js @@ -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); + }); +}); diff --git a/app/models/job.rb b/app/models/job.rb index ca05362e9..5e33c2379 100644 --- a/app/models/job.rb +++ b/app/models/job.rb @@ -16,9 +16,13 @@ class Job < ApplicationRecord validates :owner, presence: true validates :slug, uniqueness: true + # The add form sends these empty when the job is not linked to anything. + normalizes :jobbable_type, with: ->(type) { type.presence } validates :jobbable_type, inclusion: { in: JOBBABLE_TYPES }, allow_nil: true validate :jobbable_is_the_owners, if: -> { jobbable_id_changed? || jobbable_type_changed? } + before_save :stamp_finished_at, if: :finished_changed? + delegate :location, :latitude, :longitude, to: :owner delegate :login_name, :slug, :location, to: :owner, prefix: true delegate :name, to: :garden, prefix: true, allow_nil: true @@ -32,6 +36,7 @@ class Job < ApplicationRecord end scope :active, -> { where(finished: [false, nil]) } + scope :finished_this_week, -> { where(finished: true, finished_at: Time.zone.now.beginning_of_week..) } # The planting a job is about, if it is about one. The form, Copy and the API # speak of plantings; the link itself is the jobbable. @@ -58,6 +63,10 @@ class Job < ApplicationRecord private + def stamp_finished_at + self.finished_at = (Time.zone.now if finished) + end + # Plantings and seeds are a member's own. A job can't be about someone else's. def jobbable_is_the_owners return unless JOBBABLE_TYPES.include?(jobbable_type) && jobbable.respond_to?(:owner_id) diff --git a/app/views/jobs/_notepad.html.haml b/app/views/jobs/_notepad.html.haml index e3274bedb..959879bff 100644 --- a/app/views/jobs/_notepad.html.haml +++ b/app/views/jobs/_notepad.html.haml @@ -16,4 +16,4 @@ %button.notepad-link-remove{ type: 'button', 'aria-label': t('jobs.pad.remove_link') } × %p.notepad-note= t('jobs.pad.public_note') + ' ' + t('jobs.pad.link_hint') - #notepad-groups= render 'jobs/notepad_groups', groups: + #notepad-groups{ data: { celebrations: job_celebrations.to_json } }= render 'jobs/notepad_groups', groups: diff --git a/config/locales/en.yml b/config/locales/en.yml index 5a8e5361a..29ac8c36d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -646,6 +646,24 @@ en: unknown: Unknown jobs: plan_something: Plan something + celebrate: + crossed_off: Crossed off "%{name}" + undo: Undo + done: + one: 1 job done this week. + other: "%{count} jobs done this week." + one: + lead: Nice one! + note: The snail is impressed, and slightly jealous. + two: + lead: Two in a row! + note: The snail is picking up speed. + three: + lead: Hat-trick! + note: The snail has stopped to watch. + many: + lead: On a roll! + note: The snail has called the other snails. due: last: last %{day} suggestions: diff --git a/db/migrate/20261022040000_add_finished_at_to_jobs.rb b/db/migrate/20261022040000_add_finished_at_to_jobs.rb new file mode 100644 index 000000000..7ee350e3c --- /dev/null +++ b/db/migrate/20261022040000_add_finished_at_to_jobs.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# When a job was crossed off, so "jobs done this week" can be counted. Jobs +# already finished are taken to have been finished when they last changed. +class AddFinishedAtToJobs < ActiveRecord::Migration[8.1] + def up + add_column :jobs, :finished_at, :datetime + execute 'UPDATE jobs SET finished_at = updated_at WHERE finished = TRUE' + end + + def down + remove_column :jobs, :finished_at + end +end diff --git a/db/schema.rb b/db/schema.rb index 69ac22e38..c05cb32c8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_22_030000) do +ActiveRecord::Schema[8.1].define(version: 2026_10_22_040000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -682,6 +682,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_22_030000) do t.string "description" t.date "due_date" t.boolean "finished" + t.datetime "finished_at" t.bigint "garden_id" t.bigint "jobbable_id" t.string "jobbable_type" diff --git a/spec/helpers/jobs_helper_spec.rb b/spec/helpers/jobs_helper_spec.rb index 34e539f93..20663b11b 100644 --- a/spec/helpers/jobs_helper_spec.rb +++ b/spec/helpers/jobs_helper_spec.rb @@ -28,4 +28,20 @@ describe JobsHelper do it 'gives the date for a job further off' do expect(label(Date.new(2026, 11, 3))).to eq 'Tue 3 Nov' end + + describe '#job_celebrations' do + subject(:words) { helper.job_celebrations } + + it 'has a message for each size of streak' do + expect(words[:levels].keys).to eq %w(one two three many) + end + + it 'leaves the job\'s name for the script to fill in' do + expect(words[:crossed_off]).to include('{name}') + end + + it 'leaves the count for the script to fill in' do + expect(words[:done][:other]).to include('{count}') + end + end end diff --git a/spec/models/job_spec.rb b/spec/models/job_spec.rb index 64a908c1d..6ed946e87 100644 --- a/spec/models/job_spec.rb +++ b/spec/models/job_spec.rb @@ -77,4 +77,37 @@ RSpec.describe Job do expect(job.jobbable).to eq crop end end + + describe '#finished_at' do + let(:job) { create(:job, finished: false) } + + it 'is set when the job is crossed off' do + job.update!(finished: true) + expect(job.finished_at).to be_within(1.minute).of(Time.zone.now) + end + + it 'is cleared when the job is put back' do + job.update!(finished: true) + job.update!(finished: false) + expect(job.finished_at).to be_nil + end + + it 'is left alone when something else changes' do + job.update!(finished: true) + expect { job.update!(name: 'Another name') }.not_to(change { job.reload.finished_at }) + end + end + + describe '.finished_this_week' do + let!(:this_week) { create(:job, finished: true) } + + before do + create(:job, finished: false) + create(:job, finished: true).update_column(:finished_at, 2.weeks.ago) # rubocop:disable Rails/SkipsModelValidations + end + + it 'has the jobs crossed off since the week began' do + expect(described_class.finished_this_week).to eq [this_week] + end + end end diff --git a/spec/requests/jobs_controller_create_spec.rb b/spec/requests/jobs_controller_create_spec.rb index 867346c19..bcc969631 100644 --- a/spec/requests/jobs_controller_create_spec.rb +++ b/spec/requests/jobs_controller_create_spec.rb @@ -25,6 +25,18 @@ describe JobsController, '#create' do end end + context 'with the link left empty, as the notepad sends it' do + before { post jobs_path, params: { job: { name: 'Sow the peas', jobbable_type: '', jobbable_id: '' } }, headers: } + + it 'creates the job' do + expect(member.jobs.pluck(:name)).to eq ['Sow the peas'] + end + + it 'links it to nothing' do + expect(member.jobs.first.jobbable).to be_nil + end + end + context 'with a planting of the member\'s to link to' do let(:planting) { create(:planting, owner: member) } diff --git a/spec/requests/jobs_controller_update_spec.rb b/spec/requests/jobs_controller_update_spec.rb index 7ac895e32..aa023aa8e 100644 --- a/spec/requests/jobs_controller_update_spec.rb +++ b/spec/requests/jobs_controller_update_spec.rb @@ -30,4 +30,16 @@ describe JobsController, '#update' do expect(job.reload.due_date).to be_nil end end + + context 'when crossing a job off from the notepad' do + before { patch job_path(slug: job.slug), params: { job: { finished: '1' } }, headers: } + + it 'finishes the job' do + expect(job.reload).to be_finished + end + + it 'says how many jobs are done this week' do + expect(response.headers['X-Jobs-Done-This-Week']).to eq '1' + end + end end