mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-11-03 03:34:26 -05:00
Add job celebration features with animations and notifications for completed tasks
This commit is contained in:
1 parent
871c7ff9ce
commit
51923aadd8
16 files changed
+576
-7
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user