diff --git a/README.md b/README.md index d4ded6f..e800d97 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 set they want to complete +* The collection or portion they want to complete * The level of the collection to schedule * The start date * The target completion date @@ -47,10 +47,6 @@ 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 b696249..bf52fe5 100644 --- a/ai/shared.md +++ b/ai/shared.md @@ -9,9 +9,6 @@ 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 deleted file mode 100644 index 6f7df77..0000000 --- a/backend/app/Element/CreateElementDto.php +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 6eb345d..0000000 --- a/backend/app/Element/ElementModel.php +++ /dev/null @@ -1,47 +0,0 @@ -|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 deleted file mode 100644 index fc46687..0000000 --- a/backend/app/Element/ElementRepository.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ - 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 deleted file mode 100644 index cfd93f7..0000000 --- a/backend/app/Element/EloquentElementRepository.php +++ /dev/null @@ -1,171 +0,0 @@ -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 8d883fe..c9490ba 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -16,8 +16,6 @@ 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; @@ -53,10 +51,6 @@ 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 deleted file mode 100644 index 196c2c5..0000000 --- a/backend/database/migrations/2026_08_08_000000_create_elements_table.php +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index e44cb53..0000000 --- a/backend/tests/Fakes/FakeElementRepository.php +++ /dev/null @@ -1,147 +0,0 @@ - - */ - 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 b593664..3986cb9 100644 --- a/backend/tests/Feature/Auth/LogoutEndpointTest.php +++ b/backend/tests/Feature/Auth/LogoutEndpointTest.php @@ -3,7 +3,6 @@ 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; @@ -12,7 +11,6 @@ use App\User\UserRepository; use DateTimeImmutable; use DateTimeZone; use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\Fakes\FakeClock; use Tests\TestCase; class LogoutEndpointTest extends TestCase @@ -25,7 +23,6 @@ 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 e776adb..3cabaf1 100644 --- a/backend/tests/Feature/Auth/MeEndpointTest.php +++ b/backend/tests/Feature/Auth/MeEndpointTest.php @@ -3,7 +3,6 @@ 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; @@ -12,7 +11,6 @@ use App\User\UserRepository; use DateTimeImmutable; use DateTimeZone; use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\Fakes\FakeClock; use Tests\TestCase; class MeEndpointTest extends TestCase @@ -25,7 +23,6 @@ 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 deleted file mode 100644 index 737403a..0000000 --- a/backend/tests/Feature/Element/EloquentElementRepositoryTest.php +++ /dev/null @@ -1,171 +0,0 @@ -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 deleted file mode 100644 index 1a99b31..0000000 --- a/backend/tests/Unit/Element/ElementTest.php +++ /dev/null @@ -1,49 +0,0 @@ -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()); - } -}