Compare commits

...

3 Commits

Author SHA1 Message Date
dante01yoon
277b8b2fa1 feat(billing): wire workspace Activity ledger to usage feed (FE-1249)
Fill the Activity tab's data seam with the live per-workspace usage feed
(GET /api/billing/events): map gpu_usage / api_node_usage rows to the ledger's
ActivityEvent shape and resolve member names from the workspace store.

The Credits column stays 0 until per-event cost lands (FE-1249 / P1), and the
partner-node detail is best-effort until the comfy-api params contract is fixed.
2026-07-16 17:55:59 -04:00
dante01yoon
db8aee2f99 test: add FE-1247 activity tab screenshot 2026-07-14 16:24:47 -04:00
dante01yoon
388c2de4cc feat(billing): workspace Activity tab — per-member usage ledger (FE-1247)
Adds Settings ▸ Plan & Credits ▸ Activity: a role-scoped, auto-paginated
credit-usage ledger with per-user and partner-node hover-card rollups.
Owners/admins see team-wide activity; members see their own usage.

- useWorkspaceActivity is the data seam (empty until FE-1249 wires the
  per-workspace usage API); the ledger renders its empty state today.
- Splits the workspace settings sidebar into Plan & Credits + Members;
  the Plan & Credits panel hosts Credits + Activity tabs.
- Adds shared table / pagination / hover-card primitives and useAutoPageSize.

Design: DES-479 (Activity), DES-497 (tab shell). Reimplemented from the
prototype (#13487/#13593) as our own logic.
2026-07-14 16:22:59 -04:00
42 changed files with 2008 additions and 234 deletions

BIN
.github/fe-1247-activity-tab.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

View File

@@ -93,6 +93,16 @@ const config: StorybookConfig = {
replacement:
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
},
{
find: '@/composables/auth/useCurrentUser',
replacement:
process.cwd() + '/src/storybook/mocks/useCurrentUser.ts'
},
{
find: '@/platform/workspace/composables/useWorkspaceUI',
replacement:
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
},
{
find: '@/utils/formatUtil',
replacement:

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { HoverCardRoot, useForwardPropsEmits } from 'reka-ui'
import type { HoverCardRootEmits, HoverCardRootProps } from 'reka-ui'
import { provide, ref } from 'vue'
import { hoverCardOpenKey } from './hoverCardContext'
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
const props = defineProps<HoverCardRootProps>()
const emits = defineEmits<HoverCardRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
const isOpen = ref(false)
provide(hoverCardOpenKey, isOpen)
</script>
<template>
<HoverCardRoot v-bind="forwarded" v-model:open="isOpen">
<slot />
</HoverCardRoot>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import { HoverCardContent, HoverCardPortal, useForwardProps } from 'reka-ui'
import type { HoverCardContentProps } from 'reka-ui'
import { computed, inject } from 'vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { hoverCardOpenKey } from './hoverCardContext'
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
const MODAL_BASE_Z_INDEX = 1700
const {
class: className,
side = 'bottom',
sideOffset = 8,
...rest
} = defineProps<HoverCardContentProps & { class?: HTMLAttributes['class'] }>()
const forwarded = useForwardProps(computed(() => rest))
// Body-portaled content sits at a static z-1700 unless a dialog that joined
// @primeuix's 'modal' counter is open above it; then lift past that dialog.
const open = inject(hoverCardOpenKey, undefined)
const contentStyle = computed(() => {
if (!open?.value) return undefined
const topZIndex = ZIndex.getCurrent('modal')
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
})
</script>
<template>
<HoverCardPortal>
<HoverCardContent
v-bind="forwarded"
:side
:side-offset
:style="contentStyle"
:class="
cn(
'z-1700 rounded-lg border border-border-subtle bg-secondary-background p-2.5 shadow-md outline-none',
className
)
"
>
<slot />
</HoverCardContent>
</HoverCardPortal>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import { HoverCardTrigger } from 'reka-ui'
import type { HoverCardTriggerProps } from 'reka-ui'
const props = defineProps<HoverCardTriggerProps>()
</script>
<template>
<HoverCardTrigger v-bind="props">
<slot />
</HoverCardTrigger>
</template>

View File

@@ -0,0 +1,7 @@
import type { InjectionKey, Ref } from 'vue'
// Shares the root open-state with the content so it can lift its z-index above
// a dialog that joined @primeuix's incrementing 'modal' counter (otherwise the
// body-portaled content renders behind the settings dialog).
export const hoverCardOpenKey: InjectionKey<Ref<boolean>> =
Symbol('hoverCardOpen')

View File

@@ -0,0 +1,72 @@
<template>
<PaginationRoot
:page="page"
:total="total"
:items-per-page="itemsPerPage"
:sibling-count="1"
show-edges
@update:page="(p: number) => emit('update:page', p)"
>
<div class="flex items-center gap-1">
<PaginationPrev as-child>
<Button variant="muted-textonly" size="md" class="text-sm">
<i class="icon-[lucide--chevron-left] size-4" />
{{ $t('g.previous') }}
</Button>
</PaginationPrev>
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<template v-for="(item, index) in items" :key="index">
<PaginationListItem
v-if="item.type === 'page'"
:value="item.value"
as-child
>
<Button
:variant="item.value === page ? 'secondary' : 'muted-textonly'"
size="icon"
>
{{ item.value }}
</Button>
</PaginationListItem>
<PaginationEllipsis v-else :index="index" :class="ellipsisClass">
</PaginationEllipsis>
</template>
</PaginationList>
<PaginationNext as-child>
<Button variant="muted-textonly" size="md" class="text-sm">
{{ $t('g.next') }}
<i class="icon-[lucide--chevron-right] size-4" />
</Button>
</PaginationNext>
</div>
</PaginationRoot>
</template>
<script setup lang="ts">
import {
PaginationEllipsis,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
PaginationRoot
} from 'reka-ui'
import Button from '@/components/ui/button/Button.vue'
const {
page = 1,
total,
itemsPerPage = 10
} = defineProps<{
page?: number
total: number
itemsPerPage?: number
}>()
const emit = defineEmits<{ 'update:page': [page: number] }>()
const ellipsisClass =
'inline-flex size-8 items-center justify-center text-sm text-muted-foreground'
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div :class="cn('relative w-full overflow-auto', className)">
<table
class="w-full caption-bottom border-separate border-spacing-0 text-sm"
>
<slot />
</table>
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,13 @@
<template>
<tbody :class="cn('[&_tr:last-child]:border-0', className)">
<slot />
</tbody>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,13 @@
<template>
<td :class="cn('px-2 py-2.5 align-middle whitespace-nowrap', className)">
<slot />
</td>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,21 @@
<template>
<th
scope="col"
:class="
cn(
'h-10 px-2 text-left align-middle text-sm font-normal whitespace-nowrap text-muted-foreground',
className
)
"
>
<slot />
</th>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,15 @@
<template>
<thead
:class="cn('[&_tr]:border-b [&_tr]:border-interface-stroke/60', className)"
>
<slot />
</thead>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,20 @@
<template>
<tr
:class="
cn(
'border-b border-interface-stroke/60 transition-colors hover:bg-secondary-background/50 data-[state=selected]:bg-secondary-background/50',
className
)
"
>
<slot />
</tr>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -1559,6 +1559,7 @@
"3DViewer": "3DViewer",
"Canvas Navigation": "Canvas Navigation",
"PlanCredits": "Plan & Credits",
"Members": "Members",
"Vue Nodes": "Nodes 2.0",
"VueNodes": "Nodes 2.0",
"Nodes 2_0": "Nodes 2.0",
@@ -2881,9 +2882,37 @@
"updatePassword": "Update Password"
},
"workspacePanel": {
"activity": {
"columns": {
"creditsUsed": "Credits",
"date": "Date",
"eventDetails": "Event details",
"eventType": "Event type",
"user": "User"
},
"empty": "No activity yet.",
"eventType": {
"cloudRun": "Cloud workflow run",
"partnerNode": "Partner node usage"
},
"fullActivity": "Full activity",
"hoverCard": {
"lastActivity": "Last activity",
"partnerNodeUsed": "Partner node used",
"totalCreditsUsed": "Total credits used"
},
"perUserHint": "Looking for total usage per user?",
"seeMembers": "See Members"
},
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of {count} members",
"planCredits": {
"tabs": {
"activity": "Activity",
"overview": "Credits"
}
},
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
@@ -2893,6 +2922,12 @@
"placeholder": "Dashboard workspace settings"
},
"members": {
"activity": {
"daysAgo": "{count} day ago | {count} days ago",
"hoursAgo": "{n} hr ago",
"justNow": "just now",
"minutesAgo": "{n} min ago"
},
"header": "Members",
"membersCount": "{count} of {maxSeats} members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",

View File

@@ -95,6 +95,7 @@ import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMess
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type {
@@ -135,6 +136,14 @@ const { fetchBalance } = useBillingContext()
const navRef = ref<HTMLElement | null>(null)
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
// Let a panel deep-link into a sibling panel (e.g. the Activity tab → Members).
const { requestedPanelKey } = useSettingsNavigation()
watch(requestedPanelKey, (key) => {
if (!key) return
activeCategoryKey.value = key
requestedPanelKey.value = null
})
const searchableNavItems = computed(() =>
navGroups.value.flatMap((g) =>
g.items.map((item) => ({

View File

@@ -27,13 +27,14 @@ const CATEGORY_ICONS: Record<string, string> = {
keybinding: 'icon-[lucide--keyboard]',
LiteGraph: 'icon-[lucide--workflow]',
'Mask Editor': 'icon-[lucide--pen-tool]',
Members: 'icon-[lucide--users]',
Other: 'icon-[lucide--ellipsis]',
PlanCredits: 'icon-[lucide--credit-card]',
PlanCredits: 'icon-[lucide--receipt-text]',
secrets: 'icon-[lucide--key-round]',
'server-config': 'icon-[lucide--server]',
subscription: 'icon-[lucide--credit-card]',
user: 'icon-[lucide--user]',
workspace: 'icon-[lucide--building-2]'
workspace: 'icon-[lucide--receipt-text]'
}
interface SettingPanelItem {
@@ -175,16 +176,30 @@ export function useSettingUI(
)
}
// Workspace panel: only available on cloud with team workspaces enabled
const workspacePanel: SettingPanelItem = {
// Workspace panels: only available on cloud with team workspaces enabled.
// The old single "Workspace" panel is split into two sidebar entries; the
// Plan & Credits entry keeps the 'workspace' key so existing deep links land.
const planCreditsPanel: SettingPanelItem = {
node: {
key: 'workspace',
label: 'Workspace',
label: 'PlanCredits',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/WorkspacePanelContent.vue')
import('@/platform/workspace/components/dialogs/settings/PlanCreditsPanelContent.vue')
)
}
const membersPanel: SettingPanelItem = {
node: {
key: 'workspace-members',
label: 'Members',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/WorkspaceMembersPanelContent.vue')
)
}
@@ -245,7 +260,9 @@ export function useSettingUI(
aboutPanel,
creditsPanel,
userPanel,
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
...(shouldShowWorkspacePanel.value
? [planCreditsPanel, membersPanel]
: []),
keybindingPanel,
extensionPanel,
...(isDesktop ? [serverConfigPanel] : []),
@@ -295,8 +312,13 @@ export function useSettingUI(
key: 'workspace',
label: 'Workspace',
children: [
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
...(isLoggedIn.value &&
...(shouldShowWorkspacePanel.value
? [planCreditsPanel.node, membersPanel.node]
: []),
// The legacy per-account Credits panel is redundant once the workspace
// Plan & Credits panel is present, which now owns the credit balance.
...(!shouldShowWorkspacePanel.value &&
isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]
: [])

View File

@@ -0,0 +1,13 @@
import { ref } from 'vue'
// A one-shot request to switch the open Settings dialog to another panel, so a
// panel's content can deep-link into a sibling panel (e.g. Activity → Members).
const requestedPanelKey = ref<string | null>(null)
export function useSettingsNavigation() {
function navigateToPanel(key: string) {
requestedPanelKey.value = key
}
return { requestedPanelKey, navigateToPanel }
}

View File

@@ -0,0 +1,69 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import PlanCreditsPanelContent from './PlanCreditsPanelContent.vue'
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => reactive({ workspaceName: 'Acme Team' })
}))
const stubs = {
WorkspaceProfilePic: { template: '<div />' },
SubscriptionPanelContentWorkspace: {
template: '<div data-testid="credits-body" />'
},
WorkspaceActivityContent: {
props: ['search'],
template: '<div data-testid="activity-body">{{ search }}</div>'
}
}
function renderPanel() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return render(PlanCreditsPanelContent, { global: { plugins: [i18n], stubs } })
}
describe('PlanCreditsPanelContent', () => {
it('shows Credits and Activity tabs with Credits active by default', () => {
renderPanel()
expect(screen.getByRole('button', { name: 'Credits' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Activity' })).toBeTruthy()
expect(screen.getByTestId('credits-body')).toBeTruthy()
expect(screen.queryByTestId('activity-body')).toBeNull()
})
it('shows the search box only on the Activity tab', async () => {
renderPanel()
expect(screen.queryByPlaceholderText('Search')).toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Activity' }))
expect(screen.getByTestId('activity-body')).toBeTruthy()
expect(screen.queryByTestId('credits-body')).toBeNull()
expect(screen.getByPlaceholderText('Search')).toBeTruthy()
})
it('passes the search query to the Activity tab and clears it on tab change', async () => {
renderPanel()
await userEvent.click(screen.getByRole('button', { name: 'Activity' }))
await userEvent.type(screen.getByPlaceholderText('Search'), 'flux')
expect(screen.getByTestId('activity-body').textContent).toContain('flux')
await userEvent.click(screen.getByRole('button', { name: 'Credits' }))
await userEvent.click(screen.getByRole('button', { name: 'Activity' }))
expect(screen.getByTestId('activity-body').textContent).not.toContain(
'flux'
)
expect(
(screen.getByPlaceholderText('Search') as HTMLInputElement).value
).toBe('')
})
})

View File

@@ -0,0 +1,81 @@
<template>
<div class="@container flex size-full min-h-0 flex-col">
<header class="mb-6 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 text-3xl!"
:workspace-name="workspaceName"
/>
<h1 class="text-3xl font-semibold text-base-foreground">
{{ workspaceName }}
</h1>
</header>
<div
class="mb-4 flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-9"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<Button
v-for="tab in tabs"
:key="tab.key"
:variant="activeView === tab.key ? 'secondary' : 'muted-textonly'"
size="lg"
@click="setView(tab.key)"
>
{{ tab.label }}
</Button>
</div>
<SearchInput
v-if="activeView === 'activity'"
v-model="searchQuery"
:placeholder="$t('g.search')"
size="lg"
class="w-full @2xl:w-64"
/>
</div>
<SubscriptionPanelContentWorkspace v-if="activeView === 'overview'" />
<WorkspaceActivityContent
v-else
:search="searchQuery"
:events="activityEvents"
/>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import WorkspaceActivityContent from '@/platform/workspace/components/dialogs/settings/WorkspaceActivityContent.vue'
import { useWorkspaceActivitySource } from '@/platform/workspace/composables/useWorkspaceActivitySource'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
type View = 'overview' | 'activity'
const { t } = useI18n()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceName } = storeToRefs(workspaceStore)
const { events: activityEvents } = useWorkspaceActivitySource()
// The Invoices tab (owner/admin only) is added by FE-1245, which owns the
// next-invoice banner + Stripe portal link that fill it.
const tabs = computed<{ key: View; label: string }[]>(() => [
{ key: 'overview', label: t('workspacePanel.planCredits.tabs.overview') },
{ key: 'activity', label: t('workspacePanel.planCredits.tabs.activity') }
])
const activeView = ref<View>('overview')
const searchQuery = ref('')
function setView(view: View) {
activeView.value = view
searchQuery.value = ''
}
</script>

View File

@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { activityFixture } from '@/platform/workspace/fixtures/activityFixtures'
import WorkspaceActivityContent from './WorkspaceActivityContent.vue'
// The Activity tab renders inside a flex, height-constrained dialog panel and
// auto-sizes its page to the available height; the decorator reproduces that
// container (and the @container query context the footer/tabs rely on).
const meta: Meta<typeof WorkspaceActivityContent> = {
title: 'Platform/Workspace/WorkspaceActivityContent',
component: WorkspaceActivityContent,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
decorators: [
() => ({
template:
'<div class="@container flex h-[520px] flex-col p-6"><story /></div>'
})
],
args: { search: '' }
}
export default meta
type Story = StoryObj<typeof meta>
export const Populated: Story = {
args: { events: activityFixture }
}
export const Empty: Story = {
args: { events: [] }
}
export const Searched: Story = {
args: { events: activityFixture, search: 'partner' }
}

View File

@@ -0,0 +1,122 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import type { ActivityEvent } from '@/platform/workspace/composables/useWorkspaceActivity'
import WorkspaceActivityContent from './WorkspaceActivityContent.vue'
const {
mockCanManageSubscription,
mockNavigateToPanel,
mockRequestMembersSort
} = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
mockCanManageSubscription: ref(true),
mockNavigateToPanel: vi.fn(),
mockRequestMembersSort: vi.fn()
}
})
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: {
get value() {
return { canManageSubscription: mockCanManageSubscription.value }
}
}
})
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
userDisplayName: { value: 'Ada Lovelace' },
userEmail: { value: 'ada@example.com' }
})
}))
vi.mock('@/platform/settings/composables/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateToPanel: mockNavigateToPanel })
}))
vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
requestMembersSort: mockRequestMembersSort
}))
vi.mock('@/config/comfyApi', () => ({
getComfyPlatformBaseUrl: () => 'https://platform.test'
}))
class NoopResizeObserver implements ResizeObserver {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
}
function renderContent(events: ActivityEvent[] = []) {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return render(WorkspaceActivityContent, {
props: { search: '', events },
global: { plugins: [i18n] }
})
}
const creditedRow: ActivityEvent = {
id: 'inflow',
date: new Date('2026-07-14T12:00:00Z'),
userName: '',
eventType: 'Auto-reload',
detail: '—',
credits: 20000,
credited: true
}
describe('WorkspaceActivityContent', () => {
beforeEach(() => {
globalThis.ResizeObserver = NoopResizeObserver
mockCanManageSubscription.value = true
mockNavigateToPanel.mockClear()
mockRequestMembersSort.mockClear()
})
it('renders the empty state when there is no activity', () => {
renderContent([])
expect(screen.getByText('No activity yet.')).toBeTruthy()
})
it('shows the per-user footer actions to a billing manager', () => {
renderContent([])
const link = screen.getByRole('link', { name: /full activity/i })
expect(link.getAttribute('href')).toBe(
'https://platform.test/profile/usage'
)
expect(screen.getByRole('button', { name: /see members/i })).toBeTruthy()
})
it('hides the per-user footer from members', () => {
mockCanManageSubscription.value = false
renderContent([])
expect(screen.queryByRole('link', { name: /full activity/i })).toBeNull()
expect(screen.queryByRole('button', { name: /see members/i })).toBeNull()
})
it('deep-links to the Members panel pre-sorted by spend', async () => {
renderContent([])
await userEvent.click(screen.getByRole('button', { name: /see members/i }))
expect(mockRequestMembersSort).toHaveBeenCalledWith('credits')
expect(mockNavigateToPanel).toHaveBeenCalledWith('workspace-members')
})
it('marks a credit inflow with a leading plus', () => {
renderContent([creditedRow])
expect(screen.getByText(/^\+20,000$/)).toBeTruthy()
})
})

