Skip to content

Commit 4079970

Browse files
committed
Show speaker availability in schedule builder
Shortcake-Parent: main
1 parent 62a0f4e commit 4079970

6 files changed

Lines changed: 255 additions & 12 deletions

File tree

backend/conferences/admin/conference.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from django.forms.models import ModelForm
1010
from django.shortcuts import redirect, render
1111
from django.urls import path, reverse
12+
from django.utils.html import format_html
1213
from django.utils.translation import gettext_lazy as _
1314
from ordered_model.admin import (
1415
OrderedInlineModelAdminMixin,
@@ -114,7 +115,7 @@ class ConferenceAdmin(
114115
"code",
115116
)
116117
list_filter = ("organizer",)
117-
readonly_fields = ("created", "modified")
118+
readonly_fields = ("created", "modified", "schedule_builder_link")
118119
filter_horizontal = (
119120
"topics",
120121
"languages",
@@ -127,6 +128,7 @@ class ConferenceAdmin(
127128
"Details",
128129
{
129130
"fields": (
131+
"schedule_builder_link",
130132
"organizer",
131133
"name",
132134
"code",
@@ -189,6 +191,13 @@ class ConferenceAdmin(
189191
)
190192
inlines = [DeadlineInline, DurationInline, SponsorLevelInline, IncludedEventInline]
191193

194+
@admin.display(description="Schedule Builder")
195+
def schedule_builder_link(self, obj):
196+
if not obj.pk:
197+
return "Save the conference first to access the schedule builder."
198+
url = reverse("admin:schedule_builder", kwargs={"object_id": obj.pk})
199+
return format_html('<a href="{}" class="button">Open Schedule Builder</a>', url)
200+
192201
def get_urls(self):
193202
return [
194203
path(

backend/custom_admin/src/components/fragments/submission.graphql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,8 @@ fragment SubmissionFragment on Submission {
1818
speaker {
1919
id
2020
fullName
21+
participant {
22+
speakerAvailabilities
23+
}
2124
}
2225
}

backend/custom_admin/src/components/schedule-builder/add-item-modal/proposal-preview.tsx

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,63 @@
11
import type { Language } from "../../../types";
22
import type { SubmissionFragmentFragment } from "../../fragments/submission.generated";
3+
import type { AvailabilityValue } from "../../utils/availability";
4+
import { getSlotAvailabilityKey } from "../../utils/availability";
35
import { useCurrentConference } from "../../utils/conference";
46
import { useAddItemModal } from "./context";
57
import { useCreateScheduleItemMutation } from "./create-schedule-item.generated";
68
import { InfoRecap } from "./info-recap";
79

10+
const AVAILABILITY_STYLES: Record<
11+
AvailabilityValue,
12+
{ label: string; className: string }
13+
> = {
14+
preferred: {
15+
label: "Preferred",
16+
className: "bg-green-200 text-green-900 font-semibold",
17+
},
18+
available: { label: "Available", className: "bg-blue-100 text-blue-900" },
19+
unavailable: {
20+
label: "Unavailable",
21+
className: "bg-red-200 text-red-900 font-semibold",
22+
},
23+
};
24+
825
type Props = {
926
proposal: SubmissionFragmentFragment;
1027
};
28+
1129
export const ProposalPreview = ({ proposal }: Props) => {
30+
const { data } = useAddItemModal();
31+
32+
const availabilityKey =
33+
data?.day?.day && data?.slot?.hour
34+
? getSlotAvailabilityKey(data.day.day, data.slot.hour)
35+
: null;
36+
37+
const availabilities: Record<string, AvailabilityValue> =
38+
proposal.speaker?.participant?.speakerAvailabilities ?? {};
39+
40+
const slotAvailability = availabilityKey
41+
? availabilities[availabilityKey]
42+
: undefined;
43+
1244
return (
1345
<li className="p-2 bg-slate-300 odd:bg-slate-200">
14-
<strong>{proposal.title}</strong>
15-
{proposal.italianTitle !== proposal.title && (
16-
<div>{proposal.italianTitle}</div>
17-
)}
46+
<div className="flex items-start justify-between gap-2">
47+
<div>
48+
<strong>{proposal.title}</strong>
49+
{proposal.italianTitle !== proposal.title && (
50+
<div>{proposal.italianTitle}</div>
51+
)}
52+
</div>
53+
{slotAvailability && (
54+
<span
55+
className={`shrink-0 text-xs px-2 py-0.5 rounded ${AVAILABILITY_STYLES[slotAvailability].className}`}
56+
>
57+
{AVAILABILITY_STYLES[slotAvailability].label}
58+
</span>
59+
)}
60+
</div>
1861

1962
<InfoRecap
2063
info={[

backend/custom_admin/src/components/schedule-builder/calendar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export const Calendar = ({ day }: Props) => {
8585
item={item}
8686
rooms={rooms}
8787
rowStart={rowStart}
88+
date={date}
8889
/>
8990
))}
9091
</Fragment>

backend/custom_admin/src/components/schedule-builder/item.tsx

Lines changed: 181 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,128 @@
11
import { useDrag } from "react-dnd";
22

3-
import { Button } from "@radix-ui/themes";
3+
import { Button, Tooltip } from "@radix-ui/themes";
4+
import type { ScheduleItemFragmentFragment } from "../fragments/schedule-item.generated";
45
import { useDjangoAdminEditor } from "../shared/django-admin-editor-modal/context";
6+
import type { AvailabilityValue } from "../utils/availability";
7+
import { getSlotAvailabilityKey } from "../utils/availability";
58
import { convertHoursToMinutes } from "../utils/time";
69

7-
export const Item = ({ slots, slot, item, rooms, rowStart }) => {
10+
// Only the primary speaker's availability is checked. Co-speakers are not asked
11+
// for availability in the CFP form, so item.speakers is intentionally ignored here.
12+
function getSpeakerAvailability(
13+
item: ScheduleItemFragmentFragment,
14+
date: string,
15+
slotHour: string,
16+
): AvailabilityValue | null {
17+
const availabilities =
18+
item.proposal?.speaker?.participant?.speakerAvailabilities;
19+
if (!availabilities) return null;
20+
return availabilities[getSlotAvailabilityKey(date, slotHour)] ?? null;
21+
}
22+
23+
const AVAILABILITY_BADGE: Record<
24+
AvailabilityValue,
25+
{ bg: string; text: string; label: string }
26+
> = {
27+
preferred: { bg: "#dcfce7", text: "#15803d", label: "★ Preferred" },
28+
available: { bg: "#dbeafe", text: "#1d4ed8", label: "✓ Available" },
29+
unavailable: { bg: "#fee2e2", text: "#b91c1c", label: "✗ Unavailable" },
30+
};
31+
32+
function AvailabilityBadge({
33+
value,
34+
}: { value: AvailabilityValue | undefined }) {
35+
if (!value) return <span style={{ color: "#9ca3af", fontSize: 11 }}></span>;
36+
const { bg, text, label } = AVAILABILITY_BADGE[value];
37+
return (
38+
<span
39+
style={{
40+
background: bg,
41+
color: text,
42+
fontSize: 11,
43+
fontWeight: 600,
44+
padding: "2px 7px",
45+
borderRadius: 999,
46+
whiteSpace: "nowrap",
47+
}}
48+
>
49+
{label}
50+
</span>
51+
);
52+
}
53+
54+
function formatDate(dateStr: string) {
55+
const d = new Date(`${dateStr}T00:00:00`);
56+
return d.toLocaleDateString("en-GB", { month: "short", day: "numeric" });
57+
}
58+
59+
function AvailabilityTooltipContent({
60+
availabilities,
61+
}: { availabilities: Record<string, string> }) {
62+
const byDate: Record<
63+
string,
64+
{ am?: AvailabilityValue; pm?: AvailabilityValue }
65+
> = {};
66+
for (const [key, value] of Object.entries(availabilities)) {
67+
const [date, period] = key.split("@");
68+
if (!byDate[date]) byDate[date] = {};
69+
byDate[date][period as "am" | "pm"] = value as AvailabilityValue;
70+
}
71+
const dates = Object.keys(byDate).sort();
72+
if (dates.length === 0) return <span>No availability data</span>;
73+
74+
return (
75+
<div style={{ minWidth: 220, padding: "8px 4px" }}>
76+
<div
77+
style={{
78+
fontWeight: 700,
79+
fontSize: 12,
80+
marginBottom: 8,
81+
letterSpacing: "0.05em",
82+
textTransform: "uppercase",
83+
opacity: 0.7,
84+
}}
85+
>
86+
Speaker availability (half-day)
87+
</div>
88+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
89+
{dates.map((date) => (
90+
<div
91+
key={date}
92+
style={{
93+
display: "grid",
94+
gridTemplateColumns: "60px 1fr 1fr",
95+
alignItems: "center",
96+
gap: 8,
97+
}}
98+
>
99+
<span style={{ fontSize: 12, fontWeight: 600, opacity: 0.85 }}>
100+
{formatDate(date)}
101+
</span>
102+
<AvailabilityBadge value={byDate[date].am} />
103+
<AvailabilityBadge value={byDate[date].pm} />
104+
</div>
105+
))}
106+
</div>
107+
</div>
108+
);
109+
}
110+
111+
export const Item = ({
112+
slots,
113+
slot,
114+
item,
115+
rooms,
116+
rowStart,
117+
date,
118+
}: {
119+
slots: any[];
120+
slot: any;
121+
item: ScheduleItemFragmentFragment;
122+
rooms: any[];
123+
rowStart: number;
124+
date: string;
125+
}) => {
8126
const roomIndexes = item.rooms
9127
.map((room) => rooms.findIndex((r) => r.id === room.id))
10128
.sort();
@@ -41,12 +159,53 @@ export const Item = ({ slots, slot, item, rooms, rowStart }) => {
41159
}}
42160
className="z-50 bg-slate-200"
43161
>
44-
<ScheduleItemCard item={item} duration={duration} />
162+
<ScheduleItemCard
163+
item={item}
164+
duration={duration}
165+
date={date}
166+
slotHour={slot.hour}
167+
/>
45168
</div>
46169
);
47170
};
48171

49-
export const ScheduleItemCard = ({ item, duration }) => {
172+
function SpeakerNames({ item }: { item: ScheduleItemFragmentFragment }) {
173+
const speakerNames = item.speakers.map((s) => s.fullname).join(", ");
174+
const availabilities =
175+
item.proposal?.speaker?.participant?.speakerAvailabilities;
176+
const hasAvailabilities =
177+
availabilities && Object.keys(availabilities).length > 0;
178+
179+
if (!hasAvailabilities) {
180+
return <span>{speakerNames}</span>;
181+
}
182+
183+
return (
184+
<Tooltip
185+
content={<AvailabilityTooltipContent availabilities={availabilities} />}
186+
>
187+
<span style={{ cursor: "help", borderBottom: "1px dotted currentColor" }}>
188+
{speakerNames}
189+
</span>
190+
</Tooltip>
191+
);
192+
}
193+
194+
export const ScheduleItemCard = ({
195+
item,
196+
duration,
197+
date = null,
198+
slotHour = null,
199+
}: {
200+
item: ScheduleItemFragmentFragment;
201+
duration: number | null;
202+
date?: string | null;
203+
slotHour?: string | null;
204+
}) => {
205+
const availability =
206+
date && slotHour ? getSpeakerAvailability(item, date, slotHour) : null;
207+
const availabilities =
208+
item.proposal?.speaker?.participant?.speakerAvailabilities ?? {};
50209
const [{ opacity }, dragRef] = useDrag(
51210
() => ({
52211
type: "scheduleItem",
@@ -68,6 +227,23 @@ export const ScheduleItemCard = ({ item, duration }) => {
68227

69228
return (
70229
<ul className="bg-slate-200 p-3" ref={dragRef}>
230+
{availability === "unavailable" && (
231+
<li className="mb-2 flex items-center gap-1.5 bg-amber-100 text-amber-800 border border-amber-300 text-xs font-semibold px-2 py-1 rounded">
232+
<span>⚠ Speaker unavailable</span>
233+
<Tooltip
234+
content={
235+
<AvailabilityTooltipContent availabilities={availabilities} />
236+
}
237+
>
238+
<span
239+
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full bg-amber-400 text-amber-900 cursor-help leading-none"
240+
style={{ fontSize: 9, fontStyle: "italic", fontFamily: "serif" }}
241+
>
242+
i
243+
</span>
244+
</Tooltip>
245+
</li>
246+
)}
71247
<li>
72248
[{item.type} - {duration || "??"} mins]
73249
</li>
@@ -77,9 +253,7 @@ export const ScheduleItemCard = ({ item, duration }) => {
77253
</li>
78254
{item.speakers.length > 0 && (
79255
<li>
80-
<span>
81-
{item.speakers.map((speaker) => speaker.fullname).join(",")}
82-
</span>
256+
<SpeakerNames item={item} />
83257
</li>
84258
)}
85259
<li className="pt-2">
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export type AvailabilityValue = "preferred" | "available" | "unavailable";
2+
3+
// Availability is stored at half-day granularity: "am" (before 12:00) or "pm" (12:00 and after).
4+
// A slot at 09:00 and one at 11:30 map to the same "am" bucket. The badge reflects the
5+
// half-day preference, not the exact start time of the slot.
6+
export function getSlotAvailabilityKey(
7+
dayDate: string,
8+
slotHour: string,
9+
): string {
10+
const hour = Number.parseInt(slotHour.split(":")[0], 10);
11+
const period = hour < 12 ? "am" : "pm";
12+
return `${dayDate}@${period}`;
13+
}

0 commit comments

Comments
 (0)