# ปฏิทินในหน้าแอดมินของ CMS: เขตเวลาที่ไม่ต้องลง plugin, ฉบับร่าง และทางลากด้วยคีย์บอร์ด

> เล่าการเพิ่มปฏิทินเนื้อหาให้ CMS ที่ฝังในแอป Node.js ตั้งแต่การเลือก FullCalendar 6, การแสดงทุกวันที่ตามเขตเวลาของเว็บโดยไม่ลง plugin, การลากที่ไม่ข้ามขั้นตอนฉบับร่าง, Undo และทางเลือกแทนการลากสำหรับคนใช้คีย์บอร์ด

URL: https://easycms.dev/th/blog/admin-calendar-th  
Published: 2027-02-08  
Updated: 2026-11-04

บรรณาธิการของเว็บข่าวแห่งหนึ่งถามผมง่ายๆ ว่า "สัปดาห์นี้มีอะไรจะขึ้นบ้าง" หน้าแอดมินของ [Easy CMS](https://github.com/maritonx/easy-cms) ซึ่งเป็น CMS ที่รันอยู่ในแอป Nuxt หรือ Next.js ของคุณ ตอบคำถามนี้ไม่ได้ หน้ารายการเรียงตามวันที่ได้ก็จริง แต่รายการที่เรียงแล้วไม่ได้บอกว่าวันพฤหัสบดีว่าง ส่วนวันศุกร์มีงานเปิดตัวสี่ชิ้นตอน 09:00

ผมจึงเพิ่มปฏิทิน: โหมดหนึ่งของหน้ารายการทุก collection ที่มีวันที่ หน้าปฏิทินในเมนูที่เห็นทั้งเว็บ และการลากเพื่อเปลี่ยนวัน บทความนี้เล่าสี่เรื่องที่ต้องคิดนานที่สุด: จะใช้ไลบรารีอะไร, จะแสดงวันที่ตามเขตเวลาของเว็บโดยไม่ลง plugin ได้อย่างไร, การลากควรทำอะไรกับเอกสารที่มีฉบับร่างยังไม่เผยแพร่ และทำอย่างไรให้คนที่ไม่ได้ใช้เมาส์ทำสิ่งเดียวกันได้

## เปิดใช้

collection ระบุ field วันที่ของตัวเอง:

```ts
{
  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 สองตัว:

```ts
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 (การวาง หรือช่วงเวลาที่เลือก) ก็แปลงเวลาตามนาฬิกานั้นกลับเป็นเวลาจริง

![เก็บ 2026-10-30T17:30Z แสดงเป็นวันที่ 31 เวลา 00:30 ตามเวลากรุงเทพฯ และแปลงการวางที่ 31 ต.ค. 09:00 กลับเป็น 02:00Z](/images/blog/admin-calendar/diagram-1.th.png)

ทั้งสองทางต้องรู้เรื่องเดียว: ณ เวลานั้น เขตเวลานี้เร็วกว่า UTC กี่นาที `Intl.DateTimeFormat` รู้ค่านี้สำหรับทุกเขตเวลาของ IANA:

```ts
/** เขตเวลา `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 คือเอกสารที่เริ่มก่อนช่วงนั้นจบ และจบ (หรือเริ่ม ถ้าไม่มีวันจบ) หลังช่วงนั้นเริ่ม:

```ts
{ 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](https://github.com/maritonx/easy-cms/issues/300)

## การลากควรทำอะไรกับฉบับร่าง

การลาก event คือการบันทึก คำถามคือบันทึกแบบไหน

Easy CMS เก็บฉบับร่างของเอกสารที่เผยแพร่แล้วแยกไว้ การแก้ไขไปลงที่ฉบับร่างที่ค้างอยู่ และหน้าเว็บยังแสดงตัวที่เผยแพร่จนกว่าจะมีคนกดเผยแพร่ Local API มีตัวเลือก `live` ที่แก้ตัวที่เผยแพร่อยู่ได้โดยตรง การลากจึงใช้ตัวเลือกนี้ได้

ผมตัดสินใจไม่ใช้ ทีมที่เปิด drafts ต้องการให้ทุกการเปลี่ยนแปลงผ่านการเผยแพร่ การลากในปฏิทินที่ข้ามขั้นตอนนั้นคือทางลัดที่ไม่มีใครขอ การลากจึงบันทึกเหมือนกด **บันทึก** ในหน้าแก้ไขทุกอย่าง ถ้ามีฉบับร่างค้าง วันที่จะย้ายในฉบับร่าง และข้อความบอกตรงๆ ว่า "ย้ายในฉบับร่างแล้ว หน้าเว็บจะเปลี่ยนเมื่อเผยแพร่"

การแยกสองกรณีนี้ต้องใช้อีกหนึ่ง request ปฏิทินอ่านฉบับร่าง เอกสารที่มีฉบับร่างค้างจึงดูเหมือนฉบับร่าง เมื่อการบันทึกได้ฉบับร่างกลับมา ผมจึงถามหาตัวที่เผยแพร่อยู่:

```ts
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 [คู่มือแอดมิน](/th/docs/admin#calendar) มีวิธีตั้งค่า และ [ADR-0064](https://github.com/maritonx/easy-cms/blob/main/docs/adr/0064-calendar-view.md) มีเหตุผลเบื้องหลัง ถ้าลองใช้กับปฏิทินเนื้อหาจริงแล้วขาดอะไร บอกได้ใน [กระทู้ feedback](https://github.com/maritonx/easy-cms/issues/137)
