DEV Community

Cover image for How I Built the Most Overengineered TUI Weather App
Arnab Santra
Arnab Santra

Posted on

How I Built the Most Overengineered TUI Weather App

Why Would Someone Need a Weather App?

They don't.

But I had free time, a desire to learn Go, and an unexplainable need to make simple things complicated.

I believe the most effective way of learning is by building, so here I am, using a sophisticated relational database for storing a 5-minute weather cache.

I had previously built simple web servers with Go and net/http, and most YouTube tutorials focus on that.

The Idea

This was my first list of requirements:

  1. The app should be instant/fast
  2. The user can search for their location in the terminal
  3. The user can select from multiple locations within the TUI
  4. The fetched weather should not be boring to look at.

The Components

I envisioned the app having three major components:

  1. The backend: Responsible for fetching the location (latitude, longitude), and the recent weather.
  2. The UI: This will be our TUI frontend and will communicate with the backend.
  3. The Database: I realized that continuously calling a public API isn't ideal. Therefore, we needed to cache the city and weather information.

The Tech Stack

This project uses a lot of powerful (yet questionable for a simple weather app) set of tools:

  • Go - The core language.

  • Bubble Tea - Used to build the interactive terminal UI using The Elm Architecture.

  • Lip Gloss - Handles styling, layout, and colors in the TUI.

  • SQLite - A lightweight, file-based database used for caching weather data and storing city information.

  • sqlc - Generates type-safe Go code from raw SQL queries, keeping the database layer clean and maintainable.

  • Goose - Manages database migrations and schema changes.

  • OpenWeather API - Provides geocoding and weather data

demo

Note: You can find the full source code in my GitHub


The Backend

This backend is pretty straightforward. You call the API, get the result, format the response, and return it while storing the result in the database. There are multiple public APIs for this task. I think openweathermap.org is the most popular, and it's the one I'm using.

Then again, it is a public API. Though this one has a generous free tier, I don't like the idea of thousands of API calls per day for something as trivial as weather.
The solution is caching. I simply search the DB for the latest record. Each record includes a timestamp. I compare that timestamp with the current time. If the record is older than a set threshold (5 minutes in this case), we call the API; otherwise, we return the cached record.

Calling the API itself is pretty straightforward, and the same code can be used for both weather and city information.

client.go

func FetchAndDecode[T any](client *http.Client, req *http.Request) (*T, error) {
    response, err := client.Do(req)
    if err != nil {
        return nil, err
    }

    defer response.Body.Close()

    if response.StatusCode < 200 || response.StatusCode >= 300 {
        body, _ := io.ReadAll(response.Body)
        return nil, fmt.Errorf("API Error: status=%d, body=%s", response.StatusCode, body)
    }

    var result T
    if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
        return nil, err
    }

    return &result, nil
}
Enter fullscreen mode Exit fullscreen mode

This function uses generics [T any] to handle both the response payload and the return type. It executes the request req using the provided client, attempts to decode the JSON response into type T, and finally returning a pointer to the result and along with the error (if any).

Now, we can reuse this same function to fetch both the weather and city information without writing redundant decoding logic.

client.go

// Fetches weahter for a specific coordinates
func (c *WeatherClient) FetchWeather(ctx context.Context, lat, lon float64) (*WeatherResponse, error) {
    weatherUrl, err := url.Parse(c.WeatherURL)
    if err != nil {
        return nil, err
    }

    query := weatherUrl.Query()
    query.Set("lat", fmt.Sprintf("%f", lat))
    query.Set("lon", fmt.Sprintf("%f", lon))
    query.Set("appid", c.APIKey)
    query.Set("units", "metric")

    weatherUrl.RawQuery = query.Encode()
    req, err := http.NewRequestWithContext(ctx, "GET", weatherUrl.String(), nil)
    if err != nil {
        return nil, fmt.Errorf("Error forming the weather request: %s", err)
    }

    return FetchAndDecode[WeatherResponse](c.HTTPClient, req)
}

