Skip to content

Commit

Permalink
added support for adjacent and overlapping calendar activities
Browse files Browse the repository at this point in the history
  • Loading branch information
JulienR1 committed Feb 4, 2025
1 parent 26c47dd commit a66ba32
Show file tree
Hide file tree
Showing 5 changed files with 209 additions and 16 deletions.
11 changes: 11 additions & 0 deletions packages/web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"devDependencies": {
"prettier": "^3.4.2",
"prettier-plugin-astro": "^0.14.1",
"tailwind-merge": "^3.0.1",
"vitest": "^3.0.4"
}
}
102 changes: 89 additions & 13 deletions packages/web/src/components/Calendar.astro
Original file line number Diff line number Diff line change
@@ -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<ActivityRender>();
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.
Expand All @@ -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")}`;
}
---

<div class="relative w-full">
<div
class="grid grid-cols-[auto_repeat(7,1fr)]"
class="grid grid-cols-[auto_repeat(7,1fr)] [--cell-size:3rem] sm:[--cell-size:4rem]"
style={{
"grid-template-rows": `auto repeat(${(timeMax - timeMin) * cellScale}, ${cellSize / cellScale}rem)`,
"grid-template-rows": `auto repeat(${(timeMax - timeMin) * cellScale}, calc(var(--cell-size) / ${cellScale}))`,
}}
>
{
// --- Weekday column headers ---
["L", "M", "M", "J", "V", "S", "D"].map((weekday, i) => (
<div
class="text-center h-fit mb-2"
class="text-xs sm:text-sm text-center h-fit mb-2"
style={{ "grid-row": 1, "grid-column": i + 2 }}
>
<span>{weekday}</span>
Expand Down Expand Up @@ -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 }}
>
<span class="text-sm bg-white pr-1">
<span class="text-xs bg-white pr-1 sm:text-sm">
{formatTime(timeMin + i * timeStep)}
</span>
</div>
Expand All @@ -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}
<span class="hidden sm:block text-xs">
{`${formatTime(activity.time.start)} - ${formatTime(activity.time.end)}`}
</span>

<span class="block text-xs font-semibold overflow-hidden break-words">
{activity.title}
</span>
{activity.subtitle?.length && (
<span class="block text-xs">{activity.subtitle}</span>
)}
</div>
)),
)
Expand Down
20 changes: 20 additions & 0 deletions packages/web/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function asArray<T>(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;
}
91 changes: 88 additions & 3 deletions packages/web/src/pages/horaire.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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",
},
],
[],
];
---

<Layout path="/horaire">
<div class="sm:w-[90%] mx-auto my-4">
<div class="w-[90%] mx-auto my-4">
<Calendar activities={activities} />
</div>
</Layout>

0 comments on commit a66ba32

Please sign in to comment.