feat: add WannPassts booking app with Go backend and Vue frontend

Initial project setup for a privacy-focused, self-hosted scheduling app:

- Go backend with JWT auth, SQLite storage, and chi router
- Calendar integrations via Google OAuth (FreeBusy), CalDAV, and ICS links
- Public booking pages with configurable slots and booking requests
- AES-256-GCM encryption for stored provider tokens
- Vue 3 + TypeScript frontend with Vite, Pinia, and Vue Router
- Docs, .gitignore, and .env.example for local development
This commit is contained in:
Tronax 2026-08-25 18:50:28 +02:00
commit cb30223fd4
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
47 changed files with 6599 additions and 0 deletions

View file

@ -0,0 +1,145 @@
package calendar
import (
"testing"
"time"
)
const sampleICS = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//EN
BEGIN:VTIMEZONE
TZID:Europe/Berlin
BEGIN:STANDARD
DTSTART:19701025T030000
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
UID:1
DTSTART;TZID=Europe/Berlin:20260825T090000
DTEND;TZID=Europe/Berlin:20260825T100000
SUMMARY:Zahnarzt
END:VEVENT
BEGIN:VEVENT
UID:2
DTSTART:20260826T120000Z
DURATION:PT45M
SUMMARY:Call
END:VEVENT
BEGIN:VEVENT
UID:3
DTSTART;VALUE=DATE:20260827
SUMMARY:Ganztägig
END:VEVENT
BEGIN:VEVENT
UID:4
DTSTART:20260828T080000Z
DTEND:20260828T090000Z
STATUS:CANCELLED
SUMMARY:Abgesagt
END:VEVENT
BEGIN:VEVENT
UID:5
DTSTART:20260828T100000Z
DTEND:20260828T110000Z
TRANSP:TRANSPARENT
SUMMARY:Verfügbar
END:VEVENT
BEGIN:VEVENT
UID:6
DTSTART:20260829T080000Z
DTEND:202609
01T080000Z
SUMMARY:Gefaltet
END:VEVENT
END:VCALENDAR
`
func TestParseICS(t *testing.T) {
berlin, err := time.LoadLocation("Europe/Berlin")
if err != nil {
t.Fatal(err)
}
events := parseICS(sampleICS, berlin)
if len(events) != 6 {
t.Fatalf("6 Events erwartet, got %d", len(events))
}
// TZID-Event
if !events[0].start.Equal(time.Date(2026, 8, 25, 9, 0, 0, 0, berlin)) {
t.Errorf("TZID-Start falsch: %v", events[0].start)
}
if !events[0].end.Equal(time.Date(2026, 8, 25, 10, 0, 0, 0, berlin)) {
t.Errorf("TZID-Ende falsch: %v", events[0].end)
}
// UTC + DURATION
if !events[1].end.Equal(events[1].start.Add(45*time.Minute)) {
t.Errorf("DURATION nicht angewandt: %v", events[1].end)
}
// Ganztägig
if !events[2].allDay {
t.Error("VALUE=DATE nicht erkannt")
}
// Cancelled / Transparent markiert
if !events[3].cancelled {
t.Error("CANCELLED nicht erkannt")
}
if !events[4].transparent {
t.Error("TRANSPARENT nicht erkannt")
}
// Gefaltete Zeile (DTEND über zwei Zeilen)
if !events[5].end.Equal(time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)) {
t.Errorf("Folding nicht aufgelöst, Ende: %v", events[5].end)
}
}
func TestParseISODuration(t *testing.T) {
cases := map[string]time.Duration{
"PT15M": 15 * time.Minute,
"PT1H30M": 90 * time.Minute,
"P1D": 24 * time.Hour,
"P2W": 14 * 24 * time.Hour,
"PT45S": 45 * time.Second,
"": 0,
"X": 0,
"P-1D": 0,
}
for in, want := range cases {
got, ok := parseISODuration(in)
if in == "" || in == "X" || in == "P-1D" {
if ok {
t.Errorf("%q sollte ungültig sein", in)
}
continue
}
if !ok || got != want {
t.Errorf("parseISODuration(%q) = %v (ok=%v), want %v", in, got, ok, want)
}
}
}
func TestClampIntervals(t *testing.T) {
from := time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC)
to := from.Add(48 * time.Hour)
in := []Interval{
{Start: from.Add(-2 * time.Hour), End: from.Add(time.Hour)},
{Start: from.Add(3 * time.Hour), End: from.Add(2 * time.Hour)}, // ungültig
{Start: from.Add(10 * time.Hour), End: to.Add(time.Hour)},
}
out := clampIntervals(in, from, to)
if len(out) != 2 {
t.Fatalf("2 Intervalle erwartet, got %d", len(out))
}
if !out[0].Start.Equal(from) || !out[0].End.Equal(from.Add(time.Hour)) {
t.Errorf("Intervall 1 nicht geklemmt: %v%v", out[0].Start, out[0].End)
}
if !out[1].End.Equal(to) {
t.Errorf("Intervall 2 nicht geklemmt: %v", out[1].End)
}
}