add assignment completion ui
This commit is contained in:
parent
0d9e2fa71b
commit
9f53245987
3 changed files with 335 additions and 22 deletions
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,7 +80,12 @@ 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">
|
||||
<li
|
||||
v-for="assignment in assignmentsForDate"
|
||||
:key="assignment.id"
|
||||
:data-today-assignment="assignment.id"
|
||||
>
|
||||
<article class="today-assignment">
|
||||
<RouterLink
|
||||
class="today-assignment-link"
|
||||
:to="{
|
||||
|
|
@ -84,7 +93,6 @@ function formatDate(value: string): string {
|
|||
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>
|
||||
|
|
@ -96,8 +104,30 @@ function formatDate(value: string): string {
|
|||
Open schedule
|
||||
<span aria-hidden="true">→</span>
|
||||
</span>
|
||||
</article>
|
||||
</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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue