-
-
Notifications
You must be signed in to change notification settings - Fork 700
Adds a per-project monthly calendar view alongside board, backlog and gantt. #1570
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
andrejsshell
merged 10 commits into
usekaneo:main
from
fvoci:feat/project-calendar-view
Aug 20, 2026
Merged
Changes from 3 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
50f4590
feat(web): add a monthly calendar view to projects
fvoci 0f556f3
chore(i18n): seed calendar view keys for non-English locales
fvoci dac353b
chore(i18n): regenerate the locale schema
fvoci 93b78a0
fix(web): address calendar view review feedback
fvoci 1107e0a
chore(i18n): seed the calendar loadError key for non-English locales
fvoci 166c9d8
chore(i18n): regenerate the locale schema for loadError
fvoci 1e25b4d
chore(i18n): add Korean translations for the calendar view
fvoci 12dae3b
fix(web): announce the scheduled range in calendar task bars
fvoci 7323f9a
chore(i18n): reseed the calendar taskAriaLabel for non-English locales
fvoci b8f8501
chore(i18n): add the range placeholder to the Korean calendar task label
fvoci File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) { | ||
| 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> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
156
apps/web/src/components/calendar/day-overflow-popover.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.