前言
促銷後台加入「開始時間」與「結束時間」後,很快遇到一個典型問題:商家在表單選擇下午四點,儲存後重新開啟卻變成早上八點,或 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 的
startsAt/endsAt最後需要明確時間點。
「固定加八小時」只對特定地區暫時有效,一換商店或遇到 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 商品系列排程折扣,為什麼不能提前展開商品清單?
