1package views23import (4 "fmt"5 "math"6 "sort"7 "strings"8 "time"910 "github.com/charmbracelet/lipgloss"11 "github.com/dami/showings/internal/models"12)1314// GanttEntry represents a single session plotted on the Gantt chart.15type GanttEntry struct {16 Slug string17 Screen string18 MovieName string19 StartMin int // minutes from midnight20 EndMin int // minutes from midnight21 IsPast bool22 ColorIdx int23}2425// RenderGantt builds a Gantt-style timeline chart from sessions.26// If isToday is true, a "now" marker is drawn at the current time.27func RenderGantt(sessions []models.Session, data *models.Data, presentations []models.Presentation, width int, isToday bool) string {28 if len(sessions) == 0 {29 return ganttEmptyStyle.Render("No showtimes to chart")30 }3132 // Build lookup maps keyed on slug33 titleMap := make(map[string]string, len(presentations))34 certMap := make(map[string]string, len(presentations))35 colorMap := make(map[string]int)36 for i, p := range presentations {37 titleMap[p.Slug] = p.Show.Title38 certMap[p.Slug] = p.Show.Certification39 colorMap[p.Slug] = i40 }4142 // Parse sessions into raw entries43 type rawEntry struct {44 screen string45 slug string46 startMin int47 isPast bool48 }49 var entries []rawEntry50 for _, s := range sessions {51 if len(s.ShowTimeClt) < 16 {52 continue53 }54 if titleMap[s.PresentationSlug] == "" {55 continue56 }57 t, err := time.Parse("15:04", s.ShowTimeClt[11:16])58 if err != nil {59 continue60 }61 entries = append(entries, rawEntry{62 screen: s.ScreenNumber,63 slug: s.PresentationSlug,64 startMin: t.Hour()*60 + t.Minute(),65 isPast: isSessionPast(s),66 })67 }68 if len(entries) == 0 {69 return ganttEmptyStyle.Render("No showtimes to chart")70 }7172 // Group by screen, sort by time73 screenMap := make(map[string][]rawEntry)74 for _, e := range entries {75 screenMap[e.screen] = append(screenMap[e.screen], e)76 }77 for scr := range screenMap {78 sort.Slice(screenMap[scr], func(i, j int) bool {79 return screenMap[scr][i].startMin < screenMap[scr][j].startMin80 })81 }8283 var screens []string84 for s := range screenMap {85 screens = append(screens, s)86 }87 sort.Strings(screens)8889 // Estimate durations from gaps, default 120min90 const defaultDuration = 12091 var ganttEntries []GanttEntry92 for _, scr := range screens {93 raw := screenMap[scr]94 for i, e := range raw {95 endMin := e.startMin + defaultDuration96 if i+1 < len(raw) {97 gap := raw[i+1].startMin - e.startMin98 if gap > 15 && gap < 240 {99 endMin = e.startMin + gap - 15100 }101 }102 ganttEntries = append(ganttEntries, GanttEntry{103 Slug: e.slug,104 Screen: scr,105 MovieName: titleMap[e.slug],106 StartMin: e.startMin,107 EndMin: endMin,108 IsPast: e.isPast,109 ColorIdx: colorMap[e.slug],110 })111 }112 }113114 // Time range snapped to hours115 minTime := math.MaxInt32116 maxTime := 0117 for _, e := range ganttEntries {118 if e.StartMin < minTime {119 minTime = e.StartMin120 }121 if e.EndMin > maxTime {122 maxTime = e.EndMin123 }124 }125 minTime = (minTime / 60) * 60126 maxTime = ((maxTime + 59) / 60) * 60127 totalMin := maxTime - minTime128 if totalMin <= 0 {129 totalMin = 60130 }131132 // Layout133 labelW := 7 // "Scr XX "134 chartW := width - labelW - 1135 if chartW < 30 {136 chartW = 30137 }138139 // Current time marker position (-1 means not shown)140 nowPos := -1141 if isToday {142 now := time.Now()143 nowMin := now.Hour()*60 + now.Minute()144 if nowMin >= minTime && nowMin <= maxTime {145 nowPos = (nowMin - minTime) * chartW / totalMin146 if nowPos >= chartW {147 nowPos = chartW - 1148 }149 }150 }151152 // Render153 var rows []string154155 // Ruler with tick marks156 rows = append(rows, buildRuler(minTime, maxTime, totalMin, chartW, labelW, nowPos))157 rows = append(rows, buildTickLine(minTime, maxTime, totalMin, chartW, labelW, nowPos))158159 // Screen rows with a blank line between each for breathing room160 for i, scr := range screens {161 rows = append(rows, buildScreenRow(scr, ganttEntries, minTime, totalMin, chartW, labelW, nowPos))162 if i < len(screens)-1 {163 rows = append(rows, "")164 }165 }166167 // Collect active slugs and compute average duration per movie168 activeSlugSet := make(map[string]bool)169 for _, e := range entries {170 activeSlugSet[e.slug] = true171 }172 durationSums := make(map[string]int)173 durationCounts := make(map[string]int)174 for _, e := range ganttEntries {175 dur := e.EndMin - e.StartMin176 if dur > 0 {177 durationSums[e.Slug] += dur178 durationCounts[e.Slug]++179 }180 }181 durationMap := make(map[string]int)182 for slug, sum := range durationSums {183 if cnt := durationCounts[slug]; cnt > 0 {184 durationMap[slug] = sum / cnt185 }186 }187188 // Legend189 rows = append(rows, "")190 rows = append(rows, buildLegend(titleMap, certMap, colorMap, durationMap, activeSlugSet, width))191192 return strings.Join(rows, "\n")193}194195// buildRuler creates the hour labels above the chart.196func buildRuler(minTime, maxTime, totalMin, chartW, labelW, nowPos int) string {197 buf := make([]byte, chartW)198 for i := range buf {199 buf[i] = ' '200 }201202 for min := minTime; min <= maxTime; min += 60 {203 pos := (min - minTime) * chartW / totalMin204 if pos >= chartW {205 break206 }207 label := formatHour(min / 60)208 for j := 0; j < len(label) && pos+j < chartW; j++ {209 buf[pos+j] = label[j]210 }211 }212213 // Place "NOW" marker on the ruler214 if nowPos >= 0 && nowPos+3 <= chartW {215 buf[nowPos] = 'N'216 if nowPos+1 < chartW {217 buf[nowPos+1] = 'O'218 }219 if nowPos+2 < chartW {220 buf[nowPos+2] = 'W'221 }222 }223224 pad := strings.Repeat(" ", labelW)225226 // Render with NOW highlighted227 if nowPos >= 0 && nowPos < chartW {228 before := ganttRulerStyle.Render(string(buf[:nowPos]))229 nowEnd := nowPos + 3230 if nowEnd > chartW {231 nowEnd = chartW232 }233 nowLabel := ganttNowStyle.Render(string(buf[nowPos:nowEnd]))234 after := ganttRulerStyle.Render(string(buf[nowEnd:]))235 return pad + before + nowLabel + after236 }237238 return pad + ganttRulerStyle.Render(string(buf))239}240241// buildTickLine creates the tick marks connecting ruler to bars.242func buildTickLine(minTime, maxTime, totalMin, chartW, labelW, nowPos int) string {243 buf := make([]byte, chartW)244 for i := range buf {245 buf[i] = ' '246 }247 for min := minTime; min <= maxTime; min += 60 {248 pos := (min - minTime) * chartW / totalMin249 if pos < chartW {250 buf[pos] = '|'251 }252 }253 // Add half-hour ticks254 for min := minTime + 30; min < maxTime; min += 60 {255 pos := (min - minTime) * chartW / totalMin256 if pos < chartW && buf[pos] == ' ' {257 buf[pos] = ':'258 }259 }260 // Now marker261 if nowPos >= 0 && nowPos < chartW {262 buf[nowPos] = 'v'263 }264265 pad := strings.Repeat(" ", labelW)266267 if nowPos >= 0 && nowPos < chartW {268 before := ganttTickStyle.Render(string(buf[:nowPos]))269 nowCh := ganttNowStyle.Render("▾")270 after := ganttTickStyle.Render(string(buf[nowPos+1:]))271 return pad + before + nowCh + after272 }273274 return pad + ganttTickStyle.Render(string(buf))275}276277func formatHour(h int) string {278 suffix := "am"279 if h >= 12 {280 suffix = "pm"281 }282 if h > 12 {283 h -= 12284 }285 if h == 0 {286 h = 12287 }288 return fmt.Sprintf("%d%s", h, suffix)289}290291// buildScreenRow renders one screen's timeline bar.292func buildScreenRow(screen string, entries []GanttEntry, minTime, totalMin, chartW, labelW, nowPos int) string {293 label := ganttScreenStyle.Render(fmt.Sprintf("Scr %-2s ", screen))294295 // Character + color grid296 type cell struct {297 ch rune298 colorIdx int299 isPast bool300 isNow bool301 }302 grid := make([]cell, chartW)303 for i := range grid {304 grid[i] = cell{ch: '·', colorIdx: -1}305 }306307 for _, e := range entries {308 if e.Screen != screen {309 continue310 }311 startPos := (e.StartMin - minTime) * chartW / totalMin312 endPos := (e.EndMin - minTime) * chartW / totalMin313 if startPos < 0 {314 startPos = 0315 }316 if endPos > chartW {317 endPos = chartW318 }319 if endPos <= startPos {320 endPos = startPos + 1321 }322323 barLen := endPos - startPos324 name := e.MovieName325326 // Truncate or pad name to fit inside the bar327 if barLen <= 2 {328 // Very small bar: just fill with block329 for i := startPos; i < endPos; i++ {330 grid[i] = cell{ch: '█', colorIdx: e.ColorIdx, isPast: e.IsPast}331 }332 continue333 }334335 // Fit name: leave 1 char padding on each side336 innerLen := barLen - 2337 if len(name) > innerLen {338 if innerLen > 2 {339 name = name[:innerLen-1] + "~"340 } else {341 name = name[:innerLen]342 }343 }344345 for i := startPos; i < endPos; i++ {346 idx := i - startPos347 var ch rune348 if idx == 0 || idx == barLen-1 {349 ch = '█' // solid edge350 } else if idx-1 < len(name) {351 ch = rune(name[idx-1])352 } else {353 ch = ' '354 }355 grid[i] = cell{ch: ch, colorIdx: e.ColorIdx, isPast: e.IsPast}356 }357 }358359 // Overlay "now" marker only on empty cells (don't break bar text)360 if nowPos >= 0 && nowPos < chartW {361 if grid[nowPos].colorIdx == -1 {362 grid[nowPos] = cell{ch: '│', colorIdx: -1, isNow: true}363 }364 }365366 // Render grid: batch consecutive cells of the same style367 var result strings.Builder368 curColor := -2369 curPast := false370 curNow := false371 var seg strings.Builder372373 flush := func() {374 if seg.Len() == 0 {375 return376 }377 txt := seg.String()378 if curNow {379 result.WriteString(ganttNowStyle.Render(txt))380 } else if curColor == -1 {381 result.WriteString(ganttDotStyle.Render(txt))382 } else if curPast {383 result.WriteString(ganttPastBarStyle(curColor).Render(txt))384 } else {385 result.WriteString(ganttBarStyle(curColor).Render(txt))386 }387 seg.Reset()388 }389390 for _, c := range grid {391 if c.colorIdx != curColor || c.isPast != curPast || c.isNow != curNow {392 flush()393 curColor = c.colorIdx394 curPast = c.isPast395 curNow = c.isNow396 }397 seg.WriteRune(c.ch)398 }399 flush()400401 return label + result.String()402}403404func buildLegend(titleMap, certMap map[string]string, colorMap, durationMap map[string]int, activeSlugs map[string]bool, width int) string {405 type item struct {406 slug string407 title string408 cert string409 duration int410 idx int411 }412 var items []item413 for slug, title := range titleMap {414 if !activeSlugs[slug] {415 continue416 }417 items = append(items, item{418 slug: slug,419 title: title,420 cert: certMap[slug],421 duration: durationMap[slug],422 idx: colorMap[slug],423 })424 }425 sort.Slice(items, func(i, j int) bool {426 return items[i].idx < items[j].idx427 })428429 var cards []string430 for _, it := range items {431 swatch := ganttSwatchStyle(it.idx).Render(" ")432433 title := legendTitleStyle.Render(it.title)434 cert := ""435 if it.cert != "" {436 cert = " " + legendCertStyle.Render(it.cert)437 }438 dur := ""439 if it.duration > 0 {440 h := it.duration / 60441 m := it.duration % 60442 if h > 0 && m > 0 {443 dur = legendDurStyle.Render(fmt.Sprintf("~%dh%dm", h, m))444 } else if h > 0 {445 dur = legendDurStyle.Render(fmt.Sprintf("~%dh", h))446 } else {447 dur = legendDurStyle.Render(fmt.Sprintf("~%dm", m))448 }449 }450451 line := swatch + " " + title + cert452 if dur != "" {453 line += " " + dur454 }455456 card := legendBoxStyle.Render(line)457 cards = append(cards, card)458 }459460 // Flow cards horizontally using lipgloss.JoinHorizontal where they fit,461 // wrapping to new rows when width is exceeded.462 gap := " "463 gapW := lipgloss.Width(gap)464 var rows []string465 var rowCards []string466 rowW := 0467468 for _, card := range cards {469 cw := lipgloss.Width(card)470 if rowW == 0 {471 rowCards = []string{card}472 rowW = cw473 } else if rowW+gapW+cw <= width {474 rowCards = append(rowCards, card)475 rowW += gapW + cw476 } else {477 rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, joinWithGap(rowCards, gap)...))478 rowCards = []string{card}479 rowW = cw480 }481 }482 if len(rowCards) > 0 {483 rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, joinWithGap(rowCards, gap)...))484 }485 return strings.Join(rows, "\n")486}487488// joinWithGap interleaves gap strings between items for lipgloss.JoinHorizontal.489func joinWithGap(items []string, gap string) []string {490 if len(items) == 0 {491 return nil492 }493 result := []string{items[0]}494 for _, item := range items[1:] {495 result = append(result, gap, item)496 }497 return result498}499500var nowColor = lipgloss.Color("196") // bright red501502// Color palette: bright backgrounds with guaranteed white-text contrast503var barColors = []lipgloss.Color{504 lipgloss.Color("34"), // green505 lipgloss.Color("33"), // blue506 lipgloss.Color("125"), // magenta507 lipgloss.Color("172"), // orange508 lipgloss.Color("30"), // teal509 lipgloss.Color("91"), // purple510 lipgloss.Color("130"), // brown511 lipgloss.Color("26"), // deep blue512 lipgloss.Color("64"), // olive513 lipgloss.Color("124"), // red514 lipgloss.Color("23"), // dark cyan515 lipgloss.Color("94"), // dark gold516}517518// Dimmed palette for past showtimes (darker variants)519var barColorsPast = []lipgloss.Color{520 lipgloss.Color("238"),521 lipgloss.Color("238"),522 lipgloss.Color("238"),523 lipgloss.Color("238"),524 lipgloss.Color("238"),525 lipgloss.Color("238"),526 lipgloss.Color("238"),527 lipgloss.Color("238"),528 lipgloss.Color("238"),529 lipgloss.Color("238"),530 lipgloss.Color("238"),531 lipgloss.Color("238"),532}533534func ganttBarStyle(colorIdx int) lipgloss.Style {535 bg := barColors[colorIdx%len(barColors)]536 return lipgloss.NewStyle().537 Foreground(lipgloss.Color("255")).538 Background(bg).539 Bold(true)540}541542func ganttSwatchStyle(colorIdx int) lipgloss.Style {543 bg := barColors[colorIdx%len(barColors)]544 return lipgloss.NewStyle().545 Background(bg)546}547548func ganttPastBarStyle(colorIdx int) lipgloss.Style {549 bg := barColorsPast[colorIdx%len(barColorsPast)]550 return lipgloss.NewStyle().551 Foreground(lipgloss.Color("243")).552 Background(bg)553}554555var (556 ganttEmptyStyle = lipgloss.NewStyle().557 Foreground(lipgloss.Color("244")).558 Padding(1)559560 ganttRulerStyle = lipgloss.NewStyle().561 Foreground(lipgloss.Color("252")).562 Bold(true)563564 ganttTickStyle = lipgloss.NewStyle().565 Foreground(lipgloss.Color("240"))566567 ganttScreenStyle = lipgloss.NewStyle().568 Foreground(lipgloss.Color("252")).569 Bold(true)570571 ganttDotStyle = lipgloss.NewStyle().572 Foreground(lipgloss.Color("236"))573574 ganttNowStyle = lipgloss.NewStyle().575 Foreground(nowColor).576 Bold(true)577578 legendBoxStyle = lipgloss.NewStyle().579 Border(lipgloss.RoundedBorder()).580 BorderForeground(lipgloss.Color("238")).581 Padding(0, 1)582583 legendTitleStyle = lipgloss.NewStyle().584 Foreground(lipgloss.Color("252")).585 Bold(true)586587 legendCertStyle = lipgloss.NewStyle().588 Foreground(lipgloss.Color("245"))589590 legendDurStyle = lipgloss.NewStyle().591 Foreground(lipgloss.Color("243")).592 Italic(true)593)