From 7e9e93f8d0ceca31757c7f9cdd256d6024c8c86b Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 10 Aug 2026 20:16:44 +0300 Subject: [PATCH] add set scheduling ui --- .../website/cypress/e2e/set-scheduling.cy.ts | 9 +- frontend/website/src/router/index.ts | 16 + frontend/website/src/stores/schedules.ts | 220 ++++++++++ frontend/website/src/styles/main.css | 3 +- .../website/src/views/CreateScheduleView.vue | 405 ++++++++++++++++++ frontend/website/src/views/DashboardView.vue | 181 +++++++- .../website/src/views/ScheduleDetailView.vue | 323 ++++++++++++++ frontend/website/src/views/SetLayoutView.vue | 33 ++ 8 files changed, 1182 insertions(+), 8 deletions(-) create mode 100644 frontend/website/src/stores/schedules.ts create mode 100644 frontend/website/src/views/CreateScheduleView.vue create mode 100644 frontend/website/src/views/ScheduleDetailView.vue diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index 7967845..824024d 100644 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -120,12 +120,9 @@ describe('set scheduling', () => { cy.location('pathname').should('equal', '/sets/41/schedules/new') cy.get('h1').should('have.text', 'Schedule Bible') cy.get('#schedule-level option').then(($options) => { - expect([...$options].map((option) => option.textContent)).to.deep.equal([ - 'Choose a level', - 'Book (2)', - 'Portion (1)', - 'Chapter (2)', - ]) + expect([...$options].map((option) => option.textContent?.trim())).to.deep.equal( + ['Choose a level', 'Book (2)', 'Portion (1)', 'Chapter (2)'], + ) }) cy.get('#schedule-level').select('chapter') cy.get('#schedule-start-date').type('2026-08-10') diff --git a/frontend/website/src/router/index.ts b/frontend/website/src/router/index.ts index 08f1ad0..d4fad22 100644 --- a/frontend/website/src/router/index.ts +++ b/frontend/website/src/router/index.ts @@ -71,6 +71,22 @@ const router = createRouter({ requiresAuth: true, }, }, + { + path: '/sets/:setId(\\d+)/schedules/new', + name: 'schedule-create', + component: () => import('@/views/CreateScheduleView.vue'), + meta: { + requiresAuth: true, + }, + }, + { + path: '/schedules/:scheduleId(\\d+)', + name: 'schedule-detail', + component: () => import('@/views/ScheduleDetailView.vue'), + meta: { + requiresAuth: true, + }, + }, ], }) diff --git a/frontend/website/src/stores/schedules.ts b/frontend/website/src/stores/schedules.ts new file mode 100644 index 0000000..79f27a1 --- /dev/null +++ b/frontend/website/src/stores/schedules.ts @@ -0,0 +1,220 @@ +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 scheduleSummarySchema = z.object({ + id: z.number().int().positive(), + set: z.object({ + id: z.number().int().positive(), + name: z.string().min(1), + }), + elementKind: z.string().min(1), + startDate: isoDateSchema, + targetDate: isoDateSchema, + assignmentCount: z.number().int().nonnegative(), +}) + +const scheduleAssignmentSchema = z.object({ + element: z.object({ + id: z.number().int().positive(), + name: z.string().min(1), + kind: z.string().min(1), + path: z.array(z.string().min(1)).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 errorResponseSchema = z.object({ + error: z.string().min(1), +}) + +export type ScheduleSummary = z.infer +export type ScheduleDetail = z.infer +export type CreateScheduleInput = { + setId: number + elementKind: string + startDate: string + targetDate: string +} + +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." + +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) + let activeDetailRequestId = 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 + } + } + + return { + schedules, + listLoading, + listError, + activeSchedule, + detailLoading, + detailError, + detailNotFound, + creating, + createError, + fetchSchedules, + fetchSchedule, + createSchedule, + } +}) diff --git a/frontend/website/src/styles/main.css b/frontend/website/src/styles/main.css index a121333..fa7661f 100644 --- a/frontend/website/src/styles/main.css +++ b/frontend/website/src/styles/main.css @@ -31,7 +31,8 @@ body { } button, -input { +input, +select { font: inherit; } diff --git a/frontend/website/src/views/CreateScheduleView.vue b/frontend/website/src/views/CreateScheduleView.vue new file mode 100644 index 0000000..84cc585 --- /dev/null +++ b/frontend/website/src/views/CreateScheduleView.vue @@ -0,0 +1,405 @@ + + + + + diff --git a/frontend/website/src/views/DashboardView.vue b/frontend/website/src/views/DashboardView.vue index 9c77bf4..a2ccaf5 100644 --- a/frontend/website/src/views/DashboardView.vue +++ b/frontend/website/src/views/DashboardView.vue @@ -3,20 +3,89 @@ import { storeToRefs } from 'pinia' import { onMounted } from 'vue' import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue' +import { useSchedulesStore } from '@/stores/schedules' import { useSetsStore } from '@/stores/sets' const setsStore = useSetsStore() +const schedulesStore = useSchedulesStore() const { sets, loading, error } = storeToRefs(setsStore) +const { schedules, listLoading, listError } = storeToRefs(schedulesStore) onMounted(async () => { - await setsStore.fetchSets() + await Promise.all([setsStore.fetchSets(), schedulesStore.fetchSchedules()]) }) + +function humanizeKind(kind: string): string { + const words = kind.replaceAll(/[_-]+/g, ' ') + + return words.charAt(0).toUpperCase() + words.slice(1) +} + +function formatDate(value: string): string { + return new Intl.DateTimeFormat('en', { + dateStyle: 'medium', + timeZone: 'UTC', + }).format(new Date(`${value}T00:00:00Z`)) +}