ปฏิทินในหน้าแอดมินของ CMS: เขตเวลาที่ไม่ต้องลง plugin, ฉบับร่าง และทางลากด้วยคีย์บอร์ด
เล่าการเพิ่มปฏิทินเนื้อหาให้ CMS ที่ฝังในแอป Node.js ตั้งแต่การเลือก FullCalendar 6, การแสดงทุกวันที่ตามเขตเวลาของเว็บโดยไม่ลง plugin, การลากที่ไม่ข้ามขั้นตอนฉบับร่าง, Undo และทางเลือกแทนการลากสำหรับคนใช้คีย์บอร์ด
บรรณาธิการของเว็บข่าวแห่งหนึ่งถามผมง่ายๆ ว่า "สัปดาห์นี้มีอะไรจะขึ้นบ้าง" หน้าแอดมินของ Easy CMS ซึ่งเป็น CMS ที่รันอยู่ในแอป Nuxt หรือ Next.js ของคุณ ตอบคำถามนี้ไม่ได้ หน้ารายการเรียงตามวันที่ได้ก็จริง แต่รายการที่เรียงแล้วไม่ได้บอกว่าวันพฤหัสบดีว่าง ส่วนวันศุกร์มีงานเปิดตัวสี่ชิ้นตอน 09:00
ผมจึงเพิ่มปฏิทิน: โหมดหนึ่งของหน้ารายการทุก collection ที่มีวันที่ หน้าปฏิทินในเมนูที่เห็นทั้งเว็บ และการลากเพื่อเปลี่ยนวัน บทความนี้เล่าสี่เรื่องที่ต้องคิดนานที่สุด: จะใช้ไลบรารีอะไร, จะแสดงวันที่ตามเขตเวลาของเว็บโดยไม่ลง plugin ได้อย่างไร, การลากควรทำอะไรกับเอกสารที่มีฉบับร่างยังไม่เผยแพร่ และทำอย่างไรให้คนที่ไม่ได้ใช้เมาส์ทำสิ่งเดียวกันได้
เปิดใช้
collection ระบุ field วันที่ของตัวเอง:
{
slug: 'events',
admin: {
calendar: { start: 'startsAt', end: 'endsAt', color: 'category' },
},
fields: [/* … */],
}end กับ color ไม่ใส่ก็ได้ color เป็น field แบบ select หรือ relationship และแต่ละค่าได้สีของตัวเอง CMS ตรวจชื่อเหล่านี้ตอนเริ่มทำงาน: start และ end ต้องเป็น field วันที่ระดับบนสุด และ color ต้องไม่ใช่ hasMany ผมไม่เดาจากชื่อ field เพราะปฏิทินของ createdAt มีแต่ข้อมูลรก collection ที่ไม่ได้ตั้ง admin.calendar จึงไม่มีปฏิทิน
หน้ารายการจะได้ปุ่ม รายการ | ปฏิทิน มีมุมมองเดือน สัปดาห์ วัน และกำหนดการ มุมมองและวันที่เก็บอยู่ใน URL (?calendar=week&date=2026-10-26) ส่งลิงก์ไปแล้วเปิดเห็นปฏิทินเดียวกัน ตัวกรองของหน้ารายการก็ใช้ได้ "workshop ในเดือนพฤศจิกายน" จึงเป็นแค่ตัวกรองหนึ่งตัวกับการเลือกเดือน
เลือกไลบรารี: FullCalendar 6 ไม่ใช่ 7
ผมไม่อยากเขียนตารางปฏิทินเอง มุมมองสัปดาห์ที่มีงานซ้อนกัน มุมสำหรับลาก และ "+3 รายการ" เมื่อช่องเต็ม แต่ละอย่างเป็นงานใหญ่ได้เอง plugin แบบ MIT ของ FullCalendar มีมุมมองเดือน สัปดาห์ วัน รายการ และการลากครบ
FullCalendar 7 เพิ่งออกเมื่อไม่กี่สัปดาห์ก่อน รุ่นนี้รวม plugin ไปไว้ในแพ็กเกจ headless และต้องใช้ polyfill ของ Temporal เป็นการเปลี่ยนที่ใหญ่เกินกว่าที่อยากรับในช่วงที่ API ยังไม่นิ่ง ผมจึงใช้รุ่น 6
ก่อนสร้างจริง ผมใช้เวลาหนึ่งชั่วโมงทำหน้าทดลองทิ้งไว้ เพื่อเช็กสามเรื่อง:
ภาษาไทย locale
thให้ชื่อเดือนไทยและปีพุทธศักราช ("ตุลาคม 2569") แต่เริ่มสัปดาห์ที่วันจันทร์ ซึ่งปฏิทินไทยไม่ทำ ผมจึงตั้งfirstDay: 0สำหรับภาษาไทยคีย์บอร์ด ปุ่ม ตัวเลขวัน และ event โฟกัสได้ และกด Enter เปิด event ได้ แต่ลากด้วยคีย์บอร์ดไม่ได้ และเลือกช่องว่างไม่ได้ ซึ่งจะเล่าต่อด้านล่าง
โปรแกรมอ่านหน้าจอ ตารางมี
role="grid"แต่ตัวเลขวันถูกอ่านว่า "28" ไม่ใช่วันที่ ส่วนตัวเลือกnavLinkHintและeventHintของ FullCalendar สร้างเป็นtitleไม่ใช่ชื่อที่โปรแกรมอ่านหน้าจออ่าน
ข้อสุดท้ายแก้ได้ด้วย hook สองตัว:
dayCellDidMount: (arg) => {
const link = arg.el.querySelector('.fc-daygrid-day-number')
link?.setAttribute('aria-label', fullDate(arg.date)) // "วันพฤหัสบดีที่ 15 ตุลาคม พ.ศ. 2569"
link?.setAttribute('role', 'button') // กดแล้วเลือกวัน; <a> ที่ไม่มี href ไม่นับเป็นลิงก์
},
eventDidMount: (arg) => {
arg.el.setAttribute('aria-label', `${arg.event.title}, ${fullDate(arg.event.start, true)}`)
},บรรทัด role มาจาก end-to-end test ที่ไม่ผ่าน ไม่ได้มาจากการอ่านเอกสาร getByRole('link', { name: 'Wednesday, 15 May 2030' }) ของ Playwright หาอะไรไม่เจอ เพราะ FullCalendar ใช้ <a> ที่ไม่มี href สำหรับตัวเลขวัน และ anchor ที่ไม่มี href ไม่มี role โปรแกรมอ่านหน้าจอจึงไม่บอกว่ามันกดได้
ปฏิทินเป็น JavaScript ราว 270 KB (80 KB เมื่อบีบอัด) โหลดด้วย defineAsyncComponent ครั้งแรกที่มีคนเปิดปฏิทิน คนที่ไม่เคยเปิดจึงไม่ต้องดาวน์โหลด
เขตเวลาที่ไม่ต้องลง plugin
เขตเวลาของเว็บตั้งไว้ครั้งเดียวใน config (timeZone: 'Asia/Bangkok') บรรณาธิการที่อยู่ลอนดอนแล้วตั้งเวลาโพสต์ไว้ 09:00 หมายถึง 09:00 ที่กรุงเทพฯ ถ้าแต่ละเบราว์เซอร์แสดงตามเขตของตัวเอง บรรณาธิการสองคนจะเห็นเวลาของการเผยแพร่ครั้งเดียวกันไม่ตรงกัน
FullCalendar รองรับเขตเวลาแบบมีชื่อผ่าน plugin เท่านั้น (Luxon หรือ Moment) ถ้าไม่มี plugin มันเข้าใจแค่ local กับ UTC ผมไม่อยากเพิ่มไลบรารีวันที่แค่เพื่อเรื่องนี้
วิธีคือ: บอก FullCalendar ว่าเขตเวลาของมันคือ UTC แล้วส่งเวลาตามนาฬิกาของเว็บเข้าไป 08:00 ของวันที่ 30 ตุลาคมที่กรุงเทพฯ ส่งไปเป็น 2026-10-30T08:00 แล้ว FullCalendar วาดไว้ที่ 08:00 วันที่ 30 เมื่อมีอะไรกลับมาจาก FullCalendar (การวาง หรือช่วงเวลาที่เลือก) ก็แปลงเวลาตามนาฬิกานั้นกลับเป็นเวลาจริง

