Show booked days in plan date picker

This commit is contained in:
Dave 2026-09-22 21:11:58 +02:00
parent 78fc06555d
commit 8452c9d5e7
6 changed files with 78 additions and 6 deletions

View file

@ -162,15 +162,20 @@
.plan-calendar-title { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 800; }
.plan-calendar-actions { display: flex; gap: 2px; }
.plan-calendar-actions button { display: grid; width: 28px; height: 28px; place-items: center; border-radius: 6px; color: var(--text-secondary); }
.plan-calendar-hint { margin: 3px 0 13px; color: var(--text-muted); font-size: 10px; }
.plan-calendar-hint { margin: 3px 0 5px; color: var(--text-muted); font-size: 10px; }
.plan-calendar-booking-key { display: flex; align-items: center; gap: 6px; margin: 0 0 12px; color: var(--text-muted); font-size: 10px; }
.plan-calendar-booking-key span { width: 10px; height: 10px; flex: none; border: 1px solid var(--plan-amber); border-radius: 3px; background: color-mix(in srgb, var(--plan-amber) 24%, var(--surface)); }
.plan-calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; }
.plan-weekday { display: grid; height: 26px; place-items: center; color: var(--text-muted); font-size: 9px; font-weight: 800; }
.plan-calendar-day { display: grid; height: 34px; place-items: center; border-radius: 7px; color: var(--text-secondary); font-size: 11px; font-variant-numeric: tabular-nums; }
.plan-calendar-day { position: relative; display: grid; height: 34px; place-items: center; border-radius: 7px; color: var(--text-secondary); font-size: 11px; font-variant-numeric: tabular-nums; }
.plan-calendar-day:hover { background: var(--surface-raised); }
.plan-calendar-day.is-outside { opacity: .38; }
.plan-calendar-day.is-today { outline: 1px solid var(--brand); outline-offset: -2px; }
.plan-calendar-day.is-between { border-radius: 0; background: var(--brand-soft); }
.plan-calendar-day.is-selected { background: var(--brand); color: var(--brand-fg); font-weight: 800; }
.plan-calendar-day.is-booked:not(.is-selected):not(.is-between) { background: color-mix(in srgb, var(--plan-amber) 24%, var(--surface)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--plan-amber) 75%, transparent); }
.plan-calendar-day.is-booked:not(.is-selected):not(.is-between):hover { background: color-mix(in srgb, var(--plan-amber) 37%, var(--surface)); }
.plan-calendar-day.is-booked::after { position: absolute; right: 4px; bottom: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--plan-amber); content: ""; }
.plan-dialog-bottom { align-items: flex-end; gap: 14px; padding: 20px 25px 24px; }
.plan-item-dialog { width: min(100%, 560px); max-height: calc(100vh - 40px); overflow-y: auto; border: 1px solid var(--border-subtle); border-radius: 17px; background: var(--panel); box-shadow: 0 22px 70px #0017285c; }
.plan-item-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 24px 27px 18px; border-bottom: 1px solid var(--border-subtle); }

View file

@ -0,0 +1,29 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { DateRangeDialog } from "@/components/planning/date-range-dialog";
describe("planning date picker", () => {
it("marks another item's dates while leaving those days selectable", () => {
const html = renderToStaticMarkup(createElement(DateRangeDialog, {
cardTitle: "Current work",
projectName: "Client project",
initial: { start: "2026-10-05", end: "2026-10-06" },
boardTargetDate: "",
bookings: [{
draftRange: { start: "2026-09-30", end: "2026-10-02" },
approvedRange: null,
}],
pending: false,
onSave: () => undefined,
onClose: () => undefined,
}));
expect(html).toContain("Other work planned · dates can overlap");
const bookedDay = html.match(/<button[^>]*aria-label="2026-10-01, 1 other work item planned"[^>]*>1<\/button>/)?.[0];
expect(bookedDay).toContain("is-booked");
expect(bookedDay).not.toContain("disabled");
expect(html).toContain('aria-label="2026-10-03"');
});
});

View file