View File

@@ -0,0 +1,328 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-4">
<div
ref="tableContainer"
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
>
<Table class="min-h-0 flex-1 px-4">
<TableHeader class="sticky top-0 z-10 bg-base-background">
<TableRow
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead class="w-40" :aria-sort="ariaSort('date')">
<button :class="sortHeaderClass" @click="toggleSort('date')">
{{ $t('workspacePanel.activity.columns.date') }}
<i :class="sortIcon('date')" />
</button>
</TableHead>
<TableHead class="w-56" :aria-sort="ariaSort('user')">
<button :class="sortHeaderClass" @click="toggleSort('user')">
{{ $t('workspacePanel.activity.columns.user') }}
<i :class="sortIcon('user')" />
</button>
</TableHead>
<TableHead :aria-sort="ariaSort('eventType')">
<button :class="sortHeaderClass" @click="toggleSort('eventType')">
{{ $t('workspacePanel.activity.columns.eventType') }}
<i :class="sortIcon('eventType')" />
</button>
</TableHead>
<TableHead class="w-32" :aria-sort="ariaSort('detail')">
<button :class="sortHeaderClass" @click="toggleSort('detail')">
{{ $t('workspacePanel.activity.columns.eventDetails') }}
<i :class="sortIcon('detail')" />
</button>
</TableHead>
<TableHead class="w-40" :aria-sort="ariaSort('credits')">
<button
:class="cn(sortHeaderClass, 'ml-auto')"
@click="toggleSort('credits')"
>
<i class="icon-[lucide--coins] size-4" />
{{ $t('workspacePanel.activity.columns.creditsUsed') }}
<i :class="sortIcon('credits')" />
</button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="event in pagedItems"
:key="event.id"
class="hover:bg-transparent [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
>
<TableCell class="text-sm text-muted-foreground tabular-nums">
{{ formatDate(event.date) }}
</TableCell>
<TableCell>
<HoverCard
v-if="event.userName"
:open-delay="150"
:close-delay="0"
>
<HoverCardTrigger
as="div"
class="flex w-fit cursor-default items-center gap-3"
>
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full"
:style="{ backgroundColor: userBadgeColor(event.userName) }"
>
<span class="text-2xs font-bold text-base-foreground">
{{ event.userName.charAt(0).toUpperCase() }}
</span>
</span>
<span class="truncate text-sm text-base-foreground">
{{ event.userName }}
</span>
</HoverCardTrigger>
<HoverCardContent class="w-64" align="start">
<div class="flex w-full flex-col gap-2">
<div class="flex h-5 items-center justify-between">
<span class="text-sm text-muted-foreground">
{{
$t(
'workspacePanel.activity.hoverCard.totalCreditsUsed'
)
}}
</span>
<span class="flex items-center gap-1">
<i
class="icon-[lucide--coins] size-4 text-muted-foreground"
/>
<span class="text-sm text-base-foreground tabular-nums">
{{
summaryFor(
event.userName
).totalCredits.toLocaleString()
}}
</span>
</span>
</div>
<div class="flex h-5 items-center justify-between">
<span class="text-sm text-muted-foreground">
{{
$t('workspacePanel.activity.hoverCard.lastActivity')
}}
</span>
<span class="text-sm text-base-foreground">
{{ lastActivityLabel(event.userName) }}
</span>
</div>
</div>
</HoverCardContent>
</HoverCard>
<span v-else class="text-sm text-muted-foreground"></span>
</TableCell>
<TableCell class="text-sm text-muted-foreground">
<HoverCard
v-if="event.partnerNode"
:open-delay="150"
:close-delay="0"
>
<HoverCardTrigger as="span" class="cursor-default">
{{ event.eventType }}
</HoverCardTrigger>
<HoverCardContent class="w-72" align="start">
<div class="flex h-5 items-center justify-between gap-4">
<span
class="text-sm whitespace-nowrap text-muted-foreground"
>
{{
$t('workspacePanel.activity.hoverCard.partnerNodeUsed')
}}
</span>
<span class="truncate text-sm text-base-foreground">
{{ event.partnerNode }}
</span>
</div>
</HoverCardContent>
</HoverCard>
<template v-else>{{ event.eventType }}</template>
</TableCell>
<TableCell class="text-sm text-muted-foreground tabular-nums">
{{ event.detail || '—' }}
</TableCell>
<TableCell
:class="
cn(
'text-right text-sm tabular-nums',
event.credited ? 'text-credit' : 'text-muted-foreground'
)
"
>
{{ event.credited ? '+' : ''
}}{{ event.credits.toLocaleString() }}
</TableCell>
</TableRow>
<TableRow v-if="pagedItems.length === 0" class="hover:bg-transparent">
<TableCell
:colspan="5"
class="py-6 text-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.activity.empty') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div class="flex flex-col gap-3 @2xl:h-8 @2xl:flex-row @2xl:items-center">
<div
v-if="canViewTeamUsage"
class="flex flex-wrap items-center gap-x-6 gap-y-2"
>
<a
:href="fullActivityUrl"
target="_blank"
rel="noopener noreferrer"
class="flex cursor-pointer items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
<i class="icon-[lucide--external-link] size-4" />
{{ $t('workspacePanel.activity.fullActivity') }}
</a>
<div class="flex items-center gap-3">
<p class="text-sm text-muted-foreground">
{{ $t('workspacePanel.activity.perUserHint') }}
</p>
<button
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-[inherit] text-sm text-base-foreground transition-colors hover:text-muted-foreground"
@click="goToMembers"
>
{{ $t('workspacePanel.activity.seeMembers') }}
<i class="icon-[lucide--arrow-right] size-4" />
</button>
</div>
</div>
<Pagination
v-model:page="page"
:total="total"
:items-per-page="itemsPerPage"
class="@2xl:ml-auto"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import HoverCard from '@/components/ui/hover-card/HoverCard.vue'
import HoverCardContent from '@/components/ui/hover-card/HoverCardContent.vue'
import HoverCardTrigger from '@/components/ui/hover-card/HoverCardTrigger.vue'
import Pagination from '@/components/ui/pagination/Pagination.vue'
import Table from '@/components/ui/table/Table.vue'
import TableBody from '@/components/ui/table/TableBody.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableHead from '@/components/ui/table/TableHead.vue'
import TableHeader from '@/components/ui/table/TableHeader.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
import { requestMembersSort } from '@/platform/workspace/composables/useMembersPanel'
import { useWorkspaceActivity } from '@/platform/workspace/composables/useWorkspaceActivity'
import type {
ActivityEvent,
ActivitySortField
} from '@/platform/workspace/composables/useWorkspaceActivity'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
import { cn } from '@comfyorg/tailwind-utils'
// `events` is the data seam: the tab shell mounts this with no events (empty
// state) until FE-1249 wires the per-workspace usage API and passes them in.
const { search, events = [] } = defineProps<{
search: string
events?: ActivityEvent[]
}>()
const { t, d } = useI18n()
const tableContainer = ref<HTMLElement | null>(null)
const { pageSize } = useAutoPageSize(tableContainer, 1)
// Owners/admins see team-wide activity + the per-user footer; members see only
// their own usage, scoped to their name.
const { permissions } = useWorkspaceUI()
const { userDisplayName, userEmail } = useCurrentUser()
const canViewTeamUsage = computed(() => permissions.value.canManageSubscription)
const selfName = computed(() =>
canViewTeamUsage.value
? null
: userDisplayName.value || userEmail.value || t('g.you')
)
const fullActivityUrl = `${getComfyPlatformBaseUrl()}/profile/usage`
const { navigateToPanel } = useSettingsNavigation()
function goToMembers() {
requestMembersSort('credits')
navigateToPanel('workspace-members')
}
const {
page,
total,
itemsPerPage,
pagedItems,
sortField,
sortDirection,
toggleSort,
userSummaries
} = useWorkspaceActivity(
() => search,
pageSize,
selfName,
() => events
)
function summaryFor(userName: string) {
return (
userSummaries.value.get(userName) ?? {
totalCredits: 0,
lastActivity: new Date()
}
)
}
function lastActivityLabel(userName: string): string {
return formatRelativeTime(summaryFor(userName).lastActivity, new Date(), {
justNow: t('workspacePanel.members.activity.justNow'),
minutesAgo: (n) => t('workspacePanel.members.activity.minutesAgo', { n }),
hoursAgo: (n) => t('workspacePanel.members.activity.hoursAgo', { n }),
daysAgo: (n) => t('workspacePanel.members.activity.daysAgo', n)
})
}
const sortHeaderClass =
'flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left font-[inherit] text-sm text-muted-foreground'
function sortIcon(field: ActivitySortField) {
if (sortField.value !== field) return 'icon-[lucide--chevrons-up-down] size-3'
return sortDirection.value === 'asc'
? 'icon-[lucide--chevron-up] size-3'
: 'icon-[lucide--chevron-down] size-3'
}
function ariaSort(
field: ActivitySortField
): 'ascending' | 'descending' | 'none' {
if (sortField.value !== field) return 'none'
return sortDirection.value === 'asc' ? 'ascending' : 'descending'
}
function formatDate(date: Date): string {
return d(date, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
})
}
</script>

View File

@@ -0,0 +1,36 @@
import { render } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { reactive, ref } from 'vue'
import WorkspaceMembersPanelContent from './WorkspaceMembersPanelContent.vue'
const { mockFetchMembers, mockFetchPendingInvites } = vi.hoisted(() => ({
mockFetchMembers: vi.fn(),
mockFetchPendingInvites: vi.fn()
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () =>
reactive({
workspaceName: 'Acme Team',
fetchMembers: mockFetchMembers,
fetchPendingInvites: mockFetchPendingInvites
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({ workspaceRole: ref('owner') })
}))
const stubs = {
WorkspaceProfilePic: { template: '<div />' },
MembersPanelContent: { template: '<div data-testid="members-body" />' }
}
describe('WorkspaceMembersPanelContent', () => {
it('fetches members and pending invites on mount', () => {
render(WorkspaceMembersPanelContent, { global: { stubs } })
expect(mockFetchMembers).toHaveBeenCalled()
expect(mockFetchPendingInvites).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,34 @@
<template>
<div class="flex size-full flex-col">
<header class="mb-6 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 text-3xl!"
:workspace-name="workspaceName"
/>
<h1 class="text-3xl font-semibold text-base-foreground">
{{ workspaceName }}
</h1>
</header>
<MembersPanelContent :key="workspaceRole" />
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { onMounted } from 'vue'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const workspaceStore = useTeamWorkspaceStore()
const { workspaceName } = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { workspaceRole } = useWorkspaceUI()
onMounted(() => {
fetchMembers()
fetchPendingInvites()
})
</script>

View File

@@ -1,129 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import WorkspacePanelContent from './WorkspacePanelContent.vue'
const mockFetchMembers = vi.fn()
const mockFetchPendingInvites = vi.fn()
const { mockMembers, mockWorkspaceType } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
mockMembers: ref<WorkspaceMember[]>([]),
mockWorkspaceType: ref<'personal' | 'team'>('team')
}
})
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
storeToRefs: (store: Record<string, unknown>) => store
}
})
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
useTeamWorkspaceStore: () => ({
workspaceName: ref('Acme Team'),
members: mockMembers,
fetchMembers: mockFetchMembers,
fetchPendingInvites: mockFetchPendingInvites
})
}
})
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
useWorkspaceUI: () => ({
workspaceType: mockWorkspaceType,
workspaceRole: ref('owner')
})
}
})
vi.mock(
'@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue',
() => ({
default: { name: 'SubscriptionPanelContentWorkspace', template: '<div />' }
})
)
vi.mock(
'@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue',
() => ({
default: { name: 'MembersPanelContent', template: '<div />' }
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function createMember(id: string): WorkspaceMember {
return {
id,
name: `Member ${id}`,
email: `member${id}@example.com`,
joinDate: new Date('2025-01-15'),
role: 'member',
isOriginalOwner: false
}
}
function renderComponent() {
return render(WorkspacePanelContent, {
global: {
plugins: [i18n],
stubs: { WorkspaceProfilePic: true }
}
})
}
describe('WorkspacePanelContent members tab label', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMembers.value = []
mockWorkspaceType.value = 'team'
})
it('shows the counted label for team workspaces with multiple members', () => {
mockMembers.value = [createMember('1'), createMember('2')]
renderComponent()
expect(screen.getByText(/workspacePanel\.tabs\.membersCount/)).toBeTruthy()
})
it('drops the count when the owner is the only member', () => {
mockMembers.value = [createMember('1')]
renderComponent()
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
})
it('shows the plain Members label for personal workspaces', () => {
mockWorkspaceType.value = 'personal'
mockMembers.value = [createMember('1'), createMember('2')]
renderComponent()
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
})
it('fetches members and pending invites on mount', () => {
renderComponent()
expect(mockFetchMembers).toHaveBeenCalled()
expect(mockFetchPendingInvites).toHaveBeenCalled()
})
})

View File

@@ -1,95 +0,0 @@
<template>
<div class="flex size-full flex-col">
<header class="mb-6 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 text-3xl!"
:workspace-name="workspaceName"
/>
<h1 class="text-3xl font-semibold text-base-foreground">
{{ workspaceName }}
</h1>
</header>
<TabsRoot v-model="activeTab">
<TabsList class="flex items-center gap-2 pb-1">
<TabsTrigger
value="plan"
:class="
cn(
tabTriggerBase,
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{ $t('workspacePanel.tabs.planCredits') }}
</TabsTrigger>
<TabsTrigger
value="members"
:class="
cn(
tabTriggerBase,
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{
showMembersTabCount
? $t('workspacePanel.tabs.membersCount', {
count: members.length
})
: $t('workspacePanel.members.header')
}}
</TabsTrigger>
</TabsList>
<TabsContent value="plan" class="mt-4">
<SubscriptionPanelContentWorkspace />
</TabsContent>
<TabsContent value="members" class="mt-4">
<MembersPanelContent :key="workspaceRole" />
</TabsContent>
</TabsRoot>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
const tabTriggerBase =
'flex items-center justify-center shrink-0 px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200 outline-hidden border-none'
const tabTriggerActive =
'bg-interface-menu-component-surface-hovered text-text-primary font-bold'
const tabTriggerInactive =
'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
const { defaultTab = 'plan' } = defineProps<{
defaultTab?: string
}>()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceName, members } = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { workspaceType, workspaceRole } = useWorkspaceUI()
const isPersonalWorkspace = computed(() => workspaceType.value === 'personal')
const activeTab = ref(defaultTab)
// Per design, the tab counts members only when there is more than the owner
const showMembersTabCount = computed(
() => !isPersonalWorkspace.value && members.value.length > 1
)
onMounted(() => {
fetchMembers()
fetchPendingInvites()
})
</script>

View File

