mirror of
https://github.com/priyanshujain/margin-calendar.git
synced 2026-10-02 11:07:04 +00:00
352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
// Pure model helpers for the grid: slicing instances into days, mapping a calendar colour onto
|
|
// the eight muted hues, and the small amount of geometry that is not already in `src/grid`.
|
|
//
|
|
// Nothing here holds state or touches the DOM. It is a `.tsx` file only because the grid owns
|
|
// `Grid*.tsx` and nothing else.
|
|
|
|
import type { CSSProperties } from "react";
|
|
import type { Bounds, Fold } from "../grid/fit";
|
|
import type { Instance, InstanceKey } from "../ipc";
|
|
import { MINUTES_PER_DAY, addDays, isSameDay, minutesFromMidnight } from "../time";
|
|
|
|
/** Inline custom properties. The geometry is CSS's job; JS only hands it the numbers. */
|
|
export const vars = (style: Record<string, string | number | undefined>): CSSProperties =>
|
|
style as CSSProperties;
|
|
|
|
/** Drags land on a quarter hour. Fine enough to be useful, coarse enough to be predictable. */
|
|
export const SNAP_MINUTES = 15;
|
|
|
|
/** Nothing shorter than this can be dragged into existence, or resized down to. */
|
|
export const MIN_EVENT_MINUTES = 15;
|
|
|
|
/** Matches `--event-min-h`. A five minute event still has to be a bar you can hit. */
|
|
export const MIN_BLOCK_H = 12;
|
|
|
|
/** Start and end in epoch milliseconds, which is all the geometry ever needs from an event. */
|
|
export interface Times {
|
|
startMs: number;
|
|
endMs: number;
|
|
}
|
|
|
|
/** One event placed on a day column, with any optimistic override already applied. */
|
|
export interface Placed extends Times {
|
|
instance: Instance;
|
|
id: string;
|
|
}
|
|
|
|
export const keyOf = (i: Instance): InstanceKey => ({
|
|
eventId: i.eventId,
|
|
originalStart: i.originalStart,
|
|
});
|
|
|
|
/** Stable string form of an `InstanceKey`, for map keys and React keys. */
|
|
export const keyId = (k: InstanceKey): string => `${k.eventId}\0${k.originalStart ?? ""}`;
|
|
|
|
export const sameKey = (a: InstanceKey | null, b: InstanceKey | null): boolean =>
|
|
a !== null && b !== null && a.eventId === b.eventId && a.originalStart === b.originalStart;
|
|
|
|
/**
|
|
* True when the event covers more than one local day. Those go in the all-day band, because they
|
|
* genuinely occupy midnight and would otherwise pin the axis open at both ends.
|
|
*/
|
|
export function isMultiDay(t: Times): boolean {
|
|
const last = t.endMs > t.startMs ? t.endMs - 1 : t.startMs;
|
|
return !isSameDay(t.startMs, last);
|
|
}
|
|
|
|
/** All-day and multi-day events live in the band under the day headers, never on the axis. */
|
|
export const inBand = (i: Instance, t: Times = i): boolean => i.allDay || isMultiDay(t);
|
|
|
|
export const isVisible = (i: Instance): boolean => i.status !== "cancelled";
|
|
|
|
/** The event's minutes from local midnight on `dayStart`, clipped to the day. */
|
|
export function dayMinutes(t: Times, dayStart: number): { startMin: number; endMin: number } {
|
|
const dayEnd = addDays(dayStart, 1);
|
|
const startMin = t.startMs <= dayStart ? 0 : Math.min(MINUTES_PER_DAY, minutesFromMidnight(t.startMs));
|
|
const endMin =
|
|
t.endMs >= dayEnd ? MINUTES_PER_DAY : Math.max(startMin, minutesFromMidnight(t.endMs));
|
|
return { startMin, endMin };
|
|
}
|
|
|
|
/** Wall-clock minutes back to an instant, so a drag across a DST boundary keeps the clock time. */
|
|
export function minutesToMs(dayStart: number, minutes: number): number {
|
|
const d = new Date(dayStart);
|
|
d.setMinutes(Math.round(minutes));
|
|
return d.getTime();
|
|
}
|
|
|
|
export const clampMinutes = (minutes: number): number =>
|
|
minutes < 0 ? 0 : minutes > MINUTES_PER_DAY ? MINUTES_PER_DAY : minutes;
|
|
|
|
export const snapMinutes = (minutes: number, step = SNAP_MINUTES): number =>
|
|
clampMinutes(Math.round(minutes / step) * step);
|
|
|
|
export function hourLabel(hour: number): string {
|
|
const h = ((hour % 24) + 24) % 24;
|
|
const suffix = h < 12 ? "am" : "pm";
|
|
return `${h % 12 === 0 ? 12 : h % 12}${suffix}`;
|
|
}
|
|
|
|
export function rangeLabel(range: Bounds): string {
|
|
return `${hourLabel(range.start)} to ${hourLabel(range.end)}`;
|
|
}
|
|
|
|
// The hue of each --cal-N in the light palette. Both palettes keep the same order, so a slot
|
|
// chosen here still reads as the same calendar after a theme switch.
|
|
const CAL_HUES = [256, 155, 18, 216, 36, 344, 193, 74];
|
|
|
|
function parseHex(value: string): [number, number, number] | null {
|
|
const hex = value.trim().replace(/^#/, "");
|
|
if (hex.length === 3) {
|
|
const [r, g, b] = hex.split("").map((c) => parseInt(c + c, 16));
|
|
return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : [r, g, b];
|
|
}
|
|
if (hex.length !== 6) return null;
|
|
const n = parseInt(hex, 16);
|
|
return Number.isNaN(n) ? null : [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
}
|
|
|
|
/** A colour in HSL, with the hue left at zero when it is too grey to have one. */
|
|
function hexHsl(value: string | null | undefined): { h: number; s: number; l: number } | null {
|
|
if (!value) return null;
|
|
const rgb = parseHex(value);
|
|
if (!rgb) return null;
|
|
const [r, g, b] = rgb.map((c) => c / 255);
|
|
const max = Math.max(r, g, b);
|
|
const min = Math.min(r, g, b);
|
|
const d = max - min;
|
|
const l = ((max + min) / 2) * 100;
|
|
if (d < 0.08) return { h: 0, s: 0, l };
|
|
let h: number;
|
|
if (max === r) h = ((g - b) / d + 6) % 6;
|
|
else if (max === g) h = (b - r) / d + 2;
|
|
else h = (r - g) / d + 4;
|
|
return { h: h * 60, s: (d / (1 - Math.abs((max + min) - 1))) * 100, l };
|
|
}
|
|
|
|
/** Hue in degrees, or null when the colour is unparseable or too grey to have one. */
|
|
function hexHue(value: string | null | undefined): number | null {
|
|
const hsl = hexHsl(value);
|
|
return hsl === null || hsl.s === 0 ? null : hsl.h;
|
|
}
|
|
|
|
function hashSlot(seed: string): number {
|
|
let h = 2166136261;
|
|
for (let i = 0; i < seed.length; i++) {
|
|
h ^= seed.charCodeAt(i);
|
|
h = Math.imul(h, 16777619);
|
|
}
|
|
return (Math.abs(h) % CAL_HUES.length) + 1;
|
|
}
|
|
|
|
/**
|
|
* Google's palette is louder than warm paper can carry, so a calendar's colour picks the nearest
|
|
* of the eight muted hues rather than being used raw. Greys and missing colours fall back to a
|
|
* stable hash of the calendar id, so at least two calendars rarely collide.
|
|
*/
|
|
export function calSlot(colorHex: string | null | undefined, seed: string): number {
|
|
const hue = hexHue(colorHex);
|
|
if (hue === null) return hashSlot(seed);
|
|
let best = 0;
|
|
let bestD = Infinity;
|
|
for (let i = 0; i < CAL_HUES.length; i++) {
|
|
const raw = Math.abs(hue - CAL_HUES[i]) % 360;
|
|
const d = raw > 180 ? 360 - raw : raw;
|
|
if (d < bestD) {
|
|
bestD = d;
|
|
best = i;
|
|
}
|
|
}
|
|
return best + 1;
|
|
}
|
|
|
|
/**
|
|
* The hue angle a block is drawn at: the same slot `calSlot` picks, as the one number grid.css
|
|
* needs to build a whole pill out of.
|
|
*
|
|
* The blocks take the angle rather than `var(--cal-N)` because every surface and every piece of
|
|
* type on a filled pill is a different lightness of the same hue, and a colour derived with
|
|
* `color-mix()` or `oklch()` is not a legacy sRGB colour: it computes to `color(srgb ...)`, which
|
|
* nothing compositing the layers behind a block can read. `hsl()` with a substituted hue still
|
|
* computes to plain `rgb()`. Since both palettes keep the same order, the angle names the same
|
|
* calendar in either theme.
|
|
*/
|
|
export const calHue = (colorHex: string | null | undefined, seed: string): number =>
|
|
CAL_HUES[calSlot(colorHex, seed) - 1];
|
|
|
|
/** Everything grid.css needs to paint one block. See `calTint`. */
|
|
export interface CalTint {
|
|
/** Hue angle in degrees. */
|
|
h: number;
|
|
/** Multiplier on every level's saturation. Zero for a colour with no hue, so a grey stays grey. */
|
|
sat: number;
|
|
/** Lightness offset in points, positive towards the paper, negative away from it. */
|
|
lift: number;
|
|
}
|
|
|
|
const clampLift = (n: number): number => Math.max(-6, Math.min(6, Math.round(n)));
|
|
|
|
/**
|
|
* How a block is painted: the hue, how much of it, and how deep.
|
|
*
|
|
* A calendar's colour is snapped to one of the eight, because the overwhelming majority of events
|
|
* follow their calendar and eight muted hues is what keeps a week readable. An event that carries
|
|
* its own `colorId` is not snapped: picking Tangerine and getting Tomato is the feature not
|
|
* working, and Google's eleven do not fall into eight buckets. Six of them share a hue with
|
|
* another to within a few degrees, so hue alone would not tell Lavender from Blueberry or Sage
|
|
* from Basil either. What separates those pairs in Google's palette is depth, not hue, so depth is
|
|
* carried across as well: how saturated the source is scales the block's saturation, how dark it
|
|
* is moves the block away from the paper. That is the harmonising step. The eleven are never
|
|
* painted at their own lightness, which is far louder than this grid can carry; they land in the
|
|
* same band as the eight, at their own hue and their own depth within it.
|
|
*/
|
|
export function calTint(
|
|
colorHex: string | null | undefined,
|
|
colorId: string | null | undefined,
|
|
seed: string,
|
|
): CalTint {
|
|
if (!colorId) return { h: calHue(colorHex, seed), sat: 1, lift: 0 };
|
|
const own = hexHsl(colorHex);
|
|
if (own === null) return { h: calHue(colorHex, seed), sat: 1, lift: 0 };
|
|
const lift = clampLift((own.l - 50) * 0.45);
|
|
// Graphite is the one Google offers with no hue at all, and it has to come out grey.
|
|
if (own.s === 0) return { h: 0, sat: 0, lift };
|
|
return {
|
|
h: Math.round(own.h),
|
|
sat: Math.round((0.65 + (own.s / 100) * 0.45) * 100) / 100,
|
|
lift,
|
|
};
|
|
}
|
|
|
|
/** The custom properties a block hands CSS. The whole pill is built from these three numbers. */
|
|
export function calVars(i: {
|
|
colorHex: string | null;
|
|
colorId: string | null;
|
|
calendarId: string;
|
|
}): Record<string, number> {
|
|
const tint = calTint(i.colorHex, i.colorId, i.calendarId);
|
|
return { "--cal-h": tint.h, "--cal-sat": tint.sat, "--cal-lift": tint.lift };
|
|
}
|
|
|
|
/**
|
|
* What an event with no summary reads as.
|
|
*
|
|
* A calendar shared at `freeBusyReader` returns every event with an empty summary, and on a real
|
|
* account that is most of them. They are not untitled events, they are the shape of someone
|
|
* else's day, so they say what they mean.
|
|
*/
|
|
export const BUSY_LABEL = "Busy";
|
|
|
|
/** True when Google gave us no title, which is the free/busy case rather than a missing one. */
|
|
export const isBusy = (i: Instance): boolean => i.summary.trim() === "";
|
|
|
|
export const eventTitle = (i: Instance): string => i.summary.trim() || BUSY_LABEL;
|
|
|
|
/** An invitation this account turned down. Still on the grid, plainly not happening. */
|
|
export const isDeclined = (i: Instance): boolean =>
|
|
i.attendees.some((a) => a.self && a.responseStatus === "declined");
|
|
|
|
export type BlockSize = "bar" | "short" | "full";
|
|
|
|
// The ladder is tuned against the type scale, because the block has to decide what it can show
|
|
// before the browser has laid any of it out. --t-2 at line-height 1.25 is 15px, --t-1 at 1.2 is
|
|
// 13px, and the block keeps 2px of padding at each end. Moving those in grid.css moves these.
|
|
const PAD_Y = 4;
|
|
const TITLE_LINE = 15;
|
|
const META_LINE = 13;
|
|
|
|
/** More than this is a column of fragments rather than a title, however tall the block is. */
|
|
const MAX_LINES = 3;
|
|
|
|
/**
|
|
* How much of itself a block can show at this height: a title with the time and place under it,
|
|
* a title alone, or a compact bar. Never nothing: a five minute event still has to say what it
|
|
* is, so `bar` carries a single small line rather than being a featureless sliver.
|
|
*/
|
|
export function blockSize(height: number): BlockSize {
|
|
if (height >= PAD_Y + TITLE_LINE + META_LINE) return "full";
|
|
if (height >= PAD_Y + TITLE_LINE) return "short";
|
|
return "bar";
|
|
}
|
|
|
|
/**
|
|
* Lines the title may wrap to. Capped, because a narrow column breaks a long title into one word
|
|
* per line and eight of those are less readable than one line and an ellipsis. Width does the
|
|
* rest of the clamping, in grid.css, where the block's own width is knowable.
|
|
*/
|
|
export function titleLines(height: number, meta: boolean): number {
|
|
const room = height - PAD_Y - (meta ? META_LINE : 0);
|
|
return Math.max(1, Math.min(MAX_LINES, Math.floor(room / TITLE_LINE)));
|
|
}
|
|
|
|
/** Which whole hours of the day any of these events touch. */
|
|
export function busyHours(items: readonly Placed[]): boolean[] {
|
|
const busy = new Array<boolean>(24).fill(false);
|
|
for (const item of items) {
|
|
const day = new Date(item.startMs);
|
|
day.setHours(0, 0, 0, 0);
|
|
const { startMin, endMin } = dayMinutes(item, day.getTime());
|
|
const from = Math.floor(startMin / 60);
|
|
const to = Math.min(24, Math.max(from + 1, Math.ceil(endMin / 60)));
|
|
for (let h = from; h < to; h++) busy[h] = true;
|
|
}
|
|
return busy;
|
|
}
|
|
|
|
/**
|
|
* The band `z` would fold at this hour: the whole empty run around it. Null on a busy hour, since
|
|
* a fold hides empty time and an hour with an event in it would come straight back. Interior gaps
|
|
* never fold themselves, so this only runs on demand.
|
|
*/
|
|
export function bandAt(items: readonly Placed[], bounds: Bounds, hour: number): Fold | null {
|
|
const busy = busyHours(items);
|
|
if (busy[hour]) return null;
|
|
let start = hour;
|
|
let end = hour + 1;
|
|
while (start > bounds.start && !busy[start - 1]) start--;
|
|
while (end < bounds.end && !busy[end]) end++;
|
|
return { start, end };
|
|
}
|
|
|
|
/** One all-day or multi-day event, spanning `[from, to)` of the visible day columns. */
|
|
export interface BandItem {
|
|
instance: Instance;
|
|
id: string;
|
|
from: number;
|
|
to: number;
|
|
}
|
|
|
|
export function bandItems(instances: readonly Instance[], days: readonly number[]): BandItem[] {
|
|
const items: BandItem[] = [];
|
|
if (days.length === 0) return items;
|
|
const spanEnd = addDays(days[days.length - 1], 1);
|
|
for (const instance of instances) {
|
|
if (instance.startMs >= spanEnd || instance.endMs <= days[0]) continue;
|
|
let from = 0;
|
|
let to = days.length;
|
|
for (let i = 0; i < days.length; i++) {
|
|
if (instance.startMs >= addDays(days[i], 1)) from = i + 1;
|
|
if (instance.endMs > days[i]) to = i + 1;
|
|
}
|
|
if (from < to) items.push({ instance, id: keyId(keyOf(instance)), from, to });
|
|
}
|
|
items.sort(
|
|
(a, b) => a.from - b.from || b.to - b.from - (a.to - a.from) || a.instance.startMs - b.instance.startMs,
|
|
);
|
|
return items;
|
|
}
|
|
|
|
/** Greedy rows: the first row where nothing already covers those columns. */
|
|
export function bandRows(items: readonly BandItem[]): BandItem[][] {
|
|
const rows: BandItem[][] = [];
|
|
for (const item of items) {
|
|
let row = rows.find((r) => r.every((o) => o.to <= item.from || o.from >= item.to));
|
|
if (!row) {
|
|
row = [];
|
|
rows.push(row);
|
|
}
|
|
row.push(item);
|
|
}
|
|
return rows;
|
|
}
|