From 9f5324598789a0ddea2feee312bab3374264c692 Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Sat, 15 Aug 2026 22:52:13 +0300
Subject: [PATCH] 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;
+ }
}