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.
@@ -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
+ )
@@ -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