DEV Community

Ibnu Syifa Takbir Adha
Ibnu Syifa Takbir Adha

Posted on

GrassTimer: a local Gemma nudge that finds your free gap and gets you outside before sunset

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass

What I Built

GrassTimer is a small command-line tool with one job: make the screen the shortest part of your day. You give it your busy blocks for today and your location. It works out the free gaps between your commitments, calculates today's sunset, picks the longest gap that ends before dark, and then asks a local open-weight model to write a short, friendly nudge to go outside.

The split is deliberate. Code owns the facts (free time, sunset, durations). The model only writes the words. If the model invents a time that is not the start of the window or the sunset, the tool throws its text away and prints a plain, correct fallback instead. A nudge that sends you out at 19:15 when the sun set at 18:03 is worse than no nudge.

Honest note: I have not taken this outside and used it for a week, so I won't pretend to report how it went. It is a first version, tested on logic rather than in the field.

Demo

ollama pull gemma3
python3 grasstimer.py day.json
Enter fullscreen mode Exit fullscreen mode

With the example day (busy 09:00-12:00 and 13:00-16:30, Bangkok, 7 Oct 2026), the tool finds three windows: 07:00-09:00, 12:00-13:00 and 16:30-18:03. It picks the longest, 16:30 to sunset at 18:03, and hands that to the model. I verified the planning logic with tests (below). I have not run it against a real Gemma instance, so the quality of the generated wording is untested.

Code

Stdlib-only Python, one file plus a test.

grasstimer.py:

#!/usr/bin/env python3
"""GrassTimer: finds the gaps in your day and nudges you outside before sunset.

Code does the facts (free gaps from an .ics-style list, sunset math).
A local open-weight model (Gemma via Ollama) only writes the nudge.
Stdlib only.
"""
import json, math, sys, urllib.request
from datetime import date, datetime, timedelta, timezone

OLLAMA, MODEL = "http://localhost:11434/api/generate", "gemma3"

def sunset(d, lat, lon, tz_hours):
    """Sunset as local datetime (NOAA-style approximation, ~1-2 min accuracy)."""
    n = d.timetuple().tm_yday
    g = 2 * math.pi / 365 * (n - 1)
    eqtime = 229.18 * (0.000075 + 0.001868*math.cos(g) - 0.032077*math.sin(g)
                       - 0.014615*math.cos(2*g) - 0.040849*math.sin(2*g))
    decl = (0.006918 - 0.399912*math.cos(g) + 0.070257*math.sin(g)
            - 0.006758*math.cos(2*g) + 0.000907*math.sin(2*g)
            - 0.002697*math.cos(3*g) + 0.00148*math.sin(3*g))
    la = math.radians(lat)
    ha = math.degrees(math.acos(math.cos(math.radians(90.833)) / (math.cos(la)*math.cos(decl))
                                - math.tan(la)*math.tan(decl)))
    minutes_utc = 720 - 4*(lon - ha) - eqtime
    base = datetime(d.year, d.month, d.day)
    return base + timedelta(minutes=minutes_utc + tz_hours*60)

def free_gaps(busy, day_start, day_end, min_minutes):
    """busy: list of (start, end) datetimes. Returns gaps >= min_minutes inside [day_start, day_end]."""
    gaps, cursor = [], day_start
    for s, e in sorted(busy):
        if s - cursor >= timedelta(minutes=min_minutes) and s <= day_end:
            gaps.append((cursor, min(s, day_end)))
        cursor = max(cursor, e)
    if day_end - cursor >= timedelta(minutes=min_minutes):
        gaps.append((cursor, day_end))
    return gaps

def pick_gap(gaps, min_minutes):
    return max(gaps, key=lambda g: g[1] - g[0]) if gaps else None

