history page now shows sessions
This commit is contained in:
parent
8d1c5b794c
commit
f6bcd1957a
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
$MY_TITLE = "Training History";
|
||||
$MY_TITLE = "Workshop and Training History";
|
||||
$MY_CRUMB = "History";
|
||||
|
||||
|
||||
$CONTENT = '<traininghistory></traininghistory>';
|
||||
|
||||
$CONTENT = '<workshophistory></workshophistory><div class="mt-8"></div><traininghistory></traininghistory>';
|
||||
include 'layout.php';
|
||||
|
|
|
|||
198
js/dir_app.js
198
js/dir_app.js
|
|
@ -38,6 +38,17 @@ function dj(mysqldate) { return dayjs(parsesqltime(mysqldate)) }
|
|||
Object.defineProperty(Vue.prototype, '$dj', { value: dj });
|
||||
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>
|
||||
<tr>
|
||||
<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>
|
||||
<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>
|
||||
<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) }}
|
||||
<div v-if="a.location" class="text-gray-600 break-words break-all">{{ 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" class="text-gray-600">{{ $wrap(a.location) }}</div>
|
||||
<div v-if="a.location_irl" class="text-gray-600">{{ $wrap(a.location_irl) }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="a.desc">
|
||||
<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 v-if="host">
|
||||
<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>
|
||||
<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 v-if="survey">
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -2106,35 +2117,35 @@ const ActivityInfoReportTable = Vue.component('activityinforeport_table', {
|
|||
<tbody>
|
||||
<tr>
|
||||
<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>
|
||||
<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>
|
||||
<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) }}
|
||||
<div v-if="a.location" class="break-words break-all">{{ a.location }}</div>
|
||||
<div v-if="a.location_irl" class="break-words break-all">{{ a.location_irl }}</div>
|
||||
<div v-if="a.location">{{ $wrap(a.location) }}</div>
|
||||
<div v-if="a.location_irl">{{ $wrap(a.location_irl) }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="a.desc">
|
||||
<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 v-if="host">
|
||||
<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>
|
||||
<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 v-if="survey">
|
||||
<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>
|
||||
</tbody>
|
||||
</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
|
||||
result += "<ul>"
|
||||
_.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,'<').replace(/>/g,'>')
|
||||
txt = wrapLongTokens(txt)
|
||||
result += "<li>" + txt + "</li>\n"
|
||||
})
|
||||
result += "</ul>\n"
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -22,7 +22,7 @@ $MOD_DATE = file_exists(__FILE__) ? date("F d Y H:i.", filemtime(__FILE__)) : "(
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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/intranet_libs.js"/></script>
|
||||
<link rel="stylesheet" type="text/css" href="style.css" />
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ $MOD_DATE = file_exists(__FILE__) ? date("F d Y H:i.", filemtime(__FILE__)) : "(
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen">
|
||||
|
|
|
|||
17
style.css
17
style.css
|
|
@ -43,19 +43,8 @@ ol {
|
|||
}
|
||||
|
||||
/* 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;
|
||||
word-break: break-word;
|
||||
}
|
||||
.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;
|
||||
word-break: break-all; /* only affects long unbroken tokens like URLs */
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue