Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions apps/web/src/components/calendar/calendar-task-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { formatDateShort } from "@/lib/format";
import type { PackableTask, WeekSegment } from "./month-grid-model";

export type CalendarTask = PackableTask & {
title: string;
number: number | null;
};

type CalendarTaskBarProps = {
segment: WeekSegment<CalendarTask>;
projectSlug?: string;
onOpenTask: (taskId: string) => void;
};

export default function CalendarTaskBar({
segment,
projectSlug,
onOpenTask,
}: CalendarTaskBarProps) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const { t } = useTranslation();
const {
task,
lane,
columnStart,
columnEnd,
continuesBefore,
continuesAfter,
} = segment;

const taskKey =
projectSlug && task.number != null
? `${projectSlug}-${task.number}`
: undefined;

const range = `${formatDateShort(task.scheduleStart)} – ${formatDateShort(
task.scheduleEnd,
)}`;

return (
<button
type="button"
style={{
gridColumn: `${columnStart} / ${columnEnd}`,
// Row 1 holds the date numbers, so lanes start at row 2.
gridRow: lane + 2,
}}
title={
taskKey
? `${taskKey} · ${task.title} · ${range}`
: `${task.title} · ${range}`
}
aria-label={t("tasks:calendar.taskAriaLabel", { title: task.title })}
onClick={() => onOpenTask(task.id)}
className={cn(
"z-10 mb-0.5 flex h-6 min-w-0 items-center overflow-hidden border border-primary/25 bg-primary/12 px-1.5 text-left text-[11px] font-medium leading-none text-foreground transition-colors hover:border-primary/40 hover:bg-primary/18 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:h-5",
// Bars that run past a week edge lose their cap there so the two halves
// read as one continuous span across rows.
continuesBefore ? "rounded-l-none border-l-0" : "ml-1 rounded-l-md",
continuesAfter ? "rounded-r-none border-r-0" : "mr-1 rounded-r-md",
)}
>
<span className="truncate">{task.title}</span>
</button>
);
}
67 changes: 67 additions & 0 deletions apps/web/src/components/calendar/calendar-toolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { formatDate } from "@/lib/format";

type CalendarToolbarProps = {
visibleMonth: Date;
onPreviousMonth: () => void;
onNextMonth: () => void;
onToday: () => void;
};

export default function CalendarToolbar({
visibleMonth,
onPreviousMonth,
onNextMonth,
onToday,
}: CalendarToolbarProps) {
const { t } = useTranslation();

return (
<div className="border-b border-border/80 px-3 py-3 sm:px-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<h1 className="truncate text-sm font-semibold text-foreground">
{t("tasks:calendar.title")}
</h1>
<span
className="truncate text-sm text-muted-foreground"
data-testid="calendar-month-label"
>
{formatDate(visibleMonth, { month: "long", year: "numeric" })}
</span>
</div>

<div className="flex shrink-0 items-center gap-1">
<Button
variant="outline"
size="icon-xs"
className="min-h-11 touch-manipulation sm:min-h-0"
aria-label={t("tasks:calendar.previousMonth")}
onClick={onPreviousMonth}
>
<ChevronLeft />
</Button>
<Button
variant="outline"
size="xs"
className="min-h-11 touch-manipulation sm:min-h-0"
onClick={onToday}
>
{t("tasks:calendar.today")}
</Button>
<Button
variant="outline"
size="icon-xs"
className="min-h-11 touch-manipulation sm:min-h-0"
aria-label={t("tasks:calendar.nextMonth")}
onClick={onNextMonth}
>
<ChevronRight />
</Button>
</div>
</div>
</div>
);
}
156 changes: 156 additions & 0 deletions apps/web/src/components/calendar/day-overflow-popover.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CalendarTask } from "./calendar-task-bar";
import DayOverflowPopover from "./day-overflow-popover";
import MonthGrid from "./month-grid";
import { buildMonthWeeks } from "./month-grid-model";

afterEach(() => {
cleanup();
document.body.innerHTML = "";
});

vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));

vi.mock("@/lib/format", () => ({
formatDate: () => "Monday, August 10",
formatDateShort: (value: Date) => value.toISOString().slice(0, 10),
}));

const TRIGGER_NAME = "tasks:calendar.dayTasksAriaLabel";

function task(
id: string,
title: string,
number: number | null,
start: Date,
end: Date,
): CalendarTask {
return { id, title, number, scheduleStart: start, scheduleEnd: end };
}

const AUGUST_10 = new Date(2026, 7, 10);

const dayTasks = [
task("t1", "Design review", 12, new Date(2026, 7, 10), new Date(2026, 7, 12)),
task("t2", "Ship release", 13, new Date(2026, 7, 10), new Date(2026, 7, 10)),
task("t3", "Write docs", null, new Date(2026, 7, 9), new Date(2026, 7, 11)),
];

