Skip to content

Commit a2a7d37

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

6 files changed

Lines changed: 230 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: 159 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,111 @@
11
import { useDrag } from "react-dnd";
22

3-
import { Button } from "@radix-ui/themes";
3+
import { Button, Tooltip } from "@radix-ui/themes";
44
import { useDjangoAdminEditor } from "../shared/django-admin-editor-modal/context";
5+
import type { AvailabilityValue } from "../utils/availability";
6+
import { getSlotAvailabilityKey } from "../utils/availability";
57
import { convertHoursToMinutes } from "../utils/time";
68

7-
export const Item = ({ slots, slot, item, rooms, rowStart }) => {
9+
function getSpeakerAvailability(
10+
item,
11+
date: string,
12+
slotHour: string,
13+
): AvailabilityValue | null {
14+
const availabilities =
15+
item.proposal?.speaker?.participant?.speakerAvailabilities;
16+
if (!availabilities) return null;
17+
return availabilities[getSlotAvailabilityKey(date, slotHour)] ?? null;
18+
}
19+
20+
const AVAILABILITY_BADGE: Record<
21+
AvailabilityValue,
22+
{ bg: string; text: string; label: string }
23+
> = {
24+
preferred: { bg: "#dcfce7", text: "#15803d", label: "★ Preferred" },
25+
available: { bg: "#dbeafe", text: "#1d4ed8", label: "✓ Available" },
26+
unavailable: { bg: "#fee2e2", text: "#b91c1c", label: "✗ Unavailable" },
27+
};
28+
29+
function AvailabilityBadge({
30+
value,
31+
}: { value: AvailabilityValue | undefined }) {
32+
if (!value) return <span style={{ color: "#9ca3af", fontSize: 11 }}></span>;
33+
const { bg, text, label } = AVAILABILITY_BADGE[value];
34+
return (
35+
<span
36+
style={{
37+
background: bg,
38+
color: text,
39+
fontSize: 11,
40+
fontWeight: 600,
41+
padding: "2px 7px",
42+
borderRadius: 999,
43+
whiteSpace: "nowrap",
44+
}}
45+
>
46+
{label}
47+
</span>
48+
);
49+
}
50+
51+
function formatDate(dateStr: string) {
52+
const d = new Date(`${dateStr}T00:00:00`);
53+
return d.toLocaleDateString("en-GB", { month: "short", day: "numeric" });
54+
}
55+
56+
function AvailabilityTooltipContent({
57+
availabilities,
58+
}: { availabilities: Record<string, string> }) {
59+
const byDate: Record<
60+
string,
61+
{ am?: AvailabilityValue; pm?: AvailabilityValue }
62+
> = {};
63+
for (const [key, value] of Object.entries(availabilities)) {
64+
const [date, period] = key.split("@");
65+
if (!byDate[date]) byDate[date] = {};
66+
byDate[date][period as "am" | "pm"] = value as AvailabilityValue;
67+
}
68+
const dates = Object.keys(byDate).sort();
69+
if (dates.length === 0) return <span>No availability data</span>;
70+
71+
return (
72+
<div style={{ minWidth: 220, padding: "8px 4px" }}>
73+
<div
74+
style={{
75+
fontWeight: 700,
76+
fontSize: 12,
77+
marginBottom: 8,
78+
letterSpacing: "0.05em",
79+
textTransform: "uppercase",
80+
opacity: 0.7,
81+
}}
82+
>
83+
Speaker availability
84+
</div>
85+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
86+
{dates.map((date) => (
87+
<div
88+
key={date}
89+
style={{
90+
display: "grid",
91+
gridTemplateColumns: "60px 1fr 1fr",
92+
alignItems: "center",
93+
gap: 8,
94+
}}
95+
>
96+
<span style={{ fontSize: 12, fontWeight: 600, opacity: 0.85 }}>
97+
{formatDate(date)}
98+
</span>
99+
<AvailabilityBadge value={byDate[date].am} />
100+
<AvailabilityBadge value={byDate[date].pm} />
101+
</div>
102+
))}
103+
</div>
104+
</div>
105+
);
106+
}
107+
108+
export const Item = ({ slots, slot, item, rooms, rowStart, date }) => {
8109
const roomIndexes = item.rooms
9110
.map((room) => rooms.findIndex((r) => r.id === room.id))
10111
.sort();
@@ -41,12 +142,48 @@ export const Item = ({ slots, slot, item, rooms, rowStart }) => {
41142
}}
42143
className="z-50 bg-slate-200"
43144
>
44-
<ScheduleItemCard item={item} duration={duration} />
145+
<ScheduleItemCard
146+
item={item}
147+
duration={duration}
148+
date={date}
149+
slotHour={slot.hour}
150+
/>
45151
</div>
46152
);
47153
};
48154

49-
export const ScheduleItemCard = ({ item, duration }) => {
155+
function SpeakerNames({ item }: { item }) {
156+
const speakerNames = item.speakers.map((s) => s.fullname).join(", ");
157+
const availabilities =
158+
item.proposal?.speaker?.participant?.speakerAvailabilities;
159+
const hasAvailabilities =
160+
availabilities && Object.keys(availabilities).length > 0;
161+
162+
if (!hasAvailabilities) {
163+
return <span>{speakerNames}</span>;
164+
}
165+
166+
return (
167+
<Tooltip
168+
content={<AvailabilityTooltipContent availabilities={availabilities} />}
169+
>
170+
<span style={{ cursor: "help", borderBottom: "1px dotted currentColor" }}>
171+
{speakerNames}
172+
</span>
173+
</Tooltip>
174+
);
175+
}
176+
177+
export const ScheduleItemCard = ({
178+
item,
179+
duration,
180+
date = null,
181+
slotHour = null,
182+
}) => {
183+
const availability =
184+
date && slotHour ? getSpeakerAvailability(item, date, slotHour) : null;
185+
const availabilities =
186+
item.proposal?.speaker?.participant?.speakerAvailabilities ?? {};
50187
const [{ opacity }, dragRef] = useDrag(
51188
() => ({
52189
type: "scheduleItem",
@@ -68,6 +205,23 @@ export const ScheduleItemCard = ({ item, duration }) => {
68205

69206
return (
70207
<ul className="bg-slate-200 p-3" ref={dragRef}>
208+
{availability === "unavailable" && (
209+
<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">
210+
<span>⚠ Speaker unavailable</span>
211+
<Tooltip
212+
content={
213+
<AvailabilityTooltipContent availabilities={availabilities} />
214+
}
215+
>
216+
<span
217+
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"
218+
style={{ fontSize: 9, fontStyle: "italic", fontFamily: "serif" }}
219+
>
220+
i
221+
</span>
222+
</Tooltip>
223+
</li>
224+
)}
71225
<li>
72226
[{item.type} - {duration || "??"} mins]
73227
</li>
@@ -77,9 +231,7 @@ export const ScheduleItemCard = ({ item, duration }) => {
77231
</li>
78232
{item.speakers.length > 0 && (
79233
<li>
80-
<span>
81-
{item.speakers.map((speaker) => speaker.fullname).join(",")}
82-
</span>
234+
<SpeakerNames item={item} />
83235
</li>
84236
)}
85237
<li className="pt-2">
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export type AvailabilityValue = "preferred" | "available" | "unavailable";
2+
3+
export function getSlotAvailabilityKey(
4+
dayDate: string,
5+
slotHour: string,
6+
): string {
7+
const hour = Number.parseInt(slotHour.split(":")[0], 10);
8+
const period = hour < 12 ? "am" : "pm";
9+
return `${dayDate}@${period}`;
10+
}

0 commit comments

Comments
 (0)