From 6afe7864beb32292190fcbc017767fa817e16e9e Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 15 Aug 2026 21:48:34 +0300 Subject: [PATCH 1/7] test dated assignment listing --- .../Feature/Schedule/ScheduleEndpointTest.php | 132 ++++++++++++++++ .../UseCases/ListAssignmentsForDateTest.php | 143 ++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php index 0303dfe..a0e9cf8 100644 --- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -221,6 +221,136 @@ class ScheduleEndpointTest extends TestCase ]); } + public function test_it_lists_the_users_assignments_for_a_date(): void + { + $user = $this->createUser('reader@example.com'); + $otherUser = $this->createUser('other@example.com'); + $olderSet = $this->createSet($user, 'Older course'); + $olderLevel = $this->createLevel($olderSet, 'lesson'); + $olderRepository = app(ElementRepository::class); + $olderRepository->create(new CreateElementDto( + name: 'First lesson', + level: $olderLevel, + parentElement: null, + )); + $olderRepository->create(new CreateElementDto( + name: 'Second lesson', + level: $olderLevel, + parentElement: null, + )); + $newerSet = $this->createSet($user, 'Newer course'); + $newerLevel = $this->createLevel($newerSet, 'chapter'); + app(ElementRepository::class)->create(new CreateElementDto( + name: 'Only chapter', + level: $newerLevel, + parentElement: null, + )); + $this->createSession($user, 'valid-token'); + $this->createSession($otherUser, 'other-token'); + + $this->credentialedPost('/api/schedules', [ + 'setId' => $olderSet->getId(), + 'levelId' => $olderLevel->getId(), + 'startDate' => '2026-08-15', + 'targetDate' => '2026-08-15', + ])->assertCreated(); + $this->credentialedPost('/api/schedules', [ + 'setId' => $newerSet->getId(), + 'levelId' => $newerLevel->getId(), + 'startDate' => '2026-08-15', + 'targetDate' => '2026-08-15', + ])->assertCreated(); + $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'other-token', + )->postJson('/api/schedules', [ + 'setId' => $newerSet->getId(), + 'levelId' => $newerLevel->getId(), + 'startDate' => '2026-08-15', + 'targetDate' => '2026-08-15', + ])->assertCreated(); + $this->credentialedPost('/api/schedules', [ + 'setId' => $olderSet->getId(), + 'levelId' => $olderLevel->getId(), + 'startDate' => '2026-08-16', + 'targetDate' => '2026-08-16', + ])->assertCreated(); + + $this->credentialedGet('/api/assignments?date=2026-08-15') + ->assertOk() + ->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, + 'schedule' => [ + 'id' => 1, + 'set' => [ + 'name' => 'Older course', + ], + ], + 'element' => [ + 'name' => 'First lesson', + 'kind' => 'lesson', + 'path' => ['First lesson'], + ], + ], + [ + 'id' => 2, + 'schedule' => [ + 'id' => 1, + 'set' => [ + 'name' => 'Older course', + ], + ], + 'element' => [ + 'name' => 'Second lesson', + 'kind' => 'lesson', + 'path' => ['Second lesson'], + ], + ], + ], + ]); + } + + public function test_it_returns_an_empty_assignment_list_for_a_date(): void + { + $user = $this->createUser('reader@example.com'); + $this->createSession($user, 'valid-token'); + + $this->credentialedGet('/api/assignments?date=2026-08-15') + ->assertOk() + ->assertExactJson([ + 'date' => '2026-08-15', + 'assignments' => [], + ]); + } + + public function test_it_rejects_invalid_assignment_dates(): void + { + $user = $this->createUser('reader@example.com'); + $this->createSession($user, 'valid-token'); + + $this->credentialedGet('/api/assignments') + ->assertBadRequest() + ->assertExactJson(['error' => 'date is required']); + } + public function test_it_is_stable_after_sources_change_or_are_deleted(): void { $user = $this->createUser('reader@example.com'); @@ -323,6 +453,8 @@ class ScheduleEndpointTest extends TestCase public function test_schedule_endpoints_require_authentication(): void { + $this->getJson('/api/assignments?date=2026-08-15') + ->assertStatus(401); $this->getJson('/api/schedules')->assertStatus(401); $this->getJson('/api/schedules/1')->assertStatus(401); $this->postJson('/api/schedules', [])->assertStatus(401); diff --git a/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php b/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php new file mode 100644 index 0000000..1d1e3fd --- /dev/null +++ b/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php @@ -0,0 +1,143 @@ +user(1, 'reader@example.com'); + $otherUser = $this->user(2, 'other@example.com'); + $repository = new FakeScheduleRepository; + $repository->create($this->schedule( + user: $user, + setName: 'Older plan', + date: '2026-08-15', + assignmentNames: ['First', 'Second'], + )); + $repository->create($this->schedule( + user: $user, + setName: 'Newer plan', + date: '2026-08-15', + assignmentNames: ['Third'], + )); + $repository->create($this->schedule( + user: $otherUser, + setName: 'Private plan', + date: '2026-08-15', + assignmentNames: ['Hidden'], + )); + $repository->create($this->schedule( + user: $user, + setName: 'Tomorrow plan', + date: '2026-08-16', + assignmentNames: ['Later'], + )); + + $assignments = (new ListAssignmentsForDate($repository))->execute( + new ListAssignmentsForDateRequest( + user: $user, + date: '2026-08-15', + ), + ); + + $this->assertSame( + ['Newer plan', 'Older plan', 'Older plan'], + array_map(function ($assignment): string { + return $assignment->getSetName(); + }, $assignments), + ); + $this->assertSame( + ['Third', 'First', 'Second'], + array_map(function ($assignment): string { + return $assignment->getAssignment()->getName(); + }, $assignments), + ); + $this->assertSame(2, $assignments[0]->getScheduleId()); + } + + public function test_it_rejects_a_missing_date(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('date is required'); + + (new ListAssignmentsForDate(new FakeScheduleRepository))->execute( + new ListAssignmentsForDateRequest( + user: $this->user(1, 'reader@example.com'), + date: null, + ), + ); + } + + public function test_it_rejects_an_invalid_date(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'date must be a valid date in YYYY-MM-DD format', + ); + + (new ListAssignmentsForDate(new FakeScheduleRepository))->execute( + new ListAssignmentsForDateRequest( + user: $this->user(1, 'reader@example.com'), + date: '2026-02-30', + ), + ); + } + + private function user(int $id, string $email): User + { + return new User( + id: $id, + email: new EmailAddress($email), + passwordHash: 'hashed-password', + ); + } + + /** + * @param list $assignmentNames + */ + private function schedule( + User $user, + string $setName, + string $date, + array $assignmentNames, + ): CreateScheduleDto { + $scheduledDate = new DateTimeImmutable( + $date, + new DateTimeZone('UTC'), + ); + $assignments = []; + + foreach ($assignmentNames as $index => $name) { + $assignments[] = new CreateScheduleAssignmentDto( + name: $name, + kind: 'lesson', + path: [$setName, $name], + scheduledDate: $scheduledDate, + position: $index + 1, + ); + } + + return new CreateScheduleDto( + user: $user, + setName: $setName, + elementKind: 'lesson', + startDate: $scheduledDate, + targetDate: $scheduledDate, + assignments: $assignments, + ); + } +} From 483ca6fc1d36c3c4fcfa152988c125d398d7da76 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 15 Aug 2026 21:50:37 +0300 Subject: [PATCH 2/7] add dated assignment endpoint --- .../Http/Controllers/ScheduleController.php | 58 ++++++++++++++++++ backend/app/Schedule/AssignmentForDate.php | 27 +++++++++ .../Schedule/EloquentScheduleRepository.php | 56 +++++++++++++++--- .../app/Schedule/ScheduleAssignmentModel.php | 10 ++++ backend/app/Schedule/ScheduleRepository.php | 9 +++ .../ListAssignmentsForDate.php | 59 +++++++++++++++++++ .../ListAssignmentsForDateRequest.php | 13 ++++ backend/routes/api.php | 1 + .../tests/Fakes/FakeScheduleRepository.php | 27 +++++++++ 9 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 backend/app/Schedule/AssignmentForDate.php create mode 100644 backend/app/Schedule/UseCases/ListAssignmentsForDate/ListAssignmentsForDate.php create mode 100644 backend/app/Schedule/UseCases/ListAssignmentsForDate/ListAssignmentsForDateRequest.php diff --git a/backend/app/Http/Controllers/ScheduleController.php b/backend/app/Http/Controllers/ScheduleController.php index cc8cd60..9ec6588 100644 --- a/backend/app/Http/Controllers/ScheduleController.php +++ b/backend/app/Http/Controllers/ScheduleController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Exceptions\BadRequestException; use App\Exceptions\NotFoundException; +use App\Schedule\AssignmentForDate; use App\Schedule\Schedule; use App\Schedule\ScheduleAssignment; use App\Schedule\UseCases\CreateSchedule\CreateSchedule; @@ -11,6 +12,8 @@ use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest; use App\Schedule\UseCases\GetSchedule\GetSchedule; use App\Schedule\UseCases\GetSchedule\GetScheduleRequest; use App\Schedule\UseCases\ListSchedules\ListSchedules; +use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDate; +use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDateRequest; use App\Shared\Http\RequestInput; use App\User\User; use Illuminate\Http\JsonResponse; @@ -22,6 +25,7 @@ class ScheduleController extends Controller private CreateSchedule $createSchedule, private ListSchedules $listSchedules, private GetSchedule $getSchedule, + private ListAssignmentsForDate $listAssignmentsForDate, ) {} public function store(Request $request): JsonResponse @@ -90,6 +94,36 @@ class ScheduleController extends Controller ]); } + public function assignments(Request $request): JsonResponse + { + $input = new RequestInput($request); + $date = $input->string('date'); + + try { + $assignments = $this->listAssignmentsForDate->execute( + new ListAssignmentsForDateRequest( + user: $this->user($request), + date: $date, + ), + ); + } catch (BadRequestException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 400, + ); + } + + return new JsonResponse([ + 'date' => $date, + 'assignments' => array_map( + function (AssignmentForDate $assignment): array { + return $this->assignmentForDatePayload($assignment); + }, + $assignments, + ), + ]); + } + /** * @return array */ @@ -165,6 +199,30 @@ class ScheduleController extends Controller ]; } + /** + * @return array + */ + private function assignmentForDatePayload( + AssignmentForDate $assignmentForDate, + ): array { + $assignment = $assignmentForDate->getAssignment(); + + return [ + 'id' => $assignment->getId(), + 'schedule' => [ + 'id' => $assignmentForDate->getScheduleId(), + 'set' => [ + 'name' => $assignmentForDate->getSetName(), + ], + ], + 'element' => [ + 'name' => $assignment->getName(), + 'kind' => $assignment->getKind(), + 'path' => $assignment->getPath(), + ], + ]; + } + private function user(Request $request): User { /** @var User $user */ diff --git a/backend/app/Schedule/AssignmentForDate.php b/backend/app/Schedule/AssignmentForDate.php new file mode 100644 index 0000000..b877c39 --- /dev/null +++ b/backend/app/Schedule/AssignmentForDate.php @@ -0,0 +1,27 @@ +scheduleId; + } + + public function getSetName(): string + { + return $this->setName; + } + + public function getAssignment(): ScheduleAssignment + { + return $this->assignment; + } +} diff --git a/backend/app/Schedule/EloquentScheduleRepository.php b/backend/app/Schedule/EloquentScheduleRepository.php index 31bb859..7aec036 100644 --- a/backend/app/Schedule/EloquentScheduleRepository.php +++ b/backend/app/Schedule/EloquentScheduleRepository.php @@ -61,6 +61,40 @@ class EloquentScheduleRepository implements ScheduleRepository return $schedules; } + public function findAssignmentsForUserOnDate( + User $user, + DateTimeImmutable $date, + ): array { + $assignmentModels = ScheduleAssignmentModel::query() + ->select('schedule_assignments.*') + ->join( + 'schedules', + 'schedules.id', + '=', + 'schedule_assignments.schedule_id', + ) + ->where('schedules.user_id', $user->getId()) + ->where('schedule_assignments.scheduled_date', $date->format( + 'Y-m-d', + )) + ->with('schedule') + ->orderByDesc('schedules.id') + ->orderBy('schedule_assignments.position') + ->orderBy('schedule_assignments.id') + ->get(); + $assignments = []; + + foreach ($assignmentModels as $assignmentModel) { + $assignments[] = new AssignmentForDate( + scheduleId: $assignmentModel->schedule->id, + setName: $assignmentModel->schedule->set_name, + assignment: $this->assignmentToDomain($assignmentModel), + ); + } + + return $assignments; + } + private function toDomain(ScheduleModel $model, User $user): Schedule { $assignmentModels = ScheduleAssignmentModel::query() @@ -71,14 +105,7 @@ class EloquentScheduleRepository implements ScheduleRepository $assignments = []; foreach ($assignmentModels as $assignmentModel) { - $assignments[] = new ScheduleAssignment( - id: $assignmentModel->id, - name: $assignmentModel->element_name, - kind: $assignmentModel->element_kind, - path: $assignmentModel->element_path, - scheduledDate: $this->date($assignmentModel->scheduled_date), - position: $assignmentModel->position, - ); + $assignments[] = $this->assignmentToDomain($assignmentModel); } return new Schedule( @@ -92,6 +119,19 @@ class EloquentScheduleRepository implements ScheduleRepository ); } + private function assignmentToDomain( + ScheduleAssignmentModel $model, + ): ScheduleAssignment { + return new ScheduleAssignment( + id: $model->id, + name: $model->element_name, + kind: $model->element_kind, + path: $model->element_path, + scheduledDate: $this->date($model->scheduled_date), + position: $model->position, + ); + } + private function date(string $value): DateTimeImmutable { return new DateTimeImmutable($value, new DateTimeZone('UTC')); diff --git a/backend/app/Schedule/ScheduleAssignmentModel.php b/backend/app/Schedule/ScheduleAssignmentModel.php index f0f33df..3cd5577 100644 --- a/backend/app/Schedule/ScheduleAssignmentModel.php +++ b/backend/app/Schedule/ScheduleAssignmentModel.php @@ -5,6 +5,7 @@ namespace App\Schedule; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property int $id @@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Model; * @property list $element_path * @property string $scheduled_date * @property int $position + * @property-read ScheduleModel $schedule * * @method static Builder|ScheduleAssignmentModel newModelQuery() * @method static Builder|ScheduleAssignmentModel newQuery() @@ -46,4 +48,12 @@ class ScheduleAssignmentModel extends Model 'position' => 'integer', ]; } + + /** + * @return BelongsTo + */ + public function schedule(): BelongsTo + { + return $this->belongsTo(ScheduleModel::class, 'schedule_id'); + } } diff --git a/backend/app/Schedule/ScheduleRepository.php b/backend/app/Schedule/ScheduleRepository.php index bd02515..fcdcb8c 100644 --- a/backend/app/Schedule/ScheduleRepository.php +++ b/backend/app/Schedule/ScheduleRepository.php @@ -3,6 +3,7 @@ namespace App\Schedule; use App\User\User; +use DateTimeImmutable; interface ScheduleRepository { @@ -14,4 +15,12 @@ interface ScheduleRepository * @return list */ public function findAllForUser(User $user): array; + + /** + * @return list + */ + public function findAssignmentsForUserOnDate( + User $user, + DateTimeImmutable $date, + ): array; } diff --git a/backend/app/Schedule/UseCases/ListAssignmentsForDate/ListAssignmentsForDate.php b/backend/app/Schedule/UseCases/ListAssignmentsForDate/ListAssignmentsForDate.php new file mode 100644 index 0000000..9e3017d --- /dev/null +++ b/backend/app/Schedule/UseCases/ListAssignmentsForDate/ListAssignmentsForDate.php @@ -0,0 +1,59 @@ + + * @throws BadRequestException + */ + public function execute(ListAssignmentsForDateRequest $request): array + { + $date = $this->parseDate($request->date); + + return $this->scheduleRepository->findAssignmentsForUserOnDate( + $request->user, + $date, + ); + } + + /** + * @throws BadRequestException + */ + private function parseDate(?string $value): DateTimeImmutable + { + if ($value === null || $value === '') { + throw new BadRequestException('date is required'); + } + + $date = DateTimeImmutable::createFromFormat( + '!Y-m-d', + $value, + new DateTimeZone('UTC'), + ); + $errors = DateTimeImmutable::getLastErrors(); + if ( + $date === false + || $date->format('Y-m-d') !== $value + || ($errors !== false + && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) + ) { + throw new BadRequestException( + 'date must be a valid date in YYYY-MM-DD format', + ); + } + + return $date; + } +} diff --git a/backend/app/Schedule/UseCases/ListAssignmentsForDate/ListAssignmentsForDateRequest.php b/backend/app/Schedule/UseCases/ListAssignmentsForDate/ListAssignmentsForDateRequest.php new file mode 100644 index 0000000..f0db6a9 --- /dev/null +++ b/backend/app/Schedule/UseCases/ListAssignmentsForDate/ListAssignmentsForDateRequest.php @@ -0,0 +1,13 @@ +group(function (): void { Route::get('/sets', [SetController::class, 'index']); Route::get('/sets/{setId}', [SetController::class, 'show']) ->whereNumber('setId'); + Route::get('/assignments', [ScheduleController::class, 'assignments']); Route::post('/schedules', [ScheduleController::class, 'store']); Route::get('/schedules', [ScheduleController::class, 'index']); Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show']) diff --git a/backend/tests/Fakes/FakeScheduleRepository.php b/backend/tests/Fakes/FakeScheduleRepository.php index de1ae76..e30caf2 100644 --- a/backend/tests/Fakes/FakeScheduleRepository.php +++ b/backend/tests/Fakes/FakeScheduleRepository.php @@ -3,10 +3,12 @@ namespace Tests\Fakes; use App\Schedule\CreateScheduleDto; +use App\Schedule\AssignmentForDate; use App\Schedule\Schedule; use App\Schedule\ScheduleAssignment; use App\Schedule\ScheduleRepository; use App\User\User; +use DateTimeImmutable; class FakeScheduleRepository implements ScheduleRepository { @@ -72,6 +74,31 @@ class FakeScheduleRepository implements ScheduleRepository }, array_values($schedules)); } + public function findAssignmentsForUserOnDate( + User $user, + DateTimeImmutable $date, + ): array { + $assignments = []; + + foreach ($this->findAllForUser($user) as $schedule) { + foreach ($schedule->getAssignments() as $assignment) { + if ($assignment->getScheduledDate()->format('Y-m-d') + !== $date->format('Y-m-d') + ) { + continue; + } + + $assignments[] = new AssignmentForDate( + scheduleId: $schedule->getId(), + setName: $schedule->getSetName(), + assignment: $assignment, + ); + } + } + + return $assignments; + } + private function copy(Schedule $schedule): Schedule { $assignments = array_map( From 1c83eb1b84c07c9ce4cad427bf0d200be98acd6f Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 15 Aug 2026 21:54:42 +0300 Subject: [PATCH 3/7] test today's dashboard work --- .../website/cypress/e2e/confirm-email.cy.ts | 8 +- .../website/cypress/e2e/session-auth.cy.ts | 8 +- .../website/cypress/e2e/set-scheduling.cy.ts | 6 + .../website/cypress/e2e/sets-dashboard.cy.ts | 8 +- .../cypress/e2e/today-assignments.cy.ts | 157 ++++++++++++++++++ 5 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 frontend/website/cypress/e2e/today-assignments.cy.ts diff --git a/frontend/website/cypress/e2e/confirm-email.cy.ts b/frontend/website/cypress/e2e/confirm-email.cy.ts index 2ef0274..92bdf79 100644 --- a/frontend/website/cypress/e2e/confirm-email.cy.ts +++ b/frontend/website/cypress/e2e/confirm-email.cy.ts @@ -17,6 +17,12 @@ describe('email confirmation', () => { statusCode: 200, body: { schedules: [] }, }) + cy.intercept('GET', '**/api/assignments?date=*', (request) => { + request.reply({ + statusCode: 200, + body: { date: request.query.date, assignments: [] }, + }) + }) }) it('chooses a password, confirms the account, and opens the dashboard', () => { @@ -39,7 +45,7 @@ describe('email confirmation', () => { cy.wait('@confirmEmail') cy.location('pathname').should('equal', '/dashboard') - cy.get('h1').should('have.text', 'Available sets') + cy.get('#sets-heading').should('have.text', 'Available sets') }) it('validates password length and confirmation before submitting', () => { diff --git a/frontend/website/cypress/e2e/session-auth.cy.ts b/frontend/website/cypress/e2e/session-auth.cy.ts index eb88490..d4b863a 100644 --- a/frontend/website/cypress/e2e/session-auth.cy.ts +++ b/frontend/website/cypress/e2e/session-auth.cy.ts @@ -47,6 +47,12 @@ describe('session authentication', () => { statusCode: 200, body: { schedules: [] }, }) + cy.intercept('GET', '**/api/assignments?date=*', (request) => { + request.reply({ + statusCode: 200, + body: { date: request.query.date, assignments: [] }, + }) + }) }) it('restores an authenticated session on a protected route', () => { @@ -59,7 +65,7 @@ describe('session authentication', () => { cy.wait('@me') cy.location('pathname').should('equal', '/dashboard') - cy.get('h1').should('have.text', 'Available sets') + cy.get('#sets-heading').should('have.text', 'Available sets') }) it('redirects an unauthenticated protected route to login', () => { diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index 5747d6b..a12f672 100644 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -250,6 +250,12 @@ describe('set scheduling', () => { }, }) }).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') diff --git a/frontend/website/cypress/e2e/sets-dashboard.cy.ts b/frontend/website/cypress/e2e/sets-dashboard.cy.ts index 2557ded..5c9b28e 100644 --- a/frontend/website/cypress/e2e/sets-dashboard.cy.ts +++ b/frontend/website/cypress/e2e/sets-dashboard.cy.ts @@ -17,6 +17,12 @@ describe('sets dashboard', () => { statusCode: 200, body: { schedules: [] }, }) + cy.intercept('GET', '**/api/assignments?date=*', (request) => { + request.reply({ + statusCode: 200, + body: { date: request.query.date, assignments: [] }, + }) + }) }) it('shows every available set as a detail link', () => { @@ -38,7 +44,7 @@ describe('sets dashboard', () => { cy.wait('@me') cy.wait('@sets') - cy.get('h1').should('have.text', 'Available sets') + cy.get('#sets-heading').should('have.text', 'Available sets') cy.get('ul[aria-label="Available sets"] h2').then(($headings) => { expect([...$headings].map((heading) => heading.textContent)).to.deep.equal([ 'Bible', diff --git a/frontend/website/cypress/e2e/today-assignments.cy.ts b/frontend/website/cypress/e2e/today-assignments.cy.ts new file mode 100644 index 0000000..ee94323 --- /dev/null +++ b/frontend/website/cypress/e2e/today-assignments.cy.ts @@ -0,0 +1,157 @@ +const authenticatedUser = { + id: 7, + email: 'user@example.com', +} + +const browserToday = '2026-08-15' + +function interceptDashboardRequests(): void { + cy.intercept('GET', '**/api/me', { + statusCode: 200, + body: { user: authenticatedUser }, + }).as('me') + cy.intercept('GET', '**/api/sets', { + statusCode: 200, + body: { sets: [] }, + }).as('sets') + cy.intercept('GET', '**/api/schedules', { + statusCode: 200, + body: { schedules: [] }, + }).as('schedules') +} + +describe("today's assignments", () => { + beforeEach(() => { + cy.clock(new Date(2026, 7, 15, 0, 30).getTime()) + interceptDashboardRequests() + }) + + it('uses the browser date and links every assignment to its schedule', () => { + cy.intercept('GET', `**/api/assignments?date=${browserToday}`, (request) => { + expect(request.headers.accept).to.equal('application/json') + request.reply({ + statusCode: 200, + body: { + date: browserToday, + assignments: [ + { + id: 12, + schedule: { + id: 73, + set: { name: 'Bible' }, + }, + element: { + name: 'Chapter 1', + kind: 'Chapter_sections-v2', + path: ['Genesis', 'Creation', 'Chapter 1'], + }, + }, + { + id: 13, + schedule: { + id: 81, + set: { name: 'Course' }, + }, + element: { + name: 'Introduction', + kind: 'lesson', + path: ['Introduction'], + }, + }, + ], + }, + }) + }).as('todayAssignments') + + cy.visit('/dashboard') + cy.wait('@me') + cy.wait('@todayAssignments') + + cy.get('#today-heading').should('have.text', 'Today') + cy.get('[data-today-date]').should( + 'have.attr', + 'data-today-date', + browserToday, + ) + cy.get('ul[aria-label="Today\'s assignments"] > li').should( + 'have.length', + 2, + ) + cy.contains('a', 'Genesis / Creation / Chapter 1') + .should('contain.text', 'Bible') + .and('have.attr', 'href', '/schedules/73') + cy.contains('a', 'Introduction') + .should('contain.text', 'Course') + .and('have.attr', 'href', '/schedules/81') + cy.get('.today-assignment__kind') + .first() + .should('have.text', 'Chapter_sections-v2') + .and('have.css', 'text-transform', 'none') + }) + + it('shows loading and empty states', () => { + cy.intercept('GET', `**/api/assignments?date=${browserToday}`, { + delay: 500, + statusCode: 200, + body: { + date: browserToday, + assignments: [], + }, + }).as('todayAssignments') + + cy.visit('/dashboard') + cy.wait('@me') + cy.get('.today-assignments [role="status"]').should( + 'have.text', + "Loading today's assignments...", + ) + cy.wait('@todayAssignments') + + cy.get('.today-assignments [role="status"]').should( + 'have.text', + 'Nothing is assigned for today.', + ) + }) + + it('retries independently after the request fails', () => { + let requestCount = 0 + cy.intercept( + 'GET', + `**/api/assignments?date=${browserToday}`, + (request) => { + requestCount += 1 + request.alias = `todayAssignments${requestCount}` + + if (requestCount === 1) { + request.reply({ statusCode: 500 }) + return + } + + request.reply({ + statusCode: 200, + body: { + date: browserToday, + assignments: [], + }, + }) + }, + ) + + cy.visit('/dashboard') + cy.wait('@me') + cy.wait('@todayAssignments1') + + cy.get('.today-assignments [role="alert"]') + .should('contain.text', "We couldn't load today's assignments.") + .within(() => { + cy.contains('button', 'Try again').click() + }) + cy.get('#schedules-heading').should('have.text', 'Your schedules') + cy.wait('@todayAssignments2') + + cy.get('.today-assignments [role="status"]').should( + 'have.text', + 'Nothing is assigned for today.', + ) + }) +}) From e1d2262579850af18b303b22325ecab4717bba42 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 15 Aug 2026 21:57:39 +0300 Subject: [PATCH 4/7] stabilize today state assertions --- frontend/website/cypress/e2e/today-assignments.cy.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/website/cypress/e2e/today-assignments.cy.ts b/frontend/website/cypress/e2e/today-assignments.cy.ts index ee94323..9d31dde 100644 --- a/frontend/website/cypress/e2e/today-assignments.cy.ts +++ b/frontend/website/cypress/e2e/today-assignments.cy.ts @@ -91,7 +91,7 @@ describe("today's assignments", () => { it('shows loading and empty states', () => { cy.intercept('GET', `**/api/assignments?date=${browserToday}`, { - delay: 500, + delay: 2500, statusCode: 200, body: { date: browserToday, @@ -102,13 +102,13 @@ describe("today's assignments", () => { cy.visit('/dashboard') cy.wait('@me') cy.get('.today-assignments [role="status"]').should( - 'have.text', + 'contain.text', "Loading today's assignments...", ) cy.wait('@todayAssignments') cy.get('.today-assignments [role="status"]').should( - 'have.text', + 'contain.text', 'Nothing is assigned for today.', ) }) @@ -150,7 +150,7 @@ describe("today's assignments", () => { cy.wait('@todayAssignments2') cy.get('.today-assignments [role="status"]').should( - 'have.text', + 'contain.text', 'Nothing is assigned for today.', ) }) From 85c9f4bbc9813b33c44244f9bd4c0c5b9b8f5482 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 15 Aug 2026 22:02:19 +0300 Subject: [PATCH 5/7] show today's assignments --- frontend/website/src/stores/schedules.ts | 76 +++++++ frontend/website/src/views/DashboardView.vue | 211 ++++++++++++++++++- 2 files changed, 282 insertions(+), 5 deletions(-) diff --git a/frontend/website/src/stores/schedules.ts b/frontend/website/src/stores/schedules.ts index f8844e6..090db7c 100644 --- a/frontend/website/src/stores/schedules.ts +++ b/frontend/website/src/stores/schedules.ts @@ -26,6 +26,15 @@ const scheduleAssignmentSchema = z.object({ }), }) +export const assignmentForDateSchema = scheduleAssignmentSchema.extend({ + schedule: z.object({ + id: z.number().int().positive(), + set: z.object({ + name: z.string().min(1), + }), + }), +}) + export const scheduleDetailSchema = scheduleSummarySchema.extend({ days: z.array( z.object({ @@ -43,12 +52,18 @@ const scheduleResponseSchema = z.object({ schedule: scheduleDetailSchema, }) +const assignmentsForDateResponseSchema = z.object({ + date: isoDateSchema, + assignments: z.array(assignmentForDateSchema), +}) + const errorResponseSchema = z.object({ error: z.string().min(1), }) export type ScheduleSummary = z.infer export type ScheduleDetail = z.infer +export type AssignmentForDate = z.infer export type CreateScheduleInput = { setId: number levelId: number @@ -59,6 +74,7 @@ export type CreateScheduleInput = { const LIST_ERROR = "We couldn't load your schedules." const DETAIL_ERROR = "We couldn't load this schedule." const CREATE_ERROR = "We couldn't create this schedule." +const ASSIGNMENTS_ERROR = "We couldn't load today's assignments." export const useSchedulesStore = defineStore('schedules', () => { const schedules = ref([]) @@ -70,7 +86,11 @@ export const useSchedulesStore = defineStore('schedules', () => { const detailNotFound = ref(false) const creating = ref(false) const createError = ref(null) + const assignmentsForDate = ref([]) + const assignmentsLoading = ref(false) + const assignmentsError = ref(null) let activeDetailRequestId = 0 + let assignmentsRequestId = 0 async function fetchSchedules(): Promise { listLoading.value = true @@ -202,6 +222,58 @@ export const useSchedulesStore = defineStore('schedules', () => { } } + async function fetchAssignmentsForDate(date: string): Promise { + const requestId = ++assignmentsRequestId + assignmentsForDate.value = [] + assignmentsLoading.value = true + assignmentsError.value = null + + try { + const query = new URLSearchParams({ date }) + const response = await fetch(`${API_BASE}/api/assignments?${query.toString()}`, { + method: 'GET', + credentials: 'include', + headers: { + Accept: 'application/json', + }, + }) + + if (requestId !== assignmentsRequestId) { + return false + } + + if (response.status !== 200) { + assignmentsError.value = ASSIGNMENTS_ERROR + + return false + } + + const responseBody: unknown = await response.json() + if (requestId !== assignmentsRequestId) { + return false + } + + const parsedResponse = assignmentsForDateResponseSchema.parse(responseBody) + if (parsedResponse.date !== date) { + throw new Error('assignment response did not match requested date') + } + assignmentsForDate.value = parsedResponse.assignments + + return true + } catch { + if (requestId === assignmentsRequestId) { + assignmentsForDate.value = [] + assignmentsError.value = ASSIGNMENTS_ERROR + } + + return false + } finally { + if (requestId === assignmentsRequestId) { + assignmentsLoading.value = false + } + } + } + return { schedules, listLoading, @@ -212,8 +284,12 @@ export const useSchedulesStore = defineStore('schedules', () => { detailNotFound, creating, createError, + assignmentsForDate, + assignmentsLoading, + assignmentsError, fetchSchedules, fetchSchedule, createSchedule, + fetchAssignmentsForDate, } }) diff --git a/frontend/website/src/views/DashboardView.vue b/frontend/website/src/views/DashboardView.vue index 12464ee..c99ccf4 100644 --- a/frontend/website/src/views/DashboardView.vue +++ b/frontend/website/src/views/DashboardView.vue @@ -1,6 +1,6 @@ @@ -69,6 +109,23 @@ defineProps<{ box-shadow: 0 0.55rem 1.5rem rgb(40 62 52 / 6%); } +.element-node__card--toggle { + width: 100%; + color: inherit; + text-align: left; + cursor: pointer; +} + +.element-node__card--toggle:hover { + border-color: rgb(40 92 78 / 34%); + background: #fffdf7; +} + +.element-node__card--toggle:focus-visible { + outline: 3px solid rgb(86 127 112 / 34%); + outline-offset: 0.2rem; +} + .element-node__name { min-width: 0; color: #183029; @@ -78,6 +135,13 @@ defineProps<{ overflow-wrap: anywhere; } +.element-node__metadata { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 0.8rem; +} + .element-node__kind { flex: 0 0 auto; padding: 0.35rem 0.55rem; @@ -90,6 +154,18 @@ defineProps<{ text-transform: none; } +.element-node__chevron { + width: 0.55rem; + height: 0.55rem; + border-right: 2px solid #5e7067; + border-bottom: 2px solid #5e7067; + transform: rotate(-45deg); +} + +.element-node__chevron--expanded { + transform: rotate(45deg); +} + @media (max-width: 37.5rem) { .element-tree { margin-left: 0.45rem; @@ -106,6 +182,10 @@ defineProps<{ padding: 0.75rem 0.7rem; } + .element-node__metadata { + gap: 0.6rem; + } + .element-node__kind { padding-inline: 0.42rem; font-size: 0.56rem; diff --git a/frontend/website/src/views/SetLayoutView.vue b/frontend/website/src/views/SetLayoutView.vue index 6edf2b9..9b86551 100644 --- a/frontend/website/src/views/SetLayoutView.vue +++ b/frontend/website/src/views/SetLayoutView.vue @@ -11,6 +11,7 @@ const route = useRoute() const setLayoutStore = useSetLayoutStore() const { layout, loading, error, notFound } = storeToRefs(setLayoutStore) const currentSetId = ref(null) +const expandedElementIds = ref(new Set()) const EMPTY_MESSAGE = 'This set does not have any elements yet.' watch( @@ -19,6 +20,7 @@ watch( const rawSetId = Array.isArray(setIdParameter) ? setIdParameter[0] : setIdParameter const setId = Number(rawSetId) currentSetId.value = setId + expandedElementIds.value = new Set() await setLayoutStore.fetchSetLayout(setId) }, { immediate: true }, @@ -29,8 +31,21 @@ async function retry(): Promise { return } + expandedElementIds.value = new Set() await setLayoutStore.fetchSetLayout(currentSetId.value) } + +function toggleElement(elementId: number): void { + const nextExpandedElementIds = new Set(expandedElementIds.value) + + if (nextExpandedElementIds.has(elementId)) { + nextExpandedElementIds.delete(elementId) + } else { + nextExpandedElementIds.add(elementId) + } + + expandedElementIds.value = nextExpandedElementIds +}