@@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope, ref } from 'vue'
import type { Ref } from 'vue'
import { useAutoPageSize } from './useAutoPageSize'
const resizeObserverState = vi.hoisted(() => {
const state = {
callback: null as ResizeObserverCallback | null,
observe: vi.fn<(element: Element) => void>(),
disconnect: vi.fn<() => void>()
}
const MockResizeObserver: typeof ResizeObserver = class MockResizeObserver implements ResizeObserver {
observe = state.observe
unobserve = vi.fn()
disconnect = state.disconnect
constructor(callback: ResizeObserverCallback) {
state.callback = callback
}
}
globalThis.ResizeObserver = MockResizeObserver
return state
})
interface ContainerShape {
clientHeight: number
rowHeight: number | null
headerHeight: number | null
}
function fakeContainer({
clientHeight,
rowHeight,
headerHeight
}: ContainerShape): HTMLElement {
return {
clientHeight,
querySelector(selector: string) {
if (selector === 'tbody tr') {
return rowHeight === null
? null
: { getBoundingClientRect: () => ({ height: rowHeight }) }
}
if (selector === 'thead') {
return headerHeight === null
? null
: { getBoundingClientRect: () => ({ height: headerHeight }) }
}
if (selector === 'table') return {}
return null
}
} as unknown as HTMLElement
}
function runInScope(container: Ref<HTMLElement | null>, min?: number) {
const scope = effectScope()
const result = scope.run(() => useAutoPageSize(container, min))!
return { ...result, stop: () => scope.stop() }
}
describe('useAutoPageSize', () => {
beforeEach(() => {
resizeObserverState.callback = null
resizeObserverState.observe.mockClear()
resizeObserverState.disconnect.mockClear()
})
it('fits as many whole rows as the container height allows', () => {
const container = ref(
fakeContainer({ clientHeight: 500, rowHeight: 41, headerHeight: 56 })
)
const { pageSize } = runInScope(container, 1)
resizeObserverState.callback!([], {} as ResizeObserver)
// (500 - 56) / 41 = 10.83 -> floored to 10 whole rows
expect(pageSize.value).toBe(10)
})
it('floors a fractional fit so a not-quite-fitting row is left out', () => {
const container = ref(
fakeContainer({ clientHeight: 449, rowHeight: 41, headerHeight: 0 })
)
const { pageSize } = runInScope(container, 1)
resizeObserverState.callback!([], {} as ResizeObserver)
// 449 / 41 = 10.95 -> 10, never 11 (which would overflow + show a scrollbar)
expect(pageSize.value).toBe(10)
})
it('never returns fewer than the requested minimum', () => {
const container = ref(
fakeContainer({ clientHeight: 50, rowHeight: 41, headerHeight: 0 })
)
const { pageSize } = runInScope(container, 5)
resizeObserverState.callback!([], {} as ResizeObserver)
// fit is 1, but min=5 wins
expect(pageSize.value).toBe(5)
})
it('falls back to a default row height before any rows are rendered', () => {
const container = ref(
fakeContainer({ clientHeight: 420, rowHeight: null, headerHeight: 0 })
)
const { pageSize } = runInScope(container, 1)
resizeObserverState.callback!([], {} as ResizeObserver)
// no tbody row yet -> FALLBACK_ROW_HEIGHT 41: floor(420 / 41) = 10
expect(pageSize.value).toBe(10)
})
it('re-measures when the table height changes after rows load', () => {
const container = ref(
fakeContainer({ clientHeight: 420, rowHeight: null, headerHeight: 0 })
)
const { pageSize } = runInScope(container, 1)
resizeObserverState.callback!([], {} as ResizeObserver)
expect(pageSize.value).toBe(10)
// real rows arrive taller than the fallback; the observer fires again
container.value = fakeContainer({
clientHeight: 420,
rowHeight: 60,
headerHeight: 0
})
resizeObserverState.callback!([], {} as ResizeObserver)
// floor(420 / 60) = 7
expect(pageSize.value).toBe(7)
})
it('disconnects the observer when the scope stops', () => {
const container = ref(
fakeContainer({ clientHeight: 500, rowHeight: 41, headerHeight: 0 })
)
const { stop } = runInScope(container, 1)
stop()
expect(resizeObserverState.disconnect).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,55 @@
import type { Ref } from 'vue'
import { onScopeDispose, ref, watch } from 'vue'
const FALLBACK_ROW_HEIGHT = 41
const MIN_ROWS = 5
/**
* Derive a table's rows-per-page from the live height of its scroll container so
* a taller dialog shows more rows instead of leaving empty space. Row and header
* heights are read from the rendered table, so it adapts if the design changes.
*/
export function useAutoPageSize(
containerRef: Ref<HTMLElement | null>,
min: number = MIN_ROWS
) {
const pageSize = ref(min)
function measure() {
const container = containerRef.value
if (!container) return
// Use fractional (getBoundingClientRect) heights, not the integer
// offsetHeight: truncating each row's height makes the floor below think an
// extra partial row fits, which overflows the container by a few pixels and
// shows a scrollbar. Fractional heights keep a not-quite-fitting row out.
const rowHeight =
container.querySelector<HTMLElement>('tbody tr')?.getBoundingClientRect()
.height || FALLBACK_ROW_HEIGHT
const headerHeight =
container.querySelector<HTMLElement>('thead')?.getBoundingClientRect()
.height ?? 0
const fit = Math.floor((container.clientHeight - headerHeight) / rowHeight)
pageSize.value = Math.max(min, fit)
}
let observer: ResizeObserver | null = null
watch(
containerRef,
(el) => {
observer?.disconnect()
if (!el) return
observer = new ResizeObserver(() => measure())
observer.observe(el)
// Also observe the table itself: async-loaded rows change the table's
// height without resizing the container, so the initial measure (taken
// against an empty state or fallback row height) would otherwise stick.
// Re-measuring converges — pageSize stabilizes once real rows exist.
const table = el.querySelector('table')
if (table) observer.observe(table)
},
{ immediate: true }
)
onScopeDispose(() => observer?.disconnect())
return { pageSize }
}

View File

@@ -23,6 +23,17 @@ type ActiveView = 'active' | 'pending'
type SortField = 'inviteDate' | 'expiryDate' | 'role'
type SortDirection = 'asc' | 'desc'
// One-shot cross-panel request from the Activity tab to open the Members table
// sorted by a per-member column. Those columns ship with member auditing on the
// Members panel (DES-479); until then useMembersPanel clears the request without
// a matching column to apply it to.
type RequestedMembersSort = 'credits' | 'lastActivity'
const requestedMembersSort = ref<RequestedMembersSort | null>(null)
export function requestMembersSort(field: RequestedMembersSort) {
requestedMembersSort.value = field
}
export function sortMembers(
members: WorkspaceMember[],
currentUserEmail: string | null,
@@ -113,6 +124,9 @@ export function useMembersPanel() {
const { isOnTeamPlan, isCancelled, hasLapsedTeamPlan } = useTeamPlan()
const subscriptionDialog = useSubscriptionDialog()
// Consume the Activity tab's cross-panel sort request (see requestMembersSort).
if (requestedMembersSort.value) requestedMembersSort.value = null
// The team plan caps members at a flat MAX_WORKSPACE_MEMBERS, independent of
// the subscription tier.
const maxSeats = computed(() => MAX_WORKSPACE_MEMBERS)

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import { effectScope, nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import { activityFixture } from '@/platform/workspace/fixtures/activityFixtures'
import { useWorkspaceActivity } from './useWorkspaceActivity'
import type { ActivityEvent } from './useWorkspaceActivity'
interface SetupOptions {
search?: Ref<string>
pageSize?: Ref<number>
selfName?: Ref<string | null>
source?: ActivityEvent[]
}
function setup(options: SetupOptions = {}) {
const search = options.search ?? ref('')
const pageSize = options.pageSize ?? ref(100)
const selfName = options.selfName ?? ref<string | null>(null)
const scope = effectScope()
const api = scope.run(() =>
useWorkspaceActivity(
search,
pageSize,
selfName,
options.source ?? activityFixture
)
)!
return { ...api, search, pageSize, selfName, stop: () => scope.stop() }
}
describe('useWorkspaceActivity', () => {
it('shows the whole ledger to an owner (no self scope)', () => {
const { total } = setup()
expect(total.value).toBe(activityFixture.length)
})
it('scopes a member to their own usage rows plus workspace credit inflows', () => {
const { total, pagedItems } = setup({
selfName: ref('Ada Lovelace')
})
// Ada's 4 usage rows + the 2 credited inflows (userName '')
expect(total.value).toBe(6)
const names = new Set(pagedItems.value.map((e) => e.userName))
expect(names).toEqual(new Set(['Ada Lovelace', '']))
})
it('searches user name and event type only, not other columns', () => {
const search = ref('')
const { total } = setup({ search })
search.value = 'ada'
expect(total.value).toBe(4) // the 4 "Ada Lovelace" rows
search.value = 'partner'
expect(total.value).toBe(5) // the 5 "Partner node usage" rows
search.value = '2 runs' // a detail value — not searched
expect(total.value).toBe(0)
})
it('sorts by date descending by default and dispatches other fields', () => {
const { pagedItems, sortField, toggleSort } = setup()
expect(pagedItems.value[0].id).toBe('evt-01') // newest date
toggleSort('credits')
expect(sortField.value).toBe('credits')
expect(pagedItems.value[0].id).toBe('evt-12') // 50000, highest
toggleSort('credits') // flip to ascending
expect(pagedItems.value[0].id).toBe('evt-09') // 760, lowest
})
it('slices to the current page size', () => {
const { pagedItems, total } = setup({ pageSize: ref(5) })
expect(total.value).toBe(activityFixture.length)
expect(pagedItems.value).toHaveLength(5)
})
it('clamps the page when the filtered result shrinks past it', async () => {
const search = ref('')
const { page, search: s } = setup({ search, pageSize: ref(5) })
page.value = 3 // valid: ceil(14 / 5) = 3 pages
s.value = 'katherine' // narrows to 2 rows -> 1 page
await nextTick()
expect(page.value).toBe(1)
})
it('rolls up per-user totals excluding credit inflows', () => {
const { userSummaries } = setup()
const ada = userSummaries.value.get('Ada Lovelace')
expect(ada?.totalCredits).toBe(1840 + 5120 + 4100 + 2950)
expect(ada?.lastActivity).toEqual(new Date('2026-07-14T09:32:00Z'))
// credited inflows (userName '') never appear in the rollups
expect(userSummaries.value.has('')).toBe(false)
})
})

View File

@@ -0,0 +1,133 @@
import type { MaybeRefOrGetter } from 'vue'
import { computed, ref, toValue, watch } from 'vue'
export interface ActivityEvent {
id: string
date: Date
userName: string
eventType: string
detail: string
credits: number
/** The partner node used, for 'Partner node usage' events. */
partnerNode?: string
/** True for credit inflows (auto-reload, top-up) vs. usage outflows. */
credited?: boolean
}
export interface UserSummary {
totalCredits: number
lastActivity: Date
}
export type ActivitySortField =
| 'date'
| 'user'
| 'eventType'
| 'detail'
| 'credits'
/**
* Headless state for the workspace Activity ledger: role-scoped filtering,
* search, sort, auto-paginated slicing, and per-user rollups.
*
* `source` is the data seam. It defaults to an empty list, so the ledger renders
* its empty state until a caller supplies events; the per-workspace usage API
* that will feed it is tracked in FE-1249. Swapping in real data means passing a
* populated ref/getter here — every downstream computed stays untouched.
*/
export function useWorkspaceActivity(
search: MaybeRefOrGetter<string>,
pageSize: MaybeRefOrGetter<number>,
selfName: MaybeRefOrGetter<string | null> = null,
source: MaybeRefOrGetter<ActivityEvent[]> = []
) {
const page = ref(1)
const perPage = computed(() => Math.max(1, toValue(pageSize)))
const sortField = ref<ActivitySortField>('date')
const sortDirection = ref<'asc' | 'desc'>('desc')
// Members only see their own usage; credit inflows stay workspace-level.
const base = computed<ActivityEvent[]>(() => {
const events = toValue(source)
const self = toValue(selfName)
if (!self) return events
return events.filter((event) => event.credited || event.userName === self)
})
const filtered = computed(() => {
const q = toValue(search).trim().toLowerCase()
if (!q) return base.value
return base.value.filter(
(event) =>
event.userName.toLowerCase().includes(q) ||
event.eventType.toLowerCase().includes(q)
)
})
const sorted = computed(() => {
const dir = sortDirection.value === 'asc' ? 1 : -1
return [...filtered.value].sort((a, b) => {
if (sortField.value === 'credits') return dir * (a.credits - b.credits)
if (sortField.value === 'user')
return dir * a.userName.localeCompare(b.userName)
if (sortField.value === 'eventType')
return dir * a.eventType.localeCompare(b.eventType)
if (sortField.value === 'detail')
return dir * a.detail.localeCompare(b.detail)
return dir * (a.date.getTime() - b.date.getTime())
})
})
const total = computed(() => filtered.value.length)
const pagedItems = computed(() => {
const start = (page.value - 1) * perPage.value
return sorted.value.slice(start, start + perPage.value)
})
function toggleSort(field: ActivitySortField) {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'desc'
}
}
watch([total, perPage], ([count]) => {
const lastPage = Math.max(1, Math.ceil(count / perPage.value))
if (page.value > lastPage) page.value = lastPage
})
// Per-user rollups behind the User hover card: lifetime credits and the most
// recent event, aggregated across the whole (unpaged) list.
const userSummaries = computed(() => {
const map = new Map<string, UserSummary>()
for (const event of base.value) {
if (event.credited) continue
const existing = map.get(event.userName)
if (!existing) {
map.set(event.userName, {
totalCredits: event.credits,
lastActivity: event.date
})
} else {
existing.totalCredits += event.credits
if (event.date > existing.lastActivity)
existing.lastActivity = event.date
}
}
return map
})
return {
page,
total,
itemsPerPage: perPage,
pagedItems,
sortField,
sortDirection,
toggleSort,
userSummaries
}
}

View File

@@ -0,0 +1,64 @@
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ActivityEvent } from '@/platform/workspace/composables/useWorkspaceActivity'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type { BillingEventInput } from '@/platform/workspace/utils/billingEventToActivity'
import {
billingEventToActivity,
isUsageEvent
} from '@/platform/workspace/utils/billingEventToActivity'
/**
* Live data source for the workspace Activity ledger (FE-1249): fetches the
* per-workspace usage feed (`GET /api/billing/events`), keeps only usage rows,
* and maps them to the ledger's `ActivityEvent` shape, resolving member names
* from the workspace store so the mapping updates once members load.
*/
export function useWorkspaceActivitySource() {
const { t } = useI18n()
const workspaceStore = useTeamWorkspaceStore()
const { members } = storeToRefs(workspaceStore)
const rawEvents = ref<BillingEventInput[]>([])
const isLoading = ref(false)
const error = ref<unknown>(null)
function resolveUserName(userId: string | undefined): string {
if (!userId) return ''
const member = members.value.find((m) => m.id === userId)
return member?.name || member?.email || userId
}
const events = computed<ActivityEvent[]>(() => {
const labels = {
cloudRun: t('workspacePanel.activity.eventType.cloudRun'),
partnerNode: t('workspacePanel.activity.eventType.partnerNode')
}
return rawEvents.value
.filter(isUsageEvent)
.map((event) => billingEventToActivity(event, resolveUserName, labels))
})
async function refresh() {
isLoading.value = true
error.value = null
try {
const response = await workspaceApi.getBillingEvents()
rawEvents.value = response.events
} catch (err) {
error.value = err
rawEvents.value = []
} finally {
isLoading.value = false
}
}
onMounted(() => {
void refresh()
})
return { events, isLoading, error, refresh }
}

View File

@@ -11,7 +11,7 @@ import { useDialogService } from '@/services/dialogService'
* Builds the Plan & Credits overflow-menu model for the workspace subscription
* panel. Visibility and the Delete enable/disable policy are derived from the
* shared useWorkspaceUI state so this menu can't desync with the sibling
* WorkspacePanelContent menu.
* Plan & Credits panel menu.
*/
export function useWorkspaceMenuItems() {
const { t } = useI18n()

View File

@@ -0,0 +1,130 @@
import type { ActivityEvent } from '@/platform/workspace/composables/useWorkspaceActivity'
/**
* Non-production sample ledger for Storybook and unit tests only — never
* imported by shipping code. It stands in for the per-workspace usage API
* (FE-1249) so the Activity table can be exercised with a populated,
* multi-page, multi-user data set (usage outflows, partner-node rows, and
* workspace-level credit inflows) while the live source is still empty.
*/
export const activityFixture: ActivityEvent[] = [
{
id: 'evt-01',
date: new Date('2026-07-14T09:32:00Z'),
userName: 'Ada Lovelace',
eventType: 'Cloud workflow run',
detail: '2 runs',
credits: 1840
},
{
id: 'evt-02',
date: new Date('2026-07-14T08:10:00Z'),
userName: 'Grace Hopper',
eventType: 'Partner node usage',
detail: '1 run',
credits: 3200,
partnerNode: 'Flux Pro 1.1 Ultra'
},
{
id: 'evt-03',
date: new Date('2026-07-13T22:47:00Z'),
userName: 'Ada Lovelace',
eventType: 'Partner node usage',
detail: '4 runs',
credits: 5120,
partnerNode: 'Kling v2 Master'
},
{
id: 'evt-04',
date: new Date('2026-07-13T18:05:00Z'),
userName: 'Alan Turing',
eventType: 'Cloud workflow run',
detail: '7 runs',
credits: 6300
},
{
id: 'evt-05',
date: new Date('2026-07-13T12:00:00Z'),
userName: '',
eventType: 'Auto-reload',
detail: '—',
credits: 20000,
credited: true
},
{
id: 'evt-06',
date: new Date('2026-07-12T16:22:00Z'),
userName: 'Grace Hopper',
eventType: 'Cloud workflow run',
detail: '3 runs',
credits: 2700
},
{
id: 'evt-07',
date: new Date('2026-07-12T11:41:00Z'),
userName: 'Alan Turing',
eventType: 'Partner node usage',
detail: '2 runs',
credits: 4480,
partnerNode: 'Gemini 2.5 Flash Image'
},
{
id: 'evt-08',
date: new Date('2026-07-11T20:15:00Z'),
userName: 'Ada Lovelace',
eventType: 'Cloud workflow run',
detail: '5 runs',
credits: 4100
},
{
id: 'evt-09',
date: new Date('2026-07-11T09:03:00Z'),
userName: 'Katherine Johnson',
eventType: 'Cloud workflow run',
detail: '1 run',
credits: 760
},
{
id: 'evt-10',
date: new Date('2026-07-10T14:38:00Z'),
userName: 'Grace Hopper',
eventType: 'Partner node usage',
detail: '6 runs',
credits: 8900,
partnerNode: 'Veo 3'
},
{
id: 'evt-11',
date: new Date('2026-07-10T07:26:00Z'),
userName: 'Alan Turing',
eventType: 'Cloud workflow run',
detail: '2 runs',
credits: 1560
},
{
id: 'evt-12',
date: new Date('2026-07-09T19:52:00Z'),
userName: '',
eventType: 'Top-up',
detail: '—',
credits: 50000,
credited: true
},
{
id: 'evt-13',
date: new Date('2026-07-09T13:14:00Z'),
userName: 'Katherine Johnson',
eventType: 'Partner node usage',
detail: '3 runs',
credits: 3840,
partnerNode: 'Recraft V3'
},
{
id: 'evt-14',
date: new Date('2026-07-08T10:48:00Z'),
userName: 'Ada Lovelace',
eventType: 'Cloud workflow run',
detail: '4 runs',
credits: 2950
}
]

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { userBadgeColor } from './badgeColor'
const HEX = /^#[0-9a-f]{6}$/
describe('userBadgeColor', () => {
it('is deterministic for the same seed', () => {
expect(userBadgeColor('Ada Lovelace')).toBe(userBadgeColor('Ada Lovelace'))
})
it('always returns a color from the palette', () => {
for (const seed of ['', 'a', 'Grace Hopper', 'user@example.com', '你好']) {
expect(userBadgeColor(seed)).toMatch(HEX)
}
})
it('spreads different seeds across more than one color', () => {
const seeds = Array.from({ length: 30 }, (_, i) => `user-${i}`)
const distinct = new Set(seeds.map(userBadgeColor))
expect(distinct.size).toBeGreaterThan(1)
})
})

View File

@@ -0,0 +1,21 @@
// Muted, low-saturation badge palette (sampled from the Figma usage palette).
// Dark tones that read against the dark surface with a white monogram.
const BADGE_COLORS = [
'#956252', // terracotta
'#3e465f', // slate indigo
'#424f45', // olive green
'#90646e', // mauve rose
'#6d5a7a', // muted purple
'#4f6b6b', // muted teal
'#7a6a4a', // khaki
'#5a6270' // steel
]
/** Stable muted badge color for a user, keyed by name/email. */
export function userBadgeColor(seed: string): string {
let hash = 0
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0
}
return BADGE_COLORS[hash % BADGE_COLORS.length]
}

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import type { BillingEventInput } from '@/platform/workspace/utils/billingEventToActivity'
import {
billingEventToActivity,
isUsageEvent
} from '@/platform/workspace/utils/billingEventToActivity'
const labels = {
cloudRun: 'Cloud workflow run',
partnerNode: 'Partner node usage'
}
const nameById: Record<string, string> = { 'user-1': 'Ada Lovelace' }
function resolveName(userId: string | undefined): string {
if (!userId) return ''
return nameById[userId] ?? userId
}
function event(overrides: Partial<BillingEventInput>): BillingEventInput {
return {
event_type: 'gpu_usage',
event_id: 'evt',
createdAt: '2026-07-14T00:00:00Z',
...overrides
}
}
describe('isUsageEvent', () => {
it('keeps gpu and api-node usage and drops non-usage events', () => {
expect(isUsageEvent(event({ event_type: 'gpu_usage' }))).toBe(true)
expect(isUsageEvent(event({ event_type: 'api_node_usage' }))).toBe(true)
expect(isUsageEvent(event({ event_type: 'invoice_paid' }))).toBe(false)
})
})
describe('billingEventToActivity', () => {
it('maps a GPU usage row to a cloud-run entry attributed to its member', () => {
const row = billingEventToActivity(
event({
event_type: 'gpu_usage',
event_id: 'evt-1',
createdAt: '2026-07-14T09:32:00Z',
params: { user_id: 'user-1', gpu_seconds: 12 }
}),
resolveName,
labels
)
expect(row).toMatchObject({
id: 'evt-1',
userName: 'Ada Lovelace',
eventType: 'Cloud workflow run',
credits: 0,
credited: false
})
expect(row.date.toISOString()).toBe('2026-07-14T09:32:00.000Z')
expect(row.partnerNode).toBeUndefined()
})
it('maps a partner usage row with its partner node', () => {
const row = billingEventToActivity(
event({
event_type: 'api_node_usage',
event_id: 'evt-2',
params: { user_id: 'user-1', partner_node: 'Flux Pro 1.1 Ultra' }
}),
resolveName,
labels
)
expect(row.eventType).toBe('Partner node usage')
expect(row.partnerNode).toBe('Flux Pro 1.1 Ultra')
})
it('leaves the user name empty when the event has no user_id', () => {
const row = billingEventToActivity(
event({ event_id: 'evt-3', params: {} }),
resolveName,
labels
)
expect(row.userName).toBe('')
})
})

View File

@@ -0,0 +1,57 @@
import type { ActivityEvent } from '@/platform/workspace/composables/useWorkspaceActivity'
/**
* Maps a `/api/billing/events` usage row to an Activity ledger row (FE-1249).
*
* Only the fields the backend exposes today are populated: the date, the member
* the event is attributed to (`params.user_id`), and the event type. The Credits
* column stays 0 until per-event cost lands (FE-1249 / P1), and `partnerNode` is
* best-effort from `params` because the partner (comfy-api) property contract is
* not yet fixed.
*/
export interface BillingEventInput {
event_type: string
event_id: string
params?: Record<string, unknown>
createdAt: string
}
export interface ActivityEventTypeLabels {
cloudRun: string
partnerNode: string
}
const USAGE_EVENT_TYPES = new Set(['gpu_usage', 'api_node_usage'])
export function isUsageEvent(event: BillingEventInput): boolean {
return USAGE_EVENT_TYPES.has(event.event_type)
}
function stringParam(
params: Record<string, unknown> | undefined,
key: string
): string | undefined {
const value = params?.[key]
return typeof value === 'string' && value.length > 0 ? value : undefined
}
export function billingEventToActivity(
event: BillingEventInput,
resolveUserName: (userId: string | undefined) => string,
labels: ActivityEventTypeLabels
): ActivityEvent {
const isPartner = event.event_type === 'api_node_usage'
return {
id: event.event_id,
date: new Date(event.createdAt),
userName: resolveUserName(stringParam(event.params, 'user_id')),
eventType: isPartner ? labels.partnerNode : labels.cloudRun,
detail: '',
credits: 0,
partnerNode: isPartner
? stringParam(event.params, 'partner_node')
: undefined,
credited: false
}
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { formatRelativeTime } from './relativeTime'
const labels = {
justNow: 'just now',
minutesAgo: (n: number) => `${n} min ago`,
hoursAgo: (n: number) => `${n} hr ago`,
daysAgo: (n: number) => `${n} days ago`
}
const now = new Date('2026-07-14T12:00:00Z')
function ago(ms: number) {
return new Date(now.getTime() - ms)
}
const SECOND = 1000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
describe('formatRelativeTime', () => {
it('reads "just now" under a minute', () => {
expect(formatRelativeTime(ago(30 * SECOND), now, labels)).toBe('just now')
})
it('reads whole minutes under an hour', () => {
expect(formatRelativeTime(ago(6 * MINUTE), now, labels)).toBe('6 min ago')
expect(formatRelativeTime(ago(59 * MINUTE), now, labels)).toBe('59 min ago')
})
it('reads whole hours under a day', () => {
expect(formatRelativeTime(ago(2 * HOUR), now, labels)).toBe('2 hr ago')
})
it('reads whole days beyond a day', () => {
expect(formatRelativeTime(ago(3 * DAY), now, labels)).toBe('3 days ago')
})
it('never returns a negative bucket for a future timestamp', () => {
expect(formatRelativeTime(ago(-5 * MINUTE), now, labels)).toBe('just now')
})
})

View File

@@ -0,0 +1,29 @@
const MINUTE_MS = 60 * 1000
const HOUR_MS = 60 * MINUTE_MS
const DAY_MS = 24 * HOUR_MS
interface RelativeTimeLabels {
justNow: string
minutesAgo: (n: number) => string
hoursAgo: (n: number) => string
daysAgo: (n: number) => string
}
/**
* Abbreviated "time ago" label (e.g. "6 min ago", "2 hr ago", "3 days ago"),
* matching the member-list activity column. Copy is injected so callers can
* supply localized, pluralized strings.
*/
export function formatRelativeTime(
date: Date,
now: Date,
labels: RelativeTimeLabels
): string {
const elapsed = Math.max(0, now.getTime() - date.getTime())
if (elapsed < MINUTE_MS) return labels.justNow
if (elapsed < HOUR_MS)
return labels.minutesAgo(Math.floor(elapsed / MINUTE_MS))
if (elapsed < DAY_MS) return labels.hoursAgo(Math.floor(elapsed / HOUR_MS))
return labels.daysAgo(Math.floor(elapsed / DAY_MS))
}

View File

@@ -0,0 +1,16 @@
import { computed, ref } from 'vue'
/**
* Storybook mock for `useCurrentUser`.
*
* The real composable reads the Firebase-backed `authStore`, whose setup calls
* `setPersistence` and crashes in the Storybook environment (no Firebase). This
* static stub presents a signed-in user so components that only need the
* display name / email (e.g. WorkspaceActivityContent's member self-scope)
* render without any auth.
*/
export const useCurrentUser = () => ({
userDisplayName: ref('Ada Lovelace'),
userEmail: ref('ada@example.com'),
isLoggedIn: computed(() => true)
})

View File

@@ -0,0 +1,17 @@
import { computed, ref } from 'vue'
/**
* Storybook mock for `useWorkspaceUI`.
*
* The real composable derives its state from the auth/workspace stores that
* pull in Firebase and crash in Storybook. This stub presents a billing
* manager (owner) so role-gated surfaces — the Activity ledger's team-wide
* scope and its per-user footer — render in their fully-populated form.
*/
export function useWorkspaceUI() {
return {
permissions: computed(() => ({ canManageSubscription: true })),
workspaceRole: ref('owner'),
workspaceType: ref('team')
}
}