Merge branch 'feature/elements'

This commit is contained in:
Yisroel Baum 2026-08-08 22:51:25 +03:00
commit 936855efab
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
14 changed files with 728 additions and 1 deletions

View file

@ -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: 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 level of the collection to schedule
* The start date * The start date
* The target completion date * The target completion date
@ -47,6 +47,10 @@ For example, a user could choose to schedule:
* Smaller sections within each chapter * Smaller sections within each chapter
* Any other available level in the collection structure * 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 ## Daily Progress
Users can view the items scheduled for each day and mark them complete as they finish them. Users can view the items scheduled for each day and mark them complete as they finish them.

View file

@ -9,6 +9,9 @@ these rules.
- Attainly is in early development. - Attainly is in early development.
- Attainly helps users break hierarchical goals into scheduled assignments, - Attainly helps users break hierarchical goals into scheduled assignments,
complete daily work, and track progress toward a target date. 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 - Schedule recalculation must preserve completed work while redistributing
unfinished assignments across the remaining dates. unfinished assignments across the remaining dates.
- Planned features in `README.md` are future ideas, not authorized scope. - Planned features in `README.md` are future ideas, not authorized scope.

View file

@ -0,0 +1,15 @@
<?php
namespace App\Element;
use App\Set\Set;
final readonly class CreateElementDto
{
public function __construct(
public Set $set,
public string $name,
public string $kind,
public ?Element $parentElement,
) {}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Element;
use App\Set\Set;
final readonly class Element
{
public function __construct(
private int $id,
private string $name,
private string $kind,
private Set $set,
private ?Element $parentElement,
private int $position,
) {}
public function getId(): int
{
return $this->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;
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Element;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property int $set_id
* @property string $name
* @property string $kind
* @property int|null $parent_element_id
* @property int $position
*
* @method static Builder<static>|ElementModel newModelQuery()
* @method static Builder<static>|ElementModel newQuery()
* @method static Builder<static>|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<string, string>
*/
protected function casts(): array
{
return [
'set_id' => 'integer',
'parent_element_id' => 'integer',
'position' => 'integer',
];
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Element;
use App\Set\Set;
use DomainException;
interface ElementRepository
{
/**
* @throws DomainException
*/
public function create(CreateElementDto $dto): Element;
public function find(int $id): ?Element;
/**
* @return list<Element>
*/
public function findTopLevelBySet(Set $set): array;
/**
* @return list<Element>
*/
public function findByParentElement(Element $parentElement): array;
}

View file

@ -0,0 +1,171 @@
<?php
namespace App\Element;
use App\Set\Set;
use App\Set\SetRepository;
use DomainException;
use RuntimeException;
class EloquentElementRepository implements ElementRepository
{
public function __construct(
private SetRepository $setRepository,
) {}
public function create(CreateElementDto $dto): Element
{
$this->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');
}
}

View file

@ -16,6 +16,8 @@ use App\Email\Emailer;
use App\Email\EmailFactory; use App\Email\EmailFactory;
use App\Email\LaravelEmailer; use App\Email\LaravelEmailer;
use App\Email\LaravelEmailFactory; use App\Email\LaravelEmailFactory;
use App\Element\ElementRepository;
use App\Element\EloquentElementRepository;
use App\Set\EloquentSetRepository; use App\Set\EloquentSetRepository;
use App\Set\SetRepository; use App\Set\SetRepository;
use App\User\EloquentUserRepository; use App\User\EloquentUserRepository;
@ -51,6 +53,10 @@ class AppServiceProvider extends ServiceProvider
SetRepository::class, SetRepository::class,
EloquentSetRepository::class, EloquentSetRepository::class,
); );
$this->app->bind(
ElementRepository::class,
EloquentElementRepository::class,
);
$this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class); $this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class);
$this->app->bind(TokenGenerator::class, RandomTokenGenerator::class); $this->app->bind(TokenGenerator::class, RandomTokenGenerator::class);
$this->app->bind(Clock::class, SystemClock::class); $this->app->bind(Clock::class, SystemClock::class);

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('elements', function (Blueprint $table): void {
$table->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');
}
};

View file

