bugfix: organization sidebar icon updated like organization switch feature.

This commit is contained in:
2026-05-18 14:08:07 +03:30
parent 95ed1bd4ab
commit 7dba7cc144
16 changed files with 49 additions and 21 deletions

View File

@@ -0,0 +1,173 @@
import type { AppointmentRecord } from '@/types/appointment';
export type AppointmentTimedInterval = {
id: string;
start: number;
end: number;
};
export type AppointmentLaneLayout = {
lane: number;
/** Max concurrent overlaps in this appointment's cluster (column count). */
laneCount: number;
};
function intervalsOverlap(a: AppointmentTimedInterval, b: AppointmentTimedInterval): boolean {
return a.start < b.end && b.start < a.end;
}
export function toTimedInterval(apt: AppointmentRecord): AppointmentTimedInterval {
return {
id: apt.id,
start: new Date(apt.startAt).getTime(),
end: new Date(apt.endAt).getTime(),
};
}
/** Connected overlap component containing `appointmentId`. */
export function findOverlapCluster(
appointmentId: string,
appointments: AppointmentRecord[],
): AppointmentRecord[] {
const byId = new Map(appointments.map((a) => [a.id, a]));
if (!byId.has(appointmentId)) {
return [];
}
const timed = appointments.map(toTimedInterval);
const clusterIds = new Set<string>([appointmentId]);
let changed = true;
while (changed) {
changed = false;
for (const interval of timed) {
if (clusterIds.has(interval.id)) {
continue;
}
for (const memberId of clusterIds) {
const member = timed.find((t) => t.id === memberId);
if (member && intervalsOverlap(interval, member)) {
clusterIds.add(interval.id);
changed = true;
break;
}
}
}
}
return appointments.filter((a) => clusterIds.has(a.id));
}
function maxConcurrentCount(intervals: AppointmentTimedInterval[]): number {
if (intervals.length === 0) {
return 0;
}
type Point = { time: number; delta: number };
const points: Point[] = [];
for (const interval of intervals) {
points.push({ time: interval.start, delta: 1 });
points.push({ time: interval.end, delta: -1 });
}
points.sort((a, b) => a.time - b.time || a.delta - b.delta);
let current = 0;
let max = 0;
for (const point of points) {
current += point.delta;
max = Math.max(max, current);
}
return max;
}
function assignGreedyLanes(intervals: AppointmentTimedInterval[]): Map<string, number> {
const sorted = [...intervals].sort((a, b) => a.start - b.start || a.end - b.end);
const laneEndTimes: number[] = [];
const laneById = new Map<string, number>();
for (const interval of sorted) {
let lane = laneEndTimes.findIndex((end) => end <= interval.start);
if (lane === -1) {
lane = laneEndTimes.length;
laneEndTimes.push(interval.end);
} else {
laneEndTimes[lane] = interval.end;
}
laneById.set(interval.id, lane);
}
return laneById;
}
function buildClusters(intervals: AppointmentTimedInterval[]): AppointmentTimedInterval[][] {
const visited = new Set<string>();
const clusters: AppointmentTimedInterval[][] = [];
for (const seed of intervals) {
if (visited.has(seed.id)) {
continue;
}
const cluster: AppointmentTimedInterval[] = [];
const queue = [seed];
visited.add(seed.id);
while (queue.length > 0) {
const current = queue.pop()!;
cluster.push(current);
for (const other of intervals) {
if (!visited.has(other.id) && intervalsOverlap(current, other)) {
visited.add(other.id);
queue.push(other);
}
}
}
clusters.push(cluster);
}
return clusters;
}
/**
* Assigns side-by-side lanes per provider column (Google Calendar style).
*/
export function computeAppointmentLaneLayouts(
appointments: AppointmentRecord[],
): Map<string, AppointmentLaneLayout> {
const timed = appointments.map(toTimedInterval);
if (timed.length === 0) {
return new Map();
}
const layouts = new Map<string, AppointmentLaneLayout>();
const clusters = buildClusters(timed);
for (const cluster of clusters) {
const laneCount = Math.max(1, maxConcurrentCount(cluster));
const greedyLanes = assignGreedyLanes(cluster);
const usedLaneIndices = [...new Set(cluster.map((c) => greedyLanes.get(c.id) ?? 0))].sort(
(a, b) => a - b,
);
const remap = new Map(usedLaneIndices.map((lane, index) => [lane, index]));
for (const interval of cluster) {
const rawLane = greedyLanes.get(interval.id) ?? 0;
layouts.set(interval.id, {
lane: remap.get(rawLane) ?? 0,
laneCount,
});
}
}
return layouts;
}
export function lanePositionStyles(lane: number, laneCount: number): {
left: string;
width: string;
} {
const gapPct = 1;
const widthPct = (100 - gapPct * (laneCount + 1)) / laneCount;
return {
left: `calc(${gapPct}% + ${lane} * (${widthPct}% + ${gapPct}%))`,
width: `${widthPct}%`,
};
}

View File

@@ -0,0 +1,63 @@
/** Normalize to local midnight; invalid input falls back to today. */
export function startOfLocalDay(d: Date): Date {
if (Number.isNaN(d.getTime())) {
const t = new Date();
return new Date(t.getFullYear(), t.getMonth(), t.getDate(), 0, 0, 0, 0);
}
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
/** Local calendar bounds for a date (browser timezone). */
export function getLocalDayIsoRange(day: Date): { from: string; to: string } {
const start = startOfLocalDay(day);
const end = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 1, 0, 0, 0, 0);
return { from: start.toISOString(), to: end.toISOString() };
}
export function toDateInputValue(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function parseDateInput(value: string): Date {
const [y, m, d] = value.split('-').map(Number);
return new Date(y, m - 1, d, 0, 0, 0, 0);
}
export function combineLocalDateAndTime(day: Date, timeHHmm: string): Date {
const [h, min] = timeHHmm.split(':').map(Number);
return new Date(day.getFullYear(), day.getMonth(), day.getDate(), h, min, 0, 0);
}
export function formatTimeForInput(d: Date): string {
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return `${h}:${m}`;
}
export function isSameLocalCalendarDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
export function formatHourLabel(hour: number): string {
const d = new Date(2000, 0, 1, hour, 0, 0, 0);
return d.toLocaleTimeString(undefined, { hour: 'numeric', hour12: true });
}
/** Local midnight + delta calendar days. */
export function addCalendarDays(day: Date, delta: number): Date {
return new Date(day.getFullYear(), day.getMonth(), day.getDate() + delta, 0, 0, 0, 0);
}
/** Compare two calendar days at local midnight (ordering by date only). */
export function compareLocalDayStart(a: Date, b: Date): number {
const ta = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime();
const tb = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime();
return ta - tb;
}