Merge branch 'set-scheduling'

This commit is contained in:
Yisroel Baum 2026-08-14 14:16:21 +03:00
commit 66d9c8cbd7
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
67 changed files with 4183 additions and 147 deletions

View file

@ -69,6 +69,9 @@ intentionally unclaimed; the built-in health endpoint is `/up`.
relevant authorization guard instead of duplicating all business branches. relevant authorization guard instead of duplicating all business branches.
- Place endpoint tests under `tests/Feature/<Area>/`, extend - Place endpoint tests under `tests/Feature/<Area>/`, extend
`Tests\TestCase`, and use `RefreshDatabase` when database state is involved. `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. - Development and runtime use PostgreSQL through the local Unix socket.
PHPUnit uses SQLite `:memory:` as configured in `phpunit.xml`, so feature PHPUnit uses SQLite `:memory:` as configured in `phpunit.xml`, so feature
tests are self-contained and do not need the worktree stack. tests are self-contained and do not need the worktree stack.

View file

@ -57,6 +57,9 @@ backend and does not proxy the frontend.
- Keep application-wide resets and base styles in `src/styles/main.css`. - Keep application-wide resets and base styles in `src/styles/main.css`.
Keep component and view styles scoped and match established Attainly visual Keep component and view styles scoped and match established Attainly visual
patterns. 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, - Inspect similar files before introducing a component, composable, store,
route, or data-access pattern. route, or data-access pattern.

View file

@ -9,9 +9,17 @@ 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 - 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
granularity. To schedule a subset, create a separate named set containing granularity. To schedule a subset, create a separate named set containing
that subset. 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 - 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

@ -2,14 +2,13 @@
namespace App\Element; namespace App\Element;
use App\Set\Set; use App\Set\SetLevel;
final readonly class CreateElementDto final readonly class CreateElementDto
{ {
public function __construct( public function __construct(
public Set $set,
public string $name, public string $name,
public string $kind, public SetLevel $level,
public ?Element $parentElement, public ?Element $parentElement,
) {} ) {}
} }

View file

@ -3,14 +3,14 @@
namespace App\Element; namespace App\Element;
use App\Set\Set; use App\Set\Set;
use App\Set\SetLevel;
final readonly class Element final readonly class Element
{ {
public function __construct( public function __construct(
private int $id, private int $id,
private string $name, private string $name,
private string $kind, private SetLevel $level,
private Set $set,
private ?Element $parentElement, private ?Element $parentElement,
private int $position, private int $position,
) {} ) {}
@ -27,12 +27,17 @@ final readonly class Element
public function getKind(): string public function getKind(): string
{ {
return $this->kind; return $this->level->getKind();
}
public function getLevel(): SetLevel
{
return $this->level;
} }
public function getSet(): Set public function getSet(): Set
{ {
return $this->set; return $this->level->getSet();
} }
public function getParentElement(): ?Element public function getParentElement(): ?Element

View file

@ -8,9 +8,8 @@ use Illuminate\Database\Eloquent\Model;
/** /**
* @property int $id * @property int $id
* @property int $set_id * @property int $set_level_id
* @property string $name * @property string $name
* @property string $kind
* @property int|null $parent_element_id * @property int|null $parent_element_id
* @property int $position * @property int $position
* *
@ -21,9 +20,8 @@ use Illuminate\Database\Eloquent\Model;
* @mixin \Eloquent * @mixin \Eloquent
*/ */
#[Fillable([ #[Fillable([
'set_id', 'set_level_id',
'name', 'name',
'kind',
'parent_element_id', 'parent_element_id',
'position', 'position',
])] ])]
@ -39,7 +37,7 @@ class ElementModel extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'set_id' => 'integer', 'set_level_id' => 'integer',
'parent_element_id' => 'integer', 'parent_element_id' => 'integer',
'position' => 'integer', 'position' => 'integer',
]; ];

View file

@ -3,27 +3,27 @@
namespace App\Element; namespace App\Element;
use App\Set\Set; use App\Set\Set;
use App\Set\SetRepository; use App\Set\SetLevel;
use App\Set\SetLevelRepository;
use DomainException; use DomainException;
use RuntimeException; use RuntimeException;
class EloquentElementRepository implements ElementRepository class EloquentElementRepository implements ElementRepository
{ {
public function __construct( public function __construct(
private SetRepository $setRepository, private SetLevelRepository $setLevelRepository,
) {} ) {}
public function create(CreateElementDto $dto): Element public function create(CreateElementDto $dto): Element
{ {
$this->validateParentSet($dto); $this->validateLevel($dto);
$position = $this->nextPosition( $position = $this->nextPosition(
$dto->set, $dto->level,
$dto->parentElement, $dto->parentElement,
); );
$model = ElementModel::create([ $model = ElementModel::create([
'set_id' => $dto->set->getId(), 'set_level_id' => $dto->level->getId(),
'name' => $dto->name, 'name' => $dto->name,
'kind' => $dto->kind,
'parent_element_id' => $dto->parentElement?->getId(), 'parent_element_id' => $dto->parentElement?->getId(),
'position' => $position, 'position' => $position,
]); ]);
@ -31,8 +31,7 @@ class EloquentElementRepository implements ElementRepository
return new Element( return new Element(
id: $model->id, id: $model->id,
name: $model->name, name: $model->name,
kind: $model->kind, level: $dto->level,
set: $dto->set,
parentElement: $dto->parentElement, parentElement: $dto->parentElement,
position: $model->position, position: $model->position,
); );
@ -47,14 +46,27 @@ class EloquentElementRepository implements ElementRepository
public function findBySet(Set $set): array public function findBySet(Set $set): array
{ {
$levelsById = [];
foreach ($this->setLevelRepository->findBySet($set) as $level) {
$levelsById[$level->getId()] = $level;
}
if ($levelsById === []) {
return [];
}
$models = ElementModel::query() $models = ElementModel::query()
->where('set_id', $set->getId()) ->whereIn('set_level_id', array_keys($levelsById))
->orderBy('id') ->orderBy('id')
->get(); ->get();
$elements = []; $elements = [];
$elementsById = []; $elementsById = [];
foreach ($models as $model) { foreach ($models as $model) {
$level = $levelsById[$model->set_level_id] ?? null;
if ($level === null) {
throw new RuntimeException('element level not found');
}
$parentElement = null; $parentElement = null;
if ($model->parent_element_id !== null) { if ($model->parent_element_id !== null) {
$parentElement = $elementsById[$model->parent_element_id] $parentElement = $elementsById[$model->parent_element_id]
@ -66,7 +78,7 @@ class EloquentElementRepository implements ElementRepository
$element = $this->toDomainWithRelations( $element = $this->toDomainWithRelations(
model: $model, model: $model,
set: $set, level: $level,
parentElement: $parentElement, parentElement: $parentElement,
); );
$elements[] = $element; $elements[] = $element;
@ -78,8 +90,14 @@ class EloquentElementRepository implements ElementRepository
public function findTopLevelBySet(Set $set): array public function findTopLevelBySet(Set $set): array
{ {
$levels = $this->setLevelRepository->findBySet($set);
if ($levels === []) {
return [];
}
$topLevel = $levels[0];
$models = ElementModel::query() $models = ElementModel::query()
->where('set_id', $set->getId()) ->where('set_level_id', $topLevel->getId())
->whereNull('parent_element_id') ->whereNull('parent_element_id')
->orderBy('position') ->orderBy('position')
->orderBy('id') ->orderBy('id')
@ -89,7 +107,7 @@ class EloquentElementRepository implements ElementRepository
foreach ($models as $model) { foreach ($models as $model) {
$elements[] = $this->toDomainWithRelations( $elements[] = $this->toDomainWithRelations(
model: $model, model: $model,
set: $set, level: $topLevel,
parentElement: null, parentElement: null,
); );
} }
@ -100,7 +118,6 @@ class EloquentElementRepository implements ElementRepository
public function findByParentElement(Element $parentElement): array public function findByParentElement(Element $parentElement): array
{ {
$models = ElementModel::query() $models = ElementModel::query()
->where('set_id', $parentElement->getSet()->getId())
->where('parent_element_id', $parentElement->getId()) ->where('parent_element_id', $parentElement->getId())
->orderBy('position') ->orderBy('position')
->orderBy('id') ->orderBy('id')
@ -108,9 +125,10 @@ class EloquentElementRepository implements ElementRepository
$elements = []; $elements = [];
foreach ($models as $model) { foreach ($models as $model) {
$level = $this->findLevel($model->set_level_id);
$elements[] = $this->toDomainWithRelations( $elements[] = $this->toDomainWithRelations(
model: $model, model: $model,
set: $parentElement->getSet(), level: $level,
parentElement: $parentElement, parentElement: $parentElement,
); );
} }
@ -121,26 +139,44 @@ class EloquentElementRepository implements ElementRepository
/** /**
* @throws DomainException * @throws DomainException
*/ */
private function validateParentSet(CreateElementDto $dto): void private function validateLevel(CreateElementDto $dto): void
{ {
$parentElement = $dto->parentElement; $parentElement = $dto->parentElement;
if ($parentElement === null) { if ($parentElement === null) {
if ($dto->level->getDepth() !== 0) {
throw new DomainException(
'root element must use the first set level',
);
}
return; return;
} }
if ($parentElement->getSet()->getId() !== $dto->set->getId()) { if (
$parentElement->getSet()->getId()
!== $dto->level->getSet()->getId()
) {
throw new DomainException( throw new DomainException(
'parent element must belong to the same set', '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( private function nextPosition(
Set $set, SetLevel $level,
?Element $parentElement, ?Element $parentElement,
): int { ): int {
$query = ElementModel::query() $query = ElementModel::query()
->where('set_id', $set->getId()); ->where('set_level_id', $level->getId());
if ($parentElement === null) { if ($parentElement === null) {
$query->whereNull('parent_element_id'); $query->whereNull('parent_element_id');
} else { } else {
@ -157,7 +193,7 @@ class EloquentElementRepository implements ElementRepository
private function toDomain(ElementModel $model): Element private function toDomain(ElementModel $model): Element
{ {
$set = $this->findSet($model->set_id); $level = $this->findLevel($model->set_level_id);
$parentElement = null; $parentElement = null;
if ($model->parent_element_id !== null) { if ($model->parent_element_id !== null) {
@ -169,33 +205,32 @@ class EloquentElementRepository implements ElementRepository
return $this->toDomainWithRelations( return $this->toDomainWithRelations(
model: $model, model: $model,
set: $set, level: $level,
parentElement: $parentElement, parentElement: $parentElement,
); );
} }
private function toDomainWithRelations( private function toDomainWithRelations(
ElementModel $model, ElementModel $model,
Set $set, SetLevel $level,
?Element $parentElement, ?Element $parentElement,
): Element { ): Element {
return new Element( return new Element(
id: $model->id, id: $model->id,
name: $model->name, name: $model->name,
kind: $model->kind, level: $level,
set: $set,
parentElement: $parentElement, parentElement: $parentElement,
position: $model->position, position: $model->position,
); );
} }
private function findSet(int $id): Set private function findLevel(int $id): SetLevel
{ {
$set = $this->setRepository->find($id); $level = $this->setLevelRepository->find($id);
if ($set !== null) { if ($level !== null) {
return $set; return $level;
} }
throw new RuntimeException('element set not found'); throw new RuntimeException('element level not found');
} }
} }

View file

@ -0,0 +1,175 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Schedule\Schedule;
use App\Schedule\ScheduleAssignment;
use App\Schedule\UseCases\CreateSchedule\CreateSchedule;
use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest;
use App\Schedule\UseCases\GetSchedule\GetSchedule;
use App\Schedule\UseCases\GetSchedule\GetScheduleRequest;
use App\Schedule\UseCases\ListSchedules\ListSchedules;
use App\Shared\Http\RequestInput;
use App\User\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ScheduleController extends Controller
{
public function __construct(
private CreateSchedule $createSchedule,
private ListSchedules $listSchedules,
private GetSchedule $getSchedule,
) {}
public function store(Request $request): JsonResponse
{
$input = new RequestInput($request);
$user = $this->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<string, mixed>
*/
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<string, mixed>
*/
private function detailPayload(Schedule $schedule): array
{
$payload = $this->summaryPayload($schedule);
$payload['days'] = $this->dayPayloads($schedule);
return $payload;
}
/**
* @return list<array{date: string, assignments: list<mixed>}>
*/
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<string>
* }}
*/
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;
}
}

View file

@ -7,6 +7,7 @@ use App\Set\Set;
use App\Set\UseCases\GetSetLayout\ElementLayoutNode; use App\Set\UseCases\GetSetLayout\ElementLayoutNode;
use App\Set\UseCases\GetSetLayout\GetSetLayout; use App\Set\UseCases\GetSetLayout\GetSetLayout;
use App\Set\UseCases\GetSetLayout\GetSetLayoutRequest; use App\Set\UseCases\GetSetLayout\GetSetLayoutRequest;
use App\Set\UseCases\GetSetLayout\SetLayoutLevel;
use App\Set\UseCases\ListSets\ListSets; use App\Set\UseCases\ListSets\ListSets;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@ -49,6 +50,19 @@ class SetController extends Controller
'id' => $set->getId(), 'id' => $set->getId(),
'name' => $set->getName(), '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( 'elements' => array_map(
function (ElementLayoutNode $node): array { function (ElementLayoutNode $node): array {
return $this->elementPayload($node); return $this->elementPayload($node);
@ -63,6 +77,7 @@ class SetController extends Controller
* id: int, * id: int,
* name: string, * name: string,
* kind: string, * kind: string,
* levelId: int,
* children: list<mixed> * children: list<mixed>
* } * }
*/ */
@ -74,6 +89,7 @@ class SetController extends Controller
'id' => $element->getId(), 'id' => $element->getId(),
'name' => $element->getName(), 'name' => $element->getName(),
'kind' => $element->getKind(), 'kind' => $element->getKind(),
'levelId' => $element->getLevel()->getId(),
'children' => array_map( 'children' => array_map(
function (ElementLayoutNode $childNode): array { function (ElementLayoutNode $childNode): array {
return $this->elementPayload($childNode); return $this->elementPayload($childNode);

View file

@ -19,7 +19,11 @@ use App\Email\LaravelEmailFactory;
use App\Element\ElementRepository; use App\Element\ElementRepository;
use App\Element\EloquentElementRepository; use App\Element\EloquentElementRepository;
use App\Set\EloquentSetRepository; use App\Set\EloquentSetRepository;
use App\Set\EloquentSetLevelRepository;
use App\Set\SetRepository; use App\Set\SetRepository;
use App\Set\SetLevelRepository;
use App\Schedule\EloquentScheduleRepository;
use App\Schedule\ScheduleRepository;
use App\User\EloquentUserRepository; use App\User\EloquentUserRepository;
use App\User\UserRepository; use App\User\UserRepository;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
@ -53,10 +57,18 @@ class AppServiceProvider extends ServiceProvider
SetRepository::class, SetRepository::class,
EloquentSetRepository::class, EloquentSetRepository::class,
); );
$this->app->bind(
SetLevelRepository::class,
EloquentSetLevelRepository::class,
);
$this->app->bind( $this->app->bind(
ElementRepository::class, ElementRepository::class,
EloquentElementRepository::class, EloquentElementRepository::class,
); );
$this->app->bind(
ScheduleRepository::class,
EloquentScheduleRepository::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,19 @@
<?php
namespace App\Schedule;
use DateTimeImmutable;
final readonly class CreateScheduleAssignmentDto
{
/**
* @param list<string> $path
*/
public function __construct(
public string $name,
public string $kind,
public array $path,
public DateTimeImmutable $scheduledDate,
public int $position,
) {}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Schedule;
use App\User\User;
use DateTimeImmutable;
final readonly class CreateScheduleDto
{
/**
* @param list<CreateScheduleAssignmentDto> $assignments
*/
public function __construct(
public User $user,
public string $setName,
public string $elementKind,
public DateTimeImmutable $startDate,
public DateTimeImmutable $targetDate,
public array $assignments,
) {}
}

View file

@ -0,0 +1,99 @@
<?php
namespace App\Schedule;
use App\User\User;
use DateTimeImmutable;
use DateTimeZone;
use Illuminate\Support\Facades\DB;
class EloquentScheduleRepository implements ScheduleRepository
{
public function create(CreateScheduleDto $dto): Schedule
{
return DB::transaction(function () use ($dto): Schedule {
$model = ScheduleModel::create([
'user_id' => $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'));
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Schedule;
use App\User\User;
use DateTimeImmutable;
final readonly class Schedule
{
/**
* @param list<ScheduleAssignment> $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<ScheduleAssignment>
*/
public function getAssignments(): array
{
return $this->assignments;
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace App\Schedule;
use DateTimeImmutable;
final readonly class ScheduleAssignment
{
/**
* @param list<string> $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<string>
*/
public function getPath(): array
{
return $this->path;
}
public function getScheduledDate(): DateTimeImmutable
{
return $this->scheduledDate;
}
public function getPosition(): int
{
return $this->position;
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Schedule;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property int $schedule_id
* @property string $element_name
* @property string $element_kind
* @property list<string> $element_path
* @property string $scheduled_date
* @property int $position
*
* @method static Builder<static>|ScheduleAssignmentModel newModelQuery()
* @method static Builder<static>|ScheduleAssignmentModel newQuery()
* @method static Builder<static>|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<string, string>
*/
protected function casts(): array
{
return [
'schedule_id' => 'integer',
'element_path' => 'array',
'position' => 'integer',
];
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Schedule;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property int $user_id
* @property string $set_name
* @property string $element_kind
* @property string $start_date
* @property string $target_date
*
* @method static Builder<static>|ScheduleModel newModelQuery()
* @method static Builder<static>|ScheduleModel newQuery()
* @method static Builder<static>|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<string, string>
*/
protected function casts(): array
{
return [
'user_id' => 'integer',
];
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Schedule;
use App\User\User;
interface ScheduleRepository
{
public function create(CreateScheduleDto $dto): Schedule;
public function findForUser(int $id, User $user): ?Schedule;
/**
* @return list<Schedule>
*/
public function findAllForUser(User $user): array;
}

View file

@ -0,0 +1,240 @@
<?php
namespace App\Schedule\UseCases\CreateSchedule;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Schedule\CreateScheduleAssignmentDto;
use App\Schedule\CreateScheduleDto;
use App\Schedule\Schedule;
use App\Schedule\ScheduleRepository;
use App\Set\Set;
use App\Set\SetLevelRepository;
use App\Set\SetRepository;
use DateTimeImmutable;
use DateTimeZone;
class CreateSchedule
{
public function __construct(
private SetRepository $setRepository,
private SetLevelRepository $setLevelRepository,
private ElementRepository $elementRepository,
private ScheduleRepository $scheduleRepository,
) {}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(CreateScheduleRequest $request): Schedule
{
if ($request->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<Element>
*/
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<int, list<Element>> $childrenByParentId
* @param list<Element> $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<Element> $elements
* @return list<CreateScheduleAssignmentDto>
*/
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<string>
*/
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;
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Schedule\UseCases\CreateSchedule;
use App\User\User;
final readonly class CreateScheduleRequest
{
public function __construct(
public User $user,
public ?int $setId,
public ?int $levelId,
public ?string $startDate,
public ?string $targetDate,
) {}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Schedule\UseCases\GetSchedule;
use App\Exceptions\NotFoundException;
use App\Schedule\Schedule;
use App\Schedule\ScheduleRepository;
class GetSchedule
{
public function __construct(
private ScheduleRepository $scheduleRepository,
) {}
/**
* @throws NotFoundException
*/
public function execute(GetScheduleRequest $request): Schedule
{
$schedule = $this->scheduleRepository->findForUser(
$request->scheduleId,
$request->user,
);
if ($schedule === null) {
throw new NotFoundException('schedule not found');
}
return $schedule;
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Schedule\UseCases\GetSchedule;
use App\User\User;
final readonly class GetScheduleRequest
{
public function __construct(
public int $scheduleId,
public User $user,
) {}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Schedule\UseCases\ListSchedules;
use App\Schedule\Schedule;
use App\Schedule\ScheduleRepository;
use App\User\User;
class ListSchedules
{
public function __construct(
private ScheduleRepository $scheduleRepository,
) {}
/**
* @return list<Schedule>
*/
public function execute(User $user): array
{
return $this->scheduleRepository->findAllForUser($user);
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Set;
final readonly class CreateSetLevelDto
{
public function __construct(
public Set $set,
public string $kind,
) {}
}

View file

@ -0,0 +1,88 @@
<?php
namespace App\Set;
use DomainException;
use RuntimeException;
class EloquentSetLevelRepository implements SetLevelRepository
{
public function __construct(
private SetRepository $setRepository,
) {}
public function create(CreateSetLevelDto $dto): SetLevel
{
$kindExists = SetLevelModel::query()
->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,
);
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Set;
final readonly class SetLevel
{
public function __construct(
private int $id,
private Set $set,
private string $kind,
private int $depth,
) {}
public function getId(): int
{
return $this->id;
}
public function getSet(): Set
{
return $this->set;
}
public function getKind(): string
{
return $this->kind;
}
public function getDepth(): int
{
return $this->depth;
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Set;
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 $kind
* @property int $depth
*
* @method static Builder<static>|SetLevelModel newModelQuery()
* @method static Builder<static>|SetLevelModel newQuery()
* @method static Builder<static>|SetLevelModel query()
*
* @mixin \Eloquent
*/
#[Fillable(['set_id', 'kind', 'depth'])]
class SetLevelModel extends Model
{
protected $table = 'set_levels';
public $timestamps = false;
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'set_id' => 'integer',
'depth' => 'integer',
];
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Set;
use DomainException;
interface SetLevelRepository
{
/**
* @throws DomainException
*/
public function create(CreateSetLevelDto $dto): SetLevel;
public function find(int $id): ?SetLevel;
/**
* @return list<SetLevel>
*/
public function findBySet(Set $set): array;
}

View file

@ -6,11 +6,13 @@ use App\Element\Element;
use App\Element\ElementRepository; use App\Element\ElementRepository;
use App\Exceptions\NotFoundException; use App\Exceptions\NotFoundException;
use App\Set\SetRepository; use App\Set\SetRepository;
use App\Set\SetLevelRepository;
class GetSetLayout class GetSetLayout
{ {
public function __construct( public function __construct(
private SetRepository $setRepository, private SetRepository $setRepository,
private SetLevelRepository $setLevelRepository,
private ElementRepository $elementRepository, private ElementRepository $elementRepository,
) {} ) {}
@ -24,10 +26,15 @@ class GetSetLayout
throw new NotFoundException('set not found'); throw new NotFoundException('set not found');
} }
$elements = $this->elementRepository->findBySet($set);
$elementsByParentId = []; $elementsByParentId = [];
foreach ($this->elementRepository->findBySet($set) as $element) { $elementCountsByLevelId = [];
foreach ($elements as $element) {
$parentId = $element->getParentElement()?->getId() ?? 0; $parentId = $element->getParentElement()?->getId() ?? 0;
$elementsByParentId[$parentId][] = $element; $elementsByParentId[$parentId][] = $element;
$levelId = $element->getLevel()->getId();
$elementCountsByLevelId[$levelId]
= ($elementCountsByLevelId[$levelId] ?? 0) + 1;
} }
foreach ($elementsByParentId as &$siblings) { foreach ($elementsByParentId as &$siblings) {
@ -46,8 +53,17 @@ class GetSetLayout
} }
unset($siblings); unset($siblings);
$layoutLevels = [];
foreach ($this->setLevelRepository->findBySet($set) as $level) {
$layoutLevels[] = new SetLayoutLevel(
level: $level,
elementCount: $elementCountsByLevelId[$level->getId()] ?? 0,
);
}
return new SetLayout( return new SetLayout(
set: $set, set: $set,
levels: $layoutLevels,
elements: $this->buildNodes(0, $elementsByParentId), elements: $this->buildNodes(0, $elementsByParentId),
); );
} }

View file

@ -7,10 +7,12 @@ use App\Set\Set;
final readonly class SetLayout final readonly class SetLayout
{ {
/** /**
* @param list<SetLayoutLevel> $levels
* @param list<ElementLayoutNode> $elements * @param list<ElementLayoutNode> $elements
*/ */
public function __construct( public function __construct(
private Set $set, private Set $set,
private array $levels,
private array $elements, private array $elements,
) {} ) {}
@ -19,6 +21,14 @@ final readonly class SetLayout
return $this->set; return $this->set;
} }
/**
* @return list<SetLayoutLevel>
*/
public function getLevels(): array
{
return $this->levels;
}
/** /**
* @return list<ElementLayoutNode> * @return list<ElementLayoutNode>
*/ */

View file

@ -0,0 +1,23 @@
<?php
namespace App\Set\UseCases\GetSetLayout;
use App\Set\SetLevel;
final readonly class SetLayoutLevel
{
public function __construct(
private SetLevel $level,
private int $elementCount,
) {}
public function getLevel(): SetLevel
{
return $this->level;
}
public function getElementCount(): int
{
return $this->elementCount;
}
}

View file

@ -20,4 +20,11 @@ class RequestInput
return null; return null;
} }
public function integer(string $key): ?int
{
$value = $this->request->input($key);
return is_int($value) ? $value : null;
}
} }

View file

@ -0,0 +1,27 @@
<?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('set_levels', function (Blueprint $table): void {
$table->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');
}
};

View file

@ -10,18 +10,17 @@ return new class extends Migration
{ {
Schema::create('elements', function (Blueprint $table): void { Schema::create('elements', function (Blueprint $table): void {
$table->id(); $table->id();
$table->foreignId('set_id') $table->foreignId('set_level_id')
->constrained('sets') ->constrained('set_levels')
->restrictOnDelete(); ->restrictOnDelete();
$table->string('name'); $table->string('name');
$table->string('kind');
$table->foreignId('parent_element_id') $table->foreignId('parent_element_id')
->nullable() ->nullable()
->constrained('elements') ->constrained('elements')
->restrictOnDelete(); ->restrictOnDelete();
$table->unsignedInteger('position'); $table->unsignedInteger('position');
$table->index([ $table->index([
'set_id', 'set_level_id',
'parent_element_id', 'parent_element_id',
'position', 'position',
]); ]);

View file

@ -0,0 +1,28 @@
<?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('schedules', function (Blueprint $table): void {
$table->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');
}
};

View file

@ -0,0 +1,37 @@
<?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(
'schedule_assignments',
function (Blueprint $table): void {
$table->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');
}
};

View file

@ -13,6 +13,7 @@ class DatabaseSeeder extends Seeder
{ {
$this->call(UserSeeder::class); $this->call(UserSeeder::class);
$this->call(SetSeeder::class); $this->call(SetSeeder::class);
$this->call(SetLevelSeeder::class);
$this->call(ElementSeeder::class); $this->call(ElementSeeder::class);
} }
} }

View file

@ -6,8 +6,11 @@ use App\Element\CreateElementDto;
use App\Element\Element; use App\Element\Element;
use App\Element\ElementRepository; use App\Element\ElementRepository;
use App\Set\Set; use App\Set\Set;
use App\Set\SetLevel;
use App\Set\SetLevelRepository;
use App\Set\SetRepository; use App\Set\SetRepository;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use RuntimeException;
class ElementSeeder extends Seeder class ElementSeeder extends Seeder
{ {
@ -15,38 +18,31 @@ class ElementSeeder extends Seeder
'Bible' => [ 'Bible' => [
[ [
'name' => 'Genesis', 'name' => 'Genesis',
'kind' => 'book',
'children' => [ 'children' => [
[ [
'name' => 'Creation', 'name' => 'Creation',
'kind' => 'portion',
'children' => [ 'children' => [
[ [
'name' => 'Chapter 1', 'name' => 'Chapter 1',
'kind' => 'chapter',
'children' => [], 'children' => [],
], ],
[ [
'name' => 'Chapter 2', 'name' => 'Chapter 2',
'kind' => 'chapter',
'children' => [], 'children' => [],
], ],
], ],
], ],
[ [
'name' => 'Noah', 'name' => 'Noah',
'kind' => 'portion',
'children' => [], 'children' => [],
], ],
], ],
], ],
[ [
'name' => 'Exodus', 'name' => 'Exodus',
'kind' => 'book',
'children' => [ 'children' => [
[ [
'name' => 'Shemot', 'name' => 'Shemot',
'kind' => 'portion',
'children' => [], 'children' => [],
], ],
], ],
@ -55,32 +51,26 @@ class ElementSeeder extends Seeder
'Course' => [ 'Course' => [
[ [
'name' => 'Foundations', 'name' => 'Foundations',
'kind' => 'module',
'children' => [ 'children' => [
[ [
'name' => 'Welcome', 'name' => 'Welcome',
'kind' => 'lesson',
'children' => [], 'children' => [],
], ],
[ [
'name' => 'Core Concepts', 'name' => 'Core Concepts',
'kind' => 'lesson',
'children' => [], 'children' => [],
], ],
], ],
], ],
[ [
'name' => 'Applied Practice', 'name' => 'Applied Practice',
'kind' => 'module',
'children' => [ 'children' => [
[ [
'name' => 'Guided Exercise', 'name' => 'Guided Exercise',
'kind' => 'lesson',
'children' => [], 'children' => [],
], ],
[ [
'name' => 'Final Review', 'name' => 'Final Review',
'kind' => 'lesson',
'children' => [], 'children' => [],
], ],
], ],
@ -89,31 +79,25 @@ class ElementSeeder extends Seeder
'Fitness Program' => [ 'Fitness Program' => [
[ [
'name' => 'Foundation Phase', 'name' => 'Foundation Phase',
'kind' => 'phase',
'children' => [ 'children' => [
[ [
'name' => 'Strength Day', 'name' => 'Strength Day',
'kind' => 'workout',
'children' => [ 'children' => [
[ [
'name' => 'Squat', 'name' => 'Squat',
'kind' => 'exercise',
'children' => [], 'children' => [],
], ],
[ [
'name' => 'Push-up', 'name' => 'Push-up',
'kind' => 'exercise',
'children' => [], 'children' => [],
], ],
], ],
], ],
[ [
'name' => 'Mobility Day', 'name' => 'Mobility Day',
'kind' => 'workout',
'children' => [ 'children' => [
[ [
'name' => 'Hip Flow', 'name' => 'Hip Flow',
'kind' => 'exercise',
'children' => [], 'children' => [],
], ],
], ],
@ -122,11 +106,9 @@ class ElementSeeder extends Seeder
], ],
[ [
'name' => 'Build Phase', 'name' => 'Build Phase',
'kind' => 'phase',
'children' => [ 'children' => [
[ [
'name' => 'Full Body Circuit', 'name' => 'Full Body Circuit',
'kind' => 'workout',
'children' => [], 'children' => [],
], ],
], ],
@ -137,6 +119,7 @@ class ElementSeeder extends Seeder
public function run(): void public function run(): void
{ {
$elementRepository = app(ElementRepository::class); $elementRepository = app(ElementRepository::class);
$setLevelRepository = app(SetLevelRepository::class);
foreach (app(SetRepository::class)->all() as $set) { foreach (app(SetRepository::class)->all() as $set) {
$definitions = self::ELEMENTS_BY_SET[$set->getName()] ?? null; $definitions = self::ELEMENTS_BY_SET[$set->getName()] ?? null;
@ -147,6 +130,8 @@ class ElementSeeder extends Seeder
$this->seedChildren( $this->seedChildren(
repository: $elementRepository, repository: $elementRepository,
set: $set, set: $set,
levels: $setLevelRepository->findBySet($set),
depth: 0,
parentElement: null, parentElement: null,
definitions: $definitions, definitions: $definitions,
); );
@ -156,16 +141,27 @@ class ElementSeeder extends Seeder
/** /**
* @param list<array{ * @param list<array{
* name: string, * name: string,
* kind: string,
* children: list<mixed> * children: list<mixed>
* }> $definitions * }> $definitions
* @param list<SetLevel> $levels
*/ */
private function seedChildren( private function seedChildren(
ElementRepository $repository, ElementRepository $repository,
Set $set, Set $set,
array $levels,
int $depth,
?Element $parentElement, ?Element $parentElement,
array $definitions, array $definitions,
): void { ): void {
if ($definitions === []) {
return;
}
$level = $levels[$depth] ?? null;
if ($level === null) {
throw new RuntimeException('element level not defined');
}
$siblings = $parentElement === null $siblings = $parentElement === null
? $repository->findTopLevelBySet($set) ? $repository->findTopLevelBySet($set)
: $repository->findByParentElement($parentElement); : $repository->findByParentElement($parentElement);
@ -174,13 +170,12 @@ class ElementSeeder extends Seeder
$element = $this->findSibling( $element = $this->findSibling(
siblings: $siblings, siblings: $siblings,
name: $definition['name'], name: $definition['name'],
kind: $definition['kind'], level: $level,
); );
if ($element === null) { if ($element === null) {
$element = $repository->create(new CreateElementDto( $element = $repository->create(new CreateElementDto(
set: $set,
name: $definition['name'], name: $definition['name'],
kind: $definition['kind'], level: $level,
parentElement: $parentElement, parentElement: $parentElement,
)); ));
$siblings[] = $element; $siblings[] = $element;
@ -189,6 +184,8 @@ class ElementSeeder extends Seeder
$this->seedChildren( $this->seedChildren(
repository: $repository, repository: $repository,
set: $set, set: $set,
levels: $levels,
depth: $depth + 1,
parentElement: $element, parentElement: $element,
definitions: $definition['children'], definitions: $definition['children'],
); );
@ -201,12 +198,12 @@ class ElementSeeder extends Seeder
private function findSibling( private function findSibling(
array $siblings, array $siblings,
string $name, string $name,
string $kind, SetLevel $level,
): ?Element { ): ?Element {
foreach ($siblings as $sibling) { foreach ($siblings as $sibling) {
if ( if (
$sibling->getName() === $name $sibling->getName() === $name
&& $sibling->getKind() === $kind && $sibling->getLevel()->getId() === $level->getId()
) { ) {
return $sibling; return $sibling;
} }

View file

@ -0,0 +1,45 @@
<?php
namespace Database\Seeders;
use App\Set\CreateSetLevelDto;
use App\Set\SetLevelRepository;
use App\Set\SetRepository;
use Illuminate\Database\Seeder;
class SetLevelSeeder extends Seeder
{
private const array KINDS_BY_SET = [
'Bible' => ['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;
}
}
}
}

View file

@ -2,6 +2,7 @@
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\SetController; use App\Http\Controllers\SetController;
use App\Http\Controllers\ScheduleController;
use App\Http\Middleware\AuthMiddleware; use App\Http\Middleware\AuthMiddleware;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -14,5 +15,9 @@ Route::middleware(AuthMiddleware::class)->group(function (): void {
Route::get('/sets', [SetController::class, 'index']); Route::get('/sets', [SetController::class, 'index']);
Route::get('/sets/{setId}', [SetController::class, 'show']) Route::get('/sets/{setId}', [SetController::class, 'show'])
->whereNumber('setId'); ->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']); Route::post('/logout', [AuthController::class, 'logout']);
}); });

View file

@ -22,8 +22,7 @@ class FakeElementRepository implements ElementRepository
$element = new Element( $element = new Element(
id: $id, id: $id,
name: $dto->name, name: $dto->name,
kind: $dto->kind, level: $dto->level,
set: $dto->set,
parentElement: $dto->parentElement, parentElement: $dto->parentElement,
position: $this->nextPosition($dto), position: $this->nextPosition($dto),
); );
@ -88,14 +87,32 @@ class FakeElementRepository implements ElementRepository
{ {
$parentElement = $dto->parentElement; $parentElement = $dto->parentElement;
if ($parentElement === null) { if ($parentElement === null) {
if ($dto->level->getDepth() !== 0) {
throw new DomainException(
'root element must use the first set level',
);
}
return; return;
} }
if ($parentElement->getSet()->getId() !== $dto->set->getId()) { if (
$parentElement->getSet()->getId()
!== $dto->level->getSet()->getId()
) {
throw new DomainException( throw new DomainException(
'parent element must belong to the same set', '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 private function nextPosition(CreateElementDto $dto): int
@ -104,7 +121,10 @@ class FakeElementRepository implements ElementRepository
$maximumPosition = 0; $maximumPosition = 0;
foreach ($this->elements as $element) { foreach ($this->elements as $element) {
if ($element->getSet()->getId() !== $dto->set->getId()) { if (
$element->getSet()->getId()
!== $dto->level->getSet()->getId()
) {
continue; continue;
} }
@ -150,8 +170,7 @@ class FakeElementRepository implements ElementRepository
return new Element( return new Element(
id: $element->getId(), id: $element->getId(),
name: $element->getName(), name: $element->getName(),
kind: $element->getKind(), level: $element->getLevel(),
set: $element->getSet(),
parentElement: $parentElement === null parentElement: $parentElement === null
? null ? null
: $this->copy($parentElement), : $this->copy($parentElement),

View file

@ -0,0 +1,101 @@
<?php
namespace Tests\Fakes;
use App\Schedule\CreateScheduleDto;
use App\Schedule\Schedule;
use App\Schedule\ScheduleAssignment;
use App\Schedule\ScheduleRepository;
use App\User\User;
class FakeScheduleRepository implements ScheduleRepository
{
/**
* @var array<int, Schedule>
*/
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,
);
}
}

View file

@ -0,0 +1,76 @@
<?php
namespace Tests\Fakes;
use App\Set\CreateSetLevelDto;
use App\Set\Set;
use App\Set\SetLevel;
use App\Set\SetLevelRepository;
use DomainException;
class FakeSetLevelRepository implements SetLevelRepository
{
/**
* @var array<int, SetLevel>
*/
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(),
);
}
}

View file

@ -6,6 +6,7 @@ use App\Auth\PasswordHasher;
use App\Element\Element; use App\Element\Element;
use App\Element\ElementRepository; use App\Element\ElementRepository;
use App\Set\SetRepository; use App\Set\SetRepository;
use App\Set\SetLevelRepository;
use App\Shared\ValueObject\EmailAddress; use App\Shared\ValueObject\EmailAddress;
use App\User\UserRepository; use App\User\UserRepository;
use Database\Seeders\UserSeeder; use Database\Seeders\UserSeeder;
@ -70,6 +71,12 @@ class DatabaseSeederTest extends TestCase
$sets = app(SetRepository::class)->all(); $sets = app(SetRepository::class)->all();
$elementRepository = app(ElementRepository::class); $elementRepository = app(ElementRepository::class);
$setLevelRepository = app(SetLevelRepository::class);
$expectedLevels = [
'Bible' => ['book', 'portion', 'chapter'],
'Course' => ['module', 'lesson'],
'Fitness Program' => ['phase', 'workout', 'exercise'],
];
$expectedElements = [ $expectedElements = [
'Bible' => [ 'Bible' => [
'Chapter 1:chapter:Creation', 'Chapter 1:chapter:Creation',
@ -100,9 +107,16 @@ class DatabaseSeederTest extends TestCase
], ],
]; ];
$this->assertDatabaseCount('set_levels', 8);
$this->assertDatabaseCount('elements', 21); $this->assertDatabaseCount('elements', 21);
foreach ($sets as $set) { 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 { $signatures = array_map(function (Element $element): string {
$parentName = $element->getParentElement()?->getName() $parentName = $element->getParentElement()?->getName()
?? 'root'; ?? 'root';

View file

@ -5,7 +5,10 @@ namespace Tests\Feature\Element;
use App\Element\CreateElementDto; use App\Element\CreateElementDto;
use App\Element\ElementRepository; use App\Element\ElementRepository;
use App\Set\CreateSetDto; use App\Set\CreateSetDto;
use App\Set\CreateSetLevelDto;
use App\Set\Set; use App\Set\Set;
use App\Set\SetLevel;
use App\Set\SetLevelRepository;
use App\Set\SetRepository; use App\Set\SetRepository;
use App\Shared\ValueObject\EmailAddress; use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto; use App\User\CreateUserDto;
@ -22,28 +25,26 @@ class EloquentElementRepositoryTest extends TestCase
{ {
$set = $this->createSet('Bible'); $set = $this->createSet('Bible');
$repository = app(ElementRepository::class); $repository = app(ElementRepository::class);
$bookLevel = $this->createLevel($set, 'book');
$portionLevel = $this->createLevel($set, 'portion');
$genesisBook = $repository->create(new CreateElementDto( $genesisBook = $repository->create(new CreateElementDto(
set: $set,
name: 'Genesis', name: 'Genesis',
kind: 'book', level: $bookLevel,
parentElement: null, parentElement: null,
)); ));
$exodusBook = $repository->create(new CreateElementDto( $exodusBook = $repository->create(new CreateElementDto(
set: $set,
name: 'Exodus', name: 'Exodus',
kind: 'book', level: $bookLevel,
parentElement: null, parentElement: null,
)); ));
$genesisPortion = $repository->create(new CreateElementDto( $genesisPortion = $repository->create(new CreateElementDto(
set: $set,
name: 'Genesis', name: 'Genesis',
kind: 'portion', level: $portionLevel,
parentElement: $genesisBook, parentElement: $genesisBook,
)); ));
$noahPortion = $repository->create(new CreateElementDto( $noahPortion = $repository->create(new CreateElementDto(
set: $set,
name: 'Noah', name: 'Noah',
kind: 'portion', level: $portionLevel,
parentElement: $genesisBook, parentElement: $genesisBook,
)); ));
@ -53,9 +54,8 @@ class EloquentElementRepositoryTest extends TestCase
$this->assertSame(2, $noahPortion->getPosition()); $this->assertSame(2, $noahPortion->getPosition());
$this->assertDatabaseHas('elements', [ $this->assertDatabaseHas('elements', [
'id' => $genesisPortion->getId(), 'id' => $genesisPortion->getId(),
'set_id' => $set->getId(), 'set_level_id' => $portionLevel->getId(),
'name' => 'Genesis', 'name' => 'Genesis',
'kind' => 'portion',
'parent_element_id' => $genesisBook->getId(), 'parent_element_id' => $genesisBook->getId(),
'position' => 1, 'position' => 1,
]); ]);
@ -65,6 +65,10 @@ class EloquentElementRepositoryTest extends TestCase
$this->assertNotNull($foundElement); $this->assertNotNull($foundElement);
$this->assertSame('Noah', $foundElement->getName()); $this->assertSame('Noah', $foundElement->getName());
$this->assertSame('portion', $foundElement->getKind()); $this->assertSame('portion', $foundElement->getKind());
$this->assertSame(
$portionLevel->getId(),
$foundElement->getLevel()->getId(),
);
$this->assertSame($set->getId(), $foundElement->getSet()->getId()); $this->assertSame($set->getId(), $foundElement->getSet()->getId());
$this->assertSame( $this->assertSame(
$genesisBook->getId(), $genesisBook->getId(),
@ -77,10 +81,11 @@ class EloquentElementRepositoryTest extends TestCase
$bible = $this->createSet('Bible'); $bible = $this->createSet('Bible');
$course = $this->createSet('Course'); $course = $this->createSet('Course');
$repository = app(ElementRepository::class); $repository = app(ElementRepository::class);
$bookLevel = $this->createLevel($bible, 'book');
$moduleLevel = $this->createLevel($course, 'module');
$book = $repository->create(new CreateElementDto( $book = $repository->create(new CreateElementDto(
set: $bible,
name: 'Genesis', name: 'Genesis',
kind: 'book', level: $bookLevel,
parentElement: null, parentElement: null,
)); ));
@ -90,9 +95,51 @@ class EloquentElementRepositoryTest extends TestCase
); );
$repository->create(new CreateElementDto( $repository->create(new CreateElementDto(
set: $course,
name: 'Invalid lesson', name: 'Invalid lesson',
kind: '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,
parentElement: $book, parentElement: $book,
)); ));
} }
@ -102,40 +149,37 @@ class EloquentElementRepositoryTest extends TestCase
$bible = $this->createSet('Bible'); $bible = $this->createSet('Bible');
$course = $this->createSet('Course'); $course = $this->createSet('Course');
$repository = app(ElementRepository::class); $repository = app(ElementRepository::class);
$bookLevel = $this->createLevel($bible, 'book');
$portionLevel = $this->createLevel($bible, 'portion');
$moduleLevel = $this->createLevel($course, 'module');
$genesisBook = $repository->create(new CreateElementDto( $genesisBook = $repository->create(new CreateElementDto(
set: $bible,
name: 'Genesis', name: 'Genesis',
kind: 'book', level: $bookLevel,
parentElement: null, parentElement: null,
)); ));
$exodusBook = $repository->create(new CreateElementDto( $exodusBook = $repository->create(new CreateElementDto(
set: $bible,
name: 'Exodus', name: 'Exodus',
kind: 'book', level: $bookLevel,
parentElement: null, parentElement: null,
)); ));
$repository->create(new CreateElementDto( $repository->create(new CreateElementDto(
set: $course,
name: 'Module 1', name: 'Module 1',
kind: 'module', level: $moduleLevel,
parentElement: null, parentElement: null,
)); ));
$repository->create(new CreateElementDto( $repository->create(new CreateElementDto(
set: $bible,
name: 'Genesis', name: 'Genesis',
kind: 'portion', level: $portionLevel,
parentElement: $genesisBook, parentElement: $genesisBook,
)); ));
$repository->create(new CreateElementDto( $repository->create(new CreateElementDto(
set: $bible,
name: 'Noah', name: 'Noah',
kind: 'portion', level: $portionLevel,
parentElement: $genesisBook, parentElement: $genesisBook,
)); ));
$repository->create(new CreateElementDto( $repository->create(new CreateElementDto(
set: $bible,
name: 'Shemot', name: 'Shemot',
kind: 'portion', level: $portionLevel,
parentElement: $exodusBook, parentElement: $exodusBook,
)); ));
@ -161,22 +205,22 @@ class EloquentElementRepositoryTest extends TestCase
$bible = $this->createSet('Bible'); $bible = $this->createSet('Bible');
$course = $this->createSet('Course'); $course = $this->createSet('Course');
$repository = app(ElementRepository::class); $repository = app(ElementRepository::class);
$bookLevel = $this->createLevel($bible, 'book');
$portionLevel = $this->createLevel($bible, 'portion');
$moduleLevel = $this->createLevel($course, 'module');
$genesis = $repository->create(new CreateElementDto( $genesis = $repository->create(new CreateElementDto(
set: $bible,
name: 'Genesis', name: 'Genesis',
kind: 'book', level: $bookLevel,
parentElement: null, parentElement: null,
)); ));
$repository->create(new CreateElementDto( $repository->create(new CreateElementDto(
set: $bible,
name: 'Creation', name: 'Creation',
kind: 'portion', level: $portionLevel,
parentElement: $genesis, parentElement: $genesis,
)); ));
$repository->create(new CreateElementDto( $repository->create(new CreateElementDto(
set: $course,
name: 'Foundations', name: 'Foundations',
kind: 'module', level: $moduleLevel,
parentElement: null, parentElement: null,
)); ));
@ -207,4 +251,12 @@ class EloquentElementRepositoryTest extends TestCase
creator: $creator, creator: $creator,
)); ));
} }
private function createLevel(Set $set, string $kind): SetLevel
{
return app(SetLevelRepository::class)->create(new CreateSetLevelDto(
set: $set,
kind: $kind,
));
}
} }

View file

@ -0,0 +1,391 @@
<?php
namespace Tests\Feature\Schedule;
use App\Auth\CreateSessionDto;
use App\Auth\SessionRepository;
use App\Element\CreateElementDto;
use App\Element\ElementModel;
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\SetLevelModel;
use App\Set\SetLevelRepository;
use App\Set\SetModel;
use App\Set\SetRepository;
use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto;
use App\User\User;
use App\User\UserRepository;
use DateTimeImmutable;
use DateTimeZone;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
use Tests\TestCase;
class ScheduleEndpointTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_and_returns_a_schedule(): void
{
$user = $this->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<string, mixed> $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);
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace Tests\Feature\Set;
use App\Set\CreateSetDto;
use App\Set\CreateSetLevelDto;
use App\Set\Set;
use App\Set\SetLevelRepository;
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 EloquentSetLevelRepositoryTest extends TestCase
{
use RefreshDatabase;
public function testItCreatesOrderedLevelsWithinEachSet(): void
{
$bible = $this->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,
));
}
}

View file

@ -8,7 +8,10 @@ use App\Element\CreateElementDto;
use App\Element\ElementRepository; use App\Element\ElementRepository;
use App\Http\Middleware\AuthMiddleware; use App\Http\Middleware\AuthMiddleware;
use App\Set\CreateSetDto; use App\Set\CreateSetDto;
use App\Set\CreateSetLevelDto;
use App\Set\Set; use App\Set\Set;
use App\Set\SetLevel;
use App\Set\SetLevelRepository;
use App\Set\SetRepository; use App\Set\SetRepository;
use App\Shared\ValueObject\EmailAddress; use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto; use App\User\CreateUserDto;
@ -29,28 +32,27 @@ class GetSetLayoutEndpointTest extends TestCase
$user = $this->createUser(); $user = $this->createUser();
$set = $this->createSet($user, 'Bible'); $set = $this->createSet($user, 'Bible');
$repository = app(ElementRepository::class); $repository = app(ElementRepository::class);
$bookLevel = $this->createLevel($set, 'book');
$portionLevel = $this->createLevel($set, 'portion');
$chapterLevel = $this->createLevel($set, 'chapter');
$genesis = $repository->create(new CreateElementDto( $genesis = $repository->create(new CreateElementDto(
set: $set,
name: 'Genesis', name: 'Genesis',
kind: 'book', level: $bookLevel,
parentElement: null, parentElement: null,
)); ));
$repository->create(new CreateElementDto( $repository->create(new CreateElementDto(
set: $set,
name: 'Exodus', name: 'Exodus',
kind: 'book', level: $bookLevel,
parentElement: null, parentElement: null,
)); ));
$creation = $repository->create(new CreateElementDto( $creation = $repository->create(new CreateElementDto(
set: $set,
name: 'Creation', name: 'Creation',
kind: 'portion', level: $portionLevel,
parentElement: $genesis, parentElement: $genesis,
)); ));
$chapter = $repository->create(new CreateElementDto( $chapter = $repository->create(new CreateElementDto(
set: $set,
name: 'Chapter 1', name: 'Chapter 1',
kind: 'chapter', level: $chapterLevel,
parentElement: $creation, parentElement: $creation,
)); ));
$this->createSession($user); $this->createSession($user);
@ -62,21 +64,44 @@ class GetSetLayoutEndpointTest extends TestCase
'id' => $set->getId(), 'id' => $set->getId(),
'name' => 'Bible', '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' => [ 'elements' => [
[ [
'id' => $genesis->getId(), 'id' => $genesis->getId(),
'name' => 'Genesis', 'name' => 'Genesis',
'kind' => 'book', 'kind' => 'book',
'levelId' => $bookLevel->getId(),
'children' => [ 'children' => [
[ [
'id' => $creation->getId(), 'id' => $creation->getId(),
'name' => 'Creation', 'name' => 'Creation',
'kind' => 'portion', 'kind' => 'portion',
'levelId' => $portionLevel->getId(),
'children' => [ 'children' => [
[ [
'id' => $chapter->getId(), 'id' => $chapter->getId(),
'name' => 'Chapter 1', 'name' => 'Chapter 1',
'kind' => 'chapter', 'kind' => 'chapter',
'levelId' => $chapterLevel->getId(),
'children' => [], 'children' => [],
], ],
], ],
@ -87,6 +112,7 @@ class GetSetLayoutEndpointTest extends TestCase
'id' => $genesis->getId() + 1, 'id' => $genesis->getId() + 1,
'name' => 'Exodus', 'name' => 'Exodus',
'kind' => 'book', 'kind' => 'book',
'levelId' => $bookLevel->getId(),
'children' => [], 'children' => [],
], ],
], ],
@ -97,6 +123,7 @@ class GetSetLayoutEndpointTest extends TestCase
{ {
$user = $this->createUser(); $user = $this->createUser();
$set = $this->createSet($user, 'Empty set'); $set = $this->createSet($user, 'Empty set');
$taskLevel = $this->createLevel($set, 'task');
$this->createSession($user); $this->createSession($user);
$response = $this->credentialedGet("/api/sets/{$set->getId()}"); $response = $this->credentialedGet("/api/sets/{$set->getId()}");
@ -106,6 +133,12 @@ class GetSetLayoutEndpointTest extends TestCase
'id' => $set->getId(), 'id' => $set->getId(),
'name' => 'Empty set', 'name' => 'Empty set',
], ],
'levels' => [[
'id' => $taskLevel->getId(),
'kind' => 'task',
'depth' => 0,
'elementCount' => 0,
]],
'elements' => [], 'elements' => [],
]); ]);
} }
@ -147,6 +180,14 @@ 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 private function createSession(User $user): void
{ {
$createdAt = new DateTimeImmutable( $createdAt = new DateTimeImmutable(
@ -157,7 +198,7 @@ class GetSetLayoutEndpointTest extends TestCase
token: 'valid-token', token: 'valid-token',
user: $user, user: $user,
createdAt: $createdAt, createdAt: $createdAt,
expiresAt: $createdAt->modify('+7 days'), expiresAt: $createdAt->modify('+10 years'),
)); ));
} }

View file

@ -96,7 +96,7 @@ class ListSetsEndpointTest extends TestCase
token: 'valid-token', token: 'valid-token',
user: $user, user: $user,
createdAt: $createdAt, createdAt: $createdAt,
expiresAt: $createdAt->modify('+7 days'), expiresAt: $createdAt->modify('+10 years'),
)); ));
} }
} }

View file

@ -4,6 +4,7 @@ namespace Tests\Unit\Element;
use App\Element\Element; use App\Element\Element;
use App\Set\Set; use App\Set\Set;
use App\Set\SetLevel;
use App\Shared\ValueObject\EmailAddress; use App\Shared\ValueObject\EmailAddress;
use App\User\User; use App\User\User;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@ -22,19 +23,29 @@ class ElementTest extends TestCase
name: 'Bible', name: 'Bible',
creator: $creator, 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( $parentElement = new Element(
id: 21, id: 21,
name: 'Genesis', name: 'Genesis',
kind: 'book', level: $bookLevel,
set: $set,
parentElement: null, parentElement: null,
position: 1, position: 1,
); );
$element = new Element( $element = new Element(
id: 22, id: 22,
name: 'Genesis', name: 'Genesis',
kind: 'portion', level: $portionLevel,
set: $set,
parentElement: $parentElement, parentElement: $parentElement,
position: 2, position: 2,
); );
@ -42,6 +53,7 @@ class ElementTest extends TestCase
$this->assertSame(22, $element->getId()); $this->assertSame(22, $element->getId());
$this->assertSame('Genesis', $element->getName()); $this->assertSame('Genesis', $element->getName());
$this->assertSame('portion', $element->getKind()); $this->assertSame('portion', $element->getKind());
$this->assertSame($portionLevel, $element->getLevel());
$this->assertSame($set, $element->getSet()); $this->assertSame($set, $element->getSet());
$this->assertSame($parentElement, $element->getParentElement()); $this->assertSame($parentElement, $element->getParentElement());
$this->assertSame(2, $element->getPosition()); $this->assertSame(2, $element->getPosition());

View file

@ -0,0 +1,441 @@
<?php
namespace Tests\Unit\Schedule\UseCases;
use App\Element\CreateElementDto;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Schedule\UseCases\CreateSchedule\CreateSchedule;
use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest;
use App\Set\CreateSetDto;
use App\Set\CreateSetLevelDto;
use App\Set\Set;
use App\Set\SetLevel;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeElementRepository;
use Tests\Fakes\FakeScheduleRepository;
use Tests\Fakes\FakeSetRepository;
use Tests\Fakes\FakeSetLevelRepository;
class CreateScheduleTest extends TestCase
{
public function test_it_schedules_every_element_on_the_selected_level(): void
{
$user = $this->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',
);
}
}

View file

@ -5,6 +5,7 @@ namespace Tests\Unit\Set\UseCases;
use App\Element\CreateElementDto; use App\Element\CreateElementDto;
use App\Exceptions\NotFoundException; use App\Exceptions\NotFoundException;
use App\Set\CreateSetDto; use App\Set\CreateSetDto;
use App\Set\CreateSetLevelDto;
use App\Set\UseCases\GetSetLayout\ElementLayoutNode; use App\Set\UseCases\GetSetLayout\ElementLayoutNode;
use App\Set\UseCases\GetSetLayout\GetSetLayout; use App\Set\UseCases\GetSetLayout\GetSetLayout;
use App\Set\UseCases\GetSetLayout\GetSetLayoutRequest; use App\Set\UseCases\GetSetLayout\GetSetLayoutRequest;
@ -13,6 +14,7 @@ use App\User\User;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeElementRepository; use Tests\Fakes\FakeElementRepository;
use Tests\Fakes\FakeSetRepository; use Tests\Fakes\FakeSetRepository;
use Tests\Fakes\FakeSetLevelRepository;
class GetSetLayoutTest extends TestCase class GetSetLayoutTest extends TestCase
{ {
@ -24,6 +26,7 @@ class GetSetLayoutTest extends TestCase
passwordHash: 'hashed-password', passwordHash: 'hashed-password',
); );
$setRepository = new FakeSetRepository; $setRepository = new FakeSetRepository;
$setLevelRepository = new FakeSetLevelRepository;
$elementRepository = new FakeElementRepository; $elementRepository = new FakeElementRepository;
$bible = $setRepository->create(new CreateSetDto( $bible = $setRepository->create(new CreateSetDto(
name: 'Bible', name: 'Bible',
@ -33,51 +36,69 @@ class GetSetLayoutTest extends TestCase
name: 'Course', name: 'Course',
creator: $creator, creator: $creator,
)); ));
$genesis = $elementRepository->create(new CreateElementDto( $bookLevel = $setLevelRepository->create(new CreateSetLevelDto(
set: $bible, set: $bible,
name: 'Genesis',
kind: 'book', 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(
name: 'Genesis',
level: $bookLevel,
parentElement: null, parentElement: null,
)); ));
$elementRepository->create(new CreateElementDto( $elementRepository->create(new CreateElementDto(
set: $bible,
name: 'Exodus', name: 'Exodus',
kind: 'book', level: $bookLevel,
parentElement: null, parentElement: null,
)); ));
$creation = $elementRepository->create(new CreateElementDto( $creation = $elementRepository->create(new CreateElementDto(
set: $bible,
name: 'Creation', name: 'Creation',
kind: 'portion', level: $portionLevel,
parentElement: $genesis, parentElement: $genesis,
)); ));
$elementRepository->create(new CreateElementDto( $elementRepository->create(new CreateElementDto(
set: $bible,
name: 'Noah', name: 'Noah',
kind: 'portion', level: $portionLevel,
parentElement: $genesis, parentElement: $genesis,
)); ));
$elementRepository->create(new CreateElementDto( $elementRepository->create(new CreateElementDto(
set: $bible,
name: 'Chapter 1', name: 'Chapter 1',
kind: 'chapter', level: $chapterLevel,
parentElement: $creation, parentElement: $creation,
)); ));
$elementRepository->create(new CreateElementDto( $elementRepository->create(new CreateElementDto(
set: $course,
name: 'Foundations', name: 'Foundations',
kind: 'module', level: $moduleLevel,
parentElement: null, parentElement: null,
)); ));
$layout = (new GetSetLayout( $layout = (new GetSetLayout(
$setRepository, $setRepository,
$setLevelRepository,
$elementRepository, $elementRepository,
))->execute(new GetSetLayoutRequest( ))->execute(new GetSetLayoutRequest(
setId: $bible->getId(), setId: $bible->getId(),
)); ));
$this->assertSame('Bible', $layout->getSet()->getName()); $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( $this->assertSame(
['Genesis', 'Exodus'], ['Genesis', 'Exodus'],
$this->nodeNames($layout->getElements()), $this->nodeNames($layout->getElements()),
@ -103,19 +124,26 @@ class GetSetLayoutTest extends TestCase
passwordHash: 'hashed-password', passwordHash: 'hashed-password',
); );
$setRepository = new FakeSetRepository; $setRepository = new FakeSetRepository;
$setLevelRepository = new FakeSetLevelRepository;
$set = $setRepository->create(new CreateSetDto( $set = $setRepository->create(new CreateSetDto(
name: 'Empty set', name: 'Empty set',
creator: $creator, creator: $creator,
)); ));
$setLevelRepository->create(new CreateSetLevelDto(
set: $set,
kind: 'task',
));
$layout = (new GetSetLayout( $layout = (new GetSetLayout(
$setRepository, $setRepository,
$setLevelRepository,
new FakeElementRepository, new FakeElementRepository,
))->execute(new GetSetLayoutRequest( ))->execute(new GetSetLayoutRequest(
setId: $set->getId(), setId: $set->getId(),
)); ));
$this->assertSame([], $layout->getElements()); $this->assertSame([], $layout->getElements());
$this->assertSame(0, $layout->getLevels()[0]->getElementCount());
} }
public function test_it_rejects_an_unknown_set(): void public function test_it_rejects_an_unknown_set(): void
@ -125,6 +153,7 @@ class GetSetLayoutTest extends TestCase
(new GetSetLayout( (new GetSetLayout(
new FakeSetRepository, new FakeSetRepository,
new FakeSetLevelRepository,
new FakeElementRepository, new FakeElementRepository,
))->execute(new GetSetLayoutRequest( ))->execute(new GetSetLayoutRequest(
setId: 999, setId: 999,

View file

@ -13,6 +13,10 @@ describe('email confirmation', () => {
statusCode: 200, statusCode: 200,
body: { sets: [] }, body: { sets: [] },
}) })
cy.intercept('GET', '**/api/schedules', {
statusCode: 200,
body: { schedules: [] },
})
}) })
it('chooses a password, confirms the account, and opens the dashboard', () => { it('chooses a password, confirms the account, and opens the dashboard', () => {

View file

@ -31,6 +31,14 @@ describe('guest authentication pages', () => {
cy.location('search').should('include', 'redirect=/sets/41') 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', () => { it('shows the login form and links to signup', () => {
cy.visit('/login') cy.visit('/login')

View file

@ -43,6 +43,10 @@ describe('session authentication', () => {
statusCode: 200, statusCode: 200,
body: { sets: [] }, body: { sets: [] },
}) })
cy.intercept('GET', '**/api/schedules', {
statusCode: 200,
body: { schedules: [] },
})
}) })
it('restores an authenticated session on a protected route', () => { it('restores an authenticated session on a protected route', () => {

View file

@ -5,29 +5,37 @@ const authenticatedUser = {
const bibleLayout = { const bibleLayout = {
set: { id: 41, name: 'Bible' }, 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: [ elements: [
{ {
id: 1, id: 1,
name: 'Genesis', name: 'Genesis',
kind: 'book', kind: 'book',
levelId: 1,
children: [ children: [
{ {
id: 3, id: 3,
name: 'Creation', name: 'Creation',
kind: 'portion', kind: 'portion',
levelId: 2,
children: [ children: [
{ {
id: 4, id: 4,
name: 'Chapter 1', name: 'Chapter 1',
kind: 'chapter', kind: 'Chapter_sections-v2',
levelId: 3,
children: [], children: [],
}, },
], ],
}, },
{ id: 5, name: 'Noah', kind: 'portion', children: [] }, { id: 5, name: 'Noah', kind: 'portion', levelId: 2, children: [] },
], ],
}, },
{ id: 2, name: 'Exodus', kind: 'book', children: [] }, { id: 2, name: 'Exodus', kind: 'book', levelId: 1, children: [] },
], ],
} }
@ -82,14 +90,16 @@ describe('set element layout', () => {
.and('have.attr', 'data-element-id', '4') .and('have.attr', 'data-element-id', '4')
cy.contains('.element-node__card', 'Chapter 1') cy.contains('.element-node__card', 'Chapter 1')
.should('be.visible') .should('be.visible')
.and('contain.text', 'chapter') .find('.element-node__kind')
.should('have.text', 'Chapter_sections-v2')
.and('have.css', 'text-transform', 'none')
}) })
it('shows loading and empty layout states', () => { it('shows loading and empty layout states', () => {
cy.intercept('GET', '**/api/sets/41', { cy.intercept('GET', '**/api/sets/41', {
delay: 500, delay: 500,
statusCode: 200, statusCode: 200,
body: { set: { id: 41, name: 'Empty set' }, elements: [] }, body: { set: { id: 41, name: 'Empty set' }, levels: [], elements: [] },
}).as('layout') }).as('layout')
cy.visit('/sets/41') cy.visit('/sets/41')

View file

@ -0,0 +1,268 @@
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')
})
})

View file

@ -13,6 +13,10 @@ function interceptAuthenticatedUser(): void {
describe('sets dashboard', () => { describe('sets dashboard', () => {
beforeEach(() => { beforeEach(() => {
interceptAuthenticatedUser() interceptAuthenticatedUser()
cy.intercept('GET', '**/api/schedules', {
statusCode: 200,
body: { schedules: [] },
})
}) })
it('shows every available set as a detail link', () => { it('shows every available set as a detail link', () => {
@ -64,10 +68,10 @@ describe('sets dashboard', () => {
cy.visit('/dashboard') cy.visit('/dashboard')
cy.wait('@me') cy.wait('@me')
cy.get('[role="status"]').should('have.text', 'Loading sets...') cy.get('.sets-catalog [role="status"]').should('have.text', 'Loading sets...')
cy.wait('@sets') cy.wait('@sets')
cy.get('[role="status"]').should( cy.get('.sets-catalog [role="status"]').should(
'contain.text', 'contain.text',
'No sets are available yet.', 'No sets are available yet.',
) )

View file

@ -87,7 +87,7 @@ defineProps<{
font-size: 0.62rem; font-size: 0.62rem;
font-weight: 800; font-weight: 800;
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: none;
} }
@media (max-width: 37.5rem) { @media (max-width: 37.5rem) {

View file

@ -71,6 +71,22 @@ const router = createRouter({
requiresAuth: true, 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,
},
},
], ],
}) })

View file

@ -0,0 +1,219 @@
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<typeof scheduleSummarySchema>
export type ScheduleDetail = z.infer<typeof scheduleDetailSchema>
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<ScheduleSummary[]>([])
const listLoading = ref(false)
const listError = ref<string | null>(null)
const activeSchedule = ref<ScheduleDetail | null>(null)
const detailLoading = ref(false)
const detailError = ref<string | null>(null)
const detailNotFound = ref(false)
const creating = ref(false)
const createError = ref<string | null>(null)
let activeDetailRequestId = 0
async function fetchSchedules(): Promise<boolean> {
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<boolean> {
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<ScheduleDetail | null> {
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,
}
})

View file

@ -8,6 +8,7 @@ export const setElementNodeSchema = z.object({
id: z.number().int().positive(), id: z.number().int().positive(),
name: z.string().min(1), name: z.string().min(1),
kind: z.string().min(1), kind: z.string().min(1),
levelId: z.number().int().positive(),
get children() { get children() {
return z.array(setElementNodeSchema) return z.array(setElementNodeSchema)
}, },
@ -18,6 +19,14 @@ export const setLayoutResponseSchema = z.object({
id: z.number().int().positive(), id: z.number().int().positive(),
name: z.string().min(1), 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), elements: z.array(setElementNodeSchema),
}) })

View file

@ -31,7 +31,8 @@ body {
} }
button, button,
input { input,
select {
font: inherit; font: inherit;
} }

View file

@ -0,0 +1,384 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { z } from 'zod'
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
import { useSchedulesStore } from '@/stores/schedules'
import { useSetLayoutStore } from '@/stores/setLayout'
type ScheduleField = 'levelId' | 'startDate' | 'targetDate'
type ScheduleFieldErrors = Partial<Record<ScheduleField, string>>
const route = useRoute()
const router = useRouter()
const setLayoutStore = useSetLayoutStore()
const schedulesStore = useSchedulesStore()
const { layout, loading, error, notFound } = storeToRefs(setLayoutStore)
const { creating, createError } = storeToRefs(schedulesStore)
const currentSetId = ref<number | null>(null)
const form = reactive({
levelId: null as number | null,
startDate: '',
targetDate: '',
})
const fieldErrors = ref<ScheduleFieldErrors>({})
const levels = computed(() =>
(layout.value?.levels ?? []).filter((level) => level.elementCount > 0),
)
const scheduleFormSchema = z
.object({
levelId: z.number('Choose a level to schedule.').int().positive(),
startDate: z.string().min(1, 'Choose a start date.'),
targetDate: z.string().min(1, 'Choose a target date.'),
})
.superRefine((value, context) => {
if (value.startDate !== '' && value.targetDate !== '' && value.targetDate < value.startDate) {
context.addIssue({
code: 'custom',
path: ['targetDate'],
message: 'Target date cannot be before the start date.',
})
}
})
watch(
() => route.params.setId,
async (setIdParameter) => {
const rawSetId = Array.isArray(setIdParameter) ? setIdParameter[0] : setIdParameter
const setId = Number(rawSetId)
currentSetId.value = setId
if (layout.value?.set.id !== setId) {
await setLayoutStore.fetchSetLayout(setId)
}
},
{ immediate: true },
)
async function submit(): Promise<void> {
fieldErrors.value = {}
const result = scheduleFormSchema.safeParse(form)
if (!result.success) {
const errors: ScheduleFieldErrors = {}
for (const issue of result.error.issues) {
const field = issue.path[0]
if (
(field === 'levelId' || field === 'startDate' || field === 'targetDate') &&
errors[field] === undefined
) {
errors[field] = issue.message
}
}
fieldErrors.value = errors
return
}
if (
currentSetId.value === null ||
!levels.value.some((level) => level.id === result.data.levelId)
) {
fieldErrors.value = {
levelId: 'Choose an available level to schedule.',
}
return
}
const schedule = await schedulesStore.createSchedule({
setId: currentSetId.value,
...result.data,
})
if (schedule !== null) {
await router.push({
name: 'schedule-detail',
params: { scheduleId: schedule.id },
})
}
}
async function retry(): Promise<void> {
if (currentSetId.value !== null) {
await setLayoutStore.fetchSetLayout(currentSetId.value)
}
}
</script>
<template>
<main class="schedule-form-page">
<AuthenticatedHeader />
<section class="schedule-form-shell">
<RouterLink
class="back-link"
:to="{ name: 'set-layout', params: { setId: currentSetId ?? undefined } }"
>
<span aria-hidden="true"></span>
Back to set
</RouterLink>
<p v-if="loading" class="page-state" role="status">Loading scheduling options...</p>
<div v-else-if="notFound" class="page-state page-state--error" role="alert">
<h1>Set not found</h1>
<p>The set you want to schedule is not available.</p>
</div>
<div v-else-if="error !== null" class="page-state page-state--error" role="alert">
<p>{{ error }}</p>
<button type="button" class="secondary-button" @click="retry">Try again</button>
</div>
<template v-else-if="layout !== null">
<header class="schedule-form__introduction">
<p class="eyebrow">Create schedule</p>
<h1>Schedule {{ layout.set.name }}</h1>
<p>Choose one level of the set and a date range. Every matching item will be included.</p>
</header>
<form class="schedule-form" novalidate @submit.prevent="submit">
<div class="form-field">
<label for="schedule-level">Level</label>
<select
id="schedule-level"
v-model="form.levelId"
:aria-invalid="fieldErrors.levelId !== undefined"
:aria-describedby="fieldErrors.levelId ? 'schedule-level-error' : undefined"
:disabled="creating"
>
<option :value="null">Choose a level</option>
<option v-for="level in levels" :key="level.id" :value="level.id">
{{ level.kind }} ({{ level.elementCount }})
</option>
</select>
<p v-if="fieldErrors.levelId" id="schedule-level-error" class="field-error">
{{ fieldErrors.levelId }}
</p>
</div>
<div class="date-fields">
<div class="form-field">
<label for="schedule-start-date">Start date</label>
<input
id="schedule-start-date"
v-model="form.startDate"
type="date"
:aria-invalid="fieldErrors.startDate !== undefined"
:aria-describedby="fieldErrors.startDate ? 'schedule-start-date-error' : undefined"
:disabled="creating"
/>
<p v-if="fieldErrors.startDate" id="schedule-start-date-error" class="field-error">
{{ fieldErrors.startDate }}
</p>
</div>
<div class="form-field">
<label for="schedule-target-date">Target date</label>
<input
id="schedule-target-date"
v-model="form.targetDate"
type="date"
:aria-invalid="fieldErrors.targetDate !== undefined"
:aria-describedby="
fieldErrors.targetDate ? 'schedule-target-date-error' : undefined
"
:disabled="creating"
/>
<p v-if="fieldErrors.targetDate" id="schedule-target-date-error" class="field-error">
{{ fieldErrors.targetDate }}
</p>
</div>
</div>
<p v-if="createError !== null" class="form-error" role="alert">{{ createError }}</p>
<button type="submit" class="primary-button" :disabled="creating">
{{ creating ? 'Creating schedule...' : 'Create schedule' }}
</button>
</form>
</template>
</section>
</main>
</template>
<style scoped>
.schedule-form-page {
min-height: 100vh;
min-height: 100svh;
padding: 2rem clamp(1.5rem, 6vw, 5rem) 5rem;
color: #183029;
background: #f4f1e7;
}
.schedule-form-shell {
width: min(100%, 46rem);
margin: clamp(3.5rem, 8vh, 5.5rem) auto 0;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border-radius: 0.35rem;
color: #5e7067;
font-size: 0.84rem;
font-weight: 750;
text-decoration: none;
}
.back-link:focus-visible,
.primary-button:focus-visible,
.secondary-button:focus-visible,
select:focus-visible,
input:focus-visible {
outline: 3px solid rgb(86 127 112 / 34%);
outline-offset: 0.25rem;
}
.schedule-form__introduction {
margin-top: 2.75rem;
}
.eyebrow {
margin: 0 0 1rem;
color: #926044;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(2.75rem, 7vw, 4.8rem);
font-weight: 500;
line-height: 1;
letter-spacing: -0.05em;
}
.schedule-form__introduction > p:last-child {
max-width: 38rem;
margin: 1.4rem 0 0;
color: #68776f;
line-height: 1.7;
}
.schedule-form {
display: grid;
gap: 1.5rem;
margin-top: 2.75rem;
padding: clamp(1.3rem, 4vw, 2rem);
border: 1px solid rgb(24 48 41 / 12%);
border-radius: 1rem;
background: rgb(255 253 247 / 82%);
box-shadow: 0 1rem 2.5rem rgb(40 62 52 / 8%);
}
.form-field {
display: grid;
gap: 0.55rem;
}
.date-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
label {
font-size: 0.78rem;
font-weight: 800;
}
select,
input {
width: 100%;
min-height: 3rem;
padding: 0.7rem 0.8rem;
border: 1px solid rgb(24 58 49 / 22%);
border-radius: 0.7rem;
color: #183029;
background: #fffdf7;
}
select[aria-invalid='true'],
input[aria-invalid='true'] {
border-color: #a64b3c;
}
.field-error,
.form-error {
margin: 0;
color: #934033;
font-size: 0.76rem;
line-height: 1.4;
}
.primary-button,
.secondary-button {
min-height: 2.8rem;
padding: 0.7rem 1.1rem;
border-radius: 0.7rem;
font-weight: 750;
cursor: pointer;
}
.primary-button {
justify-self: start;
border: 0;
color: #fffdf7;
background: #183a31;
}
.primary-button:disabled {
cursor: wait;
opacity: 0.65;
}
.secondary-button {
border: 1px solid rgb(24 58 49 / 28%);
color: #183a31;
background: #fffdf7;
}
.page-state {
display: grid;
min-height: 10rem;
place-items: center;
margin-top: 2.75rem;
padding: 2rem;
border: 1px solid rgb(24 48 41 / 12%);
border-radius: 1rem;
color: #68776f;
background: rgb(255 253 247 / 72%);
text-align: center;
}
.page-state--error {
align-content: center;
gap: 1rem;
}
.page-state--error p {
margin: 0;
}
@media (max-width: 37.5rem) {
.schedule-form-page {
padding: 1.4rem 1.1rem 3rem;
}
.schedule-form-shell {
margin-top: 3rem;
}
.date-fields {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -3,20 +3,83 @@ import { storeToRefs } from 'pinia'
import { onMounted } from 'vue' import { onMounted } from 'vue'
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue' import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
import { useSchedulesStore } from '@/stores/schedules'
import { useSetsStore } from '@/stores/sets' import { useSetsStore } from '@/stores/sets'
const setsStore = useSetsStore() const setsStore = useSetsStore()
const schedulesStore = useSchedulesStore()
const { sets, loading, error } = storeToRefs(setsStore) const { sets, loading, error } = storeToRefs(setsStore)
const { schedules, listLoading, listError } = storeToRefs(schedulesStore)
onMounted(async () => { onMounted(async () => {
await setsStore.fetchSets() await Promise.all([setsStore.fetchSets(), schedulesStore.fetchSchedules()])
}) })
function formatDate(value: string): string {
return new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
timeZone: 'UTC',
}).format(new Date(`${value}T00:00:00Z`))
}
</script> </script>
<template> <template>
<main class="dashboard-page"> <main class="dashboard-page">
<AuthenticatedHeader /> <AuthenticatedHeader />
<section class="schedules-catalog" aria-labelledby="schedules-heading">
<div class="schedules-catalog__heading">
<div>
<p class="sets-catalog__eyebrow">Your plans</p>
<h2 id="schedules-heading">Your schedules</h2>
</div>
<p>Return to a plan and see what is assigned for each day.</p>
</div>
<p v-if="listLoading" class="catalog-state catalog-state--compact" role="status">
Loading schedules...
</p>
<div
v-else-if="listError !== null"
class="catalog-state catalog-state--compact catalog-state--error"
role="alert"
>
<p>{{ listError }}</p>
<button type="button" class="retry-button" @click="schedulesStore.fetchSchedules">
Try again
</button>
</div>
<p
v-else-if="schedules.length === 0"
class="catalog-state catalog-state--compact"
role="status"
>
You have not created a schedule yet.
</p>
<ul v-else class="schedule-grid" aria-label="Your schedules">
<li v-for="schedule in schedules" :key="schedule.id">
<RouterLink
class="schedule-card-link"
:to="{ name: 'schedule-detail', params: { scheduleId: schedule.id } }"
>
<article class="schedule-card">
<div class="schedule-card__heading">
<p class="schedule-card__kind">{{ schedule.elementKind }}</p>
<span>{{ schedule.assignmentCount }} assignments</span>
</div>
<h3>{{ schedule.set.name }}</h3>
<span class="schedule-card__dates">
{{ formatDate(schedule.startDate) }} to {{ formatDate(schedule.targetDate) }}
</span>
</article>
</RouterLink>
</li>
</ul>
</section>
<section class="sets-catalog" aria-labelledby="sets-heading"> <section class="sets-catalog" aria-labelledby="sets-heading">
<div class="sets-catalog__introduction"> <div class="sets-catalog__introduction">
<p class="sets-catalog__eyebrow">Your library</p> <p class="sets-catalog__eyebrow">Your library</p>
@ -72,6 +135,34 @@ onMounted(async () => {
margin: clamp(4.5rem, 10vh, 7rem) auto 0; margin: clamp(4.5rem, 10vh, 7rem) auto 0;
} }
.schedules-catalog {
width: min(100%, 72rem);
margin: clamp(4.5rem, 10vh, 7rem) auto 0;
}
.schedules-catalog__heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 2rem;
}
.schedules-catalog__heading h2 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(2.25rem, 5vw, 3.5rem);
font-weight: 500;
line-height: 1;
letter-spacing: -0.045em;
}
.schedules-catalog__heading > p {
max-width: 24rem;
margin: 0;
color: #68776f;
line-height: 1.6;
}
.sets-catalog__introduction { .sets-catalog__introduction {
max-width: 44rem; max-width: 44rem;
} }
@ -120,6 +211,11 @@ h1 {
gap: 1rem; gap: 1rem;
} }
.catalog-state--compact {
min-height: 7rem;
margin-top: 2rem;
}
.catalog-state--error p { .catalog-state--error p {
margin: 0; margin: 0;
} }
@ -155,6 +251,78 @@ h1 {
list-style: none; list-style: none;
} }
.schedule-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: 1rem;
margin: 2rem 0 0;
padding: 0;
list-style: none;
}
.schedule-card-link {
display: block;
height: 100%;
border-radius: 1rem;
text-decoration: none;
}
.schedule-card {
height: 100%;
padding: 1.4rem;
border: 1px solid rgb(24 48 41 / 12%);
border-radius: 1rem;
background: rgb(255 253 247 / 88%);
box-shadow: 0 0.75rem 2rem rgb(40 62 52 / 7%);
transition:
border-color 160ms ease,
transform 160ms ease;
}
.schedule-card-link:hover .schedule-card {
border-color: rgb(40 92 78 / 35%);
transform: translateY(-2px);
}
.schedule-card-link:focus-visible {
outline: 3px solid rgb(86 127 112 / 38%);
outline-offset: 0.25rem;
}
.schedule-card__heading {
display: flex;
justify-content: space-between;
gap: 1rem;
color: #926044;
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.schedule-card__heading p {
margin: 0;
}
.schedule-card__kind {
text-transform: none;
}
.schedule-card h3 {
margin: 2rem 0 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.8rem;
font-weight: 500;
line-height: 1.1;
}
.schedule-card__dates {
display: block;
margin-top: 1rem;
color: #68776f;
font-size: 0.78rem;
}
.set-card { .set-card {
height: 100%; height: 100%;
min-height: 11rem; min-height: 11rem;
@ -225,6 +393,15 @@ h1 {
margin-top: 3.75rem; margin-top: 3.75rem;
} }
.schedules-catalog {
margin-top: 3.75rem;
}
.schedules-catalog__heading {
display: grid;
gap: 1rem;
}
.set-grid { .set-grid {
margin-top: 2.25rem; margin-top: 2.25rem;
} }

View file

@ -0,0 +1,308 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
import { useSchedulesStore } from '@/stores/schedules'
const route = useRoute()
const schedulesStore = useSchedulesStore()
const { activeSchedule, detailLoading, detailError, detailNotFound } = storeToRefs(schedulesStore)
const currentScheduleId = ref<number | null>(null)
watch(
() => route.params.scheduleId,
async (scheduleIdParameter) => {
const rawScheduleId = Array.isArray(scheduleIdParameter)
? scheduleIdParameter[0]
: scheduleIdParameter
const scheduleId = Number(rawScheduleId)
currentScheduleId.value = scheduleId
if (activeSchedule.value?.id !== scheduleId) {
await schedulesStore.fetchSchedule(scheduleId)
}
},
{ immediate: true },
)
async function retry(): Promise<void> {
if (currentScheduleId.value !== null) {
await schedulesStore.fetchSchedule(currentScheduleId.value)
}
}
function formatDate(value: string): string {
return new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
timeZone: 'UTC',
}).format(new Date(`${value}T00:00:00Z`))
}
</script>
<template>
<main class="schedule-page">
<AuthenticatedHeader />
<section class="schedule-shell">
<RouterLink class="back-link" :to="{ name: 'dashboard' }">
<span aria-hidden="true"></span>
Back to dashboard
</RouterLink>
<p v-if="detailLoading" class="page-state" role="status">Loading schedule...</p>
<div v-else-if="detailNotFound" class="page-state page-state--error" role="alert">
<h1>Schedule not found</h1>
<p>This schedule is not available.</p>
</div>
<div v-else-if="detailError !== null" class="page-state page-state--error" role="alert">
<p>{{ detailError }}</p>
<button type="button" class="retry-button" @click="retry">Try again</button>
</div>
<template v-else-if="activeSchedule !== null">
<header class="schedule-introduction">
<p class="eyebrow">
<span class="schedule-kind">{{ activeSchedule.elementKind }}</span> plan
</p>
<h1>{{ activeSchedule.set.name }} schedule</h1>
<p class="schedule-summary">
{{ activeSchedule.assignmentCount }}
{{ activeSchedule.elementKind }}
{{ activeSchedule.assignmentCount === 1 ? 'assignment' : 'assignments' }} across
{{ activeSchedule.days.length }}
{{ activeSchedule.days.length === 1 ? 'day' : 'days' }}
</p>
<p class="schedule-range">
{{ formatDate(activeSchedule.startDate) }} to
{{ formatDate(activeSchedule.targetDate) }}
</p>
</header>
<ol class="schedule-days" aria-label="Scheduled days">
<li
v-for="day in activeSchedule.days"
:key="day.date"
class="schedule-day"
data-schedule-day
:data-schedule-date="day.date"
>
<header class="schedule-day__header">
<time :datetime="day.date">{{ formatDate(day.date) }}</time>
<span>{{ day.assignments.length }} assigned</span>
</header>
<p v-if="day.assignments.length === 0" class="rest-day">Rest day</p>
<ul v-else class="assignment-list">
<li v-for="assignment in day.assignments" :key="assignment.id">
<span class="assignment-path">{{ assignment.element.path.join(' / ') }}</span>
<span class="assignment-kind">{{ assignment.element.kind }}</span>
</li>
</ul>
</li>
</ol>
</template>
</section>
</main>
</template>
<style scoped>
.schedule-page {
min-height: 100vh;
min-height: 100svh;
padding: 2rem clamp(1.5rem, 6vw, 5rem) 5rem;
color: #183029;
background: #f4f1e7;
}
.schedule-shell {
width: min(100%, 56rem);
margin: clamp(3.5rem, 8vh, 5.5rem) auto 0;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border-radius: 0.35rem;
color: #5e7067;
font-size: 0.84rem;
font-weight: 750;
text-decoration: none;
}
.back-link:focus-visible,
.retry-button:focus-visible {
outline: 3px solid rgb(86 127 112 / 34%);
outline-offset: 0.25rem;
}
.schedule-introduction {
margin-top: 2.75rem;
}
.eyebrow {
margin: 0 0 1rem;
color: #926044;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.schedule-kind {
text-transform: none;
}
h1 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(3rem, 7vw, 5.25rem);
font-weight: 500;
line-height: 1;
letter-spacing: -0.055em;
}
.schedule-summary {
margin: 1.35rem 0 0;
color: #344e45;
font-weight: 750;
}
.schedule-range {
margin: 0.5rem 0 0;
color: #68776f;
}
.schedule-days {
display: grid;
gap: 1rem;
margin: 3rem 0 0;
padding: 0;
list-style: none;
}
.schedule-day {
overflow: hidden;
border: 1px solid rgb(24 48 41 / 12%);
border-radius: 1rem;
background: rgb(255 253 247 / 82%);
box-shadow: 0 0.75rem 2rem rgb(40 62 52 / 6%);
}
.schedule-day__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid rgb(24 48 41 / 10%);
color: #5e7067;
background: rgb(239 228 212 / 42%);
font-size: 0.75rem;
font-weight: 800;
}
.rest-day {
margin: 0;
padding: 1.2rem 1rem;
color: #79877f;
font-style: italic;
}
.assignment-list {
display: grid;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
}
.assignment-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
}
.assignment-list li + li {
border-top: 1px solid rgb(24 48 41 / 10%);
}
.assignment-path {
min-width: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.05rem;
overflow-wrap: anywhere;
}
.assignment-kind {
flex: 0 0 auto;
padding: 0.35rem 0.55rem;
border-radius: 999px;
color: #81533a;
background: #efe4d4;
font-size: 0.62rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: none;
}
.page-state {
display: grid;
min-height: 10rem;
place-items: center;
margin-top: 2.75rem;
padding: 2rem;
border: 1px solid rgb(24 48 41 / 12%);
border-radius: 1rem;
color: #68776f;
background: rgb(255 253 247 / 72%);
text-align: center;
}
.page-state--error {
align-content: center;
gap: 1rem;
}
.page-state--error h1,
.page-state--error p {
margin: 0;
}
.page-state--error h1 {
font-size: clamp(2.25rem, 5vw, 3.6rem);
}
.retry-button {
min-height: 2.65rem;
padding: 0.65rem 1rem;
border: 1px solid rgb(24 58 49 / 28%);
border-radius: 0.7rem;
color: #183a31;
background: #fffdf7;
font-size: 0.82rem;
font-weight: 750;
cursor: pointer;
}
@media (max-width: 37.5rem) {
.schedule-page {
padding: 1.4rem 1.1rem 3rem;
}
.schedule-shell {
margin-top: 3rem;
}
.assignment-list li {
align-items: flex-start;
}
}
</style>

View file

@ -63,6 +63,14 @@ async function retry(): Promise<void> {
<p class="set-layout__description"> <p class="set-layout__description">
Explore every element in this set and see how each level fits into the whole. Explore every element in this set and see how each level fits into the whole.
</p> </p>
<RouterLink
v-if="layout.elements.length > 0"
class="schedule-link"
:to="{ name: 'schedule-create', params: { setId: layout.set.id } }"
>
Schedule this set
<span aria-hidden="true"></span>
</RouterLink>
</header> </header>
<p <p
@ -145,6 +153,31 @@ h1 {
line-height: 1.7; line-height: 1.7;
} }
.schedule-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.8rem;
margin-top: 1.5rem;
padding: 0.7rem 1rem;
border-radius: 0.7rem;
color: #fffdf7;
background: #183a31;
box-shadow: 0 0.55rem 1.2rem rgb(24 58 49 / 14%);
font-size: 0.82rem;
font-weight: 750;
text-decoration: none;
}
.schedule-link:hover {
background: #285c4e;
}
.schedule-link:focus-visible {
outline: 3px solid rgb(86 127 112 / 34%);
outline-offset: 0.25rem;
}
.layout-outline { .layout-outline {
margin-top: 3rem; margin-top: 3rem;
} }