Compare commits

..

9 commits

21 changed files with 1133 additions and 23 deletions

View file

@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Exceptions\BadRequestException; use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException; use App\Exceptions\NotFoundException;
use App\Schedule\AssignmentForDate;
use App\Schedule\Schedule; use App\Schedule\Schedule;
use App\Schedule\ScheduleAssignment; use App\Schedule\ScheduleAssignment;
use App\Schedule\UseCases\CreateSchedule\CreateSchedule; use App\Schedule\UseCases\CreateSchedule\CreateSchedule;
@ -11,6 +12,8 @@ use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest;
use App\Schedule\UseCases\GetSchedule\GetSchedule; use App\Schedule\UseCases\GetSchedule\GetSchedule;
use App\Schedule\UseCases\GetSchedule\GetScheduleRequest; use App\Schedule\UseCases\GetSchedule\GetScheduleRequest;
use App\Schedule\UseCases\ListSchedules\ListSchedules; use App\Schedule\UseCases\ListSchedules\ListSchedules;
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDate;
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDateRequest;
use App\Shared\Http\RequestInput; use App\Shared\Http\RequestInput;
use App\User\User; use App\User\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@ -22,6 +25,7 @@ class ScheduleController extends Controller
private CreateSchedule $createSchedule, private CreateSchedule $createSchedule,
private ListSchedules $listSchedules, private ListSchedules $listSchedules,
private GetSchedule $getSchedule, private GetSchedule $getSchedule,
private ListAssignmentsForDate $listAssignmentsForDate,
) {} ) {}
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
@ -90,6 +94,36 @@ class ScheduleController extends Controller
]); ]);
} }
public function assignments(Request $request): JsonResponse
{
$input = new RequestInput($request);
$date = $input->string('date');
try {
$assignments = $this->listAssignmentsForDate->execute(
new ListAssignmentsForDateRequest(
user: $this->user($request),
date: $date,
),
);
} catch (BadRequestException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()],
400,
);
}
return new JsonResponse([
'date' => $date,
'assignments' => array_map(
function (AssignmentForDate $assignment): array {
return $this->assignmentForDatePayload($assignment);
},
$assignments,
),
]);
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@ -165,6 +199,30 @@ class ScheduleController extends Controller
]; ];
} }
/**
* @return array<string, mixed>
*/
private function assignmentForDatePayload(
AssignmentForDate $assignmentForDate,
): array {
$assignment = $assignmentForDate->getAssignment();
return [
'id' => $assignment->getId(),
'schedule' => [
'id' => $assignmentForDate->getScheduleId(),
'set' => [
'name' => $assignmentForDate->getSetName(),
],
],
'element' => [
'name' => $assignment->getName(),
'kind' => $assignment->getKind(),
'path' => $assignment->getPath(),
],
];
}
private function user(Request $request): User private function user(Request $request): User
{ {
/** @var User $user */ /** @var User $user */

View file

@ -0,0 +1,27 @@
<?php
namespace App\Schedule;
final readonly class AssignmentForDate
{
public function __construct(
private int $scheduleId,
private string $setName,
private ScheduleAssignment $assignment,
) {}
public function getScheduleId(): int
{
return $this->scheduleId;
}
public function getSetName(): string
{
return $this->setName;
}
public function getAssignment(): ScheduleAssignment
{
return $this->assignment;
}
}

View file

@ -61,6 +61,40 @@ class EloquentScheduleRepository implements ScheduleRepository
return $schedules; return $schedules;
} }
public function findAssignmentsForUserOnDate(
User $user,
DateTimeImmutable $date,
): array {
$assignmentModels = ScheduleAssignmentModel::query()
->select('schedule_assignments.*')
->join(
'schedules',
'schedules.id',
'=',
'schedule_assignments.schedule_id',
)
->where('schedules.user_id', $user->getId())
->where('schedule_assignments.scheduled_date', $date->format(
'Y-m-d',
))
->with('schedule')
->orderByDesc('schedules.id')
->orderBy('schedule_assignments.position')
->orderBy('schedule_assignments.id')
->get();
$assignments = [];
foreach ($assignmentModels as $assignmentModel) {
$assignments[] = new AssignmentForDate(
scheduleId: $assignmentModel->schedule->id,
setName: $assignmentModel->schedule->set_name,
assignment: $this->assignmentToDomain($assignmentModel),
);
}
return $assignments;
}
private function toDomain(ScheduleModel $model, User $user): Schedule private function toDomain(ScheduleModel $model, User $user): Schedule
{ {
$assignmentModels = ScheduleAssignmentModel::query() $assignmentModels = ScheduleAssignmentModel::query()
@ -71,14 +105,7 @@ class EloquentScheduleRepository implements ScheduleRepository
$assignments = []; $assignments = [];
foreach ($assignmentModels as $assignmentModel) { foreach ($assignmentModels as $assignmentModel) {
$assignments[] = new ScheduleAssignment( $assignments[] = $this->assignmentToDomain($assignmentModel);
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( return new Schedule(
@ -92,6 +119,19 @@ class EloquentScheduleRepository implements ScheduleRepository
); );
} }
private function assignmentToDomain(
ScheduleAssignmentModel $model,
): ScheduleAssignment {
return new ScheduleAssignment(
id: $model->id,
name: $model->element_name,
kind: $model->element_kind,
path: $model->element_path,
scheduledDate: $this->date($model->scheduled_date),
position: $model->position,
);
}
private function date(string $value): DateTimeImmutable private function date(string $value): DateTimeImmutable
{ {
return new DateTimeImmutable($value, new DateTimeZone('UTC')); return new DateTimeImmutable($value, new DateTimeZone('UTC'));

View file

@ -5,6 +5,7 @@ namespace App\Schedule;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
* @property int $id * @property int $id
@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Model;
* @property list<string> $element_path * @property list<string> $element_path
* @property string $scheduled_date * @property string $scheduled_date
* @property int $position * @property int $position
* @property-read ScheduleModel $schedule
* *
* @method static Builder<static>|ScheduleAssignmentModel newModelQuery() * @method static Builder<static>|ScheduleAssignmentModel newModelQuery()
* @method static Builder<static>|ScheduleAssignmentModel newQuery() * @method static Builder<static>|ScheduleAssignmentModel newQuery()
@ -46,4 +48,12 @@ class ScheduleAssignmentModel extends Model
'position' => 'integer', 'position' => 'integer',
]; ];
} }
/**
* @return BelongsTo<ScheduleModel, $this>
*/
public function schedule(): BelongsTo
{
return $this->belongsTo(ScheduleModel::class, 'schedule_id');
}
} }

