orchard
mirrorEvery 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
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}