From e0f29b700a499cb564837139da9d9443565b2eb5 Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 22 Jun 2026 21:26:29 +0800 Subject: [PATCH] feat(kanban): calendar view (month grid + agenda) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a third board view alongside Board/Table — a month grid and an agenda list keyed off each card's `due`. Pure presentational layer over the shared board model; no backend, migration, or WS changes. Because both the desktop file board and the web DB board render the shared BoardSurface, the calendar lands on both for free. - shared/lib/board.ts: widen BoardViewType with "calendar", add CalendarMode + calendarMode config field, and pure helpers isIsoDate / currentMonth / shiftMonth / groupCardsByDay / monthMatrix (zero-padded ISO strings, no date library — matches sortCards/todayStr). - shared/components/board/BoardCalendar.tsx: new month-grid + agenda component. Overdue (due < today && not in done column) shows red; today is highlighted; out-of-month and undated cards handled; card click opens the same BoardPeek. - BoardSurface: Calendar toggle button, third render branch, hide sort in calendar mode (group-by was already board-only); persist calendarMode via setConfig. - Thread calendar viewType + calendarMode through all three config types (desktop BoardConfig, web file-board BoardConfigJSON, web DB-board view). - Fix stale setConfig persistence comment in board/types.ts. - tests/unit/boardCalendar.spec.ts: cover the calendar helpers. - i18n: extract new shared strings, add zh translations, compile. Verified: both tsc projects clean, 9 unit tests pass, web build succeeds, and a throwaway harness render confirmed month grid, agenda, overdue styling, today highlight, mode toggle, and peek-on-click. Co-Authored-By: Claude Opus 4.8 --- .../jtype-web/frontend/src/pages/Kanban.tsx | 1 + .../frontend/src/pages/WebBoardView.tsx | 4 +- shared/components/board/BoardCalendar.tsx | 203 ++++++++++++++++++ shared/components/board/BoardSurface.tsx | 43 +++- shared/components/board/index.ts | 1 + shared/components/board/types.ts | 5 +- shared/i18n/locales/en/messages.mjs | 2 +- shared/i18n/locales/en/messages.po | 29 +++ shared/i18n/locales/ja/messages.mjs | 2 +- shared/i18n/locales/ja/messages.po | 29 +++ shared/i18n/locales/ko/messages.mjs | 2 +- shared/i18n/locales/ko/messages.po | 29 +++ shared/i18n/locales/zh/messages.mjs | 2 +- shared/i18n/locales/zh/messages.po | 29 +++ shared/lib/board.ts | 69 +++++- src/components/BoardView.tsx | 1 + src/lib/types.ts | 6 +- tests/unit/boardCalendar.spec.ts | 81 +++++++ 18 files changed, 519 insertions(+), 19 deletions(-) create mode 100644 shared/components/board/BoardCalendar.tsx create mode 100644 tests/unit/boardCalendar.spec.ts diff --git a/services/jtype-web/frontend/src/pages/Kanban.tsx b/services/jtype-web/frontend/src/pages/Kanban.tsx index 225c182..367fdd0 100644 --- a/services/jtype-web/frontend/src/pages/Kanban.tsx +++ b/services/jtype-web/frontend/src/pages/Kanban.tsx @@ -148,6 +148,7 @@ export function Kanban() { columns: board.columns.slice().sort((a, b) => a.position - b.position).map(c => ({ key: c.id, name: c.name, color: c.color, limit: c.wipLimit })), groupBy: (view.groupBy as BoardViewConfig['groupBy']) ?? 'status', viewType: view.viewType ?? 'board', + calendarMode: view.calendarMode, colorColumns: view.colorColumns, doneColumn: view.doneColumn, } diff --git a/services/jtype-web/frontend/src/pages/WebBoardView.tsx b/services/jtype-web/frontend/src/pages/WebBoardView.tsx index 3cc3c9c..2529340 100644 --- a/services/jtype-web/frontend/src/pages/WebBoardView.tsx +++ b/services/jtype-web/frontend/src/pages/WebBoardView.tsx @@ -22,7 +22,8 @@ type BoardConfigJSON = { columns: { key: string; name: string; color?: string | null; limit?: number | null }[] doneColumn?: string colorColumns?: boolean - viewType?: 'board' | 'table' + viewType?: 'board' | 'table' | 'calendar' + calendarMode?: 'month' | 'agenda' } function rand() { @@ -175,6 +176,7 @@ export function WebBoardView({ doneColumn: config.doneColumn, colorColumns: config.colorColumns, viewType: config.viewType, + calendarMode: config.calendarMode, groupBy: (config.groupBy as BoardViewConfig['groupBy']) || 'status', } : { title: boardDir, columns: [] }, diff --git a/shared/components/board/BoardCalendar.tsx b/shared/components/board/BoardCalendar.tsx new file mode 100644 index 0000000..a7311e0 --- /dev/null +++ b/shared/components/board/BoardCalendar.tsx @@ -0,0 +1,203 @@ +import { useState } from "react"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { ChevronLeftIcon, ChevronRightIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; +import { + PRIORITY_STYLE, + currentMonth, + groupCardsByDay, + isIsoDate, + monthMatrix, + shiftMonth, + sortCards, + type BoardViewCard, + type CalendarMode, +} from "../../lib/board"; + +/** Localized short weekday labels, Sunday-first (2023-01-01 was a Sunday). */ +const WEEKDAYS = Array.from({ length: 7 }, (_, i) => + new Date(2023, 0, 1 + i).toLocaleDateString(undefined, { weekday: "short" }), +); + +/** + * Calendar view over the same cards (Notion's "one data, many views"): a month + * grid or an agenda list, keyed off each card's `due`. A card click opens the + * same peek as the board/table. The remembered sub-mode lives in the board + * config (`calendarMode`); the visible month is local cursor state. + */ +export function BoardCalendar({ + cards, + today, + doneKey, + mode, + onModeChange, + selectedId, + onSelect, +}: { + cards: BoardViewCard[]; + today: string; + doneKey: string; + mode: CalendarMode; + onModeChange: (mode: CalendarMode) => void; + selectedId?: string; + onSelect: (card: BoardViewCard) => void; +}) { + const [month, setMonth] = useState(() => currentMonth()); + const byDay = groupCardsByDay(cards); + const [ys, ms] = month.split("-"); + const monthLabel = new Date(Number(ys), Number(ms) - 1, 1).toLocaleDateString(undefined, { year: "numeric", month: "long" }); + + const isOverdue = (c: BoardViewCard) => !!c.due && c.due < today && c.columnKey !== doneKey; + + const navBtn = + "inline-flex h-7 w-7 items-center justify-center rounded-md border border-stone-200 text-stone-500 hover:border-brand/40 hover:text-brand-dark"; + const modeBtn = (m: CalendarMode) => + `rounded-md px-2 py-1 text-xs font-medium ${ + mode === m ? "bg-brand-soft text-brand-dark" : "text-stone-500 hover:text-brand-dark" + }`; + + const cardChip = (card: BoardViewCard, compact: boolean) => { + const overdue = isOverdue(card); + return ( + + ); + }; + + const header = ( +
+ {mode === "month" && ( + <> + + + {monthLabel} + + + )} +
+ + +
+
+ ); + + if (mode === "agenda") { + const sorted = sortCards(cards, "due"); + const dated = sorted.filter((c) => isIsoDate(c.due)); + const undated = sorted.filter((c) => !isIsoDate(c.due)); + let lastDay = ""; + return ( +
+ {header} +
+ {dated.length === 0 && undated.length === 0 && ( +
+ No cards +
+ )} + {dated.map((card) => { + const showHeader = card.due !== lastDay; + lastDay = card.due as string; + return ( +
+ {showHeader && ( +
+ {card.due} + {card.due === today && ( + + Today + + )} +
+ )} +
{cardChip(card, false)}
+
+ ); + })} + {undated.length > 0 && ( + <> +
+ Unscheduled +
+
{undated.map((card) => cardChip(card, false))}
+ + )} +
+
+ ); + } + + const weeks = monthMatrix(month); + return ( +
+ {header} +
+ {WEEKDAYS.map((w) => ( +
+ {w} +
+ ))} +
+
+ {weeks.flat().map((day) => { + const inMonth = day.slice(0, 7) === month; + const isToday = day === today; + const dayCards = byDay.get(day) ?? []; + return ( +
+
+ {Number(day.slice(8, 10))} +
+
+ {dayCards.slice(0, 4).map((card) => cardChip(card, true))} + {dayCards.length > 4 && ( + +{dayCards.length - 4} + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/shared/components/board/BoardSurface.tsx b/shared/components/board/BoardSurface.tsx index 787f3f1..9d9e025 100644 --- a/shared/components/board/BoardSurface.tsx +++ b/shared/components/board/BoardSurface.tsx @@ -46,6 +46,7 @@ import { } from "../../lib/board"; import { BoardPeek } from "./BoardPeek"; import { BoardTable } from "./BoardTable"; +import { BoardCalendar } from "./BoardCalendar"; import type { BoardSurfaceProps } from "./types"; type DropTarget = { col: string; index: number }; @@ -276,6 +277,16 @@ export function BoardSurface({ Table + {editableColumns && viewType === "board" && (