Show booked days in plan date picker
This commit is contained in:
parent
78fc06555d
commit
8452c9d5e7
6 changed files with 78 additions and 6 deletions
|
|
@ -162,15 +162,20 @@
|
||||||
.plan-calendar-title { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 800; }
|
.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 { 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-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-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-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:hover { background: var(--surface-raised); }
|
||||||
.plan-calendar-day.is-outside { opacity: .38; }
|
.plan-calendar-day.is-outside { opacity: .38; }
|
||||||
.plan-calendar-day.is-today { outline: 1px solid var(--brand); outline-offset: -2px; }
|
.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-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-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-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-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); }
|
.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); }
|
||||||
|
|
|
||||||
29
src/components/planning/date-range-dialog.test.ts
Normal file
29
src/components/planning/date-range-dialog.test.ts
Normal 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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { CalendarDays, ChevronLeft, ChevronRight, X } from "lucide-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";
|
import { validPlanRange, type PlanRange } from "@/lib/planning/state";
|
||||||
|
|
||||||
interface DateRangeDialogProps {
|
interface DateRangeDialogProps {
|
||||||
|
|
@ -11,6 +11,7 @@ interface DateRangeDialogProps {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
initial: PlanRange | null;
|
initial: PlanRange | null;
|
||||||
boardTargetDate: string;
|
boardTargetDate: string;
|
||||||
|
bookings: CalendarBooking[];
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
onSave: (range: PlanRange | null) => void;
|
onSave: (range: PlanRange | null) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -40,6 +41,7 @@ export function DateRangeDialog({
|
||||||
projectName,
|
projectName,
|
||||||
initial,
|
initial,
|
||||||
boardTargetDate,
|
boardTargetDate,
|
||||||
|
bookings,
|
||||||
pending,
|
pending,
|
||||||
onSave,
|
onSave,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -148,19 +150,22 @@ export function DateRangeDialog({
|
||||||
</div>
|
</div>
|
||||||
</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-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">
|
<div className="plan-calendar-grid">
|
||||||
{WEEKDAYS.map((day) => <span key={day} className="plan-weekday">{day}</span>)}
|
{WEEKDAYS.map((day) => <span key={day} className="plan-weekday">{day}</span>)}
|
||||||
{monthDays(month).map(({ date, inMonth }) => {
|
{monthDays(month).map(({ date, inMonth }) => {
|
||||||
const selected = date === start || date === end;
|
const selected = date === start || date === end;
|
||||||
const between = date > start && date < end;
|
const between = date > start && date < end;
|
||||||
|
const booked = bookedItemCount(date, bookings);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={date}
|
key={date}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => pickDate(date)}
|
onClick={() => pickDate(date)}
|
||||||
className={`plan-calendar-day${inMonth ? "" : " is-outside"}${selected ? " is-selected" : ""}${between ? " is-between" : ""}${date === today ? " is-today" : ""}`}
|
className={`plan-calendar-day${inMonth ? "" : " is-outside"}${selected ? " is-selected" : ""}${between ? " is-between" : ""}${date === today ? " is-today" : ""}${booked ? " is-booked" : ""}`}
|
||||||
aria-label={date}
|
aria-label={`${date}${booked ? `, ${booked} other ${booked === 1 ? "work item" : "work items"} planned` : ""}`}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
|
title={booked ? `${booked} other ${booked === 1 ? "work item" : "work items"} planned · overlaps allowed` : undefined}
|
||||||
>
|
>
|
||||||
{Number(date.slice(-2))}
|
{Number(date.slice(-2))}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import {
|
||||||
shiftCalendar,
|
shiftCalendar,
|
||||||
todayLocalIso,
|
todayLocalIso,
|
||||||
type CalendarAxis,
|
type CalendarAxis,
|
||||||
|
type CalendarBooking,
|
||||||
type CalendarZoom,
|
type CalendarZoom,
|
||||||
} from "@/lib/planning/calendar";
|
} from "@/lib/planning/calendar";
|
||||||
import type { PlanningCard, PlanningProject } from "@/lib/planning/load";
|
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 waiting = board.filter((project) => project.plan.status === "submitted").length;
|
||||||
const editProject = board.find((project) => project.id === editing?.projectId);
|
const editProject = board.find((project) => project.id === editing?.projectId);
|
||||||
const editCard = editProject?.cards.find((card) => card.id === editing?.cardId);
|
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 detailsProject = board.find((project) => project.id === details?.projectId);
|
||||||
const detailsCard = detailsProject?.cards.find((card) => card.id === details?.cardId);
|
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}`}
|
projectName={`${editProject.clientName} · ${editProject.name}`}
|
||||||
initial={editCard.draftRange}
|
initial={editCard.draftRange}
|
||||||
boardTargetDate={editCard.boardTargetDate}
|
boardTargetDate={editCard.boardTargetDate}
|
||||||
|
bookings={otherBookings}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
onSave={saveRange}
|
onSave={saveRange}
|
||||||
onClose={() => !pending && setEditing(null)}
|
onClose={() => !pending && setEditing(null)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { calendarAxis, rangePixels } from "@/lib/planning/calendar";
|
import { bookedItemCount, calendarAxis, rangePixels } from "@/lib/planning/calendar";
|
||||||
|
|
||||||
describe("planning calendar", () => {
|
describe("planning calendar", () => {
|
||||||
it("places a short task on exact daily columns", () => {
|
it("places a short task on exact daily columns", () => {
|
||||||
|
|
@ -20,4 +20,16 @@ describe("planning calendar", () => {
|
||||||
width: 2 * axis.dayWidth,
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,20 @@ export interface CalendarAxis {
|
||||||
groups: { label: string; startDay: number; days: number }[];
|
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;
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
export function todayLocalIso(): string {
|
export function todayLocalIso(): string {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue