due-time.spec.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import { expect, type Page, test } from '@playwright/test'
  2. import { deleteRemindersTitled, reminderDueDate } from '../../support/db'
  3. import { settle } from '../../support/hydration'
  4. import { setWorkshopClock } from '../../support/settings'
  5. import { seededVehicleUrl } from '../../support/work-order'
  6. /**
  7. * A reminder due at half past two is due at half past two tomorrow as well.
  8. *
  9. * The trap here is not the storing, it is the editing. A due date is handed
  10. * to the server as a wall clock ("2026-09-11T14:30") and read in the
  11. * workshop's zone, while the two fields that produce that string were filled
  12. * from whatever zone the browser happens to be in. Where the two agree, and
  13. * they do on every developer's laptop, nothing looks wrong. Where they do not,
  14. * the form opens on the wrong time and saving it moves the reminder, without
  15. * anyone touching the field.
  16. *
  17. * So the workshop is put somewhere far from the browser and one reminder is
  18. * followed through creation, display, the edit form, and a save that changes
  19. * nothing. The last of those is the assertion that matters: reopening and
  20. * saving a reminder has to leave it where it was.
  21. */
  22. test.describe.configure({ mode: 'serial' })
  23. const stamp = Date.now()
  24. const TITLE = `E2E reminder ${stamp}`
  25. /** Far from the harness's Europe/Oslo, and on the other side of a date line. */
  26. const FAR_AWAY = 'Pacific/Auckland'
  27. /** The wall clock the calendar slot stood for, as the form was seeded with it. */
  28. let due = ''
  29. /** The reminders list, hydrated. */
  30. async function openReminders(page: Page) {
  31. await page.goto('/reminders')
  32. await settle(page)
  33. }
  34. /**
  35. * The row for this spec's reminder: the innermost element holding both its
  36. * title and its own menu button, which is the row and not the whole list.
  37. */
  38. function reminderRow(page: Page) {
  39. return page
  40. .locator('div')
  41. .filter({ has: page.getByText(TITLE, { exact: true }) })
  42. .filter({ has: page.getByRole('button', { name: 'Open menu' }) })
  43. .last()
  44. }
  45. /** Opens the edit dialog from the row's own menu. */
  46. async function openEdit(page: Page) {
  47. const row = reminderRow(page)
  48. await expect(async () => {
  49. await row.getByRole('button', { name: 'Open menu' }).first().click()
  50. await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible({ timeout: 2_000 })
  51. }).toPass({ timeout: 30_000 })
  52. await page.getByRole('menuitem', { name: 'Edit' }).click()
  53. await expect(page.getByRole('dialog', { name: 'Edit Reminder' })).toBeVisible()
  54. }
  55. test.beforeAll(async ({ browser }) => {
  56. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  57. await setWorkshopClock(page, { timezone: FAR_AWAY, format: '24h' })
  58. await page.close()
  59. })
  60. test.afterAll(async ({ browser }) => {
  61. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  62. await setWorkshopClock(page, { timezone: '', format: '12h' })
  63. await page.close()
  64. })
  65. test.describe('a reminder due at a time of day', () => {
  66. test('is booked at the time the calendar slot named', async ({ page }) => {
  67. // A day nothing is booked on: a right-click that lands on a chip opens
  68. // that job's menu, not the empty slot's.
  69. const day = new Date(Date.now() + 45 * 86_400_000)
  70. const empty = `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`
  71. await page.goto(`/calendar?view=day&date=${empty}`)
  72. await settle(page)
  73. await expect(async () => {
  74. await page.keyboard.press('d')
  75. await expect(page.locator('[data-testid^="timegrid-day-"]').first()).toBeVisible({
  76. timeout: 2_000,
  77. })
  78. }).toPass({ timeout: 30_000 })
  79. const column = page.locator('[data-testid^="timegrid-day-"]').first()
  80. const box = await column.boundingBox()
  81. await column.click({
  82. button: 'right',
  83. position: { x: Math.min(40, box!.width / 2), y: box!.height * 0.45 },
  84. })
  85. await page.getByRole('menuitem', { name: 'New reminder' }).click()
  86. const dialog = page.getByRole('dialog', { name: 'Add Reminder' })
  87. await expect(dialog).toBeVisible({ timeout: 30_000 })
  88. // The slot the pointer was on, seeded into the form by the calendar. The
  89. // spec never picks the time itself: it reads back what the app offered.
  90. due = await dialog.locator('#reminder-time').inputValue()
  91. expect(due, 'the calendar seeded a time').toMatch(/^\d{2}:\d{2}$/)
  92. await dialog.locator('#reminder-title').fill(TITLE)
  93. await dialog.getByRole('button', { name: 'Add Reminder', exact: true }).click()
  94. await expect(page.getByText('Reminder created')).toBeVisible({ timeout: 30_000 })
  95. // What was stored is that wall clock in the workshop's zone, whatever the
  96. // browser's zone is. Read from the database, so the check does not lean
  97. // on the same formatting the page uses.
  98. const stored = await reminderDueDate(TITLE)
  99. const inWorkshop = new Intl.DateTimeFormat('en-GB', {
  100. timeZone: FAR_AWAY,
  101. hour: '2-digit',
  102. minute: '2-digit',
  103. hour12: false,
  104. }).format(stored)
  105. expect(inWorkshop, 'stored as the workshop reads it').toBe(due)
  106. })
  107. test('is listed at that time', async ({ page }) => {
  108. await openReminders(page)
  109. await expect(reminderRow(page).getByText(due)).toBeVisible()
  110. })
  111. test('opens on that time in the edit form', async ({ page }) => {
  112. await openReminders(page)
  113. await openEdit(page)
  114. // The field a person would look at before deciding whether to change
  115. // anything. Filled from the browser's clock, this read thirteen hours out.
  116. await expect(page.locator('#reminder-time')).toHaveValue(due)
  117. })
  118. test('and a save that changes nothing leaves it where it was', async ({ page }) => {
  119. const before = await reminderDueDate(TITLE)
  120. await openReminders(page)
  121. await openEdit(page)
  122. await page
  123. .getByRole('dialog', { name: 'Edit Reminder' })
  124. .getByRole('button', { name: 'Save Changes', exact: true })
  125. .click()
  126. await expect(page.getByText('Reminder updated')).toBeVisible({ timeout: 30_000 })
  127. expect((await reminderDueDate(TITLE)).getTime(), 'the reminder did not move').toBe(
  128. before.getTime()
  129. )
  130. await openReminders(page)
  131. await expect(reminderRow(page).getByText(due)).toBeVisible()
  132. })
  133. test('and is tidied away again', async ({ page }) => {
  134. // The calendar and the reminder list are read by other specs; this one
  135. // does not leave a reminder sitting in them.
  136. await openReminders(page)
  137. const row = reminderRow(page)
  138. await expect(async () => {
  139. await row.getByRole('button', { name: 'Open menu' }).first().click()
  140. await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible({ timeout: 2_000 })
  141. }).toPass({ timeout: 30_000 })
  142. await page.getByRole('menuitem', { name: 'Delete' }).click()
  143. await expect(page.getByText('Reminder deleted')).toBeVisible({ timeout: 30_000 })
  144. })
  145. })
  146. test.describe('the same reminder, from the vehicle it belongs to', () => {
  147. /**
  148. * A second form, on the vehicle's own reminders tab, filling the same two
  149. * fields from the same stored instant. It had the same defect, and one form
  150. * being right says nothing about the other.
  151. */
  152. const VEHICLE_TITLE = `E2E vehicle reminder ${stamp}`
  153. const AT = '15:45'
  154. test('is opened on the workshop clock there too', async ({ page }) => {
  155. const vehicleUrl = await seededVehicleUrl(page)
  156. await page.goto(`${vehicleUrl}?tab=reminders`)
  157. await settle(page)
  158. await expect(async () => {
  159. await page.getByRole('button', { name: 'Add Reminder' }).first().click()
  160. await expect(page.getByRole('dialog', { name: 'Add Reminder' })).toBeVisible({
  161. timeout: 2_000,
  162. })
  163. }).toPass({ timeout: 30_000 })
  164. const dialog = page.getByRole('dialog', { name: 'Add Reminder' })
  165. await dialog.locator('#reminder-title').fill(VEHICLE_TITLE)
  166. // A day far enough ahead that "overdue" cannot change what the row says.
  167. await dialog.locator('#reminder-dueDate').fill('2027-03-15')
  168. await dialog.locator('#reminder-dueTime').fill(AT)
  169. await dialog.getByRole('button', { name: 'Add Reminder', exact: true }).click()
  170. await expect(page.getByText('Reminder created')).toBeVisible({ timeout: 30_000 })
  171. // Typed as the workshop's clock, so that is the instant that was stored.
  172. const stored = await reminderDueDate(VEHICLE_TITLE)
  173. expect(
  174. new Intl.DateTimeFormat('en-GB', {
  175. timeZone: FAR_AWAY,
  176. hour: '2-digit',
  177. minute: '2-digit',
  178. hour12: false,
  179. }).format(stored)
  180. ).toBe(AT)
  181. // And reopening shows what was typed, rather than the same instant read
  182. // on the clock of whoever opened it.
  183. await page.reload()
  184. await settle(page)
  185. const row = page
  186. .locator('div')
  187. .filter({ has: page.getByText(VEHICLE_TITLE, { exact: true }) })
  188. .filter({ has: page.getByRole('button', { name: 'Open menu' }) })
  189. .last()
  190. await expect(async () => {
  191. await row.getByRole('button', { name: 'Open menu' }).first().click()
  192. await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible({ timeout: 2_000 })
  193. }).toPass({ timeout: 30_000 })
  194. await page.getByRole('menuitem', { name: 'Edit' }).click()
  195. const edit = page.getByRole('dialog', { name: 'Edit Reminder' })
  196. await expect(edit).toBeVisible()
  197. // Empty here is not a cosmetic problem: saving the form as it opens sends
  198. // "no time", which rewrites the reminder to midday and drops the hour the
  199. // workshop chose.
  200. await expect(edit.locator('#reminder-dueTime')).toHaveValue(AT)
  201. await expect(edit.locator('#reminder-dueDate')).toHaveValue(/15/)
  202. await edit.getByRole('button', { name: 'Save Changes', exact: true }).click()
  203. await expect(page.getByText('Reminder updated')).toBeVisible({ timeout: 30_000 })
  204. // The whole point: opening and saving changed nothing.
  205. const after = await reminderDueDate(VEHICLE_TITLE)
  206. expect(after.getTime()).toBe(stored.getTime())
  207. })
  208. test('and is tidied away', async () => {
  209. await deleteRemindersTitled(VEHICLE_TITLE)
  210. })
  211. })