From bdb266746da800ec4a6dec7adb567e50f5bb281f Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 10 Aug 2026 20:01:28 +0300 Subject: [PATCH 01/25] test set scheduling flow --- .../tests/Fakes/FakeScheduleRepository.php | 97 ++++++ .../Feature/Schedule/ScheduleEndpointTest.php | 313 +++++++++++++++++ .../Schedule/UseCases/CreateScheduleTest.php | 314 ++++++++++++++++++ 3 files changed, 724 insertions(+) create mode 100644 backend/tests/Fakes/FakeScheduleRepository.php create mode 100644 backend/tests/Feature/Schedule/ScheduleEndpointTest.php create mode 100644 backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php diff --git a/backend/tests/Fakes/FakeScheduleRepository.php b/backend/tests/Fakes/FakeScheduleRepository.php new file mode 100644 index 0000000..06e281e --- /dev/null +++ b/backend/tests/Fakes/FakeScheduleRepository.php @@ -0,0 +1,97 @@ + + */ + private array $schedules = []; + + public function create(CreateScheduleDto $dto): Schedule + { + $id = count($this->schedules) + 1; + $assignments = []; + + foreach ($dto->assignments as $assignmentDto) { + $assignments[] = new ScheduleAssignment( + id: count($assignments) + 1, + element: $assignmentDto->element, + scheduledDate: $assignmentDto->scheduledDate, + position: $assignmentDto->position, + ); + } + + $schedule = new Schedule( + id: $id, + user: $dto->user, + set: $dto->set, + elementKind: $dto->elementKind, + startDate: $dto->startDate, + targetDate: $dto->targetDate, + assignments: $assignments, + ); + $this->schedules[$id] = $schedule; + + return $this->copy($schedule); + } + + public function findForUser(int $id, User $user): ?Schedule + { + $schedule = $this->schedules[$id] ?? null; + if ($schedule === null || $schedule->getUser()->getId() + !== $user->getId() + ) { + return null; + } + + return $this->copy($schedule); + } + + public function findAllForUser(User $user): array + { + $schedules = array_filter( + $this->schedules, + function (Schedule $schedule) use ($user): bool { + return $schedule->getUser()->getId() === $user->getId(); + }, + ); + krsort($schedules); + + return array_map(function (Schedule $schedule): Schedule { + return $this->copy($schedule); + }, array_values($schedules)); + } + + private function copy(Schedule $schedule): Schedule + { + $assignments = array_map( + function (ScheduleAssignment $assignment): ScheduleAssignment { + return new ScheduleAssignment( + id: $assignment->getId(), + element: $assignment->getElement(), + scheduledDate: $assignment->getScheduledDate(), + position: $assignment->getPosition(), + ); + }, + $schedule->getAssignments(), + ); + + return new Schedule( + id: $schedule->getId(), + user: $schedule->getUser(), + set: $schedule->getSet(), + elementKind: $schedule->getElementKind(), + startDate: $schedule->getStartDate(), + targetDate: $schedule->getTargetDate(), + assignments: $assignments, + ); + } +} diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php new file mode 100644 index 0000000..731a221 --- /dev/null +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -0,0 +1,313 @@ +createUser('reader@example.com'); + $creator = $this->createUser('creator@example.com'); + $set = $this->createSet($creator, 'Bible'); + $repository = app(ElementRepository::class); + $genesis = $repository->create(new CreateElementDto( + set: $set, + name: 'Genesis', + kind: 'book', + parentElement: null, + )); + $creation = $repository->create(new CreateElementDto( + set: $set, + name: 'Creation', + kind: 'portion', + parentElement: $genesis, + )); + $chapterOne = $repository->create(new CreateElementDto( + set: $set, + name: 'Chapter 1', + kind: 'chapter', + parentElement: $creation, + )); + $exodus = $repository->create(new CreateElementDto( + set: $set, + name: 'Exodus', + kind: 'book', + parentElement: null, + )); + $chapterTwo = $repository->create(new CreateElementDto( + set: $set, + name: 'Chapter 1', + kind: 'chapter', + parentElement: $exodus, + )); + $this->createSession($user, 'valid-token'); + + $response = $this->credentialedPost('/api/schedules', [ + 'setId' => $set->getId(), + 'elementKind' => 'chapter', + 'startDate' => '2026-08-10', + 'targetDate' => '2026-08-12', + ]); + + $response->assertCreated()->assertExactJson([ + 'schedule' => [ + 'id' => 1, + 'set' => [ + 'id' => $set->getId(), + 'name' => 'Bible', + ], + 'elementKind' => 'chapter', + 'startDate' => '2026-08-10', + 'targetDate' => '2026-08-12', + 'assignmentCount' => 2, + 'days' => [ + [ + 'date' => '2026-08-10', + 'assignments' => [ + [ + 'element' => [ + 'id' => $chapterOne->getId(), + 'name' => 'Chapter 1', + 'kind' => 'chapter', + 'path' => [ + 'Genesis', + 'Creation', + 'Chapter 1', + ], + ], + ], + ], + ], + [ + 'date' => '2026-08-11', + 'assignments' => [], + ], + [ + 'date' => '2026-08-12', + 'assignments' => [ + [ + 'element' => [ + 'id' => $chapterTwo->getId(), + 'name' => 'Chapter 1', + 'kind' => 'chapter', + 'path' => [ + 'Exodus', + 'Chapter 1', + ], + ], + ], + ], + ], + ], + ], + ]); + $this->assertDatabaseHas('schedules', [ + 'user_id' => $user->getId(), + 'set_id' => $set->getId(), + 'element_kind' => 'chapter', + 'start_date' => '2026-08-10', + 'target_date' => '2026-08-12', + ]); + $this->assertDatabaseCount('schedule_assignments', 2); + + $this->credentialedGet('/api/schedules/1') + ->assertOk() + ->assertExactJson($response->json()); + } + + public function test_it_lists_only_the_users_schedules_newest_first(): void + { + $user = $this->createUser('reader@example.com'); + $otherUser = $this->createUser('other@example.com'); + $set = $this->createSet($user, 'Course'); + app(ElementRepository::class)->create(new CreateElementDto( + set: $set, + name: 'Welcome', + kind: 'lesson', + parentElement: null, + )); + $this->createSession($user, 'valid-token'); + $this->createSession($otherUser, 'other-token'); + + $this->credentialedPost('/api/schedules', [ + 'setId' => $set->getId(), + 'elementKind' => 'lesson', + 'startDate' => '2026-08-01', + 'targetDate' => '2026-08-01', + ])->assertCreated(); + $this->credentialedPost('/api/schedules', [ + 'setId' => $set->getId(), + 'elementKind' => 'lesson', + 'startDate' => '2026-09-01', + 'targetDate' => '2026-09-01', + ])->assertCreated(); + $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'other-token', + )->postJson('/api/schedules', [ + 'setId' => $set->getId(), + 'elementKind' => 'lesson', + 'startDate' => '2026-10-01', + 'targetDate' => '2026-10-01', + ])->assertCreated(); + + $this->credentialedGet('/api/schedules') + ->assertOk() + ->assertExactJson([ + 'schedules' => [ + [ + 'id' => 2, + 'set' => [ + 'id' => $set->getId(), + 'name' => 'Course', + ], + 'elementKind' => 'lesson', + 'startDate' => '2026-09-01', + 'targetDate' => '2026-09-01', + 'assignmentCount' => 1, + ], + [ + 'id' => 1, + 'set' => [ + 'id' => $set->getId(), + 'name' => 'Course', + ], + 'elementKind' => 'lesson', + 'startDate' => '2026-08-01', + 'targetDate' => '2026-08-01', + 'assignmentCount' => 1, + ], + ], + ]); + } + + public function test_it_hides_another_users_schedule(): void + { + $owner = $this->createUser('owner@example.com'); + $viewer = $this->createUser('viewer@example.com'); + $set = $this->createSet($owner, 'Course'); + app(ElementRepository::class)->create(new CreateElementDto( + set: $set, + name: 'Welcome', + kind: 'lesson', + parentElement: null, + )); + $this->createSession($owner, 'owner-token'); + $this->createSession($viewer, 'viewer-token'); + + $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'owner-token', + )->postJson('/api/schedules', [ + 'setId' => $set->getId(), + 'elementKind' => 'lesson', + 'startDate' => '2026-08-01', + 'targetDate' => '2026-08-01', + ])->assertCreated(); + + $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'viewer-token', + )->getJson('/api/schedules/1') + ->assertNotFound() + ->assertExactJson(['error' => 'schedule not found']); + } + + public function test_it_rejects_invalid_creation_input(): void + { + $user = $this->createUser('reader@example.com'); + $this->createSession($user, 'valid-token'); + + $this->credentialedPost('/api/schedules', [ + 'setId' => 999, + 'elementKind' => 'chapter', + 'startDate' => '2026-08-12', + 'targetDate' => '2026-08-10', + ])->assertNotFound()->assertExactJson([ + 'error' => 'set not found', + ]); + } + + public function test_schedule_endpoints_require_authentication(): void + { + $this->getJson('/api/schedules')->assertStatus(401); + $this->getJson('/api/schedules/1')->assertStatus(401); + $this->postJson('/api/schedules', [])->assertStatus(401); + } + + private function createUser(string $email): User + { + return app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress($email), + passwordHash: 'hashed-password', + )); + } + + private function createSet(User $user, string $name): Set + { + return app(SetRepository::class)->create(new CreateSetDto( + name: $name, + creator: $user, + )); + } + + private function createSession(User $user, string $token): void + { + $createdAt = new DateTimeImmutable( + '2026-08-03T12:00:00', + new DateTimeZone('UTC'), + ); + app(SessionRepository::class)->create(new CreateSessionDto( + token: $token, + user: $user, + createdAt: $createdAt, + expiresAt: $createdAt->modify('+7 days'), + )); + } + + /** + * @param array $payload + */ + private function credentialedPost( + string $uri, + array $payload, + ): TestResponse { + return $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->postJson($uri, $payload); + } + + private function credentialedGet(string $uri): TestResponse + { + return $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->getJson($uri); + } +} diff --git a/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php b/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php new file mode 100644 index 0000000..3814a56 --- /dev/null +++ b/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php @@ -0,0 +1,314 @@ +user(); + $setRepository = new FakeSetRepository; + $elementRepository = new FakeElementRepository; + $scheduleRepository = new FakeScheduleRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Bible', + creator: $user, + )); + $genesis = $elementRepository->create(new CreateElementDto( + set: $set, + name: 'Genesis', + kind: 'book', + parentElement: null, + )); + $exodus = $elementRepository->create(new CreateElementDto( + set: $set, + name: 'Exodus', + kind: 'book', + parentElement: null, + )); + $exodusChapterOne = $elementRepository->create( + new CreateElementDto( + set: $set, + name: 'Exodus 1', + kind: 'chapter', + parentElement: $exodus, + ), + ); + $creation = $elementRepository->create(new CreateElementDto( + set: $set, + name: 'Creation', + kind: 'portion', + parentElement: $genesis, + )); + $genesisChapterOne = $elementRepository->create( + new CreateElementDto( + set: $set, + name: 'Genesis 1', + kind: 'chapter', + parentElement: $creation, + ), + ); + $genesisChapterTwo = $elementRepository->create( + new CreateElementDto( + set: $set, + name: 'Genesis 2', + kind: 'chapter', + parentElement: $creation, + ), + ); + $genesisChapterThree = $elementRepository->create( + new CreateElementDto( + set: $set, + name: 'Genesis 3', + kind: 'chapter', + parentElement: $creation, + ), + ); + $exodusChapterTwo = $elementRepository->create( + new CreateElementDto( + set: $set, + name: 'Exodus 2', + kind: 'chapter', + parentElement: $exodus, + ), + ); + + $schedule = (new CreateSchedule( + $setRepository, + $elementRepository, + $scheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + elementKind: 'chapter', + startDate: '2026-08-10', + targetDate: '2026-08-12', + )); + + $this->assertSame('chapter', $schedule->getElementKind()); + $this->assertSame('2026-08-10', $schedule->getStartDate()->format( + 'Y-m-d', + )); + $this->assertSame('2026-08-12', $schedule->getTargetDate()->format( + 'Y-m-d', + )); + $this->assertSame( + [ + $genesisChapterOne->getId(), + $genesisChapterTwo->getId(), + $genesisChapterThree->getId(), + $exodusChapterOne->getId(), + $exodusChapterTwo->getId(), + ], + array_map(function ($assignment): int { + return $assignment->getElement()->getId(); + }, $schedule->getAssignments()), + ); + $this->assertSame( + [ + '2026-08-10', + '2026-08-10', + '2026-08-11', + '2026-08-11', + '2026-08-12', + ], + array_map(function ($assignment): string { + return $assignment->getScheduledDate()->format('Y-m-d'); + }, $schedule->getAssignments()), + ); + } + + public function test_it_spreads_sparse_work_across_the_full_range(): void + { + $user = $this->user(); + $setRepository = new FakeSetRepository; + $elementRepository = new FakeElementRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Course', + creator: $user, + )); + + foreach (['First', 'Second', 'Third'] as $name) { + $elementRepository->create(new CreateElementDto( + set: $set, + name: $name, + kind: 'lesson', + parentElement: null, + )); + } + + $schedule = (new CreateSchedule( + $setRepository, + $elementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + elementKind: 'lesson', + startDate: '2026-08-10', + targetDate: '2026-08-16', + )); + + $this->assertSame( + ['2026-08-10', '2026-08-13', '2026-08-16'], + array_map(function ($assignment): string { + return $assignment->getScheduledDate()->format('Y-m-d'); + }, $schedule->getAssignments()), + ); + } + + public function test_it_places_one_element_on_the_start_date(): void + { + $user = $this->user(); + $setRepository = new FakeSetRepository; + $elementRepository = new FakeElementRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Project', + creator: $user, + )); + $elementRepository->create(new CreateElementDto( + set: $set, + name: 'Ship it', + kind: 'milestone', + parentElement: null, + )); + + $schedule = (new CreateSchedule( + $setRepository, + $elementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + elementKind: 'milestone', + startDate: '2020-01-01', + targetDate: '2030-01-01', + )); + + $this->assertSame( + '2020-01-01', + $schedule->getAssignments()[0] + ->getScheduledDate() + ->format('Y-m-d'), + ); + } + + public function test_it_rejects_an_unknown_set(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('set not found'); + + (new CreateSchedule( + new FakeSetRepository, + new FakeElementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $this->user(), + setId: 999, + elementKind: 'chapter', + startDate: '2026-08-10', + targetDate: '2026-08-12', + )); + } + + public function test_it_rejects_an_unavailable_element_kind(): void + { + $user = $this->user(); + $setRepository = new FakeSetRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Bible', + creator: $user, + )); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'elementKind is not available for set', + ); + + (new CreateSchedule( + $setRepository, + new FakeElementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + elementKind: 'Chapter', + startDate: '2026-08-10', + targetDate: '2026-08-12', + )); + } + + public function test_it_rejects_invalid_dates(): void + { + $user = $this->user(); + $setRepository = new FakeSetRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Bible', + creator: $user, + )); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('startDate must be a valid date'); + + (new CreateSchedule( + $setRepository, + new FakeElementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + elementKind: 'chapter', + startDate: '2026-02-30', + targetDate: '2026-08-12', + )); + } + + public function test_it_rejects_a_target_before_the_start(): void + { + $user = $this->user(); + $setRepository = new FakeSetRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Bible', + creator: $user, + )); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'targetDate must not be before startDate', + ); + + (new CreateSchedule( + $setRepository, + new FakeElementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + elementKind: 'chapter', + startDate: '2026-08-12', + targetDate: '2026-08-10', + )); + } + + private function user(): User + { + return new User( + id: 7, + email: new EmailAddress('reader@example.com'), + passwordHash: 'hashed-password', + ); + } +} From 98ae9bf088f60370009b71065df63c69ffa42aa6 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 10 Aug 2026 20:05:53 +0300 Subject: [PATCH 02/25] add set scheduling api --- .../Http/Controllers/ScheduleController.php | 198 ++++++++++++++++ backend/app/Providers/AppServiceProvider.php | 6 + .../Schedule/CreateScheduleAssignmentDto.php | 15 ++ backend/app/Schedule/CreateScheduleDto.php | 22 ++ .../Schedule/EloquentScheduleRepository.php | 115 ++++++++++ backend/app/Schedule/Schedule.php | 61 +++++ backend/app/Schedule/ScheduleAssignment.php | 36 +++ .../app/Schedule/ScheduleAssignmentModel.php | 45 ++++ backend/app/Schedule/ScheduleModel.php | 46 ++++ backend/app/Schedule/ScheduleRepository.php | 17 ++ .../CreateSchedule/CreateSchedule.php | 212 ++++++++++++++++++ .../CreateSchedule/CreateScheduleRequest.php | 16 ++ .../UseCases/GetSchedule/GetSchedule.php | 30 +++ .../GetSchedule/GetScheduleRequest.php | 13 ++ .../UseCases/ListSchedules/ListSchedules.php | 22 ++ backend/app/Shared/Http/RequestInput.php | 7 + ...26_08_10_000000_create_schedules_table.php | 30 +++ ...0001_create_schedule_assignments_table.php | 38 ++++ backend/routes/api.php | 5 + .../Feature/Schedule/ScheduleEndpointTest.php | 2 +- 20 files changed, 935 insertions(+), 1 deletion(-) create mode 100644 backend/app/Http/Controllers/ScheduleController.php create mode 100644 backend/app/Schedule/CreateScheduleAssignmentDto.php create mode 100644 backend/app/Schedule/CreateScheduleDto.php create mode 100644 backend/app/Schedule/EloquentScheduleRepository.php create mode 100644 backend/app/Schedule/Schedule.php create mode 100644 backend/app/Schedule/ScheduleAssignment.php create mode 100644 backend/app/Schedule/ScheduleAssignmentModel.php create mode 100644 backend/app/Schedule/ScheduleModel.php create mode 100644 backend/app/Schedule/ScheduleRepository.php create mode 100644 backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php create mode 100644 backend/app/Schedule/UseCases/CreateSchedule/CreateScheduleRequest.php create mode 100644 backend/app/Schedule/UseCases/GetSchedule/GetSchedule.php create mode 100644 backend/app/Schedule/UseCases/GetSchedule/GetScheduleRequest.php create mode 100644 backend/app/Schedule/UseCases/ListSchedules/ListSchedules.php create mode 100644 backend/database/migrations/2026_08_10_000000_create_schedules_table.php create mode 100644 backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php diff --git a/backend/app/Http/Controllers/ScheduleController.php b/backend/app/Http/Controllers/ScheduleController.php new file mode 100644 index 0000000..3ff4a39 --- /dev/null +++ b/backend/app/Http/Controllers/ScheduleController.php @@ -0,0 +1,198 @@ +user($request); + + try { + $schedule = $this->createSchedule->execute( + new CreateScheduleRequest( + user: $user, + setId: $input->integer('setId'), + elementKind: $input->string('elementKind'), + startDate: $input->string('startDate'), + targetDate: $input->string('targetDate'), + ), + ); + } catch (BadRequestException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 400, + ); + } catch (NotFoundException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 404, + ); + } + + return new JsonResponse( + ['schedule' => $this->detailPayload($schedule)], + 201, + ); + } + + public function index(Request $request): JsonResponse + { + $schedules = array_map( + function (Schedule $schedule): array { + return $this->summaryPayload($schedule); + }, + $this->listSchedules->execute($this->user($request)), + ); + + return new JsonResponse(['schedules' => $schedules]); + } + + public function show(Request $request, int $scheduleId): JsonResponse + { + try { + $schedule = $this->getSchedule->execute( + new GetScheduleRequest( + scheduleId: $scheduleId, + user: $this->user($request), + ), + ); + } catch (NotFoundException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 404, + ); + } + + return new JsonResponse([ + 'schedule' => $this->detailPayload($schedule), + ]); + } + + /** + * @return array + */ + private function summaryPayload(Schedule $schedule): array + { + $set = $schedule->getSet(); + + return [ + 'id' => $schedule->getId(), + 'set' => [ + 'id' => $set->getId(), + 'name' => $set->getName(), + ], + 'elementKind' => $schedule->getElementKind(), + 'startDate' => $schedule->getStartDate()->format('Y-m-d'), + 'targetDate' => $schedule->getTargetDate()->format('Y-m-d'), + 'assignmentCount' => count($schedule->getAssignments()), + ]; + } + + /** + * @return array + */ + private function detailPayload(Schedule $schedule): array + { + $payload = $this->summaryPayload($schedule); + $payload['days'] = $this->dayPayloads($schedule); + + return $payload; + } + + /** + * @return list}> + */ + private function dayPayloads(Schedule $schedule): array + { + $assignmentsByDate = []; + foreach ($schedule->getAssignments() as $assignment) { + $date = $assignment->getScheduledDate()->format('Y-m-d'); + $assignmentsByDate[$date][] = $this->assignmentPayload( + $assignment, + ); + } + + $days = []; + $date = $schedule->getStartDate(); + while ($date <= $schedule->getTargetDate()) { + $formattedDate = $date->format('Y-m-d'); + $days[] = [ + 'date' => $formattedDate, + 'assignments' => $assignmentsByDate[$formattedDate] ?? [], + ]; + $date = $date->modify('+1 day'); + } + + return $days; + } + + /** + * @return array{element: array{ + * id: int, + * name: string, + * kind: string, + * path: list + * }} + */ + private function assignmentPayload( + ScheduleAssignment $assignment, + ): array { + $element = $assignment->getElement(); + + return [ + 'element' => [ + 'id' => $element->getId(), + 'name' => $element->getName(), + 'kind' => $element->getKind(), + 'path' => $this->elementPath($element), + ], + ]; + } + + /** + * @return list + */ + private function elementPath(Element $element): array + { + $path = []; + $currentElement = $element; + + while ($currentElement !== null) { + array_unshift($path, $currentElement->getName()); + $currentElement = $currentElement->getParentElement(); + } + + return $path; + } + + private function user(Request $request): User + { + /** @var User $user */ + $user = $request->attributes->get('user'); + + return $user; + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 8d883fe..d0d019d 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -20,6 +20,8 @@ use App\Element\ElementRepository; use App\Element\EloquentElementRepository; use App\Set\EloquentSetRepository; use App\Set\SetRepository; +use App\Schedule\EloquentScheduleRepository; +use App\Schedule\ScheduleRepository; use App\User\EloquentUserRepository; use App\User\UserRepository; use Carbon\CarbonImmutable; @@ -57,6 +59,10 @@ class AppServiceProvider extends ServiceProvider ElementRepository::class, EloquentElementRepository::class, ); + $this->app->bind( + ScheduleRepository::class, + EloquentScheduleRepository::class, + ); $this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class); $this->app->bind(TokenGenerator::class, RandomTokenGenerator::class); $this->app->bind(Clock::class, SystemClock::class); diff --git a/backend/app/Schedule/CreateScheduleAssignmentDto.php b/backend/app/Schedule/CreateScheduleAssignmentDto.php new file mode 100644 index 0000000..682438c --- /dev/null +++ b/backend/app/Schedule/CreateScheduleAssignmentDto.php @@ -0,0 +1,15 @@ + $assignments + */ + public function __construct( + public User $user, + public Set $set, + public string $elementKind, + public DateTimeImmutable $startDate, + public DateTimeImmutable $targetDate, + public array $assignments, + ) {} +} diff --git a/backend/app/Schedule/EloquentScheduleRepository.php b/backend/app/Schedule/EloquentScheduleRepository.php new file mode 100644 index 0000000..f23cb30 --- /dev/null +++ b/backend/app/Schedule/EloquentScheduleRepository.php @@ -0,0 +1,115 @@ + $dto->user->getId(), + 'set_id' => $dto->set->getId(), + 'element_kind' => $dto->elementKind, + 'start_date' => $dto->startDate->format('Y-m-d'), + 'target_date' => $dto->targetDate->format('Y-m-d'), + ]); + + foreach ($dto->assignments as $assignmentDto) { + ScheduleAssignmentModel::create([ + 'schedule_id' => $model->id, + 'element_id' => $assignmentDto->element->getId(), + 'scheduled_date' => $assignmentDto->scheduledDate + ->format('Y-m-d'), + 'position' => $assignmentDto->position, + ]); + } + + return $this->toDomain($model, $dto->user); + }); + } + + public function findForUser(int $id, User $user): ?Schedule + { + $model = ScheduleModel::query() + ->where('id', $id) + ->where('user_id', $user->getId()) + ->first(); + + return $model === null ? null : $this->toDomain($model, $user); + } + + public function findAllForUser(User $user): array + { + $models = ScheduleModel::query() + ->where('user_id', $user->getId()) + ->orderByDesc('id') + ->get(); + $schedules = []; + + foreach ($models as $model) { + $schedules[] = $this->toDomain($model, $user); + } + + return $schedules; + } + + private function toDomain(ScheduleModel $model, User $user): Schedule + { + $set = $this->setRepository->find($model->set_id); + if ($set === null) { + throw new RuntimeException('schedule set not found'); + } + + $assignmentModels = ScheduleAssignmentModel::query() + ->where('schedule_id', $model->id) + ->orderBy('position') + ->orderBy('id') + ->get(); + $assignments = []; + + foreach ($assignmentModels as $assignmentModel) { + $element = $this->elementRepository->find( + $assignmentModel->element_id, + ); + if ($element === null) { + throw new RuntimeException('schedule element not found'); + } + + $assignments[] = new ScheduleAssignment( + id: $assignmentModel->id, + element: $element, + scheduledDate: $this->date($assignmentModel->scheduled_date), + position: $assignmentModel->position, + ); + } + + return new Schedule( + id: $model->id, + user: $user, + set: $set, + elementKind: $model->element_kind, + startDate: $this->date($model->start_date), + targetDate: $this->date($model->target_date), + assignments: $assignments, + ); + } + + private function date(string $value): DateTimeImmutable + { + return new DateTimeImmutable($value, new DateTimeZone('UTC')); + } +} diff --git a/backend/app/Schedule/Schedule.php b/backend/app/Schedule/Schedule.php new file mode 100644 index 0000000..ecb3cfa --- /dev/null +++ b/backend/app/Schedule/Schedule.php @@ -0,0 +1,61 @@ + $assignments + */ + public function __construct( + private int $id, + private User $user, + private Set $set, + private string $elementKind, + private DateTimeImmutable $startDate, + private DateTimeImmutable $targetDate, + private array $assignments, + ) {} + + public function getId(): int + { + return $this->id; + } + + public function getUser(): User + { + return $this->user; + } + + public function getSet(): Set + { + return $this->set; + } + + public function getElementKind(): string + { + return $this->elementKind; + } + + public function getStartDate(): DateTimeImmutable + { + return $this->startDate; + } + + public function getTargetDate(): DateTimeImmutable + { + return $this->targetDate; + } + + /** + * @return list + */ + public function getAssignments(): array + { + return $this->assignments; + } +} diff --git a/backend/app/Schedule/ScheduleAssignment.php b/backend/app/Schedule/ScheduleAssignment.php new file mode 100644 index 0000000..97c67a3 --- /dev/null +++ b/backend/app/Schedule/ScheduleAssignment.php @@ -0,0 +1,36 @@ +id; + } + + public function getElement(): Element + { + return $this->element; + } + + public function getScheduledDate(): DateTimeImmutable + { + return $this->scheduledDate; + } + + public function getPosition(): int + { + return $this->position; + } +} diff --git a/backend/app/Schedule/ScheduleAssignmentModel.php b/backend/app/Schedule/ScheduleAssignmentModel.php new file mode 100644 index 0000000..782b906 --- /dev/null +++ b/backend/app/Schedule/ScheduleAssignmentModel.php @@ -0,0 +1,45 @@ +|ScheduleAssignmentModel newModelQuery() + * @method static Builder|ScheduleAssignmentModel newQuery() + * @method static Builder|ScheduleAssignmentModel query() + * + * @mixin \Eloquent + */ +#[Fillable([ + 'schedule_id', + 'element_id', + 'scheduled_date', + 'position', +])] +class ScheduleAssignmentModel extends Model +{ + protected $table = 'schedule_assignments'; + + public $timestamps = false; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'schedule_id' => 'integer', + 'element_id' => 'integer', + 'position' => 'integer', + ]; + } +} diff --git a/backend/app/Schedule/ScheduleModel.php b/backend/app/Schedule/ScheduleModel.php new file mode 100644 index 0000000..6d0132d --- /dev/null +++ b/backend/app/Schedule/ScheduleModel.php @@ -0,0 +1,46 @@ +|ScheduleModel newModelQuery() + * @method static Builder|ScheduleModel newQuery() + * @method static Builder|ScheduleModel query() + * + * @mixin \Eloquent + */ +#[Fillable([ + 'user_id', + 'set_id', + 'element_kind', + 'start_date', + 'target_date', +])] +class ScheduleModel extends Model +{ + protected $table = 'schedules'; + + public $timestamps = false; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'user_id' => 'integer', + 'set_id' => 'integer', + ]; + } +} diff --git a/backend/app/Schedule/ScheduleRepository.php b/backend/app/Schedule/ScheduleRepository.php new file mode 100644 index 0000000..bd02515 --- /dev/null +++ b/backend/app/Schedule/ScheduleRepository.php @@ -0,0 +1,17 @@ + + */ + public function findAllForUser(User $user): array; +} diff --git a/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php b/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php new file mode 100644 index 0000000..c41b19c --- /dev/null +++ b/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php @@ -0,0 +1,212 @@ +setId === null || $request->setId < 1) { + throw new BadRequestException('setId is required'); + } + + $set = $this->setRepository->find($request->setId); + if ($set === null) { + throw new NotFoundException('set not found'); + } + + if ($request->elementKind === null || $request->elementKind === '') { + throw new BadRequestException('elementKind is required'); + } + + $startDate = $this->parseDate($request->startDate, 'startDate'); + $targetDate = $this->parseDate($request->targetDate, 'targetDate'); + if ($targetDate < $startDate) { + throw new BadRequestException( + 'targetDate must not be before startDate', + ); + } + + $elements = array_values(array_filter( + $this->orderedElements($set), + function (Element $element) use ($request): bool { + return $element->getKind() === $request->elementKind; + }, + )); + if ($elements === []) { + throw new BadRequestException( + 'elementKind is not available for set', + ); + } + + $assignments = $this->assignments( + elements: $elements, + startDate: $startDate, + targetDate: $targetDate, + ); + + return $this->scheduleRepository->create(new CreateScheduleDto( + user: $request->user, + set: $set, + elementKind: $request->elementKind, + startDate: $startDate, + targetDate: $targetDate, + assignments: $assignments, + )); + } + + /** + * @return list + */ + private function orderedElements(Set $set): array + { + $childrenByParentId = []; + + foreach ($this->elementRepository->findBySet($set) as $element) { + $parentId = $element->getParentElement()?->getId() ?? 0; + $childrenByParentId[$parentId][] = $element; + } + + foreach ($childrenByParentId as &$children) { + usort($children, function (Element $first, Element $second): int { + $positionComparison = $first->getPosition() + <=> $second->getPosition(); + if ($positionComparison !== 0) { + return $positionComparison; + } + + return $first->getId() <=> $second->getId(); + }); + } + unset($children); + + $orderedElements = []; + $this->appendChildren( + parentId: 0, + childrenByParentId: $childrenByParentId, + orderedElements: $orderedElements, + ); + + return $orderedElements; + } + + /** + * @param array> $childrenByParentId + * @param list $orderedElements + */ + private function appendChildren( + int $parentId, + array $childrenByParentId, + array &$orderedElements, + ): void { + foreach ($childrenByParentId[$parentId] ?? [] as $element) { + $orderedElements[] = $element; + $this->appendChildren( + parentId: $element->getId(), + childrenByParentId: $childrenByParentId, + orderedElements: $orderedElements, + ); + } + } + + /** + * @param list $elements + * @return list + */ + private function assignments( + array $elements, + DateTimeImmutable $startDate, + DateTimeImmutable $targetDate, + ): array { + $differenceInDays = $startDate->diff($targetDate)->days; + $dayCount = $differenceInDays + 1; + $elementCount = count($elements); + $assignments = []; + + foreach ($elements as $index => $element) { + $dayIndex = $this->dayIndex( + elementIndex: $index, + elementCount: $elementCount, + dayCount: $dayCount, + ); + $assignments[] = new CreateScheduleAssignmentDto( + element: $element, + scheduledDate: $startDate->modify("+{$dayIndex} days"), + position: $index + 1, + ); + } + + return $assignments; + } + + private function dayIndex( + int $elementIndex, + int $elementCount, + int $dayCount, + ): int { + if ($elementCount === 1 || $dayCount === 1) { + return 0; + } + + if ($elementCount < $dayCount) { + $scaledIndex = $elementIndex * ($dayCount - 1) + / ($elementCount - 1); + + return (int) floor($scaledIndex + 0.5); + } + + return intdiv($elementIndex * $dayCount, $elementCount); + } + + /** + * @throws BadRequestException + */ + private function parseDate(?string $value, string $field): DateTimeImmutable + { + if ($value === null || $value === '') { + throw new BadRequestException("{$field} 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( + "{$field} must be a valid date in YYYY-MM-DD format", + ); + } + + return $date; + } +} diff --git a/backend/app/Schedule/UseCases/CreateSchedule/CreateScheduleRequest.php b/backend/app/Schedule/UseCases/CreateSchedule/CreateScheduleRequest.php new file mode 100644 index 0000000..d75ab71 --- /dev/null +++ b/backend/app/Schedule/UseCases/CreateSchedule/CreateScheduleRequest.php @@ -0,0 +1,16 @@ +scheduleRepository->findForUser( + $request->scheduleId, + $request->user, + ); + if ($schedule === null) { + throw new NotFoundException('schedule not found'); + } + + return $schedule; + } +} diff --git a/backend/app/Schedule/UseCases/GetSchedule/GetScheduleRequest.php b/backend/app/Schedule/UseCases/GetSchedule/GetScheduleRequest.php new file mode 100644 index 0000000..c737d1e --- /dev/null +++ b/backend/app/Schedule/UseCases/GetSchedule/GetScheduleRequest.php @@ -0,0 +1,13 @@ + + */ + public function execute(User $user): array + { + return $this->scheduleRepository->findAllForUser($user); + } +} diff --git a/backend/app/Shared/Http/RequestInput.php b/backend/app/Shared/Http/RequestInput.php index 6dffb92..322817a 100644 --- a/backend/app/Shared/Http/RequestInput.php +++ b/backend/app/Shared/Http/RequestInput.php @@ -20,4 +20,11 @@ class RequestInput return null; } + + public function integer(string $key): ?int + { + $value = $this->request->input($key); + + return is_int($value) ? $value : null; + } } diff --git a/backend/database/migrations/2026_08_10_000000_create_schedules_table.php b/backend/database/migrations/2026_08_10_000000_create_schedules_table.php new file mode 100644 index 0000000..cfe3028 --- /dev/null +++ b/backend/database/migrations/2026_08_10_000000_create_schedules_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id') + ->constrained('users') + ->restrictOnDelete(); + $table->foreignId('set_id') + ->constrained('sets') + ->restrictOnDelete(); + $table->string('element_kind'); + $table->date('start_date'); + $table->date('target_date'); + $table->index(['user_id', 'id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('schedules'); + } +}; diff --git a/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php b/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php new file mode 100644 index 0000000..88c05b3 --- /dev/null +++ b/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('schedule_id') + ->constrained('schedules') + ->cascadeOnDelete(); + $table->foreignId('element_id') + ->constrained('elements') + ->restrictOnDelete(); + $table->date('scheduled_date'); + $table->unsignedInteger('position'); + $table->unique(['schedule_id', 'element_id']); + $table->unique(['schedule_id', 'position']); + $table->index([ + 'schedule_id', + 'scheduled_date', + 'position', + ]); + }, + ); + } + + public function down(): void + { + Schema::dropIfExists('schedule_assignments'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 9b70efe..7f984c5 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -2,6 +2,7 @@ use App\Http\Controllers\AuthController; use App\Http\Controllers\SetController; +use App\Http\Controllers\ScheduleController; use App\Http\Middleware\AuthMiddleware; use Illuminate\Support\Facades\Route; @@ -14,5 +15,9 @@ Route::middleware(AuthMiddleware::class)->group(function (): void { Route::get('/sets', [SetController::class, 'index']); Route::get('/sets/{setId}', [SetController::class, 'show']) ->whereNumber('setId'); + Route::post('/schedules', [ScheduleController::class, 'store']); + Route::get('/schedules', [ScheduleController::class, 'index']); + Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show']) + ->whereNumber('scheduleId'); Route::post('/logout', [AuthController::class, 'logout']); }); diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php index 731a221..6b9a679 100644 --- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -284,7 +284,7 @@ class ScheduleEndpointTest extends TestCase token: $token, user: $user, createdAt: $createdAt, - expiresAt: $createdAt->modify('+7 days'), + expiresAt: $createdAt->modify('+10 years'), )); } From b812a4b21b6acbe7ab8b4ed11c6f01c768474f27 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 10 Aug 2026 20:06:15 +0300 Subject: [PATCH 03/25] stabilize set endpoint sessions --- backend/tests/Feature/Set/GetSetLayoutEndpointTest.php | 2 +- backend/tests/Feature/Set/ListSetsEndpointTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php b/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php index 074f7f5..3a71638 100644 --- a/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php +++ b/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php @@ -157,7 +157,7 @@ class GetSetLayoutEndpointTest extends TestCase token: 'valid-token', user: $user, createdAt: $createdAt, - expiresAt: $createdAt->modify('+7 days'), + expiresAt: $createdAt->modify('+10 years'), )); } diff --git a/backend/tests/Feature/Set/ListSetsEndpointTest.php b/backend/tests/Feature/Set/ListSetsEndpointTest.php index 6f8085b..e37c464 100644 --- a/backend/tests/Feature/Set/ListSetsEndpointTest.php +++ b/backend/tests/Feature/Set/ListSetsEndpointTest.php @@ -96,7 +96,7 @@ class ListSetsEndpointTest extends TestCase token: 'valid-token', user: $user, createdAt: $createdAt, - expiresAt: $createdAt->modify('+7 days'), + expiresAt: $createdAt->modify('+10 years'), )); } } From b7fb594c264cff020acc6357e17e7c03fd0ffe0a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 10 Aug 2026 20:11:09 +0300 Subject: [PATCH 04/25] test set scheduling ui --- .../website/cypress/e2e/confirm-email.cy.ts | 4 + frontend/website/cypress/e2e/guest-auth.cy.ts | 8 + .../website/cypress/e2e/session-auth.cy.ts | 4 + .../website/cypress/e2e/set-scheduling.cy.ts | 242 ++++++++++++++++++ .../website/cypress/e2e/sets-dashboard.cy.ts | 4 + 5 files changed, 262 insertions(+) create mode 100644 frontend/website/cypress/e2e/set-scheduling.cy.ts diff --git a/frontend/website/cypress/e2e/confirm-email.cy.ts b/frontend/website/cypress/e2e/confirm-email.cy.ts index deabca6..2ef0274 100644 --- a/frontend/website/cypress/e2e/confirm-email.cy.ts +++ b/frontend/website/cypress/e2e/confirm-email.cy.ts @@ -13,6 +13,10 @@ describe('email confirmation', () => { statusCode: 200, body: { sets: [] }, }) + cy.intercept('GET', '**/api/schedules', { + statusCode: 200, + body: { schedules: [] }, + }) }) it('chooses a password, confirms the account, and opens the dashboard', () => { diff --git a/frontend/website/cypress/e2e/guest-auth.cy.ts b/frontend/website/cypress/e2e/guest-auth.cy.ts index 75c441c..00d0d19 100644 --- a/frontend/website/cypress/e2e/guest-auth.cy.ts +++ b/frontend/website/cypress/e2e/guest-auth.cy.ts @@ -31,6 +31,14 @@ describe('guest authentication pages', () => { cy.location('search').should('include', 'redirect=/sets/41') }) + it('redirects guests away from protected schedule routes', () => { + cy.visit('/sets/41/schedules/new') + cy.location('pathname').should('equal', '/login') + + cy.visit('/schedules/73') + cy.location('pathname').should('equal', '/login') + }) + it('shows the login form and links to signup', () => { cy.visit('/login') diff --git a/frontend/website/cypress/e2e/session-auth.cy.ts b/frontend/website/cypress/e2e/session-auth.cy.ts index defb942..eb88490 100644 --- a/frontend/website/cypress/e2e/session-auth.cy.ts +++ b/frontend/website/cypress/e2e/session-auth.cy.ts @@ -43,6 +43,10 @@ describe('session authentication', () => { statusCode: 200, body: { sets: [] }, }) + cy.intercept('GET', '**/api/schedules', { + statusCode: 200, + body: { schedules: [] }, + }) }) it('restores an authenticated session on a protected route', () => { diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts new file mode 100644 index 0000000..7967845 --- /dev/null +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -0,0 +1,242 @@ +const authenticatedUser = { + id: 7, + email: 'user@example.com', +} + +const bibleLayout = { + set: { id: 41, name: 'Bible' }, + elements: [ + { + id: 1, + name: 'Genesis', + kind: 'book', + children: [ + { + id: 3, + name: 'Creation', + kind: 'portion', + children: [ + { + id: 4, + name: 'Chapter 1', + kind: 'chapter', + children: [], + }, + ], + }, + ], + }, + { + id: 2, + name: 'Exodus', + kind: 'book', + children: [ + { + id: 5, + name: 'Chapter 1', + kind: 'chapter', + children: [], + }, + ], + }, + ], +} + +const scheduleDetail = { + schedule: { + id: 73, + set: { id: 41, name: 'Bible' }, + elementKind: 'chapter', + startDate: '2026-08-10', + targetDate: '2026-08-12', + assignmentCount: 2, + days: [ + { + date: '2026-08-10', + assignments: [ + { + element: { + id: 4, + name: 'Chapter 1', + kind: 'chapter', + path: ['Genesis', 'Creation', 'Chapter 1'], + }, + }, + ], + }, + { date: '2026-08-11', assignments: [] }, + { + date: '2026-08-12', + assignments: [ + { + element: { + id: 5, + name: 'Chapter 1', + kind: 'chapter', + path: ['Exodus', 'Chapter 1'], + }, + }, + ], + }, + ], + }, +} + +function interceptAuthenticatedUser(): void { + cy.intercept('GET', '**/api/me', { + statusCode: 200, + body: { user: authenticatedUser }, + }).as('me') +} + +describe('set scheduling', () => { + beforeEach(() => { + interceptAuthenticatedUser() + }) + + it('creates a schedule from a set and shows every day', () => { + cy.intercept('GET', '**/api/sets/41', { + statusCode: 200, + body: bibleLayout, + }).as('layout') + cy.intercept('POST', '**/api/schedules', (request) => { + expect(request.headers.accept).to.equal('application/json') + expect(request.body).to.deep.equal({ + setId: 41, + elementKind: 'chapter', + startDate: '2026-08-10', + targetDate: '2026-08-12', + }) + 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)).to.deep.equal([ + 'Choose a level', + 'Book (2)', + 'Portion (1)', + 'Chapter (2)', + ]) + }) + cy.get('#schedule-level').select('chapter') + 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') + .and('contain.text', 'Chapter') + 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 / Chapter 1', + ) + }) + + 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('chapter') + 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.contains('2 chapters 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('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: { id: 41, name: 'Bible' }, + elementKind: 'chapter', + startDate: '2026-08-10', + targetDate: '2026-08-12', + assignmentCount: 2, + }, + ], + }, + }) + }).as('schedules') + + 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', 'Chapter') + .and('contain.text', '2 assignments') + cy.contains('a', 'Bible').should('have.attr', 'href', '/schedules/73') + }) +}) diff --git a/frontend/website/cypress/e2e/sets-dashboard.cy.ts b/frontend/website/cypress/e2e/sets-dashboard.cy.ts index 4b6fc2d..6cff9ed 100644 --- a/frontend/website/cypress/e2e/sets-dashboard.cy.ts +++ b/frontend/website/cypress/e2e/sets-dashboard.cy.ts @@ -13,6 +13,10 @@ function interceptAuthenticatedUser(): void { describe('sets dashboard', () => { beforeEach(() => { interceptAuthenticatedUser() + cy.intercept('GET', '**/api/schedules', { + statusCode: 200, + body: { schedules: [] }, + }) }) it('shows every available set as a detail link', () => { From 7e9e93f8d0ceca31757c7f9cdd256d6024c8c86b Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 10 Aug 2026 20:16:44 +0300 Subject: [PATCH 05/25] add set scheduling ui --- .../website/cypress/e2e/set-scheduling.cy.ts | 9 +- frontend/website/src/router/index.ts | 16 + frontend/website/src/stores/schedules.ts | 220 ++++++++++ frontend/website/src/styles/main.css | 3 +- .../website/src/views/CreateScheduleView.vue | 405 ++++++++++++++++++ frontend/website/src/views/DashboardView.vue | 181 +++++++- .../website/src/views/ScheduleDetailView.vue | 323 ++++++++++++++ frontend/website/src/views/SetLayoutView.vue | 33 ++ 8 files changed, 1182 insertions(+), 8 deletions(-) create mode 100644 frontend/website/src/stores/schedules.ts create mode 100644 frontend/website/src/views/CreateScheduleView.vue create mode 100644 frontend/website/src/views/ScheduleDetailView.vue diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index 7967845..824024d 100644 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -120,12 +120,9 @@ 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)).to.deep.equal([ - 'Choose a level', - 'Book (2)', - 'Portion (1)', - 'Chapter (2)', - ]) + expect([...$options].map((option) => option.textContent?.trim())).to.deep.equal( + ['Choose a level', 'Book (2)', 'Portion (1)', 'Chapter (2)'], + ) }) cy.get('#schedule-level').select('chapter') cy.get('#schedule-start-date').type('2026-08-10') diff --git a/frontend/website/src/router/index.ts b/frontend/website/src/router/index.ts index 08f1ad0..d4fad22 100644 --- a/frontend/website/src/router/index.ts +++ b/frontend/website/src/router/index.ts @@ -71,6 +71,22 @@ const router = createRouter({ requiresAuth: true, }, }, + { + path: '/sets/:setId(\\d+)/schedules/new', + name: 'schedule-create', + component: () => import('@/views/CreateScheduleView.vue'), + meta: { + requiresAuth: true, + }, + }, + { + path: '/schedules/:scheduleId(\\d+)', + name: 'schedule-detail', + component: () => import('@/views/ScheduleDetailView.vue'), + meta: { + requiresAuth: true, + }, + }, ], }) diff --git a/frontend/website/src/stores/schedules.ts b/frontend/website/src/stores/schedules.ts new file mode 100644 index 0000000..79f27a1 --- /dev/null +++ b/frontend/website/src/stores/schedules.ts @@ -0,0 +1,220 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { z } from 'zod' + +import { API_BASE } from '@/utils/apiBase' + +const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/) + +export const scheduleSummarySchema = z.object({ + id: z.number().int().positive(), + set: z.object({ + id: z.number().int().positive(), + name: z.string().min(1), + }), + elementKind: z.string().min(1), + startDate: isoDateSchema, + targetDate: isoDateSchema, + assignmentCount: z.number().int().nonnegative(), +}) + +const scheduleAssignmentSchema = z.object({ + element: z.object({ + id: z.number().int().positive(), + name: z.string().min(1), + kind: z.string().min(1), + path: z.array(z.string().min(1)).min(1), + }), +}) + +export const scheduleDetailSchema = scheduleSummarySchema.extend({ + days: z.array( + z.object({ + date: isoDateSchema, + assignments: z.array(scheduleAssignmentSchema), + }), + ), +}) + +const schedulesResponseSchema = z.object({ + schedules: z.array(scheduleSummarySchema), +}) + +const scheduleResponseSchema = z.object({ + schedule: scheduleDetailSchema, +}) + +const errorResponseSchema = z.object({ + error: z.string().min(1), +}) + +export type ScheduleSummary = z.infer +export type ScheduleDetail = z.infer +export type CreateScheduleInput = { + setId: number + elementKind: string + startDate: string + targetDate: string +} + +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." + +export const useSchedulesStore = defineStore('schedules', () => { + const schedules = ref([]) + const listLoading = ref(false) + const listError = ref(null) + const activeSchedule = ref(null) + const detailLoading = ref(false) + const detailError = ref(null) + const detailNotFound = ref(false) + const creating = ref(false) + const createError = ref(null) + let activeDetailRequestId = 0 + + async function fetchSchedules(): Promise { + listLoading.value = true + listError.value = null + + try { + const response = await fetch(`${API_BASE}/api/schedules`, { + method: 'GET', + credentials: 'include', + headers: { + Accept: 'application/json', + }, + }) + + if (response.status !== 200) { + schedules.value = [] + listError.value = LIST_ERROR + + return false + } + + const responseBody: unknown = await response.json() + schedules.value = schedulesResponseSchema.parse(responseBody).schedules + + return true + } catch { + schedules.value = [] + listError.value = LIST_ERROR + + return false + } finally { + listLoading.value = false + } + } + + async function fetchSchedule(scheduleId: number): Promise { + const requestId = ++activeDetailRequestId + activeSchedule.value = null + detailLoading.value = true + detailError.value = null + detailNotFound.value = false + + try { + const response = await fetch(`${API_BASE}/api/schedules/${scheduleId}`, { + method: 'GET', + credentials: 'include', + headers: { + Accept: 'application/json', + }, + }) + + if (requestId !== activeDetailRequestId) { + return false + } + + if (response.status === 404) { + detailNotFound.value = true + + return false + } + + if (response.status !== 200) { + detailError.value = DETAIL_ERROR + + return false + } + + const responseBody: unknown = await response.json() + if (requestId !== activeDetailRequestId) { + return false + } + + const parsedSchedule = scheduleResponseSchema.parse(responseBody).schedule + if (parsedSchedule.id !== scheduleId) { + throw new Error('schedule response did not match requested schedule') + } + activeSchedule.value = parsedSchedule + + return true + } catch { + if (requestId === activeDetailRequestId) { + activeSchedule.value = null + detailError.value = DETAIL_ERROR + } + + return false + } finally { + if (requestId === activeDetailRequestId) { + detailLoading.value = false + } + } + } + + async function createSchedule(input: CreateScheduleInput): Promise { + creating.value = true + createError.value = null + + try { + const response = await fetch(`${API_BASE}/api/schedules`, { + method: 'POST', + credentials: 'include', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }) + const responseBody: unknown = await response.json() + + if (response.status !== 201) { + const parsedError = errorResponseSchema.safeParse(responseBody) + createError.value = parsedError.success ? parsedError.data.error : CREATE_ERROR + + return null + } + + const createdSchedule = scheduleResponseSchema.parse(responseBody).schedule + activeSchedule.value = createdSchedule + detailError.value = null + detailNotFound.value = false + + return createdSchedule + } catch { + createError.value = CREATE_ERROR + + return null + } finally { + creating.value = false + } + } + + return { + schedules, + listLoading, + listError, + activeSchedule, + detailLoading, + detailError, + detailNotFound, + creating, + createError, + fetchSchedules, + fetchSchedule, + createSchedule, + } +}) diff --git a/frontend/website/src/styles/main.css b/frontend/website/src/styles/main.css index a121333..fa7661f 100644 --- a/frontend/website/src/styles/main.css +++ b/frontend/website/src/styles/main.css @@ -31,7 +31,8 @@ body { } button, -input { +input, +select { font: inherit; } diff --git a/frontend/website/src/views/CreateScheduleView.vue b/frontend/website/src/views/CreateScheduleView.vue new file mode 100644 index 0000000..84cc585 --- /dev/null +++ b/frontend/website/src/views/CreateScheduleView.vue @@ -0,0 +1,405 @@ + + + + + diff --git a/frontend/website/src/views/DashboardView.vue b/frontend/website/src/views/DashboardView.vue index 9c77bf4..a2ccaf5 100644 --- a/frontend/website/src/views/DashboardView.vue +++ b/frontend/website/src/views/DashboardView.vue @@ -3,20 +3,89 @@ import { storeToRefs } from 'pinia' import { onMounted } from 'vue' import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue' +import { useSchedulesStore } from '@/stores/schedules' import { useSetsStore } from '@/stores/sets' const setsStore = useSetsStore() +const schedulesStore = useSchedulesStore() const { sets, loading, error } = storeToRefs(setsStore) +const { schedules, listLoading, listError } = storeToRefs(schedulesStore) onMounted(async () => { - await setsStore.fetchSets() + await Promise.all([setsStore.fetchSets(), schedulesStore.fetchSchedules()]) }) + +function humanizeKind(kind: string): string { + const words = kind.replaceAll(/[_-]+/g, ' ') + + return words.charAt(0).toUpperCase() + words.slice(1) +} + +function formatDate(value: string): string { + return new Intl.DateTimeFormat('en', { + dateStyle: 'medium', + timeZone: 'UTC', + }).format(new Date(`${value}T00:00:00Z`)) +}