diff --git a/ai/backend-context.md b/ai/backend-context.md index 9850c44..7fc2f71 100644 --- a/ai/backend-context.md +++ b/ai/backend-context.md @@ -69,6 +69,9 @@ intentionally unclaimed; the built-in health endpoint is `/up`. relevant authorization guard instead of duplicating all business branches. - Place endpoint tests under `tests/Feature//`, extend `Tests\TestCase`, and use `RefreshDatabase` when database state is involved. +- Do not keep tests that only assert an obsolete column or other removed + schema detail is absent. Such assertions may verify a removal while it is + being developed, but discard them afterward and retain behavioral coverage. - Development and runtime use PostgreSQL through the local Unix socket. PHPUnit uses SQLite `:memory:` as configured in `phpunit.xml`, so feature tests are self-contained and do not need the worktree stack. diff --git a/ai/frontend-context.md b/ai/frontend-context.md index a2acb1e..1d2997c 100644 --- a/ai/frontend-context.md +++ b/ai/frontend-context.md @@ -57,6 +57,9 @@ backend and does not proxy the frontend. - Keep application-wide resets and base styles in `src/styles/main.css`. Keep component and view styles scoped and match established Attainly visual patterns. +- Display user-authored set level kinds verbatim. Do not capitalize, replace + separators, pluralize, or apply `text-transform` to a kind value; pluralize + surrounding interface words such as "assignment" instead. - Inspect similar files before introducing a component, composable, store, route, or data-access pattern. diff --git a/ai/shared.md b/ai/shared.md index b696249..ed0a14a 100644 --- a/ai/shared.md +++ b/ai/shared.md @@ -9,9 +9,17 @@ these rules. - Attainly is in early development. - Attainly helps users break hierarchical goals into scheduled assignments, complete daily work, and track progress toward a target date. -- Schedules target whole sets. Users choose an element kind as the assignment +- Sets define explicit ordered element levels. Every depth has one kind, and + a kind can appear at only one depth within a set. Root elements use the + first level, and children use the level immediately after their parent. +- Schedules target whole sets. Users choose a set level as the assignment granularity. To schedule a subset, create a separate named set containing that subset. +- Schedules are immutable historical snapshots. Copy the set name and each + assignment's name, kind, and full path when creating a schedule. +- Persisted schedules must not reference their source set or selected level. + Persisted assignments must not reference their source elements. Later + source edits or deletions must not change an existing schedule. - Schedule recalculation must preserve completed work while redistributing unfinished assignments across the remaining dates. - Planned features in `README.md` are future ideas, not authorized scope. diff --git a/backend/app/Element/CreateElementDto.php b/backend/app/Element/CreateElementDto.php index 6f7df77..d8a9fdc 100644 --- a/backend/app/Element/CreateElementDto.php +++ b/backend/app/Element/CreateElementDto.php @@ -2,14 +2,13 @@ namespace App\Element; -use App\Set\Set; +use App\Set\SetLevel; final readonly class CreateElementDto { public function __construct( - public Set $set, public string $name, - public string $kind, + public SetLevel $level, public ?Element $parentElement, ) {} } diff --git a/backend/app/Element/Element.php b/backend/app/Element/Element.php index 5e4c6bd..5eb6fe8 100644 --- a/backend/app/Element/Element.php +++ b/backend/app/Element/Element.php @@ -3,14 +3,14 @@ namespace App\Element; use App\Set\Set; +use App\Set\SetLevel; final readonly class Element { public function __construct( private int $id, private string $name, - private string $kind, - private Set $set, + private SetLevel $level, private ?Element $parentElement, private int $position, ) {} @@ -27,12 +27,17 @@ final readonly class Element public function getKind(): string { - return $this->kind; + return $this->level->getKind(); + } + + public function getLevel(): SetLevel + { + return $this->level; } public function getSet(): Set { - return $this->set; + return $this->level->getSet(); } public function getParentElement(): ?Element diff --git a/backend/app/Element/ElementModel.php b/backend/app/Element/ElementModel.php index 6eb345d..5a228d7 100644 --- a/backend/app/Element/ElementModel.php +++ b/backend/app/Element/ElementModel.php @@ -8,9 +8,8 @@ use Illuminate\Database\Eloquent\Model; /** * @property int $id - * @property int $set_id + * @property int $set_level_id * @property string $name - * @property string $kind * @property int|null $parent_element_id * @property int $position * @@ -21,9 +20,8 @@ use Illuminate\Database\Eloquent\Model; * @mixin \Eloquent */ #[Fillable([ - 'set_id', + 'set_level_id', 'name', - 'kind', 'parent_element_id', 'position', ])] @@ -39,7 +37,7 @@ class ElementModel extends Model protected function casts(): array { return [ - 'set_id' => 'integer', + 'set_level_id' => 'integer', 'parent_element_id' => 'integer', 'position' => 'integer', ]; diff --git a/backend/app/Element/EloquentElementRepository.php b/backend/app/Element/EloquentElementRepository.php index 1f30e24..fd12d58 100644 --- a/backend/app/Element/EloquentElementRepository.php +++ b/backend/app/Element/EloquentElementRepository.php @@ -3,27 +3,27 @@ namespace App\Element; use App\Set\Set; -use App\Set\SetRepository; +use App\Set\SetLevel; +use App\Set\SetLevelRepository; use DomainException; use RuntimeException; class EloquentElementRepository implements ElementRepository { public function __construct( - private SetRepository $setRepository, + private SetLevelRepository $setLevelRepository, ) {} public function create(CreateElementDto $dto): Element { - $this->validateParentSet($dto); + $this->validateLevel($dto); $position = $this->nextPosition( - $dto->set, + $dto->level, $dto->parentElement, ); $model = ElementModel::create([ - 'set_id' => $dto->set->getId(), + 'set_level_id' => $dto->level->getId(), 'name' => $dto->name, - 'kind' => $dto->kind, 'parent_element_id' => $dto->parentElement?->getId(), 'position' => $position, ]); @@ -31,8 +31,7 @@ class EloquentElementRepository implements ElementRepository return new Element( id: $model->id, name: $model->name, - kind: $model->kind, - set: $dto->set, + level: $dto->level, parentElement: $dto->parentElement, position: $model->position, ); @@ -47,14 +46,27 @@ class EloquentElementRepository implements ElementRepository public function findBySet(Set $set): array { + $levelsById = []; + foreach ($this->setLevelRepository->findBySet($set) as $level) { + $levelsById[$level->getId()] = $level; + } + if ($levelsById === []) { + return []; + } + $models = ElementModel::query() - ->where('set_id', $set->getId()) + ->whereIn('set_level_id', array_keys($levelsById)) ->orderBy('id') ->get(); $elements = []; $elementsById = []; foreach ($models as $model) { + $level = $levelsById[$model->set_level_id] ?? null; + if ($level === null) { + throw new RuntimeException('element level not found'); + } + $parentElement = null; if ($model->parent_element_id !== null) { $parentElement = $elementsById[$model->parent_element_id] @@ -66,7 +78,7 @@ class EloquentElementRepository implements ElementRepository $element = $this->toDomainWithRelations( model: $model, - set: $set, + level: $level, parentElement: $parentElement, ); $elements[] = $element; @@ -78,8 +90,14 @@ class EloquentElementRepository implements ElementRepository public function findTopLevelBySet(Set $set): array { + $levels = $this->setLevelRepository->findBySet($set); + if ($levels === []) { + return []; + } + + $topLevel = $levels[0]; $models = ElementModel::query() - ->where('set_id', $set->getId()) + ->where('set_level_id', $topLevel->getId()) ->whereNull('parent_element_id') ->orderBy('position') ->orderBy('id') @@ -89,7 +107,7 @@ class EloquentElementRepository implements ElementRepository foreach ($models as $model) { $elements[] = $this->toDomainWithRelations( model: $model, - set: $set, + level: $topLevel, parentElement: null, ); } @@ -100,7 +118,6 @@ class EloquentElementRepository implements ElementRepository public function findByParentElement(Element $parentElement): array { $models = ElementModel::query() - ->where('set_id', $parentElement->getSet()->getId()) ->where('parent_element_id', $parentElement->getId()) ->orderBy('position') ->orderBy('id') @@ -108,9 +125,10 @@ class EloquentElementRepository implements ElementRepository $elements = []; foreach ($models as $model) { + $level = $this->findLevel($model->set_level_id); $elements[] = $this->toDomainWithRelations( model: $model, - set: $parentElement->getSet(), + level: $level, parentElement: $parentElement, ); } @@ -121,26 +139,44 @@ class EloquentElementRepository implements ElementRepository /** * @throws DomainException */ - private function validateParentSet(CreateElementDto $dto): void + private function validateLevel(CreateElementDto $dto): void { $parentElement = $dto->parentElement; if ($parentElement === null) { + if ($dto->level->getDepth() !== 0) { + throw new DomainException( + 'root element must use the first set level', + ); + } + return; } - if ($parentElement->getSet()->getId() !== $dto->set->getId()) { + if ( + $parentElement->getSet()->getId() + !== $dto->level->getSet()->getId() + ) { throw new DomainException( 'parent element must belong to the same set', ); } + + if ( + $dto->level->getDepth() + !== $parentElement->getLevel()->getDepth() + 1 + ) { + throw new DomainException( + 'child element must use the next set level', + ); + } } private function nextPosition( - Set $set, + SetLevel $level, ?Element $parentElement, ): int { $query = ElementModel::query() - ->where('set_id', $set->getId()); + ->where('set_level_id', $level->getId()); if ($parentElement === null) { $query->whereNull('parent_element_id'); } else { @@ -157,7 +193,7 @@ class EloquentElementRepository implements ElementRepository private function toDomain(ElementModel $model): Element { - $set = $this->findSet($model->set_id); + $level = $this->findLevel($model->set_level_id); $parentElement = null; if ($model->parent_element_id !== null) { @@ -169,33 +205,32 @@ class EloquentElementRepository implements ElementRepository return $this->toDomainWithRelations( model: $model, - set: $set, + level: $level, parentElement: $parentElement, ); } private function toDomainWithRelations( ElementModel $model, - Set $set, + SetLevel $level, ?Element $parentElement, ): Element { return new Element( id: $model->id, name: $model->name, - kind: $model->kind, - set: $set, + level: $level, parentElement: $parentElement, position: $model->position, ); } - private function findSet(int $id): Set + private function findLevel(int $id): SetLevel { - $set = $this->setRepository->find($id); - if ($set !== null) { - return $set; + $level = $this->setLevelRepository->find($id); + if ($level !== null) { + return $level; } - throw new RuntimeException('element set not found'); + throw new RuntimeException('element level not found'); } } diff --git a/backend/app/Http/Controllers/ScheduleController.php b/backend/app/Http/Controllers/ScheduleController.php new file mode 100644 index 0000000..cc8cd60 --- /dev/null +++ b/backend/app/Http/Controllers/ScheduleController.php @@ -0,0 +1,175 @@ +user($request); + + try { + $schedule = $this->createSchedule->execute( + new CreateScheduleRequest( + user: $user, + setId: $input->integer('setId'), + levelId: $input->integer('levelId'), + 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 + { + return [ + 'id' => $schedule->getId(), + 'set' => [ + 'name' => $schedule->getSetName(), + ], + '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{id: int, element: array{ + * name: string, + * kind: string, + * path: list + * }} + */ + private function assignmentPayload( + ScheduleAssignment $assignment, + ): array { + return [ + 'id' => $assignment->getId(), + 'element' => [ + 'name' => $assignment->getName(), + 'kind' => $assignment->getKind(), + 'path' => $assignment->getPath(), + ], + ]; + } + + private function user(Request $request): User + { + /** @var User $user */ + $user = $request->attributes->get('user'); + + return $user; + } +} diff --git a/backend/app/Http/Controllers/SetController.php b/backend/app/Http/Controllers/SetController.php index 024e83c..fe09b72 100644 --- a/backend/app/Http/Controllers/SetController.php +++ b/backend/app/Http/Controllers/SetController.php @@ -7,6 +7,7 @@ use App\Set\Set; use App\Set\UseCases\GetSetLayout\ElementLayoutNode; use App\Set\UseCases\GetSetLayout\GetSetLayout; use App\Set\UseCases\GetSetLayout\GetSetLayoutRequest; +use App\Set\UseCases\GetSetLayout\SetLayoutLevel; use App\Set\UseCases\ListSets\ListSets; use Illuminate\Http\JsonResponse; @@ -49,6 +50,19 @@ class SetController extends Controller 'id' => $set->getId(), 'name' => $set->getName(), ], + 'levels' => array_map( + function (SetLayoutLevel $layoutLevel): array { + $level = $layoutLevel->getLevel(); + + return [ + 'id' => $level->getId(), + 'kind' => $level->getKind(), + 'depth' => $level->getDepth(), + 'elementCount' => $layoutLevel->getElementCount(), + ]; + }, + $layout->getLevels(), + ), 'elements' => array_map( function (ElementLayoutNode $node): array { return $this->elementPayload($node); @@ -63,6 +77,7 @@ class SetController extends Controller * id: int, * name: string, * kind: string, + * levelId: int, * children: list * } */ @@ -74,6 +89,7 @@ class SetController extends Controller 'id' => $element->getId(), 'name' => $element->getName(), 'kind' => $element->getKind(), + 'levelId' => $element->getLevel()->getId(), 'children' => array_map( function (ElementLayoutNode $childNode): array { return $this->elementPayload($childNode); diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 8d883fe..aaf8dfd 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -19,7 +19,11 @@ use App\Email\LaravelEmailFactory; use App\Element\ElementRepository; use App\Element\EloquentElementRepository; use App\Set\EloquentSetRepository; +use App\Set\EloquentSetLevelRepository; use App\Set\SetRepository; +use App\Set\SetLevelRepository; +use App\Schedule\EloquentScheduleRepository; +use App\Schedule\ScheduleRepository; use App\User\EloquentUserRepository; use App\User\UserRepository; use Carbon\CarbonImmutable; @@ -53,10 +57,18 @@ class AppServiceProvider extends ServiceProvider SetRepository::class, EloquentSetRepository::class, ); + $this->app->bind( + SetLevelRepository::class, + EloquentSetLevelRepository::class, + ); $this->app->bind( 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..6acf329 --- /dev/null +++ b/backend/app/Schedule/CreateScheduleAssignmentDto.php @@ -0,0 +1,19 @@ + $path + */ + public function __construct( + public string $name, + public string $kind, + public array $path, + public DateTimeImmutable $scheduledDate, + public int $position, + ) {} +} diff --git a/backend/app/Schedule/CreateScheduleDto.php b/backend/app/Schedule/CreateScheduleDto.php new file mode 100644 index 0000000..5c5ffdc --- /dev/null +++ b/backend/app/Schedule/CreateScheduleDto.php @@ -0,0 +1,21 @@ + $assignments + */ + public function __construct( + public User $user, + public string $setName, + 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..31bb859 --- /dev/null +++ b/backend/app/Schedule/EloquentScheduleRepository.php @@ -0,0 +1,99 @@ + $dto->user->getId(), + 'set_name' => $dto->setName, + '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_name' => $assignmentDto->name, + 'element_kind' => $assignmentDto->kind, + 'element_path' => $assignmentDto->path, + '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 + { + $assignmentModels = ScheduleAssignmentModel::query() + ->where('schedule_id', $model->id) + ->orderBy('position') + ->orderBy('id') + ->get(); + $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, + ); + } + + return new Schedule( + id: $model->id, + user: $user, + setName: $model->set_name, + 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..0a14ce8 --- /dev/null +++ b/backend/app/Schedule/Schedule.php @@ -0,0 +1,60 @@ + $assignments + */ + public function __construct( + private int $id, + private User $user, + private string $setName, + 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 getSetName(): string + { + return $this->setName; + } + + 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..482b45d --- /dev/null +++ b/backend/app/Schedule/ScheduleAssignment.php @@ -0,0 +1,53 @@ + $path + */ + public function __construct( + private int $id, + private string $name, + private string $kind, + private array $path, + private DateTimeImmutable $scheduledDate, + private int $position, + ) {} + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function getKind(): string + { + return $this->kind; + } + + /** + * @return list + */ + public function getPath(): array + { + return $this->path; + } + + 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..f0f33df --- /dev/null +++ b/backend/app/Schedule/ScheduleAssignmentModel.php @@ -0,0 +1,49 @@ + $element_path + * @property string $scheduled_date + * @property int $position + * + * @method static Builder|ScheduleAssignmentModel newModelQuery() + * @method static Builder|ScheduleAssignmentModel newQuery() + * @method static Builder|ScheduleAssignmentModel query() + * + * @mixin \Eloquent + */ +#[Fillable([ + 'schedule_id', + 'element_name', + 'element_kind', + 'element_path', + '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_path' => 'array', + 'position' => 'integer', + ]; + } +} diff --git a/backend/app/Schedule/ScheduleModel.php b/backend/app/Schedule/ScheduleModel.php new file mode 100644 index 0000000..f19fb97 --- /dev/null +++ b/backend/app/Schedule/ScheduleModel.php @@ -0,0 +1,45 @@ +|ScheduleModel newModelQuery() + * @method static Builder|ScheduleModel newQuery() + * @method static Builder|ScheduleModel query() + * + * @mixin \Eloquent + */ +#[Fillable([ + 'user_id', + 'set_name', + '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', + ]; + } +} 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..fb8635e --- /dev/null +++ b/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php @@ -0,0 +1,240 @@ +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->levelId === null || $request->levelId < 1) { + throw new BadRequestException('levelId is required'); + } + + $level = $this->setLevelRepository->find($request->levelId); + if ( + $level === null + || $level->getSet()->getId() !== $set->getId() + ) { + throw new BadRequestException( + 'level is not available for set', + ); + } + + $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 ($level): bool { + return $element->getLevel()->getId() === $level->getId(); + }, + )); + if ($elements === []) { + throw new BadRequestException('level has no elements'); + } + + $assignments = $this->assignments( + elements: $elements, + startDate: $startDate, + targetDate: $targetDate, + ); + + return $this->scheduleRepository->create(new CreateScheduleDto( + user: $request->user, + setName: $set->getName(), + elementKind: $level->getKind(), + 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( + name: $element->getName(), + kind: $element->getKind(), + path: $this->elementPath($element), + scheduledDate: $startDate->modify("+{$dayIndex} days"), + position: $index + 1, + ); + } + + return $assignments; + } + + /** + * @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 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..70ab5db --- /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/Set/CreateSetLevelDto.php b/backend/app/Set/CreateSetLevelDto.php new file mode 100644 index 0000000..1920a50 --- /dev/null +++ b/backend/app/Set/CreateSetLevelDto.php @@ -0,0 +1,11 @@ +where('set_id', $dto->set->getId()) + ->where('kind', $dto->kind) + ->exists(); + if ($kindExists) { + throw new DomainException( + 'level kind must be unique within set', + ); + } + + $currentMaximum = SetLevelModel::query() + ->where('set_id', $dto->set->getId()) + ->max('depth'); + $depth = $currentMaximum === null + ? 0 + : (int) $currentMaximum + 1; + $model = SetLevelModel::create([ + 'set_id' => $dto->set->getId(), + 'kind' => $dto->kind, + 'depth' => $depth, + ]); + + return new SetLevel( + id: $model->id, + set: $dto->set, + kind: $model->kind, + depth: $model->depth, + ); + } + + public function find(int $id): ?SetLevel + { + $model = SetLevelModel::find($id); + + return $model === null ? null : $this->toDomain($model); + } + + public function findBySet(Set $set): array + { + $models = SetLevelModel::query() + ->where('set_id', $set->getId()) + ->orderBy('depth') + ->orderBy('id') + ->get(); + $levels = []; + + foreach ($models as $model) { + $levels[] = new SetLevel( + id: $model->id, + set: $set, + kind: $model->kind, + depth: $model->depth, + ); + } + + return $levels; + } + + private function toDomain(SetLevelModel $model): SetLevel + { + $set = $this->setRepository->find($model->set_id); + if ($set === null) { + throw new RuntimeException('set level set not found'); + } + + return new SetLevel( + id: $model->id, + set: $set, + kind: $model->kind, + depth: $model->depth, + ); + } +} diff --git a/backend/app/Set/SetLevel.php b/backend/app/Set/SetLevel.php new file mode 100644 index 0000000..23b5a99 --- /dev/null +++ b/backend/app/Set/SetLevel.php @@ -0,0 +1,33 @@ +id; + } + + public function getSet(): Set + { + return $this->set; + } + + public function getKind(): string + { + return $this->kind; + } + + public function getDepth(): int + { + return $this->depth; + } +} diff --git a/backend/app/Set/SetLevelModel.php b/backend/app/Set/SetLevelModel.php new file mode 100644 index 0000000..50daf3e --- /dev/null +++ b/backend/app/Set/SetLevelModel.php @@ -0,0 +1,38 @@ +|SetLevelModel newModelQuery() + * @method static Builder|SetLevelModel newQuery() + * @method static Builder|SetLevelModel query() + * + * @mixin \Eloquent + */ +#[Fillable(['set_id', 'kind', 'depth'])] +class SetLevelModel extends Model +{ + protected $table = 'set_levels'; + + public $timestamps = false; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'set_id' => 'integer', + 'depth' => 'integer', + ]; + } +} diff --git a/backend/app/Set/SetLevelRepository.php b/backend/app/Set/SetLevelRepository.php new file mode 100644 index 0000000..ba4b68c --- /dev/null +++ b/backend/app/Set/SetLevelRepository.php @@ -0,0 +1,20 @@ + + */ + public function findBySet(Set $set): array; +} diff --git a/backend/app/Set/UseCases/GetSetLayout/GetSetLayout.php b/backend/app/Set/UseCases/GetSetLayout/GetSetLayout.php index 39085e1..12c59d9 100644 --- a/backend/app/Set/UseCases/GetSetLayout/GetSetLayout.php +++ b/backend/app/Set/UseCases/GetSetLayout/GetSetLayout.php @@ -6,11 +6,13 @@ use App\Element\Element; use App\Element\ElementRepository; use App\Exceptions\NotFoundException; use App\Set\SetRepository; +use App\Set\SetLevelRepository; class GetSetLayout { public function __construct( private SetRepository $setRepository, + private SetLevelRepository $setLevelRepository, private ElementRepository $elementRepository, ) {} @@ -24,10 +26,15 @@ class GetSetLayout throw new NotFoundException('set not found'); } + $elements = $this->elementRepository->findBySet($set); $elementsByParentId = []; - foreach ($this->elementRepository->findBySet($set) as $element) { + $elementCountsByLevelId = []; + foreach ($elements as $element) { $parentId = $element->getParentElement()?->getId() ?? 0; $elementsByParentId[$parentId][] = $element; + $levelId = $element->getLevel()->getId(); + $elementCountsByLevelId[$levelId] + = ($elementCountsByLevelId[$levelId] ?? 0) + 1; } foreach ($elementsByParentId as &$siblings) { @@ -46,8 +53,17 @@ class GetSetLayout } unset($siblings); + $layoutLevels = []; + foreach ($this->setLevelRepository->findBySet($set) as $level) { + $layoutLevels[] = new SetLayoutLevel( + level: $level, + elementCount: $elementCountsByLevelId[$level->getId()] ?? 0, + ); + } + return new SetLayout( set: $set, + levels: $layoutLevels, elements: $this->buildNodes(0, $elementsByParentId), ); } diff --git a/backend/app/Set/UseCases/GetSetLayout/SetLayout.php b/backend/app/Set/UseCases/GetSetLayout/SetLayout.php index c98974a..69cf008 100644 --- a/backend/app/Set/UseCases/GetSetLayout/SetLayout.php +++ b/backend/app/Set/UseCases/GetSetLayout/SetLayout.php @@ -7,10 +7,12 @@ use App\Set\Set; final readonly class SetLayout { /** + * @param list $levels * @param list $elements */ public function __construct( private Set $set, + private array $levels, private array $elements, ) {} @@ -19,6 +21,14 @@ final readonly class SetLayout return $this->set; } + /** + * @return list + */ + public function getLevels(): array + { + return $this->levels; + } + /** * @return list */ diff --git a/backend/app/Set/UseCases/GetSetLayout/SetLayoutLevel.php b/backend/app/Set/UseCases/GetSetLayout/SetLayoutLevel.php new file mode 100644 index 0000000..6ebac06 --- /dev/null +++ b/backend/app/Set/UseCases/GetSetLayout/SetLayoutLevel.php @@ -0,0 +1,23 @@ +level; + } + + public function getElementCount(): int + { + return $this->elementCount; + } +} 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_07_000000_create_set_levels_table.php b/backend/database/migrations/2026_08_07_000000_create_set_levels_table.php new file mode 100644 index 0000000..552c31a --- /dev/null +++ b/backend/database/migrations/2026_08_07_000000_create_set_levels_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('set_id') + ->constrained('sets') + ->cascadeOnDelete(); + $table->string('kind'); + $table->unsignedInteger('depth'); + $table->unique(['set_id', 'kind']); + $table->unique(['set_id', 'depth']); + }); + } + + public function down(): void + { + Schema::dropIfExists('set_levels'); + } +}; diff --git a/backend/database/migrations/2026_08_08_000000_create_elements_table.php b/backend/database/migrations/2026_08_08_000000_create_elements_table.php index 196c2c5..04fe1d4 100644 --- a/backend/database/migrations/2026_08_08_000000_create_elements_table.php +++ b/backend/database/migrations/2026_08_08_000000_create_elements_table.php @@ -10,18 +10,17 @@ return new class extends Migration { Schema::create('elements', function (Blueprint $table): void { $table->id(); - $table->foreignId('set_id') - ->constrained('sets') + $table->foreignId('set_level_id') + ->constrained('set_levels') ->restrictOnDelete(); $table->string('name'); - $table->string('kind'); $table->foreignId('parent_element_id') ->nullable() ->constrained('elements') ->restrictOnDelete(); $table->unsignedInteger('position'); $table->index([ - 'set_id', + 'set_level_id', 'parent_element_id', 'position', ]); 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..390b246 --- /dev/null +++ b/backend/database/migrations/2026_08_10_000000_create_schedules_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('user_id') + ->constrained('users') + ->restrictOnDelete(); + $table->string('set_name'); + $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..b52c60a --- /dev/null +++ b/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('schedule_id') + ->constrained('schedules') + ->cascadeOnDelete(); + $table->string('element_name'); + $table->string('element_kind'); + $table->json('element_path'); + $table->date('scheduled_date'); + $table->unsignedInteger('position'); + $table->unique(['schedule_id', 'position']); + $table->index([ + 'schedule_id', + 'scheduled_date', + 'position', + ]); + }, + ); + } + + public function down(): void + { + Schema::dropIfExists('schedule_assignments'); + } +}; diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index c90217c..afd1905 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -13,6 +13,7 @@ class DatabaseSeeder extends Seeder { $this->call(UserSeeder::class); $this->call(SetSeeder::class); + $this->call(SetLevelSeeder::class); $this->call(ElementSeeder::class); } } diff --git a/backend/database/seeders/ElementSeeder.php b/backend/database/seeders/ElementSeeder.php index bde3ed9..52fe294 100644 --- a/backend/database/seeders/ElementSeeder.php +++ b/backend/database/seeders/ElementSeeder.php @@ -6,8 +6,11 @@ use App\Element\CreateElementDto; use App\Element\Element; use App\Element\ElementRepository; use App\Set\Set; +use App\Set\SetLevel; +use App\Set\SetLevelRepository; use App\Set\SetRepository; use Illuminate\Database\Seeder; +use RuntimeException; class ElementSeeder extends Seeder { @@ -15,38 +18,31 @@ class ElementSeeder extends Seeder 'Bible' => [ [ 'name' => 'Genesis', - 'kind' => 'book', 'children' => [ [ 'name' => 'Creation', - 'kind' => 'portion', 'children' => [ [ 'name' => 'Chapter 1', - 'kind' => 'chapter', 'children' => [], ], [ 'name' => 'Chapter 2', - 'kind' => 'chapter', 'children' => [], ], ], ], [ 'name' => 'Noah', - 'kind' => 'portion', 'children' => [], ], ], ], [ 'name' => 'Exodus', - 'kind' => 'book', 'children' => [ [ 'name' => 'Shemot', - 'kind' => 'portion', 'children' => [], ], ], @@ -55,32 +51,26 @@ class ElementSeeder extends Seeder 'Course' => [ [ 'name' => 'Foundations', - 'kind' => 'module', 'children' => [ [ 'name' => 'Welcome', - 'kind' => 'lesson', 'children' => [], ], [ 'name' => 'Core Concepts', - 'kind' => 'lesson', 'children' => [], ], ], ], [ 'name' => 'Applied Practice', - 'kind' => 'module', 'children' => [ [ 'name' => 'Guided Exercise', - 'kind' => 'lesson', 'children' => [], ], [ 'name' => 'Final Review', - 'kind' => 'lesson', 'children' => [], ], ], @@ -89,31 +79,25 @@ class ElementSeeder extends Seeder 'Fitness Program' => [ [ 'name' => 'Foundation Phase', - 'kind' => 'phase', 'children' => [ [ 'name' => 'Strength Day', - 'kind' => 'workout', 'children' => [ [ 'name' => 'Squat', - 'kind' => 'exercise', 'children' => [], ], [ 'name' => 'Push-up', - 'kind' => 'exercise', 'children' => [], ], ], ], [ 'name' => 'Mobility Day', - 'kind' => 'workout', 'children' => [ [ 'name' => 'Hip Flow', - 'kind' => 'exercise', 'children' => [], ], ], @@ -122,11 +106,9 @@ class ElementSeeder extends Seeder ], [ 'name' => 'Build Phase', - 'kind' => 'phase', 'children' => [ [ 'name' => 'Full Body Circuit', - 'kind' => 'workout', 'children' => [], ], ], @@ -137,6 +119,7 @@ class ElementSeeder extends Seeder public function run(): void { $elementRepository = app(ElementRepository::class); + $setLevelRepository = app(SetLevelRepository::class); foreach (app(SetRepository::class)->all() as $set) { $definitions = self::ELEMENTS_BY_SET[$set->getName()] ?? null; @@ -147,6 +130,8 @@ class ElementSeeder extends Seeder $this->seedChildren( repository: $elementRepository, set: $set, + levels: $setLevelRepository->findBySet($set), + depth: 0, parentElement: null, definitions: $definitions, ); @@ -156,16 +141,27 @@ class ElementSeeder extends Seeder /** * @param list * }> $definitions + * @param list $levels */ private function seedChildren( ElementRepository $repository, Set $set, + array $levels, + int $depth, ?Element $parentElement, array $definitions, ): void { + if ($definitions === []) { + return; + } + + $level = $levels[$depth] ?? null; + if ($level === null) { + throw new RuntimeException('element level not defined'); + } + $siblings = $parentElement === null ? $repository->findTopLevelBySet($set) : $repository->findByParentElement($parentElement); @@ -174,13 +170,12 @@ class ElementSeeder extends Seeder $element = $this->findSibling( siblings: $siblings, name: $definition['name'], - kind: $definition['kind'], + level: $level, ); if ($element === null) { $element = $repository->create(new CreateElementDto( - set: $set, name: $definition['name'], - kind: $definition['kind'], + level: $level, parentElement: $parentElement, )); $siblings[] = $element; @@ -189,6 +184,8 @@ class ElementSeeder extends Seeder $this->seedChildren( repository: $repository, set: $set, + levels: $levels, + depth: $depth + 1, parentElement: $element, definitions: $definition['children'], ); @@ -201,12 +198,12 @@ class ElementSeeder extends Seeder private function findSibling( array $siblings, string $name, - string $kind, + SetLevel $level, ): ?Element { foreach ($siblings as $sibling) { if ( $sibling->getName() === $name - && $sibling->getKind() === $kind + && $sibling->getLevel()->getId() === $level->getId() ) { return $sibling; } diff --git a/backend/database/seeders/SetLevelSeeder.php b/backend/database/seeders/SetLevelSeeder.php new file mode 100644 index 0000000..72c239c --- /dev/null +++ b/backend/database/seeders/SetLevelSeeder.php @@ -0,0 +1,45 @@ + ['book', 'portion', 'chapter'], + 'Course' => ['module', 'lesson'], + 'Fitness Program' => ['phase', 'workout', 'exercise'], + ]; + + public function run(): void + { + $repository = app(SetLevelRepository::class); + + foreach (app(SetRepository::class)->all() as $set) { + $kinds = self::KINDS_BY_SET[$set->getName()] ?? null; + if ($kinds === null) { + continue; + } + + $existingKinds = array_map(function ($level): string { + return $level->getKind(); + }, $repository->findBySet($set)); + + foreach ($kinds as $kind) { + if (in_array($kind, $existingKinds, true)) { + continue; + } + + $repository->create(new CreateSetLevelDto( + set: $set, + kind: $kind, + )); + $existingKinds[] = $kind; + } + } + } +} 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/Fakes/FakeElementRepository.php b/backend/tests/Fakes/FakeElementRepository.php index 1e9200d..628a37f 100644 --- a/backend/tests/Fakes/FakeElementRepository.php +++ b/backend/tests/Fakes/FakeElementRepository.php @@ -22,8 +22,7 @@ class FakeElementRepository implements ElementRepository $element = new Element( id: $id, name: $dto->name, - kind: $dto->kind, - set: $dto->set, + level: $dto->level, parentElement: $dto->parentElement, position: $this->nextPosition($dto), ); @@ -88,14 +87,32 @@ class FakeElementRepository implements ElementRepository { $parentElement = $dto->parentElement; if ($parentElement === null) { + if ($dto->level->getDepth() !== 0) { + throw new DomainException( + 'root element must use the first set level', + ); + } + return; } - if ($parentElement->getSet()->getId() !== $dto->set->getId()) { + if ( + $parentElement->getSet()->getId() + !== $dto->level->getSet()->getId() + ) { throw new DomainException( 'parent element must belong to the same set', ); } + + if ( + $dto->level->getDepth() + !== $parentElement->getLevel()->getDepth() + 1 + ) { + throw new DomainException( + 'child element must use the next set level', + ); + } } private function nextPosition(CreateElementDto $dto): int @@ -104,7 +121,10 @@ class FakeElementRepository implements ElementRepository $maximumPosition = 0; foreach ($this->elements as $element) { - if ($element->getSet()->getId() !== $dto->set->getId()) { + if ( + $element->getSet()->getId() + !== $dto->level->getSet()->getId() + ) { continue; } @@ -150,8 +170,7 @@ class FakeElementRepository implements ElementRepository return new Element( id: $element->getId(), name: $element->getName(), - kind: $element->getKind(), - set: $element->getSet(), + level: $element->getLevel(), parentElement: $parentElement === null ? null : $this->copy($parentElement), diff --git a/backend/tests/Fakes/FakeScheduleRepository.php b/backend/tests/Fakes/FakeScheduleRepository.php new file mode 100644 index 0000000..de1ae76 --- /dev/null +++ b/backend/tests/Fakes/FakeScheduleRepository.php @@ -0,0 +1,101 @@ + + */ + 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, + name: $assignmentDto->name, + kind: $assignmentDto->kind, + path: $assignmentDto->path, + scheduledDate: $assignmentDto->scheduledDate, + position: $assignmentDto->position, + ); + } + + $schedule = new Schedule( + id: $id, + user: $dto->user, + setName: $dto->setName, + 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(), + name: $assignment->getName(), + kind: $assignment->getKind(), + path: $assignment->getPath(), + scheduledDate: $assignment->getScheduledDate(), + position: $assignment->getPosition(), + ); + }, + $schedule->getAssignments(), + ); + + return new Schedule( + id: $schedule->getId(), + user: $schedule->getUser(), + setName: $schedule->getSetName(), + elementKind: $schedule->getElementKind(), + startDate: $schedule->getStartDate(), + targetDate: $schedule->getTargetDate(), + assignments: $assignments, + ); + } +} diff --git a/backend/tests/Fakes/FakeSetLevelRepository.php b/backend/tests/Fakes/FakeSetLevelRepository.php new file mode 100644 index 0000000..0f3f076 --- /dev/null +++ b/backend/tests/Fakes/FakeSetLevelRepository.php @@ -0,0 +1,76 @@ + + */ + private array $levels = []; + + public function create(CreateSetLevelDto $dto): SetLevel + { + foreach ($this->levels as $level) { + if ( + $level->getSet()->getId() === $dto->set->getId() + && $level->getKind() === $dto->kind + ) { + throw new DomainException( + 'level kind must be unique within set', + ); + } + } + + $id = count($this->levels) + 1; + $level = new SetLevel( + id: $id, + set: $dto->set, + kind: $dto->kind, + depth: count($this->findBySet($dto->set)), + ); + $this->levels[$id] = $level; + + return $this->copy($level); + } + + public function find(int $id): ?SetLevel + { + $level = $this->levels[$id] ?? null; + + return $level === null ? null : $this->copy($level); + } + + public function findBySet(Set $set): array + { + $levels = array_filter( + $this->levels, + function (SetLevel $level) use ($set): bool { + return $level->getSet()->getId() === $set->getId(); + }, + ); + usort($levels, function (SetLevel $first, SetLevel $second): int { + return $first->getDepth() <=> $second->getDepth(); + }); + + return array_map(function (SetLevel $level): SetLevel { + return $this->copy($level); + }, $levels); + } + + private function copy(SetLevel $level): SetLevel + { + return new SetLevel( + id: $level->getId(), + set: $level->getSet(), + kind: $level->getKind(), + depth: $level->getDepth(), + ); + } +} diff --git a/backend/tests/Feature/Database/DatabaseSeederTest.php b/backend/tests/Feature/Database/DatabaseSeederTest.php index e6ce46e..59d8be8 100644 --- a/backend/tests/Feature/Database/DatabaseSeederTest.php +++ b/backend/tests/Feature/Database/DatabaseSeederTest.php @@ -6,6 +6,7 @@ use App\Auth\PasswordHasher; use App\Element\Element; use App\Element\ElementRepository; use App\Set\SetRepository; +use App\Set\SetLevelRepository; use App\Shared\ValueObject\EmailAddress; use App\User\UserRepository; use Database\Seeders\UserSeeder; @@ -70,6 +71,12 @@ class DatabaseSeederTest extends TestCase $sets = app(SetRepository::class)->all(); $elementRepository = app(ElementRepository::class); + $setLevelRepository = app(SetLevelRepository::class); + $expectedLevels = [ + 'Bible' => ['book', 'portion', 'chapter'], + 'Course' => ['module', 'lesson'], + 'Fitness Program' => ['phase', 'workout', 'exercise'], + ]; $expectedElements = [ 'Bible' => [ 'Chapter 1:chapter:Creation', @@ -100,9 +107,16 @@ class DatabaseSeederTest extends TestCase ], ]; + $this->assertDatabaseCount('set_levels', 8); $this->assertDatabaseCount('elements', 21); foreach ($sets as $set) { + $this->assertSame( + $expectedLevels[$set->getName()], + array_map(function ($level): string { + return $level->getKind(); + }, $setLevelRepository->findBySet($set)), + ); $signatures = array_map(function (Element $element): string { $parentName = $element->getParentElement()?->getName() ?? 'root'; diff --git a/backend/tests/Feature/Element/EloquentElementRepositoryTest.php b/backend/tests/Feature/Element/EloquentElementRepositoryTest.php index 76766c5..9cc6020 100644 --- a/backend/tests/Feature/Element/EloquentElementRepositoryTest.php +++ b/backend/tests/Feature/Element/EloquentElementRepositoryTest.php @@ -5,7 +5,10 @@ namespace Tests\Feature\Element; use App\Element\CreateElementDto; use App\Element\ElementRepository; use App\Set\CreateSetDto; +use App\Set\CreateSetLevelDto; use App\Set\Set; +use App\Set\SetLevel; +use App\Set\SetLevelRepository; use App\Set\SetRepository; use App\Shared\ValueObject\EmailAddress; use App\User\CreateUserDto; @@ -22,28 +25,26 @@ class EloquentElementRepositoryTest extends TestCase { $set = $this->createSet('Bible'); $repository = app(ElementRepository::class); + $bookLevel = $this->createLevel($set, 'book'); + $portionLevel = $this->createLevel($set, 'portion'); $genesisBook = $repository->create(new CreateElementDto( - set: $set, name: 'Genesis', - kind: 'book', + level: $bookLevel, parentElement: null, )); $exodusBook = $repository->create(new CreateElementDto( - set: $set, name: 'Exodus', - kind: 'book', + level: $bookLevel, parentElement: null, )); $genesisPortion = $repository->create(new CreateElementDto( - set: $set, name: 'Genesis', - kind: 'portion', + level: $portionLevel, parentElement: $genesisBook, )); $noahPortion = $repository->create(new CreateElementDto( - set: $set, name: 'Noah', - kind: 'portion', + level: $portionLevel, parentElement: $genesisBook, )); @@ -53,9 +54,8 @@ class EloquentElementRepositoryTest extends TestCase $this->assertSame(2, $noahPortion->getPosition()); $this->assertDatabaseHas('elements', [ 'id' => $genesisPortion->getId(), - 'set_id' => $set->getId(), + 'set_level_id' => $portionLevel->getId(), 'name' => 'Genesis', - 'kind' => 'portion', 'parent_element_id' => $genesisBook->getId(), 'position' => 1, ]); @@ -65,6 +65,10 @@ class EloquentElementRepositoryTest extends TestCase $this->assertNotNull($foundElement); $this->assertSame('Noah', $foundElement->getName()); $this->assertSame('portion', $foundElement->getKind()); + $this->assertSame( + $portionLevel->getId(), + $foundElement->getLevel()->getId(), + ); $this->assertSame($set->getId(), $foundElement->getSet()->getId()); $this->assertSame( $genesisBook->getId(), @@ -77,10 +81,11 @@ class EloquentElementRepositoryTest extends TestCase $bible = $this->createSet('Bible'); $course = $this->createSet('Course'); $repository = app(ElementRepository::class); + $bookLevel = $this->createLevel($bible, 'book'); + $moduleLevel = $this->createLevel($course, 'module'); $book = $repository->create(new CreateElementDto( - set: $bible, name: 'Genesis', - kind: 'book', + level: $bookLevel, parentElement: null, )); @@ -90,9 +95,51 @@ class EloquentElementRepositoryTest extends TestCase ); $repository->create(new CreateElementDto( - set: $course, name: 'Invalid lesson', - kind: 'lesson', + level: $moduleLevel, + parentElement: $book, + )); + } + + public function testItRejectsARootOutsideTheFirstLevel(): void + { + $set = $this->createSet('Bible'); + $this->createLevel($set, 'book'); + $portionLevel = $this->createLevel($set, 'portion'); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage( + 'root element must use the first set level', + ); + + app(ElementRepository::class)->create(new CreateElementDto( + name: 'Creation', + level: $portionLevel, + parentElement: null, + )); + } + + public function testItRejectsAChildOutsideTheNextLevel(): void + { + $set = $this->createSet('Bible'); + $bookLevel = $this->createLevel($set, 'book'); + $this->createLevel($set, 'portion'); + $chapterLevel = $this->createLevel($set, 'chapter'); + $repository = app(ElementRepository::class); + $book = $repository->create(new CreateElementDto( + name: 'Genesis', + level: $bookLevel, + parentElement: null, + )); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage( + 'child element must use the next set level', + ); + + $repository->create(new CreateElementDto( + name: 'Chapter 1', + level: $chapterLevel, parentElement: $book, )); } @@ -102,40 +149,37 @@ class EloquentElementRepositoryTest extends TestCase $bible = $this->createSet('Bible'); $course = $this->createSet('Course'); $repository = app(ElementRepository::class); + $bookLevel = $this->createLevel($bible, 'book'); + $portionLevel = $this->createLevel($bible, 'portion'); + $moduleLevel = $this->createLevel($course, 'module'); $genesisBook = $repository->create(new CreateElementDto( - set: $bible, name: 'Genesis', - kind: 'book', + level: $bookLevel, parentElement: null, )); $exodusBook = $repository->create(new CreateElementDto( - set: $bible, name: 'Exodus', - kind: 'book', + level: $bookLevel, parentElement: null, )); $repository->create(new CreateElementDto( - set: $course, name: 'Module 1', - kind: 'module', + level: $moduleLevel, parentElement: null, )); $repository->create(new CreateElementDto( - set: $bible, name: 'Genesis', - kind: 'portion', + level: $portionLevel, parentElement: $genesisBook, )); $repository->create(new CreateElementDto( - set: $bible, name: 'Noah', - kind: 'portion', + level: $portionLevel, parentElement: $genesisBook, )); $repository->create(new CreateElementDto( - set: $bible, name: 'Shemot', - kind: 'portion', + level: $portionLevel, parentElement: $exodusBook, )); @@ -161,22 +205,22 @@ class EloquentElementRepositoryTest extends TestCase $bible = $this->createSet('Bible'); $course = $this->createSet('Course'); $repository = app(ElementRepository::class); + $bookLevel = $this->createLevel($bible, 'book'); + $portionLevel = $this->createLevel($bible, 'portion'); + $moduleLevel = $this->createLevel($course, 'module'); $genesis = $repository->create(new CreateElementDto( - set: $bible, name: 'Genesis', - kind: 'book', + level: $bookLevel, parentElement: null, )); $repository->create(new CreateElementDto( - set: $bible, name: 'Creation', - kind: 'portion', + level: $portionLevel, parentElement: $genesis, )); $repository->create(new CreateElementDto( - set: $course, name: 'Foundations', - kind: 'module', + level: $moduleLevel, parentElement: null, )); @@ -207,4 +251,12 @@ class EloquentElementRepositoryTest extends TestCase creator: $creator, )); } + + private function createLevel(Set $set, string $kind): SetLevel + { + return app(SetLevelRepository::class)->create(new CreateSetLevelDto( + set: $set, + kind: $kind, + )); + } } diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php new file mode 100644 index 0000000..0303dfe --- /dev/null +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -0,0 +1,391 @@ +createUser('reader@example.com'); + $creator = $this->createUser('creator@example.com'); + $set = $this->createSet($creator, 'Bible'); + $bookLevel = $this->createLevel($set, 'book'); + $portionLevel = $this->createLevel($set, 'portion'); + $chapterLevel = $this->createLevel($set, 'chapter'); + $repository = app(ElementRepository::class); + $genesis = $repository->create(new CreateElementDto( + name: 'Genesis', + level: $bookLevel, + parentElement: null, + )); + $creation = $repository->create(new CreateElementDto( + name: 'Creation', + level: $portionLevel, + parentElement: $genesis, + )); + $repository->create(new CreateElementDto( + name: 'Chapter 1', + level: $chapterLevel, + parentElement: $creation, + )); + $exodus = $repository->create(new CreateElementDto( + name: 'Exodus', + level: $bookLevel, + parentElement: null, + )); + $shemot = $repository->create(new CreateElementDto( + name: 'Shemot', + level: $portionLevel, + parentElement: $exodus, + )); + $repository->create(new CreateElementDto( + name: 'Chapter 1', + level: $chapterLevel, + parentElement: $shemot, + )); + $this->createSession($user, 'valid-token'); + + $response = $this->credentialedPost('/api/schedules', [ + 'setId' => $set->getId(), + 'levelId' => $chapterLevel->getId(), + 'startDate' => '2026-08-10', + 'targetDate' => '2026-08-12', + ]); + + $response->assertCreated()->assertExactJson([ + 'schedule' => [ + 'id' => 1, + 'set' => [ + 'name' => 'Bible', + ], + 'elementKind' => 'chapter', + 'startDate' => '2026-08-10', + 'targetDate' => '2026-08-12', + 'assignmentCount' => 2, + 'days' => [ + [ + 'date' => '2026-08-10', + 'assignments' => [ + [ + 'id' => 1, + 'element' => [ + 'name' => 'Chapter 1', + 'kind' => 'chapter', + 'path' => [ + 'Genesis', + 'Creation', + 'Chapter 1', + ], + ], + ], + ], + ], + [ + 'date' => '2026-08-11', + 'assignments' => [], + ], + [ + 'date' => '2026-08-12', + 'assignments' => [ + [ + 'id' => 2, + 'element' => [ + 'name' => 'Chapter 1', + 'kind' => 'chapter', + 'path' => [ + 'Exodus', + 'Shemot', + 'Chapter 1', + ], + ], + ], + ], + ], + ], + ], + ]); + $this->assertDatabaseHas('schedules', [ + 'user_id' => $user->getId(), + 'set_name' => 'Bible', + 'element_kind' => 'chapter', + 'start_date' => '2026-08-10', + 'target_date' => '2026-08-12', + ]); + $this->assertDatabaseHas('schedule_assignments', [ + 'schedule_id' => 1, + 'element_name' => 'Chapter 1', + 'element_kind' => 'chapter', + 'element_path' => json_encode([ + 'Genesis', + 'Creation', + 'Chapter 1', + ]), + 'scheduled_date' => '2026-08-10', + 'position' => 1, + ]); + $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'); + $lessonLevel = $this->createLevel($set, 'lesson'); + app(ElementRepository::class)->create(new CreateElementDto( + name: 'Welcome', + level: $lessonLevel, + parentElement: null, + )); + $this->createSession($user, 'valid-token'); + $this->createSession($otherUser, 'other-token'); + + $this->credentialedPost('/api/schedules', [ + 'setId' => $set->getId(), + 'levelId' => $lessonLevel->getId(), + 'startDate' => '2026-08-01', + 'targetDate' => '2026-08-01', + ])->assertCreated(); + $this->credentialedPost('/api/schedules', [ + 'setId' => $set->getId(), + 'levelId' => $lessonLevel->getId(), + 'startDate' => '2026-09-01', + 'targetDate' => '2026-09-01', + ])->assertCreated(); + $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'other-token', + )->postJson('/api/schedules', [ + 'setId' => $set->getId(), + 'levelId' => $lessonLevel->getId(), + 'startDate' => '2026-10-01', + 'targetDate' => '2026-10-01', + ])->assertCreated(); + + $this->credentialedGet('/api/schedules') + ->assertOk() + ->assertExactJson([ + 'schedules' => [ + [ + 'id' => 2, + 'set' => [ + 'name' => 'Course', + ], + 'elementKind' => 'lesson', + 'startDate' => '2026-09-01', + 'targetDate' => '2026-09-01', + 'assignmentCount' => 1, + ], + [ + 'id' => 1, + 'set' => [ + 'name' => 'Course', + ], + 'elementKind' => 'lesson', + 'startDate' => '2026-08-01', + 'targetDate' => '2026-08-01', + 'assignmentCount' => 1, + ], + ], + ]); + } + + public function test_it_is_stable_after_sources_change_or_are_deleted(): void + { + $user = $this->createUser('reader@example.com'); + $set = $this->createSet($user, 'Original set'); + $groupLevel = $this->createLevel($set, 'group'); + $taskLevel = $this->createLevel($set, 'task'); + $repository = app(ElementRepository::class); + $parent = $repository->create(new CreateElementDto( + name: 'Original parent', + level: $groupLevel, + parentElement: null, + )); + $element = $repository->create(new CreateElementDto( + name: 'Original item', + level: $taskLevel, + parentElement: $parent, + )); + $this->createSession($user, 'valid-token'); + $createdResponse = $this->credentialedPost('/api/schedules', [ + 'setId' => $set->getId(), + 'levelId' => $taskLevel->getId(), + 'startDate' => '2026-08-10', + 'targetDate' => '2026-08-10', + ])->assertCreated(); + + SetModel::query()->whereKey($set->getId())->update([ + 'name' => 'Changed set', + ]); + SetLevelModel::query()->whereKey($taskLevel->getId())->update([ + 'kind' => 'changed-kind', + ]); + ElementModel::query()->whereKey($parent->getId())->update([ + 'name' => 'Changed parent', + ]); + ElementModel::query()->whereKey($element->getId())->update([ + 'name' => 'Changed item', + ]); + + $this->credentialedGet('/api/schedules/1') + ->assertOk() + ->assertExactJson($createdResponse->json()); + + ElementModel::query()->whereKey($element->getId())->delete(); + ElementModel::query()->whereKey($parent->getId())->delete(); + SetModel::query()->whereKey($set->getId())->delete(); + + $this->credentialedGet('/api/schedules/1') + ->assertOk() + ->assertExactJson($createdResponse->json()); + } + + 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'); + $lessonLevel = $this->createLevel($set, 'lesson'); + app(ElementRepository::class)->create(new CreateElementDto( + name: 'Welcome', + level: $lessonLevel, + 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(), + 'levelId' => $lessonLevel->getId(), + '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, + 'levelId' => 1, + '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 createLevel(Set $set, string $kind): SetLevel + { + return app(SetLevelRepository::class)->create(new CreateSetLevelDto( + set: $set, + kind: $kind, + )); + } + + 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('+10 years'), + )); + } + + /** + * @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/Feature/Set/EloquentSetLevelRepositoryTest.php b/backend/tests/Feature/Set/EloquentSetLevelRepositoryTest.php new file mode 100644 index 0000000..29f1c40 --- /dev/null +++ b/backend/tests/Feature/Set/EloquentSetLevelRepositoryTest.php @@ -0,0 +1,92 @@ +createSet('Bible'); + $course = $this->createSet('Course'); + $repository = app(SetLevelRepository::class); + + $book = $repository->create(new CreateSetLevelDto( + set: $bible, + kind: 'book', + )); + $portion = $repository->create(new CreateSetLevelDto( + set: $bible, + kind: 'portion', + )); + $module = $repository->create(new CreateSetLevelDto( + set: $course, + kind: 'module', + )); + + $this->assertSame(0, $book->getDepth()); + $this->assertSame(1, $portion->getDepth()); + $this->assertSame(0, $module->getDepth()); + $this->assertSame( + ['book', 'portion'], + array_map(function ($level): string { + return $level->getKind(); + }, $repository->findBySet($bible)), + ); + $this->assertSame( + $portion->getId(), + $repository->find($portion->getId())?->getId(), + ); + $this->assertDatabaseHas('set_levels', [ + 'set_id' => $bible->getId(), + 'kind' => 'portion', + 'depth' => 1, + ]); + } + + public function testItRejectsARepeatedKindWithinASet(): void + { + $set = $this->createSet('Bible'); + $repository = app(SetLevelRepository::class); + $repository->create(new CreateSetLevelDto( + set: $set, + kind: 'book', + )); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage( + 'level kind must be unique within set', + ); + + $repository->create(new CreateSetLevelDto( + set: $set, + kind: 'book', + )); + } + + private function createSet(string $name): Set + { + $user = app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress(strtolower($name).'@example.com'), + passwordHash: 'hashed-password', + )); + + return app(SetRepository::class)->create(new CreateSetDto( + name: $name, + creator: $user, + )); + } +} diff --git a/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php b/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php index 074f7f5..de3f898 100644 --- a/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php +++ b/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php @@ -8,7 +8,10 @@ use App\Element\CreateElementDto; use App\Element\ElementRepository; use App\Http\Middleware\AuthMiddleware; use App\Set\CreateSetDto; +use App\Set\CreateSetLevelDto; use App\Set\Set; +use App\Set\SetLevel; +use App\Set\SetLevelRepository; use App\Set\SetRepository; use App\Shared\ValueObject\EmailAddress; use App\User\CreateUserDto; @@ -29,28 +32,27 @@ class GetSetLayoutEndpointTest extends TestCase $user = $this->createUser(); $set = $this->createSet($user, 'Bible'); $repository = app(ElementRepository::class); + $bookLevel = $this->createLevel($set, 'book'); + $portionLevel = $this->createLevel($set, 'portion'); + $chapterLevel = $this->createLevel($set, 'chapter'); $genesis = $repository->create(new CreateElementDto( - set: $set, name: 'Genesis', - kind: 'book', + level: $bookLevel, parentElement: null, )); $repository->create(new CreateElementDto( - set: $set, name: 'Exodus', - kind: 'book', + level: $bookLevel, parentElement: null, )); $creation = $repository->create(new CreateElementDto( - set: $set, name: 'Creation', - kind: 'portion', + level: $portionLevel, parentElement: $genesis, )); $chapter = $repository->create(new CreateElementDto( - set: $set, name: 'Chapter 1', - kind: 'chapter', + level: $chapterLevel, parentElement: $creation, )); $this->createSession($user); @@ -62,21 +64,44 @@ class GetSetLayoutEndpointTest extends TestCase 'id' => $set->getId(), 'name' => 'Bible', ], + 'levels' => [ + [ + 'id' => $bookLevel->getId(), + 'kind' => 'book', + 'depth' => 0, + 'elementCount' => 2, + ], + [ + 'id' => $portionLevel->getId(), + 'kind' => 'portion', + 'depth' => 1, + 'elementCount' => 1, + ], + [ + 'id' => $chapterLevel->getId(), + 'kind' => 'chapter', + 'depth' => 2, + 'elementCount' => 1, + ], + ], 'elements' => [ [ 'id' => $genesis->getId(), 'name' => 'Genesis', 'kind' => 'book', + 'levelId' => $bookLevel->getId(), 'children' => [ [ 'id' => $creation->getId(), 'name' => 'Creation', 'kind' => 'portion', + 'levelId' => $portionLevel->getId(), 'children' => [ [ 'id' => $chapter->getId(), 'name' => 'Chapter 1', 'kind' => 'chapter', + 'levelId' => $chapterLevel->getId(), 'children' => [], ], ], @@ -87,6 +112,7 @@ class GetSetLayoutEndpointTest extends TestCase 'id' => $genesis->getId() + 1, 'name' => 'Exodus', 'kind' => 'book', + 'levelId' => $bookLevel->getId(), 'children' => [], ], ], @@ -97,6 +123,7 @@ class GetSetLayoutEndpointTest extends TestCase { $user = $this->createUser(); $set = $this->createSet($user, 'Empty set'); + $taskLevel = $this->createLevel($set, 'task'); $this->createSession($user); $response = $this->credentialedGet("/api/sets/{$set->getId()}"); @@ -106,6 +133,12 @@ class GetSetLayoutEndpointTest extends TestCase 'id' => $set->getId(), 'name' => 'Empty set', ], + 'levels' => [[ + 'id' => $taskLevel->getId(), + 'kind' => 'task', + 'depth' => 0, + 'elementCount' => 0, + ]], 'elements' => [], ]); } @@ -147,6 +180,14 @@ class GetSetLayoutEndpointTest extends TestCase )); } + private function createLevel(Set $set, string $kind): SetLevel + { + return app(SetLevelRepository::class)->create(new CreateSetLevelDto( + set: $set, + kind: $kind, + )); + } + private function createSession(User $user): void { $createdAt = new DateTimeImmutable( @@ -157,7 +198,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'), )); } } diff --git a/backend/tests/Unit/Element/ElementTest.php b/backend/tests/Unit/Element/ElementTest.php index 1a99b31..3acd12d 100644 --- a/backend/tests/Unit/Element/ElementTest.php +++ b/backend/tests/Unit/Element/ElementTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\Element; use App\Element\Element; use App\Set\Set; +use App\Set\SetLevel; use App\Shared\ValueObject\EmailAddress; use App\User\User; use PHPUnit\Framework\TestCase; @@ -22,19 +23,29 @@ class ElementTest extends TestCase name: 'Bible', creator: $creator, ); + $bookLevel = new SetLevel( + id: 31, + set: $set, + kind: 'book', + depth: 0, + ); + $portionLevel = new SetLevel( + id: 32, + set: $set, + kind: 'portion', + depth: 1, + ); $parentElement = new Element( id: 21, name: 'Genesis', - kind: 'book', - set: $set, + level: $bookLevel, parentElement: null, position: 1, ); $element = new Element( id: 22, name: 'Genesis', - kind: 'portion', - set: $set, + level: $portionLevel, parentElement: $parentElement, position: 2, ); @@ -42,6 +53,7 @@ class ElementTest extends TestCase $this->assertSame(22, $element->getId()); $this->assertSame('Genesis', $element->getName()); $this->assertSame('portion', $element->getKind()); + $this->assertSame($portionLevel, $element->getLevel()); $this->assertSame($set, $element->getSet()); $this->assertSame($parentElement, $element->getParentElement()); $this->assertSame(2, $element->getPosition()); diff --git a/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php b/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php new file mode 100644 index 0000000..02b7a83 --- /dev/null +++ b/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php @@ -0,0 +1,441 @@ +user(); + $setRepository = new FakeSetRepository; + $setLevelRepository = new FakeSetLevelRepository; + $elementRepository = new FakeElementRepository; + $scheduleRepository = new FakeScheduleRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Bible', + creator: $user, + )); + $bookLevel = $this->createLevel( + $setLevelRepository, + $set, + 'book', + ); + $portionLevel = $this->createLevel( + $setLevelRepository, + $set, + 'portion', + ); + $chapterLevel = $this->createLevel( + $setLevelRepository, + $set, + 'chapter', + ); + $genesis = $elementRepository->create(new CreateElementDto( + name: 'Genesis', + level: $bookLevel, + parentElement: null, + )); + $exodus = $elementRepository->create(new CreateElementDto( + name: 'Exodus', + level: $bookLevel, + parentElement: null, + )); + $creation = $elementRepository->create(new CreateElementDto( + name: 'Creation', + level: $portionLevel, + parentElement: $genesis, + )); + $shemot = $elementRepository->create(new CreateElementDto( + name: 'Shemot', + level: $portionLevel, + parentElement: $exodus, + )); + $genesisChapterOne = $elementRepository->create( + new CreateElementDto( + name: 'Genesis 1', + level: $chapterLevel, + parentElement: $creation, + ), + ); + $genesisChapterTwo = $elementRepository->create( + new CreateElementDto( + name: 'Genesis 2', + level: $chapterLevel, + parentElement: $creation, + ), + ); + $genesisChapterThree = $elementRepository->create( + new CreateElementDto( + name: 'Genesis 3', + level: $chapterLevel, + parentElement: $creation, + ), + ); + $exodusChapterOne = $elementRepository->create( + new CreateElementDto( + name: 'Exodus 1', + level: $chapterLevel, + parentElement: $shemot, + ), + ); + $exodusChapterTwo = $elementRepository->create( + new CreateElementDto( + name: 'Exodus 2', + level: $chapterLevel, + parentElement: $shemot, + ), + ); + + $schedule = (new CreateSchedule( + $setRepository, + $setLevelRepository, + $elementRepository, + $scheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + levelId: $chapterLevel->getId(), + 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->getName(), + $genesisChapterTwo->getName(), + $genesisChapterThree->getName(), + $exodusChapterOne->getName(), + $exodusChapterTwo->getName(), + ], + array_map(function ($assignment): string { + return $assignment->getName(); + }, $schedule->getAssignments()), + ); + $this->assertSame( + ['Genesis', 'Creation', 'Genesis 1'], + $schedule->getAssignments()[0]->getPath(), + ); + $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; + $setLevelRepository = new FakeSetLevelRepository; + $elementRepository = new FakeElementRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Course', + creator: $user, + )); + $lessonLevel = $this->createLevel( + $setLevelRepository, + $set, + 'lesson', + ); + + foreach (['First', 'Second', 'Third'] as $name) { + $elementRepository->create(new CreateElementDto( + name: $name, + level: $lessonLevel, + parentElement: null, + )); + } + + $schedule = (new CreateSchedule( + $setRepository, + $setLevelRepository, + $elementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + levelId: $lessonLevel->getId(), + 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; + $setLevelRepository = new FakeSetLevelRepository; + $elementRepository = new FakeElementRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Project', + creator: $user, + )); + $milestoneLevel = $this->createLevel( + $setLevelRepository, + $set, + 'milestone', + ); + $elementRepository->create(new CreateElementDto( + name: 'Ship it', + level: $milestoneLevel, + parentElement: null, + )); + + $schedule = (new CreateSchedule( + $setRepository, + $setLevelRepository, + $elementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + levelId: $milestoneLevel->getId(), + 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 FakeSetLevelRepository, + new FakeElementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $this->user(), + setId: 999, + levelId: 1, + startDate: '2026-08-10', + targetDate: '2026-08-12', + )); + } + + public function test_it_rejects_a_missing_level(): void + { + $user = $this->user(); + $setRepository = new FakeSetRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Bible', + creator: $user, + )); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('levelId is required'); + + (new CreateSchedule( + $setRepository, + new FakeSetLevelRepository, + new FakeElementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + levelId: null, + startDate: '2026-08-10', + targetDate: '2026-08-12', + )); + } + + public function test_it_rejects_a_level_from_another_set(): void + { + $user = $this->user(); + $setRepository = new FakeSetRepository; + $setLevelRepository = new FakeSetLevelRepository; + $bible = $setRepository->create(new CreateSetDto( + name: 'Bible', + creator: $user, + )); + $course = $setRepository->create(new CreateSetDto( + name: 'Course', + creator: $user, + )); + $moduleLevel = $this->createLevel( + $setLevelRepository, + $course, + 'module', + ); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('level is not available for set'); + + (new CreateSchedule( + $setRepository, + $setLevelRepository, + new FakeElementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $bible->getId(), + levelId: $moduleLevel->getId(), + startDate: '2026-08-10', + targetDate: '2026-08-12', + )); + } + + public function test_it_rejects_a_level_without_elements(): void + { + $user = $this->user(); + $setRepository = new FakeSetRepository; + $setLevelRepository = new FakeSetLevelRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Bible', + creator: $user, + )); + $chapterLevel = $this->createLevel( + $setLevelRepository, + $set, + 'chapter', + ); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('level has no elements'); + + (new CreateSchedule( + $setRepository, + $setLevelRepository, + new FakeElementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + levelId: $chapterLevel->getId(), + startDate: '2026-08-10', + targetDate: '2026-08-12', + )); + } + + public function test_it_rejects_invalid_dates(): void + { + $user = $this->user(); + $setRepository = new FakeSetRepository; + $setLevelRepository = new FakeSetLevelRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Bible', + creator: $user, + )); + $chapterLevel = $this->createLevel( + $setLevelRepository, + $set, + 'chapter', + ); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('startDate must be a valid date'); + + (new CreateSchedule( + $setRepository, + $setLevelRepository, + new FakeElementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + levelId: $chapterLevel->getId(), + 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; + $setLevelRepository = new FakeSetLevelRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Bible', + creator: $user, + )); + $chapterLevel = $this->createLevel( + $setLevelRepository, + $set, + 'chapter', + ); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'targetDate must not be before startDate', + ); + + (new CreateSchedule( + $setRepository, + $setLevelRepository, + new FakeElementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + levelId: $chapterLevel->getId(), + startDate: '2026-08-12', + targetDate: '2026-08-10', + )); + } + + private function createLevel( + FakeSetLevelRepository $repository, + Set $set, + string $kind, + ): SetLevel { + return $repository->create(new CreateSetLevelDto( + set: $set, + kind: $kind, + )); + } + + private function user(): User + { + return new User( + id: 7, + email: new EmailAddress('reader@example.com'), + passwordHash: 'hashed-password', + ); + } +} diff --git a/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php b/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php index 69a7714..2b4a7f1 100644 --- a/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php +++ b/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Set\UseCases; use App\Element\CreateElementDto; use App\Exceptions\NotFoundException; use App\Set\CreateSetDto; +use App\Set\CreateSetLevelDto; use App\Set\UseCases\GetSetLayout\ElementLayoutNode; use App\Set\UseCases\GetSetLayout\GetSetLayout; use App\Set\UseCases\GetSetLayout\GetSetLayoutRequest; @@ -13,6 +14,7 @@ use App\User\User; use PHPUnit\Framework\TestCase; use Tests\Fakes\FakeElementRepository; use Tests\Fakes\FakeSetRepository; +use Tests\Fakes\FakeSetLevelRepository; class GetSetLayoutTest extends TestCase { @@ -24,6 +26,7 @@ class GetSetLayoutTest extends TestCase passwordHash: 'hashed-password', ); $setRepository = new FakeSetRepository; + $setLevelRepository = new FakeSetLevelRepository; $elementRepository = new FakeElementRepository; $bible = $setRepository->create(new CreateSetDto( name: 'Bible', @@ -33,51 +36,69 @@ class GetSetLayoutTest extends TestCase name: 'Course', creator: $creator, )); - $genesis = $elementRepository->create(new CreateElementDto( + $bookLevel = $setLevelRepository->create(new CreateSetLevelDto( set: $bible, - name: 'Genesis', kind: 'book', + )); + $portionLevel = $setLevelRepository->create(new CreateSetLevelDto( + set: $bible, + kind: 'portion', + )); + $chapterLevel = $setLevelRepository->create(new CreateSetLevelDto( + set: $bible, + kind: 'chapter', + )); + $moduleLevel = $setLevelRepository->create(new CreateSetLevelDto( + set: $course, + kind: 'module', + )); + $genesis = $elementRepository->create(new CreateElementDto( + name: 'Genesis', + level: $bookLevel, parentElement: null, )); $elementRepository->create(new CreateElementDto( - set: $bible, name: 'Exodus', - kind: 'book', + level: $bookLevel, parentElement: null, )); $creation = $elementRepository->create(new CreateElementDto( - set: $bible, name: 'Creation', - kind: 'portion', + level: $portionLevel, parentElement: $genesis, )); $elementRepository->create(new CreateElementDto( - set: $bible, name: 'Noah', - kind: 'portion', + level: $portionLevel, parentElement: $genesis, )); $elementRepository->create(new CreateElementDto( - set: $bible, name: 'Chapter 1', - kind: 'chapter', + level: $chapterLevel, parentElement: $creation, )); $elementRepository->create(new CreateElementDto( - set: $course, name: 'Foundations', - kind: 'module', + level: $moduleLevel, parentElement: null, )); $layout = (new GetSetLayout( $setRepository, + $setLevelRepository, $elementRepository, ))->execute(new GetSetLayoutRequest( setId: $bible->getId(), )); $this->assertSame('Bible', $layout->getSet()->getName()); + $this->assertSame( + ['book:2', 'portion:2', 'chapter:1'], + array_map(function ($level): string { + return $level->getLevel()->getKind() + . ":{$level->getElementCount()}"; + }, $layout->getLevels()), + ); $this->assertSame( ['Genesis', 'Exodus'], $this->nodeNames($layout->getElements()), @@ -103,19 +124,26 @@ class GetSetLayoutTest extends TestCase passwordHash: 'hashed-password', ); $setRepository = new FakeSetRepository; + $setLevelRepository = new FakeSetLevelRepository; $set = $setRepository->create(new CreateSetDto( name: 'Empty set', creator: $creator, )); + $setLevelRepository->create(new CreateSetLevelDto( + set: $set, + kind: 'task', + )); $layout = (new GetSetLayout( $setRepository, + $setLevelRepository, new FakeElementRepository, ))->execute(new GetSetLayoutRequest( setId: $set->getId(), )); $this->assertSame([], $layout->getElements()); + $this->assertSame(0, $layout->getLevels()[0]->getElementCount()); } public function test_it_rejects_an_unknown_set(): void @@ -125,6 +153,7 @@ class GetSetLayoutTest extends TestCase (new GetSetLayout( new FakeSetRepository, + new FakeSetLevelRepository, new FakeElementRepository, ))->execute(new GetSetLayoutRequest( setId: 999, 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-layout.cy.ts b/frontend/website/cypress/e2e/set-layout.cy.ts index 5430694..aad6b24 100644 --- a/frontend/website/cypress/e2e/set-layout.cy.ts +++ b/frontend/website/cypress/e2e/set-layout.cy.ts @@ -5,29 +5,37 @@ const authenticatedUser = { const bibleLayout = { set: { id: 41, name: 'Bible' }, + levels: [ + { id: 1, kind: 'book', depth: 0, elementCount: 2 }, + { id: 2, kind: 'portion', depth: 1, elementCount: 2 }, + { id: 3, kind: 'Chapter_sections-v2', depth: 2, elementCount: 1 }, + ], elements: [ { id: 1, name: 'Genesis', kind: 'book', + levelId: 1, children: [ { id: 3, name: 'Creation', kind: 'portion', + levelId: 2, children: [ { id: 4, name: 'Chapter 1', - kind: 'chapter', + kind: 'Chapter_sections-v2', + levelId: 3, children: [], }, ], }, - { id: 5, name: 'Noah', kind: 'portion', children: [] }, + { id: 5, name: 'Noah', kind: 'portion', levelId: 2, children: [] }, ], }, - { id: 2, name: 'Exodus', kind: 'book', children: [] }, + { id: 2, name: 'Exodus', kind: 'book', levelId: 1, children: [] }, ], } @@ -82,14 +90,16 @@ describe('set element layout', () => { .and('have.attr', 'data-element-id', '4') cy.contains('.element-node__card', 'Chapter 1') .should('be.visible') - .and('contain.text', 'chapter') + .find('.element-node__kind') + .should('have.text', 'Chapter_sections-v2') + .and('have.css', 'text-transform', 'none') }) it('shows loading and empty layout states', () => { cy.intercept('GET', '**/api/sets/41', { delay: 500, statusCode: 200, - body: { set: { id: 41, name: 'Empty set' }, elements: [] }, + body: { set: { id: 41, name: 'Empty set' }, levels: [], elements: [] }, }).as('layout') cy.visit('/sets/41') 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..5747d6b --- /dev/null +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -0,0 +1,268 @@ +const authenticatedUser = { + id: 7, + email: 'user@example.com', +} + +const bibleLayout = { + set: { id: 41, name: 'Bible' }, + levels: [ + { id: 1, kind: 'book', depth: 0, elementCount: 2 }, + { id: 2, kind: 'portion', depth: 1, elementCount: 2 }, + { id: 3, kind: 'Chapter_sections-v2', depth: 2, elementCount: 2 }, + ], + elements: [ + { + id: 1, + name: 'Genesis', + kind: 'book', + levelId: 1, + children: [ + { + id: 3, + name: 'Creation', + kind: 'portion', + levelId: 2, + children: [ + { + id: 4, + name: 'Chapter 1', + kind: 'Chapter_sections-v2', + levelId: 3, + children: [], + }, + ], + }, + ], + }, + { + id: 2, + name: 'Exodus', + kind: 'book', + levelId: 1, + children: [ + { + id: 5, + name: 'Shemot', + kind: 'portion', + levelId: 2, + children: [ + { + id: 6, + name: 'Chapter 1', + kind: 'Chapter_sections-v2', + levelId: 3, + children: [], + }, + ], + }, + ], + }, + ], +} + +const scheduleDetail = { + schedule: { + id: 73, + set: { name: 'Bible' }, + elementKind: 'Chapter_sections-v2', + startDate: '2026-08-10', + targetDate: '2026-08-12', + assignmentCount: 2, + days: [ + { + date: '2026-08-10', + assignments: [ + { + id: 1, + element: { + name: 'Chapter 1', + kind: 'Chapter_sections-v2', + path: ['Genesis', 'Creation', 'Chapter 1'], + }, + }, + ], + }, + { date: '2026-08-11', assignments: [] }, + { + date: '2026-08-12', + assignments: [ + { + id: 2, + element: { + name: 'Chapter 1', + kind: 'Chapter_sections-v2', + path: ['Exodus', 'Shemot', '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, + levelId: 3, + 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?.trim())).to.deep.equal( + ['Choose a level', 'book (2)', 'portion (2)', 'Chapter_sections-v2 (2)'], + ) + }) + cy.get('#schedule-level').select('3') + cy.get('#schedule-start-date').type('2026-08-10') + cy.get('#schedule-target-date').type('2026-08-12') + cy.get('form').submit() + cy.wait('@createSchedule') + + cy.location('pathname').should('equal', '/schedules/73') + cy.get('h1').should('have.text', 'Bible schedule') + cy.get('[data-schedule-day]').should('have.length', 3) + cy.get('[data-schedule-date="2026-08-10"]') + .should('contain.text', 'Genesis / Creation / Chapter 1') + .find('.assignment-kind') + .should('have.text', 'Chapter_sections-v2') + .and('have.css', 'text-transform', 'none') + cy.get('[data-schedule-date="2026-08-11"]') + .should('contain.text', 'Rest day') + .and('not.contain.text', 'Chapter 1') + cy.get('[data-schedule-date="2026-08-12"]').should( + 'contain.text', + 'Exodus / Shemot / Chapter 1', + ) + }) + + it('validates the schedule form before submitting', () => { + cy.intercept('GET', '**/api/sets/41', { + statusCode: 200, + body: bibleLayout, + }).as('layout') + cy.intercept('POST', '**/api/schedules').as('createSchedule') + + cy.visit('/sets/41/schedules/new') + cy.wait('@me') + cy.wait('@layout') + cy.get('form').submit() + + cy.get('#schedule-level-error') + .should('have.text', 'Choose a level to schedule.') + .and('be.visible') + cy.get('#schedule-start-date-error') + .should('have.text', 'Choose a start date.') + .and('be.visible') + cy.get('#schedule-target-date-error') + .should('have.text', 'Choose a target date.') + .and('be.visible') + cy.get('@createSchedule.all').should('have.length', 0) + + cy.get('#schedule-level').select('3') + cy.get('#schedule-start-date').type('2026-08-12') + cy.get('#schedule-target-date').type('2026-08-10') + cy.get('form').submit() + cy.get('#schedule-target-date-error').should( + 'have.text', + 'Target date cannot be before the start date.', + ) + cy.get('@createSchedule.all').should('have.length', 0) + }) + + it('loads a persisted schedule directly and handles missing schedules', () => { + cy.intercept('GET', '**/api/schedules/73', { + statusCode: 200, + body: scheduleDetail, + }).as('schedule') + + cy.visit('/schedules/73') + cy.wait('@me') + cy.wait('@schedule') + cy.get('h1').should('have.text', 'Bible schedule') + cy.get('.eyebrow') + .should(($eyebrow) => { + expect($eyebrow.text().trim()).to.equal('Chapter_sections-v2 plan') + }) + .find('.schedule-kind') + .should('have.text', 'Chapter_sections-v2') + .should('have.css', 'text-transform', 'none') + cy.contains('2 Chapter_sections-v2 assignments across 3 days').should('be.visible') + + cy.intercept('GET', '**/api/schedules/74', { + statusCode: 404, + body: { error: 'schedule not found' }, + }).as('missingSchedule') + cy.visit('/schedules/74') + cy.wait('@missingSchedule') + cy.get('h1').should('have.text', 'Schedule not found') + }) + + it('lists the users schedules on the dashboard', () => { + cy.intercept('GET', '**/api/sets', { + statusCode: 200, + body: { sets: [] }, + }).as('sets') + cy.intercept('GET', '**/api/schedules', (request) => { + expect(request.headers.accept).to.equal('application/json') + request.reply({ + statusCode: 200, + body: { + schedules: [ + { + id: 73, + set: { name: 'Bible' }, + elementKind: 'Chapter_sections-v2', + startDate: '2026-08-10', + targetDate: '2026-08-12', + assignmentCount: 2, + }, + ], + }, + }) + }).as('schedules') + + cy.visit('/dashboard') + cy.wait('@me') + cy.wait('@sets') + cy.wait('@schedules') + + cy.get('#schedules-heading').should('have.text', 'Your schedules') + cy.get('ul[aria-label="Your schedules"]') + .should('contain.text', 'Bible') + .and('contain.text', '2 assignments') + cy.get('.schedule-card__heading p') + .should('have.text', 'Chapter_sections-v2') + .and('have.css', 'text-transform', 'none') + cy.contains('a', 'Bible').should('have.attr', 'href', '/schedules/73') + }) +}) diff --git a/frontend/website/cypress/e2e/sets-dashboard.cy.ts b/frontend/website/cypress/e2e/sets-dashboard.cy.ts index 4b6fc2d..2557ded 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', () => { @@ -64,10 +68,10 @@ describe('sets dashboard', () => { cy.visit('/dashboard') cy.wait('@me') - cy.get('[role="status"]').should('have.text', 'Loading sets...') + cy.get('.sets-catalog [role="status"]').should('have.text', 'Loading sets...') cy.wait('@sets') - cy.get('[role="status"]').should( + cy.get('.sets-catalog [role="status"]').should( 'contain.text', 'No sets are available yet.', ) diff --git a/frontend/website/src/components/ElementTree.vue b/frontend/website/src/components/ElementTree.vue index 6cc1302..54d7e39 100644 --- a/frontend/website/src/components/ElementTree.vue +++ b/frontend/website/src/components/ElementTree.vue @@ -87,7 +87,7 @@ defineProps<{ font-size: 0.62rem; font-weight: 800; letter-spacing: 0.1em; - text-transform: uppercase; + text-transform: none; } @media (max-width: 37.5rem) { 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..f8844e6 --- /dev/null +++ b/frontend/website/src/stores/schedules.ts @@ -0,0 +1,219 @@ +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({ + name: z.string().min(1), + }), + elementKind: z.string().min(1), + startDate: isoDateSchema, + targetDate: isoDateSchema, + assignmentCount: z.number().int().nonnegative(), +}) + +const scheduleAssignmentSchema = z.object({ + id: z.number().int().positive(), + element: z.object({ + 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 + levelId: number + 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/stores/setLayout.ts b/frontend/website/src/stores/setLayout.ts index 919c108..2b097c4 100644 --- a/frontend/website/src/stores/setLayout.ts +++ b/frontend/website/src/stores/setLayout.ts @@ -8,6 +8,7 @@ export const setElementNodeSchema = z.object({ id: z.number().int().positive(), name: z.string().min(1), kind: z.string().min(1), + levelId: z.number().int().positive(), get children() { return z.array(setElementNodeSchema) }, @@ -18,6 +19,14 @@ export const setLayoutResponseSchema = z.object({ id: z.number().int().positive(), name: z.string().min(1), }), + levels: z.array( + z.object({ + id: z.number().int().positive(), + kind: z.string().min(1), + depth: z.number().int().nonnegative(), + elementCount: z.number().int().nonnegative(), + }), + ), elements: z.array(setElementNodeSchema), }) 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..deea7a2 --- /dev/null +++ b/frontend/website/src/views/CreateScheduleView.vue @@ -0,0 +1,384 @@ + + + + + diff --git a/frontend/website/src/views/DashboardView.vue b/frontend/website/src/views/DashboardView.vue index 9c77bf4..12464ee 100644 --- a/frontend/website/src/views/DashboardView.vue +++ b/frontend/website/src/views/DashboardView.vue @@ -3,20 +3,83 @@ 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 formatDate(value: string): string { + return new Intl.DateTimeFormat('en', { + dateStyle: 'medium', + timeZone: 'UTC', + }).format(new Date(`${value}T00:00:00Z`)) +}