Attainly/frontend/website/src/stores/schedules.ts

397 lines
11 KiB
TypeScript

import { ref } from 'vue'
import { defineStore } from 'pinia'
import { z } from 'zod'
import { API_BASE } from '@/utils/apiBase'
const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
export const workloadPlacementSchema = z.enum(['start', 'middle', 'end'])
export const scheduleSummarySchema = z.object({
id: z.number().int().positive(),
set: z.object({
name: z.string().min(1),
}),
elementKind: z.string().min(1),
startDate: isoDateSchema,
targetDate: isoDateSchema,
assignmentCount: z.number().int().nonnegative(),
})
const assignmentIdentitySchema = z.object({
id: z.number().int().positive(),
element: z.object({
name: z.string().min(1),
kind: z.string().min(1),
path: z.array(z.string().min(1)).min(1),
}),
})
const scheduleAssignmentSchema = assignmentIdentitySchema.extend({
completedAt: z.string().datetime({ offset: true }).nullable(),
})
export const assignmentForDateSchema = assignmentIdentitySchema.extend({
scheduledDate: isoDateSchema,
schedule: z.object({
id: z.number().int().positive(),
set: z.object({
name: z.string().min(1),
}),
}),
})
export const scheduleDetailSchema = scheduleSummarySchema.extend({
days: z.array(
z.object({
date: isoDateSchema,
assignments: z.array(scheduleAssignmentSchema),
}),
),
})
const schedulesResponseSchema = z.object({
schedules: z.array(scheduleSummarySchema),
})
const scheduleResponseSchema = z.object({
schedule: scheduleDetailSchema,
})
const assignmentsForDateResponseSchema = z.object({
date: isoDateSchema,
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),
})
export type ScheduleSummary = z.infer<typeof scheduleSummarySchema>
export type ScheduleDetail = z.infer<typeof scheduleDetailSchema>
export type AssignmentForDate = z.infer<typeof assignmentForDateSchema>
export type WorkloadPlacement = z.infer<typeof workloadPlacementSchema>
export type CreateScheduleInput = {
setId: number
levelId: number
startDate: string
targetDate: string
workloadPlacement: WorkloadPlacement
}
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[]>([])
const listLoading = ref(false)
const listError = ref<string | null>(null)
const activeSchedule = ref<ScheduleDetail | null>(null)
const detailLoading = ref(false)
const detailError = ref<string | null>(null)
const detailNotFound = ref(false)
const creating = ref(false)
const createError = ref<string | null>(null)
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
async function fetchSchedules(): Promise<boolean> {
listLoading.value = true
listError.value = null
try {
const response = await fetch(`${API_BASE}/api/schedules`, {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
if (response.status !== 200) {
schedules.value = []
listError.value = LIST_ERROR
return false
}
const responseBody: unknown = await response.json()
schedules.value = schedulesResponseSchema.parse(responseBody).schedules
return true
} catch {
schedules.value = []
listError.value = LIST_ERROR
return false
} finally {
listLoading.value = false
}
}
async function fetchSchedule(scheduleId: number): Promise<boolean> {
const requestId = ++activeDetailRequestId
activeSchedule.value = null
detailLoading.value = true
detailError.value = null
detailNotFound.value = false
try {
const response = await fetch(`${API_BASE}/api/schedules/${scheduleId}`, {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
if (requestId !== activeDetailRequestId) {
return false
}
if (response.status === 404) {
detailNotFound.value = true
return false
}
if (response.status !== 200) {
detailError.value = DETAIL_ERROR
return false
}
const responseBody: unknown = await response.json()
if (requestId !== activeDetailRequestId) {
return false
}
const parsedSchedule = scheduleResponseSchema.parse(responseBody).schedule
if (parsedSchedule.id !== scheduleId) {
throw new Error('schedule response did not match requested schedule')
}
activeSchedule.value = parsedSchedule
return true
} catch {
if (requestId === activeDetailRequestId) {
activeSchedule.value = null
detailError.value = DETAIL_ERROR
}
return false
} finally {
if (requestId === activeDetailRequestId) {
detailLoading.value = false
}
}
}
async function createSchedule(input: CreateScheduleInput): Promise<ScheduleDetail | null> {
creating.value = true
createError.value = null
try {
const response = await fetch(`${API_BASE}/api/schedules`, {
method: 'POST',
credentials: 'include',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
})
const responseBody: unknown = await response.json()
if (response.status !== 201) {
const parsedError = errorResponseSchema.safeParse(responseBody)
createError.value = parsedError.success ? parsedError.data.error : CREATE_ERROR
return null
}
const createdSchedule = scheduleResponseSchema.parse(responseBody).schedule
activeSchedule.value = createdSchedule
detailError.value = null
detailNotFound.value = false
return createdSchedule
} catch {
createError.value = CREATE_ERROR
return null
} finally {
creating.value = false
}
}
async function fetchAssignmentsForDate(date: string): Promise<boolean> {
const requestId = ++assignmentsRequestId
assignmentsForDate.value = []
assignmentsLoading.value = true
assignmentsError.value = null
try {
const query = new URLSearchParams({ date })
const response = await fetch(`${API_BASE}/api/assignments?${query.toString()}`, {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
if (requestId !== assignmentsRequestId) {
return false
}
if (response.status !== 200) {
assignmentsError.value = ASSIGNMENTS_ERROR
return false
}
const responseBody: unknown = await response.json()
if (requestId !== assignmentsRequestId) {
return false
}
const parsedResponse = assignmentsForDateResponseSchema.parse(responseBody)
if (parsedResponse.date !== date) {
throw new Error('assignment response did not match requested date')
}
assignmentsForDate.value = parsedResponse.assignments
return true
} catch {
if (requestId === assignmentsRequestId) {
assignmentsForDate.value = []
assignmentsError.value = ASSIGNMENTS_ERROR
}
return false
} finally {
if (requestId === assignmentsRequestId) {
assignmentsLoading.value = false
}
}
}
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,
listError,
activeSchedule,
detailLoading,
detailError,
detailNotFound,
creating,
createError,
assignmentsForDate,
assignmentsLoading,
assignmentsError,
assignmentCompletionPendingIds,
assignmentCompletionErrors,
fetchSchedules,
fetchSchedule,
createSchedule,
fetchAssignmentsForDate,
setAssignmentCompleted,
isAssignmentCompletionPending,
assignmentCompletionError,
}
})