feat(time): add span-based attendance tracking under 2.0
This commit is contained in:
32
frontend/utils/staffTimeSpans.ts
Normal file
32
frontend/utils/staffTimeSpans.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** Collapse legacy-compatible work segments into complete persisted attendances for widgets. */
|
||||
export function staffTimeRows(spans: any[], userId: string, now = Date.now()) {
|
||||
const seen = new Set<string>();
|
||||
return (spans || []).filter(span => {
|
||||
if (span.model !== 'spans-v2') return true;
|
||||
if (seen.has(span.id)) return false;
|
||||
seen.add(span.id);
|
||||
return true;
|
||||
}).map(span => {
|
||||
const s = span.attendance;
|
||||
if (s) {
|
||||
const entry = {
|
||||
id: s.id, eventIds: [s.id], state: s.status, started_at: s.started_at,
|
||||
stopped_at: s.ended_at, user_id: s.user_id, type: s.type, description: s.description,
|
||||
breaks: s.breaks, paused: s.breaks.some((p: any) => !p.ended_at), model: 'spans-v2',
|
||||
};
|
||||
return { ...entry, duration_minutes: workingMinutes(entry, now) };
|
||||
}
|
||||
return {
|
||||
id: span.sourceEventIds?.[0] || null, eventIds: span.sourceEventIds || [], state: span.status,
|
||||
started_at: span.startedAt, stopped_at: span.endedAt, user_id: userId, type: span.type,
|
||||
description: span.description || span.payload?.description || '',
|
||||
duration_minutes: Math.floor(((span.endedAt ? Date.parse(span.endedAt) : now) - Date.parse(span.startedAt)) / 60000),
|
||||
};
|
||||
}).sort((a, b) => Date.parse(b.started_at) - Date.parse(a.started_at));
|
||||
}
|
||||
export function workingMinutes(entry: any, now = Date.now()) {
|
||||
if (!entry.started_at) return 0;
|
||||
const end = entry.stopped_at ? Date.parse(entry.stopped_at) : now;
|
||||
const pauses = (entry.breaks || []).reduce((total: number, p: any) => total + ((p.ended_at ? Date.parse(p.ended_at) : end) - Date.parse(p.started_at)), 0);
|
||||
return Math.max(0, Math.floor((end - Date.parse(entry.started_at) - pauses) / 60000));
|
||||
}
|
||||
29
frontend/utils/timeSpanInput.ts
Normal file
29
frontend/utils/timeSpanInput.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/** Use Intl directly: converting repeated hours through a local Date loses their UTC offset. */
|
||||
export function localTimeInput(value: string, zone = 'Europe/Berlin') {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||
}).formatToParts(new Date(value));
|
||||
const get = (type: string) => parts.find(part => part.type === type)!.value;
|
||||
return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}`;
|
||||
}
|
||||
|
||||
/** Preserve existing instants. Require an explicit UTC input for ambiguous new local times. */
|
||||
export function timeSpanInput(value: string, zone: string, original?: string | null) {
|
||||
if (original && localTimeInput(original, zone) === value) return original;
|
||||
const wall = Date.parse(`${value}:00Z`);
|
||||
if (!Number.isFinite(wall)) throw new Error('Ungültiger Zeitpunkt.');
|
||||
// Sample both sides of a transition; supports non-hour offsets and 30-minute DST changes.
|
||||
const offsets = new Set<number>();
|
||||
for (let hours = -36; hours <= 36; hours += 6) {
|
||||
const sample = wall + hours * 3600000;
|
||||
offsets.add(Date.parse(`${localTimeInput(new Date(sample).toISOString(), zone)}:00Z`) - sample);
|
||||
}
|
||||
const matches = [...offsets].map(offset => wall - offset)
|
||||
.filter(candidate => localTimeInput(new Date(candidate).toISOString(), zone) === value);
|
||||
if (!matches.length) throw new Error('Diese Ortszeit existiert nicht. Bitte Zeitumstellung beachten.');
|
||||
if (matches.length > 1) {
|
||||
throw new Error('Diese Ortszeit kommt zweimal vor. Bitte Zeitzone UTC wählen und den eindeutigen UTC-Zeitpunkt eingeben.');
|
||||
}
|
||||
return new Date(matches[0]).toISOString();
|
||||
}
|
||||
Reference in New Issue
Block a user