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
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function assigneeDisplayNames(issue: Issue): string[] {
}

function isOverdueIssue(issue: Issue) {
if (issue.status === "FINISHED" || !issue.date) return false;
if (issue.status === "Finished" || !issue.date) return false;
const dueDate = new Date(issue.date);
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
Expand All @@ -68,6 +68,7 @@ export function IssueDayAgenda(props: {
issues: Issue[];
isLoading: boolean;
roleNameById: Map<string, string> | undefined;
roleColorById: Map<string, string | null> | undefined;
onIssueSelect?: (issueId: string) => void;
onIssuesChanged?: () => void;
}) {
Expand All @@ -76,6 +77,7 @@ export function IssueDayAgenda(props: {
issues,
isLoading,
roleNameById,
roleColorById,
onIssueSelect,
onIssuesChanged,
} = props;
Expand All @@ -101,7 +103,7 @@ export function IssueDayAgenda(props: {

const copyIssueLink = useCallback((issueId: string) => {
const origin = typeof window !== "undefined" ? window.location.origin : "";
const url = `${origin}/issues/${issueId}`;
const url = `${origin}/admin/issues/${issueId}`;
void navigator.clipboard.writeText(url).then(
() => {
toast.success("Issue link copied");
Expand Down Expand Up @@ -171,6 +173,7 @@ export function IssueDayAgenda(props: {
const teamsText = teams.join(" · ");
const showTeamsBlock = teamsText.length > 0;
const assigneeNames = assigneeDisplayNames(issue);
const teamColor = roleColorById?.get(issue.team) ?? null;
const assigneesText =
assigneeNames.length > 0
? assigneeNames.join(" · ")
Expand All @@ -180,6 +183,14 @@ export function IssueDayAgenda(props: {
<li
key={issue.id}
className="rounded-xl border border-border bg-card/80 px-4 py-3.5 shadow-sm ring-1 ring-border/40"
style={
teamColor
? {
borderLeftColor: teamColor,
borderLeftWidth: 4,
}
: undefined
}
>
<div className="flex min-h-8 items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function isIssueOverdue(
status: GetIssueResult["status"],
date: Date | string | null | undefined,
) {
if (status === "FINISHED" || !date) return false;
if (status === "Finished" || !date) return false;
const dueDate = new Date(date);
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
Expand Down Expand Up @@ -113,7 +113,7 @@ export function CalendarIssueDialog({

async function handleCopyIssueUrl() {
if (!issue || typeof window === "undefined") return;
const url = `${window.location.origin}/issues/${issue.id}`;
const url = `${window.location.origin}/admin/issues/${issue.id}`;
try {
await navigator.clipboard.writeText(url);
toast.success("Issue link copied");
Expand Down Expand Up @@ -146,7 +146,7 @@ export function CalendarIssueDialog({
asChild
>
<Link
href={`/issues/${issue.id}`}
href={`/admin/issues/${issue.id}`}
className="block text-left text-foreground underline-offset-4 hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{issue.name}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ 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",
Backlog: "Backlog",
Planning: "Planning",
"In Progress": "In Progress",
Finished: "Finished",
};

export function IssueStatusDotLegend() {
Expand Down
145 changes: 43 additions & 102 deletions apps/blade/src/app/_components/issue-calendar/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,20 @@ import {
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 { ChevronLeft, ChevronRight } 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 {
getActiveIssueFilterTags,
IssueViewControlBar,
} from "../issues/issue-view-control-bar";
import { IssueDayAgenda } from "./calendar-day-agenda";
import { CalendarIssueDialog } from "./calendar-issue-dialog";
import { IssueStatusDotLegend } from "./calendar-status-dot-legend";
Expand All @@ -56,6 +52,16 @@ function issueStatusLabel(status: IssueCalendarStatus) {
.join(" ");
}

function calendarEventTextColor(backgroundColor: string) {
const hex = backgroundColor.replace("#", "");
if (!/^[0-9a-fA-F]{6}$/.test(hex)) return "#ffffff";
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq >= 160 ? "#111827" : "#ffffff";
}

function startOfLocalDay(isoOrDate: Date): Date {
const d = new Date(isoOrDate);
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
Expand Down Expand Up @@ -124,13 +130,6 @@ function dismissFullCalendarMorePopovers() {
});
}

function formatStatus(status: string) {
return status
.toLowerCase()
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
}

export default function CalendarView() {
const calendarRef = useRef<FullCalendar | null>(null);
const calendarSectionRef = useRef<HTMLElement | null>(null);
Expand Down Expand Up @@ -172,30 +171,16 @@ export default function CalendarView() {
}, [paneData, rawPaneIssues, deferredPaneIssues]);

const openCount = useMemo(
() => rawPaneIssues.filter((issue) => issue.status !== "FINISHED").length,
() => 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]);
return getActiveIssueFilterTags(filters, paneData?.roleNameById);
}, [filters, paneData?.roleNameById]);

const issuesForCurrentView = useMemo(() => {
if (view === "issueDayAgenda") {
Expand Down Expand Up @@ -236,10 +221,18 @@ export default function CalendarView() {
return issuesForCurrentView.flatMap((issue): EventInput[] => {
if (!issue.date) return [];
const d = new Date(issue.date);
const teamColor = paneData?.roleColorById.get(issue.team) ?? null;
const eventPalette = teamColor
? {
backgroundColor: teamColor,
borderColor: teamColor,
textColor: calendarEventTextColor(teamColor),
}
: {};
const baseClassNames = [
"calendar-issue",
issue.event ? "calendar-issue--linked" : "calendar-issue--task",
...(issue.status === "FINISHED" ? ["calendar-issue--finished"] : []),
...(issue.status === "Finished" ? ["calendar-issue--finished"] : []),
] as string[];

const useAllDayBand = !issue.event && isDefaultTaskDueMoment(d);
Expand All @@ -254,6 +247,7 @@ export default function CalendarView() {
display: "block" as const,
extendedProps: { issueStatus: issue.status },
classNames: baseClassNames,
...eventPalette,
},
];
}
Expand All @@ -269,10 +263,11 @@ export default function CalendarView() {
display: "block" as const,
extendedProps: { issueStatus: issue.status },
classNames: baseClassNames,
...eventPalette,
},
];
});
}, [view, issuesForCurrentView]);
}, [paneData?.roleColorById, view, issuesForCurrentView]);

const fullCalendarViews = useMemo(
() => ({
Expand Down Expand Up @@ -409,7 +404,7 @@ export default function CalendarView() {
return undefined;
}
const ex = arg.event.extendedProps as { issueStatus?: IssueCalendarStatus };
const status: IssueCalendarStatus = ex.issueStatus ?? "BACKLOG";
const status: IssueCalendarStatus = ex.issueStatus ?? "Backlog";
const statusLabel = issueStatusLabel(status);
return (
<div
Expand Down Expand Up @@ -532,8 +527,18 @@ export default function CalendarView() {
return (
<section
ref={calendarSectionRef}
className="calendar-theme mx-auto flex min-h-0 w-full min-w-0 max-w-6xl flex-1 flex-col gap-3 py-1"
className="calendar-theme mx-auto flex min-h-0 w-full min-w-0 max-w-7xl flex-1 flex-col gap-4 py-4"
>
<IssueViewControlBar
openCount={openCount}
closedCount={closedCount}
activeFilters={activeFilters}
createInitialValues={headerCreateInitialValues}
onBeforeCreate={dismissFullCalendarMorePopovers}
onBeforeOpenFilters={dismissFullCalendarMorePopovers}
onOpenFilters={() => setIsFiltersOpen(true)}
/>

<div className="flex shrink-0 flex-col gap-3">
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 flex-wrap items-center gap-2">
Expand Down Expand Up @@ -578,71 +583,6 @@ export default function CalendarView() {
</TabsList>
</Tabs>
</div>

<div className="rounded-lg border border-border bg-muted/20 px-3 py-2.5">
<div
className={cn(
"flex flex-col gap-3",
activeFilters.length > 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",
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<CircleDot className="h-4 w-4 shrink-0 text-emerald-500" />
<span>{openCount} Open</span>
</div>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span>{closedCount} Closed</span>
</div>
</div>
{activeFilters.length > 0 ? (
<div className="flex min-w-0 flex-wrap gap-2 border-t border-border/60 pt-2">
<span className="sr-only">Active filters</span>
{activeFilters.map((tag) => (
<span
key={tag}
className="shrink-0 rounded-full border border-border bg-background/80 px-2.5 py-1 text-xs text-muted-foreground"
>
{tag}
</span>
))}
</div>
) : null}
</div>

<div className="flex shrink-0 flex-wrap items-center gap-2 md:justify-end">
<CreateEditDialog
intent="create"
initialValues={headerCreateInitialValues}
>
<Button
type="button"
onClick={() => {
dismissFullCalendarMorePopovers();
}}
>
Create issue
</Button>
</CreateEditDialog>
<IssueTemplateDialog />
<Button
type="button"
variant="outline"
onClick={() => {
dismissFullCalendarMorePopovers();
setIsFiltersOpen(true);
}}
>
<SlidersHorizontal className="mr-2 h-4 w-4" />
Filters
</Button>
</div>
</div>
</div>
</div>

<div className="relative z-0 flex min-h-0 min-w-0 flex-1 flex-col rounded-lg border border-border bg-card shadow-sm">
Expand Down Expand Up @@ -688,6 +628,7 @@ export default function CalendarView() {
issues={issuesForCurrentView}
isLoading={paneData?.isLoading ?? true}
roleNameById={paneData?.roleNameById}
roleColorById={paneData?.roleColorById}
onIssueSelect={(issueId: string) => {
setDetailIssueId(issueId);
setIsDetailOpen(true);
Expand Down
Loading
Loading