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 { 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 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("today.radar.goProfile") : t("today.radar.goWatches")}

)}
{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 ? (
} /> ) : (
    {topicCards.map((item) => (
  • {item.label} {typeof item.heat === "number" && item.heat > 0 ? ( {t("today.heat", { n: item.heat })} ) : null} {item.source_label ? ( · {item.source_label} ) : null} {item.summary?.slice(0, 100) || item.samples?.[0] || t("today.topicAngle")} {(item.summary?.length || 0) > 100 ? "…" : ""}
  • ))}
{topicCards[0] ? ( ) : null}
)} {/* 發送摘要 */} {failedOutbox.length === 0 && runningOutbox.length === 0 && sentToday === 0 ? (

{t("today.outbox.empty")}{" "} {t("today.newThread")} {t("today.outbox.emptyMid")}{" "} {t("nav.outbox")} {t("today.outbox.emptyEnd")}

) : (

{t("today.outbox.summary", { sent: sentToday, running: runningOutbox.length, failed: failedOutbox.length, })}

{failedOutbox.slice(0, 2).map((o) => (

{t("today.badge.failed")}{" "} {o.title || o.id.slice(0, 8)}

))} {runningOutbox.slice(0, 2).map((o) => (

{o.status === "scheduling" ? t("today.badge.scheduling") : t("today.badge.sending")} {" "} {o.title || o.id.slice(0, 8)}

))}
)}
{/* 帳號成效 */} {accountPulses.length === 0 ? ( {accounts.length > 0 ? ( ) : ( )} } /> ) : (
    {accountPulses.map((row) => (
  • @{row.account.username} {t("today.postsCount", { n: row.posts })}
    {t("today.views")}{" "} {row.views.toLocaleString(dateLocale)} {t("today.likes")} {row.likes} {t("today.repliesShort")} {row.replies}
    {row.topInsight ? (

    {row.topInsight}

    ) : null}
  • ))}
)}
); }