diff --git a/ai/backend-context.md b/ai/backend-context.md index 7fc2f71..9850c44 100644 --- a/ai/backend-context.md +++ b/ai/backend-context.md @@ -69,9 +69,6 @@ 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 1d2997c..a2acb1e 100644 --- a/ai/frontend-context.md +++ b/ai/frontend-context.md @@ -57,9 +57,6 @@ 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 ed0a14a..b696249 100644 --- a/ai/shared.md +++ b/ai/shared.md @@ -9,17 +9,9 @@ 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. -- 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 +- Schedules target whole sets. Users choose an element kind 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 d8a9fdc..6f7df77 100644 --- a/backend/app/Element/CreateElementDto.php +++ b/backend/app/Element/CreateElementDto.php @@ -2,13 +2,14 @@ namespace App\Element; -use App\Set\SetLevel; +use App\Set\Set; final readonly class CreateElementDto { public function __construct( + public Set $set, public string $name, - public SetLevel $level, + public string $kind, public ?Element $parentElement, ) {} } diff --git a/backend/app/Element/Element.php b/backend/app/Element/Element.php index 5eb6fe8..5e4c6bd 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 SetLevel $level, + private string $kind, + private Set $set, private ?Element $parentElement, private int $position, ) {} @@ -27,17 +27,12 @@ final readonly class Element public function getKind(): string { - return $this->level->getKind(); - } - - public function getLevel(): SetLevel - { - return $this->level; + return $this->kind; } public function getSet(): Set { - return $this->level->getSet(); + return $this->set; } public function getParentElement(): ?Element diff --git a/backend/app/Element/ElementModel.php b/backend/app/Element/ElementModel.php index 5a228d7..6eb345d 100644 --- a/backend/app/Element/ElementModel.php +++ b/backend/app/Element/ElementModel.php @@ -8,8 +8,9 @@ use Illuminate\Database\Eloquent\Model; /** * @property int $id - * @property int $set_level_id + * @property int $set_id * @property string $name + * @property string $kind * @property int|null $parent_element_id * @property int $position * @@ -20,8 +21,9 @@ use Illuminate\Database\Eloquent\Model; * @mixin \Eloquent */ #[Fillable([ - 'set_level_id', + 'set_id', 'name', + 'kind', 'parent_element_id', 'position', ])] @@ -37,7 +39,7 @@ class ElementModel extends Model protected function casts(): array { return [ - 'set_level_id' => 'integer', + 'set_id' => 'integer', 'parent_element_id' => 'integer', 'position' => 'integer', ]; diff --git a/backend/app/Element/EloquentElementRepository.php b/backend/app/Element/EloquentElementRepository.php index fd12d58..1f30e24 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\SetLevel; -use App\Set\SetLevelRepository; +use App\Set\SetRepository; use DomainException; use RuntimeException; class EloquentElementRepository implements ElementRepository { public function __construct( - private SetLevelRepository $setLevelRepository, + private SetRepository $setRepository, ) {} public function create(CreateElementDto $dto): Element { - $this->validateLevel($dto); + $this->validateParentSet($dto); $position = $this->nextPosition( - $dto->level, + $dto->set, $dto->parentElement, ); $model = ElementModel::create([ - 'set_level_id' => $dto->level->getId(), + 'set_id' => $dto->set->getId(), 'name' => $dto->name, + 'kind' => $dto->kind, 'parent_element_id' => $dto->parentElement?->getId(), 'position' => $position, ]); @@ -31,7 +31,8 @@ class EloquentElementRepository implements ElementRepository return new Element( id: $model->id, name: $model->name, - level: $dto->level, + kind: $model->kind, + set: $dto->set, parentElement: $dto->parentElement, position: $model->position, ); @@ -46,27 +47,14 @@ 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() - ->whereIn('set_level_id', array_keys($levelsById)) + ->where('set_id', $set->getId()) ->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] @@ -78,7 +66,7 @@ class EloquentElementRepository implements ElementRepository $element = $this->toDomainWithRelations( model: $model, - level: $level, + set: $set, parentElement: $parentElement, ); $elements[] = $element; @@ -90,14 +78,8 @@ 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_level_id', $topLevel->getId()) + ->where('set_id', $set->getId()) ->whereNull('parent_element_id') ->orderBy('position') ->orderBy('id') @@ -107,7 +89,7 @@ class EloquentElementRepository implements ElementRepository foreach ($models as $model) { $elements[] = $this->toDomainWithRelations( model: $model, - level: $topLevel, + set: $set, parentElement: null, ); } @@ -118,6 +100,7 @@ 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') @@ -125,10 +108,9 @@ class EloquentElementRepository implements ElementRepository $elements = []; foreach ($models as $model) { - $level = $this->findLevel($model->set_level_id); $elements[] = $this->toDomainWithRelations( model: $model, - level: $level, + set: $parentElement->getSet(), parentElement: $parentElement, ); } @@ -139,44 +121,26 @@ class EloquentElementRepository implements ElementRepository /** * @throws DomainException */ - private function validateLevel(CreateElementDto $dto): void + private function validateParentSet(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->level->getSet()->getId() - ) { + if ($parentElement->getSet()->getId() !== $dto->set->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( - SetLevel $level, + Set $set, ?Element $parentElement, ): int { $query = ElementModel::query() - ->where('set_level_id', $level->getId()); + ->where('set_id', $set->getId()); if ($parentElement === null) { $query->whereNull('parent_element_id'); } else { @@ -193,7 +157,7 @@ class EloquentElementRepository implements ElementRepository private function toDomain(ElementModel $model): Element { - $level = $this->findLevel($model->set_level_id); + $set = $this->findSet($model->set_id); $parentElement = null; if ($model->parent_element_id !== null) { @@ -205,32 +169,33 @@ class EloquentElementRepository implements ElementRepository return $this->toDomainWithRelations( model: $model, - level: $level, + set: $set, parentElement: $parentElement, ); } private function toDomainWithRelations( ElementModel $model, - SetLevel $level, + Set $set, ?Element $parentElement, ): Element { return new Element( id: $model->id, name: $model->name, - level: $level, + kind: $model->kind, + set: $set, parentElement: $parentElement, position: $model->position, ); } - private function findLevel(int $id): SetLevel + private function findSet(int $id): Set { - $level = $this->setLevelRepository->find($id); - if ($level !== null) { - return $level; + $set = $this->setRepository->find($id); + if ($set !== null) { + return $set; } - throw new RuntimeException('element level not found'); + throw new RuntimeException('element set not found'); } } diff --git a/backend/app/Http/Controllers/ScheduleController.php b/backend/app/Http/Controllers/ScheduleController.php deleted file mode 100644 index cc8cd60..0000000 --- a/backend/app/Http/Controllers/ScheduleController.php +++ /dev/null @@ -1,175 +0,0 @@ -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 fe09b72..024e83c 100644 --- a/backend/app/Http/Controllers/SetController.php +++ b/backend/app/Http/Controllers/SetController.php @@ -7,7 +7,6 @@ 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; @@ -50,19 +49,6 @@ 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); @@ -77,7 +63,6 @@ class SetController extends Controller * id: int, * name: string, * kind: string, - * levelId: int, * children: list * } */ @@ -89,7 +74,6 @@ 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 aaf8dfd..8d883fe 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -19,11 +19,7 @@ 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; @@ -57,18 +53,10 @@ 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 deleted file mode 100644 index 6acf329..0000000 --- a/backend/app/Schedule/CreateScheduleAssignmentDto.php +++ /dev/null @@ -1,19 +0,0 @@ - $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 deleted file mode 100644 index 5c5ffdc..0000000 --- a/backend/app/Schedule/CreateScheduleDto.php +++ /dev/null @@ -1,21 +0,0 @@ - $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 deleted file mode 100644 index 31bb859..0000000 --- a/backend/app/Schedule/EloquentScheduleRepository.php +++ /dev/null @@ -1,99 +0,0 @@ - $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 deleted file mode 100644 index 0a14ce8..0000000 --- a/backend/app/Schedule/Schedule.php +++ /dev/null @@ -1,60 +0,0 @@ - $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 deleted file mode 100644 index 482b45d..0000000 --- a/backend/app/Schedule/ScheduleAssignment.php +++ /dev/null @@ -1,53 +0,0 @@ - $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 deleted file mode 100644 index f0f33df..0000000 --- a/backend/app/Schedule/ScheduleAssignmentModel.php +++ /dev/null @@ -1,49 +0,0 @@ - $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 deleted file mode 100644 index f19fb97..0000000 --- a/backend/app/Schedule/ScheduleModel.php +++ /dev/null @@ -1,45 +0,0 @@ -|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 deleted file mode 100644 index bd02515..0000000 --- a/backend/app/Schedule/ScheduleRepository.php +++ /dev/null @@ -1,17 +0,0 @@ - - */ - public function findAllForUser(User $user): array; -} diff --git a/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php b/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php deleted file mode 100644 index fb8635e..0000000 --- a/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php +++ /dev/null @@ -1,240 +0,0 @@ -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 deleted file mode 100644 index 70ab5db..0000000 --- a/backend/app/Schedule/UseCases/CreateSchedule/CreateScheduleRequest.php +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index c737d1e..0000000 --- a/backend/app/Schedule/UseCases/GetSchedule/GetScheduleRequest.php +++ /dev/null @@ -1,13 +0,0 @@ - - */ - 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 deleted file mode 100644 index 1920a50..0000000 --- a/backend/app/Set/CreateSetLevelDto.php +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 23b5a99..0000000 --- a/backend/app/Set/SetLevel.php +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 50daf3e..0000000 --- a/backend/app/Set/SetLevelModel.php +++ /dev/null @@ -1,38 +0,0 @@ -|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 deleted file mode 100644 index ba4b68c..0000000 --- a/backend/app/Set/SetLevelRepository.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ - 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 12c59d9..39085e1 100644 --- a/backend/app/Set/UseCases/GetSetLayout/GetSetLayout.php +++ b/backend/app/Set/UseCases/GetSetLayout/GetSetLayout.php @@ -6,13 +6,11 @@ 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, ) {} @@ -26,15 +24,10 @@ class GetSetLayout throw new NotFoundException('set not found'); } - $elements = $this->elementRepository->findBySet($set); $elementsByParentId = []; - $elementCountsByLevelId = []; - foreach ($elements as $element) { + foreach ($this->elementRepository->findBySet($set) as $element) { $parentId = $element->getParentElement()?->getId() ?? 0; $elementsByParentId[$parentId][] = $element; - $levelId = $element->getLevel()->getId(); - $elementCountsByLevelId[$levelId] - = ($elementCountsByLevelId[$levelId] ?? 0) + 1; } foreach ($elementsByParentId as &$siblings) { @@ -53,17 +46,8 @@ 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 69cf008..c98974a 100644 --- a/backend/app/Set/UseCases/GetSetLayout/SetLayout.php +++ b/backend/app/Set/UseCases/GetSetLayout/SetLayout.php @@ -7,12 +7,10 @@ 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, ) {} @@ -21,14 +19,6 @@ 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 deleted file mode 100644 index 6ebac06..0000000 --- a/backend/app/Set/UseCases/GetSetLayout/SetLayoutLevel.php +++ /dev/null @@ -1,23 +0,0 @@ -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 322817a..6dffb92 100644 --- a/backend/app/Shared/Http/RequestInput.php +++ b/backend/app/Shared/Http/RequestInput.php @@ -20,11 +20,4 @@ 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 deleted file mode 100644 index 552c31a..0000000 --- a/backend/database/migrations/2026_08_07_000000_create_set_levels_table.php +++ /dev/null @@ -1,27 +0,0 @@ -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 04fe1d4..196c2c5 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,17 +10,18 @@ return new class extends Migration { Schema::create('elements', function (Blueprint $table): void { $table->id(); - $table->foreignId('set_level_id') - ->constrained('set_levels') + $table->foreignId('set_id') + ->constrained('sets') ->restrictOnDelete(); $table->string('name'); + $table->string('kind'); $table->foreignId('parent_element_id') ->nullable() ->constrained('elements') ->restrictOnDelete(); $table->unsignedInteger('position'); $table->index([ - 'set_level_id', + 'set_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 deleted file mode 100644 index 390b246..0000000 --- a/backend/database/migrations/2026_08_10_000000_create_schedules_table.php +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index b52c60a..0000000 --- a/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php +++ /dev/null @@ -1,37 +0,0 @@ -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 afd1905..c90217c 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -13,7 +13,6 @@ 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 52fe294..bde3ed9 100644 --- a/backend/database/seeders/ElementSeeder.php +++ b/backend/database/seeders/ElementSeeder.php @@ -6,11 +6,8 @@ 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 { @@ -18,31 +15,38 @@ 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' => [], ], ], @@ -51,26 +55,32 @@ 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' => [], ], ], @@ -79,25 +89,31 @@ 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' => [], ], ], @@ -106,9 +122,11 @@ class ElementSeeder extends Seeder ], [ 'name' => 'Build Phase', + 'kind' => 'phase', 'children' => [ [ 'name' => 'Full Body Circuit', + 'kind' => 'workout', 'children' => [], ], ], @@ -119,7 +137,6 @@ 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; @@ -130,8 +147,6 @@ class ElementSeeder extends Seeder $this->seedChildren( repository: $elementRepository, set: $set, - levels: $setLevelRepository->findBySet($set), - depth: 0, parentElement: null, definitions: $definitions, ); @@ -141,27 +156,16 @@ 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); @@ -170,12 +174,13 @@ class ElementSeeder extends Seeder $element = $this->findSibling( siblings: $siblings, name: $definition['name'], - level: $level, + kind: $definition['kind'], ); if ($element === null) { $element = $repository->create(new CreateElementDto( + set: $set, name: $definition['name'], - level: $level, + kind: $definition['kind'], parentElement: $parentElement, )); $siblings[] = $element; @@ -184,8 +189,6 @@ class ElementSeeder extends Seeder $this->seedChildren( repository: $repository, set: $set, - levels: $levels, - depth: $depth + 1, parentElement: $element, definitions: $definition['children'], ); @@ -198,12 +201,12 @@ class ElementSeeder extends Seeder private function findSibling( array $siblings, string $name, - SetLevel $level, + string $kind, ): ?Element { foreach ($siblings as $sibling) { if ( $sibling->getName() === $name - && $sibling->getLevel()->getId() === $level->getId() + && $sibling->getKind() === $kind ) { return $sibling; } diff --git a/backend/database/seeders/SetLevelSeeder.php b/backend/database/seeders/SetLevelSeeder.php deleted file mode 100644 index 72c239c..0000000 --- a/backend/database/seeders/SetLevelSeeder.php +++ /dev/null @@ -1,45 +0,0 @@ - ['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 7f984c5..9b70efe 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -2,7 +2,6 @@ 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; @@ -15,9 +14,5 @@ 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 628a37f..1e9200d 100644 --- a/backend/tests/Fakes/FakeElementRepository.php +++ b/backend/tests/Fakes/FakeElementRepository.php @@ -22,7 +22,8 @@ class FakeElementRepository implements ElementRepository $element = new Element( id: $id, name: $dto->name, - level: $dto->level, + kind: $dto->kind, + set: $dto->set, parentElement: $dto->parentElement, position: $this->nextPosition($dto), ); @@ -87,32 +88,14 @@ 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->level->getSet()->getId() - ) { + if ($parentElement->getSet()->getId() !== $dto->set->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 @@ -121,10 +104,7 @@ class FakeElementRepository implements ElementRepository $maximumPosition = 0; foreach ($this->elements as $element) { - if ( - $element->getSet()->getId() - !== $dto->level->getSet()->getId() - ) { + if ($element->getSet()->getId() !== $dto->set->getId()) { continue; } @@ -170,7 +150,8 @@ class FakeElementRepository implements ElementRepository return new Element( id: $element->getId(), name: $element->getName(), - level: $element->getLevel(), + kind: $element->getKind(), + set: $element->getSet(), parentElement: $parentElement === null ? null : $this->copy($parentElement), diff --git a/backend/tests/Fakes/FakeScheduleRepository.php b/backend/tests/Fakes/FakeScheduleRepository.php deleted file mode 100644 index de1ae76..0000000 --- a/backend/tests/Fakes/FakeScheduleRepository.php +++ /dev/null @@ -1,101 +0,0 @@ - - */ - 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 deleted file mode 100644 index 0f3f076..0000000 --- a/backend/tests/Fakes/FakeSetLevelRepository.php +++ /dev/null @@ -1,76 +0,0 @@ - - */ - 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 59d8be8..e6ce46e 100644 --- a/backend/tests/Feature/Database/DatabaseSeederTest.php +++ b/backend/tests/Feature/Database/DatabaseSeederTest.php @@ -6,7 +6,6 @@ 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; @@ -71,12 +70,6 @@ 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', @@ -107,16 +100,9 @@ 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 9cc6020..76766c5 100644 --- a/backend/tests/Feature/Element/EloquentElementRepositoryTest.php +++ b/backend/tests/Feature/Element/EloquentElementRepositoryTest.php @@ -5,10 +5,7 @@ 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; @@ -25,26 +22,28 @@ 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', - level: $bookLevel, + kind: 'book', parentElement: null, )); $exodusBook = $repository->create(new CreateElementDto( + set: $set, name: 'Exodus', - level: $bookLevel, + kind: 'book', parentElement: null, )); $genesisPortion = $repository->create(new CreateElementDto( + set: $set, name: 'Genesis', - level: $portionLevel, + kind: 'portion', parentElement: $genesisBook, )); $noahPortion = $repository->create(new CreateElementDto( + set: $set, name: 'Noah', - level: $portionLevel, + kind: 'portion', parentElement: $genesisBook, )); @@ -54,8 +53,9 @@ class EloquentElementRepositoryTest extends TestCase $this->assertSame(2, $noahPortion->getPosition()); $this->assertDatabaseHas('elements', [ 'id' => $genesisPortion->getId(), - 'set_level_id' => $portionLevel->getId(), + 'set_id' => $set->getId(), 'name' => 'Genesis', + 'kind' => 'portion', 'parent_element_id' => $genesisBook->getId(), 'position' => 1, ]); @@ -65,10 +65,6 @@ 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(), @@ -81,11 +77,10 @@ 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', - level: $bookLevel, + kind: 'book', parentElement: null, )); @@ -95,51 +90,9 @@ class EloquentElementRepositoryTest extends TestCase ); $repository->create(new CreateElementDto( + set: $course, name: 'Invalid 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, + kind: 'lesson', parentElement: $book, )); } @@ -149,37 +102,40 @@ 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', - level: $bookLevel, + kind: 'book', parentElement: null, )); $exodusBook = $repository->create(new CreateElementDto( + set: $bible, name: 'Exodus', - level: $bookLevel, + kind: 'book', parentElement: null, )); $repository->create(new CreateElementDto( + set: $course, name: 'Module 1', - level: $moduleLevel, + kind: 'module', parentElement: null, )); $repository->create(new CreateElementDto( + set: $bible, name: 'Genesis', - level: $portionLevel, + kind: 'portion', parentElement: $genesisBook, )); $repository->create(new CreateElementDto( + set: $bible, name: 'Noah', - level: $portionLevel, + kind: 'portion', parentElement: $genesisBook, )); $repository->create(new CreateElementDto( + set: $bible, name: 'Shemot', - level: $portionLevel, + kind: 'portion', parentElement: $exodusBook, )); @@ -205,22 +161,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', - level: $bookLevel, + kind: 'book', parentElement: null, )); $repository->create(new CreateElementDto( + set: $bible, name: 'Creation', - level: $portionLevel, + kind: 'portion', parentElement: $genesis, )); $repository->create(new CreateElementDto( + set: $course, name: 'Foundations', - level: $moduleLevel, + kind: 'module', parentElement: null, )); @@ -251,12 +207,4 @@ 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 deleted file mode 100644 index 0303dfe..0000000 --- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php +++ /dev/null @@ -1,391 +0,0 @@ -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 deleted file mode 100644 index 29f1c40..0000000 --- a/backend/tests/Feature/Set/EloquentSetLevelRepositoryTest.php +++ /dev/null @@ -1,92 +0,0 @@ -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 de3f898..074f7f5 100644 --- a/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php +++ b/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php @@ -8,10 +8,7 @@ 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; @@ -32,27 +29,28 @@ 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', - level: $bookLevel, + kind: 'book', parentElement: null, )); $repository->create(new CreateElementDto( + set: $set, name: 'Exodus', - level: $bookLevel, + kind: 'book', parentElement: null, )); $creation = $repository->create(new CreateElementDto( + set: $set, name: 'Creation', - level: $portionLevel, + kind: 'portion', parentElement: $genesis, )); $chapter = $repository->create(new CreateElementDto( + set: $set, name: 'Chapter 1', - level: $chapterLevel, + kind: 'chapter', parentElement: $creation, )); $this->createSession($user); @@ -64,44 +62,21 @@ 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' => [], ], ], @@ -112,7 +87,6 @@ class GetSetLayoutEndpointTest extends TestCase 'id' => $genesis->getId() + 1, 'name' => 'Exodus', 'kind' => 'book', - 'levelId' => $bookLevel->getId(), 'children' => [], ], ], @@ -123,7 +97,6 @@ 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()}"); @@ -133,12 +106,6 @@ class GetSetLayoutEndpointTest extends TestCase 'id' => $set->getId(), 'name' => 'Empty set', ], - 'levels' => [[ - 'id' => $taskLevel->getId(), - 'kind' => 'task', - 'depth' => 0, - 'elementCount' => 0, - ]], 'elements' => [], ]); } @@ -180,14 +147,6 @@ 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( @@ -198,7 +157,7 @@ class GetSetLayoutEndpointTest extends TestCase token: 'valid-token', user: $user, createdAt: $createdAt, - expiresAt: $createdAt->modify('+10 years'), + expiresAt: $createdAt->modify('+7 days'), )); } diff --git a/backend/tests/Feature/Set/ListSetsEndpointTest.php b/backend/tests/Feature/Set/ListSetsEndpointTest.php index e37c464..6f8085b 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('+10 years'), + expiresAt: $createdAt->modify('+7 days'), )); } } diff --git a/backend/tests/Unit/Element/ElementTest.php b/backend/tests/Unit/Element/ElementTest.php index 3acd12d..1a99b31 100644 --- a/backend/tests/Unit/Element/ElementTest.php +++ b/backend/tests/Unit/Element/ElementTest.php @@ -4,7 +4,6 @@ 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; @@ -23,29 +22,19 @@ 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', - level: $bookLevel, + kind: 'book', + set: $set, parentElement: null, position: 1, ); $element = new Element( id: 22, name: 'Genesis', - level: $portionLevel, + kind: 'portion', + set: $set, parentElement: $parentElement, position: 2, ); @@ -53,7 +42,6 @@ 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 deleted file mode 100644 index 02b7a83..0000000 --- a/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php +++ /dev/null @@ -1,441 +0,0 @@ -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 2b4a7f1..69a7714 100644 --- a/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php +++ b/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php @@ -5,7 +5,6 @@ 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; @@ -14,7 +13,6 @@ use App\User\User; use PHPUnit\Framework\TestCase; use Tests\Fakes\FakeElementRepository; use Tests\Fakes\FakeSetRepository; -use Tests\Fakes\FakeSetLevelRepository; class GetSetLayoutTest extends TestCase { @@ -26,7 +24,6 @@ class GetSetLayoutTest extends TestCase passwordHash: 'hashed-password', ); $setRepository = new FakeSetRepository; - $setLevelRepository = new FakeSetLevelRepository; $elementRepository = new FakeElementRepository; $bible = $setRepository->create(new CreateSetDto( name: 'Bible', @@ -36,69 +33,51 @@ class GetSetLayoutTest extends TestCase name: 'Course', creator: $creator, )); - $bookLevel = $setLevelRepository->create(new CreateSetLevelDto( - set: $bible, - 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( + set: $bible, name: 'Genesis', - level: $bookLevel, + kind: 'book', parentElement: null, )); $elementRepository->create(new CreateElementDto( + set: $bible, name: 'Exodus', - level: $bookLevel, + kind: 'book', parentElement: null, )); $creation = $elementRepository->create(new CreateElementDto( + set: $bible, name: 'Creation', - level: $portionLevel, + kind: 'portion', parentElement: $genesis, )); $elementRepository->create(new CreateElementDto( + set: $bible, name: 'Noah', - level: $portionLevel, + kind: 'portion', parentElement: $genesis, )); $elementRepository->create(new CreateElementDto( + set: $bible, name: 'Chapter 1', - level: $chapterLevel, + kind: 'chapter', parentElement: $creation, )); $elementRepository->create(new CreateElementDto( + set: $course, name: 'Foundations', - level: $moduleLevel, + kind: 'module', 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()), @@ -124,26 +103,19 @@ 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 @@ -153,7 +125,6 @@ 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 2ef0274..deabca6 100644 --- a/frontend/website/cypress/e2e/confirm-email.cy.ts +++ b/frontend/website/cypress/e2e/confirm-email.cy.ts @@ -13,10 +13,6 @@ 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 00d0d19..75c441c 100644 --- a/frontend/website/cypress/e2e/guest-auth.cy.ts +++ b/frontend/website/cypress/e2e/guest-auth.cy.ts @@ -31,14 +31,6 @@ 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 eb88490..defb942 100644 --- a/frontend/website/cypress/e2e/session-auth.cy.ts +++ b/frontend/website/cypress/e2e/session-auth.cy.ts @@ -43,10 +43,6 @@ 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 aad6b24..5430694 100644 --- a/frontend/website/cypress/e2e/set-layout.cy.ts +++ b/frontend/website/cypress/e2e/set-layout.cy.ts @@ -5,37 +5,29 @@ 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_sections-v2', - levelId: 3, + kind: 'chapter', children: [], }, ], }, - { id: 5, name: 'Noah', kind: 'portion', levelId: 2, children: [] }, + { id: 5, name: 'Noah', kind: 'portion', children: [] }, ], }, - { id: 2, name: 'Exodus', kind: 'book', levelId: 1, children: [] }, + { id: 2, name: 'Exodus', kind: 'book', children: [] }, ], } @@ -90,16 +82,14 @@ describe('set element layout', () => { .and('have.attr', 'data-element-id', '4') cy.contains('.element-node__card', 'Chapter 1') .should('be.visible') - .find('.element-node__kind') - .should('have.text', 'Chapter_sections-v2') - .and('have.css', 'text-transform', 'none') + .and('contain.text', 'chapter') }) it('shows loading and empty layout states', () => { cy.intercept('GET', '**/api/sets/41', { delay: 500, statusCode: 200, - body: { set: { id: 41, name: 'Empty set' }, levels: [], elements: [] }, + body: { set: { id: 41, name: 'Empty set' }, 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 deleted file mode 100644 index 5747d6b..0000000 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ /dev/null @@ -1,268 +0,0 @@ -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 2557ded..4b6fc2d 100644 --- a/frontend/website/cypress/e2e/sets-dashboard.cy.ts +++ b/frontend/website/cypress/e2e/sets-dashboard.cy.ts @@ -13,10 +13,6 @@ 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', () => { @@ -68,10 +64,10 @@ describe('sets dashboard', () => { cy.visit('/dashboard') cy.wait('@me') - cy.get('.sets-catalog [role="status"]').should('have.text', 'Loading sets...') + cy.get('[role="status"]').should('have.text', 'Loading sets...') cy.wait('@sets') - cy.get('.sets-catalog [role="status"]').should( + cy.get('[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 54d7e39..6cc1302 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: none; + text-transform: uppercase; } @media (max-width: 37.5rem) { diff --git a/frontend/website/src/router/index.ts b/frontend/website/src/router/index.ts index d4fad22..08f1ad0 100644 --- a/frontend/website/src/router/index.ts +++ b/frontend/website/src/router/index.ts @@ -71,22 +71,6 @@ 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 deleted file mode 100644 index f8844e6..0000000 --- a/frontend/website/src/stores/schedules.ts +++ /dev/null @@ -1,219 +0,0 @@ -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 2b097c4..919c108 100644 --- a/frontend/website/src/stores/setLayout.ts +++ b/frontend/website/src/stores/setLayout.ts @@ -8,7 +8,6 @@ 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) }, @@ -19,14 +18,6 @@ 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 fa7661f..a121333 100644 --- a/frontend/website/src/styles/main.css +++ b/frontend/website/src/styles/main.css @@ -31,8 +31,7 @@ body { } button, -input, -select { +input { font: inherit; } diff --git a/frontend/website/src/views/CreateScheduleView.vue b/frontend/website/src/views/CreateScheduleView.vue deleted file mode 100644 index deea7a2..0000000 --- a/frontend/website/src/views/CreateScheduleView.vue +++ /dev/null @@ -1,384 +0,0 @@ - - - - - diff --git a/frontend/website/src/views/DashboardView.vue b/frontend/website/src/views/DashboardView.vue index 12464ee..9c77bf4 100644 --- a/frontend/website/src/views/DashboardView.vue +++ b/frontend/website/src/views/DashboardView.vue @@ -3,83 +3,20 @@ 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 Promise.all([setsStore.fetchSets(), schedulesStore.fetchSchedules()]) + await setsStore.fetchSets() }) - -function formatDate(value: string): string { - return new Intl.DateTimeFormat('en', { - dateStyle: 'medium', - timeZone: 'UTC', - }).format(new Date(`${value}T00:00:00Z`)) -}