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 export type ScheduleDetail = z.infer export type AssignmentForDate = z.infer export type WorkloadPlacement = z.infer 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([]) const listLoading = ref(false) const listError = ref(null) const activeSchedule = ref(null) const detailLoading = ref(false) const detailError = ref(null) const detailNotFound = ref(false) const creating = ref(false) const createError = ref(null) const assignmentsForDate = ref([]) const assignmentsLoading = ref(false) const assignmentsError = ref(null) const assignmentCompletionPendingIds = ref([]) const assignmentCompletionErrors = ref>({}) let activeDetailRequestId = 0 let assignmentsRequestId = 0 async function fetchSchedules(): Promise { 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 { 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 { 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 { 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 { 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, } })