ทั้งสองทางต้องรู้เรื่องเดียว: ณ เวลานั้น เขตเวลานี้เร็วกว่า UTC กี่นาที Intl.DateTimeFormat รู้ค่านี้สำหรับทุกเขตเวลาของ IANA:
/** เขตเวลา `timeZone` เร็วกว่า UTC กี่นาที ณ เวลา `ms` */
function offsetAt(ms: number, timeZone: string): number {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone, hourCycle: 'h23',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
}).formatToParts(new Date(ms))
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value)
const asUTC = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'))
return Math.round((asUTC - Math.floor(ms / 1000) * 1000) / 60_000)
}
/** เวลาจริง → `YYYY-MM-DDTHH:mm` ตามนาฬิกาของเขตนั้น */
const toZoned = (iso: string, timeZone: string) => {
const ms = Date.parse(iso)
return new Date(ms + offsetAt(ms, timeZone) * 60_000).toISOString().slice(0, 16)
}
/** `YYYY-MM-DDTHH:mm` ตามนาฬิกาของเขตนั้น → เวลาจริง */
function fromZoned(local: string, timeZone: string): string {
const wall = Date.parse(`${local}:00Z`)
let ms = wall - offsetAt(wall, timeZone) * 60_000
// ข้ามช่วงเปลี่ยนเวลาฤดูร้อน: คำนวณอีกครั้งด้วย offset ของเวลานั้น
ms = wall - offsetAt(ms, timeZone) * 60_000
return new Date(ms).toISOString()
}รอบที่สองใน fromZoned สำคัญสำหรับเขตที่มีเวลาฤดูร้อน offset ตอนอ่านเวลาตามนาฬิกาเป็น UTC อาจไม่เท่ากับ offset ของเวลาจริง unit test ตรึงกรณีนี้ไว้ด้วยเวลาของนิวยอร์กช่วงต้นพฤศจิกายน
อีกสองเรื่อง "วันนี้" และเส้นเวลาปัจจุบันสีแดงของ FullCalendar ใช้ now ซึ่งจะเป็นเที่ยงคืนตาม UTC ผมจึงส่ง now: () => toZoned(new Date().toISOString(), zone) ไปด้วย และสองฟังก์ชันนี้ตอนนี้ใช้กับช่องกรอกวันที่ทุกช่องในแอดมิน ไม่ใช่แค่ปฏิทิน คนที่เบราว์เซอร์อยู่คนละเขตจะเห็น "เขตเวลา: เวลาอินโดจีน (GMT+7)" ข้างช่องกรอกวันที่ ส่วนคนที่อยู่เขตเดียวกับเว็บไม่เห็นบรรทัดนี้
โหลดข้อมูลทีละช่วง
ปฏิทินขอเอกสารที่คาบเกี่ยวกับช่วงที่แสดงอยู่จาก REST API คือเอกสารที่เริ่มก่อนช่วงนั้นจบ และจบ (หรือเริ่ม ถ้าไม่มีวันจบ) หลังช่วงนั้นเริ่ม:
{ and: [
{ startsAt: { lt: to } },
{ or: [{ endsAt: { gte: from } }, { startsAt: { gte: from } }] },
] }ตัวกรองของหน้ารายการต่อด้วย and API ให้ได้ไม่เกิน 100 รายการต่อครั้ง ปฏิทินจึงโหลดหน้าแรกก่อน แล้วโหลดหน้าที่เหลือพร้อมกัน สูงสุด 500 รายการ ถ้าเกินจะบอกว่า "ช่วงนี้มีเกิน 500 รายการ กรองเพิ่มเพื่อดูส่วนที่เหลือ" เดือนที่มีเกิน 500 รายการอ่านไม่รู้เรื่องอยู่แล้ว
มีกับดักหนึ่งที่เจอตอนทดสอบ ถ้าเปิดทั้ง drafts และ versions ?draft=true จะเทียบตัวกรองกับตัวที่เผยแพร่อยู่ แต่ส่งฉบับร่างที่ค้างกลับมา ถ้าย้ายวันของโพสต์ที่เผยแพร่แล้วในฉบับร่าง query จะเจอโพสต์นั้นจากวันเดิม ขณะที่เอกสารบอกวันใหม่ ตอนนี้ปฏิทินตัดเอกสารที่วันที่แสดงอยู่นอกช่วงทิ้ง ส่วนการแก้ query เองอยู่ใน issue #300
การลากควรทำอะไรกับฉบับร่าง
การลาก event คือการบันทึก คำถามคือบันทึกแบบไหน
Easy CMS เก็บฉบับร่างของเอกสารที่เผยแพร่แล้วแยกไว้ การแก้ไขไปลงที่ฉบับร่างที่ค้างอยู่ และหน้าเว็บยังแสดงตัวที่เผยแพร่จนกว่าจะมีคนกดเผยแพร่ Local API มีตัวเลือก live ที่แก้ตัวที่เผยแพร่อยู่ได้โดยตรง การลากจึงใช้ตัวเลือกนี้ได้
ผมตัดสินใจไม่ใช้ ทีมที่เปิด drafts ต้องการให้ทุกการเปลี่ยนแปลงผ่านการเผยแพร่ การลากในปฏิทินที่ข้ามขั้นตอนนั้นคือทางลัดที่ไม่มีใครขอ การลากจึงบันทึกเหมือนกด บันทึก ในหน้าแก้ไขทุกอย่าง ถ้ามีฉบับร่างค้าง วันที่จะย้ายในฉบับร่าง และข้อความบอกตรงๆ ว่า "ย้ายในฉบับร่างแล้ว หน้าเว็บจะเปลี่ยนเมื่อเผยแพร่"
การแยกสองกรณีนี้ต้องใช้อีกหนึ่ง request ปฏิทินอ่านฉบับร่าง เอกสารที่มีฉบับร่างค้างจึงดูเหมือนฉบับร่าง เมื่อการบันทึกได้ฉบับร่างกลับมา ผมจึงถามหาตัวที่เผยแพร่อยู่:
const saved = await api('PATCH', `${path}?depth=0`, body)
if (saved.status !== 'draft') return false
// ฉบับร่างที่ค้างของเอกสารที่เผยแพร่อยู่ หรือเอกสารที่ไม่เคยเผยแพร่?
const live = await api('GET', `${path}?depth=0`).catch(() => null)
return live?.status === 'published'การตั้งเวลาเผยแพร่ก็ขึ้นในหน้าปฏิทินด้วย เป็นกรอบเส้นประ การลากมันคือการตั้งเวลาใหม่ ซึ่งแทนงานเดิมที่รออยู่ และต้องมีสิทธิ์เผยแพร่ ไม่ใช่แค่สิทธิ์แก้ไข
ทุกการย้ายมีข้อความพร้อมปุ่ม เลิกทำ อยู่ 8 วินาที การเลิกทำคือการบันทึกค่าเดิมอีกครั้ง ถ้า server ไม่รับการบันทึก เช่นติดกฎตรวจข้อมูลหรือกฎสิทธิ์ event จะเด้งกลับที่เดิมพร้อมเหตุผลจาก server
ลากโดยไม่ใช้เมาส์
WCAG 2.2 กำหนดให้ทุกอย่างที่ลากได้ต้องทำได้ด้วยการคลิกครั้งเดียวและด้วยคีย์บอร์ด FullCalendar ลากด้วยคีย์บอร์ดไม่ได้ ปฏิทินจึงมีคอลัมน์ข้างที่แสดงเอกสารของวันที่เลือก คลิกวัน หรือกด Enter ที่ตัวเลขวัน เพื่อเลือกวันนั้น แต่ละรายการในคอลัมน์มีปุ่ม ย้ายไปวันที่… ที่เปิดช่องกรอกวันเวลา และคงระยะเวลาเดิมไว้
คอลัมน์เดียวกันนี้แก้อีกช่องโหว่จากหน้าทดลอง เลือกช่องว่างด้วยคีย์บอร์ดไม่ได้ การสร้างเอกสารจากวันที่จึงไม่มีทางทำด้วยคีย์บอร์ด ตอนนี้ปฏิทินของหน้ารายการมีปุ่ม สร้างในวันนี้ ที่เปิด drawer เอกสารใหม่พร้อมวันที่ การคลิกวันครั้งเดียวจึงเปลี่ยนเป็นการเลือกวัน ส่วนการลากผ่านช่วงเวลาในมุมมองสัปดาห์ยังใช้สร้างเอกสารได้ ด้วย selectMinDistance: 5 ของ FullCalendar
สถานะที่บอกว่ามีอะไรรออยู่
ปฏิทินทำให้เห็นปัญหาเก่าอีกเรื่อง ฉบับร่างที่ตั้งเวลาเผยแพร่ไว้ขึ้นว่า "ฉบับร่าง" ทั้งที่หัวหน้าแก้ไขและในหน้ารายการ ข้อมูลการตั้งเวลาอยู่แค่ในการ์ดที่อยู่ล่างหน้าแก้ไข ตอนนี้สถานะบอกสิ่งที่รออยู่: ตั้งเวลาแล้ว · 1 พ.ย. 09:00 สำหรับฉบับร่างที่รอขึ้นเว็บ และข้าง เผยแพร่แล้ว จะมี มีการแก้ไขที่ยังไม่เผยแพร่, จะเผยแพร่ · วันที่ หรือ จะเลิกเผยแพร่ · วันที่ ในหน้าแก้ไข วันที่เป็นปุ่มที่เปิดหน้าต่างตั้งเวลา และเมนูสถานะของหน้ารายการมี ตั้งเวลาแล้ว
สิ่งที่อยากบอกตัวเองตอนเริ่ม
ลองไลบรารีกับข้อกำหนดที่ยากที่สุดก่อน สำหรับผมคือโปรแกรมอ่านหน้าจอกับภาษาไทย หนึ่งชั่วโมงที่ใช้ไปเจอสองเรื่องที่ต้องแก้ ก่อนเขียนโค้ดจริงสักบรรทัด
เขียน end-to-end test ด้วย role ไม่ใช่ CSS selector เรื่อง
role="button"และจุลภาคที่ Chromium ใส่ในวันที่ ("Wednesday, 15 May") โผล่มาเพราะ test ถามแบบเดียวกับที่โปรแกรมอ่านหน้าจอถามตัดสินใจว่าทางลัดจะทำอะไรกับกลไกป้องกันที่มีอยู่ การลากคือทางลัด จะให้มันข้ามฉบับร่างได้หรือไม่เป็นการตัดสินใจเรื่องผลิตภัณฑ์ ไม่ใช่รายละเอียดของโค้ด
ปฏิทินออกใน Easy CMS 1.22 และ 1.23 คู่มือแอดมิน มีวิธีตั้งค่า และ ADR-0064 มีเหตุผลเบื้องหลัง ถ้าลองใช้กับปฏิทินเนื้อหาจริงแล้วขาดอะไร บอกได้ใน กระทู้ feedback