chopstick
All widgets

Weather

Comes with Chopstick

The weather where you are on the island (now, today's range and the next days), refreshed every 15 minutes; ask for another city to look it up.

Already installed? It's in your widget library.

Type it in the notch's search

  • Weather in city
  • Show weather
  • Refresh weather

What it runs

A widget is a folder of plain files. Here is all of its code.

widget.json
{
  "id": "weather",
  "name": "Weather",
  "icon": "icon.png",
  "description": "The weather where you are on the island (now, today's range and the next days), refreshed every 15 minutes; ask for another city to look it up.",
  "ambient": true,
  "commands": {
    "city": {
      "phrase": "Weather in {city}",
      "description": "Show the weather in a city.",
      "inputs": {
        "city": {
          "type": "string",
          "description": "City name, e.g. Lisbon"
        }
      },
      "preview": {
        "state": "lookup",
        "run": "fetch.sh"
      },
      "do": [
        {
          "set": {
            "city": "{{input.city}}"
          }
        },
        {
          "go": "loading"
        }
      ]
    },
    "show": {
      "description": "Show the weather where you are.",
      "phrase": "Show weather",
      "do": [
        {
          "go": "now"
        }
      ]
    },
    "refresh": {
      "description": "Fetch the weather again.",
      "phrase": "Refresh weather",
      "do": [
        {
          "refresh": "here"
        }
      ]
    }
  },
  "states": {
    "now": {
      "compact": {
        "leading": {
          "symbol": "{{here.symbol | cloud.sun.fill}}",
          "tint": "{{here.tint}}"
        },
        "trailing": {
          "text": "{{here.temp | –}}"
        }
      },
      "expanded": {
        "size": "regular",
        "header": {
          "symbol": "location.fill",
          "title": "{{here.city | Weather}}"
        },
        "blocks": [
          {
            "row": [
              {
                "stack": [
                  {
                    "text": "{{here.temp}}  {{here.summary}}",
                    "style": "title"
                  },
                  {
                    "text": "H {{here.high}} · L {{here.low}} · Feels {{here.feels}}",
                    "style": "caption"
                  }
                ],
                "align": "leading",
                "spacing": "tight"
              },
              "spacer",
              {
                "stack": [
                  {
                    "note": "Humidity {{here.humidity}}"
                  },
                  {
                    "note": "Wind {{here.wind}}"
                  }
                ],
                "align": "trailing",
                "spacing": "tight"
              }
            ],
            "align": "center"
          },
          {
            "list": {
              "from": "here.days",
              "limit": 5,
              "symbol": "{{item.symbol}}",
              "title": "{{item.name}}  {{item.range}}",
              "subtitle": "{{item.summary}}"
            }
          }
        ]
      },
      "keys": [
        {
          "key": "cmd+r",
          "label": "Refresh",
          "do": [
            {
              "refresh": "here"
            }
          ]
        }
      ]
    },
    "loading": {
      "task": true,
      "compact": {
        "leading": {
          "symbol": "cloud.sun.fill"
        },
        "trailing": "spinner"
      },
      "on_enter": [
        {
          "run": "fetch.sh",
          "then": "ready",
          "else": "failed"
        }
      ],
      "keys": [
        {
          "key": "esc",
          "do": [
            "dismiss"
          ]
        }
      ]
    },
    "ready": {
      "task": true,
      "open": true,
      "dismiss_after": 10,
      "compact": {
        "leading": {
          "symbol": "{{result.symbol}}",
          "tint": "{{result.tint}}"
        },
        "trailing": {
          "text": "{{result.temp}}"
        }
      },
      "expanded": {
        "size": "regular",
        "header": {
          "symbol": "location.fill",
          "title": "{{result.city}}"
        },
        "blocks": [
          {
            "row": [
              {
                "stack": [
                  {
                    "text": "{{result.temp}}  {{result.summary}}",
                    "style": "title"
                  },
                  {
                    "text": "H {{result.high}} · L {{result.low}} · Feels {{result.feels}}",
                    "style": "caption"
                  }
                ],
                "align": "leading",
                "spacing": "tight"
              },
              "spacer",
              {
                "stack": [
                  {
                    "note": "Humidity {{result.humidity}}"
                  },
                  {
                    "note": "Wind {{result.wind}}"
                  }
                ],
                "align": "trailing",
                "spacing": "tight"
              }
            ],
            "align": "center"
          },
          {
            "list": {
              "from": "result.days",
              "limit": 5,
              "symbol": "{{item.symbol}}",
              "title": "{{item.name}}  {{item.range}}",
              "subtitle": "{{item.summary}}"
            }
          }
        ]
      },
      "keys": [
        {
          "key": "r",
          "label": "Refresh",
          "do": [
            {
              "go": "loading"
            }
          ]
        },
        {
          "key": "esc",
          "do": [
            "dismiss"
          ]
        }
      ]
    },
    "lookup": {
      "task": true,
      "open": true,
      "compact": {
        "leading": {
          "symbol": "{{result.symbol | cloud.sun.fill}}",
          "tint": "{{result.tint}}"
        },
        "trailing": {
          "text": "{{result.temp}}"
        }
      },
      "expanded": {
        "size": "regular",
        "header": {
          "symbol": "location.fill",
          "title": "{{result.city | Weather}}"
        },
        "blocks": [
          {
            "text": "{{result.city | Looking it up…}}",
            "style": "caption"
          },
          {
            "text": "{{result.headline | …}}",
            "style": "title"
          },
          {
            "list": {
              "from": "result.days",
              "limit": 3,
              "symbol": "{{item.symbol}}",
              "title": "{{item.name}}  {{item.range}}",
              "subtitle": "{{item.summary}}"
            }
          },
          {
            "note": "{{error}}"
          },
          {
            "input": {
              "placeholder": "Another city…",
              "value": "{{input.city}}",
              "do": [
                {
                  "command": "city",
                  "with": {
                    "city": "{{item.text}}"
                  }
                }
              ]
            }
          }
        ]
      },
      "keys": [
        {
          "key": "esc",
          "do": [
            "dismiss"
          ]
        }
      ]
    },
    "failed": {
      "task": true,
      "open": true,
      "dismiss_after": 6,
      "compact": {
        "leading": {
          "symbol": "exclamationmark.triangle",
          "tint": "orange"
        }
      },
      "expanded": {
        "size": "compact",
        "header": {
          "symbol": "cloud.sun.fill",
          "title": "Weather"
        },
        "blocks": [
          {
            "text": "{{error | Couldn't load the weather}}",
            "style": "body",
            "lines": 3
          }
        ]
      },
      "keys": [
        {
          "key": "r",
          "label": "Retry",
          "do": [
            {
              "go": "loading"
            }
          ]
        },
        {
          "key": "esc",
          "do": [
            "dismiss"
          ]
        }
      ]
    }
  },
  "preview": {
    "states": [
      "now",
      "loading",
      "ready",
      "failed",
      "lookup"
    ],
    "data": {
      "error": "Couldn't reach the weather service",
      "result": {
        "city": "Berlin",
        "temp": "14°",
        "feels": "12°",
        "humidity": "72%",
        "wind": "11 km/h",
        "symbol": "cloud.sun.fill",
        "summary": "Partly cloudy",
        "tint": "yellow",
        "high": "16°",
        "low": "8°",
        "days": [
          {
            "name": "Today",
            "symbol": "cloud.sun.fill",
            "summary": "Partly cloudy",
            "range": "8° / 16°"
          },
          {
            "name": "Mon",
            "symbol": "cloud.rain.fill",
            "summary": "Rain",
            "range": "9° / 13°"
          },
          {
            "name": "Tue",
            "symbol": "cloud.fill",
            "summary": "Overcast",
            "range": "7° / 12°"
          },
          {
            "name": "Wed",
            "symbol": "sun.max.fill",
            "summary": "Clear",
            "range": "6° / 15°"
          },
          {
            "name": "Thu",
            "symbol": "cloud.drizzle.fill",
            "summary": "Drizzle",
            "range": "8° / 14°"
          }
        ],
        "headline": "14°  Partly cloudy"
      },
      "here": {
        "city": "Berlin",
        "temp": "14°",
        "feels": "12°",
        "humidity": "72%",
        "wind": "11 km/h",
        "symbol": "cloud.sun.fill",
        "summary": "Partly cloudy",
        "tint": "yellow",
        "high": "16°",
        "low": "8°",
        "days": [
          {
            "name": "Today",
            "symbol": "cloud.sun.fill",
            "summary": "Partly cloudy",
            "range": "8° / 16°"
          },
          {
            "name": "Mon",
            "symbol": "cloud.rain.fill",
            "summary": "Rain",
            "range": "9° / 13°"
          },
          {
            "name": "Tue",
            "symbol": "cloud.fill",
            "summary": "Overcast",
            "range": "7° / 12°"
          },
          {
            "name": "Wed",
            "symbol": "sun.max.fill",
            "summary": "Clear",
            "range": "6° / 15°"
          },
          {
            "name": "Thu",
            "symbol": "cloud.drizzle.fill",
            "summary": "Drizzle",
            "range": "8° / 14°"
          }
        ]
      },
      "input": {
        "city": "Berlin"
      }
    }
  },
  "initial": "now",
  "watch": [
    {
      "script": "fetch.sh",
      "every": 900,
      "as": "here"
    }
  ]
}
fetch.sh
#!/bin/sh
# Finds the location by IP (ipinfo.io), gets the weather from Open-Meteo (no key), prints one JSON object.
# With a city (the city command sets it), looks it up instead (Open-Meteo geocoding).
if [ -n "$CHOPSTICK_CITY" ]; then
  loc=$(curl -fsS --max-time 8 -G https://geocoding-api.open-meteo.com/v1/search --data-urlencode "name=$CHOPSTICK_CITY" -d count=1) \
    || { echo "Couldn't look up $CHOPSTICK_CITY" >&2; exit 1; }
  lat=$(printf '%s' "$loc" | sed -n 's/.*"latitude": *\([-0-9.]*\).*/\1/p')
  lon=$(printf '%s' "$loc" | sed -n 's/.*"longitude": *\([-0-9.]*\).*/\1/p')
  [ -n "$lat" ] || { echo "Couldn't find a city called $CHOPSTICK_CITY" >&2; exit 1; }
else
  loc=$(curl -fsS --max-time 8 https://ipinfo.io/json) || { echo "Couldn't find your location" >&2; exit 1; }
  latlon=$(printf '%s' "$loc" | sed -n 's/.*"loc": *"\([^"]*\)".*/\1/p')
  lat=${latlon%,*}
  lon=${latlon#*,}
  [ -n "$lat" ] || { echo "Couldn't find your location" >&2; exit 1; }
fi

wx=$(curl -fsS --max-time 8 "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon&current=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code,is_day&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=5") \
  || { echo "Couldn't reach the weather service" >&2; exit 1; }

osascript -l JavaScript - "$loc" "$wx" <<'JS'
function run(argv) {
  const loc = JSON.parse(argv[0]), wx = JSON.parse(argv[1]);
  const c = wx.current, d = wx.daily, day = c.is_day === 1;
  function info(code, isDay) {
    if (code === 0) return [isDay ? "sun.max.fill" : "moon.stars.fill", "Clear", isDay ? "yellow" : "cyan"];
    if (code <= 2) return [isDay ? "cloud.sun.fill" : "cloud.moon.fill", "Partly cloudy", "yellow"];
    if (code === 3) return ["cloud.fill", "Overcast", "gray"];
    if (code <= 48) return ["cloud.fog.fill", "Fog", "gray"];
    if (code <= 57) return ["cloud.drizzle.fill", "Drizzle", "cyan"];
    if (code <= 67) return ["cloud.rain.fill", "Rain", "blue"];
    if (code <= 77) return ["cloud.snow.fill", "Snow", "cyan"];
    if (code <= 82) return ["cloud.heavyrain.fill", "Showers", "blue"];
    if (code <= 86) return ["cloud.snow.fill", "Snow showers", "cyan"];
    return ["cloud.bolt.rain.fill", "Thunderstorm", "purple"];
  }
  const r = n => Math.round(n) + "°";
  const now = info(c.weather_code, day);
  const names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
  const days = d.time.map((t, i) => {
    const w = info(d.weather_code[i], true);
    return {
      name: i === 0 ? "Today" : names[new Date(t + "T12:00:00").getDay()],
      symbol: w[0], summary: w[1],
      range: r(d.temperature_2m_min[i]) + " / " + r(d.temperature_2m_max[i])
    };
  });
  return JSON.stringify({
    city: loc.city || (loc.results && loc.results[0].name) || "Here", temp: r(c.temperature_2m), feels: r(c.apparent_temperature),
    humidity: c.relative_humidity_2m + "%", wind: Math.round(c.wind_speed_10m) + " km/h",
    symbol: now[0], summary: now[1], tint: now[2], headline: r(c.temperature_2m) + "  " + now[1],
    high: r(d.temperature_2m_max[0]), low: r(d.temperature_2m_min[0]), days: days
  });
}
JS