// Fetches city information (such as coordinates) for a city name
func (c *WeatherClient) FetchGeocoding(ctx context.Context, cityName string, limit int) ([]City, error) {
    geocoderUrl, err := url.Parse(fmt.Sprintf("%s/direct", c.GeocoderURL))
    if err != nil {
        return nil, err
    }

    query := geocoderUrl.Query()
    query.Set("q", cityName)
    query.Set("limit", fmt.Sprintf("%d", limit))
    query.Set("appid", c.APIKey)
    geocoderUrl.RawQuery = query.Encode()

    req, err := http.NewRequestWithContext(ctx, "GET", geocoderUrl.String(), nil)
    if err != nil {
        return nil, fmt.Errorf("Error forming the geocoding request: %s", err)
    }

    cities, err := FetchAndDecode[[]City](c.HTTPClient, req)
    if err != nil {
        return nil, err
    }

    if len(*cities) == 0 {
        return nil, fmt.Errorf("no cities found with name: %s", cityName)
    }

    return *cities, nil
}
Enter fullscreen mode Exit fullscreen mode

The openweathermap.org API expects latitude and longitude as query params.
This means we need resolve the coordinates of a city before we can actually as for the weather. This process is called Geocoding.

In this step, we pass the city name as a query parameter and receive one or more matching cities as results, with their coordinates and other metadata. We then store those results in the following struct:

types.go

type City struct {
    Name    string  `json:"name"`
    Country string  `json:"country"`
    Lat     float64 `json:"lat"`
    Lon     float64 `json:"lon"`
    Id      int     `json:"id"`
}
Enter fullscreen mode Exit fullscreen mode

The openweathermap.org API returns a massive JSON payload with dozens of fields. For a TUI, we don't need everything. I’ve simplified the structures to focus on the essentials

types.go

type Coordinates struct {
    Lat float64 `json:"lat"`
    Lon float64 `json:"lon"`
}

type BasicWeather struct {
    Type string `json:"main"`
    Desc string `json:"description"`
    Icon string `json:"icon"`
    Id   int    `json:"id"`
}

type MainWeather struct {
    Temp        float64 `json:"temp"`
    FeelsLike   float64 `json:"feels_like"`
    TempMin     float64 `json:"temp_min"`
    TempMax     float64 `json:"temp_max"`
    Pressure    int     `json:"pressure"`
    Humidity    int     `json:"humidity"`
    SeaLevel    int     `json:"sea_level"`
    GroundLevel int     `json:"grnd_level"`
}

type Wind struct {
    Speed float64 `json:"speed"`
    Gust  float64 `json:"gust"`
    Deg   int     `json:"deg"`
}

type WeatherSys struct {
    Country string `json:"country"`
    Sunrise int64  `json:"sunrise"`
    Sunset  int64  `json:"sunset"`
    Type    int    `json:"type"`
    Id      int    `json:"id"`
}

type Location struct {
    Name  string      `json:"name"`
    Coord Coordinates `json:"coord"`
    Id    int         `json:"id"`
}

type WeatherResponse struct {
    Weather  []BasicWeather `json:"weather"`
    Main     MainWeather    `json:"main"`
    Sys      WeatherSys     `json:"sys"`
    Wind     Wind           `json:"wind"`
    Coord    Coordinates    `json:"coord"`
    Rain     float64        `json:"rain.1h"`
    Base     string         `json:"base"`
    Name     string         `json:"name"`
    DT       int64          `json:"dt"`
    COD      int            `json:"cod"`
    ID       int            `json:"id"`
    Clouds   int            `json:"clouds.all"`
    Timezone int            `json:"timezone"`
    Vis      int            `json:"visibility"`
}
Enter fullscreen mode Exit fullscreen mode

Note: I might have skipped one or two fields, the sole reason being nothing but my laziness. You can take a look at the full response schema here.

The Weather Service

With this let's create high-level functions to fetch the weather and cities. Because we have a caching mechanism in-place with SQLite , it's better to have a weatherSevice struct to handle both the DB connection and env variables.

type WeatherService struct {
    DB     *database.Queries
    Client *WeatherClient
}
Enter fullscreen mode Exit fullscreen mode

Then we add the methods.

// for weather
func (s *WeatherService) GetWeather(ctx context.Context, loc Location) (*WeatherResponse, error) {
    var w *WeatherResponse
    if loc.Coord.Lat == 0 && loc.Coord.Lon == 0 {
        cities, err := s.ResolveCity(ctx, loc.Name)
        if err != nil {
            return nil, err
        }
        loc.Coord = Coordinates{Lat: cities[0].Lat, Lon: cities[0].Lon}
    }

    cacheParams := database.GetFreshWeatherByCoordsParams{
        Lat:       sql.NullFloat64{Float64: loc.Coord.Lat - EPSILON, Valid: true},
        Lat_2:     sql.NullFloat64{Float64: loc.Coord.Lat + EPSILON, Valid: true},
        Lon:       sql.NullFloat64{Float64: loc.Coord.Lon - EPSILON, Valid: true},
        Lon_2:     sql.NullFloat64{Float64: loc.Coord.Lon + EPSILON, Valid: true},
        FetchedAt: sql.NullInt64{Int64: time.Now().Add(-10 * time.Minute).Unix(), Valid: true},
    }

    if cached, err := s.DB.GetFreshWeatherByCoords(ctx, cacheParams); err == nil {
        cachedWeatherRes := WeatherCacheToResponse(cached)
        return &cachedWeatherRes, nil
    }

    w, err := s.Client.FetchWeather(ctx, loc.Coord.Lat, loc.Coord.Lon)
    if err != nil {
        return nil, err
    }

    func() {
        if err := s.DB.InsertWeather(ctx, w.ToDBWeather()); err != nil {
            log.Printf("Failed to cache the response: %s", err)
        }
    }()

    return w, nil
}