describe("DayOverflowPopover", () => {
it("keeps the task list closed until the overflow label is clicked", () => {
render(
<DayOverflowPopover
day={AUGUST_10}
tasks={dayTasks}
hiddenCount={2}
projectSlug="KAN"
onOpenTask={vi.fn()}
/>,
);

expect(screen.getByRole("button", { name: TRIGGER_NAME })).toBeVisible();
expect(screen.queryByText("Design review")).toBeNull();
});

it("lists every task for the day, not only the hidden ones", async () => {
render(
<DayOverflowPopover
day={AUGUST_10}
tasks={dayTasks}
hiddenCount={2}
projectSlug="KAN"
onOpenTask={vi.fn()}
/>,
);

fireEvent.click(screen.getByRole("button", { name: TRIGGER_NAME }));

expect(await screen.findByText("Design review")).toBeVisible();
expect(screen.getByText("Ship release")).toBeVisible();
expect(screen.getByText("Write docs")).toBeVisible();
expect(screen.getByText("KAN-12")).toBeVisible();
});

it("opens the task and closes the popover when an entry is clicked", async () => {
const onOpenTask = vi.fn();

render(
<DayOverflowPopover
day={AUGUST_10}
tasks={dayTasks}
hiddenCount={2}
projectSlug="KAN"
onOpenTask={onOpenTask}
/>,
);

fireEvent.click(screen.getByRole("button", { name: TRIGGER_NAME }));
fireEvent.click(await screen.findByText("Ship release"));

expect(onOpenTask).toHaveBeenCalledTimes(1);
expect(onOpenTask).toHaveBeenCalledWith("t2");
expect(screen.queryByText("Design review")).toBeNull();
});

it("omits the task key when the project slug is unknown", async () => {
render(
<DayOverflowPopover
day={AUGUST_10}
tasks={dayTasks}
hiddenCount={2}
onOpenTask={vi.fn()}
/>,
);

fireEvent.click(screen.getByRole("button", { name: TRIGGER_NAME }));

expect(await screen.findByText("Design review")).toBeVisible();
expect(screen.queryByText("KAN-12")).toBeNull();
});
});

describe("MonthGrid overflow wiring", () => {
const weeks = buildMonthWeeks(AUGUST_10, 1);

it("surfaces hidden tasks through the overflow popover", async () => {
const onOpenTask = vi.fn();

render(
<MonthGrid
weeks={weeks}
tasks={dayTasks}
visibleMonth={AUGUST_10}
maxLanes={1}
projectSlug="KAN"
onOpenTask={onOpenTask}
/>,
);

// Only one lane fits, so the other two tasks on Aug 10 have to overflow.
const trigger = screen.getAllByRole("button", { name: TRIGGER_NAME })[0];
expect(trigger).toBeVisible();

fireEvent.click(trigger);
fireEvent.click(await screen.findByText("Ship release"));

expect(onOpenTask).toHaveBeenCalledWith("t2");
});

it("renders no overflow trigger when every task fits", () => {
render(
<MonthGrid
weeks={weeks}
tasks={dayTasks}
visibleMonth={AUGUST_10}
maxLanes={5}
projectSlug="KAN"
onOpenTask={vi.fn()}
/>,
);

expect(screen.queryByRole("button", { name: TRIGGER_NAME })).toBeNull();
});
});
81 changes: 81 additions & 0 deletions apps/web/src/components/calendar/day-overflow-popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { formatDate, formatDateShort } from "@/lib/format";
import type { CalendarTask } from "./calendar-task-bar";

type DayOverflowPopoverProps = {
day: Date;
/** Every task on this day, not just the ones the lane cap hid. */
tasks: CalendarTask[];
hiddenCount: number;
projectSlug?: string;
onOpenTask: (taskId: string) => void;
};

export default function DayOverflowPopover({
day,
tasks,
hiddenCount,
projectSlug,
onOpenTask,
}: DayOverflowPopoverProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);

const dayLabel = formatDate(day, {
weekday: "long",
month: "long",
day: "numeric",
});

const handleSelectTask = (taskId: string) => {
setOpen(false);
onOpenTask(taskId);
};

return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger
aria-label={t("tasks:calendar.dayTasksAriaLabel", { date: dayLabel })}
className="w-full truncate rounded-sm px-1.5 pb-1 text-left text-[10px] leading-tight text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{t("tasks:calendar.moreTasks", { count: hiddenCount })}
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-2">
<div className="space-y-1">
<p className="px-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{dayLabel}
</p>
<div className="max-h-64 space-y-0.5 overflow-y-auto">
{tasks.map((task) => (
<button
key={task.id}
type="button"
onClick={() => handleSelectTask(task.id)}
className="flex w-full min-w-0 flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{projectSlug && task.number != null ? (
<span className="truncate text-[10px] text-muted-foreground">
{projectSlug}-{task.number}
</span>
) : null}
<span className="w-full truncate text-xs font-medium text-foreground">
{task.title}
</span>
<span className="w-full truncate text-[11px] text-muted-foreground">
{formatDateShort(task.scheduleStart)} –{" "}
{formatDateShort(task.scheduleEnd)}
</span>
</button>
))}
</div>
</div>
</PopoverContent>
</Popover>
);
}
Loading
Loading