1package views23import (4 "fmt"5 "sort"6 "strings"7 "time"89 "github.com/charmbracelet/lipgloss"10 "github.com/dami/showings/internal/models"11)1213// MovieGroup holds a presentation and its resolved showtimes for rendering.14type MovieGroup struct {15 Title string16 Certification string17 Headline string18 Showtimes []Showtime19}2021// Showtime holds the display-ready fields for a single session.22type Showtime struct {23 Time string // display time, e.g. "7:00pm"24 SortKey string // 24h time for sorting, e.g. "19:00"25 Screen string // "7"26 Format string // only set if non-default (e.g. "Big Show")27 IsPast bool28}2930// GroupByMovie takes all sessions + presentations and returns MovieGroups31// ordered by earliest showtime per movie, with showtimes sorted chronologically.32func GroupByMovie(sessions []models.Session, data *models.Data, presentations []models.Presentation) []MovieGroup {33 type presInfo struct {34 title string35 cert string36 headline string37 }3839 infoMap := make(map[string]presInfo, len(presentations))40 for _, p := range presentations {41 infoMap[p.Slug] = presInfo{title: p.Show.Title, cert: p.Show.Certification, headline: p.Show.Headline}42 }4344 // Find the most common format to treat it as "default" (suppress in display)45 formatCounts := make(map[string]int)46 for _, s := range sessions {47 formatCounts[s.FormatSlug]++48 }49 defaultFormat := ""50 maxCount := 051 for slug, count := range formatCounts {52 if count > maxCount {53 maxCount = count54 defaultFormat = slug55 }56 }5758 // Group sessions by presentation slug59 groupMap := make(map[string]*MovieGroup)60 var slugOrder []string6162 for _, s := range sessions {63 info, ok := infoMap[s.PresentationSlug]64 if !ok {65 continue66 }6768 if _, exists := groupMap[s.PresentationSlug]; !exists {69 slugOrder = append(slugOrder, s.PresentationSlug)70 groupMap[s.PresentationSlug] = &MovieGroup{71 Title: info.title,72 Certification: info.cert,73 Headline: info.headline,74 }75 }7677 sortKey := ""78 timeStr := ""79 if len(s.ShowTimeClt) >= 16 {80 sortKey = s.ShowTimeClt[11:16]81 t, err := time.Parse("15:04", sortKey)82 if err == nil {83 timeStr = t.Format("3:04pm")84 } else {85 timeStr = sortKey86 }87 }8889 formatDisplay := ""90 if s.FormatSlug != defaultFormat {91 f := data.GetFormat(s.FormatSlug)92 if f != nil {93 formatDisplay = f.Title94 }95 }9697 groupMap[s.PresentationSlug].Showtimes = append(groupMap[s.PresentationSlug].Showtimes, Showtime{98 Time: timeStr,99 SortKey: sortKey,100 Screen: s.ScreenNumber,101 Format: formatDisplay,102 IsPast: isSessionPast(s),103 })104 }105106 // Sort showtimes within each group by 24h time107 for _, g := range groupMap {108 sort.Slice(g.Showtimes, func(i, j int) bool {109 return g.Showtimes[i].SortKey < g.Showtimes[j].SortKey110 })111 }112113 // Sort groups by earliest showtime114 sort.Slice(slugOrder, func(i, j int) bool {115 gi := groupMap[slugOrder[i]]116 gj := groupMap[slugOrder[j]]117 if len(gi.Showtimes) == 0 {118 return false119 }120 if len(gj.Showtimes) == 0 {121 return true122 }123 return gi.Showtimes[0].SortKey < gj.Showtimes[0].SortKey124 })125126 groups := make([]MovieGroup, 0, len(slugOrder))127 for _, slug := range slugOrder {128 groups = append(groups, *groupMap[slug])129 }130 return groups131}132133// RenderGroups renders all movie groups into a styled string with bordered boxes.134func RenderGroups(groups []MovieGroup, width int) string {135 if len(groups) == 0 {136 return emptyStyle.Render("No showtimes available")137 }138139 var sections []string140 for _, g := range groups {141 sections = append(sections, renderGroup(g, width))142 }143 return strings.Join(sections, "\n")144}145146func renderGroup(g MovieGroup, width int) string {147 // Inner width accounts for border (2) + padding (2)148 innerWidth := width - 4149 if innerWidth < 30 {150 innerWidth = 30151 }152153 // Title line: "Movie Title R" left, "5 showtimes" right154 title := movieTitleStyle.Render(g.Title)155 cert := ""156 if g.Certification != "" {157 cert = " " + certStyle.Render(g.Certification)158 }159 count := countStyle.Render(fmt.Sprintf("%d showtimes", len(g.Showtimes)))160161 titleLen := lipgloss.Width(title) + lipgloss.Width(cert)162 gap := innerWidth - titleLen - lipgloss.Width(count)163 if gap < 2 {164 gap = 2165 }166 header := title + cert + strings.Repeat(" ", gap) + count167168 var rows []string169 rows = append(rows, header)170171 if g.Headline != "" {172 rows = append(rows, headlineStyle.Render(g.Headline))173 }174175 // Showtime chips with background color176 var chips []string177 for _, st := range g.Showtimes {178 chip := formatChip(st)179 chips = append(chips, chip)180 }181182 lines := layoutChips(chips, innerWidth)183 if len(lines) > 0 {184 rows = append(rows, "") // blank line before showtimes185 for _, line := range lines {186 rows = append(rows, line)187 }188 }189190 inner := strings.Join(rows, "\n")191192 return boxStyle.Width(innerWidth).Render(inner)193}194195func formatChip(st Showtime) string {196 timeStr := st.Time197 screenStr := "Scr " + st.Screen198199 if st.IsPast {200 chip := pastTimeStyle.Render(timeStr) + " " + pastScreenStyle.Render(screenStr)201 if st.Format != "" {202 chip += " " + pastFormatStyle.Render(st.Format)203 }204 return chip205 }206207 chip := chipTimeStyle.Render(timeStr) + " " + chipScreenStyle.Render(screenStr)208 if st.Format != "" {209 chip += " " + chipFormatStyle.Render(st.Format)210 }211 return chip212}213214func layoutChips(chips []string, maxWidth int) []string {215 if len(chips) == 0 {216 return nil217 }218219 chipGap := " "220 gapWidth := lipgloss.Width(chipGap)221 var lines []string222 var currentLine string223 currentWidth := 0224225 for _, chip := range chips {226 chipWidth := lipgloss.Width(chip)227 if currentWidth == 0 {228 currentLine = chip229 currentWidth = chipWidth230 } else if currentWidth+gapWidth+chipWidth <= maxWidth {231 currentLine += chipGap + chip232 currentWidth += gapWidth + chipWidth233 } else {234 lines = append(lines, currentLine)235 currentLine = chip236 currentWidth = chipWidth237 }238 }239 if currentLine != "" {240 lines = append(lines, currentLine)241 }242 return lines243}244245// isSessionPast checks if a session's showtime has already passed.246func isSessionPast(s models.Session) bool {247 if len(s.ShowTimeClt) < 16 {248 return false249 }250 showTimeStr := s.ShowTimeClt[:16]251 loc, err := time.LoadLocation(s.CinemaTimeZoneName)252 if err != nil {253 return false254 }255 showTime, err := time.ParseInLocation("2006-01-02T15:04", showTimeStr, loc)256 if err != nil {257 return false258 }259 return showTime.Before(time.Now())260}261262var (263 boxStyle = lipgloss.NewStyle().264 Border(lipgloss.RoundedBorder()).265 BorderForeground(lipgloss.Color("238")).266 Padding(0, 1)267268 movieTitleStyle = lipgloss.NewStyle().269 Foreground(lipgloss.Color("142")).270 Bold(true)271272 certStyle = lipgloss.NewStyle().273 Foreground(lipgloss.Color("245"))274275 headlineStyle = lipgloss.NewStyle().276 Foreground(lipgloss.Color("243")).277 Italic(true)278279 countStyle = lipgloss.NewStyle().280 Foreground(lipgloss.Color("243"))281282 // Active showtime chip styles283 chipTimeStyle = lipgloss.NewStyle().284 Foreground(lipgloss.Color("230")).285 Background(lipgloss.Color("238")).286 Bold(true).287 Padding(0, 1)288289 chipScreenStyle = lipgloss.NewStyle().290 Foreground(lipgloss.Color("252"))291292 chipFormatStyle = lipgloss.NewStyle().293 Foreground(lipgloss.Color("109")).294 Italic(true)295296 // Past showtime chip styles (strikethrough + dimmed to indicate unavailable)297 pastTimeStyle = lipgloss.NewStyle().298 Foreground(lipgloss.Color("240")).299 Background(lipgloss.Color("236")).300 Strikethrough(true).301 Padding(0, 1)302303 pastScreenStyle = lipgloss.NewStyle().304 Foreground(lipgloss.Color("239")).305 Strikethrough(true)306307 pastFormatStyle = lipgloss.NewStyle().308 Foreground(lipgloss.Color("239")).309 Strikethrough(true).310 Italic(true)311312 emptyStyle = lipgloss.NewStyle().313 Foreground(lipgloss.Color("244")).314 Padding(1)315)