// for city
func (s *WeatherService) ResolveCity(ctx context.Context, name string) ([]City, error) {
    var cities []City
    query := name + "%"
    dbCities, err := s.DB.FuzzYFindCity(ctx, query)

    log.Printf("db queried")

    if err != nil {
        log.Printf("fuzzyfind Error: %v", err)
    }

    if err == nil && len(dbCities) > 0 {
        for _, dbCity := range dbCities {
            cities = append(cities, City{
                Id:      int(dbCity.ID),
                Name:    dbCity.Name,
                Country: dbCity.Country,
                Lat:     dbCity.Lat,
                Lon:     dbCity.Lon,
            })
        }
        return cities, nil
    }

    cities, err = s.Client.FetchGeocoding(ctx, name, 1)
    log.Printf("api queried")
    if err != nil || len(cities) == 0 {
        log.Printf("city '%s' not found locally or via API", name)
        return nil, fmt.Errorf("city '%s' not found locally or via API", name)
    }

    return cities, nil
}
Enter fullscreen mode Exit fullscreen mode

Let's also create a function to initialize the service.

func NewWeatherService(conn *sql.DB, client *WeatherClient) *WeatherService {
    return &WeatherService{
        DB:     database.New(conn),
        Client: client,
    }
}
Enter fullscreen mode Exit fullscreen mode

The Database (Caching with SQLite)

Most people would use a simple JSON file or an in-memory map for caching. I chose SQLite . Why? Because I wanted full-text indexing on 200,000 cities and the ability to perform fuzzy searches without hitting an external API every time the user types a letter.

SQLite is simple, single-file based, relational yet surprisingly powerful database. The whole point of using a database in an weather app is speed. This is achieved with denormalized schemas and lots of indexing.

The most difficult part of designing any application, in my opinion, is the database schema. For this one, I just added simple tables to store:

  1. API responses
  2. City information
