diff --git a/app/assets/javascripts/lib/utils/recurrence.js b/app/assets/javascripts/lib/utils/recurrence.js
new file mode 100644
index 0000000000000000000000000000000000000000..b9afb939090b06961a74e3f1aeae79380cf21b02
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/recurrence.js
@@ -0,0 +1,154 @@
+import { uuids } from '../../diffs/utils/uuids';
+
+/**
+ * @module recurrence
+ */
+
+const instances = {};
+
+/**
+ * Create a new unique {@link module:recurrence~RecurInstance|RecurInstance}
+ * @returns {module:recurrence.RecurInstance} The newly created {@link module:recurrence~RecurInstance|RecurInstance}
+ */
+export function create() {
+ const id = uuids()[0];
+ let handlers = {};
+ let count = 0;
+
+ /**
+ * @namespace RecurInstance
+ * @description A RecurInstance tracks the count of any occurrence as registered by calls to occur.
+ *
+ * It maintains an internal counter and a registry of handlers that can be arbitrarily assigned by a user.
+ *
+ * While a RecurInstance isn't specific to any particular use-case, it may be useful for:
+ *
+ *
onCount number of times.
+ * @param {Number} onCount - The number of times the occurrence has been seen to respond to
+ * @param {Function} behavior - A callback function to run when the occurrence has been seen onCount times
+ * @memberof module:recurrence~RecurInstance
+ * @inner
+ */
+ handle(onCount, behavior) {
+ if (onCount && behavior) {
+ handlers[onCount] = behavior;
+ }
+ },
+ /**
+ * @description Remove the behavior callback handler that would be run when the occurrence is seen onCount times
+ * @param {Number} onCount - The count identifier for which to eject the callback handler
+ * @memberof module:recurrence~RecurInstance
+ * @inner
+ */
+ eject(onCount) {
+ if (onCount) {
+ delete handlers[onCount];
+ }
+ },
+ /**
+ * @description Register that this occurrence has been seen and trigger any appropriate handlers
+ * @memberof module:recurrence~RecurInstance
+ * @inner
+ */
+ occur() {
+ count += 1;
+
+ if (typeof handlers[count] === 'function') {
+ handlers[count](count);
+ }
+ },
+ /**
+ * @description Reset this recurrence instance without destroying it entirely
+ * @param {Object} [options]
+ * @param {Boolean} [options.currentCount = true] - Whether to reset the count
+ * @param {Boolean} [options.handlersList = false] - Whether to reset the list of attached handlers back to an empty state
+ * @memberof module:recurrence~RecurInstance
+ * @inner
+ */
+ reset({ currentCount = true, handlersList = false } = {}) {
+ if (currentCount) {
+ count = 0;
+ }
+
+ if (handlersList) {
+ handlers = {};
+ }
+ },
+ };
+
+ instances[id] = instance;
+
+ return instance;
+}
+
+/**
+ * Retrieve a stored {@link module:recurrence~RecurInstance|RecurInstance} by {@link module:uuids~UUIDv4|UUID}
+ * @param {module:uuids~UUIDv4} id - The {@link module:uuids~UUIDv4|UUID} of a previously created {@link module:recurrence~RecurInstance|RecurInstance}
+ * @returns {(module:recurrence~RecurInstance|undefined)} The {@link module:recurrence~RecurInstance|RecurInstance}, or undefined if the UUID doesn't refer to a known Instance
+ */
+export function recall(id) {
+ return instances[id];
+}
+
+/**
+ * Release the memory space for a given {@link module:recurrence~RecurInstance|RecurInstance} by {@link module:uuids~UUIDv4|UUID}
+ * @param {module:uuids~UUIDv4} id - The {@link module:uuids~UUIDv4|UUID} of a previously created {@link module:recurrence~RecurInstance|RecurInstance}
+ * @returns {Boolean} Whether the reference to the stored {@link module:recurrence~RecurInstance|RecurInstance} was released
+ */
+export function free(id) {
+ return recall(id)?.free() || false;
+}
diff --git a/spec/frontend/lib/utils/recurrence_spec.js b/spec/frontend/lib/utils/recurrence_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..fc22529dffc9778ab4ff99c5e8bd1a0fb5959278
--- /dev/null
+++ b/spec/frontend/lib/utils/recurrence_spec.js
@@ -0,0 +1,333 @@
+import { create, free, recall } from '~/lib/utils/recurrence';
+
+const HEX = /[a-f0-9]/i;
+const HEX_RE = HEX.source;
+const UUIDV4 = new RegExp(
+ `${HEX_RE}{8}-${HEX_RE}{4}-4${HEX_RE}{3}-[89ab]${HEX_RE}{3}-${HEX_RE}{12}`,
+ 'i',
+);
+
+describe('recurrence', () => {
+ let recurInstance;
+ let id;
+
+ beforeEach(() => {
+ recurInstance = create();
+ id = recurInstance.id;
+ });
+
+ afterEach(() => {
+ id = null;
+ recurInstance.free();
+ });
+
+ describe('create', () => {
+ it('returns an object with the correct external api', () => {
+ expect(recurInstance).toMatchObject(
+ expect.objectContaining({
+ id: expect.stringMatching(UUIDV4),
+ count: 0,
+ handlers: {},
+ free: expect.any(Function),
+ handle: expect.any(Function),
+ eject: expect.any(Function),
+ occur: expect.any(Function),
+ reset: expect.any(Function),
+ }),
+ );
+ });
+ });
+
+ describe('recall', () => {
+ it('returns a previously created RecurInstance', () => {
+ expect(recall(id).id).toBe(id);
+ });
+
+ it("returns undefined if the provided UUID doesn't refer to a stored RecurInstance", () => {
+ expect(recall('1234')).toBeUndefined();
+ });
+ });
+
+ describe('free', () => {
+ it('returns true when the RecurInstance exists', () => {
+ expect(free(id)).toBe(true);
+ });
+
+ it("returns false when the ID doesn't refer to a known RecurInstance", () => {
+ expect(free('1234')).toBe(false);
+ });
+
+ it('removes the correct RecurInstance from the list of references', () => {
+ const anotherInstance = create();
+
+ expect(recall(id)).toEqual(recurInstance);
+ expect(recall(anotherInstance.id)).toEqual(anotherInstance);
+
+ free(id);
+
+ expect(recall(id)).toBeUndefined();
+ expect(recall(anotherInstance.id)).toEqual(anotherInstance);
+
+ anotherInstance.free();
+ });
+ });
+
+ describe('RecurInstance (`create()` return value)', () => {
+ it.each`
+ property | value | alias
+ ${'id'} | ${expect.stringMatching(UUIDV4)} | ${'[a string matching the UUIDv4 specification]'}
+ ${'count'} | ${0} | ${0}
+ ${'handlers'} | ${{}} | ${{}}
+ `(
+ 'has the correct primitive value $alias for the member `$property` to start',
+ ({ property, value }) => {
+ expect(recurInstance[property]).toEqual(value);
+ },
+ );
+
+ describe('id', () => {
+ it('cannot be changed manually', () => {
+ expect(() => {
+ recurInstance.id = 'new-id';
+ }).toThrow(TypeError);
+
+ expect(recurInstance.id).toBe(id);
+ });
+
+ it.each`
+ method
+ ${'free'}
+ ${'handle'}
+ ${'eject'}
+ ${'occur'}
+ ${'reset'}
+ `('does not change across any method call - like after `$method`', ({ method }) => {
+ recurInstance[method]();
+
+ expect(recurInstance.id).toBe(id);
+ });
+ });
+
+ describe('count', () => {
+ it('cannot be changed manually', () => {
+ expect(() => {
+ recurInstance.count = 9999;
+ }).toThrow(TypeError);
+
+ expect(recurInstance.count).toBe(0);
+ });
+
+ it.each`
+ method
+ ${'free'}
+ ${'handle'}
+ ${'eject'}
+ ${'reset'}
+ `("doesn't change in unexpected scenarios - like after a call to `$method`", ({ method }) => {
+ recurInstance[method]();
+
+ expect(recurInstance.count).toBe(0);
+ });
+
+ it('increments by one each time `.occur()` is called', () => {
+ expect(recurInstance.count).toBe(0);
+ recurInstance.occur();
+ expect(recurInstance.count).toBe(1);
+ recurInstance.occur();
+ expect(recurInstance.count).toBe(2);
+ });
+ });
+
+ describe('handlers', () => {
+ it('cannot be changed manually', () => {
+ const fn = jest.fn();
+
+ recurInstance.handle(1, fn);
+ expect(() => {
+ recurInstance.handlers = {};
+ }).toThrow(TypeError);
+
+ expect(recurInstance.handlers).toStrictEqual({
+ 1: fn,
+ });
+ });
+
+ it.each`
+ method
+ ${'free'}
+ ${'occur'}
+ ${'eject'}
+ ${'reset'}
+ `("doesn't change in unexpected scenarios - like after a call to `$method`", ({ method }) => {
+ recurInstance[method]();
+
+ expect(recurInstance.handlers).toEqual({});
+ });
+
+ it('adds handlers to the correct slots', () => {
+ const fn1 = jest.fn();
+ const fn2 = jest.fn();
+
+ recurInstance.handle(100, fn1);
+ recurInstance.handle(1000, fn2);
+
+ expect(recurInstance.handlers).toMatchObject({
+ 100: fn1,
+ 1000: fn2,
+ });
+ });
+ });
+
+ describe('free', () => {
+ it('removes itself from recallable memory', () => {
+ expect(recall(id)).toEqual(recurInstance);
+
+ recurInstance.free();
+
+ expect(recall(id)).toBeUndefined();
+ });
+ });
+
+ describe('handle', () => {
+ it('adds a handler for the provided count', () => {
+ const fn = jest.fn();
+
+ recurInstance.handle(5, fn);
+
+ expect(recurInstance.handlers[5]).toEqual(fn);
+ });
+
+ it("doesn't add any handlers if either the count or behavior aren't provided", () => {
+ const fn = jest.fn();
+
+ recurInstance.handle(null, fn);
+ // Note that it's not possible to react to something not happening (without timers)
+ recurInstance.handle(0, fn);
+ recurInstance.handle(5, null);
+
+ expect(recurInstance.handlers).toEqual({});
+ });
+ });
+
+ describe('eject', () => {
+ it('removes the handler assigned to the particular count slot', () => {
+ recurInstance.handle(1, jest.fn());
+
+ expect(recurInstance.handlers[1]).toBeTruthy();
+
+ recurInstance.eject(1);
+
+ expect(recurInstance.handlers).toEqual({});
+ });
+
+ it("succeeds (or fails gracefully) when the count provided doesn't have a handler assigned", () => {
+ recurInstance.eject('abc');
+ recurInstance.eject(1);
+
+ expect(recurInstance.handlers).toEqual({});
+ });
+
+ it('makes no changes if no count is provided', () => {
+ const fn = jest.fn();
+
+ recurInstance.handle(1, fn);
+
+ recurInstance.eject();
+
+ expect(recurInstance.handlers[1]).toStrictEqual(fn);
+ });
+ });
+
+ describe('occur', () => {
+ it('increments the .count property by 1', () => {
+ expect(recurInstance.count).toBe(0);
+
+ recurInstance.occur();
+
+ expect(recurInstance.count).toBe(1);
+ });
+
+ it('calls the appropriate handlers', () => {
+ const fn1 = jest.fn();
+ const fn5 = jest.fn();
+ const fn10 = jest.fn();
+
+ recurInstance.handle(1, fn1);
+ recurInstance.handle(5, fn5);
+ recurInstance.handle(10, fn10);
+
+ expect(fn1).not.toHaveBeenCalled();
+ expect(fn5).not.toHaveBeenCalled();
+ expect(fn10).not.toHaveBeenCalled();
+
+ recurInstance.occur();
+
+ expect(fn1).toHaveBeenCalledTimes(1);
+ expect(fn5).not.toHaveBeenCalled();
+ expect(fn10).not.toHaveBeenCalled();
+
+ recurInstance.occur();
+ recurInstance.occur();
+ recurInstance.occur();
+ recurInstance.occur();
+
+ expect(fn1).toHaveBeenCalledTimes(1);
+ expect(fn5).toHaveBeenCalledTimes(1);
+ expect(fn10).not.toHaveBeenCalled();
+
+ recurInstance.occur();
+ recurInstance.occur();
+ recurInstance.occur();
+ recurInstance.occur();
+ recurInstance.occur();
+
+ expect(fn1).toHaveBeenCalledTimes(1);
+ expect(fn5).toHaveBeenCalledTimes(1);
+ expect(fn10).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('reset', () => {
+ it('resets the count only, by default', () => {
+ const fn = jest.fn();
+
+ recurInstance.handle(3, fn);
+ recurInstance.occur();
+ recurInstance.occur();
+
+ expect(recurInstance.count).toBe(2);
+
+ recurInstance.reset();
+
+ expect(recurInstance.count).toBe(0);
+ expect(recurInstance.handlers).toEqual({ 3: fn });
+ });
+
+ it('also resets the handlers, by specific request', () => {
+ const fn = jest.fn();
+
+ recurInstance.handle(3, fn);
+ recurInstance.occur();
+ recurInstance.occur();
+
+ expect(recurInstance.count).toBe(2);
+
+ recurInstance.reset({ handlersList: true });
+
+ expect(recurInstance.count).toBe(0);
+ expect(recurInstance.handlers).toEqual({});
+ });
+
+ it('leaves the count in place, by request', () => {
+ recurInstance.occur();
+ recurInstance.occur();
+
+ expect(recurInstance.count).toBe(2);
+
+ recurInstance.reset({ currentCount: false });
+
+ expect(recurInstance.count).toBe(2);
+ });
+ });
+ });
+});