前言

促銷後台加入「開始時間」與「結束時間」後,很快遇到一個典型問題:商家在表單選擇下午四點,儲存後重新開啟卻變成早上八點,或 Automatic App Discount 比預期提早八小時開始。

這不是單純把時間加回八小時就能解決。Shopify 商店可能設在台北,操作後台的人可能人在東京,Vercel server 則使用 UTC;另一間商店還可能位於有日光節約時間的地區。

真正需要定義的是:商家在 datetime-local 欄位輸入的 wall-clock time,究竟屬於哪個時區?

上一篇先處理了 Discount Function 設定大小限制,這篇則專注在排程從表單到 Shopify 的時間語意。

原本的做法與問題症狀

第一版表單使用:

<input type="datetime-local">

編輯既有排程時,前端會建立 Date,再用瀏覽器的 getTimezoneOffset() 調整後填回欄位。這段寫法在開發者電腦與商店都位於同一時區時看似正常。

問題是 datetime-local 的值只有:

2026-07-24T16:13

它沒有 Z,也沒有 +08:00。瀏覽器知道操作者的本機時區,卻不知道 Shopify 商店設定的時區。若直接使用 new Date(value),得到的是「操作者或執行環境如何解讀這串時間」,而不是「商家希望商店於何時開始促銷」。

因此同一份資料會在不同位置產生不同結果:

  • 開發者瀏覽器依本機時區顯示。
  • 遠端商家瀏覽器依另一個時區解析。
  • Serverless action 可能依 UTC 執行。
  • Shopify discount node 的 startsAtendsAt 最後需要明確時間點。

「固定加八小時」只對特定地區暫時有效,一換商店或遇到 daylight saving time 就會再次出錯。

問題是怎麼推敲出來的?

排查時先把時間分成三種表示:

階段 時間語意
表單 商店當地看到的 wall-clock time,不帶 offset
應用程式與資料庫 可比較、可傳輸的 UTC ISO timestamp
重新編輯 將 UTC timestamp 依商店 IANA timezone 還原成表單值

接著確認 Shopify Admin GraphQL 的 Shop 物件提供 ianaTimezone。這比只使用 timezoneOffsetMinutes 更適合長期排程,因為 IANA timezone 包含地區的 daylight-saving 規則,不只是某一刻的固定 offset。

例如同樣輸入下午四點:

Asia/Taipei       → UTC 08:00
America/New_York  → 夏季可能是 UTC 20:00

New York 不能全年寫死 -05:00。排程跨季節時,固定 offset 會把日光節約時間算錯。

最後也確認問題存在於兩個方向:只修「儲存」不夠,重新開啟表單時仍可能顯示錯誤;只修「顯示」也不夠,action 還是會把無時區字串當成 server time。

最後採用的架構

修正後,loader 與 action 都以 Shopify 商店時區作為權威來源:

query ShopTimezone {
  shop {
    ianaTimezone
  }
}

讀取失敗時使用 UTC 作為保守 fallback,不依賴部署主機的 local timezone。

表單顯示與送出採用對稱轉換:

儲存的 UTC ISO
   └─ formatInTimeZone(shopTimezone)
        └─ datetime-local 顯示值

datetime-local 送出值
   └─ fromZonedTime(shopTimezone)
        └─ UTC ISO 儲存值

也就是說,2026-07-24T16:13 不由使用者電腦自行猜測,而是明確解讀為「商店所在地的 2026-07-24 16:13」。

關鍵實作

解析表單時先辨認字串是否已帶有 Z 或 offset:

function parseShopDateTimeInput(value, timeZone) {
  const normalized = value.trim();
  if (!normalized) return undefined;

  const date = hasTimezone(normalized)
    ? new Date(normalized)
    : fromZonedTime(normalized, timeZone);

  return Number.isFinite(date.getTime())
    ? date.toISOString()
    : normalized;
}

這樣可以同時處理新的 datetime-local 值與已經帶時區的資料。

重新編輯時則把 UTC 格式化為商店時間:

formatInTimeZone(date, shopTimezone, "yyyy-MM-dd'T'HH:mm");

舊資料庫若曾保存沒有 timezone 的 legacy value,介面先保留原來的 wall-clock 字串,不擅自套用一次可能錯誤的轉換。這是 migration 期間的取捨:沒有來源時區的歷史資料,無法只靠字串推回唯一正確的 UTC。

新增與編輯 route 的 action 都會重新查詢 ianaTimezone 再解析 form data。不能只把 timezone 交給 React form,因為真正寫入資料與 Shopify discount schedule 的責任仍在 server action。

實際驗證

這次測試包含幾個容易被忽略的方向:

  • 台北下午四點能正確轉成早上八點 UTC。
  • 儲存的 UTC 能轉回同一個台北表單時間。
  • New York 夏季輸入會套用當時正確的 daylight-saving offset。
  • 已帶 Z 或 offset 的 timestamp 不會被當成本地時間重複轉換。
  • legacy 無時區字串在表單顯示時保持原值。
  • loader 會把 Shopify ianaTimezone 傳給表單。
  • create 與 edit action 都使用同一個商店時區解析函式。
  • Shopify timezone 查詢失敗時明確 fallback 到 UTC。

這些測試重點不是證明某個 +08:00 算術,而是證明「輸入 → UTC → 重新顯示」能在不同 IANA timezone 下 round trip。

仍然存在的限制

IANA timezone 解決了固定 offset 與 DST 規則,仍有兩類產品決策不能靠函式自動回答。

第一類是 DST 切換造成的不存在或重複 wall-clock time。某些地區在跳時時,特定時間可能出現兩次或完全不存在。若促銷精確到這些區間,介面應進一步提示,而不是假設所有本地時間都唯一。

第二類是商店之後修改 timezone。已儲存的 UTC timestamp 代表固定瞬間,不會因商店設定改變而平移;重新顯示時則會依新的商店時區呈現。這通常比保存模糊字串安全,但產品仍要決定排程應代表「固定瞬間」還是「永遠固定當地下午四點」。

另外,fallback UTC 只是避免 server local timezone 造成不可預測結果,不代表查不到商店時區時仍有完整使用體驗。正式系統應記錄可診斷的錯誤並讓商家知道排程可能需要確認。

可以延伸到哪些情境?

任何讓商家選擇日期時間的 Shopify App 都會遇到相同邊界,例如:

  • 預約商品上架或下架。
  • 限時折扣與會員活動。
  • 店取、配送或客服時段。
  • 報表依商店日界線彙整。

實作時可以遵守一個簡單原則:資料層保存明確瞬間,輸入與顯示層才使用商店 IANA timezone。不要讓操作者瀏覽器或部署主機偷偷成為業務時區。

下一篇會處理排程與商品系列交會後的另一個問題:Shopify 商品系列排程折扣,為什麼不能提前展開商品清單?

參考資料