@ -0,0 +1,147 @@
<?php
namespace Tests\Fakes;
use App\Element\CreateElementDto;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Set\Set;
use DomainException;
class FakeElementRepository implements ElementRepository
{
/**
* @var array<int, Element>
*/
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<int, Element> $elements
* @return list<Element>
*/
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(),
);
}
}

View file

@ -3,6 +3,7 @@
namespace Tests\Feature\Auth; namespace Tests\Feature\Auth;
use App\Auth\CreateSessionDto; use App\Auth\CreateSessionDto;
use App\Auth\Clock;
use App\Auth\SessionRepository; use App\Auth\SessionRepository;
use App\Http\Middleware\AuthMiddleware; use App\Http\Middleware\AuthMiddleware;
use App\Shared\ValueObject\EmailAddress; use App\Shared\ValueObject\EmailAddress;
@ -11,6 +12,7 @@ use App\User\UserRepository;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeZone; use DateTimeZone;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Fakes\FakeClock;
use Tests\TestCase; use Tests\TestCase;
class LogoutEndpointTest extends TestCase class LogoutEndpointTest extends TestCase
@ -23,6 +25,7 @@ class LogoutEndpointTest extends TestCase
'2026-07-31T12:00:00', '2026-07-31T12:00:00',
new DateTimeZone('UTC'), new DateTimeZone('UTC'),
); );
$this->app->instance(Clock::class, new FakeClock($now));
$user = app(UserRepository::class)->create(new CreateUserDto( $user = app(UserRepository::class)->create(new CreateUserDto(
email: new EmailAddress('user@example.com'), email: new EmailAddress('user@example.com'),
passwordHash: 'hashed-password', passwordHash: 'hashed-password',

View file

@ -3,6 +3,7 @@
namespace Tests\Feature\Auth; namespace Tests\Feature\Auth;
use App\Auth\CreateSessionDto; use App\Auth\CreateSessionDto;
use App\Auth\Clock;
use App\Auth\SessionRepository; use App\Auth\SessionRepository;
use App\Http\Middleware\AuthMiddleware; use App\Http\Middleware\AuthMiddleware;
use App\Shared\ValueObject\EmailAddress; use App\Shared\ValueObject\EmailAddress;
@ -11,6 +12,7 @@ use App\User\UserRepository;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeZone; use DateTimeZone;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Fakes\FakeClock;
use Tests\TestCase; use Tests\TestCase;
class MeEndpointTest extends TestCase class MeEndpointTest extends TestCase
@ -23,6 +25,7 @@ class MeEndpointTest extends TestCase
'2026-07-31T12:00:00', '2026-07-31T12:00:00',
new DateTimeZone('UTC'), new DateTimeZone('UTC'),
); );
$this->app->instance(Clock::class, new FakeClock($now));
$user = app(UserRepository::class)->create(new CreateUserDto( $user = app(UserRepository::class)->create(new CreateUserDto(
email: new EmailAddress('user@example.com'), email: new EmailAddress('user@example.com'),
passwordHash: 'hashed-password', passwordHash: 'hashed-password',

View file

@ -0,0 +1,171 @@
<?php
namespace Tests\Feature\Element;
use App\Element\CreateElementDto;
use App\Element\ElementRepository;
use App\Set\CreateSetDto;
use App\Set\Set;
use App\Set\SetRepository;
use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto;
use App\User\UserRepository;
use DomainException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class EloquentElementRepositoryTest extends TestCase
{
use RefreshDatabase;
public function testItPersistsNestedElementsInSiblingOrder(): void
{
$set = $this->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,
));
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace Tests\Unit\Element;
use App\Element\Element;
use App\Set\Set;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use PHPUnit\Framework\TestCase;
class ElementTest extends TestCase
{
public function testItExposesItsHierarchicalIdentity(): void
{
$creator = new User(
id: 7,
email: new EmailAddress('creator@example.com'),
passwordHash: 'hashed-password',
);
$set = new Set(
id: 11,
name: 'Bible',
creator: $creator,
);
$parentElement = new Element(
id: 21,
name: 'Genesis',
kind: 'book',
set: $set,
parentElement: null,
position: 1,
);
$element = new Element(
id: 22,
name: 'Genesis',
kind: 'portion',
set: $set,
parentElement: $parentElement,
position: 2,
);
$this->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());
}
}