关键改动: - 支持需求覆盖和调研方向开始工作记录 - 日报按计划记录开始时间和进度下次开始时间计算证据 - 更新版本编辑校验、项目展示和 workflow 说明 Co-Authored-By: Codex GPT-5 <codex@openai.com>
564 lines
20 KiB
TypeScript
564 lines
20 KiB
TypeScript
import type { TaskWorklog } from './task-worklog';
|
||
import type { WorkActivity, WorkActivityCategory } from './work-activity';
|
||
import type { WorkItem } from './workspace-engine';
|
||
import { calcActualElapsedHours } from './work-hours';
|
||
|
||
export interface WorkspaceDailyReportItem {
|
||
id: string;
|
||
taskId: string;
|
||
taskTitle: string;
|
||
workContent: string;
|
||
hours: number;
|
||
createdAt: string;
|
||
productName?: string;
|
||
projectName?: string;
|
||
versionName?: string;
|
||
}
|
||
|
||
export interface WorkspaceDailyReportActivityItem {
|
||
id: string;
|
||
sourceId: string;
|
||
sourceType: WorkActivity['sourceType'];
|
||
action: WorkActivity['action'];
|
||
category: WorkActivityCategory;
|
||
title: string;
|
||
summary: string;
|
||
occurredAt: string;
|
||
context?: string;
|
||
metadata?: WorkActivity['metadata'];
|
||
}
|
||
|
||
export interface WorkspaceDailyReportNeedsProgressItem {
|
||
id: string;
|
||
title: string;
|
||
status: string;
|
||
type: WorkItem['type'];
|
||
context?: string;
|
||
}
|
||
|
||
export type WorkspaceDailyReportGroups = Record<WorkActivityCategory, WorkspaceDailyReportActivityItem[]>;
|
||
|
||
export interface WorkspaceDailyReport {
|
||
date: string;
|
||
totalHours: number;
|
||
totalCount: number;
|
||
items: WorkspaceDailyReportItem[];
|
||
groups: WorkspaceDailyReportGroups;
|
||
needsProgressItems: WorkspaceDailyReportNeedsProgressItem[];
|
||
}
|
||
|
||
interface GetWorkspaceDailyReportInput {
|
||
activities?: WorkActivity[];
|
||
worklogs: TaskWorklog[];
|
||
workItems: WorkItem[];
|
||
userId: string;
|
||
date: string;
|
||
now?: Date;
|
||
}
|
||
|
||
export function getWorkspaceDailyReport({
|
||
activities = [],
|
||
worklogs,
|
||
workItems,
|
||
userId,
|
||
date,
|
||
now = new Date(),
|
||
}: GetWorkspaceDailyReportInput): WorkspaceDailyReport {
|
||
const workItemMap = new Map(workItems.map((item) => [item.id, item]));
|
||
|
||
const items = worklogs
|
||
.filter((log) => log.userId === userId && log.date === date)
|
||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||
.map((log) => {
|
||
const item = workItemMap.get(log.taskId);
|
||
|
||
return {
|
||
id: log.id,
|
||
taskId: log.taskId,
|
||
taskTitle: item?.title ?? '未知任务',
|
||
workContent: log.workContent,
|
||
hours: log.hours,
|
||
createdAt: log.createdAt,
|
||
productName: item?.productName,
|
||
projectName: item?.projectName,
|
||
versionName: item?.versionName,
|
||
};
|
||
});
|
||
|
||
const userActivities = activities
|
||
.filter((activity) => activity.actorId === userId)
|
||
.sort((a, b) => a.occurredAt.localeCompare(b.occurredAt));
|
||
const dailyActivities = userActivities
|
||
.filter((activity) => activity.date === date)
|
||
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
||
const activityKeys = new Set(dailyActivities.map(getActivityKey));
|
||
const evidenceSourceIds = new Set([
|
||
...dailyActivities.map((activity) => activity.sourceId),
|
||
...items.map((item) => item.taskId),
|
||
]);
|
||
const reportActivities = [
|
||
...dailyActivities,
|
||
...buildTimestampFallbackActivities(workItems, userId, date, activityKeys, evidenceSourceIds),
|
||
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
||
|
||
const groups = makeEmptyGroups();
|
||
for (const activity of reportActivities) {
|
||
const item = workItemMap.get(activity.sourceId);
|
||
groups[activity.category].push({
|
||
id: activity.id,
|
||
sourceId: activity.sourceId,
|
||
sourceType: activity.sourceType,
|
||
action: activity.action,
|
||
category: activity.category,
|
||
title: activity.title,
|
||
summary: activity.summary,
|
||
occurredAt: activity.occurredAt,
|
||
context: getContext(item),
|
||
metadata: activity.metadata,
|
||
});
|
||
}
|
||
|
||
const touchedWorkItemIds = buildTouchedWorkItemIds(reportActivities, items, workItems);
|
||
const worklogTaskIds = new Set(items.map((item) => item.taskId));
|
||
const activityHours = calcActivityHoursForDate(
|
||
reportActivities,
|
||
userActivities,
|
||
workItemMap,
|
||
worklogTaskIds,
|
||
date,
|
||
now,
|
||
);
|
||
|
||
const needsProgressItems = workItems
|
||
.filter((item) => shouldRequireProgress(item, date, touchedWorkItemIds))
|
||
.map((item) => ({
|
||
id: item.id,
|
||
title: item.title,
|
||
status: item.status,
|
||
type: item.type,
|
||
context: getContext(item),
|
||
}));
|
||
|
||
return {
|
||
date,
|
||
totalHours: roundHalfHour(items.reduce((sum, item) => sum + item.hours, 0) + activityHours),
|
||
totalCount: items.length + reportActivities.length,
|
||
items,
|
||
groups,
|
||
needsProgressItems,
|
||
};
|
||
}
|
||
|
||
function makeEmptyGroups(): WorkspaceDailyReportGroups {
|
||
return {
|
||
delivery: [],
|
||
progress: [],
|
||
creation: [],
|
||
risk: [],
|
||
note: [],
|
||
};
|
||
}
|
||
|
||
function getContext(item?: WorkItem): string | undefined {
|
||
if (!item) return undefined;
|
||
return [item.productName, item.projectName, item.versionName].filter(Boolean).join(' / ');
|
||
}
|
||
|
||
function getActivityKey(activity: Pick<WorkActivity, 'sourceId' | 'action'>): string {
|
||
return `${activity.sourceId}:${activity.action}`;
|
||
}
|
||
|
||
function getRaw(item: WorkItem): Record<string, unknown> {
|
||
return ((item.raw ?? {}) as unknown) as Record<string, unknown>;
|
||
}
|
||
|
||
function buildTouchedWorkItemIds(
|
||
activities: WorkActivity[],
|
||
items: WorkspaceDailyReportItem[],
|
||
workItems: WorkItem[],
|
||
): Set<string> {
|
||
const touched = new Set([
|
||
...activities.map((activity) => activity.sourceId),
|
||
...items.map((item) => item.taskId),
|
||
]);
|
||
const touchedRequirementIds = new Set(
|
||
activities
|
||
.map(getPlanProgressRequirementId)
|
||
.filter((requirementId): requirementId is string => Boolean(requirementId)),
|
||
);
|
||
|
||
if (touchedRequirementIds.size === 0) return touched;
|
||
|
||
for (const item of workItems) {
|
||
const requirementId = getWorkItemRequirementId(item);
|
||
if (requirementId && touchedRequirementIds.has(requirementId)) {
|
||
touched.add(item.id);
|
||
}
|
||
}
|
||
|
||
return touched;
|
||
}
|
||
|
||
function getPlanProgressRequirementId(activity: WorkActivity): string | undefined {
|
||
if (activity.sourceType !== 'version_plan' || activity.action !== 'version_plan_requirement_progress') return undefined;
|
||
return asString(activity.metadata?.requirementId);
|
||
}
|
||
|
||
function getWorkItemRequirementId(item: WorkItem): string | undefined {
|
||
const raw = getRaw(item);
|
||
return asString(raw.requirementId) ?? asString(item.extra?.requirementId);
|
||
}
|
||
|
||
function shouldRequireProgress(item: WorkItem, date: string, touchedWorkItemIds: Set<string>): boolean {
|
||
if (item.completed) return false;
|
||
if (touchedWorkItemIds.has(item.id)) return false;
|
||
if (!isInProgressStatus(item.status)) return false;
|
||
|
||
const startedAt = getStartedAt(item);
|
||
if (!startedAt) return false;
|
||
return startedAt.slice(0, 10) < date;
|
||
}
|
||
|
||
function isInProgressStatus(status: string): boolean {
|
||
return ['in_progress', 'testing', 'running', 'fixing', 'verifying'].includes(status);
|
||
}
|
||
|
||
function getStartedAt(item: WorkItem): string | undefined {
|
||
const extra = item.extra ?? {};
|
||
if (typeof extra.actualStartAt === 'string') return extra.actualStartAt;
|
||
if (typeof extra.startedAt === 'string') return extra.startedAt;
|
||
if (typeof extra.startTime === 'string') return extra.startTime;
|
||
return undefined;
|
||
}
|
||
|
||
function buildTimestampFallbackActivities(
|
||
workItems: WorkItem[],
|
||
userId: string,
|
||
date: string,
|
||
existingKeys: Set<string>,
|
||
evidenceSourceIds: Set<string>,
|
||
): WorkActivity[] {
|
||
const out: WorkActivity[] = [];
|
||
|
||
for (const item of workItems) {
|
||
if (evidenceSourceIds.has(item.id)) continue;
|
||
for (const draft of getTimestampFallbackDrafts(item)) {
|
||
if (!isWithinLocalDate(draft.occurredAt, date)) continue;
|
||
const activity: WorkActivity = {
|
||
id: `fallback-${draft.action}-${item.id}`,
|
||
actorId: userId,
|
||
date,
|
||
sourceType: draft.sourceType,
|
||
sourceId: item.id,
|
||
action: draft.action,
|
||
category: draft.category,
|
||
title: item.title,
|
||
summary: draft.summary,
|
||
occurredAt: draft.occurredAt,
|
||
};
|
||
const key = getActivityKey(activity);
|
||
if (existingKeys.has(key)) continue;
|
||
existingKeys.add(key);
|
||
out.push(activity);
|
||
}
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
function getTimestampFallbackDrafts(item: WorkItem): Array<Pick<WorkActivity, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'>> {
|
||
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<WorkActivity, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined {
|
||
if (!occurredAt) return undefined;
|
||
return { sourceType, action, category, summary, occurredAt };
|
||
}
|
||
|
||
function isFallbackDraft(
|
||
draft: Pick<WorkActivity, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined,
|
||
): draft is Pick<WorkActivity, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> {
|
||
return Boolean(draft);
|
||
}
|
||
|
||
function getTestCaseTerminalFallback(
|
||
status: string,
|
||
title: string,
|
||
completedAt: string | undefined,
|
||
): Pick<WorkActivity, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | 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 calcActivityHoursForDate(
|
||
activities: WorkActivity[],
|
||
allUserActivities: WorkActivity[],
|
||
workItemMap: Map<string, WorkItem>,
|
||
excludedSourceIds: Set<string>,
|
||
date: string,
|
||
now: Date,
|
||
): number {
|
||
const recordScopedSourceIds = new Set<string>();
|
||
const intervals = activities
|
||
.filter((activity) => !excludedSourceIds.has(activity.sourceId))
|
||
.map((activity) => {
|
||
if (!isRecordScopedProgressActivity(activity)) return undefined;
|
||
recordScopedSourceIds.add(activity.sourceId);
|
||
const workStartedAt = getRecordScopedWorkStartedAt(activity, allUserActivities, workItemMap, 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(workItemMap.get(sourceId), date, now))
|
||
.filter(isTimeInterval);
|
||
|
||
return calcMergedIntervalHours([...intervals, ...fallbackIntervals]);
|
||
}
|
||
|
||
function isPlanRecordProgressActivity(activity: WorkActivity): boolean {
|
||
return activity.sourceType === 'version_plan'
|
||
&& (activity.action === 'version_plan_requirement_progress'
|
||
|| activity.action === 'version_plan_research_direction_progress');
|
||
}
|
||
|
||
function isProgressNoteRecordActivity(activity: WorkActivity): boolean {
|
||
return activity.action === 'progress_note_added' && Boolean(asString(activity.metadata?.nextStartAt));
|
||
}
|
||
|
||
function isRecordScopedProgressActivity(activity: WorkActivity): boolean {
|
||
return isPlanRecordProgressActivity(activity) || isProgressNoteRecordActivity(activity);
|
||
}
|
||
|
||
function getRecordScopedWorkStartedAt(
|
||
activity: WorkActivity,
|
||
allUserActivities: WorkActivity[],
|
||
workItemMap: Map<string, WorkItem>,
|
||
date: string,
|
||
): string | undefined {
|
||
if (isPlanRecordProgressActivity(activity)) {
|
||
return asString(activity.metadata?.workStartedAt)
|
||
?? getPreviousRecordNextStartAt(activity, allUserActivities)
|
||
?? getSameDayWorkItemStartAt(workItemMap.get(activity.sourceId), date);
|
||
}
|
||
|
||
if (isProgressNoteRecordActivity(activity)) {
|
||
return getPreviousRecordNextStartAt(activity, allUserActivities)
|
||
?? getSameDayWorkItemStartAt(workItemMap.get(activity.sourceId), date);
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
function getPreviousRecordNextStartAt(activity: WorkActivity, allUserActivities: WorkActivity[]): string | undefined {
|
||
const currentTime = new Date(activity.occurredAt).getTime();
|
||
if (!Number.isFinite(currentTime)) return undefined;
|
||
|
||
return [...allUserActivities]
|
||
.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 startAt = getStartedAt(item);
|
||
if (!startAt || !isWithinLocalDate(startAt, date)) return undefined;
|
||
return startAt;
|
||
}
|
||
|
||
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, unknown>): 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<string, unknown>;
|
||
return row.action === 'status_change' && row.toValue === 'fixing' && typeof row.createdAt === 'string';
|
||
}) as Record<string, unknown> | undefined;
|
||
return asString(fixingLog?.createdAt);
|
||
}
|
||
|
||
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 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;
|
||
}
|