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
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()
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"]]}
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")
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
astrallibrary 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)