統合フィードアイテム データ設計書
最終更新: 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[]/latestStatus(StatusEntry、types/member.ts)。専用の投稿一覧取得関数を持たず、membersとAmebaSyncStatusから動的算出する(§4-2参照) |
blogUrl ← hasOfficialAmebaBlog(member)(member.officialSnsにAmebaブログのactiveエントリがあるか)でメンバーを絞り込んだ上でfindSupportedBlogUrl(entry.sources)、title ← entry.content.split("\n")[0]、lastUpdated ← entry.lastUpdated、capturedAt ← entry.capturedAt、isRetroactive ← isRetroactivePost(entry.lastUpdated, amebaSyncStatus.previousAutoSyncedAt)、publishedAtLabel ← 遡及時のみformatJST(entry.lastUpdated) |
DailyNewPost(types/instagram.ts、InstagramPostSchemaをmemberNameで拡張) |
postUrl・thumbnailUrl・isLive・isReel・caption・capturedAt・memberIdはそのまま対応。isRetroactiveは既存に相当フィールドなし(要調整、§4-2参照) |
|
| TikTok | TikTokPost(types/tiktok.ts) |
postUrl・thumbnailUrl・caption・capturedAt・accountId・channelType・memberIdはそのまま対応。memberIdはchannelType: 'og'のみ存在し、公式アカウントの投稿ではundefined(§3-1参照) |
| YouTube | YoutubePost(types/youtube.ts) |
videoId・title・publishedAt・capturedAt・channelType・memberIdはそのまま対応。memberIdはchannelType: '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 |
membersとAmebaSyncStatusから新着ウィンドウ判定・フィルタリングを行い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/latestStatusをAmebaSyncStatusの同期ウィンドウ([前々回の定期実行時刻, 直近の実行時刻])でフィルタし、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.tsx(NewPostsSectionの呼び出し元)で、Next.js App Routerの典型パターンに従い、サーバー側でprefetchQuery→dehydrate→HydrationBoundaryを用いてクライアントに渡す。これにより初回表示時のちらつき・ローディングスピナーを回避する。クライアント側の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ブログ機能 データ設計書 | StatusEntry・AmebaSyncStatusの詳細、遡及判定ロジック |
| Instagram投稿機能 データ設計書 | DailyNewPostの詳細 |
| TikTok投稿機能 データ設計書 | TikTokPostの詳細 |
| YouTube投稿一覧機能 データ設計書 | YoutubePostの詳細 |
| データ設計書(全体概要) | エンティティ一覧への登録 |
改訂履歴
| 版 | 更新日 | 変更内容 |
|---|---|---|
| 1.0 | 2026-09-17 | 初版作成(#90) |