import { CSSProperties, FC, useRef } from "react"; import style from "./scheduleView.module.css"; import { useClipboardStore } from "@store/Clipboard.Store"; import { useAlert } from "@core/Store/Alert.Store"; import Avatar from "@components/Avatar/Avatar"; /** * Given a background color and two foreground color options, returns the * foreground color with the best contrast ratio to the background color. */ export const testContrast = ( bg: CSSProperties["color"], fg1: CSSProperties["color"], fg2: CSSProperties["color"], skewReturnLighter: number = 0 ): CSSProperties["color"] => { const bgLuminance = clamp(getLuminance(bg) - skewReturnLighter, 0, 1); const fg1Luminance = getLuminance(fg1); const fg2Luminance = getLuminance(fg2); const fg1Contrast = getContrast(bgLuminance, fg1Luminance); const fg2Contrast = getContrast(bgLuminance, fg2Luminance); return fg1Contrast > fg2Contrast ? fg1 : fg2; }; /** * Returns the contrast ratio between two luminances */ export const getContrast = (bgLuminance: number, fgLuminance: number): number => { return Math.abs(bgLuminance - fgLuminance); }; /** * Returns the luminance value of a CSS color */ export const getLuminance = (color: CSSProperties["color"]): number => { const rgb = cssColorToRGB(color); const r = rgb[0] / 255; const g = rgb[1] / 255; const b = rgb[2] / 255; const luminance = Math.sqrt(0.299 * r ** 2 + 0.587 * g ** 2 + 0.114 * b ** 2); return luminance; }; /** * Converts a CSS color string to RGB values */ export const cssColorToRGB = (color: CSSProperties["color"]): number[] => { const isBrowser = typeof window !== "undefined"; if (!color || !isBrowser) return [0, 0, 0]; const canvas = document.createElement("canvas"); canvas.width = 1; canvas.height = 1; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Could not get canvas context"); ctx.fillStyle = color; ctx.fillRect(0, 0, 1, 1); const data = ctx.getImageData(0, 0, 1, 1).data; return [data[0], data[1], data[2]]; }; // https://github.com/30-seconds/30-seconds-of-code/blob/master/snippets/RGBToHSL.md export const RGBToHSL = ([r, g, b]: number[]) => { r /= 255; g /= 255; b /= 255; const l = Math.max(r, g, b); const s = l - Math.min(r, g, b); const h = s ? (l === r ? (g - b) / s : l === g ? 2 + (b - r) / s : 4 + (r - g) / s) : 0; return [ 60 * h < 0 ? 60 * h + 360 : 60 * h, 100 * (s ? (l <= 0.5 ? s / (2 * l - s) : s / (2 - (2 * l - s))) : 0), (100 * (2 * l - s)) / 2, ]; }; export const SUBDIVISIONS_PER_HOUR = 12; export interface DaySchedule { name: string; events: CustomCalendarEvent[]; } export interface CalendarEvent { startTime: number; endTime: number; title: string; description?: string; color?: CSSProperties["backgroundColor"]; } // Rows/cols indexed starting from 1 to match CSS export interface PositionedEvent { event: CustomCalendarEvent; col: number; endCol: number; // Row numbers are relative to the first row of the event group row: number; endRow: number; } export interface PositionedEventGroup { totalCols: number; groupStartRow: number; groupEndRow: number; positionedEvents: PositionedEvent[]; } export type ScheduleThemeOverride = DeepPartial; export type DeepPartial = { [P in keyof T]?: DeepPartial; }; export interface ScheduleTheme { style?: ScheduleThemeStyle; hourHeight: CSSProperties["height"]; minorGridlinesPerHour: number; timeRangeFormatter: (startTime: number, endTime: number) => string; timeFormatter: (time: number) => string; defaultTileColor: | CSSProperties["backgroundColor"] | ((event: CustomCalendarEvent) => CSSProperties["backgroundColor"]); customTileComponent?: FC<{ event: CustomCalendarEvent }>; themeTileContent?: FC<{ event: CustomCalendarEvent }>; } export interface ScheduleThemeStyle { root?: CSSProperties; dayLabels?: CSSProperties; timeScaleLabels?: CSSProperties; eventTiles?: | CSSProperties | (( event: CustomCalendarEvent, theme: ScheduleTheme ) => CSSProperties); majorGridlinesBorder?: CSSProperties["borderStyle"]; minorGridlinesBorder?: CSSProperties["borderStyle"]; verticalGridlinesBorder?: CSSProperties["borderStyle"]; } export const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); export const numToHHMM = (num: number) => { let hours = Math.floor(num) % 12; if (hours === 0) { hours = 12; } const minutes = Math.round((num % 1) * 60); return `${hours}:${minutes < 10 ? "0" : ""}${minutes} ${num >= 12 && num < 24 ? "PM" : "AM"}`; }; export const numToHH = (num: number) => { let hours = Math.floor(num) % 12; if (hours === 0) { hours = 12; } return `${hours} ${num >= 12 && num < 24 ? "PM" : "AM"}`; }; export const timeFormatter = (startTime: number) => `${numToHHMM(startTime)}`; export const timeRangeFormatter = (startTime: number, endTime: number) => `${numToHHMM(startTime)} - ${numToHHMM(endTime)}`; export const googleColors = { greyGridline: "#a6a7ac", greyTimeLabel: "#94979b", greyDayLabel: "#666a6d", blue: "#5186EC", red: "#C3281C", yellow: "#EEC04B", purple: "#8332A4", green: "#397D49", indigo: "#4153AF", pink: "#C63461", lavender: "#AF9FD7", orange: "#E35C33", brown: "#74574A", }; const calculateTextColor = (event: CalendarEvent, theme: ScheduleTheme): CSSProperties["color"] => { const defaultColor = typeof theme.defaultTileColor === "function" ? theme.defaultTileColor(event) : theme.defaultTileColor; return testContrast(event.color ?? defaultColor, "white", "black", 0.3); }; const calculateBackgroundOpacity = (event: TXCalendarEvent): CSSProperties["opacity"] => { return event.appointmentId ? 1 : 0.7; }; export interface TXCalendarEvent extends CalendarEvent { appointmentId?: string; clientId?: string; turnoId?: string; employeeId?: string; serviceId?: string; avatar?: string; repeatId?: string; payment?: boolean; } const TurnosXpressTitle = (props: { event: TXCalendarEvent }) => { let imageAvatar = ""; const alert = useAlert(); const setCopiedAppointment = useClipboardStore(state => state.setCopiedAppointment); const pressTimeout = useRef(null); const isLongPress = useRef(false); if (props.event.avatar) { imageAvatar = props.event.avatar; } const handleStart = (e: React.TouchEvent | React.MouseEvent) => { isLongPress.current = false; const target = e.currentTarget; target.parentElement?.classList.add(style.eventRowViewAnimated); pressTimeout.current = setTimeout(() => { isLongPress.current = true; target.parentElement?.classList.remove(style.eventRowViewAnimated); if (props.event.appointmentId) { setCopiedAppointment(props.event); alert.showSuccess("ID del turno copiado exitosamente."); } }, 500); }; const handleCancel = (e: React.TouchEvent | React.MouseEvent) => { if (pressTimeout.current) { clearTimeout(pressTimeout.current); pressTimeout.current = null; } e.currentTarget.parentElement?.classList.remove(style.eventRowViewAnimated); }; const handleClick = (e: React.MouseEvent) => { if (isLongPress.current) { e.stopPropagation(); e.preventDefault(); } }; return (

{props.event.title}

{timeRangeFormatter(props.event.startTime, props.event.endTime)} {props.event.payment && ( 💰 )}
); }; export const scViewTurnosExpress = (fractionTime: number = 60): ScheduleTheme => { const rowHeight = (100 * 60) / fractionTime; return { style: { root: { fontFamily: "Kanit, Roboto, Helvetica, Arial, sans-serif", }, dayLabels: { color: googleColors.greyDayLabel, textTransform: "uppercase", }, timeScaleLabels: { color: googleColors.greyTimeLabel, fontSize: "0.7rem", }, majorGridlinesBorder: `1px solid #dad8d8`, minorGridlinesBorder: `1px dotted ${googleColors.greyGridline}`, verticalGridlinesBorder: `1x solid ${googleColors.greyGridline}`, eventTiles: (event, theme) => ({ color: calculateTextColor(event, theme), opacity: calculateBackgroundOpacity(event), }), }, hourHeight: `${rowHeight}px`, minorGridlinesPerHour: 4, timeRangeFormatter: timeRangeFormatter, defaultTileColor: "var(--wine-darkest)", timeFormatter: numToHH, //customTileComponent: TurnosXpressTitle, themeTileContent: TurnosXpressTitle, }; };