feat(plugin): migrate time investment plugin

This commit is contained in:
digua committed 2026-08-18 14:29:38 +08:00
1 parent b00959fd5f
commit 0f46352b8d
28 files changed
+511 -436

No files matched your search

+2 -2
View File
@@ -45,7 +45,7 @@ import {
MergeSessionCache, MergeSessionCache,
type SemanticIndexRuntime, type SemanticIndexRuntime,
} from '@openchatlab/node-runtime' } from '@openchatlab/node-runtime'
import { annualSummaryNodePlugin, registerSharedRoutes } from '@openchatlab/http-routes' import { annualSummaryNodePlugin, registerSharedRoutes, timeInvestmentNodePlugin } from '@openchatlab/http-routes'
import type { HttpRouteContext } from '@openchatlab/http-routes' import type { HttpRouteContext } from '@openchatlab/http-routes'
import { registerImportRoutes } from './import' import { registerImportRoutes } from './import'
import { openDirectoryPath, showPathInFolder } from './cache' import { openDirectoryPath, showPathInFolder } from './cache'
@@ -177,7 +177,7 @@ export function registerWebRoutes(
}), }),
}, },
{ {
nodePlugins: [annualSummaryNodePlugin], nodePlugins: [annualSummaryNodePlugin, timeInvestmentNodePlugin],
...(ai && { requireAi: true }), ...(ai && { requireAi: true }),
} }
) )
+7 -2
View File
@@ -29,7 +29,12 @@ import {
import type { StreamImportDeps, SemanticIndexRuntime } from '@openchatlab/node-runtime' import type { StreamImportDeps, SemanticIndexRuntime } from '@openchatlab/node-runtime'
import { getLoadablePath as getSqliteVecLoadablePath } from 'sqlite-vec' import { getLoadablePath as getSqliteVecLoadablePath } from 'sqlite-vec'
import multipart from '@fastify/multipart' import multipart from '@fastify/multipart'
import { annualSummaryNodePlugin, registerSharedRoutes, registerAutomationRoutes } from '@openchatlab/http-routes' import {
annualSummaryNodePlugin,
registerSharedRoutes,
registerAutomationRoutes,
timeInvestmentNodePlugin,
} from '@openchatlab/http-routes'
import { createApiServer } from '@openchatlab/http-routes/server' import { createApiServer } from '@openchatlab/http-routes/server'
import type { HttpRouteContext } from '@openchatlab/http-routes' import type { HttpRouteContext } from '@openchatlab/http-routes'
import { reloadTimer, stopTimer, type DataSourceManager, type PullEngine } from '@openchatlab/sync' import { reloadTimer, stopTimer, type DataSourceManager, type PullEngine } from '@openchatlab/sync'
@@ -208,7 +213,7 @@ export async function startInternalServer(
registerSharedRoutes(newServer, ctx, { registerSharedRoutes(newServer, ctx, {
requireAi: true, requireAi: true,
nodePlugins: [annualSummaryNodePlugin], nodePlugins: [annualSummaryNodePlugin, timeInvestmentNodePlugin],
}) })
registerAutomationRoutes(newServer, ctx) registerAutomationRoutes(newServer, ctx)
+3 -1
View File
@@ -18,7 +18,8 @@ import { WebWasmSessionSync } from './session-sync'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { initializeWebWasmLocale } from './locale-bootstrap' import { initializeWebWasmLocale } from './locale-bootstrap'
import { installInsightPluginRuntime } from '@/plugins/insight-vue' import { installInsightPluginRuntime } from '@/plugins/insight-vue'
import { webWasmInsightRuntime, webWasmUiHost } from '@/plugins/web-wasm' import { webWasmInsightBuiltins, webWasmInsightRuntime, webWasmUiHost, webWasmUiServices } from '@/plugins/web-wasm'
import { installStaticInsightPluginUiServices } from '@/plugins/static-insight'
import '@/assets/styles/main.css' import '@/assets/styles/main.css'
initializeWebWasmLocale() initializeWebWasmLocale()
@@ -52,6 +53,7 @@ async function start(): Promise<void> {
app.use(router) app.use(router)
app.use(ui) app.use(ui)
app.use(i18n) app.use(i18n)
await installStaticInsightPluginUiServices(webWasmInsightBuiltins, webWasmInsightRuntime, webWasmUiServices)
installInsightPluginRuntime(app, webWasmInsightRuntime) installInsightPluginRuntime(app, webWasmInsightRuntime)
const sessionStore = useSessionStore(pinia) const sessionStore = useSessionStore(pinia)
+7 -9
View File
@@ -1,4 +1,9 @@
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import { createVueInsightRouteRecords } from '@/plugins/insight-vue'
import { webWasmInsightRuntime } from '@/plugins/web-wasm'
const defaultInsightRoute =
webWasmInsightRuntime.getDefaultPage()?.routeName ?? webWasmInsightRuntime.listPages()[0]?.routeName
export const webWasmRoutes: RouteRecordRaw[] = [ export const webWasmRoutes: RouteRecordRaw[] = [
{ {
@@ -19,14 +24,7 @@ export const webWasmRoutes: RouteRecordRaw[] = [
{ {
path: '/insight', path: '/insight',
component: () => import('@/pages/insight/index.vue'), component: () => import('@/pages/insight/index.vue'),
redirect: { name: 'insight-time-investment' }, redirect: defaultInsightRoute ? { name: defaultInsightRoute } : undefined,
children: [ children: createVueInsightRouteRecords(webWasmInsightRuntime),
{
path: 'time-investment',
name: 'insight-time-investment',
component: () => import('@/pages/insight/time-investment/index.vue'),
meta: { insightPageId: 'time-investment' },
},
],
}, },
] ]
+1
View File
@@ -9,6 +9,7 @@ export type {
export { registerSharedRoutes } from './register' export { registerSharedRoutes } from './register'
export type { SharedRouteOptions } from './register' export type { SharedRouteOptions } from './register'
export { annualSummaryNodePlugin } from './plugins/builtin/annual-summary' export { annualSummaryNodePlugin } from './plugins/builtin/annual-summary'
export { timeInvestmentNodePlugin } from './plugins/builtin/time-investment'
export { registerNodePlugins } from './plugins/node' export { registerNodePlugins } from './plugins/node'
export type { NodePluginContext, NodePluginDescriptor } from './plugins/node' export type { NodePluginContext, NodePluginDescriptor } from './plugins/node'
export { executeRegistryTool } from './ai/tool-executor' export { executeRegistryTool } from './ai/tool-executor'
@@ -8,9 +8,8 @@ import type {
} from '@openchatlab/node-runtime' } from '@openchatlab/node-runtime'
import type { TimeInvestmentResponse } from '@openchatlab/shared-types' import type { TimeInvestmentResponse } from '@openchatlab/shared-types'
import type { PathProvider } from '@openchatlab/core' import type { PathProvider } from '@openchatlab/core'
import { registerTimeInvestmentRoutes } from './time-investment' import { registerNodePlugins, type NodePluginContext } from '../../node'
import { timeInvestmentNodePlugin } from '.'
type TimeInvestmentRouteContext = Parameters<typeof registerTimeInvestmentRoutes>[1]
function emptyTimeInvestmentResponse(): TimeInvestmentResponse { function emptyTimeInvestmentResponse(): TimeInvestmentResponse {
return { return {
@@ -58,7 +57,7 @@ class FakeTimeInvestmentService implements TimeInvestmentService {
} }
} }
function timeInvestmentContext(service: TimeInvestmentService): TimeInvestmentRouteContext { function context(service: TimeInvestmentService): NodePluginContext {
return { return {
timeInvestmentService: service, timeInvestmentService: service,
sessionAdapter: {} as SessionRuntimeAdapter, sessionAdapter: {} as SessionRuntimeAdapter,
@@ -66,10 +65,10 @@ function timeInvestmentContext(service: TimeInvestmentService): TimeInvestmentRo
} }
} }
test('time investment routes forward range, stale, recompute, and close the service', async () => { test('time investment plugin forwards range, stale, recompute, and owns its service lifecycle', async () => {
const timeInvestmentService = new FakeTimeInvestmentService() const timeInvestmentService = new FakeTimeInvestmentService()
const app = Fastify() const app = Fastify()
registerTimeInvestmentRoutes(app, timeInvestmentContext(timeInvestmentService)) registerNodePlugins(app, context(timeInvestmentService), [timeInvestmentNodePlugin])
await app.ready() await app.ready()
const getResponse = await app.inject({ const getResponse = await app.inject({
@@ -88,3 +87,17 @@ test('time investment routes forward range, stale, recompute, and close the serv
await app.close() await app.close()
assert.equal(timeInvestmentService.closeCalls, 1) assert.equal(timeInvestmentService.closeCalls, 1)
}) })
test('omitting the time investment plugin leaves its API unavailable', async (t) => {
const service = new FakeTimeInvestmentService()
const app = Fastify()
t.after(() => app.close())
registerNodePlugins(app, context(service), [])
await app.ready()
const response = await app.inject({ method: 'GET', url: '/_web/global-insight/time-investment' })
assert.equal(response.statusCode, 404)
assert.equal(service.getCalls.length, 0)
assert.equal(service.closeCalls, 0)
})
@@ -0,0 +1,62 @@
import { createTimeInvestmentService, PreferencesManager } from '@openchatlab/node-runtime'
import { TIME_INVESTMENT_PLUGIN_ID } from '@openchatlab/shared-types'
import type { NodePluginDescriptor } from '../../node'
interface TimeInvestmentQuery {
mode?: string
year?: string
days?: string
acceptStale?: string
}
export const timeInvestmentNodePlugin = {
id: TIME_INVESTMENT_PLUGIN_ID,
registerHttpRoutes(server, context) {
let preferences = context.preferencesManager
const getExcludedSessionIds = () => {
preferences ??= new PreferencesManager(context.pathProvider.getSystemDir())
return preferences.load().ownerExcludedSessionIds
}
const service =
context.timeInvestmentService ??
createTimeInvestmentService({
adapter: context.sessionAdapter,
pathProvider: context.pathProvider,
runtimeIdentity: context.runtimeIdentity,
nativeBinding: context.nativeBinding,
getExcludedSessionIds,
})
server.addHook('onClose', () => service.close())
server.get<{ Querystring: TimeInvestmentQuery }>('/_web/global-insight/time-investment', async (request) =>
service.getTimeInvestment({
...parseRange(request.query),
acceptStale: isTruthy(request.query.acceptStale),
})
)
server.post<{ Querystring: TimeInvestmentQuery }>(
'/_web/global-insight/time-investment/recompute',
async (request) => service.startRecompute(parseRange(request.query))
)
},
} satisfies NodePluginDescriptor
function parseRange(query: TimeInvestmentQuery) {
const mode = query.mode === 'recent' ? ('recent' as const) : ('year' as const)
return {
mode,
year: parseInteger(query.year),
days: query.days === '365' ? (365 as const) : undefined,
}
}
function parseInteger(value: string | undefined): number | undefined {
if (!value) return undefined
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : undefined
}
function isTruthy(value: string | undefined): boolean {
return value === '1' || value === 'true' || value === 'yes'
}
@@ -231,7 +231,7 @@ describe('registerSharedRoutes smoke tests', () => {
}) })
it('registers host insights without implicitly enabling optional Node plugins', () => { it('registers host insights without implicitly enabling optional Node plugins', () => {
assert.equal(app.hasRoute({ method: 'GET', url: '/_web/global-insight/time-investment' }), true) assert.equal(app.hasRoute({ method: 'GET', url: '/_web/global-insight/time-investment' }), false)
assert.equal(app.hasRoute({ method: 'GET', url: '/_web/global-insight/annual-summary' }), false) assert.equal(app.hasRoute({ method: 'GET', url: '/_web/global-insight/annual-summary' }), false)
}) })
@@ -11,7 +11,6 @@ import { registerAnalyticsRoutes } from './analytics'
import { registerCacheRoutes } from './cache' import { registerCacheRoutes } from './cache'
import { registerContactsRoutes } from './contacts' import { registerContactsRoutes } from './contacts'
import { registerExportRoutes } from './export' import { registerExportRoutes } from './export'
import { registerTimeInvestmentRoutes } from './time-investment'
import { registerLogRoutes } from './logs' import { registerLogRoutes } from './logs'
import { registerMemberRoutes } from './members' import { registerMemberRoutes } from './members'
import { registerMergeRoutes } from './merge' import { registerMergeRoutes } from './merge'
@@ -45,7 +44,6 @@ export function registerWebRoutes(server: FastifyInstance, ctx: WebRoutesContext
registerMemberRoutes(server, resolvedCtx) registerMemberRoutes(server, resolvedCtx)
registerContactsRoutes(server, resolvedCtx) registerContactsRoutes(server, resolvedCtx)
registerPeopleRelationshipsRoutes(server, resolvedCtx) registerPeopleRelationshipsRoutes(server, resolvedCtx)
registerTimeInvestmentRoutes(server, resolvedCtx)
registerNodePlugins(server, resolvedCtx, options?.nodePlugins ?? []) registerNodePlugins(server, resolvedCtx, options?.nodePlugins ?? [])
registerPreferencesRoutes(server, resolvedCtx) registerPreferencesRoutes(server, resolvedCtx)
registerAnalyticsRoutes(server, resolvedCtx) registerAnalyticsRoutes(server, resolvedCtx)
@@ -1,65 +0,0 @@
import type { FastifyInstance } from 'fastify'
import { createTimeInvestmentService, PreferencesManager } from '@openchatlab/node-runtime'
import type { RuntimeRouteContext } from '../../context/runtime'
import type { ServiceRouteContext } from '../../context/services'
interface TimeInvestmentQuery {
mode?: string
year?: string
days?: string
acceptStale?: string
}
type TimeInvestmentRouteContext = Pick<
RuntimeRouteContext,
'sessionAdapter' | 'pathProvider' | 'runtimeIdentity' | 'nativeBinding'
> &
Pick<ServiceRouteContext, 'timeInvestmentService' | 'preferencesManager'>
export function registerTimeInvestmentRoutes(server: FastifyInstance, ctx: TimeInvestmentRouteContext): void {
let preferences = ctx.preferencesManager
const getExcludedSessionIds = () => {
preferences ??= new PreferencesManager(ctx.pathProvider.getSystemDir())
return preferences.load().ownerExcludedSessionIds
}
const service =
ctx.timeInvestmentService ??
createTimeInvestmentService({
adapter: ctx.sessionAdapter,
pathProvider: ctx.pathProvider,
runtimeIdentity: ctx.runtimeIdentity,
nativeBinding: ctx.nativeBinding,
getExcludedSessionIds,
})
server.addHook('onClose', () => service.close())
server.get<{ Querystring: TimeInvestmentQuery }>('/_web/global-insight/time-investment', async (request) =>
service.getTimeInvestment({
...parseRange(request.query),
acceptStale: isTruthy(request.query.acceptStale),
})
)
server.post<{ Querystring: TimeInvestmentQuery }>('/_web/global-insight/time-investment/recompute', async (request) =>
service.startRecompute(parseRange(request.query))
)
}
function parseRange(query: TimeInvestmentQuery) {
const mode = query.mode === 'recent' ? ('recent' as const) : ('year' as const)
return {
mode,
year: parseInteger(query.year),
days: query.days === '365' ? (365 as const) : undefined,
}
}
function parseInteger(value: string | undefined): number | undefined {
if (!value) return undefined
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : undefined
}
function isTruthy(value: string | undefined): boolean {
return value === '1' || value === 'true' || value === 'yes'
}
+1
View File
@@ -7,6 +7,7 @@
/** Stable identity shared by the UI and Node facets of the built-in plugin. */ /** Stable identity shared by the UI and Node facets of the built-in plugin. */
export const ANNUAL_SUMMARY_PLUGIN_ID = 'chatlab.insight.annual-summary' export const ANNUAL_SUMMARY_PLUGIN_ID = 'chatlab.insight.annual-summary'
export const TIME_INVESTMENT_PLUGIN_ID = 'chatlab.insight.time-investment'
// ==================== 时间筛选 ==================== // ==================== 时间筛选 ====================
-57
View File
@@ -3,63 +3,6 @@
"reserved": "This view will be available in a future release", "reserved": "This view will be available in a future release",
"tabs": { "tabs": {
"nav": "Footprint views", "nav": "Footprint views",
"timeInvestment": "Time Investment",
"relationshipChanges": "Relationship Changes" "relationshipChanges": "Relationship Changes"
},
"monthLabel": "Month {month}",
"noData": "No analyzable data in this time range",
"overviewCard": {
"keyMetrics": "Key Metrics"
},
"timeInvestment": {
"yearTitle": "Time invested in {year}",
"recentTitle": "Time invested in the last year",
"estimateNote": "Estimated from message timing with overlapping conversations merged. It shows engagement trends, not exact screen time.",
"narrative": {
"totalPrefix": "Over the year, your estimated chat investment was",
"totalSuffix": ".",
"daysPrefix": "It was spread across",
"daysMiddle": "active days, averaging",
"daysSuffix": "per active day."
},
"loading": "Loading time investment…",
"failed": "Time investment calculation failed. You can retry it.",
"privateChat": "Private chats",
"groupChat": "Group chats",
"shareOfTotal": "{share}% of total investment",
"peakDay": "Highest-investment day",
"peakWeekday": "Highest daily-average weekday",
"chatTypeTitle": "Conversation types",
"monthlyTitle": "Monthly trend",
"monthlyDescription": "Estimated chat investment by month, in hours",
"rhythmTitle": "Investment rhythm",
"rhythmDescription": "Average daily investment by weekday",
"topDaysTitle": "Highest-investment days",
"rankingTitle": "Where the time went",
"rankingDescription": "Shows the 10 private and group chats with the most invested time; overlapping time is divided evenly between conversations",
"rankingCount": "{count} chats",
"kpis": {
"estimated": "Estimated chat investment",
"activeDays": "Investment days",
"dailyAverage": "Average per active day"
},
"duration": {
"lessThanMinute": "Less than 1 min",
"minutes": "{count} min",
"hours": "{count} hr",
"hoursMinutes": "{hours} hr {minutes} min"
}
},
"status": {
"computingProgress": "Analyzing conversations {processed}/{total}…",
"updating": "Data changed; this result is being refreshed in the background",
"coverage": "Analyzed {analyzed} of {total} conversations",
"ownerIssues": "{count} conversations still need a valid “me” identity",
"noAnalyzableOwner": "No conversation has a usable identity yet. Set who “me” is in a conversation first.",
"noDataWithLatest": "No analyzable data for {year}; the latest available year is {latestYear}"
},
"actions": {
"retry": "Retry",
"switchYear": "View {year}"
} }
} }
-57
View File
@@ -3,63 +3,6 @@
"reserved": "このビューは今後のバージョンで公開予定です", "reserved": "このビューは今後のバージョンで公開予定です",
"tabs": { "tabs": {
"nav": "足跡ビュー", "nav": "足跡ビュー",
"timeInvestment": "時間の投入",
"relationshipChanges": "関係の変化" "relationshipChanges": "関係の変化"
},
"monthLabel": "{month}月",
"noData": "この期間には分析可能なデータがありません",
"overviewCard": {
"keyMetrics": "主要指標"
},
"timeInvestment": {
"yearTitle": "{year} 年の時間投入",
"recentTitle": "直近1年間の時間投入",
"estimateNote": "メッセージの時間間隔から推定し、複数の会話で重なる時間帯を統合しています。正確な画面使用時間ではなく、会話への投入傾向を示します。",
"narrative": {
"totalPrefix": "この一年の推定チャット投入時間は",
"totalSuffix": "でした。",
"daysPrefix": "投入は",
"daysMiddle": "日間にわたり、アクティブ日の平均は",
"daysSuffix": "でした。"
},
"loading": "時間投入を読み込んでいます…",
"failed": "時間投入の計算に失敗しました。再試行できます。",
"privateChat": "個別チャット",
"groupChat": "グループチャット",
"shareOfTotal": "全体の {share}%",
"peakDay": "投入時間が最も長い日",
"peakWeekday": "1日平均が最も長い曜日",
"chatTypeTitle": "会話タイプ",
"monthlyTitle": "月別推移",
"monthlyDescription": "月ごとの推定チャット投入時間。縦軸は時間です",
"rhythmTitle": "投入のリズム",
"rhythmDescription": "曜日別の1日平均投入時間",
"topDaysTitle": "投入時間が長い日",
"rankingTitle": "時間を使った会話",
"rankingDescription": "個別チャットとグループチャットで投入時間の多い上位10件を表示します。重なる時間は会話間で均等に配分されます",
"rankingCount": "{count} 件",
"kpis": {
"estimated": "推定チャット投入時間",
"activeDays": "投入日数",
"dailyAverage": "活動日あたりの平均"
},
"duration": {
"lessThanMinute": "1分未満",
"minutes": "{count}分",
"hours": "{count}時間",
"hoursMinutes": "{hours}時間{minutes}分"
}
},
"status": {
"computingProgress": "会話を分析しています {processed}/{total}…",
"updating": "データが変更されたため、バックグラウンドで更新しています",
"coverage": "{total} 件中 {analyzed} 件の会話を分析済み",
"ownerIssues": "{count} 件の会話で「自分」の設定が必要です",
"noAnalyzableOwner": "足跡の分析に使用できる本人情報がありません。先に会話で「自分」を設定してください。",
"noDataWithLatest": "{year} 年の分析データはありません。最新の有効年は {latestYear} 年です"
},
"actions": {
"retry": "再試行",
"switchYear": "{year} 年を見る"
} }
} }
-57
View File
@@ -3,63 +3,6 @@
"reserved": "该视图将在后续版本开放", "reserved": "该视图将在后续版本开放",
"tabs": { "tabs": {
"nav": "足迹视图", "nav": "足迹视图",
"timeInvestment": "时间投入",
"relationshipChanges": "关系变化" "relationshipChanges": "关系变化"
},
"monthLabel": "{month}月",
"noData": "当前时间范围内暂无可分析数据",
"overviewCard": {
"keyMetrics": "关键指标"
},
"timeInvestment": {
"yearTitle": "{year} 年的时间投入",
"recentTitle": "最近一年的时间投入",
"estimateNote": "根据消息时间间隔估算,并合并多个会话的重叠时段;它反映聊天投入趋势,不是精确的屏幕使用时间。",
"narrative": {
"totalPrefix": "这一年,你预估在聊天中投入了",
"totalSuffix": "。",
"daysPrefix": "这些时间分布在",
"daysMiddle": "个活跃日,活跃日平均投入",
"daysSuffix": "。"
},
"loading": "正在读取时间投入…",
"failed": "时间投入计算失败,可以重试",
"privateChat": "私聊",
"groupChat": "群聊",
"shareOfTotal": "占全部投入 {share}%",
"peakDay": "最高投入日",
"peakWeekday": "日均投入最高星期",
"chatTypeTitle": "对话类型",
"monthlyTitle": "月度趋势",
"monthlyDescription": "每月预估聊天投入,纵轴单位为小时",
"rhythmTitle": "投入节奏",
"rhythmDescription": "比较一周中各天的平均投入",
"topDaysTitle": "高投入日",
"rankingTitle": "时间都去了哪里",
"rankingDescription": "私聊与群聊分别展示投入时间最多的 10 个会话;重叠时间会在会话之间平均分配",
"rankingCount": "{count} 个会话",
"kpis": {
"estimated": "预估聊天投入",
"activeDays": "投入天数",
"dailyAverage": "活跃日均投入"
},
"duration": {
"lessThanMinute": "不足 1 分钟",
"minutes": "{count} 分钟",
"hours": "{count} 小时",
"hoursMinutes": "{hours} 小时 {minutes} 分"
}
},
"status": {
"computingProgress": "正在分析会话 {processed}/{total}…",
"updating": "数据发生变化,正在后台更新当前结果",
"coverage": "已分析 {analyzed}/{total} 个会话",
"ownerIssues": "{count} 个会话尚未正确设置“我是谁”",
"noAnalyzableOwner": "还没有可用于足迹分析的身份,请先在会话中设置“我是谁”",
"noDataWithLatest": "{year} 年暂无可分析数据,最近有效数据为 {latestYear} 年"
},
"actions": {
"retry": "重试",
"switchYear": "查看 {year} 年"
} }
} }
-57
View File
@@ -3,63 +3,6 @@
"reserved": "此檢視將於後續版本開放", "reserved": "此檢視將於後續版本開放",
"tabs": { "tabs": {
"nav": "足跡檢視", "nav": "足跡檢視",
"timeInvestment": "時間投入",
"relationshipChanges": "關係變化" "relationshipChanges": "關係變化"
},
"monthLabel": "{month}月",
"noData": "目前時間範圍內沒有可分析資料",
"overviewCard": {
"keyMetrics": "關鍵指標"
},
"timeInvestment": {
"yearTitle": "{year} 年的時間投入",
"recentTitle": "最近一年的時間投入",
"estimateNote": "根據訊息時間間隔估算,並合併多個會話的重疊時段;它反映聊天投入趨勢,不是精確的螢幕使用時間。",
"narrative": {
"totalPrefix": "這一年,你預估在聊天中投入了",
"totalSuffix": "。",
"daysPrefix": "這些時間分佈在",
"daysMiddle": "個活躍日,活躍日平均投入",
"daysSuffix": "。"
},
"loading": "正在讀取時間投入…",
"failed": "時間投入計算失敗,可以重試",
"privateChat": "私聊",
"groupChat": "群聊",
"shareOfTotal": "佔全部投入 {share}%",
"peakDay": "最高投入日",
"peakWeekday": "日均投入最高星期",
"chatTypeTitle": "對話類型",
"monthlyTitle": "月度趨勢",
"monthlyDescription": "每月預估聊天投入,縱軸單位為小時",
"rhythmTitle": "投入節奏",
"rhythmDescription": "比較一週中每天的平均投入",
"topDaysTitle": "高投入日",
"rankingTitle": "時間都去了哪裡",
"rankingDescription": "私聊與群聊分別展示投入時間最多的 10 個會話;重疊時間會在會話之間平均分配",
"rankingCount": "{count} 個會話",
"kpis": {
"estimated": "預估聊天投入",
"activeDays": "投入天數",
"dailyAverage": "活躍日均投入"
},
"duration": {
"lessThanMinute": "不足 1 分鐘",
"minutes": "{count} 分鐘",
"hours": "{count} 小時",
"hoursMinutes": "{hours} 小時 {minutes} 分"
}
},
"status": {
"computingProgress": "正在分析會話 {processed}/{total}…",
"updating": "資料已變更,正在背景更新目前結果",
"coverage": "已分析 {analyzed}/{total} 個會話",
"ownerIssues": "{count} 個會話尚未正確設定「我是誰」",
"noAnalyzableOwner": "尚無可用於足跡分析的身分,請先在會話中設定「我是誰」",
"noDataWithLatest": "{year} 年沒有可分析資料,最近有效資料為 {latestYear} 年"
},
"actions": {
"retry": "重試",
"switchYear": "查看 {year} 年"
} }
} }
@@ -0,0 +1,4 @@
import { TIME_INVESTMENT_PLUGIN_ID } from '@openchatlab/shared-types'
export { TIME_INVESTMENT_PLUGIN_ID }
export const TIME_INVESTMENT_LOCALE_NAMESPACE = `plugins.${TIME_INVESTMENT_PLUGIN_ID}`
@@ -0,0 +1,28 @@
import { watch } from 'vue'
import { useDataService } from '@/services'
import { useLayoutStore } from '@/stores/layout'
import { PLATFORM_CAPABILITIES } from '@/utils/platform-capabilities'
import type { UiServiceRegistrar } from '../../ui-host'
import { TIME_INVESTMENT_UI_SERVICE, type TimeInvestmentUiService } from './service'
export function createTimeInvestmentUiService(): TimeInvestmentUiService {
return {
canConfigureOwner: !PLATFORM_CAPABILITIES.usesBrowserRuntime,
get: (options) => useDataService().getTimeInvestment(options),
recompute: (options) => useDataService().recomputeTimeInvestment(options),
openOwnerSettings: () => useLayoutStore().openSettings('data', 'missing-owner'),
subscribeOwnerSettingsClosed: (listener) => {
const layoutStore = useLayoutStore()
return watch(
() => layoutStore.showSettings,
(visible, wasVisible) => {
if (wasVisible && !visible) listener()
}
)
},
}
}
export function registerTimeInvestmentUiService(services: UiServiceRegistrar): void {
services.register(TIME_INVESTMENT_UI_SERVICE, createTimeInvestmentUiService())
}
@@ -0,0 +1 @@
export { timeInvestmentBuiltin } from './plugin'
@@ -0,0 +1,183 @@
import type { PluginLocaleDictionary, PluginLocaleMessages, PluginTranslate } from '../../locale'
const enUS = {
title: 'Time Investment',
yearTitle: 'Time invested in {year}',
recentTitle: 'Time invested in the last year',
estimateNote:
'Estimated from message timing with overlapping conversations merged. It shows engagement trends, not exact screen time.',
'narrative.totalPrefix': 'Over the year, your estimated chat investment was',
'narrative.totalSuffix': '.',
'narrative.daysPrefix': 'It was spread across',
'narrative.daysMiddle': 'active days, averaging',
'narrative.daysSuffix': 'per active day.',
loading: 'Loading time investment…',
failed: 'Time investment calculation failed. You can retry it.',
privateChat: 'Private chats',
groupChat: 'Group chats',
shareOfTotal: '{share}% of total investment',
peakDay: 'Highest-investment day',
peakWeekday: 'Highest daily-average weekday',
monthlyTitle: 'Monthly trend',
monthlyDescription: 'Estimated chat investment by month, in hours',
rhythmTitle: 'Investment rhythm',
rhythmDescription: 'Average daily investment by weekday',
topDaysTitle: 'Highest-investment days',
rankingTitle: 'Where the time went',
rankingDescription:
'Shows the 10 private and group chats with the most invested time; overlapping time is divided evenly between conversations',
rankingCount: '{count} chats',
'duration.lessThanMinute': 'Less than 1 min',
'duration.minutes': '{count} min',
'duration.hours': '{count} hr',
'duration.hoursMinutes': '{hours} hr {minutes} min',
monthLabel: 'Month {month}',
noData: 'No analyzable data in this time range',
keyMetrics: 'Key Metrics',
'status.computingProgress': 'Analyzing conversations {processed}/{total}…',
'status.updating': 'Data changed; this result is being refreshed in the background',
'status.coverage': 'Analyzed {analyzed} of {total} conversations',
'status.ownerIssues': '{count} conversations still need a valid “me” identity',
'status.noAnalyzableOwner': 'No conversation has a usable identity yet. Set who “me” is in a conversation first.',
'status.noDataWithLatest': 'No analyzable data for {year}; the latest available year is {latestYear}',
'actions.retry': 'Retry',
'actions.switchYear': 'View {year}',
} as const
export type TimeInvestmentLocaleKey = keyof typeof enUS
export type TimeInvestmentTranslate = PluginTranslate<TimeInvestmentLocaleKey>
const zhCN = {
title: '时间投入',
yearTitle: '{year} 年的时间投入',
recentTitle: '最近一年的时间投入',
estimateNote: '根据消息时间间隔估算,并合并多个会话的重叠时段;它反映聊天投入趋势,不是精确的屏幕使用时间。',
'narrative.totalPrefix': '这一年,你预估在聊天中投入了',
'narrative.totalSuffix': '。',
'narrative.daysPrefix': '这些时间分布在',
'narrative.daysMiddle': '个活跃日,活跃日平均投入',
'narrative.daysSuffix': '。',
loading: '正在读取时间投入…',
failed: '时间投入计算失败,可以重试',
privateChat: '私聊',
groupChat: '群聊',
shareOfTotal: '占全部投入 {share}%',
peakDay: '最高投入日',
peakWeekday: '日均投入最高星期',
monthlyTitle: '月度趋势',
monthlyDescription: '每月预估聊天投入,纵轴单位为小时',
rhythmTitle: '投入节奏',
rhythmDescription: '比较一周中各天的平均投入',
topDaysTitle: '高投入日',
rankingTitle: '时间都去了哪里',
rankingDescription: '私聊与群聊分别展示投入时间最多的 10 个会话;重叠时间会在会话之间平均分配',
rankingCount: '{count} 个会话',
'duration.lessThanMinute': '不足 1 分钟',
'duration.minutes': '{count} 分钟',
'duration.hours': '{count} 小时',
'duration.hoursMinutes': '{hours} 小时 {minutes} 分',
monthLabel: '{month}月',
noData: '当前时间范围内暂无可分析数据',
keyMetrics: '关键指标',
'status.computingProgress': '正在分析会话 {processed}/{total}…',
'status.updating': '数据发生变化,正在后台更新当前结果',
'status.coverage': '已分析 {analyzed}/{total} 个会话',
'status.ownerIssues': '{count} 个会话尚未正确设置“我是谁”',
'status.noAnalyzableOwner': '还没有可用于足迹分析的身份,请先在会话中设置“我是谁”',
'status.noDataWithLatest': '{year} 年暂无可分析数据,最近有效数据为 {latestYear} 年',
'actions.retry': '重试',
'actions.switchYear': '查看 {year} 年',
} as const satisfies PluginLocaleDictionary<TimeInvestmentLocaleKey>
const zhTW = {
title: '時間投入',
yearTitle: '{year} 年的時間投入',
recentTitle: '最近一年的時間投入',
estimateNote: '根據訊息時間間隔估算,並合併多個會話的重疊時段;它反映聊天投入趨勢,不是精確的螢幕使用時間。',
'narrative.totalPrefix': '這一年,你預估在聊天中投入了',
'narrative.totalSuffix': '。',
'narrative.daysPrefix': '這些時間分佈在',
'narrative.daysMiddle': '個活躍日,活躍日平均投入',
'narrative.daysSuffix': '。',
loading: '正在讀取時間投入…',
failed: '時間投入計算失敗,可以重試',
privateChat: '私聊',
groupChat: '群聊',
shareOfTotal: '佔全部投入 {share}%',
peakDay: '最高投入日',
peakWeekday: '日均投入最高星期',
monthlyTitle: '月度趨勢',
monthlyDescription: '每月預估聊天投入,縱軸單位為小時',
rhythmTitle: '投入節奏',
rhythmDescription: '比較一週中每天的平均投入',
topDaysTitle: '高投入日',
rankingTitle: '時間都去了哪裡',
rankingDescription: '私聊與群聊分別展示投入時間最多的 10 個會話;重疊時間會在會話之間平均分配',
rankingCount: '{count} 個會話',
'duration.lessThanMinute': '不足 1 分鐘',
'duration.minutes': '{count} 分鐘',
'duration.hours': '{count} 小時',
'duration.hoursMinutes': '{hours} 小時 {minutes} 分',
monthLabel: '{month}月',
noData: '目前時間範圍內沒有可分析資料',
keyMetrics: '關鍵指標',
'status.computingProgress': '正在分析會話 {processed}/{total}…',
'status.updating': '資料已變更,正在背景更新目前結果',
'status.coverage': '已分析 {analyzed}/{total} 個會話',
'status.ownerIssues': '{count} 個會話尚未正確設定「我是誰」',
'status.noAnalyzableOwner': '尚無可用於足跡分析的身分,請先在會話中設定「我是誰」',
'status.noDataWithLatest': '{year} 年沒有可分析資料,最近有效資料為 {latestYear} 年',
'actions.retry': '重試',
'actions.switchYear': '查看 {year} 年',
} as const satisfies PluginLocaleDictionary<TimeInvestmentLocaleKey>
const jaJP = {
title: '時間の投入',
yearTitle: '{year} 年の時間投入',
recentTitle: '直近1年間の時間投入',
estimateNote:
'メッセージの時間間隔から推定し、複数の会話で重なる時間帯を統合しています。正確な画面使用時間ではなく、会話への投入傾向を示します。',
'narrative.totalPrefix': 'この一年の推定チャット投入時間は',
'narrative.totalSuffix': 'でした。',
'narrative.daysPrefix': '投入は',
'narrative.daysMiddle': '日間にわたり、アクティブ日の平均は',
'narrative.daysSuffix': 'でした。',
loading: '時間投入を読み込んでいます…',
failed: '時間投入の計算に失敗しました。再試行できます。',
privateChat: '個別チャット',
groupChat: 'グループチャット',
shareOfTotal: '全体の {share}%',
peakDay: '投入時間が最も長い日',
peakWeekday: '1日平均が最も長い曜日',
monthlyTitle: '月別推移',
monthlyDescription: '月ごとの推定チャット投入時間。縦軸は時間です',
rhythmTitle: '投入のリズム',
rhythmDescription: '曜日別の1日平均投入時間',
topDaysTitle: '投入時間が長い日',
rankingTitle: '時間を使った会話',
rankingDescription:
'個別チャットとグループチャットで投入時間の多い上位10件を表示します。重なる時間は会話間で均等に配分されます',
rankingCount: '{count} 件',
'duration.lessThanMinute': '1分未満',
'duration.minutes': '{count}分',
'duration.hours': '{count}時間',
'duration.hoursMinutes': '{hours}時間{minutes}分',
monthLabel: '{month}月',
noData: 'この期間には分析可能なデータがありません',
keyMetrics: '主要指標',
'status.computingProgress': '会話を分析しています {processed}/{total}…',
'status.updating': 'データが変更されたため、バックグラウンドで更新しています',
'status.coverage': '{total} 件中 {analyzed} 件の会話を分析済み',
'status.ownerIssues': '{count} 件の会話で「自分」の設定が必要です',
'status.noAnalyzableOwner': '足跡の分析に使用できる本人情報がありません。先に会話で「自分」を設定してください。',
'status.noDataWithLatest': '{year} 年の分析データはありません。最新の有効年は {latestYear} 年です',
'actions.retry': '再試行',
'actions.switchYear': '{year} 年を見る',
} as const satisfies PluginLocaleDictionary<TimeInvestmentLocaleKey>
export const timeInvestmentLocaleMessages = {
'en-US': enUS,
'zh-CN': zhCN,
'zh-TW': zhTW,
'ja-JP': jaJP,
} satisfies PluginLocaleMessages<TimeInvestmentLocaleKey>
@@ -0,0 +1,42 @@
import type { InsightPlugin } from '../../insight'
import type { StaticInsightPluginDescriptor } from '../../static-insight'
import { TIME_INVESTMENT_LOCALE_NAMESPACE, TIME_INVESTMENT_PLUGIN_ID } from './constants'
import { timeInvestmentLocaleMessages } from './locales'
export const timeInvestmentPlugin: InsightPlugin = {
id: TIME_INVESTMENT_PLUGIN_ID,
platforms: ['electron', 'cli-web', 'web-wasm'],
activate(context) {
context.locale.register(TIME_INVESTMENT_LOCALE_NAMESPACE, timeInvestmentLocaleMessages)
context.pages.register({
id: 'time-investment',
path: 'time-investment',
routeName: 'insight-time-investment',
title: { namespace: TIME_INVESTMENT_LOCALE_NAMESPACE, key: 'title' },
icon: 'i-lucide-clock-3',
filters: {
time: {
allowedModes: ['recent', 'year'],
allowedRecentDays: [365],
defaultMode: 'year',
},
},
view: {
load: () => import('./ui/index.vue'),
},
})
context.navigation.register({
id: 'insight.time-investment',
pageId: 'time-investment',
order: 20,
})
},
}
export const timeInvestmentBuiltin: StaticInsightPluginDescriptor = {
plugin: timeInvestmentPlugin,
installUiServices: async (services) => {
const { registerTimeInvestmentUiService } = await import('./host')
registerTimeInvestmentUiService(services)
},
}
@@ -0,0 +1,16 @@
import type { TimeInvestmentResponse } from '@openchatlab/shared-types'
import type { AnnualSummaryFetchOptions } from '@/services/data/types'
import type { Disposer } from '../../core'
import { createUiServiceKey } from '../../ui-host'
export interface TimeInvestmentUiService {
readonly canConfigureOwner: boolean
get(options: AnnualSummaryFetchOptions): Promise<TimeInvestmentResponse>
recompute(options: AnnualSummaryFetchOptions): Promise<TimeInvestmentResponse>
openOwnerSettings(): void
subscribeOwnerSettingsClosed(listener: () => void): Disposer
}
export const TIME_INVESTMENT_UI_SERVICE = createUiServiceKey<TimeInvestmentUiService>(
'insight.time-investment.ui-service'
)
@@ -1,35 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { ChatType, type TimeInvestmentResponse } from '@openchatlab/shared-types' import { ChatType, type TimeInvestmentResponse } from '@openchatlab/shared-types'
import { useLayoutStore } from '@/stores/layout'
import type { AnnualSummaryFetchOptions } from '@/services/data/types' import type { AnnualSummaryFetchOptions } from '@/services/data/types'
import { useDataService } from '@/services'
import { reportError } from '@/services/log-report' import { reportError } from '@/services/log-report'
import { formatDateRange } from '@/utils'
import LoadingState from '@/components/UI/LoadingState.vue' import LoadingState from '@/components/UI/LoadingState.vue'
import { CardDecoration, ThemeCard } from '@/components/UI' import { CardDecoration, ThemeCard } from '@/components/UI'
import { EChartLine } from '@/components/charts' import { EChartLine } from '@/components/charts'
import { PLATFORM_CAPABILITIES } from '@/utils/platform-capabilities' import { useHostLocale, usePluginLocale, useUiHostContext } from '@/plugins/insight-vue'
import { useInsightTimeRange, watchInsightSettingsClose } from '../insight-time-range' import { TIME_INVESTMENT_LOCALE_NAMESPACE } from '../constants'
import type { TimeInvestmentLocaleKey } from '../locales'
import { TIME_INVESTMENT_UI_SERVICE } from '../service'
const { t, locale } = useI18n() const uiHost = useUiHostContext()
const layoutStore = useLayoutStore() const t = usePluginLocale<TimeInvestmentLocaleKey>(TIME_INVESTMENT_LOCALE_NAMESPACE)
const { showSettings } = storeToRefs(layoutStore) const { formatDate, formatNumber } = useHostLocale()
const insightScope = uiHost.insightScope
const timeInvestmentService = uiHost.services.get(TIME_INVESTMENT_UI_SERVICE)
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const timeRange = useInsightTimeRange() const scopeSnapshot = ref(insightScope.getSnapshot())
const response = ref<TimeInvestmentResponse | null>(null) const response = ref<TimeInvestmentResponse | null>(null)
const errorMessage = ref('') const errorMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null let pollTimer: ReturnType<typeof setTimeout> | null = null
let requestToken = 0 let requestToken = 0
const requestOptions = computed<AnnualSummaryFetchOptions | null>(() => { const requestOptions = computed<AnnualSummaryFetchOptions | null>(() => {
const state = timeRange.modelValue.value?.state const time = scopeSnapshot.value.time
if (!state) return null if (!time) return null
return state.mode === 'recent' return time.mode === 'recent'
? { mode: 'recent', days: 365, acceptStale: true } ? { mode: 'recent', days: 365, acceptStale: true }
: { mode: 'year', year: state.year ?? currentYear, acceptStale: true } : { mode: 'year', year: time.year ?? currentYear, acceptStale: true }
}) })
const requestKey = computed(() => JSON.stringify(requestOptions.value)) const requestKey = computed(() => JSON.stringify(requestOptions.value))
const ownerIssueCount = computed( const ownerIssueCount = computed(
@@ -44,9 +43,8 @@ const hasNoAnalyzableOwner = computed(
response.value?.coverage.analyzedSessions === 0 && response.value?.coverage.analyzedSessions === 0 &&
ownerIssueCount.value > 0 ownerIssueCount.value > 0
) )
const canConfigureOwner = !PLATFORM_CAPABILITIES.usesBrowserRuntime
const selectedYear = computed(() => const selectedYear = computed(() =>
timeRange.modelValue.value?.state.mode === 'year' ? timeRange.modelValue.value.state.year : undefined scopeSnapshot.value.time?.mode === 'year' ? scopeSnapshot.value.time.year : undefined
) )
const latestYearSuggestion = computed(() => { const latestYearSuggestion = computed(() => {
const year = selectedYear.value const year = selectedYear.value
@@ -55,22 +53,22 @@ const latestYearSuggestion = computed(() => {
return { year, latestYear } return { year, latestYear }
}) })
const title = computed(() => const title = computed(() =>
response.value?.range.mode === 'year' response.value?.range.mode === 'year' ? t('yearTitle', { year: response.value.range.year }) : t('recentTitle')
? t('insight.timeInvestment.yearTitle', { year: response.value.range.year })
: t('insight.timeInvestment.recentTitle')
) )
const timeRangeText = computed(() => { const timeRangeText = computed(() => {
const range = response.value?.range const range = response.value?.range
return range ? formatDateRange(range.startTs, range.endTs, 'YYYY/MM/DD') : '' if (!range) return ''
const options = { day: '2-digit', month: '2-digit', year: 'numeric' } as const
const start = formatDate(range.startTs * 1000, options)
const end = formatDate(range.endTs * 1000, options)
return start === end ? start : `${start} – ${end}`
}) })
const monthlyChartData = computed(() => { const monthlyChartData = computed(() => {
const range = response.value?.range const range = response.value?.range
const data = response.value?.monthlyActivity ?? [] const data = response.value?.monthlyActivity ?? []
return { return {
labels: data.map((item) => labels: data.map((item) =>
range?.mode === 'year' range?.mode === 'year' ? t('monthLabel', { month: Number(item.key.slice(5)) }) : item.key.replace('-', '/')
? t('insight.monthLabel', { month: Number(item.key.slice(5)) })
: item.key.replace('-', '/')
), ),
values: data.map((item) => secondsToHours(item.estimatedSeconds)), values: data.map((item) => secondsToHours(item.estimatedSeconds)),
} }
@@ -93,19 +91,15 @@ const weekdayInvestmentItems = computed(() => {
} }
const averages = totals.map((total, index) => (dayCounts[index] ? Math.round(total / dayCounts[index]) : 0)) const averages = totals.map((total, index) => (dayCounts[index] ? Math.round(total / dayCounts[index]) : 0))
const maxAverage = Math.max(...averages, 1) const maxAverage = Math.max(...averages, 1)
const weekdayFormatter = new Intl.DateTimeFormat(locale.value, { weekday: 'short' })
return averages.map((seconds, index) => ({ return averages.map((seconds, index) => ({
key: index, key: index,
label: weekdayFormatter.format(new Date(2024, 0, 1 + index)), label: formatDate(new Date(2024, 0, 1 + index), { weekday: 'short' }),
seconds, seconds,
width: seconds > 0 ? Math.max((seconds / maxAverage) * 100, 3) : 0, width: seconds > 0 ? Math.max((seconds / maxAverage) * 100, 3) : 0,
})) }))
}) })
const hasWeekdayInvestment = computed(() => weekdayInvestmentItems.value.some((item) => item.seconds > 0)) const hasWeekdayInvestment = computed(() => weekdayInvestmentItems.value.some((item) => item.seconds > 0))
const topInvestmentDays = computed(() => { const topInvestmentDays = computed(() => {
const weekdayFormatter = new Intl.DateTimeFormat(locale.value, { weekday: 'short' })
const dateFormatter = new Intl.DateTimeFormat(locale.value, { month: '2-digit', day: '2-digit' })
return [...(response.value?.dailyActivity ?? [])] return [...(response.value?.dailyActivity ?? [])]
.filter((item) => item.estimatedSeconds > 0) .filter((item) => item.estimatedSeconds > 0)
.sort((a, b) => b.estimatedSeconds - a.estimatedSeconds || a.key.localeCompare(b.key)) .sort((a, b) => b.estimatedSeconds - a.estimatedSeconds || a.key.localeCompare(b.key))
@@ -114,8 +108,8 @@ const topInvestmentDays = computed(() => {
const date = parseLocalDate(item.key) const date = parseLocalDate(item.key)
return { return {
...item, ...item,
dateLabel: dateFormatter.format(date), dateLabel: formatDate(date, { month: '2-digit', day: '2-digit' }),
weekdayLabel: weekdayFormatter.format(date), weekdayLabel: formatDate(date, { weekday: 'short' }),
} }
}) })
}) })
@@ -135,23 +129,23 @@ const investmentDetailStats = computed(() => {
key: 'private', key: 'private',
icon: 'i-heroicons-user', icon: 'i-heroicons-user',
colorClass: 'text-pink-600 dark:text-pink-400', colorClass: 'text-pink-600 dark:text-pink-400',
label: t('insight.timeInvestment.privateChat'), label: t('privateChat'),
value: privateItem ? formatDuration(privateItem.seconds) : '—', value: privateItem ? formatDuration(privateItem.seconds) : '—',
subtext: t('insight.timeInvestment.shareOfTotal', { share: privateItem?.share ?? 0 }), subtext: t('shareOfTotal', { share: privateItem?.share ?? 0 }),
}, },
{ {
key: 'group', key: 'group',
icon: 'i-heroicons-user-group', icon: 'i-heroicons-user-group',
colorClass: 'text-blue-600 dark:text-blue-400', colorClass: 'text-blue-600 dark:text-blue-400',
label: t('insight.timeInvestment.groupChat'), label: t('groupChat'),
value: groupItem ? formatDuration(groupItem.seconds) : '—', value: groupItem ? formatDuration(groupItem.seconds) : '—',
subtext: t('insight.timeInvestment.shareOfTotal', { share: groupItem?.share ?? 0 }), subtext: t('shareOfTotal', { share: groupItem?.share ?? 0 }),
}, },
{ {
key: 'peakDay', key: 'peakDay',
icon: 'i-heroicons-fire', icon: 'i-heroicons-fire',
colorClass: 'text-red-600 dark:text-red-400', colorClass: 'text-red-600 dark:text-red-400',
label: t('insight.timeInvestment.peakDay'), label: t('peakDay'),
value: peakDay?.dateLabel ?? '—', value: peakDay?.dateLabel ?? '—',
subtext: peakDay ? formatDuration(peakDay.estimatedSeconds) : '—', subtext: peakDay ? formatDuration(peakDay.estimatedSeconds) : '—',
}, },
@@ -159,7 +153,7 @@ const investmentDetailStats = computed(() => {
key: 'peakWeekday', key: 'peakWeekday',
icon: 'i-heroicons-calendar-days', icon: 'i-heroicons-calendar-days',
colorClass: 'text-amber-600 dark:text-amber-400', colorClass: 'text-amber-600 dark:text-amber-400',
label: t('insight.timeInvestment.peakWeekday'), label: t('peakWeekday'),
value: peakWeekday?.seconds ? peakWeekday.label : '—', value: peakWeekday?.seconds ? peakWeekday.label : '—',
subtext: peakWeekday?.seconds ? formatDuration(peakWeekday.seconds) : '—', subtext: peakWeekday?.seconds ? formatDuration(peakWeekday.seconds) : '—',
}, },
@@ -179,6 +173,10 @@ const sessionRankingsByType = computed(() =>
}) })
) )
const hasRankedSessions = computed(() => sessionRankingsByType.value.some((ranking) => ranking.items.length > 0)) const hasRankedSessions = computed(() => sessionRankingsByType.value.some((ranking) => ranking.items.length > 0))
const unsubscribeScope = insightScope.subscribe(() => {
scopeSnapshot.value = insightScope.getSnapshot()
})
const unsubscribeSettings = timeInvestmentService.subscribeOwnerSettingsClosed(() => void loadTimeInvestment(false))
watch( watch(
requestKey, requestKey,
@@ -190,8 +188,12 @@ watch(
}, },
{ immediate: true } { immediate: true }
) )
watchInsightSettingsClose(showSettings, () => void loadTimeInvestment(false)) onBeforeUnmount(() => {
onBeforeUnmount(clearPoll) requestToken++
clearPoll()
unsubscribeScope()
unsubscribeSettings()
})
async function loadTimeInvestment(recompute: boolean): Promise<void> { async function loadTimeInvestment(recompute: boolean): Promise<void> {
const options = requestOptions.value const options = requestOptions.value
@@ -199,12 +201,10 @@ async function loadTimeInvestment(recompute: boolean): Promise<void> {
const token = ++requestToken const token = ++requestToken
errorMessage.value = '' errorMessage.value = ''
try { try {
const result = recompute const result = recompute ? await timeInvestmentService.recompute(options) : await timeInvestmentService.get(options)
? await useDataService().recomputeTimeInvestment(options)
: await useDataService().getTimeInvestment(options)
if (token !== requestToken) return if (token !== requestToken) return
response.value = result response.value = result
if (result.metrics) timeRange.setAvailableYears(result.availableDataYears) if (result.metrics) insightScope.setAvailableTimeYears(result.availableDataYears)
if (result.task.status === 'running') schedulePoll() if (result.task.status === 'running') schedulePoll()
} catch (error) { } catch (error) {
if (token !== requestToken) return if (token !== requestToken) return
@@ -227,22 +227,20 @@ function clearPoll(): void {
function switchToLatestYear(): void { function switchToLatestYear(): void {
const year = response.value?.latestDataYear const year = response.value?.latestDataYear
if (year) timeRange.switchToYear(year) if (year) insightScope.switchTimeToYear(year)
} }
function openSessions(): void { function openSessions(): void {
layoutStore.openSettings('data', 'missing-owner') timeInvestmentService.openOwnerSettings()
} }
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
const roundedMinutes = Math.round(seconds / 60) const roundedMinutes = Math.round(seconds / 60)
if (seconds > 0 && roundedMinutes < 1) return t('insight.timeInvestment.duration.lessThanMinute') if (seconds > 0 && roundedMinutes < 1) return t('duration.lessThanMinute')
if (roundedMinutes < 60) return t('insight.timeInvestment.duration.minutes', { count: roundedMinutes }) if (roundedMinutes < 60) return t('duration.minutes', { count: roundedMinutes })
const hours = Math.floor(roundedMinutes / 60) const hours = Math.floor(roundedMinutes / 60)
const minutes = roundedMinutes % 60 const minutes = roundedMinutes % 60
return minutes return minutes ? t('duration.hoursMinutes', { hours, minutes }) : t('duration.hours', { count: hours })
? t('insight.timeInvestment.duration.hoursMinutes', { hours, minutes })
: t('insight.timeInvestment.duration.hours', { count: hours })
} }
function secondsToHours(seconds: number): number { function secondsToHours(seconds: number): number {
@@ -263,7 +261,7 @@ function parseLocalDate(value: string): Date {
} }
function chatTypeLabel(type: ChatType): string { function chatTypeLabel(type: ChatType): string {
return t(type === ChatType.PRIVATE ? 'insight.timeInvestment.privateChat' : 'insight.timeInvestment.groupChat') return t(type === ChatType.PRIVATE ? 'privateChat' : 'groupChat')
} }
</script> </script>
@@ -271,13 +269,13 @@ function chatTypeLabel(type: ChatType): string {
<main class="min-h-0 flex-1 overflow-y-auto"> <main class="min-h-0 flex-1 overflow-y-auto">
<div class="mx-auto w-full max-w-[920px] space-y-6 px-4 py-5 sm:px-6 sm:py-6"> <div class="mx-auto w-full max-w-[920px] space-y-6 px-4 py-5 sm:px-6 sm:py-6">
<button <button
v-if="ownerIssueCount > 0 && canConfigureOwner" v-if="ownerIssueCount > 0 && timeInvestmentService.canConfigureOwner"
type="button" type="button"
class="inline-flex w-fit max-w-full items-center gap-2 rounded-lg bg-amber-50 px-3 py-2 text-left text-xs text-amber-800 transition-colors hover:bg-amber-100 dark:bg-amber-950/20 dark:text-amber-300 dark:hover:bg-amber-950/30" class="inline-flex w-fit max-w-full items-center gap-2 rounded-lg bg-amber-50 px-3 py-2 text-left text-xs text-amber-800 transition-colors hover:bg-amber-100 dark:bg-amber-950/20 dark:text-amber-300 dark:hover:bg-amber-950/30"
@click="openSessions" @click="openSessions"
> >
<UIcon name="i-heroicons-user-circle" class="h-4 w-4 shrink-0" /> <UIcon name="i-heroicons-user-circle" class="h-4 w-4 shrink-0" />
<span class="min-w-0">{{ t('insight.status.ownerIssues', { count: ownerIssueCount }) }}</span> <span class="min-w-0">{{ t('status.ownerIssues', { count: ownerIssueCount }) }}</span>
<UIcon name="i-heroicons-arrow-right" class="h-3.5 w-3.5 shrink-0 opacity-70" /> <UIcon name="i-heroicons-arrow-right" class="h-3.5 w-3.5 shrink-0 opacity-70" />
</button> </button>
@@ -286,16 +284,16 @@ function chatTypeLabel(type: ChatType): string {
class="flex items-center gap-2 rounded-xl border border-amber-100 bg-amber-50/50 px-4 py-2.5 text-xs text-amber-800 dark:border-amber-950/40 dark:bg-amber-950/20 dark:text-amber-300" class="flex items-center gap-2 rounded-xl border border-amber-100 bg-amber-50/50 px-4 py-2.5 text-xs text-amber-800 dark:border-amber-950/40 dark:bg-amber-950/20 dark:text-amber-300"
> >
<UIcon name="i-heroicons-arrow-path" class="h-4 w-4 shrink-0 animate-spin" /> <UIcon name="i-heroicons-arrow-path" class="h-4 w-4 shrink-0 animate-spin" />
{{ t('insight.status.updating') }} {{ t('status.updating') }}
</div> </div>
<div <div
v-if="errorMessage || response?.task.status === 'failed'" v-if="errorMessage || response?.task.status === 'failed'"
class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-red-200 bg-red-50/50 px-4 py-2.5 text-xs text-red-800 dark:border-red-950/40 dark:bg-red-950/20 dark:text-red-300" class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-red-200 bg-red-50/50 px-4 py-2.5 text-xs text-red-800 dark:border-red-950/40 dark:bg-red-950/20 dark:text-red-300"
> >
<span>{{ t('insight.timeInvestment.failed') }}</span> <span>{{ t('failed') }}</span>
<UButton size="xs" color="error" variant="soft" icon="i-heroicons-arrow-path" @click="loadTimeInvestment(true)"> <UButton size="xs" color="error" variant="soft" icon="i-heroicons-arrow-path" @click="loadTimeInvestment(true)">
{{ t('insight.actions.retry') }} {{ t('actions.retry') }}
</UButton> </UButton>
</div> </div>
@@ -304,11 +302,11 @@ function chatTypeLabel(type: ChatType): string {
height="min(52vh, 420px)" height="min(52vh, 420px)"
:text=" :text="
response?.task.status === 'running' response?.task.status === 'running'
? t('insight.status.computingProgress', { ? t('status.computingProgress', {
processed: response.task.processedSessions, processed: response.task.processedSessions,
total: response.task.totalSessions, total: response.task.totalSessions,
}) })
: t('insight.timeInvestment.loading') : t('loading')
" "
/> />
@@ -317,9 +315,9 @@ function chatTypeLabel(type: ChatType): string {
v-if="isZeroData" v-if="isZeroData"
class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-600 dark:border-zinc-800 dark:bg-zinc-900/50 dark:text-zinc-300" class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-600 dark:border-zinc-800 dark:bg-zinc-900/50 dark:text-zinc-300"
> >
<span v-if="hasNoAnalyzableOwner">{{ t('insight.status.noAnalyzableOwner') }}</span> <span v-if="hasNoAnalyzableOwner">{{ t('status.noAnalyzableOwner') }}</span>
<span v-else-if="latestYearSuggestion">{{ t('insight.status.noDataWithLatest', latestYearSuggestion) }}</span> <span v-else-if="latestYearSuggestion">{{ t('status.noDataWithLatest', latestYearSuggestion) }}</span>
<span v-else>{{ t('insight.noData') }}</span> <span v-else>{{ t('noData') }}</span>
<UButton <UButton
v-if="latestYearSuggestion" v-if="latestYearSuggestion"
size="xs" size="xs"
@@ -328,7 +326,7 @@ function chatTypeLabel(type: ChatType): string {
icon="i-heroicons-arrow-right" icon="i-heroicons-arrow-right"
@click="switchToLatestYear" @click="switchToLatestYear"
> >
{{ t('insight.actions.switchYear', { year: latestYearSuggestion.latestYear }) }} {{ t('actions.switchYear', { year: latestYearSuggestion.latestYear }) }}
</UButton> </UButton>
</div> </div>
@@ -340,31 +338,31 @@ function chatTypeLabel(type: ChatType): string {
<div class="mt-6 max-w-3xl space-y-3 text-gray-600 dark:text-zinc-300"> <div class="mt-6 max-w-3xl space-y-3 text-gray-600 dark:text-zinc-300">
<p class="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-lg leading-relaxed sm:text-xl"> <p class="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-lg leading-relaxed sm:text-xl">
<span>{{ t('insight.timeInvestment.narrative.totalPrefix') }}</span> <span>{{ t('narrative.totalPrefix') }}</span>
<span <span
class="font-black text-2xl tracking-tight tabular-nums text-pink-600 sm:text-3xl dark:text-pink-400" class="font-black text-2xl tracking-tight tabular-nums text-pink-600 sm:text-3xl dark:text-pink-400"
> >
{{ formatDuration(response.metrics.estimatedSeconds) }} {{ formatDuration(response.metrics.estimatedSeconds) }}
</span> </span>
<span>{{ t('insight.timeInvestment.narrative.totalSuffix') }}</span> <span>{{ t('narrative.totalSuffix') }}</span>
</p> </p>
<p class="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-base leading-relaxed sm:text-lg"> <p class="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-base leading-relaxed sm:text-lg">
<span>{{ t('insight.timeInvestment.narrative.daysPrefix') }}</span> <span>{{ t('narrative.daysPrefix') }}</span>
<span class="font-black text-2xl tabular-nums text-indigo-600 dark:text-indigo-400"> <span class="font-black text-2xl tabular-nums text-indigo-600 dark:text-indigo-400">
{{ response.metrics.activeDayCount.toLocaleString() }} {{ formatNumber(response.metrics.activeDayCount) }}
</span> </span>
<span>{{ t('insight.timeInvestment.narrative.daysMiddle') }}</span> <span>{{ t('narrative.daysMiddle') }}</span>
<span class="font-black text-2xl tabular-nums text-blue-600 dark:text-blue-400"> <span class="font-black text-2xl tabular-nums text-blue-600 dark:text-blue-400">
{{ formatDuration(response.metrics.averagePerActiveDaySeconds) }} {{ formatDuration(response.metrics.averagePerActiveDaySeconds) }}
</span> </span>
<span>{{ t('insight.timeInvestment.narrative.daysSuffix') }}</span> <span>{{ t('narrative.daysSuffix') }}</span>
</p> </p>
</div> </div>
<div class="mt-7"> <div class="mt-7">
<div class="mb-2 text-[10px] font-bold uppercase tracking-widest text-gray-400 dark:text-zinc-500"> <div class="mb-2 text-[10px] font-bold uppercase tracking-widest text-gray-400 dark:text-zinc-500">
{{ t('insight.overviewCard.keyMetrics') }} {{ t('keyMetrics') }}
</div> </div>
<div class="grid grid-cols-2 gap-2 lg:grid-cols-4"> <div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
<div <div
@@ -394,10 +392,10 @@ function chatTypeLabel(type: ChatType): string {
<div class="mt-6"> <div class="mt-6">
<div> <div>
<h3 class="text-xs font-bold tracking-wide text-gray-700 dark:text-zinc-300"> <h3 class="text-xs font-bold tracking-wide text-gray-700 dark:text-zinc-300">
{{ t('insight.timeInvestment.monthlyTitle') }} {{ t('monthlyTitle') }}
</h3> </h3>
<p class="mt-1 text-[10px] text-gray-400 dark:text-zinc-500"> <p class="mt-1 text-[10px] text-gray-400 dark:text-zinc-500">
{{ t('insight.timeInvestment.monthlyDescription') }} {{ t('monthlyDescription') }}
</p> </p>
</div> </div>
<div class="mt-2"> <div class="mt-2">
@@ -410,7 +408,7 @@ function chatTypeLabel(type: ChatType): string {
/> />
</div> </div>
<p class="mt-2 text-[10px] leading-5 text-gray-400 dark:text-zinc-500"> <p class="mt-2 text-[10px] leading-5 text-gray-400 dark:text-zinc-500">
{{ t('insight.timeInvestment.estimateNote') }} {{ t('estimateNote') }}
</p> </p>
</div> </div>
@@ -420,7 +418,7 @@ function chatTypeLabel(type: ChatType): string {
<span>{{ timeRangeText }}</span> <span>{{ timeRangeText }}</span>
<span> <span>
{{ {{
t('insight.status.coverage', { t('status.coverage', {
analyzed: response.coverage.analyzedSessions, analyzed: response.coverage.analyzedSessions,
total: response.coverage.totalSessions, total: response.coverage.totalSessions,
}) })
@@ -436,10 +434,10 @@ function chatTypeLabel(type: ChatType): string {
class="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-zinc-300" class="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-zinc-300"
> >
<span class="inline-block h-2 w-2 rounded-full bg-pink-500 dark:bg-pink-400" /> <span class="inline-block h-2 w-2 rounded-full bg-pink-500 dark:bg-pink-400" />
{{ t('insight.timeInvestment.rhythmTitle') }} {{ t('rhythmTitle') }}
</h3> </h3>
<p class="mt-1 text-[11px] text-gray-400 dark:text-zinc-500"> <p class="mt-1 text-[11px] text-gray-400 dark:text-zinc-500">
{{ t('insight.timeInvestment.rhythmDescription') }} {{ t('rhythmDescription') }}
</p> </p>
<div class="mt-5 grid gap-6 lg:grid-cols-7 lg:gap-0"> <div class="mt-5 grid gap-6 lg:grid-cols-7 lg:gap-0">
@@ -464,14 +462,14 @@ function chatTypeLabel(type: ChatType): string {
</span> </span>
</div> </div>
</div> </div>
<p v-else class="text-xs text-gray-400">{{ t('insight.noData') }}</p> <p v-else class="text-xs text-gray-400">{{ t('noData') }}</p>
</div> </div>
<div <div
class="min-w-0 border-t border-gray-200/60 pt-5 lg:col-span-3 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-8 dark:border-white/5" class="min-w-0 border-t border-gray-200/60 pt-5 lg:col-span-3 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-8 dark:border-white/5"
> >
<h4 class="text-xs font-semibold text-gray-700 dark:text-zinc-300"> <h4 class="text-xs font-semibold text-gray-700 dark:text-zinc-300">
{{ t('insight.timeInvestment.topDaysTitle') }} {{ t('topDaysTitle') }}
</h4> </h4>
<div v-if="topInvestmentDays.length" class="mt-3 divide-y divide-gray-200/60 dark:divide-white/5"> <div v-if="topInvestmentDays.length" class="mt-3 divide-y divide-gray-200/60 dark:divide-white/5">
<div <div
@@ -499,7 +497,7 @@ function chatTypeLabel(type: ChatType): string {
</span> </span>
</div> </div>
</div> </div>
<p v-else class="mt-3 text-xs text-gray-400">{{ t('insight.noData') }}</p> <p v-else class="mt-3 text-xs text-gray-400">{{ t('noData') }}</p>
</div> </div>
</div> </div>
</section> </section>
@@ -510,10 +508,10 @@ function chatTypeLabel(type: ChatType): string {
<div class="flex flex-wrap items-end justify-between gap-x-6 gap-y-2"> <div class="flex flex-wrap items-end justify-between gap-x-6 gap-y-2">
<div> <div>
<h3 class="text-base font-semibold text-gray-800 dark:text-zinc-200"> <h3 class="text-base font-semibold text-gray-800 dark:text-zinc-200">
{{ t('insight.timeInvestment.rankingTitle') }} {{ t('rankingTitle') }}
</h3> </h3>
<p class="mt-1 text-[11px] text-gray-400 dark:text-zinc-500"> <p class="mt-1 text-[11px] text-gray-400 dark:text-zinc-500">
{{ t('insight.timeInvestment.rankingDescription') }} {{ t('rankingDescription') }}
</p> </p>
</div> </div>
</div> </div>
@@ -544,7 +542,7 @@ function chatTypeLabel(type: ChatType): string {
</h4> </h4>
</div> </div>
<span class="shrink-0 text-[10px] text-gray-400 dark:text-zinc-500"> <span class="shrink-0 text-[10px] text-gray-400 dark:text-zinc-500">
{{ t('insight.timeInvestment.rankingCount', { count: ranking.items.length }) }} {{ t('rankingCount', { count: ranking.items.length }) }}
</span> </span>
</div> </div>
@@ -595,10 +593,10 @@ function chatTypeLabel(type: ChatType): string {
</div> </div>
</div> </div>
</div> </div>
<p v-else class="pt-4 text-xs text-gray-400">{{ t('insight.noData') }}</p> <p v-else class="pt-4 text-xs text-gray-400">{{ t('noData') }}</p>
</div> </div>
</div> </div>
<p v-else class="mt-5 text-xs text-gray-400">{{ t('insight.noData') }}</p> <p v-else class="mt-5 text-xs text-gray-400">{{ t('noData') }}</p>
</section> </section>
</ThemeCard> </ThemeCard>
</div> </div>
+2 -1
View File
@@ -1,12 +1,13 @@
import { PLATFORM_CAPABILITIES } from '@/utils/platform-capabilities' import { PLATFORM_CAPABILITIES } from '@/utils/platform-capabilities'
import { annualSummaryBuiltin } from './builtin/annual-summary' import { annualSummaryBuiltin } from './builtin/annual-summary'
import { timeInvestmentBuiltin } from './builtin/time-investment'
import { getLegacyInsightPages } from './insight-catalog' import { getLegacyInsightPages } from './insight-catalog'
import { createStaticInsightPluginRuntime } from './static-insight' import { createStaticInsightPluginRuntime } from './static-insight'
import { UiServiceRegistry } from './ui-host' import { UiServiceRegistry } from './ui-host'
import { createVueUiHostContext } from './vue-ui-host' import { createVueUiHostContext } from './vue-ui-host'
const platform = PLATFORM_CAPABILITIES.platform const platform = PLATFORM_CAPABILITIES.platform
export const desktopCliWebInsightBuiltins = [annualSummaryBuiltin] as const export const desktopCliWebInsightBuiltins = [annualSummaryBuiltin, timeInvestmentBuiltin] as const
export const desktopCliWebUiServices = new UiServiceRegistry() export const desktopCliWebUiServices = new UiServiceRegistry()
export const desktopCliWebUiHost = createVueUiHostContext({ services: desktopCliWebUiServices }) export const desktopCliWebUiHost = createVueUiHostContext({ services: desktopCliWebUiServices })
export const desktopCliWebInsightRuntime = createStaticInsightPluginRuntime( export const desktopCliWebInsightRuntime = createStaticInsightPluginRuntime(
-16
View File
@@ -7,22 +7,6 @@ export interface LegacyInsightPage extends InsightPageDefinition {
} }
export const LEGACY_INSIGHT_PAGES: readonly LegacyInsightPage[] = [ export const LEGACY_INSIGHT_PAGES: readonly LegacyInsightPage[] = [
{
id: 'time-investment',
path: 'time-investment',
routeName: 'insight-time-investment',
title: { key: 'insight.tabs.timeInvestment' },
icon: 'i-lucide-clock-3',
platforms: ['electron', 'cli-web', 'web-wasm'],
order: 20,
filters: {
time: {
allowedModes: ['recent', 'year'],
allowedRecentDays: [365],
defaultMode: 'year',
},
},
},
{ {
id: 'relationship-changes', id: 'relationship-changes',
path: 'relationship-changes', path: 'relationship-changes',
+31 -4
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { annualSummaryBuiltin } from './builtin/annual-summary' import { annualSummaryBuiltin } from './builtin/annual-summary'
import { timeInvestmentBuiltin } from './builtin/time-investment'
import { getLegacyInsightPages, listInsightShellPages } from './insight-catalog' import { getLegacyInsightPages, listInsightShellPages } from './insight-catalog'
import { createInsightPluginRuntime, type InsightPlugin } from './insight' import { createInsightPluginRuntime, type InsightPlugin } from './insight'
import { InsightScopeController } from './insight-scope' import { InsightScopeController } from './insight-scope'
@@ -8,6 +9,7 @@ import { PluginLocaleHost } from './locale'
import { UiServiceRegistry, type UiHostContext } from './ui-host' import { UiServiceRegistry, type UiHostContext } from './ui-host'
const annualSummaryPlugin = annualSummaryBuiltin.plugin const annualSummaryPlugin = annualSummaryBuiltin.plugin
const timeInvestmentPlugin = timeInvestmentBuiltin.plugin
function createTestHost(): { localeHost: PluginLocaleHost; uiHost: UiHostContext } { function createTestHost(): { localeHost: PluginLocaleHost; uiHost: UiHostContext } {
const localeHost = new PluginLocaleHost({ const localeHost = new PluginLocaleHost({
@@ -51,19 +53,44 @@ test('registers annual summary as a removable Desktop and CLI Web contribution',
test('keeps the annual summary plugin out of Web WASM', () => { test('keeps the annual summary plugin out of Web WASM', () => {
const { uiHost, localeHost } = createTestHost() const { uiHost, localeHost } = createTestHost()
const runtime = createInsightPluginRuntime('web-wasm', uiHost, localeHost, [annualSummaryPlugin]) const runtime = createInsightPluginRuntime('web-wasm', uiHost, localeHost, [
annualSummaryPlugin,
timeInvestmentPlugin,
])
assert.equal(runtime.isActive(annualSummaryPlugin.id), false) assert.equal(runtime.isActive(annualSummaryPlugin.id), false)
assert.deepEqual(runtime.listPages(), []) assert.equal(runtime.isActive(timeInvestmentPlugin.id), true)
assert.deepEqual( assert.deepEqual(
listInsightShellPages(runtime).map((page) => page.id), listInsightShellPages(runtime).map((page) => page.id),
['time-investment'] ['time-investment']
) )
}) })
test('registers time investment on every platform and removes all of its contributions', () => {
for (const platform of ['electron', 'cli-web', 'web-wasm'] as const) {
const { uiHost, localeHost } = createTestHost()
const runtime = createInsightPluginRuntime(platform, uiHost, localeHost, [timeInvestmentPlugin])
const title = runtime.getPage('time-investment')!.title
assert.equal(runtime.isActive(timeInvestmentPlugin.id), true)
assert.equal(uiHost.locale.translate(title), 'Time Investment')
assert.deepEqual(
runtime.listNavigation().map(({ page }) => page.id),
['time-investment']
)
runtime.dispose(timeInvestmentPlugin.id)
assert.equal(runtime.getPage('time-investment'), undefined)
assert.equal(uiHost.locale.translate(title), 'title')
}
})
test('combines plugin and legacy Insight pages by navigation order', () => { test('combines plugin and legacy Insight pages by navigation order', () => {
const { uiHost, localeHost } = createTestHost() const { uiHost, localeHost } = createTestHost()
const runtime = createInsightPluginRuntime('electron', uiHost, localeHost, [annualSummaryPlugin]) const runtime = createInsightPluginRuntime('electron', uiHost, localeHost, [
annualSummaryPlugin,
timeInvestmentPlugin,
])
assert.deepEqual( assert.deepEqual(
listInsightShellPages(runtime).map((page) => page.id), listInsightShellPages(runtime).map((page) => page.id),
@@ -158,7 +185,7 @@ test('rejects plugin pages that conflict with host-owned legacy pages', () => {
platforms: ['cli-web'], platforms: ['cli-web'],
activate(context) { activate(context) {
context.pages.register({ context.pages.register({
id: 'time-investment', id: 'relationship-changes',
path: 'another-path', path: 'another-path',
routeName: 'another-route', routeName: 'another-route',
title: { namespace: 'plugins.conflicting-page', key: 'another.title' }, title: { namespace: 'plugins.conflicting-page', key: 'another.title' },
+8 -4
View File
@@ -1,12 +1,16 @@
import { createInsightPluginRuntime } from './insight' import { timeInvestmentBuiltin } from './builtin/time-investment'
import { getLegacyInsightPages } from './insight-catalog' import { getLegacyInsightPages } from './insight-catalog'
import { createStaticInsightPluginRuntime } from './static-insight'
import { UiServiceRegistry } from './ui-host'
import { createVueUiHostContext } from './vue-ui-host' import { createVueUiHostContext } from './vue-ui-host'
export const webWasmUiHost = createVueUiHostContext() export const webWasmInsightBuiltins = [timeInvestmentBuiltin] as const
export const webWasmInsightRuntime = createInsightPluginRuntime( export const webWasmUiServices = new UiServiceRegistry()
export const webWasmUiHost = createVueUiHostContext({ services: webWasmUiServices })
export const webWasmInsightRuntime = createStaticInsightPluginRuntime(
'web-wasm', 'web-wasm',
webWasmUiHost, webWasmUiHost,
webWasmUiHost.locale, webWasmUiHost.locale,
[], webWasmInsightBuiltins,
getLegacyInsightPages('web-wasm') getLegacyInsightPages('web-wasm')
) )
+8 -4
View File
@@ -50,7 +50,7 @@ test('registers annual summary as the default insight child route', () => {
) )
}) })
test('removes the annual summary route and changes the default when its plugin is absent', () => { test('removes optional plugin routes and falls back to the remaining Insight page', () => {
const localeHost = new PluginLocaleHost({ const localeHost = new PluginLocaleHost({
getLocale: () => 'en-US', getLocale: () => 'en-US',
subscribe: () => () => {}, subscribe: () => () => {},
@@ -74,16 +74,20 @@ test('removes the annual summary route and changes the default when its plugin i
assert.ok(insightRoute) assert.ok(insightRoute)
assert.deepEqual( assert.deepEqual(
listInsightShellPages(runtimeWithoutPlugins).map((page) => page.id), listInsightShellPages(runtimeWithoutPlugins).map((page) => page.id),
['time-investment', 'relationship-changes'] ['relationship-changes']
) )
assert.deepEqual(insightRoute.redirect, { name: 'insight-time-investment' }) assert.deepEqual(insightRoute.redirect, { name: 'insight-relationship-changes' })
assert.equal( assert.equal(
insightRoute.children?.some((route) => route.name === 'insight-annual-summary'), insightRoute.children?.some((route) => route.name === 'insight-annual-summary'),
false false
) )
assert.equal(
insightRoute.children?.some((route) => route.name === 'insight-time-investment'),
false
)
}) })
test('reserves time investment and relationship change insight routes', () => { test('registers plugin and reserved Insight routes', () => {
const insightRoute = findRoute('/insight') const insightRoute = findRoute('/insight')
assert.ok(insightRoute) assert.ok(insightRoute)
+4 -8
View File
@@ -1,6 +1,7 @@
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import { desktopCliWebInsightRuntime } from '@/plugins/desktop-cli-web' import { desktopCliWebInsightRuntime } from '@/plugins/desktop-cli-web'
import type { InsightPluginRuntime } from '@/plugins/insight' import type { InsightPluginRuntime } from '@/plugins/insight'
import { listInsightShellPages } from '@/plugins/insight-catalog'
import { createVueInsightRouteRecords } from '@/plugins/insight-vue' import { createVueInsightRouteRecords } from '@/plugins/insight-vue'
/** 开发模式按需加载页面,避免失败的预加载请求污染后续动态路由导航。 */ /** 开发模式按需加载页面,避免失败的预加载请求污染后续动态路由导航。 */
@@ -9,7 +10,8 @@ export function shouldPreloadCriticalRoutes(isProduction: boolean): boolean {
} }
export function createAppRoutes(insightRuntime: InsightPluginRuntime = desktopCliWebInsightRuntime): RouteRecordRaw[] { export function createAppRoutes(insightRuntime: InsightPluginRuntime = desktopCliWebInsightRuntime): RouteRecordRaw[] {
const defaultInsightRoute = insightRuntime.getDefaultPage()?.routeName ?? 'insight-time-investment' const defaultInsightRoute =
insightRuntime.getDefaultPage()?.routeName ?? listInsightShellPages(insightRuntime)[0]?.routeName
return [ return [
{ {
@@ -36,15 +38,9 @@ export function createAppRoutes(insightRuntime: InsightPluginRuntime = desktopCl
{ {
path: '/insight', path: '/insight',
component: () => import('@/pages/insight/index.vue'), component: () => import('@/pages/insight/index.vue'),
redirect: { name: defaultInsightRoute }, redirect: defaultInsightRoute ? { name: defaultInsightRoute } : undefined,
children: [ children: [
...createVueInsightRouteRecords(insightRuntime), ...createVueInsightRouteRecords(insightRuntime),
{
path: 'time-investment',
name: 'insight-time-investment',
component: () => import('@/pages/insight/time-investment/index.vue'),
meta: { insightPageId: 'time-investment' },
},
{ {
path: 'relationship-changes', path: 'relationship-changes',
name: 'insight-relationship-changes', name: 'insight-relationship-changes',