repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

7.4 KB · 211 lines · Go Raw History
  1package skills
  2
  3import (
  4	"context"
  5	"fmt"
  6	"strings"
  7	"time"
  8)
  9
 10// Time answers what the clock and the calendar say. It never leaves the
 11// process, so it is the cheapest skill here and the one most certain to be
 12// right, which is the opposite of how the web handles the same question: a
 13// search for the time in another city returns a page that has to be rendered
 14// and read, and a search for how many days until a date returns a countdown
 15// widget the model cannot see.
 16type Time struct{}
 17
 18func (Time) Card() Card {
 19	return Card{
 20		Name: "time",
 21		Does: "gives the current time in a named city or zone, or counts the days between today and a date.",
 22		Fires: []string{
 23			"what time is it in tokyo",
 24			"how many days until christmas",
 25			"what is the date today",
 26			"what time is it in london right now",
 27			"how many days until new year",
 28		},
 29		NotFor: []string{
 30			"what time does the game start",
 31			"why do we have time zones",
 32			"when was the declaration of independence signed",
 33			"what time does the shop close",
 34			"how long does it take to fly to tokyo",
 35		},
 36		Keywords: []string{"what time is it", "days until", "what is the date", "todays date", "what day is it"},
 37	}
 38}
 39
 40// zones are the cities worth naming. A full tz database lookup would take any
 41// string and get "what time is it in the morning" wrong, so this is a list.
 42var zones = map[string]string{
 43	"utc": "UTC", "gmt": "UTC",
 44	"london": "Europe/London", "uk": "Europe/London", "england": "Europe/London",
 45	"paris": "Europe/Paris", "berlin": "Europe/Berlin", "madrid": "Europe/Madrid",
 46	"rome": "Europe/Rome", "amsterdam": "Europe/Amsterdam", "dublin": "Europe/Dublin",
 47	"lisbon": "Europe/Lisbon", "moscow": "Europe/Moscow", "istanbul": "Europe/Istanbul",
 48	"stockholm": "Europe/Stockholm", "oslo": "Europe/Oslo", "zurich": "Europe/Zurich",
 49	"new york": "America/New_York", "nyc": "America/New_York", "boston": "America/New_York",
 50	"chicago": "America/Chicago", "denver": "America/Denver", "phoenix": "America/Phoenix",
 51	"los angeles": "America/Los_Angeles", "la": "America/Los_Angeles",
 52	"san francisco": "America/Los_Angeles", "seattle": "America/Los_Angeles",
 53	"toronto": "America/Toronto", "vancouver": "America/Vancouver",
 54	"mexico city": "America/Mexico_City", "sao paulo": "America/Sao_Paulo",
 55	"tokyo": "Asia/Tokyo", "japan": "Asia/Tokyo", "seoul": "Asia/Seoul",
 56	"beijing": "Asia/Shanghai", "shanghai": "Asia/Shanghai", "china": "Asia/Shanghai",
 57	"hong kong": "Asia/Hong_Kong", "singapore": "Asia/Singapore",
 58	"mumbai": "Asia/Kolkata", "delhi": "Asia/Kolkata", "india": "Asia/Kolkata",
 59	"dubai": "Asia/Dubai", "tel aviv": "Asia/Jerusalem",
 60	"sydney": "Australia/Sydney", "melbourne": "Australia/Melbourne",
 61	"auckland": "Pacific/Auckland", "perth": "Australia/Perth",
 62	"cairo": "Africa/Cairo", "lagos": "Africa/Lagos", "nairobi": "Africa/Nairobi",
 63	"johannesburg": "Africa/Johannesburg",
 64}
 65
 66func (Time) Run(ctx context.Context, question string, d Deps) (*Result, error) {
 67	start := d.now()
 68	l := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(question), "?"))
 69
 70	if text, ok := daysUntil(l, d); ok {
 71		return &Result{Skill: "time", Shape: "factual", Text: text,
 72			Elapsed: d.now().Sub(start).Round(time.Millisecond).String()}, nil
 73	}
 74	if text, ok := clockIn(l, d); ok {
 75		return &Result{Skill: "time", Shape: "factual", Text: text,
 76			Elapsed: d.now().Sub(start).Round(time.Millisecond).String()}, nil
 77	}
 78	if containsAny(l, "what is the date", "what's the date", "todays date", "today's date",
 79		"what day is it", "what is today", "what time is it") {
 80		now := d.now()
 81		text := fmt.Sprintf("**%s**\n\n- **%s** locally\n- **%s** UTC",
 82			now.Format("Monday, 2 January 2006"),
 83			now.Format("3:04 PM MST"), now.UTC().Format("15:04"))
 84		return &Result{Skill: "time", Shape: "factual", Text: text,
 85			Elapsed: d.now().Sub(start).Round(time.Millisecond).String()}, nil
 86	}
 87	return nil, nil
 88}
 89
 90func clockIn(l string, d Deps) (string, bool) {
 91	if !containsAny(l, "what time", "time is it", "current time", "local time") {
 92		return "", false
 93	}
 94	name, tz := matchZone(l)
 95	if tz == "" {
 96		return "", false
 97	}
 98	loc, err := time.LoadLocation(tz)
 99	if err != nil {
100		return "", false
101	}
102	now := d.now()
103	there := now.In(loc)
104	// The day difference is the thing people actually want and the thing a
105	// bare clock reading hides.
106	rel := ""
107	switch dayDiff := there.YearDay() - now.YearDay(); {
108	case there.Year() > now.Year() || dayDiff == 1:
109		rel = ", which is tomorrow"
110	case there.Year() < now.Year() || dayDiff == -1:
111		rel = ", which is yesterday"
112	}
113	text := fmt.Sprintf("**%s in %s**%s\n\n- **%s** there\n- **%s** where you are\n- **%s** UTC",
114		there.Format("3:04 PM"), title(name), rel,
115		there.Format("Monday, 2 January"), now.Format("3:04 PM MST"), now.UTC().Format("15:04"))
116	return text, true
117}
118
119func matchZone(l string) (string, string) {
120	best, bestTZ := "", ""
121	for name, tz := range zones {
122		if !strings.Contains(l, name) {
123			continue
124		}
125		// Longest name wins, so "new york" beats the "la" inside it elsewhere.
126		if len(name) > len(best) {
127			best, bestTZ = name, tz
128		}
129	}
130	return best, bestTZ
131}
132
133func daysUntil(l string, d Deps) (string, bool) {
134	if !containsAny(l, "days until", "days till", "days to ", "how long until") {
135		return "", false
136	}
137	now := d.now()
138	target, label, ok := namedDate(l, now)
139	if !ok {
140		return "", false
141	}
142	days := int(truncateDay(target).Sub(truncateDay(now)).Hours() / 24)
143	switch {
144	case days == 0:
145		return fmt.Sprintf("**Today.**\n\n- **%s** is **%s**", label, target.Format("Monday, 2 January 2006")), true
146	case days < 0:
147		return fmt.Sprintf("**%d days ago.**\n\n- **%s** was **%s**",
148			-days, label, target.Format("Monday, 2 January 2006")), true
149	}
150	weeks := ""
151	if days >= 14 {
152		weeks = fmt.Sprintf("\n- about **%d weeks**", days/7)
153	}
154	return fmt.Sprintf("**%d days.**\n\n- **%s** falls on **%s**%s",
155		days, label, target.Format("Monday, 2 January 2006"), weeks), true
156}
157
158// namedDate handles the handful of dates people count down to. Anything else
159// falls through to the web, which is right, because a made up date is worse
160// than a slow answer.
161func namedDate(l string, now time.Time) (time.Time, string, bool) {
162	y := now.Year()
163	fixed := []struct {
164		words []string
165		label string
166		m     time.Month
167		d     int
168	}{
169		{[]string{"christmas"}, "Christmas Day", time.December, 25},
170		{[]string{"christmas eve"}, "Christmas Eve", time.December, 24},
171		{[]string{"new year", "new years", "new year's"}, "New Year's Day", time.January, 1},
172		{[]string{"halloween"}, "Halloween", time.October, 31},
173		{[]string{"valentine"}, "Valentine's Day", time.February, 14},
174		{[]string{"independence day", "4th of july", "fourth of july"}, "Independence Day", time.July, 4},
175		{[]string{"new years eve", "new year's eve"}, "New Year's Eve", time.December, 31},
176	}
177	best := -1
178	for i, f := range fixed {
179		for _, w := range f.words {
180			if strings.Contains(l, w) && (best < 0 || len(w) > len(fixed[best].words[0])) {
181				best = i
182			}
183		}
184	}
185	if best < 0 {
186		return time.Time{}, "", false
187	}
188	f := fixed[best]
189	t := time.Date(y, f.m, f.d, 0, 0, 0, 0, now.Location())
190	if t.Before(truncateDay(now)) {
191		t = t.AddDate(1, 0, 0)
192	}
193	return t, f.label, true
194}
195
196func truncateDay(t time.Time) time.Time {
197	return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
198}
199
200func title(s string) string {
201	parts := strings.Fields(s)
202	for i, p := range parts {
203		if len(p) <= 3 && (p == "uk" || p == "la" || p == "nyc" || p == "utc" || p == "gmt") {
204			parts[i] = strings.ToUpper(p)
205			continue
206		}
207		parts[i] = strings.ToUpper(p[:1]) + p[1:]
208	}
209	return strings.Join(parts, " ")
210}