Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions services/jtype-web/frontend/src/pages/Kanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion services/jtype-web/frontend/src/pages/WebBoardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down Expand Up @@ -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',
Expand Down
203 changes: 203 additions & 0 deletions shared/components/board/BoardCalendar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { useState } from "react";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -path "*shared/components/board/BoardCalendar.tsx" -type f

Repository: cnjack/jtype

Length of output: 194


🏁 Script executed:

if [ -f shared/components/board/BoardCalendar.tsx ]; then
  wc -l shared/components/board/BoardCalendar.tsx
fi

Repository: cnjack/jtype

Length of output: 196


🏁 Script executed:

cat -n shared/components/board/BoardCalendar.tsx

Repository: cnjack/jtype

Length of output: 9469


Hoist month cursor state out of the shared component.

Line 45 introduces local useState, and Lines 90/93/100 mutate it internally. In shared/components, this should be controlled via props/callbacks so the component stays Props-in/Callbacks-out.

Suggested refactor
-import { useState } from "react";
 import { t } from "`@lingui/core/macro`";
 import { Trans } from "`@lingui/react/macro`";
@@
 export function BoardCalendar({
   cards,
   today,
   doneKey,
+  month,
+  onMonthChange,
   mode,
   onModeChange,
   selectedId,
   onSelect,
 }: {
   cards: BoardViewCard[];
   today: string;
   doneKey: string;
+  month: string;
+  onMonthChange: (month: string) => void;
   mode: CalendarMode;
   onModeChange: (mode: CalendarMode) => void;
   selectedId?: string;
   onSelect: (card: BoardViewCard) => void;
 }) {
-  const [month, setMonth] = useState(() => currentMonth());
   const byDay = groupCardsByDay(cards);
@@
-          <button type="button" className={navBtn} title={t`Previous month`} aria-label={t`Previous month`} onClick={() => setMonth((m) => shiftMonth(m, -1))}>
+          <button type="button" className={navBtn} title={t`Previous month`} aria-label={t`Previous month`} onClick={() => onMonthChange(shiftMonth(month, -1))}>
@@
-          <button type="button" className={navBtn} title={t`Next month`} aria-label={t`Next month`} onClick={() => setMonth((m) => shiftMonth(m, 1))}>
+          <button type="button" className={navBtn} title={t`Next month`} aria-label={t`Next month`} onClick={() => onMonthChange(shiftMonth(month, 1))}>
@@
-            onClick={() => setMonth(currentMonth())}
+            onClick={() => onMonthChange(currentMonth())}

Per coding guidelines: "Shared React components in shared/ must follow Props-in/Callbacks-out pattern and must not depend on useReducer, useState, or any specific state pattern."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shared/components/board/BoardCalendar.tsx` at line 1, The BoardCalendar
component uses useState on line 45 to manage month cursor state internally,
violating the Props-in/Callbacks-out pattern required for shared components.
Remove the useState hook for month cursor from BoardCalendar and replace it with
props passed from the parent component. Convert the state mutations occurring at
lines 90, 93, and 100 into callback props (such as onMonthChange or similar)
that invoke parent-provided handlers instead of directly updating state. The
parent component should be responsible for managing the month cursor state and
passing both the current value and update callbacks as props to BoardCalendar.

Source: Coding guidelines

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 (
<button
key={card.id}
type="button"
onClick={() => onSelect(card)}
title={card.title}
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left text-[11px] transition-colors ${
selectedId === card.id ? "bg-brand-soft/60" : "bg-stone-100/70 hover:bg-brand-soft/40"
} ${overdue ? "text-red-600" : "text-stone-700"}`}
>
{card.priority && card.priority !== "none" && (
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${PRIORITY_STYLE[card.priority]?.split(" ")[0] ?? "bg-stone-300"}`} />
)}
{card.icon && <span className="shrink-0">{card.icon}</span>}
<span className="truncate">{card.title}</span>
{!compact && (card.taskTotal ?? 0) > 0 && (
<span className="ml-auto inline-flex shrink-0 items-center gap-0.5 text-[10px] text-stone-400">
<CheckCircleIcon className="h-2.5 w-2.5" />
{card.taskDone}/{card.taskTotal}
</span>
)}
</button>
);
};

const header = (
<div className="flex items-center gap-2 border-b border-black/[0.04] px-4 py-2">
{mode === "month" && (
<>
<button type="button" className={navBtn} title={t`Previous month`} aria-label={t`Previous month`} onClick={() => setMonth((m) => shiftMonth(m, -1))}>
<ChevronLeftIcon className="h-4 w-4" />
</button>
<button type="button" className={navBtn} title={t`Next month`} aria-label={t`Next month`} onClick={() => setMonth((m) => shiftMonth(m, 1))}>
<ChevronRightIcon className="h-4 w-4" />
</button>
<span className="min-w-[8rem] text-sm font-medium text-brand-dark">{monthLabel}</span>
<button
type="button"
className="rounded-md border border-stone-200 px-2 py-1 text-xs text-stone-600 hover:border-brand/40 hover:text-brand-dark"
onClick={() => setMonth(currentMonth())}
>
<Trans>Today</Trans>
</button>
</>
)}
<div className="ml-auto inline-flex items-center rounded-lg border border-stone-200 p-0.5">
<button type="button" className={modeBtn("month")} onClick={() => onModeChange("month")}>
<Trans>Month</Trans>
</button>
<button type="button" className={modeBtn("agenda")} onClick={() => onModeChange("agenda")}>
<Trans>Agenda</Trans>
</button>
</div>
</div>
);

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 (
<div className="flex min-h-0 flex-1 flex-col">
{header}
<div className="min-h-0 flex-1 overflow-auto p-4">
{dated.length === 0 && undated.length === 0 && (
<div className="px-3 py-8 text-center text-sm text-stone-400">
<Trans>No cards</Trans>
</div>
)}
{dated.map((card) => {
const showHeader = card.due !== lastDay;
lastDay = card.due as string;
return (
<div key={card.id}>
{showHeader && (
<div className={`mt-3 mb-1 text-xs font-medium ${card.due === today ? "text-brand-dark" : "text-brand-gray"}`}>
{card.due}
{card.due === today && (
<span className="ml-1 rounded bg-brand-soft px-1 text-[10px] text-brand-dark">
<Trans>Today</Trans>
</span>
)}
</div>
)}
<div className="max-w-xl">{cardChip(card, false)}</div>
</div>
);
})}
{undated.length > 0 && (
<>
<div className="mt-4 mb-1 text-xs font-medium text-stone-400">
<Trans>Unscheduled</Trans>
</div>
<div className="max-w-xl space-y-0.5">{undated.map((card) => cardChip(card, false))}</div>
</>
)}
</div>
</div>
);
}

const weeks = monthMatrix(month);
return (
<div className="flex min-h-0 flex-1 flex-col">
{header}
<div className="grid grid-cols-7 border-b border-black/[0.04] bg-[#fbfdfb]">
{WEEKDAYS.map((w) => (
<div key={w} className="px-2 py-1 text-center text-[11px] font-medium uppercase tracking-wide text-brand-gray">
{w}
</div>
))}
</div>
<div className="grid min-h-0 flex-1 grid-cols-7 grid-rows-6 overflow-auto">
{weeks.flat().map((day) => {
const inMonth = day.slice(0, 7) === month;
const isToday = day === today;
const dayCards = byDay.get(day) ?? [];
return (
<div
key={day}
className={`flex min-h-[5.5rem] flex-col gap-0.5 border-b border-r border-black/[0.04] p-1 ${inMonth ? "" : "bg-stone-50/60"}`}
>
<div
className={`mb-0.5 inline-flex h-5 w-5 items-center justify-center self-start rounded-full text-[11px] ${
isToday ? "bg-brand text-white" : inMonth ? "text-stone-500" : "text-stone-300"
}`}
>
{Number(day.slice(8, 10))}
</div>
<div className="flex flex-col gap-0.5 overflow-hidden">
{dayCards.slice(0, 4).map((card) => cardChip(card, true))}
{dayCards.length > 4 && (
<span className="px-1 text-[10px] text-stone-400">+{dayCards.length - 4}</span>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
43 changes: 33 additions & 10 deletions shared/components/board/BoardSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -300,6 +301,16 @@ export function BoardSurface({
<TableCellsIcon className="h-3.5 w-3.5" />
<Trans>Table</Trans>
</button>
<button
type="button"
onClick={() => void actions.setConfig({ viewType: "calendar" })}
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium ${
viewType === "calendar" ? "bg-brand-soft text-brand-dark" : "text-stone-500 hover:text-brand-dark"
}`}
>
<CalendarDaysIcon className="h-3.5 w-3.5" />
<Trans>Calendar</Trans>
</button>
</div>
{editableColumns && viewType === "board" && (
<button
Expand Down Expand Up @@ -377,15 +388,17 @@ export function BoardSurface({
</select>
</label>
)}
<label className="inline-flex items-center gap-1 text-xs text-brand-gray">
<BarsArrowDownIcon className="h-3.5 w-3.5" />
<select className={ctrlCls} value={sortBy} onChange={(e) => setSortBy(e.target.value as BoardSortKey)}>
<option value="manual">{t`Sort: Manual`}</option>
<option value="due">{t`Sort: Due`}</option>
<option value="priority">{t`Sort: Priority`}</option>
<option value="title">{t`Sort: Title`}</option>
</select>
</label>
{viewType !== "calendar" && (
<label className="inline-flex items-center gap-1 text-xs text-brand-gray">
<BarsArrowDownIcon className="h-3.5 w-3.5" />
<select className={ctrlCls} value={sortBy} onChange={(e) => setSortBy(e.target.value as BoardSortKey)}>
<option value="manual">{t`Sort: Manual`}</option>
<option value="due">{t`Sort: Due`}</option>
<option value="priority">{t`Sort: Priority`}</option>
<option value="title">{t`Sort: Title`}</option>
</select>
</label>
)}

<Menu as="div" className="relative">
<MenuButton
Expand Down Expand Up @@ -462,7 +475,7 @@ export function BoardSurface({
</div>
)}

{/* Body: table or columns */}
{/* Body: table, calendar, or columns */}
{viewType === "table" ? (
<BoardTable
cards={sortCardsFn(vis, sortBy)}
Expand All @@ -472,6 +485,16 @@ export function BoardSurface({
selectedId={selected?.id}
onSelect={(c) => setSelectedId(c.id)}
/>
) : viewType === "calendar" ? (
<BoardCalendar
cards={vis}
today={today}
doneKey={doneKey}
mode={config.calendarMode ?? "month"}
onModeChange={(m) => void actions.setConfig({ calendarMode: m })}
selectedId={selected?.id}
onSelect={(c) => setSelectedId(c.id)}
/>
) : swimlaneActive && swimlaneKey ? (
<BoardSwimlanes
cards={vis}
Expand Down
1 change: 1 addition & 0 deletions shared/components/board/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { BoardSurface } from "./BoardSurface";
export { BoardPeek } from "./BoardPeek";
export { BoardTable } from "./BoardTable";
export { BoardCalendar } from "./BoardCalendar";
export { BoardSwimlanes } from "./BoardSwimlanes";
export { EmojiField, ListboxSelect, TagMultiSelect, fieldCls } from "./controls";
export type { BoardActions, BoardSurfaceProps, BoardOption } from "./types";
5 changes: 4 additions & 1 deletion shared/components/board/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export type BoardActions = {
setColumnColor?: (key: string, color: string | null) => Promise<void> | void;
setColumnLimit?: (key: string) => Promise<void> | void;
toggleDoneColumn?: (key: string) => Promise<void> | void;
/** Persist a view-config patch (desktop → .board; web → localStorage). */
/**
* Persist a view-config patch. Desktop + web file boards write the `.board`
* document; the web DB kanban stores it per-board in localStorage.
*/
setConfig: (patch: Partial<BoardViewConfig>) => Promise<void> | void;
refresh?: () => Promise<void> | void;
};
Expand Down
Loading
Loading