@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { CalendarDays, ChevronLeft, ChevronRight, X } from "lucide-react";
import { addDays, todayLocalIso } from "@/lib/planning/calendar";
import { addDays, bookedItemCount, todayLocalIso, type CalendarBooking } from "@/lib/planning/calendar";
import { validPlanRange, type PlanRange } from "@/lib/planning/state";
interface DateRangeDialogProps {
@ -11,6 +11,7 @@ interface DateRangeDialogProps {
projectName: string;
initial: PlanRange | null;
boardTargetDate: string;
bookings: CalendarBooking[];
pending: boolean;
onSave: (range: PlanRange | null) => void;
onClose: () => void;
@ -40,6 +41,7 @@ export function DateRangeDialog({
projectName,
initial,
boardTargetDate,
bookings,
pending,
onSave,
onClose,
@ -148,19 +150,22 @@ export function DateRangeDialog({
</div>
</div>
<p className="plan-calendar-hint">{choosingEnd ? "Choose the end date" : "Choose a start date, then an end date"}</p>
<p className="plan-calendar-booking-key"><span aria-hidden="true" /> Other work planned · dates can overlap</p>
<div className="plan-calendar-grid">
{WEEKDAYS.map((day) => <span key={day} className="plan-weekday">{day}</span>)}
{monthDays(month).map(({ date, inMonth }) => {
const selected = date === start || date === end;
const between = date > start && date < end;
const booked = bookedItemCount(date, bookings);
return (
<button
key={date}
type="button"
onClick={() => pickDate(date)}
className={`plan-calendar-day${inMonth ? "" : " is-outside"}${selected ? " is-selected" : ""}${between ? " is-between" : ""}${date === today ? " is-today" : ""}`}
aria-label={date}
className={`plan-calendar-day${inMonth ? "" : " is-outside"}${selected ? " is-selected" : ""}${between ? " is-between" : ""}${date === today ? " is-today" : ""}${booked ? " is-booked" : ""}`}
aria-label={`${date}${booked ? `, ${booked} other ${booked === 1 ? "work item" : "work items"} planned` : ""}`}
aria-pressed={selected}
title={booked ? `${booked} other ${booked === 1 ? "work item" : "work items"} planned · overlaps allowed` : undefined}
>
{Number(date.slice(-2))}
</button>

View file

@ -29,6 +29,7 @@ import {
shiftCalendar,
todayLocalIso,
type CalendarAxis,
type CalendarBooking,
type CalendarZoom,
} from "@/lib/planning/calendar";
import type { PlanningCard, PlanningProject } from "@/lib/planning/load";
@ -91,6 +92,11 @@ export function PlanBoard({ projects, role }: { projects: PlanningProject[]; rol
const waiting = board.filter((project) => project.plan.status === "submitted").length;
const editProject = board.find((project) => project.id === editing?.projectId);
const editCard = editProject?.cards.find((card) => card.id === editing?.cardId);
const otherBookings: CalendarBooking[] = editing
? board.flatMap((project) => project.cards
.filter((card) => project.id !== editing.projectId || card.id !== editing.cardId)
.map((card) => ({ draftRange: card.draftRange, approvedRange: card.approvedRange })))
: [];
const detailsProject = board.find((project) => project.id === details?.projectId);
const detailsCard = detailsProject?.cards.find((card) => card.id === details?.cardId);
@ -327,6 +333,7 @@ export function PlanBoard({ projects, role }: { projects: PlanningProject[]; rol
projectName={`${editProject.clientName} · ${editProject.name}`}
initial={editCard.draftRange}
boardTargetDate={editCard.boardTargetDate}
bookings={otherBookings}
pending={pending}
onSave={saveRange}
onClose={() => !pending && setEditing(null)}

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { calendarAxis, rangePixels } from "@/lib/planning/calendar";
import { bookedItemCount, calendarAxis, rangePixels } from "@/lib/planning/calendar";
describe("planning calendar", () => {
it("places a short task on exact daily columns", () => {
@ -20,4 +20,16 @@ describe("planning calendar", () => {
width: 2 * axis.dayWidth,
});
});
it("marks inclusive booked days across months and counts each work item once", () => {
const bookings = [
{ draftRange: { start: "2026-09-30", end: "2026-10-02" }, approvedRange: { start: "2026-09-30", end: "2026-10-01" } },
{ draftRange: null, approvedRange: { start: "2026-10-01", end: "2026-10-03" } },
];
expect(bookedItemCount("2026-09-29", bookings)).toBe(0);
expect(bookedItemCount("2026-09-30", bookings)).toBe(1);
expect(bookedItemCount("2026-10-01", bookings)).toBe(2);
expect(bookedItemCount("2026-10-03", bookings)).toBe(1);
expect(bookedItemCount("2026-10-04", bookings)).toBe(0);
});
});

View file

@ -11,6 +11,20 @@ export interface CalendarAxis {
groups: { label: string; startDay: number; days: number }[];
}
export interface CalendarBooking {
draftRange: PlanRange | null;
approvedRange: PlanRange | null;
}
/** Count work items once per day, even when their draft and approved dates overlap. */
export function bookedItemCount(date: string, bookings: CalendarBooking[]): number {
return bookings.reduce((count, booking) => {
const coversDate = (range: PlanRange | null) =>
range !== null && range.start <= date && date <= range.end;
return count + (coversDate(booking.draftRange) || coversDate(booking.approvedRange) ? 1 : 0);
}, 0);
}
const DAY_MS = 86_400_000;
export function todayLocalIso(): string {