diff --git a/services/jtype-web/frontend/src/pages/Kanban.tsx b/services/jtype-web/frontend/src/pages/Kanban.tsx index 0fc4bef..17aa7d0 100644 --- a/services/jtype-web/frontend/src/pages/Kanban.tsx +++ b/services/jtype-web/frontend/src/pages/Kanban.tsx @@ -159,6 +159,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, fields: view.fields, swimlaneBy: view.swimlaneBy, colorColumns: view.colorColumns, diff --git a/services/jtype-web/frontend/src/pages/WebBoardView.tsx b/services/jtype-web/frontend/src/pages/WebBoardView.tsx index 1e161b7..b4048d1 100644 --- a/services/jtype-web/frontend/src/pages/WebBoardView.tsx +++ b/services/jtype-web/frontend/src/pages/WebBoardView.tsx @@ -27,7 +27,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' fields?: { key: string; label: string; type?: 'text' | 'number' | 'date' }[] swimlaneBy?: 'status' | 'priority' | 'assignee' } @@ -187,6 +188,7 @@ export function WebBoardView({ doneColumn: config.doneColumn, colorColumns: config.colorColumns, viewType: config.viewType, + calendarMode: config.calendarMode, fields: config.fields, swimlaneBy: config.swimlaneBy as BoardViewConfig['swimlaneBy'], groupBy: (config.groupBy as BoardViewConfig['groupBy']) || 'status', 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 c40373b..39e5e40 100644 --- a/shared/components/board/BoardSurface.tsx +++ b/shared/components/board/BoardSurface.tsx @@ -52,6 +52,7 @@ import { } from "../../lib/board"; import { BoardPeek } from "./BoardPeek"; import { BoardTable } from "./BoardTable"; +import { BoardCalendar } from "./BoardCalendar"; import { BoardSwimlanes } from "./BoardSwimlanes"; import type { BoardSurfaceProps } from "./types"; @@ -300,6 +301,16 @@ export function BoardSurface({ Table + {editableColumns && viewType === "board" && (