環境
- Node.js 18.18
- TypeScript 5.2
- date-fns 2.30 / date-fns-tz 2.0
- Prisma 5.0 / PostgreSQL 15
- 移行・バッチ処理は GitHub Actions の cron で実行
はじめに
X(旧Twitter)で運用している複数アカウントを、これまでの手動投稿から自動投稿システムへ移行する機会がありました。自動投稿システムには「過去いつ投稿したか」の履歴をDBに保存し、ダッシュボードに表示する機能があります。ところが、移行後の動作確認中に投稿時刻が正しく表示されないという問題が発生しました。
原因を調べると、手動で記録されていたログの日時フォーマットがバラバラであり、さらにタイムゾーン情報が欠落しているデータが大量にあることが判明しました。本記事では、この問題を調査・解決したプロセスを、実装コードとともに詳しく紹介します。
発生した問題の症状
- 一部の投稿がダッシュボード上で「未来の日時」として表示される
- 別の投稿は「前日の残業で投稿した?」と思うくらい過去に表示される
- 同じ日付でも、データによって日本時間とUTCの混在が見られる
- 日付を修正して保存し直しても、バックエンドでDBを読むたびに表示がズレる
典型的な「日時データの時間帯解釈不一致」によるバグです。
手動ログの日時フォーマット実態調査
過去の投稿ログを抽出してフォーマットを集計したところ、以下の5種類が確認できました。
2026-03-28 07:302026-03-28T07:30:00+09:003/28 7:30am2026/3/28 07:302026-03-28 07:30 JST
このうち、末尾にUTCオフセットもタイムゾーン名もないデータが全体の約45%を占めていました。これらは手動でメモ帳やExcelに入力されたもので、暗黙的に「日本時間」として記録されています。しかし、移行先の自動投稿システムがこの文字列をnew Date()にそのまま渡していたため、JavaScriptエンジンの実装によってUTCとして解釈されたり、ローカルタイムのサーバ設定に依存したりしていました。
原因の分析
- 入力者がそのときに使ったツールのフォーマットに依存していた
- 手動運用のルールが「ローカル時刻のみ記録」で、タイムゾーン情報まで残していなかった
- 移行側のパーサが全フォーマットに対応していなかった
- 保存先のDBカラムが
timestamp without time zoneだったため、解釈がアプリ任せになっていた
特に4番は設計上の問題であり、timestamp with time zoneを使っていればこの手の事故は防げた可能性が高いです。
対策: date-fnsで全パターンをパースし、JSTに統一する
日時操作ライブラリにはdate-fnsとdate-fns-tzを採用しました。date-fnsは軽量で、フォーマット解析に必要なparse関数が揃っています。フォーマットが複数あるため、候補を配列で持ち、順番にparseを試す方式にしました。
import { parse, isValid, format } from 'date-fns';
import { fromZonedTime, utcToZonedTime } from 'date-fns-tz';
タイムゾーン情報が省略されているフォーマットは「JST」であるとみなし、fromZonedTimeで日本のローカル時刻をUTCのDateオブジェクトへ変換します。これにより、DBには正しいUTCが保存され、あとでどのタイムゾーンの画面からでも安全に表示できます。
const JST_TIMEZONE = 'Asia/Tokyo';
const FORMAT_CANDIDATES = [
'yyyy-MM-dd HH:mm',
'yyyy/MM/dd HH:mm',
'M/d h:mma',
'M/d HH:mm',
'yyyy-MM-dd\'T\'HH:mm:ssXXX',
'yyyy-MM-dd HH:mm zzz',
];
export function parseManualDateTime(input: string, now: Date = new Date()): Date {
const normalized = input.trim().replace(/\u00A0/g, ' ');
for (const fmt of FORMAT_CANDIDATES) {
const parsed = parse(normalized, fmt, now);
if (isValid(parsed)) {
// タイムゾーン指定がないものはJSTとして解釈
if (fmt !== 'yyyy-MM-dd\'T\'HH:mm:ssXXX' && fmt !== 'yyyy-MM-dd HH:mm zzz') {
return fromZonedTime(parsed, JST_TIMEZONE);
}
return parsed;
}
}
throw new Error(`Unsupported datetime format: ${input}`);
}
parseは基準日を与える必要があるため、実行時点のnowを渡しています。zを含むフォーマットはdate-fns-tzではなくdate-fnsの組み込みでは解釈できないため、実際には正規表現でJSTを除去してからパースするなど、別途処理をします。これは本記事のコードを簡略化するため省略しています。
DB保存と表示の実装
PostgreSQLではtimestamp with time zone型で保存します。Prismaのスキーマは以下のようにしました。
model PostHistory {
id Int @id @default(autoincrement())
content String
postedAt DateTime @db.Timestamptz(6)
}
保存する際はUTCのDateオブジェクトをそのまま渡します。表示時はutcToZonedTimeでJSTに変換してから文字列化します。
import { format, utcToZonedTime } from 'date-fns-tz';
const displayDate = utcToZonedTime(history.postedAt, JST_TIMEZONE);
const displayText = format(displayDate, 'yyyy-MM-dd HH:mm', {
timeZone: JST_TIMEZONE,
});
これで管理画面・ダッシュボードの表示が必ずJSTになります。たとえサーバのタイムゾーンがUTCやPSTでも、表示結果はブレません。
ハマりポイントと対策
1. new Date()に依存してはいけない
new Date('2026-03-28 07:30')はブラウザやNode.jsのバージョンによって解釈が異なります。特にiOS SafariではInvalid Dateになることがあり、原因特定に時間がかかりました。日時文字列の解析は必ずライブラリ経由にしましょう。
2. 12時間制のam/pmと24時間制の区別
date-fnsのフォーマットでは、分のプレースホルダーはm、月はMです。3/28 7:30amを正しくパースするには'M/d h:mma'を使う必要があります。'M/d HH:mm'でパースしようとするとamが余分な文字列として残り、isValidがfalseになります。
3. 空白や特殊文字の混入
Excelなどからコピーした文字列には、見た目は半角スペースでも実際はNBSP(U+00A0)や全角スペースが混入していることがあります。パース前にnormalized = input.trim().replace(/\u00A0/g, ' ')を通すことで防げます。
4. JST表記のパース
date-fns本体にはタイムゾーン名をパースする機能がありません。2026-03-28 07:30 JSTのような文字列は、JSTを除去してからyyyy-MM-dd HH:mmとしてパースし、fromZonedTimeでJST変換するのが安全です。
フォーマット比較表
| 入力例 | date-fnsフォーマット | タイムゾーン情報 | 備考 |
|---|---|---|---|
2026-03-28 07:30 |
yyyy-MM-dd HH:mm |
省略(JSTとみなす) | 最も多いパターン |
2026-03-28T07:30:00+09:00 |
yyyy-MM-dd'T'HH:mm:ssXXX |
+09:00明示 |
ISO 8601 |
3/28 7:30am |
M/d h:mma |
省略(JSTとみなす) | 12時間制 |
2026/3/28 07:30 |
yyyy/MM/dd HH:mm |
省略(JSTとみなす) | スラッシュ区切り |
2026-03-28 07:30 JST |
yyyy-MM-dd HH:mm zzz |
JST明示 |
タイムゾーン名あり |
移行作業の手順
- ログファイルをJSON Lines形式に変換
-
parseManualDateTimeを使って全行をパースするスクリプトを実行 - パース失敗行は
failed.logに原文を残す - パース成功行は
postedAtとしてPostgreSQLに保存 - ダッシュボード表示APIのレスポンスでJST変換を確認
- 既存の手動記録と突き合わせ、1000件のサンプルを目視検証
この手順を踏むことで、移行後の時刻ズレはゼロになりました。
よくある質問(FAQ)
Q. すべてのフォーマットを列挙するのは大変ではないですか?
A. 最初は大変ですが、手動運用で発生するフォーマットは種類が限られています。本番投入前にログの全パターンを抽出してカバレッジを確認することを推奨します。パース失敗をログに残しておけば、新フォーマットが出現したときも即座に気づけます。
Q. タイムゾーン情報がないデータが40%以上あるのに、JTSと決めつけて大丈夫?
A. 対象アカウントはすべて日本時間の運用だったため、JSTとみなして問題ありません。ただし、海外拠点が混在する場合は、入力画面側でデフォルトタイムゾーンを選択する仕組みが必要です。
Q. timestamp with time zoneならタイムゾーンを自動で変換してくれるのでは?
A. PostgreSQLのtimestamptzは内部的にUTCで保持し、クライアントのセッションタイムゾーンに応じて表示されます。しかし、アプリケーション層の日時ライブラリがサーバのタイムゾーンに引きずられるケースがあります。DBだけで完結させるのではなく、アプリ層でも表示タイムゾーンを明示するのが安全です。
まとめ
手動投稿データを自動化する際、日時フォーマットのバリエーションとタイムゾーン情報の欠落は、思わぬ表示ズレを引き起こします。今回の解決策は次の3点です。
- date-fnsの
parseでフォーマットを列挙してパースする - タイムゾーン省略時はJSTとして
fromZonedTimeで変換する - DBはUTC、表示はJSTに統一する
手動運用のログは「正しい」とは限りません。移行作業では必ずデータクレンジング工程を組み込み、自分でテストデータを作って検証しましょう。同じような課題に直面している方の参考になれば幸いです。
この記事を書いた人
BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。
GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。
👉 業務自動化サービス — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中