投稿管理ダッシュボードの更新では、公開済みの投稿を追加するだけでなく、前回は予約中だった投稿の状態も置き換えました。同じ記事が公開されたなら、予約と公開の2件ではなく、公開済みの1件として数える必要があります。
今回の運用では、取得した現在の一覧を更新し、画面はその一覧から公開件数と予約件数を分けて集計しています。前回と今回の一覧をそのまま足す方式にはしませんでした。
この記事では、この更新ルールをJavaScriptの小さな例に切り出します。ポイントは重複を消す書き方よりも先に、入力が「現在の全量」なのか「前回からの差分」なのかを決めることでした。
予約が公開へ変わったとき、何を数えるか
自分が使っているダッシュボードには、投稿の公開状況と、今後の予約を表示しています。更新前後のデータを照合すると、同じ記事URLのまま、状態が予約から公開へ変わっているものがありました。
画面側の集計は、公開済みならpublished、予約中ならscheduledで絞り込むだけです。ここで古い予約レコードを残したまま公開レコードを追加すると、画面の集計式が正しくても入力の時点で二重になります。
実データから離れた例で考えます。以下のIDと内容は記事用の架空データです。
const before = [
{ id: 'post-a', status: 'scheduled' },
{ id: 'post-b', status: 'published' },
];
const latest = [
{ id: 'post-a', status: 'published' },
{ id: 'post-b', status: 'published' },
{ id: 'post-c', status: 'scheduled' },
];
const appended = [...before, ...latest];
appendedでは、公開済みのpost-bも重複しています。予約から公開への変更だけが問題なのではなく、全量の一覧を差分として追加したことが原因です。
| 集計対象 | 公開済み | 予約中 | 合計 |
|---|---|---|---|
| 更新前の一覧 | 1 | 1 | 2 |
| 今回確認した一覧 | 2 | 1 | 3 |
| 単純に連結した一覧 | 3 | 2 | 5 |
今回知りたいのは現在の公開状況なので、集計に使うのは2行目の3件です。前回の状態は、必要なら履歴として別に残します。現在の件数を出す配列へ混ぜるものではありません。
日時やタイトルではなく、同じ投稿を指すキーで照合した
元のデータでは、記事URLを照合すると、同じ記事の状態変更だと確認できました。サンプルではその識別部分をidに置き換えています。
タイトルは変更される可能性があり、予約日時も変更できます。同じ時刻に複数の投稿を予約する場合もあるので、それだけで同じ投稿だとは判断できません。
この例が前提にしているのは、予約中から公開後まで追跡できるキーがあることです。予約管理側のIDと公開後のIDが別になるサービスでは、確認できる対応情報が必要です。日時が近いという理由だけで、自動的に同じ投稿へまとめる処理は含めません。
また、複数のサービスやアカウントを集計するときは、その取得範囲も区別します。別サービスで同じID文字列が使われても、同じ投稿にはなりません。
現在の一覧を検査してから置き換える
元のダッシュボードは、確認した一覧をデータファイルへ反映する構成です。以下はその運用ルールを、検証できる関数として表した記事用の例です。取得処理そのものを実装したものではありません。
入力は、同じサービス・アカウント・対象範囲について確認した全量スナップショットとします。ここでは取得範囲をscope、取得が完了したことをcompleteで渡します。
// snapshot.mjs
export function compileSnapshot(input, expectedScope) {
if (input.scope !== expectedScope || input.complete !== true) {
throw new Error('Snapshot scope is wrong or collection is incomplete');
}
if (typeof input.fetchedAt !== 'string' ||
!Number.isFinite(Date.parse(input.fetchedAt)) ||
!Array.isArray(input.records)) {
throw new Error('Invalid snapshot');
}
const byId = new Map();
for (const row of input.records) {
if (!row || typeof row.id !== 'string' || !row.id.trim() ||
!['published', 'scheduled'].includes(row.status)) {
throw new Error('Invalid record');
}
if (byId.has(row.id)) throw new Error('Duplicate record ID');
byId.set(row.id, { id: row.id, status: row.status });
}
return {
scope: input.scope,
fetchedAt: input.fetchedAt,
records: [...byId.values()],
};
}
export function countStatus(snapshot) {
return {
published: snapshot.records.filter(r => r.status === 'published').length,
scheduled: snapshot.records.filter(r => r.status === 'scheduled').length,
};
}
使う側では、検査済みの一覧ができてから現在の状態を更新します。
const next = compileSnapshot({
scope: 'blog:all',
complete: true,
fetchedAt: '2026-10-16T09:00:00+09:00',
records: latest,
}, 'blog:all');
current = next;
console.log(countStatus(current));
// { published: 2, scheduled: 1 }
ここでMapは、同じIDが入力に2回現れていないかを調べるために使っています。同じキーへset()すると値が更新される性質はありますが、今回はそれで黙って片方を採用しません。
1回の全量取得の中に、同じIDの予約と公開が混ざっていたら、取得元の状態やページをまたぐ取得タイミングを調べる対象にします。配列で後ろにある方が新しいとは限らないためです。記事用の例では、まず不整合を検出し、再取得や確認へ戻せるようにしています。
この例の関数はIDと状態だけを扱います。タイトル、日時、公開先などを表示するアプリでは、必要な項目を別途検証して保持します。外部サービスの認証や、レスポンス全体のスキーマ検証もこの関数の責任には含めていません。
空の一覧と、取得できなかった一覧を分ける
全量で置き換える場合に気になったのは、取得に失敗した結果を空配列として採用しないことです。
認証が切れていた、途中のページを取得できなかった、予約一覧だけを確認できなかった。その状態を「投稿0件」として扱うと、前回確認できた表示まで消えてしまいます。
completeは、単にHTTPリクエストが成功したら付けるフラグではありません。取得側でログイン状態、必要なページ、公開と予約の両方など、対象範囲を確認したうえで付ける契約です。この関数だけでは取得漏れを証明できません。
元の運用でも、再ログインが必要ならデータを上書きせず、最後に成功したサイトを維持する方針です。これを自動化する場合は、古い一覧を残すだけでなく、最終取得時刻と更新失敗を画面へ示す設計にすると、最新情報と誤解されにくくなります。上の最小関数には、その表示処理までは含めていません。
一方、取得が完了し、本当に対象が0件だったなら、空配列への更新を認めます。
対象範囲が「直近3か月」のように動く場合も同じです。新しい投稿を足すだけでは、期間外になった古い投稿が残ります。今回の対象範囲の全量で更新するなら、範囲外になったものも現在の一覧から外せます。元サービスから投稿を削除する話ではありません。
予定時刻を過ぎても、公開済みにはしない
予約日時が過ぎたことと、実際に公開されたことは別です。予約の変更や取り消し、公開処理の失敗があれば、予定どおりの状態にはなりません。
そのため、今回の集計例には「現在時刻を過ぎていたらpublishedに変える」という分岐を入れていません。公開状況の取得で確認した状態を使います。確認できていないものは、時刻から公開成功を補って数えません。
取得時刻を残しているのも、画面の件数がいつ確認した状態なのかを分けるためです。予約があることを示す画面と、その投稿が公開されたことを示す画面では、必要な根拠が違いました。
架空データで更新前後をテストする
確認したかったのは、予約が公開へ変わったあとに1件として数えられることと、不完全な入力で現在の状態を失わないことです。
たとえば、次のテストはsnapshot.mjsと同じフォルダーへ保存して実行できます。
// snapshot.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
import { compileSnapshot, countStatus } from './snapshot.mjs';
const batch = (records, complete = true) => ({
scope: 'blog:all',
complete,
fetchedAt: '2026-10-16T09:00:00+09:00',
records,
});
test('予約から公開へ変わっても1件として数える', () => {
const latest = [
{ id: 'post-a', status: 'published' },
{ id: 'post-b', status: 'published' },
{ id: 'post-c', status: 'scheduled' },
];
const next = compileSnapshot(batch(latest), 'blog:all');
assert.equal(next.records.length, 3);
assert.deepEqual(countStatus(next), { published: 2, scheduled: 1 });
});
test('取得途中なら前回の一覧を残す', () => {
let current = compileSnapshot(batch([
{ id: 'post-a', status: 'scheduled' },
]), 'blog:all');
const previous = current;
assert.throws(() => {
current = compileSnapshot(batch([], false), 'blog:all');
});
assert.strictEqual(current, previous);
});
node --test snapshot.test.mjs
この2件に加え、記事用の検証では、単純連結による二重集計、同じIDの重複、完全な空配列、取得範囲の取り違え、不正な入力も確認しました。Node.js v24.19.0で計7件のテストを実行し、すべて成功しています。外部サービスへアクセスした結果ではなく、集計と置き換え条件を架空データで確かめたものです。
元のデータ更新についても、変更前後を記事URLで照合し、予約から公開への切り替えと、更新後のキーの重複がないことを確認しました。記事の最小関数を、そのまま元のアプリへ追加したわけではありません。
まとめ
今回の更新では、予約と公開を別々に増える履歴として扱わず、同じ投稿の現在の状態として整理しました。
全量の一覧なら、前回分へ追加せず、取得範囲と完了を確認して置き換える。差分取得なら、識別子による更新や削除の扱いを別に設計する。この区別がないまま、重複を消す処理だけを足しても、どちらの状態を残すかは決まりません。
自分のダッシュボードでは、「いま何件公開され、何件予約されているか」を確認したかったので、現在の一覧と過去の状態を混ぜないことを優先しました。