diff --git a/appinfo/routes.php b/appinfo/routes.php index 54bc8e58..74a79a39 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -10,6 +10,7 @@ ['name' => 'page#appointment', 'url' => '/appointment/{id}', 'verb' => 'GET'], // Legacy shape produced by <=1.42, redirects to the canonical URL. ['name' => 'page#legacyAllAppointment', 'url' => '/all/appointment/{id}', 'verb' => 'GET'], + ['name' => 'page#statistics', 'url' => '/statistics', 'verb' => 'GET'], ['name' => 'page#checkin', 'url' => '/checkin/{id}', 'verb' => 'GET'], ['name' => 'page#create', 'url' => '/create', 'verb' => 'GET'], ['name' => 'page#edit', 'url' => '/edit/{id}', 'verb' => 'GET'], @@ -86,6 +87,12 @@ // Export ['name' => 'appointment#export', 'url' => '/api/export', 'verb' => 'POST'], + // Statistics + // NOTE: Specific routes must come BEFORE wildcard {targetUserId} routes + ['name' => 'statistics#index', 'url' => '/api/statistics', 'verb' => 'GET'], + ['name' => 'statistics#export', 'url' => '/api/statistics/export', 'verb' => 'POST'], + ['name' => 'statistics#person', 'url' => '/api/statistics/person/{targetUserId}', 'verb' => 'GET'], + // iCal feed ['name' => 'ical#getToken', 'url' => '/api/ical/token', 'verb' => 'GET'], ['name' => 'ical#regenerateToken', 'url' => '/api/ical/token/regenerate', 'verb' => 'POST'], diff --git a/l10n/de.js b/l10n/de.js index 38ece05d..285e2bce 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -462,7 +462,6 @@ OC.L10N.register( "Someone has to create the appointments people reply to. Usually that is a small group — a board, a team of trainers, the office." : "Jemand muss die Termine anlegen, auf die geantwortet wird. Meist ist das ein kleiner Kreis — ein Vorstand, ein Trainerteam, das Büro.", "At the appointment itself, check-in records who really showed up. The check-in screen shows every attendee with a single tick per person." : "Beim Termin selbst hält der Check-in fest, wer wirklich da war. Die Check-in-Ansicht zeigt jeden Teilnehmer mit einem einzigen Haken pro Person.", "Responses" : "Antworten", - "Who gets to see the replies of everyone else? The detailed summary shows names, the counts show only how many said yes, no or maybe." : "Wer darf die Antworten der anderen sehen? Die ausführliche Übersicht zeigt Namen, die Zahlen zeigen nur, wie viele mit Ja, Nein oder Vielleicht geantwortet haben.", "Audit log" : "Auditprotokoll", "Attendance can record every reply, change, withdrawal and check-in — who did what, when, and from where. The timeline shows up on each appointment, and it is what lets managers get a push notification when somebody changes their mind." : "Anwesenheit kann jede Antwort, Änderung, Rücknahme und jeden Check-in festhalten — wer was wann und von wo aus getan hat. Der Verlauf erscheint bei jedem Termin und ist die Grundlage dafür, dass Verantwortliche eine Push-Benachrichtigung bekommen, wenn jemand es sich anders überlegt.", "Response summary groups" : "Gruppen in Antwortübersicht", @@ -879,6 +878,59 @@ OC.L10N.register( "_{count} attendee not yet checked in_::_{count} attendees not yet checked in_" : ["{count} Teilnehmer noch nicht eingecheckt","{count} Teilnehmer noch nicht eingecheckt"], "_{count} reminder sent_::_{count} reminders sent_" : ["{count} Erinnerung gesendet","{count} Erinnerungen gesendet"], "_This appointment is part of a series of {count} appointment. How would you like to apply this change?_::_This appointment is part of a series of {count} appointments. How would you like to apply this change?_" : ["Dieser Termin ist Teil einer Reihe von {count} Termin. Worauf soll diese Änderung angewendet werden?","Dieser Termin ist Teil einer Reihe von {count} Terminen. Worauf soll diese Änderung angewendet werden?"], - "_Do you want to set {count} user to {action}?_::_Do you want to set {count} users to {action}?_" : ["Möchtest du {count} Benutzer auf {action} festlegen?","Möchtest du {count} Benutzer auf {action} festlegen?"] + "_Do you want to set {count} user to {action}?_::_Do you want to set {count} users to {action}?_" : ["Möchtest du {count} Benutzer auf {action} festlegen?","Möchtest du {count} Benutzer auf {action} festlegen?"], + "Statistics" : "Statistik", + "Acceptance rate" : "Zusagequote", + "Attendance rate" : "Anwesenheitsquote", + "Response rate" : "Antwortquote", + "No-show" : "Nicht erschienen", + "Not recorded" : "Nicht erfasst", + "Said yes but was recorded as absent" : "Hat zugesagt, wurde aber als abwesend erfasst", + "Said no but attended" : "Hat abgesagt, war aber da", + "Total" : "Gesamt", + "Name" : "Name", + "Guest" : "Gast", + "People" : "Personen", + "Groups" : "Gruppen", + "Teams" : "Teams", + "Group by" : "Gruppieren nach", + "Period" : "Zeitraum", + "Range" : "Zeitspanne", + "Year" : "Jahr", + "Half-year" : "Halbjahr", + "Quarter" : "Quartal", + "Custom" : "Benutzerdefiniert", + "Q{quarter} {year}" : "Q{quarter} {year}", + "H{half} {year}" : "H{half} {year}", + "Over time" : "Zeitlicher Verlauf", + "By group" : "Nach Gruppe", + "By team" : "Nach Team", + "By category" : "Nach Kategorie", + "Without category" : "Ohne Kategorie", + "Search people" : "Personen suchen", + "No appointments in this period" : "Keine Termine in diesem Zeitraum", + "Too many appointments in this period" : "Zu viele Termine in diesem Zeitraum", + "Attendance recorded for {recorded} of {past} past appointments" : "Anwesenheit bei {recorded} von {past} vergangenen Terminen erfasst", + "People in several groups are counted in each of them. The total row counts every person once." : "Personen in mehreren Gruppen zählen in jeder davon mit. Die Gesamtzeile zählt jede Person einmal.", + "Failed to load statistics" : "Statistik konnte nicht geladen werden", + "Failed to export statistics" : "Statistik konnte nicht exportiert werden", + "May see everyone's statistics" : "Darf die Statistik aller sehen", + "See the evaluation of responses and attendance across all appointments, per person." : "Die Auswertung von Antworten und Anwesenheit über alle Termine hinweg sehen, pro Person.", + "Every user can see how often each person responded and attended." : "Jeder Nutzer kann sehen, wie oft jede Person geantwortet hat und anwesend war.", + "Who may see the evaluation across all appointments" : "Wer die Auswertung über alle Termine sieht", + "Who gets to see the replies of everyone else? The detailed summary shows names, the counts show only how many said yes, no or maybe, and the statistics evaluate both across all appointments." : "Wer darf die Antworten der anderen sehen? Die ausführliche Übersicht zeigt Namen, die Zahlen zeigen nur, wie viele mit Ja, Nein oder Vielleicht geantwortet haben, und die Statistik wertet beides über alle Termine hinweg aus.", + "Your numbers" : "Deine Zahlen", + "_%n appointment_::_%n appointments_" : ["%n Termin","%n Termine"], + "_%n appointment in this period_::_%n appointments in this period_" : ["%n Termin in diesem Zeitraum","%n Termine in diesem Zeitraum"], + "_%n person_::_%n people_" : ["%n Person","%n Personen"], + "_The evaluation handles up to %n appointment at a time. Please choose a shorter period._::_The evaluation handles up to %n appointments at a time. Please choose a shorter period._" : ["Die Auswertung verarbeitet höchstens %n Termin auf einmal. Bitte einen kürzeren Zeitraum wählen.","Die Auswertung verarbeitet höchstens %n Termine auf einmal. Bitte einen kürzeren Zeitraum wählen."], + "Without this, users only see their own numbers." : "Ohne diese Berechtigung sind nur die eigenen Zahlen sichtbar.", + "You were not invited to any appointment in this period" : "Du warst in diesem Zeitraum zu keinem Termin eingeladen", + "Grouping" : "Gruppierung", + "Grouped" : "Gruppiert", + "Ungrouped" : "Ohne Gruppierung", + "Detail" : "Detailgrad", + "Compact" : "Kompakt", + "Full" : "Vollständig" }, "nplurals=2; plural=(n != 1);"); diff --git a/l10n/de.json b/l10n/de.json index f4eb2a23..fa620750 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -460,7 +460,6 @@ "Someone has to create the appointments people reply to. Usually that is a small group — a board, a team of trainers, the office." : "Jemand muss die Termine anlegen, auf die geantwortet wird. Meist ist das ein kleiner Kreis — ein Vorstand, ein Trainerteam, das Büro.", "At the appointment itself, check-in records who really showed up. The check-in screen shows every attendee with a single tick per person." : "Beim Termin selbst hält der Check-in fest, wer wirklich da war. Die Check-in-Ansicht zeigt jeden Teilnehmer mit einem einzigen Haken pro Person.", "Responses" : "Antworten", - "Who gets to see the replies of everyone else? The detailed summary shows names, the counts show only how many said yes, no or maybe." : "Wer darf die Antworten der anderen sehen? Die ausführliche Übersicht zeigt Namen, die Zahlen zeigen nur, wie viele mit Ja, Nein oder Vielleicht geantwortet haben.", "Audit log" : "Auditprotokoll", "Attendance can record every reply, change, withdrawal and check-in — who did what, when, and from where. The timeline shows up on each appointment, and it is what lets managers get a push notification when somebody changes their mind." : "Anwesenheit kann jede Antwort, Änderung, Rücknahme und jeden Check-in festhalten — wer was wann und von wo aus getan hat. Der Verlauf erscheint bei jedem Termin und ist die Grundlage dafür, dass Verantwortliche eine Push-Benachrichtigung bekommen, wenn jemand es sich anders überlegt.", "Response summary groups" : "Gruppen in Antwortübersicht", @@ -877,6 +876,59 @@ "_{count} attendee not yet checked in_::_{count} attendees not yet checked in_" : ["{count} Teilnehmer noch nicht eingecheckt","{count} Teilnehmer noch nicht eingecheckt"], "_{count} reminder sent_::_{count} reminders sent_" : ["{count} Erinnerung gesendet","{count} Erinnerungen gesendet"], "_This appointment is part of a series of {count} appointment. How would you like to apply this change?_::_This appointment is part of a series of {count} appointments. How would you like to apply this change?_" : ["Dieser Termin ist Teil einer Reihe von {count} Termin. Worauf soll diese Änderung angewendet werden?","Dieser Termin ist Teil einer Reihe von {count} Terminen. Worauf soll diese Änderung angewendet werden?"], - "_Do you want to set {count} user to {action}?_::_Do you want to set {count} users to {action}?_" : ["Möchtest du {count} Benutzer auf {action} festlegen?","Möchtest du {count} Benutzer auf {action} festlegen?"] + "_Do you want to set {count} user to {action}?_::_Do you want to set {count} users to {action}?_" : ["Möchtest du {count} Benutzer auf {action} festlegen?","Möchtest du {count} Benutzer auf {action} festlegen?"], + "Statistics" : "Statistik", + "Acceptance rate" : "Zusagequote", + "Attendance rate" : "Anwesenheitsquote", + "Response rate" : "Antwortquote", + "No-show" : "Nicht erschienen", + "Not recorded" : "Nicht erfasst", + "Said yes but was recorded as absent" : "Hat zugesagt, wurde aber als abwesend erfasst", + "Said no but attended" : "Hat abgesagt, war aber da", + "Total" : "Gesamt", + "Name" : "Name", + "Guest" : "Gast", + "People" : "Personen", + "Groups" : "Gruppen", + "Teams" : "Teams", + "Group by" : "Gruppieren nach", + "Period" : "Zeitraum", + "Range" : "Zeitspanne", + "Year" : "Jahr", + "Half-year" : "Halbjahr", + "Quarter" : "Quartal", + "Custom" : "Benutzerdefiniert", + "Q{quarter} {year}" : "Q{quarter} {year}", + "H{half} {year}" : "H{half} {year}", + "Over time" : "Zeitlicher Verlauf", + "By group" : "Nach Gruppe", + "By team" : "Nach Team", + "By category" : "Nach Kategorie", + "Without category" : "Ohne Kategorie", + "Search people" : "Personen suchen", + "No appointments in this period" : "Keine Termine in diesem Zeitraum", + "Too many appointments in this period" : "Zu viele Termine in diesem Zeitraum", + "Attendance recorded for {recorded} of {past} past appointments" : "Anwesenheit bei {recorded} von {past} vergangenen Terminen erfasst", + "People in several groups are counted in each of them. The total row counts every person once." : "Personen in mehreren Gruppen zählen in jeder davon mit. Die Gesamtzeile zählt jede Person einmal.", + "Failed to load statistics" : "Statistik konnte nicht geladen werden", + "Failed to export statistics" : "Statistik konnte nicht exportiert werden", + "May see everyone's statistics" : "Darf die Statistik aller sehen", + "See the evaluation of responses and attendance across all appointments, per person." : "Die Auswertung von Antworten und Anwesenheit über alle Termine hinweg sehen, pro Person.", + "Every user can see how often each person responded and attended." : "Jeder Nutzer kann sehen, wie oft jede Person geantwortet hat und anwesend war.", + "Who may see the evaluation across all appointments" : "Wer die Auswertung über alle Termine sieht", + "Who gets to see the replies of everyone else? The detailed summary shows names, the counts show only how many said yes, no or maybe, and the statistics evaluate both across all appointments." : "Wer darf die Antworten der anderen sehen? Die ausführliche Übersicht zeigt Namen, die Zahlen zeigen nur, wie viele mit Ja, Nein oder Vielleicht geantwortet haben, und die Statistik wertet beides über alle Termine hinweg aus.", + "Your numbers" : "Deine Zahlen", + "_%n appointment_::_%n appointments_" : ["%n Termin","%n Termine"], + "_%n appointment in this period_::_%n appointments in this period_" : ["%n Termin in diesem Zeitraum","%n Termine in diesem Zeitraum"], + "_%n person_::_%n people_" : ["%n Person","%n Personen"], + "_The evaluation handles up to %n appointment at a time. Please choose a shorter period._::_The evaluation handles up to %n appointments at a time. Please choose a shorter period._" : ["Die Auswertung verarbeitet höchstens %n Termin auf einmal. Bitte einen kürzeren Zeitraum wählen.","Die Auswertung verarbeitet höchstens %n Termine auf einmal. Bitte einen kürzeren Zeitraum wählen."], + "Without this, users only see their own numbers." : "Ohne diese Berechtigung sind nur die eigenen Zahlen sichtbar.", + "You were not invited to any appointment in this period" : "Du warst in diesem Zeitraum zu keinem Termin eingeladen", + "Grouping" : "Gruppierung", + "Grouped" : "Gruppiert", + "Ungrouped" : "Ohne Gruppierung", + "Detail" : "Detailgrad", + "Compact" : "Kompakt", + "Full" : "Vollständig" },"pluralForm" :"nplurals=2; plural=(n != 1);" } \ No newline at end of file diff --git a/l10n/de_DE.js b/l10n/de_DE.js index 0a9eff85..feb00f24 100644 --- a/l10n/de_DE.js +++ b/l10n/de_DE.js @@ -462,7 +462,6 @@ OC.L10N.register( "Someone has to create the appointments people reply to. Usually that is a small group — a board, a team of trainers, the office." : "Jemand muss die Termine anlegen, auf die geantwortet wird. Meist ist das ein kleiner Kreis — ein Vorstand, ein Trainerteam, das Büro.", "At the appointment itself, check-in records who really showed up. The check-in screen shows every attendee with a single tick per person." : "Beim Termin selbst hält der Check-in fest, wer wirklich da war. Die Check-in-Ansicht zeigt jeden Teilnehmer mit einem einzigen Haken pro Person.", "Responses" : "Antworten", - "Who gets to see the replies of everyone else? The detailed summary shows names, the counts show only how many said yes, no or maybe." : "Wer darf die Antworten der anderen sehen? Die ausführliche Übersicht zeigt Namen, die Zahlen zeigen nur, wie viele mit Ja, Nein oder Vielleicht geantwortet haben.", "Audit log" : "Auditprotokoll", "Attendance can record every reply, change, withdrawal and check-in — who did what, when, and from where. The timeline shows up on each appointment, and it is what lets managers get a push notification when somebody changes their mind." : "Anwesenheit kann jede Antwort, Änderung, Rücknahme und jeden Check-in festhalten — wer was wann und von wo aus getan hat. Der Verlauf erscheint bei jedem Termin und ist die Grundlage dafür, dass Verantwortliche eine Push-Benachrichtigung bekommen, wenn jemand es sich anders überlegt.", "Response summary groups" : "Gruppen in Antwortübersicht", @@ -879,6 +878,59 @@ OC.L10N.register( "_{count} attendee not yet checked in_::_{count} attendees not yet checked in_" : ["{count} Teilnehmer noch nicht eingecheckt","{count} Teilnehmer noch nicht eingecheckt"], "_{count} reminder sent_::_{count} reminders sent_" : ["{count} Erinnerung gesendet","{count} Erinnerungen gesendet"], "_This appointment is part of a series of {count} appointment. How would you like to apply this change?_::_This appointment is part of a series of {count} appointments. How would you like to apply this change?_" : ["Dieser Termin ist Teil einer Reihe von {count} Termin. Worauf soll diese Änderung angewendet werden?","Dieser Termin ist Teil einer Reihe von {count} Terminen. Worauf soll diese Änderung angewendet werden?"], - "_Do you want to set {count} user to {action}?_::_Do you want to set {count} users to {action}?_" : ["Möchten Sie {count} Benutzer auf {action} festlegen?","Möchten Sie {count} Benutzer auf {action} festlegen?"] + "_Do you want to set {count} user to {action}?_::_Do you want to set {count} users to {action}?_" : ["Möchten Sie {count} Benutzer auf {action} festlegen?","Möchten Sie {count} Benutzer auf {action} festlegen?"], + "Statistics" : "Statistik", + "Acceptance rate" : "Zusagequote", + "Attendance rate" : "Anwesenheitsquote", + "Response rate" : "Antwortquote", + "No-show" : "Nicht erschienen", + "Not recorded" : "Nicht erfasst", + "Said yes but was recorded as absent" : "Hat zugesagt, wurde aber als abwesend erfasst", + "Said no but attended" : "Hat abgesagt, war aber da", + "Total" : "Gesamt", + "Name" : "Name", + "Guest" : "Gast", + "People" : "Personen", + "Groups" : "Gruppen", + "Teams" : "Teams", + "Group by" : "Gruppieren nach", + "Period" : "Zeitraum", + "Range" : "Zeitspanne", + "Year" : "Jahr", + "Half-year" : "Halbjahr", + "Quarter" : "Quartal", + "Custom" : "Benutzerdefiniert", + "Q{quarter} {year}" : "Q{quarter} {year}", + "H{half} {year}" : "H{half} {year}", + "Over time" : "Zeitlicher Verlauf", + "By group" : "Nach Gruppe", + "By team" : "Nach Team", + "By category" : "Nach Kategorie", + "Without category" : "Ohne Kategorie", + "Search people" : "Personen suchen", + "No appointments in this period" : "Keine Termine in diesem Zeitraum", + "Too many appointments in this period" : "Zu viele Termine in diesem Zeitraum", + "Attendance recorded for {recorded} of {past} past appointments" : "Anwesenheit bei {recorded} von {past} vergangenen Terminen erfasst", + "People in several groups are counted in each of them. The total row counts every person once." : "Personen in mehreren Gruppen zählen in jeder davon mit. Die Gesamtzeile zählt jede Person einmal.", + "Failed to load statistics" : "Statistik konnte nicht geladen werden", + "Failed to export statistics" : "Statistik konnte nicht exportiert werden", + "May see everyone's statistics" : "Darf die Statistik aller sehen", + "See the evaluation of responses and attendance across all appointments, per person." : "Die Auswertung von Antworten und Anwesenheit über alle Termine hinweg sehen, pro Person.", + "Every user can see how often each person responded and attended." : "Jeder Nutzer kann sehen, wie oft jede Person geantwortet hat und anwesend war.", + "Who may see the evaluation across all appointments" : "Wer die Auswertung über alle Termine sieht", + "Who gets to see the replies of everyone else? The detailed summary shows names, the counts show only how many said yes, no or maybe, and the statistics evaluate both across all appointments." : "Wer darf die Antworten der anderen sehen? Die ausführliche Übersicht zeigt Namen, die Zahlen zeigen nur, wie viele mit Ja, Nein oder Vielleicht geantwortet haben, und die Statistik wertet beides über alle Termine hinweg aus.", + "Your numbers" : "Ihre Zahlen", + "_%n appointment_::_%n appointments_" : ["%n Termin","%n Termine"], + "_%n appointment in this period_::_%n appointments in this period_" : ["%n Termin in diesem Zeitraum","%n Termine in diesem Zeitraum"], + "_%n person_::_%n people_" : ["%n Person","%n Personen"], + "_The evaluation handles up to %n appointment at a time. Please choose a shorter period._::_The evaluation handles up to %n appointments at a time. Please choose a shorter period._" : ["Die Auswertung verarbeitet höchstens %n Termin auf einmal. Bitte einen kürzeren Zeitraum wählen.","Die Auswertung verarbeitet höchstens %n Termine auf einmal. Bitte einen kürzeren Zeitraum wählen."], + "Without this, users only see their own numbers." : "Ohne diese Berechtigung sind nur die eigenen Zahlen sichtbar.", + "You were not invited to any appointment in this period" : "Sie waren in diesem Zeitraum zu keinem Termin eingeladen", + "Grouping" : "Gruppierung", + "Grouped" : "Gruppiert", + "Ungrouped" : "Ohne Gruppierung", + "Detail" : "Detailgrad", + "Compact" : "Kompakt", + "Full" : "Vollständig" }, "nplurals=2; plural=(n != 1);"); diff --git a/l10n/de_DE.json b/l10n/de_DE.json index 52bc7d13..6f665832 100644 --- a/l10n/de_DE.json +++ b/l10n/de_DE.json @@ -460,7 +460,6 @@ "Someone has to create the appointments people reply to. Usually that is a small group — a board, a team of trainers, the office." : "Jemand muss die Termine anlegen, auf die geantwortet wird. Meist ist das ein kleiner Kreis — ein Vorstand, ein Trainerteam, das Büro.", "At the appointment itself, check-in records who really showed up. The check-in screen shows every attendee with a single tick per person." : "Beim Termin selbst hält der Check-in fest, wer wirklich da war. Die Check-in-Ansicht zeigt jeden Teilnehmer mit einem einzigen Haken pro Person.", "Responses" : "Antworten", - "Who gets to see the replies of everyone else? The detailed summary shows names, the counts show only how many said yes, no or maybe." : "Wer darf die Antworten der anderen sehen? Die ausführliche Übersicht zeigt Namen, die Zahlen zeigen nur, wie viele mit Ja, Nein oder Vielleicht geantwortet haben.", "Audit log" : "Auditprotokoll", "Attendance can record every reply, change, withdrawal and check-in — who did what, when, and from where. The timeline shows up on each appointment, and it is what lets managers get a push notification when somebody changes their mind." : "Anwesenheit kann jede Antwort, Änderung, Rücknahme und jeden Check-in festhalten — wer was wann und von wo aus getan hat. Der Verlauf erscheint bei jedem Termin und ist die Grundlage dafür, dass Verantwortliche eine Push-Benachrichtigung bekommen, wenn jemand es sich anders überlegt.", "Response summary groups" : "Gruppen in Antwortübersicht", @@ -877,6 +876,59 @@ "_{count} attendee not yet checked in_::_{count} attendees not yet checked in_" : ["{count} Teilnehmer noch nicht eingecheckt","{count} Teilnehmer noch nicht eingecheckt"], "_{count} reminder sent_::_{count} reminders sent_" : ["{count} Erinnerung gesendet","{count} Erinnerungen gesendet"], "_This appointment is part of a series of {count} appointment. How would you like to apply this change?_::_This appointment is part of a series of {count} appointments. How would you like to apply this change?_" : ["Dieser Termin ist Teil einer Reihe von {count} Termin. Worauf soll diese Änderung angewendet werden?","Dieser Termin ist Teil einer Reihe von {count} Terminen. Worauf soll diese Änderung angewendet werden?"], - "_Do you want to set {count} user to {action}?_::_Do you want to set {count} users to {action}?_" : ["Möchten Sie {count} Benutzer auf {action} festlegen?","Möchten Sie {count} Benutzer auf {action} festlegen?"] + "_Do you want to set {count} user to {action}?_::_Do you want to set {count} users to {action}?_" : ["Möchten Sie {count} Benutzer auf {action} festlegen?","Möchten Sie {count} Benutzer auf {action} festlegen?"], + "Statistics" : "Statistik", + "Acceptance rate" : "Zusagequote", + "Attendance rate" : "Anwesenheitsquote", + "Response rate" : "Antwortquote", + "No-show" : "Nicht erschienen", + "Not recorded" : "Nicht erfasst", + "Said yes but was recorded as absent" : "Hat zugesagt, wurde aber als abwesend erfasst", + "Said no but attended" : "Hat abgesagt, war aber da", + "Total" : "Gesamt", + "Name" : "Name", + "Guest" : "Gast", + "People" : "Personen", + "Groups" : "Gruppen", + "Teams" : "Teams", + "Group by" : "Gruppieren nach", + "Period" : "Zeitraum", + "Range" : "Zeitspanne", + "Year" : "Jahr", + "Half-year" : "Halbjahr", + "Quarter" : "Quartal", + "Custom" : "Benutzerdefiniert", + "Q{quarter} {year}" : "Q{quarter} {year}", + "H{half} {year}" : "H{half} {year}", + "Over time" : "Zeitlicher Verlauf", + "By group" : "Nach Gruppe", + "By team" : "Nach Team", + "By category" : "Nach Kategorie", + "Without category" : "Ohne Kategorie", + "Search people" : "Personen suchen", + "No appointments in this period" : "Keine Termine in diesem Zeitraum", + "Too many appointments in this period" : "Zu viele Termine in diesem Zeitraum", + "Attendance recorded for {recorded} of {past} past appointments" : "Anwesenheit bei {recorded} von {past} vergangenen Terminen erfasst", + "People in several groups are counted in each of them. The total row counts every person once." : "Personen in mehreren Gruppen zählen in jeder davon mit. Die Gesamtzeile zählt jede Person einmal.", + "Failed to load statistics" : "Statistik konnte nicht geladen werden", + "Failed to export statistics" : "Statistik konnte nicht exportiert werden", + "May see everyone's statistics" : "Darf die Statistik aller sehen", + "See the evaluation of responses and attendance across all appointments, per person." : "Die Auswertung von Antworten und Anwesenheit über alle Termine hinweg sehen, pro Person.", + "Every user can see how often each person responded and attended." : "Jeder Nutzer kann sehen, wie oft jede Person geantwortet hat und anwesend war.", + "Who may see the evaluation across all appointments" : "Wer die Auswertung über alle Termine sieht", + "Who gets to see the replies of everyone else? The detailed summary shows names, the counts show only how many said yes, no or maybe, and the statistics evaluate both across all appointments." : "Wer darf die Antworten der anderen sehen? Die ausführliche Übersicht zeigt Namen, die Zahlen zeigen nur, wie viele mit Ja, Nein oder Vielleicht geantwortet haben, und die Statistik wertet beides über alle Termine hinweg aus.", + "Your numbers" : "Ihre Zahlen", + "_%n appointment_::_%n appointments_" : ["%n Termin","%n Termine"], + "_%n appointment in this period_::_%n appointments in this period_" : ["%n Termin in diesem Zeitraum","%n Termine in diesem Zeitraum"], + "_%n person_::_%n people_" : ["%n Person","%n Personen"], + "_The evaluation handles up to %n appointment at a time. Please choose a shorter period._::_The evaluation handles up to %n appointments at a time. Please choose a shorter period._" : ["Die Auswertung verarbeitet höchstens %n Termin auf einmal. Bitte einen kürzeren Zeitraum wählen.","Die Auswertung verarbeitet höchstens %n Termine auf einmal. Bitte einen kürzeren Zeitraum wählen."], + "Without this, users only see their own numbers." : "Ohne diese Berechtigung sind nur die eigenen Zahlen sichtbar.", + "You were not invited to any appointment in this period" : "Sie waren in diesem Zeitraum zu keinem Termin eingeladen", + "Grouping" : "Gruppierung", + "Grouped" : "Gruppiert", + "Ungrouped" : "Ohne Gruppierung", + "Detail" : "Detailgrad", + "Compact" : "Kompakt", + "Full" : "Vollständig" },"pluralForm" :"nplurals=2; plural=(n != 1);" } \ No newline at end of file diff --git a/lib/Controller/AppointmentController.php b/lib/Controller/AppointmentController.php index 0a9dda32..fab41559 100644 --- a/lib/Controller/AppointmentController.php +++ b/lib/Controller/AppointmentController.php @@ -903,6 +903,8 @@ public function getPermissions(): DataResponse { 'canSeeComments' => $this->permissionService->canSeeComments($user->getUID()), 'canSelfCheckin' => $this->permissionService->canSelfCheckin($user->getUID()), 'canRespondForOthers' => $this->permissionService->canRespondForOthers($user->getUID()), + 'canSeeStatistics' => $this->permissionService->canSeeStatistics($user->getUID()), + 'canSeeIndividualResponses' => $this->permissionService->canSeeIndividualResponses($user->getUID()), ]); } @@ -968,6 +970,10 @@ public function getCapabilities(): DataResponse { // categoryId on appointments). Mobile clients hide the category // picker/badge/filter when this is false. 'categoriesAvailable' => true, + // Server supports the cross-appointment evaluation (GET + // /statistics, see_statistics permission). Clients hide the + // statistics entry point when this is false. + 'statisticsAvailable' => true, ]); } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index d0437903..cc23ae6d 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -68,6 +68,16 @@ public function all(): TemplateResponse { ); } + #[NoCSRFRequired] + #[NoAdminRequired] + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] + public function statistics(): TemplateResponse { + return new TemplateResponse( + Application::APP_ID, + 'index', + ); + } + #[NoCSRFRequired] #[NoAdminRequired] #[OpenAPI(OpenAPI::SCOPE_IGNORE)] diff --git a/lib/Controller/StatisticsController.php b/lib/Controller/StatisticsController.php new file mode 100644 index 00000000..91c8045e --- /dev/null +++ b/lib/Controller/StatisticsController.php @@ -0,0 +1,197 @@ + $categoryIds Categories to include; empty means every category + * @param bool $includeUncategorized Also include appointments without a category + * @param string $groupBy Section axis: `groups` or `teams` + * @return DataResponse|DataResponse|DataResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[OpenAPI] + public function index( + ?string $startDate = null, + ?string $endDate = null, + array $categoryIds = [], + bool $includeUncategorized = false, + string $groupBy = StatisticsFilter::GROUP_BY_GROUPS, + ): DataResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $filter = $this->filterFrom($startDate, $endDate, $categoryIds, $includeUncategorized, $groupBy); + + $limitToUserId = $this->permissionService->canSeeStatistics($user->getUID()) + ? null + : $user->getUID(); + + try { + return new DataResponse($this->statisticsService->getStatistics($filter, $limitToUserId)); + } catch (StatisticsRangeException $e) { + return $this->tooManyAppointments($e); + } + } + + /** + * List one person's appointments behind their statistics row + * + * @param string $targetUserId The person to drill into + * @param ?string $startDate Start of the range (Y-m-d, inclusive) + * @param ?string $endDate End of the range (Y-m-d, inclusive) + * @param list $categoryIds Categories to include; empty means every category + * @param bool $includeUncategorized Also include appointments without a category + * @return DataResponse|DataResponse|DataResponse|DataResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[OpenAPI] + public function person( + string $targetUserId, + ?string $startDate = null, + ?string $endDate = null, + array $categoryIds = [], + bool $includeUncategorized = false, + ): DataResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + if ($targetUserId !== $user->getUID() && !$this->permissionService->canSeeStatistics($user->getUID())) { + return new DataResponse(['error' => 'Insufficient permissions'], Http::STATUS_FORBIDDEN); + } + + // The drill-down names what somebody answered per appointment, which is + // what the response overview governs — the aggregate row is not enough. + if (!$this->permissionService->canSeeIndividualResponses($user->getUID())) { + return new DataResponse(['error' => 'Insufficient permissions'], Http::STATUS_FORBIDDEN); + } + + $filter = $this->filterFrom($startDate, $endDate, $categoryIds, $includeUncategorized); + + // Own comments are the viewer's own words; anyone else's follow the same + // permission that governs them in the response overview. + $withComments = $targetUserId === $user->getUID() + || $this->permissionService->canSeeComments($user->getUID()); + + try { + return new DataResponse($this->statisticsService->getPersonDetail($filter, $targetUserId, $withComments)); + } catch (StatisticsRangeException $e) { + return $this->tooManyAppointments($e); + } + } + + /** + * Export the statistics table as an ODS file + * + * @param ?string $startDate Start of the range (Y-m-d, inclusive) + * @param ?string $endDate End of the range (Y-m-d, inclusive) + * @param list $categoryIds Categories to include; empty means every category + * @param bool $includeUncategorized Also include appointments without a category + * @param string $groupBy Section axis: `groups` or `teams` + * @return DataResponse|DataResponse|DataResponse|DataResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[OpenAPI] + public function export( + ?string $startDate = null, + ?string $endDate = null, + array $categoryIds = [], + bool $includeUncategorized = false, + string $groupBy = StatisticsFilter::GROUP_BY_GROUPS, + ): DataResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + // The file holds every person's numbers, so unlike the view it has no + // reduced shape — without the permission there is nothing to export. + if (!$this->permissionService->canSeeStatistics($user->getUID())) { + return new DataResponse(['error' => 'Insufficient permissions'], Http::STATUS_FORBIDDEN); + } + + $filter = $this->filterFrom($startDate, $endDate, $categoryIds, $includeUncategorized, $groupBy); + + try { + return new DataResponse($this->statisticsExportService->exportToOds($user->getUID(), $filter)); + } catch (\Exception $e) { + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + + /** + * @param list $categoryIds + */ + private function filterFrom( + ?string $startDate, + ?string $endDate, + array $categoryIds, + bool $includeUncategorized, + string $groupBy = StatisticsFilter::GROUP_BY_GROUPS, + ): StatisticsFilter { + return StatisticsFilter::fromWire( + $startDate, + $endDate, + array_map('intval', $categoryIds), + $includeUncategorized, + $groupBy, + ); + } + + /** + * @return DataResponse + */ + private function tooManyAppointments(StatisticsRangeException $e): DataResponse { + return new DataResponse([ + 'error' => 'Too many appointments in range', + 'found' => $e->getFound(), + 'limit' => $e->getLimit(), + ], Http::STATUS_BAD_REQUEST); + } +} diff --git a/lib/Db/AppointmentMapper.php b/lib/Db/AppointmentMapper.php index 7d21415d..275cfa08 100644 --- a/lib/Db/AppointmentMapper.php +++ b/lib/Db/AppointmentMapper.php @@ -482,4 +482,56 @@ public function findForExport(?array $appointmentIds = null, ?string $startDate return $this->findEntities($qb); } + + /** + * Appointments the statistics evaluate: active, not cancelled, within the + * date range and matching the category filter. + * + * @param list $categoryIds Categories to include; empty means no category filter + * @param bool $includeUncategorized Also include appointments without a category + * @param ?int $limit Stop after this many rows, so an over-large range is + * detected without hydrating everything behind it + * @return list + */ + public function findForStatistics( + ?string $startDate, + ?string $endDate, + array $categoryIds = [], + bool $includeUncategorized = false, + ?int $limit = null, + ): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('is_active', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNull('cancelled_at')); + + if ($startDate !== null) { + $qb->andWhere($qb->expr()->gte('start_datetime', $qb->createNamedParameter($startDate . ' 00:00:00'))); + } + + if ($endDate !== null) { + $qb->andWhere($qb->expr()->lte('start_datetime', $qb->createNamedParameter($endDate . ' 23:59:59'))); + } + + $categoryFilters = []; + if ($categoryIds !== []) { + $categoryFilters[] = $qb->expr()->in('category_id', $qb->createNamedParameter($categoryIds, IQueryBuilder::PARAM_INT_ARRAY)); + } + if ($includeUncategorized) { + $categoryFilters[] = $qb->expr()->isNull('category_id'); + } + if ($categoryFilters !== []) { + $qb->andWhere($qb->expr()->orX(...$categoryFilters)); + } + + $qb->orderBy('start_datetime', 'ASC'); + + if ($limit !== null) { + $qb->setMaxResults($limit); + } + + return $this->findEntities($qb); + } } diff --git a/lib/Db/AttendanceResponseMapper.php b/lib/Db/AttendanceResponseMapper.php index f14fc4a9..2f3d8a19 100644 --- a/lib/Db/AttendanceResponseMapper.php +++ b/lib/Db/AttendanceResponseMapper.php @@ -6,6 +6,7 @@ use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; /** @@ -71,6 +72,87 @@ public function findByAppointment(int $appointmentId): array { return $this->findEntities($qb); } + /** + * The few columns the statistics evaluate, for a set of appointments, in + * one query. Deliberately not entities: at the 1000-appointment cap this + * can be hundreds of thousands of rows, and hydrating all 15 columns of + * each costs far more than the evaluation itself. + * + * @param list $appointmentIds + * @param ?string $userId Restrict to one person, for the drill-down + * @param bool $withComments Read the comment column too — only the drill-down can afford to, it being one person's rows + * @return list + */ + public function findStatisticsRows(array $appointmentIds, ?string $userId = null, bool $withComments = false): array { + if ($appointmentIds === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('appointment_id', 'user_id', 'response', 'checkin_state') + ->from($this->getTableName()) + ->where( + $qb->expr()->in('appointment_id', $qb->createNamedParameter($appointmentIds, IQueryBuilder::PARAM_INT_ARRAY)) + ); + + if ($withComments) { + $qb->addSelect('comment'); + } + + if ($userId !== null) { + $qb->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))); + } + + $result = $qb->executeQuery(); + /** @var list> $raw */ + $raw = $result->fetchAll(); + $rows = []; + foreach ($raw as $row) { + $mapped = [ + 'appointmentId' => (int)$row['appointment_id'], + 'userId' => (string)$row['user_id'], + 'response' => $row['response'] !== null ? (string)$row['response'] : null, + 'checkinState' => $row['checkin_state'] !== null ? (string)$row['checkin_state'] : null, + ]; + if ($withComments) { + $mapped['comment'] = isset($row['comment']) ? (string)$row['comment'] : null; + } + $rows[] = $mapped; + } + $result->closeCursor(); + + return $rows; + } + + /** + * Which of these appointments had their check-in list worked at all. + * + * @param list $appointmentIds + * @return list + */ + public function findAppointmentIdsWithCheckins(array $appointmentIds): array { + if ($appointmentIds === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->selectDistinct('appointment_id') + ->from($this->getTableName()) + ->where( + $qb->expr()->in('appointment_id', $qb->createNamedParameter($appointmentIds, IQueryBuilder::PARAM_INT_ARRAY)) + ) + ->andWhere( + $qb->expr()->in('checkin_state', $qb->createNamedParameter(['yes', 'no'], IQueryBuilder::PARAM_STR_ARRAY)) + ); + + $result = $qb->executeQuery(); + /** @var list> $raw */ + $raw = $result->fetchAll(); + $result->closeCursor(); + + return array_map(static fn (array $row): int => (int)$row['appointment_id'], $raw); + } + /** * @param string $userId * @return array diff --git a/lib/Migration/Version000021Date20260808120000.php b/lib/Migration/Version000021Date20260808120000.php new file mode 100644 index 00000000..e1c9353a --- /dev/null +++ b/lib/Migration/Version000021Date20260808120000.php @@ -0,0 +1,87 @@ +appConfig->getValueString(self::APP_ID, $targetModeKey), self::MODES, true)) { + return; + } + + $roles = array_values(array_filter( + $this->appConfig->getValueArray(self::APP_ID, 'permission_' . self::SOURCE), + 'is_string', + )); + + $mode = $this->appConfig->getValueString(self::APP_ID, 'permission_' . self::SOURCE . '_mode'); + if (!in_array($mode, self::MODES, true)) { + $mode = $roles !== [] ? 'groups' : 'all'; + } + + if ($mode !== 'groups' || $roles === []) { + $this->appConfig->setValueString(self::APP_ID, $targetModeKey, 'nobody'); + return; + } + + // Written through IConfig like PermissionService does — the typed + // setter would mark the key as an array and collide on the next save. + $this->config->setAppValue( + self::APP_ID, + 'permission_' . self::TARGET, + json_encode($roles, JSON_THROW_ON_ERROR), + ); + $this->appConfig->setValueString(self::APP_ID, $targetModeKey, 'groups'); + + $output->info('Seeded see_statistics from manage_appointments (' . count($roles) . ' group(s))'); + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index b59cc95b..63893312 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -162,6 +162,8 @@ * canSeeComments: bool, * canSelfCheckin: bool, * canRespondForOthers: bool, + * canSeeStatistics: bool, + * canSeeIndividualResponses: bool, * } * @psalm-type AttendanceCapabilities = array{ * calendarAvailable: bool, @@ -183,6 +185,7 @@ * responseCounts: bool, * locationsAvailable: bool, * categoriesAvailable: bool, + * statisticsAvailable: bool, * } * @psalm-type AttendanceAuditUserRef = array{ * userId: string, @@ -319,6 +322,104 @@ * sent: int, * appointmentName: string, * } + * @psalm-type AttendanceStatisticsPerson = array{ + * userId: string, + * displayName: string, + * isGuest: bool, + * sections: list, + * targetCount: int, + * yes: int, + * no: int, + * maybe: int, + * noResponse: int, + * present: int, + * absent: int, + * notRecorded: int, + * attendanceBase: int, + * noShow: int, + * responseRate: ?float, + * acceptRate: ?float, + * attendanceRate: ?float, + * } + * @psalm-type AttendanceStatisticsTotals = array{ + * targetCount: int, + * yes: int, + * no: int, + * maybe: int, + * noResponse: int, + * present: int, + * absent: int, + * notRecorded: int, + * attendanceBase: int, + * noShow: int, + * responseRate: ?float, + * acceptRate: ?float, + * attendanceRate: ?float, + * } + * @psalm-type AttendanceStatisticsSection = array{ + * id: string, + * displayName: string, + * personCount: int, + * targetCount: int, + * yes: int, + * no: int, + * maybe: int, + * noResponse: int, + * present: int, + * absent: int, + * notRecorded: int, + * attendanceBase: int, + * noShow: int, + * responseRate: ?float, + * acceptRate: ?float, + * attendanceRate: ?float, + * } + * @psalm-type AttendanceStatisticsTimelinePoint = array{ + * appointmentId: int, + * name: string, + * startDatetime: ?string, + * targetCount: int, + * yes: int, + * present: int, + * attendanceRecorded: bool, + * } + * @psalm-type AttendanceStatisticsCategory = array{ + * categoryId: ?int, + * displayName: string, + * appointmentCount: int, + * targetCount: int, + * yes: int, + * present: int, + * attendanceBase: int, + * acceptRate: ?float, + * attendanceRate: ?float, + * } + * @psalm-type AttendanceStatistics = array{ + * appointmentCount: int, + * pastCount: int, + * attendanceRecordedCount: int, + * groupBy: string, + * people: list, + * sections: list, + * totals: AttendanceStatisticsTotals, + * timeline: list, + * byCategory: list, + * } + * @psalm-type AttendanceStatisticsPersonEntry = array{ + * appointmentId: int, + * name: string, + * startDatetime: ?string, + * response: ?string, + * checkinState: ?string, + * attendanceRecorded: bool, + * comment: ?string, + * } + * @psalm-type AttendanceStatisticsPersonDetail = array{ + * userId: string, + * displayName: string, + * isGuest: bool, + * entries: list, + * } */ class ResponseDefinitions { } diff --git a/lib/Service/ExportService.php b/lib/Service/ExportService.php index d075582a..0f6286d9 100644 --- a/lib/Service/ExportService.php +++ b/lib/Service/ExportService.php @@ -20,6 +20,7 @@ class ExportService { private IUserManager $userManager; private IGroupManager $groupManager; private ConfigService $configService; + private OdsWriter $odsWriter; private IL10N $l10n; public function __construct( @@ -29,6 +30,7 @@ public function __construct( IUserManager $userManager, IGroupManager $groupManager, ConfigService $configService, + OdsWriter $odsWriter, IL10N $l10n, ) { $this->appointmentMapper = $appointmentMapper; @@ -37,6 +39,7 @@ public function __construct( $this->userManager = $userManager; $this->groupManager = $groupManager; $this->configService = $configService; + $this->odsWriter = $odsWriter; $this->l10n = $l10n; } @@ -115,7 +118,7 @@ public function exportToOds(string $userId, ?array $appointmentIds = null, ?stri return strcmp($a['displayName'], $b['displayName']); }); - $odsContent = $this->generateOdsContent($appointments, $users, $appointmentResponses, $includeComments); + $odsContent = $this->odsWriter->write($this->getTableXml($appointments, $users, $appointmentResponses, $includeComments)); // Create the Attendance folder try { @@ -167,156 +170,10 @@ public function exportToOds(string $userId, ?array $appointmentIds = null, ?stri } /** - * Generate ODS file content - * - * @param array $appointments Array of Appointment entities - * @param array $users Array of user data - * @param array $appointmentResponses Map of appointment ID to user responses - * @param bool $includeComments Whether to include comment columns - * @return string Binary ODS content - */ - private function generateOdsContent(array $appointments, array $users, array $appointmentResponses, bool $includeComments = false): string { - // Check if ZipArchive extension is available - if (!class_exists('\ZipArchive')) { - throw new \Exception('ZipArchive extension is not available. Please install php-zip extension.'); - } - - // Create temporary file for the ODS - $tempFile = tempnam(sys_get_temp_dir(), 'ods_'); - if ($tempFile === false) { - throw new \Exception('Failed to create temporary file'); - } - - $zip = new \ZipArchive(); - $result = $zip->open($tempFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); - if ($result !== true) { - throw new \Exception('Failed to create ODS file. ZipArchive error code: ' . (int)$result); - } - - // Add mimetype (must be first and uncompressed) - $zip->addFromString('mimetype', 'application/vnd.oasis.opendocument.spreadsheet'); - $zip->setCompressionName('mimetype', \ZipArchive::CM_STORE); - - $zip->addFromString('META-INF/manifest.xml', $this->getManifestXml()); - - // Add content.xml with the table - $zip->addFromString('content.xml', $this->getContentXml($appointments, $users, $appointmentResponses, $includeComments)); - - $zip->addFromString('styles.xml', $this->getStylesXml()); - - $zip->addFromString('meta.xml', $this->getMetaXml()); - - $zip->close(); - - // Read the file content - $content = file_get_contents($tempFile); - if ($content === false) { - throw new \Exception('Failed to read generated ODS file'); - } - - unlink($tempFile); - - return $content; - } - - /** - * Get manifest.xml content - */ - private function getManifestXml(): string { - return ' - - - - - -'; - } - - /** - * Get meta.xml content - */ - private function getMetaXml(): string { - $date = date('Y-m-d\TH:i:s'); - return ' - - - Nextcloud Attendance App - Attendance Export - ' . $date . ' - -'; - } - - /** - * Get styles.xml content - */ - private function getStylesXml(): string { - return ' - - - - - - - - - -'; - } - - /** - * Get content.xml with the table + * Render the export sheet. The document around it comes from OdsWriter. */ - private function getContentXml(array $appointments, array $users, array $appointmentResponses, bool $includeComments = false): string { - $xml = ' - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + private function getTableXml(array $appointments, array $users, array $appointmentResponses, bool $includeComments = false): string { + $xml = ' '; // Calculate number of columns: 2 for Name+Group + (2 or 3 * number of appointments) @@ -333,19 +190,19 @@ private function getContentXml(array $appointments, array $users, array $appoint // Add first header row with appointment names only $xml .= ' - + Name - + ' . $this->l10n->t('Group') . ' '; foreach ($appointments as $appointment) { - $appointmentName = $this->escapeXml($appointment->getName()); + $appointmentName = $this->odsWriter->escape($appointment->getName()); // Add merged cell for appointment name spanning correct number of columns $xml .= ' - + ' . $appointmentName . ' '; @@ -361,21 +218,21 @@ private function getContentXml(array $appointments, array $users, array $appoint // Add second header row with dates $xml .= ' - + - + '; foreach ($appointments as $appointment) { $startDate = date('Y-m-d', strtotime($appointment->getStartDatetime())); $location = $appointment->getLocation(); - $dateLabel = $location !== null ? $startDate . ' · ' . $this->escapeXml($location) : $startDate; + $dateLabel = $location !== null ? $startDate . ' · ' . $this->odsWriter->escape($location) : $startDate; // Add date (+ location, when set) merged cell spanning correct number of columns $xml .= ' - + ' . $dateLabel . ' '; @@ -391,25 +248,25 @@ private function getContentXml(array $appointments, array $users, array $appoint // Add third header row with RSVP and CheckIn labels $xml .= ' - + - + '; foreach ($appointments as $appointment) { $xml .= ' - + RSVP - + CheckIn '; if ($includeComments) { $xml .= ' - + Comment '; } @@ -422,11 +279,11 @@ private function getContentXml(array $appointments, array $users, array $appoint foreach ($users as $user) { $xml .= ' - - ' . $this->escapeXml($user['displayName']) . ' + + ' . $this->odsWriter->escape($user['displayName']) . ' - - ' . $this->escapeXml($user['group']) . ' + + ' . $this->odsWriter->escape($user['group']) . ' '; // Add RSVP, CheckIn, and optionally Comment data for each appointment @@ -453,9 +310,9 @@ private function getContentXml(array $appointments, array $users, array $appoint // Comment column (if enabled) if ($includeComments) { - $comment = $response && $response->getComment() ? $this->escapeXml($response->getComment()) : ''; + $comment = $response && $response->getComment() ? $this->odsWriter->escape($response->getComment()) : ''; $xml .= ' - + ' . $comment . ' '; } @@ -466,10 +323,7 @@ private function getContentXml(array $appointments, array $users, array $appoint } $xml .= ' - - - -'; + '; return $xml; } @@ -500,23 +354,16 @@ private function formatResponse(?string $response): string { private function getResponseCellStyle(?string $response): string { switch ($response) { case 'yes': - return 'ce-yes'; + return OdsWriter::STYLE_YES; case 'no': - return 'ce-no'; + return OdsWriter::STYLE_NO; case 'maybe': - return 'ce-maybe'; + return OdsWriter::STYLE_MAYBE; default: - return 'ce1'; + return OdsWriter::STYLE_CELL; } } - /** - * Escape XML special characters - */ - private function escapeXml(string $text): string { - return htmlspecialchars($text, ENT_XML1 | ENT_QUOTES, 'UTF-8'); - } - /** * Generate filename suffix based on filtering options */ diff --git a/lib/Service/OdsWriter.php b/lib/Service/OdsWriter.php new file mode 100644 index 00000000..24271dd6 --- /dev/null +++ b/lib/Service/OdsWriter.php @@ -0,0 +1,219 @@ +` blocks — + * everything around them (zip container, manifest, styles, meta) is identical + * for every export this app produces. + * + * @psalm-type OdsCell = scalar|null|array{value?: scalar|null, type?: string, style?: string} + */ +class OdsWriter { + public const STYLE_CELL = 'ce1'; + public const STYLE_HEADER = 'ce2'; + public const STYLE_YES = 'ce-yes'; + public const STYLE_NO = 'ce-no'; + public const STYLE_MAYBE = 'ce-maybe'; + public const STYLE_SECTION = 'ce-section'; + + /** + * @param string $tablesXml One or more `` elements + * @throws \Exception When the file cannot be assembled + */ + public function write(string $tablesXml): string { + if (!class_exists('\ZipArchive')) { + throw new \Exception('ZipArchive extension is not available. Please install php-zip extension.'); + } + + $tempFile = tempnam(sys_get_temp_dir(), 'ods_'); + if ($tempFile === false) { + throw new \Exception('Failed to create temporary file'); + } + + $zip = new \ZipArchive(); + $result = $zip->open($tempFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + if ($result !== true) { + throw new \Exception('Failed to create ODS file. ZipArchive error code: ' . (int)$result); + } + + // Must be first and uncompressed for readers to recognise the format. + $zip->addFromString('mimetype', 'application/vnd.oasis.opendocument.spreadsheet'); + $zip->setCompressionName('mimetype', \ZipArchive::CM_STORE); + + $zip->addFromString('META-INF/manifest.xml', $this->manifestXml()); + $zip->addFromString('content.xml', $this->contentXml($tablesXml)); + $zip->addFromString('styles.xml', $this->stylesXml()); + $zip->addFromString('meta.xml', $this->metaXml()); + + $zip->close(); + + $content = file_get_contents($tempFile); + if ($content === false) { + throw new \Exception('Failed to read generated ODS file'); + } + + unlink($tempFile); + + return $content; + } + + /** + * Render a sheet from plain values. + * + * A cell is either a scalar (rendered as text) or + * `['value' => scalar, 'type' => 'string'|'float'|'percentage', 'style' => string]`. + * + * @param list> $rows + */ + public function renderTable(string $name, array $rows): string { + $xml = ' + '; + + foreach ($rows as $row) { + $xml .= ' + '; + foreach ($row as $cell) { + $xml .= $this->renderCell($cell); + } + $xml .= ' + '; + } + + return $xml . ' + '; + } + + public function escape(string $text): string { + return htmlspecialchars($text, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + } + + /** + * @param OdsCell $cell + */ + private function renderCell(mixed $cell): string { + $spec = is_array($cell) ? $cell : ['value' => $cell]; + $value = $spec['value'] ?? ''; + $type = $spec['type'] ?? 'string'; + $style = $spec['style'] ?? self::STYLE_CELL; + + if ($value === '') { + return ' + '; + } + + if ($type === 'float' || $type === 'percentage') { + $numeric = (float)$value; + $display = $type === 'percentage' + ? number_format($numeric * 100.0, 1) . ' %' + : (string)$numeric; + return ' + + ' . $this->escape($display) . ' + '; + } + + return ' + + ' . $this->escape((string)$value) . ' + '; + } + + private function contentXml(string $tablesXml): string { + return ' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ' . $tablesXml . ' + + +'; + } + + private function manifestXml(): string { + return ' + + + + + +'; + } + + private function metaXml(): string { + $date = date('Y-m-d\TH:i:s'); + return ' + + + Nextcloud Attendance App + Attendance Export + ' . $date . ' + +'; + } + + private function stylesXml(): string { + return ' + + + + + + + + + +'; + } +} diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index 698e3f30..9a0e9940 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -38,6 +38,7 @@ class PermissionService { public const PERMISSION_SELF_CHECKIN = 'self_checkin'; public const PERMISSION_CREATE_APPOINTMENTS = 'create_appointments'; public const PERMISSION_RESPOND_FOR_OTHERS = 'respond_for_others'; + public const PERMISSION_SEE_STATISTICS = 'see_statistics'; public const MODE_ALL = 'all'; public const MODE_GROUPS = 'groups'; @@ -54,6 +55,7 @@ class PermissionService { self::PERMISSION_SELF_CHECKIN, self::PERMISSION_CREATE_APPOINTMENTS, self::PERMISSION_RESPOND_FOR_OTHERS, + self::PERMISSION_SEE_STATISTICS, ]; private const GUEST_BLOCKED_PERMISSIONS = [ @@ -61,6 +63,7 @@ class PermissionService { self::PERMISSION_CHECKIN, self::PERMISSION_CREATE_APPOINTMENTS, self::PERMISSION_RESPOND_FOR_OTHERS, + self::PERMISSION_SEE_STATISTICS, ]; /** @@ -73,6 +76,7 @@ class PermissionService { private const DEFAULT_NOBODY = [ self::PERMISSION_CREATE_APPOINTMENTS, self::PERMISSION_RESPOND_FOR_OTHERS, + self::PERMISSION_SEE_STATISTICS, ]; public function __construct( @@ -388,6 +392,24 @@ public function canRespondForOthers(string $userId): bool { return $this->hasPermission($userId, self::PERMISSION_RESPOND_FOR_OTHERS); } + /** + * Check if user can see the cross-appointment statistics of everyone else. + * Without it a user sees their own row and nothing more — the permission + * gates other people's numbers, not the page. + */ + public function canSeeStatistics(string $userId): bool { + return $this->hasPermission($userId, self::PERMISSION_SEE_STATISTICS); + } + + /** + * Check if user may see who answered what, on any single appointment. + * Managers get it along with everything else they can do to an appointment. + */ + public function canSeeIndividualResponses(string $userId): bool { + return $this->canSeeResponseOverview($userId) + || $this->canManageAppointments($userId); + } + /** * Check if a user is a Nextcloud admin */ diff --git a/lib/Service/StatisticsExportService.php b/lib/Service/StatisticsExportService.php new file mode 100644 index 00000000..290c9a69 --- /dev/null +++ b/lib/Service/StatisticsExportService.php @@ -0,0 +1,206 @@ +statisticsService->getStatistics($filter); + + if ($statistics['people'] === []) { + throw new \Exception('No statistics found to export'); + } + + /** @var array $sectionNames */ + $sectionNames = []; + foreach ($statistics['sections'] as $section) { + $sectionNames[$section['id']] = $section['displayName']; + } + + $content = $this->odsWriter->write( + $this->odsWriter->renderTable( + // TRANSLATORS Name of the spreadsheet sheet holding one row per person. The same word labels the person-count column on the other sheet. + $this->l10n->t('People'), + $this->peopleRows($statistics['people'], $statistics['totals'], $sectionNames), + ) + . $this->odsWriter->renderTable( + $this->l10n->t('Groups'), + $this->sectionRows($statistics['sections'], $statistics['totals']), + ), + ); + + return $this->store($userId, $content, $filter); + } + + /** + * @param list $people + * @param StatsCounts $totals + * @param array $sectionNames + * @return list> + */ + private function peopleRows(array $people, array $totals, array $sectionNames): array { + $rows = [$this->header([$this->l10n->t('Name'), $this->l10n->t('Groups')])]; + + foreach ($people as $person) { + $names = array_map( + static fn (string $id): string => $sectionNames[$id] ?? $id, + $person['sections'], + ); + $rows[] = array_merge( + [$person['displayName'], implode(', ', $names)], + $this->counts($person), + ); + } + + $rows[] = array_merge( + [['value' => $this->l10n->t('Total'), 'style' => OdsWriter::STYLE_SECTION], ''], + $this->counts($totals), + ); + + return $rows; + } + + /** + * @param list $sections + * @param StatsCounts $totals + * @return list> + */ + private function sectionRows(array $sections, array $totals): array { + // TRANSLATORS "People" here is the column counting how many people are in the group, and also names the other sheet. + $rows = [$this->header([$this->l10n->t('Group'), $this->l10n->t('People')])]; + + foreach ($sections as $section) { + $rows[] = array_merge( + [$section['displayName'], ['value' => $section['personCount'], 'type' => 'float']], + $this->counts($section), + ); + } + + $rows[] = array_merge( + [['value' => $this->l10n->t('Total'), 'style' => OdsWriter::STYLE_SECTION], ''], + $this->counts($totals), + ); + + return $rows; + } + + /** + * @param list $leading + * @return list + */ + private function header(array $leading): array { + $labels = array_merge($leading, [ + $this->l10n->t('Appointments'), + $this->l10n->t('Yes'), + $this->l10n->t('No'), + $this->l10n->t('Maybe'), + $this->l10n->t('No response'), + $this->l10n->t('Present'), + $this->l10n->t('Absent'), + $this->l10n->t('Not recorded'), + $this->l10n->t('No-show'), + $this->l10n->t('Response rate'), + $this->l10n->t('Acceptance rate'), + $this->l10n->t('Attendance rate'), + ]); + + return array_map( + static fn (string $label): array => ['value' => $label, 'style' => OdsWriter::STYLE_HEADER], + $labels, + ); + } + + /** + * @param StatsCounts|StatsPerson|StatsSection $counts + * @return list + */ + private function counts(array $counts): array { + return [ + ['value' => $counts['targetCount'], 'type' => 'float'], + ['value' => $counts['yes'], 'type' => 'float'], + ['value' => $counts['no'], 'type' => 'float'], + ['value' => $counts['maybe'], 'type' => 'float'], + ['value' => $counts['noResponse'], 'type' => 'float'], + ['value' => $counts['present'], 'type' => 'float'], + ['value' => $counts['absent'], 'type' => 'float'], + ['value' => $counts['notRecorded'], 'type' => 'float'], + ['value' => $counts['noShow'], 'type' => 'float'], + ['value' => $counts['responseRate'], 'type' => 'percentage'], + ['value' => $counts['acceptRate'], 'type' => 'percentage'], + ['value' => $counts['attendanceRate'], 'type' => 'percentage'], + ]; + } + + /** + * @return array{path: string, filename: string} + */ + private function store(string $userId, string $content, StatisticsFilter $filter): array { + $folder = $this->attendanceFolder($userId); + + $range = trim(($filter->startDate ?? '') . '_' . ($filter->endDate ?? ''), '_'); + $filename = 'attendance_statistics' + . ($range !== '' ? '_' . $range : '') + . '_' . date('Y-m-d_His') . '.ods'; + + if ($folder->nodeExists($filename)) { + $folder->get($filename)->delete(); + } + + $folder->newFile($filename)->putContent($content); + + return [ + 'path' => '/Attendance/' . $filename, + 'filename' => $filename, + ]; + } + + private function attendanceFolder(string $userId): Folder { + try { + /** @var Folder $userFolder */ + $userFolder = $this->rootFolder->getUserFolder($userId); + } catch (\Exception $e) { + throw new \Exception('Failed to get user folder: ' . $e->getMessage()); + } + + try { + $folder = $userFolder->get('Attendance'); + } catch (NotFoundException $e) { + return $userFolder->newFolder('Attendance'); + } + + if (!$folder instanceof Folder) { + throw new \Exception('Attendance path exists but is not a folder'); + } + + return $folder; + } +} diff --git a/lib/Service/StatisticsFilter.php b/lib/Service/StatisticsFilter.php new file mode 100644 index 00000000..00cb1c5f --- /dev/null +++ b/lib/Service/StatisticsFilter.php @@ -0,0 +1,64 @@ + $categoryIds Categories to include; empty means every category + * @param bool $includeUncategorized Also include appointments without a category + */ + private function __construct( + public readonly ?string $startDate, + public readonly ?string $endDate, + public readonly array $categoryIds, + public readonly bool $includeUncategorized, + public readonly string $groupBy, + ) { + } + + /** + * Build from wire values, dropping anything malformed rather than failing — + * a stale bookmark with a removed category should still render a table. + * + * @param list $categoryIds + */ + public static function fromWire( + ?string $startDate, + ?string $endDate, + array $categoryIds = [], + bool $includeUncategorized = false, + string $groupBy = self::GROUP_BY_GROUPS, + ): self { + return new self( + self::normalizeDate($startDate), + self::normalizeDate($endDate), + array_values(array_unique(array_filter($categoryIds, static fn (int $id): bool => $id > 0))), + $includeUncategorized, + $groupBy === self::GROUP_BY_TEAMS ? self::GROUP_BY_TEAMS : self::GROUP_BY_GROUPS, + ); + } + + public function groupsByTeams(): bool { + return $this->groupBy === self::GROUP_BY_TEAMS; + } + + private static function normalizeDate(?string $date): ?string { + if ($date === null || $date === '') { + return null; + } + $parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date); + return $parsed !== false && $parsed->format('Y-m-d') === $date ? $date : null; + } +} diff --git a/lib/Service/StatisticsRangeException.php b/lib/Service/StatisticsRangeException.php new file mode 100644 index 00000000..b747ace8 --- /dev/null +++ b/lib/Service/StatisticsRangeException.php @@ -0,0 +1,26 @@ +found; + } + + public function getLimit(): int { + return $this->limit; + } +} diff --git a/lib/Service/StatisticsService.php b/lib/Service/StatisticsService.php new file mode 100644 index 00000000..0c4d16f4 --- /dev/null +++ b/lib/Service/StatisticsService.php @@ -0,0 +1,574 @@ +, targetCount: int, yes: int, no: int, maybe: int, noResponse: int, present: int, absent: int, notRecorded: int, attendanceBase: int, noShow: int, responseRate: ?float, acceptRate: ?float, attendanceRate: ?float} + * @psalm-type StatsSection = array{id: string, displayName: string, personCount: int, targetCount: int, yes: int, no: int, maybe: int, noResponse: int, present: int, absent: int, notRecorded: int, attendanceBase: int, noShow: int, responseRate: ?float, acceptRate: ?float, attendanceRate: ?float} + * @psalm-type StatsTimelinePoint = array{appointmentId: int, name: string, startDatetime: ?string, targetCount: int, yes: int, present: int, attendanceRecorded: bool} + * @psalm-type StatsCategory = array{categoryId: ?int, displayName: string, appointmentCount: int, targetCount: int, yes: int, present: int, attendanceBase: int, acceptRate: ?float, attendanceRate: ?float} + * @psalm-type StatsPersonDetail = array{userId: string, displayName: string, isGuest: bool, entries: list} + * @psalm-type StatsCategoryTally = array{categoryId: ?int, appointmentCount: int, tally: StatisticsTally} + */ +class StatisticsService { + /** + * Above this the evaluation refuses to run. The target expansion is + * appointments × addressees, so an unbounded "everything" range on a large + * instance would hit the PHP time limit instead of answering. + */ + public const MAX_APPOINTMENTS = 1000; + + public const SECTION_OTHERS = '__others__'; + + /** @var array> groupId → members */ + private array $groupMembersCache = []; + /** @var ?array the audience of an unrestricted appointment */ + private ?array $openAudienceCache = null; + /** @var ?array the same audience as a lookup set */ + private ?array $openAudienceIdsCache = null; + + public function __construct( + private AppointmentMapper $appointmentMapper, + private AttendanceResponseMapper $responseMapper, + private CategoryMapper $categoryMapper, + private ConfigService $configService, + private VisibilityService $visibilityService, + private GuestService $guestService, + private IGroupManager $groupManager, + private IUserManager $userManager, + private ITimeFactory $timeFactory, + private IL10N $l10n, + ) { + } + + /** + * @param ?string $limitToUserId When set, only this person's row is + * returned and the chart series are left out — + * the shape for users without the permission. + * @return array{appointmentCount: int, pastCount: int, attendanceRecordedCount: int, groupBy: string, people: list, sections: list, totals: StatsCounts, timeline: list, byCategory: list} + * @throws StatisticsRangeException + */ + public function getStatistics(StatisticsFilter $filter, ?string $limitToUserId = null): array { + $appointments = $this->loadAppointments($filter); + $evaluation = $this->evaluate($appointments); + $tallies = $evaluation['tallies']; + + $membership = $this->buildMembership(array_keys($tallies), $filter); + + // A group average is a statement about colleagues too, so it needs the + // same permission the individual rows do. + $sections = $limitToUserId === null ? $this->buildSections($tallies, $membership, $filter) : []; + + $totals = new StatisticsTally(); + /** @var array $displayNames */ + $displayNames = []; + foreach ($tallies as $userId => $tally) { + if ($limitToUserId !== null && $userId !== $limitToUserId) { + continue; + } + $totals->add($tally); + $displayNames[$userId] = $this->userManager->get($userId)?->getDisplayName() ?? $userId; + } + uasort($displayNames, static fn (string $a, string $b): int => strcasecmp($a, $b)); + + $people = []; + foreach ($displayNames as $userId => $displayName) { + $people[] = [ + 'userId' => $userId, + 'displayName' => $displayName, + 'isGuest' => $this->guestService->isGuestUser($userId), + 'sections' => $membership[$userId] ?? [], + ] + $tallies[$userId]->toArray(); + } + + return [ + 'appointmentCount' => count($appointments), + 'pastCount' => $evaluation['pastCount'], + 'attendanceRecordedCount' => $evaluation['attendanceRecordedCount'], + 'groupBy' => $filter->groupBy, + 'people' => $people, + 'sections' => $sections, + 'totals' => $totals->toArray(), + 'timeline' => $limitToUserId === null ? $evaluation['timeline'] : [], + 'byCategory' => $limitToUserId === null ? $this->buildCategorySeries($evaluation['byCategory']) : [], + ]; + } + + /** + * One person's appointments in the filtered range, for the drill-down. + * + * @param bool $withComments Whether the viewer may read this person's comments + * @return StatsPersonDetail + * @throws StatisticsRangeException + */ + public function getPersonDetail(StatisticsFilter $filter, string $userId, bool $withComments = false): array { + $appointments = $this->loadAppointments($filter); + $appointmentIds = $this->idsOf($appointments); + $responses = $this->indexResponses($appointmentIds, $userId, $withComments); + $checkedIn = $this->appointmentsWithCheckins($appointmentIds); + $user = $this->userManager->get($userId); + + $entries = []; + foreach ($appointments as $appointment) { + $appointmentId = $appointment->getId(); + if (!isset($this->targetUserIdSet($appointment)[$userId])) { + continue; + } + + $answer = $responses[$appointmentId][$userId] ?? null; + $entries[] = [ + 'appointmentId' => $appointmentId, + 'name' => $appointment->getName(), + 'startDatetime' => $this->startOf($appointment), + 'response' => $answer !== null ? $this->responseValue($answer['response']) : null, + 'checkinState' => $answer !== null ? $this->checkinState($answer['checkinState']) : null, + 'attendanceRecorded' => $this->hasEnded($appointment) && isset($checkedIn[$appointmentId]), + 'comment' => $answer['comment'] ?? null, + ]; + } + + return [ + 'userId' => $userId, + 'displayName' => $user?->getDisplayName() ?? $userId, + 'isGuest' => $this->guestService->isGuestUser($userId), + 'entries' => $entries, + ]; + } + + /** + * @return list + * @throws StatisticsRangeException + */ + private function loadAppointments(StatisticsFilter $filter): array { + // One over the cap is enough to know the range is too wide, and stops + // the query hydrating everything behind it just to be refused. + $appointments = $this->appointmentMapper->findForStatistics( + $filter->startDate, + $filter->endDate, + $filter->categoryIds, + $filter->includeUncategorized, + self::MAX_APPOINTMENTS + 1, + ); + + if (count($appointments) > self::MAX_APPOINTMENTS) { + throw new StatisticsRangeException(count($appointments), self::MAX_APPOINTMENTS); + } + + return $appointments; + } + + /** + * @param list $appointments + * @return array{tallies: array, timeline: list, byCategory: array, pastCount: int, attendanceRecordedCount: int} + */ + private function evaluate(array $appointments): array { + $appointmentIds = $this->idsOf($appointments); + $responses = $this->indexResponses($appointmentIds); + $checkedIn = $this->appointmentsWithCheckins($appointmentIds); + + /** @var array $tallies */ + $tallies = []; + $timeline = []; + /** @var array $byCategory */ + $byCategory = []; + $pastCount = 0; + $attendanceRecordedCount = 0; + + foreach ($appointments as $appointment) { + $appointmentId = $appointment->getId(); + $targets = $this->targetUserIds($appointment); + $isPast = $this->hasEnded($appointment); + $countsForAttendance = $isPast && isset($checkedIn[$appointmentId]); + + if ($isPast) { + $pastCount++; + } + if ($countsForAttendance) { + $attendanceRecordedCount++; + } + + $perAppointment = new StatisticsTally(); + foreach ($targets as $targetUserId) { + $answer = $responses[$appointmentId][$targetUserId] ?? null; + $response = $answer !== null ? $this->responseValue($answer['response']) : null; + $checkin = $answer !== null ? $this->checkinState($answer['checkinState']) : null; + + $tallies[$targetUserId] ??= new StatisticsTally(); + $tallies[$targetUserId]->record($response, $checkin, $countsForAttendance); + $perAppointment->record($response, $checkin, $countsForAttendance); + } + + $categoryKey = $appointment->getCategoryId() ?? 0; + if (!isset($byCategory[$categoryKey])) { + $byCategory[$categoryKey] = [ + 'categoryId' => $appointment->getCategoryId(), + 'appointmentCount' => 0, + 'tally' => new StatisticsTally(), + ]; + } + $byCategory[$categoryKey]['appointmentCount']++; + $byCategory[$categoryKey]['tally']->add($perAppointment); + + $timeline[] = [ + 'appointmentId' => $appointmentId, + 'name' => $appointment->getName(), + 'startDatetime' => $this->startOf($appointment), + 'targetCount' => $perAppointment->targetCount, + 'yes' => $perAppointment->yes, + 'present' => $perAppointment->present, + 'attendanceRecorded' => $countsForAttendance, + ]; + } + + return [ + 'tallies' => $tallies, + 'timeline' => $timeline, + 'byCategory' => $byCategory, + 'pastCount' => $pastCount, + 'attendanceRecordedCount' => $attendanceRecordedCount, + ]; + } + + /** + * @param list $appointments + * @return list + */ + private function idsOf(array $appointments): array { + $ids = []; + foreach ($appointments as $appointment) { + $ids[] = $appointment->getId(); + } + return $ids; + } + + /** + * @param list $appointmentIds + * @return array> appointmentId → userId → answer + */ + private function indexResponses(array $appointmentIds, ?string $userId = null, bool $withComments = false): array { + $indexed = []; + foreach ($this->responseMapper->findStatisticsRows($appointmentIds, $userId, $withComments) as $row) { + $answer = [ + 'response' => $row['response'], + 'checkinState' => $row['checkinState'], + ]; + if ($withComments) { + $answer['comment'] = $row['comment'] ?? null; + } + $indexed[$row['appointmentId']][$row['userId']] = $answer; + } + + return $indexed; + } + + /** + * @param list $appointmentIds + * @return array appointment IDs whose check-in list was worked + */ + private function appointmentsWithCheckins(array $appointmentIds): array { + return array_fill_keys($this->responseMapper->findAppointmentIdsWithCheckins($appointmentIds), true); + } + + /** + * Everyone the appointment was addressed to, keyed by user ID. + * + * Mirrors VisibilityService::isUserTargetAttendee(), but resolves the + * audience once per appointment instead of once per candidate — over a + * thousand appointments the per-user check decodes the same JSON fields + * hundreds of thousands of times. + * + * @return array + */ + private function targetUserIdSet(Appointment $appointment): array { + $settings = $this->visibilityService->getVisibilitySettings($appointment); + + if ($settings['users'] === [] && $settings['groups'] === [] && $settings['teams'] === []) { + return $this->openAudienceIdsCache ??= array_fill_keys(array_keys($this->openAudience($appointment)), true); + } + + $userIds = []; + foreach ($settings['users'] as $userId) { + if ($this->userManager->get($userId) !== null) { + $userIds[$userId] = true; + } + } + foreach ($settings['groups'] as $groupId) { + foreach (array_keys($this->groupMembers($groupId)) as $memberId) { + $userIds[$memberId] = true; + } + } + foreach ($settings['teams'] as $teamId) { + foreach ($this->visibilityService->getTeamMembers($teamId) as $memberId) { + $userIds[$memberId] = true; + } + } + + return $userIds; + } + + /** + * @return list + */ + private function targetUserIds(Appointment $appointment): array { + // Numeric-string user IDs come back as int array keys (issue #63). + return array_map('strval', array_keys($this->targetUserIdSet($appointment))); + } + + /** + * The audience of an appointment nobody restricted: the whitelisted groups + * when an admin configured any, everyone otherwise. Identical for every + * such appointment, so it is resolved once per request. + * + * @return array + */ + private function openAudience(Appointment $appointment): array { + return $this->openAudienceCache ??= $this->visibilityService->getRelevantUsersForAppointment( + $appointment, + $this->configService->getWhitelistedGroups(), + ); + } + + /** + * @return array + */ + private function groupMembers(string $groupId): array { + if (isset($this->groupMembersCache[$groupId])) { + return $this->groupMembersCache[$groupId]; + } + + $members = []; + $group = $this->groupManager->get($groupId); + if ($group !== null) { + foreach ($group->getUsers() as $user) { + $members[$user->getUID()] = $user; + } + } + + return $this->groupMembersCache[$groupId] = $members; + } + + /** + * Which sections each person shows up in. People in several whitelisted + * groups appear in each of them, exactly like the per-appointment summary — + * the totals row is what counts everyone once. + * + * Resolved per section rather than per person: with a whitelist configured + * the member lists are already loaded, so asking every person for their + * groups would be one database round trip per person for an answer that is + * a handful of lookups away. + * + * @param list $userIds + * @return array> userId → section IDs + */ + private function buildMembership(array $userIds, StatisticsFilter $filter): array { + $membership = array_fill_keys($userIds, []); + $known = array_fill_keys($userIds, true); + + foreach ($this->sectionMembers($filter) as $sectionId => $memberIds) { + foreach ($memberIds as $memberId) { + if (isset($known[$memberId])) { + $membership[$memberId][] = (string)$sectionId; + } + } + } + + foreach ($membership as $userId => $sections) { + if ($sections === []) { + $membership[$userId] = [self::SECTION_OTHERS]; + } + } + + return $membership; + } + + /** + * @return array> sectionId → member user IDs + */ + private function sectionMembers(StatisticsFilter $filter): array { + if ($filter->groupsByTeams()) { + $members = []; + foreach ($this->configService->getWhitelistedTeams() as $teamId) { + $members[$teamId] = $this->visibilityService->getTeamMembers($teamId); + } + return $members; + } + + $whitelisted = $this->configService->getWhitelistedGroups(); + if ($whitelisted !== []) { + $members = []; + foreach ($whitelisted as $groupId) { + $members[$groupId] = array_keys($this->groupMembers($groupId)); + } + return $members; + } + + // No whitelist: every group is a section, so the group list has to come + // from the people themselves. The Guests app's system group is hidden — + // it would otherwise lump every guest into one section. + $members = []; + foreach (array_keys($this->openAudienceCache ?? []) as $userId) { + $user = $this->userManager->get($userId); + if ($user === null) { + continue; + } + foreach ($this->groupManager->getUserGroupIds($user) as $groupId) { + if (GuestService::isGuestsSystemGroup($groupId)) { + continue; + } + $members[$groupId][] = $userId; + } + } + + return $members; + } + + /** + * @param array $tallies + * @param array> $membership userId → section IDs + * @return list + */ + private function buildSections(array $tallies, array $membership, StatisticsFilter $filter): array { + /** @var array $buckets */ + $buckets = []; + /** @var array $memberCounts */ + $memberCounts = []; + + foreach ($tallies as $userId => $tally) { + foreach ($membership[$userId] ?? [] as $sectionId) { + $buckets[$sectionId] ??= new StatisticsTally(); + $buckets[$sectionId]->add($tally); + $memberCounts[$sectionId] = ($memberCounts[$sectionId] ?? 0) + 1; + } + } + + $sections = []; + foreach ($buckets as $sectionId => $tally) { + $sections[(string)$sectionId] = [ + 'id' => (string)$sectionId, + 'displayName' => $this->sectionName((string)$sectionId, $filter), + 'personCount' => $memberCounts[$sectionId] ?? 0, + ] + $tally->toArray(); + } + + return $this->sortSections($sections, $filter); + } + + private function sectionName(string $sectionId, StatisticsFilter $filter): string { + if ($sectionId === self::SECTION_OTHERS) { + return $this->l10n->t('Others'); + } + if ($filter->groupsByTeams()) { + $info = $this->visibilityService->getTeamInfo($sectionId); + return isset($info['label']) ? (string)$info['label'] : $sectionId; + } + return $this->groupManager->get($sectionId)?->getDisplayName() ?? $sectionId; + } + + /** + * Admin-configured order first, the rest alphabetically, "Others" last — + * the same order the per-appointment summary uses. + * + * @param array $sections + * @return list + */ + private function sortSections(array $sections, StatisticsFilter $filter): array { + $configured = $filter->groupsByTeams() + ? $this->configService->getWhitelistedTeams() + : $this->configService->getWhitelistedGroups(); + + $sorted = []; + foreach ($configured as $sectionId) { + if (isset($sections[$sectionId])) { + $sorted[] = $sections[$sectionId]; + unset($sections[$sectionId]); + } + } + + $others = $sections[self::SECTION_OTHERS] ?? null; + unset($sections[self::SECTION_OTHERS]); + + $remaining = array_values($sections); + usort($remaining, static fn (array $a, array $b): int => strcasecmp((string)$a['displayName'], (string)$b['displayName'])); + + $sorted = array_merge($sorted, $remaining); + if ($others !== null) { + $sorted[] = $others; + } + + return $sorted; + } + + /** + * @param array $byCategory + * @return list + */ + private function buildCategorySeries(array $byCategory): array { + $names = []; + foreach ($this->categoryMapper->findAll() as $category) { + $names[$category->getId()] = $category->getName(); + } + + $series = []; + foreach ($byCategory as $entry) { + $categoryId = $entry['categoryId']; + $tally = $entry['tally']; + $series[] = [ + 'categoryId' => $categoryId, + 'displayName' => $categoryId !== null + ? ($names[$categoryId] ?? (string)$categoryId) + : $this->l10n->t('Without category'), + 'appointmentCount' => $entry['appointmentCount'], + 'targetCount' => $tally->targetCount, + 'yes' => $tally->yes, + 'present' => $tally->present, + 'attendanceBase' => $tally->attendanceBase, + 'acceptRate' => StatisticsTally::rate($tally->yes, $tally->targetCount), + 'attendanceRate' => StatisticsTally::rate($tally->present, $tally->attendanceBase), + ]; + } + + usort($series, static fn (array $a, array $b): int => strcasecmp((string)$a['displayName'], (string)$b['displayName'])); + + return $series; + } + + private function hasEnded(Appointment $appointment): bool { + $now = $this->timeFactory->getDateTime('now', new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'); + return $appointment->getEndDatetime() < $now; + } + + private function startOf(Appointment $appointment): ?string { + $serialized = $appointment->jsonSerialize(); + return isset($serialized['startDatetime']) ? (string)$serialized['startDatetime'] : null; + } + + private function responseValue(?string $value): ?string { + return in_array($value, ['yes', 'no', 'maybe'], true) ? $value : null; + } + + private function checkinState(?string $state): ?string { + return in_array($state, ['yes', 'no'], true) ? $state : null; + } +} diff --git a/lib/Service/StatisticsTally.php b/lib/Service/StatisticsTally.php new file mode 100644 index 00000000..99656519 --- /dev/null +++ b/lib/Service/StatisticsTally.php @@ -0,0 +1,94 @@ +targetCount++; + + match ($answer) { + 'yes' => $this->yes++, + 'no' => $this->no++, + 'maybe' => $this->maybe++, + default => $this->noResponse++, + }; + + if (!$countsForAttendance) { + return; + } + + $this->attendanceBase++; + + if ($checkin === 'yes') { + $this->present++; + } elseif ($checkin === 'no') { + $this->absent++; + if ($answer === 'yes') { + $this->noShow++; + } + } else { + $this->notRecorded++; + } + } + + public function add(self $other): void { + $this->targetCount += $other->targetCount; + $this->yes += $other->yes; + $this->no += $other->no; + $this->maybe += $other->maybe; + $this->noResponse += $other->noResponse; + $this->present += $other->present; + $this->absent += $other->absent; + $this->notRecorded += $other->notRecorded; + $this->attendanceBase += $other->attendanceBase; + $this->noShow += $other->noShow; + } + + /** + * @return array{targetCount: int, yes: int, no: int, maybe: int, noResponse: int, present: int, absent: int, notRecorded: int, attendanceBase: int, noShow: int, responseRate: ?float, acceptRate: ?float, attendanceRate: ?float} + */ + public function toArray(): array { + return [ + 'targetCount' => $this->targetCount, + 'yes' => $this->yes, + 'no' => $this->no, + 'maybe' => $this->maybe, + 'noResponse' => $this->noResponse, + 'present' => $this->present, + 'absent' => $this->absent, + 'notRecorded' => $this->notRecorded, + 'attendanceBase' => $this->attendanceBase, + 'noShow' => $this->noShow, + 'responseRate' => self::rate($this->yes + $this->no + $this->maybe, $this->targetCount), + 'acceptRate' => self::rate($this->yes, $this->targetCount), + 'attendanceRate' => self::rate($this->present, $this->attendanceBase), + ]; + } + + public static function rate(int $part, int $total): ?float { + return $total > 0 ? round($part / $total, 4) : null; + } +} diff --git a/openapi-administration.json b/openapi-administration.json index e0683c75..b98fa89a 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -213,7 +213,8 @@ "respondOnBehalf", "responseCounts", "locationsAvailable", - "categoriesAvailable" + "categoriesAvailable", + "statisticsAvailable" ], "properties": { "calendarAvailable": { @@ -272,6 +273,9 @@ }, "categoriesAvailable": { "type": "boolean" + }, + "statisticsAvailable": { + "type": "boolean" } } }, diff --git a/openapi-full.json b/openapi-full.json index faf736a7..63397456 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -667,7 +667,8 @@ "respondOnBehalf", "responseCounts", "locationsAvailable", - "categoriesAvailable" + "categoriesAvailable", + "statisticsAvailable" ], "properties": { "calendarAvailable": { @@ -726,6 +727,9 @@ }, "categoriesAvailable": { "type": "boolean" + }, + "statisticsAvailable": { + "type": "boolean" } } }, @@ -1377,172 +1381,655 @@ } } }, - "TeamOption": { + "Statistics": { "type": "object", "required": [ - "id", - "displayName" + "appointmentCount", + "pastCount", + "attendanceRecordedCount", + "groupBy", + "people", + "sections", + "totals", + "timeline", + "byCategory" ], "properties": { - "id": { - "type": "string" + "appointmentCount": { + "type": "integer", + "format": "int64" }, - "displayName": { + "pastCount": { + "type": "integer", + "format": "int64" + }, + "attendanceRecordedCount": { + "type": "integer", + "format": "int64" + }, + "groupBy": { "type": "string" + }, + "people": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatisticsPerson" + } + }, + "sections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatisticsSection" + } + }, + "totals": { + "$ref": "#/components/schemas/StatisticsTotals" + }, + "timeline": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatisticsTimelinePoint" + } + }, + "byCategory": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatisticsCategory" + } } } }, - "TestReminderResult": { + "StatisticsCategory": { "type": "object", "required": [ - "sent", - "appointmentName" + "categoryId", + "displayName", + "appointmentCount", + "targetCount", + "yes", + "present", + "attendanceBase", + "acceptRate", + "attendanceRate" ], "properties": { - "sent": { + "categoryId": { "type": "integer", - "format": "int64" + "format": "int64", + "nullable": true }, - "appointmentName": { + "displayName": { "type": "string" + }, + "appointmentCount": { + "type": "integer", + "format": "int64" + }, + "targetCount": { + "type": "integer", + "format": "int64" + }, + "yes": { + "type": "integer", + "format": "int64" + }, + "present": { + "type": "integer", + "format": "int64" + }, + "attendanceBase": { + "type": "integer", + "format": "int64" + }, + "acceptRate": { + "type": "number", + "format": "double", + "nullable": true + }, + "attendanceRate": { + "type": "number", + "format": "double", + "nullable": true } } }, - "UserConfig": { + "StatisticsPerson": { "type": "object", "required": [ - "displayOrder", - "mobileAppBannerEnabled", - "hasPushDevice", - "onboarding" + "userId", + "displayName", + "isGuest", + "sections", + "targetCount", + "yes", + "no", + "maybe", + "noResponse", + "present", + "absent", + "notRecorded", + "attendanceBase", + "noShow", + "responseRate", + "acceptRate", + "attendanceRate" ], "properties": { - "displayOrder": { + "userId": { "type": "string" }, - "mobileAppBannerEnabled": { - "type": "boolean" + "displayName": { + "type": "string" }, - "hasPushDevice": { + "isGuest": { "type": "boolean" }, - "onboarding": { - "$ref": "#/components/schemas/OnboardingState" + "sections": { + "type": "array", + "items": { + "type": "string" + } + }, + "targetCount": { + "type": "integer", + "format": "int64" + }, + "yes": { + "type": "integer", + "format": "int64" + }, + "no": { + "type": "integer", + "format": "int64" + }, + "maybe": { + "type": "integer", + "format": "int64" + }, + "noResponse": { + "type": "integer", + "format": "int64" + }, + "present": { + "type": "integer", + "format": "int64" + }, + "absent": { + "type": "integer", + "format": "int64" + }, + "notRecorded": { + "type": "integer", + "format": "int64" + }, + "attendanceBase": { + "type": "integer", + "format": "int64" + }, + "noShow": { + "type": "integer", + "format": "int64" + }, + "responseRate": { + "type": "number", + "format": "double", + "nullable": true + }, + "acceptRate": { + "type": "number", + "format": "double", + "nullable": true + }, + "attendanceRate": { + "type": "number", + "format": "double", + "nullable": true } } }, - "UserPermissions": { + "StatisticsPersonDetail": { "type": "object", "required": [ - "canManageAppointments", - "canCreateAppointments", - "canCheckin", - "canSeeResponseOverview", - "canSeeResponseCounts", - "canSeeComments", - "canSelfCheckin", - "canRespondForOthers" + "userId", + "displayName", + "isGuest", + "entries" ], "properties": { - "canManageAppointments": { - "type": "boolean" + "userId": { + "type": "string" }, - "canCreateAppointments": { - "type": "boolean" + "displayName": { + "type": "string" }, - "canCheckin": { + "isGuest": { "type": "boolean" }, - "canSeeResponseOverview": { - "type": "boolean" + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatisticsPersonEntry" + } + } + } + }, + "StatisticsPersonEntry": { + "type": "object", + "required": [ + "appointmentId", + "name", + "startDatetime", + "response", + "checkinState", + "attendanceRecorded", + "comment" + ], + "properties": { + "appointmentId": { + "type": "integer", + "format": "int64" }, - "canSeeResponseCounts": { - "type": "boolean" + "name": { + "type": "string" }, - "canSeeComments": { - "type": "boolean" + "startDatetime": { + "type": "string", + "nullable": true }, - "canSelfCheckin": { - "type": "boolean" + "response": { + "type": "string", + "nullable": true }, - "canRespondForOthers": { + "checkinState": { + "type": "string", + "nullable": true + }, + "attendanceRecorded": { "type": "boolean" + }, + "comment": { + "type": "string", + "nullable": true } } }, - "WritableCalendar": { + "StatisticsSection": { "type": "object", "required": [ - "uri", + "id", "displayName", - "color" + "personCount", + "targetCount", + "yes", + "no", + "maybe", + "noResponse", + "present", + "absent", + "notRecorded", + "attendanceBase", + "noShow", + "responseRate", + "acceptRate", + "attendanceRate" ], "properties": { - "uri": { + "id": { "type": "string" }, "displayName": { "type": "string" }, - "color": { - "type": "string" - } - } - } - } - }, - "paths": { - "/index.php/apps/attendance/api/appointments": { - "get": { - "operationId": "appointment-index", - "summary": "List appointments visible to the current user", - "tags": [ - "appointment" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "showPastAppointments", - "in": "query", - "description": "Whether to show past appointments instead of upcoming ones", - "schema": { - "type": "boolean", - "default": false - } + "personCount": { + "type": "integer", + "format": "int64" }, - { - "name": "unansweredOnly", - "in": "query", - "description": "When true, only return upcoming appointments that the user has not answered yet AND that are still open. Ignored when $showPastAppointments is true.", - "schema": { - "type": "boolean", - "default": false - } + "targetCount": { + "type": "integer", + "format": "int64" }, - { - "name": "onlyForMe", - "in": "query", - "description": "When true, restrict the result to appointments the user is part of the target audience for (visibleUsers/Groups/Teams membership; appointments with no visibility restriction count as \"for everyone\" and are included). Useful for managers, who otherwise see every appointment in the system.", - "schema": { - "type": "boolean", - "default": false - } + "yes": { + "type": "integer", + "format": "int64" }, - { - "name": "notScheduledOut", - "in": "query", - "description": "When true, additionally drop closed appointments where somebody was scheduled but the user was not. Only has an effect while the planning feature is enabled; appointments nobody was scheduled for stay visible to everyone. Implies $onlyForMe.", - "schema": { - "type": "boolean", - "default": false - } + "no": { + "type": "integer", + "format": "int64" + }, + "maybe": { + "type": "integer", + "format": "int64" + }, + "noResponse": { + "type": "integer", + "format": "int64" + }, + "present": { + "type": "integer", + "format": "int64" + }, + "absent": { + "type": "integer", + "format": "int64" + }, + "notRecorded": { + "type": "integer", + "format": "int64" + }, + "attendanceBase": { + "type": "integer", + "format": "int64" + }, + "noShow": { + "type": "integer", + "format": "int64" + }, + "responseRate": { + "type": "number", + "format": "double", + "nullable": true + }, + "acceptRate": { + "type": "number", + "format": "double", + "nullable": true + }, + "attendanceRate": { + "type": "number", + "format": "double", + "nullable": true + } + } + }, + "StatisticsTimelinePoint": { + "type": "object", + "required": [ + "appointmentId", + "name", + "startDatetime", + "targetCount", + "yes", + "present", + "attendanceRecorded" + ], + "properties": { + "appointmentId": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "startDatetime": { + "type": "string", + "nullable": true + }, + "targetCount": { + "type": "integer", + "format": "int64" + }, + "yes": { + "type": "integer", + "format": "int64" + }, + "present": { + "type": "integer", + "format": "int64" + }, + "attendanceRecorded": { + "type": "boolean" + } + } + }, + "StatisticsTotals": { + "type": "object", + "required": [ + "targetCount", + "yes", + "no", + "maybe", + "noResponse", + "present", + "absent", + "notRecorded", + "attendanceBase", + "noShow", + "responseRate", + "acceptRate", + "attendanceRate" + ], + "properties": { + "targetCount": { + "type": "integer", + "format": "int64" + }, + "yes": { + "type": "integer", + "format": "int64" + }, + "no": { + "type": "integer", + "format": "int64" + }, + "maybe": { + "type": "integer", + "format": "int64" + }, + "noResponse": { + "type": "integer", + "format": "int64" + }, + "present": { + "type": "integer", + "format": "int64" + }, + "absent": { + "type": "integer", + "format": "int64" + }, + "notRecorded": { + "type": "integer", + "format": "int64" + }, + "attendanceBase": { + "type": "integer", + "format": "int64" + }, + "noShow": { + "type": "integer", + "format": "int64" + }, + "responseRate": { + "type": "number", + "format": "double", + "nullable": true + }, + "acceptRate": { + "type": "number", + "format": "double", + "nullable": true + }, + "attendanceRate": { + "type": "number", + "format": "double", + "nullable": true + } + } + }, + "TeamOption": { + "type": "object", + "required": [ + "id", + "displayName" + ], + "properties": { + "id": { + "type": "string" + }, + "displayName": { + "type": "string" + } + } + }, + "TestReminderResult": { + "type": "object", + "required": [ + "sent", + "appointmentName" + ], + "properties": { + "sent": { + "type": "integer", + "format": "int64" + }, + "appointmentName": { + "type": "string" + } + } + }, + "UserConfig": { + "type": "object", + "required": [ + "displayOrder", + "mobileAppBannerEnabled", + "hasPushDevice", + "onboarding" + ], + "properties": { + "displayOrder": { + "type": "string" + }, + "mobileAppBannerEnabled": { + "type": "boolean" + }, + "hasPushDevice": { + "type": "boolean" + }, + "onboarding": { + "$ref": "#/components/schemas/OnboardingState" + } + } + }, + "UserPermissions": { + "type": "object", + "required": [ + "canManageAppointments", + "canCreateAppointments", + "canCheckin", + "canSeeResponseOverview", + "canSeeResponseCounts", + "canSeeComments", + "canSelfCheckin", + "canRespondForOthers", + "canSeeStatistics", + "canSeeIndividualResponses" + ], + "properties": { + "canManageAppointments": { + "type": "boolean" + }, + "canCreateAppointments": { + "type": "boolean" + }, + "canCheckin": { + "type": "boolean" + }, + "canSeeResponseOverview": { + "type": "boolean" + }, + "canSeeResponseCounts": { + "type": "boolean" + }, + "canSeeComments": { + "type": "boolean" + }, + "canSelfCheckin": { + "type": "boolean" + }, + "canRespondForOthers": { + "type": "boolean" + }, + "canSeeStatistics": { + "type": "boolean" + }, + "canSeeIndividualResponses": { + "type": "boolean" + } + } + }, + "WritableCalendar": { + "type": "object", + "required": [ + "uri", + "displayName", + "color" + ], + "properties": { + "uri": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "color": { + "type": "string" + } + } + } + } + }, + "paths": { + "/index.php/apps/attendance/api/appointments": { + "get": { + "operationId": "appointment-index", + "summary": "List appointments visible to the current user", + "tags": [ + "appointment" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "showPastAppointments", + "in": "query", + "description": "Whether to show past appointments instead of upcoming ones", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "unansweredOnly", + "in": "query", + "description": "When true, only return upcoming appointments that the user has not answered yet AND that are still open. Ignored when $showPastAppointments is true.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "onlyForMe", + "in": "query", + "description": "When true, restrict the result to appointments the user is part of the target audience for (visibleUsers/Groups/Teams membership; appointments with no visibility restriction count as \"for everyone\" and are included). Useful for managers, who otherwise see every appointment in the system.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "notScheduledOut", + "in": "query", + "description": "When true, additionally drop closed appointments where somebody was scheduled but the user was not. Only has an effect while the planning feature is enabled; appointments nobody was scheduled for stay visible to everyone. Implies $onlyForMe.", + "schema": { + "type": "boolean", + "default": false + } }, { "name": "onlyScheduled", @@ -4466,13 +4953,195 @@ } }, { - "name": "userId", - "in": "path", - "description": "The responder to unbook", - "required": true, - "schema": { - "type": "string" - } + "name": "userId", + "in": "path", + "description": "The responder to unbook", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResponseData" + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + ] + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/index.php/apps/attendance/api/categories": { + "get": { + "operationId": "category-index", + "summary": "List all categories, alphabetically", + "description": "Available to any logged-in user — appointments' category picker and the appointment list filter both need the full list, not just admins.", + "tags": [ + "category" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CategoryData" + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + ] + } + } + } + } + } + } + }, + "/index.php/apps/attendance/api/user/permissions": { + "get": { + "operationId": "appointment-get-permissions", + "summary": "Get the current user's permissions", + "tags": [ + "appointment" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] } ], "responses": { @@ -4481,7 +5150,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResponseData" + "$ref": "#/components/schemas/UserPermissions" } } } @@ -4518,54 +5187,47 @@ } } } + } + } + } + }, + "/index.php/apps/attendance/api/user/config": { + "get": { + "operationId": "appointment-get-user-config", + "summary": "Get user-relevant app configuration", + "tags": [ + "appointment" + ], + "security": [ + { + "bearer_auth": [] }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "400": { + { + "basic_auth": [] + } + ], + "responses": { + "200": { "description": "", "content": { "application/json": { "schema": { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string" - } - } + "$ref": "#/components/schemas/UserConfig" } } } }, - "404": { - "description": "", + "401": { + "description": "Current user is not logged in", "content": { "application/json": { "schema": { "type": "object", "required": [ - "error" + "message" ], "properties": { - "error": { + "message": { "type": "string" } } @@ -4576,13 +5238,13 @@ } } }, - "/index.php/apps/attendance/api/categories": { + "/index.php/apps/attendance/api/user/settings": { "get": { - "operationId": "category-index", - "summary": "List all categories, alphabetically", - "description": "Available to any logged-in user — appointments' category picker and the appointment list filter both need the full list, not just admins.", + "operationId": "appointment-get-user-settings", + "summary": "Get personal user settings", + "description": "Response-change notification toggle is only included for users who can actually receive these notifications (managers, users allowed to create appointments, or organizers of at least one appointment — with audit log enabled). Frontend uses the key's presence to decide whether to render the toggle at all.", "tags": [ - "category" + "appointment" ], "security": [ { @@ -4598,9 +5260,20 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CategoryData" + "type": "object", + "required": [ + "icalReminderTriggers" + ], + "properties": { + "icalReminderTriggers": { + "type": "array", + "items": { + "type": "string" + } + }, + "notifyResponseChanges": { + "type": "boolean" + } } } } @@ -4640,12 +5313,10 @@ } } } - } - }, - "/index.php/apps/attendance/api/user/permissions": { - "get": { - "operationId": "appointment-get-permissions", - "summary": "Get the current user's permissions", + }, + "post": { + "operationId": "appointment-save-user-settings", + "summary": "Save personal user settings", "tags": [ "appointment" ], @@ -4657,13 +5328,47 @@ "basic_auth": [] } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "icalReminderTriggers": { + "type": "array", + "default": [], + "description": "List of duration strings for iCal reminder triggers", + "items": { + "type": "string" + } + }, + "notifyResponseChanges": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "Opt-in toggle for response-change notifications (only honoured for eligible users)" + } + } + } + } + } + }, "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserPermissions" + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean" + } + } } } } @@ -4704,10 +5409,10 @@ } } }, - "/index.php/apps/attendance/api/user/config": { + "/index.php/apps/attendance/api/capabilities": { "get": { - "operationId": "appointment-get-user-config", - "summary": "Get user-relevant app configuration", + "operationId": "appointment-get-capabilities", + "summary": "Get system-wide capabilities", "tags": [ "appointment" ], @@ -4725,7 +5430,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserConfig" + "$ref": "#/components/schemas/Capabilities" } } } @@ -4751,11 +5456,58 @@ } } }, - "/index.php/apps/attendance/api/user/settings": { + "/index.php/apps/attendance/api/push-config": { "get": { - "operationId": "appointment-get-user-settings", - "summary": "Get personal user settings", - "description": "Response-change notification toggle is only included for users who can actually receive these notifications (managers, users allowed to create appointments, or organizers of at least one appointment — with audit log enabled). Frontend uses the key's presence to decide whether to render the toggle at all.", + "operationId": "appointment-get-push-config", + "summary": "Get push notification configuration", + "description": "Returns the push proxy server URL for mobile clients.", + "tags": [ + "appointment" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PushConfig" + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/index.php/apps/attendance/api/search/users-groups-teams": { + "get": { + "operationId": "appointment-search-users-groups-teams", + "summary": "Search for users, groups, and teams", "tags": [ "appointment" ], @@ -4763,29 +5515,71 @@ { "bearer_auth": [] }, - { - "basic_auth": [] - } - ], - "responses": { - "200": { + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "search", + "in": "query", + "description": "Search query", + "schema": { + "type": "string", + "default": "" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "label", + "type", + "icon", + "isGuest" + ], + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "isGuest": { + "type": "boolean" + } + } + } + } + } + } + }, + "400": { "description": "", "content": { "application/json": { "schema": { "type": "object", "required": [ - "icalReminderTriggers" + "error" ], "properties": { - "icalReminderTriggers": { - "type": "array", - "items": { - "type": "string" - } - }, - "notifyResponseChanges": { - "type": "boolean" + "error": { + "type": "string" } } } @@ -4824,12 +5618,33 @@ } } } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } } } - }, + } + }, + "/index.php/apps/attendance/api/guests": { "post": { - "operationId": "appointment-save-user-settings", - "summary": "Save personal user settings", + "operationId": "appointment-create-guest", + "summary": "Create a guest account via the Nextcloud Guests app", + "description": "Allows organizers to invite people without a Nextcloud account by provisioning a guest user. The new account is identified by its email (used both as UID and email). Guests cannot manage appointments or check in others — that is enforced by `PermissionService` regardless of group configuration.", "tags": [ "appointment" ], @@ -4848,19 +5663,15 @@ "schema": { "type": "object", "properties": { - "icalReminderTriggers": { - "type": "array", - "default": [], - "description": "List of duration strings for iCal reminder triggers", - "items": { - "type": "string" - } + "email": { + "type": "string", + "default": "", + "description": "Email address that becomes both the guest's UID and contact address" }, - "notifyResponseChanges": { - "type": "boolean", - "nullable": true, - "default": null, - "description": "Opt-in toggle for response-change notifications (only honoured for eligible users)" + "displayName": { + "type": "string", + "default": "", + "description": "Optional human-readable name for the guest" } } } @@ -4869,17 +5680,27 @@ }, "responses": { "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GuestCreationResult" + } + } + } + }, + "400": { "description": "", "content": { "application/json": { "schema": { "type": "object", "required": [ - "success" + "error" ], "properties": { - "success": { - "type": "boolean" + "error": { + "type": "string" } } } @@ -4918,47 +5739,36 @@ } } } - } - } - } - }, - "/index.php/apps/attendance/api/capabilities": { - "get": { - "operationId": "appointment-get-capabilities", - "summary": "Get system-wide capabilities", - "tags": [ - "appointment" - ], - "security": [ - { - "bearer_auth": [] }, - { - "basic_auth": [] - } - ], - "responses": { - "200": { + "403": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Capabilities" + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } } } } }, - "401": { - "description": "Current user is not logged in", + "500": { + "description": "", "content": { "application/json": { "schema": { "type": "object", "required": [ - "message" + "error" ], "properties": { - "message": { + "error": { "type": "string" } } @@ -4969,11 +5779,10 @@ } } }, - "/index.php/apps/attendance/api/push-config": { - "get": { - "operationId": "appointment-get-push-config", - "summary": "Get push notification configuration", - "description": "Returns the push proxy server URL for mobile clients.", + "/index.php/apps/attendance/api/export": { + "post": { + "operationId": "appointment-export", + "summary": "Export appointments to ODS file with optional filtering", "tags": [ "appointment" ], @@ -4985,103 +5794,108 @@ "basic_auth": [] } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "appointmentIds": { + "type": "array", + "nullable": true, + "default": null, + "description": "Specific appointment IDs to export, or null for all", + "items": { + "type": "integer", + "format": "int64" + } + }, + "startDate": { + "type": "string", + "nullable": true, + "default": null, + "description": "Start date filter in Y-m-d format" + }, + "endDate": { + "type": "string", + "nullable": true, + "default": null, + "description": "End date filter in Y-m-d format" + }, + "includeComments": { + "type": "boolean", + "default": false, + "description": "Whether to include user comments in the export" + } + } + } + } + } + }, "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PushConfig" + "$ref": "#/components/schemas/ExportResult" } } } }, - "401": { - "description": "Current user is not logged in", + "400": { + "description": "", "content": { "application/json": { "schema": { "type": "object", "required": [ - "message" + "error" ], "properties": { - "message": { + "error": { "type": "string" } } } } } - } - } - } - }, - "/index.php/apps/attendance/api/search/users-groups-teams": { - "get": { - "operationId": "appointment-search-users-groups-teams", - "summary": "Search for users, groups, and teams", - "tags": [ - "appointment" - ], - "security": [ - { - "bearer_auth": [] }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "search", - "in": "query", - "description": "Search query", - "schema": { - "type": "string", - "default": "" - } - } - ], - "responses": { - "200": { - "description": "", + "401": { + "description": "Current user is not logged in", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "label", - "type", - "icon", - "isGuest" - ], - "properties": { - "id": { - "type": "string" - }, - "label": { - "type": "string" - }, - "type": { - "type": "string" - }, - "icon": { - "type": "string" - }, - "isGuest": { - "type": "boolean" + "anyOf": [ + { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } } } - } + ] } } } }, - "400": { + "403": { "description": "", "content": { "application/json": { @@ -5098,6 +5912,88 @@ } } } + } + } + } + }, + "/index.php/apps/attendance/api/statistics": { + "get": { + "operationId": "statistics-index", + "summary": "Evaluate responses and attendance across appointments", + "description": "Users without the see_statistics permission get their own row and nothing else: no other people, no sections, no totals and no chart series.", + "tags": [ + "statistics" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "startDate", + "in": "query", + "description": "Start of the range (Y-m-d, inclusive)", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "endDate", + "in": "query", + "description": "End of the range (Y-m-d, inclusive)", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "categoryIds[]", + "in": "query", + "description": "Categories to include; empty means every category", + "schema": { + "type": "array", + "default": [], + "items": { + "type": "integer", + "format": "int64" + } + } + }, + { + "name": "includeUncategorized", + "in": "query", + "description": "Also include appointments without a category", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "groupBy", + "in": "query", + "description": "Section axis: `groups` or `teams`", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Statistics" + } + } + } }, "401": { "description": "Current user is not logged in", @@ -5132,18 +6028,28 @@ } } }, - "403": { + "400": { "description": "", "content": { "application/json": { "schema": { "type": "object", "required": [ - "error" + "error", + "found", + "limit" ], "properties": { "error": { "type": "string" + }, + "found": { + "type": "integer", + "format": "int64" + }, + "limit": { + "type": "integer", + "format": "int64" } } } @@ -5153,13 +6059,12 @@ } } }, - "/index.php/apps/attendance/api/guests": { + "/index.php/apps/attendance/api/statistics/export": { "post": { - "operationId": "appointment-create-guest", - "summary": "Create a guest account via the Nextcloud Guests app", - "description": "Allows organizers to invite people without a Nextcloud account by provisioning a guest user. The new account is identified by its email (used both as UID and email). Guests cannot manage appointments or check in others — that is enforced by `PermissionService` regardless of group configuration.", + "operationId": "statistics-export", + "summary": "Export the statistics table as an ODS file", "tags": [ - "appointment" + "statistics" ], "security": [ { @@ -5176,15 +6081,35 @@ "schema": { "type": "object", "properties": { - "email": { + "startDate": { "type": "string", - "default": "", - "description": "Email address that becomes both the guest's UID and contact address" + "nullable": true, + "default": null, + "description": "Start of the range (Y-m-d, inclusive)" }, - "displayName": { + "endDate": { "type": "string", - "default": "", - "description": "Optional human-readable name for the guest" + "nullable": true, + "default": null, + "description": "End of the range (Y-m-d, inclusive)" + }, + "categoryIds": { + "type": "array", + "default": [], + "description": "Categories to include; empty means every category", + "items": { + "type": "integer", + "format": "int64" + } + }, + "includeUncategorized": { + "type": "boolean", + "default": false, + "description": "Also include appointments without a category" + }, + "groupBy": { + "type": "string", + "description": "Section axis: `groups` or `teams`" } } } @@ -5197,25 +6122,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GuestCreationResult" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string" - } - } + "$ref": "#/components/schemas/ExportResult" } } } @@ -5271,7 +6178,7 @@ } } }, - "500": { + "400": { "description": "", "content": { "application/json": { @@ -5292,12 +6199,12 @@ } } }, - "/index.php/apps/attendance/api/export": { - "post": { - "operationId": "appointment-export", - "summary": "Export appointments to ODS file with optional filtering", + "/index.php/apps/attendance/api/statistics/person/{targetUserId}": { + "get": { + "operationId": "statistics-person", + "summary": "List one person's appointments behind their statistics row", "tags": [ - "appointment" + "statistics" ], "security": [ { @@ -5307,70 +6214,66 @@ "basic_auth": [] } ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "appointmentIds": { - "type": "array", - "nullable": true, - "default": null, - "description": "Specific appointment IDs to export, or null for all", - "items": { - "type": "integer", - "format": "int64" - } - }, - "startDate": { - "type": "string", - "nullable": true, - "default": null, - "description": "Start date filter in Y-m-d format" - }, - "endDate": { - "type": "string", - "nullable": true, - "default": null, - "description": "End date filter in Y-m-d format" - }, - "includeComments": { - "type": "boolean", - "default": false, - "description": "Whether to include user comments in the export" - } - } + "parameters": [ + { + "name": "targetUserId", + "in": "path", + "description": "The person to drill into", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "startDate", + "in": "query", + "description": "Start of the range (Y-m-d, inclusive)", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "endDate", + "in": "query", + "description": "End of the range (Y-m-d, inclusive)", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "categoryIds[]", + "in": "query", + "description": "Categories to include; empty means every category", + "schema": { + "type": "array", + "default": [], + "items": { + "type": "integer", + "format": "int64" } } + }, + { + "name": "includeUncategorized", + "in": "query", + "description": "Also include appointments without a category", + "schema": { + "type": "boolean", + "default": false + } } - }, + ], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExportResult" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string" - } - } + "$ref": "#/components/schemas/StatisticsPersonDetail" } } } @@ -5425,6 +6328,34 @@ } } } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error", + "found", + "limit" + ], + "properties": { + "error": { + "type": "string" + }, + "found": { + "type": "integer", + "format": "int64" + }, + "limit": { + "type": "integer", + "format": "int64" + } + } + } + } + } } } } diff --git a/openapi.json b/openapi.json index c889df43..49b6afa0 100644 --- a/openapi.json +++ b/openapi.json @@ -495,7 +495,8 @@ "respondOnBehalf", "responseCounts", "locationsAvailable", - "categoriesAvailable" + "categoriesAvailable", + "statisticsAvailable" ], "properties": { "calendarAvailable": { @@ -554,6 +555,9 @@ }, "categoriesAvailable": { "type": "boolean" + }, + "statisticsAvailable": { + "type": "boolean" } } }, @@ -1147,187 +1151,670 @@ } } }, - "UserConfig": { + "Statistics": { "type": "object", "required": [ - "displayOrder", - "mobileAppBannerEnabled", - "hasPushDevice", - "onboarding" + "appointmentCount", + "pastCount", + "attendanceRecordedCount", + "groupBy", + "people", + "sections", + "totals", + "timeline", + "byCategory" ], "properties": { - "displayOrder": { + "appointmentCount": { + "type": "integer", + "format": "int64" + }, + "pastCount": { + "type": "integer", + "format": "int64" + }, + "attendanceRecordedCount": { + "type": "integer", + "format": "int64" + }, + "groupBy": { "type": "string" }, - "mobileAppBannerEnabled": { - "type": "boolean" + "people": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatisticsPerson" + } }, - "hasPushDevice": { - "type": "boolean" + "sections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatisticsSection" + } }, - "onboarding": { - "$ref": "#/components/schemas/OnboardingState" + "totals": { + "$ref": "#/components/schemas/StatisticsTotals" + }, + "timeline": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatisticsTimelinePoint" + } + }, + "byCategory": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatisticsCategory" + } } } }, - "UserPermissions": { + "StatisticsCategory": { "type": "object", "required": [ - "canManageAppointments", - "canCreateAppointments", - "canCheckin", - "canSeeResponseOverview", - "canSeeResponseCounts", - "canSeeComments", - "canSelfCheckin", - "canRespondForOthers" + "categoryId", + "displayName", + "appointmentCount", + "targetCount", + "yes", + "present", + "attendanceBase", + "acceptRate", + "attendanceRate" ], "properties": { - "canManageAppointments": { - "type": "boolean" + "categoryId": { + "type": "integer", + "format": "int64", + "nullable": true }, - "canCreateAppointments": { - "type": "boolean" + "displayName": { + "type": "string" }, - "canCheckin": { - "type": "boolean" + "appointmentCount": { + "type": "integer", + "format": "int64" }, - "canSeeResponseOverview": { - "type": "boolean" + "targetCount": { + "type": "integer", + "format": "int64" }, - "canSeeResponseCounts": { - "type": "boolean" + "yes": { + "type": "integer", + "format": "int64" }, - "canSeeComments": { - "type": "boolean" + "present": { + "type": "integer", + "format": "int64" }, - "canSelfCheckin": { - "type": "boolean" + "attendanceBase": { + "type": "integer", + "format": "int64" }, - "canRespondForOthers": { - "type": "boolean" + "acceptRate": { + "type": "number", + "format": "double", + "nullable": true + }, + "attendanceRate": { + "type": "number", + "format": "double", + "nullable": true } } - } - } - }, - "paths": { - "/index.php/apps/attendance/api/appointments": { - "get": { - "operationId": "appointment-index", - "summary": "List appointments visible to the current user", - "tags": [ - "appointment" + }, + "StatisticsPerson": { + "type": "object", + "required": [ + "userId", + "displayName", + "isGuest", + "sections", + "targetCount", + "yes", + "no", + "maybe", + "noResponse", + "present", + "absent", + "notRecorded", + "attendanceBase", + "noShow", + "responseRate", + "acceptRate", + "attendanceRate" ], - "security": [ - { - "bearer_auth": [] + "properties": { + "userId": { + "type": "string" }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "showPastAppointments", - "in": "query", - "description": "Whether to show past appointments instead of upcoming ones", - "schema": { - "type": "boolean", - "default": false - } + "displayName": { + "type": "string" }, - { - "name": "unansweredOnly", - "in": "query", - "description": "When true, only return upcoming appointments that the user has not answered yet AND that are still open. Ignored when $showPastAppointments is true.", - "schema": { - "type": "boolean", - "default": false - } + "isGuest": { + "type": "boolean" }, - { - "name": "onlyForMe", - "in": "query", - "description": "When true, restrict the result to appointments the user is part of the target audience for (visibleUsers/Groups/Teams membership; appointments with no visibility restriction count as \"for everyone\" and are included). Useful for managers, who otherwise see every appointment in the system.", - "schema": { - "type": "boolean", - "default": false + "sections": { + "type": "array", + "items": { + "type": "string" } }, - { - "name": "notScheduledOut", - "in": "query", - "description": "When true, additionally drop closed appointments where somebody was scheduled but the user was not. Only has an effect while the planning feature is enabled; appointments nobody was scheduled for stay visible to everyone. Implies $onlyForMe.", - "schema": { - "type": "boolean", - "default": false - } + "targetCount": { + "type": "integer", + "format": "int64" }, - { - "name": "onlyScheduled", - "in": "query", - "description": "When true, return only appointments the user has been given a place in. Only has an effect while the planning feature is enabled. Open inquiries count as soon as a manager booked the user — being booked is an explicit act, unlike not being booked. Implies $onlyForMe.", - "schema": { - "type": "boolean", - "default": false - } + "yes": { + "type": "integer", + "format": "int64" + }, + "no": { + "type": "integer", + "format": "int64" + }, + "maybe": { + "type": "integer", + "format": "int64" + }, + "noResponse": { + "type": "integer", + "format": "int64" + }, + "present": { + "type": "integer", + "format": "int64" + }, + "absent": { + "type": "integer", + "format": "int64" + }, + "notRecorded": { + "type": "integer", + "format": "int64" + }, + "attendanceBase": { + "type": "integer", + "format": "int64" + }, + "noShow": { + "type": "integer", + "format": "int64" + }, + "responseRate": { + "type": "number", + "format": "double", + "nullable": true + }, + "acceptRate": { + "type": "number", + "format": "double", + "nullable": true + }, + "attendanceRate": { + "type": "number", + "format": "double", + "nullable": true } + } + }, + "StatisticsPersonDetail": { + "type": "object", + "required": [ + "userId", + "displayName", + "isGuest", + "entries" ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AppointmentWithResponse" - } - } - } - } + "properties": { + "userId": { + "type": "string" }, - "401": { - "description": "Current user is not logged in", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string" - } - } - }, - { - "type": "object", - "required": [ - "message" - ], - "properties": { - "message": { - "type": "string" - } - } - } - ] - } - } + "displayName": { + "type": "string" + }, + "isGuest": { + "type": "boolean" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatisticsPersonEntry" } } } }, - "post": { - "operationId": "appointment-create", - "summary": "Create a new appointment", - "tags": [ - "appointment" + "StatisticsPersonEntry": { + "type": "object", + "required": [ + "appointmentId", + "name", + "startDatetime", + "response", + "checkinState", + "attendanceRecorded", + "comment" + ], + "properties": { + "appointmentId": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "startDatetime": { + "type": "string", + "nullable": true + }, + "response": { + "type": "string", + "nullable": true + }, + "checkinState": { + "type": "string", + "nullable": true + }, + "attendanceRecorded": { + "type": "boolean" + }, + "comment": { + "type": "string", + "nullable": true + } + } + }, + "StatisticsSection": { + "type": "object", + "required": [ + "id", + "displayName", + "personCount", + "targetCount", + "yes", + "no", + "maybe", + "noResponse", + "present", + "absent", + "notRecorded", + "attendanceBase", + "noShow", + "responseRate", + "acceptRate", + "attendanceRate" + ], + "properties": { + "id": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "personCount": { + "type": "integer", + "format": "int64" + }, + "targetCount": { + "type": "integer", + "format": "int64" + }, + "yes": { + "type": "integer", + "format": "int64" + }, + "no": { + "type": "integer", + "format": "int64" + }, + "maybe": { + "type": "integer", + "format": "int64" + }, + "noResponse": { + "type": "integer", + "format": "int64" + }, + "present": { + "type": "integer", + "format": "int64" + }, + "absent": { + "type": "integer", + "format": "int64" + }, + "notRecorded": { + "type": "integer", + "format": "int64" + }, + "attendanceBase": { + "type": "integer", + "format": "int64" + }, + "noShow": { + "type": "integer", + "format": "int64" + }, + "responseRate": { + "type": "number", + "format": "double", + "nullable": true + }, + "acceptRate": { + "type": "number", + "format": "double", + "nullable": true + }, + "attendanceRate": { + "type": "number", + "format": "double", + "nullable": true + } + } + }, + "StatisticsTimelinePoint": { + "type": "object", + "required": [ + "appointmentId", + "name", + "startDatetime", + "targetCount", + "yes", + "present", + "attendanceRecorded" + ], + "properties": { + "appointmentId": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "startDatetime": { + "type": "string", + "nullable": true + }, + "targetCount": { + "type": "integer", + "format": "int64" + }, + "yes": { + "type": "integer", + "format": "int64" + }, + "present": { + "type": "integer", + "format": "int64" + }, + "attendanceRecorded": { + "type": "boolean" + } + } + }, + "StatisticsTotals": { + "type": "object", + "required": [ + "targetCount", + "yes", + "no", + "maybe", + "noResponse", + "present", + "absent", + "notRecorded", + "attendanceBase", + "noShow", + "responseRate", + "acceptRate", + "attendanceRate" + ], + "properties": { + "targetCount": { + "type": "integer", + "format": "int64" + }, + "yes": { + "type": "integer", + "format": "int64" + }, + "no": { + "type": "integer", + "format": "int64" + }, + "maybe": { + "type": "integer", + "format": "int64" + }, + "noResponse": { + "type": "integer", + "format": "int64" + }, + "present": { + "type": "integer", + "format": "int64" + }, + "absent": { + "type": "integer", + "format": "int64" + }, + "notRecorded": { + "type": "integer", + "format": "int64" + }, + "attendanceBase": { + "type": "integer", + "format": "int64" + }, + "noShow": { + "type": "integer", + "format": "int64" + }, + "responseRate": { + "type": "number", + "format": "double", + "nullable": true + }, + "acceptRate": { + "type": "number", + "format": "double", + "nullable": true + }, + "attendanceRate": { + "type": "number", + "format": "double", + "nullable": true + } + } + }, + "UserConfig": { + "type": "object", + "required": [ + "displayOrder", + "mobileAppBannerEnabled", + "hasPushDevice", + "onboarding" + ], + "properties": { + "displayOrder": { + "type": "string" + }, + "mobileAppBannerEnabled": { + "type": "boolean" + }, + "hasPushDevice": { + "type": "boolean" + }, + "onboarding": { + "$ref": "#/components/schemas/OnboardingState" + } + } + }, + "UserPermissions": { + "type": "object", + "required": [ + "canManageAppointments", + "canCreateAppointments", + "canCheckin", + "canSeeResponseOverview", + "canSeeResponseCounts", + "canSeeComments", + "canSelfCheckin", + "canRespondForOthers", + "canSeeStatistics", + "canSeeIndividualResponses" + ], + "properties": { + "canManageAppointments": { + "type": "boolean" + }, + "canCreateAppointments": { + "type": "boolean" + }, + "canCheckin": { + "type": "boolean" + }, + "canSeeResponseOverview": { + "type": "boolean" + }, + "canSeeResponseCounts": { + "type": "boolean" + }, + "canSeeComments": { + "type": "boolean" + }, + "canSelfCheckin": { + "type": "boolean" + }, + "canRespondForOthers": { + "type": "boolean" + }, + "canSeeStatistics": { + "type": "boolean" + }, + "canSeeIndividualResponses": { + "type": "boolean" + } + } + } + } + }, + "paths": { + "/index.php/apps/attendance/api/appointments": { + "get": { + "operationId": "appointment-index", + "summary": "List appointments visible to the current user", + "tags": [ + "appointment" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "showPastAppointments", + "in": "query", + "description": "Whether to show past appointments instead of upcoming ones", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "unansweredOnly", + "in": "query", + "description": "When true, only return upcoming appointments that the user has not answered yet AND that are still open. Ignored when $showPastAppointments is true.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "onlyForMe", + "in": "query", + "description": "When true, restrict the result to appointments the user is part of the target audience for (visibleUsers/Groups/Teams membership; appointments with no visibility restriction count as \"for everyone\" and are included). Useful for managers, who otherwise see every appointment in the system.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "notScheduledOut", + "in": "query", + "description": "When true, additionally drop closed appointments where somebody was scheduled but the user was not. Only has an effect while the planning feature is enabled; appointments nobody was scheduled for stay visible to everyone. Implies $onlyForMe.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "onlyScheduled", + "in": "query", + "description": "When true, return only appointments the user has been given a place in. Only has an effect while the planning feature is enabled. Open inquiries count as soon as a manager booked the user — being booked is an explicit act, unlike not being booked. Implies $onlyForMe.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AppointmentWithResponse" + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + ] + } + } + } + } + } + }, + "post": { + "operationId": "appointment-create", + "summary": "Create a new appointment", + "tags": [ + "appointment" ], "security": [ { @@ -4186,13 +4673,195 @@ } }, { - "name": "userId", - "in": "path", - "description": "The responder to unbook", - "required": true, - "schema": { - "type": "string" - } + "name": "userId", + "in": "path", + "description": "The responder to unbook", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResponseData" + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + ] + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/index.php/apps/attendance/api/categories": { + "get": { + "operationId": "category-index", + "summary": "List all categories, alphabetically", + "description": "Available to any logged-in user — appointments' category picker and the appointment list filter both need the full list, not just admins.", + "tags": [ + "category" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CategoryData" + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + ] + } + } + } + } + } + } + }, + "/index.php/apps/attendance/api/user/permissions": { + "get": { + "operationId": "appointment-get-permissions", + "summary": "Get the current user's permissions", + "tags": [ + "appointment" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] } ], "responses": { @@ -4201,7 +4870,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResponseData" + "$ref": "#/components/schemas/UserPermissions" } } } @@ -4238,54 +4907,47 @@ } } } + } + } + } + }, + "/index.php/apps/attendance/api/user/config": { + "get": { + "operationId": "appointment-get-user-config", + "summary": "Get user-relevant app configuration", + "tags": [ + "appointment" + ], + "security": [ + { + "bearer_auth": [] }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "400": { + { + "basic_auth": [] + } + ], + "responses": { + "200": { "description": "", "content": { "application/json": { "schema": { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string" - } - } + "$ref": "#/components/schemas/UserConfig" } } } }, - "404": { - "description": "", + "401": { + "description": "Current user is not logged in", "content": { "application/json": { "schema": { "type": "object", "required": [ - "error" + "message" ], "properties": { - "error": { + "message": { "type": "string" } } @@ -4296,13 +4958,13 @@ } } }, - "/index.php/apps/attendance/api/categories": { + "/index.php/apps/attendance/api/user/settings": { "get": { - "operationId": "category-index", - "summary": "List all categories, alphabetically", - "description": "Available to any logged-in user — appointments' category picker and the appointment list filter both need the full list, not just admins.", + "operationId": "appointment-get-user-settings", + "summary": "Get personal user settings", + "description": "Response-change notification toggle is only included for users who can actually receive these notifications (managers, users allowed to create appointments, or organizers of at least one appointment — with audit log enabled). Frontend uses the key's presence to decide whether to render the toggle at all.", "tags": [ - "category" + "appointment" ], "security": [ { @@ -4318,9 +4980,20 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CategoryData" + "type": "object", + "required": [ + "icalReminderTriggers" + ], + "properties": { + "icalReminderTriggers": { + "type": "array", + "items": { + "type": "string" + } + }, + "notifyResponseChanges": { + "type": "boolean" + } } } } @@ -4360,12 +5033,10 @@ } } } - } - }, - "/index.php/apps/attendance/api/user/permissions": { - "get": { - "operationId": "appointment-get-permissions", - "summary": "Get the current user's permissions", + }, + "post": { + "operationId": "appointment-save-user-settings", + "summary": "Save personal user settings", "tags": [ "appointment" ], @@ -4377,13 +5048,47 @@ "basic_auth": [] } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "icalReminderTriggers": { + "type": "array", + "default": [], + "description": "List of duration strings for iCal reminder triggers", + "items": { + "type": "string" + } + }, + "notifyResponseChanges": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "Opt-in toggle for response-change notifications (only honoured for eligible users)" + } + } + } + } + } + }, "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserPermissions" + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean" + } + } } } } @@ -4424,10 +5129,10 @@ } } }, - "/index.php/apps/attendance/api/user/config": { + "/index.php/apps/attendance/api/capabilities": { "get": { - "operationId": "appointment-get-user-config", - "summary": "Get user-relevant app configuration", + "operationId": "appointment-get-capabilities", + "summary": "Get system-wide capabilities", "tags": [ "appointment" ], @@ -4445,7 +5150,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserConfig" + "$ref": "#/components/schemas/Capabilities" } } } @@ -4471,11 +5176,58 @@ } } }, - "/index.php/apps/attendance/api/user/settings": { + "/index.php/apps/attendance/api/push-config": { "get": { - "operationId": "appointment-get-user-settings", - "summary": "Get personal user settings", - "description": "Response-change notification toggle is only included for users who can actually receive these notifications (managers, users allowed to create appointments, or organizers of at least one appointment — with audit log enabled). Frontend uses the key's presence to decide whether to render the toggle at all.", + "operationId": "appointment-get-push-config", + "summary": "Get push notification configuration", + "description": "Returns the push proxy server URL for mobile clients.", + "tags": [ + "appointment" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PushConfig" + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/index.php/apps/attendance/api/search/users-groups-teams": { + "get": { + "operationId": "appointment-search-users-groups-teams", + "summary": "Search for users, groups, and teams", "tags": [ "appointment" ], @@ -4483,29 +5235,71 @@ { "bearer_auth": [] }, - { - "basic_auth": [] - } - ], - "responses": { - "200": { + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "search", + "in": "query", + "description": "Search query", + "schema": { + "type": "string", + "default": "" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "label", + "type", + "icon", + "isGuest" + ], + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "isGuest": { + "type": "boolean" + } + } + } + } + } + } + }, + "400": { "description": "", "content": { "application/json": { "schema": { "type": "object", "required": [ - "icalReminderTriggers" + "error" ], "properties": { - "icalReminderTriggers": { - "type": "array", - "items": { - "type": "string" - } - }, - "notifyResponseChanges": { - "type": "boolean" + "error": { + "type": "string" } } } @@ -4544,12 +5338,33 @@ } } } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } } } - }, + } + }, + "/index.php/apps/attendance/api/guests": { "post": { - "operationId": "appointment-save-user-settings", - "summary": "Save personal user settings", + "operationId": "appointment-create-guest", + "summary": "Create a guest account via the Nextcloud Guests app", + "description": "Allows organizers to invite people without a Nextcloud account by provisioning a guest user. The new account is identified by its email (used both as UID and email). Guests cannot manage appointments or check in others — that is enforced by `PermissionService` regardless of group configuration.", "tags": [ "appointment" ], @@ -4568,19 +5383,15 @@ "schema": { "type": "object", "properties": { - "icalReminderTriggers": { - "type": "array", - "default": [], - "description": "List of duration strings for iCal reminder triggers", - "items": { - "type": "string" - } + "email": { + "type": "string", + "default": "", + "description": "Email address that becomes both the guest's UID and contact address" }, - "notifyResponseChanges": { - "type": "boolean", - "nullable": true, - "default": null, - "description": "Opt-in toggle for response-change notifications (only honoured for eligible users)" + "displayName": { + "type": "string", + "default": "", + "description": "Optional human-readable name for the guest" } } } @@ -4589,17 +5400,27 @@ }, "responses": { "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GuestCreationResult" + } + } + } + }, + "400": { "description": "", "content": { "application/json": { "schema": { "type": "object", "required": [ - "success" + "error" ], "properties": { - "success": { - "type": "boolean" + "error": { + "type": "string" } } } @@ -4638,47 +5459,36 @@ } } } - } - } - } - }, - "/index.php/apps/attendance/api/capabilities": { - "get": { - "operationId": "appointment-get-capabilities", - "summary": "Get system-wide capabilities", - "tags": [ - "appointment" - ], - "security": [ - { - "bearer_auth": [] }, - { - "basic_auth": [] - } - ], - "responses": { - "200": { + "403": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Capabilities" + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } } } } }, - "401": { - "description": "Current user is not logged in", + "500": { + "description": "", "content": { "application/json": { "schema": { "type": "object", "required": [ - "message" + "error" ], "properties": { - "message": { + "error": { "type": "string" } } @@ -4689,11 +5499,10 @@ } } }, - "/index.php/apps/attendance/api/push-config": { - "get": { - "operationId": "appointment-get-push-config", - "summary": "Get push notification configuration", - "description": "Returns the push proxy server URL for mobile clients.", + "/index.php/apps/attendance/api/export": { + "post": { + "operationId": "appointment-export", + "summary": "Export appointments to ODS file with optional filtering", "tags": [ "appointment" ], @@ -4705,103 +5514,108 @@ "basic_auth": [] } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "appointmentIds": { + "type": "array", + "nullable": true, + "default": null, + "description": "Specific appointment IDs to export, or null for all", + "items": { + "type": "integer", + "format": "int64" + } + }, + "startDate": { + "type": "string", + "nullable": true, + "default": null, + "description": "Start date filter in Y-m-d format" + }, + "endDate": { + "type": "string", + "nullable": true, + "default": null, + "description": "End date filter in Y-m-d format" + }, + "includeComments": { + "type": "boolean", + "default": false, + "description": "Whether to include user comments in the export" + } + } + } + } + } + }, "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PushConfig" + "$ref": "#/components/schemas/ExportResult" } } } }, - "401": { - "description": "Current user is not logged in", + "400": { + "description": "", "content": { "application/json": { "schema": { "type": "object", "required": [ - "message" + "error" ], "properties": { - "message": { + "error": { "type": "string" } } } } } - } - } - } - }, - "/index.php/apps/attendance/api/search/users-groups-teams": { - "get": { - "operationId": "appointment-search-users-groups-teams", - "summary": "Search for users, groups, and teams", - "tags": [ - "appointment" - ], - "security": [ - { - "bearer_auth": [] }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "search", - "in": "query", - "description": "Search query", - "schema": { - "type": "string", - "default": "" - } - } - ], - "responses": { - "200": { - "description": "", + "401": { + "description": "Current user is not logged in", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "label", - "type", - "icon", - "isGuest" - ], - "properties": { - "id": { - "type": "string" - }, - "label": { - "type": "string" - }, - "type": { - "type": "string" - }, - "icon": { - "type": "string" - }, - "isGuest": { - "type": "boolean" + "anyOf": [ + { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } } } - } + ] } } } }, - "400": { + "403": { "description": "", "content": { "application/json": { @@ -4818,6 +5632,88 @@ } } } + } + } + } + }, + "/index.php/apps/attendance/api/statistics": { + "get": { + "operationId": "statistics-index", + "summary": "Evaluate responses and attendance across appointments", + "description": "Users without the see_statistics permission get their own row and nothing else: no other people, no sections, no totals and no chart series.", + "tags": [ + "statistics" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "startDate", + "in": "query", + "description": "Start of the range (Y-m-d, inclusive)", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "endDate", + "in": "query", + "description": "End of the range (Y-m-d, inclusive)", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "categoryIds[]", + "in": "query", + "description": "Categories to include; empty means every category", + "schema": { + "type": "array", + "default": [], + "items": { + "type": "integer", + "format": "int64" + } + } + }, + { + "name": "includeUncategorized", + "in": "query", + "description": "Also include appointments without a category", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "groupBy", + "in": "query", + "description": "Section axis: `groups` or `teams`", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Statistics" + } + } + } }, "401": { "description": "Current user is not logged in", @@ -4852,18 +5748,28 @@ } } }, - "403": { + "400": { "description": "", "content": { "application/json": { "schema": { "type": "object", "required": [ - "error" + "error", + "found", + "limit" ], "properties": { "error": { "type": "string" + }, + "found": { + "type": "integer", + "format": "int64" + }, + "limit": { + "type": "integer", + "format": "int64" } } } @@ -4873,13 +5779,12 @@ } } }, - "/index.php/apps/attendance/api/guests": { + "/index.php/apps/attendance/api/statistics/export": { "post": { - "operationId": "appointment-create-guest", - "summary": "Create a guest account via the Nextcloud Guests app", - "description": "Allows organizers to invite people without a Nextcloud account by provisioning a guest user. The new account is identified by its email (used both as UID and email). Guests cannot manage appointments or check in others — that is enforced by `PermissionService` regardless of group configuration.", + "operationId": "statistics-export", + "summary": "Export the statistics table as an ODS file", "tags": [ - "appointment" + "statistics" ], "security": [ { @@ -4896,15 +5801,35 @@ "schema": { "type": "object", "properties": { - "email": { + "startDate": { "type": "string", - "default": "", - "description": "Email address that becomes both the guest's UID and contact address" + "nullable": true, + "default": null, + "description": "Start of the range (Y-m-d, inclusive)" }, - "displayName": { + "endDate": { "type": "string", - "default": "", - "description": "Optional human-readable name for the guest" + "nullable": true, + "default": null, + "description": "End of the range (Y-m-d, inclusive)" + }, + "categoryIds": { + "type": "array", + "default": [], + "description": "Categories to include; empty means every category", + "items": { + "type": "integer", + "format": "int64" + } + }, + "includeUncategorized": { + "type": "boolean", + "default": false, + "description": "Also include appointments without a category" + }, + "groupBy": { + "type": "string", + "description": "Section axis: `groups` or `teams`" } } } @@ -4917,25 +5842,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GuestCreationResult" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string" - } - } + "$ref": "#/components/schemas/ExportResult" } } } @@ -4991,7 +5898,7 @@ } } }, - "500": { + "400": { "description": "", "content": { "application/json": { @@ -5012,12 +5919,12 @@ } } }, - "/index.php/apps/attendance/api/export": { - "post": { - "operationId": "appointment-export", - "summary": "Export appointments to ODS file with optional filtering", + "/index.php/apps/attendance/api/statistics/person/{targetUserId}": { + "get": { + "operationId": "statistics-person", + "summary": "List one person's appointments behind their statistics row", "tags": [ - "appointment" + "statistics" ], "security": [ { @@ -5027,70 +5934,66 @@ "basic_auth": [] } ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "appointmentIds": { - "type": "array", - "nullable": true, - "default": null, - "description": "Specific appointment IDs to export, or null for all", - "items": { - "type": "integer", - "format": "int64" - } - }, - "startDate": { - "type": "string", - "nullable": true, - "default": null, - "description": "Start date filter in Y-m-d format" - }, - "endDate": { - "type": "string", - "nullable": true, - "default": null, - "description": "End date filter in Y-m-d format" - }, - "includeComments": { - "type": "boolean", - "default": false, - "description": "Whether to include user comments in the export" - } - } + "parameters": [ + { + "name": "targetUserId", + "in": "path", + "description": "The person to drill into", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "startDate", + "in": "query", + "description": "Start of the range (Y-m-d, inclusive)", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "endDate", + "in": "query", + "description": "End of the range (Y-m-d, inclusive)", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "categoryIds[]", + "in": "query", + "description": "Categories to include; empty means every category", + "schema": { + "type": "array", + "default": [], + "items": { + "type": "integer", + "format": "int64" } } + }, + { + "name": "includeUncategorized", + "in": "query", + "description": "Also include appointments without a category", + "schema": { + "type": "boolean", + "default": false + } } - }, + ], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExportResult" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string" - } - } + "$ref": "#/components/schemas/StatisticsPersonDetail" } } } @@ -5145,6 +6048,34 @@ } } } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error", + "found", + "limit" + ], + "properties": { + "error": { + "type": "string" + }, + "found": { + "type": "integer", + "format": "int64" + }, + "limit": { + "type": "integer", + "format": "int64" + } + } + } + } + } } } } diff --git a/package-lock.json b/package-lock.json index 91112461..14ae31f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "attendance", - "version": "1.45.1", + "version": "1.46.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "attendance", - "version": "1.45.1", + "version": "1.46.0", "license": "AGPL-3.0-or-later", "dependencies": { "@nextcloud/axios": "^2.6.0", @@ -16,6 +16,7 @@ "@nextcloud/router": "^3.1.0", "@nextcloud/vue": "^9.9.0", "canvas-confetti": "^1.9.4", + "chart.js": "^4.5.1", "date-fns": "^4.4.0", "date-fns-tz": "^3.2.0", "dompurify": "^3.4.13", @@ -24,6 +25,7 @@ "qrcode": "^1.5.4", "rrule": "^2.8.1", "vue": "^3.5.41", + "vue-chartjs": "^5.3.4", "vue-material-design-icons": "^5.3.1" }, "devDependencies": { @@ -197,7 +199,7 @@ "version": "2.13.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.13.0.tgz", "integrity": "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g==", - "devOptional": true, + "dev": true, "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@buttercup/fetch": { @@ -503,6 +505,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -519,6 +522,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -535,6 +539,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -551,6 +556,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -567,6 +573,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -583,6 +590,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -599,6 +607,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -615,6 +624,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -631,6 +641,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -647,6 +658,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -663,6 +675,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -679,6 +692,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -695,6 +709,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -711,6 +726,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -727,6 +743,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -743,6 +760,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -759,6 +777,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -775,6 +794,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -791,6 +811,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -807,6 +828,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -823,6 +845,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -839,6 +862,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -855,6 +879,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -871,6 +896,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -887,6 +913,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -903,6 +930,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1358,6 +1386,12 @@ "license": "MIT", "peer": true }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@mdi/js": { "version": "7.4.47", "resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz", @@ -1495,6 +1529,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1970,6 +2005,7 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2008,6 +2044,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2028,6 +2065,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2048,6 +2086,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2068,6 +2107,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2088,6 +2128,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2108,6 +2149,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2128,6 +2170,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2148,6 +2191,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2168,6 +2212,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2188,6 +2233,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2208,6 +2254,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2228,6 +2275,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2405,6 +2453,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2418,6 +2467,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2431,6 +2481,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2444,6 +2495,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2457,6 +2509,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2470,6 +2523,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2483,6 +2537,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2496,6 +2551,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2509,6 +2565,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2522,6 +2579,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2535,6 +2593,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2548,6 +2607,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2561,6 +2621,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2574,6 +2635,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2587,6 +2649,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2600,6 +2663,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2613,6 +2677,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2626,6 +2691,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2639,6 +2705,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2652,6 +2719,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2665,6 +2733,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2678,6 +2747,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2691,6 +2761,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2704,6 +2775,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2717,6 +2789,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3039,7 +3112,7 @@ "version": "26.1.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -4682,6 +4755,18 @@ "node": "*" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -4826,7 +4911,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -5294,6 +5379,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", "optional": true, "engines": { @@ -6511,6 +6597,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6744,7 +6831,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7068,7 +7155,7 @@ "version": "5.1.9", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/import-fresh": { @@ -7252,7 +7339,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7291,7 +7378,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -8743,6 +8830,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, "license": "MIT", "optional": true }, @@ -10335,7 +10423,7 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -10391,7 +10479,7 @@ "version": "1.102.0", "resolved": "https://registry.npmjs.org/sass/-/sass-1.102.0.tgz", "integrity": "sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==", - "devOptional": true, + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -10413,7 +10501,7 @@ "version": "1.100.0", "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.100.0.tgz", "integrity": "sha512-Ut8wlQSk19tm7jMK6mz6cF1+e+E7tUnW2tM02zQDPnOTcVbV8qCQG8UWxZkkNlY50+hV3hqP24OOkUlMz8xBpw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@bufbuild/protobuf": "^2.5.0", @@ -10461,6 +10549,7 @@ "!riscv64", "!x64" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -10471,6 +10560,7 @@ "version": "1.100.0", "resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz", "integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -10495,6 +10585,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10511,6 +10602,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10527,6 +10619,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10543,6 +10636,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10559,6 +10653,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10575,6 +10670,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10591,6 +10687,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10607,6 +10704,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10623,6 +10721,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10639,6 +10738,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10655,6 +10755,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10671,6 +10772,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10687,6 +10789,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10703,6 +10806,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10716,6 +10820,7 @@ "version": "1.100.0", "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.100.0.tgz", "integrity": "sha512-c+naBgWId4MIpToXcI0DgqetjdAkwTTAxFAuOaBz7HUXLdyG1oZRrEvSsbe41nEdQOKH0vgofVFCeSQgoXOG9A==", + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10732,6 +10837,7 @@ "version": "1.100.0", "resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz", "integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -10756,6 +10862,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10772,6 +10879,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11639,7 +11747,7 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -11721,7 +11829,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "sync-message-port": "^1.0.0" @@ -11734,7 +11842,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.2.0.tgz", "integrity": "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=16.0.0" @@ -12131,7 +12239,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -12448,7 +12556,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/vfile": { @@ -12615,6 +12723,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12631,6 +12740,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12647,6 +12757,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12663,6 +12774,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12679,6 +12791,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12695,6 +12808,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12711,6 +12825,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12727,6 +12842,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12743,6 +12859,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12759,6 +12876,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12775,6 +12893,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12791,6 +12910,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12807,6 +12927,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12823,6 +12944,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12839,6 +12961,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12855,6 +12978,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12871,6 +12995,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12887,6 +13012,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12903,6 +13029,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12919,6 +13046,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12935,6 +13063,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12951,6 +13080,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12967,6 +13097,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12983,6 +13114,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12999,6 +13131,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13015,6 +13148,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13070,6 +13204,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -13115,6 +13250,16 @@ } } }, + "node_modules/vue-chartjs": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.4.tgz", + "integrity": "sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "vue": "^3.0.0-0 || ^2.7.0" + } + }, "node_modules/vue-eslint-parser": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz", diff --git a/package.json b/package.json index cdad8c3e..1ff686d9 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@nextcloud/router": "^3.1.0", "@nextcloud/vue": "^9.9.0", "canvas-confetti": "^1.9.4", + "chart.js": "^4.5.1", "date-fns": "^4.4.0", "date-fns-tz": "^3.2.0", "dompurify": "^3.4.13", @@ -40,6 +41,7 @@ "qrcode": "^1.5.4", "rrule": "^2.8.1", "vue": "^3.5.41", + "vue-chartjs": "^5.3.4", "vue-material-design-icons": "^5.3.1" }, "devDependencies": { diff --git a/playwright.config.js b/playwright.config.js index 75b697f6..afbcc5e6 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -93,6 +93,8 @@ export default defineConfig({ // the parallel project's other files (2-appointment, 3-voting, // 30-location, ...) mid-run. '31-category.spec.js', + // Grants and revokes see_statistics instance-wide. + '32-statistics.spec.js', ], fullyParallel: false, workers: 1, diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 2e9b173a..9f51f55b 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -376,6 +376,14 @@ + + + + + + + + @@ -1179,6 +1187,21 @@ + + + + + + + + + + + + + + + @@ -1792,6 +1815,7 @@ + @@ -1930,6 +1954,11 @@ + + + + + @@ -2268,6 +2297,31 @@ + + + + + + rootFolder]]> + + + + + + + + + + + + + + + + + + + diff --git a/scripts/reset-dev-data.sh b/scripts/reset-dev-data.sh index 6c3b66c1..a3029f8a 100755 --- a/scripts/reset-dev-data.sh +++ b/scripts/reset-dev-data.sh @@ -41,6 +41,7 @@ PERMISSIONS=( manage_appointments checkin see_response_overview see_response_counts see_comments self_checkin create_appointments respond_for_others + see_statistics ) TABLES=( diff --git a/src/App.vue b/src/App.vue index 1663d040..1f0026a6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,7 @@