コンテンツにスキップ
Documents for MorningStatusApp
Esc
移動開く⌘Jプレビュー
このページの内容

統合フィードアイテム データ設計書

最終更新: 2026-09-17

1. 概要

トップ画面の新着投稿セクション(components/NewPostsSection.tsx)等における、Ameba・Instagram・TikTok・YouTube 4種のSNSデータ取得を、TanStack Query(@tanstack/react-query)によるクライアントサイドキャッシュ層に置き換えるための設計。

現状、NewPostsSectionは完全なサーバーコンポーネントとして実装されており、Blob/DB直接アクセス関数をawaitで直列に呼び出し、UI層とデータ取得・整形ロジックが密結合になっている。本設計では、各SNSのデータ取得をAPI層(Route Handler)に切り出し、クライアント側からTanStack QueryのuseQueryで取得・キャッシュする構成に変更する。これにより、UI層とデータ取得層を分離し、将来的にライブ・リリース等の他領域にも同じパターンを展開できるようにする。

対応する実装Issue: MorningStatusApp#1569


2. 背景・目的

目的 詳細
UI層とデータ取得層の分離 NewPostsSection.tsx(857行)がSNS4種の同期判定・フィルタリング・遡及判定・ソート・UI描画を一括で担っている「Fat Component」状態を解消する
クライアントサイドキャッシュの導入 Blob/DB専用のサーバー関数をAPI層(Route Handler)経由でクライアントから呼び出し可能にし、TanStack Queryでキャッシュ・再取得を管理する
汎用的なデータ取得基盤の確立 SNS4種で先行導入し、有効性を確認できればライブ・リリース等他領域にも水平展開する(§6参照)

3. 統合フィードアイテム(UnifiedFeedItem)スキーマ

3-1. 判別共用体の定義(types/feed.ts 想定)

sourceフィールドを判別キーとする判別共用体(Discriminated Union)として定義する。

const FeedItemBaseSchema = z.object({
  memberId: z.string(),
  capturedAt: z.string(),      // 収集日時(新着判定の基準。投稿日時ではない)
  isRetroactive: z.boolean(),  // 遡及収集判定
});

export const AmebaFeedItemSchema = FeedItemBaseSchema.extend({
  source: z.literal("ameba"),
  blogUrl: z.string().url(),
  title: z.string(),
  publishedAtLabel: z.string().nullable(), // 遡及時のみ実投稿日時ラベルを表示
  lastUpdated: z.string(),
});

export const InstagramFeedItemSchema = FeedItemBaseSchema.extend({
  source: z.literal("instagram"),
  postUrl: z.string().url(),
  thumbnailUrl: z.string().url().optional(),
  isLive: z.boolean().optional(),
  isReel: z.boolean().optional(),
  caption: z.string(),
});

export const TikTokFeedItemSchema = FeedItemBaseSchema.extend({
  source: z.literal("tiktok"),
  memberId: z.string().optional(), // OGメンバーのみ。公式アカウントの場合はundefined
  postUrl: z.string().url(),
  thumbnailUrl: z.string().url().optional(),
  caption: z.string(),
  accountId: z.string().optional(),
  channelType: z.enum(["official", "og"]).optional(),
});

export const YoutubeFeedItemSchema = FeedItemBaseSchema.extend({
  source: z.literal("youtube"),
  memberId: z.string().optional(), // OGメンバーのみ。公式チャンネルの場合はundefined
  videoId: z.string(),
  title: z.string(),
  publishedAt: z.string(),
  channelType: z.enum(["official", "og"]),
});

export const UnifiedFeedItemSchema = z.discriminatedUnion("source", [
  AmebaFeedItemSchema,
  InstagramFeedItemSchema,
  TikTokFeedItemSchema,
  YoutubeFeedItemSchema,
]);
export type UnifiedFeedItem = z.infer<typeof UnifiedFeedItemSchema>;

3-2. 既存型からのマッピングルール

