Skip to content

Commit f9802b7

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

6 files changed

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

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

69207
return (
70208
<ul className="bg-slate-200 p-3" ref={dragRef}>
209+
{availability === "unavailable" && (
210+
<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">
211+
<span>⚠ Speaker unavailable</span>
212+
<Tooltip
213+
content={
214+
<AvailabilityTooltipContent availabilities={availabilities} />
215+
}
216+
>
217+
<span
218+
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"
219+
style={{ fontSize: 9, fontStyle: "italic", fontFamily: "serif" }}
220+
>
221+
i
222+
</span>
223+
</Tooltip>
224+
</li>
225+
)}
71226
<li>
72227
[{item.type} - {duration || "??"} mins]
73228
</li>
@@ -77,9 +232,7 @@ export const ScheduleItemCard = ({ item, duration }) => {
77232
</li>
78233
{item.speakers.length > 0 && (
79234
<li>
80-
<span>
81-
{item.speakers.map((speaker) => speaker.fullname).join(",")}
82-
</span>
235+
<SpeakerNames item={item} />
83236
</li>
84237
)}
85238
<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)