Attainly/frontend/website/cypress/e2e/set-scheduling.cy.ts

846 lines
26 KiB
TypeScript

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'],
},
},
],
},
],
},
}
const rescheduleSourceDetail = {
schedule: {
...scheduleDetail.schedule,
startDate: '2026-08-10',
targetDate: '2026-08-20',
assignmentCount: 4,
days: [
{
date: '2026-08-10',
assignments: [
{
...scheduleDetail.schedule.days[0]?.assignments[0],
completedAt: '2026-08-11T08:30:00+00:00',
},
],
},
{
date: '2026-08-18',
assignments: [
{
id: 2,
completedAt: null,
element: {
name: 'Chapter 2',
kind: 'Chapter_sections-v2',
path: ['Genesis', 'Creation', 'Chapter 2'],
},
},
],
},
{
date: '2026-08-19',
assignments: [
{
id: 3,
completedAt: null,
element: {
name: 'Chapter 3',
kind: 'Chapter_sections-v2',
path: ['Genesis', 'Creation', 'Chapter 3'],
},
},
],
},
{
date: '2026-08-20',
assignments: [
{
id: 4,
completedAt: null,
element: {
name: 'Chapter 4',
kind: 'Chapter_sections-v2',
path: ['Genesis', 'Creation', 'Chapter 4'],
},
},
],
},
],
},
}
const rescheduledDetail = {
schedule: {
...rescheduleSourceDetail.schedule,
startDate: '2026-08-10',
targetDate: '2026-08-16',
days: [
rescheduleSourceDetail.schedule.days[0],
{ date: '2026-08-11', assignments: [] },
{ date: '2026-08-12', assignments: [] },
{ date: '2026-08-13', assignments: [] },
{ date: '2026-08-14', assignments: [] },
{
date: '2026-08-15',
assignments: [rescheduleSourceDetail.schedule.days[1]?.assignments[0]],
},
{
date: '2026-08-16',
assignments: [
rescheduleSourceDetail.schedule.days[2]?.assignments[0],
rescheduleSourceDetail.schedule.days[3]?.assignments[0],
],
},
],
},
}
const fullyCompletedScheduleDetail = {
schedule: {
...scheduleDetail.schedule,
days: scheduleDetail.schedule.days.map((day) => ({
...day,
assignments: day.assignments.map((assignment) => ({
...assignment,
completedAt: '2026-08-15T09:30:00+00:00',
})),
})),
},
}
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('reschedules unfinished assignments while preserving completed work', () => {
cy.clock(new Date(2026, 7, 15, 12).getTime(), ['Date'])
cy.intercept('GET', '**/api/schedules/73', {
statusCode: 200,
body: rescheduleSourceDetail,
}).as('schedule')
cy.intercept('PATCH', '**/api/schedules/73', (request) => {
expect(request.headers.accept).to.equal('application/json')
expect(request.body).to.deep.equal({
startDate: '2026-08-15',
targetDate: '2026-08-16',
workloadPlacement: 'end',
})
request.reply({ statusCode: 200, body: rescheduledDetail })
}).as('reschedule')
cy.visit('/schedules/73')
cy.wait('@me')
cy.wait('@schedule')
cy.get('[data-reschedule-form]').should('not.exist')
cy.get('[data-reschedule-toggle]').should('have.text', 'Reschedule remaining').click()
cy.get('#reschedule-start-date').should('have.value', '2026-08-15')
cy.get('#reschedule-target-date').should('have.value', '2026-08-20')
cy.get('[data-workload-placement]').should('not.exist')
cy.get('#reschedule-target-date').clear().type('2026-08-16')
cy.get('[data-workload-placement]').should('be.visible').within(() => {
cy.get('input[value="middle"]').should('be.checked')
cy.get('input[value="end"]').check()
})
cy.viewport(375, 667)
cy.document().then((document) => {
expect(document.documentElement.scrollWidth).to.be.at.most(
document.documentElement.clientWidth,
)
})
cy.get('[data-reschedule-form]').submit()
cy.wait('@reschedule')
cy.get('[data-reschedule-form]').should('not.exist')
cy.get('[data-reschedule-announcement]').should(
'have.text',
'3 remaining assignments rescheduled.',
)
cy.contains('4 Chapter_sections-v2 assignments across 7 days').should('be.visible')
cy.get('.schedule-range').should('contain.text', 'Aug 10, 2026 to Aug 16, 2026')
cy.get('[data-assignment-section="remaining"] [data-schedule-day]').then(($days) => {
expect([...$days].map((day) => day.getAttribute('data-schedule-date'))).to.deep.equal([
'2026-08-11',
'2026-08-12',
'2026-08-13',
'2026-08-14',
'2026-08-15',
'2026-08-16',
])
})
cy.get('[data-assignment-section="completed"] summary').click()
cy.get('[data-assignment-section="completed"] [data-schedule-day]')
.should('have.length', 1)
.and('have.attr', 'data-schedule-date', '2026-08-10')
cy.get('[data-reschedule-toggle]').click()
cy.get('#reschedule-start-date').should('have.value', '2026-08-15')
cy.get('#reschedule-target-date').should('have.value', '2026-08-16')
})
it('uses today for an expired target and validates before rescheduling', () => {
cy.clock(new Date(2026, 7, 15, 12).getTime(), ['Date'])
cy.intercept('GET', '**/api/schedules/73', {
statusCode: 200,
body: scheduleDetail,
}).as('schedule')
cy.intercept('PATCH', '**/api/schedules/73').as('reschedule')
cy.visit('/schedules/73')
cy.wait('@me')
cy.wait('@schedule')
cy.get('[data-reschedule-toggle]').click()
cy.get('#reschedule-start-date').should('have.value', '2026-08-15')
cy.get('#reschedule-target-date').should('have.value', '2026-08-15')
cy.get('#reschedule-target-date').clear().type('2026-08-14')
cy.get('[data-reschedule-form]').submit()
cy.get('#reschedule-target-date-error').should(
'have.text',
'Target date cannot be before the start date.',
)
cy.get('@reschedule.all').should('have.length', 0)
})
it('does not offer rescheduling after all assignments are complete', () => {
cy.intercept('GET', '**/api/schedules/73', {
statusCode: 200,
body: fullyCompletedScheduleDetail,
}).as('schedule')
cy.visit('/schedules/73')
cy.wait('@me')
cy.wait('@schedule')
cy.get('[data-reschedule-toggle]').should('not.exist')
})
it('keeps the current plan visible when rescheduling fails', () => {
cy.clock(new Date(2026, 7, 15, 12).getTime(), ['Date'])
cy.intercept('GET', '**/api/schedules/73', {
statusCode: 200,
body: rescheduleSourceDetail,
}).as('schedule')
cy.intercept('PATCH', '**/api/schedules/73', {
statusCode: 500,
body: { error: 'Rescheduling is temporarily unavailable.' },
}).as('reschedule')
cy.visit('/schedules/73')
cy.wait('@me')
cy.wait('@schedule')
cy.get('[data-reschedule-toggle]').click()
cy.get('[data-reschedule-form]').submit()
cy.wait('@reschedule')
cy.get('[data-reschedule-form]')
.should('be.visible')
.and('contain.text', 'Rescheduling is temporarily unavailable.')
cy.get('.schedule-range').should('contain.text', 'Aug 10, 2026 to Aug 20, 2026')
cy.get('[data-reschedule-announcement]').should('have.text', '')
})
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))
}