Say what the availability bar is showing, and show all of it

The bar was a day wide whatever it was drawing. It began at midnight on
the event's start day and stopped 24 hours later, so an event running over
two days showed availability for the first of them and gave no sign there
was more. And it carried no marks at all, which left "is this the whole
day or only working hours" unanswerable without dragging the event about
to see where its own outline moved.

It now covers whole days from the day the event starts to the day it ends,
and the free/busy lookup asks for the same range it draws. Above the bars
is an axis: hours every three across a single day, every six across two,
day names beyond that. The marks are drawn down the bars too, so a busy
block can be read against the hour it starts at rather than guessed at.

Whole days, always. A bar starting at the event's own start time would
move under the reader every time they adjusted it, and "busy from about a
third of the way along" is not a time anybody can read.

A week is as far as it goes. Something running longer is not an event
anybody is hunting a free slot in, and a month at eight pixels a day would
say nothing; it says how many days it left out instead.

The span is measured between two real midnights rather than counted in
24-hour days, because twice a year they differ, and every position on the
bar is a fraction of it.

The mock answered with a single busy block on the first day whatever range
it was asked for -- all a day-wide bar could show -- which would have left
a multi-day bar looking like everyone was free from the second day on. It
now answers across the range.

This is parts 1 and 2 of #172. The separate multi-day scheduling view it
also asks for is still open, and needs an answer first on what to show for
participants who have no free/busy to read.
This commit is contained in:
2026-09-01 09:43:41 -07:00
parent df9349935d
commit c4649e0084
5 changed files with 263 additions and 29 deletions
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { availabilityWindow } from "@/lib/availabilityWindow";
const at = (s: string) => new Date(s);
const hours = (w: { ticks: { time: Date }[] }) => w.ticks.map((t) => `${t.time.getDate()}@${t.time.getHours()}`);
describe("the span an availability bar covers", () => {
it("covers the whole day for an event inside one", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:30:00"));
expect(w.start.getHours()).toBe(0);
expect(w.days).toBe(1);
expect(w.end.getDate()).toBe(3);
expect(w.end.getHours()).toBe(0);
});
it("stretches to cover an event running over several days", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"));
expect(w.days).toBe(3);
expect(w.start.getDate()).toBe(2);
expect(w.end.getDate()).toBe(5);
});
it("ends an event on the day it ends on, not the midnight it stops at", () => {
// An all-day event on the 2nd runs to midnight starting the 3rd; it does
// not touch the 3rd and the bar should not show it.
const w = availabilityWindow(at("2026-09-02T00:00:00"), at("2026-09-03T00:00:00"));
expect(w.days).toBe(1);
expect(w.end.getDate()).toBe(3);
});
it("never collapses to nothing, even when start and end are the same moment", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T09:00:00"));
expect(w.days).toBe(1);
expect(w.span).toBeGreaterThan(0);
});
it("marks a single day every three hours, labelling every six", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
expect(w.scale).toBe("hours");
expect(hours(w)).toEqual(["2@0", "2@3", "2@6", "2@9", "2@12", "2@15", "2@18", "2@21"]);
expect(w.ticks.filter((t) => t.major).map((t) => t.time.getHours())).toEqual([0, 6, 12, 18]);
});
it("thins the marks out to every six hours across two days", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-03T10:00:00"));
expect(w.scale).toBe("hours");
expect(hours(w)).toEqual(["2@0", "2@6", "2@12", "2@18", "3@0", "3@6", "3@12", "3@18"]);
});
it("marks day boundaries once there are more than two", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-05T10:00:00"));
expect(w.scale).toBe("days");
expect(hours(w)).toEqual(["2@0", "3@0", "4@0", "5@0"]);
expect(w.ticks.every((t) => t.major)).toBe(true);
});
it("puts every mark at its true fraction of the span", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
expect(w.ticks[0]!.at).toBe(0);
expect(w.ticks[4]!.at).toBeCloseTo(0.5, 5); // noon
expect(w.ticks.every((t) => t.at >= 0 && t.at < 1)).toBe(true);
});
it("stops at a week and says how much it left out", () => {
const w = availabilityWindow(at("2026-09-01T09:00:00"), at("2026-09-30T17:00:00"));
expect(w.days).toBe(7);
expect(w.daysHidden).toBe(23);
});
it("hides nothing when the event fits", () => {
expect(availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00")).daysHidden).toBe(0);
});
it("lands on real midnights, and measures the span between them", () => {
/*
* The span is what every position is a fraction of, so it has to be the
* distance between the two boundaries rather than a count of 24-hour days:
* on the day a clock changes those differ by an hour, which would end the
* bar early and put every block after the change in the wrong place. This
* asserts the relationship; whether the run happens to sit in a zone with
* DST is not something a test should depend on.
*/
for (const day of ["2026-03-29", "2026-10-25", "2026-09-02"]) {
const w = availabilityWindow(at(`${day}T09:00:00`), at(`${day}T10:00:00`));
expect(w.start.getHours(), day).toBe(0);
expect(w.end.getHours(), day).toBe(0);
expect(w.span, day).toBe(w.end.getTime() - w.start.getTime());
}
});
});