397 lines
11 KiB
TypeScript
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,
|
|
}
|
|
})
|