From 916f070455f0ff61ea5dddafeaf9768e978efb7d Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Sat, 15 Aug 2026 22:39:34 +0300
Subject: [PATCH 1/5] test assignment completion
---
.../Feature/Schedule/ScheduleEndpointTest.php | 145 +++++++++++++++
.../Unit/Schedule/ScheduleAssignmentTest.php | 49 +++++
.../UseCases/ListAssignmentsForDateTest.php | 12 +-
.../UseCases/SetAssignmentCompletionTest.php | 175 ++++++++++++++++++
4 files changed, 378 insertions(+), 3 deletions(-)
create mode 100644 backend/tests/Unit/Schedule/ScheduleAssignmentTest.php
create mode 100644 backend/tests/Unit/Schedule/UseCases/SetAssignmentCompletionTest.php
diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php
index a0e9cf8..b68e2ad 100644
--- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php
+++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php
@@ -3,6 +3,7 @@
namespace Tests\Feature\Schedule;
use App\Auth\CreateSessionDto;
+use App\Auth\Clock;
use App\Auth\SessionRepository;
use App\Element\CreateElementDto;
use App\Element\ElementModel;
@@ -25,6 +26,7 @@ use DateTimeZone;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
use Tests\TestCase;
+use Tests\Fakes\FakeClock;
class ScheduleEndpointTest extends TestCase
{
@@ -94,6 +96,7 @@ class ScheduleEndpointTest extends TestCase
'assignments' => [
[
'id' => 1,
+ 'completedAt' => null,
'element' => [
'name' => 'Chapter 1',
'kind' => 'chapter',
@@ -115,6 +118,7 @@ class ScheduleEndpointTest extends TestCase
'assignments' => [
[
'id' => 2,
+ 'completedAt' => null,
'element' => [
'name' => 'Chapter 1',
'kind' => 'chapter',
@@ -351,6 +355,130 @@ 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');
@@ -455,6 +583,9 @@ 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);
@@ -520,4 +651,18 @@ class ScheduleEndpointTest extends TestCase
'valid-token',
)->getJson($uri);
}
+
+ /**
+ * @param array $payload
+ */
+ private function credentialedPatch(
+ string $uri,
+ array $payload,
+ ): TestResponse {
+ return $this->withCredentials()
+ ->withUnencryptedCookie(
+ AuthMiddleware::COOKIE_NAME,
+ 'valid-token',
+ )->patchJson($uri, $payload);
+ }
}
diff --git a/backend/tests/Unit/Schedule/ScheduleAssignmentTest.php b/backend/tests/Unit/Schedule/ScheduleAssignmentTest.php
new file mode 100644
index 0000000..12f86f5
--- /dev/null
+++ b/backend/tests/Unit/Schedule/ScheduleAssignmentTest.php
@@ -0,0 +1,49 @@
+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'));
+ }
+}
diff --git a/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php b/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php
index 1d1e3fd..c6bc95f 100644
--- a/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php
+++ b/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php
@@ -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;
- $repository->create($this->schedule(
+ $olderSchedule = $repository->create($this->schedule(
user: $user,
setName: 'Older plan',
date: '2026-08-15',
@@ -46,6 +46,12 @@ 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(
@@ -55,13 +61,13 @@ class ListAssignmentsForDateTest extends TestCase
);
$this->assertSame(
- ['Newer plan', 'Older plan', 'Older plan'],
+ ['Newer plan', 'Older plan'],
array_map(function ($assignment): string {
return $assignment->getSetName();
}, $assignments),
);
$this->assertSame(
- ['Third', 'First', 'Second'],
+ ['Third', 'Second'],
array_map(function ($assignment): string {
return $assignment->getAssignment()->getName();
}, $assignments),
diff --git a/backend/tests/Unit/Schedule/UseCases/SetAssignmentCompletionTest.php b/backend/tests/Unit/Schedule/UseCases/SetAssignmentCompletionTest.php
new file mode 100644
index 0000000..8950edc
--- /dev/null
+++ b/backend/tests/Unit/Schedule/UseCases/SetAssignmentCompletionTest.php
@@ -0,0 +1,175 @@
+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'));
+ }
+}
From 350d2ec0b7a1111974e938b00db8ece77d9cdf86 Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Sat, 15 Aug 2026 22:43:06 +0300
Subject: [PATCH 2/5] add assignment completion api
---
.../Http/Controllers/ScheduleController.php | 42 ++++++++-
.../Schedule/EloquentScheduleRepository.php | 56 +++++++++++
backend/app/Schedule/ScheduleAssignment.php | 20 +++-
.../app/Schedule/ScheduleAssignmentModel.php | 4 +
backend/app/Schedule/ScheduleRepository.php | 9 ++
.../SetAssignmentCompletion.php | 47 ++++++++++
.../SetAssignmentCompletionRequest.php | 14 +++
backend/app/Shared/Http/RequestInput.php | 7 ++
...0001_create_schedule_assignments_table.php | 2 +
backend/routes/api.php | 4 +
.../tests/Fakes/FakeScheduleRepository.php | 92 ++++++++++++++++---
11 files changed, 281 insertions(+), 16 deletions(-)
create mode 100644 backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletion.php
create mode 100644 backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletionRequest.php
diff --git a/backend/app/Http/Controllers/ScheduleController.php b/backend/app/Http/Controllers/ScheduleController.php
index 9ec6588..f80461a 100644
--- a/backend/app/Http/Controllers/ScheduleController.php
+++ b/backend/app/Http/Controllers/ScheduleController.php
@@ -14,6 +14,8 @@ 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;
@@ -26,6 +28,7 @@ class ScheduleController extends Controller
private ListSchedules $listSchedules,
private GetSchedule $getSchedule,
private ListAssignmentsForDate $listAssignmentsForDate,
+ private SetAssignmentCompletion $setAssignmentCompletion,
) {}
public function store(Request $request): JsonResponse
@@ -124,6 +127,41 @@ 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
*/
@@ -180,7 +218,7 @@ class ScheduleController extends Controller
}
/**
- * @return array{id: int, element: array{
+ * @return array{id: int, completedAt: string|null, element: array{
* name: string,
* kind: string,
* path: list
@@ -191,6 +229,8 @@ class ScheduleController extends Controller
): array {
return [
'id' => $assignment->getId(),
+ 'completedAt' => $assignment->getCompletedAt()
+ ?->format(DATE_ATOM),
'element' => [
'name' => $assignment->getName(),
'kind' => $assignment->getKind(),
diff --git a/backend/app/Schedule/EloquentScheduleRepository.php b/backend/app/Schedule/EloquentScheduleRepository.php
index 7aec036..8cbde2a 100644
--- a/backend/app/Schedule/EloquentScheduleRepository.php
+++ b/backend/app/Schedule/EloquentScheduleRepository.php
@@ -5,6 +5,7 @@ namespace App\Schedule;
use App\User\User;
use DateTimeImmutable;
use DateTimeZone;
+use DomainException;
use Illuminate\Support\Facades\DB;
class EloquentScheduleRepository implements ScheduleRepository
@@ -29,6 +30,7 @@ class EloquentScheduleRepository implements ScheduleRepository
'scheduled_date' => $assignmentDto->scheduledDate
->format('Y-m-d'),
'position' => $assignmentDto->position,
+ 'completed_at' => null,
]);
}
@@ -77,6 +79,7 @@ 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')
@@ -95,6 +98,50 @@ 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 {
+ $query = ScheduleAssignmentModel::query()
+ ->whereKey($assignment->getId());
+ if ($assignment->getCompletedAt() === null) {
+ $query->update(['completed_at' => null]);
+ } else {
+ $query->whereNull('completed_at')->update([
+ 'completed_at' => $assignment->getCompletedAt(),
+ ]);
+ }
+
+ $model = ScheduleAssignmentModel::find($assignment->getId());
+ if ($model === null) {
+ throw new DomainException(
+ "Assignment with id {$assignment->getId()} not found",
+ );
+ }
+
+ return $this->assignmentToDomain($model);
+ }
+
private function toDomain(ScheduleModel $model, User $user): Schedule
{
$assignmentModels = ScheduleAssignmentModel::query()
@@ -129,6 +176,9 @@ 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),
);
}
@@ -136,4 +186,10 @@ 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'));
+ }
}
diff --git a/backend/app/Schedule/ScheduleAssignment.php b/backend/app/Schedule/ScheduleAssignment.php
index 482b45d..8f90435 100644
--- a/backend/app/Schedule/ScheduleAssignment.php
+++ b/backend/app/Schedule/ScheduleAssignment.php
@@ -4,7 +4,7 @@ namespace App\Schedule;
use DateTimeImmutable;
-final readonly class ScheduleAssignment
+final class ScheduleAssignment
{
/**
* @param list $path
@@ -16,6 +16,7 @@ final readonly class ScheduleAssignment
private array $path,
private DateTimeImmutable $scheduledDate,
private int $position,
+ private ?DateTimeImmutable $completedAt,
) {}
public function getId(): int
@@ -50,4 +51,21 @@ final readonly 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;
+ }
}
diff --git a/backend/app/Schedule/ScheduleAssignmentModel.php b/backend/app/Schedule/ScheduleAssignmentModel.php
index 3cd5577..33b2953 100644
--- a/backend/app/Schedule/ScheduleAssignmentModel.php
+++ b/backend/app/Schedule/ScheduleAssignmentModel.php
@@ -2,6 +2,7 @@
namespace App\Schedule;
+use DateTimeImmutable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@@ -15,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property list $element_path
* @property string $scheduled_date
* @property int $position
+ * @property DateTimeImmutable|null $completed_at
* @property-read ScheduleModel $schedule
*
* @method static Builder|ScheduleAssignmentModel newModelQuery()
@@ -30,6 +32,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'element_path',
'scheduled_date',
'position',
+ 'completed_at',
])]
class ScheduleAssignmentModel extends Model
{
@@ -46,6 +49,7 @@ class ScheduleAssignmentModel extends Model
'schedule_id' => 'integer',
'element_path' => 'array',
'position' => 'integer',
+ 'completed_at' => 'immutable_datetime',
];
}
diff --git a/backend/app/Schedule/ScheduleRepository.php b/backend/app/Schedule/ScheduleRepository.php
index fcdcb8c..3222311 100644
--- a/backend/app/Schedule/ScheduleRepository.php
+++ b/backend/app/Schedule/ScheduleRepository.php
@@ -23,4 +23,13 @@ interface ScheduleRepository
User $user,
DateTimeImmutable $date,
): array;
+
+ public function findAssignmentForUser(
+ int $id,
+ User $user,
+ ): ?ScheduleAssignment;
+
+ public function updateAssignment(
+ ScheduleAssignment $assignment,
+ ): ScheduleAssignment;
}
diff --git a/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletion.php b/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletion.php
new file mode 100644
index 0000000..df66b8d
--- /dev/null
+++ b/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletion.php
@@ -0,0 +1,47 @@
+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);
+ }
+}
diff --git a/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletionRequest.php b/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletionRequest.php
new file mode 100644
index 0000000..e591375
--- /dev/null
+++ b/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletionRequest.php
@@ -0,0 +1,14 @@
+request->input($key);
+
+ return is_bool($value) ? $value : null;
+ }
}
diff --git a/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php b/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php
index b52c60a..c98992a 100644
--- a/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php
+++ b/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php
@@ -20,10 +20,12 @@ 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',
]);
},
diff --git a/backend/routes/api.php b/backend/routes/api.php
index 2355570..76caf16 100644
--- a/backend/routes/api.php
+++ b/backend/routes/api.php
@@ -16,6 +16,10 @@ 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'])
diff --git a/backend/tests/Fakes/FakeScheduleRepository.php b/backend/tests/Fakes/FakeScheduleRepository.php
index e30caf2..e2f35bc 100644
--- a/backend/tests/Fakes/FakeScheduleRepository.php
+++ b/backend/tests/Fakes/FakeScheduleRepository.php
@@ -9,6 +9,7 @@ use App\Schedule\ScheduleAssignment;
use App\Schedule\ScheduleRepository;
use App\User\User;
use DateTimeImmutable;
+use DomainException;
class FakeScheduleRepository implements ScheduleRepository
{
@@ -17,6 +18,8 @@ class FakeScheduleRepository implements ScheduleRepository
*/
private array $schedules = [];
+ private int $nextAssignmentId = 1;
+
public function create(CreateScheduleDto $dto): Schedule
{
$id = count($this->schedules) + 1;
@@ -24,12 +27,13 @@ class FakeScheduleRepository implements ScheduleRepository
foreach ($dto->assignments as $assignmentDto) {
$assignments[] = new ScheduleAssignment(
- id: count($assignments) + 1,
+ id: $this->nextAssignmentId++,
name: $assignmentDto->name,
kind: $assignmentDto->kind,
path: $assignmentDto->path,
scheduledDate: $assignmentDto->scheduledDate,
position: $assignmentDto->position,
+ completedAt: null,
);
}
@@ -84,6 +88,7 @@ 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;
}
@@ -99,21 +104,66 @@ 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 new ScheduleAssignment(
- id: $assignment->getId(),
- name: $assignment->getName(),
- kind: $assignment->getKind(),
- path: $assignment->getPath(),
- scheduledDate: $assignment->getScheduledDate(),
- position: $assignment->getPosition(),
- );
- },
- $schedule->getAssignments(),
- );
+ $assignments = array_map(function (
+ ScheduleAssignment $assignment,
+ ): ScheduleAssignment {
+ return $this->copyAssignment($assignment);
+ }, $schedule->getAssignments());
return new Schedule(
id: $schedule->getId(),
@@ -125,4 +175,18 @@ 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(),
+ );
+ }
}
From 0d9e2fa71bf772e0690397d3decbc3780cc0f1a6 Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Sat, 15 Aug 2026 22:47:49 +0300
Subject: [PATCH 3/5] test assignment completion ui
---
.../website/cypress/e2e/set-scheduling.cy.ts | 87 +++++++++++++++++++
.../cypress/e2e/today-assignments.cy.ts | 68 +++++++++++++++
2 files changed, 155 insertions(+)
diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts
index a12f672..9f83629 100644
--- a/frontend/website/cypress/e2e/set-scheduling.cy.ts
+++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts
@@ -74,6 +74,7 @@ const scheduleDetail = {
assignments: [
{
id: 1,
+ completedAt: null,
element: {
name: 'Chapter 1',
kind: 'Chapter_sections-v2',
@@ -88,6 +89,7 @@ const scheduleDetail = {
assignments: [
{
id: 2,
+ completedAt: null,
element: {
name: 'Chapter 1',
kind: 'Chapter_sections-v2',
@@ -100,6 +102,25 @@ 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,
@@ -227,6 +248,65 @@ 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,
@@ -272,3 +352,10 @@ 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))
+}
diff --git a/frontend/website/cypress/e2e/today-assignments.cy.ts b/frontend/website/cypress/e2e/today-assignments.cy.ts
index 9d31dde..77d2ba5 100644
--- a/frontend/website/cypress/e2e/today-assignments.cy.ts
+++ b/frontend/website/cypress/e2e/today-assignments.cy.ts
@@ -113,6 +113,74 @@ 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(
From 9f5324598789a0ddea2feee312bab3374264c692 Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Sat, 15 Aug 2026 22:52:13 +0300
Subject: [PATCH 4/5] add assignment completion ui
---
frontend/website/src/stores/schedules.ts | 101 +++++++++++-
frontend/website/src/views/DashboardView.vue | 101 ++++++++++--
.../website/src/views/ScheduleDetailView.vue | 155 +++++++++++++++++-
3 files changed, 335 insertions(+), 22 deletions(-)
diff --git a/frontend/website/src/stores/schedules.ts b/frontend/website/src/stores/schedules.ts
index 090db7c..2471d66 100644
--- a/frontend/website/src/stores/schedules.ts
+++ b/frontend/website/src/stores/schedules.ts
@@ -17,7 +17,7 @@ export const scheduleSummarySchema = z.object({
assignmentCount: z.number().int().nonnegative(),
})
-const scheduleAssignmentSchema = z.object({
+const assignmentIdentitySchema = z.object({
id: z.number().int().positive(),
element: z.object({
name: z.string().min(1),
@@ -26,7 +26,11 @@ const scheduleAssignmentSchema = z.object({
}),
})
-export const assignmentForDateSchema = scheduleAssignmentSchema.extend({
+const scheduleAssignmentSchema = assignmentIdentitySchema.extend({
+ completedAt: z.string().datetime({ offset: true }).nullable(),
+})
+
+export const assignmentForDateSchema = assignmentIdentitySchema.extend({
schedule: z.object({
id: z.number().int().positive(),
set: z.object({
@@ -57,6 +61,13 @@ 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),
})
@@ -75,6 +86,7 @@ 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([])
@@ -89,6 +101,8 @@ export const useSchedulesStore = defineStore('schedules', () => {
const assignmentsForDate = ref([])
const assignmentsLoading = ref(false)
const assignmentsError = ref(null)
+ const assignmentCompletionPendingIds = ref([])
+ const assignmentCompletionErrors = ref>({})
let activeDetailRequestId = 0
let assignmentsRequestId = 0
@@ -274,6 +288,84 @@ export const useSchedulesStore = defineStore('schedules', () => {
}
}
+ async function setAssignmentCompleted(
+ assignmentId: number,
+ completed: boolean,
+ ): Promise {
+ 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,
@@ -287,9 +379,14 @@ export const useSchedulesStore = defineStore('schedules', () => {
assignmentsForDate,
assignmentsLoading,
assignmentsError,
+ assignmentCompletionPendingIds,
+ assignmentCompletionErrors,
fetchSchedules,
fetchSchedule,
createSchedule,
fetchAssignmentsForDate,
+ setAssignmentCompleted,
+ isAssignmentCompletionPending,
+ assignmentCompletionError,
}
})
diff --git a/frontend/website/src/views/DashboardView.vue b/frontend/website/src/views/DashboardView.vue
index c99ccf4..822da86 100644
--- a/frontend/website/src/views/DashboardView.vue
+++ b/frontend/website/src/views/DashboardView.vue
@@ -35,6 +35,10 @@ function formatDate(value: string): string {
timeZone: 'UTC',
}).format(new Date(`${value}T00:00:00Z`))
}
+
+async function completeAssignment(assignmentId: number): Promise {
+ await schedulesStore.setAssignmentCompleted(assignmentId, true)
+}
@@ -76,15 +80,19 @@ function formatDate(value: string): string {
- -
-
-
+
-
+
+
{{ assignment.schedule.set.name }}
{{ assignment.element.kind }}
@@ -96,8 +104,30 @@ function formatDate(value: string): string {
Open schedule
→
-
-
+
+
+
+ {{ schedulesStore.assignmentCompletionError(assignment.id) }}
+
+
+
+
@@ -313,12 +343,13 @@ function formatDate(value: string): string {
.today-assignment-link {
display: block;
- height: 100%;
- border-radius: 1rem;
+ color: inherit;
text-decoration: none;
}
.today-assignment {
+ display: flex;
+ flex-direction: column;
height: 100%;
padding: 1.4rem;
border: 1px solid rgb(24 48 41 / 12%);
@@ -330,7 +361,7 @@ function formatDate(value: string): string {
transform 160ms ease;
}
-.today-assignment-link:hover .today-assignment {
+.today-assignment:hover {
border-color: rgb(40 92 78 / 35%);
transform: translateY(-2px);
}
@@ -377,6 +408,48 @@ function formatDate(value: string): string {
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;
diff --git a/frontend/website/src/views/ScheduleDetailView.vue b/frontend/website/src/views/ScheduleDetailView.vue
index 352f44f..9a455a9 100644
--- a/frontend/website/src/views/ScheduleDetailView.vue
+++ b/frontend/website/src/views/ScheduleDetailView.vue
@@ -39,6 +39,17 @@ 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
{
+ await schedulesStore.setAssignmentCompleted(assignmentId, completed)
+}
@@ -98,9 +109,53 @@ function formatDate(value: string): string {
Rest day
@@ -223,13 +278,17 @@ h1 {
}
.assignment-list li {
- display: flex;
- align-items: center;
- justify-content: space-between;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: start;
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%);
}
@@ -241,6 +300,14 @@ 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;
@@ -253,6 +320,68 @@ 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;
@@ -302,7 +431,21 @@ 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;
+ }
}
From 0466552c4f42f7b195ded00c6d21a1bf44e1c4bd Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Sun, 16 Aug 2026 10:19:08 +0300
Subject: [PATCH 5/5] simplify assignment persistence
---
backend/app/Schedule/EloquentScheduleRepository.php | 13 +++----------
1 file changed, 3 insertions(+), 10 deletions(-)
diff --git a/backend/app/Schedule/EloquentScheduleRepository.php b/backend/app/Schedule/EloquentScheduleRepository.php
index 8cbde2a..214e8a5 100644
--- a/backend/app/Schedule/EloquentScheduleRepository.php
+++ b/backend/app/Schedule/EloquentScheduleRepository.php
@@ -122,16 +122,6 @@ class EloquentScheduleRepository implements ScheduleRepository
public function updateAssignment(
ScheduleAssignment $assignment,
): ScheduleAssignment {
- $query = ScheduleAssignmentModel::query()
- ->whereKey($assignment->getId());
- if ($assignment->getCompletedAt() === null) {
- $query->update(['completed_at' => null]);
- } else {
- $query->whereNull('completed_at')->update([
- 'completed_at' => $assignment->getCompletedAt(),
- ]);
- }
-
$model = ScheduleAssignmentModel::find($assignment->getId());
if ($model === null) {
throw new DomainException(
@@ -139,6 +129,9 @@ class EloquentScheduleRepository implements ScheduleRepository
);
}
+ $model->completed_at = $assignment->getCompletedAt();
+ $model->save();
+
return $this->assignmentToDomain($model);
}