SNS 既存の型・データソース UnifiedFeedItemへのマッピング
Ameba Member.statusHistory[]/latestStatusStatusEntrytypes/member.ts)。専用の投稿一覧取得関数を持たず、membersAmebaSyncStatusから動的算出する(§4-2参照) blogUrlhasOfficialAmebaBlog(member)member.officialSnsにAmebaブログのactiveエントリがあるか)でメンバーを絞り込んだ上でfindSupportedBlogUrl(entry.sources)titleentry.content.split("\n")[0]lastUpdatedentry.lastUpdatedcapturedAtentry.capturedAtisRetroactiveisRetroactivePost(entry.lastUpdated, amebaSyncStatus.previousAutoSyncedAt)publishedAtLabel ← 遡及時のみformatJST(entry.lastUpdated)
Instagram DailyNewPosttypes/instagram.tsInstagramPostSchemamemberNameで拡張) postUrlthumbnailUrlisLiveisReelcaptioncapturedAtmemberIdはそのまま対応。isRetroactiveは既存に相当フィールドなし(要調整、§4-2参照)
TikTok TikTokPosttypes/tiktok.ts postUrlthumbnailUrlcaptioncapturedAtaccountIdchannelTypememberIdはそのまま対応。memberIdchannelType: 'og'のみ存在し、公式アカウントの投稿ではundefined(§3-1参照)
YouTube YoutubePosttypes/youtube.ts videoIdtitlepublishedAtcapturedAtchannelTypememberIdはそのまま対応。memberIdchannelType: 'og'のみ存在し、公式チャンネルの投稿ではundefined(§3-1参照)