def ask_model(prompt):
    body = json.dumps({"model": MODEL, "prompt": prompt, "stream": False}).encode()
    req = urllib.request.Request(OLLAMA, body, {"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=300) as r:
        return json.loads(r.read())["response"].strip()

def nudge(gap, sunset_dt, ask=ask_model):
    s, e = gap
    mins = int((e - s).total_seconds() // 60)
    prompt = (f"Write a friendly 2-sentence nudge (no emojis) telling someone to go outside "
              f"from {s:%H:%M} for {mins} minutes; sunset is at {sunset_dt:%H:%M}. "
              f"Suggest one simple outdoor activity. Do not mention any other times.")
    text = ask(prompt)
    allowed = {f"{s:%H:%M}", f"{sunset_dt:%H:%M}"}
    import re
    if any(t not in allowed for t in re.findall(r"\b\d{1,2}:\d{2}\b", text)):
        text = f"Go outside at {s:%H:%M} for {mins} minutes. Sunset is at {sunset_dt:%H:%M}."
    return text

def main():
    cfg = json.load(open(sys.argv[1] if len(sys.argv) > 1 else "day.json"))
    d = date.fromisoformat(cfg["date"])
    ss = sunset(d, cfg["lat"], cfg["lon"], cfg["tz"])
    P = lambda t: datetime.fromisoformat(f"{cfg['date']}T{t}")
    busy = [(P(a), P(b)) for a, b in cfg["busy"]]
    gap = pick_gap(free_gaps(busy, P(cfg["wake"]), ss, cfg["min_minutes"]), cfg["min_minutes"])
    print(nudge(gap, ss) if gap else "No outdoor window before sunset today. Try tomorrow.")

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

day.json:

{"date":"2026-10-07","lat":13.75,"lon":100.5,"tz":7,"wake":"07:00","min_minutes":20,
 "busy":[["09:00","12:00"],["13:00","16:30"]]}
Enter fullscreen mode Exit fullscreen mode

test_grasstimer.py (passes; uses a fake model):

import json
from datetime import date, datetime as dt
import grasstimer as g
# Reference: astral library gives 18:03:36 for Bangkok on 7 Oct 2026 (+/- 4 min tolerance)
ss = g.sunset(date(2026,10,7), 13.75, 100.5, 7)
assert abs((ss - dt(2026,10,7,18,3,36)).total_seconds()) <= 240, ss
busy = [(dt(2026,10,7,9),dt(2026,10,7,12)), (dt(2026,10,7,13),dt(2026,10,7,16,30))]
gaps = g.free_gaps(busy, dt(2026,10,7,7), ss, 20)
assert gaps[0] == (dt(2026,10,7,7), dt(2026,10,7,9))          # morning
assert gaps[-1][0] == dt(2026,10,7,16,30)                       # evening, ends at sunset
assert all((e-s).total_seconds() >= 1200 for s,e in gaps)       # 12-13 gap is 60 min -> kept
assert g.pick_gap([], 20) is None
gap = (dt(2026,10,7,16,30), ss)
# model hallucinates a time -> falls back to deterministic text
bad = g.nudge(gap, ss, ask=lambda p: "Walk at 19:15 to the park!")
assert "19:15" not in bad and "16:30" in bad
good = g.nudge(gap, ss, ask=lambda p: "Head out at 16:30 and stroll the park before sunset at 18:03.")
assert "stroll" in good or "18:0" in good
print("all tests passed")
Enter fullscreen mode Exit fullscreen mode

How I Built It

  • Open-weight model: Gemma, served locally by Ollama over its HTTP API. It writes two sentences; that is all it does.
  • Deterministic core: a NOAA-style sunset approximation and an interval-gap finder, both plain Python. I checked the sunset function against the independent astral library for Bangkok on 7 October 2026 (18:03:36 vs my 18:03:59), and my first draft of the test used a wrong hand-remembered value (17:49), which the check caught.
  • Guardrail on the model: any clock time in the output that is not the window start or the sunset triggers the fallback sentence.
  • Zero dependencies, so it works on any laptop.

Known limits: sunset is an approximation (a couple of minutes), the busy list is typed by hand rather than read from a real calendar, and it does not check weather. Those are the obvious next steps.

Why Does Open Innovation Matter?

Your calendar says when you are busy, where you live and how you spend your day. That is not something I want to send to a server I don't control just to be told to go for a walk. A local open-weight model keeps the whole schedule on the laptop, works with no internet (handy right before you head out to somewhere with no signal), and costs nothing per nudge. Because the model is swappable by changing one string, a small model is good enough here, and the surrounding code keeps its mistakes harmless.

My Agent Session

Not included.

Prize Categories

Best Use of Gemma: Gemma, served locally via Ollama, writes the nudge at the core of the project.

Top comments (0)