Compare commits

..

12 commits

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:
* 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.

View file

@ -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.

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\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);

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;
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',

View file

@ -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',

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());
}
}