diff --git a/README.md b/README.md index e800d97..d4ded6f 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The structure is flexible and is not limited to reading plans. Attainly can be u When creating a schedule, the user selects: -* The collection or portion they want to complete +* The set they want to complete * The level of the collection to schedule * The start date * The target completion date @@ -47,6 +47,10 @@ For example, a user could choose to schedule: * Smaller sections within each chapter * Any other available level in the collection structure +Schedules always cover a whole set. To schedule only a subset of an existing +set, the user creates a separate set containing that subset and gives it its +own name. + ## Daily Progress Users can view the items scheduled for each day and mark them complete as they finish them. diff --git a/ai/shared.md b/ai/shared.md index bf52fe5..b696249 100644 --- a/ai/shared.md +++ b/ai/shared.md @@ -9,6 +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. +- 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. - 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 new file mode 100644 index 0000000..6f7df77 --- /dev/null +++ b/backend/app/Element/CreateElementDto.php @@ -0,0 +1,15 @@ +id; + } + + public function getName(): string + { + return $this->name; + } + + public function getKind(): string + { + return $this->kind; + } + + public function getSet(): Set + { + return $this->set; + } + + public function getParentElement(): ?Element + { + return $this->parentElement; + } + + public function getPosition(): int + { + return $this->position; + } +} diff --git a/backend/app/Element/ElementModel.php b/backend/app/Element/ElementModel.php new file mode 100644 index 0000000..6eb345d --- /dev/null +++ b/backend/app/Element/ElementModel.php @@ -0,0 +1,47 @@ +|ElementModel newModelQuery() + * @method static Builder|ElementModel newQuery() + * @method static Builder|ElementModel query() + * + * @mixin \Eloquent + */ +#[Fillable([ + 'set_id', + 'name', + 'kind', + 'parent_element_id', + 'position', +])] +class ElementModel extends Model +{ + protected $table = 'elements'; + + public $timestamps = false; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'set_id' => 'integer', + 'parent_element_id' => 'integer', + 'position' => 'integer', + ]; + } +} diff --git a/backend/app/Element/ElementRepository.php b/backend/app/Element/ElementRepository.php new file mode 100644 index 0000000..fc46687 --- /dev/null +++ b/backend/app/Element/ElementRepository.php @@ -0,0 +1,26 @@ + + */ + public function findTopLevelBySet(Set $set): array; + + /** + * @return list + */ + public function findByParentElement(Element $parentElement): array; +} diff --git a/backend/app/Element/EloquentElementRepository.php b/backend/app/Element/EloquentElementRepository.php new file mode 100644 index 0000000..cfd93f7 --- /dev/null +++ b/backend/app/Element/EloquentElementRepository.php @@ -0,0 +1,171 @@ +validateParentSet($dto); + $position = $this->nextPosition( + $dto->set, + $dto->parentElement, + ); + $model = ElementModel::create([ + 'set_id' => $dto->set->getId(), + 'name' => $dto->name, + 'kind' => $dto->kind, + 'parent_element_id' => $dto->parentElement?->getId(), + 'position' => $position, + ]); + + return new Element( + id: $model->id, + name: $model->name, + kind: $model->kind, + set: $dto->set, + parentElement: $dto->parentElement, + position: $model->position, + ); + } + + public function find(int $id): ?Element + { + $model = ElementModel::find($id); + + return $model === null ? null : $this->toDomain($model); + } + + public function findTopLevelBySet(Set $set): array + { + $models = ElementModel::query() + ->where('set_id', $set->getId()) + ->whereNull('parent_element_id') + ->orderBy('position') + ->orderBy('id') + ->get(); + $elements = []; + + foreach ($models as $model) { + $elements[] = $this->toDomainWithRelations( + model: $model, + set: $set, + parentElement: null, + ); + } + + return $elements; + } + + 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') + ->get(); + $elements = []; + + foreach ($models as $model) { + $elements[] = $this->toDomainWithRelations( + model: $model, + set: $parentElement->getSet(), + parentElement: $parentElement, + ); + } + + return $elements; + } + + /** + * @throws DomainException + */ + private function validateParentSet(CreateElementDto $dto): void + { + $parentElement = $dto->parentElement; + if ($parentElement === null) { + return; + } + + if ($parentElement->getSet()->getId() !== $dto->set->getId()) { + throw new DomainException( + 'parent element must belong to the same set', + ); + } + } + + private function nextPosition( + Set $set, + ?Element $parentElement, + ): int { + $query = ElementModel::query() + ->where('set_id', $set->getId()); + if ($parentElement === null) { + $query->whereNull('parent_element_id'); + } else { + $query->where('parent_element_id', $parentElement->getId()); + } + + $currentMaximum = $query->max('position'); + if ($currentMaximum === null) { + return 1; + } + + return (int) $currentMaximum + 1; + } + + private function toDomain(ElementModel $model): Element + { + $set = $this->findSet($model->set_id); + + $parentElement = null; + if ($model->parent_element_id !== null) { + $parentElement = $this->find($model->parent_element_id); + if ($parentElement === null) { + throw new RuntimeException('element parent not found'); + } + } + + return $this->toDomainWithRelations( + model: $model, + set: $set, + parentElement: $parentElement, + ); + } + + private function toDomainWithRelations( + ElementModel $model, + Set $set, + ?Element $parentElement, + ): Element { + return new Element( + id: $model->id, + name: $model->name, + kind: $model->kind, + set: $set, + parentElement: $parentElement, + position: $model->position, + ); + } + + private function findSet(int $id): Set + { + foreach ($this->setRepository->all() as $set) { + if ($set->getId() === $id) { + return $set; + } + } + + throw new RuntimeException('element set not found'); + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index c9490ba..8d883fe 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -16,6 +16,8 @@ use App\Email\Emailer; use App\Email\EmailFactory; use App\Email\LaravelEmailer; use App\Email\LaravelEmailFactory; +use App\Element\ElementRepository; +use App\Element\EloquentElementRepository; use App\Set\EloquentSetRepository; use App\Set\SetRepository; use App\User\EloquentUserRepository; @@ -51,6 +53,10 @@ class AppServiceProvider extends ServiceProvider SetRepository::class, EloquentSetRepository::class, ); + $this->app->bind( + ElementRepository::class, + EloquentElementRepository::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/database/migrations/2026_08_08_000000_create_elements_table.php b/backend/database/migrations/2026_08_08_000000_create_elements_table.php new file mode 100644 index 0000000..196c2c5 --- /dev/null +++ b/backend/database/migrations/2026_08_08_000000_create_elements_table.php @@ -0,0 +1,35 @@ +id(); + $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_id', + 'parent_element_id', + 'position', + ]); + }); + } + + public function down(): void + { + Schema::dropIfExists('elements'); + } +}; diff --git a/backend/tests/Fakes/FakeElementRepository.php b/backend/tests/Fakes/FakeElementRepository.php new file mode 100644 index 0000000..e44cb53 --- /dev/null +++ b/backend/tests/Fakes/FakeElementRepository.php @@ -0,0 +1,147 @@ + + */ + private array $elements = []; + + public function create(CreateElementDto $dto): Element + { + $this->validateParentSet($dto); + $id = count($this->elements) + 1; + $element = new Element( + id: $id, + name: $dto->name, + kind: $dto->kind, + set: $dto->set, + parentElement: $dto->parentElement, + position: $this->nextPosition($dto), + ); + $this->elements[$id] = $element; + + return $this->copy($element); + } + + public function find(int $id): ?Element + { + $element = $this->elements[$id] ?? null; + + return $element === null ? null : $this->copy($element); + } + + public function findTopLevelBySet(Set $set): array + { + $elements = array_filter( + $this->elements, + function (Element $element) use ($set): bool { + return $element->getSet()->getId() === $set->getId() + && $element->getParentElement() === null; + }, + ); + + return $this->orderedCopies($elements); + } + + public function findByParentElement(Element $parentElement): array + { + $elements = array_filter( + $this->elements, + function (Element $element) use ($parentElement): bool { + return $element->getSet()->getId() + === $parentElement->getSet()->getId() + && $element->getParentElement()?->getId() + === $parentElement->getId(); + }, + ); + + return $this->orderedCopies($elements); + } + + /** + * @throws DomainException + */ + private function validateParentSet(CreateElementDto $dto): void + { + $parentElement = $dto->parentElement; + if ($parentElement === null) { + return; + } + + if ($parentElement->getSet()->getId() !== $dto->set->getId()) { + throw new DomainException( + 'parent element must belong to the same set', + ); + } + } + + private function nextPosition(CreateElementDto $dto): int + { + $requestedParentId = $dto->parentElement?->getId(); + $maximumPosition = 0; + + foreach ($this->elements as $element) { + if ($element->getSet()->getId() !== $dto->set->getId()) { + continue; + } + + $elementParentId = $element->getParentElement()?->getId(); + if ($elementParentId !== $requestedParentId) { + continue; + } + + $maximumPosition = max( + $maximumPosition, + $element->getPosition(), + ); + } + + return $maximumPosition + 1; + } + + /** + * @param array $elements + * @return list + */ + private function orderedCopies(array $elements): array + { + usort($elements, function (Element $first, Element $second): int { + $positionComparison = $first->getPosition() + <=> $second->getPosition(); + if ($positionComparison !== 0) { + return $positionComparison; + } + + return $first->getId() <=> $second->getId(); + }); + + return array_map(function (Element $element): Element { + return $this->copy($element); + }, $elements); + } + + private function copy(Element $element): Element + { + $parentElement = $element->getParentElement(); + + return new Element( + id: $element->getId(), + name: $element->getName(), + kind: $element->getKind(), + set: $element->getSet(), + parentElement: $parentElement === null + ? null + : $this->copy($parentElement), + position: $element->getPosition(), + ); + } +} diff --git a/backend/tests/Feature/Auth/LogoutEndpointTest.php b/backend/tests/Feature/Auth/LogoutEndpointTest.php index 3986cb9..b593664 100644 --- a/backend/tests/Feature/Auth/LogoutEndpointTest.php +++ b/backend/tests/Feature/Auth/LogoutEndpointTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Auth; use App\Auth\CreateSessionDto; +use App\Auth\Clock; use App\Auth\SessionRepository; use App\Http\Middleware\AuthMiddleware; use App\Shared\ValueObject\EmailAddress; @@ -11,6 +12,7 @@ use App\User\UserRepository; use DateTimeImmutable; use DateTimeZone; use Illuminate\Foundation\Testing\RefreshDatabase; +use Tests\Fakes\FakeClock; use Tests\TestCase; class LogoutEndpointTest extends TestCase @@ -23,6 +25,7 @@ class LogoutEndpointTest extends TestCase '2026-07-31T12:00:00', new DateTimeZone('UTC'), ); + $this->app->instance(Clock::class, new FakeClock($now)); $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), passwordHash: 'hashed-password', diff --git a/backend/tests/Feature/Auth/MeEndpointTest.php b/backend/tests/Feature/Auth/MeEndpointTest.php index 3cabaf1..e776adb 100644 --- a/backend/tests/Feature/Auth/MeEndpointTest.php +++ b/backend/tests/Feature/Auth/MeEndpointTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Auth; use App\Auth\CreateSessionDto; +use App\Auth\Clock; use App\Auth\SessionRepository; use App\Http\Middleware\AuthMiddleware; use App\Shared\ValueObject\EmailAddress; @@ -11,6 +12,7 @@ use App\User\UserRepository; use DateTimeImmutable; use DateTimeZone; use Illuminate\Foundation\Testing\RefreshDatabase; +use Tests\Fakes\FakeClock; use Tests\TestCase; class MeEndpointTest extends TestCase @@ -23,6 +25,7 @@ class MeEndpointTest extends TestCase '2026-07-31T12:00:00', new DateTimeZone('UTC'), ); + $this->app->instance(Clock::class, new FakeClock($now)); $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), passwordHash: 'hashed-password', diff --git a/backend/tests/Feature/Element/EloquentElementRepositoryTest.php b/backend/tests/Feature/Element/EloquentElementRepositoryTest.php new file mode 100644 index 0000000..737403a --- /dev/null +++ b/backend/tests/Feature/Element/EloquentElementRepositoryTest.php @@ -0,0 +1,171 @@ +createSet('Bible'); + $repository = app(ElementRepository::class); + $genesisBook = $repository->create(new CreateElementDto( + set: $set, + name: 'Genesis', + kind: 'book', + parentElement: null, + )); + $exodusBook = $repository->create(new CreateElementDto( + set: $set, + name: 'Exodus', + kind: 'book', + parentElement: null, + )); + $genesisPortion = $repository->create(new CreateElementDto( + set: $set, + name: 'Genesis', + kind: 'portion', + parentElement: $genesisBook, + )); + $noahPortion = $repository->create(new CreateElementDto( + set: $set, + name: 'Noah', + kind: 'portion', + parentElement: $genesisBook, + )); + + $this->assertSame(1, $genesisBook->getPosition()); + $this->assertSame(2, $exodusBook->getPosition()); + $this->assertSame(1, $genesisPortion->getPosition()); + $this->assertSame(2, $noahPortion->getPosition()); + $this->assertDatabaseHas('elements', [ + 'id' => $genesisPortion->getId(), + 'set_id' => $set->getId(), + 'name' => 'Genesis', + 'kind' => 'portion', + 'parent_element_id' => $genesisBook->getId(), + 'position' => 1, + ]); + + $foundElement = $repository->find($noahPortion->getId()); + + $this->assertNotNull($foundElement); + $this->assertSame('Noah', $foundElement->getName()); + $this->assertSame('portion', $foundElement->getKind()); + $this->assertSame($set->getId(), $foundElement->getSet()->getId()); + $this->assertSame( + $genesisBook->getId(), + $foundElement->getParentElement()?->getId(), + ); + } + + public function testItRejectsAParentFromAnotherSet(): void + { + $bible = $this->createSet('Bible'); + $course = $this->createSet('Course'); + $repository = app(ElementRepository::class); + $book = $repository->create(new CreateElementDto( + set: $bible, + name: 'Genesis', + kind: 'book', + parentElement: null, + )); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage( + 'parent element must belong to the same set', + ); + + $repository->create(new CreateElementDto( + set: $course, + name: 'Invalid lesson', + kind: 'lesson', + parentElement: $book, + )); + } + + public function testItListsOrderedElementsWithinTheirTreeScope(): void + { + $bible = $this->createSet('Bible'); + $course = $this->createSet('Course'); + $repository = app(ElementRepository::class); + $genesisBook = $repository->create(new CreateElementDto( + set: $bible, + name: 'Genesis', + kind: 'book', + parentElement: null, + )); + $exodusBook = $repository->create(new CreateElementDto( + set: $bible, + name: 'Exodus', + kind: 'book', + parentElement: null, + )); + $repository->create(new CreateElementDto( + set: $course, + name: 'Module 1', + kind: 'module', + parentElement: null, + )); + $repository->create(new CreateElementDto( + set: $bible, + name: 'Genesis', + kind: 'portion', + parentElement: $genesisBook, + )); + $repository->create(new CreateElementDto( + set: $bible, + name: 'Noah', + kind: 'portion', + parentElement: $genesisBook, + )); + $repository->create(new CreateElementDto( + set: $bible, + name: 'Shemot', + kind: 'portion', + parentElement: $exodusBook, + )); + + $topLevelElements = $repository->findTopLevelBySet($bible); + $childElements = $repository->findByParentElement($genesisBook); + + $this->assertSame( + ['Genesis', 'Exodus'], + array_map(function ($element): string { + return $element->getName(); + }, $topLevelElements), + ); + $this->assertSame( + ['Genesis', 'Noah'], + array_map(function ($element): string { + return $element->getName(); + }, $childElements), + ); + } + + private function createSet(string $name): Set + { + $creator = 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: $creator, + )); + } +} diff --git a/backend/tests/Unit/Element/ElementTest.php b/backend/tests/Unit/Element/ElementTest.php new file mode 100644 index 0000000..1a99b31 --- /dev/null +++ b/backend/tests/Unit/Element/ElementTest.php @@ -0,0 +1,49 @@ +assertSame(22, $element->getId()); + $this->assertSame('Genesis', $element->getName()); + $this->assertSame('portion', $element->getKind()); + $this->assertSame($set, $element->getSet()); + $this->assertSame($parentElement, $element->getParentElement()); + $this->assertSame(2, $element->getPosition()); + } +}