Compare commits
No commits in common. "8fcd7fef420a6ee431f87fa3ca4c9872f678779e" and "8218de16f5cecc95b3940a682160d0e09c394d42" have entirely different histories.
8fcd7fef42
...
8218de16f5
20 changed files with 41 additions and 1142 deletions
|
|
@ -14,8 +14,6 @@ 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\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletion;
|
||||
use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletionRequest;
|
||||
use App\Shared\Http\RequestInput;
|
||||
use App\User\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
|
@ -28,7 +26,6 @@ class ScheduleController extends Controller
|
|||
private ListSchedules $listSchedules,
|
||||
private GetSchedule $getSchedule,
|
||||
private ListAssignmentsForDate $listAssignmentsForDate,
|
||||
private SetAssignmentCompletion $setAssignmentCompletion,
|
||||
) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
|
|
@ -127,41 +124,6 @@ class ScheduleController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
public function updateAssignment(
|
||||
Request $request,
|
||||
int $assignmentId,
|
||||
): JsonResponse {
|
||||
$input = new RequestInput($request);
|
||||
|
||||
try {
|
||||
$assignment = $this->setAssignmentCompletion->execute(
|
||||
new SetAssignmentCompletionRequest(
|
||||
assignmentId: $assignmentId,
|
||||
user: $this->user($request),
|
||||
completed: $input->boolean('completed'),
|
||||
),
|
||||
);
|
||||
} catch (BadRequestException $exception) {
|
||||
return new JsonResponse(
|
||||
['error' => $exception->getMessage()],
|
||||
400,
|
||||
);
|
||||
} catch (NotFoundException $exception) {
|
||||
return new JsonResponse(
|
||||
['error' => $exception->getMessage()],
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'assignment' => [
|
||||
'id' => $assignment->getId(),
|
||||
'completedAt' => $assignment->getCompletedAt()
|
||||
?->format(DATE_ATOM),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
|
@ -218,7 +180,7 @@ class ScheduleController extends Controller
|
|||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, completedAt: string|null, element: array{
|
||||
* @return array{id: int, element: array{
|
||||
* name: string,
|
||||
* kind: string,
|
||||
* path: list<string>
|
||||
|
|
@ -229,8 +191,6 @@ class ScheduleController extends Controller
|
|||
): array {
|
||||
return [
|
||||
'id' => $assignment->getId(),
|
||||
'completedAt' => $assignment->getCompletedAt()
|
||||
?->format(DATE_ATOM),
|
||||
'element' => [
|
||||
'name' => $assignment->getName(),
|
||||
'kind' => $assignment->getKind(),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ namespace App\Schedule;
|
|||
use App\User\User;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use DomainException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class EloquentScheduleRepository implements ScheduleRepository
|
||||
|
|
@ -30,7 +29,6 @@ class EloquentScheduleRepository implements ScheduleRepository
|
|||
'scheduled_date' => $assignmentDto->scheduledDate
|
||||
->format('Y-m-d'),
|
||||
'position' => $assignmentDto->position,
|
||||
'completed_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +77,6 @@ class EloquentScheduleRepository implements ScheduleRepository
|
|||
->where('schedule_assignments.scheduled_date', $date->format(
|
||||
'Y-m-d',
|
||||
))
|
||||
->whereNull('schedule_assignments.completed_at')
|
||||
->with('schedule')
|
||||
->orderByDesc('schedules.id')
|
||||
->orderBy('schedule_assignments.position')
|
||||
|
|
@ -98,43 +95,6 @@ class EloquentScheduleRepository implements ScheduleRepository
|
|||
return $assignments;
|
||||
}
|
||||
|
||||
public function findAssignmentForUser(
|
||||
int $id,
|
||||
User $user,
|
||||
): ?ScheduleAssignment {
|
||||
$model = ScheduleAssignmentModel::query()
|
||||
->select('schedule_assignments.*')
|
||||
->join(
|
||||
'schedules',
|
||||
'schedules.id',
|
||||
'=',
|
||||
'schedule_assignments.schedule_id',
|
||||
)
|
||||
->where('schedule_assignments.id', $id)
|
||||
->where('schedules.user_id', $user->getId())
|
||||
->first();
|
||||
|
||||
return $model === null
|
||||
? null
|
||||
: $this->assignmentToDomain($model);
|
||||
}
|
||||
|
||||
public function updateAssignment(
|
||||
ScheduleAssignment $assignment,
|
||||
): ScheduleAssignment {
|
||||
$model = ScheduleAssignmentModel::find($assignment->getId());
|
||||
if ($model === null) {
|
||||
throw new DomainException(
|
||||
"Assignment with id {$assignment->getId()} not found",
|
||||
);
|
||||
}
|
||||
|
||||
$model->completed_at = $assignment->getCompletedAt();
|
||||
$model->save();
|
||||
|
||||
return $this->assignmentToDomain($model);
|
||||
}
|
||||
|
||||
private function toDomain(ScheduleModel $model, User $user): Schedule
|
||||
{
|
||||
$assignmentModels = ScheduleAssignmentModel::query()
|
||||
|
|
@ -169,9 +129,6 @@ class EloquentScheduleRepository implements ScheduleRepository
|
|||
path: $model->element_path,
|
||||
scheduledDate: $this->date($model->scheduled_date),
|
||||
position: $model->position,
|
||||
completedAt: $model->completed_at === null
|
||||
? null
|
||||
: $this->dateTime($model->completed_at),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -179,10 +136,4 @@ class EloquentScheduleRepository implements ScheduleRepository
|
|||
{
|
||||
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
|
||||
}
|
||||
|
||||
private function dateTime(DateTimeImmutable $value): DateTimeImmutable
|
||||
{
|
||||
return DateTimeImmutable::createFromInterface($value)
|
||||
->setTimezone(new DateTimeZone('UTC'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ namespace App\Schedule;
|
|||
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class ScheduleAssignment
|
||||
final readonly class ScheduleAssignment
|
||||
{
|
||||
/**
|
||||
* @param list<string> $path
|
||||
|
|
@ -16,7 +16,6 @@ final class ScheduleAssignment
|
|||
private array $path,
|
||||
private DateTimeImmutable $scheduledDate,
|
||||
private int $position,
|
||||
private ?DateTimeImmutable $completedAt,
|
||||
) {}
|
||||
|
||||
public function getId(): int
|
||||
|
|
@ -51,21 +50,4 @@ final class ScheduleAssignment
|
|||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function getCompletedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->completedAt;
|
||||
}
|
||||
|
||||
public function complete(DateTimeImmutable $completedAt): void
|
||||
{
|
||||
if ($this->completedAt === null) {
|
||||
$this->completedAt = $completedAt;
|
||||
}
|
||||
}
|
||||
|
||||
public function reopen(): void
|
||||
{
|
||||
$this->completedAt = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Schedule;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
|
@ -16,7 +15,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
* @property list<string> $element_path
|
||||
* @property string $scheduled_date
|
||||
* @property int $position
|
||||
* @property DateTimeImmutable|null $completed_at
|
||||
* @property-read ScheduleModel $schedule
|
||||
*
|
||||
* @method static Builder<static>|ScheduleAssignmentModel newModelQuery()
|
||||
|
|
@ -32,7 +30,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
'element_path',
|
||||
'scheduled_date',
|
||||
'position',
|
||||
'completed_at',
|
||||
])]
|
||||
class ScheduleAssignmentModel extends Model
|
||||
{
|
||||
|
|
@ -49,7 +46,6 @@ class ScheduleAssignmentModel extends Model
|
|||
'schedule_id' => 'integer',
|
||||
'element_path' => 'array',
|
||||
'position' => 'integer',
|
||||
'completed_at' => 'immutable_datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,13 +23,4 @@ interface ScheduleRepository
|
|||
User $user,
|
||||
DateTimeImmutable $date,
|
||||
): array;
|
||||
|
||||
public function findAssignmentForUser(
|
||||
int $id,
|
||||
User $user,
|
||||
): ?ScheduleAssignment;
|
||||
|
||||
public function updateAssignment(
|
||||
ScheduleAssignment $assignment,
|
||||
): ScheduleAssignment;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Schedule\UseCases\SetAssignmentCompletion;
|
||||
|
||||
use App\Auth\Clock;
|
||||
use App\Exceptions\BadRequestException;
|
||||
use App\Exceptions\NotFoundException;
|
||||
use App\Schedule\ScheduleAssignment;
|
||||
use App\Schedule\ScheduleRepository;
|
||||
|
||||
class SetAssignmentCompletion
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleRepository $scheduleRepository,
|
||||
private Clock $clock,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function execute(
|
||||
SetAssignmentCompletionRequest $request,
|
||||
): ScheduleAssignment {
|
||||
if ($request->completed === null) {
|
||||
throw new BadRequestException(
|
||||
'completed must be a boolean',
|
||||
);
|
||||
}
|
||||
|
||||
$assignment = $this->scheduleRepository->findAssignmentForUser(
|
||||
$request->assignmentId,
|
||||
$request->user,
|
||||
);
|
||||
if ($assignment === null) {
|
||||
throw new NotFoundException('assignment not found');
|
||||
}
|
||||
|
||||
if ($request->completed) {
|
||||
$assignment->complete($this->clock->now());
|
||||
} else {
|
||||
$assignment->reopen();
|
||||
}
|
||||
|
||||
return $this->scheduleRepository->updateAssignment($assignment);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Schedule\UseCases\SetAssignmentCompletion;
|
||||
|
||||
use App\User\User;
|
||||
|
||||
final readonly class SetAssignmentCompletionRequest
|
||||
{
|
||||
public function __construct(
|
||||
public int $assignmentId,
|
||||
public User $user,
|
||||
public ?bool $completed,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -27,11 +27,4 @@ class RequestInput
|
|||
|
||||
return is_int($value) ? $value : null;
|
||||
}
|
||||
|
||||
public function boolean(string $key): ?bool
|
||||
{
|
||||
$value = $this->request->input($key);
|
||||
|
||||
return is_bool($value) ? $value : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,12 +20,10 @@ return new class extends Migration
|
|||
$table->json('element_path');
|
||||
$table->date('scheduled_date');
|
||||
$table->unsignedInteger('position');
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->unique(['schedule_id', 'position']);
|
||||
$table->index([
|
||||
'schedule_id',
|
||||
'scheduled_date',
|
||||
'completed_at',
|
||||
'position',
|
||||
]);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -16,10 +16,6 @@ Route::middleware(AuthMiddleware::class)->group(function (): void {
|
|||
Route::get('/sets/{setId}', [SetController::class, 'show'])
|
||||
->whereNumber('setId');
|
||||
Route::get('/assignments', [ScheduleController::class, 'assignments']);
|
||||
Route::patch(
|
||||
'/assignments/{assignmentId}',
|
||||
[ScheduleController::class, 'updateAssignment'],
|
||||
)->whereNumber('assignmentId');
|
||||
Route::post('/schedules', [ScheduleController::class, 'store']);
|
||||
Route::get('/schedules', [ScheduleController::class, 'index']);
|
||||
Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show'])
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ use App\Schedule\ScheduleAssignment;
|
|||
use App\Schedule\ScheduleRepository;
|
||||
use App\User\User;
|
||||
use DateTimeImmutable;
|
||||
use DomainException;
|
||||
|
||||
class FakeScheduleRepository implements ScheduleRepository
|
||||
{
|
||||
|
|
@ -18,8 +17,6 @@ class FakeScheduleRepository implements ScheduleRepository
|
|||
*/
|
||||
private array $schedules = [];
|
||||
|
||||
private int $nextAssignmentId = 1;
|
||||
|
||||
public function create(CreateScheduleDto $dto): Schedule
|
||||
{
|
||||
$id = count($this->schedules) + 1;
|
||||
|
|
@ -27,13 +24,12 @@ class FakeScheduleRepository implements ScheduleRepository
|
|||
|
||||
foreach ($dto->assignments as $assignmentDto) {
|
||||
$assignments[] = new ScheduleAssignment(
|
||||
id: $this->nextAssignmentId++,
|
||||
id: count($assignments) + 1,
|
||||
name: $assignmentDto->name,
|
||||
kind: $assignmentDto->kind,
|
||||
path: $assignmentDto->path,
|
||||
scheduledDate: $assignmentDto->scheduledDate,
|
||||
position: $assignmentDto->position,
|
||||
completedAt: null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -88,7 +84,6 @@ class FakeScheduleRepository implements ScheduleRepository
|
|||
foreach ($schedule->getAssignments() as $assignment) {
|
||||
if ($assignment->getScheduledDate()->format('Y-m-d')
|
||||
!== $date->format('Y-m-d')
|
||||
|| $assignment->getCompletedAt() !== null
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -104,66 +99,21 @@ class FakeScheduleRepository implements ScheduleRepository
|
|||
return $assignments;
|
||||
}
|
||||
|
||||
public function findAssignmentForUser(
|
||||
int $id,
|
||||
User $user,
|
||||
): ?ScheduleAssignment {
|
||||
foreach ($this->findAllForUser($user) as $schedule) {
|
||||
foreach ($schedule->getAssignments() as $assignment) {
|
||||
if ($assignment->getId() === $id) {
|
||||
return $this->copyAssignment($assignment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function updateAssignment(
|
||||
ScheduleAssignment $assignment,
|
||||
): ScheduleAssignment {
|
||||
foreach ($this->schedules as $id => $schedule) {
|
||||
$assignments = [];
|
||||
$found = false;
|
||||
|
||||
foreach ($schedule->getAssignments() as $storedAssignment) {
|
||||
if ($storedAssignment->getId() === $assignment->getId()) {
|
||||
$assignments[] = $this->copyAssignment($assignment);
|
||||
$found = true;
|
||||
} else {
|
||||
$assignments[] = $this->copyAssignment(
|
||||
$storedAssignment,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($found) {
|
||||
$this->schedules[$id] = new Schedule(
|
||||
id: $schedule->getId(),
|
||||
user: $schedule->getUser(),
|
||||
setName: $schedule->getSetName(),
|
||||
elementKind: $schedule->getElementKind(),
|
||||
startDate: $schedule->getStartDate(),
|
||||
targetDate: $schedule->getTargetDate(),
|
||||
assignments: $assignments,
|
||||
);
|
||||
|
||||
return $this->copyAssignment($assignment);
|
||||
}
|
||||
}
|
||||
|
||||
throw new DomainException(
|
||||
"Assignment with id {$assignment->getId()} not found",
|
||||
);
|
||||
}
|
||||
|
||||
private function copy(Schedule $schedule): Schedule
|
||||
{
|
||||
$assignments = array_map(function (
|
||||
ScheduleAssignment $assignment,
|
||||
): ScheduleAssignment {
|
||||
return $this->copyAssignment($assignment);
|
||||
}, $schedule->getAssignments());
|
||||
$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(),
|
||||
|
|
@ -175,18 +125,4 @@ class FakeScheduleRepository implements ScheduleRepository
|
|||
assignments: $assignments,
|
||||
);
|
||||
}
|
||||
|
||||
private function copyAssignment(
|
||||
ScheduleAssignment $assignment,
|
||||
): ScheduleAssignment {
|
||||
return new ScheduleAssignment(
|
||||
id: $assignment->getId(),
|
||||
name: $assignment->getName(),
|
||||
kind: $assignment->getKind(),
|
||||
path: $assignment->getPath(),
|
||||
scheduledDate: $assignment->getScheduledDate(),
|
||||
position: $assignment->getPosition(),
|
||||
completedAt: $assignment->getCompletedAt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
namespace Tests\Feature\Schedule;
|
||||
|
||||
use App\Auth\CreateSessionDto;
|
||||
use App\Auth\Clock;
|
||||
use App\Auth\SessionRepository;
|
||||
use App\Element\CreateElementDto;
|
||||
use App\Element\ElementModel;
|
||||
|
|
@ -26,7 +25,6 @@ use DateTimeZone;
|
|||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use Tests\TestCase;
|
||||
use Tests\Fakes\FakeClock;
|
||||
|
||||
class ScheduleEndpointTest extends TestCase
|
||||
{
|
||||
|
|
@ -96,7 +94,6 @@ class ScheduleEndpointTest extends TestCase
|
|||
'assignments' => [
|
||||
[
|
||||
'id' => 1,
|
||||
'completedAt' => null,
|
||||
'element' => [
|
||||
'name' => 'Chapter 1',
|
||||
'kind' => 'chapter',
|
||||
|
|
@ -118,7 +115,6 @@ class ScheduleEndpointTest extends TestCase
|
|||
'assignments' => [
|
||||
[
|
||||
'id' => 2,
|
||||
'completedAt' => null,
|
||||
'element' => [
|
||||
'name' => 'Chapter 1',
|
||||
'kind' => 'chapter',
|
||||
|
|
@ -355,130 +351,6 @@ class ScheduleEndpointTest extends TestCase
|
|||
->assertExactJson(['error' => 'date is required']);
|
||||
}
|
||||
|
||||
public function test_it_completes_and_reopens_an_assignment(): void
|
||||
{
|
||||
$user = $this->createUser('reader@example.com');
|
||||
$set = $this->createSet($user, 'Course');
|
||||
$lessonLevel = $this->createLevel($set, 'lesson');
|
||||
$repository = app(ElementRepository::class);
|
||||
$repository->create(new CreateElementDto(
|
||||
name: 'First lesson',
|
||||
level: $lessonLevel,
|
||||
parentElement: null,
|
||||
));
|
||||
$repository->create(new CreateElementDto(
|
||||
name: 'Second lesson',
|
||||
level: $lessonLevel,
|
||||
parentElement: null,
|
||||
));
|
||||
$this->createSession($user, 'valid-token');
|
||||
$completedAt = new DateTimeImmutable(
|
||||
'2026-08-15T12:30:45',
|
||||
new DateTimeZone('UTC'),
|
||||
);
|
||||
$this->app->instance(Clock::class, new FakeClock($completedAt));
|
||||
$this->credentialedPost('/api/schedules', [
|
||||
'setId' => $set->getId(),
|
||||
'levelId' => $lessonLevel->getId(),
|
||||
'startDate' => '2026-08-20',
|
||||
'targetDate' => '2026-08-20',
|
||||
])->assertCreated();
|
||||
|
||||
$this->credentialedPatch('/api/assignments/1', [
|
||||
'completed' => true,
|
||||
])->assertOk()->assertExactJson([
|
||||
'assignment' => [
|
||||
'id' => 1,
|
||||
'completedAt' => '2026-08-15T12:30:45+00:00',
|
||||
],
|
||||
]);
|
||||
$this->assertDatabaseHas('schedule_assignments', [
|
||||
'id' => 1,
|
||||
'completed_at' => '2026-08-15 12:30:45',
|
||||
]);
|
||||
$this->credentialedGet('/api/schedules/1')
|
||||
->assertOk()
|
||||
->assertJsonPath(
|
||||
'schedule.days.0.assignments.0.completedAt',
|
||||
'2026-08-15T12:30:45+00:00',
|
||||
)
|
||||
->assertJsonPath(
|
||||
'schedule.days.0.assignments.1.completedAt',
|
||||
null,
|
||||
);
|
||||
$this->credentialedGet('/api/assignments?date=2026-08-20')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'assignments')
|
||||
->assertJsonPath('assignments.0.id', 2);
|
||||
|
||||
$this->credentialedPatch('/api/assignments/1', [
|
||||
'completed' => false,
|
||||
])->assertOk()->assertExactJson([
|
||||
'assignment' => [
|
||||
'id' => 1,
|
||||
'completedAt' => null,
|
||||
],
|
||||
]);
|
||||
$this->assertDatabaseHas('schedule_assignments', [
|
||||
'id' => 1,
|
||||
'completed_at' => null,
|
||||
]);
|
||||
$this->credentialedGet('/api/assignments?date=2026-08-20')
|
||||
->assertOk()
|
||||
->assertJsonCount(2, 'assignments');
|
||||
}
|
||||
|
||||
public function test_it_rejects_invalid_assignment_completion_input(): void
|
||||
{
|
||||
$user = $this->createUser('reader@example.com');
|
||||
$this->createSession($user, 'valid-token');
|
||||
|
||||
$this->credentialedPatch('/api/assignments/1', [
|
||||
'completed' => 'yes',
|
||||
])->assertBadRequest()->assertExactJson([
|
||||
'error' => 'completed must be a boolean',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_hides_another_users_assignment(): 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-15',
|
||||
'targetDate' => '2026-08-15',
|
||||
])->assertCreated();
|
||||
|
||||
$this->withCredentials()
|
||||
->withUnencryptedCookie(
|
||||
AuthMiddleware::COOKIE_NAME,
|
||||
'viewer-token',
|
||||
)->patchJson('/api/assignments/1', [
|
||||
'completed' => true,
|
||||
])->assertNotFound()->assertExactJson([
|
||||
'error' => 'assignment not found',
|
||||
]);
|
||||
$this->assertDatabaseHas('schedule_assignments', [
|
||||
'id' => 1,
|
||||
'completed_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_is_stable_after_sources_change_or_are_deleted(): void
|
||||
{
|
||||
$user = $this->createUser('reader@example.com');
|
||||
|
|
@ -583,9 +455,6 @@ class ScheduleEndpointTest extends TestCase
|
|||
{
|
||||
$this->getJson('/api/assignments?date=2026-08-15')
|
||||
->assertStatus(401);
|
||||
$this->patchJson('/api/assignments/1', [
|
||||
'completed' => true,
|
||||
])->assertStatus(401);
|
||||
$this->getJson('/api/schedules')->assertStatus(401);
|
||||
$this->getJson('/api/schedules/1')->assertStatus(401);
|
||||
$this->postJson('/api/schedules', [])->assertStatus(401);
|
||||
|
|
@ -651,18 +520,4 @@ class ScheduleEndpointTest extends TestCase
|
|||
'valid-token',
|
||||
)->getJson($uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function credentialedPatch(
|
||||
string $uri,
|
||||
array $payload,
|
||||
): TestResponse {
|
||||
return $this->withCredentials()
|
||||
->withUnencryptedCookie(
|
||||
AuthMiddleware::COOKIE_NAME,
|
||||
'valid-token',
|
||||
)->patchJson($uri, $payload);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Schedule;
|
||||
|
||||
use App\Schedule\ScheduleAssignment;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ScheduleAssignmentTest extends TestCase
|
||||
{
|
||||
public function test_it_completes_only_once_until_reopened(): void
|
||||
{
|
||||
$assignment = $this->assignment();
|
||||
$firstCompletion = $this->utc('2026-08-15T12:00:00');
|
||||
$laterCompletion = $this->utc('2026-08-15T13:00:00');
|
||||
|
||||
$assignment->complete($firstCompletion);
|
||||
$assignment->complete($laterCompletion);
|
||||
|
||||
$this->assertSame($firstCompletion, $assignment->getCompletedAt());
|
||||
|
||||
$assignment->reopen();
|
||||
|
||||
$this->assertNull($assignment->getCompletedAt());
|
||||
|
||||
$assignment->complete($laterCompletion);
|
||||
|
||||
$this->assertSame($laterCompletion, $assignment->getCompletedAt());
|
||||
}
|
||||
|
||||
private function assignment(): ScheduleAssignment
|
||||
{
|
||||
return new ScheduleAssignment(
|
||||
id: 1,
|
||||
name: 'Lesson',
|
||||
kind: 'lesson',
|
||||
path: ['Course', 'Lesson'],
|
||||
scheduledDate: $this->utc('2026-08-15'),
|
||||
position: 1,
|
||||
completedAt: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function utc(string $value): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ class ListAssignmentsForDateTest extends TestCase
|
|||
$user = $this->user(1, 'reader@example.com');
|
||||
$otherUser = $this->user(2, 'other@example.com');
|
||||
$repository = new FakeScheduleRepository;
|
||||
$olderSchedule = $repository->create($this->schedule(
|
||||
$repository->create($this->schedule(
|
||||
user: $user,
|
||||
setName: 'Older plan',
|
||||
date: '2026-08-15',
|
||||
|
|
@ -46,12 +46,6 @@ class ListAssignmentsForDateTest extends TestCase
|
|||
date: '2026-08-16',
|
||||
assignmentNames: ['Later'],
|
||||
));
|
||||
$completedAssignment = $olderSchedule->getAssignments()[0];
|
||||
$completedAssignment->complete(new DateTimeImmutable(
|
||||
'2026-08-15T12:00:00',
|
||||
new DateTimeZone('UTC'),
|
||||
));
|
||||
$repository->updateAssignment($completedAssignment);
|
||||
|
||||
$assignments = (new ListAssignmentsForDate($repository))->execute(
|
||||
new ListAssignmentsForDateRequest(
|
||||
|
|
@ -61,13 +55,13 @@ class ListAssignmentsForDateTest extends TestCase
|
|||
);
|
||||
|
||||
$this->assertSame(
|
||||
['Newer plan', 'Older plan'],
|
||||
['Newer plan', 'Older plan', 'Older plan'],
|
||||
array_map(function ($assignment): string {
|
||||
return $assignment->getSetName();
|
||||
}, $assignments),
|
||||
);
|
||||
$this->assertSame(
|
||||
['Third', 'Second'],
|
||||
['Third', 'First', 'Second'],
|
||||
array_map(function ($assignment): string {
|
||||
return $assignment->getAssignment()->getName();
|
||||
}, $assignments),
|
||||
|
|
|
|||
|
|
@ -1,175 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Schedule\UseCases;
|
||||
|
||||
use App\Exceptions\BadRequestException;
|
||||
use App\Exceptions\NotFoundException;
|
||||
use App\Schedule\CreateScheduleAssignmentDto;
|
||||
use App\Schedule\CreateScheduleDto;
|
||||
use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletion;
|
||||
use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletionRequest;
|
||||
use App\Shared\ValueObject\EmailAddress;
|
||||
use App\User\User;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\Fakes\FakeClock;
|
||||
use Tests\Fakes\FakeScheduleRepository;
|
||||
|
||||
class SetAssignmentCompletionTest extends TestCase
|
||||
{
|
||||
public function test_it_completes_an_owned_assignment_at_the_current_time(): void
|
||||
{
|
||||
$user = $this->user(1, 'reader@example.com');
|
||||
$repository = new FakeScheduleRepository;
|
||||
$schedule = $repository->create($this->schedule($user));
|
||||
$now = $this->utc('2026-08-15T12:00:00');
|
||||
|
||||
$assignment = (new SetAssignmentCompletion(
|
||||
$repository,
|
||||
new FakeClock($now),
|
||||
))->execute(new SetAssignmentCompletionRequest(
|
||||
assignmentId: $schedule->getAssignments()[0]->getId(),
|
||||
user: $user,
|
||||
completed: true,
|
||||
));
|
||||
|
||||
$this->assertSame($now, $assignment->getCompletedAt());
|
||||
$this->assertSame(
|
||||
$now,
|
||||
$repository->findAssignmentForUser(
|
||||
$assignment->getId(),
|
||||
$user,
|
||||
)?->getCompletedAt(),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_preserves_the_first_completion_time(): void
|
||||
{
|
||||
$user = $this->user(1, 'reader@example.com');
|
||||
$repository = new FakeScheduleRepository;
|
||||
$schedule = $repository->create($this->schedule($user));
|
||||
$assignmentId = $schedule->getAssignments()[0]->getId();
|
||||
$firstCompletion = $this->utc('2026-08-15T12:00:00');
|
||||
$laterCompletion = $this->utc('2026-08-15T13:00:00');
|
||||
|
||||
(new SetAssignmentCompletion(
|
||||
$repository,
|
||||
new FakeClock($firstCompletion),
|
||||
))->execute(new SetAssignmentCompletionRequest(
|
||||
assignmentId: $assignmentId,
|
||||
user: $user,
|
||||
completed: true,
|
||||
));
|
||||
$assignment = (new SetAssignmentCompletion(
|
||||
$repository,
|
||||
new FakeClock($laterCompletion),
|
||||
))->execute(new SetAssignmentCompletionRequest(
|
||||
assignmentId: $assignmentId,
|
||||
user: $user,
|
||||
completed: true,
|
||||
));
|
||||
|
||||
$this->assertSame(
|
||||
$firstCompletion,
|
||||
$assignment->getCompletedAt(),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_reopens_a_completed_assignment(): void
|
||||
{
|
||||
$user = $this->user(1, 'reader@example.com');
|
||||
$repository = new FakeScheduleRepository;
|
||||
$schedule = $repository->create($this->schedule($user));
|
||||
$assignmentId = $schedule->getAssignments()[0]->getId();
|
||||
$useCase = new SetAssignmentCompletion(
|
||||
$repository,
|
||||
new FakeClock($this->utc('2026-08-15T12:00:00')),
|
||||
);
|
||||
$useCase->execute(new SetAssignmentCompletionRequest(
|
||||
assignmentId: $assignmentId,
|
||||
user: $user,
|
||||
completed: true,
|
||||
));
|
||||
|
||||
$assignment = $useCase->execute(
|
||||
new SetAssignmentCompletionRequest(
|
||||
assignmentId: $assignmentId,
|
||||
user: $user,
|
||||
completed: false,
|
||||
),
|
||||
);
|
||||
|
||||
$this->assertNull($assignment->getCompletedAt());
|
||||
}
|
||||
|
||||
public function test_it_hides_another_users_assignment(): void
|
||||
{
|
||||
$repository = new FakeScheduleRepository;
|
||||
$owner = $this->user(1, 'owner@example.com');
|
||||
$schedule = $repository->create($this->schedule($owner));
|
||||
|
||||
$this->expectException(NotFoundException::class);
|
||||
$this->expectExceptionMessage('assignment not found');
|
||||
|
||||
(new SetAssignmentCompletion(
|
||||
$repository,
|
||||
new FakeClock($this->utc('2026-08-15T12:00:00')),
|
||||
))->execute(new SetAssignmentCompletionRequest(
|
||||
assignmentId: $schedule->getAssignments()[0]->getId(),
|
||||
user: $this->user(2, 'viewer@example.com'),
|
||||
completed: true,
|
||||
));
|
||||
}
|
||||
|
||||
public function test_it_requires_a_boolean_completion_value(): void
|
||||
{
|
||||
$this->expectException(BadRequestException::class);
|
||||
$this->expectExceptionMessage('completed must be a boolean');
|
||||
|
||||
(new SetAssignmentCompletion(
|
||||
new FakeScheduleRepository,
|
||||
new FakeClock($this->utc('2026-08-15T12:00:00')),
|
||||
))->execute(new SetAssignmentCompletionRequest(
|
||||
assignmentId: 1,
|
||||
user: $this->user(1, 'reader@example.com'),
|
||||
completed: null,
|
||||
));
|
||||
}
|
||||
|
||||
private function schedule(User $user): CreateScheduleDto
|
||||
{
|
||||
$scheduledDate = $this->utc('2026-08-20');
|
||||
|
||||
return new CreateScheduleDto(
|
||||
user: $user,
|
||||
setName: 'Course',
|
||||
elementKind: 'lesson',
|
||||
startDate: $scheduledDate,
|
||||
targetDate: $scheduledDate,
|
||||
assignments: [
|
||||
new CreateScheduleAssignmentDto(
|
||||
name: 'Lesson',
|
||||
kind: 'lesson',
|
||||
path: ['Course', 'Lesson'],
|
||||
scheduledDate: $scheduledDate,
|
||||
position: 1,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function user(int $id, string $email): User
|
||||
{
|
||||
return new User(
|
||||
id: $id,
|
||||
email: new EmailAddress($email),
|
||||
passwordHash: 'hashed-password',
|
||||
);
|
||||
}
|
||||
|
||||
private function utc(string $value): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
|
||||
}
|
||||
}
|
||||
|
|
@ -74,7 +74,6 @@ const scheduleDetail = {
|
|||
assignments: [
|
||||
{
|
||||
id: 1,
|
||||
completedAt: null,
|
||||
element: {
|
||||
name: 'Chapter 1',
|
||||
kind: 'Chapter_sections-v2',
|
||||
|
|
@ -89,7 +88,6 @@ const scheduleDetail = {
|
|||
assignments: [
|
||||
{
|
||||
id: 2,
|
||||
completedAt: null,
|
||||
element: {
|
||||
name: 'Chapter 1',
|
||||
kind: 'Chapter_sections-v2',
|
||||
|
|
@ -102,25 +100,6 @@ const scheduleDetail = {
|
|||
},
|
||||
}
|
||||
|
||||
const completedScheduleDetail = {
|
||||
schedule: {
|
||||
...scheduleDetail.schedule,
|
||||
days: [
|
||||
scheduleDetail.schedule.days[0],
|
||||
scheduleDetail.schedule.days[1],
|
||||
{
|
||||
...scheduleDetail.schedule.days[2],
|
||||
assignments: [
|
||||
{
|
||||
...scheduleDetail.schedule.days[2]?.assignments[0],
|
||||
completedAt: '2026-08-15T09:30:00+00:00',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
function interceptAuthenticatedUser(): void {
|
||||
cy.intercept('GET', '**/api/me', {
|
||||
statusCode: 200,
|
||||
|
|
@ -248,65 +227,6 @@ describe('set scheduling', () => {
|
|||
cy.get('h1').should('have.text', 'Schedule not found')
|
||||
})
|
||||
|
||||
it('completes and reopens assignments from the schedule', () => {
|
||||
const completedAt = '2026-08-15T12:30:00+00:00'
|
||||
cy.intercept('GET', '**/api/schedules/73', {
|
||||
statusCode: 200,
|
||||
body: completedScheduleDetail,
|
||||
}).as('schedule')
|
||||
cy.intercept('PATCH', '**/api/assignments/1', (request) => {
|
||||
expect(request.body).to.deep.equal({ completed: true })
|
||||
request.reply({
|
||||
delay: 400,
|
||||
statusCode: 200,
|
||||
body: {
|
||||
assignment: { id: 1, completedAt },
|
||||
},
|
||||
})
|
||||
}).as('completeAssignment')
|
||||
cy.intercept('PATCH', '**/api/assignments/2', (request) => {
|
||||
expect(request.body).to.deep.equal({ completed: false })
|
||||
request.reply({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
assignment: { id: 2, completedAt: null },
|
||||
},
|
||||
})
|
||||
}).as('reopenAssignment')
|
||||
|
||||
cy.visit('/schedules/73')
|
||||
cy.wait('@me')
|
||||
cy.wait('@schedule')
|
||||
|
||||
cy.get('[data-schedule-assignment="2"]').within(() => {
|
||||
cy.contains('Completed').should('be.visible')
|
||||
cy.get('time')
|
||||
.should('have.attr', 'datetime', '2026-08-15T09:30:00+00:00')
|
||||
.and('have.text', formatCompletionTime('2026-08-15T09:30:00+00:00'))
|
||||
cy.contains('button', 'Reopen').click()
|
||||
})
|
||||
cy.wait('@reopenAssignment')
|
||||
cy.get('[data-schedule-assignment="2"]')
|
||||
.should('contain.text', 'Not completed')
|
||||
.and('not.contain.text', 'Completed')
|
||||
.within(() => {
|
||||
cy.contains('button', 'Mark complete').should('be.enabled')
|
||||
})
|
||||
|
||||
cy.get('[data-schedule-assignment="1"]').within(() => {
|
||||
cy.contains('button', 'Mark complete').click()
|
||||
cy.contains('button', 'Saving...').should('be.disabled')
|
||||
})
|
||||
cy.wait('@completeAssignment')
|
||||
cy.get('[data-schedule-assignment="1"]').within(() => {
|
||||
cy.contains('Completed').should('be.visible')
|
||||
cy.get('time')
|
||||
.should('have.attr', 'datetime', completedAt)
|
||||
.and('have.text', formatCompletionTime(completedAt))
|
||||
cy.contains('button', 'Reopen').should('be.enabled')
|
||||
})
|
||||
})
|
||||
|
||||
it('lists the users schedules on the dashboard', () => {
|
||||
cy.intercept('GET', '**/api/sets', {
|
||||
statusCode: 200,
|
||||
|
|
@ -352,10 +272,3 @@ describe('set scheduling', () => {
|
|||
cy.contains('a', 'Bible').should('have.attr', 'href', '/schedules/73')
|
||||
})
|
||||
})
|
||||
|
||||
function formatCompletionTime(value: string): string {
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,74 +113,6 @@ describe("today's assignments", () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('completes an assignment and keeps failures retryable', () => {
|
||||
cy.intercept('GET', `**/api/assignments?date=${browserToday}`, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
date: browserToday,
|
||||
assignments: [
|
||||
{
|
||||
id: 12,
|
||||
schedule: {
|
||||
id: 73,
|
||||
set: { name: 'Bible' },
|
||||
},
|
||||
element: {
|
||||
name: 'Chapter 1',
|
||||
kind: 'chapter',
|
||||
path: ['Genesis', 'Chapter 1'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}).as('todayAssignments')
|
||||
let requestCount = 0
|
||||
cy.intercept('PATCH', '**/api/assignments/12', (request) => {
|
||||
requestCount += 1
|
||||
request.alias = `completion${requestCount}`
|
||||
expect(request.body).to.deep.equal({ completed: true })
|
||||
|
||||
if (requestCount === 1) {
|
||||
request.reply({ delay: 400, statusCode: 500 })
|
||||
return
|
||||
}
|
||||
|
||||
request.reply({
|
||||
delay: 400,
|
||||
statusCode: 200,
|
||||
body: {
|
||||
assignment: {
|
||||
id: 12,
|
||||
completedAt: '2026-08-15T12:30:00+00:00',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
cy.visit('/dashboard')
|
||||
cy.wait('@me')
|
||||
cy.wait('@todayAssignments')
|
||||
|
||||
cy.get('[data-today-assignment="12"]').within(() => {
|
||||
cy.contains('button', 'Mark complete').click()
|
||||
cy.contains('button', 'Completing...').should('be.disabled')
|
||||
})
|
||||
cy.wait('@completion1')
|
||||
cy.get('[data-today-assignment="12"]')
|
||||
.should('contain.text', "We couldn't update this assignment.")
|
||||
.within(() => {
|
||||
cy.contains('button', 'Mark complete').click()
|
||||
cy.contains('button', 'Completing...').should('be.disabled')
|
||||
})
|
||||
cy.wait('@completion2')
|
||||
|
||||
cy.get('[data-today-assignment="12"]').should('not.exist')
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const scheduleSummarySchema = z.object({
|
|||
assignmentCount: z.number().int().nonnegative(),
|
||||
})
|
||||
|
||||
const assignmentIdentitySchema = z.object({
|
||||
const scheduleAssignmentSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
element: z.object({
|
||||
name: z.string().min(1),
|
||||
|
|
@ -26,11 +26,7 @@ const assignmentIdentitySchema = z.object({
|
|||
}),
|
||||
})
|
||||
|
||||
const scheduleAssignmentSchema = assignmentIdentitySchema.extend({
|
||||
completedAt: z.string().datetime({ offset: true }).nullable(),
|
||||
})
|
||||
|
||||
export const assignmentForDateSchema = assignmentIdentitySchema.extend({
|
||||
export const assignmentForDateSchema = scheduleAssignmentSchema.extend({
|
||||
schedule: z.object({
|
||||
id: z.number().int().positive(),
|
||||
set: z.object({
|
||||
|
|
@ -61,13 +57,6 @@ const assignmentsForDateResponseSchema = z.object({
|
|||
assignments: z.array(assignmentForDateSchema),
|
||||
})
|
||||
|
||||
const assignmentCompletionResponseSchema = z.object({
|
||||
assignment: z.object({
|
||||
id: z.number().int().positive(),
|
||||
completedAt: z.string().datetime({ offset: true }).nullable(),
|
||||
}),
|
||||
})
|
||||
|
||||
const errorResponseSchema = z.object({
|
||||
error: z.string().min(1),
|
||||
})
|
||||
|
|
@ -86,7 +75,6 @@ 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."
|
||||
const COMPLETION_ERROR = "We couldn't update this assignment."
|
||||
|
||||
export const useSchedulesStore = defineStore('schedules', () => {
|
||||
const schedules = ref<ScheduleSummary[]>([])
|
||||
|
|
@ -101,8 +89,6 @@ export const useSchedulesStore = defineStore('schedules', () => {
|
|||
const assignmentsForDate = ref<AssignmentForDate[]>([])
|
||||
const assignmentsLoading = ref(false)
|
||||
const assignmentsError = ref<string | null>(null)
|
||||
const assignmentCompletionPendingIds = ref<number[]>([])
|
||||
const assignmentCompletionErrors = ref<Record<number, string>>({})
|
||||
let activeDetailRequestId = 0
|
||||
let assignmentsRequestId = 0
|
||||
|
||||
|
|
@ -288,84 +274,6 @@ export const useSchedulesStore = defineStore('schedules', () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function setAssignmentCompleted(
|
||||
assignmentId: number,
|
||||
completed: boolean,
|
||||
): Promise<boolean> {
|
||||
if (assignmentCompletionPendingIds.value.includes(assignmentId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
assignmentCompletionPendingIds.value = [...assignmentCompletionPendingIds.value, assignmentId]
|
||||
const remainingErrors = { ...assignmentCompletionErrors.value }
|
||||
delete remainingErrors[assignmentId]
|
||||
assignmentCompletionErrors.value = remainingErrors
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/assignments/${assignmentId}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ completed }),
|
||||
})
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error('assignment completion request failed')
|
||||
}
|
||||
|
||||
const responseBody: unknown = await response.json()
|
||||
const assignment = assignmentCompletionResponseSchema.parse(responseBody).assignment
|
||||
const completionDoesNotMatchRequest = completed
|
||||
? assignment.completedAt === null
|
||||
: assignment.completedAt !== null
|
||||
if (assignment.id !== assignmentId || completionDoesNotMatchRequest) {
|
||||
throw new Error('assignment completion response did not match request')
|
||||
}
|
||||
|
||||
if (activeSchedule.value !== null) {
|
||||
for (const day of activeSchedule.value.days) {
|
||||
const activeAssignment = day.assignments.find(
|
||||
(candidate) => candidate.id === assignmentId,
|
||||
)
|
||||
if (activeAssignment !== undefined) {
|
||||
activeAssignment.completedAt = assignment.completedAt
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (assignment.completedAt !== null) {
|
||||
assignmentsForDate.value = assignmentsForDate.value.filter(
|
||||
(candidate) => candidate.id !== assignmentId,
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch {
|
||||
assignmentCompletionErrors.value = {
|
||||
...assignmentCompletionErrors.value,
|
||||
[assignmentId]: COMPLETION_ERROR,
|
||||
}
|
||||
|
||||
return false
|
||||
} finally {
|
||||
assignmentCompletionPendingIds.value = assignmentCompletionPendingIds.value.filter(
|
||||
(pendingId) => pendingId !== assignmentId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function isAssignmentCompletionPending(assignmentId: number): boolean {
|
||||
return assignmentCompletionPendingIds.value.includes(assignmentId)
|
||||
}
|
||||
|
||||
function assignmentCompletionError(assignmentId: number): string | null {
|
||||
return assignmentCompletionErrors.value[assignmentId] ?? null
|
||||
}
|
||||
|
||||
return {
|
||||
schedules,
|
||||
listLoading,
|
||||
|
|
@ -379,14 +287,9 @@ export const useSchedulesStore = defineStore('schedules', () => {
|
|||
assignmentsForDate,
|
||||
assignmentsLoading,
|
||||
assignmentsError,
|
||||
assignmentCompletionPendingIds,
|
||||
assignmentCompletionErrors,
|
||||
fetchSchedules,
|
||||
fetchSchedule,
|
||||
createSchedule,
|
||||
fetchAssignmentsForDate,
|
||||
setAssignmentCompleted,
|
||||
isAssignmentCompletionPending,
|
||||
assignmentCompletionError,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -35,10 +35,6 @@ function formatDate(value: string): string {
|
|||
timeZone: 'UTC',
|
||||
}).format(new Date(`${value}T00:00:00Z`))
|
||||
}
|
||||
|
||||
async function completeAssignment(assignmentId: number): Promise<void> {
|
||||
await schedulesStore.setAssignmentCompleted(assignmentId, true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -80,12 +76,7 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
|||
</p>
|
||||
|
||||
<ul v-else class="today-assignment-list" aria-label="Today's assignments">
|
||||
<li
|
||||
v-for="assignment in assignmentsForDate"
|
||||
:key="assignment.id"
|
||||
:data-today-assignment="assignment.id"
|
||||
>
|
||||
<article class="today-assignment">
|
||||
<li v-for="assignment in assignmentsForDate" :key="assignment.id">
|
||||
<RouterLink
|
||||
class="today-assignment-link"
|
||||
:to="{
|
||||
|
|
@ -93,6 +84,7 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
|||
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>
|
||||
|
|
@ -104,30 +96,8 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
|||
Open schedule
|
||||
<span aria-hidden="true">→</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
<div class="today-assignment__completion">
|
||||
<p
|
||||
v-if="schedulesStore.assignmentCompletionError(assignment.id) !== null"
|
||||
class="assignment-completion-error"
|
||||
role="alert"
|
||||
>
|
||||
{{ schedulesStore.assignmentCompletionError(assignment.id) }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="assignment-completion-button"
|
||||
:aria-label="`Mark ${assignment.element.path.join(' / ')} complete`"
|
||||
:disabled="schedulesStore.isAssignmentCompletionPending(assignment.id)"
|
||||
@click="completeAssignment(assignment.id)"
|
||||
>
|
||||
{{
|
||||
schedulesStore.isAssignmentCompletionPending(assignment.id)
|
||||
? 'Completing...'
|
||||
: 'Mark complete'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
|
@ -343,13 +313,12 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
|||
|
||||
.today-assignment-link {
|
||||
display: block;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
border-radius: 1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.today-assignment {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 1.4rem;
|
||||
border: 1px solid rgb(24 48 41 / 12%);
|
||||
|
|
@ -361,7 +330,7 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
|||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.today-assignment:hover {
|
||||
.today-assignment-link:hover .today-assignment {
|
||||
border-color: rgb(40 92 78 / 35%);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
|
@ -408,48 +377,6 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
|||
font-weight: 750;
|
||||
}
|
||||
|
||||
.today-assignment__completion {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 0.65rem;
|
||||
margin-top: auto;
|
||||
padding-top: 1.15rem;
|
||||
border-top: 1px solid rgb(24 48 41 / 10%);
|
||||
}
|
||||
|
||||
.assignment-completion-button {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border: 1px solid rgb(24 58 49 / 28%);
|
||||
border-radius: 0.7rem;
|
||||
color: #fffdf7;
|
||||
background: #285c4e;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.assignment-completion-button:hover:not(:disabled) {
|
||||
background: #183a31;
|
||||
}
|
||||
|
||||
.assignment-completion-button:focus-visible {
|
||||
outline: 3px solid rgb(86 127 112 / 34%);
|
||||
outline-offset: 0.25rem;
|
||||
}
|
||||
|
||||
.assignment-completion-button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.assignment-completion-error {
|
||||
margin: 0;
|
||||
color: #9b3f32;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sets-catalog__description {
|
||||
max-width: 38rem;
|
||||
margin: 1.5rem 0 0;
|
||||
|
|
|
|||
|
|
@ -39,17 +39,6 @@ function formatDate(value: string): string {
|
|||
timeZone: 'UTC',
|
||||
}).format(new Date(`${value}T00:00:00Z`))
|
||||
}
|
||||
|
||||
function formatCompletionTime(value: string): string {
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
async function setAssignmentCompleted(assignmentId: number, completed: boolean): Promise<void> {
|
||||
await schedulesStore.setAssignmentCompleted(assignmentId, completed)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -109,53 +98,9 @@ async function setAssignmentCompleted(assignmentId: number, completed: boolean):
|
|||
<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"
|
||||
:class="{ 'is-completed': assignment.completedAt !== null }"
|
||||
:data-schedule-assignment="assignment.id"
|
||||
>
|
||||
<div class="assignment-description">
|
||||
<span class="assignment-path">
|
||||
{{ assignment.element.path.join(' / ') }}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
<div class="assignment-completion">
|
||||
<p v-if="assignment.completedAt !== null" class="assignment-completion__status">
|
||||
Completed
|
||||
<time :datetime="assignment.completedAt">
|
||||
{{ formatCompletionTime(assignment.completedAt) }}
|
||||
</time>
|
||||
</p>
|
||||
<p v-else class="assignment-completion__status">Not completed</p>
|
||||
<button
|
||||
type="button"
|
||||
class="assignment-completion-button"
|
||||
:aria-label="
|
||||
assignment.completedAt === null
|
||||
? `Mark ${assignment.element.path.join(' / ')} complete`
|
||||
: `Reopen ${assignment.element.path.join(' / ')}`
|
||||
"
|
||||
:disabled="schedulesStore.isAssignmentCompletionPending(assignment.id)"
|
||||
@click="setAssignmentCompleted(assignment.id, assignment.completedAt === null)"
|
||||
>
|
||||
{{
|
||||
schedulesStore.isAssignmentCompletionPending(assignment.id)
|
||||
? 'Saving...'
|
||||
: assignment.completedAt === null
|
||||
? 'Mark complete'
|
||||
: 'Reopen'
|
||||
}}
|
||||
</button>
|
||||
<p
|
||||
v-if="schedulesStore.assignmentCompletionError(assignment.id) !== null"
|
||||
class="assignment-completion__error"
|
||||
role="alert"
|
||||
>
|
||||
{{ schedulesStore.assignmentCompletionError(assignment.id) }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
|
@ -278,17 +223,13 @@ h1 {
|
|||
}
|
||||
|
||||
.assignment-list li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.assignment-list li.is-completed {
|
||||
background: rgb(220 235 224 / 34%);
|
||||
}
|
||||
|
||||
.assignment-list li + li {
|
||||
border-top: 1px solid rgb(24 48 41 / 10%);
|
||||
}
|
||||
|
|
@ -300,14 +241,6 @@ h1 {
|
|||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.assignment-description {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.assignment-kind {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.35rem 0.55rem;
|
||||
|
|
@ -320,68 +253,6 @@ h1 {
|
|||
text-transform: none;
|
||||
}
|
||||
|
||||
.assignment-completion {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.55rem;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.assignment-completion__status,
|
||||
.assignment-completion__error {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.assignment-completion__status {
|
||||
color: #5e7067;
|
||||
}
|
||||
|
||||
.assignment-completion__status time {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
color: #344e45;
|
||||
}
|
||||
|
||||
.assignment-completion__error {
|
||||
max-width: 14rem;
|
||||
color: #9b3f32;
|
||||
}
|
||||
|
||||
.assignment-completion-button {
|
||||
min-height: 2.4rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
border: 1px solid rgb(24 58 49 / 28%);
|
||||
border-radius: 0.65rem;
|
||||
color: #183a31;
|
||||
background: #fffdf7;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.is-completed .assignment-completion-button {
|
||||
color: #5e7067;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.assignment-completion-button:hover:not(:disabled) {
|
||||
border-color: #285c4e;
|
||||
background: #f9f5e9;
|
||||
}
|
||||
|
||||
.assignment-completion-button:focus-visible {
|
||||
outline: 3px solid rgb(86 127 112 / 34%);
|
||||
outline-offset: 0.25rem;
|
||||
}
|
||||
|
||||
.assignment-completion-button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.page-state {
|
||||
display: grid;
|
||||
min-height: 10rem;
|
||||
|
|
@ -431,21 +302,7 @@ h1 {
|
|||
}
|
||||
|
||||
.assignment-list li {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.assignment-description {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.assignment-completion {
|
||||
justify-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.assignment-completion__status,
|
||||
.assignment-completion__error {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue