import type { SilentRisk } from './xiaobao-risk'; import type { Bug } from './bug'; import type { DevTask } from './dev-task'; import type { TaskWorklog } from './task-worklog'; import type { TestCase } from './test-case'; import type { VersionPlan } from './version-plan'; import type { WorkActivity, WorkActivityCategory } from './work-activity'; import type { WorkItem } from './workspace-engine'; import { calcActualElapsedHours } from './work-hours'; export interface EvidenceItem { id: string; title: string; summary: string; occurredAt: string; actorId?: string; } export interface VersionDailyEvidence { todayDeliveries: EvidenceItem[]; todayProgress: EvidenceItem[]; todayCreations: EvidenceItem[]; todayRisks: EvidenceItem[]; progressNotes: EvidenceItem[]; needsProgressItems: EvidenceItem[]; recentActivityCount: number; totalActivityCount: number; todayActualHours: number; lastActivityAt?: string; silentRisks?: SilentRisk[]; } export interface BuildVersionDailyEvidenceInput { versionId: string; workItems: WorkItem[]; activities?: WorkActivity[]; worklogs?: TaskWorklog[]; now?: Date; } export interface XiaobaoVersionContext { id: string; name: string; productName?: string; projectName?: string; } export interface BuildXiaobaoWorkItemsInput { plans?: VersionPlan[]; devTasks?: DevTask[]; testCases?: TestCase[]; bugs?: Bug[]; versions: XiaobaoVersionContext[]; requirementVersionMap: Map | Record; } const ACTIVE_STATUSES = new Set(['in_progress', 'testing', 'running', 'open', 'fixing', 'fixed', 'verifying']); type EvidenceDraft = EvidenceItem & { sourceId: string; sourceType: WorkActivity['sourceType']; action: WorkActivity['action']; category: WorkActivityCategory; metadata?: WorkActivity['metadata']; }; export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] { const versionMap = new Map(input.versions.map((version) => [version.id, version])); const requirementVersionMap = normalizeRequirementVersionMap(input.requirementVersionMap); const items: WorkItem[] = []; for (const plan of input.plans ?? []) { const version = versionMap.get(plan.versionId); items.push({ id: plan.id, type: plan.type === 'research' ? 'plan_research' : plan.type === 'product' ? 'plan_product' : 'plan_ui', title: plan.title, status: plan.status, completed: plan.status === 'completed', productName: version?.productName ?? '-', projectName: version?.projectName ?? '-', versionName: version?.name ?? '-', versionId: plan.versionId, extra: { startTime: plan.startTime, endTime: plan.endTime, actualStartAt: plan.actualStartAt, completedAt: plan.completedAt, }, raw: plan, }); } for (const task of input.devTasks ?? []) { const versionId = task.versionId || requirementVersionMap.get(task.requirementId) || ''; const version = versionMap.get(versionId); items.push({ id: task.id, type: 'devTask', title: task.title, status: task.status, completed: task.status === 'submitted', productName: version?.productName ?? '-', projectName: version?.projectName ?? '-', versionName: version?.name ?? '-', versionId, priority: task.priority, extra: { taskNo: task.taskNo, expectedStartAt: task.expectedStartAt, expectedEndAt: task.expectedEndAt, actualStartAt: task.actualStartAt, actualEndAt: task.actualEndAt, isBlocked: task.isBlocked, blockReason: task.blockReason, }, raw: task, }); } for (const testCase of input.testCases ?? []) { const version = versionMap.get(testCase.versionId); items.push({ id: testCase.id, type: 'testCase', title: testCase.title, status: testCase.status, completed: testCase.status === 'passed', productName: version?.productName ?? '-', projectName: version?.projectName ?? '-', versionName: version?.name ?? '-', versionId: testCase.versionId, priority: testCase.priority, extra: { caseNo: testCase.caseNo, requirementId: testCase.requirementId, plannedTestAt: testCase.plannedTestAt, plannedEndAt: testCase.plannedEndAt, startedAt: testCase.startedAt, completedAt: testCase.completedAt, }, raw: testCase, }); } for (const bug of input.bugs ?? []) { const version = versionMap.get(bug.versionId); items.push({ id: bug.id, type: 'bug', title: bug.title, status: bug.status, completed: bug.status === 'closed' || bug.status === 'rejected', productName: version?.productName ?? '-', projectName: version?.projectName ?? '-', versionName: version?.name ?? '-', versionId: bug.versionId, priority: bug.priority, extra: { bugNo: bug.bugNo, severity: bug.severity, testCaseId: bug.testCaseId, plannedFixAt: bug.plannedFixAt, }, raw: bug, }); } return items; } export function buildVersionDailyEvidence(input: BuildVersionDailyEvidenceInput): VersionDailyEvidence { const now = input.now ?? new Date(); const today = now.toISOString().slice(0, 10); const versionItems = input.workItems.filter((item) => item.versionId === input.versionId); const versionItemIds = new Set(versionItems.map((item) => item.id)); const itemMap = new Map(versionItems.map((item) => [item.id, item])); const versionActivities = (input.activities ?? []).filter((activity) => versionItemIds.has(activity.sourceId)); const versionWorklogs = (input.worklogs ?? []).filter((worklog) => versionItemIds.has(worklog.taskId)); const todayActivities = versionActivities.filter((activity) => activity.date === today); const todayWorklogs = versionWorklogs.filter((worklog) => worklog.date === today); const activityKeys = new Set(todayActivities.map(getActivityKey)); const evidenceSourceIds = new Set([ ...todayActivities.map((activity) => activity.sourceId), ...todayWorklogs.map((worklog) => worklog.taskId), ]); const fallbackEvidence = buildTimestampFallbackEvidence(versionItems, today, activityKeys, evidenceSourceIds); const activityEvidence = [ ...todayActivities.map(activityToEvidenceDraft), ...fallbackEvidence, ].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)); const evidenceActivities = [ ...versionActivities, ...fallbackEvidence.map(evidenceDraftToActivity), ]; const recentSince = now.getTime() - 3 * 86_400_000; const recentActivityCount = [ ...evidenceActivities.map((activity) => activity.occurredAt), ...versionWorklogs.map((worklog) => worklog.createdAt), ].filter((value) => { const date = parseDate(value); return Boolean(date && date.getTime() >= recentSince); }).length; const lastActivityAt = latestIso([ ...evidenceActivities.map((activity) => activity.occurredAt), ...versionWorklogs.map((worklog) => worklog.createdAt), ]); const touchedTodayIds = new Set([ ...activityEvidence.map((activity) => activity.sourceId), ...todayWorklogs.map((worklog) => worklog.taskId), ]); const todayDeliveries = activityEvidence.filter((activity) => activity.category === 'delivery').map(evidenceDraftToEvidenceItem); const todayProgress = activityEvidence.filter((activity) => activity.category === 'progress').map(evidenceDraftToEvidenceItem); const todayCreations = activityEvidence.filter((activity) => activity.category === 'creation').map(evidenceDraftToEvidenceItem); const todayRisks = activityEvidence.filter((activity) => activity.category === 'risk').map(evidenceDraftToEvidenceItem); const progressNotes = [ ...activityEvidence.filter((activity) => activity.category === 'note').map(evidenceDraftToEvidenceItem), ...todayWorklogs.map((worklog) => worklogToEvidenceItem(worklog, itemMap.get(worklog.taskId))), ].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)); const worklogTaskIds = new Set(todayWorklogs.map((worklog) => worklog.taskId)); const activityHours = calcActivityHoursForDate(activityEvidence, versionActivities, itemMap, worklogTaskIds, today, now); const worklogHours = todayWorklogs.reduce((sum, worklog) => sum + worklog.hours, 0); return { todayDeliveries, todayProgress, todayCreations, todayRisks, progressNotes, needsProgressItems: versionItems .filter((item) => shouldNeedProgress(item, touchedTodayIds, evidenceActivities, versionWorklogs, now)) .map(itemToEvidenceItem), recentActivityCount, totalActivityCount: todayDeliveries.length + todayProgress.length + todayCreations.length + todayRisks.length + progressNotes.length, todayActualHours: roundHalfHour(worklogHours + activityHours), lastActivityAt, silentRisks: buildSilentRisks(versionItems, evidenceActivities, versionWorklogs, now), }; } function normalizeRequirementVersionMap(input: Map | Record): Map { return input instanceof Map ? input : new Map(Object.entries(input)); } function activityToEvidenceItem(activity: WorkActivity): EvidenceItem { return { id: activity.id, title: activity.title, summary: activity.summary, occurredAt: activity.occurredAt, actorId: activity.actorId, }; } function activityToEvidenceDraft(activity: WorkActivity): EvidenceDraft { return { ...activityToEvidenceItem(activity), sourceId: activity.sourceId, sourceType: activity.sourceType, action: activity.action, category: activity.category, metadata: activity.metadata, }; } function evidenceDraftToEvidenceItem(item: EvidenceDraft): EvidenceItem { return { id: item.id, title: item.title, summary: item.summary, occurredAt: item.occurredAt, actorId: item.actorId, }; } function evidenceDraftToActivity(item: EvidenceDraft): WorkActivity { return { id: item.id, actorId: item.actorId ?? '', date: item.occurredAt.slice(0, 10), sourceType: item.sourceType, sourceId: item.sourceId, action: item.action, category: item.category, title: item.title, summary: item.summary, occurredAt: item.occurredAt, metadata: item.metadata, }; } function worklogToEvidenceItem(worklog: TaskWorklog, item?: WorkItem): EvidenceItem { return { id: worklog.id, title: item?.title ?? worklog.taskId, summary: worklog.workContent, occurredAt: worklog.createdAt, actorId: worklog.userId, }; } function itemToEvidenceItem(item: WorkItem): EvidenceItem { return { id: item.id, title: item.title, summary: `${item.status}`, occurredAt: getLatestItemTouchAt(item) ?? '', }; } function buildTimestampFallbackEvidence( items: WorkItem[], date: string, existingKeys: Set, evidenceSourceIds: Set, ): EvidenceDraft[] { const out: EvidenceDraft[] = []; for (const item of items) { if (evidenceSourceIds.has(item.id)) continue; for (const draft of getTimestampFallbackDrafts(item)) { if (!isWithinLocalDate(draft.occurredAt, date)) continue; const key = `${item.id}:${draft.action}`; if (existingKeys.has(key)) continue; existingKeys.add(key); out.push({ id: `fallback-${draft.action}-${item.id}`, title: item.title, sourceId: item.id, sourceType: draft.sourceType, action: draft.action, category: draft.category, summary: draft.summary, occurredAt: draft.occurredAt, }); } } return out; } function getTimestampFallbackDrafts( item: WorkItem, ): Array> { const raw = getRaw(item); const extra = item.extra ?? {}; if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') { return [ makeFallbackDraft('version_plan', 'version_plan_created', 'creation', asString(raw.createdAt), `新建计划:${item.title}`), makeFallbackDraft('version_plan', 'version_plan_started', 'progress', asString(raw.actualStartAt) ?? asString(extra.actualStartAt), `开始计划:${item.title}`), makeFallbackDraft('version_plan', 'version_plan_completed', 'delivery', asString(raw.completedAt), `完成计划:${item.title}`), ].filter(isFallbackDraft); } if (item.type === 'devTask') { return [ makeFallbackDraft('dev_task', 'dev_task_created', 'creation', asString(raw.createdAt), `新建开发任务:${item.title}`), makeFallbackDraft('dev_task', 'dev_task_started', 'progress', asString(raw.actualStartAt) ?? asString(extra.actualStartAt), `开始开发:${item.title}`), makeFallbackDraft('dev_task', 'dev_task_submitted', 'delivery', asString(raw.actualEndAt) ?? asString(extra.actualEndAt), `已提测开发任务:${item.title}`), ].filter(isFallbackDraft); } if (item.type === 'testCase') { const completedAt = asString(raw.completedAt) ?? asString(extra.completedAt); const terminal = getTestCaseTerminalFallback(item.status, item.title, completedAt); return [ makeFallbackDraft('test_case', 'test_case_created', 'creation', asString(raw.createdAt), `新建测试用例:${item.title}`), makeFallbackDraft('test_case', 'test_case_started', 'progress', asString(raw.startedAt) ?? asString(extra.startedAt), `开始测试:${item.title}`), terminal, ].filter(isFallbackDraft); } if (item.type === 'bug') { return [ makeFallbackDraft('bug', 'bug_created', 'creation', asString(raw.createdAt), `新建 Bug:${item.title}`), makeFallbackDraft('bug', 'bug_fixing', 'progress', getBugFixingStartedAt(raw), `开始修复 Bug:${item.title}`), makeFallbackDraft('bug', 'bug_fixed', 'delivery', asString(raw.resolvedAt), `已修复 Bug:${item.title}`), makeFallbackDraft('bug', 'bug_closed', 'delivery', asString(raw.closedAt), `已关闭 Bug:${item.title}`), ].filter(isFallbackDraft); } return []; } function makeFallbackDraft( sourceType: WorkActivity['sourceType'], action: WorkActivity['action'], category: WorkActivityCategory, occurredAt: string | undefined, summary: string, ): Pick | undefined { if (!occurredAt) return undefined; return { sourceType, action, category, summary, occurredAt }; } function isFallbackDraft( draft: Pick | undefined, ): draft is Pick { return Boolean(draft); } function getTestCaseTerminalFallback( status: string, title: string, completedAt: string | undefined, ): Pick | undefined { if (status === 'passed') return makeFallbackDraft('test_case', 'test_case_passed', 'delivery', completedAt, `测试通过:${title}`); if (status === 'failed') return makeFallbackDraft('test_case', 'test_case_failed', 'risk', completedAt, `测试不通过:${title}`); if (status === 'blocked') return makeFallbackDraft('test_case', 'test_case_blocked', 'risk', completedAt, `测试阻塞:${title}`); return undefined; } function buildSilentRisks( items: WorkItem[], activities: WorkActivity[], worklogs: TaskWorklog[], now: Date, ): SilentRisk[] { const risks: SilentRisk[] = []; for (const item of items) { if (item.completed) continue; const latestUpdateAt = getLatestItemTouchAt(item); const latestEvidenceAt = latestIso([ ...activities.filter((activity) => activity.sourceId === item.id).map((activity) => activity.occurredAt), ...worklogs.filter((worklog) => worklog.taskId === item.id).map((worklog) => worklog.createdAt), ]); const latestTouchAt = latestIso([latestUpdateAt, latestEvidenceAt]); const evidenceDays = daysSince(latestEvidenceAt ?? latestUpdateAt, now); const updateDays = daysSince(latestUpdateAt, now); const touchDays = daysSince(latestTouchAt, now); if (Number.isFinite(updateDays) && updateDays >= 5) { risks.push(makeSilentRisk('no_update', item, `No status update for ${Math.floor(updateDays)} days.`)); } if (Number.isFinite(evidenceDays) && evidenceDays >= 8) { risks.push(makeSilentRisk('no_report', item, `No progress report for ${Math.floor(evidenceDays)} days.`)); } if (Number.isFinite(evidenceDays) && evidenceDays >= 4) { risks.push(makeSilentRisk('no_activity', item, `No work activity for ${Math.floor(evidenceDays)} days.`)); } if (isActiveUnfinished(item) && Number.isFinite(touchDays) && touchDays >= 3) { risks.push(makeSilentRisk('unhandled', item, `Active work has been untouched for ${Math.floor(touchDays)} days.`)); } } return risks; } function makeSilentRisk(key: string, item: WorkItem, detail: string): SilentRisk { return { key, title: item.title, detail, itemId: item.id, itemType: workItemToSilentItemType(item), }; } function shouldNeedProgress( item: WorkItem, touchedTodayIds: Set, activities: WorkActivity[], worklogs: TaskWorklog[], now: Date, ): boolean { if (item.completed || touchedTodayIds.has(item.id) || !isActiveUnfinished(item)) return false; const latestTouchAt = latestIso([ getLatestItemTouchAt(item), ...activities.filter((activity) => activity.sourceId === item.id).map((activity) => activity.occurredAt), ...worklogs.filter((worklog) => worklog.taskId === item.id).map((worklog) => worklog.createdAt), ]); return daysSince(latestTouchAt, now) >= 3; } function isActiveUnfinished(item: WorkItem): boolean { return !item.completed && ACTIVE_STATUSES.has(item.status); } function workItemToSilentItemType(item: WorkItem): SilentRisk['itemType'] { if (item.type === 'devTask') return 'dev_task'; if (item.type === 'testCase') return 'test_case'; if (item.type === 'bug') return 'bug'; return 'version'; } function getLatestItemTouchAt(item: WorkItem): string | undefined { const raw = (item.raw ?? {}) as unknown as Record; const extra = item.extra ?? {}; return latestIso([ asString(raw.updatedAt), asString(raw.completedAt), asString(raw.actualEndAt), asString(raw.resolvedAt), asString(raw.closedAt), asString(raw.actualStartAt), asString(raw.startedAt), asString(raw.createdAt), asString(extra.completedAt), asString(extra.actualEndAt), asString(extra.actualStartAt), asString(extra.startedAt), asString(extra.startTime), ]); } function calcActivityHoursForDate( activities: EvidenceDraft[], allActivities: WorkActivity[], itemMap: Map, excludedSourceIds: Set, date: string, now: Date, ): number { const recordScopedSourceIds = new Set(); const intervals = activities .filter((activity) => !excludedSourceIds.has(activity.sourceId)) .map((activity) => { if (!isRecordScopedProgressActivity(activity)) return undefined; recordScopedSourceIds.add(activity.sourceId); const workStartedAt = getRecordScopedWorkStartedAt(activity, allActivities, itemMap, date); if (!workStartedAt) return undefined; return getActivityIntervalForDate(workStartedAt, activity.occurredAt, date); }) .filter(isTimeInterval); const sourceIds = Array.from(new Set(activities.map((activity) => activity.sourceId))); const fallbackIntervals = sourceIds .filter((sourceId) => !excludedSourceIds.has(sourceId)) .filter((sourceId) => !recordScopedSourceIds.has(sourceId)) .map((sourceId) => getWorkItemIntervalForDate(itemMap.get(sourceId), date, now)) .filter(isTimeInterval); return calcMergedIntervalHours([...intervals, ...fallbackIntervals]); } function isPlanRecordProgressActivity(activity: Pick): boolean { return activity.sourceType === 'version_plan' && (activity.action === 'version_plan_requirement_progress' || activity.action === 'version_plan_research_direction_progress'); } function isProgressNoteRecordActivity(activity: Pick): boolean { return activity.action === 'progress_note_added' && Boolean(asString(activity.metadata?.nextStartAt)); } function isRecordScopedProgressActivity(activity: Pick): boolean { return isPlanRecordProgressActivity(activity) || isProgressNoteRecordActivity(activity); } function getRecordScopedWorkStartedAt( activity: EvidenceDraft, allActivities: WorkActivity[], itemMap: Map, date: string, ): string | undefined { if (isPlanRecordProgressActivity(activity)) { return asString(activity.metadata?.workStartedAt) ?? getPreviousRecordNextStartAt(activity, allActivities) ?? getSameDayWorkItemStartAt(itemMap.get(activity.sourceId), date); } if (isProgressNoteRecordActivity(activity)) { return getPreviousRecordNextStartAt(activity, allActivities) ?? getSameDayWorkItemStartAt(itemMap.get(activity.sourceId), date); } return undefined; } function getPreviousRecordNextStartAt(activity: Pick, allActivities: WorkActivity[]): string | undefined { const currentTime = new Date(activity.occurredAt).getTime(); if (!Number.isFinite(currentTime)) return undefined; return [...allActivities] .filter((item) => item.sourceId === activity.sourceId && item.occurredAt < activity.occurredAt) .sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)) .map((item) => asString(item.metadata?.nextStartAt)) .find((nextStartAt) => { if (!nextStartAt) return false; const startTime = new Date(nextStartAt).getTime(); return Number.isFinite(startTime) && startTime < currentTime; }); } function getSameDayWorkItemStartAt(item: WorkItem | undefined, date: string): string | undefined { if (!item) return undefined; const interval = getActualInterval(item, new Date()); if (!interval) return undefined; if (!interval.start || !isWithinLocalDate(interval.start, date)) return undefined; return interval.start; } function getActivityIntervalForDate( startAt: string, occurredAt: string, date: string, ): { start: number; end: number } | undefined { const day = getLocalDateBounds(date); if (!day) return undefined; const start = new Date(startAt).getTime(); const end = new Date(occurredAt).getTime(); if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return undefined; const overlapStart = Math.max(start, day.start.getTime()); const overlapEnd = Math.min(end, day.end.getTime()); if (overlapEnd <= overlapStart) return undefined; return { start: overlapStart, end: overlapEnd }; } function getWorkItemIntervalForDate(item: WorkItem | undefined, date: string, now: Date): { start: number; end: number } | undefined { const interval = getActualInterval(item, now); if (!interval) return undefined; const day = getLocalDateBounds(date); if (!day) return undefined; const start = new Date(interval.start).getTime(); const end = new Date(interval.end).getTime(); if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return undefined; const overlapStart = Math.max(start, day.start.getTime()); const overlapEnd = Math.min(end, day.end.getTime()); if (overlapEnd <= overlapStart) return undefined; return { start: overlapStart, end: overlapEnd }; } function isTimeInterval(interval: { start: number; end: number } | undefined): interval is { start: number; end: number } { return Boolean(interval); } function calcMergedIntervalHours(intervals: Array<{ start: number; end: number }>): number { if (intervals.length === 0) return 0; const sorted = [...intervals].sort((a, b) => a.start - b.start); const merged: Array<{ start: number; end: number }> = []; let current = { ...sorted[0] }; for (const interval of sorted.slice(1)) { if (interval.start <= current.end) { current.end = Math.max(current.end, interval.end); } else { merged.push(current); current = { ...interval }; } } merged.push(current); return merged.reduce((sum, interval) => sum + calcActualElapsedHours( new Date(interval.start).toISOString(), new Date(interval.end).toISOString(), ), 0); } function getActualInterval(item: WorkItem | undefined, now: Date): { start: string; end: string } | undefined { if (!item) return undefined; const raw = getRaw(item); const extra = item.extra ?? {}; const nowIso = now.toISOString(); if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') { const start = asString(raw.actualStartAt) ?? asString(extra.actualStartAt); const end = asString(raw.completedAt) ?? nowIso; return start ? { start, end } : undefined; } if (item.type === 'devTask') { const start = asString(raw.actualStartAt) ?? asString(extra.actualStartAt); const end = asString(raw.actualEndAt) ?? asString(extra.actualEndAt) ?? (item.status === 'submitted' ? asString(raw.updatedAt) : undefined) ?? nowIso; return start ? { start, end } : undefined; } if (item.type === 'testCase') { const start = asString(raw.startedAt) ?? asString(extra.startedAt); const isTerminal = ['passed', 'failed', 'blocked'].includes(item.status); const end = asString(raw.completedAt) ?? asString(extra.completedAt) ?? (isTerminal ? asString(raw.updatedAt) : undefined) ?? nowIso; return start ? { start, end } : undefined; } if (item.type === 'bug') { const start = getBugFixingStartedAt(raw) ?? asString(raw.createdAt) ?? asString(extra.createdAt); const isTerminal = ['closed', 'rejected'].includes(item.status); const end = asString(raw.closedAt) ?? asString(raw.resolvedAt) ?? (isTerminal ? asString(raw.updatedAt) : undefined) ?? nowIso; return start ? { start, end } : undefined; } return undefined; } function getBugFixingStartedAt(raw: Record): string | undefined { const logs = raw.logs; if (!Array.isArray(logs)) return undefined; const fixingLog = logs.find((log) => { if (!log || typeof log !== 'object') return false; const row = log as Record; return row.action === 'status_change' && row.toValue === 'fixing' && typeof row.createdAt === 'string'; }) as Record | undefined; return asString(fixingLog?.createdAt); } function getActivityKey(activity: Pick): string { return `${activity.sourceId}:${activity.action}`; } function getRaw(item: WorkItem): Record { return ((item.raw ?? {}) as unknown) as Record; } function getLocalDateBounds(date: string): { start: Date; end: Date } | undefined { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); if (!match) return undefined; const year = Number(match[1]); const month = Number(match[2]); const day = Number(match[3]); if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return undefined; const start = new Date(year, month - 1, day, 0, 0, 0, 0); const end = new Date(year, month - 1, day + 1, 0, 0, 0, 0); if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) return undefined; return { start, end }; } function isWithinLocalDate(value: string, date: string): boolean { const bounds = getLocalDateBounds(date); if (!bounds) return false; const time = new Date(value).getTime(); if (!Number.isFinite(time)) return false; return time >= bounds.start.getTime() && time < bounds.end.getTime(); } function latestIso(values: Array): string | undefined { let latest: string | undefined; let latestTime = Number.NEGATIVE_INFINITY; for (const value of values) { const date = parseDate(value); if (!date) continue; const time = date.getTime(); if (time > latestTime) { latest = value; latestTime = time; } } return latest; } function daysSince(value: string | undefined, now: Date): number { const date = parseDate(value); if (!date) return Number.POSITIVE_INFINITY; return Math.max(0, (now.getTime() - date.getTime()) / 86_400_000); } function parseDate(value: string | undefined): Date | undefined { if (!value) return undefined; const date = new Date(value); return Number.isFinite(date.getTime()) ? date : undefined; } function asString(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined; } function roundHalfHour(hours: number): number { if (!Number.isFinite(hours) || hours <= 0) return 0; return Math.round(hours * 2) / 2; }