diff --git a/workspaces/client/src/app/createRoutes.tsx b/workspaces/client/src/app/createRoutes.tsx index a81e12561..7c3e54d39 100644 --- a/workspaces/client/src/app/createRoutes.tsx +++ b/workspaces/client/src/app/createRoutes.tsx @@ -13,7 +13,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { HomePage, prefetch } = await lazy( import('@wsh-2025/client/src/pages/home/components/HomePage'), - 1000, + 100, ); return { Component: HomePage, @@ -27,7 +27,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { EpisodePage, prefetch } = await lazy( import('@wsh-2025/client/src/pages/episode/components/EpisodePage'), - 1000, + 100, ); return { Component: EpisodePage, @@ -42,7 +42,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { prefetch, ProgramPage } = await lazy( import('@wsh-2025/client/src/pages/program/components/ProgramPage'), - 1000, + 100, ); return { Component: ProgramPage, @@ -57,7 +57,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { prefetch, SeriesPage } = await lazy( import('@wsh-2025/client/src/pages/series/components/SeriesPage'), - 1000, + 100, ); return { Component: SeriesPage, @@ -72,7 +72,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { prefetch, TimetablePage } = await lazy( import('@wsh-2025/client/src/pages/timetable/components/TimetablePage'), - 1000, + 100, ); return { Component: TimetablePage, @@ -87,7 +87,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { NotFoundPage, prefetch } = await lazy( import('@wsh-2025/client/src/pages/not_found/components/NotFoundPage'), - 1000, + 100, ); return { Component: NotFoundPage, diff --git a/workspaces/client/src/features/episode/services/episodeService.ts b/workspaces/client/src/features/episode/services/episodeService.ts index 3a21b8194..7c022d098 100644 --- a/workspaces/client/src/features/episode/services/episodeService.ts +++ b/workspaces/client/src/features/episode/services/episodeService.ts @@ -1,7 +1,6 @@ import { createFetch, createSchema } from '@better-fetch/fetch'; import { StandardSchemaV1 } from '@standard-schema/spec'; import * as schema from '@wsh-2025/schema/src/api/schema'; -import * as batshit from '@yornaath/batshit'; import { schedulePlugin } from '@wsh-2025/client/src/features/requests/schedulePlugin'; @@ -15,47 +14,87 @@ const $fetch = createFetch({ }, '/episodes/:episodeId': { output: schema.getEpisodeByIdResponse, + params: schema.getEpisodeByIdRequestParams, }, }), throw: true, }); -const batcher = batshit.create({ - async fetcher(queries: { episodeId: string }[]) { - const data = await $fetch('/episodes', { - query: { - episodeIds: queries.map((q) => q.episodeId).join(','), - }, - }); - return data; - }, - resolver(items, query: { episodeId: string }) { - const item = items.find((item) => item.id === query.episodeId); - if (item == null) { - throw new Error('Episode is not found.'); - } - return item; - }, - scheduler: batshit.windowedFiniteBatchScheduler({ - maxBatchSize: 100, - windowMs: 1000, - }), -}); - interface EpisodeService { - fetchEpisodeById: (query: { + fetchEpisodeById: (params: { episodeId: string; }) => Promise>; - fetchEpisodes: () => Promise>; + fetchEpisodes: (params: { + episodeIds?: string[]; + }) => Promise>; } +// キャッシュの設定 +const CACHE_TTL = 5 * 60 * 1000; // キャッシュの有効期限を5分に設定 +const episodeCache = new Map>(); +const cacheTimestamps = new Map(); + +// バッチ処理用のキュー +let batchQueue: string[] = []; +let batchTimeout: NodeJS.Timeout | null = null; +const BATCH_WINDOW = 1; // バッチウィンドウを1msに短縮 +const MAX_BATCH_SIZE = 10; // 最大バッチサイズを制限 + export const episodeService: EpisodeService = { async fetchEpisodeById({ episodeId }) { - const channel = await batcher.fetch({ episodeId }); - return channel; + // キャッシュをチェック + const cachedEpisode = episodeCache.get(episodeId); + const timestamp = cacheTimestamps.get(episodeId); + if (cachedEpisode && timestamp && Date.now() - timestamp < CACHE_TTL) { + return cachedEpisode; + } + + // バッチキューに追加 + batchQueue.push(episodeId); + + // 既存のタイマーをクリア + if (batchTimeout) { + clearTimeout(batchTimeout); + } + + // 新しいタイマーを設定 + return new Promise((resolve, reject) => { + batchTimeout = setTimeout(async () => { + // 重複を除去し、最大サイズを制限 + const uniqueIds = [...new Set(batchQueue)].slice(0, MAX_BATCH_SIZE); + + try { + const episodes = await this.fetchEpisodes({ episodeIds: uniqueIds }); + + // キャッシュを更新 + const now = Date.now(); + episodes.forEach(episode => { + episodeCache.set(episode.id, episode); + cacheTimestamps.set(episode.id, now); + }); + + const episode = episodes.find(e => e.id === episodeId); + if (!episode) { + reject(new Error(`Episode not found: ${episodeId}`)); + return; + } + resolve(episode); + } catch (error) { + console.error('Error fetching episodes:', error); + reject(error); + } finally { + batchQueue = []; + } + }, BATCH_WINDOW); + }); }, - async fetchEpisodes() { - const data = await $fetch('/episodes', { query: {} }); + + async fetchEpisodes({ episodeIds }) { + if (!episodeIds?.length) return []; + + const data = await $fetch('/episodes', { + query: { episodeIds: episodeIds.join(',') }, + }); return data; }, }; diff --git a/workspaces/client/src/features/program/services/programService.ts b/workspaces/client/src/features/program/services/programService.ts index dc17a25c0..f2ed9e7c0 100644 --- a/workspaces/client/src/features/program/services/programService.ts +++ b/workspaces/client/src/features/program/services/programService.ts @@ -38,8 +38,8 @@ const batcher = batshit.create({ return item; }, scheduler: batshit.windowedFiniteBatchScheduler({ - maxBatchSize: 100, - windowMs: 1000, + maxBatchSize: 500, + windowMs: 0, }), }); diff --git a/workspaces/client/src/features/recommended/services/recommendedService.ts b/workspaces/client/src/features/recommended/services/recommendedService.ts index 494ad911f..70f3e5663 100644 --- a/workspaces/client/src/features/recommended/services/recommendedService.ts +++ b/workspaces/client/src/features/recommended/services/recommendedService.ts @@ -10,6 +10,7 @@ const $fetch = createFetch({ schema: createSchema({ '/recommended/:referenceId': { output: schema.getRecommendedModulesResponse, + params: schema.getRecommendedModulesRequestParams, }, }), throw: true, @@ -21,11 +22,63 @@ interface RecommendedService { }) => Promise>; } +// キャッシュの設定 +const CACHE_TTL = 5 * 60 * 1000; // キャッシュの有効期限を5分に設定 +const recommendedCache = new Map>(); +const cacheTimestamps = new Map(); + +// バッチ処理用のキュー +let batchQueue: string[] = []; +let batchTimeout: NodeJS.Timeout | null = null; +const BATCH_WINDOW = 0; // バッチウィンドウを0msに設定(即時実行) +const MAX_BATCH_SIZE = 5; // 最大バッチサイズを制限 + +// プリフェッチ用のキュー +const prefetchQueue = new Set(); + export const recommendedService: RecommendedService = { async fetchRecommendedModulesByReferenceId({ referenceId }) { - const data = await $fetch('/recommended/:referenceId', { - params: { referenceId }, + // キャッシュをチェック + const cachedData = recommendedCache.get(referenceId); + const timestamp = cacheTimestamps.get(referenceId); + if (cachedData && timestamp && Date.now() - timestamp < CACHE_TTL) { + return cachedData; + } + + // プリフェッチキューに追加 + prefetchQueue.add(referenceId); + + // バッチキューに追加 + batchQueue.push(referenceId); + + // 既存のタイマーをクリア + if (batchTimeout) { + clearTimeout(batchTimeout); + } + + // 新しいタイマーを設定 + return new Promise((resolve, reject) => { + batchTimeout = setTimeout(async () => { + // 重複を除去し、最大サイズを制限 + const uniqueIds = [...new Set(batchQueue)].slice(0, MAX_BATCH_SIZE); + + try { + const data = await $fetch('/recommended/:referenceId', { + params: { referenceId }, + }); + + // キャッシュを更新 + recommendedCache.set(referenceId, data); + cacheTimestamps.set(referenceId, Date.now()); + + resolve(data); + } catch (error) { + console.error('Error fetching recommended modules:', error); + reject(error); + } finally { + batchQueue = []; + } + }, BATCH_WINDOW); }); - return data; }, }; diff --git a/workspaces/client/src/features/recommended/stores/recommendedStore.ts b/workspaces/client/src/features/recommended/stores/recommendedStore.ts new file mode 100644 index 000000000..9bc8fc9ba --- /dev/null +++ b/workspaces/client/src/features/recommended/stores/recommendedStore.ts @@ -0,0 +1,26 @@ +import { create } from 'zustand'; +import { StandardSchemaV1 } from '@standard-schema/spec'; +import * as schema from '@wsh-2025/schema/src/api/schema'; +import { recommendedService } from '../services/recommendedService'; + +type RecommendedState = { + modules: StandardSchemaV1.InferOutput | null; + isLoading: boolean; + error: Error | null; + fetchModules: (referenceId: string) => Promise; +}; + +export const useRecommendedStore = create((set) => ({ + modules: null, + isLoading: false, + error: null, + fetchModules: async (referenceId: string) => { + try { + set({ isLoading: true, error: null }); + const modules = await recommendedService.fetchRecommendedModulesByReferenceId(referenceId); + set({ modules, isLoading: false }); + } catch (error) { + set({ error: error as Error, isLoading: false }); + } + }, +})); \ No newline at end of file diff --git a/workspaces/client/src/features/requests/schedulePlugin.ts b/workspaces/client/src/features/requests/schedulePlugin.ts index 0c6cb6c6d..9816168d0 100644 --- a/workspaces/client/src/features/requests/schedulePlugin.ts +++ b/workspaces/client/src/features/requests/schedulePlugin.ts @@ -6,13 +6,9 @@ export const schedulePlugin = { const scheduler = typeof window !== 'undefined' ? window.scheduler : undefined; if (scheduler) { - return await scheduler.postTask(() => request, { delay: 1000 }); + return await scheduler.postTask(() => request); } else { - return await new Promise((resolve) => { - queueMicrotask(() => { - resolve(request); - }); - }); + return request; } }, }, diff --git a/workspaces/client/src/pages/home/components/HomePage.tsx b/workspaces/client/src/pages/home/components/HomePage.tsx index 86cb77e6c..2f025882f 100644 --- a/workspaces/client/src/pages/home/components/HomePage.tsx +++ b/workspaces/client/src/pages/home/components/HomePage.tsx @@ -5,7 +5,10 @@ import { useRecommended } from '@wsh-2025/client/src/features/recommended/hooks/ export const prefetch = async (store: ReturnType) => { const modules = await store .getState() - .features.recommended.fetchRecommendedModulesByReferenceId({ referenceId: 'entrance' }); + .features.recommended.fetchRecommendedModulesByReferenceId({ + referenceId: 'entrance', + limit: 1 // 最初のモジュールのみを取得 + }); return { modules }; }; diff --git a/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx b/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx index 41e0ec14c..996cc68fb 100644 --- a/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx +++ b/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx @@ -1,6 +1,6 @@ import { StandardSchemaV1 } from '@standard-schema/spec'; import * as schema from '@wsh-2025/schema/src/api/schema'; -import { ReactElement } from 'react'; +import { ReactElement, useCallback, useEffect, useMemo } from 'react'; import { Link } from 'react-router'; import { ArrayValues } from 'type-fest'; @@ -17,51 +17,76 @@ export const ProgramDetailDialog = ({ isOpen, program }: Props): ReactElement => const episode = useEpisode(program.episodeId); const [, setProgram] = useSelectedProgramId(); - const onClose = () => { + const onClose = useCallback(() => { setProgram(null); - }; + }, [setProgram]); - return ( - -
-

番組詳細

+ // プログラムデータをメモ化 + const programData = useMemo(() => ({ + title: program.title, + description: program.description, + thumbnailUrl: program.thumbnailUrl, + id: program.id, + }), [program.title, program.description, program.thumbnailUrl, program.id]); + + // エピソードデータをメモ化 + const episodeData = useMemo(() => { + if (!episode) return null; + return { + title: episode.title, + description: episode.description, + thumbnailUrl: episode.thumbnailUrl, + }; + }, [episode]); + + // ダイアログの内容をメモ化 + const dialogContent = useMemo(() => ( +
+

番組詳細

-

{program.title}

-
-
{program.description}
-
- +

{programData.title}

+
+
{programData.description}
+
+ - {episode != null ? ( - <> -

番組で放送するエピソード

+ {episodeData != null ? ( + <> +

番組で放送するエピソード

-

{episode.title}

-
-
{episode.description}
-
- - - ) : null} +

{episodeData.title}

+
+
{episodeData.description}
+
+ + + ) : null} -
- - 番組をみる - -
+
+ + 番組をみる +
+
+ ), [programData, episodeData, onClose]); + + return ( + + {dialogContent} ); }; diff --git a/workspaces/server/migrations/0005_add_indexes.sql b/workspaces/server/migrations/0005_add_indexes.sql new file mode 100644 index 000000000..06f6f5cf9 --- /dev/null +++ b/workspaces/server/migrations/0005_add_indexes.sql @@ -0,0 +1,16 @@ +-- recommendedModuleのインデックス +CREATE INDEX idx_recommended_module_reference_id ON recommendedModule(referenceId); +CREATE INDEX idx_recommended_module_order ON recommendedModule(order); + +-- recommendedItemのインデックス +CREATE INDEX idx_recommended_item_module_id ON recommendedItem(moduleId); +CREATE INDEX idx_recommended_item_order ON recommendedItem(order); +CREATE INDEX idx_recommended_item_series_id ON recommendedItem(seriesId); +CREATE INDEX idx_recommended_item_episode_id ON recommendedItem(episodeId); + +-- episodeのインデックス +CREATE INDEX idx_episode_order ON episode(order); +CREATE INDEX idx_episode_series_id ON episode(seriesId); + +-- seriesのインデックス +CREATE INDEX idx_series_id ON series(id); \ No newline at end of file