From 8452c9d5e7ae64d0f7b14572d8221259378e682f Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 22 Sep 2026 21:11:58 +0200 Subject: [PATCH] Show booked days in plan date picker --- src/app/plan/plan.css | 9 ++++-- .../planning/date-range-dialog.test.ts | 29 +++++++++++++++++++ src/components/planning/date-range-dialog.tsx | 11 +++++-- src/components/planning/plan-board.tsx | 7 +++++ src/lib/planning/calendar.test.ts | 14 ++++++++- src/lib/planning/calendar.ts | 14 +++++++++ 6 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 src/components/planning/date-range-dialog.test.ts diff --git a/src/app/plan/plan.css b/src/app/plan/plan.css index 031ec34..1e232e4 100644 --- a/src/app/plan/plan.css +++ b/src/app/plan/plan.css @@ -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); } diff --git a/src/components/planning/date-range-dialog.test.ts b/src/components/planning/date-range-dialog.test.ts new file mode 100644 index 0000000..272ec3d --- /dev/null +++ b/src/components/planning/date-range-dialog.test.ts @@ -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(/]*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"'); + }); +}); diff --git a/src/components/planning/date-range-dialog.tsx b/src/components/planning/date-range-dialog.tsx index 08b771d..c535d60 100644 --- a/src/components/planning/date-range-dialog.tsx +++ b/src/components/planning/date-range-dialog.tsx @@ -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({

{choosingEnd ? "Choose the end date" : "Choose a start date, then an end date"}

+

{WEEKDAYS.map((day) => {day})} {monthDays(month).map(({ date, inMonth }) => { const selected = date === start || date === end; const between = date > start && date < end; + const booked = bookedItemCount(date, bookings); return ( diff --git a/src/components/planning/plan-board.tsx b/src/components/planning/plan-board.tsx index 4e7c0dd..e7d9ad7 100644 --- a/src/components/planning/plan-board.tsx +++ b/src/components/planning/plan-board.tsx @@ -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)} diff --git a/src/lib/planning/calendar.test.ts b/src/lib/planning/calendar.test.ts index 39638ab..1f76fcb 100644 --- a/src/lib/planning/calendar.test.ts +++ b/src/lib/planning/calendar.test.ts @@ -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); + }); }); diff --git a/src/lib/planning/calendar.ts b/src/lib/planning/calendar.ts index 5a7e765..2cdf4c7 100644 --- a/src/lib/planning/calendar.ts +++ b/src/lib/planning/calendar.ts @@ -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 {