clone

a terminal ui for alamo drafthouse showtimes, as movie cards or a timeline of every screen

views

Dami committed b5cb757
commit
b5cb7578d4c65c4ce6d9055ca6c8be94245b1591
parent
381427e560ff512a6d100155bc2257354ea658d6
author
Dami <dami.etoile@gmail.com>
13 files changed, +1640 -426
Minternal/api/client.go56---
Minternal/api/client_test.go111------
Minternal/models/types.go61+++
Ainternal/models/types_test.go109++++++
Ainternal/state/dates.go106+++++
Minternal/state/model.go340+++++++++--------
Ainternal/state/render.go124++++++
Ainternal/state/styles.go132+++++++
Ainternal/views/gantt.go593++++++++++++++++++++++++++++++
Dinternal/views/presentations.go95-----
Ainternal/views/sessions.go315++++++++++++++++
Mmain.go24+-
Dshowingsbin
M internal/api/client.go +0 -56
@@ -53,59 +53,3 @@ func (c *Client) FetchSchedule(venue string) (*models.APIResponse, error) {
5353 5454 return &result, nil5555 }56-57-func (c *Client) FilterUpcomingPresentations(data *models.Data) []models.Presentation {58- upcomingSlugs := make(map[string]bool)59- for _, session := range data.Sessions {60- if session.Status == "ONSALE" {61- upcomingSlugs[session.PresentationSlug] = true62- }63- }64-65- var presentations []models.Presentation66- for _, p := range data.Presentations {67- if upcomingSlugs[p.Slug] && !p.IsHidden {68- presentations = append(presentations, p)69- }70- }71- return presentations72-}73-74-func (c *Client) GetSessionsForPresentation(data *models.Data, slug string) []models.Session {75- var sessions []models.Session76- for _, s := range data.Sessions {77- if s.PresentationSlug == slug && s.Status == "ONSALE" {78- sessions = append(sessions, s)79- }80- }81- return sessions82-}83-84-func (c *Client) GetCinema(data *models.Data, cinemaID string) *models.Cinema {85- for _, market := range data.Market {86- for _, cinema := range market.Cinemas {87- if cinema.ID == cinemaID {88- return &cinema89- }90- }91- }92- return nil93-}94-95-func (c *Client) GetFormat(data *models.Data, slug string) *models.Format {96- for _, f := range data.Formats {97- if f.Slug == slug {98- return &f99- }100- }101- return nil102-}103-104-func (c *Client) GetAgePolicy(data *models.Data, slug string) *models.AgePolicy {105- for _, ap := range data.AgePolicies {106- if ap.Slug == slug {107- return &ap108- }109- }110- return nil111-}
M internal/api/client_test.go +0 -111
@@ -81,114 +81,3 @@ func TestFetchSchedule(t *testing.T) {
8181 t.Errorf("Expected title 'Test Movie (2026)', got '%s'", result.Data.Presentations[0].Show.Title)8282 }8383 }84-85-func TestFilterUpcomingPresentations(t *testing.T) {86- data := &models.Data{87- Presentations: []models.Presentation{88- {Slug: "movie-1", IsHidden: false},89- {Slug: "movie-2", IsHidden: false},90- {Slug: "movie-3", IsHidden: true},91- },92- Sessions: []models.Session{93- {PresentationSlug: "movie-1", Status: "ONSALE"},94- {PresentationSlug: "movie-2", Status: "PAST"},95- {PresentationSlug: "movie-3", Status: "ONSALE"},96- },97- }98-99- client := NewClient()100- presentations := client.FilterUpcomingPresentations(data)101-102- if len(presentations) != 1 {103- t.Errorf("Expected 1 upcoming presentation, got %d", len(presentations))104- }105-106- if presentations[0].Slug != "movie-1" {107- t.Errorf("Expected 'movie-1', got '%s'", presentations[0].Slug)108- }109-}110-111-func TestGetSessionsForPresentation(t *testing.T) {112- data := &models.Data{113- Sessions: []models.Session{114- {PresentationSlug: "movie-1", Status: "ONSALE", SessionId: "1"},115- {PresentationSlug: "movie-1", Status: "ONSALE", SessionId: "2"},116- {PresentationSlug: "movie-1", Status: "PAST", SessionId: "3"},117- {PresentationSlug: "movie-2", Status: "ONSALE", SessionId: "4"},118- },119- }120-121- client := NewClient()122- sessions := client.GetSessionsForPresentation(data, "movie-1")123-124- if len(sessions) != 2 {125- t.Errorf("Expected 2 sessions for movie-1, got %d", len(sessions))126- }127-}128-129-func TestGetCinema(t *testing.T) {130- data := &models.Data{131- Market: []models.Market{132- {133- Cinemas: []models.Cinema{134- {ID: "0403", Name: "Westminster"},135- },136- },137- },138- }139-140- client := NewClient()141- cinema := client.GetCinema(data, "0403")142-143- if cinema == nil {144- t.Fatal("Expected cinema, got nil")145- }146-147- if cinema.Name != "Westminster" {148- t.Errorf("Expected 'Westminster', got '%s'", cinema.Name)149- }150-151- if client.GetCinema(data, "invalid") != nil {152- t.Error("Expected nil for invalid cinema ID")153- }154-}155-156-func TestGetFormat(t *testing.T) {157- data := &models.Data{158- Formats: []models.Format{159- {Slug: "2d-digital", Title: "2D Digital"},160- {Slug: "the-big-show", Title: "The Big Show"},161- },162- }163-164- client := NewClient()165- format := client.GetFormat(data, "2d-digital")166-167- if format == nil {168- t.Fatal("Expected format, got nil")169- }170-171- if format.Title != "2D Digital" {172- t.Errorf("Expected '2D Digital', got '%s'", format.Title)173- }174-}175-176-func TestGetAgePolicy(t *testing.T) {177- data := &models.Data{178- AgePolicies: []models.AgePolicy{179- {Slug: "rated-pg-13", Name: "Rated PG-13"},180- {Slug: "rated-r", Name: "Rated R"},181- },182- }183-184- client := NewClient()185- policy := client.GetAgePolicy(data, "rated-pg-13")186-187- if policy == nil {188- t.Fatal("Expected age policy, got nil")189- }190-191- if policy.Name != "Rated PG-13" {192- t.Errorf("Expected 'Rated PG-13', got '%s'", policy.Name)193- }194-}
M internal/models/types.go +61 -0
@@ -16,6 +16,67 @@ type Data struct {
1616 Queues []interface{} `json:"queues"`1717 }1818 19+// GetCinema looks up a cinema by ID across all markets.20+func (d *Data) GetCinema(cinemaID string) *Cinema {21+ for i := range d.Market {22+ for j := range d.Market[i].Cinemas {23+ if d.Market[i].Cinemas[j].ID == cinemaID {24+ return &d.Market[i].Cinemas[j]25+ }26+ }27+ }28+ return nil29+}30+31+// GetFormat looks up a format by slug.32+func (d *Data) GetFormat(slug string) *Format {33+ for i := range d.Formats {34+ if d.Formats[i].Slug == slug {35+ return &d.Formats[i]36+ }37+ }38+ return nil39+}40+41+// GetAgePolicy looks up an age policy by slug.42+func (d *Data) GetAgePolicy(slug string) *AgePolicy {43+ for i := range d.AgePolicies {44+ if d.AgePolicies[i].Slug == slug {45+ return &d.AgePolicies[i]46+ }47+ }48+ return nil49+}50+51+// FilterUpcomingPresentations returns non-hidden presentations that have ONSALE sessions.52+func (d *Data) FilterUpcomingPresentations() []Presentation {53+ upcomingSlugs := make(map[string]bool)54+ for _, session := range d.Sessions {55+ if session.Status == "ONSALE" {56+ upcomingSlugs[session.PresentationSlug] = true57+ }58+ }59+60+ var presentations []Presentation61+ for _, p := range d.Presentations {62+ if upcomingSlugs[p.Slug] && !p.IsHidden {63+ presentations = append(presentations, p)64+ }65+ }66+ return presentations67+}68+69+// GetSessionsForPresentation returns ONSALE sessions for a given presentation slug.70+func (d *Data) GetSessionsForPresentation(slug string) []Session {71+ var sessions []Session72+ for _, s := range d.Sessions {73+ if s.PresentationSlug == slug && s.Status == "ONSALE" {74+ sessions = append(sessions, s)75+ }76+ }77+ return sessions78+}79+1980 type Presentation struct {2081 Slug string `json:"slug"`2182 LegacySlug *string `json:"legacySlug"`
A internal/models/types_test.go +109 -0
@@ -0,0 +1,109 @@
1+package models2+3+import "testing"4+5+func TestFilterUpcomingPresentations(t *testing.T) {6+ data := &Data{7+ Presentations: []Presentation{8+ {Slug: "movie-1", IsHidden: false},9+ {Slug: "movie-2", IsHidden: false},10+ {Slug: "movie-3", IsHidden: true},11+ },12+ Sessions: []Session{13+ {PresentationSlug: "movie-1", Status: "ONSALE"},14+ {PresentationSlug: "movie-2", Status: "PAST"},15+ {PresentationSlug: "movie-3", Status: "ONSALE"},16+ },17+ }18+19+ presentations := data.FilterUpcomingPresentations()20+21+ if len(presentations) != 1 {22+ t.Errorf("Expected 1 upcoming presentation, got %d", len(presentations))23+ }24+25+ if presentations[0].Slug != "movie-1" {26+ t.Errorf("Expected 'movie-1', got '%s'", presentations[0].Slug)27+ }28+}29+30+func TestGetSessionsForPresentation(t *testing.T) {31+ data := &Data{32+ Sessions: []Session{33+ {PresentationSlug: "movie-1", Status: "ONSALE", SessionId: "1"},34+ {PresentationSlug: "movie-1", Status: "ONSALE", SessionId: "2"},35+ {PresentationSlug: "movie-1", Status: "PAST", SessionId: "3"},36+ {PresentationSlug: "movie-2", Status: "ONSALE", SessionId: "4"},37+ },38+ }39+40+ sessions := data.GetSessionsForPresentation("movie-1")41+42+ if len(sessions) != 2 {43+ t.Errorf("Expected 2 sessions for movie-1, got %d", len(sessions))44+ }45+}46+47+func TestGetCinema(t *testing.T) {48+ data := &Data{49+ Market: []Market{50+ {51+ Cinemas: []Cinema{52+ {ID: "0403", Name: "Westminster"},53+ },54+ },55+ },56+ }57+58+ cinema := data.GetCinema("0403")59+60+ if cinema == nil {61+ t.Fatal("Expected cinema, got nil")62+ }63+64+ if cinema.Name != "Westminster" {65+ t.Errorf("Expected 'Westminster', got '%s'", cinema.Name)66+ }67+68+ if data.GetCinema("invalid") != nil {69+ t.Error("Expected nil for invalid cinema ID")70+ }71+}72+73+func TestGetFormat(t *testing.T) {74+ data := &Data{75+ Formats: []Format{76+ {Slug: "2d-digital", Title: "2D Digital"},77+ {Slug: "the-big-show", Title: "The Big Show"},78+ },79+ }80+81+ format := data.GetFormat("2d-digital")82+83+ if format == nil {84+ t.Fatal("Expected format, got nil")85+ }86+87+ if format.Title != "2D Digital" {88+ t.Errorf("Expected '2D Digital', got '%s'", format.Title)89+ }90+}91+92+func TestGetAgePolicy(t *testing.T) {93+ data := &Data{94+ AgePolicies: []AgePolicy{95+ {Slug: "rated-pg-13", Name: "Rated PG-13"},96+ {Slug: "rated-r", Name: "Rated R"},97+ },98+ }99+100+ policy := data.GetAgePolicy("rated-pg-13")101+102+ if policy == nil {103+ t.Fatal("Expected age policy, got nil")104+ }105+106+ if policy.Name != "Rated PG-13" {107+ t.Errorf("Expected 'Rated PG-13', got '%s'", policy.Name)108+ }109+}
A internal/state/dates.go +106 -0
@@ -0,0 +1,106 @@
1+package state2+3+import (4+ "sort"5+ "time"6+7+ "github.com/dami/showings/internal/models"8+)9+10+func (m *Model) computeAvailableDates() {11+ dateSet := make(map[string]bool)12+ for _, session := range m.data.Sessions {13+ if session.Status == "ONSALE" {14+ dateSet[session.BusinessDateClt] = true15+ }16+ }17+18+ m.availableDates = make([]time.Time, 0, len(dateSet))19+ for dateStr := range dateSet {20+ if len(dateStr) >= 10 {21+ t, err := time.Parse("2006-01-02", dateStr[:10])22+ if err == nil {23+ m.availableDates = append(m.availableDates, t)24+ }25+ }26+ }27+28+ sort.Slice(m.availableDates, func(i, j int) bool {29+ return m.availableDates[i].Before(m.availableDates[j])30+ })31+}32+33+func (m *Model) setTodayFilter() {34+ today := time.Now().Truncate(24 * time.Hour)35+ m.selectedDate = &today36+}37+38+func (m *Model) shiftDate(delta int) {39+ if len(m.availableDates) == 0 {40+ return41+ }42+43+ if m.selectedDate == nil {44+ if delta > 0 {45+ m.selectedDate = &m.availableDates[0]46+ } else {47+ m.selectedDate = &m.availableDates[len(m.availableDates)-1]48+ }49+ return50+ }51+52+ currentIdx := -153+ for i, d := range m.availableDates {54+ if d.Equal(*m.selectedDate) {55+ currentIdx = i56+ break57+ }58+ }59+60+ if currentIdx == -1 {61+ // Find nearest date62+ for i, d := range m.availableDates {63+ if !d.Before(*m.selectedDate) {64+ if delta > 0 {65+ currentIdx = i66+ } else if i > 0 {67+ currentIdx = i - 168+ } else {69+ currentIdx = 070+ }71+ break72+ }73+ }74+ if currentIdx == -1 {75+ currentIdx = len(m.availableDates) - 176+ }77+ } else {78+ currentIdx += delta79+ }80+81+ if currentIdx < 0 {82+ currentIdx = 083+ }84+ if currentIdx >= len(m.availableDates) {85+ currentIdx = len(m.availableDates) - 186+ }87+88+ m.selectedDate = &m.availableDates[currentIdx]89+}90+91+func (m *Model) filterSessionsByDate(sessions []models.Session) []models.Session {92+ if m.selectedDate == nil {93+ return sessions94+ }95+96+ var filtered []models.Session97+ for _, s := range sessions {98+ if len(s.BusinessDateClt) >= 10 {99+ sessionDate, err := time.Parse("2006-01-02", s.BusinessDateClt[:10])100+ if err == nil && sessionDate.Equal(*m.selectedDate) {101+ filtered = append(filtered, s)102+ }103+ }104+ }105+ return filtered106+}
M internal/state/model.go +177 -163
@@ -1,22 +1,19 @@
11 package state22 33 import (4- "fmt"5-6- "github.com/charmbracelet/bubbles/list"7- "github.com/charmbracelet/bubbletea"8- "github.com/charmbracelet/lipgloss"4+ "github.com/charmbracelet/bubbles/viewport"5+ tea "github.com/charmbracelet/bubbletea"96 "github.com/dami/showings/internal/api"107 "github.com/dami/showings/internal/models"118 "github.com/dami/showings/internal/views"9+ "time"1210 )1311 1412 type ViewState int1513 1614 const (1715 ViewLoading ViewState = iota18- ViewPresentations19- ViewSessions16+ ViewShowtimes2017 )2118 2219 type FetchError struct {
@@ -28,24 +25,45 @@ type DataLoaded struct {
2825 Presentations []models.Presentation2926 }3027 28+// VenueOption represents a selectable cinema location.29+type VenueOption struct {30+ Slug string31+ Name string32+}33+3134 type Model struct {3235 viewState ViewState3336 presentations []models.Presentation34- sessions []models.Session3537 data *models.Data3638 apiClient *api.Client3739 venue string40+ cinemaName string41+42+ viewport viewport.Model43+ ganttMode bool44+ err error45+46+ selectedDate *time.Time47+ availableDates []time.Time3848 39- presentationList list.Model40- spinner int41- err error49+ // Venue selector modal50+ showVenueModal bool51+ venues []VenueOption52+ venueCursor int53+54+ width int55+ height int4256 }4357 44-func New(venue string) *Model {58+func New(venue string, dateFilter *time.Time, gantt bool) *Model {4559 return &Model{46- viewState: ViewLoading,47- venue: venue,48- apiClient: api.NewClient(),60+ viewState: ViewLoading,61+ venue: venue,62+ apiClient: api.NewClient(),63+ selectedDate: dateFilter,64+ ganttMode: gantt,65+ width: 120,66+ height: 40,4967 }5068 }5169
@@ -59,7 +77,7 @@ func (m *Model) fetchData() tea.Msg {
5977 return FetchError{err}6078 }6179 62- presentations := m.apiClient.FilterUpcomingPresentations(&data.Data)80+ presentations := data.Data.FilterUpcomingPresentations()6381 6482 return DataLoaded{6583 Data: data.Data,
@@ -76,191 +94,187 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7694 case DataLoaded:7795 m.data = &msg.Data7896 m.presentations = msg.Presentations79- m.viewState = ViewPresentations80- m.initPresentationList()97+ m.viewState = ViewShowtimes98+99+ // Extract cinema name and available venues100+ m.venues = nil101+ m.cinemaName = m.venue102+ for _, market := range m.data.Market {103+ for _, cinema := range market.Cinemas {104+ m.venues = append(m.venues, VenueOption{105+ Slug: cinema.Slug,106+ Name: cinema.Name,107+ })108+ if cinema.Slug == m.venue {109+ m.cinemaName = cinema.Name110+ }111+ }112+ }113+ // Set cursor to current venue114+ for i, v := range m.venues {115+ if v.Slug == m.venue {116+ m.venueCursor = i117+ break118+ }119+ }120+121+ m.computeAvailableDates()122+ m.rebuildView()123+ return m, nil124+125+ case tea.WindowSizeMsg:126+ m.width = msg.Width127+ m.height = msg.Height128+ if m.data != nil {129+ m.rebuildView()130+ }81131 return m, nil82132 83133 case tea.KeyMsg:134+ // Modal input takes priority when open135+ if m.showVenueModal {136+ return m.updateVenueModal(msg)137+ }138+84139 switch msg.String() {85- case "esc":86- if m.viewState == ViewSessions {87- m.viewState = ViewPresentations88- }140+ case "q", "ctrl+c":141+ return m, tea.Quit142+143+ case "t":144+ m.setTodayFilter()145+ m.rebuildView()89146 return m, nil90147 91- case "enter":92- if m.viewState == ViewPresentations {93- idx := m.presentationList.Index()94- if idx >= 0 && idx < len(m.presentations) {95- m.sessions = m.apiClient.GetSessionsForPresentation(m.data, m.presentations[idx].Slug)96- m.viewState = ViewSessions97- }98- }148+ case "n":149+ m.shiftDate(1)150+ m.rebuildView()151+ return m, nil152+153+ case "p":154+ m.shiftDate(-1)155+ m.rebuildView()156+ return m, nil157+158+ case "g":159+ m.ganttMode = !m.ganttMode160+ m.rebuildView()161+ return m, nil162+163+ case "v":164+ m.showVenueModal = true99165 return m, nil100166 }101167 }102168 103- if m.viewState == ViewPresentations {169+ if m.viewState == ViewShowtimes && !m.showVenueModal {104170 var cmd tea.Cmd105- m.presentationList, cmd = m.presentationList.Update(msg)171+ m.viewport, cmd = m.viewport.Update(msg)106172 return m, cmd107173 }108174 109175 return m, nil110176 }111177 178+func (m *Model) updateVenueModal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {179+ switch msg.String() {180+ case "esc", "v":181+ m.showVenueModal = false182+ return m, nil183+184+ case "up", "k":185+ if m.venueCursor > 0 {186+ m.venueCursor--187+ }188+ return m, nil189+190+ case "down", "j":191+ if m.venueCursor < len(m.venues)-1 {192+ m.venueCursor++193+ }194+ return m, nil195+196+ case "enter":197+ if m.venueCursor >= 0 && m.venueCursor < len(m.venues) {198+ selected := m.venues[m.venueCursor]199+ if selected.Slug != m.venue {200+ m.venue = selected.Slug201+ m.cinemaName = selected.Name202+ m.showVenueModal = false203+ m.viewState = ViewLoading204+ return m, m.fetchData205+ }206+ }207+ m.showVenueModal = false208+ return m, nil209+210+ case "q", "ctrl+c":211+ return m, tea.Quit212+ }213+214+ return m, nil215+}216+112217 func (m *Model) View() string {113218 if m.err != nil {114219 return errorStyle.Render(m.err.Error())115220 }116221 222+ var base string117223 switch m.viewState {118224 case ViewLoading:119- return loadingStyle.Render("Loading schedule...")120- case ViewPresentations:121- return m.presentationList.View()122- case ViewSessions:123- return m.renderSessionsView()225+ base = loadingStyle.Render("Loading schedule...")226+ case ViewShowtimes:227+ base = m.renderShowtimesView()124228 }125- return ""126-}127229 128-func (m *Model) initPresentationList() {129- items := make([]list.Item, len(m.presentations))130- for i, p := range m.presentations {131- items[i] = views.PresentationItem{132- Presentation: p,133- SessionCount: len(m.apiClient.GetSessionsForPresentation(m.data, p.Slug)),134- }230+ if m.showVenueModal {231+ return m.renderWithModal(base)135232 }136233 137- m.presentationList = list.New(items, views.PresentationDelegate{}, 0, 0)138- m.presentationList.Title = "Upcoming Shows"139- m.presentationList.Styles = listStyles234+ return base140235 }141236 142-func (m *Model) renderSessionsView() string {143- if len(m.sessions) == 0 {144- return "No sessions available"237+func (m *Model) viewportHeight() int {238+ chrome := 6239+ h := m.height - chrome240+ if h < 5 {241+ h = 5145242 }243+ return h244+}146245 147- presentation := m.presentations[m.presentationList.Index()]148- title := presentation.Show.Title149-150- header := headerStyle.Render(title)151- separator := separatorStyle.Render(separatorText)152- backHint := hintStyle.Render(hintText)153-154- var content []string155- content = append(content, header)156- content = append(content, "")157- content = append(content, backHint)158- content = append(content, separator)159- content = append(content, "")160-161- type cinemaGroup struct {162- cinema *models.Cinema163- sessions []models.Session246+func (m *Model) contentWidth() int {247+ w := m.width - 2248+ if w < 40 {249+ w = 40164250 }251+ return w252+}165253 166- cinemaMap := make(map[string]*cinemaGroup)167- for _, s := range m.sessions {168- cinema := m.apiClient.GetCinema(m.data, s.CinemaId)169- if cinema == nil {170- continue171- }172- if _, ok := cinemaMap[cinema.ID]; !ok {173- cinemaMap[cinema.ID] = &cinemaGroup{cinema: cinema}174- }175- cinemaMap[cinema.ID].sessions = append(cinemaMap[cinema.ID].sessions, s)176- }177-178- for _, cg := range cinemaMap {179- cinemaHeader := cinemaStyle.Render(cg.cinema.Name)180- content = append(content, cinemaHeader)181-182- dateMap := make(map[string][]models.Session)183- for _, s := range cg.sessions {184- dateMap[s.BusinessDateClt] = append(dateMap[s.BusinessDateClt], s)185- }186-187- for date, sessions := range dateMap {188- dateHeader := dateStyle.Render(date)189- content = append(content, " "+dateHeader)190- for _, s := range sessions {191- format := m.apiClient.GetFormat(m.data, s.FormatSlug)192- formatName := ""193- if format != nil {194- formatName = format.Title195- }196-197- agePolicy := m.apiClient.GetAgePolicy(m.data, s.AgePolicySlug)198- agePolicyName := ""199- if agePolicy != nil {200- agePolicyName = agePolicy.Name201- }202-203- sessionLine := sessionStyle.Render(204- fmt.Sprintf("%s Screen %s %s %s", s.ShowTimeClt[11:16], s.ScreenNumber, formatName, agePolicyName),205- )206- content = append(content, " "+sessionLine)207- }254+func (m *Model) collectSessions() []models.Session {255+ var sessions []models.Session256+ for _, p := range m.presentations {257+ s := m.data.GetSessionsForPresentation(p.Slug)258+ if m.selectedDate != nil {259+ s = m.filterSessionsByDate(s)208260 }209- content = append(content, "")261+ sessions = append(sessions, s...)210262 }211-212- return lipgloss.JoinVertical(lipgloss.Left, content...)263+ return sessions213264 }214265 215-var (216- loadingStyle lipgloss.Style217- errorStyle lipgloss.Style218- headerStyle lipgloss.Style219- separatorStyle lipgloss.Style220- separatorText string221- hintStyle lipgloss.Style222- hintText string223- listStyles list.Styles224- cinemaStyle lipgloss.Style225- dateStyle lipgloss.Style226- sessionStyle lipgloss.Style227-)228-229-func init() {230- loadingStyle = lipgloss.NewStyle().231- Foreground(lipgloss.Color("86")).232- Padding(1)266+func (m *Model) rebuildView() {267+ sessions := m.collectSessions()233268 234- errorStyle = lipgloss.NewStyle().235- Foreground(lipgloss.Color("196")).236- Padding(1)237-238- headerStyle = lipgloss.NewStyle().239- Foreground(lipgloss.Color("226")).240- Bold(true).241- Padding(0, 0, 1, 0)242-243- separatorStyle = lipgloss.NewStyle().244- Foreground(lipgloss.Color("240"))245-246- separatorText = "────────────────────────────────────────"247-248- hintStyle = lipgloss.NewStyle().249- Foreground(lipgloss.Color("245"))250-251- hintText = "←→ select Enter view Esc back"252-253- listStyles = list.DefaultStyles()254-255- cinemaStyle = lipgloss.NewStyle().256- Foreground(lipgloss.Color("75")).257- Bold(true).258- Padding(0, 0, 1, 0)259-260- dateStyle = lipgloss.NewStyle().261- Foreground(lipgloss.Color("228")).262- Bold(true)269+ var content string270+ if m.ganttMode {271+ isToday := m.selectedDate != nil && m.selectedDate.Equal(time.Now().Truncate(24*time.Hour))272+ content = views.RenderGantt(sessions, m.data, m.presentations, m.contentWidth(), isToday)273+ } else {274+ groups := views.GroupByMovie(sessions, m.data, m.presentations)275+ content = views.RenderGroups(groups, m.contentWidth())276+ }263277 264- sessionStyle = lipgloss.NewStyle().265- Foreground(lipgloss.Color("252"))278+ m.viewport = viewport.New(m.width, m.viewportHeight())279+ m.viewport.SetContent(content)266280 }
A internal/state/render.go +124 -0
@@ -0,0 +1,124 @@
1+package state2+3+import (4+ "fmt"5+ "strings"6+7+ "github.com/charmbracelet/lipgloss"8+)9+10+func (m *Model) renderShowtimesView() string {11+ var content []string12+13+ content = append(content, m.renderHeader())14+ content = append(content, separatorStyle.Render(separatorText))15+ content = append(content, "")16+ content = append(content, m.viewport.View())17+ content = append(content, "")18+ content = append(content, m.renderFooter())19+20+ return lipgloss.JoinVertical(lipgloss.Left, content...)21+}22+23+func (m *Model) renderHeader() string {24+ name := ""25+ if m.cinemaName != "" {26+ name = cinemaNameStyle.Render(m.cinemaName) + " "27+ }28+29+ dateInfo := m.renderFilterInfo()30+31+ mode := ""32+ if m.ganttMode {33+ mode = " " + modeBadgeStyle.Render(" Timeline ")34+ }35+36+ return name + dateInfo + mode37+}38+39+func (m *Model) renderFilterInfo() string {40+ if m.selectedDate == nil {41+ return filterStyle.Render(" All dates ")42+ }43+44+ formatted := m.selectedDate.Format("Mon Jan 2")45+ return filterActive.Render(" " + formatted + " ")46+}47+48+func (m *Model) renderFooter() string {49+ hint := hintText50+ if m.ganttMode {51+ hint = hintTextGantt52+ }53+ rendered := hintStyle.Render(hint)54+55+ scrollPct := ""56+ if m.viewport.TotalLineCount() > m.viewport.Height {57+ pct := int(m.viewport.ScrollPercent() * 100)58+ scrollPct = scrollStyle.Render(fmt.Sprintf(" %d%% ", pct))59+ }60+61+ return rendered + " " + scrollPct62+}63+64+func (m *Model) renderWithModal(base string) string {65+ // Build the modal content66+ var items []string67+ items = append(items, modalTitleStyle.Render("Select Location"))68+ items = append(items, "")69+70+ // Find max venue name length for consistent row widths71+ maxLen := 072+ for _, v := range m.venues {73+ if len(v.Name) > maxLen {74+ maxLen = len(v.Name)75+ }76+ }77+ rowWidth := maxLen + 4 // "* " prefix + padding78+79+ for i, v := range m.venues {80+ isCurrent := v.Slug == m.venue81+ isHovered := i == m.venueCursor82+83+ // Color dot indicator84+ dot := modalDotStyle(i).Render("●")85+86+ prefix := " "87+ if isCurrent {88+ prefix = "* "89+ }90+91+ label := prefix + v.Name92+93+ var style lipgloss.Style94+ if isHovered && isCurrent {95+ style = modalSelectedCurrentStyle96+ } else if isHovered {97+ style = modalSelectedStyle98+ } else if isCurrent {99+ style = modalCurrentStyle100+ } else {101+ style = modalItemStyle102+ }103+104+ styled := style.Width(rowWidth).Render(label)105+ items = append(items, dot+" "+styled)106+ }107+108+ items = append(items, "")109+ items = append(items, modalHintStyle.Render("↑↓ navigate enter select esc close"))110+111+ inner := strings.Join(items, "\n")112+ modal := modalBoxStyle.Render(inner)113+114+ // Center the modal on a dim background that fills the screen.115+ // The base view is not shown to avoid ANSI compositing issues --116+ // instead we use a uniform dim background.117+ return lipgloss.Place(118+ m.width, m.height,119+ lipgloss.Center, lipgloss.Center,120+ modal,121+ lipgloss.WithWhitespaceChars("░"),122+ lipgloss.WithWhitespaceForeground(lipgloss.Color("236")),123+ )124+}
A internal/state/styles.go +132 -0
@@ -0,0 +1,132 @@
1+package state2+3+import (4+ "github.com/charmbracelet/lipgloss"5+)6+7+var (8+ loadingStyle lipgloss.Style9+ errorStyle lipgloss.Style10+ cinemaNameStyle lipgloss.Style11+ separatorStyle lipgloss.Style12+ separatorText string13+ hintStyle lipgloss.Style14+ hintText string15+ hintTextGantt string16+ scrollStyle lipgloss.Style17+ filterStyle lipgloss.Style18+ filterActive lipgloss.Style19+ modeBadgeStyle lipgloss.Style20+21+ // Modal styles22+ modalBoxStyle lipgloss.Style23+ modalTitleStyle lipgloss.Style24+ modalItemStyle lipgloss.Style25+ modalCurrentStyle lipgloss.Style26+ modalSelectedStyle lipgloss.Style27+ modalSelectedCurrentStyle lipgloss.Style28+ modalHintStyle lipgloss.Style29+)30+31+func init() {32+ loadingStyle = lipgloss.NewStyle().33+ Foreground(lipgloss.Color("72")).34+ Padding(1)35+36+ errorStyle = lipgloss.NewStyle().37+ Foreground(lipgloss.Color("167")).38+ Padding(1).39+ Border(lipgloss.NormalBorder()).40+ BorderForeground(lipgloss.Color("167"))41+42+ cinemaNameStyle = lipgloss.NewStyle().43+ Foreground(lipgloss.Color("255")).44+ Bold(true)45+46+ separatorStyle = lipgloss.NewStyle().47+ Foreground(lipgloss.Color("240"))48+49+ separatorText = "────────────────────────────────────────"50+51+ hintStyle = lipgloss.NewStyle().52+ Foreground(lipgloss.Color("245"))53+54+ hintText = "↑↓ scroll t today n/p dates g chart v venue q quit"55+ hintTextGantt = "↑↓ scroll t today n/p dates g list v venue q quit"56+57+ scrollStyle = lipgloss.NewStyle().58+ Foreground(lipgloss.Color("245")).59+ Background(lipgloss.Color("236")).60+ Padding(0, 1)61+62+ filterStyle = lipgloss.NewStyle().63+ Foreground(lipgloss.Color("245")).64+ Background(lipgloss.Color("236")).65+ Padding(0, 1)66+67+ filterActive = lipgloss.NewStyle().68+ Foreground(lipgloss.Color("235")).69+ Background(lipgloss.Color("150")).70+ Bold(true).71+ Padding(0, 1)72+73+ modeBadgeStyle = lipgloss.NewStyle().74+ Foreground(lipgloss.Color("235")).75+ Background(lipgloss.Color("109")).76+ Bold(true).77+ Padding(0, 1)78+79+ // Modal styles80+ modalBoxStyle = lipgloss.NewStyle().81+ Border(lipgloss.RoundedBorder()).82+ BorderForeground(lipgloss.Color("109")).83+ Background(lipgloss.Color("235")).84+ Padding(1, 2)85+86+ modalTitleStyle = lipgloss.NewStyle().87+ Foreground(lipgloss.Color("255")).88+ Bold(true)89+90+ modalItemStyle = lipgloss.NewStyle().91+ Foreground(lipgloss.Color("250")).92+ Background(lipgloss.Color("237")).93+ Padding(0, 1).94+ MarginBottom(0)95+96+ modalCurrentStyle = lipgloss.NewStyle().97+ Foreground(lipgloss.Color("255")).98+ Background(lipgloss.Color("237")).99+ Bold(true).100+ Padding(0, 1)101+102+ modalSelectedStyle = lipgloss.NewStyle().103+ Foreground(lipgloss.Color("255")).104+ Background(lipgloss.Color("109")).105+ Padding(0, 1)106+107+ modalSelectedCurrentStyle = lipgloss.NewStyle().108+ Foreground(lipgloss.Color("255")).109+ Background(lipgloss.Color("150")).110+ Bold(true).111+ Padding(0, 1)112+113+ modalHintStyle = lipgloss.NewStyle().114+ Foreground(lipgloss.Color("243"))115+}116+117+// Color palette for venue dots118+var venueDotColors = []lipgloss.Color{119+ lipgloss.Color("34"), // green120+ lipgloss.Color("33"), // blue121+ lipgloss.Color("125"), // magenta122+ lipgloss.Color("172"), // orange123+ lipgloss.Color("30"), // teal124+ lipgloss.Color("91"), // purple125+ lipgloss.Color("130"), // brown126+ lipgloss.Color("26"), // deep blue127+}128+129+func modalDotStyle(idx int) lipgloss.Style {130+ c := venueDotColors[idx%len(venueDotColors)]131+ return lipgloss.NewStyle().Foreground(c)132+}
A internal/views/gantt.go +593 -0
@@ -0,0 +1,593 @@
1+package views2+3+import (4+ "fmt"5+ "math"6+ "sort"7+ "strings"8+ "time"9+10+ "github.com/charmbracelet/lipgloss"11+ "github.com/dami/showings/internal/models"12+)13+14+// GanttEntry represents a single session plotted on the Gantt chart.15+type GanttEntry struct {16+ Slug string17+ Screen string18+ MovieName string19+ StartMin int // minutes from midnight20+ EndMin int // minutes from midnight21+ IsPast bool22+ ColorIdx int23+}24+25+// RenderGantt builds a Gantt-style timeline chart from sessions.26+// If isToday is true, a "now" marker is drawn at the current time.27+func 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+ }31+32+ // 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+ }41+42+ // 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+ }71+72+ // 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+ }82+83+ var screens []string84+ for s := range screenMap {85+ screens = append(screens, s)86+ }87+ sort.Strings(screens)88+89+ // 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+ }113+114+ // 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+ }131+132+ // Layout133+ labelW := 7 // "Scr XX "134+ chartW := width - labelW - 1135+ if chartW < 30 {136+ chartW = 30137+ }138+139+ // 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+ }151+152+ // Render153+ var rows []string154+155+ // 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))158+159+ // 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+ }166+167+ // 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+ }187+188+ // Legend189+ rows = append(rows, "")190+ rows = append(rows, buildLegend(titleMap, certMap, colorMap, durationMap, activeSlugSet, width))191+192+ return strings.Join(rows, "\n")193+}194+195+// buildRuler creates the hour labels above the chart.196+func buildRuler(minTime, maxTime, totalMin, chartW, labelW, nowPos int) string {197+ buf := make([]byte, chartW)198+ for i := range buf {199+ buf[i] = ' '200+ }201+202+ 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+ }212+213+ // 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+ }223+224+ pad := strings.Repeat(" ", labelW)225+226+ // 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+ }237+238+ return pad + ganttRulerStyle.Render(string(buf))239+}240+241+// buildTickLine creates the tick marks connecting ruler to bars.242+func 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+ }264+265+ pad := strings.Repeat(" ", labelW)266+267+ 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+ }273+274+ return pad + ganttTickStyle.Render(string(buf))275+}276+277+func 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+}290+291+// buildScreenRow renders one screen's timeline bar.292+func buildScreenRow(screen string, entries []GanttEntry, minTime, totalMin, chartW, labelW, nowPos int) string {293+ label := ganttScreenStyle.Render(fmt.Sprintf("Scr %-2s ", screen))294+295+ // 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+ }306+307+ 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+ }322+323+ barLen := endPos - startPos324+ name := e.MovieName325+326+ // 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+ }334+335+ // 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+ }344+345+ 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+ }358+359+ // 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+ }365+366+ // Render grid: batch consecutive cells of the same style367+ var result strings.Builder368+ curColor := -2369+ curPast := false370+ curNow := false371+ var seg strings.Builder372+373+ 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+ }389+390+ 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()400+401+ return label + result.String()402+}403+404+func 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+ })428+429+ var cards []string430+ for _, it := range items {431+ swatch := ganttSwatchStyle(it.idx).Render(" ")432+433+ 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+ }450+451+ line := swatch + " " + title + cert452+ if dur != "" {453+ line += " " + dur454+ }455+456+ card := legendBoxStyle.Render(line)457+ cards = append(cards, card)458+ }459+460+ // 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 := 0467+468+ 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+}487+488+// joinWithGap interleaves gap strings between items for lipgloss.JoinHorizontal.489+func 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+}499+500+var nowColor = lipgloss.Color("196") // bright red501+502+// Color palette: bright backgrounds with guaranteed white-text contrast503+var 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+}517+518+// Dimmed palette for past showtimes (darker variants)519+var 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+}533+534+func 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+}541+542+func ganttSwatchStyle(colorIdx int) lipgloss.Style {543+ bg := barColors[colorIdx%len(barColors)]544+ return lipgloss.NewStyle().545+ Background(bg)546+}547+548+func ganttPastBarStyle(colorIdx int) lipgloss.Style {549+ bg := barColorsPast[colorIdx%len(barColorsPast)]550+ return lipgloss.NewStyle().551+ Foreground(lipgloss.Color("243")).552+ Background(bg)553+}554+555+var (556+ ganttEmptyStyle = lipgloss.NewStyle().557+ Foreground(lipgloss.Color("244")).558+ Padding(1)559+560+ ganttRulerStyle = lipgloss.NewStyle().561+ Foreground(lipgloss.Color("252")).562+ Bold(true)563+564+ ganttTickStyle = lipgloss.NewStyle().565+ Foreground(lipgloss.Color("240"))566+567+ ganttScreenStyle = lipgloss.NewStyle().568+ Foreground(lipgloss.Color("252")).569+ Bold(true)570+571+ ganttDotStyle = lipgloss.NewStyle().572+ Foreground(lipgloss.Color("236"))573+574+ ganttNowStyle = lipgloss.NewStyle().575+ Foreground(nowColor).576+ Bold(true)577+578+ legendBoxStyle = lipgloss.NewStyle().579+ Border(lipgloss.RoundedBorder()).580+ BorderForeground(lipgloss.Color("238")).581+ Padding(0, 1)582+583+ legendTitleStyle = lipgloss.NewStyle().584+ Foreground(lipgloss.Color("252")).585+ Bold(true)586+587+ legendCertStyle = lipgloss.NewStyle().588+ Foreground(lipgloss.Color("245"))589+590+ legendDurStyle = lipgloss.NewStyle().591+ Foreground(lipgloss.Color("243")).592+ Italic(true)593+)
D internal/views/presentations.go +0 -95
@@ -1,95 +0,0 @@
1-package views2-3-import (4- "fmt"5- "io"6-7- "github.com/charmbracelet/bubbles/list"8- "github.com/charmbracelet/bubbletea"9- "github.com/charmbracelet/lipgloss"10- "github.com/dami/showings/internal/models"11-)12-13-type PresentationItem struct {14- Presentation models.Presentation15- SessionCount int16-}17-18-func (i PresentationItem) Title() string {19- title := i.Presentation.Show.Title20- if i.Presentation.Show.Certification != "" {21- title += fmt.Sprintf(" [%s]", i.Presentation.Show.Certification)22- }23- return title24-}25-26-func (i PresentationItem) Description() string {27- attrs := ""28- if len(i.Presentation.PresentationAttributeSlugs) > 0 {29- attrs = " • " + joinAttributes(i.Presentation.PresentationAttributeSlugs)30- }31- return fmt.Sprintf("%d sessions%s", i.SessionCount, attrs)32-}33-34-func (i PresentationItem) FilterValue() string {35- return i.Presentation.Show.Title36-}37-38-type PresentationDelegate struct{}39-40-func (d PresentationDelegate) Height() int { return 1 }41-func (d PresentationDelegate) Spacing() int { return 0 }42-func (d PresentationDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }43-func (d PresentationDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {44- i, ok := item.(PresentationItem)45- if !ok {46- return47- }48-49- title := i.Title()50- desc := i.Description()51-52- ts := titleStyle53- ds := descStyle54-55- if index == m.Index() {56- ts = selectedTitleStyle57- ds = selectedDescStyle58- }59-60- fmt.Fprintf(w, "%s %s", ts.Render(title), ds.Render(desc))61-}62-63-func joinAttributes(slugs []string) string {64- result := ""65- for i, s := range slugs {66- if i > 0 {67- result += ", "68- }69- result += attributeNames[s]70- }71- return result72-}73-74-var attributeNames = map[string]string{75- "first-run": "First Run",76- "alamo-exclusive": "Alamo Exclusive",77- "advance-sales": "Advance Sales",78- "family-friendly": "Family Friendly",79-}80-81-var (82- titleStyle = lipgloss.NewStyle().83- Foreground(lipgloss.Color("252")).84- Bold(false)85-86- descStyle = lipgloss.NewStyle().87- Foreground(lipgloss.Color("245"))88-89- selectedTitleStyle = lipgloss.NewStyle().90- Foreground(lipgloss.Color("226")).91- Bold(true)92-93- selectedDescStyle = lipgloss.NewStyle().94- Foreground(lipgloss.Color("228"))95-)
A internal/views/sessions.go +315 -0
@@ -0,0 +1,315 @@
1+package views2+3+import (4+ "fmt"5+ "sort"6+ "strings"7+ "time"8+9+ "github.com/charmbracelet/lipgloss"10+ "github.com/dami/showings/internal/models"11+)12+13+// MovieGroup holds a presentation and its resolved showtimes for rendering.14+type MovieGroup struct {15+ Title string16+ Certification string17+ Headline string18+ Showtimes []Showtime19+}20+21+// Showtime holds the display-ready fields for a single session.22+type 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+}29+30+// GroupByMovie takes all sessions + presentations and returns MovieGroups31+// ordered by earliest showtime per movie, with showtimes sorted chronologically.32+func GroupByMovie(sessions []models.Session, data *models.Data, presentations []models.Presentation) []MovieGroup {33+ type presInfo struct {34+ title string35+ cert string36+ headline string37+ }38+39+ 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+ }43+44+ // 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+ }57+58+ // Group sessions by presentation slug59+ groupMap := make(map[string]*MovieGroup)60+ var slugOrder []string61+62+ for _, s := range sessions {63+ info, ok := infoMap[s.PresentationSlug]64+ if !ok {65+ continue66+ }67+68+ 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+ }76+77+ 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+ }88+89+ formatDisplay := ""90+ if s.FormatSlug != defaultFormat {91+ f := data.GetFormat(s.FormatSlug)92+ if f != nil {93+ formatDisplay = f.Title94+ }95+ }96+97+ 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+ }105+106+ // 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+ }112+113+ // 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+ })125+126+ groups := make([]MovieGroup, 0, len(slugOrder))127+ for _, slug := range slugOrder {128+ groups = append(groups, *groupMap[slug])129+ }130+ return groups131+}132+133+// RenderGroups renders all movie groups into a styled string with bordered boxes.134+func RenderGroups(groups []MovieGroup, width int) string {135+ if len(groups) == 0 {136+ return emptyStyle.Render("No showtimes available")137+ }138+139+ var sections []string140+ for _, g := range groups {141+ sections = append(sections, renderGroup(g, width))142+ }143+ return strings.Join(sections, "\n")144+}145+146+func 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+ }152+153+ // 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)))160+161+ 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) + count167+168+ var rows []string169+ rows = append(rows, header)170+171+ if g.Headline != "" {172+ rows = append(rows, headlineStyle.Render(g.Headline))173+ }174+175+ // Showtime chips with background color176+ var chips []string177+ for _, st := range g.Showtimes {178+ chip := formatChip(st)179+ chips = append(chips, chip)180+ }181+182+ 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+ }189+190+ inner := strings.Join(rows, "\n")191+192+ return boxStyle.Width(innerWidth).Render(inner)193+}194+195+func formatChip(st Showtime) string {196+ timeStr := st.Time197+ screenStr := "Scr " + st.Screen198+199+ 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+ }206+207+ chip := chipTimeStyle.Render(timeStr) + " " + chipScreenStyle.Render(screenStr)208+ if st.Format != "" {209+ chip += " " + chipFormatStyle.Render(st.Format)210+ }211+ return chip212+}213+214+func layoutChips(chips []string, maxWidth int) []string {215+ if len(chips) == 0 {216+ return nil217+ }218+219+ chipGap := " "220+ gapWidth := lipgloss.Width(chipGap)221+ var lines []string222+ var currentLine string223+ currentWidth := 0224+225+ 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+}244+245+// isSessionPast checks if a session's showtime has already passed.246+func 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+}261+262+var (263+ boxStyle = lipgloss.NewStyle().264+ Border(lipgloss.RoundedBorder()).265+ BorderForeground(lipgloss.Color("238")).266+ Padding(0, 1)267+268+ movieTitleStyle = lipgloss.NewStyle().269+ Foreground(lipgloss.Color("142")).270+ Bold(true)271+272+ certStyle = lipgloss.NewStyle().273+ Foreground(lipgloss.Color("245"))274+275+ headlineStyle = lipgloss.NewStyle().276+ Foreground(lipgloss.Color("243")).277+ Italic(true)278+279+ countStyle = lipgloss.NewStyle().280+ Foreground(lipgloss.Color("243"))281+282+ // Active showtime chip styles283+ chipTimeStyle = lipgloss.NewStyle().284+ Foreground(lipgloss.Color("230")).285+ Background(lipgloss.Color("238")).286+ Bold(true).287+ Padding(0, 1)288+289+ chipScreenStyle = lipgloss.NewStyle().290+ Foreground(lipgloss.Color("252"))291+292+ chipFormatStyle = lipgloss.NewStyle().293+ Foreground(lipgloss.Color("109")).294+ Italic(true)295+296+ // 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)302+303+ pastScreenStyle = lipgloss.NewStyle().304+ Foreground(lipgloss.Color("239")).305+ Strikethrough(true)306+307+ pastFormatStyle = lipgloss.NewStyle().308+ Foreground(lipgloss.Color("239")).309+ Strikethrough(true).310+ Italic(true)311+312+ emptyStyle = lipgloss.NewStyle().313+ Foreground(lipgloss.Color("244")).314+ Padding(1)315+)
M main.go +23 -1
D showings +0 -0

Binary file.