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" && (