- {draft.id && !draft.brandId ? (
+ {draft.id && rebinding ? (
diff --git a/apps/web/src/pages/TodayPage.test.tsx b/apps/web/src/pages/TodayPage.test.tsx
index fe85d47..21c6bc5 100644
--- a/apps/web/src/pages/TodayPage.test.tsx
+++ b/apps/web/src/pages/TodayPage.test.tsx
@@ -1,57 +1,9 @@
-import { render, waitFor } from "@testing-library/react";
-import { MemoryRouter } from "react-router-dom";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import type { Repos } from "../data/repos";
-import { I18nProvider } from "../i18n/I18nContext";
+import { describe, expect, it } from "vitest";
+import { RadarOpportunitiesPage } from "./RadarOpportunitiesPage";
import { TodayPage } from "./TodayPage";
-const harness = vi.hoisted(() => ({
- repos: null as Repos | null,
- legacyListPostsCalls: 0,
-}));
-
-vi.mock("../data/DataContext", () => ({
- useRepos: () => harness.repos,
- useData: () => ({ repos: harness.repos, tick: 0, refresh: () => {} }),
-}));
-
-function buildRepos(): Repos {
- return {
- scout: {
- async listPosts() {
- harness.legacyListPostsCalls += 1;
- return [];
- },
- async listRuns() { return { list: [], pagination: { page: 1, pageSize: 10, total: 0, totalPages: 0 } }; },
- async listRunPosts() { throw new Error("not used"); },
- },
- accounts: { async list() { return []; } },
- outbox: { async list() { return []; } },
- inspiration: {
- async listTrends() { return []; },
- async refreshTrends() { return []; },
- },
- ownPosts: { async list() { return []; }, async sync() { return []; } },
- mentions: { async list() { return []; } },
- growth: { async getOutcomeSummary() { return null; }, async getLatestCheckup() { return null; } },
- radar: { async getToday() { return null; } },
- } as unknown as Repos;
-}
-
-describe("TodayPage legacy Scout compatibility", () => {
- beforeEach(() => {
- harness.repos = buildRepos();
- harness.legacyListPostsCalls = 0;
- });
-
- it("continues to load the legacy listPosts endpoint", async () => {
- render(
-
-
-
-
- ,
- );
- await waitFor(() => expect(harness.legacyListPostsCalls).toBeGreaterThan(0));
+describe("TodayPage", () => {
+ it("is the demand inbox, not the old dashboard", () => {
+ expect(TodayPage).toBe(RadarOpportunitiesPage);
});
});
diff --git a/apps/web/src/pages/TodayPage.tsx b/apps/web/src/pages/TodayPage.tsx
index 2ffefe5..23e503c 100644
--- a/apps/web/src/pages/TodayPage.tsx
+++ b/apps/web/src/pages/TodayPage.tsx
@@ -1,757 +1,2 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { Link } from "react-router-dom";
-import { PageHeader } from "../components/layout/PageHeader";
-import { Badge, Button, Card, EmptyState } from "../components/ui";
-import { useData, useRepos } from "../data/DataContext";
-import type {
- MentionItem,
- OutcomeSummary,
- OutboxBundle,
- OwnPost,
- RadarToday,
- ScoutPost,
- ThreadsAccount,
- TrendItem,
- WeeklyCheckup,
-} from "../domain/types";
-import { useFirstRun } from "../firstRun/FirstRunContext";
-import { useI18n } from "../i18n/I18nContext";
-import { useFormatApiError } from "../lib/apiErrors";
-import { loadScoutToday } from "../lib/scoutToday";
-
-function isPendingScout(p: ScoutPost): boolean {
- return p.outreach_status === "new" || p.outreach_status === "drafted";
-}
-
-function startOfLocalDayNano(): number {
- const d = new Date();
- d.setHours(0, 0, 0, 0);
- return d.getTime() * 1_000_000;
-}
-
-type AccountPulse = {
- account: ThreadsAccount;
- posts: number;
- views: number;
- likes: number;
- replies: number;
- topInsight?: string;
-};
-
-/**
- * 今日儀表板:海巡待回、今日目標、發送、話題、帳號成效。
- * 全部接 live repos;話題可刷新;點話題進靈感 tab。
- */
-export function TodayPage() {
- const repos = useRepos();
- const { tick, refresh } = useData();
- const { t, locale } = useI18n();
- const formatApiError = useFormatApiError();
-
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState("");
- const [trendMessage, setTrendMessage] = useState("");
- const [refreshingTrends, setRefreshingTrends] = useState(false);
- const [syncingPosts, setSyncingPosts] = useState(false);
-
- const [scoutPosts, setScoutPosts] = useState
([]);
- const [outbox, setOutbox] = useState([]);
- const [trends, setTrends] = useState([]);
- const [ownPosts, setOwnPosts] = useState([]);
- const [mentions, setMentions] = useState([]);
- const [accounts, setAccounts] = useState([]);
- const [scoutDone, setScoutDone] = useState(0);
- const [scoutGoal, setScoutGoal] = useState(8);
- const [outcomeSummary, setOutcomeSummary] = useState(null);
- const [checkup, setCheckup] = useState(null);
- const [radarToday, setRadarToday] = useState(null);
- const { active: firstRun } = useFirstRun();
-
- const dateLocale = locale === "en" ? "en-US" : "zh-TW";
-
- const todayLabel = new Date().toLocaleDateString(dateLocale, {
- month: "numeric",
- day: "numeric",
- weekday: "short",
- });
-
- const load = useCallback(async () => {
- setLoading(true);
- setError("");
- try {
- const scoutLocal = loadScoutToday();
- setScoutGoal(scoutLocal.goalValue);
-
- const [posts, box, trendList, acc, owns, mentionList, outcomes, latestCheckup, radar] =
- await Promise.all([
- repos.scout.listPosts(),
- repos.outbox.list(),
- repos.inspiration.listTrends("all").catch(() => [] as TrendItem[]),
- repos.accounts.list(),
- repos.ownPosts.list().catch(() => [] as OwnPost[]),
- repos.mentions.list().catch(() => [] as MentionItem[]),
- repos.growth.getOutcomeSummary("week").catch(() => null),
- repos.growth.getLatestCheckup().catch(() => null),
- repos.radar.getToday().catch(() => null),
- ]);
- setOutcomeSummary(outcomes);
- setCheckup(latestCheckup);
- setRadarToday(radar);
-
- const usable = acc.filter((a) => a.is_usable);
- setAccounts(usable.length ? usable : acc);
- setScoutPosts(posts);
- setOutbox(box);
- setTrends(
- [...trendList]
- .sort((a, b) => (b.heat || 0) - (a.heat || 0))
- .slice(0, 12),
- );
- setOwnPosts(owns);
- setMentions(mentionList);
-
- // 舊資料沒有發佈時間時不可拿歷史 published 總數冒充今日完成量。
- const dayStart = startOfLocalDayNano();
- const publishedN = posts.filter(
- (p) =>
- p.outreach_status === "published" &&
- p.published_at != null &&
- p.published_at >= dayStart,
- ).length;
- setScoutDone(Math.max(scoutLocal.done, publishedN));
-
- // 舊 API 若不支援全帳列表,分帳 fallback 在背景補齊,不阻塞首屏。
- if (!owns.length && usable.length) {
- void Promise.all(
- usable.slice(0, 6).map((a) => repos.ownPosts.list(a.id).catch(() => [] as OwnPost[])),
- ).then((chunks) => setOwnPosts(chunks.flat()));
- }
-
- if (!mentionList.length && usable[0]?.id) {
- void repos.mentions.list(usable[0].id).then(setMentions).catch(() => undefined);
- }
- } catch (e) {
- setError(formatApiError(e, "today.loadFail"));
- } finally {
- setLoading(false);
- }
- }, [repos, formatApiError]);
-
- useEffect(() => {
- void load();
- }, [load, tick]);
-
- useEffect(() => {
- const onStore = () => refresh();
- window.addEventListener("harbor:store", onStore);
- return () => window.removeEventListener("harbor:store", onStore);
- }, [refresh]);
-
- const dayStart = startOfLocalDayNano();
-
- const pendingScout = useMemo(
- () =>
- scoutPosts
- .filter(isPendingScout)
- .slice()
- .sort((a, b) => (b.score || 0) - (a.score || 0)),
- [scoutPosts],
- );
-
- const pendingMentions = useMemo(
- () => mentions.filter((m) => m.status === "pending"),
- [mentions],
- );
-
- const failedOutbox = useMemo(
- () => outbox.filter((o) => o.status === "partial_failed"),
- [outbox],
- );
- const runningOutbox = useMemo(
- () => outbox.filter((o) => o.status === "scheduling" || o.status === "active"),
- [outbox],
- );
- const sentToday = useMemo(() => {
- return outbox.filter((o) => {
- if (o.status !== "completed") return false;
- return o.updated_at >= dayStart;
- }).length;
- }, [outbox, dayStart]);
-
- const goalPct = Math.min(100, Math.round((scoutDone / Math.max(1, scoutGoal)) * 100));
-
- const topicCards = useMemo(() => trends.slice(0, 6), [trends]);
-
- const accountPulses = useMemo((): AccountPulse[] => {
- const byAcc = new Map();
- for (const p of ownPosts) {
- const list = byAcc.get(p.account_id) || [];
- list.push(p);
- byAcc.set(p.account_id, list);
- }
- const rows: AccountPulse[] = [];
- for (const acc of accounts) {
- const posts = byAcc.get(acc.id) || [];
- if (!posts.length && accounts.length > 3) continue;
- const views = posts.reduce((s, p) => s + (p.view_count || 0), 0);
- const likes = posts.reduce((s, p) => s + (p.like_count || 0), 0);
- const replies = posts.reduce((s, p) => s + (p.reply_count || 0), 0);
- const top = posts.slice().sort((a, b) => (b.view_count || 0) - (a.view_count || 0))[0];
- rows.push({
- account: acc,
- posts: posts.length,
- views,
- likes,
- replies,
- topInsight: top?.insight || top?.formula_summary,
- });
- }
- if (!rows.length) {
- for (const [accId, posts] of byAcc) {
- const acc =
- accounts.find((a) => a.id === accId) ||
- ({
- id: accId,
- username: accId,
- display_name: accId,
- connection: "connected",
- is_usable: true,
- avatar_color: "var(--hb-muted)",
- } as ThreadsAccount);
- rows.push({
- account: acc,
- posts: posts.length,
- views: posts.reduce((s, p) => s + (p.view_count || 0), 0),
- likes: posts.reduce((s, p) => s + (p.like_count || 0), 0),
- replies: posts.reduce((s, p) => s + (p.reply_count || 0), 0),
- topInsight: posts[0]?.insight,
- });
- }
- }
- return rows
- .sort((a, b) => b.views - a.views || b.likes - a.likes)
- .slice(0, 4);
- }, [accounts, ownPosts]);
-
- const pendingPreview = pendingScout.slice(0, 5);
-
- const hasOutcome = Boolean(
- outcomeSummary &&
- (outcomeSummary.reach ||
- outcomeSummary.conversations ||
- outcomeSummary.follows_possible ||
- outcomeSummary.follows_confirmed ||
- outcomeSummary.conversions),
- );
-
- async function onRefreshTrends() {
- setRefreshingTrends(true);
- setError("");
- setTrendMessage("");
- try {
- const list = await repos.inspiration.refreshTrends("all");
- if (!list.length) {
- throw new Error(t("today.trendsFail"));
- }
- setTrends(
- [...list].sort((a, b) => (b.heat || 0) - (a.heat || 0)).slice(0, 12),
- );
- setTrendMessage(t("today.trendsUpdated", { n: list.length }));
- refresh();
- } catch (e) {
- setError(formatApiError(e, "today.trendsFail"));
- } finally {
- setRefreshingTrends(false);
- }
- }
-
- async function onSyncOwnPosts() {
- const acc = accounts[0];
- if (!acc) {
- setError(t("today.needAccount"));
- return;
- }
- setSyncingPosts(true);
- setError("");
- try {
- const list = await repos.ownPosts.sync(acc.id);
- setOwnPosts((prev) => {
- const others = prev.filter((p) => p.account_id !== acc.id);
- return [...list, ...others];
- });
- refresh();
- } catch (e) {
- setError(formatApiError(e, "today.syncPostsFail"));
- } finally {
- setSyncingPosts(false);
- }
- }
-
- function topicHref(label: string) {
- const q = new URLSearchParams({ tab: "inspire", topic: label });
- return `/app/studio?${q.toString()}`;
- }
-
- if (loading && !scoutPosts.length && !trends.length && !outbox.length) {
- return (
- <>
-
- {t("common.loading")}
- >
- );
- }
-
- return (
- <>
-
-
- {error ? (
-
- {error}
-
- ) : null}
-
-
- {radarToday && radarToday.stats.total > 0 ? (
- <>
-
-
- {t("today.radar.total")}
- {radarToday.stats.total}
-
-
- {t("today.radar.high")}
- {radarToday.stats.high}
-
-
- {t("today.radar.mid")}
- {radarToday.stats.mid}
-
-
- {t("today.radar.low")}
- {radarToday.stats.low}
-
-
-
- {t("today.radar.open")}
-
- >
- ) : (
-
- {radarToday?.empty_hint || t("today.radar.empty")}{" "}
-
- {radarToday?.empty_reason === "no_profile"
- ? t("radar.today.empty.goBrands")
- : t("today.radar.goWatches")}
-
-
- )}
-
-
- {firstRun ? null : <>
-
-
-
- {t("today.outcome.reach")}
- {outcomeSummary?.reach ?? 0}
-
-
- {t("today.outcome.conversations")}
- {outcomeSummary?.conversations ?? 0}
-
-
- {t("today.outcome.follows")}
-
- {outcomeSummary?.follows_possible ?? 0}
- {outcomeSummary?.follows_confirmed ? (
-
- {t("today.outcome.followsConfirmedHint", { n: outcomeSummary.follows_confirmed })}
-
- ) : null}
-
- {t("today.outcome.followsHint")}
-
-
- {t("today.outcome.conversions")}
- {outcomeSummary?.conversions ?? 0}
- {outcomeSummary?.conversion_amount ? (
-
- ${Math.round(outcomeSummary.conversion_amount)}
-
- ) : null}
-
-
- {!hasOutcome ? (
-
- {t("today.outcome.emptyHint")}{" "}
- {t("today.goScout")}
-
- ) : null}
- {checkup ? (
-
-
- {t("today.checkup.prefix")}
- {checkup.summary}
-
-
- {checkup.actions.slice(0, 3).map((a) => (
- -
-
- {a.title}
-
- — {a.reason}
-
- ))}
-
-
- ) : (
-
- {t("today.checkup.empty")}
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
- {/* 數值列 */}
-
-
-
{t("today.metric.pending")}
-
{pendingScout.length}
-
{t("today.metric.pendingHint")}
-
-
-
{t("today.metric.doneGoal")}
-
- {scoutDone}
- /{scoutGoal}
-
-
-
-
-
{t("today.metric.sentToday")}
-
{sentToday}
-
- {runningOutbox.length
- ? t("today.metric.running", { n: runningOutbox.length })
- : t("today.metric.sentDone")}
-
-
-
-
{t("today.metric.failed")}
-
{failedOutbox.length}
-
- {failedOutbox.length ? t("today.metric.needAction") : t("today.metric.ok")}
-
-
-
-
{t("today.metric.mentions")}
-
{pendingMentions.length}
-
{t("today.metric.mentionsHint")}
-
-
-
- {/* 待回覆 · 海巡 */}
-
- {pendingPreview.length === 0 ? (
-
-
-
- }
- />
- ) : (
-
-
- {pendingPreview.map((p) => (
- -
-
-
- @{p.author}
- {p.search_tag ? ` · ${p.search_tag}` : ""}
- {typeof p.score === "number" ? ` · ${Math.round(p.score)}` : ""}
- {p.outreach_status === "drafted" ? (
- <>
- {" · "}
- {t("today.badge.drafted")}
- >
- ) : null}
-
-
- {p.text.slice(0, 96)}
- {p.text.length > 96 ? "…" : ""}
-
- {p.opportunity ? (
- {p.opportunity}
- ) : null}
-
-
- ))}
-
- {pendingScout.length > pendingPreview.length ? (
-
- {t("today.pending.more", { n: pendingScout.length - pendingPreview.length })}{" "}
- {t("today.pending.handle")}
-
- ) : (
-
-
-
- )}
-
- )}
-
-
- {/* 找話題 */}
-
-
-
- {trendMessage ? (
-
- {trendMessage}
-
- ) : null}
-
- {topicCards.length === 0 ? (
-
-
-
-
-
-
- }
- />
- ) : (
-