View file

@ -3,6 +3,7 @@
namespace App\Schedule; namespace App\Schedule;
use App\User\User; use App\User\User;
use DateTimeImmutable;
interface ScheduleRepository interface ScheduleRepository
{ {
@ -14,4 +15,12 @@ interface ScheduleRepository
* @return list<Schedule> * @return list<Schedule>
*/ */
public function findAllForUser(User $user): array; public function findAllForUser(User $user): array;
/**
* @return list<AssignmentForDate>
*/
public function findAssignmentsForUserOnDate(
User $user,
DateTimeImmutable $date,
): array;
} }

View file

@ -0,0 +1,59 @@
<?php
namespace App\Schedule\UseCases\ListAssignmentsForDate;
use App\Exceptions\BadRequestException;
use App\Schedule\AssignmentForDate;
use App\Schedule\ScheduleRepository;
use DateTimeImmutable;
use DateTimeZone;
class ListAssignmentsForDate
{
public function __construct(
private ScheduleRepository $scheduleRepository,
) {}
/**
* @return list<AssignmentForDate>
* @throws BadRequestException
*/
public function execute(ListAssignmentsForDateRequest $request): array
{
$date = $this->parseDate($request->date);
return $this->scheduleRepository->findAssignmentsForUserOnDate(
$request->user,
$date,
);
}
/**
* @throws BadRequestException
*/
private function parseDate(?string $value): DateTimeImmutable
{
if ($value === null || $value === '') {
throw new BadRequestException('date 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(
'date must be a valid date in YYYY-MM-DD format',
);
}
return $date;
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Schedule\UseCases\ListAssignmentsForDate;
use App\User\User;
final readonly class ListAssignmentsForDateRequest
{
public function __construct(
public User $user,
public ?string $date,
) {}
}

View file

@ -15,6 +15,7 @@ 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::get('/assignments', [ScheduleController::class, 'assignments']);
Route::post('/schedules', [ScheduleController::class, 'store']); Route::post('/schedules', [ScheduleController::class, 'store']);
Route::get('/schedules', [ScheduleController::class, 'index']); Route::get('/schedules', [ScheduleController::class, 'index']);
Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show']) Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show'])

View file

