From a66ba32bf8280a39d899096191d5ef61788f0cf5 Mon Sep 17 00:00:00 2001 From: Julien Rousseau Date: Tue, 4 Feb 2025 14:11:55 -0500 Subject: [PATCH] added support for adjacent and overlapping calendar activities --- packages/web/package-lock.json | 11 +++ packages/web/package.json | 1 + packages/web/src/components/Calendar.astro | 102 ++++++++++++++++++--- packages/web/src/lib/utils.ts | 20 ++++ packages/web/src/pages/horaire.astro | 91 +++++++++++++++++- 5 files changed, 209 insertions(+), 16 deletions(-) diff --git a/packages/web/package-lock.json b/packages/web/package-lock.json index 595c71c..b7424bd 100644 --- a/packages/web/package-lock.json +++ b/packages/web/package-lock.json @@ -19,6 +19,7 @@ "devDependencies": { "prettier": "^3.4.2", "prettier-plugin-astro": "^0.14.1", + "tailwind-merge": "^3.0.1", "vitest": "^3.0.4" } }, @@ -5667,6 +5668,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.1.tgz", + "integrity": "sha512-AvzE8FmSoXC7nC+oU5GlQJbip2UO7tmOhOfQyOmPhrStOGXHU08j8mZEHZ4BmCqY5dWTCo4ClWkNyRNx1wpT0g==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", diff --git a/packages/web/package.json b/packages/web/package.json index ef50522..7bdceee 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -22,6 +22,7 @@ "devDependencies": { "prettier": "^3.4.2", "prettier-plugin-astro": "^0.14.1", + "tailwind-merge": "^3.0.1", "vitest": "^3.0.4" } } diff --git a/packages/web/src/components/Calendar.astro b/packages/web/src/components/Calendar.astro index b5c0b3a..a626aed 100644 --- a/packages/web/src/components/Calendar.astro +++ b/packages/web/src/components/Calendar.astro @@ -1,17 +1,57 @@ --- -import type { Activity } from "common"; +import type { Activity, Time } from "common"; import { gcd } from "../lib/maths"; +import { cn, dark } from "../lib/utils"; interface Props { activities: Activity[][]; } -const { activities } = Astro.props; - const timeMin = 8; const timeMax = 22; -const cellSize = 4; -const minutesPerTick = 30; +const minutesPerTick = 60; + +type ActivityRender = Activity & { + inset: number; + adjacent?: { position: number; count: number }; +}; + +const activities = Astro.props.activities.map((w) => { + const weekday: ActivityRender[] = w + .sort((a, b) => decimal(a.time.start) - decimal(b.time.start)) + .map((a) => ({ ...a, inset: 0 })); + + const adjacents = new Set(); + for (let i = 1; i < weekday.length; i++) { + const adjacentCount = adjacents.size; + const current = weekday[i]; + const previous = weekday[i - 1]; + + if (overlap(previous, current)) { + if (decimal(current.time.start) - decimal(previous.time.start) < 0.5) { + adjacents.add(current); + adjacents.add(previous); + } else { + current.inset = previous.inset + 1; + } + } + + if ( + adjacents.size === adjacentCount || + (adjacents.size > 0 && i === weekday.length - 1) + ) { + [...adjacents] + .sort((a, b) => decimal(a.time.start) - decimal(b.time.start)) + .forEach( + (activity, position) => + (activity.adjacent = { position, count: adjacents.size }), + ); + adjacents.clear(); + } + } + + return weekday; +}); // NOTE: CSS grid does not allow to have fractional spans. // Therefore, it is needed to scale it by the smallest time interval in every activity. @@ -30,25 +70,37 @@ const gridScale = minutesPerTick / minuteScale; const timeStep = minutesPerTick / 60; const cellScale = gridScale / timeStep; -function formatTime(time: number): string { - const hour = Math.floor(time); - const minutes = Math.round((time - hour) * 60); +function decimal(time: Time): number { + return time.hour + time.minute / 60; +} + +function overlap(a: Activity, b: Activity): boolean { + return ( + decimal(a.time.start) <= decimal(b.time.start) && + decimal(b.time.start) < decimal(a.time.end) + ); +} + +function formatTime(time: number | Time): string { + const hour = typeof time === "number" ? Math.floor(time) : time.hour; + const minutes = + typeof time === "number" ? Math.round((time - hour) * 60) : time.minute; return `${hour.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; } ---
{ // --- Weekday column headers --- ["L", "M", "M", "J", "V", "S", "D"].map((weekday, i) => (
{weekday} @@ -82,7 +134,7 @@ function formatTime(time: number): string { class="flex items-center col-start-1 -translate-y-1/2 pr-2" style={{ "grid-row": i * gridScale + 2 }} > - + {formatTime(timeMin + i * timeStep)}
@@ -98,9 +150,33 @@ function formatTime(time: number): string { background: activity.color, "grid-row": `${(activity.time.start.hour - timeMin) * cellScale + activity.time.start.minute / minuteScale + 2} / span ${(activity.time.end.hour - activity.time.start.hour) * cellScale + (activity.time.end.minute - activity.time.start.minute) / minuteScale}`, "grid-column": ((weekday + 6) % 7) + 2, + ...(activity.adjacent + ? { + width: 100 / activity.adjacent.count + "%", + "margin-left": + 100 * + (activity.adjacent.position / activity.adjacent.count) + + "%", + } + : { width: 100 - 10 * activity.inset + "%" }), }} + class={cn([ + "min-w-0 min-h-0 rounded p-1 overflow-hidden ml-auto bg-primary", + dark(activity.color) && "text-white", + activity.inset > 0 && + "border-l-[1px] border-t-[1px] border-white", + ])} > - {activity.title} - {activity.subtitle} + + + + {activity.title} + + {activity.subtitle?.length && ( + {activity.subtitle} + )}
)), ) diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 5aa5954..3409923 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -1,3 +1,23 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + export function asArray(x: T | T[]) { return Array.isArray(x) ? x : [x] } + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + + +export function dark(background: string, threshold = 60) { + background = background.replace("#", ""); + + const rgb = parseInt(background, 16); + const r = (rgb >> 16) & 0xff; + const g = (rgb >> 8) & 0xff; + const b = (rgb >> 0) & 0xff; + + // https://en.wikipedia.org/wiki/Rec._709#Luma_coefficients + return 0.2126 * r + 0.7152 * g + 0.0722 * b < threshold; +} diff --git a/packages/web/src/pages/horaire.astro b/packages/web/src/pages/horaire.astro index 7b08c2f..5665280 100644 --- a/packages/web/src/pages/horaire.astro +++ b/packages/web/src/pages/horaire.astro @@ -19,16 +19,89 @@ const activities: Activity[][] = [ price: 120, color: "#ff00ff", }, + { + type: 0, + title: "Précompétitif", + time: { start: { hour: 8, minute: 30 }, end: { hour: 12, minute: 0 } }, + lessons: { + count: 8, + first: new Date(2025, 1, 6), + last: new Date(2025, 5, 5), + }, + price: 120, + color: "#0ff0ff", + }, + { + type: 0, + title: "idk", + time: { start: { hour: 10, minute: 30 }, end: { hour: 11, minute: 15 } }, + lessons: { + count: 8, + first: new Date(2025, 1, 6), + last: new Date(2025, 5, 5), + }, + price: 120, + color: "#f0f0ff", + }, ], [], - [], + [ + { + type: 0, + title: "activite 1", + time: { start: { hour: 13, minute: 0 }, end: { hour: 15, minute: 0 } }, + lessons: { + count: 8, + first: new Date(2025, 1, 6), + last: new Date(2025, 5, 5), + }, + price: 120, + color: "#0f0f00", + }, + { + type: 0, + title: "activite 2", + time: { start: { hour: 13, minute: 15 }, end: { hour: 15, minute: 0 } }, + lessons: { + count: 8, + first: new Date(2025, 1, 6), + last: new Date(2025, 5, 5), + }, + price: 120, + color: "#f0f000", + }, + { + type: 0, + title: "activite 3", + time: { start: { hour: 14, minute: 15 }, end: { hour: 16, minute: 0 } }, + lessons: { + count: 8, + first: new Date(2025, 1, 6), + last: new Date(2025, 5, 5), + }, + price: 120, + color: "#0fff30", + }, + { + type: 0, + title: "activite 4", + time: { start: { hour: 18, minute: 0 }, end: { hour: 21, minute: 45 } }, + lessons: { + count: 8, + first: new Date(2025, 1, 6), + last: new Date(2025, 5, 5), + }, + price: 120, + color: "#ffff00", + }, + ], [], [ { type: 0, title: "Récréatif", subtitle: "12 ans et plus", - time: { start: { hour: 10, minute: 15 }, end: { hour: 12, minute: 45 } }, + time: { start: { hour: 10, minute: 15 }, end: { hour: 10, minute: 45 } }, lessons: { count: 8, first: new Date(2025, 1, 6), @@ -37,13 +110,25 @@ const activities: Activity[][] = [ price: 120, color: "#ff0000", }, + { + type: 1, + title: "Compétitif", + time: { start: { hour: 14, minute: 0 }, end: { hour: 16, minute: 0 } }, + lessons: { + count: 8, + first: new Date(2025, 1, 6), + last: new Date(2025, 5, 5), + }, + price: 120, + color: "#0ff000", + }, ], [], ]; --- -
+