heinzel-clock 0.9.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- heinzel_clock/__init__.py +354 -0
- heinzel_clock/demo.tape +21 -0
- heinzel_clock/views/index.js +1535 -0
- heinzel_clock/views/licenses.md +73 -0
- heinzel_clock/views/style.css +3 -0
- heinzel_clock-0.9.1.dist-info/METADATA +40 -0
- heinzel_clock-0.9.1.dist-info/RECORD +10 -0
- heinzel_clock-0.9.1.dist-info/WHEEL +4 -0
- heinzel_clock-0.9.1.dist-info/entry_points.txt +2 -0
- heinzel_clock-0.9.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""What time it is for everyone on the team.
|
|
2
|
+
|
|
3
|
+
The whole plugin: one module, its settings, one operation, a health check, a setup step, and three views of the
|
|
4
|
+
operation for the web app: a status item, a header action and a Today widget (ADR 0056). Heinzelmännchen provides the
|
|
5
|
+
CLI command, MCP tool, REST endpoint, configuration, validation, health reporting, the setup wizard and the web app.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import warnings
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated, Any, Self, cast
|
|
13
|
+
from zoneinfo import ZoneInfo, available_timezones
|
|
14
|
+
|
|
15
|
+
from heinzel import (
|
|
16
|
+
Check,
|
|
17
|
+
ConfigEditor,
|
|
18
|
+
DetailLine,
|
|
19
|
+
HeinzelError,
|
|
20
|
+
LiveClock,
|
|
21
|
+
MetricWidget,
|
|
22
|
+
Module,
|
|
23
|
+
ModuleSettings,
|
|
24
|
+
Navigate,
|
|
25
|
+
NavSettings,
|
|
26
|
+
Prompter,
|
|
27
|
+
Status,
|
|
28
|
+
StatusItem,
|
|
29
|
+
)
|
|
30
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
31
|
+
from rich.table import Table
|
|
32
|
+
|
|
33
|
+
module = Module("clock", "World clock: what time it is for everyone on the team.")
|
|
34
|
+
|
|
35
|
+
module.nav(
|
|
36
|
+
title="World clock",
|
|
37
|
+
icon="clock",
|
|
38
|
+
settings=NavSettings(sections=("clock",), group="preferences"),
|
|
39
|
+
sidebar=Navigate(path="/?panel=clock:world-clock"),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
DEFAULT_HOURS = (9, 18)
|
|
43
|
+
DEFAULT_ZONES = ("Europe/Berlin", "America/New_York", "America/Los_Angeles")
|
|
44
|
+
SATURDAY = 5
|
|
45
|
+
"""`datetime.weekday()` of Saturday: working days are Monday to Friday."""
|
|
46
|
+
MAX_MATCHES = 15
|
|
47
|
+
"""More matches than this, and setup asks for more of the name instead of listing them."""
|
|
48
|
+
EXAMPLE = "hz system config set clock.zones '[{ name = \"Europe/Berlin\", hours = [9, 18] }]'"
|
|
49
|
+
Hour = Annotated[int, Field(ge=0, le=24)]
|
|
50
|
+
"""An hour of the day; 24 ends a working day at midnight."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Zone(BaseModel):
|
|
54
|
+
"""A time zone the team works in and the working hours there; a plain name gets the default hours."""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(frozen=True)
|
|
57
|
+
|
|
58
|
+
name: str = Field(
|
|
59
|
+
title="Time zone",
|
|
60
|
+
description="The time zone, such as Europe/Berlin.",
|
|
61
|
+
json_schema_extra={"format": "iana-timezone"},
|
|
62
|
+
)
|
|
63
|
+
hours: tuple[Hour, Hour] = Field(
|
|
64
|
+
default=DEFAULT_HOURS,
|
|
65
|
+
title="Working hours",
|
|
66
|
+
description="When a working day starts and ends there.",
|
|
67
|
+
json_schema_extra={"x-unit": "hour-of-day"},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@model_validator(mode="before")
|
|
71
|
+
@classmethod
|
|
72
|
+
def _from_name(cls, data: Any) -> Any: # noqa: ANN401 - whatever the configuration holds
|
|
73
|
+
return {"name": data} if isinstance(data, str) else data
|
|
74
|
+
|
|
75
|
+
@model_validator(mode="after")
|
|
76
|
+
def _check_hours(self) -> Self:
|
|
77
|
+
start, end = self.hours
|
|
78
|
+
if not 0 <= start < end <= 24: # noqa: PLR2004 - hours of a day
|
|
79
|
+
msg = f"hours of {self.name} must be [start, end] with 0 <= start < end <= 24, not [{start}, {end}]"
|
|
80
|
+
raise ValueError(msg)
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@module.settings
|
|
85
|
+
class ClockSettings(ModuleSettings):
|
|
86
|
+
"""Where the team is, and when it works there."""
|
|
87
|
+
|
|
88
|
+
zones: list[Zone] = Field(
|
|
89
|
+
max_length=64,
|
|
90
|
+
default=[Zone(name=name) for name in DEFAULT_ZONES],
|
|
91
|
+
title="Time zones",
|
|
92
|
+
description="The time zones your team works in, with the working hours in each. The first one is primary.",
|
|
93
|
+
examples=[[{"name": "Asia/Tokyo", "hours": [10, 19]}]],
|
|
94
|
+
)
|
|
95
|
+
working_hours: tuple[int, int] | None = Field(
|
|
96
|
+
default=None,
|
|
97
|
+
description="Deprecated: the hours of every zone given by name only. Give each zone its hours instead.",
|
|
98
|
+
json_schema_extra={"deprecated": True}, # the web app's Settings leave it out; doctor reports it when set
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@model_validator(mode="before")
|
|
102
|
+
@classmethod
|
|
103
|
+
def _migrate(cls, data: Any) -> Any: # noqa: ANN401 - whatever the configuration holds
|
|
104
|
+
"""Read the old shape: one `working_hours` for every zone, and zones as names only."""
|
|
105
|
+
if not isinstance(data, dict):
|
|
106
|
+
return data
|
|
107
|
+
data = cast("dict[str, Any]", data)
|
|
108
|
+
if data.get("working_hours") is None:
|
|
109
|
+
return data
|
|
110
|
+
warnings.warn(
|
|
111
|
+
f"clock.working_hours is deprecated: give each zone its hours, with `hz setup clock` or `{EXAMPLE}`",
|
|
112
|
+
DeprecationWarning,
|
|
113
|
+
stacklevel=2,
|
|
114
|
+
)
|
|
115
|
+
hours = data["working_hours"]
|
|
116
|
+
zones = data.get("zones", DEFAULT_ZONES)
|
|
117
|
+
return {**data, "zones": [{"name": z, "hours": hours} if isinstance(z, str) else z for z in zones]}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ZoneTime(BaseModel):
|
|
121
|
+
"""The time in one zone."""
|
|
122
|
+
|
|
123
|
+
zone: str = Field(description="IANA time zone name, e.g. Europe/Berlin.")
|
|
124
|
+
time: str = Field(description="Local weekday and time there, e.g. Mon 14:05.")
|
|
125
|
+
hours: tuple[int, int] = Field(description="Start and end hour of a working day there.")
|
|
126
|
+
working: bool = Field(description="Whether it is within the working hours there, Monday to Friday.")
|
|
127
|
+
primary: bool = Field(default=False, description="Whether this is the primary zone: the first one configured.")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _render(times: list[ZoneTime]) -> Table:
|
|
131
|
+
table = Table()
|
|
132
|
+
for column in ("Zone", "Time", "Working hours", "Working"):
|
|
133
|
+
table.add_column(column)
|
|
134
|
+
for item in times:
|
|
135
|
+
zone = f"{item.zone} (Primary)" if item.primary else item.zone
|
|
136
|
+
table.add_row(zone, item.time, f"{item.hours[0]:02d}-{item.hours[1]:02d}", "yes" if item.working else "no")
|
|
137
|
+
return table
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@module.operation(render=_render, audience="everyday")
|
|
141
|
+
def clock(
|
|
142
|
+
settings: ClockSettings,
|
|
143
|
+
at: Annotated[datetime | None, Field(description="Moment to show instead of now (ISO 8601).")] = None,
|
|
144
|
+
) -> list[ZoneTime]:
|
|
145
|
+
"""Show the time in each of the team's time zones.
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
HeinzelError: If a configured zone is unknown.
|
|
149
|
+
"""
|
|
150
|
+
if unknown := _unknown(settings):
|
|
151
|
+
msg = f"Unknown time zones: {', '.join(unknown)}. Run `hz system doctor` for the fix."
|
|
152
|
+
raise HeinzelError(msg)
|
|
153
|
+
moment = at.astimezone(UTC) if at else datetime.now(UTC)
|
|
154
|
+
return [
|
|
155
|
+
ZoneTime(
|
|
156
|
+
zone=zone.name,
|
|
157
|
+
time=local.strftime("%a %H:%M"),
|
|
158
|
+
hours=zone.hours,
|
|
159
|
+
working=local.weekday() < SATURDAY and zone.hours[0] <= local.hour < zone.hours[1],
|
|
160
|
+
primary=index == 0,
|
|
161
|
+
)
|
|
162
|
+
for index, zone in enumerate(settings.zones)
|
|
163
|
+
for local in [moment.astimezone(ZoneInfo(zone.name))]
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def city(zone: str) -> str:
|
|
168
|
+
"""`America/Los_Angeles` as people say it: `Los Angeles`."""
|
|
169
|
+
return zone.rsplit("/", 1)[-1].replace("_", " ")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _work_status(zone: ZoneTime, moment: datetime) -> str:
|
|
173
|
+
"""Keep the workday countdown in this plugin, including the next weekday after Friday."""
|
|
174
|
+
local = moment.astimezone(ZoneInfo(zone.zone))
|
|
175
|
+
if local.weekday() >= SATURDAY:
|
|
176
|
+
return "Weekend"
|
|
177
|
+
current = local.hour * 60 + local.minute
|
|
178
|
+
start, end = (hour * 60 for hour in zone.hours)
|
|
179
|
+
if current < start:
|
|
180
|
+
label, minutes = "Starts", start - current
|
|
181
|
+
elif current < end:
|
|
182
|
+
label, minutes = "Ends", end - current
|
|
183
|
+
else:
|
|
184
|
+
days = 1
|
|
185
|
+
while (local.weekday() + days) % 7 >= SATURDAY:
|
|
186
|
+
days += 1
|
|
187
|
+
label, minutes = "Starts", days * 24 * 60 + start - current
|
|
188
|
+
hours, minutes = divmod(minutes, 60)
|
|
189
|
+
duration = f"{hours}h {minutes}m" if hours else f"{minutes}m"
|
|
190
|
+
return f"{label} in {duration}"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _detail(zone: ZoneTime) -> DetailLine:
|
|
194
|
+
return DetailLine(
|
|
195
|
+
text=city(zone.zone),
|
|
196
|
+
live=LiveClock(zone=zone.zone),
|
|
197
|
+
secondary=_work_status(zone, datetime.now(UTC)),
|
|
198
|
+
tone="ok" if zone.working else "neutral",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@module.status(source="clock", name="primary-zone", order=900, slots={"footer": True})
|
|
203
|
+
def primary_zone(times: list[ZoneTime]) -> StatusItem | None:
|
|
204
|
+
"""The primary zone with every configured zone in its live detail."""
|
|
205
|
+
if not times:
|
|
206
|
+
return None
|
|
207
|
+
first = times[0]
|
|
208
|
+
return StatusItem(
|
|
209
|
+
text=city(first.zone),
|
|
210
|
+
icon="globe",
|
|
211
|
+
live=LiveClock(zone=first.zone),
|
|
212
|
+
presentation="value",
|
|
213
|
+
detail=[_detail(zone) for zone in times],
|
|
214
|
+
effect=Navigate(path="/?panel=clock:world-clock"),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@module.status(source="clock", name="zones-ticker", order=900, slots={"sidebar": True})
|
|
219
|
+
def zones_ticker(times: list[ZoneTime]) -> StatusItem | None:
|
|
220
|
+
"""All zones on a continuous live line with an accessible static alternative."""
|
|
221
|
+
if not times:
|
|
222
|
+
return None
|
|
223
|
+
return StatusItem(
|
|
224
|
+
text="World clock",
|
|
225
|
+
icon="globe",
|
|
226
|
+
presentation="ticker",
|
|
227
|
+
segments=[_detail(zone) for zone in times],
|
|
228
|
+
effect=Navigate(path="/?panel=clock:world-clock"),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def working(times: list[ZoneTime]) -> int:
|
|
233
|
+
"""How many of the team's zones are within working hours now."""
|
|
234
|
+
return sum(zone.working for zone in times)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
module.action(
|
|
238
|
+
"team-clock",
|
|
239
|
+
label="World clock",
|
|
240
|
+
icon="globe",
|
|
241
|
+
effect=Navigate(path="/?panel=clock:world-clock"),
|
|
242
|
+
source="clock",
|
|
243
|
+
badge=working,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@module.widget(source="clock")
|
|
248
|
+
def at_work(times: list[ZoneTime]) -> MetricWidget | None:
|
|
249
|
+
"""How many of the team's zones are at work now, out of all of them."""
|
|
250
|
+
if not times:
|
|
251
|
+
return None
|
|
252
|
+
count = working(times)
|
|
253
|
+
return MetricWidget(label="Zones at work now", value=f"{count}/{len(times)}", tone="ok" if count else "neutral")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@module.health
|
|
257
|
+
def zones(settings: ClockSettings) -> list[Check]:
|
|
258
|
+
"""Every configured zone must be a known IANA time zone; the old settings shape is deprecated."""
|
|
259
|
+
checks: list[Check] = []
|
|
260
|
+
if unknown := _unknown(settings):
|
|
261
|
+
checks.append(Check(name="zones", status=Status.DOWN, detail=f"unknown: {', '.join(unknown)}", fix=EXAMPLE))
|
|
262
|
+
else:
|
|
263
|
+
checks.append(Check(name="zones", status=Status.UP, detail=", ".join(map(_describe, settings.zones))))
|
|
264
|
+
if settings.working_hours is not None:
|
|
265
|
+
checks.append(
|
|
266
|
+
Check(
|
|
267
|
+
name="working_hours",
|
|
268
|
+
status=Status.DEGRADED,
|
|
269
|
+
detail="clock.working_hours is deprecated; each zone has its own hours now",
|
|
270
|
+
fix="Run `hz setup clock`, or give clock.zones hours and `hz system config unset clock.working_hours`",
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
return checks
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@module.setup
|
|
277
|
+
def setup(settings: ClockSettings, prompter: Prompter, editor: ConfigEditor) -> None:
|
|
278
|
+
"""Pick the team's time zones and the working hours in each."""
|
|
279
|
+
zones = list(settings.zones)
|
|
280
|
+
if zones:
|
|
281
|
+
names = [zone.name for zone in zones]
|
|
282
|
+
keep = prompter.choices("Time zones to keep", names, default=names)
|
|
283
|
+
zones = [zone for zone in zones if zone.name in keep]
|
|
284
|
+
zones += _add_zones(prompter, [zone.name for zone in zones])
|
|
285
|
+
chosen = [_hours(prompter, zone) for zone in zones]
|
|
286
|
+
prompter.say("The team's time zones:")
|
|
287
|
+
for zone in chosen:
|
|
288
|
+
prompter.say(f" {_describe(zone)}")
|
|
289
|
+
if not prompter.confirm("Save these time zones?"):
|
|
290
|
+
prompter.say("Nothing was saved.")
|
|
291
|
+
return
|
|
292
|
+
path = editor.write("clock.zones", [zone.model_dump(mode="json") for zone in chosen])
|
|
293
|
+
prompter.say(f"Saved to {path}.")
|
|
294
|
+
if settings.working_hours is not None:
|
|
295
|
+
# The zones now carry their hours. It may be set where setup doesn't write (a project file, HZ_* variables).
|
|
296
|
+
with suppress(HeinzelError):
|
|
297
|
+
editor.unset("clock.working_hours")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def search(query: str) -> list[str]:
|
|
301
|
+
"""IANA time zone names containing `query`, ignoring case; spaces match underscores (`new york`)."""
|
|
302
|
+
needle = query.strip().lower().replace(" ", "_")
|
|
303
|
+
return sorted(name for name in available_timezones() if needle in name.lower())
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _add_zones(prompter: Prompter, taken: list[str]) -> list[Zone]:
|
|
307
|
+
added: list[Zone] = []
|
|
308
|
+
while True:
|
|
309
|
+
query = prompter.text("Add a time zone: part of a city or region, empty when done", default="")
|
|
310
|
+
if not query:
|
|
311
|
+
if taken or added:
|
|
312
|
+
return added
|
|
313
|
+
prompter.say("The team needs at least one time zone.")
|
|
314
|
+
continue
|
|
315
|
+
matches = search(query)
|
|
316
|
+
if not matches:
|
|
317
|
+
prompter.say(f"No time zone matches {query!r}; try a city such as Tokyo or a region such as America.")
|
|
318
|
+
continue
|
|
319
|
+
if len(matches) > MAX_MATCHES:
|
|
320
|
+
prompter.say(f"{len(matches)} time zones match {query!r}; type more of the name.")
|
|
321
|
+
continue
|
|
322
|
+
name = matches[0] if len(matches) == 1 else prompter.choice("Which one?", matches)
|
|
323
|
+
if name in taken or name in {zone.name for zone in added}:
|
|
324
|
+
prompter.say(f"{name} is already in the list.")
|
|
325
|
+
continue
|
|
326
|
+
added.append(Zone(name=name))
|
|
327
|
+
prompter.say(f"Added {name}.")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _hours(prompter: Prompter, zone: Zone) -> Zone:
|
|
331
|
+
start = prompter.number(f"{zone.name}: working day starts at", default=zone.hours[0], minimum=0, maximum=23)
|
|
332
|
+
end = prompter.number(
|
|
333
|
+
f"{zone.name}: working day ends at", default=max(zone.hours[1], start + 1), minimum=start + 1, maximum=24
|
|
334
|
+
)
|
|
335
|
+
return Zone(name=zone.name, hours=(start, end))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _describe(zone: Zone) -> str:
|
|
339
|
+
return f"{zone.name} {zone.hours[0]:02d}-{zone.hours[1]:02d}"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _unknown(settings: ClockSettings) -> list[str]:
|
|
343
|
+
return [zone.name for zone in settings.zones if zone.name not in available_timezones()]
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
module.view(
|
|
347
|
+
Path(__file__).parent / "views",
|
|
348
|
+
abi=1,
|
|
349
|
+
name="world-clock",
|
|
350
|
+
source="clock",
|
|
351
|
+
title="World clock",
|
|
352
|
+
fallback="World clock is unavailable; run hz clock for the text view.",
|
|
353
|
+
refresh=60,
|
|
354
|
+
)
|
heinzel_clock/demo.tape
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# The recorder already installed the plugins: show the inventory, the clocks, then the same as JSON into jq.
|
|
2
|
+
Require jq
|
|
3
|
+
|
|
4
|
+
Type "hz system plugin list"
|
|
5
|
+
Enter
|
|
6
|
+
Wait@60s
|
|
7
|
+
Sleep 1.5s
|
|
8
|
+
|
|
9
|
+
Type "hz clock"
|
|
10
|
+
Enter
|
|
11
|
+
Wait
|
|
12
|
+
Sleep 2.5s
|
|
13
|
+
|
|
14
|
+
Hide
|
|
15
|
+
Type "clear"
|
|
16
|
+
Enter
|
|
17
|
+
Show
|
|
18
|
+
Type "hz clock --json | jq"
|
|
19
|
+
Enter
|
|
20
|
+
Wait
|
|
21
|
+
Sleep 3s
|