@ -3,10 +3,12 @@
namespace Tests\Fakes; namespace Tests\Fakes;
use App\Schedule\CreateScheduleDto; use App\Schedule\CreateScheduleDto;
use App\Schedule\AssignmentForDate;
use App\Schedule\Schedule; use App\Schedule\Schedule;
use App\Schedule\ScheduleAssignment; use App\Schedule\ScheduleAssignment;
use App\Schedule\ScheduleRepository; use App\Schedule\ScheduleRepository;
use App\User\User; use App\User\User;
use DateTimeImmutable;
class FakeScheduleRepository implements ScheduleRepository class FakeScheduleRepository implements ScheduleRepository
{ {
@ -72,6 +74,31 @@ class FakeScheduleRepository implements ScheduleRepository
}, array_values($schedules)); }, array_values($schedules));
} }
public function findAssignmentsForUserOnDate(
User $user,
DateTimeImmutable $date,
): array {
$assignments = [];
foreach ($this->findAllForUser($user) as $schedule) {
foreach ($schedule->getAssignments() as $assignment) {
if ($assignment->getScheduledDate()->format('Y-m-d')
!== $date->format('Y-m-d')
) {
continue;
}
$assignments[] = new AssignmentForDate(
scheduleId: $schedule->getId(),
setName: $schedule->getSetName(),
assignment: $assignment,
);
}
}
return $assignments;
}
private function copy(Schedule $schedule): Schedule private function copy(Schedule $schedule): Schedule
{ {
$assignments = array_map( $assignments = array_map(

View file

@ -221,6 +221,136 @@ class ScheduleEndpointTest extends TestCase
]); ]);
} }
public function test_it_lists_the_users_assignments_for_a_date(): void
{
$user = $this->createUser('reader@example.com');
$otherUser = $this->createUser('other@example.com');
$olderSet = $this->createSet($user, 'Older course');
$olderLevel = $this->createLevel($olderSet, 'lesson');
$olderRepository = app(ElementRepository::class);
$olderRepository->create(new CreateElementDto(
name: 'First lesson',
level: $olderLevel,
parentElement: null,
));
$olderRepository->create(new CreateElementDto(
name: 'Second lesson',
level: $olderLevel,
parentElement: null,
));
$newerSet = $this->createSet($user, 'Newer course');
$newerLevel = $this->createLevel($newerSet, 'chapter');
app(ElementRepository::class)->create(new CreateElementDto(
name: 'Only chapter',
level: $newerLevel,
parentElement: null,
));
$this->createSession($user, 'valid-token');
$this->createSession($otherUser, 'other-token');
$this->credentialedPost('/api/schedules', [
'setId' => $olderSet->getId(),
'levelId' => $olderLevel->getId(),
'startDate' => '2026-08-15',
'targetDate' => '2026-08-15',
])->assertCreated();
$this->credentialedPost('/api/schedules', [
'setId' => $newerSet->getId(),
'levelId' => $newerLevel->getId(),
'startDate' => '2026-08-15',
'targetDate' => '2026-08-15',
])->assertCreated();
$this->withCredentials()
->withUnencryptedCookie(
AuthMiddleware::COOKIE_NAME,
'other-token',
)->postJson('/api/schedules', [
'setId' => $newerSet->getId(),
'levelId' => $newerLevel->getId(),
'startDate' => '2026-08-15',
'targetDate' => '2026-08-15',
])->assertCreated();
$this->credentialedPost('/api/schedules', [
'setId' => $olderSet->getId(),
'levelId' => $olderLevel->getId(),
'startDate' => '2026-08-16',
'targetDate' => '2026-08-16',
])->assertCreated();
$this->credentialedGet('/api/assignments?date=2026-08-15')
->assertOk()
->assertExactJson([
'date' => '2026-08-15',
'assignments' => [
[
'id' => 3,
'schedule' => [
'id' => 2,
'set' => [
'name' => 'Newer course',
],
],
'element' => [
'name' => 'Only chapter',
'kind' => 'chapter',
'path' => ['Only chapter'],
],
],
[
'id' => 1,
'schedule' => [
'id' => 1,
'set' => [
'name' => 'Older course',
],
],
'element' => [
'name' => 'First lesson',
'kind' => 'lesson',
'path' => ['First lesson'],
],
],
[
'id' => 2,
'schedule' => [
'id' => 1,
'set' => [
'name' => 'Older course',
],
],
'element' => [
'name' => 'Second lesson',
'kind' => 'lesson',
'path' => ['Second lesson'],
],
],
],
]);
}
public function test_it_returns_an_empty_assignment_list_for_a_date(): void
{
$user = $this->createUser('reader@example.com');
$this->createSession($user, 'valid-token');
$this->credentialedGet('/api/assignments?date=2026-08-15')
->assertOk()
->assertExactJson([
'date' => '2026-08-15',
'assignments' => [],
]);
}
public function test_it_rejects_invalid_assignment_dates(): void
{
$user = $this->createUser('reader@example.com');
$this->createSession($user, 'valid-token');
$this->credentialedGet('/api/assignments')
->assertBadRequest()
->assertExactJson(['error' => 'date is required']);
}
public function test_it_is_stable_after_sources_change_or_are_deleted(): void public function test_it_is_stable_after_sources_change_or_are_deleted(): void
{ {
$user = $this->createUser('reader@example.com'); $user = $this->createUser('reader@example.com');
@ -323,6 +453,8 @@ class ScheduleEndpointTest extends TestCase
public function test_schedule_endpoints_require_authentication(): void public function test_schedule_endpoints_require_authentication(): void
{ {
$this->getJson('/api/assignments?date=2026-08-15')
->assertStatus(401);
$this->getJson('/api/schedules')->assertStatus(401); $this->getJson('/api/schedules')->assertStatus(401);
$this->getJson('/api/schedules/1')->assertStatus(401); $this->getJson('/api/schedules/1')->assertStatus(401);
$this->postJson('/api/schedules', [])->assertStatus(401); $this->postJson('/api/schedules', [])->assertStatus(401);

View file

@ -0,0 +1,143 @@
<?php
namespace Tests\Unit\Schedule\UseCases;
use App\Exceptions\BadRequestException;
use App\Schedule\CreateScheduleAssignmentDto;
use App\Schedule\CreateScheduleDto;
use App\Schedule\ScheduleAssignment;
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDate;
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDateRequest;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeScheduleRepository;
class ListAssignmentsForDateTest extends TestCase
{
public function test_it_lists_the_users_assignments_for_the_date(): void
{
$user = $this->user(1, 'reader@example.com');
$otherUser = $this->user(2, 'other@example.com');
$repository = new FakeScheduleRepository;
$repository->create($this->schedule(
user: $user,
setName: 'Older plan',
date: '2026-08-15',
assignmentNames: ['First', 'Second'],
));
$repository->create($this->schedule(
user: $user,
setName: 'Newer plan',
date: '2026-08-15',
assignmentNames: ['Third'],
));
$repository->create($this->schedule(
user: $otherUser,
setName: 'Private plan',
date: '2026-08-15',
assignmentNames: ['Hidden'],
));
$repository->create($this->schedule(
user: $user,
setName: 'Tomorrow plan',
date: '2026-08-16',
assignmentNames: ['Later'],
));
$assignments = (new ListAssignmentsForDate($repository))->execute(
new ListAssignmentsForDateRequest(
user: $user,
date: '2026-08-15',
),
);
$this->assertSame(
['Newer plan', 'Older plan', 'Older plan'],
array_map(function ($assignment): string {
return $assignment->getSetName();
}, $assignments),
);
$this->assertSame(
['Third', 'First', 'Second'],
array_map(function ($assignment): string {
return $assignment->getAssignment()->getName();
}, $assignments),
);
$this->assertSame(2, $assignments[0]->getScheduleId());
}
public function test_it_rejects_a_missing_date(): void
{
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('date is required');
(new ListAssignmentsForDate(new FakeScheduleRepository))->execute(
new ListAssignmentsForDateRequest(
user: $this->user(1, 'reader@example.com'),
date: null,
),
);
}
public function test_it_rejects_an_invalid_date(): void
{
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage(
'date must be a valid date in YYYY-MM-DD format',
);
(new ListAssignmentsForDate(new FakeScheduleRepository))->execute(
new ListAssignmentsForDateRequest(
user: $this->user(1, 'reader@example.com'),
date: '2026-02-30',
),
);
}
private function user(int $id, string $email): User
{
return new User(
id: $id,
email: new EmailAddress($email),
passwordHash: 'hashed-password',
);
}
/**
* @param list<string> $assignmentNames
*/
private function schedule(
User $user,
string $setName,
string $date,
array $assignmentNames,
): CreateScheduleDto {
$scheduledDate = new DateTimeImmutable(
$date,
new DateTimeZone('UTC'),
);
$assignments = [];
foreach ($assignmentNames as $index => $name) {
$assignments[] = new CreateScheduleAssignmentDto(
name: $name,
kind: 'lesson',
path: [$setName, $name],
scheduledDate: $scheduledDate,
position: $index + 1,
);
}
return new CreateScheduleDto(
user: $user,
setName: $setName,
elementKind: 'lesson',
startDate: $scheduledDate,
targetDate: $scheduledDate,
assignments: $assignments,
);
}
}

View file

@ -17,6 +17,12 @@ describe('email confirmation', () => {
statusCode: 200, statusCode: 200,
body: { schedules: [] }, body: { schedules: [] },
}) })
cy.intercept('GET', '**/api/assignments?date=*', (request) => {
request.reply({
statusCode: 200,
body: { date: request.query.date, assignments: [] },
})
})
}) })
it('chooses a password, confirms the account, and opens the dashboard', () => { it('chooses a password, confirms the account, and opens the dashboard', () => {
@ -39,7 +45,7 @@ describe('email confirmation', () => {
cy.wait('@confirmEmail') cy.wait('@confirmEmail')
cy.location('pathname').should('equal', '/dashboard') cy.location('pathname').should('equal', '/dashboard')
cy.get('h1').should('have.text', 'Available sets') cy.get('#sets-heading').should('have.text', 'Available sets')
}) })
it('validates password length and confirmation before submitting', () => { it('validates password length and confirmation before submitting', () => {

View file

@ -47,6 +47,12 @@ describe('session authentication', () => {
statusCode: 200, statusCode: 200,
body: { schedules: [] }, body: { schedules: [] },
}) })
cy.intercept('GET', '**/api/assignments?date=*', (request) => {
request.reply({
statusCode: 200,
body: { date: request.query.date, assignments: [] },
})
})
}) })
it('restores an authenticated session on a protected route', () => { it('restores an authenticated session on a protected route', () => {
@ -59,7 +65,7 @@ describe('session authentication', () => {
cy.wait('@me') cy.wait('@me')
cy.location('pathname').should('equal', '/dashboard') cy.location('pathname').should('equal', '/dashboard')
cy.get('h1').should('have.text', 'Available sets') cy.get('#sets-heading').should('have.text', 'Available sets')
}) })
it('redirects an unauthenticated protected route to login', () => { it('redirects an unauthenticated protected route to login', () => {

View file

@ -51,7 +51,7 @@ describe('set element layout', () => {
interceptAuthenticatedUser() interceptAuthenticatedUser()
}) })
it('opens a set from the dashboard and shows its full hierarchy', () => { it('opens a set with a foldable element hierarchy', () => {
cy.intercept('GET', '**/api/sets', { cy.intercept('GET', '**/api/sets', {
statusCode: 200, statusCode: 200,
body: { sets: [{ id: 41, name: 'Bible' }] }, body: { sets: [{ id: 41, name: 'Bible' }] },
@ -79,13 +79,35 @@ describe('set element layout', () => {
cy.get('[data-element-id="1"] > .element-node__card') cy.get('[data-element-id="1"] > .element-node__card')
.should('contain.text', 'Genesis') .should('contain.text', 'Genesis')
.and('contain.text', 'book') .and('contain.text', 'book')
.and('have.attr', 'aria-expanded', 'false')
.and('have.attr', 'aria-controls', 'element-children-1')
cy.get('[data-element-id="2"] > .element-node__card').should(
'not.match',
'button',
)
cy.get('[data-element-id="3"]').should('not.exist')
cy.get('[data-element-id="1"] > button.element-node__card').click()
cy.get('[data-element-id="1"] > .element-node__card').should(
'have.attr',
'aria-expanded',
'true',
)
cy.get('[data-element-id="1"] > ol > li').then(($nodes) => { cy.get('[data-element-id="1"] > ol > li').then(($nodes) => {
expect([...$nodes].map((node) => node.dataset.elementId)).to.deep.equal([ expect([...$nodes].map((node) => node.dataset.elementId)).to.deep.equal([
'3', '3',
'5', '5',
]) ])
}) })
cy.get('[data-element-id="3"] > ol > li') cy.get('[data-element-id="4"]').should('not.exist')
cy.get('[data-element-id="3"] > button.element-node__card').click()
cy.get('[data-element-id="3"] > .element-node__card').should(
'have.attr',
'aria-expanded',
'true',
)
cy.get('#element-children-3 > li')
.should('have.length', 1) .should('have.length', 1)
.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')
@ -93,6 +115,15 @@ describe('set element layout', () => {
.find('.element-node__kind') .find('.element-node__kind')
.should('have.text', 'Chapter_sections-v2') .should('have.text', 'Chapter_sections-v2')
.and('have.css', 'text-transform', 'none') .and('have.css', 'text-transform', 'none')
cy.get('[data-element-id="1"] > button.element-node__card').click()
cy.get('[data-element-id="3"]').should('not.exist')
cy.get('[data-element-id="1"] > button.element-node__card').click()
cy.get('[data-element-id="4"] > .element-node__card').should(
'contain.text',
'Chapter 1',
)
}) })
it('shows loading and empty layout states', () => { it('shows loading and empty layout states', () => {
@ -183,6 +214,8 @@ describe('set element layout', () => {
cy.wait('@me') cy.wait('@me')
cy.wait('@layout') cy.wait('@layout')
cy.get('[data-element-id="1"] > button.element-node__card').click()
cy.get('[data-element-id="3"] > button.element-node__card').click()
cy.contains('.element-node__card', 'Chapter 1').should('be.visible') cy.contains('.element-node__card', 'Chapter 1').should('be.visible')
cy.document().then((document) => { cy.document().then((document) => {
expect(document.documentElement.scrollWidth).to.be.at.most( expect(document.documentElement.scrollWidth).to.be.at.most(

View file

@ -250,6 +250,12 @@ describe('set scheduling', () => {
}, },
}) })
}).as('schedules') }).as('schedules')
cy.intercept('GET', '**/api/assignments?date=*', (request) => {
request.reply({
statusCode: 200,
body: { date: request.query.date, assignments: [] },
})
})
cy.visit('/dashboard') cy.visit('/dashboard')
cy.wait('@me') cy.wait('@me')

View file

@ -17,6 +17,12 @@ describe('sets dashboard', () => {
statusCode: 200, statusCode: 200,
body: { schedules: [] }, body: { schedules: [] },
}) })
cy.intercept('GET', '**/api/assignments?date=*', (request) => {
request.reply({
statusCode: 200,
body: { date: request.query.date, assignments: [] },
})
})
}) })
it('shows every available set as a detail link', () => { it('shows every available set as a detail link', () => {
@ -38,7 +44,7 @@ describe('sets dashboard', () => {
cy.wait('@me') cy.wait('@me')
cy.wait('@sets') cy.wait('@sets')
cy.get('h1').should('have.text', 'Available sets') cy.get('#sets-heading').should('have.text', 'Available sets')
cy.get('ul[aria-label="Available sets"] h2').then(($headings) => { cy.get('ul[aria-label="Available sets"] h2').then(($headings) => {
expect([...$headings].map((heading) => heading.textContent)).to.deep.equal([ expect([...$headings].map((heading) => heading.textContent)).to.deep.equal([
'Bible', 'Bible',

View file

@ -0,0 +1,157 @@
const authenticatedUser = {
id: 7,
email: 'user@example.com',
}
const browserToday = '2026-08-15'
function interceptDashboardRequests(): void {
cy.intercept('GET', '**/api/me', {
statusCode: 200,
body: { user: authenticatedUser },
}).as('me')
cy.intercept('GET', '**/api/sets', {
statusCode: 200,
body: { sets: [] },
}).as('sets')
cy.intercept('GET', '**/api/schedules', {
statusCode: 200,
body: { schedules: [] },
}).as('schedules')
}
describe("today's assignments", () => {
beforeEach(() => {
cy.clock(new Date(2026, 7, 15, 0, 30).getTime())
interceptDashboardRequests()
})
it('uses the browser date and links every assignment to its schedule', () => {
cy.intercept('GET', `**/api/assignments?date=${browserToday}`, (request) => {
expect(request.headers.accept).to.equal('application/json')
request.reply({
statusCode: 200,
body: {
date: browserToday,
assignments: [
{
id: 12,
schedule: {
id: 73,
set: { name: 'Bible' },
},
element: {
name: 'Chapter 1',
kind: 'Chapter_sections-v2',
path: ['Genesis', 'Creation', 'Chapter 1'],
},
},
{
id: 13,
schedule: {
id: 81,
set: { name: 'Course' },
},
element: {
name: 'Introduction',
kind: 'lesson',
path: ['Introduction'],
},
},
],
},
})
}).as('todayAssignments')
cy.visit('/dashboard')
cy.wait('@me')
cy.wait('@todayAssignments')
cy.get('#today-heading').should('have.text', 'Today')
cy.get('[data-today-date]').should(
'have.attr',
'data-today-date',
browserToday,
)
cy.get('ul[aria-label="Today\'s assignments"] > li').should(
'have.length',
2,
)
cy.contains('a', 'Genesis / Creation / Chapter 1')
.should('contain.text', 'Bible')
.and('have.attr', 'href', '/schedules/73')
cy.contains('a', 'Introduction')
.should('contain.text', 'Course')
.and('have.attr', 'href', '/schedules/81')
cy.get('.today-assignment__kind')
.first()
.should('have.text', 'Chapter_sections-v2')
.and('have.css', 'text-transform', 'none')
})
it('shows loading and empty states', () => {
cy.intercept('GET', `**/api/assignments?date=${browserToday}`, {
delay: 2500,
statusCode: 200,
body: {
date: browserToday,
assignments: [],
},
}).as('todayAssignments')
cy.visit('/dashboard')
cy.wait('@me')
cy.get('.today-assignments [role="status"]').should(
'contain.text',
"Loading today's assignments...",
)
cy.wait('@todayAssignments')
cy.get('.today-assignments [role="status"]').should(
'contain.text',
'Nothing is assigned for today.',
)
})
it('retries independently after the request fails', () => {
let requestCount = 0
cy.intercept(
'GET',
`**/api/assignments?date=${browserToday}`,
(request) => {
requestCount += 1
request.alias = `todayAssignments${requestCount}`
if (requestCount === 1) {
request.reply({ statusCode: 500 })
return
}
request.reply({
statusCode: 200,
body: {
date: browserToday,
assignments: [],
},
})
},
)
cy.visit('/dashboard')
cy.wait('@me')
cy.wait('@todayAssignments1')
cy.get('.today-assignments [role="alert"]')
.should('contain.text', "We couldn't load today's assignments.")
.within(() => {
cy.contains('button', 'Try again').click()
})
cy.get('#schedules-heading').should('have.text', 'Your schedules')
cy.wait('@todayAssignments2')
cy.get('.today-assignments [role="status"]').should(
'contain.text',
'Nothing is assigned for today.',
)
})
})

View file

@ -1,10 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SetElementNode } from '@/stores/setLayout' import type { SetElementNode } from '@/stores/setLayout'
defineProps<{ const props = defineProps<{
nodes: SetElementNode[] nodes: SetElementNode[]
expandedElementIds: ReadonlySet<number>
label?: string label?: string
}>() }>()
defineEmits<{
'toggle-element': [elementId: number]
}>()
function childListId(elementId: number): string {
return `element-children-${elementId}`
}
function isExpanded(elementId: number): boolean {
return props.expandedElementIds.has(elementId)
}
</script> </script>
<template> <template>
@ -14,12 +27,39 @@ defineProps<{
:aria-label="label" :aria-label="label"
> >
<li v-for="node in nodes" :key="node.id" class="element-tree__item" :data-element-id="node.id"> <li v-for="node in nodes" :key="node.id" class="element-tree__item" :data-element-id="node.id">
<div class="element-node__card"> <button
v-if="node.children.length > 0"
type="button"
class="element-node__card element-node__card--toggle"
:aria-expanded="isExpanded(node.id)"
:aria-controls="childListId(node.id)"
@click="$emit('toggle-element', node.id)"
>
<span class="element-node__name">{{ node.name }}</span> <span class="element-node__name">{{ node.name }}</span>
<span class="element-node__metadata">
<span class="element-node__kind">{{ node.kind }}</span> <span class="element-node__kind">{{ node.kind }}</span>
<span
class="element-node__chevron"
:class="{ 'element-node__chevron--expanded': isExpanded(node.id) }"
aria-hidden="true"
></span>
</span>
</button>
<div v-else class="element-node__card">
<span class="element-node__name">{{ node.name }}</span>
<span class="element-node__metadata">
<span class="element-node__kind">{{ node.kind }}</span>
</span>
</div> </div>
<ElementTree v-if="node.children.length > 0" :nodes="node.children" /> <ElementTree
v-if="node.children.length > 0 && isExpanded(node.id)"
:id="childListId(node.id)"
:nodes="node.children"
:expanded-element-ids="expandedElementIds"
@toggle-element="$emit('toggle-element', $event)"
/>
</li> </li>
</ol> </ol>
</template> </template>
@ -69,6 +109,23 @@ defineProps<{
box-shadow: 0 0.55rem 1.5rem rgb(40 62 52 / 6%); box-shadow: 0 0.55rem 1.5rem rgb(40 62 52 / 6%);
} }
.element-node__card--toggle {
width: 100%;
color: inherit;
text-align: left;
cursor: pointer;
}
.element-node__card--toggle:hover {
border-color: rgb(40 92 78 / 34%);
background: #fffdf7;
}
.element-node__card--toggle:focus-visible {
outline: 3px solid rgb(86 127 112 / 34%);
outline-offset: 0.2rem;
}
.element-node__name { .element-node__name {
min-width: 0; min-width: 0;
color: #183029; color: #183029;
@ -78,6 +135,13 @@ defineProps<{
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.element-node__metadata {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.8rem;
}
.element-node__kind { .element-node__kind {
flex: 0 0 auto; flex: 0 0 auto;
padding: 0.35rem 0.55rem; padding: 0.35rem 0.55rem;
@ -90,6 +154,18 @@ defineProps<{
text-transform: none; text-transform: none;
} }
.element-node__chevron {
width: 0.55rem;
height: 0.55rem;
border-right: 2px solid #5e7067;
border-bottom: 2px solid #5e7067;
transform: rotate(-45deg);
}
.element-node__chevron--expanded {
transform: rotate(45deg);
}
@media (max-width: 37.5rem) { @media (max-width: 37.5rem) {
.element-tree { .element-tree {
margin-left: 0.45rem; margin-left: 0.45rem;
@ -106,6 +182,10 @@ defineProps<{
padding: 0.75rem 0.7rem; padding: 0.75rem 0.7rem;
} }
.element-node__metadata {
gap: 0.6rem;
}
.element-node__kind { .element-node__kind {
padding-inline: 0.42rem; padding-inline: 0.42rem;
font-size: 0.56rem; font-size: 0.56rem;

View file

@ -26,6 +26,15 @@ const scheduleAssignmentSchema = z.object({
}), }),
}) })
export const assignmentForDateSchema = scheduleAssignmentSchema.extend({
schedule: z.object({
id: z.number().int().positive(),
set: z.object({
name: z.string().min(1),
}),
}),
})
export const scheduleDetailSchema = scheduleSummarySchema.extend({ export const scheduleDetailSchema = scheduleSummarySchema.extend({
days: z.array( days: z.array(
z.object({ z.object({
@ -43,12 +52,18 @@ const scheduleResponseSchema = z.object({
schedule: scheduleDetailSchema, schedule: scheduleDetailSchema,
}) })
const assignmentsForDateResponseSchema = z.object({
date: isoDateSchema,
assignments: z.array(assignmentForDateSchema),
})
const errorResponseSchema = z.object({ const errorResponseSchema = z.object({
error: z.string().min(1), error: z.string().min(1),
}) })
export type ScheduleSummary = z.infer<typeof scheduleSummarySchema> export type ScheduleSummary = z.infer<typeof scheduleSummarySchema>
export type ScheduleDetail = z.infer<typeof scheduleDetailSchema> export type ScheduleDetail = z.infer<typeof scheduleDetailSchema>
export type AssignmentForDate = z.infer<typeof assignmentForDateSchema>
export type CreateScheduleInput = { export type CreateScheduleInput = {
setId: number setId: number
levelId: number levelId: number
@ -59,6 +74,7 @@ export type CreateScheduleInput = {
const LIST_ERROR = "We couldn't load your schedules." const LIST_ERROR = "We couldn't load your schedules."
const DETAIL_ERROR = "We couldn't load this schedule." const DETAIL_ERROR = "We couldn't load this schedule."
const CREATE_ERROR = "We couldn't create this schedule." const CREATE_ERROR = "We couldn't create this schedule."
const ASSIGNMENTS_ERROR = "We couldn't load today's assignments."
export const useSchedulesStore = defineStore('schedules', () => { export const useSchedulesStore = defineStore('schedules', () => {
const schedules = ref<ScheduleSummary[]>([]) const schedules = ref<ScheduleSummary[]>([])
@ -70,7 +86,11 @@ export const useSchedulesStore = defineStore('schedules', () => {
const detailNotFound = ref(false) const detailNotFound = ref(false)
const creating = ref(false) const creating = ref(false)
const createError = ref<string | null>(null) const createError = ref<string | null>(null)
const assignmentsForDate = ref<AssignmentForDate[]>([])
const assignmentsLoading = ref(false)
const assignmentsError = ref<string | null>(null)
let activeDetailRequestId = 0 let activeDetailRequestId = 0
let assignmentsRequestId = 0
async function fetchSchedules(): Promise<boolean> { async function fetchSchedules(): Promise<boolean> {
listLoading.value = true listLoading.value = true
@ -202,6 +222,58 @@ export const useSchedulesStore = defineStore('schedules', () => {
} }
} }
async function fetchAssignmentsForDate(date: string): Promise<boolean> {
const requestId = ++assignmentsRequestId
assignmentsForDate.value = []
assignmentsLoading.value = true
assignmentsError.value = null
try {
const query = new URLSearchParams({ date })
const response = await fetch(`${API_BASE}/api/assignments?${query.toString()}`, {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
if (requestId !== assignmentsRequestId) {
return false
}
if (response.status !== 200) {
assignmentsError.value = ASSIGNMENTS_ERROR
return false
}
const responseBody: unknown = await response.json()
if (requestId !== assignmentsRequestId) {
return false
}
const parsedResponse = assignmentsForDateResponseSchema.parse(responseBody)
if (parsedResponse.date !== date) {
throw new Error('assignment response did not match requested date')
}
assignmentsForDate.value = parsedResponse.assignments
return true
} catch {
if (requestId === assignmentsRequestId) {
assignmentsForDate.value = []
assignmentsError.value = ASSIGNMENTS_ERROR
}
return false
} finally {
if (requestId === assignmentsRequestId) {
assignmentsLoading.value = false
}
}
}
return { return {
schedules, schedules,
listLoading, listLoading,
@ -212,8 +284,12 @@ export const useSchedulesStore = defineStore('schedules', () => {
detailNotFound, detailNotFound,
creating, creating,
createError, createError,
assignmentsForDate,
assignmentsLoading,
assignmentsError,
fetchSchedules, fetchSchedules,
fetchSchedule, fetchSchedule,
createSchedule, createSchedule,
fetchAssignmentsForDate,
} }
}) })

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { onMounted } from 'vue' import { onMounted, ref } from 'vue'
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue' import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
import { useSchedulesStore } from '@/stores/schedules' import { useSchedulesStore } from '@/stores/schedules'
@ -10,11 +10,25 @@ const setsStore = useSetsStore()
const schedulesStore = useSchedulesStore() const schedulesStore = useSchedulesStore()
const { sets, loading, error } = storeToRefs(setsStore) const { sets, loading, error } = storeToRefs(setsStore)
const { schedules, listLoading, listError } = storeToRefs(schedulesStore) const { schedules, listLoading, listError } = storeToRefs(schedulesStore)
const { assignmentsForDate, assignmentsLoading, assignmentsError } = storeToRefs(schedulesStore)
const todayDate = ref(browserDate(new Date()))
onMounted(async () => { onMounted(async () => {
await Promise.all([setsStore.fetchSets(), schedulesStore.fetchSchedules()]) await Promise.all([
setsStore.fetchSets(),
schedulesStore.fetchSchedules(),
schedulesStore.fetchAssignmentsForDate(todayDate.value),
])
}) })
function browserDate(date: Date): string {
const year = String(date.getFullYear()).padStart(4, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function formatDate(value: string): string { function formatDate(value: string): string {
return new Intl.DateTimeFormat('en', { return new Intl.DateTimeFormat('en', {
dateStyle: 'medium', dateStyle: 'medium',
@ -27,6 +41,67 @@ function formatDate(value: string): string {
<main class="dashboard-page"> <main class="dashboard-page">
<AuthenticatedHeader /> <AuthenticatedHeader />
<section class="today-assignments" aria-labelledby="today-heading">
<header class="today-assignments__introduction">
<div>
<p class="sets-catalog__eyebrow">Today's work</p>
<h1 id="today-heading">Today</h1>
</div>
<time :datetime="todayDate" :data-today-date="todayDate">
{{ formatDate(todayDate) }}
</time>
</header>
<p v-if="assignmentsLoading" class="today-state" role="status">
Loading today's assignments...
</p>
<div
v-else-if="assignmentsError !== null"
class="today-state today-state--error"
role="alert"
>
<p>{{ assignmentsError }}</p>
<button
type="button"
class="retry-button"
@click="schedulesStore.fetchAssignmentsForDate(todayDate)"
>
Try again
</button>
</div>
<p v-else-if="assignmentsForDate.length === 0" class="today-state" role="status">
Nothing is assigned for today.
</p>
<ul v-else class="today-assignment-list" aria-label="Today's assignments">
<li v-for="assignment in assignmentsForDate" :key="assignment.id">
<RouterLink
class="today-assignment-link"
:to="{
name: 'schedule-detail',
params: { scheduleId: assignment.schedule.id },
}"
>
<article class="today-assignment">
<div class="today-assignment__heading">
<p>{{ assignment.schedule.set.name }}</p>
<span class="today-assignment__kind">{{ assignment.element.kind }}</span>
</div>
<p class="today-assignment__path">
{{ assignment.element.path.join(' / ') }}
</p>
<span class="today-assignment__action">
Open schedule
<span aria-hidden="true"></span>
</span>
</article>
</RouterLink>
</li>
</ul>
</section>
<section class="schedules-catalog" aria-labelledby="schedules-heading"> <section class="schedules-catalog" aria-labelledby="schedules-heading">
<div class="schedules-catalog__heading"> <div class="schedules-catalog__heading">
<div> <div>
@ -83,7 +158,7 @@ function formatDate(value: string): string {
<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>
<h1 id="sets-heading">Available sets</h1> <h2 id="sets-heading">Available sets</h2>
<p class="sets-catalog__description"> <p class="sets-catalog__description">
Browse every set available in Attainly and find the collection that fits your next goal. Browse every set available in Attainly and find the collection that fits your next goal.
</p> </p>
@ -135,11 +210,29 @@ function formatDate(value: string): string {
margin: clamp(4.5rem, 10vh, 7rem) auto 0; margin: clamp(4.5rem, 10vh, 7rem) auto 0;
} }
.schedules-catalog { .today-assignments {
width: min(100%, 72rem); width: min(100%, 72rem);
margin: clamp(4.5rem, 10vh, 7rem) auto 0; margin: clamp(4.5rem, 10vh, 7rem) auto 0;
} }
.today-assignments__introduction {
display: flex;
align-items: end;
justify-content: space-between;
gap: 2rem;
}
.today-assignments__introduction time {
color: #68776f;
font-size: 0.82rem;
font-weight: 700;
}
.schedules-catalog {
width: min(100%, 72rem);
margin: clamp(3.5rem, 8vh, 5.5rem) auto 0;
}
.schedules-catalog__heading { .schedules-catalog__heading {
display: flex; display: flex;
align-items: end; align-items: end;
@ -176,7 +269,8 @@ function formatDate(value: string): string {
text-transform: uppercase; text-transform: uppercase;
} }
h1 { .today-assignments h1,
.sets-catalog__introduction h2 {
margin: 0; margin: 0;
font-family: Georgia, 'Times New Roman', serif; font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(3rem, 7vw, 5.25rem); font-size: clamp(3rem, 7vw, 5.25rem);
@ -185,6 +279,104 @@ h1 {
letter-spacing: -0.055em; letter-spacing: -0.055em;
} }
.today-state {
width: 100%;
min-height: 7rem;
display: grid;
place-items: center;
margin: 2rem 0 0;
padding: 1.5rem;
border: 1px solid rgb(24 48 41 / 12%);
border-radius: 1rem;
color: #68776f;
background: rgb(255 253 247 / 72%);
text-align: center;
}
.today-state--error {
align-content: center;
gap: 1rem;
}
.today-state--error p {
margin: 0;
}
.today-assignment-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
gap: 1rem;
margin: 2rem 0 0;
padding: 0;
list-style: none;
}
.today-assignment-link {
display: block;
height: 100%;
border-radius: 1rem;
text-decoration: none;
}
.today-assignment {
height: 100%;
padding: 1.4rem;
border: 1px solid rgb(24 48 41 / 12%);
border-radius: 1rem;
background: linear-gradient(135deg, rgb(255 253 247 / 98%), rgb(244 238 225 / 86%));
box-shadow: 0 0.75rem 2rem rgb(40 62 52 / 7%);
transition:
border-color 160ms ease,
transform 160ms ease;
}
.today-assignment-link:hover .today-assignment {
border-color: rgb(40 92 78 / 35%);
transform: translateY(-2px);
}
.today-assignment-link:focus-visible {
outline: 3px solid rgb(86 127 112 / 38%);
outline-offset: 0.25rem;
}
.today-assignment__heading {
display: flex;
justify-content: space-between;
gap: 1rem;
color: #926044;
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.today-assignment__heading p {
margin: 0;
}
.today-assignment__kind {
text-transform: none;
}
.today-assignment__path {
margin: 1.75rem 0 0;
color: #183029;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.45rem;
line-height: 1.2;
}
.today-assignment__action {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-top: 1.25rem;
color: #567064;
font-size: 0.76rem;
font-weight: 750;
}
.sets-catalog__description { .sets-catalog__description {
max-width: 38rem; max-width: 38rem;
margin: 1.5rem 0 0; margin: 1.5rem 0 0;
@ -393,6 +585,15 @@ h1 {
margin-top: 3.75rem; margin-top: 3.75rem;
} }
.today-assignments {
margin-top: 3.75rem;
}
.today-assignments__introduction {
display: grid;
gap: 1rem;
}
.schedules-catalog { .schedules-catalog {
margin-top: 3.75rem; margin-top: 3.75rem;
} }

View file

@ -11,6 +11,7 @@ const route = useRoute()
const setLayoutStore = useSetLayoutStore() const setLayoutStore = useSetLayoutStore()
const { layout, loading, error, notFound } = storeToRefs(setLayoutStore) const { layout, loading, error, notFound } = storeToRefs(setLayoutStore)
const currentSetId = ref<number | null>(null) const currentSetId = ref<number | null>(null)
const expandedElementIds = ref(new Set<number>())
const EMPTY_MESSAGE = 'This set does not have any elements yet.' const EMPTY_MESSAGE = 'This set does not have any elements yet.'
watch( watch(
@ -19,6 +20,7 @@ watch(
const rawSetId = Array.isArray(setIdParameter) ? setIdParameter[0] : setIdParameter const rawSetId = Array.isArray(setIdParameter) ? setIdParameter[0] : setIdParameter
const setId = Number(rawSetId) const setId = Number(rawSetId)
currentSetId.value = setId currentSetId.value = setId
expandedElementIds.value = new Set<number>()
await setLayoutStore.fetchSetLayout(setId) await setLayoutStore.fetchSetLayout(setId)
}, },
{ immediate: true }, { immediate: true },
@ -29,8 +31,21 @@ async function retry(): Promise<void> {
return return
} }
expandedElementIds.value = new Set<number>()
await setLayoutStore.fetchSetLayout(currentSetId.value) await setLayoutStore.fetchSetLayout(currentSetId.value)
} }
function toggleElement(elementId: number): void {
const nextExpandedElementIds = new Set(expandedElementIds.value)
if (nextExpandedElementIds.has(elementId)) {
nextExpandedElementIds.delete(elementId)
} else {
nextExpandedElementIds.add(elementId)
}
expandedElementIds.value = nextExpandedElementIds
}
</script> </script>
<template> <template>
@ -81,7 +96,12 @@ async function retry(): Promise<void> {
></p> ></p>
<div v-else class="layout-outline"> <div v-else class="layout-outline">
<ElementTree :nodes="layout.elements" :label="`${layout.set.name} element layout`" /> <ElementTree
:nodes="layout.elements"
:expanded-element-ids="expandedElementIds"
:label="`${layout.set.name} element layout`"
@toggle-element="toggleElement"
/>
</div> </div>
</template> </template>
</section> </section>