Merge branch 'module/poll' into dev
All checks were successful
Build Docker Image / Docker-Build (push) Successful in 2m23s
Build Docker Image / Clean-Registry (push) Successful in -6s

This commit is contained in:
2026-03-10 11:35:01 +01:00
13 changed files with 153 additions and 27 deletions

View File

@@ -144,6 +144,7 @@ public class Field {
public static final String START_DATE = "start_date";
public static final String START_TIME = "start_time";
public static final String STATE = "state";
public static final String STATS = "stats";
public static final String STATUS = "status";
public static final String STATUS_CODE = "code";
public static final String SUBJECT = "subject";

View File

@@ -107,18 +107,79 @@ public class Poll implements Mappable {
}
public static class Evaluation {
// Map<Option → Map<Weight → Count>>
private HashMap<Integer,Map<Integer,Integer>> selections = new HashMap<>();
public void count(ResultSet rs) throws SQLException {
var optionId = rs.getInt(OPTION_ID);
var userId = rs.getObject(USER);
var weight = rs.getInt(WEIGHT);
var optionStats = selections.computeIfAbsent(optionId, k -> new HashMap<>());
optionStats.compute(weight, (k, sum) -> sum == null ? 1 : sum + 1);
private static class Histogram extends HashMap<Integer,Integer>{
public Double average() {
var sum = 0;
var count = 0;
for (var entry : entrySet()){
var weight = entry.getKey();
var votes = entry.getValue();
count += votes;
sum += weight * votes;
}
if (count < 1) return null;
return sum/(double) count;
}
}
private static class OptionStats extends HashMap<Integer,Histogram>{}
private static class Stats extends HashMap<Double,OptionStats>{
public Stats(OptionStats optionStats) {
for (var entry : optionStats.entrySet()){
var optionId = entry.getKey();
var histo = entry.getValue();
var average = histo.average();
var os = get(average);
if (os == null) put(average,os = new OptionStats());
os.put(optionId,histo);
}
}
}
/*
{
options : {
1: option 1
2: option 2
},
stat : {
0.571 : { // average
1 : { // option
-2 : 0, // weight → selections
-1 : 1,
0 : 2
1 : 3
2 : 1
}
2 :
}
}
}
public HashMap<Integer, Map<Integer, Integer>> toMap() {
return selections;
*/
private final Map<Integer, Option> options = new HashMap<>();
private final OptionStats optionStats = new OptionStats();
public Evaluation(Poll poll) {
for (var option : poll.options){
options.put(option.id,option);
var histo = new Histogram();
for (var w : poll.weights.keySet()) histo.put(w,0);
optionStats.put(option.id,histo);
}
}
public void count(ResultSet rs) throws SQLException {
var optionId = rs.getInt(OPTION_ID);
//var userId = rs.getObject(USER);
var weight = rs.getInt(WEIGHT);
var histogram = optionStats.get(optionId);
histogram.put(weight,histogram.get(weight)+1);
}
public Map<Double,?> toMap() {
return new Stats(optionStats);
}
}

View File

@@ -0,0 +1,28 @@
<script>
import { t } from '../translations.svelte';
let { data = {} } = $props();
let max = $derived(Math.max(...Object.values(data)));
</script>
<style>
.histo {
display: flex;
align-items: flex-end;
gap: 2px;
}
.bar {
display: flex; /* flexbox for text alignment */
align-items: flex-end; /* text sticks to bottom of bar */
height: 100%; /* full height of flex item */
padding: 0 2px; /* space for text */
box-sizing: border-box; /* include padding in height */
}
</style>
<div class="histo">
{#each Object.entries(data).sort((a,b) => a[0] - b[0]) as [weight,count]}
<div class="bar" style="height: {100*count/max}%" title={t('voted {count} times',{count})}>{weight}</div>
{/each}
</div>

View File

@@ -1,4 +1,5 @@
<script>
import Histogram from '../../Components/Histogram.svelte';
import { onMount } from 'svelte';
import { api, get } from '../../urls.svelte';
import { error, yikes } from '../../warn.svelte';
@@ -62,25 +63,22 @@
</tr>
</thead>
<tbody>
{#each Object.entries(poll.evaluation) as [option_id,hist]}
{#each Object.entries(poll.evaluation).sort((a,b) => b[0] - a[0]) as [avg,optionset]}
{#each Object.entries(optionset) as [option_id,histo]}
<tr>
<td>
{poll.options[option_id].name}
</td>
<td>
{average(hist)}
{(+avg).toFixed(3)}
</td>
<td class="histogram">
{#each Object.entries(hist).sort((a,b) => a[0] - b[0]) as [weight,count]}
<span style="height: {100*count/max_val(hist)}%" title={t('voted {count} times',{count:count})}>
<span>{weight}</span>
</span>
{/each}
<td>
<Histogram data={histo} />
</td>
</tr>
{/each}
{/each}
</tbody>
</table>
</fieldset>
{/if}
<span class="warn">TODO: sort by average</span>

View File

@@ -10,7 +10,7 @@ import java.util.Map;
public interface PollDb {
Collection<Poll> listPolls(UmbrellaUser user);
Poll.Evaluation loadEvaluation(String id);
Poll.Evaluation loadEvaluation(Poll poll);
Poll loadPoll(String id);

View File

@@ -113,7 +113,7 @@ public class PollModule extends BaseHandler implements PollService {
}
}
var result = new HashMap<>(poll.toMap());
var evaluation = pollDb.loadEvaluation(poll.id());
var evaluation = pollDb.loadEvaluation(poll);
result.put(Field.EVALUATION,evaluation.toMap());
return sendContent(ex,result);
}

View File

@@ -132,25 +132,27 @@ public class SqliteDb extends BaseDb implements PollDb {
ps.setLong(1,user.id());
ps.setLong(2, user.id());
var rs = ps.executeQuery();
var list = new ArrayList<Poll>();
var map = new HashMap<String,Poll>();
while (rs.next()) {
var pollId = rs.getString(ID);
if (map.containsKey(pollId)) continue;
var poll = Poll.of(rs);
var perm = rs.getInt(PERMISSION);
if (perm != 0) poll.permissions().put(user,Permission.of(perm));
list.add(poll);
map.put(pollId,poll);
}
rs.close();
return list;
return map.values().stream().sorted(Comparator.comparing(Poll::name)).toList();
} catch (SQLException sqle){
throw failedToLoadObject(TABLE_POLLS);
}
}
@Override
public Poll.Evaluation loadEvaluation(String pollId) {
public Poll.Evaluation loadEvaluation(Poll poll) {
try {
var result = new Poll.Evaluation();
var rs = select(ALL).from(TABLE_SELECTIONS).where(POLL_ID,equal(pollId)).exec(db);
var result = new Poll.Evaluation(poll);
var rs = select(ALL).from(TABLE_SELECTIONS).where(POLL_ID,equal(poll.id())).exec(db);
while (rs.next()) result.count(rs);
rs.close();
return result;

View File

@@ -313,6 +313,10 @@ tr:hover .taglist .tag button {
background: black;
}
.histo .bar{
border: 1px solid red;
}
@media screen and (max-width: 900px) {
#app nav a{
background: black;

View File

@@ -504,6 +504,14 @@ a.wikilink{
}
}
.poll .weight .description{
display: block;
margin: 0 5px;
}
.histo {
height: 100px;
}
@media screen and (max-width: 600px) {
.grid2{

View File

@@ -304,6 +304,10 @@ tr:hover .taglist .tag button {
background: black;
}
.histo .bar{
background: #a00;
}
@media screen and (max-width: 900px) {
#app nav a{
background: black;

View File

@@ -619,6 +619,14 @@ a.wikilink{
}
}
.poll .weight .description{
display: block;
margin: 0 5px;
}
.histo {
height: 100px;
}
@media screen and (max-width: 600px) {
.grid2,

View File

@@ -283,6 +283,10 @@ tr:hover .taglist .tag button {
color: blue;
}
.histo .bar{
background: cyan;
}
@media screen and (max-width: 900px) {
#app nav a{
background: white;

View File

@@ -504,6 +504,14 @@ a.wikilink{
}
}
.poll .weight .description{
display: block;
margin: 0 5px;
}
.histo {
height: 100px;
}
@media screen and (max-width: 600px) {
.grid2{