opsdash-app/opsdash/test/widgetsRegistry.test.ts
blade34242 5cb0d79f4a Redesign email hero, add chart blocks, fix days_off, add checkpoint/recap test buttons
- Email hero: formatted period title, RECAP/CHECKPOINT badge, 2x2 stat grid,
  inline meta tags, greeting separated from title, no addHeading()
- Checkpoint vs Recap distinction in subject, badge, and footer
- Replace "Calendar pace" with Balance index in calendar_goals hero stats
- Email chart blocks: Calendar split (pie as horizontal bars) and
  Day-of-week pattern for calendar_goals; adds Category split for
  category_and_calendar_goals; charts data pre-aggregated in
  ReportSummaryService.buildChartData() with per-weekday DOW averages
- Fix: days_off no longer counts future dates in the period as quiet days
- Fix: detectTimeSummaryDisplayMode and detectTargetsDisplayMode now always
  return the strategy-mapped mode when strategy is set, preventing stale
  categories config from overriding a known onboarding strategy
- Fix: resolveTodayGroups filters today groups by active display mode
- Add Checkpoint and Recap test-send buttons to onboarding Preferences;
  test sends now always use offset=-1 (recap) or offset=0 (checkpoint)
2026-05-19 14:01:31 +07:00

591 lines
21 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { createDefaultTargetsConfig } from '../src/services/targets'
import { mapWidgetToComponent, syncWidgetTabsForStrategy, widgetsRegistry } from '../src/services/widgetsRegistry'
describe('widgetsRegistry targets_v2', () => {
it('overrides UI flags without mutating base config', () => {
const entry = widgetsRegistry.targets_v2
const baseCfg = createDefaultTargetsConfig()
baseCfg.ui.showTotalDelta = true
baseCfg.includeZeroDaysInStats = false
const def: any = {
options: {
showTotalDelta: false,
showNeedPerDay: false,
badges: false,
includeWeekendToggle: false,
includeZeroDaysInStats: true,
},
}
const ctx: any = { targetsConfig: baseCfg }
const props = entry.buildProps(def, ctx) as any
expect(props.config.ui.showTotalDelta).toBe(false)
expect(props.config.ui.showNeedPerDay).toBe(false)
expect(props.config.ui.badges).toBe(false)
expect(props.config.ui.includeWeekendToggle).toBe(false)
expect(props.config.includeZeroDaysInStats).toBe(true)
expect(baseCfg.ui.showTotalDelta).toBe(true)
expect(baseCfg.includeZeroDaysInStats).toBe(false)
})
it('applies showPace toggle', () => {
const entry = widgetsRegistry.targets_v2
const def: any = { options: { showPace: false } }
const props = entry.buildProps(def, {}) as any
expect(props.showPace).toBe(false)
})
it('passes never finished mode only when explicitly enabled', () => {
const entry = widgetsRegistry.targets_v2
const offProps = entry.buildProps({ options: {} } as any, {}) as any
const onProps = entry.buildProps({ options: { neverFinishedMode: true } } as any, {}) as any
expect(entry.defaultOptions?.neverFinishedMode).toBe(false)
expect(offProps.neverFinishedMode).toBe(false)
expect(onProps.neverFinishedMode).toBe(true)
})
it('uses localConfig when enabled', () => {
const entry = widgetsRegistry.targets_v2
const baseCfg = createDefaultTargetsConfig()
baseCfg.totalHours = 48
const localCfg = { ...baseCfg, totalHours: 12 }
localCfg.categories = [{ id: 'only', label: 'Only', targetHours: 12, includeWeekend: true, groupIds: [1] }]
const def: any = {
options: {
useLocalConfig: true,
localConfig: localCfg,
showPace: true,
},
}
const ctx: any = {
targetsConfig: baseCfg,
targetsSummary: { total: { targetHours: 48 } },
stats: {},
byDay: [],
byCal: [{ total_hours: 6 }],
groupsById: {},
rangeMode: 'week',
from: '2024-01-01',
to: '2024-01-07',
}
const props = entry.buildProps(def, ctx) as any
expect(props.config.totalHours).toBe(12)
expect(props.summary.total.targetHours).toBe(12)
expect(props.summary.total.actualHours).toBe(6)
expect(Array.isArray(props.groups)).toBe(true)
expect(props.groups).toHaveLength(1)
expect(props.groups[0].id).toBe('only')
expect(ctx.targetsConfig.totalHours).toBe(48)
})
it('local summary respects current category list', () => {
const entry = widgetsRegistry.targets_v2
const baseCfg = createDefaultTargetsConfig()
const localCfg = {
...baseCfg,
categories: [{ id: 'only', label: 'Only', targetHours: 10, includeWeekend: true, groupIds: [1] }],
}
const def: any = { options: { useLocalConfig: true, localConfig: localCfg } }
const ctx: any = {
targetsConfig: baseCfg,
stats: {},
byDay: [],
byCal: [{ id: 'cal-1', total_hours: 5, group: 1 }],
groupsById: { 'cal-1': 1 },
rangeMode: 'week',
from: '2024-01-01',
to: '2024-01-07',
}
const props = entry.buildProps(def, ctx) as any
expect(props.summary.categories).toHaveLength(1)
expect(props.summary.categories[0].id).toBe('only')
expect(props.summary.categories[0].actualHours).toBe(5)
})
it('shows calendar rows for calendar goal mode', () => {
const entry = widgetsRegistry.targets_v2
const cfg = createDefaultTargetsConfig()
cfg.categories = []
cfg.ui.showCategoryBlocks = false
const def: any = { options: {} }
const ctx: any = {
onboardingStrategy: 'total_plus_categories',
targetsConfig: cfg,
targetsSummary: {
total: {
id: 'total',
label: 'Total',
actualHours: 6,
plannedHours: 1,
targetHours: 12,
percent: 50,
deltaHours: -6,
remainingHours: 6,
needPerDay: 3,
daysLeft: 2,
calendarPercent: 40,
gap: 10,
status: 'on_track',
statusLabel: 'On Track',
includeWeekend: true,
paceMode: 'days_only',
},
categories: [],
forecast: { text: '', linear: 0, momentum: 0, primaryMethod: 'linear' as const },
},
byCal: [{ id: 'cal-1', calendar: 'Primary', total_hours: 6, future_hours: 1 }],
calendars: [{ id: 'cal-1', displayname: 'Primary', color: '#ff0000' }],
currentTargets: { 'cal-1': 8 },
calendarTodayHours: { 'cal-1': 2 },
}
const props = entry.buildProps(def, ctx) as any
expect(props.config.ui.showCategoryBlocks).toBe(true)
expect(props.groups).toHaveLength(1)
expect(props.groups[0].id).toBe('cal-1')
expect(props.groups[0].label).toBe('Primary')
expect(props.groups[0].summary.targetHours).toBe(8)
expect(props.groups[0].todayHours).toBe(2)
})
it('keeps over-target calendar row percentages above 200%', () => {
const entry = widgetsRegistry.targets_v2
const cfg = createDefaultTargetsConfig()
cfg.categories = []
const props = entry.buildProps({ options: {} } as any, {
onboardingStrategy: 'total_plus_categories',
targetsConfig: cfg,
targetsSummary: {
total: {
id: 'total',
label: 'Total',
actualHours: 25,
plannedHours: 0,
targetHours: 10,
percent: 250,
deltaHours: 15,
remainingHours: 0,
needPerDay: 0,
daysLeft: 0,
calendarPercent: 100,
gap: 150,
status: 'done',
statusLabel: 'Done',
includeWeekend: true,
paceMode: 'days_only',
},
categories: [],
forecast: { text: '', linear: 0, momentum: 0, primaryMethod: 'linear' as const },
},
byCal: [{ id: 'cal-1', calendar: 'Primary', total_hours: 25 }],
calendars: [{ id: 'cal-1', displayname: 'Primary', color: '#ff0000' }],
currentTargets: { 'cal-1': 10 },
}) as any
expect(props.groups[0].summary.percent).toBe(250)
expect(props.groups[0].summary.gap).toBe(150)
})
it('prefers single goal mode over stale categories when strategy says total only', () => {
const entry = widgetsRegistry.targets_v2
const cfg = createDefaultTargetsConfig()
const def: any = { options: {} }
const ctx: any = {
onboardingStrategy: 'total_only',
targetsConfig: cfg,
targetsSummary: {
total: {
id: 'total',
label: 'Total',
actualHours: 6,
targetHours: 12,
percent: 50,
deltaHours: -6,
remainingHours: 6,
needPerDay: 3,
daysLeft: 2,
calendarPercent: 40,
gap: 10,
status: 'on_track',
statusLabel: 'On Track',
includeWeekend: true,
paceMode: 'days_only',
},
categories: cfg.categories,
forecast: { text: '', linear: 0, momentum: 0, primaryMethod: 'linear' as const },
},
byCal: [{ id: 'cal-1', calendar: 'Primary', total_hours: 6 }],
calendars: [{ id: 'cal-1', displayname: 'Primary', color: '#ff0000' }],
currentTargets: {},
}
const props = entry.buildProps(def, ctx) as any
expect(props.config.ui.showCategoryBlocks).toBe(false)
expect(props.groups).toEqual([])
})
it('time summary overview applies overrides to config', () => {
const entry = widgetsRegistry.time_summary_overview
const baseCfg = createDefaultTargetsConfig()
const def: any = {
options: {
showTotal: false,
showWeekendShare: false,
showBalance: false,
mode: 'all',
},
}
const ctx: any = { targetsConfig: baseCfg, summary: { rangeLabel: 'Week' }, activeDayMode: 'active' }
const props = entry.buildProps(def, ctx) as any
expect(props.config.showTotal).toBe(false)
expect(props.config.showWeekendShare).toBe(false)
expect(props.config.showBalance).toBe(true)
expect(props.showOverview).toBe(true)
expect(props.showLookback).toBe(false)
expect(props.showDelta).toBe(false)
expect(props.mode).toBe('all')
})
it('time summary defaults collapse strategy-specific rows for single goal mode', () => {
const entry = widgetsRegistry.time_summary_overview
const baseCfg = createDefaultTargetsConfig()
const props = entry.buildProps({ options: {} } as any, {
onboardingStrategy: 'total_only',
targetsConfig: baseCfg,
summary: { rangeLabel: 'Week' },
activeDayMode: 'active',
currentTargets: {},
}) as any
expect(props.displayMode).toBe('single_goal')
expect(props.config.showCalendarSummary).toBe(false)
expect(props.config.showTopCategory).toBe(false)
expect(props.config.showBalance).toBe(false)
})
it('time summary keeps calendar summary for calendar goals but hides category-specific rows', () => {
const entry = widgetsRegistry.time_summary_overview
const baseCfg = createDefaultTargetsConfig()
baseCfg.categories = []
const props = entry.buildProps({ options: {} } as any, {
onboardingStrategy: 'total_plus_categories',
targetsConfig: baseCfg,
summary: { rangeLabel: 'Week' },
activeDayMode: 'active',
currentTargets: { 'cal-1': 8 },
calendarTodayHours: { 'cal-1': 2.5, 'cal-2': 1 },
calendars: [
{ id: 'cal-1', displayname: 'Primary', color: '#ff0000' },
{ id: 'cal-2', displayname: 'Secondary', color: '#00ff00' },
],
groups: [
{ id: '__uncategorized__', label: 'Unassigned', todayHours: 3.5, isUnassigned: true },
],
}) as any
expect(props.displayMode).toBe('calendar_goals')
expect(props.config.showCalendarSummary).toBe(true)
expect(props.config.showTopCategory).toBe(false)
expect(props.config.showBalance).toBe(false)
expect(props.todayGroups).toEqual([
{ id: 'cal-1', label: 'Primary', todayHours: 2.5, color: '#ff0000' },
{ id: 'cal-2', label: 'Secondary', todayHours: 1, color: '#00ff00' },
])
})
it('time summary keeps category rows for category and calendar goal mode', () => {
const entry = widgetsRegistry.time_summary_overview
const baseCfg = createDefaultTargetsConfig()
const props = entry.buildProps({ options: {} } as any, {
onboardingStrategy: 'full_granular',
targetsConfig: baseCfg,
summary: { rangeLabel: 'Week' },
activeDayMode: 'active',
currentTargets: { 'cal-1': 8 },
}) as any
expect(props.displayMode).toBe('category_and_calendar_goals')
expect(props.config.showCalendarSummary).toBe(true)
expect(props.config.showTopCategory).toBe(true)
expect(props.config.showBalance).toBe(true)
})
it('syncs legacy widget defaults to the current strategy on first normalization', () => {
const synced = syncWidgetTabsForStrategy({
tabs: [{
id: 'tab-1',
label: 'Overview',
widgets: [
{
id: 'targets-1',
type: 'targets_v2',
options: { showCategoryBlocks: true },
layout: { width: 'half', height: 'm', order: 10 },
version: 1,
},
{
id: 'summary-1',
type: 'time_summary_overview',
options: {
showCalendarSummary: true,
showTopCategory: true,
showBalance: true,
},
layout: { width: 'half', height: 'm', order: 20 },
version: 1,
},
],
}],
defaultTabId: 'tab-1',
}, 'total_plus_categories')
expect(synced.tabs[0].widgets[0].options?.showCategoryBlocks).toBe(true)
expect(synced.tabs[0].widgets[1].options?.showCalendarSummary).toBe(true)
expect(synced.tabs[0].widgets[1].options?.showTopCategory).toBe(false)
expect(synced.tabs[0].widgets[1].options?.showBalance).toBe(false)
})
it('preserves manual display overrides while migrating strategy-owned defaults', () => {
const synced = syncWidgetTabsForStrategy({
tabs: [{
id: 'tab-1',
label: 'Overview',
widgets: [
{
id: 'summary-1',
type: 'time_summary_overview',
options: {
showCalendarSummary: false,
showTopCategory: true,
showBalance: false,
},
layout: { width: 'half', height: 'm', order: 20 },
version: 1,
},
],
}],
defaultTabId: 'tab-1',
}, 'total_plus_categories', 'full_granular')
expect(synced.tabs[0].widgets[0].options?.showCalendarSummary).toBe(false)
expect(synced.tabs[0].widgets[0].options?.showTopCategory).toBe(false)
expect(synced.tabs[0].widgets[0].options?.showBalance).toBe(false)
})
it('time summary lookback exposes defaults for options', () => {
const entry = widgetsRegistry.time_summary_lookback
expect(entry.defaultOptions?.showTotal).toBe(true)
expect(entry.defaultOptions?.mode).toBe('active')
expect(entry.defaultOptions?.showDelta).toBe(true)
})
it('calendar table prefers known strategy over stale category config', () => {
const entry = widgetsRegistry.calendar_table
const cfg = createDefaultTargetsConfig()
cfg.categories = [{ id: 'focus', label: 'Focus', targetHours: 12, includeWeekend: true, groupIds: [1] }]
const props = entry.buildProps({ options: {} } as any, {
onboardingStrategy: 'total_only',
targetsConfig: cfg,
byCal: [{ id: 'cal-1', calendar: 'Primary', total_hours: 6 }],
currentTargets: {},
calendarGroups: [{ id: 'focus', label: 'Focus' }],
}) as any
expect(props.mode).toBe('single_goal')
expect(props.groups).toEqual([])
})
it('balance_index uses defaults when options/context missing', () => {
const entry = widgetsRegistry.balance_index
const def: any = { options: {}, layout: {}, type: 'balance_index', id: 'w1', version: 1 }
const ctx: any = { balanceOverview: { trend: { history: [], delta: [], badge: '' }, categories: [], relations: [], warnings: [], index: 0 } }
const props = entry.buildProps(def, ctx) as any
expect(props.indexBasis).toBe('category')
expect(props.thresholds.noticeAbove).toBeCloseTo(0.15)
expect(props.thresholds.warnIndex).toBeCloseTo(0.6)
expect(props.showConfig).toBe(true)
expect(entry.defaultOptions?.indexBasis).toBe('category')
expect(entry.defaultOptions?.noticeAbove).toBeCloseTo(0.15)
})
it('balance_index falls back to calendar basis when category mapping is disabled', () => {
const entry = widgetsRegistry.balance_index
const cfg = createDefaultTargetsConfig()
cfg.balance.useCategoryMapping = false
cfg.balance.index.basis = 'category'
const def: any = {
options: { indexBasis: 'category' },
layout: {},
type: 'balance_index',
id: 'w-balance-calendar-fallback',
version: 1,
}
const ctx: any = {
targetsConfig: cfg,
balanceConfig: cfg.balance,
balanceOverview: { trend: { history: [], delta: [], badge: '' }, categories: [], relations: [], warnings: [], index: 0 },
}
const props = entry.buildProps(def, ctx) as any
expect(props.indexBasis).toBe('calendar')
})
it('balance_index propagates trend color and background', () => {
const entry = widgetsRegistry.balance_index
const def: any = {
options: {
trendColor: '#111111',
cardBg: '#fafafa',
},
layout: {},
type: 'balance_index',
id: 'w2',
version: 1,
}
const ctx: any = { balanceOverview: { trend: { history: [], delta: [], badge: '' }, categories: [], relations: [], warnings: [], index: 0 } }
const props = entry.buildProps(def, ctx) as any
expect(props.trendColor).toBe('#111111')
expect(props.cardBg).toBe('#fafafa')
})
it('trend widgets use global lookback and ignore per-widget lookback options', () => {
const balanceEntry = widgetsRegistry.balance_index
const balanceProps = balanceEntry.buildProps(
{
options: {
lookbackWeeks: 1,
loopbackCount: 1,
},
layout: {},
type: 'balance_index',
id: 'b1',
version: 1,
} as any,
{
lookbackWeeks: 4,
balanceOverview: { trend: { history: [], delta: [], badge: '' }, categories: [], relations: [], warnings: [], index: 0 },
} as any,
) as any
expect(balanceProps.lookbackWeeks).toBe(4)
expect(balanceProps.loopbackCount).toBe(4)
const mixEntry = widgetsRegistry.category_mix_trend
const mixProps = mixEntry.buildProps(
{
options: { lookbackWeeks: 1 },
layout: {},
type: 'category_mix_trend',
id: 'c1',
version: 1,
} as any,
{
lookbackWeeks: 3,
balanceOverview: { trend: { history: [], delta: [], badge: '' }, categories: [], relations: [], warnings: [], index: 0 },
} as any,
) as any
expect(mixProps.lookbackWeeks).toBe(3)
expect(mixEntry.defaultOptions?.colorMode).toBe('hybrid')
expect(mixEntry.defaultOptions?.trendIndicator).toBe('none')
expect(mixEntry.defaultOptions?.shareLowColor).toBe('#e2e8f0')
expect(mixEntry.defaultOptions?.shareHighColor).toBe('#60a5fa')
expect(mixEntry.defaultOptions?.toneLowColor).toBe('#e11d48')
expect(mixEntry.defaultOptions?.toneHighColor).toBe('#10b981')
})
it('dayoff_trend uses global unit and defaults tone colors', () => {
const entry = widgetsRegistry.dayoff_trend
const def: any = { options: { lookback: 1 }, layout: {}, type: 'dayoff_trend', id: 'd1', version: 1 }
const ctx: any = { activityDayOffTrend: [], activityTrendUnit: 'mo', activityDayOffLookback: 2 }
const props = entry.buildProps(def, ctx) as any
const keys = (entry.controls || []).map((control: any) => control.key)
expect(keys).not.toContain('unit')
expect(keys).not.toContain('lookback')
expect(keys).not.toContain('showBadges')
expect(keys).toContain('labelMode')
expect(keys).toContain('interpretation')
expect(entry.defaultOptions?.toneLowColor).toBe('#dc2626')
expect(entry.defaultOptions?.toneHighColor).toBe('#16a34a')
expect(entry.defaultOptions?.labelMode).toBe('period')
expect(entry.defaultOptions?.interpretation).toBe('more_off_positive')
expect(props.unit).toBe('mo')
expect(props.lookback).toBe(2)
})
it('common title prefix is applied when provided', () => {
const entry = widgetsRegistry.balance_index
const def: any = {
options: {
titlePrefix: 'My ',
},
layout: {},
type: 'balance_index',
id: 'w3',
version: 1,
}
const ctx: any = { balanceOverview: { trend: { history: [], delta: [], badge: '' }, categories: [], relations: [], warnings: [], index: 0 } }
const props = entry.buildProps(def, ctx) as any
expect(props.title.startsWith('My ')).toBe(true)
})
it('computes loading per widget type', () => {
const baseCtx: any = {
hasInitialLoad: true,
isLoading: false,
isInitialLoading: false,
isRefreshing: false,
deckLoading: false,
rangeLabel: 'Week',
rangeMode: 'week',
from: '2024-01-01',
to: '2024-01-07',
summary: {},
targetsConfig: {},
}
const defSummary: any = { id: 's1', type: 'time_summary_overview', layout: { width: 'half', height: 'm', order: 1 }, options: {}, version: 1 }
const defDeck: any = { id: 'd1', type: 'deck_cards', layout: { width: 'half', height: 'm', order: 1 }, options: {}, version: 1 }
expect(mapWidgetToComponent(defSummary, { ...baseCtx, isInitialLoading: true })?.loading).toBe(true)
expect(mapWidgetToComponent(defDeck, { ...baseCtx, deckLoading: true })?.loading).toBe(false)
expect(mapWidgetToComponent({ ...defSummary, type: 'unknown' }, baseCtx)).toBeNull()
})
it('deck_stats builds compact props from widget options', () => {
const entry = widgetsRegistry.deck_stats
const def: any = {
id: 'deck-stats-1',
type: 'deck_stats',
layout: { width: 'half', height: 'm', order: 2 },
options: {
boardIds: [1],
stackIds: [11],
tagIds: ['tag_urgent'],
metrics: ['open_now', 'due_in_range'],
scope: 'mine',
mineMode: 'both',
},
version: 1,
}
const props = entry.buildProps(def, {
deckCards: [],
rangeLabel: 'This week',
from: '2026-04-01',
to: '2026-04-07',
uid: 'me',
} as any) as any
expect(props.metrics).toEqual(['open_now', 'due_in_range'])
expect(props.scope).toBe('mine')
expect(props.mineMode).toBe('both')
expect(props.selectionText).toContain('1 board')
expect(props.selectionText).toContain('Mine')
})
})