diff --git a/apps/blade/package.json b/apps/blade/package.json index 6270908ad..bdc485ac6 100644 --- a/apps/blade/package.json +++ b/apps/blade/package.json @@ -28,6 +28,10 @@ "@forge/ui": "workspace:*", "@forge/utils": "workspace:*", "@forge/validators": "workspace:*", + "@fullcalendar/core": "^6.1.20", + "@fullcalendar/daygrid": "^6.1.20", + "@fullcalendar/interaction": "^6.1.20", + "@fullcalendar/react": "^6.1.20", "@react-email/render": "^2.0.0", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.8.0", diff --git a/apps/blade/src/app/_components/issue-calendar/calendar-day-agenda.tsx b/apps/blade/src/app/_components/issue-calendar/calendar-day-agenda.tsx new file mode 100644 index 000000000..92098adaa --- /dev/null +++ b/apps/blade/src/app/_components/issue-calendar/calendar-day-agenda.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { useCallback } from "react"; +import { AlertCircle, Copy, Pencil, User, Users } from "lucide-react"; + +import type { ISSUE } from "@forge/consts"; +import { cn } from "@forge/ui"; +import { Button } from "@forge/ui/button"; +import { toast } from "@forge/ui/toast"; + +import { CreateEditDialog } from "~/app/_components/issues/create-edit-dialog"; +import { api } from "~/trpc/react"; + +type Issue = ISSUE.IssueFetcherPaneIssue; + +function startOfLocalDay(d: Date) { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} + +function teamLabels( + issue: Issue, + roleNameById: Map | undefined, +): string[] { + const ids = [issue.team, ...issue.teamVisibility.map((t) => t.teamId)]; + const unique = [...new Set(ids)]; + const labels = unique + .map((id) => roleNameById?.get(id)) + .filter((label): label is string => Boolean(label?.trim())); + return labels; +} + +function formatTeamLabel(roleName: string) { + const trimmed = roleName.replace(/\s+team$/i, "").trim(); + return trimmed || roleName; +} + +function assigneeDisplayNames(issue: Issue): string[] { + const rows = issue.userAssignments as unknown as { + user?: { name?: string | null; discordUserId?: string | null }; + }[]; + return rows + .map((a) => { + const n = a.user?.name?.trim(); + if (n) return n; + const d = a.user?.discordUserId?.trim(); + return d ?? ""; + }) + .filter(Boolean); +} + +function isOverdueIssue(issue: Issue) { + if (issue.status === "FINISHED" || !issue.date) return false; + const dueDate = new Date(issue.date); + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + return dueDate < todayStart; +} + +function issueStatusForAria(status: Issue["status"]) { + return status + .split("_") + .map((w) => w.charAt(0) + w.slice(1).toLowerCase()) + .join(" "); +} + +export function IssueDayAgenda(props: { + day: Date; + issues: Issue[]; + isLoading: boolean; + roleNameById: Map | undefined; + onIssueSelect?: (issueId: string) => void; + onIssuesChanged?: () => void; +}) { + const { + day, + issues, + isLoading, + roleNameById, + onIssueSelect, + onIssuesChanged, + } = props; + + const utils = api.useUtils(); + const deleteIssueMutation = api.issues.deleteIssue.useMutation({ + onSuccess: async () => { + await utils.issues.invalidate(); + await utils.issues.getAllIssues.invalidate(); + onIssuesChanged?.(); + toast.success("Issue deleted"); + }, + onError: () => { + toast.error("Failed to delete issue"); + }, + }); + + const handleSubmitEdit = useCallback(async () => { + await utils.issues.invalidate(); + await utils.issues.getAllIssues.invalidate(); + onIssuesChanged?.(); + }, [onIssuesChanged, utils.issues]); + + const copyIssueLink = useCallback((issueId: string) => { + const origin = typeof window !== "undefined" ? window.location.origin : ""; + const url = `${origin}/issues/${issueId}`; + void navigator.clipboard.writeText(url).then( + () => { + toast.success("Issue link copied"); + }, + () => { + toast.error("Could not copy link"); + }, + ); + }, []); + + const isToday = + startOfLocalDay(day).getTime() === startOfLocalDay(new Date()).getTime(); + const weekdayShort = day.toLocaleDateString(undefined, { + weekday: "short", + }); + const dayOfMonth = day.getDate(); + + const header = ( +
+ {weekdayShort} {dayOfMonth} +
+ ); + + if (isLoading) { + return ( +
+ {header} +
+ Loading issues… +
+
+ ); + } + + if (issues.length === 0) { + return ( +
+ {header} +
+ Nothing due on this day. +
+
+ ); + } + + const sorted = [...issues].sort((a, b) => { + const ta = a.date ? +new Date(a.date) : 0; + const tb = b.date ? +new Date(b.date) : 0; + return ta - tb; + }); + + return ( +
+ {header} +
+
    + {sorted.map((issue) => { + const overdue = isOverdueIssue(issue); + const teams = teamLabels(issue, roleNameById).map(formatTeamLabel); + const teamsText = teams.join(" · "); + const showTeamsBlock = teamsText.length > 0; + const assigneeNames = assigneeDisplayNames(issue); + const assigneesText = + assigneeNames.length > 0 + ? assigneeNames.join(" · ") + : "Unassigned"; + + return ( +
  • +
    +
    + + {onIssueSelect ? ( + + ) : ( +

    + {issue.name} +

    + )} +
    +
    + + { + if (!values.id || deleteIssueMutation.isPending) return; + deleteIssueMutation.mutate({ id: values.id }); + }} + > + + +
    +
    + +
    + {overdue ? ( +
    + + Past due +
    + ) : null} + + {showTeamsBlock ? ( +
    + + + {teamsText} + +
    + ) : null} + +
    + +
    +

    + Assignees +

    +

    + {assigneesText} +

    +
    +
    +
    +
  • + ); + })} +
+
+
+ ); +} diff --git a/apps/blade/src/app/_components/issue-calendar/calendar-issue-dialog.tsx b/apps/blade/src/app/_components/issue-calendar/calendar-issue-dialog.tsx new file mode 100644 index 000000000..8108921af --- /dev/null +++ b/apps/blade/src/app/_components/issue-calendar/calendar-issue-dialog.tsx @@ -0,0 +1,275 @@ +"use client"; + +import type { ReactNode } from "react"; +import Link from "next/link"; +import { skipToken } from "@tanstack/react-query"; +import { + AlignLeft, + Calendar, + CircleDot, + Copy, + Eye, + Link2, + Loader2, + Pencil, + User, + Users, +} from "lucide-react"; + +import type { RouterOutputs } from "@forge/api"; +import type { ISSUE } from "@forge/consts"; +import { Button } from "@forge/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@forge/ui/dialog"; +import { toast } from "@forge/ui/toast"; + +import { api } from "~/trpc/react"; + +export interface CalendarIssueDialogProps { + issueId: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onRequestEdit: (values: Partial) => void; +} + +type GetIssueResult = RouterOutputs["issues"]["getIssue"]; + +function isIssueOverdue( + status: GetIssueResult["status"], + date: Date | string | null | undefined, +) { + if (status === "FINISHED" || !date) return false; + const dueDate = new Date(date); + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + return dueDate < todayStart; +} + +function getIssueToEditValues( + issue: GetIssueResult, +): Partial { + return { + id: issue.id, + status: issue.status, + name: issue.name, + description: issue.description, + links: issue.links ?? [], + date: issue.date ?? undefined, + priority: issue.priority, + team: issue.team.id, + parent: issue.parent ?? undefined, + isEvent: issue.event !== null, + event: issue.event, + teamVisibilityIds: issue.teamVisibility.map((v) => v.teamId), + assigneeIds: issue.userAssignments.map((a) => a.userId), + }; +} + +function DetailRow(props: { + icon: ReactNode; + label: string; + children: ReactNode; +}) { + return ( +
+
+ {props.icon} +
+
+
{props.label}
+
+ {props.children} +
+
+
+ ); +} + +export function CalendarIssueDialog({ + issueId, + open, + onOpenChange, + onRequestEdit, +}: CalendarIssueDialogProps) { + const { + data: issue, + isLoading, + isError, + error, + } = api.issues.getIssue.useQuery( + open && issueId ? { id: issueId } : skipToken, + ); + + function handleEdit() { + if (!issue) return; + onRequestEdit(getIssueToEditValues(issue)); + onOpenChange(false); + } + + async function handleCopyIssueUrl() { + if (!issue || typeof window === "undefined") return; + const url = `${window.location.origin}/issues/${issue.id}`; + try { + await navigator.clipboard.writeText(url); + toast.success("Issue link copied"); + } catch { + toast.error("Could not copy link"); + } + } + + return ( + + + + {isLoading ? ( + <> + Loading issue +
+ + Loading… +
+ + ) : isError ? ( + + {error.message} + + ) : issue ? ( + <> +

Issue

+ + + {issue.name} + + + + Issue details including status, due date, team, and assignees. + + + ) : ( + + Issue + + )} +
+ +
+
+ {issue ? ( + <> + } label="Status"> + {issue.status} + + } label="Due Date"> + {issue.date ? ( + + {new Date(issue.date).toLocaleDateString()} + + ) : ( + "No due date" + )} + + } label="Team"> + {issue.team.name} + + } label="Description"> + {issue.description ? ( + + {issue.description} + + ) : ( + + No description + + )} + + } label="Assignees"> + {issue.userAssignments.length > 0 ? ( +
    + {issue.userAssignments.map((assignment) => ( +
  • + {assignment.user.name ?? + assignment.user.discordUserId} +
  • + ))} +
+ ) : ( + "Unassigned" + )} +
+ } label="Visible Teams"> + {issue.teamVisibility.length > 0 ? ( +
    + {issue.teamVisibility.map((visibility) => ( +
  • {visibility.team.name}
  • + ))} +
+ ) : ( + "No team visibility rules" + )} +
+ } label="Links"> + {issue.links && issue.links.length > 0 ? ( + + ) : ( + "No links" + )} + + + ) : null} +
+
+ +
+ + +
+
+
+ ); +} diff --git a/apps/blade/src/app/_components/issue-calendar/calendar-status-dot-legend.tsx b/apps/blade/src/app/_components/issue-calendar/calendar-status-dot-legend.tsx new file mode 100644 index 000000000..2eec2b5ea --- /dev/null +++ b/apps/blade/src/app/_components/issue-calendar/calendar-status-dot-legend.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { ISSUE } from "@forge/consts"; + +const STATUS_LEGEND_LABEL: Record<(typeof ISSUE.ISSUE_STATUS)[number], string> = + { + BACKLOG: "Backlog", + PLANNING: "Planning", + IN_PROGRESS: "In Progress", + FINISHED: "Finished", + }; + +export function IssueStatusDotLegend() { + return ( +
+ {ISSUE.ISSUE_STATUS.map((status) => ( + + + {STATUS_LEGEND_LABEL[status]} + + ))} +
+ ); +} diff --git a/apps/blade/src/app/_components/issue-calendar/calendar.css b/apps/blade/src/app/_components/issue-calendar/calendar.css new file mode 100644 index 000000000..820bdb4d3 --- /dev/null +++ b/apps/blade/src/app/_components/issue-calendar/calendar.css @@ -0,0 +1,628 @@ +@layer components { + .calendar-theme { + --fc-border-color: hsl(var(--border)); + --fc-page-bg-color: hsl(var(--card)); + --fc-neutral-bg-color: hsl(var(--muted) / 0.45); + --fc-neutral-text-color: hsl(var(--muted-foreground)); + --fc-today-bg-color: hsl(var(--primary) / 0.12); + --fc-event-bg-color: hsl(var(--primary)); + --fc-event-border-color: hsl(var(--primary)); + --fc-event-text-color: hsl(var(--primary-foreground)); + --fc-button-bg-color: hsl(var(--primary)); + --fc-button-border-color: hsl(var(--primary)); + --fc-button-hover-bg-color: hsl(var(--primary-lighter)); + --fc-button-hover-border-color: hsl(var(--primary-lighter)); + --fc-button-active-bg-color: hsl(var(--primary-lighter)); + --fc-button-active-border-color: hsl(var(--primary-lighter)); + color: hsl(var(--foreground)); + } + + .calendar-theme .fc { + font-family: inherit; + color: hsl(var(--foreground)); + /* Keep calendar inside its card (no sideways overflow). */ + min-width: 0; + width: 100%; + } + + .calendar-theme .fc-toolbar { + gap: 0.75rem; + margin-bottom: 0.75rem; + padding-bottom: 0.75rem; + } + + .calendar-theme .fc-toolbar-chunk { + display: flex; + flex-direction: row-reverse; + gap: 1rem; + } + + .calendar-theme .fc-toolbar-title { + font-size: 2rem; + font-weight: 700; + letter-spacing: -0.025em; + } + + .calendar-theme .fc-button { + border-radius: 0; + box-shadow: none; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.25rem; + padding: 0.5rem 0.875rem; + text-transform: none; + transition: + background-color 150ms ease, + border-color 150ms ease, + color 150ms ease, + opacity 150ms ease; + } + + .calendar-theme .fc-button:focus-visible { + box-shadow: + 0 0 0 2px hsl(var(--background)), + 0 0 0 4px hsl(var(--ring)); + outline: none; + } + + .calendar-theme .fc-button .fc-icon { + font-size: 1rem; + } + + .calendar-theme .fc-button-primary:not(:disabled).fc-button-active, + .calendar-theme .fc-button-primary:not(:disabled):active { + background-color: hsl(var(--primary-lighter)); + border-color: hsl(var(--primary-lighter)); + } + + .calendar-theme .fc-button-primary:disabled { + opacity: 0.6; + } + + .calendar-theme .fc .fc-button-primary { + background-color: hsl(var(--secondary)); + border-color: hsl(var(--border)); + color: hsl(var(--foreground)); + } + + .calendar-theme .fc .fc-button-primary:hover { + background-color: hsl(var(--accent)); + border-color: hsl(var(--border)); + color: hsl(var(--accent-foreground)); + } + + .calendar-theme .fc-scrollgrid, + .calendar-theme .fc-theme-standard td, + .calendar-theme .fc-theme-standard th { + border-color: hsl(var(--border)); + } + + .calendar-theme .fc-scrollgrid { + /* So the “+N more” popup isn’t cut off. */ + overflow: visible; + background-color: hsl(var(--card)); + } + + /* Month / week: vertical scroll inside the grid when the pane is short. */ + .calendar-theme .fc-dayGridMonth-view .fc-scroller, + .calendar-theme .fc-dayGridWeek-view .fc-scroller { + overflow-x: hidden; + overflow-y: auto !important; + scrollbar-width: thin; + scrollbar-color: hsl(var(--muted-foreground) / 0.35) transparent; + } + + .calendar-theme .fc-dayGridMonth-view .fc-scroller::-webkit-scrollbar, + .calendar-theme .fc-dayGridWeek-view .fc-scroller::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + .calendar-theme .fc-dayGridMonth-view .fc-scroller::-webkit-scrollbar-thumb, + .calendar-theme .fc-dayGridWeek-view .fc-scroller::-webkit-scrollbar-thumb { + background-color: hsl(var(--muted-foreground) / 0.35); + border-radius: 9999px; + border: 2px solid transparent; + background-clip: padding-box; + } + + .calendar-theme .fc-dayGridMonth-view .fc-scroller::-webkit-scrollbar-track, + .calendar-theme .fc-dayGridWeek-view .fc-scroller::-webkit-scrollbar-track { + background: transparent; + } + + .calendar-theme .fc-view-harness { + min-height: 0 !important; + min-width: 0; + max-width: 100%; + } + + /* Day header bar: card color on normal days; today stays clear so the “today” tint shows. */ + .calendar-theme .fc-daygrid-day:not(.fc-day-today) .fc-daygrid-day-top, + .calendar-theme .fc-list-day-cushion { + background-color: hsl(var(--card)); + } + + /* Top row of weekday names — styled like the issues table header. */ + .calendar-theme .fc-col-header-cell { + background-color: hsl(var(--muted) / 0.3); + border-color: hsl(var(--border)); + } + + .calendar-theme .fc-col-header-cell-cushion { + padding: 0.5rem 1rem; + color: hsl(var(--muted-foreground)); + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.025em; + text-transform: uppercase; + } + + .calendar-theme .fc-daygrid-day-number { + padding: 0.5rem; + color: hsl(var(--foreground)); + font-size: 0.875rem; + font-weight: 500; + } + + /* Click the day number to open day view; hover adds a light circle. */ + .calendar-theme .fc-dayGridMonth-view .fc-daygrid-day-number, + .calendar-theme .fc-dayGridWeek-view .fc-daygrid-day-number { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2rem; + min-height: 2rem; + margin: 0.125rem; + padding: 0; + border-radius: 9999px; + cursor: pointer; + transition: background-color 120ms ease; + } + + .calendar-theme .fc-dayGridMonth-view .fc-daygrid-day-number:hover, + .calendar-theme .fc-dayGridWeek-view .fc-daygrid-day-number:hover { + background-color: hsl(var(--muted-foreground) / 0.16); + } + + .calendar-theme .fc-dayGridMonth-view .fc-daygrid-day-number:active, + .calendar-theme .fc-dayGridWeek-view .fc-daygrid-day-number:active { + background-color: hsl(var(--muted-foreground) / 0.26); + } + + .calendar-theme .fc-dayGridMonth-view .fc-daygrid-day-number:focus-visible, + .calendar-theme .fc-dayGridWeek-view .fc-daygrid-day-number:focus-visible { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; + } + + .calendar-theme .fc-daygrid-day { + background-color: hsl(var(--background)); + } + + .calendar-theme .fc-dayGridMonth-view .fc-daygrid-day, + .calendar-theme .fc-dayGridWeek-view .fc-daygrid-day { + transition: background-color 140ms ease; + } + + @media (prefers-reduced-motion: reduce) { + .calendar-theme .fc-dayGridMonth-view .fc-daygrid-day, + .calendar-theme .fc-dayGridWeek-view .fc-daygrid-day { + transition: none; + } + } + + .calendar-theme .fc-dayGridMonth-view .fc-daygrid-day:hover, + .calendar-theme .fc-dayGridWeek-view .fc-daygrid-day:hover { + cursor: pointer; + background-color: hsl(var(--accent) / 0.28); + } + + .calendar-theme .fc-dayGridMonth-view .fc-daygrid-day.fc-day-today:hover, + .calendar-theme .fc-dayGridWeek-view .fc-daygrid-day.fc-day-today:hover { + background-color: hsl(var(--primary) / 0.22) !important; + } + + .calendar-theme .fc-day-other .fc-daygrid-day-number { + color: hsl(var(--muted-foreground)); + opacity: 0.65; + } + + .calendar-theme .fc-day-today .fc-daygrid-day-number { + color: hsl(var(--primary)); + font-weight: 700; + } + + .calendar-theme .fc-dayGridMonth-view .fc-daygrid-body td, + .calendar-theme .fc-dayGridWeek-view .fc-daygrid-body td { + vertical-align: top; + } + + .calendar-theme .fc-dayGridMonth-view .fc-daygrid-day-frame, + .calendar-theme .fc-dayGridWeek-view .fc-daygrid-day-frame { + min-height: 0; + } + + .calendar-theme .fc-dayGridMonth-view .fc-daygrid-day-events, + .calendar-theme .fc-dayGridWeek-view .fc-daygrid-day-events { + padding-bottom: 0.125rem; + } + + .calendar-theme .fc-daygrid-event { + border-radius: var(--radius); + padding-inline: 0.25rem; + background-color: hsl(var(--primary)); + border-color: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + } + + /* Issue rows are a bit shorter than default events. */ + .calendar-theme .fc-daygrid-event.calendar-issue { + padding-block: 0.125rem; + line-height: 1.2; + } + + /* Smaller text for issue pills in month/week cells. */ + .calendar-theme .fc-dayGridMonth-view .fc-daygrid-event, + .calendar-theme .fc-dayGridWeek-view .fc-daygrid-event { + font-size: 0.6875rem; + line-height: 1.2; + padding-block: 0.125rem; + } + + /* Month: even smaller pills (fits a few + “+N more”). */ + .calendar-theme .fc-dayGridMonth-view .fc-daygrid-event, + .calendar-theme .fc-dayGridMonth-view .fc-daygrid-event .fc-event-title { + font-size: 0.625rem; + line-height: 1.15; + font-weight: 500; + } + + .calendar-theme .fc-dayGridMonth-view .fc-daygrid-event.calendar-issue { + padding-block: 0; + padding-inline: 0.1875rem; + line-height: 1.1; + min-height: 0; + } + + .calendar-theme .fc-dayGridWeek-view .fc-daygrid-event.calendar-issue { + line-height: 1.15; + padding-block: 1px; + } + + .calendar-theme + .fc-dayGridMonth-view + .fc-daygrid-event + .issue-calendar-status-dot, + .calendar-theme + .fc-dayGridWeek-view + .fc-daygrid-event + .issue-calendar-status-dot { + width: 0.5rem; + height: 0.5rem; + } + + .calendar-theme + .fc-dayGridMonth-view + .fc-daygrid-event + .issue-calendar-status-dot { + width: 0.375rem; + height: 0.375rem; + } + + .calendar-theme .fc-daygrid-event .fc-event-title { + font-weight: 500; + } + + /* Space between stacked issue rows. */ + .calendar-theme .fc-daygrid-event-harness:has(.fc-event.calendar-issue) { + margin-bottom: 2px; + margin-right: 0.375rem; + } + + .calendar-theme + .fc-dayGridMonth-view + .fc-daygrid-event-harness:has(.fc-event.calendar-issue) { + margin-bottom: 0.1875rem; + margin-right: 0.25rem; + } + + .calendar-theme + .fc-dayGridWeek-view + .fc-daygrid-event-harness:has(.fc-event.calendar-issue) { + margin-bottom: 0.1875rem; + margin-right: 0.25rem; + } + + .fc-more-popover .fc-daygrid-event-harness:has(.fc-event.calendar-issue) { + margin-bottom: 2px; + margin-right: 0.375rem; + } + + .calendar-theme .fc-dayGridMonth-view .fc-daygrid-more-link { + margin-top: 0.125rem; + font-size: 0.625rem; + line-height: 1.2; + } + + /* Timed events sit above the grid lines. */ + .calendar-theme .fc-timegrid-event-harness { + z-index: 1; + } + + .calendar-theme .fc-timegrid-event, + .calendar-theme .fc-v-event { + border-radius: var(--radius); + background-color: hsl(var(--primary)); + border-color: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + } + + .calendar-theme .fc-timegrid-event.calendar-issue, + .calendar-theme .fc-v-event.calendar-issue { + font-size: 0.8125rem; + line-height: 1.2; + } + + /* 30-min slots: hide the extra line between half hours; keep hour lines. */ + .calendar-theme .fc-timegrid-body td.fc-timegrid-slot-minor { + border-top-width: 0 !important; + border-top-style: none !important; + } + + /* Time labels row: no extra line under the whole row. */ + .calendar-theme + .fc-timegrid-body + tr:has(.fc-timegrid-slot-label.fc-scrollgrid-shrink) + > td { + border-bottom-width: 0 !important; + border-bottom-style: none !important; + } + + /* AM/PM in the time column (not the left “all‑day” strip). */ + .calendar-theme .fc-timegrid-slot-label-cushion { + text-transform: uppercase; + } + + .calendar-theme .fc-timeGridWeek-view .fc-timegrid-axis, + .calendar-theme .fc-timeGridDay-view .fc-timegrid-axis { + background-color: hsl(var(--background)); + border-right: 1px solid hsl(var(--border)); + } + + .calendar-theme + .fc-timeGridWeek-view + .fc-timegrid-slots + td.fc-timegrid-slot-label, + .calendar-theme + .fc-timeGridDay-view + .fc-timegrid-slots + td.fc-timegrid-slot-label { + background-color: hsl(var(--background)); + } + + .calendar-theme .fc-timeGridWeek-view .fc-timegrid-slot-label-cushion, + .calendar-theme .fc-timeGridDay-view .fc-timegrid-slot-label-cushion { + padding: 0 0.625rem 0 0.125rem; + font-size: 0.8125rem; + font-weight: 400; + line-height: 1.35; + letter-spacing: 0.02em; + color: hsl(var(--muted-foreground)); + } + + .calendar-theme .fc-timeGridWeek-view .fc-timegrid-axis-cushion, + .calendar-theme .fc-timeGridDay-view .fc-timegrid-axis-cushion { + padding: 0.375rem 0.625rem 0.375rem 0.125rem; + text-align: right; + font-size: 0.75rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); + } + + .calendar-theme .fc-event-time { + display: none; + } + + .calendar-theme .fc-list-event-time { + text-transform: uppercase; + } + + .calendar-theme .fc-daygrid-event-dot { + border-color: hsl(var(--primary-foreground)); + } + + .calendar-theme .fc-daygrid-more-link { + display: inline-block; + margin-top: 0.125rem; + color: hsl(var(--primary)); + font-weight: 500; + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + } + + .calendar-theme .fc-daygrid-more-link:hover { + color: hsl(var(--primary) / 0.9); + } +} + +/* “+N more” popup */ +.calendar-theme .fc-popover, +.calendar-theme .fc-more-popover.fc-popover { + z-index: 60; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius)); + background-color: hsl(var(--popover)); + color: hsl(var(--popover-foreground)); + box-shadow: + 0 4px 6px -1px hsl(0 0% 0% / 0.12), + 0 2px 4px -2px hsl(0 0% 0% / 0.08); +} + +.calendar-theme .fc-popover-header { + padding: 0.5rem 0.75rem; + font-size: 0.75rem; + font-weight: 600; + background-color: hsl(var(--muted) / 0.35); + border-bottom: 1px solid hsl(var(--border)); +} + +.calendar-theme .fc-more-popover .fc-popover-body { + padding: 0.25rem; +} + +.calendar-theme .fc-scrollgrid { + border-color: hsl(var(--border)); + border-top-width: 0 !important; + border-left-width: 0 !important; + overflow: visible; + max-width: 100%; +} + +.calendar-theme .fc-dayGridMonth-view .fc-scroller { + overflow-x: hidden !important; + overflow-y: auto !important; +} + +.calendar-theme .fc-dayGridMonth-view .fc-scroller-harness, +.calendar-theme .fc-dayGridMonth-view .fc-scroller-liquid-absolute { + overflow-x: hidden !important; +} + +.calendar-theme .fc-dayGridMonth-view .fc-view-harness { + min-height: 0; +} + +.calendar-theme .fc-scrollgrid-section, +.calendar-theme .fc-scrollgrid-section > td, +.calendar-theme .fc-scrollgrid-section > th, +.calendar-theme .fc-scrollgrid-sync-table, +.calendar-theme .fc-scrollgrid-sync-table td, +.calendar-theme .fc-scrollgrid-sync-table th, +.calendar-theme .fc-theme-standard td, +.calendar-theme .fc-theme-standard th { + border-color: hsl(var(--border)); +} + +.calendar-theme .fc-scrollgrid-section-sticky > * { + border-color: hsl(var(--border)); +} + +.calendar-theme .fc-scrollgrid td, +.calendar-theme .fc-scrollgrid th { + border-color: hsl(var(--border)); +} + +.calendar-theme .fc-col-header .fc-col-header-cell:last-child, +.calendar-theme .fc-daygrid-body .fc-daygrid-day:last-child { + border-right-width: 1px !important; + border-right-style: solid !important; +} + +.calendar-theme + .fc-timeGridWeek-view + .fc-timegrid-cols + .fc-timegrid-col:last-child { + border-right-width: 1px !important; + border-right-style: solid !important; +} + +.calendar-theme .fc-timeGridDay-view .fc-timegrid-cols .fc-timegrid-col { + border-right-width: 1px !important; + border-right-style: solid !important; +} + +.calendar-theme .fc-dayGridMonth-view .fc-daygrid-day.fc-day-today, +.calendar-theme .fc-dayGridWeek-view .fc-daygrid-day.fc-day-today { + background-color: hsl(var(--primary) / 0.12) !important; +} + +.calendar-theme + .fc-timeGridDay-view + .fc-timegrid-body + .fc-timegrid-col.fc-day-today, +.calendar-theme + .fc-timeGridWeek-view + .fc-timegrid-body + .fc-timegrid-col.fc-day-today { + background-color: transparent !important; +} + +.calendar-theme .fc-timeGridDay-view .fc-col-header-cell.fc-day-today, +.calendar-theme .fc-timeGridWeek-view .fc-col-header-cell.fc-day-today { + background-color: hsl(var(--primary) / 0.12) !important; +} + +.calendar-theme + .fc-timeGridDay-view + .fc-col-header-cell.fc-day-today + .fc-col-header-cell-cushion, +.calendar-theme + .fc-timeGridWeek-view + .fc-col-header-cell.fc-day-today + .fc-col-header-cell-cushion { + color: hsl(var(--primary)); + font-weight: 700; +} + +.calendar-theme .fc-dayGridWeek-view .fc-col-header-cell.fc-day-today { + background-color: hsl(var(--primary) / 0.12) !important; +} + +.calendar-theme + .fc-dayGridWeek-view + .fc-col-header-cell.fc-day-today + .fc-col-header-cell-cushion { + color: hsl(var(--primary)); + font-weight: 700; +} + +/* Issue bar: a bit darker on hover. */ +.calendar-theme .fc-event.calendar-issue:hover { + filter: brightness(0.88); +} + +/* Issue row: status dot + title. */ +.calendar-theme .fc-daygrid-event .calendar-issue-event-main, +.calendar-theme .fc-timegrid-event .calendar-issue-event-main, +.calendar-theme .fc-v-event .calendar-issue-event-main { + display: flex; + align-items: center; + gap: 0.25rem; + min-width: 0; + width: 100%; + overflow: hidden; + line-height: 1.2; +} + +.calendar-theme + .fc-dayGridMonth-view + .fc-daygrid-event + .calendar-issue-event-main { + gap: 0.125rem; + line-height: 1.1; +} + +.calendar-theme .fc-event.calendar-issue--finished .fc-event-title { + text-decoration: line-through; + opacity: 0.88; +} + +.calendar-theme .issue-calendar-status-dot[data-issue-status="BACKLOG"] { + background-color: rgb(148 163 184); +} + +.calendar-theme .issue-calendar-status-dot[data-issue-status="PLANNING"] { + background-color: rgb(245 158 11); +} + +.calendar-theme .issue-calendar-status-dot[data-issue-status="IN_PROGRESS"] { + background-color: rgb(16 185 129); +} + +.calendar-theme .issue-calendar-status-dot[data-issue-status="FINISHED"] { + background-color: rgb(244 63 94); +} diff --git a/apps/blade/src/app/_components/issue-calendar/calendar.tsx b/apps/blade/src/app/_components/issue-calendar/calendar.tsx new file mode 100644 index 000000000..b3389006b --- /dev/null +++ b/apps/blade/src/app/_components/issue-calendar/calendar.tsx @@ -0,0 +1,745 @@ +"use client"; + +import "./calendar.css"; + +import type { + DateSelectArg, + DatesSetArg, + DayHeaderContentArg, + EventClickArg, + EventContentArg, + EventInput, + MoreLinkArg, +} from "@fullcalendar/core"; +import type { DateClickArg } from "@fullcalendar/interaction"; +import { + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import dayGridPlugin from "@fullcalendar/daygrid"; +import interactionPlugin from "@fullcalendar/interaction"; +import FullCalendar from "@fullcalendar/react"; +import { + CheckCircle2, + ChevronLeft, + ChevronRight, + CircleDot, + SlidersHorizontal, +} from "lucide-react"; + +import { ISSUE } from "@forge/consts"; +import { cn } from "@forge/ui"; +import { Button } from "@forge/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@forge/ui/tabs"; +import { toast } from "@forge/ui/toast"; + +import { api } from "~/trpc/react"; +import { CreateEditDialog } from "../issues/create-edit-dialog"; +import { IssueFetcherPane } from "../issues/issue-fetcher-pane"; +import IssueTemplateDialog from "../issues/issue-template-dialog"; +import { IssueDayAgenda } from "./calendar-day-agenda"; +import { CalendarIssueDialog } from "./calendar-issue-dialog"; +import { IssueStatusDotLegend } from "./calendar-status-dot-legend"; + +type CalendarView = "dayGridMonth" | "dayGridWeek" | "issueDayAgenda"; + +type IssueCalendarStatus = ISSUE.IssueFetcherPaneIssue["status"]; + +function issueStatusLabel(status: IssueCalendarStatus) { + return status + .split("_") + .map((w) => w.charAt(0) + w.slice(1).toLowerCase()) + .join(" "); +} + +function startOfLocalDay(isoOrDate: Date): Date { + const d = new Date(isoOrDate); + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} + +function issueCalendarSlot(issueDate: Date): { start: Date; end: Date } { + const start = new Date(issueDate); + const end = new Date(start.getTime() + 30 * 60 * 1000); + return { start, end }; +} + +/** Default task-due time → all-day band in month/week grid (see normalizeTaskDueDate). */ +function isDefaultTaskDueMoment(d: Date): boolean { + return ( + d.getHours() === ISSUE.TASK_DUE_HOURS && + d.getMinutes() === ISSUE.TASK_DUE_MINUTES && + d.getSeconds() === 0 && + d.getMilliseconds() === 0 + ); +} + +function dateFromDataDate(dateStr: string): Date { + const parts = dateStr.split("-"); + if (parts.length !== 3) return new Date(dateStr); + const y = Number(parts[0]); + const m = Number(parts[1]); + const d = Number(parts[2]); + if (![y, m, d].every((n) => Number.isFinite(n))) return new Date(dateStr); + return new Date(y, m - 1, d, 12, 0, 0, 0); +} + +/** FC day-grid header `date` is often UTC-based; local formatting shifts the weekday. `dow` matches the column (0 = Sunday). */ +function weekdayShortFromFullCalendarDow(dow: number): string { + const sun = new Date(2024, 0, 7); + const d = new Date(sun.getFullYear(), sun.getMonth(), sun.getDate() + dow); + return d.toLocaleDateString(undefined, { weekday: "short" }); +} + +function dayNumberFromDayHeaderArg(arg: DayHeaderContentArg): number { + const raw = (arg as { dateStr?: string }).dateStr; + if (typeof raw === "string" && /^\d{4}-\d{2}-\d{2}/.test(raw)) { + return Number(raw.slice(8, 10)); + } + const d = arg.date; + return new Date( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + 12, + 0, + 0, + 0, + ).getDate(); +} + +function elementFromEventTarget(target: EventTarget | null): Element | null { + if (!target) return null; + if (target instanceof Element) return target; + if (target instanceof Text) return target.parentElement; + return null; +} + +function dismissFullCalendarMorePopovers() { + document.querySelectorAll(".fc-more-popover").forEach((el) => { + el.remove(); + }); +} + +function formatStatus(status: string) { + return status + .toLowerCase() + .replace(/_/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +export default function CalendarView() { + const calendarRef = useRef(null); + const calendarSectionRef = useRef(null); + const suppressNextDateSelectRef = useRef(false); + const prevViewRef = useRef("dayGridMonth"); + const [view, setView] = useState("dayGridMonth"); + const [agendaDay, setAgendaDay] = useState(() => startOfLocalDay(new Date())); + const [title, setTitle] = useState("Calendar"); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDetailOpen, setIsDetailOpen] = useState(false); + const [detailIssueId, setDetailIssueId] = useState(null); + const [modalIntent, setModalIntent] = useState<"create" | "edit">("create"); + const [selectedIssueData, setSelectedIssueData] = useState< + Partial + >({}); + const [visibleRange, setVisibleRange] = useState<{ + start: Date; + end: Date; + } | null>(null); + const [isFiltersOpen, setIsFiltersOpen] = useState(false); + const [paneData, setPaneData] = useState( + null, + ); + + const rawPaneIssues = useMemo( + () => paneData?.issues ?? [], + [paneData?.issues], + ); + const deferredPaneIssues = useDeferredValue(rawPaneIssues); + const issuesForCalendar = useMemo(() => { + if (!paneData) return [] as ISSUE.IssueFetcherPaneIssue[]; + if (!paneData.isLoading) { + return rawPaneIssues; + } + if (rawPaneIssues.length > 0) { + return rawPaneIssues; + } + return deferredPaneIssues; + }, [paneData, rawPaneIssues, deferredPaneIssues]); + + const openCount = useMemo( + () => rawPaneIssues.filter((issue) => issue.status !== "FINISHED").length, + [rawPaneIssues], + ); + const closedCount = rawPaneIssues.length - openCount; + + const filters = paneData?.filters; + + const activeFilters = useMemo(() => { + if (!filters) return []; + const tags: string[] = []; + if (filters.statusFilter !== "all") + tags.push(formatStatus(filters.statusFilter)); + if (filters.teamFilter !== "all") tags.push("Team selected"); + if (filters.issueKind !== "all") + tags.push( + filters.issueKind === "task" ? "Tasks only" : "Event-linked only", + ); + if (filters.rootOnly) tags.push("Root only"); + if (filters.dateFrom) tags.push("From " + filters.dateFrom); + if (filters.dateTo) tags.push("To " + filters.dateTo); + if (filters.searchTerm.trim()) + tags.push('Search "' + filters.searchTerm.trim() + '"'); + return tags; + }, [filters]); + + const issuesForCurrentView = useMemo(() => { + if (view === "issueDayAgenda") { + const vs = startOfLocalDay(agendaDay).getTime(); + const ve = vs + 86400000; + return issuesForCalendar.filter((issue) => { + if (!issue.date) return false; + const day = startOfLocalDay(new Date(issue.date)).getTime(); + return day >= vs && day < ve; + }); + } + if (!visibleRange) return issuesForCalendar; + const vs = startOfLocalDay(visibleRange.start).getTime(); + const ve = visibleRange.end.getTime(); + return issuesForCalendar.filter((issue) => { + if (!issue.date) return false; + const day = startOfLocalDay(new Date(issue.date)).getTime(); + return day >= vs && day < ve; + }); + }, [view, agendaDay, visibleRange, issuesForCalendar]); + + const utils = api.useUtils(); + + const deleteIssueMutation = api.issues.deleteIssue.useMutation({ + onSuccess: async () => { + await utils.issues.invalidate(); + paneData?.refresh(); + toast.success("Issue deleted successfully"); + setIsModalOpen(false); + }, + onError: () => { + toast.error("Failed to delete issue"); + }, + }); + + const issueCalendarItems = useMemo(() => { + if (view === "issueDayAgenda") return []; + return issuesForCurrentView.flatMap((issue): EventInput[] => { + if (!issue.date) return []; + const d = new Date(issue.date); + const baseClassNames = [ + "calendar-issue", + issue.event ? "calendar-issue--linked" : "calendar-issue--task", + ...(issue.status === "FINISHED" ? ["calendar-issue--finished"] : []), + ] as string[]; + + const useAllDayBand = !issue.event && isDefaultTaskDueMoment(d); + + if (useAllDayBand) { + return [ + { + id: issue.id, + title: issue.name, + start: startOfLocalDay(d), + allDay: true, + display: "block" as const, + extendedProps: { issueStatus: issue.status }, + classNames: baseClassNames, + }, + ]; + } + + const { start, end } = issueCalendarSlot(d); + return [ + { + id: issue.id, + title: issue.name, + start, + end, + allDay: false, + display: "block" as const, + extendedProps: { issueStatus: issue.status }, + classNames: baseClassNames, + }, + ]; + }); + }, [view, issuesForCurrentView]); + + const fullCalendarViews = useMemo( + () => ({ + dayGridMonth: { + dayMaxEvents: 3, + fixedWeekCount: true, + }, + dayGridWeek: { + dayMaxEvents: false, + }, + }), + [], + ); + + /** “+more” day: prefer data-date / hiddenSegs; arg.date can drift across TZs. */ + const dayDateFromMoreLinkArg = useCallback((arg: MoreLinkArg) => { + const el = elementFromEventTarget(arg.jsEvent.target ?? null); + if (el) { + const dayEl = el.closest(".fc-daygrid-day"); + const ds = dayEl?.getAttribute("data-date"); + if (ds) return dateFromDataDate(ds); + } + const hidden = ( + arg as unknown as { + hiddenSegs?: { eventRange?: { range?: { start?: Date } } }[]; + } + ).hiddenSegs; + const start = hidden?.[0]?.eventRange?.range?.start; + if (start) return startOfLocalDay(start); + return startOfLocalDay(arg.date); + }, []); + + const goToAgendaForDay = useCallback((d: Date) => { + const day = startOfLocalDay(d); + dismissFullCalendarMorePopovers(); + setAgendaDay(day); + setView("issueDayAgenda"); + setVisibleRange({ + start: day, + end: new Date(day.getTime() + 86400000), + }); + }, []); + + const handleMoreLinkClick = useCallback( + (arg: MoreLinkArg) => { + goToAgendaForDay(dayDateFromMoreLinkArg(arg)); + return "none" as const; + }, + [dayDateFromMoreLinkArg, goToAgendaForDay], + ); + + useEffect(() => { + const root = calendarSectionRef.current; + if (!root) return; + + function onMoreLinkClickCapture(e: MouseEvent) { + const section = calendarSectionRef.current; + if (!section) return; + const el = elementFromEventTarget(e.target); + const link = el?.closest(".fc-daygrid-more-link"); + if (!link || !section.contains(link)) return; + const dayEl = link.closest(".fc-daygrid-day"); + const ds = dayEl?.getAttribute("data-date"); + if (!ds) return; + e.preventDefault(); + e.stopPropagation(); + goToAgendaForDay(dateFromDataDate(ds)); + } + + root.addEventListener("click", onMoreLinkClickCapture, true); + return () => { + root.removeEventListener("click", onMoreLinkClickCapture, true); + }; + }, [goToAgendaForDay]); + + useEffect(() => { + if (isModalOpen || isDetailOpen || isFiltersOpen) { + dismissFullCalendarMorePopovers(); + } + }, [isModalOpen, isDetailOpen, isFiltersOpen]); + + useEffect(() => { + const prev = prevViewRef.current; + prevViewRef.current = view; + if (view !== "dayGridMonth" && view !== "dayGridWeek") return; + if (prev !== "issueDayAgenda") return; + requestAnimationFrame(() => { + calendarRef.current?.getApi().gotoDate(agendaDay); + }); + }, [view, agendaDay]); + + const headerCreateInitialValues = useMemo>( + () => ({ + date: view === "issueDayAgenda" ? agendaDay : new Date(), + isEvent: false, + }), + [view, agendaDay], + ); + + function handleDateSelect(selectionInfo: DateSelectArg) { + selectionInfo.view.calendar.unselect(); + + const rangeStart = selectionInfo.start; + // `select` can run before `dateClick` on the same click; defer so `handleDateClick` can set `suppressNextDateSelectRef`. + queueMicrotask(() => { + if (suppressNextDateSelectRef.current) { + suppressNextDateSelectRef.current = false; + return; + } + + dismissFullCalendarMorePopovers(); + setModalIntent("create"); + setSelectedIssueData({ + date: rangeStart, + }); + setIsModalOpen(true); + }); + } + + /** Month: weekday only; week: weekday + day number (matches day agenda). */ + const issueDayHeaderContent = useCallback((arg: DayHeaderContentArg) => { + const wd = weekdayShortFromFullCalendarDow(arg.dow); + if (arg.view.type === "dayGridMonth") { + return { html: wd }; + } + if (arg.view.type === "dayGridWeek") { + return { html: `${wd} ${dayNumberFromDayHeaderArg(arg)}` }; + } + return undefined; + }, []); + + const issueEventContent = useCallback((arg: EventContentArg) => { + if (!arg.event.classNames.includes("calendar-issue")) { + return undefined; + } + const ex = arg.event.extendedProps as { issueStatus?: IssueCalendarStatus }; + const status: IssueCalendarStatus = ex.issueStatus ?? "BACKLOG"; + const statusLabel = issueStatusLabel(status); + return ( +
+ + + {arg.event.title} + +
+ ); + }, []); + + const handleDateClick = useCallback((arg: DateClickArg) => { + const vt = arg.view.type; + if (vt !== "dayGridMonth" && vt !== "dayGridWeek") return; + dismissFullCalendarMorePopovers(); + const d = startOfLocalDay(arg.date); + suppressNextDateSelectRef.current = true; + arg.view.calendar.unselect(); + setAgendaDay(d); + setView("issueDayAgenda"); + setVisibleRange({ + start: d, + end: new Date(d.getTime() + 86400000), + }); + }, []); + + function handleIssueClick(clickInfo: EventClickArg) { + const id = String(clickInfo.event.id); + const issue = issuesForCurrentView.find((i) => i.id === id); + if (!issue) return; + dismissFullCalendarMorePopovers(); + setDetailIssueId(id); + setIsDetailOpen(true); + } + + function handleDatesSet(arg: DatesSetArg) { + const t = arg.view.type; + if (t === "dayGridMonth" || t === "dayGridWeek") { + setView(t); + } + setTitle(arg.view.title); + setVisibleRange({ start: arg.start, end: arg.end }); + } + + function handleViewChange(nextView: string) { + if (nextView === "issueDayAgenda") { + const api = calendarRef.current?.getApi(); + const d = api?.getDate() ?? new Date(); + setAgendaDay(startOfLocalDay(d)); + setView("issueDayAgenda"); + const start = startOfLocalDay(d); + setVisibleRange({ start, end: new Date(start.getTime() + 86400000) }); + return; + } + const typed = nextView as "dayGridMonth" | "dayGridWeek"; + setView(typed); + requestAnimationFrame(() => { + calendarRef.current?.getApi().changeView(typed); + }); + } + + function handleToday() { + if (view === "issueDayAgenda") { + const t = startOfLocalDay(new Date()); + setAgendaDay(t); + setVisibleRange({ + start: t, + end: new Date(t.getTime() + 86400000), + }); + return; + } + calendarRef.current?.getApi().today(); + } + + function handlePrev() { + if (view === "issueDayAgenda") { + setAgendaDay((d) => { + const next = new Date(d); + next.setDate(next.getDate() - 1); + return startOfLocalDay(next); + }); + return; + } + calendarRef.current?.getApi().prev(); + } + + function handleNext() { + if (view === "issueDayAgenda") { + setAgendaDay((d) => { + const next = new Date(d); + next.setDate(next.getDate() + 1); + return startOfLocalDay(next); + }); + return; + } + calendarRef.current?.getApi().next(); + } + + const headingPrimary = useMemo(() => { + if (view === "issueDayAgenda") { + return agendaDay.toLocaleDateString(undefined, { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }); + } + return title; + }, [view, agendaDay, title]); + + const showFullCalendar = view === "dayGridMonth" || view === "dayGridWeek"; + + return ( +
+
+
+
+ + + +

+ {headingPrimary} +

+
+ + + + Month + Week + Day + + +
+ +
+
0 + ? "md:flex-row md:items-start md:justify-between md:gap-6" + : "sm:flex-row sm:items-center sm:justify-between sm:gap-4 md:gap-6", + )} + > +
+
+
+ + {openCount} Open +
+
+ + {closedCount} Closed +
+
+ {activeFilters.length > 0 ? ( +
+ Active filters + {activeFilters.map((tag) => ( + + {tag} + + ))} +
+ ) : null} +
+ +
+ + + + + +
+
+
+
+ +
+ +
+ {showFullCalendar ? ( + ({ + html: `+${arg.num} more`, + })} + height="100%" + stickyHeaderDates={true} + handleWindowResize={true} + selectable={true} + selectMinDistance={10} + select={handleDateSelect} + dateClick={handleDateClick} + eventClick={handleIssueClick} + eventContent={issueEventContent} + dayHeaderContent={issueDayHeaderContent} + selectMirror={true} + /> + ) : ( +
+ { + setDetailIssueId(issueId); + setIsDetailOpen(true); + }} + onIssuesChanged={() => { + void utils.issues.getAllIssues.invalidate(); + void utils.issues.invalidate(); + paneData?.refresh(); + }} + /> +
+ )} +
+
+ + { + setIsDetailOpen(next); + if (!next) setDetailIssueId(null); + }} + onRequestEdit={(values) => { + setModalIntent("edit"); + setSelectedIssueData(values); + setIsModalOpen(true); + }} + /> + + setIsModalOpen(false)} + onSubmit={() => { + setIsModalOpen(false); + void utils.issues.getAllIssues.invalidate(); + void utils.issues.invalidate(); + paneData?.refresh(); + }} + onDelete={(values) => { + if (!values.id || deleteIssueMutation.isPending) return; + deleteIssueMutation.mutate({ id: values.id }); + }} + /> + + +
+ ); +} diff --git a/apps/blade/src/app/issues/calendar/page.tsx b/apps/blade/src/app/issues/calendar/page.tsx new file mode 100644 index 000000000..bd35f7b61 --- /dev/null +++ b/apps/blade/src/app/issues/calendar/page.tsx @@ -0,0 +1,38 @@ +import { notFound, redirect } from "next/navigation"; + +import { auth } from "@forge/auth"; + +import Calendar from "~/app/_components/issue-calendar/calendar"; +import { SessionNavbar } from "~/app/_components/navigation/session-navbar"; +import { SIGN_IN_PATH } from "~/consts"; +import { api, HydrateClient } from "~/trpc/server"; + +export default async function Events() { + const session = await auth(); + if (!session) { + redirect(SIGN_IN_PATH); + } + + const hasAccess = await api.roles.hasPermission({ + and: [ + "READ_ISSUES", + "EDIT_ISSUES", + "EDIT_ISSUE_TEMPLATES", + "READ_ISSUE_TEMPLATES", + ], + }); + if (!hasAccess) notFound(); + + return ( + +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d3256b82..b78f92e46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,7 +141,7 @@ importers: version: 10.0.2(jiti@2.6.1) eslint-config-next: specifier: ^16.0.0 - version: 16.1.6(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.6(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) postcss: specifier: ^8.5.6 version: 8.5.6 @@ -202,6 +202,18 @@ importers: '@forge/validators': specifier: workspace:* version: link:../../packages/validators + '@fullcalendar/core': + specifier: ^6.1.20 + version: 6.1.20 + '@fullcalendar/daygrid': + specifier: ^6.1.20 + version: 6.1.20(@fullcalendar/core@6.1.20) + '@fullcalendar/interaction': + specifier: ^6.1.20 + version: 6.1.20(@fullcalendar/core@6.1.20) + '@fullcalendar/react': + specifier: ^6.1.20 + version: 6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-email/render': specifier: ^2.0.0 version: 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -243,7 +255,7 @@ importers: version: 6.6.0 geist: specifier: ^1.7.0 - version: 1.7.0(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 1.7.0(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) google-auth-library: specifier: ^10.6.1 version: 10.6.1 @@ -370,7 +382,7 @@ importers: version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) geist: specifier: ^1.7.0 - version: 1.7.0(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 1.7.0(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) gsap: specifier: ^3.14.2 version: 3.14.2 @@ -552,7 +564,7 @@ importers: version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) geist: specifier: ^1.7.0 - version: 1.7.0(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 1.7.0(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) gsap: specifier: ^3.14.2 version: 3.14.2 @@ -646,7 +658,7 @@ importers: version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) geist: specifier: ^1.7.0 - version: 1.7.0(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 1.7.0(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) gsap: specifier: ^3.14.2 version: 3.14.2 @@ -1271,7 +1283,7 @@ importers: version: 16.1.6 eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1)) + version: 2.32.0(eslint@10.0.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: specifier: ^6.10.2 version: 6.10.2(eslint@10.0.2(jiti@2.6.1)) @@ -2732,6 +2744,26 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@fullcalendar/core@6.1.20': + resolution: {integrity: sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==} + + '@fullcalendar/daygrid@6.1.20': + resolution: {integrity: sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==} + peerDependencies: + '@fullcalendar/core': ~6.1.20 + + '@fullcalendar/interaction@6.1.20': + resolution: {integrity: sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==} + peerDependencies: + '@fullcalendar/core': ~6.1.20 + + '@fullcalendar/react@6.1.20': + resolution: {integrity: sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==} + peerDependencies: + '@fullcalendar/core': ~6.1.20 + react: ^19.2.4 + react-dom: ^19.2.4 + '@gsap/react@2.1.2': resolution: {integrity: sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==} peerDependencies: @@ -6001,7 +6033,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -7178,6 +7210,9 @@ packages: preact@10.11.3: resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==} + preact@10.12.1: + resolution: {integrity: sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==} + preact@10.24.3: resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} @@ -10010,6 +10045,24 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@fullcalendar/core@6.1.20': + dependencies: + preact: 10.12.1 + + '@fullcalendar/daygrid@6.1.20(@fullcalendar/core@6.1.20)': + dependencies: + '@fullcalendar/core': 6.1.20 + + '@fullcalendar/interaction@6.1.20(@fullcalendar/core@6.1.20)': + dependencies: + '@fullcalendar/core': 6.1.20 + + '@fullcalendar/react@6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@fullcalendar/core': 6.1.20 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@gsap/react@2.1.2(gsap@3.14.2)(react@19.2.4)': dependencies: gsap: 3.14.2 @@ -13050,13 +13103,13 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.1.6(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.1.6(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.1.6 eslint: 10.0.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@10.0.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@10.0.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@10.0.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@10.0.2(jiti@2.6.1)) @@ -13078,7 +13131,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@10.0.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -13089,31 +13142,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.0.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) eslint: 10.0.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@10.0.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.2(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - eslint: 10.0.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.2(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -13124,7 +13168,7 @@ snapshots: doctrine: 2.1.0 eslint: 10.0.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.0.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -13142,7 +13186,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(eslint@10.0.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -13153,7 +13197,7 @@ snapshots: doctrine: 2.1.0 eslint: 10.0.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -13462,7 +13506,7 @@ snapshots: transitivePeerDependencies: - supports-color - geist@1.7.0(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + geist@1.7.0(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): dependencies: next: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -14917,6 +14961,8 @@ snapshots: preact@10.11.3: {} + preact@10.12.1: {} + preact@10.24.3: {} prelude-ls@1.2.1: {}