備考: InstagramのDailyNewPostには遡及収集判定に相当するフィールドが現状存在しない。実装Issue(#1569)側で、既存の新着判定ロジック(instagramPostsAll = rawDailyNewPosts.filter(...))に遡及判定が含まれているかを確認し、無ければisRetroactive: false固定とするか、Ameba同様のウィンドウ判定を追加するかを実装時に判断する。

備考: findSupportedBlogUrl(sources)sources配列からAmeba URLを探索するだけで、それが本人の公式登録ブログかどうかは検証しない。officialSnsを見ずにこの関数の戻り値だけで「Ameba投稿」と判定すると、公式Amebaブログ未登録のメンバーが一般Web検索で他人のameblo.jp URLを拾った際に誤判定してしまう(MorningStatusApp #1628で実例が確認され修正済み)。hasOfficialAmebaBlogによるメンバー単位の事前フィルタは、この誤判定を避けるための必須の前段ステップである。


4. API層設計(Route Handler)

4-1. エンドポイント一覧

エンドポイント 責務 内部で呼ぶ既存関数
GET /api/feed/ameba membersAmebaSyncStatusから新着ウィンドウ判定・フィルタリングを行いAmebaFeedItem[]を返す getMembersFromBlob()getAmebaSyncStatusFromBlob()lib/blob.ts
GET /api/feed/instagram DailyNewPost[]InstagramFeedItem[]に変換して返す getDailyNewPostsFromBlob()lib/blob.ts
GET /api/feed/tiktok TikTokPost[]TikTokFeedItem[]に変換して返す getRecentTikTokPosts()lib/tiktok.ts
GET /api/feed/youtube YoutubePost[]YoutubeFeedItem[]に変換して返す getRecentYoutubePosts()lib/youtube.ts

各エンドポイントはUnifiedFeedItem[](該当SNSのバリアントのみ)を返す。official/active/og等の表示ブロック分類・ソート順は、既存実装と同様にUI層(クライアント側)の責務として残し、Route Handler側では行わない。

4-2. Ameba専用の非対称な設計

TikTok/YouTubeはgetRecentXPosts()が投稿一覧を独立して返すデータソースだが、Amebaには投稿一覧を返す専用関数が存在しない。現行のNewPostsSection.tsxでは、hasOfficialAmebaBlog(member)member.officialSnsにAmebaブログのactiveエントリがあるか)で対象メンバーを絞り込んだ上で、members配列のstatusHistory/latestStatusAmebaSyncStatusの同期ウィンドウ([前々回の定期実行時刻, 直近の実行時刻])でフィルタし、isRetroactivePost()で遡及収集を判定する処理を都度実行している。このhasOfficialAmebaBlogによる事前フィルタは、sources内のURLだけで「Ameba投稿」を判定してしまう誤判定(MorningStatusApp #1628)を避けるために必須であり、Route Handlerへの移植時も省略してはならない。

GET /api/feed/amebaは、この「members取得+ウィンドウ計算+フィルタリング」ロジックを内部にカプセル化する。単純な既存関数のラップでは済まないため、実装時は現行の該当ロジック(NewPostsSection.tsxのAmebaセクション、L98-145付近)をRoute Handler側に移植する。

4-3. キャッシュ戦略

新設するRoute Handler群は、独自のキャッシュ戦略を持たせず、内部で呼び出す既存lib関数のfetch設定(next: { revalidate: ... })をそのまま継承する(例: getDailyNewPostsFromBlob()revalidate: 1800)。Route Handler側でcache: "no-store"等を独自に指定しない。

これは、既存のGET /api/membersが独自にcache: "no-store"を指定した結果、同じBlobデータに対して経路(直接呼び出し vs Route Handler経由)でキャッシュ戦略が異なり、パッチスクリプト実行直後に「片方の画面では反映済み、片方では古いまま」という食い違いが起きる問題が知られているため(開発ノート参照)。同じ混乱を新設APIで再発させないよう、既存関数の設定に統一する。


5. TanStack Query導入方針

5-1. 依存追加

package.json@tanstack/react-queryを追加する(このプロジェクトで初導入)。

5-2. QueryClientProviderの設置

app/providers.tsxを新規作成し、クライアントコンポーネント("use client")としてQueryClientProviderを生成する。app/layout.tsx<body>内で<Providers>{children}</Providers>のように全体をラップする(ヘッダー・フッターを含む<body>全体を対象とし、将来の非SNS領域展開時にも同じProviderを再利用できるようにする)。

// app/providers.tsx(イメージ)
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

5-3. サーバー側プリフェッチ

app/page.tsxNewPostsSectionの呼び出し元)で、Next.js App Routerの典型パターンに従い、サーバー側でprefetchQuerydehydrateHydrationBoundaryを用いてクライアントに渡す。これにより初回表示時のちらつき・ローディングスピナーを回避する。クライアント側のNewPostsSection(の該当箇所)はuseQueryでRoute Handlerからフィードを取得・キャッシュする。


6. 拡張の方向性(SNS以外への展開)

本設計で確立するパターン(Route Handler + UnifiedFeedItem的な判別共用体ドメイン型 + useQuery + prefetch/hydrate)は、SNS4種に限定しない汎用的なデータ取得基盤として位置づける。

次の展開候補は以下の通り。詳細スキーマはここでは定義せず、実際に拡張する際にこの方針をもとに個別Issueを起票する。

  • ライブ情報(Neon DB): ツアー・フェス出演情報を含む
  • リリース情報(Vercel Blob / Neon DB)
  • メンバー情報(Vercel Blob)
  • ユニット情報(Vercel Blob)
  • 将来的には、ラジオ・イベント等、Blob/DBを参照する他の画面領域にも同じ基盤を展開しうる

7. 関連設計書

設計書 本書との関係
Amebaブログ機能 データ設計書 StatusEntryAmebaSyncStatusの詳細、遡及判定ロジック
Instagram投稿機能 データ設計書 DailyNewPostの詳細
TikTok投稿機能 データ設計書 TikTokPostの詳細
YouTube投稿一覧機能 データ設計書 YoutubePostの詳細
データ設計書(全体概要) エンティティ一覧への登録

改訂履歴

更新日 変更内容
1.0 2026-09-17 初版作成(#90)

このページは役に立ちましたか?