dev.metalisp.survey/src/survey.lisp

260 lines
10 KiB
Common Lisp
Raw Normal View History

2024-07-07 13:46:09 +02:00
;;;; -*- mode: common-lisp; coding: utf-8; -*-
2024-07-09 20:23:19 +02:00
(defpackage ml-survey/survey
(:use #:cl)
(:import-from #:hunchentoot
#:define-easy-handler)
(:import-from #:dev.metalisp.sbt
2024-07-14 16:36:25 +02:00
#:*use-cdn*
2024-07-09 20:23:19 +02:00
#:with-page
#:body-header)
(:export #:survey-id
#:survey
#:survey-properties-title
#:survey-properties-description))
(in-package #:ml-survey/survey)
2024-06-12 22:24:00 +02:00
2024-07-14 16:36:25 +02:00
(defparameter *use-cdn* nil)
2024-06-11 18:27:17 +02:00
(defclass survey ()
((id :initarg :id :reader survey-id)
(data-dir :initarg :data-dir :reader survey-data-dir)
(properties :initarg :properties :reader survey-properties)))
(defmethod initialize-instance :after ((survey survey) &key)
(with-slots (id data-dir properties) survey
(setf data-dir (uiop:merge-pathnames*
(format nil "~a/" id)
2024-07-09 20:23:19 +02:00
(ml-survey/fileops:ensure-surveys-dir)))
2024-06-11 18:27:17 +02:00
(setf properties (first (rest (assoc (parse-integer id)
2024-07-09 20:23:19 +02:00
(ml-survey/fileops:read-from-file (ml-survey/fileops:make-surveys-db-file))))))))
2024-06-11 18:27:17 +02:00
(defgeneric survey-id-p (survey)
(:documentation "Check if the survey ID is present in the surveys database."))
(defgeneric survey-data-dir-files (survey)
(:documentation "Get the list of files in the survey's data directory."))
(defgeneric survey-data-dir-p (survey)
(:documentation "Check if the survey's data directory exists."))
2024-06-23 19:29:39 +02:00
(defgeneric survey-properties-title (survey)
(:documentation "Get title property."))
(defgeneric survey-properties-description (survey)
(:documentation "Get description property."))
2024-06-11 18:27:17 +02:00
(defmethod survey-id-p ((survey survey))
2024-07-09 20:23:19 +02:00
(let ((ids (mapcar #'car (read-from-file (ml-survey/fileops:make-surveys-db-file)))))
2024-06-11 18:27:17 +02:00
(if (member (parse-integer (survey-id survey)) ids) t nil)))
(defmethod survey-data-dir-files ((survey survey))
(uiop:directory-files (survey-data-dir survey)))
(defmethod survey-data-dir-p ((survey survey))
(uiop:directory-exists-p (survey-data-dir survey)))
(defmethod survey-properties-title ((survey survey))
(cdr (assoc "title" (survey-properties survey) :test #'string-equal)))
(defmethod survey-properties-description ((survey survey))
(cdr (assoc "description" (survey-properties survey) :test #'string-equal)))
2024-06-11 18:27:17 +02:00
(defun build-questionnaire-link (survey-id resource)
(format nil "/survey/~a~a" survey-id resource))
2024-06-11 18:27:17 +02:00
(defmethod survey-html ((survey survey))
(spinneret:with-html
2024-06-14 17:47:54 +02:00
(:dl (loop for property in (survey-properties survey)
for key = (car property)
for value = (cdr property) do
(:dt key)
(cond ((string= key "questionnaire")
2024-06-20 23:29:47 +02:00
(:dd (:a :href (build-questionnaire-link (survey-id survey) value)
(format nil "Open Questionnaire ~a" value))))
2024-06-14 17:47:54 +02:00
(t (:dd value)))))))
2024-07-09 20:23:19 +02:00
(defun results-not-null (results)
(some (lambda (x) (and (listp x) (not (null x)))) results))
(defun group-in-chunks (lst)
"Group LST into sublists of three elements."
(loop for i from 0 by 3 while (< i (length lst))
collect (subseq lst i (min (+ i 3) (length lst)))))
(defun likert-results-html (results)
(let ((sus-average (loop for sublist in (getf results :sus)
sum (first (last sublist)) into total
count sublist into count
finally (return (/ total count)))))
(spinneret:with-html
(loop for (name data) on results by #'cddr
do (:h3 :class "py-1" (if (eq name :sus)
(format nil "~a: ~,1f" name sus-average)
(format nil "~a" name)))
(:table :class "table table-hover"
(:caption "Questionnaire results table")
(:thead
(:tr
(:th :scope "col" "Time")
(loop for index from 1 below (length (cdr (car data)))
do (:th :scope "col" (format nil "Q ~a" index)))
(:th :scope "col" "Score")))
(:tbody
(loop for row in data
do (:tr (mapcar (lambda (col) (:td col)) row)))))))))
(defun mixed-results-html (results)
(loop for result in results
for name = (car result)
for data = (cdr result)
do (spinneret:with-html (:h3 :class "py-1" (format nil "~a" name))
2024-07-10 23:01:40 +02:00
(:div :class "container"
(loop for row in (group-in-chunks data)
do (:div :class "row"
(loop for col in row
do (:ul :class "col-4 list-group py-3"
(loop for entry in col
for i from 0
do (:li :class (if (zerop i)
"list-group-item active"
"list-group-item")
entry))))))))))
2024-07-09 20:23:19 +02:00
(defun view (survey &optional results)
"Generates the view to show the survey created."
(check-type survey survey)
(let ((results-not-null (results-not-null results))
(likert-results (getf results :likert))
(mixed-results (loop for (name data) on results by #'cddr
unless (eq name :likert)
collect (cons name data))))
2024-07-09 20:23:19 +02:00
2024-07-14 16:36:25 +02:00
(with-page (:title "Survey Details")
2024-07-09 20:23:19 +02:00
(body-header "Survey Details" (ml-survey/navbar:navbar-en))
(:main :id "main-content"
:class "container"
(:p (format nil "ID: ~a" (survey-id survey)))
(:h2 :class "py-3" "Properties")
(survey-html survey)
(when results-not-null
(:h2 :class "py-3" "Questionnaire Results")
(if likert-results
(likert-results-html likert-results))
2024-07-10 23:01:40 +02:00
(if mixed-results
(mixed-results-html mixed-results)))))))
2024-07-10 23:30:27 +02:00
2024-07-09 20:23:19 +02:00
(defun extract-numbers (results)
"Extract numbers from a questionnaire RESULTS list.
Returns a list of integers."
(check-type results list)
(mapcar (lambda (x)
(parse-integer (remove-if (complement #'digit-char-p)
(cdr x)))) results))
(defun sus-calc-score (results)
(check-type results list)
(let ((counter 0))
(mapcar (lambda (x)
(setq counter (1+ counter))
(if (evenp counter)
(- 5 x)
(1- x)))
results)))
(defun sus-calc-score-per-row (results)
(check-type results list)
(reverse (cons (* (apply #'+ (sus-calc-score results)) 2.5) (reverse results))))
(defun sus-calc (files)
(check-type files list)
(cons (car files) (sus-calc-score-per-row (extract-numbers (cdr files)))))
(defun nps-calc (scores)
"Calculate the Net Promoter Score (NPS) from a list of SCORES."
(check-type scores list)
(let ((promoters 0)
(detractors 0)
(total-responses (length scores)))
(dolist (score scores)
(cond
((>= score 9) (incf promoters))
((<= score 6) (incf detractors))))
(let ((nps (* (- (/ promoters total-responses)
(/ detractors total-responses))
100)))
nps)))
2024-07-09 20:23:19 +02:00
(defstruct questionnaire-result
type
name
2024-07-09 20:23:19 +02:00
timestamp
post-data)
(defun questionnaire-result-list-p (list)
"Check if all elements in LIST are of type 'questionnaire-result'."
(if (every 'questionnaire-result-p list) t nil))
(deftype questionnaire-result-list ()
"Define a type representing a list containing only questionnaire-result instances."
'(and list (satisfies questionnaire-result-list-p)))
2024-07-09 20:23:19 +02:00
(defun questionnaire-result-from-file (filename)
"Create a 'questionnaire-result' instance from data read from the file specified by FILENAME."
2024-07-09 20:23:19 +02:00
(check-type filename (or string pathname))
(let ((data (ml-survey/fileops:read-from-file filename)))
(make-questionnaire-result :type (getf data :type)
:name (getf data :name)
2024-07-09 20:23:19 +02:00
:timestamp (getf data :timestamp)
:post-data (getf data :post-data))))
(defun likert-calc (name args)
"Calculate metrics based on NAME from provided ARGS."
(case name
(:sus (sus-calc args))
(:nps (nps-calc args))))
(defun string->keyword (string)
(intern (string-upcase string) :keyword))
2024-07-09 20:23:19 +02:00
(defun list-of-categorized-results (result-objs)
"Categorize and process questionnaire results listed in RESULT-OBJs.
Each result must conform to 'questionnaire-result-list' type."
(declare (type questionnaire-result-list result-objs))
(let ((categorized-results (list :likert nil)))
2024-07-09 20:23:19 +02:00
(dolist (result result-objs categorized-results)
(let ((name (string->keyword (questionnaire-result-name result)))
(type (string->keyword (questionnaire-result-type result)))
2024-07-09 20:23:19 +02:00
(data (questionnaire-result-post-data result))
(timestamp (questionnaire-result-timestamp result)))
(cond
((eq type :likert)
(setf (getf (getf categorized-results :likert) name)
(cons (likert-calc name (cons timestamp data))
(getf (getf categorized-results :likert) name))))
2024-07-09 20:23:19 +02:00
(t
(setf (getf categorized-results name)
2024-07-09 20:23:19 +02:00
(cons (cons timestamp (mapcar #'cdr data))
(getf categorized-results name)))))))))
2024-07-09 20:23:19 +02:00
(defun survey-uri-p (uri)
(let ((parts (ml-survey/app:split-uri uri)))
(and (= (length parts) 2)
(string= (first parts) "survey")
(every #'digit-char-p (second parts)))))
(defun survey-uri (request)
(survey-uri-p (hunchentoot:request-uri request)))
(define-easy-handler (survey-handler :uri #'survey-uri) ()
(let* ((s (make-instance 'survey
:id (ml-survey/app:extract-from (hunchentoot:request-uri*) :survey-id)))
(result-objs (mapcar 'questionnaire-result-from-file
(survey-data-dir-files s))))
(view s (list-of-categorized-results result-objs))))