diff --git a/backend/app/Http/Controllers/ScheduleController.php b/backend/app/Http/Controllers/ScheduleController.php index f80461a..33a6316 100644 --- a/backend/app/Http/Controllers/ScheduleController.php +++ b/backend/app/Http/Controllers/ScheduleController.php @@ -249,6 +249,8 @@ class ScheduleController extends Controller return [ 'id' => $assignment->getId(), + 'scheduledDate' => $assignment->getScheduledDate() + ->format('Y-m-d'), 'schedule' => [ 'id' => $assignmentForDate->getScheduleId(), 'set' => [ diff --git a/backend/app/Schedule/EloquentScheduleRepository.php b/backend/app/Schedule/EloquentScheduleRepository.php index 214e8a5..8576591 100644 --- a/backend/app/Schedule/EloquentScheduleRepository.php +++ b/backend/app/Schedule/EloquentScheduleRepository.php @@ -63,7 +63,7 @@ class EloquentScheduleRepository implements ScheduleRepository return $schedules; } - public function findAssignmentsForUserOnDate( + public function findIncompleteAssignmentsForUserDueOnOrBefore( User $user, DateTimeImmutable $date, ): array { @@ -76,11 +76,14 @@ class EloquentScheduleRepository implements ScheduleRepository 'schedule_assignments.schedule_id', ) ->where('schedules.user_id', $user->getId()) - ->where('schedule_assignments.scheduled_date', $date->format( - 'Y-m-d', - )) + ->where( + 'schedule_assignments.scheduled_date', + '<=', + $date->format('Y-m-d'), + ) ->whereNull('schedule_assignments.completed_at') ->with('schedule') + ->orderBy('schedule_assignments.scheduled_date') ->orderByDesc('schedules.id') ->orderBy('schedule_assignments.position') ->orderBy('schedule_assignments.id') diff --git a/backend/app/Schedule/ScheduleRepository.php b/backend/app/Schedule/ScheduleRepository.php index 3222311..20b5f9f 100644 --- a/backend/app/Schedule/ScheduleRepository.php +++ b/backend/app/Schedule/ScheduleRepository.php @@ -19,7 +19,7 @@ interface ScheduleRepository /** * @return list */ - public function findAssignmentsForUserOnDate( + public function findIncompleteAssignmentsForUserDueOnOrBefore( User $user, DateTimeImmutable $date, ): array; diff --git a/backend/app/Schedule/UseCases/ListAssignmentsForDate/ListAssignmentsForDate.php b/backend/app/Schedule/UseCases/ListAssignmentsForDate/ListAssignmentsForDate.php index 9e3017d..23f388d 100644 --- a/backend/app/Schedule/UseCases/ListAssignmentsForDate/ListAssignmentsForDate.php +++ b/backend/app/Schedule/UseCases/ListAssignmentsForDate/ListAssignmentsForDate.php @@ -22,10 +22,11 @@ class ListAssignmentsForDate { $date = $this->parseDate($request->date); - return $this->scheduleRepository->findAssignmentsForUserOnDate( - $request->user, - $date, - ); + return $this->scheduleRepository + ->findIncompleteAssignmentsForUserDueOnOrBefore( + $request->user, + $date, + ); } /** diff --git a/backend/tests/Fakes/FakeScheduleRepository.php b/backend/tests/Fakes/FakeScheduleRepository.php index e2f35bc..e12d126 100644 --- a/backend/tests/Fakes/FakeScheduleRepository.php +++ b/backend/tests/Fakes/FakeScheduleRepository.php @@ -78,7 +78,7 @@ class FakeScheduleRepository implements ScheduleRepository }, array_values($schedules)); } - public function findAssignmentsForUserOnDate( + public function findIncompleteAssignmentsForUserDueOnOrBefore( User $user, DateTimeImmutable $date, ): array { @@ -86,8 +86,7 @@ class FakeScheduleRepository implements ScheduleRepository foreach ($this->findAllForUser($user) as $schedule) { foreach ($schedule->getAssignments() as $assignment) { - if ($assignment->getScheduledDate()->format('Y-m-d') - !== $date->format('Y-m-d') + if ($assignment->getScheduledDate() > $date || $assignment->getCompletedAt() !== null ) { continue; @@ -101,6 +100,37 @@ class FakeScheduleRepository implements ScheduleRepository } } + usort( + $assignments, + function ( + AssignmentForDate $first, + AssignmentForDate $second, + ): int { + $firstAssignment = $first->getAssignment(); + $secondAssignment = $second->getAssignment(); + $dateComparison = $firstAssignment->getScheduledDate() + <=> $secondAssignment->getScheduledDate(); + if ($dateComparison !== 0) { + return $dateComparison; + } + + $scheduleComparison = $second->getScheduleId() + <=> $first->getScheduleId(); + if ($scheduleComparison !== 0) { + return $scheduleComparison; + } + + $positionComparison = $firstAssignment->getPosition() + <=> $secondAssignment->getPosition(); + if ($positionComparison !== 0) { + return $positionComparison; + } + + return $firstAssignment->getId() + <=> $secondAssignment->getId(); + }, + ); + return $assignments; } diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php index b68e2ad..295a245 100644 --- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -225,7 +225,7 @@ class ScheduleEndpointTest extends TestCase ]); } - public function test_it_lists_the_users_assignments_for_a_date(): void + public function test_it_lists_the_users_assignments_due_through_a_date(): void { $user = $this->createUser('reader@example.com'); $otherUser = $this->createUser('other@example.com'); @@ -255,8 +255,8 @@ class ScheduleEndpointTest extends TestCase $this->credentialedPost('/api/schedules', [ 'setId' => $olderSet->getId(), 'levelId' => $olderLevel->getId(), - 'startDate' => '2026-08-15', - 'targetDate' => '2026-08-15', + 'startDate' => '2026-08-14', + 'targetDate' => '2026-08-14', ])->assertCreated(); $this->credentialedPost('/api/schedules', [ 'setId' => $newerSet->getId(), @@ -286,22 +286,9 @@ class ScheduleEndpointTest extends TestCase ->assertExactJson([ 'date' => '2026-08-15', 'assignments' => [ - [ - 'id' => 3, - 'schedule' => [ - 'id' => 2, - 'set' => [ - 'name' => 'Newer course', - ], - ], - 'element' => [ - 'name' => 'Only chapter', - 'kind' => 'chapter', - 'path' => ['Only chapter'], - ], - ], [ 'id' => 1, + 'scheduledDate' => '2026-08-14', 'schedule' => [ 'id' => 1, 'set' => [ @@ -316,6 +303,7 @@ class ScheduleEndpointTest extends TestCase ], [ 'id' => 2, + 'scheduledDate' => '2026-08-14', 'schedule' => [ 'id' => 1, 'set' => [ @@ -328,6 +316,21 @@ class ScheduleEndpointTest extends TestCase 'path' => ['Second lesson'], ], ], + [ + 'id' => 3, + 'scheduledDate' => '2026-08-15', + 'schedule' => [ + 'id' => 2, + 'set' => [ + 'name' => 'Newer course', + ], + ], + 'element' => [ + 'name' => 'Only chapter', + 'kind' => 'chapter', + 'path' => ['Only chapter'], + ], + ], ], ]); } diff --git a/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php b/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php index c6bc95f..4fca1fc 100644 --- a/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php +++ b/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php @@ -17,7 +17,7 @@ use Tests\Fakes\FakeScheduleRepository; class ListAssignmentsForDateTest extends TestCase { - public function test_it_lists_the_users_assignments_for_the_date(): void + public function test_it_lists_the_users_assignments_due_through_the_date(): void { $user = $this->user(1, 'reader@example.com'); $otherUser = $this->user(2, 'other@example.com'); @@ -25,7 +25,7 @@ class ListAssignmentsForDateTest extends TestCase $olderSchedule = $repository->create($this->schedule( user: $user, setName: 'Older plan', - date: '2026-08-15', + date: '2026-08-14', assignmentNames: ['First', 'Second'], )); $repository->create($this->schedule( @@ -37,7 +37,7 @@ class ListAssignmentsForDateTest extends TestCase $repository->create($this->schedule( user: $otherUser, setName: 'Private plan', - date: '2026-08-15', + date: '2026-08-13', assignmentNames: ['Hidden'], )); $repository->create($this->schedule( @@ -61,18 +61,18 @@ class ListAssignmentsForDateTest extends TestCase ); $this->assertSame( - ['Newer plan', 'Older plan'], + ['Older plan', 'Newer plan'], array_map(function ($assignment): string { return $assignment->getSetName(); }, $assignments), ); $this->assertSame( - ['Third', 'Second'], + ['Second', 'Third'], array_map(function ($assignment): string { return $assignment->getAssignment()->getName(); }, $assignments), ); - $this->assertSame(2, $assignments[0]->getScheduleId()); + $this->assertSame(1, $assignments[0]->getScheduleId()); } public function test_it_rejects_a_missing_date(): void diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index 9f83629..0511405 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,12 @@ describe('set scheduling', () => { '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('validates the schedule form before submitting', () => { @@ -248,6 +306,72 @@ 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', + ) + 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', { @@ -278,33 +402,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', () => { diff --git a/frontend/website/cypress/e2e/today-assignments.cy.ts b/frontend/website/cypress/e2e/today-assignments.cy.ts index 77d2ba5..553c57c 100644 --- a/frontend/website/cypress/e2e/today-assignments.cy.ts +++ b/frontend/website/cypress/e2e/today-assignments.cy.ts @@ -36,6 +36,7 @@ describe("today's assignments", () => { assignments: [ { id: 12, + scheduledDate: '2026-08-13', schedule: { id: 73, set: { name: 'Bible' }, @@ -48,6 +49,7 @@ describe("today's assignments", () => { }, { id: 13, + scheduledDate: browserToday, schedule: { id: 81, set: { name: 'Course' }, @@ -77,6 +79,16 @@ describe("today's assignments", () => { 'have.length', 2, ) + cy.get('[data-today-assignment]') + .first() + .should('have.attr', 'data-today-assignment', '12') + cy.get('[data-today-assignment="12"] .today-assignment__overdue').should( + 'have.text', + 'Overdue · Due Aug 13, 2026', + ) + cy.get('[data-today-assignment="13"] .today-assignment__overdue').should( + 'not.exist', + ) cy.contains('a', 'Genesis / Creation / Chapter 1') .should('contain.text', 'Bible') .and('have.attr', 'href', '/schedules/73') @@ -109,7 +121,7 @@ describe("today's assignments", () => { cy.get('.today-assignments [role="status"]').should( 'contain.text', - 'Nothing is assigned for today.', + 'Nothing is due today.', ) }) @@ -121,6 +133,7 @@ describe("today's assignments", () => { assignments: [ { id: 12, + scheduledDate: browserToday, schedule: { id: 73, set: { name: 'Bible' }, @@ -177,7 +190,7 @@ describe("today's assignments", () => { cy.get('[data-today-assignment="12"]').should('not.exist') cy.get('.today-assignments [role="status"]').should( 'contain.text', - 'Nothing is assigned for today.', + 'Nothing is due today.', ) }) @@ -219,7 +232,7 @@ describe("today's assignments", () => { cy.get('.today-assignments [role="status"]').should( 'contain.text', - 'Nothing is assigned for today.', + 'Nothing is due today.', ) }) }) 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/stores/schedules.ts b/frontend/website/src/stores/schedules.ts index 2471d66..825bbfb 100644 --- a/frontend/website/src/stores/schedules.ts +++ b/frontend/website/src/stores/schedules.ts @@ -31,6 +31,7 @@ const scheduleAssignmentSchema = assignmentIdentitySchema.extend({ }) export const assignmentForDateSchema = assignmentIdentitySchema.extend({ + scheduledDate: isoDateSchema, schedule: z.object({ id: z.number().int().positive(), set: z.object({ diff --git a/frontend/website/src/views/DashboardView.vue b/frontend/website/src/views/DashboardView.vue index 822da86..7d1eeaf 100644 --- a/frontend/website/src/views/DashboardView.vue +++ b/frontend/website/src/views/DashboardView.vue @@ -36,6 +36,14 @@ function formatDate(value: string): string { }).format(new Date(`${value}T00:00:00Z`)) } +function isOverdue(scheduledDate: string): boolean { + return scheduledDate < todayDate.value +} + +function overdueLabel(scheduledDate: string): string { + return `Overdue · Due ${formatDate(scheduledDate)}` +} + async function completeAssignment(assignmentId: number): Promise { await schedulesStore.setAssignmentCompleted(assignmentId, true) } @@ -76,7 +84,7 @@ async function completeAssignment(assignmentId: number): Promise {

- Nothing is assigned for today. + Nothing is due today.

    @@ -97,6 +105,12 @@ async function completeAssignment(assignmentId: number): Promise {

    {{ assignment.schedule.set.name }}

    {{ assignment.element.kind }} +

    {{ assignment.element.path.join(' / ') }}

    @@ -390,6 +404,19 @@ async function completeAssignment(assignmentId: number): Promise { text-transform: none; } +.today-assignment__overdue { + display: inline-flex; + width: fit-content; + margin-top: 0.85rem; + padding: 0.3rem 0.55rem; + border-radius: 999px; + color: #923f31; + background: #f7e5dc; + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.025em; +} + .today-assignment__path { margin: 1.75rem 0 0; color: #183029; 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; } }