add assignment completion ui

This commit is contained in:
Yisroel Baum 2026-08-15 22:52:13 +03:00
parent 0d9e2fa71b
commit 9f53245987
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
3 changed files with 335 additions and 22 deletions

View file

@ -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<ScheduleSummary[]>([])
@ -89,6 +101,8 @@ 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
@ -274,6 +288,84 @@ 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,
@ -287,9 +379,14 @@ export const useSchedulesStore = defineStore('schedules', () => {
assignmentsForDate,
assignmentsLoading,
assignmentsError,
assignmentCompletionPendingIds,
assignmentCompletionErrors,
fetchSchedules,
fetchSchedule,
createSchedule,
fetchAssignmentsForDate,
setAssignmentCompleted,
isAssignmentCompletionPending,
assignmentCompletionError,
}
})

View file

@ -35,6 +35,10 @@ 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>
@ -76,15 +80,19 @@ function formatDate(value: string): string {
</p>
<ul v-else class="today-assignment-list" aria-label="Today's assignments">
<li v-for="assignment in assignmentsForDate" :key="assignment.id">
<RouterLink
class="today-assignment-link"
:to="{
name: 'schedule-detail',
params: { scheduleId: assignment.schedule.id },
}"
>
<article class="today-assignment">
<li
v-for="assignment in assignmentsForDate"
:key="assignment.id"
:data-today-assignment="assignment.id"
>
<article class="today-assignment">
<RouterLink
class="today-assignment-link"
:to="{
name: 'schedule-detail',
params: { scheduleId: assignment.schedule.id },
}"
>
<div class="today-assignment__heading">
<p>{{ assignment.schedule.set.name }}</p>
<span class="today-assignment__kind">{{ assignment.element.kind }}</span>
@ -96,8 +104,30 @@ function formatDate(value: string): string {
Open schedule
<span aria-hidden="true"></span>
</span>
</article>
</RouterLink>
</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>
</li>
</ul>
</section>
@ -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;

View file

@ -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<void> {
await schedulesStore.setAssignmentCompleted(assignmentId, completed)
}
</script>
<template>
@ -98,9 +109,53 @@ function formatDate(value: string): string {
<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">
<span class="assignment-path">{{ assignment.element.path.join(' / ') }}</span>
<span class="assignment-kind">{{ assignment.element.kind }}</span>
<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>
<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>
@ -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;
}
}
</style>