diff --git a/frontend/website/src/components/ScheduleRescheduleForm.vue b/frontend/website/src/components/ScheduleRescheduleForm.vue
new file mode 100644
index 0000000..e61e166
--- /dev/null
+++ b/frontend/website/src/components/ScheduleRescheduleForm.vue
@@ -0,0 +1,358 @@
+
+
+
+
+
+
+
diff --git a/frontend/website/src/stores/schedules.ts b/frontend/website/src/stores/schedules.ts
index c6c3fde..75d3c63 100644
--- a/frontend/website/src/stores/schedules.ts
+++ b/frontend/website/src/stores/schedules.ts
@@ -86,10 +86,16 @@ export type CreateScheduleInput = {
targetDate: string
workloadPlacement: WorkloadPlacement
}
+export type RescheduleScheduleInput = {
+ 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 RESCHEDULE_ERROR = "We couldn't reschedule this schedule."
const ASSIGNMENTS_ERROR = "We couldn't load today's assignments."
const COMPLETION_ERROR = "We couldn't update this assignment."
@@ -103,6 +109,8 @@ export const useSchedulesStore = defineStore('schedules', () => {
const detailNotFound = ref(false)
const creating = ref(false)
const createError = ref(null)
+ const rescheduling = ref(false)
+ const rescheduleError = ref(null)
const assignmentsForDate = ref([])
const assignmentsLoading = ref(false)
const assignmentsError = ref(null)
@@ -241,6 +249,56 @@ export const useSchedulesStore = defineStore('schedules', () => {
}
}
+ async function rescheduleSchedule(
+ scheduleId: number,
+ input: RescheduleScheduleInput,
+ ): Promise {
+ 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 {
const requestId = ++assignmentsRequestId
assignmentsForDate.value = []
@@ -381,6 +439,8 @@ export const useSchedulesStore = defineStore('schedules', () => {
detailNotFound,
creating,
createError,
+ rescheduling,
+ rescheduleError,
assignmentsForDate,
assignmentsLoading,
assignmentsError,
@@ -389,6 +449,8 @@ export const useSchedulesStore = defineStore('schedules', () => {
fetchSchedules,
fetchSchedule,
createSchedule,
+ rescheduleSchedule,
+ clearRescheduleError,
fetchAssignmentsForDate,
setAssignmentCompleted,
isAssignmentCompletionPending,
diff --git a/frontend/website/src/views/ScheduleDetailView.vue b/frontend/website/src/views/ScheduleDetailView.vue
index 5c72243..bf700ac 100644
--- a/frontend/website/src/views/ScheduleDetailView.vue
+++ b/frontend/website/src/views/ScheduleDetailView.vue
@@ -5,7 +5,12 @@ import { useRoute } from 'vue-router'
import AuthenticatedHeader from '@/components/AuthenticatedHeader.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]
@@ -18,9 +23,13 @@ const {
detailLoading,
detailError,
detailNotFound,
+ rescheduling,
+ rescheduleError,
} = storeToRefs(schedulesStore)
const currentScheduleId = ref(null)
const completionAnnouncement = ref('')
+const rescheduleAnnouncement = ref('')
+const rescheduleFormOpen = ref(false)
const todayDate = browserDate(new Date())
const remainingDays = computed(() => {
@@ -28,9 +37,12 @@ const remainingDays = computed(() => {
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 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 }] : []
})
@@ -50,6 +62,13 @@ const completedDays = computed(() => {
const remainingAssignmentCount = computed(() => assignmentCount(remainingDays.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(
() => route.params.scheduleId,
@@ -59,6 +78,9 @@ watch(
: scheduleIdParameter
const scheduleId = Number(rawScheduleId)
currentScheduleId.value = scheduleId
+ rescheduleFormOpen.value = false
+ rescheduleAnnouncement.value = ''
+ schedulesStore.clearRescheduleError()
if (activeSchedule.value?.id !== scheduleId) {
await schedulesStore.fetchSchedule(scheduleId)
@@ -92,6 +114,48 @@ function assignmentCount(days: ScheduleDay[]): number {
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 {
+ 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 {
completionAnnouncement.value = ''
const updated = await schedulesStore.setAssignmentCompleted(assignmentId, completed)
@@ -136,15 +200,47 @@ async function setAssignmentCompleted(assignmentId: number, completed: boolean):
{{ activeSchedule.assignmentCount }}
{{ activeSchedule.elementKind }}
{{ activeSchedule.assignmentCount === 1 ? 'assignment' : 'assignments' }} across
- {{ activeSchedule.days.length }}
- {{ activeSchedule.days.length === 1 ? 'day' : 'days' }}
+ {{ activeDayCount }}
+ {{ activeDayCount === 1 ? 'day' : 'days' }}
{{ formatDate(activeSchedule.startDate) }} to
{{ formatDate(activeSchedule.targetDate) }}
+
+ {{ rescheduleFormOpen ? 'Close rescheduling' : 'Reschedule remaining' }}
+
+
+ {{ rescheduleAnnouncement }}
+
+
+
+