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
11 changes: 10 additions & 1 deletion backend/conferences/admin/conference.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from django.forms.models import ModelForm
from django.shortcuts import redirect, render
from django.urls import path, reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from ordered_model.admin import (
OrderedInlineModelAdminMixin,
Expand Down Expand Up @@ -114,7 +115,7 @@ class ConferenceAdmin(
"code",
)
list_filter = ("organizer",)
readonly_fields = ("created", "modified")
readonly_fields = ("created", "modified", "schedule_builder_link")
filter_horizontal = (
"topics",
"languages",
Expand All @@ -127,6 +128,7 @@ class ConferenceAdmin(
"Details",
{
"fields": (
"schedule_builder_link",
"organizer",
"name",
"code",
Expand Down Expand Up @@ -189,6 +191,13 @@ class ConferenceAdmin(
)
inlines = [DeadlineInline, DurationInline, SponsorLevelInline, IncludedEventInline]

@admin.display(description="Schedule Builder")
def schedule_builder_link(self, obj):
if not obj.pk:
return "Save the conference first to access the schedule builder."
url = reverse("admin:schedule_builder", kwargs={"object_id": obj.pk})
return format_html('<a href="{}" class="button">Open Schedule Builder</a>', url)

def get_urls(self):
return [
path(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,8 @@ fragment SubmissionFragment on Submission {
speaker {
id
fullName
participant {
speakerAvailabilities
}
}
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,63 @@
import type { Language } from "../../../types";
import type { SubmissionFragmentFragment } from "../../fragments/submission.generated";
import type { AvailabilityValue } from "../../utils/availability";
import { getSlotAvailabilityKey } from "../../utils/availability";
import { useCurrentConference } from "../../utils/conference";
import { useAddItemModal } from "./context";
import { useCreateScheduleItemMutation } from "./create-schedule-item.generated";
import { InfoRecap } from "./info-recap";

const AVAILABILITY_STYLES: Record<
AvailabilityValue,
{ label: string; className: string }
> = {
preferred: {
label: "Preferred",
className: "bg-green-200 text-green-900 font-semibold",
},
available: { label: "Available", className: "bg-blue-100 text-blue-900" },
unavailable: {
label: "Unavailable",
className: "bg-red-200 text-red-900 font-semibold",
},
};

type Props = {
proposal: SubmissionFragmentFragment;
};

export const ProposalPreview = ({ proposal }: Props) => {
const { data } = useAddItemModal();

const availabilityKey =
data?.day?.day && data?.slot?.hour
? getSlotAvailabilityKey(data.day.day, data.slot.hour)
: null;

const availabilities: Record<string, AvailabilityValue> =
proposal.speaker?.participant?.speakerAvailabilities ?? {};

const slotAvailability = availabilityKey
? availabilities[availabilityKey]
: undefined;

return (
<li className="p-2 bg-slate-300 odd:bg-slate-200">
<strong>{proposal.title}</strong>
{proposal.italianTitle !== proposal.title && (
<div>{proposal.italianTitle}</div>
)}
<div className="flex items-start justify-between gap-2">
<div>
<strong>{proposal.title}</strong>
{proposal.italianTitle !== proposal.title && (
<div>{proposal.italianTitle}</div>
)}
</div>
{slotAvailability && (
<span
className={`shrink-0 text-xs px-2 py-0.5 rounded ${AVAILABILITY_STYLES[slotAvailability].className}`}
>
{AVAILABILITY_STYLES[slotAvailability].label}
</span>
)}
</div>

<InfoRecap
info={[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const Calendar = ({ day }: Props) => {
item={item}
rooms={rooms}
rowStart={rowStart}
date={date}
/>
))}
</Fragment>
Expand Down
188 changes: 181 additions & 7 deletions backend/custom_admin/src/components/schedule-builder/item.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,128 @@
import { useDrag } from "react-dnd";

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

export const Item = ({ slots, slot, item, rooms, rowStart }) => {
// Only the primary speaker's availability is checked. Co-speakers are not asked
// for availability in the CFP form, so item.speakers is intentionally ignored here.
function getSpeakerAvailability(
item: ScheduleItemFragmentFragment,
date: string,
slotHour: string,
): AvailabilityValue | null {
const availabilities =
item.proposal?.speaker?.participant?.speakerAvailabilities;
if (!availabilities) return null;
return availabilities[getSlotAvailabilityKey(date, slotHour)] ?? null;
}

const AVAILABILITY_BADGE: Record<
AvailabilityValue,
{ bg: string; text: string; label: string }
> = {
preferred: { bg: "#dcfce7", text: "#15803d", label: "★ Preferred" },
available: { bg: "#dbeafe", text: "#1d4ed8", label: "✓ Available" },
unavailable: { bg: "#fee2e2", text: "#b91c1c", label: "✗ Unavailable" },
};

function AvailabilityBadge({
value,
}: { value: AvailabilityValue | undefined }) {
if (!value) return <span style={{ color: "#9ca3af", fontSize: 11 }}>—</span>;
const { bg, text, label } = AVAILABILITY_BADGE[value];
return (
<span
style={{
background: bg,
color: text,
fontSize: 11,
fontWeight: 600,
padding: "2px 7px",
borderRadius: 999,
whiteSpace: "nowrap",
}}
>
{label}
</span>
);
}

function formatDate(dateStr: string) {
const d = new Date(`${dateStr}T00:00:00`);
return d.toLocaleDateString("en-GB", { month: "short", day: "numeric" });
}

function AvailabilityTooltipContent({
availabilities,
}: { availabilities: Record<string, string> }) {
const byDate: Record<
string,
{ am?: AvailabilityValue; pm?: AvailabilityValue }
> = {};
for (const [key, value] of Object.entries(availabilities)) {
const [date, period] = key.split("@");
if (!byDate[date]) byDate[date] = {};
byDate[date][period as "am" | "pm"] = value as AvailabilityValue;
}
const dates = Object.keys(byDate).sort();
if (dates.length === 0) return <span>No availability data</span>;

return (
<div style={{ minWidth: 220, padding: "8px 4px" }}>
<div
style={{
fontWeight: 700,
fontSize: 12,
marginBottom: 8,
letterSpacing: "0.05em",
textTransform: "uppercase",
opacity: 0.7,
}}
>
Speaker availability (half-day)
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{dates.map((date) => (
<div
key={date}
style={{
display: "grid",
gridTemplateColumns: "60px 1fr 1fr",
alignItems: "center",
gap: 8,
}}
>
<span style={{ fontSize: 12, fontWeight: 600, opacity: 0.85 }}>
{formatDate(date)}
</span>
<AvailabilityBadge value={byDate[date].am} />
<AvailabilityBadge value={byDate[date].pm} />
</div>
))}
</div>
</div>
);
}

export const Item = ({
slots,
slot,
item,
rooms,
rowStart,
date,
}: {
slots: any[];
slot: any;
item: ScheduleItemFragmentFragment;
rooms: any[];
rowStart: number;
date: string;
}) => {
const roomIndexes = item.rooms
.map((room) => rooms.findIndex((r) => r.id === room.id))
.sort();
Expand Down Expand Up @@ -41,12 +159,53 @@ export const Item = ({ slots, slot, item, rooms, rowStart }) => {
}}
className="z-50 bg-slate-200"
>
<ScheduleItemCard item={item} duration={duration} />
<ScheduleItemCard
item={item}
duration={duration}
date={date}
slotHour={slot.hour}
/>
</div>
);
};

export const ScheduleItemCard = ({ item, duration }) => {
function SpeakerNames({ item }: { item: ScheduleItemFragmentFragment }) {
const speakerNames = item.speakers.map((s) => s.fullname).join(", ");
const availabilities =
item.proposal?.speaker?.participant?.speakerAvailabilities;
const hasAvailabilities =
availabilities && Object.keys(availabilities).length > 0;

if (!hasAvailabilities) {
return <span>{speakerNames}</span>;
}

return (
<Tooltip
content={<AvailabilityTooltipContent availabilities={availabilities} />}
>
<span style={{ cursor: "help", borderBottom: "1px dotted currentColor" }}>
{speakerNames}
</span>
</Tooltip>
);
}

export const ScheduleItemCard = ({
item,
duration,
date = null,
slotHour = null,
}: {
item: ScheduleItemFragmentFragment;
duration: number | null;
date?: string | null;
slotHour?: string | null;
}) => {
const availability =
date && slotHour ? getSpeakerAvailability(item, date, slotHour) : null;
const availabilities =
item.proposal?.speaker?.participant?.speakerAvailabilities ?? {};
const [{ opacity }, dragRef] = useDrag(
() => ({
type: "scheduleItem",
Expand All @@ -68,6 +227,23 @@ export const ScheduleItemCard = ({ item, duration }) => {

return (
<ul className="bg-slate-200 p-3" ref={dragRef}>
{availability === "unavailable" && (
<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">
<span>⚠ Speaker unavailable</span>
<Tooltip
content={
<AvailabilityTooltipContent availabilities={availabilities} />
}
>
<span
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"
style={{ fontSize: 9, fontStyle: "italic", fontFamily: "serif" }}
>
i
</span>
</Tooltip>
</li>
)}
<li>
[{item.type} - {duration || "??"} mins]
</li>
Expand All @@ -77,9 +253,7 @@ export const ScheduleItemCard = ({ item, duration }) => {
</li>
{item.speakers.length > 0 && (
<li>
<span>
{item.speakers.map((speaker) => speaker.fullname).join(",")}
</span>
<SpeakerNames item={item} />
</li>
)}
<li className="pt-2">
Expand Down
13 changes: 13 additions & 0 deletions backend/custom_admin/src/components/utils/availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export type AvailabilityValue = "preferred" | "available" | "unavailable";

// Availability is stored at half-day granularity: "am" (before 12:00) or "pm" (12:00 and after).
// A slot at 09:00 and one at 11:30 map to the same "am" bucket. The badge reflects the
// half-day preference, not the exact start time of the slot.
export function getSlotAvailabilityKey(
dayDate: string,
slotHour: string,
): string {
const hour = Number.parseInt(slotHour.split(":")[0], 10);
const period = hour < 12 ? "am" : "pm";
return `${dayDate}@${period}`;
}
Loading