add reschedule form

This commit is contained in:
Yisroel Baum 2026-08-19 22:57:43 +03:00
parent 3a34249e0e
commit db6b41ee42
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
3 changed files with 552 additions and 6 deletions

View file

@ -0,0 +1,358 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { z } from 'zod'
import {
workloadPlacementSchema,
type RescheduleScheduleInput,
type WorkloadPlacement,
} from '@/stores/schedules'
type RescheduleField = 'startDate' | 'targetDate'
type RescheduleFieldErrors = Partial<Record<RescheduleField, string>>
const props = defineProps<{
currentTargetDate: string
remainingAssignmentCount: number
serverError: string | null
submitting: boolean
today: string
}>()
const emit = defineEmits<{
cancel: []
submit: [input: RescheduleScheduleInput]
}>()
const form = reactive({
startDate: props.today,
targetDate: props.currentTargetDate < props.today ? props.today : props.currentTargetDate,
workloadPlacement: 'middle' as WorkloadPlacement,
})
const fieldErrors = ref<RescheduleFieldErrors>({})
const workloadPlacementOptions: Array<{
value: WorkloadPlacement
label: string
}> = [
{ value: 'start', label: 'At the start' },
{ value: 'middle', label: 'In the middle' },
{ value: 'end', label: 'At the end' },
]
const formSchema = z
.object({
startDate: z.string().min(1, 'Choose a start date.'),
targetDate: z.string().min(1, 'Choose a target date.'),
workloadPlacement: workloadPlacementSchema,
})
.superRefine((value, context) => {
if (value.startDate !== '' && value.targetDate !== '' && value.targetDate < value.startDate) {
context.addIssue({
code: 'custom',
path: ['targetDate'],
message: 'Target date cannot be before the start date.',
})
}
})
const showWorkloadPlacement = computed(() => {
if (form.startDate === '' || form.targetDate === '' || form.targetDate < form.startDate) {
return false
}
const dayCount = inclusiveDayCount(form.startDate, form.targetDate)
return (
dayCount > 0 &&
props.remainingAssignmentCount > dayCount &&
props.remainingAssignmentCount % dayCount !== 0
)
})
function inclusiveDayCount(startDate: string, targetDate: string): number {
const startTime = Date.parse(`${startDate}T00:00:00Z`)
const targetTime = Date.parse(`${targetDate}T00:00:00Z`)
if (Number.isNaN(startTime) || Number.isNaN(targetTime) || targetTime < startTime) {
return 0
}
const millisecondsPerDay = 24 * 60 * 60 * 1000
return Math.round((targetTime - startTime) / millisecondsPerDay) + 1
}
function submit(): void {
fieldErrors.value = {}
const result = formSchema.safeParse(form)
if (!result.success) {
const errors: RescheduleFieldErrors = {}
for (const issue of result.error.issues) {
const field = issue.path[0]
if ((field === 'startDate' || field === 'targetDate') && errors[field] === undefined) {
errors[field] = issue.message
}
}
fieldErrors.value = errors
return
}
emit('submit', result.data)
}
</script>
<template>
<section id="reschedule-panel" class="reschedule-panel" aria-labelledby="reschedule-heading">
<header>
<p class="reschedule-panel__eyebrow">Adjust your plan</p>
<h2 id="reschedule-heading">Reschedule remaining assignments</h2>
<p>
Completed work stays on its original date. Only the
{{ remainingAssignmentCount }} unfinished
{{ remainingAssignmentCount === 1 ? 'assignment' : 'assignments' }}
will move.
</p>
</header>
<form data-reschedule-form novalidate @submit.prevent="submit">
<div class="reschedule-date-fields">
<div class="reschedule-field">
<label for="reschedule-start-date">Start date</label>
<input
id="reschedule-start-date"
v-model="form.startDate"
type="date"
:aria-invalid="fieldErrors.startDate !== undefined"
:aria-describedby="fieldErrors.startDate ? 'reschedule-start-date-error' : undefined"
:disabled="submitting"
/>
<p v-if="fieldErrors.startDate" id="reschedule-start-date-error" class="field-error">
{{ fieldErrors.startDate }}
</p>
</div>
<div class="reschedule-field">
<label for="reschedule-target-date">Target date</label>
<input
id="reschedule-target-date"
v-model="form.targetDate"
type="date"
:aria-invalid="fieldErrors.targetDate !== undefined"
:aria-describedby="fieldErrors.targetDate ? 'reschedule-target-date-error' : undefined"
:disabled="submitting"
/>
<p v-if="fieldErrors.targetDate" id="reschedule-target-date-error" class="field-error">
{{ fieldErrors.targetDate }}
</p>
</div>
</div>
<fieldset
v-if="showWorkloadPlacement"
class="workload-placement"
data-workload-placement
:disabled="submitting"
>
<legend>Heavier days</legend>
<p>Some days need one extra assignment. Choose where those days appear in the schedule.</p>
<div class="workload-placement__options">
<label
v-for="option in workloadPlacementOptions"
:key="option.value"
class="workload-placement__option"
>
<input
v-model="form.workloadPlacement"
type="radio"
name="rescheduleWorkloadPlacement"
:value="option.value"
/>
<span>{{ option.label }}</span>
</label>
</div>
</fieldset>
<p v-if="serverError !== null" class="form-error" role="alert">
{{ serverError }}
</p>
<div class="reschedule-actions">
<button type="submit" class="primary-button" :disabled="submitting">
{{ submitting ? 'Rescheduling...' : 'Reschedule assignments' }}
</button>
<button
type="button"
class="secondary-button"
:disabled="submitting"
@click="emit('cancel')"
>
Cancel
</button>
</div>
</form>
</section>
</template>
<style scoped>
.reschedule-panel {
display: grid;
gap: 1.5rem;
margin-top: 1.5rem;
padding: clamp(1.3rem, 4vw, 2rem);
border: 1px solid rgb(24 48 41 / 12%);
border-radius: 1rem;
background: rgb(255 253 247 / 82%);
box-shadow: 0 1rem 2.5rem rgb(40 62 52 / 8%);
}
.reschedule-panel__eyebrow {
margin: 0 0 0.45rem;
color: #926044;
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.reschedule-panel h2 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(1.75rem, 4vw, 2.5rem);
font-weight: 500;
letter-spacing: -0.035em;
}
.reschedule-panel header > p:last-child {
max-width: 44rem;
margin: 0.75rem 0 0;
color: #68776f;
line-height: 1.55;
}
.reschedule-panel form {
display: grid;
gap: 1.35rem;
}
.reschedule-date-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.reschedule-field {
display: grid;
gap: 0.55rem;
}
.reschedule-field label,
.workload-placement legend {
color: #344e45;
font-size: 0.78rem;
font-weight: 800;
}
.reschedule-field input {
min-width: 0;
min-height: 2.8rem;
padding: 0.55rem 0.7rem;
border: 1px solid rgb(24 48 41 / 22%);
border-radius: 0.65rem;
color: #183029;
background: #fffdf7;
font: inherit;
}
.field-error,
.form-error {
margin: 0;
color: #9b3f32;
font-size: 0.78rem;
font-weight: 700;
}
.workload-placement {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 1rem;
border: 1px solid rgb(24 48 41 / 12%);
border-radius: 0.8rem;
}
.workload-placement > p {
margin: 0;
color: #68776f;
font-size: 0.8rem;
line-height: 1.5;
}
.workload-placement__options {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.workload-placement__option {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 0.7rem;
border: 1px solid rgb(24 48 41 / 16%);
border-radius: 999px;
color: #344e45;
background: #f9f5e9;
font-size: 0.78rem;
font-weight: 750;
cursor: pointer;
}
.reschedule-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.primary-button,
.secondary-button {
min-height: 2.75rem;
padding: 0.65rem 1rem;
border-radius: 0.7rem;
font-size: 0.82rem;
font-weight: 800;
cursor: pointer;
}
.primary-button {
border: 1px solid #285c4e;
color: #fffdf7;
background: #285c4e;
}
.secondary-button {
border: 1px solid rgb(24 58 49 / 28%);
color: #183a31;
background: transparent;
}
.reschedule-field input:focus-visible,
.workload-placement__option input:focus-visible,
.primary-button:focus-visible,
.secondary-button:focus-visible {
outline: 3px solid rgb(86 127 112 / 34%);
outline-offset: 0.25rem;
}
.primary-button:disabled,
.secondary-button:disabled {
cursor: wait;
opacity: 0.65;
}
@media (max-width: 37.5rem) {
.reschedule-date-fields {
grid-template-columns: 1fr;
}
.reschedule-actions {
align-items: stretch;
flex-direction: column;
}
}
</style>

View file

@ -86,10 +86,16 @@ export type CreateScheduleInput = {
targetDate: string targetDate: string
workloadPlacement: WorkloadPlacement workloadPlacement: WorkloadPlacement
} }
export type RescheduleScheduleInput = {
startDate: string
targetDate: string
workloadPlacement: WorkloadPlacement
}
const LIST_ERROR = "We couldn't load your schedules." const LIST_ERROR = "We couldn't load your schedules."
const DETAIL_ERROR = "We couldn't load this schedule." const DETAIL_ERROR = "We couldn't load this schedule."
const CREATE_ERROR = "We couldn't create this schedule." const CREATE_ERROR = "We couldn't create this schedule."
const RESCHEDULE_ERROR = "We couldn't reschedule this schedule."
const ASSIGNMENTS_ERROR = "We couldn't load today's assignments." const ASSIGNMENTS_ERROR = "We couldn't load today's assignments."
const COMPLETION_ERROR = "We couldn't update this assignment." const COMPLETION_ERROR = "We couldn't update this assignment."
@ -103,6 +109,8 @@ export const useSchedulesStore = defineStore('schedules', () => {
const detailNotFound = ref(false) const detailNotFound = ref(false)
const creating = ref(false) const creating = ref(false)
const createError = ref<string | null>(null) const createError = ref<string | null>(null)
const rescheduling = ref(false)
const rescheduleError = ref<string | null>(null)
const assignmentsForDate = ref<AssignmentForDate[]>([]) const assignmentsForDate = ref<AssignmentForDate[]>([])
const assignmentsLoading = ref(false) const assignmentsLoading = ref(false)
const assignmentsError = ref<string | null>(null) const assignmentsError = ref<string | null>(null)
@ -241,6 +249,56 @@ export const useSchedulesStore = defineStore('schedules', () => {
} }
} }
async function rescheduleSchedule(
scheduleId: number,
input: RescheduleScheduleInput,
): Promise<ScheduleDetail | null> {
rescheduling.value = true
rescheduleError.value = null
try {
const response = await fetch(`${API_BASE}/api/schedules/${scheduleId}`, {
method: 'PATCH',
credentials: 'include',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
})
const responseBody: unknown = await response.json()
if (response.status !== 200) {
const parsedError = errorResponseSchema.safeParse(responseBody)
rescheduleError.value = parsedError.success ? parsedError.data.error : RESCHEDULE_ERROR
return null
}
const rescheduled = scheduleResponseSchema.parse(responseBody).schedule
if (rescheduled.id !== scheduleId) {
throw new Error('schedule response did not match requested schedule')
}
activeSchedule.value = rescheduled
const summary = scheduleSummarySchema.parse(rescheduled)
schedules.value = schedules.value.map((schedule) =>
schedule.id === scheduleId ? summary : schedule,
)
return rescheduled
} catch {
rescheduleError.value = RESCHEDULE_ERROR
return null
} finally {
rescheduling.value = false
}
}
function clearRescheduleError(): void {
rescheduleError.value = null
}
async function fetchAssignmentsForDate(date: string): Promise<boolean> { async function fetchAssignmentsForDate(date: string): Promise<boolean> {
const requestId = ++assignmentsRequestId const requestId = ++assignmentsRequestId
assignmentsForDate.value = [] assignmentsForDate.value = []
@ -381,6 +439,8 @@ export const useSchedulesStore = defineStore('schedules', () => {
detailNotFound, detailNotFound,
creating, creating,
createError, createError,
rescheduling,
rescheduleError,
assignmentsForDate, assignmentsForDate,
assignmentsLoading, assignmentsLoading,
assignmentsError, assignmentsError,
@ -389,6 +449,8 @@ export const useSchedulesStore = defineStore('schedules', () => {
fetchSchedules, fetchSchedules,
fetchSchedule, fetchSchedule,
createSchedule, createSchedule,
rescheduleSchedule,
clearRescheduleError,
fetchAssignmentsForDate, fetchAssignmentsForDate,
setAssignmentCompleted, setAssignmentCompleted,
isAssignmentCompletionPending, isAssignmentCompletionPending,

View file

@ -5,7 +5,12 @@ import { useRoute } from 'vue-router'
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue' import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
import ScheduleAssignmentTimeline from '@/components/ScheduleAssignmentTimeline.vue' import ScheduleAssignmentTimeline from '@/components/ScheduleAssignmentTimeline.vue'
import { useSchedulesStore, type ScheduleDetail } from '@/stores/schedules' import ScheduleRescheduleForm from '@/components/ScheduleRescheduleForm.vue'
import {
useSchedulesStore,
type RescheduleScheduleInput,
type ScheduleDetail,
} from '@/stores/schedules'
type ScheduleDay = ScheduleDetail['days'][number] type ScheduleDay = ScheduleDetail['days'][number]
@ -18,9 +23,13 @@ const {
detailLoading, detailLoading,
detailError, detailError,
detailNotFound, detailNotFound,
rescheduling,
rescheduleError,
} = storeToRefs(schedulesStore) } = storeToRefs(schedulesStore)
const currentScheduleId = ref<number | null>(null) const currentScheduleId = ref<number | null>(null)
const completionAnnouncement = ref('') const completionAnnouncement = ref('')
const rescheduleAnnouncement = ref('')
const rescheduleFormOpen = ref(false)
const todayDate = browserDate(new Date()) const todayDate = browserDate(new Date())
const remainingDays = computed<ScheduleDay[]>(() => { const remainingDays = computed<ScheduleDay[]>(() => {
@ -28,9 +37,12 @@ const remainingDays = computed<ScheduleDay[]>(() => {
return [] return []
} }
return activeSchedule.value.days.flatMap((day) => { const schedule = activeSchedule.value
return schedule.days.flatMap((day) => {
const assignments = day.assignments.filter((assignment) => assignment.completedAt === null) const assignments = day.assignments.filter((assignment) => assignment.completedAt === null)
const isRestDay = day.assignments.length === 0 const isActiveDate = day.date >= schedule.startDate && day.date <= schedule.targetDate
const isRestDay = isActiveDate && day.assignments.length === 0
return assignments.length > 0 || isRestDay ? [{ date: day.date, assignments }] : [] return assignments.length > 0 || isRestDay ? [{ date: day.date, assignments }] : []
}) })
@ -50,6 +62,13 @@ const completedDays = computed<ScheduleDay[]>(() => {
const remainingAssignmentCount = computed(() => assignmentCount(remainingDays.value)) const remainingAssignmentCount = computed(() => assignmentCount(remainingDays.value))
const completedAssignmentCount = computed(() => assignmentCount(completedDays.value)) const completedAssignmentCount = computed(() => assignmentCount(completedDays.value))
const activeDayCount = computed(() => {
if (activeSchedule.value === null) {
return 0
}
return inclusiveDayCount(activeSchedule.value.startDate, activeSchedule.value.targetDate)
})
watch( watch(
() => route.params.scheduleId, () => route.params.scheduleId,
@ -59,6 +78,9 @@ watch(
: scheduleIdParameter : scheduleIdParameter
const scheduleId = Number(rawScheduleId) const scheduleId = Number(rawScheduleId)
currentScheduleId.value = scheduleId currentScheduleId.value = scheduleId
rescheduleFormOpen.value = false
rescheduleAnnouncement.value = ''
schedulesStore.clearRescheduleError()
if (activeSchedule.value?.id !== scheduleId) { if (activeSchedule.value?.id !== scheduleId) {
await schedulesStore.fetchSchedule(scheduleId) await schedulesStore.fetchSchedule(scheduleId)
@ -92,6 +114,48 @@ function assignmentCount(days: ScheduleDay[]): number {
return days.reduce((count, day) => count + day.assignments.length, 0) return days.reduce((count, day) => count + day.assignments.length, 0)
} }
function inclusiveDayCount(startDate: string, targetDate: string): number {
const startTime = Date.parse(`${startDate}T00:00:00Z`)
const targetTime = Date.parse(`${targetDate}T00:00:00Z`)
if (Number.isNaN(startTime) || Number.isNaN(targetTime) || targetTime < startTime) {
return 0
}
const millisecondsPerDay = 24 * 60 * 60 * 1000
return Math.round((targetTime - startTime) / millisecondsPerDay) + 1
}
function openRescheduleForm(): void {
if (activeSchedule.value === null) {
return
}
rescheduleAnnouncement.value = ''
schedulesStore.clearRescheduleError()
rescheduleFormOpen.value = true
}
function cancelReschedule(): void {
schedulesStore.clearRescheduleError()
rescheduleFormOpen.value = false
}
async function submitReschedule(input: RescheduleScheduleInput): Promise<void> {
if (currentScheduleId.value === null) {
return
}
const rescheduledAssignmentCount = remainingAssignmentCount.value
const schedule = await schedulesStore.rescheduleSchedule(currentScheduleId.value, input)
if (schedule !== null) {
rescheduleFormOpen.value = false
rescheduleAnnouncement.value = `${rescheduledAssignmentCount} remaining ${
rescheduledAssignmentCount === 1 ? 'assignment' : 'assignments'
} rescheduled.`
}
}
async function setAssignmentCompleted(assignmentId: number, completed: boolean): Promise<void> { async function setAssignmentCompleted(assignmentId: number, completed: boolean): Promise<void> {
completionAnnouncement.value = '' completionAnnouncement.value = ''
const updated = await schedulesStore.setAssignmentCompleted(assignmentId, completed) const updated = await schedulesStore.setAssignmentCompleted(assignmentId, completed)
@ -136,15 +200,47 @@ async function setAssignmentCompleted(assignmentId: number, completed: boolean):
{{ activeSchedule.assignmentCount }} {{ activeSchedule.assignmentCount }}
{{ activeSchedule.elementKind }} {{ activeSchedule.elementKind }}
{{ activeSchedule.assignmentCount === 1 ? 'assignment' : 'assignments' }} across {{ activeSchedule.assignmentCount === 1 ? 'assignment' : 'assignments' }} across
{{ activeSchedule.days.length }} {{ activeDayCount }}
{{ activeSchedule.days.length === 1 ? 'day' : 'days' }} {{ activeDayCount === 1 ? 'day' : 'days' }}
</p> </p>
<p class="schedule-range"> <p class="schedule-range">
{{ formatDate(activeSchedule.startDate) }} to {{ formatDate(activeSchedule.startDate) }} to
{{ formatDate(activeSchedule.targetDate) }} {{ formatDate(activeSchedule.targetDate) }}
</p> </p>
<button
v-if="remainingAssignmentCount > 0"
type="button"
class="reschedule-toggle"
data-reschedule-toggle
:aria-expanded="rescheduleFormOpen"
aria-controls="reschedule-panel"
:disabled="rescheduling"
@click="rescheduleFormOpen ? cancelReschedule() : openRescheduleForm()"
>
{{ rescheduleFormOpen ? 'Close rescheduling' : 'Reschedule remaining' }}
</button>
</header> </header>
<p
class="reschedule-announcement"
role="status"
aria-live="polite"
data-reschedule-announcement
>
{{ rescheduleAnnouncement }}
</p>
<ScheduleRescheduleForm
v-if="rescheduleFormOpen"
:current-target-date="activeSchedule.targetDate"
:remaining-assignment-count="remainingAssignmentCount"
:server-error="rescheduleError"
:submitting="rescheduling"
:today="todayDate"
@cancel="cancelReschedule"
@submit="submitReschedule"
/>
<p <p
class="completion-announcement" class="completion-announcement"
role="status" role="status"
@ -252,7 +348,8 @@ async function setAssignmentCompleted(assignmentId: number, completed: boolean):
} }
.back-link:focus-visible, .back-link:focus-visible,
.retry-button:focus-visible { .retry-button:focus-visible,
.reschedule-toggle:focus-visible {
outline: 3px solid rgb(86 127 112 / 34%); outline: 3px solid rgb(86 127 112 / 34%);
outline-offset: 0.25rem; outline-offset: 0.25rem;
} }
@ -294,6 +391,35 @@ h1 {
color: #68776f; color: #68776f;
} }
.reschedule-toggle {
min-height: 2.65rem;
margin-top: 1.25rem;
padding: 0.65rem 1rem;
border: 1px solid rgb(24 58 49 / 28%);
border-radius: 0.7rem;
color: #183a31;
background: #fffdf7;
font-size: 0.82rem;
font-weight: 750;
cursor: pointer;
}
.reschedule-toggle:disabled {
cursor: wait;
opacity: 0.65;
}
.reschedule-announcement:empty {
display: none;
}
.reschedule-announcement {
margin: 1rem 0 0;
color: #285c4e;
font-size: 0.85rem;
font-weight: 750;
}
.completion-announcement { .completion-announcement {
position: absolute; position: absolute;
width: 1px; width: 1px;