mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(plugin): migrate time investment plugin
This commit is contained in:
1 parent
b00959fd5f
commit
0f46352b8d
28 files changed
+511
-436
No files matched your search
@@ -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 }),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -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'
|
||||||
|
|||||||
+19
-6
@@ -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'
|
|
||||||
}
|
|
||||||
@@ -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'
|
||||||
|
|
||||||
// ==================== 时间筛选 ====================
|
// ==================== 时间筛选 ====================
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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} 年を見る"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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} 年"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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'
|
||||||
|
)
|
||||||
+81
-83
@@ -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>
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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')
|
||||||
)
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in new issue
Block a user