history page now shows sessions

This commit is contained in:
Peter Howell 2025-09-26 21:53:20 +00:00
parent 8d1c5b794c
commit f6bcd1957a
6 changed files with 270 additions and 38 deletions

View File

@ -1,7 +1,7 @@
<?php <?php
$MY_TITLE = "Training History"; $MY_TITLE = "Workshop and Training History";
$MY_CRUMB = "History"; $MY_CRUMB = "History";
$CONTENT = '<traininghistory></traininghistory>'; $CONTENT = '<workshophistory></workshophistory><div class="mt-8"></div><traininghistory></traininghistory>';
include 'layout.php'; include 'layout.php';

View File

@ -38,6 +38,17 @@ function dj(mysqldate) { return dayjs(parsesqltime(mysqldate)) }
Object.defineProperty(Vue.prototype, '$dj', { value: dj }); Object.defineProperty(Vue.prototype, '$dj', { value: dj });
Object.defineProperty(Vue.prototype, '$parsesqltime', { value: parsesqltime }); Object.defineProperty(Vue.prototype, '$parsesqltime', { value: parsesqltime });
// Insert zero-width spaces into long unbroken tokens so they wrap nicely.
function wrapLongTokens(str, n=30) {
if (str === null || str === undefined) return ''
const s = String(str)
return s.split(/(\s+)/).map(tok => {
if (!tok || /\s/.test(tok) || tok.length < n) return tok
return tok.replace(new RegExp('(.{'+n+'})', 'g'), '$1\u200B')
}).join('')
}
Object.defineProperty(Vue.prototype, '$wrap', { value: wrapLongTokens });
@ -2060,35 +2071,35 @@ const ActivityInfoReport2 = Vue.component('activityinforeport2', {
<tbody> <tbody>
<tr> <tr>
<th class="text-left align-top pr-4 py-1 w-32">Title:</th> <th class="text-left align-top pr-4 py-1 w-32">Title:</th>
<td class="py-1 font-semibold break-words break-all">{{ a.title }}</td> <td class="py-1 font-semibold">{{ $wrap(a.title) }}</td>
</tr> </tr>
<tr> <tr>
<th class="text-left align-top pr-4 py-1">Date:</th> <th class="text-left align-top pr-4 py-1">Date:</th>
<td class="py-1 break-words break-all">{{ $root.$dj(a.starttime).format('YYYY MMM DD dd h:mma') }}</td> <td class="py-1">{{ $root.$dj(a.starttime).format('YYYY MMM DD dd h:mma') }}</td>
</tr> </tr>
<tr> <tr>
<th class="text-left align-top pr-4 py-1">Mode / Location:</th> <th class="text-left align-top pr-4 py-1">Mode / Location:</th>
<td class="py-1 break-words break-all"> <td class="py-1">
{{ mode_string(a) }} {{ mode_string(a) }}
<div v-if="a.location" class="text-gray-600 break-words break-all">{{ a.location }}</div> <div v-if="a.location" class="text-gray-600">{{ $wrap(a.location) }}</div>
<div v-if="a.location_irl" class="text-gray-600 break-words break-all">{{ a.location_irl }}</div> <div v-if="a.location_irl" class="text-gray-600">{{ $wrap(a.location_irl) }}</div>
</td> </td>
</tr> </tr>
<tr v-if="a.desc"> <tr v-if="a.desc">
<th class="text-left align-top pr-4 py-1">Description:</th> <th class="text-left align-top pr-4 py-1">Description:</th>
<td class="py-1 break-words break-all" v-html="a.desc"></td> <td class="py-1" v-html="a.desc"></td>
</tr> </tr>
<tr v-if="host"> <tr v-if="host">
<th class="text-left align-top pr-4 py-1">Hosts:</th> <th class="text-left align-top pr-4 py-1">Hosts:</th>
<td class="py-1 break-words break-all">{{ host }}</td> <td class="py-1">{{ host }}</td>
</tr> </tr>
<tr> <tr>
<th class="text-left align-top pr-4 py-1">Attendees:</th> <th class="text-left align-top pr-4 py-1">Attendees:</th>
<td class="py-1 break-words break-all">{{ user }}</td> <td class="py-1">{{ user }}</td>
</tr> </tr>
<tr v-if="survey"> <tr v-if="survey">
<th class="text-left align-top pr-4 py-1">Survey Results:</th> <th class="text-left align-top pr-4 py-1">Survey Results:</th>
<td class="py-1 break-words break-all" v-html="survey"></td> <td class="py-1" v-html="survey"></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -2106,35 +2117,35 @@ const ActivityInfoReportTable = Vue.component('activityinforeport_table', {
<tbody> <tbody>
<tr> <tr>
<th class="text-left align-top pr-4 py-1 w-28">Title:</th> <th class="text-left align-top pr-4 py-1 w-28">Title:</th>
<td class="py-1 font-semibold break-words break-all">{{ a.title }}</td> <td class="py-1 font-semibold">{{ $wrap(a.title) }}</td>
</tr> </tr>
<tr> <tr>
<th class="text-left align-top pr-4 py-1">Date:</th> <th class="text-left align-top pr-4 py-1">Date:</th>
<td class="py-1 break-words break-all">{{ $root.$dj(a.starttime).format('YYYY MMM DD dd h:mma') }}</td> <td class="py-1">{{ $root.$dj(a.starttime).format('YYYY MMM DD dd h:mma') }}</td>
</tr> </tr>
<tr> <tr>
<th class="text-left align-top pr-4 py-1">Mode / Location:</th> <th class="text-left align-top pr-4 py-1">Mode / Location:</th>
<td class="py-1 break-words break-all"> <td class="py-1">
{{ mode_string(a) }} {{ mode_string(a) }}
<div v-if="a.location" class="break-words break-all">{{ a.location }}</div> <div v-if="a.location">{{ $wrap(a.location) }}</div>
<div v-if="a.location_irl" class="break-words break-all">{{ a.location_irl }}</div> <div v-if="a.location_irl">{{ $wrap(a.location_irl) }}</div>
</td> </td>
</tr> </tr>
<tr v-if="a.desc"> <tr v-if="a.desc">
<th class="text-left align-top pr-4 py-1">Description:</th> <th class="text-left align-top pr-4 py-1">Description:</th>
<td class="py-1 break-words break-all" v-html="a.desc"></td> <td class="py-1" v-html="a.desc"></td>
</tr> </tr>
<tr v-if="host"> <tr v-if="host">
<th class="text-left align-top pr-4 py-1">Hosts:</th> <th class="text-left align-top pr-4 py-1">Hosts:</th>
<td class="py-1 break-words break-all">{{ host }}</td> <td class="py-1">{{ host }}</td>
</tr> </tr>
<tr> <tr>
<th class="text-left align-top pr-4 py-1">Attendees:</th> <th class="text-left align-top pr-4 py-1">Attendees:</th>
<td class="py-1 break-words break-all">{{ user }}</td> <td class="py-1">{{ user }}</td>
</tr> </tr>
<tr v-if="survey"> <tr v-if="survey">
<th class="text-left align-top pr-4 py-1">Survey Results:</th> <th class="text-left align-top pr-4 py-1">Survey Results:</th>
<td class="py-1 break-words break-all" v-html="survey"></td> <td class="py-1" v-html="survey"></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -2142,6 +2153,151 @@ const ActivityInfoReportTable = Vue.component('activityinforeport_table', {
}) })
// Workshop history (signed up and hosted), grouped by semester
const WorkshopHistory = Vue.component('workshophistory', {
props: [ '' ],
data: function () { return {
ready: false,
sessionsLoaded: false,
sessions: [], // sessions user signed up for or hosted
hostsBySes: {},
rostersBySes: {},
answersBySes: {}, // numeric-only for averages
grouped: {}, // { 'Fall 2024': [sessions...] }
collapsed: {}, // { 'Fall 2024': true|false }
} },
mounted: function() {
var self = this
const init = function() { self.loadData() }
if (this.$root && this.$root.active) { init() } else { this.$root.do_after_load(init) }
},
methods: {
semesterLabel(dt) {
const d = dayjs(dt)
const y = d.year()
const m = d.month() + 1
if (m >= 8) return `Fall ${y}`
return `Spring ${y}`
},
loadData() {
var self = this
const uid = this.$root.user.conf_id
if (!uid) { this.ready = true; return }
// 1) sessions for this user (signed up or hosted) across all time
basic_get(`dir_api.php?a=get/sessions/${uid}`,
function(r2) {
self.sessions = _.sortBy(r2, s => s.starttime)
self.buildGroups()
self.sessionsLoaded = true
self.ready = true
self.$forceUpdate()
})
// 2) support data for hosts, rosters, survey answers
basic_get('dir_api.php?a=get/hosts&all=1', function(r2) {
self.hostsBySes = _.groupBy(r2, x => x.id)
self.$forceUpdate()
})
basic_get('dir_api.php?a=get/rosters&all=1', function(r2) {
self.rostersBySes = _.groupBy(r2, x => x.sesid)
self.$forceUpdate()
})
basic_get('dir_api.php?a=get/answers/all&all=1', function(r2) {
// keep only numeric answers per session
const bySes = _.groupBy(r2, a => a.ses_id)
_.each(bySes, function(list, sid) {
const nums = []
_.each(list, function(a) {
const v = parseFloat(a.answer)
if (!isNaN(v)) nums.push(v)
})
if (nums.length) self.answersBySes[sid] = nums
})
self.$forceUpdate()
})
// ready is set when sessions load; keep other data loading async
},
buildGroups() {
const self = this
const out = {}
_.each(this.sessions, function(s) {
const label = self.semesterLabel(s.starttime)
if (!out[label]) out[label] = []
out[label].push(s)
})
// sort newest semester first, and sessions within by date desc
this.grouped = {}
const order = _.sortBy(Object.keys(out), lbl => {
const m = lbl.match(/(Fall|Spring)\s(\d{4})/)
if (!m) return 0
const season = m[1] === 'Fall' ? 2 : 1
const year = parseInt(m[2])
return -(year*10 + season)
})
_.each(order, function(lbl) {
self.grouped[lbl] = _.sortBy(out[lbl], s => -dayjs(s.starttime).valueOf())
if (self.collapsed[lbl] === undefined) self.$set(self.collapsed, lbl, false)
})
},
isHost(sessionId) {
const uid = this.$root.user.conf_id
const hosts = this.hostsBySes[sessionId] || []
return _.some(hosts, h => String(h.hostid) === String(uid))
},
hostNames(sessionId) {
const hosts = this.hostsBySes[sessionId] || []
// Filter out null/empty names and dedupe
const names = _.chain(hosts)
.pluck('name')
.filter(n => n && String(n).trim().length)
.uniq()
.value()
return names.join(', ')
},
rosterCount(sessionId) {
return (this.rostersBySes[sessionId] || []).length
},
avgRating(sessionId) {
const nums = this.answersBySes[sessionId]
if (!nums || !nums.length) return null
const sum = _.reduce(nums, (m,v)=>m+v, 0)
return (sum/nums.length).toFixed(2)
},
toggle(label) { this.$set(this.collapsed, label, !this.collapsed[label]) }
},
template: `<div class="space-y-6">
<div v-if="!sessionsLoaded" class="text-gray-600 text-sm">Loading...</div>
<div v-else-if="Object.keys(grouped).length === 0" class="text-gray-600 text-sm">No workshop history yet.</div>
<div v-for="(list, label) in grouped" :key="label" class="">
<div class="flex items-center justify-between border-b pb-1 mb-3 clickme" @click="toggle(label)">
<div class="text-lg font-semibold text-gray-700">{{ label }}</div>
<button @click.stop="toggle(label)" class="text-sm text-blue-600 hover:underline">
{{ collapsed[label] ? 'Expand' : 'Collapse' }}
</button>
</div>
<div v-show="!collapsed[label]" class="grid gap-4 md:grid-cols-2">
<div v-for="s in list" :key="s.id" class="bg-white rounded-md shadow p-4">
<div class="flex items-start justify-between">
<div>
<div class="text-base font-semibold text-gray-900">{{ s.title }}</div>
<div class="text-sm text-gray-600">{{ $root.$dj(s.starttime).format('YYYY MMM DD, ddd h:mma') }}</div>
<div v-if="hostNames(s.id)" class="text-xs text-gray-500">Hosts: {{ hostNames(s.id) }}</div>
</div>
<div v-if="isHost(s.id)" class="ml-3 inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-purple-100 text-purple-700">Host</div>
</div>
<div class="mt-3 flex flex-wrap gap-4 text-sm text-gray-700">
<div>Attendees: <span class="font-medium">{{ rosterCount(s.id) }}</span></div>
<div v-if="avgRating(s.id)">Avg Rating: <span class="font-medium">{{ avgRating(s.id) }}</span></div>
</div>
<div class="mt-3">
<a v-if="isHost(s.id)" :href="'report.php?s=' + s.id" class="text-sm text-blue-600 hover:underline">Info</a>
</div>
</div>
</div>
</div>
</div>`
})
@ -2283,7 +2439,11 @@ const ActivityReport = Vue.component('activityreport', {
// fallback: list non-numeric answers // fallback: list non-numeric answers
result += "<ul>" result += "<ul>"
_.each( qlist, function(qanswer) { _.each( qlist, function(qanswer) {
result += "<li>" + (qanswer['answer']||'') + "</li>\n" var txt = (qanswer['answer']||'')
// escape < and > to avoid breaking markup, then wrap long tokens
txt = String(txt).replace(/</g,'&lt;').replace(/>/g,'&gt;')
txt = wrapLongTokens(txt)
result += "<li>" + txt + "</li>\n"
}) })
result += "</ul>\n" result += "</ul>\n"
} }

83
js/tailwind.js Executable file

File diff suppressed because one or more lines are too long

View File

@ -22,7 +22,7 @@ $MOD_DATE = file_exists(__FILE__) ? date("F d Y H:i.", filemtime(__FILE__)) : "(
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($MY_TITLE) ?> | Gavilan Intranet</title> <title><?= htmlspecialchars($MY_TITLE) ?> | Gavilan Intranet</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="js/tailwind.js"></script>
<script src="js/vue27max.js"></script> <script src="js/vue27max.js"></script>
<script src="js/intranet_libs.js"/></script> <script src="js/intranet_libs.js"/></script>
<link rel="stylesheet" type="text/css" href="style.css" /> <link rel="stylesheet" type="text/css" href="style.css" />

View File

@ -15,7 +15,7 @@ $MOD_DATE = file_exists(__FILE__) ? date("F d Y H:i.", filemtime(__FILE__)) : "(
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($MY_TITLE) ?> | Gavilan Intranet</title> <title><?= htmlspecialchars($MY_TITLE) ?> | Gavilan Intranet</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="js/tailwind.js"></script>
<script src="js/vue27max.js"></script> <script src="js/vue27max.js"></script>
</head> </head>
<body class="bg-gray-100 min-h-screen"> <body class="bg-gray-100 min-h-screen">

View File

@ -43,19 +43,8 @@ ol {
} }
/* Report page: prevent long URLs or tokens from causing horizontal scroll */ /* Report page: prevent long URLs or tokens from causing horizontal scroll */
.activityreport { /* Report page: Allow long URLs to wrap in anchors without breaking normal words */
.activityreport a {
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; word-break: break-all; /* only affects long unbroken tokens like URLs */
}
.activityreport table,
.activityreport td,
.activityreport th,
.activityreport p,
.activityreport li,
.activityreport div,
.activityreport span,
.activityreport a,
.activityreport code {
overflow-wrap: anywhere;
word-break: break-word;
} }