From ea74ce545e727699ad34bd2bb5cff429dd7a99cd Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Tue, 18 Aug 2026 00:16:50 +0300 Subject: [PATCH 1/2] test schedule progress split --- .../website/cypress/e2e/set-scheduling.cy.ts | 201 ++++++++++++++++-- 1 file changed, 185 insertions(+), 16 deletions(-) diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index 9f83629..2a6984e 100644 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -121,6 +121,55 @@ const completedScheduleDetail = { }, } +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, @@ -159,9 +208,12 @@ describe('set scheduling', () => { 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)'], - ) + 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') @@ -184,6 +236,14 @@ describe('set scheduling', () => { 'contain.text', 'Exodus / Shemot / Chapter 1', ) + cy.get('[data-assignment-section="completed"]') + .should('not.have.attr', 'open') + .find('summary') + .click() + cy.get('[data-assignment-section="completed"]').should( + 'contain.text', + 'No assignments completed yet.', + ) }) it('validates the schedule form before submitting', () => { @@ -248,6 +308,69 @@ describe('set scheduling', () => { 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') + .and('not.have.attr', 'open') + .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', { @@ -278,33 +401,79 @@ describe('set scheduling', () => { cy.wait('@me') cy.wait('@schedule') - cy.get('[data-schedule-assignment="2"]').within(() => { + 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-schedule-assignment="2"]') + 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.') + }) - cy.get('[data-schedule-assignment="1"]').within(() => { - cy.contains('button', 'Mark complete').click() - cy.contains('button', 'Saving...').should('be.disabled') - }) + 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-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-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', () => { From ccc78bf38fb4952806036f48ef3239fd85a71878 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Tue, 18 Aug 2026 00:22:34 +0300 Subject: [PATCH 2/2] split schedule progress --- .../website/cypress/e2e/set-scheduling.cy.ts | 47 +- .../components/ScheduleAssignmentTimeline.vue | 335 ++++++++++++ .../website/src/views/ScheduleDetailView.vue | 480 ++++++++++-------- 3 files changed, 626 insertions(+), 236 deletions(-) create mode 100644 frontend/website/src/components/ScheduleAssignmentTimeline.vue diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index 2a6984e..0511405 100644 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -236,10 +236,8 @@ describe('set scheduling', () => { 'contain.text', 'Exodus / Shemot / Chapter 1', ) - cy.get('[data-assignment-section="completed"]') - .should('not.have.attr', 'open') - .find('summary') - .click() + 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.', @@ -342,26 +340,29 @@ describe('set scheduling', () => { cy.get('[data-schedule-date="2026-08-13"]').should('not.exist') }) - cy.get('[data-assignment-section="completed"]') - .should('have.attr', 'data-assignment-count', '2') - .and('not.have.attr', 'open') - .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.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) => { diff --git a/frontend/website/src/components/ScheduleAssignmentTimeline.vue b/frontend/website/src/components/ScheduleAssignmentTimeline.vue new file mode 100644 index 0000000..779d712 --- /dev/null +++ b/frontend/website/src/components/ScheduleAssignmentTimeline.vue @@ -0,0 +1,335 @@ + + + + + diff --git a/frontend/website/src/views/ScheduleDetailView.vue b/frontend/website/src/views/ScheduleDetailView.vue index 9a455a9..5c72243 100644 --- a/frontend/website/src/views/ScheduleDetailView.vue +++ b/frontend/website/src/views/ScheduleDetailView.vue @@ -1,15 +1,55 @@ @@ -93,73 +145,82 @@ async function setAssignmentCompleted(assignmentId: number, completed: boolean):

-
    -
  1. -
    - - {{ day.assignments.length }} assigned -
    +

    + {{ completionAnnouncement }} +

    -

    Rest day

    +
    +
    +
    +

    Your plan

    +

    Remaining

    +

    Everything still ahead, kept in scheduled order.

    +
    + + {{ remainingAssignmentCount }} + {{ remainingAssignmentCount === 1 ? 'assignment' : 'assignments' }} + +
    -
      -
    • -
      - - {{ assignment.element.path.join(' / ') }} - - {{ assignment.element.kind }} -
      -
      -

      - Completed - -

      -

      Not completed

      - - -
      -
    • -
    -
  2. -
+

+ All assignments are complete. +

+ + + + +
+ + + Completed + Finished work, grouped by its original schedule date. + + + + {{ completedAssignmentCount }} + {{ completedAssignmentCount === 1 ? 'assignment' : 'assignments' }} + + + + + +
+

+ No assignments completed yet. +

+ +
+
@@ -233,153 +294,142 @@ h1 { color: #68776f; } -.schedule-days { - display: grid; - gap: 1rem; - margin: 3rem 0 0; +.completion-announcement { + position: absolute; + width: 1px; + height: 1px; padding: 0; - list-style: none; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } -.schedule-day { +.assignment-section { + margin-top: 3rem; +} + +.assignment-section__header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 2rem; +} + +.assignment-section__eyebrow { + margin: 0 0 0.45rem; + color: #926044; + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.assignment-section__header h2, +.completed-section__heading { + font-family: Georgia, 'Times New Roman', serif; + font-weight: 500; + letter-spacing: -0.035em; +} + +.assignment-section__header h2 { + margin: 0; + font-size: clamp(2rem, 5vw, 3rem); + line-height: 1; +} + +.assignment-section__header p:last-child { + margin: 0.65rem 0 0; + color: #68776f; + line-height: 1.55; +} + +.assignment-section__count { + flex: 0 0 auto; + padding: 0.45rem 0.7rem; + border-radius: 999px; + color: #4f665d; + background: rgb(224 233 223 / 72%); + font-size: 0.72rem; + font-weight: 800; +} + +.section-state { + display: grid; + min-height: 6rem; + place-items: center; + margin: 1.4rem 0 0; + padding: 1.25rem; + border: 1px dashed rgb(24 48 41 / 18%); + border-radius: 1rem; + color: #5e7067; + background: rgb(255 253 247 / 52%); + text-align: center; +} + +.completed-section { overflow: hidden; border: 1px solid rgb(24 48 41 / 12%); border-radius: 1rem; - background: rgb(255 253 247 / 82%); - box-shadow: 0 0.75rem 2rem rgb(40 62 52 / 6%); + background: rgb(246 245 237 / 78%); } -.schedule-day__header { +.completed-section__summary { display: flex; align-items: center; justify-content: space-between; - gap: 1rem; - padding: 0.85rem 1rem; - border-bottom: 1px solid rgb(24 48 41 / 10%); - color: #5e7067; - background: rgb(239 228 212 / 42%); - font-size: 0.75rem; - font-weight: 800; -} - -.rest-day { - margin: 0; - padding: 1.2rem 1rem; - color: #79877f; - font-style: italic; -} - -.assignment-list { - display: grid; - gap: 0; - margin: 0; - padding: 0; + gap: 1.5rem; + padding: 1.15rem 1.25rem; + cursor: pointer; list-style: none; } -.assignment-list li { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: start; - gap: 1rem; - padding: 1rem; +.completed-section__summary::-webkit-details-marker { + display: none; } -.assignment-list li.is-completed { - background: rgb(220 235 224 / 34%); -} - -.assignment-list li + li { - border-top: 1px solid rgb(24 48 41 / 10%); -} - -.assignment-path { - min-width: 0; - font-family: Georgia, 'Times New Roman', serif; - font-size: 1.05rem; - overflow-wrap: anywhere; -} - -.assignment-description { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - min-width: 0; -} - -.assignment-kind { - flex: 0 0 auto; - padding: 0.35rem 0.55rem; - border-radius: 999px; - color: #81533a; - background: #efe4d4; - font-size: 0.62rem; - font-weight: 800; - letter-spacing: 0.1em; - text-transform: none; -} - -.assignment-completion { - display: grid; - justify-items: end; - gap: 0.55rem; - min-width: 12rem; -} - -.assignment-completion__status, -.assignment-completion__error { - margin: 0; - font-size: 0.74rem; - font-weight: 700; - text-align: right; -} - -.assignment-completion__status { - color: #5e7067; -} - -.assignment-completion__status time { - display: block; - margin-top: 0.2rem; - color: #344e45; -} - -.assignment-completion__error { - max-width: 14rem; - color: #9b3f32; -} - -.assignment-completion-button { - min-height: 2.4rem; - padding: 0.55rem 0.8rem; - border: 1px solid rgb(24 58 49 / 28%); - border-radius: 0.65rem; - color: #183a31; - background: #fffdf7; - font-size: 0.75rem; - font-weight: 800; - cursor: pointer; -} - -.is-completed .assignment-completion-button { - color: #5e7067; - background: transparent; -} - -.assignment-completion-button:hover:not(:disabled) { - border-color: #285c4e; - background: #f9f5e9; -} - -.assignment-completion-button:focus-visible { +.completed-section__summary:focus-visible { outline: 3px solid rgb(86 127 112 / 34%); - outline-offset: 0.25rem; + outline-offset: -0.25rem; } -.assignment-completion-button:disabled { - cursor: wait; - opacity: 0.65; +.completed-section__introduction { + display: grid; + gap: 0.25rem; +} + +.completed-section__heading { + color: #344e45; + font-size: 1.35rem; +} + +.completed-section__introduction > span:last-child { + color: #79877f; + font-size: 0.78rem; + line-height: 1.45; +} + +.completed-section__controls { + display: inline-flex; + align-items: center; + gap: 0.75rem; +} + +.completed-section__chevron { + color: #68776f; + font-size: 1.35rem; + line-height: 1; + transition: transform 160ms ease; +} + +.completed-section[open] .completed-section__chevron { + transform: rotate(180deg); +} + +.completed-section__content { + padding: 0 1rem 1rem; + border-top: 1px solid rgb(24 48 41 / 10%); } .page-state { @@ -430,22 +480,26 @@ h1 { margin-top: 3rem; } - .assignment-list li { - grid-template-columns: 1fr; - } - - .assignment-description { + .assignment-section__header { align-items: flex-start; + flex-direction: column; + gap: 1rem; } - .assignment-completion { - justify-items: start; - min-width: 0; + .completed-section__summary { + align-items: flex-start; + gap: 1rem; + padding: 1rem; } - .assignment-completion__status, - .assignment-completion__error { - text-align: left; + .completed-section__controls { + flex-direction: column; + align-items: flex-end; + gap: 0.35rem; + } + + .completed-section__content { + padding: 0 0.75rem 0.75rem; } }