11import { 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" ;
45import { useDjangoAdminEditor } from "../shared/django-admin-editor-modal/context" ;
6+ import type { AvailabilityValue } from "../utils/availability" ;
7+ import { getSlotAvailabilityKey } from "../utils/availability" ;
58import { 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" >
0 commit comments