CREATE TABLE cities (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    country TEXT NOT NULL,
    lat REAL NOT NULL,
    lon REAL NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE weather_cache (
    id INTEGER PRIMARY KEY AUTOINCREMENT,

    -- Location
    city_id INTEGER,
    city_name TEXT,
    country TEXT,
    lat REAL,
    lon REAL,

    -- Weather summary
    weather_main TEXT,
    weather_desc TEXT,
    weather_icon TEXT,

    -- Temperature
    temp REAL,
    feels_like REAL,
    temp_min REAL,
    temp_max REAL,
    humidity INTEGER,
    pressure INTEGER,

    -- Wind
    wind_speed REAL,
    wind_deg INTEGER,
    wind_gust REAL,

    -- Rain (nullable)
    rain_1h REAL,

    -- Clouds
    cloudiness INTEGER,

    -- Visibility
    visibility INTEGER,

    -- Time
    weather_time INTEGER,   -- `dt` from API
    fetched_at INTEGER,    -- when WE fetched it (unix time)
    timezone INTEGER
);
Enter fullscreen mode Exit fullscreen mode

Searching the cities with city names was a core feature, so I added a simple index on city name.

-- v1
CREATE INDEX city_name_idx ON cities (name);    
Enter fullscreen mode Exit fullscreen mode

Then I realized I needed more of a "fuzzy find" approach.

-- v2
CREATE INDEX city_name_idx ON cities (name COLLATE NOCASE);
Enter fullscreen mode Exit fullscreen mode

This enables case-insensitive searches, which works well for city lookups.

For weather data, queries often involve multiple columns. For example, I might need to fetch the latest weather information for a city based on cities name, country or even coordinated. To support this efficiently, I added indexes on all relevant columns.

CREATE INDEX idx_weather_cid ON weather_cache(city_id);
CREATE INDEX idx_weather_city ON weather_cache(city_name, country);
CREATE INDEX idx_weather_coords ON weather_cache(lat, lon);
CREATE INDEX idx_weather_fetched ON weather_cache(fetched_at);
Enter fullscreen mode Exit fullscreen mode

That's it.

Migrations with goose

For table creation and schema migrations, I went with a command line tool called goose.

# instaltion command
go install github.com/pressly/goose/v3/cmd/goose@latest
Enter fullscreen mode Exit fullscreen mode

To use goose , I have put all the migration code in to a directory called sql\schema

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a---          17-01-2026    23:47            272 001_cities.sql
-a---          17-01-2026    23:52            117 002_index_cities.sql
-a---          18-01-2026    13:25            851 003_weather_cache.sql
-a---          18-01-2026    13:28            483 004_index_weather_cache.sql
-a---          18-01-2026    21:04            193 005_index_cities.sql
-a---          24-01-2026    17:46            726 006_add_cols_weather_cache.sql
Enter fullscreen mode Exit fullscreen mode

Each migration is prefixed with a sequence number (001, 002 etc.) so goose can keep track of which migrations have already been applied.

001_cities.sql

-- +goose Up

CREATE TABLE cities (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    country TEXT NOT NULL,
    lat REAL NOT NULL,
    lon REAL NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- +goose Down
DROP TABLE cities;
Enter fullscreen mode Exit fullscreen mode

The comment -- +goose Up marks the SQL to be executed during an up migration. Similarly, -- +goose Down defines how to revert the changes.
-- +goose Down is added before the command for down migration.

Important: Always include both **Up* and Down migrations.
The Down migration should fully undo the changes made by the Up migration*

To execute the migrations, we just have to run the following command
from sql\schema directory:

goose sqlite3 ../../small.db up
Enter fullscreen mode Exit fullscreen mode

Here small.db is the SQLite DB file. Basically we have to provide the relative path to it.

Type Safe queries with sqlc

If you've worked with SQL before, you probably already know there are several ways we can execute SQL queries from a programming language, such as:

  1. Database Driver: This is a low level approach where you install a DB-specific driver for your language or choice. Then you open a connection, send raw SQL strings, and finally closing the connection. This approach is fast, but it gets messy quickly and the DX is usually not great.
  2. Query Builders:
    These are built on top of database drivers. You use a library that lets you build queries using method chaining or function calls, and the library generates the SQL under the hood.

    Example:

db.select('*').from('users').where('age', '>', 18)
Enter fullscreen mode Exit fullscreen mode
  1. ORMs *(Object-Relational Mappers)*: This might be highest level of abstraction, where:
    • Tables <-> classes
    • Roes <-> objects The SQL is mostly hidden. Here the DX is great but performance can degrade, and you have almost no control over the generated SQL .

sqlctakes a different approach. We write raw SQL queries in separate files and sqlc generates type-safe Go methods from them.

Example:

sql\queries\cities.sql

-- name: CreateCity :one
INSERT INTO cities (id, name, country, lat, lon)
VALUES (?, ?, ?, ?, ?)
RETURNING *;

-- name: DeleteCity :exec
DELETE FROM cities
WHERE id = ?;

-- name: FindCity :many
SELECT *
FROM cities
WHERE LOWER(name)=LOWER(?);


-- name: FindCityWithID :one
SELECT *
FROM cities
WHERE id = ?;

-- name: FuzzYFindCity :many
SELECT *
FROM cities
WHERE name like ?;
Enter fullscreen mode Exit fullscreen mode

Here also, we add special comments to instruct sqlc. The syntax is

-- name: <FunctionName> : <return type>
Enter fullscreen mode Exit fullscreen mode

Where the return type can be :one, :many, or :exec

Similarly, for weather data:

sql\queries\weather_cache.sql

-- name: GetLatestWeatherByCity :one
SELECT *
FROM weather_cache
WHERE city_name = ?
ORDER BY fetched_at DESC
LIMIT 1;

-- name: GetFreshWeatherByCity :one
SELECT *
FROM weather_cache
WHERE city_name = ?
  AND fetched_at >= ?
ORDER BY fetched_at DESC
LIMIT 1;

-- name: GetLatestWeatherByCoords :one
SELECT *
FROM weather_cache
WHERE lat >= ?
  AND lat <= ?
  AND lon >= ?
  AND lon <= ?
  AND fetched_at >= ?
ORDER BY fetched_at DESC
LIMIT 1;
.
.
.
Enter fullscreen mode Exit fullscreen mode

Configuring sqlc

To specify details such as SQL engine, target language, and the schema and queries directories, we create a configuration file at the root of our project called sqlc.yaml.

sqlc.yaml

version: "2"
sql:
  - schema: "sql/schema"
    queries: "sql/queries"
    engine: "sqlite"
    gen:
      go:
        out: "internal/database"
Enter fullscreen mode Exit fullscreen mode

Then let's install sqlc if not already installed:

go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
Enter fullscreen mode Exit fullscreen mode

Then we run the following command at the project root:

sqlc generate
Enter fullscreen mode Exit fullscreen mode

This command validates the schema and queries, then generates the corresponding Go code in the internal/database directory, as specified in sqlc.yaml.

Now, that we are mostly done with DB.
Let's move on to the interesting part - the TUI.

Making a TUI with Bubble Tea

Bubble Tea is a pretty popular framework for building terminal apps. I had wanted to use it for a long time. So let's get started.

TUI Weather App

How Does Bubble Tea render UI ?

Without diving deep into the internals of Bubble Tea, let's just say it uses The Elm Architecture .

This architecture has three core components:

  1. Model - the state of you application
  2. View - a way to render the UI based on the current Model or state.
  3. Update - a way to update the Model / state based of messages (or events).

That's it.


The Model

The first step is to design the model which represents the state of the application. This model holds all the application data (such as current city, current weather, searching, error etc.)

type StateModel struct {
    service *weather.WeatherService // holds http clients, db connection etc

    textInput         textinput.Model  // a text input component provied by `bubble tea`
    searchResults     list.Model   // a list component from `bubble tea`
    curItem           *weather.City  // pointer to current city
    curWeather        *weather.WeatherResponse  // pointer to current weather
    isFilterOpen      bool  // indicates if filtering is on
    isFetchingWeather bool  
    keys              *itemsKeyMap  // pointer to keyboard shotcuts
    help              help.Model  // pointer to help component
    err               error
    debounceId        int  // used in debouncing
    width             int  
    height            int  // both height an width is used for terminal size

    logging bool
}
Enter fullscreen mode Exit fullscreen mode

Now, we create the initial model:

func NewModel(service *weather.WeatherService) StateModel {
    newSearchResults := list.New(nil, list.NewDefaultDelegate(), 0, 0)
    ti := textinput.New()
    ti.Placeholder = "Search for a city"
    ti.CharLimit = 50
    ti.Width = 40

    newModel := StateModel{
        service:           service,
        textInput:         ti,
        searchResults:     newSearchResults,
        curItem:           nil,
        curWeather:        nil,
        isFilterOpen:      false,
        isFetchingWeather: false,
        debounceId:        0,
        err:               nil,
        keys:              newItemsKeyMap(),
        help:              help.New(),
    }
    newModel.searchResults.Title = "Find Cities"
    newModel.searchResults.SetShowFilter(false)
    newModel.searchResults.SetShowHelp(false)
    newModel.searchResults.SetFilteringEnabled(false)

    return newModel
}
Enter fullscreen mode Exit fullscreen mode

The Init() method

The Init() method runs when the UI is first rendered.
In this case, there’s nothing we need to do at startup, so we simply return nil

package ui

import tea "github.com/charmbracelet/bubbletea"

func (curM StateModel) Init() tea.Cmd {
    return nil
}
Enter fullscreen mode Exit fullscreen mode

We need two additional methods:

  1. Update
  2. View

If either of these three methods ( Init(), Update(), View() ) of our StateModel struct is missing, the application will fail to compile.


The View() method

This method is responsible for displaying the UI based on the current model state.

func (curM StateModel) View() string {
    var content string

    helpView := curM.renderContextualHelp()
    height := max(curM.height-lipgloss.Height(helpView), 0)

    if curM.err != nil {
        content = windowStyle.
            Width(curM.width).
            Height(height).
            Render(errorStyle.Render(fmt.Sprintf("❌ Error: %v", curM.err)))
    } else if curM.isFilterOpen {
        searchContent := lipgloss.JoinVertical(lipgloss.Left,
            titleStyle.Render("🌤️ Weather Search"),
            curM.textInput.View(),
            "",
            curM.searchResults.View(),
        )
        content = windowStyle.
            Width(curM.width).
            Height(height).
            Render(searchContent)
    } else if curM.curItem != nil && curM.curWeather != nil {
        content = renderWeather(curM.curWeather, curM.width, height)
    } else {
        content = windowStyle.
            Width(curM.width).
            Height(height).
            Render("Loading...")
    }

    return lipgloss.JoinVertical(lipgloss.Left, content, helpView)
}
Enter fullscreen mode Exit fullscreen mode

There are two main states, either the filter for the cities is open or closed. When the filter is not open we check if we have a city and weather and render it. The full working code can be accessed from github. We are using lipgloss for colors and styling.


The Update() method

The Update() method has the following signature:

func (curM StateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd)
Enter fullscreen mode Exit fullscreen mode

It take a tea.Msg as an argument, which is like an event. Whenever something changes ( like terminal resize, key press, mouse input, etc.) Bubble Tea emits a message ( tea.Msg ) that is handled in this Update() method.

If you look closely you can see that Update() method returns a

  1. an updated tea.Model
  2. tea.Cmd, which tells Bubble Tea to perform an action asynchronously.

Because commands run asynchronously, heavy or blocking work should never be done directly inside Update():

func (curM StateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd = nil
    switch msg := msg.(type) {

    case tea.WindowSizeMsg:  // window is resized
        curM.width, curM.height = msg.Width, msg.Height
        curM.searchResults.SetSize(msg.Width-4, msg.Height-8)

    case citySearchResultMsg:  // handles city search results
        curM.searchResults.SetItems(msg.locs)

    case weatherSearchResultMsg:  // handle weather search results
        curM.curWeather = msg.weather
        curM.isFetchingWeather = false

    case debouncedMsg:  // handle debounced requests
        if curM.debounceId != msg.id {
            return curM, nil
        }
        return curM, curM.performLocationSearch()

    case errorMsg:
        curM.err = msg
        return curM, tea.Quit

    case tea.KeyMsg:  // core logic. a key msg can be navigating keys
                      // or keypresses when user searches for a city
                      // quit or open/close filter view commands
        switch {

        case key.Matches(msg, curM.keys.quit) && !curM.textInput.Focused():
            return curM, tea.Quit

        case key.Matches(msg, curM.keys.toggleFilter):
            if !curM.isFilterOpen {
                curM.isFilterOpen = true
                return curM, curM.textInput.Focus()
            }

            if !curM.textInput.Focused() {
                return curM, curM.textInput.Focus()
            }
            curM.isFilterOpen = false
            curM.textInput.Blur()
            return curM, nil

        case key.Matches(msg, curM.keys.choose):
            if i, ok := curM.searchResults.SelectedItem().(weather.City); ok {
                curM.curItem = &i
                curM.isFilterOpen = false
                curM.textInput.Blur()
                curM.isFetchingWeather = true
                cmd = curM.performWeatherSearch()
            }
            return curM, cmd
        case key.Matches(msg, curM.keys.back):
            if curM.textInput.Focused() {
                curM.textInput.Blur()
                return curM, nil
            }

            if curM.isFilterOpen {
                curM.isFilterOpen = false
                return curM, nil
            }

            return curM, tea.Quit

        default:
            if !curM.textInput.Focused() {
                curM.searchResults, cmd = curM.searchResults.Update(msg)
                return curM, cmd
            }

            curM.textInput, cmd = curM.textInput.Update(msg)
            return curM, tea.Batch(cmd, curM.debouncedSearch())
        }

    }

    return curM, cmd

}
Enter fullscreen mode Exit fullscreen mode

As you can see in this code snippet we are handling the filtering and results ourselves.

  • When the text changes in the filter's text input. The textInput.Model registers a tea.keyMsg.
  • We debounce the search request. After certain delay, we perform the search and register a citySearchResultMsg which contains the city results.
  • Finally based on this results we update the list on the TUI.

Fetching the weather is handled similarly.
When the weather data is received, we update our model, close the filter view, and render the weather beautifully in the terminal.

This is basically it.


Some Small Details

  • Here we are actually caching the weather responses. If the weather is already available in the cache, that cache version is show, instead of making another API call.
  • I downloaded a huge list of city information containing almost 200000 cities. Then I put them into our SQLite table. When the app searches for a city, it first looks up this local table, if not found only then it calls the openweathermap.org API to get the location coordinates.
  • While building the binaries for a release version, I compressed the DB in a .gz file which contains all the tables and the city information.

Conclusion

There you have it, the most complicated weather app no one asked for, which I built anyway. I learned, GO backend, how TUI's work, migrations, effective debouncing, how to build a release binary, create GitHub assets and many more.
Maybe the real learning is the fun I made along the way !!!

Top comments (0)