- Persist active_preset to backend on every save (PersistController) - Read active_preset back on page load via OverviewLoadContextService and OverviewCorePayloadComposer - Seed activePresetRef from onCoreLoaded so the pill shows on every page load, not just the session where a profile was loaded - Watch lastLoadedPreset → activePresetRef so loading or saving a profile updates the widget live - TimeTargetsCard: new presetLabel prop renders a brand-colored pill next to the title - targets_v2 buildProps passes ctx.activePreset as presetLabel - WidgetRenderContext carries activePreset field - ReportRenderService: show profile name as frosted pill in hero card when set - ReportSummaryService: include active_preset in email summary payload - Fix activity highlights sprintf arg mismatch (extra 'Quiet days' literal) - Redesign all email sections as widget-style cards matching app visual language
212 lines
7.3 KiB
TypeScript
212 lines
7.3 KiB
TypeScript
import { ref, type Ref } from 'vue'
|
|
|
|
import {
|
|
cloneTargetsConfig,
|
|
normalizeTargetsConfig,
|
|
type TargetsConfig,
|
|
} from '../src/services/targets'
|
|
import type { WidgetTabsState } from '../src/services/widgetsRegistry'
|
|
import type { ThemePreference } from './useThemePreference'
|
|
import {
|
|
normalizeDeckSettings,
|
|
normalizeReportingConfig,
|
|
type DeckFeatureSettings,
|
|
type ReportingConfig,
|
|
} from '../src/services/reporting'
|
|
import { normalizeWidgetTabs, createDefaultWidgetTabs } from '../src/services/widgetsRegistry'
|
|
import { compareReleaseVersions, normalizeReleaseVersion } from '../src/services/releaseNotes'
|
|
import type { OnboardingState } from './useDashboard'
|
|
|
|
interface DashboardPersistenceDeps {
|
|
route: (name: 'persist') => string
|
|
postJson: (url: string, body: Record<string, unknown>) => Promise<any>
|
|
notifyError: (message: string) => void
|
|
notifySuccess: (message: string) => void
|
|
onReload?: () => Promise<void> | void
|
|
selected: Ref<string[]>
|
|
groupsById: Ref<Record<string, number>>
|
|
targetsWeek: Ref<Record<string, number>>
|
|
targetsMonth: Ref<Record<string, number>>
|
|
targetsConfig: Ref<TargetsConfig>
|
|
themePreference?: Ref<ThemePreference>
|
|
reportingConfig?: Ref<ReportingConfig>
|
|
deckSettings?: Ref<DeckFeatureSettings>
|
|
widgetTabs?: Ref<WidgetTabsState>
|
|
onboardingState?: Ref<OnboardingState | null>
|
|
activePreset?: Ref<string | null>
|
|
}
|
|
|
|
export function useDashboardPersistence(deps: DashboardPersistenceDeps) {
|
|
const isSaving = ref(false)
|
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
|
let saveSequence = 0
|
|
let latestRequestId = 0
|
|
|
|
function queueSave(reload = true) {
|
|
if (saveTimer) {
|
|
clearTimeout(saveTimer)
|
|
}
|
|
saveTimer = setTimeout(async () => {
|
|
saveTimer = null
|
|
const requestId = ++saveSequence
|
|
latestRequestId = requestId
|
|
try {
|
|
isSaving.value = true
|
|
const previousConfig = cloneTargetsConfig(deps.targetsConfig.value)
|
|
const payload: Record<string, any> = {
|
|
cals: deps.selected.value,
|
|
groups: deps.groupsById.value,
|
|
targets_week: deps.targetsWeek.value,
|
|
targets_month: deps.targetsMonth.value,
|
|
targets_config: previousConfig,
|
|
}
|
|
if (deps.themePreference) {
|
|
payload.theme_preference = deps.themePreference.value
|
|
}
|
|
if (deps.reportingConfig) {
|
|
payload.reporting_config = deps.reportingConfig.value
|
|
}
|
|
if (deps.deckSettings) {
|
|
payload.deck_settings = deps.deckSettings.value
|
|
}
|
|
if (deps.widgetTabs) {
|
|
payload.widgets = deps.widgetTabs.value
|
|
}
|
|
if (deps.onboardingState?.value) {
|
|
payload.onboarding = deps.onboardingState.value
|
|
}
|
|
if (deps.activePreset !== undefined) {
|
|
payload.active_preset = deps.activePreset.value ?? null
|
|
}
|
|
const result = await deps.postJson(deps.route('persist'), payload)
|
|
if (requestId !== latestRequestId) {
|
|
return
|
|
}
|
|
|
|
if (Array.isArray(result.read)) {
|
|
deps.selected.value = result.read.map((id: any) => String(id))
|
|
} else if (Array.isArray(result.saved)) {
|
|
deps.selected.value = result.saved.map((id: any) => String(id))
|
|
}
|
|
|
|
const cfgRead = result.targets_config_read as TargetsConfig | undefined
|
|
const cfgSaved = result.targets_config_saved as TargetsConfig | undefined
|
|
const nextConfig = mergeIncomingTargetsConfig(cfgRead ?? cfgSaved, previousConfig)
|
|
if (nextConfig) {
|
|
deps.targetsConfig.value = nextConfig
|
|
}
|
|
|
|
if (deps.themePreference) {
|
|
const nextTheme = normalizeThemePreference(
|
|
result.theme_preference_read ?? result.theme_preference_saved,
|
|
)
|
|
if (nextTheme) {
|
|
deps.themePreference.value = nextTheme
|
|
}
|
|
}
|
|
if (deps.reportingConfig) {
|
|
const nextReporting = normalizeReportingConfig(
|
|
result.reporting_config_read ?? result.reporting_config_saved,
|
|
deps.reportingConfig.value,
|
|
)
|
|
if (nextReporting) {
|
|
deps.reportingConfig.value = nextReporting
|
|
}
|
|
}
|
|
if (deps.deckSettings) {
|
|
const nextDeck = normalizeDeckSettings(
|
|
result.deck_settings_read ?? result.deck_settings_saved,
|
|
deps.deckSettings.value,
|
|
)
|
|
if (nextDeck) {
|
|
deps.deckSettings.value = nextDeck
|
|
}
|
|
}
|
|
if (deps.widgetTabs) {
|
|
const nextWidgets = result.widgets_read ?? result.widgets_saved ?? result.widgets
|
|
const fallback = deps.widgetTabs.value || createDefaultWidgetTabs('standard')
|
|
deps.widgetTabs.value = normalizeWidgetTabs(nextWidgets, fallback)
|
|
}
|
|
if (deps.onboardingState) {
|
|
const nextOnboarding = result.onboarding_read ?? result.onboarding_saved
|
|
if (nextOnboarding && typeof nextOnboarding === 'object') {
|
|
const currentSeenVersion = normalizeReleaseVersion(deps.onboardingState.value?.releaseNotesSeenVersion ?? '')
|
|
const incomingSeenVersion = normalizeReleaseVersion(nextOnboarding.releaseNotesSeenVersion ?? '')
|
|
deps.onboardingState.value = {
|
|
...(deps.onboardingState.value || {}),
|
|
...nextOnboarding,
|
|
releaseNotesSeenVersion: pickLatestReleaseVersion(currentSeenVersion, incomingSeenVersion),
|
|
} as OnboardingState
|
|
}
|
|
}
|
|
|
|
if (reload && deps.onReload) {
|
|
await deps.onReload()
|
|
}
|
|
|
|
deps.notifySuccess('Selection saved')
|
|
} catch (error) {
|
|
if (requestId !== latestRequestId) {
|
|
return
|
|
}
|
|
console.error(error)
|
|
deps.notifyError('Failed to save selection')
|
|
} finally {
|
|
if (requestId === latestRequestId) {
|
|
isSaving.value = false
|
|
}
|
|
}
|
|
}, 250)
|
|
}
|
|
|
|
return {
|
|
queueSave,
|
|
isSaving,
|
|
}
|
|
}
|
|
|
|
function normalizeThemePreference(value: any): ThemePreference | null {
|
|
if (value === 'light' || value === 'dark' || value === 'auto') {
|
|
return value
|
|
}
|
|
return null
|
|
}
|
|
|
|
function pickLatestReleaseVersion(currentVersion: string, incomingVersion: string): string {
|
|
if (!currentVersion) return incomingVersion
|
|
if (!incomingVersion) return currentVersion
|
|
return compareReleaseVersions(currentVersion, incomingVersion) >= 0
|
|
? incomingVersion
|
|
: currentVersion
|
|
}
|
|
|
|
function mergeIncomingTargetsConfig(
|
|
incoming: TargetsConfig | undefined,
|
|
previous: TargetsConfig,
|
|
): TargetsConfig | undefined {
|
|
if (!incoming) return undefined
|
|
|
|
const raw = JSON.parse(JSON.stringify(incoming)) as any
|
|
|
|
const prevCategories = Array.isArray(previous?.categories) ? previous.categories : []
|
|
const prevCategoryMap = new Map<string, any>()
|
|
prevCategories.forEach((cat: any) => {
|
|
const id = String(cat?.id ?? '')
|
|
if (id) prevCategoryMap.set(id, cat)
|
|
})
|
|
if (Array.isArray(raw.categories)) {
|
|
raw.categories = raw.categories.map((cat: any) => {
|
|
const id = String(cat?.id ?? '')
|
|
if (!id) return cat
|
|
const prev = prevCategoryMap.get(id)
|
|
if (prev && (cat?.color == null || cat.color === '')) {
|
|
if (typeof prev.color === 'string' && prev.color) {
|
|
cat.color = prev.color
|
|
}
|
|
}
|
|
return cat
|
|
})
|
|
}
|
|
|
|
return normalizeTargetsConfig(raw as TargetsConfig)
|
|
}
|