history page now shows sessions
This commit is contained in:
parent
8d1c5b794c
commit
f6bcd1957a
|
|
@ -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';
|
||||||
|
|
|
||||||
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, '$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,'<').replace(/>/g,'>')
|
||||||
|
txt = wrapLongTokens(txt)
|
||||||
|
result += "<li>" + txt + "</li>\n"
|
||||||
})
|
})
|
||||||
result += "</ul>\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 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" />
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
17
style.css
17
style.css
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue