package services import ( "context" "encoding/csv" "fmt" "io" "strings" "time" ) // LessonExport is the flat row shape used by both CSV and ICS exports. // We materialise it once via SQL so the two encoders share zero logic. type LessonExport struct { DayOfWeek int PeriodIndex int StartTime string // HH:MM EndTime string // HH:MM ClassName string SubjectName string SubjectCode string TeacherName string RoomName string Pinned bool } // LoadExportLessons joins tt_lesson against the period schedule so each row // already carries the wall-clock time. Ownership enforced via the parent // solution.created_by_user_id. func (s *TimetableService) LoadExportLessons(ctx context.Context, solutionID, userID string) ([]LessonExport, error) { rows, err := s.db.Query(ctx, ` SELECT l.day_of_week, l.period_index, to_char(p.start_time, 'HH24:MI') AS st, to_char(p.end_time, 'HH24:MI') AS et, cl.name, sub.name, sub.short_code, t.last_name || ', ' || t.first_name, COALESCE(r.name, ''), l.pinned FROM tt_lesson l JOIN tt_solution s ON l.solution_id = s.id JOIN tt_class cl ON l.class_id = cl.id JOIN tt_subject sub ON l.subject_id = sub.id JOIN tt_teacher t ON l.teacher_id = t.id LEFT JOIN tt_room r ON l.room_id = r.id LEFT JOIN tt_period p ON p.day_of_week = l.day_of_week AND p.period_index = l.period_index AND p.created_by_user_id = s.created_by_user_id WHERE s.id = $1 AND s.created_by_user_id = $2 ORDER BY l.day_of_week, l.period_index, cl.name `, solutionID, userID) if err != nil { return nil, err } defer rows.Close() var out []LessonExport for rows.Next() { var le LessonExport var st, et *string if err := rows.Scan(&le.DayOfWeek, &le.PeriodIndex, &st, &et, &le.ClassName, &le.SubjectName, &le.SubjectCode, &le.TeacherName, &le.RoomName, &le.Pinned); err != nil { return nil, err } if st != nil { le.StartTime = *st } if et != nil { le.EndTime = *et } out = append(out, le) } return out, nil } // WriteCSV streams the lesson list as comma-separated UTF-8. func WriteCSV(w io.Writer, lessons []LessonExport) error { csvw := csv.NewWriter(w) defer csvw.Flush() if err := csvw.Write([]string{ "day_of_week", "period_index", "start_time", "end_time", "class", "subject", "subject_code", "teacher", "room", "pinned", }); err != nil { return err } for _, l := range lessons { pinned := "false" if l.Pinned { pinned = "true" } if err := csvw.Write([]string{ fmt.Sprintf("%d", l.DayOfWeek), fmt.Sprintf("%d", l.PeriodIndex), l.StartTime, l.EndTime, l.ClassName, l.SubjectName, l.SubjectCode, l.TeacherName, l.RoomName, pinned, }); err != nil { return err } } return nil } // WriteICS emits one VEVENT per lesson, anchored to weekStart (a Monday). // RFC 5545 line endings are CRLF; we let strings.Builder handle that. // // The icsTimestamp helper drops the ":" + seconds so the emitted string // matches Apple Calendar's and Google Calendar's expectations exactly. func WriteICS(w io.Writer, lessons []LessonExport, weekStart time.Time, solutionName string) error { if solutionName == "" { solutionName = "BreakPilot Stundenplan" } var b strings.Builder b.WriteString("BEGIN:VCALENDAR\r\n") b.WriteString("VERSION:2.0\r\n") b.WriteString("PRODID:-//BreakPilot//Timetable//DE\r\n") b.WriteString("CALSCALE:GREGORIAN\r\n") b.WriteString("METHOD:PUBLISH\r\n") now := time.Now().UTC() for i, l := range lessons { if l.StartTime == "" || l.EndTime == "" { continue // skip rows where no matching period row found } // day_of_week 1=Mo..7=So → offset 0..6 from weekStart (Mon). date := weekStart.AddDate(0, 0, l.DayOfWeek-1) dtStart, err := combineDateTime(date, l.StartTime) if err != nil { return err } dtEnd, err := combineDateTime(date, l.EndTime) if err != nil { return err } b.WriteString("BEGIN:VEVENT\r\n") fmt.Fprintf(&b, "UID:lesson-%d-d%dp%d-%s@breakpilot\r\n", i, l.DayOfWeek, l.PeriodIndex, dtStart.Format("20060102")) fmt.Fprintf(&b, "DTSTAMP:%s\r\n", now.Format("20060102T150405Z")) fmt.Fprintf(&b, "DTSTART:%s\r\n", dtStart.Format("20060102T150405")) fmt.Fprintf(&b, "DTEND:%s\r\n", dtEnd.Format("20060102T150405")) fmt.Fprintf(&b, "SUMMARY:%s (%s)\r\n", icsEscape(l.SubjectName), icsEscape(l.ClassName)) if l.RoomName != "" { fmt.Fprintf(&b, "LOCATION:%s\r\n", icsEscape(l.RoomName)) } fmt.Fprintf(&b, "DESCRIPTION:Lehrer: %s\\n%s\r\n", icsEscape(l.TeacherName), icsEscape(solutionName)) b.WriteString("END:VEVENT\r\n") } b.WriteString("END:VCALENDAR\r\n") _, err := io.WriteString(w, b.String()) return err } func icsEscape(s string) string { r := strings.NewReplacer(",", "\\,", ";", "\\;", "\n", "\\n") return r.Replace(s) } // combineDateTime fuses a date (yyyy-mm-dd) and an HH:MM string into a // timezone-naive local timestamp. func combineDateTime(date time.Time, hhmm string) (time.Time, error) { parts := strings.SplitN(hhmm, ":", 2) if len(parts) != 2 { return time.Time{}, fmt.Errorf("invalid HH:MM %q", hhmm) } var hour, minute int if _, err := fmt.Sscanf(parts[0], "%d", &hour); err != nil { return time.Time{}, err } if _, err := fmt.Sscanf(parts[1], "%d", &minute); err != nil { return time.Time{}, err } return time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, time.Local), nil } // NextMonday returns the next Monday on or after the given reference time. func NextMonday(ref time.Time) time.Time { weekday := int(ref.Weekday()) // 0=Sun..6=Sat if weekday == 0 { weekday = 7 // shift Sun to 7 so Mon=1..Sun=7 mapping works } offset := (8 - weekday) % 7 // distance to next Mon (0 if today is Mon) return time.Date(ref.Year(), ref.Month(), ref.Day()+offset, 0, 0, 0, 0, ref.Location()) }