const authenticatedUser = { id: 7, email: 'user@example.com', } const bibleLayout = { set: { id: 41, name: 'Bible' }, levels: [ { id: 1, kind: 'book', depth: 0, elementCount: 2 }, { id: 2, kind: 'portion', depth: 1, elementCount: 2 }, { id: 3, kind: 'Chapter_sections-v2', depth: 2, elementCount: 2 }, ], elements: [ { id: 1, name: 'Genesis', kind: 'book', levelId: 1, children: [ { id: 3, name: 'Creation', kind: 'portion', levelId: 2, children: [ { id: 4, name: 'Chapter 1', kind: 'Chapter_sections-v2', levelId: 3, children: [], }, ], }, ], }, { id: 2, name: 'Exodus', kind: 'book', levelId: 1, children: [ { id: 5, name: 'Shemot', kind: 'portion', levelId: 2, children: [ { id: 6, name: 'Chapter 1', kind: 'Chapter_sections-v2', levelId: 3, children: [], }, ], }, ], }, ], } const overloadedBibleLayout = { ...bibleLayout, levels: bibleLayout.levels.map((level) => level.id === 3 ? { ...level, elementCount: 11 } : level, ), } const sparseBibleLayout = { ...bibleLayout, levels: bibleLayout.levels.map((level) => level.id === 3 ? { ...level, elementCount: 4 } : level, ), } const scheduleDetail = { schedule: { id: 73, set: { name: 'Bible' }, elementKind: 'Chapter_sections-v2', startDate: '2026-08-10', targetDate: '2026-08-12', assignmentCount: 2, days: [ { date: '2026-08-10', assignments: [ { id: 1, completedAt: null, element: { name: 'Chapter 1', kind: 'Chapter_sections-v2', path: ['Genesis', 'Creation', 'Chapter 1'], }, }, ], }, { date: '2026-08-11', assignments: [] }, { date: '2026-08-12', assignments: [ { id: 2, completedAt: null, element: { name: 'Chapter 1', kind: 'Chapter_sections-v2', path: ['Exodus', 'Shemot', 'Chapter 1'], }, }, ], }, ], }, } const completedScheduleDetail = { schedule: { ...scheduleDetail.schedule, days: [ scheduleDetail.schedule.days[0], scheduleDetail.schedule.days[1], { ...scheduleDetail.schedule.days[2], assignments: [ { ...scheduleDetail.schedule.days[2]?.assignments[0], completedAt: '2026-08-15T09:30:00+00:00', }, ], }, ], }, } const splitScheduleDetail = { schedule: { ...scheduleDetail.schedule, targetDate: '2026-08-13', assignmentCount: 4, days: [ scheduleDetail.schedule.days[0], scheduleDetail.schedule.days[1], { date: '2026-08-12', assignments: [ { id: 2, completedAt: '2026-08-11T08:30:00+00:00', element: { name: 'Chapter 1', kind: 'Chapter_sections-v2', path: ['Exodus', 'Shemot', 'Chapter 1'], }, }, { id: 3, completedAt: null, element: { name: 'Chapter 1', kind: 'Chapter_sections-v2', path: ['Leviticus', 'Vayikra', 'Chapter 1'], }, }, ], }, { date: '2026-08-13', assignments: [ { id: 4, completedAt: '2026-08-11T09:45:00+00:00', element: { name: 'Chapter 1', kind: 'Chapter_sections-v2', path: ['Numbers', 'Bamidbar', 'Chapter 1'], }, }, ], }, ], }, } function interceptAuthenticatedUser(): void { cy.intercept('GET', '**/api/me', { statusCode: 200, body: { user: authenticatedUser }, }).as('me') } describe('set scheduling', () => { beforeEach(() => { interceptAuthenticatedUser() }) it('creates a schedule from a set and shows every day', () => { cy.intercept('GET', '**/api/sets/41', { statusCode: 200, body: bibleLayout, }).as('layout') cy.intercept('POST', '**/api/schedules', (request) => { expect(request.headers.accept).to.equal('application/json') expect(request.body).to.deep.equal({ setId: 41, levelId: 3, startDate: '2026-08-10', targetDate: '2026-08-12', workloadPlacement: 'spread', }) request.reply({ statusCode: 201, body: scheduleDetail }) }).as('createSchedule') cy.visit('/sets/41') cy.wait('@me') cy.wait('@layout') cy.contains('a', 'Schedule this set') .should('have.attr', 'href', '/sets/41/schedules/new') .click() cy.location('pathname').should('equal', '/sets/41/schedules/new') cy.get('h1').should('have.text', 'Schedule Bible') cy.get('#schedule-level option').then(($options) => { expect([...$options].map((option) => option.textContent?.trim())).to.deep.equal([ 'Choose a level', 'book (2)', 'portion (2)', 'Chapter_sections-v2 (2)', ]) }) cy.get('#schedule-level').select('3') cy.get('#schedule-start-date').type('2026-08-10') cy.get('#schedule-target-date').type('2026-08-12') cy.get('form').submit() cy.wait('@createSchedule') cy.location('pathname').should('equal', '/schedules/73') cy.get('h1').should('have.text', 'Bible schedule') cy.get('[data-schedule-day]').should('have.length', 3) cy.get('[data-schedule-date="2026-08-10"]') .should('contain.text', 'Genesis / Creation / Chapter 1') .find('.assignment-kind') .should('have.text', 'Chapter_sections-v2') .and('have.css', 'text-transform', 'none') cy.get('[data-schedule-date="2026-08-11"]') .should('contain.text', 'Rest day') .and('not.contain.text', 'Chapter 1') cy.get('[data-schedule-date="2026-08-12"]').should( 'contain.text', 'Exodus / Shemot / Chapter 1', ) cy.get('[data-assignment-section="completed"]').should('not.have.attr', 'open') cy.get('[data-assignment-section="completed"] summary').click() cy.get('[data-assignment-section="completed"]').should( 'contain.text', 'No assignments completed yet.', ) }) it('spreads heavier days where the user chooses', () => { cy.intercept('GET', '**/api/sets/41', { statusCode: 200, body: overloadedBibleLayout, }).as('layout') cy.intercept('POST', '**/api/schedules', (request) => { expect(request.body).to.deep.equal({ setId: 41, levelId: 3, startDate: '2026-08-10', targetDate: '2026-08-14', workloadPlacement: 'spread', }) request.reply({ statusCode: 201, body: scheduleDetail }) }).as('createSchedule') cy.visit('/sets/41/schedules/new') cy.wait('@me') cy.wait('@layout') cy.get('[data-workload-placement]').should('not.exist') cy.get('#schedule-level').select('3') cy.get('#schedule-start-date').type('2026-08-10') cy.get('#schedule-target-date').type('2026-08-14') cy.get('[data-workload-placement]').should('be.visible').within(() => { cy.contains('legend', 'Heavier days').should('be.visible') cy.contains('Spread evenly').should('be.visible') cy.get('input[value="middle"]').should('be.checked') cy.get('input[value="spread"]').check() }) cy.get('form').submit() cy.wait('@createSchedule') }) it('packs sparse assignment days where the user chooses', () => { cy.intercept('GET', '**/api/sets/41', { statusCode: 200, body: sparseBibleLayout, }).as('layout') cy.intercept('POST', '**/api/schedules', (request) => { expect(request.body).to.deep.equal({ setId: 41, levelId: 3, startDate: '2026-08-10', targetDate: '2026-08-15', workloadPlacement: 'end', }) request.reply({ statusCode: 201, body: scheduleDetail }) }).as('createSchedule') cy.visit('/sets/41/schedules/new') cy.wait('@me') cy.wait('@layout') cy.get('#schedule-level').select('3') cy.get('#schedule-start-date').type('2026-08-10') cy.get('#schedule-target-date').type('2026-08-15') cy.get('[data-workload-placement]').should('be.visible').within(() => { cy.contains('legend', 'Assignment days').should('be.visible') cy.contains('Spread evenly').should('be.visible') cy.contains('At the start').should('be.visible') cy.contains('In the middle').should('be.visible') cy.contains('At the end').should('be.visible') cy.get('input[value="spread"]').should('be.checked') cy.get('input[value="end"]').check() }) cy.get('form').submit() cy.wait('@createSchedule') }) it('validates the schedule form before submitting', () => { cy.intercept('GET', '**/api/sets/41', { statusCode: 200, body: bibleLayout, }).as('layout') cy.intercept('POST', '**/api/schedules').as('createSchedule') cy.visit('/sets/41/schedules/new') cy.wait('@me') cy.wait('@layout') cy.get('form').submit() cy.get('#schedule-level-error') .should('have.text', 'Choose a level to schedule.') .and('be.visible') cy.get('#schedule-start-date-error') .should('have.text', 'Choose a start date.') .and('be.visible') cy.get('#schedule-target-date-error') .should('have.text', 'Choose a target date.') .and('be.visible') cy.get('@createSchedule.all').should('have.length', 0) cy.get('#schedule-level').select('3') cy.get('#schedule-start-date').type('2026-08-12') cy.get('#schedule-target-date').type('2026-08-10') cy.get('form').submit() cy.get('#schedule-target-date-error').should( 'have.text', 'Target date cannot be before the start date.', ) cy.get('@createSchedule.all').should('have.length', 0) }) it('loads a persisted schedule directly and handles missing schedules', () => { cy.intercept('GET', '**/api/schedules/73', { statusCode: 200, body: scheduleDetail, }).as('schedule') cy.visit('/schedules/73') cy.wait('@me') cy.wait('@schedule') cy.get('h1').should('have.text', 'Bible schedule') cy.get('.eyebrow') .should(($eyebrow) => { expect($eyebrow.text().trim()).to.equal('Chapter_sections-v2 plan') }) .find('.schedule-kind') .should('have.text', 'Chapter_sections-v2') .should('have.css', 'text-transform', 'none') cy.contains('2 Chapter_sections-v2 assignments across 3 days').should('be.visible') cy.intercept('GET', '**/api/schedules/74', { statusCode: 404, body: { error: 'schedule not found' }, }).as('missingSchedule') cy.visit('/schedules/74') cy.wait('@missingSchedule') cy.get('h1').should('have.text', 'Schedule not found') }) it('splits assignments by progress while preserving the schedule timeline', () => { cy.clock(new Date(2026, 7, 11, 12).getTime(), ['Date']) cy.intercept('GET', '**/api/schedules/73', { statusCode: 200, body: splitScheduleDetail, }).as('schedule') cy.visit('/schedules/73') cy.wait('@me') cy.wait('@schedule') cy.get('[data-assignment-section="remaining"]') .should('have.attr', 'data-assignment-count', '2') .within(() => { cy.get('h2').should('have.text', 'Remaining') cy.get('[data-schedule-day]').then(($days) => { expect([...$days].map((day) => day.getAttribute('data-schedule-date'))).to.deep.equal([ '2026-08-10', '2026-08-11', '2026-08-12', ]) }) cy.get('[data-schedule-date="2026-08-10"]') .should('contain.text', 'Overdue') .and('contain.text', 'Genesis / Creation / Chapter 1') cy.get('[data-schedule-date="2026-08-11"]') .should('contain.text', 'Rest day') .and('not.contain.text', 'Overdue') cy.get('[data-schedule-date="2026-08-12"]') .should('contain.text', 'Leviticus / Vayikra / Chapter 1') .and('not.contain.text', 'Exodus / Shemot / Chapter 1') cy.get('[data-schedule-date="2026-08-13"]').should('not.exist') }) cy.get('[data-assignment-section="completed"]').should( 'have.attr', 'data-assignment-count', '2', ) cy.get('[data-assignment-section="completed"]').should('not.have.attr', 'open') cy.get('[data-assignment-section="completed"]').within(() => { cy.get('summary').should('contain.text', 'Completed').click() cy.get('[data-schedule-day]').then(($days) => { expect([...$days].map((day) => day.getAttribute('data-schedule-date'))).to.deep.equal([ '2026-08-12', '2026-08-13', ]) }) cy.get('[data-schedule-date="2026-08-12"]') .should('contain.text', 'Exodus / Shemot / Chapter 1') .and('not.contain.text', 'Leviticus / Vayikra / Chapter 1') cy.get('[data-schedule-date="2026-08-13"]').should( 'contain.text', 'Numbers / Bamidbar / Chapter 1', ) cy.contains('Rest day').should('not.exist') }) cy.viewport(375, 667) cy.document().then((document) => { expect(document.documentElement.scrollWidth).to.be.at.most( document.documentElement.clientWidth, ) }) }) it('completes and reopens assignments from the schedule', () => { const completedAt = '2026-08-15T12:30:00+00:00' cy.intercept('GET', '**/api/schedules/73', { statusCode: 200, body: completedScheduleDetail, }).as('schedule') cy.intercept('PATCH', '**/api/assignments/1', (request) => { expect(request.body).to.deep.equal({ completed: true }) request.reply({ delay: 400, statusCode: 200, body: { assignment: { id: 1, completedAt }, }, }) }).as('completeAssignment') cy.intercept('PATCH', '**/api/assignments/2', (request) => { expect(request.body).to.deep.equal({ completed: false }) request.reply({ statusCode: 200, body: { assignment: { id: 2, completedAt: null }, }, }) }).as('reopenAssignment') cy.visit('/schedules/73') cy.wait('@me') cy.wait('@schedule') cy.get('[data-assignment-section="completed"] summary').click() cy.get('[data-assignment-section="completed"] [data-schedule-assignment="2"]').within(() => { cy.contains('Completed').should('be.visible') cy.get('time') .should('have.attr', 'datetime', '2026-08-15T09:30:00+00:00') .and('have.text', formatCompletionTime('2026-08-15T09:30:00+00:00')) }) cy.get('[data-assignment-section="remaining"] [data-schedule-assignment="1"]').within(() => { cy.contains('button', 'Mark complete').click() cy.contains('button', 'Saving...').should('be.disabled') }) cy.wait('@completeAssignment') cy.get('[data-assignment-section="remaining"]') .should('have.attr', 'data-assignment-count', '0') .and('contain.text', 'All assignments are complete.') .and('contain.text', 'Rest day') cy.get('[data-assignment-section="completed"]') .should('have.attr', 'data-assignment-count', '2') .within(() => { cy.get('[data-schedule-assignment="1"]').within(() => { cy.contains('Completed').should('be.visible') cy.get('time') .should('have.attr', 'datetime', completedAt) .and('have.text', formatCompletionTime(completedAt)) cy.contains('button', 'Reopen').should('be.enabled') }) }) cy.get('[data-completion-announcement]').should('have.text', 'Assignment moved to Completed.') cy.get('[data-assignment-section="completed"] [data-schedule-assignment="2"]').within(() => { cy.contains('button', 'Reopen').click() }) cy.wait('@reopenAssignment') cy.get('[data-assignment-section="remaining"]') .should('have.attr', 'data-assignment-count', '1') .find('[data-schedule-assignment="2"]') .should('contain.text', 'Not completed') .and('not.contain.text', 'Completed') .within(() => { cy.contains('button', 'Mark complete').should('be.enabled') }) cy.get('[data-assignment-section="completed"]') .should('have.attr', 'data-assignment-count', '1') .find('[data-schedule-assignment="2"]') .should('not.exist') cy.get('[data-completion-announcement]').should('have.text', 'Assignment moved to Remaining.') }) it('keeps an assignment in place when a completion request fails', () => { cy.intercept('GET', '**/api/schedules/73', { statusCode: 200, body: scheduleDetail, }).as('schedule') cy.intercept('PATCH', '**/api/assignments/1', { statusCode: 500, }).as('completeAssignment') cy.visit('/schedules/73') cy.wait('@me') cy.wait('@schedule') cy.get('[data-assignment-section="remaining"] [data-schedule-assignment="1"]') .contains('button', 'Mark complete') .click() cy.wait('@completeAssignment') cy.get('[data-assignment-section="remaining"] [data-schedule-assignment="1"]') .should('be.visible') .and('contain.text', "We couldn't update this assignment.") cy.get('[data-assignment-section="completed"]') .find('[data-schedule-assignment="1"]') .should('not.exist') }) it('lists the users schedules on the dashboard', () => { cy.intercept('GET', '**/api/sets', { statusCode: 200, body: { sets: [] }, }).as('sets') cy.intercept('GET', '**/api/schedules', (request) => { expect(request.headers.accept).to.equal('application/json') request.reply({ statusCode: 200, body: { schedules: [ { id: 73, set: { name: 'Bible' }, elementKind: 'Chapter_sections-v2', startDate: '2026-08-10', targetDate: '2026-08-12', assignmentCount: 2, }, ], }, }) }).as('schedules') cy.intercept('GET', '**/api/assignments?date=*', (request) => { request.reply({ statusCode: 200, body: { date: request.query.date, assignments: [] }, }) }) cy.visit('/dashboard') cy.wait('@me') cy.wait('@sets') cy.wait('@schedules') cy.get('#schedules-heading').should('have.text', 'Your schedules') cy.get('ul[aria-label="Your schedules"]') .should('contain.text', 'Bible') .and('contain.text', '2 assignments') cy.get('.schedule-card__heading p') .should('have.text', 'Chapter_sections-v2') .and('have.css', 'text-transform', 'none') cy.contains('a', 'Bible').should('have.attr', 'href', '/schedules/73') }) }) function formatCompletionTime(value: string): string { return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short', }).format(new Date(value)) }