Compare commits

..

No commits in common. "0b0733cbec11f6595cb4917ecb403713cb9b14a9" and "66d9c8cbd765f55b5b4d5f3975dbb704a471479d" have entirely different histories.

21 changed files with 23 additions and 1133 deletions

View file

@ -4,7 +4,6 @@ namespace App\Http\Controllers;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Schedule\AssignmentForDate;
use App\Schedule\Schedule;
use App\Schedule\ScheduleAssignment;
use App\Schedule\UseCases\CreateSchedule\CreateSchedule;
@ -12,8 +11,6 @@ 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\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDate;
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDateRequest;
use App\Shared\Http\RequestInput;
use App\User\User;
use Illuminate\Http\JsonResponse;
@ -25,7 +22,6 @@ class ScheduleController extends Controller
private CreateSchedule $createSchedule,
private ListSchedules $listSchedules,
private GetSchedule $getSchedule,
private ListAssignmentsForDate $listAssignmentsForDate,
) {}
public function store(Request $request): JsonResponse
@ -94,36 +90,6 @@ 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>
*/
@ -199,30 +165,6 @@ 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
{
/** @var User $user */

View file

@ -1,27 +0,0 @@
<?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,40 +61,6 @@ class EloquentScheduleRepository implements ScheduleRepository
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
{
$assignmentModels = ScheduleAssignmentModel::query()
@ -105,7 +71,14 @@ class EloquentScheduleRepository implements ScheduleRepository
$assignments = [];
foreach ($assignmentModels as $assignmentModel) {
$assignments[] = $this->assignmentToDomain($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(
@ -119,19 +92,6 @@ 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
{
return new DateTimeImmutable($value, new DateTimeZone('UTC'));

View file

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

View file

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

View file

@ -1,59 +0,0 @@
<?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

@ -1,13 +0,0 @@
<?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,7 +15,6 @@ Route::middleware(AuthMiddleware::class)->group(function (): void {
Route::get('/sets', [SetController::class, 'index']);
Route::get('/sets/{setId}', [SetController::class, 'show'])
->whereNumber('setId');
Route::get('/assignments', [ScheduleController::class, 'assignments']);
Route::post('/schedules', [ScheduleController::class, 'store']);
Route::get('/schedules', [ScheduleController::class, 'index']);
Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show'])

View file

@ -3,12 +3,10 @@
namespace Tests\Fakes;
use App\Schedule\CreateScheduleDto;
use App\Schedule\AssignmentForDate;
use App\Schedule\Schedule;
use App\Schedule\ScheduleAssignment;
use App\Schedule\ScheduleRepository;
use App\User\User;
use DateTimeImmutable;
class FakeScheduleRepository implements ScheduleRepository
{
@ -74,31 +72,6 @@ class FakeScheduleRepository implements ScheduleRepository
}, 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
{
$assignments = array_map(

View file

@ -221,136 +221,6 @@ 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
{
$user = $this->createUser('reader@example.com');
@ -453,8 +323,6 @@ class ScheduleEndpointTest extends TestCase
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/1')->assertStatus(401);
$this->postJson('/api/schedules', [])->assertStatus(401);

View file

@ -1,143 +0,0 @@
<?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,12 +17,6 @@ describe('email confirmation', () => {
statusCode: 200,
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', () => {
@ -45,7 +39,7 @@ describe('email confirmation', () => {
cy.wait('@confirmEmail')
cy.location('pathname').should('equal', '/dashboard')
cy.get('#sets-heading').should('have.text', 'Available sets')
cy.get('h1').should('have.text', 'Available sets')
})
it('validates password length and confirmation before submitting', () => {

View file

@ -47,12 +47,6 @@ describe('session authentication', () => {
statusCode: 200,
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', () => {
@ -65,7 +59,7 @@ describe('session authentication', () => {
cy.wait('@me')
cy.location('pathname').should('equal', '/dashboard')
cy.get('#sets-heading').should('have.text', 'Available sets')
cy.get('h1').should('have.text', 'Available sets')
})
it('redirects an unauthenticated protected route to login', () => {

View file

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

View file

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

View file

@ -17,12 +17,6 @@ describe('sets dashboard', () => {
statusCode: 200,
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', () => {
@ -44,7 +38,7 @@ describe('sets dashboard', () => {
cy.wait('@me')
cy.wait('@sets')
cy.get('#sets-heading').should('have.text', 'Available sets')
cy.get('h1').should('have.text', 'Available sets')
cy.get('ul[aria-label="Available sets"] h2').then(($headings) => {
expect([...$headings].map((heading) => heading.textContent)).to.deep.equal([
'Bible',

View file

@ -1,157 +0,0 @@
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,23 +1,10 @@
<script setup lang="ts">
import type { SetElementNode } from '@/stores/setLayout'
const props = defineProps<{
defineProps<{
nodes: SetElementNode[]
expandedElementIds: ReadonlySet<number>
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>
<template>
@ -27,39 +14,12 @@ function isExpanded(elementId: number): boolean {
:aria-label="label"
>
<li v-for="node in nodes" :key="node.id" class="element-tree__item" :data-element-id="node.id">
<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)"
>
<div 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
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>
<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)"
/>
<ElementTree v-if="node.children.length > 0" :nodes="node.children" />
</li>
</ol>
</template>
@ -109,23 +69,6 @@ function isExpanded(elementId: number): boolean {
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 {
min-width: 0;
color: #183029;
@ -135,13 +78,6 @@ function isExpanded(elementId: number): boolean {
overflow-wrap: anywhere;
}
.element-node__metadata {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.8rem;
}
.element-node__kind {
flex: 0 0 auto;
padding: 0.35rem 0.55rem;
@ -154,18 +90,6 @@ function isExpanded(elementId: number): boolean {
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) {
.element-tree {
margin-left: 0.45rem;
@ -182,10 +106,6 @@ function isExpanded(elementId: number): boolean {
padding: 0.75rem 0.7rem;
}
.element-node__metadata {
gap: 0.6rem;
}
.element-node__kind {
padding-inline: 0.42rem;
font-size: 0.56rem;

View file

@ -26,15 +26,6 @@ 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({
days: z.array(
z.object({
@ -52,18 +43,12 @@ const scheduleResponseSchema = z.object({
schedule: scheduleDetailSchema,
})
const assignmentsForDateResponseSchema = z.object({
date: isoDateSchema,
assignments: z.array(assignmentForDateSchema),
})
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 AssignmentForDate = z.infer<typeof assignmentForDateSchema>
export type CreateScheduleInput = {
setId: number
levelId: number
@ -74,7 +59,6 @@ export type CreateScheduleInput = {
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."
const ASSIGNMENTS_ERROR = "We couldn't load today's assignments."
export const useSchedulesStore = defineStore('schedules', () => {
const schedules = ref<ScheduleSummary[]>([])
@ -86,11 +70,7 @@ export const useSchedulesStore = defineStore('schedules', () => {
const detailNotFound = ref(false)
const creating = ref(false)
const createError = ref<string | null>(null)
const assignmentsForDate = ref<AssignmentForDate[]>([])
const assignmentsLoading = ref(false)
const assignmentsError = ref<string | null>(null)
let activeDetailRequestId = 0
let assignmentsRequestId = 0
async function fetchSchedules(): Promise<boolean> {
listLoading.value = true
@ -222,58 +202,6 @@ 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 {
schedules,
listLoading,
@ -284,12 +212,8 @@ export const useSchedulesStore = defineStore('schedules', () => {
detailNotFound,
creating,
createError,
assignmentsForDate,
assignmentsLoading,
assignmentsError,
fetchSchedules,
fetchSchedule,
createSchedule,
fetchAssignmentsForDate,
}
})

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { onMounted, ref } from 'vue'
import { onMounted } from 'vue'
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
import { useSchedulesStore } from '@/stores/schedules'
@ -10,25 +10,11 @@ const setsStore = useSetsStore()
const schedulesStore = useSchedulesStore()
const { sets, loading, error } = storeToRefs(setsStore)
const { schedules, listLoading, listError } = storeToRefs(schedulesStore)
const { assignmentsForDate, assignmentsLoading, assignmentsError } = storeToRefs(schedulesStore)
const todayDate = ref(browserDate(new Date()))
onMounted(async () => {
await Promise.all([
setsStore.fetchSets(),
schedulesStore.fetchSchedules(),
schedulesStore.fetchAssignmentsForDate(todayDate.value),
])
await Promise.all([setsStore.fetchSets(), schedulesStore.fetchSchedules()])
})
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 {
return new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
@ -41,67 +27,6 @@ function formatDate(value: string): string {
<main class="dashboard-page">
<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">
<div class="schedules-catalog__heading">
<div>
@ -158,7 +83,7 @@ function formatDate(value: string): string {
<section class="sets-catalog" aria-labelledby="sets-heading">
<div class="sets-catalog__introduction">
<p class="sets-catalog__eyebrow">Your library</p>
<h2 id="sets-heading">Available sets</h2>
<h1 id="sets-heading">Available sets</h1>
<p class="sets-catalog__description">
Browse every set available in Attainly and find the collection that fits your next goal.
</p>
@ -210,27 +135,9 @@ function formatDate(value: string): string {
margin: clamp(4.5rem, 10vh, 7rem) auto 0;
}
.today-assignments {
width: min(100%, 72rem);
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;
margin: clamp(4.5rem, 10vh, 7rem) auto 0;
}
.schedules-catalog__heading {
@ -269,8 +176,7 @@ function formatDate(value: string): string {
text-transform: uppercase;
}
.today-assignments h1,
.sets-catalog__introduction h2 {
h1 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(3rem, 7vw, 5.25rem);
@ -279,104 +185,6 @@ function formatDate(value: string): string {
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 {
max-width: 38rem;
margin: 1.5rem 0 0;
@ -585,15 +393,6 @@ function formatDate(value: string): string {
margin-top: 3.75rem;
}
.today-assignments {
margin-top: 3.75rem;
}
.today-assignments__introduction {
display: grid;
gap: 1rem;
}
.schedules-catalog {
margin-top: 3.75rem;
}

View file

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