Compare commits
26 commits
0049248231
...
66d9c8cbd7
| Author | SHA1 | Date | |
|---|---|---|---|
| 66d9c8cbd7 | |||
| f683f46c3e | |||
| bde48331de | |||
| 14eeee9e99 | |||
| 974d57ba15 | |||
| 74feb38300 | |||
| 2101ab50d2 | |||
| 00cfeebb9a | |||
| 98a4e2e007 | |||
| 8e38b8f7e8 | |||
| dfca13936a | |||
| 3cf1aff6a9 | |||
| a64df64b76 | |||
| 99e1ba051c | |||
| 25495b6c4d | |||
| 0d102fcee0 | |||
| 7e8850b1b2 | |||
| 0e8d45a408 | |||
| f500bd6095 | |||
| 14fe2fcb46 | |||
| 92905c981b | |||
| 7e9e93f8d0 | |||
| b7fb594c26 | |||
| b812a4b21b | |||
| 98ae9bf088 | |||
| bdb266746d |
67 changed files with 4183 additions and 147 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
10
ai/shared.md
10
ai/shared.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
175
backend/app/Http/Controllers/ScheduleController.php
Normal file
175
backend/app/Http/Controllers/ScheduleController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
19
backend/app/Schedule/CreateScheduleAssignmentDto.php
Normal file
19
backend/app/Schedule/CreateScheduleAssignmentDto.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
21
backend/app/Schedule/CreateScheduleDto.php
Normal file
21
backend/app/Schedule/CreateScheduleDto.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
99
backend/app/Schedule/EloquentScheduleRepository.php
Normal file
99
backend/app/Schedule/EloquentScheduleRepository.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
60
backend/app/Schedule/Schedule.php
Normal file
60
backend/app/Schedule/Schedule.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
backend/app/Schedule/ScheduleAssignment.php
Normal file
53
backend/app/Schedule/ScheduleAssignment.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
backend/app/Schedule/ScheduleAssignmentModel.php
Normal file
49
backend/app/Schedule/ScheduleAssignmentModel.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
45
backend/app/Schedule/ScheduleModel.php
Normal file
45
backend/app/Schedule/ScheduleModel.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/app/Schedule/ScheduleRepository.php
Normal file
17
backend/app/Schedule/ScheduleRepository.php
Normal 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;
|
||||||
|
}
|
||||||
240
backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php
Normal file
240
backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
30
backend/app/Schedule/UseCases/GetSchedule/GetSchedule.php
Normal file
30
backend/app/Schedule/UseCases/GetSchedule/GetSchedule.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
backend/app/Set/CreateSetLevelDto.php
Normal file
11
backend/app/Set/CreateSetLevelDto.php
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Set;
|
||||||
|
|
||||||
|
final readonly class CreateSetLevelDto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public Set $set,
|
||||||
|
public string $kind,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
88
backend/app/Set/EloquentSetLevelRepository.php
Normal file
88
backend/app/Set/EloquentSetLevelRepository.php
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/app/Set/SetLevel.php
Normal file
33
backend/app/Set/SetLevel.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
backend/app/Set/SetLevelModel.php
Normal file
38
backend/app/Set/SetLevelModel.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/app/Set/SetLevelRepository.php
Normal file
20
backend/app/Set/SetLevelRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
23
backend/app/Set/UseCases/GetSetLayout/SetLayoutLevel.php
Normal file
23
backend/app/Set/UseCases/GetSetLayout/SetLayoutLevel.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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',
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
45
backend/database/seeders/SetLevelSeeder.php
Normal file
45
backend/database/seeders/SetLevelSeeder.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
101
backend/tests/Fakes/FakeScheduleRepository.php
Normal file
101
backend/tests/Fakes/FakeScheduleRepository.php
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
backend/tests/Fakes/FakeSetLevelRepository.php
Normal file
76
backend/tests/Fakes/FakeSetLevelRepository.php
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
391
backend/tests/Feature/Schedule/ScheduleEndpointTest.php
Normal file
391
backend/tests/Feature/Schedule/ScheduleEndpointTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
backend/tests/Feature/Set/EloquentSetLevelRepositoryTest.php
Normal file
92
backend/tests/Feature/Set/EloquentSetLevelRepositoryTest.php
Normal 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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
441
backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php
Normal file
441
backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php
Normal 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
268
frontend/website/cypress/e2e/set-scheduling.cy.ts
Normal file
268
frontend/website/cypress/e2e/set-scheduling.cy.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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.',
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
219
frontend/website/src/stores/schedules.ts
Normal file
219
frontend/website/src/stores/schedules.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input {
|
input,
|
||||||
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
384
frontend/website/src/views/CreateScheduleView.vue
Normal file
384
frontend/website/src/views/CreateScheduleView.vue
Normal 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>
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
308
frontend/website/src/views/ScheduleDetailView.vue
Normal file
308
frontend/website/src/views/ScheduleDetailView.vue
Normal 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>
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue