Skip to content

Commit 93c5d80

Browse files
Blade/issue calendar page (#440)
Co-authored-by: FernandoAilon <fernandoailon26@yahoo.com>
1 parent 88c8a12 commit 93c5d80

8 files changed

Lines changed: 2101 additions & 28 deletions

File tree

apps/blade/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
"@forge/ui": "workspace:*",
2929
"@forge/utils": "workspace:*",
3030
"@forge/validators": "workspace:*",
31+
"@fullcalendar/core": "^6.1.20",
32+
"@fullcalendar/daygrid": "^6.1.20",
33+
"@fullcalendar/interaction": "^6.1.20",
34+
"@fullcalendar/react": "^6.1.20",
3135
"@react-email/render": "^2.0.0",
3236
"@stripe/react-stripe-js": "^5.6.0",
3337
"@stripe/stripe-js": "^8.8.0",
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
"use client";
2+
3+
import { useCallback } from "react";
4+
import { AlertCircle, Copy, Pencil, User, Users } from "lucide-react";
5+
6+
import type { ISSUE } from "@forge/consts";
7+
import { cn } from "@forge/ui";
8+
import { Button } from "@forge/ui/button";
9+
import { toast } from "@forge/ui/toast";
10+
11+
import { CreateEditDialog } from "~/app/_components/issues/create-edit-dialog";
12+
import { api } from "~/trpc/react";
13+
14+
type Issue = ISSUE.IssueFetcherPaneIssue;
15+
16+
function startOfLocalDay(d: Date) {
17+
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
18+
}
19+
20+
function teamLabels(
21+
issue: Issue,
22+
roleNameById: Map<string, string> | undefined,
23+
): string[] {
24+
const ids = [issue.team, ...issue.teamVisibility.map((t) => t.teamId)];
25+
const unique = [...new Set(ids)];
26+
const labels = unique
27+
.map((id) => roleNameById?.get(id))
28+
.filter((label): label is string => Boolean(label?.trim()));
29+
return labels;
30+
}
31+
32+
function formatTeamLabel(roleName: string) {
33+
const trimmed = roleName.replace(/\s+team$/i, "").trim();
34+
return trimmed || roleName;
35+
}
36+
37+
function assigneeDisplayNames(issue: Issue): string[] {
38+
const rows = issue.userAssignments as unknown as {
39+
user?: { name?: string | null; discordUserId?: string | null };
40+
}[];
41+
return rows
42+
.map((a) => {
43+
const n = a.user?.name?.trim();
44+
if (n) return n;
45+
const d = a.user?.discordUserId?.trim();
46+
return d ?? "";
47+
})
48+
.filter(Boolean);
49+
}
50+
51+
function isOverdueIssue(issue: Issue) {
52+
if (issue.status === "FINISHED" || !issue.date) return false;
53+
const dueDate = new Date(issue.date);
54+
const todayStart = new Date();
55+
todayStart.setHours(0, 0, 0, 0);
56+
return dueDate < todayStart;
57+
}
58+
59+
function issueStatusForAria(status: Issue["status"]) {
60+
return status
61+
.split("_")
62+
.map((w) => w.charAt(0) + w.slice(1).toLowerCase())
63+
.join(" ");
64+
}
65+
66+
export function IssueDayAgenda(props: {
67+
day: Date;
68+
issues: Issue[];
69+
isLoading: boolean;
70+
roleNameById: Map<string, string> | undefined;
71+
onIssueSelect?: (issueId: string) => void;
72+
onIssuesChanged?: () => void;
73+
}) {
74+
const {
75+
day,
76+
issues,
77+
isLoading,
78+
roleNameById,
79+
onIssueSelect,
80+
onIssuesChanged,
81+
} = props;
82+
83+
const utils = api.useUtils();
84+
const deleteIssueMutation = api.issues.deleteIssue.useMutation({
85+
onSuccess: async () => {
86+
await utils.issues.invalidate();
87+
await utils.issues.getAllIssues.invalidate();
88+
onIssuesChanged?.();
89+
toast.success("Issue deleted");
90+
},
91+
onError: () => {
92+
toast.error("Failed to delete issue");
93+
},
94+
});
95+
96+
const handleSubmitEdit = useCallback(async () => {
97+
await utils.issues.invalidate();
98+
await utils.issues.getAllIssues.invalidate();
99+
onIssuesChanged?.();
100+
}, [onIssuesChanged, utils.issues]);
101+
102+
const copyIssueLink = useCallback((issueId: string) => {
103+
const origin = typeof window !== "undefined" ? window.location.origin : "";
104+
const url = `${origin}/issues/${issueId}`;
105+
void navigator.clipboard.writeText(url).then(
106+
() => {
107+
toast.success("Issue link copied");
108+
},
109+
() => {
110+
toast.error("Could not copy link");
111+
},
112+
);
113+
}, []);
114+
115+
const isToday =
116+
startOfLocalDay(day).getTime() === startOfLocalDay(new Date()).getTime();
117+
const weekdayShort = day.toLocaleDateString(undefined, {
118+
weekday: "short",
119+
});
120+
const dayOfMonth = day.getDate();
121+
122+
const header = (
123+
<div
124+
className={cn(
125+
"issue-agenda-day-header shrink-0 border-b border-border px-4 py-2 text-center text-xs font-medium uppercase tracking-wide",
126+
isToday
127+
? "bg-primary/12 font-bold text-primary"
128+
: "bg-muted/30 text-muted-foreground",
129+
)}
130+
>
131+
{weekdayShort} {dayOfMonth}
132+
</div>
133+
);
134+
135+
if (isLoading) {
136+
return (
137+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
138+
{header}
139+
<div className="px-4 py-10 text-sm text-muted-foreground">
140+
Loading issues…
141+
</div>
142+
</div>
143+
);
144+
}
145+
146+
if (issues.length === 0) {
147+
return (
148+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
149+
{header}
150+
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-4 py-10 text-center text-sm text-muted-foreground">
151+
Nothing due on this day.
152+
</div>
153+
</div>
154+
);
155+
}
156+
157+
const sorted = [...issues].sort((a, b) => {
158+
const ta = a.date ? +new Date(a.date) : 0;
159+
const tb = b.date ? +new Date(b.date) : 0;
160+
return ta - tb;
161+
});
162+
163+
return (
164+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
165+
{header}
166+
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3 sm:px-4">
167+
<ul className="list-none space-y-3">
168+
{sorted.map((issue) => {
169+
const overdue = isOverdueIssue(issue);
170+
const teams = teamLabels(issue, roleNameById).map(formatTeamLabel);
171+
const teamsText = teams.join(" · ");
172+
const showTeamsBlock = teamsText.length > 0;
173+
const assigneeNames = assigneeDisplayNames(issue);
174+
const assigneesText =
175+
assigneeNames.length > 0
176+
? assigneeNames.join(" · ")
177+
: "Unassigned";
178+
179+
return (
180+
<li
181+
key={issue.id}
182+
className="rounded-xl border border-border bg-card/80 px-4 py-3.5 shadow-sm ring-1 ring-border/40"
183+
>
184+
<div className="flex min-h-8 items-center justify-between gap-3">
185+
<div className="flex min-w-0 flex-1 items-center gap-2.5">
186+
<span
187+
className="issue-calendar-status-dot size-2.5 shrink-0 self-center rounded-full ring-1 ring-border/60"
188+
data-issue-status={issue.status}
189+
aria-hidden
190+
/>
191+
{onIssueSelect ? (
192+
<button
193+
type="button"
194+
className="min-w-0 flex-1 cursor-pointer text-left text-base font-semibold leading-snug tracking-tight text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
195+
title={issueStatusForAria(issue.status)}
196+
aria-label={`View details: ${issue.name}`}
197+
onClick={() => onIssueSelect(issue.id)}
198+
>
199+
{issue.name}
200+
</button>
201+
) : (
202+
<h3
203+
className="min-w-0 flex-1 text-base font-semibold leading-snug tracking-tight text-foreground"
204+
title={issueStatusForAria(issue.status)}
205+
>
206+
{issue.name}
207+
</h3>
208+
)}
209+
</div>
210+
<div className="flex shrink-0 items-center gap-1 self-center">
211+
<Button
212+
type="button"
213+
variant="outline"
214+
size="icon"
215+
className="size-8 shrink-0"
216+
aria-label={`Copy link to ${issue.name}`}
217+
onClick={() => copyIssueLink(issue.id)}
218+
>
219+
<Copy className="h-4 w-4" aria-hidden />
220+
</Button>
221+
<CreateEditDialog
222+
intent="edit"
223+
initialValues={{
224+
id: issue.id,
225+
status: issue.status,
226+
name: issue.name,
227+
description: issue.description,
228+
links: issue.links ?? [],
229+
date: issue.date ?? undefined,
230+
priority: issue.priority,
231+
team: issue.team,
232+
parent: issue.parent ?? undefined,
233+
isEvent: issue.event !== null,
234+
event: issue.event,
235+
}}
236+
onSubmit={handleSubmitEdit}
237+
onDelete={(values) => {
238+
if (!values.id || deleteIssueMutation.isPending) return;
239+
deleteIssueMutation.mutate({ id: values.id });
240+
}}
241+
>
242+
<Button
243+
type="button"
244+
variant="outline"
245+
size="icon"
246+
className="size-8 shrink-0"
247+
aria-label={`Edit ${issue.name}`}
248+
>
249+
<Pencil className="h-4 w-4" aria-hidden />
250+
</Button>
251+
</CreateEditDialog>
252+
</div>
253+
</div>
254+
255+
<div className="mt-3 space-y-2 border-t border-border/70 pt-3">
256+
{overdue ? (
257+
<div
258+
className="flex items-center gap-1.5 text-xs font-medium text-red-900 dark:text-red-400"
259+
role="status"
260+
>
261+
<AlertCircle
262+
className="size-3.5 shrink-0 opacity-90"
263+
aria-hidden
264+
/>
265+
<span>Past due</span>
266+
</div>
267+
) : null}
268+
269+
{showTeamsBlock ? (
270+
<div className="flex items-start gap-1.5 text-xs text-muted-foreground">
271+
<Users
272+
className="mt-0.5 size-3.5 shrink-0 opacity-80"
273+
aria-hidden
274+
/>
275+
<span className="min-w-0 leading-relaxed">
276+
{teamsText}
277+
</span>
278+
</div>
279+
) : null}
280+
281+
<div className="flex items-start gap-1.5 text-xs text-muted-foreground">
282+
<User
283+
className="mt-0.5 size-3.5 shrink-0 opacity-80"
284+
aria-hidden
285+
/>
286+
<div className="min-w-0 leading-relaxed">
287+
<p className="text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground">
288+
Assignees
289+
</p>
290+
<p className="mt-0.5 text-muted-foreground">
291+
{assigneesText}
292+
</p>
293+
</div>
294+
</div>
295+
</div>
296+
</li>
297+
);
298+
})}
299+
</ul>
300+
</div>
301+
</div>
302+
);
303+
}

0 commit comments

Comments
 (0)