spring updates, timeslots

This commit is contained in:
Peter Howell 2025-12-09 22:58:35 +00:00
parent 725547faa8
commit 4d40559205
4 changed files with 175 additions and 134 deletions

View File

@ -1,5 +1,5 @@
<?php
$MY_TITLE = "Events, Training, & Workshops";
$MY_CRUMB = "Activities";
$CONTENT = '<activitylist :static="0" :itineraryview="false" zoom_on="0" survey_on="0"></activitylist>';
$MY_TITLE = "Schedule & Sessions";
$MY_CRUMB = "Schedule & Sessions";
$CONTENT = '<activitylist :itineraryview="1" :show_all_sessions="true"></activitylist>';
include 'layout.php';

View File

@ -1,5 +1,5 @@
<?php
$MY_TITLE = "Itinerary";
$MY_TITLE = "Schedule & Sessions";
$MY_CRUMB = "My Schedule";
$CONTENT = "<activitylist :itineraryview='1'></activitylist>";
$CONTENT = '<activitylist :itineraryview="1" :show_all_sessions="true"></activitylist>';
include 'layout.php';

View File

@ -856,11 +856,11 @@ const ActivityEditor = Vue.component('activityedit', {
//
//
const ActivityList = Vue.component('activitylist', {
props: [ 'itineraryview','static' ],
props: [ 'itineraryview','static','show_all_sessions' ],
data: function () {
return { activities:[], mysessions:[], search:'', sortby:'starttime', reversed:false, my_ses_ids:[], my_host_ids:[],
show_filters: 'all', expanded: 1, editing: -1, active:-1, hosts:{},
hosts_by_sesid: {}, options:{}, conference:{}, ay:{}, conf:-1, survey_on:0, zoom_on:1, } },
show_filters: 'all', expanded: 1, editing: -1, active:-1, hosts:{}, selectedSlotKey: null,
hosts_by_sesid: {}, options:{}, conference:{}, ay:{}, conf:-1, survey_on:0, zoom_on:1, cancelingIds:[], } },
mounted: function() {
this.fetch_myevents()
},
@ -890,7 +890,22 @@ const ActivityList = Vue.component('activitylist', {
para.classList.toggle('line-clamp-2');
btn.textContent = para.classList.contains('line-clamp-2') ? 'Show More' : 'Show Less';
},
addTime: function(time, x) {
slotKeyFromStart: function(starttime) {
if (!starttime) { return null; }
return this.$root.$dj(starttime).format('YYYY-MM-DDTHH:mm');
},
slotSelection: function(slot) {
var self = this;
var list = this.filtered(this.mysessions, { applySearch: false, applySlot: false });
return _.filter(list, function(s) { return self.slotKeyFromStart(s.starttime) === slot.key; });
},
selectSlot: function(slot) {
if (this.selectedSlotKey === slot.key) { this.selectedSlotKey = null; return; }
this.selectedSlotKey = slot.key;
this.search = '';
},
clearSlot: function() { this.selectedSlotKey = null; },
addTime: function(time, x) {
x = parseInt(x)
let [y, m, d, h, i, s] = time.split(/[- :]/); // split time into parts
let dt = new Date(y, m-1, d, h, i, s); // create Date object
@ -949,16 +964,38 @@ const ActivityList = Vue.component('activitylist', {
},
dumpme: function(id) {
var self = this
if (!self.cancelingIds.includes(id)) { self.cancelingIds.push(id) }
basic_get('dir_api.php?a=signdown/' + id,
function(r2) {
self.mysessions = _.without( self.mysessions, _.findWhere(self.activities, {'id':id}))
self.my_ses_ids = _.without( self.my_ses_ids, id)
self.$forceUpdate()
alert_message("Removed activity") })
setTimeout(function() {
self.mysessions = _.without( self.mysessions, _.findWhere(self.activities, {'id':id}))
self.my_ses_ids = _.without( self.my_ses_ids, id)
self.cancelingIds = _.without(self.cancelingIds, id)
self.$forceUpdate()
alert_message("Removed activity")
}, 250)
})
},
filtered: function(ff) {
sessionsForSlot: function(slot) {
var self=this;
return _.chain(this.activities_for_slots)
.filter(function(item) { return self.slotKeyFromStart(item.starttime) === slot.key; })
.sortBy('starttime')
.value();
},
chooseSession: function(activity) {
var self=this;
if (!self.my_ses_ids.includes(activity.id)) {
self.joinme(activity.id);
}
self.clearSlot();
},
isCanceling: function(id) { return this.cancelingIds.includes(id) },
filtered: function(ff, opts = { applySearch: true, applySlot: false }) {
var self = this
if (this.search) {
var applySearch = (opts.applySearch !== false)
var applySlot = (opts.applySlot !== false)
if (applySearch && this.search) {
var ss = self.search.toLowerCase()
ff = ff.filter(function(x) { return ('searchable' in x ? x.searchable.includes(ss) : 0) })
}
@ -971,6 +1008,9 @@ const ActivityList = Vue.component('activitylist', {
ff = ff.filter( function(item,index) {
this_time = dayjs(item.starttime)
return this_time.isBefore(end) && start.isBefore(this_time) } )
if (this.selectedSlotKey && applySlot) {
ff = ff.filter(function(item) { return self.slotKeyFromStart(item.starttime) === self.selectedSlotKey })
}
ff = _.sortBy(ff, function(x) {
if (x[self.sortby]) { var s = x[self.sortby]; return s.trim().toLowerCase() }
return '' })
@ -984,7 +1024,33 @@ const ActivityList = Vue.component('activitylist', {
current_time: function() { return dayjs().format('MMM D, h:mma') },
activities_filtered: function() { var a = this.filtered(this.activities); return a; },
mysessions_filtered: function() { return this.filtered(this.mysessions) },
activities_for_slots: function() { return this.filtered(this.activities, { applySearch: false, applySlot: false }) },
timeSlotsByDay: function() {
if (this.active < 1) { return {}; }
var self=this;
var grouped = _.groupBy(this.activities_for_slots, function(x) { return self.month_year(x.starttime) });
return _.mapObject(grouped, function(list) {
return _.chain(list)
.sortBy('starttime')
.map(function(item) {
var start = self.$root.$dj(item.starttime);
return {
key: self.slotKeyFromStart(item.starttime),
startLabel: start.format('h:mma'),
endLabel: self.addTime(item.starttime, item.length),
dayLabel: self.month_year(item.starttime)
};
})
.uniq(false, function(slot) { return slot.key; })
.value();
});
},
selectedSlotLabel: function() {
if (!this.selectedSlotKey) { return '' }
var m = dayjs(this.selectedSlotKey);
if (!m.isValid()) { return '' }
return m.format('MMM D, h:mma');
},
activities_g_filtered: function() { if (this.active<1) { return {} }
var self=this;
return _.groupBy(self.activities_filtered, function(x) { return self.month_year(x.starttime) } ); },
@ -1003,6 +1069,83 @@ const ActivityList = Vue.component('activitylist', {
</p>
</div>
<!-- Time slots view -->
<div v-if="itineraryview && active > 0" class="mb-8">
<div v-if="Object.keys(timeSlotsByDay).length === 0" class="bg-white border rounded p-4 text-sm text-gray-700">
No session times are available yet.
</div>
<div v-for="(slots, mmyy) in timeSlotsByDay" :key="mmyy" class="mb-6">
<h4 class="text-lg font-semibold text-gray-700 mb-2">{{ mmyy }} {{ get_day_title(mmyy) }}</h4>
<div class="space-y-3">
<div v-for="slot in slots" :key="slot.key" :class="['border rounded-lg p-4 bg-white shadow-sm flex items-start gap-4', selectedSlotKey === slot.key ? 'ring-2 ring-blue-400' : '']">
<div class="w-32 text-right">
<div class="text-2xl font-extrabold text-blue-800 leading-tight">{{ slot.startLabel }}</div>
<div class="text-sm text-gray-500">to {{ slot.endLabel }}</div>
<span class="block text-xs text-gray-500 mt-1" v-if="selectedSlotKey === slot.key">Selected</span>
</div>
<div class="flex-1">
<div v-if="slotSelection(slot).length" class="space-y-2">
<div v-for="sel in slotSelection(slot)" :key="sel.id" :class="['border-b last:border-none pb-2 last:pb-0 transition-opacity duration-300', isCanceling(sel.id) ? 'opacity-40' : '']">
<div class="font-semibold text-gray-900">{{ sel.title }}</div>
<div class="text-sm text-gray-600">
{{ $root.$dj(sel.starttime).format('h:mma') }} · {{ mode_string(sel) }}
</div>
<button v-if="!my_host_ids.includes(sel.id)"
class="mt-2 text-sm text-red-600 hover:underline"
@click.prevent="dumpme(sel.id)">Cancel</button>
</div>
<button class="mt-1 text-sm text-blue-600 hover:underline" @click="selectSlot(slot)">Change selection</button>
</div>
<div v-else class="mt-1">
<button class="w-full border-2 border-dashed border-blue-300 text-blue-700 font-semibold rounded-md py-3 hover:bg-blue-50 text-left px-4" @click="selectSlot(slot)">
Pick a session
</button>
</div>
<transition name="slotpanel">
<div v-if="selectedSlotKey === slot.key" class="mt-4 border rounded-lg bg-blue-50 border-blue-200 p-4 shadow-inner">
<div class="flex items-start justify-between gap-3 mb-3">
<div>
<div class="text-sm text-gray-600">Pick for {{ slot.startLabel }} - {{ slot.endLabel }}</div>
<div class="text-base font-semibold text-gray-800">Available sessions</div>
</div>
<button class="text-sm text-gray-600 hover:text-gray-800" @click="clearSlot">X</button>
</div>
<div v-if="sessionsForSlot(slot).length" class="space-y-3 max-h-72 overflow-y-auto pr-1">
<div v-for="a in sessionsForSlot(slot)" :key="a.id" class="bg-white rounded border border-gray-200 p-3 shadow-sm">
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<div class="font-semibold text-gray-900">{{ a.title }}</div>
<div class="text-xs uppercase text-gray-500">{{ mode_string(a) }}</div>
<div class="text-sm text-gray-600 mt-1">
<span v-if="a.mode === 'hybrid'">In person at {{ a.location_irl }} or online</span>
<span v-if="a.mode === 'inperson'">In person at {{ a.location_irl }}</span>
<span v-if="a.mode === 'online'">Online</span>
</div>
</div>
<div class="flex items-center gap-2">
<button v-if="!my_ses_ids.includes(a.id)"
class="px-3 py-1 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700"
@click.prevent="chooseSession(a)">
Sign Up
</button>
<span v-else class="text-sm text-green-700 font-semibold">In itinerary</span>
</div>
</div>
</div>
</div>
<div v-else class="text-sm text-gray-700">No sessions are available in this time slot.</div>
</div>
</transition>
</div>
</div>
</div>
</div>
</div>
<!-- No sessions signed up -->
<div v-if="itineraryview && mysessions_filtered.length === 0" class="mb-6">
<p class="font-semibold text-gray-800">It looks like you haven't signed up for any sessions yet!</p>
@ -1011,128 +1154,10 @@ const ActivityList = Vue.component('activitylist', {
</p>
</div>
<!-- Grouped sessions by date -->
<div v-if="itineraryview && active > 0" v-for="(items, mmyy) in mysessions_g_filtered" :key="mmyy" class="mb-8">
<h3 class="text-xl font-bold text-gray-800 mb-2">{{ mmyy }} {{ get_day_title(mmyy) }}</h3>
<div v-for="a in items" :key="a.id" class="bg-white rounded-lg shadow p-4 mb-6 border-l-4 border-blue-200">
<div class="flex flex-col md:flex-row md:justify-between md:items-start gap-4">
<!-- Left side controls -->
<div class="flex flex-wrap gap-2 md:flex-col md:min-w-[100px]">
<a v-if="my_host_ids.includes(a.id)"
:href="'ed_act.php?w=' + a.id"
class="text-sm text-blue-600 hover:underline">Edit</a>
<a v-if="my_host_ids.includes(a.id)"
:href="'report.php?s=' + a.id"
class="text-sm text-blue-600 hover:underline">Info</a>
</div>
<!-- Session info -->
<div class="flex-grow relative">
<h2 class="text-base font-semibold text-gray-800">{{ a.title }}</h2>
<p class="text-sm text-gray-600">
{{ $root.$dj(a.starttime).format('h:mma') }} - {{ addTime(a.starttime, a.length) }} <!--· {{ mode_string(a) }} -->
<span v-if="a.mode === 'hybrid'"> · In person at {{ a.location_irl }} or
<a v-if="zoom_on && a.location" :href="a.location" class="underline text-blue-600">online</a>
<span v-else>online</span>
</span>
<span v-if="a.mode === 'inperson'"> · In person at {{ a.location_irl }}</span>
<span v-if="zoom_on && a.mode === 'online'"> · <a :href="a.location" class="underline text-blue-600">Online</a></span>
<span v-if="!zoom_on && a.mode === 'online'"> · Online</span>
</p>
<!-- Bottom action buttons -->
<div class="mt-4 flex gap-3 flex-wrap">
<a v-if="zoom_on && a.location"
:href="a.location"
class="px-3 py-1 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700">
Join Zoom
</a>
<a v-if="survey_on"
:href="'survey.php?s=' + a.id"
class="px-3 py-1 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700">
Survey
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="!itineraryview && active > 0" v-for="(items, mmyy) in activities_g_filtered" :key="mmyy" class="mb-6">
<div
class="flex justify-between items-baseline text-xl font-semibold text-gray-800 border-b pb-1 mb-2 clickme"
@click="toggleDay(get_day_index(mmyy))">
{{ mmyy }} {{ get_day_title(mmyy) }}
<button
:id="get_day_index(mmyy) + '-toggle'"
class="text-sm text-blue-600 hover:underline font-normal"
>
Collapse Day
</button>
</div>
<!-- all sessions in a day -->
<div :id="get_day_index(mmyy)">
<div v-for="a in items" :key="a.id" class="relative bg-white rounded-lg shadow p-4 mb-4">
<div class="flex flex-col md:flex-row md:justify-between md:items-start gap-4">
<!-- Action buttons (left on desktop) -->
<div v-if="!static" class="flex-shrink-0 flex gap-2">
<a v-if="my_ses_ids.includes(a.id) && !my_host_ids.includes(a.id)"
@click.prevent="dumpme(a.id)"
href="#"
class="px-3 py-1 text-sm font-medium rounded text-sm text-white bg-red-600 hover:underline">Cancel</a>
<a v-if="!special_signup(a.id) && !my_ses_ids.includes(a.id) && !my_host_ids.includes(a.id)"
@click.prevent="joinme(a.id)"
href="#"
class="px-3 py-1 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700">Sign Up</a>
<!--<a v-if="special_signup(a.id)"
target="_blank"
href="https://forms.office.com/r/GGz56DdSEG"
class="px-3 py-1 text-sm font-medium text-white bg-amber-500 rounded hover:bg-amber-700">Sign Up</a>
-->
<span v-if="special_signup(a.id)"
class="px-3 py-1 text-sm font-medium text-white">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</span>
<a v-if="my_host_ids.includes(a.id)"
:href="'ed_act.php?w=' + a.id"
class="px-3 py-1 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700">Edit</a>
<b v-if="my_host_ids.includes(a.id)">You are Host.</b>
</div>
<!-- Main session info -->
<div class="flex-grow">
<h2 class="text-lg font-semibold text-gray-900">{{ a.title }}</h2>
<ModePill :mode="a.mode" class="absolute top-2 right-2" />
<p class="text-sm text-gray-700">
{{ $root.$dj(a.starttime).format('h:mma') }} - {{ addTime(a.starttime, a.length) }} ·
{{ mode_string(a) }}
<span v-if="a.mode == 'hybrid' || a.mode == 'inperson'"> · {{ a.location_irl }}</span>
</p>
<p v-if="hoststr(a.id)" class="text-sm text-gray-600">Presented by: {{ hoststr(a.id) }}</p>
<p v-if="static && a.location_irl" class="text-sm text-gray-600">Location: {{ a.location_irl }}</p>
<p v-if="static && a.location" class="text-sm text-blue-600">
Zoom Link:
<a :href="a.location" class="underline hover:text-blue-800">{{ a.location }}</a>
</p>
<div v-if="expanded" class="mt-2 text-sm text-gray-700" v-html="a.desc"></div>
</div>
</div>
</div>
</div>
</div>
</div>` })

View File

@ -48,3 +48,19 @@ ol {
overflow-wrap: anywhere;
word-break: break-all; /* only affects long unbroken tokens like URLs */
}
/* Slot picker slide/fade */
.slotpanel-enter-active,
.slotpanel-leave-active {
transition: all 0.25s ease;
}
.slotpanel-enter,
.slotpanel-leave-to {
opacity: 0;
transform: translateY(-6px);
max-height: 0;
}
.slotpanel-enter-to,
.slotpanel-leave {
max-height: 600px;
}