diff --git a/bin/omarchy b/bin/omarchy index 4219109be68..f8667be71fe 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -32,6 +32,7 @@ GROUP_DESCRIPTIONS[audio]="Audio input and output controls" GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings" GROUP_DESCRIPTIONS[battery]="Battery status helpers" GROUP_DESCRIPTIONS[bluetooth]="Bluetooth device controls" +GROUP_DESCRIPTIONS[calendar]="Calendar and planning" GROUP_DESCRIPTIONS[branch]="Omarchy git branch management" GROUP_DESCRIPTIONS[branding]="About and screensaver branding" GROUP_DESCRIPTIONS[brightness]="Display and keyboard brightness" diff --git a/bin/omarchy-calendar b/bin/omarchy-calendar new file mode 100755 index 00000000000..7dd66250729 --- /dev/null +++ b/bin/omarchy-calendar @@ -0,0 +1,493 @@ +#!/usr/bin/python3 + +# omarchy:summary=Manage local calendar events and planning +# omarchy:group=calendar +# omarchy:args=[--json] [options] +# omarchy:examples=omarchy calendar add-event --title "Dentist" --start 2026-09-07T10:00:00+02:00 --end 2026-09-07T11:00:00+02:00 + +"""User-facing calendar client for the native Omarchy calendar service.""" + +import argparse +import json +import os +import re +import subprocess +import sys +import time + + +class CalendarError(Exception): + pass + + +def call(target, method, *arguments, allow_empty=False): + try: + result = subprocess.run( + ["omarchy-shell", target, method, *arguments], + check=False, + capture_output=True, + text=True, + ) + except OSError as error: + raise CalendarError(f"could not contact the Omarchy shell: {error}") from error + + if result.returncode != 0: + message = (result.stderr or result.stdout).strip() + if target == "omarchy.calendar": + message = message or "the calendar service is not loaded" + raise CalendarError(message) + + output = result.stdout.strip() + if not output and not allow_empty: + raise CalendarError("the Omarchy shell returned an empty response") + return output + + +def call_json(target, method, *arguments): + output = call(target, method, *arguments) + try: + response = json.loads(output) + except json.JSONDecodeError as error: + raise CalendarError(f"the Omarchy shell returned invalid JSON: {output}") from error + if not isinstance(response, dict): + raise CalendarError("the Omarchy shell returned an invalid calendar response") + if response.get("ok") is False: + raise CalendarError(response.get("error") or "calendar operation failed") + return response + + +def status(): + return call_json("omarchy.calendar", "status") + + +def loaded_status(timeout_seconds=10): + deadline = time.monotonic() + timeout_seconds + value = status() + while not value.get("loaded") and time.monotonic() < deadline: + time.sleep(0.1) + value = status() + if not value.get("loaded"): + raise CalendarError("the calendar service is still loading") + return value + + +def state(): + return loaded_status()["state"] + + +def mutate(method, *arguments): + loaded_status() + return call_json("omarchy.calendar", method, *arguments)["state"] + + +def json_argument(value): + return json.dumps(value, separators=(",", ":")) + + +def emit(value, json_output): + if json_output: + print(json.dumps(value, indent=2, ensure_ascii=False)) + + +def human_events(events): + for event in sorted(events, key=lambda item: (item.get("startAt", ""), item.get("id", ""))): + origin = event.get("origin", "manual") + print(f"{event['id']} {event['startAt']} — {event['endAt']} {event['title']} [{origin}]") + if event.get("description"): + print(f" {event['description']}") + + +def human_tasks(tasks): + order = {"high": 0, "normal": 1, "low": 2} + rows = sorted(tasks, key=lambda item: (order.get(item.get("priority"), 9), item.get("title", ""), item.get("id", ""))) + for task in rows: + state_label = task.get("state", "inbox") + details = f"{task['durationMinutes']}m, {task['priority']}, {task['cognitiveLoad']}" + if task.get("deadlineAt"): + details += f", {task['deadlineKind']} deadline {task['deadlineAt']}" + print(f"{task['id']} {task['title']} [{state_label}; {details}]") + + +def human_status(value): + current = value.get("state", {}) + proposal = current.get("proposal") or {} + proposal_status = proposal.get("status", "none") + print(f"Calendar service: {'ready' if value.get('loaded') else 'loading'}") + print(f"Planning configuration: {'ready' if value.get('configured') else 'needed'}") + print(f"Planning: {value.get('solveState', 'unknown')}") + print(f"Events: {len(current.get('events', []))}") + print(f"Tasks: {len(current.get('tasks', []))} ({sum(task.get('state') == 'inbox' for task in current.get('tasks', []))} in inbox)") + print(f"Suggested schedule: {proposal_status}") + if value.get("error"): + print(f"Error: {value['error']}", file=sys.stderr) + + +def human_proposal(proposal): + if not proposal: + print("No suggested schedule is available.") + return + print(f"Suggested schedule ({proposal.get('status', 'unknown')})") + print(f"Horizon: {proposal.get('horizonStart', '')} for {proposal.get('horizonDays', '')} days ({proposal.get('timezone', '')})") + for item in proposal.get("items", []): + outcome = item.get("diagnostics", {}).get("outcome", "unknown") + if item.get("scheduled"): + when = f"{item.get('startAt', '')} — {item.get('endAt', '')}" + else: + when = "unscheduled" + explanation = item.get("explanation") or outcome + print(f"{item.get('taskId', '')} {when} {explanation}") + + +def add_common_event_arguments(parser, required=True): + parser.add_argument("--title", required=required) + parser.add_argument("--start", dest="startAt", required=required) + parser.add_argument("--end", dest="endAt", required=required) + parser.add_argument("--timezone") + parser.add_argument("--description", default=None) + parser.add_argument("--rrule", default=None) + parser.add_argument("--id") + parser.add_argument("--all-day", action="store_true") + + +def add_common_task_arguments(parser, required=True): + parser.add_argument("--title", required=required) + parser.add_argument("--duration", dest="durationMinutes", type=int, required=required) + parser.add_argument("--priority", choices=("low", "normal", "high")) + parser.add_argument("--cognitive-load", choices=("low", "medium", "high"), dest="cognitiveLoad") + parser.add_argument("--earliest", dest="earliestAt") + parser.add_argument("--deadline-kind", choices=("none", "hard", "soft"), dest="deadlineKind") + parser.add_argument("--deadline-at", dest="deadlineAt") + parser.add_argument("--id") + + +def add_settings_arguments(parser): + parser.add_argument("--timezone") + parser.add_argument("--availability", action="append", metavar="DAY=START-END") + parser.add_argument("--clear-availability", action="store_true") + parser.add_argument("--horizon-days", type=int) + parser.add_argument("--slot-minutes", type=int) + parser.add_argument("--solve-seconds", type=int) + parser.add_argument("--priority-low-weight", type=int) + parser.add_argument("--priority-normal-weight", type=int) + parser.add_argument("--priority-high-weight", type=int) + parser.add_argument("--cognitive-enabled", action="store_true", default=None) + parser.add_argument("--no-cognitive", action="store_false", dest="cognitive_enabled") + for load in ("low", "medium", "high"): + parser.add_argument(f"--{load}-window-start") + parser.add_argument(f"--{load}-window-end") + parser.add_argument(f"--{load}-outside-penalty", type=int) + parser.add_argument("--high-streak-limit", type=int) + parser.add_argument("--recovery-minutes", type=int) + parser.add_argument("--excess-high-penalty", type=int) + + +def settings_patch(arguments, current): + patch = {} + mapping = { + "timezone": "timezone", + "horizon_days": "horizonDays", + "slot_minutes": "slotMinutes", + "solve_seconds": "solveSeconds", + "priority_low_weight": "priorityLowWeight", + "priority_normal_weight": "priorityNormalWeight", + "priority_high_weight": "priorityHighWeight", + "cognitive_enabled": "cognitiveEnabled", + "high_streak_limit": "highStreakLimit", + "recovery_minutes": "recoveryMinutes", + "excess_high_penalty": "excessHighPenalty", + } + for load in ("low", "medium", "high"): + mapping[f"{load}_window_start"] = f"{load}WindowStart" + mapping[f"{load}_window_end"] = f"{load}WindowEnd" + mapping[f"{load}_outside_penalty"] = f"{load}OutsidePenalty" + for source, target in mapping.items(): + value = getattr(arguments, source, None) + if value is not None: + patch[target] = value + + if arguments.clear_availability: + patch["availability"] = {} + elif arguments.availability: + availability = dict(current.get("availability", {})) + for specification in arguments.availability: + match = re.fullmatch(r"([a-z]+)=(\d{1,2}:\d{1,2})-(\d{1,2}:\d{1,2})", specification.strip()) + if not match: + raise CalendarError(f"invalid availability; use DAY=START-END: {specification}") + day, start, end = match.groups() + availability[day] = [{"start": start, "end": end}] + patch["availability"] = availability + return patch + + +def event_input(arguments, current): + value = { + "title": arguments.title, + "startAt": arguments.startAt, + "endAt": arguments.endAt, + "allDay": bool(arguments.all_day), + } + timezone = arguments.timezone or current.get("settings", {}).get("timezone") + if not timezone: + raise CalendarError("--timezone is required until planner settings has a timezone") + value["timezone"] = timezone + for name in ("description", "rrule", "id"): + argument = getattr(arguments, name, None) + if argument is not None: + value[name] = argument + return value + + +def event_patch(arguments): + value = {} + for argument, field in (("title", "title"), ("startAt", "startAt"), ("endAt", "endAt"), ("timezone", "timezone"), ("description", "description"), ("rrule", "rrule")): + data = getattr(arguments, argument, None) + if data is not None: + value[field] = data + if arguments.no_rrule: + value["rrule"] = None + if arguments.all_day: + value["allDay"] = True + if arguments.not_all_day: + value["allDay"] = False + if not value: + raise CalendarError("edit-event needs at least one field") + return value + + +def task_input(arguments): + value = { + "title": arguments.title, + "durationMinutes": arguments.durationMinutes, + "priority": arguments.priority or "normal", + "cognitiveLoad": arguments.cognitiveLoad or "medium", + "deadlineKind": arguments.deadlineKind or "none", + "earliestAt": arguments.earliestAt, + "deadlineAt": arguments.deadlineAt, + } + if arguments.id is not None: + value["id"] = arguments.id + if value["deadlineKind"] == "none": + value["deadlineAt"] = None + elif not value["deadlineAt"]: + raise CalendarError("--deadline-at is required when --deadline-kind is hard or soft") + return value + + +def task_patch(arguments): + value = {} + for argument, field in (("title", "title"), ("durationMinutes", "durationMinutes"), ("priority", "priority"), ("cognitiveLoad", "cognitiveLoad"), ("earliestAt", "earliestAt"), ("deadlineKind", "deadlineKind"), ("deadlineAt", "deadlineAt")): + data = getattr(arguments, argument, None) + if data is not None: + value[field] = data + if value.get("deadlineKind") == "none": + value["deadlineAt"] = None + if not value: + raise CalendarError("edit-task needs at least one field") + return value + + +def wait_for_proposal(timeout_seconds): + deadline = time.monotonic() + timeout_seconds + first = status() + if not first.get("loaded"): + while time.monotonic() < deadline: + time.sleep(0.1) + first = status() + if first.get("loaded"): + break + if not first.get("configured"): + raise CalendarError("planning is not configured; use `omarchy calendar settings`") + if not any(task.get("state") == "inbox" for task in first.get("state", {}).get("tasks", [])): + raise CalendarError("there are no planning tasks in the inbox") + + call_json("omarchy.calendar", "plan") + while time.monotonic() < deadline: + current = status() + current_state = current.get("state", {}) + proposal = current_state.get("proposal") + if ( + proposal + and proposal.get("status") == "ready" + and proposal.get("baseInputRevision") == current_state.get("inputRevision") + ): + return proposal + if current.get("solveState") == "error": + detail = current.get("error") or current.get("errorOutput") or "the planner failed" + raise CalendarError(detail) + time.sleep(0.1) + raise CalendarError("timed out waiting for the schedule proposal") + + +def build_parser(): + parser = argparse.ArgumentParser(prog="omarchy calendar", description="Manage Omarchy's local calendar and planner.") + commands = parser.add_subparsers(dest="command", required=True) + + commands.add_parser("status", help="show service, configuration, and proposal status") + commands.add_parser("events", aliases=("event",), help="list calendar events") + commands.add_parser("tasks", aliases=("task",), help="list planner tasks") + commands.add_parser("proposal", help="show the latest schedule proposal") + commands.add_parser("plan", help="generate and show a suggested schedule") + commands.add_parser("apply", help="apply the current suggested schedule to the calendar") + + add_event = commands.add_parser("add-event", help="add a manual busy event") + add_common_event_arguments(add_event) + edit_event = commands.add_parser("edit-event", help="edit a manual event") + edit_event.add_argument("id") + add_common_event_arguments(edit_event, required=False) + edit_event.add_argument("--no-rrule", action="store_true") + edit_event.add_argument("--not-all-day", action="store_true") + delete_event = commands.add_parser("delete-event", help="delete a manual event") + delete_event.add_argument("id") + + add_task = commands.add_parser("add-task", help="add a task to the planner inbox") + add_common_task_arguments(add_task) + edit_task = commands.add_parser("edit-task", help="edit an inbox task") + edit_task.add_argument("id") + add_common_task_arguments(edit_task, required=False) + delete_task = commands.add_parser("delete-task", help="delete an inbox task") + delete_task.add_argument("id") + + dependency = commands.add_parser("dependency", aliases=("dependencies",), help="manage task dependencies") + dependency_commands = dependency.add_subparsers(dest="dependency_command", required=True) + dependency_add = dependency_commands.add_parser("add", help="make the first task precede the second") + dependency_add.add_argument("from_task") + dependency_add.add_argument("to_task") + dependency_remove = dependency_commands.add_parser("remove", help="remove a dependency") + dependency_remove.add_argument("from_task") + dependency_remove.add_argument("to_task") + + settings = commands.add_parser("settings", help="show or update planner settings") + add_settings_arguments(settings) + return_inbox = commands.add_parser("return-inbox", help="return an applied task and remove its linked planner event") + return_inbox.add_argument("task_id") + open_view = commands.add_parser("open", help="open the calendar popup in a chosen view") + open_view.add_argument("view", choices=("calendar", "agenda", "plan"), nargs="?", default="calendar") + return parser + + +def main(argv): + json_output = "--json" in argv + parser = build_parser() + arguments = parser.parse_args([value for value in argv if value != "--json"]) + + try: + command = arguments.command + if command == "status": + value = status() + if json_output: + emit(value, True) + else: + human_status(value) + return 0 + if command in ("events", "event"): + values = state().get("events", []) + emit(values, json_output) + if not json_output: + human_events(values) + return 0 + if command in ("tasks", "task"): + values = state().get("tasks", []) + emit(values, json_output) + if not json_output: + human_tasks(values) + return 0 + if command == "proposal": + value = state().get("proposal") + emit(value, json_output) + if not json_output: + human_proposal(value) + return 0 + if command == "plan": + value = wait_for_proposal(180) + emit(value, json_output) + if not json_output: + human_proposal(value) + return 0 + if command == "apply": + value = mutate("apply") + emit(value, json_output) + if not json_output: + print("Proposal applied.") + return 0 + if command == "add-event": + value = mutate("addEvent", json_argument(event_input(arguments, state()))) + emit(value, json_output) + if not json_output: + print(f"Added event {value['events'][-1]['id']}.") + return 0 + if command == "edit-event": + value = mutate("updateEvent", arguments.id, json_argument(event_patch(arguments))) + emit(value, json_output) + if not json_output: + print(f"Updated event {arguments.id}.") + return 0 + if command == "delete-event": + value = mutate("deleteEvent", arguments.id) + emit(value, json_output) + if not json_output: + print(f"Deleted event {arguments.id}.") + return 0 + if command == "add-task": + value = mutate("addTask", json_argument(task_input(arguments))) + emit(value, json_output) + if not json_output: + print(f"Added task {value['tasks'][-1]['id']}.") + return 0 + if command == "edit-task": + value = mutate("updateTask", arguments.id, json_argument(task_patch(arguments))) + emit(value, json_output) + if not json_output: + print(f"Updated task {arguments.id}.") + return 0 + if command == "delete-task": + value = mutate("deleteTask", arguments.id) + emit(value, json_output) + if not json_output: + print(f"Deleted task {arguments.id}.") + return 0 + if command in ("dependency", "dependencies"): + if arguments.dependency_command == "add": + value = mutate("addDependency", arguments.from_task, arguments.to_task) + else: + value = mutate("deleteDependency", arguments.from_task, arguments.to_task) + emit(value, json_output) + if not json_output: + print("Dependency updated.") + return 0 + if command == "settings": + current = state().get("settings", {}) + patch = settings_patch(arguments, current) + if not patch: + emit(current, json_output) + if not json_output: + print(json.dumps(current, indent=2, ensure_ascii=False)) + else: + value = mutate("setSettings", json_argument(patch)) + emit(value.get("settings", {}), json_output) + if not json_output: + print("Planner settings updated.") + return 0 + if command == "return-inbox": + value = mutate("returnToInbox", arguments.task_id) + emit(value, json_output) + if not json_output: + print(f"Returned task {arguments.task_id} to the inbox.") + return 0 + if command == "open": + call("omarchy.clock", "openView", arguments.view, allow_empty=True) + if json_output: + print(json.dumps({"ok": True, "view": arguments.view})) + else: + print(f"Opened calendar {arguments.view} view.") + return 0 + except CalendarError as error: + print(f"omarchy calendar: {error}", file=sys.stderr) + return 2 + except (KeyError, TypeError, ValueError) as error: + print(f"omarchy calendar: invalid service response: {error}", file=sys.stderr) + return 2 + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index c5013ba6080..9e90a066246 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -94,6 +94,7 @@ obsidian omacalc omacut omawrite +omarchy-calendar-solver omarchy-nvim pacman-contrib pamixer diff --git a/manual/05-the-top-bar.md b/manual/05-the-top-bar.md index 09a028986f4..a4769454a01 100644 --- a/manual/05-the-top-bar.md +++ b/manual/05-the-top-bar.md @@ -54,10 +54,19 @@ The panels aren't read-outs. They're where you actually do the thing: - **Bluetooth** lists your devices with connect/disconnect and battery levels. - **Power** shows battery stats, switches power profiles (it remembers a separate choice for battery and AC), and prints some system info. - **Display** carries a brightness slider, text size, monitor scaling presets, and — when you have more than one screen — per-monitor controls. See [monitors](33-monitors.md) for the deeper story. -- **Clock** opens a month grid with ISO week numbers and month stepping. +- **Clock** opens a month grid with ISO week numbers and month stepping. The same popup also has **Agenda** for manual and applied events and **Plan** for an inbox of tasks and schedule suggestions. + +The planner is local to Omarchy. Open **Plan → Settings** once to choose a timezone and at least one weekly availability window. In **Plan**, add a planning task, choose **Plan tasks**, then choose **Review schedule** to see the suggested times. The calendar is unchanged until you choose **Apply schedule**. Applied planner events can be returned to the inbox from their task or agenda entry. + +The same planner is available from a terminal through `omarchy calendar`. +`status`, `events`, and `tasks` inspect the shared state; `add-event`, +`edit-event`, `delete-event`, `add-task`, `edit-task`, `delete-task`, +`dependency`, and `settings` manage it; `plan` generates and shows a suggested schedule and `apply` commits it. Add `--json` to any command for scripting, and use `omarchy calendar open agenda` or `omarchy calendar open plan` to open the corresponding view in this popup. The CLI and the popup use the same service, revision checks, and atomic state file. Every panel takes the keyboard as well as the mouse: arrows move, Return activates, Tab steps to the neighbouring panel, and Escape closes. +Inside the Clock popup, `[` and `]` step months, `{` and `}` step years, `t` returns to today, and `w` toggles the week start. Press `a` for Agenda or `p` for Plan. Escape goes back one view before closing the popup. + `Super + Ctrl + 1-9` counts panels left to right in the right section, skipping the tray since it has no panel of its own. So the number matches the icon you'd point at. ### Tailscale and Dropbox diff --git a/manual/14-omarchy-cli.md b/manual/14-omarchy-cli.md index fa5d0a05b70..079223fe9ec 100644 --- a/manual/14-omarchy-cli.md +++ b/manual/14-omarchy-cli.md @@ -57,6 +57,26 @@ Capture commands — Screenshots and screen recording: Every command takes `--help` too, whether you ask a whole group (`omarchy capture --help`) or a single command (`omarchy capture screenshot --help`). +### Calendar and planning + +The clock popup's local calendar and planner are also available through the +`calendar` group. It talks to the running Omarchy shell, so the terminal and +the popup always operate on the same versioned state: + +```bash +omarchy calendar status +omarchy calendar add-event --title "Dentist" \\ + --start 2026-09-07T10:00:00+02:00 --end 2026-09-07T11:00:00+02:00 \\ + --timezone Europe/Rome +omarchy calendar add-task --title "Write proposal" --duration 90 --priority high +omarchy calendar settings --timezone Europe/Rome --availability monday=09:00-17:00 +omarchy calendar plan +omarchy calendar apply +omarchy calendar open agenda +``` + +Use `events`, `tasks`, and `proposal` to inspect data, `edit-*` and `delete-*` to manage it, and `--json` for scripts. `plan` explicitly generates a fresh suggested schedule; it never changes the calendar. Planning is always a suggested schedule until `apply` is run; `return-inbox ` reverses an applied planner task without changing unrelated manual events. + ### Opening the menu from the terminal The Omarchy menu is scriptable as well, which is handy for your own keybindings. `omarchy menu` opens it at the root, and you can jump straight to any point in the tree by naming it: `omarchy menu summon style.theme` goes right to the theme picker, `omarchy menu toggle system` opens the system menu and closes it again if it's already up, and `omarchy menu close` puts it away. diff --git a/migrations/1788584591.sh b/migrations/1788584591.sh new file mode 100644 index 00000000000..2e603b967f4 --- /dev/null +++ b/migrations/1788584591.sh @@ -0,0 +1,3 @@ +echo "Install the native calendar planner solver" + +omarchy-pkg-add omarchy-calendar-solver diff --git a/shell/Ui/MultiSelect.qml b/shell/Ui/MultiSelect.qml index 85705779fc7..b150d95a9b0 100644 --- a/shell/Ui/MultiSelect.qml +++ b/shell/Ui/MultiSelect.qml @@ -289,7 +289,16 @@ Item { } Keys.onPressed: function(event) { - if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter + // A popup can take a round trip through the window focus scope while + // it opens. If the trigger receives a printable key in that small + // handoff, keep the same searchable-input behavior instead of + // dropping the user's first characters. + if (popup.opened && event.text !== "" + && !(event.modifiers & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier))) { + searchField.forceActiveFocus() + searchField.insert(searchField.cursorPosition, event.text) + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space || event.key === Qt.Key_Down) { popup.opened ? popup.close() : popup.open() event.accepted = true @@ -387,9 +396,15 @@ Item { searchField.text = "" root.refresh() root.recomputeFiltered() - Qt.callLater(function() { searchField.forceActiveFocus() }) + searchField.forceActiveFocus() + Qt.callLater(function() { + if (popup.opened) searchField.forceActiveFocus() + }) + } + onClosed: { + searchField.text = "" + trigger.forceActiveFocus() } - onClosed: searchField.text = "" contentItem: Column { spacing: 0 @@ -408,6 +423,9 @@ Item { id: searchField width: parent.width - refreshButton.width - parent.spacing height: parent.height + focus: true + activeFocusOnTab: true + Keys.priority: Keys.BeforeItem placeholderText: root.placeholderText foreground: root.foreground accent: root.accent diff --git a/shell/plugins/bar/README.md b/shell/plugins/bar/README.md index 5741c5f3114..fd00cdf80f7 100644 --- a/shell/plugins/bar/README.md +++ b/shell/plugins/bar/README.md @@ -56,7 +56,7 @@ Example `shell.json` (bar subtree only shown): |---|---|---| | `omarchy.menu` | Omarchy menu launcher | left = menu · right = terminal | | `omarchy.workspaces` | Hyprland workspace switcher | left = focus workspace | -| `omarchy.clock` | Date/time label + popup with a month grid, ISO week numbers, and month stepping | left = popup · right = cycle label format · middle = timezone selector | +| `omarchy.clock` | Date/time label + popup with Calendar, Agenda, and Plan views | left = popup · right = cycle label format · middle = timezone selector | | `omarchy.media` | MPRIS now-playing — scrolling track + artist, cover-art popup | left = play/pause · middle = next · scroll = prev/next · right = popup | | `omarchy.indicators` | Manual state indicators | left = indicator action | | `omarchy.system-update` | Available update indicator | left = update | diff --git a/shell/plugins/panels/clock/AgendaView.qml b/shell/plugins/panels/clock/AgendaView.qml new file mode 100644 index 00000000000..08d3f1af83c --- /dev/null +++ b/shell/plugins/panels/clock/AgendaView.qml @@ -0,0 +1,141 @@ +import QtQuick +import qs.Commons +import qs.Ui +import "PlannerModel.js" as PlannerModel + +// Reusable agenda surface for the planner tab. Panel.qml keeps the original +// month grid untouched; this component owns the event-oriented view beside it. +Item { + id: root + + property var service: null + property var bar: null + property color foreground: bar ? bar.foreground : Color.foreground + property string fontFamily: bar ? bar.fontFamily : Style.font.family + signal editEventRequested(var event) + + implicitHeight: content.implicitHeight + + function state() { + return root.service && root.service.calendarState + ? root.service.calendarState + : { settings: {}, events: [] } + } + + function dayKeys() { + var settings = state().settings || {} + var timezone = settings.timezone || "" + var grouped = PlannerModel.eventsByDay(state().events, timezone) + var today = PlannerModel.dayKey(new Date(), timezone) + var horizonDays = Math.max(1, Number(settings.horizonDays) || 14) + var end = PlannerModel.dayKey(new Date(Date.now() + horizonDays * 86400000), timezone) + return Object.keys(grouped).filter(function(key) { return key >= today && key <= end }).sort() + } + + Column { + id: content + width: parent.width + spacing: Style.space(6) + + Repeater { + model: root.dayKeys() + delegate: Column { + required property string modelData + property string dayKey: modelData + width: content.width + spacing: Style.space(5) + + Text { + textFormat: Text.PlainText + text: PlannerModel.formatDayLabel(dayKey) + color: Qt.darker(root.foreground, 1.35) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + font.bold: true + } + + Repeater { + model: PlannerModel.eventsForDay(root.state().events, dayKey, root.state().settings.timezone) + delegate: Rectangle { + required property var modelData + width: content.width + height: eventColumn.implicitHeight + Style.space(16) + radius: Style.cornerRadius + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.06) + + Column { + id: eventColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + spacing: Style.space(3) + Row { + width: parent.width + Button { + focusable: true + id: eventAction + visible: modelData.origin === "manual" || !!modelData.taskId + text: modelData.origin === "manual" ? "Edit" : "Return to inbox" + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: { + if (modelData.origin === "manual") root.editEventRequested(modelData) + else root.service.returnToInbox(modelData.taskId) + } + } + Text { + textFormat: Text.PlainText + width: parent.width - (eventAction.visible ? eventAction.width : 0) - Style.space(8) + text: modelData.title + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: true + elide: Text.ElideRight + } + } + Text { + textFormat: Text.PlainText + text: Qt.formatDateTime(new Date(modelData.startAt), "ddd d MMM HH:mm") + + " — " + Qt.formatDateTime(new Date(modelData.endAt), "HH:mm") + color: Qt.darker(root.foreground, 1.35) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + Text { + textFormat: Text.PlainText + visible: modelData.origin !== "manual" + text: "Scheduled from a planning task — manage it in Plan" + color: Color.accent + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + Text { + textFormat: Text.PlainText + visible: modelData.description !== "" + text: modelData.description + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + width: parent.width + } + } + } + } + } + } + + Text { + textFormat: Text.PlainText + visible: root.dayKeys().length === 0 + text: "No events yet." + color: Qt.darker(root.foreground, 1.45) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + } +} diff --git a/shell/plugins/panels/clock/AvailabilityEditor.qml b/shell/plugins/panels/clock/AvailabilityEditor.qml new file mode 100644 index 00000000000..4f658d98f16 --- /dev/null +++ b/shell/plugins/panels/clock/AvailabilityEditor.qml @@ -0,0 +1,313 @@ +import QtQuick +import QtQuick.Controls +import qs.Commons +import qs.Ui + +// Weekly availability editor. Each day gets an always-available add row and +// can grow without changing the normalized state shape: weekday -> [{start,end}]. +Item { + id: root + + property var service: null + property var bar: null + property color foreground: bar ? bar.foreground : Color.foreground + property string fontFamily: bar ? bar.fontFamily : Style.font.family + property var days: ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + property var draft: ({}) + property string errorText: "" + + signal saved() + signal cancelled() + + // The popup grows with the form when space allows. On shorter screens only + // the weekday list scrolls; the action row stays pinned in view. + implicitHeight: form.implicitHeight + footer.implicitHeight + Style.space(44) + + function copy(value) { return JSON.parse(JSON.stringify(value || {})) } + + function loadDraft() { + var source = root.service && root.service.calendarState + ? root.service.calendarState.settings.availability + : {} + var next = {} + for (var i = 0; i < root.days.length; i++) + next[root.days[i]] = Array.isArray(source[root.days[i]]) ? copy(source[root.days[i]]) : [] + root.draft = next + root.errorText = "" + } + + function windowValue(day, slot) { + var list = root.draft[day] + if (!Array.isArray(list) || !list[slot]) return { start: "", end: "" } + return { start: String(list[slot].start || ""), end: String(list[slot].end || "") } + } + + function rowCount(day) { + var list = root.draft[day] + var count = Array.isArray(list) ? list.length : 0 + if (count === 0) return 1 + var last = list[count - 1] || {} + var complete = String(last.start || "").trim() !== "" + && String(last.end || "").trim() !== "" + return count + (complete ? 1 : 0) + } + + function setWindow(day, slot, start, end) { + var next = copy(root.draft) + if (!Array.isArray(next[day])) next[day] = [] + while (next[day].length <= slot) next[day].push({ start: "", end: "" }) + next[day][slot] = { start: String(start || "").trim(), end: String(end || "").trim() } + root.draft = next + } + + function removeWindow(day, slot) { + var next = copy(root.draft) + if (Array.isArray(next[day])) next[day].splice(slot, 1) + root.draft = next + } + + function clock(value) { + var match = String(value || "").trim().match(/^(\d{1,2}):(\d{1,2})$/) + if (!match) return false + var hours = Number(match[1]) + var minutes = Number(match[2]) + if (hours >= 24 || minutes >= 60) return false + return (hours < 10 ? "0" : "") + hours + ":" + (minutes < 10 ? "0" : "") + minutes + } + + function save() { + var result = {} + var count = 0 + for (var i = 0; i < root.days.length; i++) { + var day = root.days[i] + var source = Array.isArray(root.draft[day]) ? root.draft[day] : [] + var windows = [] + for (var slot = 0; slot < source.length; slot++) { + var value = source[slot] || {} + var start = String(value.start || "").trim() + var end = String(value.end || "").trim() + if (start === "" && end === "") continue + var normalizedStart = clock(start) + var normalizedEnd = clock(end) + if (!normalizedStart || !normalizedEnd || normalizedEnd <= normalizedStart) { + root.errorText = "Each availability window needs a start before its end (HH:MM)." + return + } + windows.push({ start: normalizedStart, end: normalizedEnd }) + count += 1 + } + if (windows.length > 0) result[day] = windows + } + if (count === 0) { + root.errorText = "Add at least one weekly availability window before saving." + return + } + var next = root.service ? root.service.updateSettings({ availability: result }) : null + if (next) root.saved() + else root.errorText = root.service ? root.service.lastSolverError : "Availability could not be saved." + } + + Component.onCompleted: loadDraft() + onServiceChanged: loadDraft() + + Rectangle { + anchors.fill: parent + color: Color.popups.background + border.width: Style.spacing.hairline + border.color: Color.popups.border + radius: Style.cornerRadius + } + + Item { + id: viewport + anchors.fill: parent + anchors.margins: Style.space(18) + clip: true + + ScrollView { + id: bodyScroll + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: footer.top + anchors.bottomMargin: Style.space(10) + contentWidth: form.width + contentHeight: form.implicitHeight + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: form.implicitHeight > height ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff + + Column { + id: form + width: Math.max(viewport.width, Style.space(390)) + spacing: Style.space(8) + + Text { + textFormat: Text.PlainText + text: "Weekly availability" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.display + font.bold: true + } + Text { + textFormat: Text.PlainText + text: "Omarchy may schedule tasks only inside these local-time windows." + color: Qt.darker(root.foreground, 1.45) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } + + Repeater { + model: root.days + delegate: Column { + required property string modelData + property string day: modelData + width: form.width + spacing: Style.space(3) + + Text { + textFormat: Text.PlainText + text: day.charAt(0).toUpperCase() + day.slice(1) + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: true + } + + Repeater { + model: root.rowCount(day) + delegate: Row { + id: slotRow + required property int index + readonly property string rowDay: day + readonly property var editor: root + width: parent.width + spacing: Style.space(6) + + Text { + textFormat: Text.PlainText + width: Style.space(42) + anchors.verticalCenter: parent.verticalCenter + text: "Slot " + (index + 1) + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + TextField { + id: startField + width: Style.space(100) + activeFocusOnTab: true + KeyNavigation.tab: endField + Keys.priority: Keys.BeforeItem + foreground: root.foreground + font.family: root.fontFamily + placeholderText: "09:00" + text: root.windowValue(day, index).start + onEditingFinished: root.setWindow(day, index, text, endField.text) + Keys.onTabPressed: function(event) { + if (event.modifiers & Qt.ShiftModifier) return + event.accepted = true + endField.forceActiveFocus() + } + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Tab && !(event.modifiers & Qt.ShiftModifier)) { + event.accepted = true + endField.forceActiveFocus() + } + } + Keys.onEscapePressed: root.cancelled() + Component.onCompleted: if (day === "monday" && index === 0) forceActiveFocus() + } + Text { + textFormat: Text.PlainText + text: "—" + anchors.verticalCenter: parent.verticalCenter + color: Qt.darker(root.foreground, 1.5) + } + TextField { + id: endField + width: Style.space(100) + activeFocusOnTab: true + KeyNavigation.backtab: startField + Keys.priority: Keys.BeforeItem + foreground: root.foreground + font.family: root.fontFamily + placeholderText: "17:00" + text: root.windowValue(day, index).end + onEditingFinished: { + slotRow.editor.setWindow(slotRow.rowDay, slotRow.index, startField.text, text) + if (slotRow.rowDay === "monday" && slotRow.index === 0 && startField.text !== "" && text !== "") + slotRow.editor.save() + } + Keys.onBacktabPressed: function(event) { + event.accepted = true + startField.forceActiveFocus() + } + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Tab && event.modifiers & Qt.ShiftModifier) { + event.accepted = true + startField.forceActiveFocus() + } + } + Keys.onEscapePressed: root.cancelled() + } + Button { + focusable: true + anchors.verticalCenter: parent.verticalCenter + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + text: root.windowValue(day, index).start !== "" ? "Remove" : "Add" + onClicked: { + var value = root.windowValue(day, index) + if (value.start !== "") root.removeWindow(day, index) + else root.setWindow(day, index, "09:00", "17:00") + } + } + } + } + } + } + + Text { + textFormat: Text.PlainText + text: root.errorText + visible: root.errorText !== "" + color: Color.urgent + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } + } + } + + Row { + id: footer + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + spacing: Style.space(8) + Button { + focusable: true + text: "Save availability" + foreground: activeFocus || hot ? Color.foreground : Color.background + background: Color.accent + fontFamily: root.fontFamily + onClicked: root.save() + } + Button { + focusable: true + text: "Cancel" + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: root.cancelled() + } + } + } +} diff --git a/shell/plugins/panels/clock/BarWidget.qml b/shell/plugins/panels/clock/BarWidget.qml index 61cf0bf5c58..d3521c4f0f2 100644 --- a/shell/plugins/panels/clock/BarWidget.qml +++ b/shell/plugins/panels/clock/BarWidget.qml @@ -15,6 +15,7 @@ BarWidget { moduleName: "omarchy.clock" property date displayDate: clock.date + property var calendarService: null readonly property string configuredFormat: vertical ? setting("verticalFormat", "HH\n—\nmm") @@ -77,6 +78,12 @@ BarWidget { if (panelLoader.item) panelLoader.item.toggle() } + function openView(view) { + if (!panelLoader.item) return + panelLoader.item.selectView(String(view || "calendar")) + panelLoader.item.open() + } + function toggleWeekStart() { if (panelLoader.item) panelLoader.item.toggleWeekStart() } @@ -101,8 +108,11 @@ BarWidget { function injectPanel() { var target = panelLoader.item if (!target) return + if (root.bar && root.bar.shell && typeof root.bar.shell.serviceFor === "function") + root.calendarService = root.bar.shell.serviceFor("omarchy.clock") if ("bar" in target) target.bar = root.bar if ("settings" in target) target.settings = root.settings + if ("service" in target) target.service = root.calendarService if ("anchorItem" in target) target.anchorItem = button if ("hostWidget" in target) target.hostWidget = root } @@ -141,6 +151,7 @@ BarWidget { function show(): void { root.open() } function hide(): void { root.close() } function toggle(): void { root.togglePanel() } + function openView(view: string): void { root.openView(view) } } WidgetButton { diff --git a/shell/plugins/panels/clock/CalendarTabs.qml b/shell/plugins/panels/clock/CalendarTabs.qml new file mode 100644 index 00000000000..38ead845398 --- /dev/null +++ b/shell/plugins/panels/clock/CalendarTabs.qml @@ -0,0 +1,44 @@ +import QtQuick +import qs.Commons +import qs.Ui + +Item { + id: root + + property string selected: "calendar" + property color foreground: Color.foreground + property string fontFamily: Style.font.family + signal tabRequested(string tab) + + readonly property var tabs: [ + { id: "calendar", label: "Calendar" }, + { id: "agenda", label: "Agenda" }, + { id: "plan", label: "Plan" } + ] + + implicitWidth: tabsRow.implicitWidth + implicitHeight: tabsRow.implicitHeight + + Row { + id: tabsRow + spacing: Style.spacing.xs + + Repeater { + model: root.tabs + + Button { + focusable: true + required property var modelData + width: Style.space(94) + height: Style.space(30) + text: modelData.label + selected: root.selected === modelData.id + bordered: true + foreground: root.foreground + fontFamily: root.fontFamily + fontSize: Style.font.bodySmall + onClicked: root.tabRequested(modelData.id) + } + } + } +} diff --git a/shell/plugins/panels/clock/CalendarView.qml b/shell/plugins/panels/clock/CalendarView.qml new file mode 100644 index 00000000000..0f9a5b6bf86 --- /dev/null +++ b/shell/plugins/panels/clock/CalendarView.qml @@ -0,0 +1,555 @@ +import QtQuick +import qs.Commons +import qs.Ui +import "Model.js" as Model +import "PlannerModel.js" as PlannerModel + +// The original month calendar, extracted from Panel.qml. Its public signals +// keep date navigation, week-start preferences, and the memento-mori editor in +// the coordinator, while this file owns all calendar geometry and painting. +Item { + id: root + + property var service: null + property var bar: null + property date today: new Date() + property int viewYear: today.getFullYear() + property int viewMonth: today.getMonth() + property int weekStart: 1 + property bool editingLife: false + property int birthYear: 0 + property int lifeExpectancy: 0 + property color foreground: bar ? bar.foreground : Color.foreground + property string fontFamily: bar ? bar.fontFamily : Style.font.family + readonly property date viewDate: new Date(viewYear, viewMonth, 1) + readonly property bool viewingCurrentMonth: viewYear === today.getFullYear() && viewMonth === today.getMonth() + readonly property string todayKey: Model.keyForDate(today) + readonly property real yearDone: Model.yearProgress(today.getFullYear(), today.getMonth(), today.getDate()) + readonly property int yearDonePercent: Model.yearProgressPercent(today.getFullYear(), today.getMonth(), today.getDate()) + readonly property int age: Model.ageFromBirthYear(birthYear, today.getFullYear()) + readonly property real lifeDone: Model.lifeProgress(age, lifeExpectancy) + readonly property int lifeDonePercent: Model.lifeProgressPercent(age, lifeExpectancy) + readonly property var weekdays: Model.weekdayOrder(weekStart) + readonly property var weeks: Model.monthGrid(viewYear, viewMonth, weekStart, todayKey) + readonly property var markerMap: PlannerModel.eventMarkers( + root.service && root.service.calendarState ? root.service.calendarState.events : [], + root.service && root.service.calendarState ? root.service.calendarState.settings.timezone : "") + + readonly property int cellWidth: Style.space(52) + readonly property int cellHeight: Style.space(34) + readonly property int cellSpacing: Style.space(2) + readonly property int weekColumnWidth: Style.space(32) + readonly property int gutterWidth: Style.space(14) + readonly property var labelLocale: Qt.locale("en_US") + readonly property string nextWeekStartLabel: labelLocale.dayName(Model.toggledWeekStart(weekStart), Locale.LongFormat) + + signal todayRequested() + signal monthRequested(int delta) + signal weekStartRequested() + signal plannerRequested() + signal lifeEditRequested() + signal lifeClearRequested() + signal lifeCommitRequested(string birth, string expectancy) + signal lifeCancelRequested() + + implicitHeight: calendarColumn.implicitHeight + + function weekdayLabel(weekday) { + return String(labelLocale.dayName(weekday, Locale.ShortFormat)).toUpperCase() + } + + function markerCount(key) { + return Number(root.markerMap[key] || 0) + } + + function beginLifeEdit() { + Qt.callLater(function() { + bornField.text = root.birthYear > 0 ? String(root.birthYear) : "" + expectancyField.text = String(root.lifeExpectancy) + bornField.selectAll() + bornField.forceActiveFocus() + }) + } + + function handleLifeKey(event, other) { + if (event.key === Qt.Key_Escape) { + root.lifeCancelRequested() + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.lifeCommitRequested(bornField.text, expectancyField.text) + event.accepted = true + } else if (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) { + other.selectAll() + other.forceActiveFocus() + event.accepted = true + } + } + + onEditingLifeChanged: if (root.editingLife) root.beginLifeEdit() + + Flickable { + id: calendarScroll + anchors.fill: parent + contentWidth: calendarColumn.width + contentHeight: calendarColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + interactive: contentHeight > height || contentWidth > width + + Column { + id: calendarColumn + width: Math.max(calendarScroll.width, gridColumn.width) + spacing: Style.space(8) + + Item { + width: parent.width + height: heroRow.height + + Row { + id: heroRow + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.space(22) + + Text { + textFormat: Text.PlainText + anchors.baseline: heroDate.baseline + text: "󰃭" + color: heroMouse.containsMouse + ? Style.hoverStateColor(root.foreground, Color.accent) + : root.foreground + font.family: root.fontFamily + font.pixelSize: 48 + } + + Text { + textFormat: Text.PlainText + id: heroDate + anchors.verticalCenter: parent.verticalCenter + text: Qt.formatDate(root.today, "MMMM d") + color: heroMouse.containsMouse + ? Style.hoverStateColor(root.foreground, Color.accent) + : root.foreground + font.family: root.fontFamily + font.pixelSize: 52 + font.bold: true + } + } + + MouseArea { + id: heroMouse + x: heroRow.x + y: heroRow.y + width: heroRow.width + height: heroRow.height + enabled: !root.viewingCurrentMonth + hoverEnabled: enabled + cursorShape: Qt.PointingHandCursor + onClicked: root.todayRequested() + + PanelToolTip { + visible: heroMouse.containsMouse + text: "Back to today" + fontFamily: root.fontFamily + } + } + } + + Item { + width: parent.width + height: yearBlock.y + yearBlock.height + + Item { + id: yearBlock + y: Style.space(6) + anchors.horizontalCenter: parent.horizontalCenter + width: gridColumn.width + height: Math.max(yearLabel.implicitHeight, Style.space(10)) + + TapHandler { + enabled: !root.editingLife + onDoubleTapped: root.lifeEditRequested() + } + + Row { + visible: root.editingLife + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + spacing: Style.space(10) + + Text { + textFormat: Text.PlainText + anchors.verticalCenter: parent.verticalCenter + text: "BORN" + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + font.letterSpacing: 1 + } + + TextField { + id: bornField + width: Style.space(70) + anchors.verticalCenter: parent.verticalCenter + placeholderText: "year" + foreground: root.foreground + font.family: root.fontFamily + inputMethodHints: Qt.ImhDigitsOnly + Keys.onPressed: function(event) { root.handleLifeKey(event, expectancyField) } + } + + Text { + textFormat: Text.PlainText + anchors.verticalCenter: parent.verticalCenter + leftPadding: Style.space(6) + text: "LIVE TO" + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + font.letterSpacing: 1 + } + + TextField { + id: expectancyField + width: Style.space(60) + anchors.verticalCenter: parent.verticalCenter + placeholderText: "90" + foreground: root.foreground + font.family: root.fontFamily + inputMethodHints: Qt.ImhDigitsOnly + Keys.onPressed: function(event) { root.handleLifeKey(event, bornField) } + } + } + + Text { + textFormat: Text.PlainText + id: yearLabel + visible: !root.editingLife + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: root.today.getFullYear() + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + font.letterSpacing: 1 + } + + Text { + textFormat: Text.PlainText + id: yearPercent + visible: !root.editingLife + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.yearDonePercent + "%" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + Rectangle { + id: yearTrack + visible: !root.editingLife + anchors.left: yearLabel.right + anchors.right: yearPercent.left + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + anchors.verticalCenter: parent.verticalCenter + height: Style.space(6) + radius: Style.cornerRadius > 0 ? height / 2 : 0 + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12) + + Rectangle { + width: Math.round(parent.width * root.yearDone) + height: parent.height + radius: parent.radius + color: Style.selectedStateColor(root.foreground, Color.accent) + Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } + } + } + } + } + + Item { + visible: root.birthYear > 0 + width: parent.width + height: visible ? lifeBlock.height : 0 + + Item { + id: lifeBlock + anchors.horizontalCenter: parent.horizontalCenter + width: gridColumn.width + height: Math.max(lifeLabel.implicitHeight, Style.space(10)) + + Text { + textFormat: Text.PlainText + id: lifeLabel + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "LIFE" + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + font.letterSpacing: 1 + } + + Text { + textFormat: Text.PlainText + id: lifePercent + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.lifeDonePercent + "%" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + Rectangle { + anchors.left: lifeLabel.right + anchors.right: lifePercent.left + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + anchors.verticalCenter: parent.verticalCenter + height: Style.space(6) + radius: Style.cornerRadius > 0 ? height / 2 : 0 + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12) + + Rectangle { + width: Math.round(parent.width * root.lifeDone) + height: parent.height + radius: parent.radius + color: Style.selectedStateColor(root.foreground, Color.accent) + Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } + } + } + + TapHandler { onDoubleTapped: root.lifeClearRequested() } + + MouseArea { + id: lifeMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + PanelToolTip { + visible: lifeMouse.containsMouse + text: "Memento Mori" + fontFamily: root.fontFamily + } + } + } + } + + Item { + width: parent.width + height: gridColumn.y + gridColumn.height + + WheelHandler { + onWheel: function(event) { + if (event.angleDelta.y === 0) return + root.monthRequested(event.angleDelta.y > 0 ? -1 : 1) + } + } + + Column { + id: gridColumn + y: Style.space(18) + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.space(3) + + Row { + id: headerRow + spacing: root.cellSpacing + + Rectangle { + width: root.weekColumnWidth + height: Style.space(16) + radius: Style.cornerRadius + color: weekStartMouse.containsMouse + ? Style.hoverFillFor(root.foreground, Color.accent) + : "transparent" + + Text { + textFormat: Text.PlainText + anchors.centerIn: parent + text: "W" + color: weekStartMouse.containsMouse + ? Style.hoverStateColor(root.foreground, Color.accent) + : Qt.darker(root.foreground, 1.9) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.letterSpacing: 1 + font.bold: true + } + + MouseArea { + id: weekStartMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.weekStartRequested() + } + + PanelToolTip { + visible: weekStartMouse.containsMouse + text: "Start weeks on " + root.nextWeekStartLabel + fontFamily: root.fontFamily + } + } + + Item { width: root.gutterWidth; height: Style.space(16) } + + Repeater { + model: root.weekdays + Text { + textFormat: Text.PlainText + required property var modelData + width: root.cellWidth + height: Style.space(16) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: root.weekdayLabel(modelData) + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.letterSpacing: 1 + font.bold: true + } + } + } + + Repeater { + model: root.weeks + Row { + required property var modelData + spacing: root.cellSpacing + + Text { + textFormat: Text.PlainText + width: root.weekColumnWidth + height: root.cellHeight + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: modelData.week + color: Qt.darker(root.foreground, 1.9) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + Item { width: root.gutterWidth; height: root.cellHeight } + + Repeater { + model: modelData.days + Rectangle { + required property var modelData + width: root.cellWidth + height: root.cellHeight + radius: Style.cornerRadius + color: "transparent" + border.width: modelData.today ? Style.spacing.hairline : 0 + border.color: Style.normalBorderFor(root.foreground, Color.accent) + + Text { + textFormat: Text.PlainText + anchors.centerIn: parent + text: modelData.day + color: modelData.inMonth + ? (modelData.weekend ? Qt.darker(root.foreground, 1.45) : root.foreground) + : Qt.darker(root.foreground, 2.2) + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: modelData.today + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Style.space(3) + spacing: Style.space(2) + visible: root.markerCount(modelData.key) > 0 + Repeater { + model: Math.min(root.markerCount(modelData.key), 3) + Rectangle { + required property int index + width: Style.space(3) + height: width + radius: width / 2 + color: Color.accent + } + } + } + } + } + } + } + } + + Rectangle { + x: gridColumn.x + root.weekColumnWidth + root.cellSpacing + Math.round((root.gutterWidth - width) / 2) + y: gridColumn.y + headerRow.height + gridColumn.spacing + width: Style.spacing.hairline + height: gridColumn.height - headerRow.height - gridColumn.spacing + color: root.foreground + opacity: 0.1 + } + } + + Item { + width: parent.width + height: monthNav.height + + Item { + id: monthNav + anchors.horizontalCenter: parent.horizontalCenter + width: gridColumn.width + height: monthLabel.implicitHeight + Style.space(10) + + Text { + textFormat: Text.PlainText + id: monthLabel + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + width: Style.space(130) + horizontalAlignment: Text.AlignHCenter + text: Qt.formatDate(root.viewDate, "MMMM yyyy").toUpperCase() + color: Qt.darker(root.foreground, 1.4) + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.letterSpacing: 1 + } + + PanelActionButton { + anchors.left: parent.left + anchors.leftMargin: -Style.space(8) + anchors.verticalCenter: parent.verticalCenter + iconText: "󰅁" + tooltipText: "Previous month" + foreground: root.foreground + fontFamily: root.fontFamily + onClicked: root.monthRequested(-1) + } + + PanelActionButton { + anchors.right: parent.right + anchors.rightMargin: Style.space(30) + anchors.verticalCenter: parent.verticalCenter + iconText: "P" + tooltipText: "Open planner" + foreground: root.foreground + fontFamily: root.fontFamily + fontSize: Style.font.caption + onClicked: root.plannerRequested() + } + + PanelActionButton { + anchors.right: parent.right + anchors.rightMargin: -Style.space(8) + anchors.verticalCenter: parent.verticalCenter + iconText: "󰅂" + tooltipText: "Next month" + foreground: root.foreground + fontFamily: root.fontFamily + onClicked: root.monthRequested(1) + } + } + } + } + } +} diff --git a/shell/plugins/panels/clock/EventEditor.qml b/shell/plugins/panels/clock/EventEditor.qml new file mode 100644 index 00000000000..07ae3cd42ac --- /dev/null +++ b/shell/plugins/panels/clock/EventEditor.qml @@ -0,0 +1,304 @@ +import QtQuick +import qs.Commons +import qs.Ui +import "." + +// Manual event editor. Dates are picked visually and converted to ISO-8601 +// only at the state boundary, so users never need to type a timestamp. +Item { + id: root + + property var service: null + property var bar: null + property var event: null + property color foreground: bar ? bar.foreground : Color.foreground + property string fontFamily: bar ? bar.fontFamily : Style.font.family + property string errorText: "" + property bool allDay: false + property string startAt: "" + property string endAt: "" + readonly property bool manualEvent: !root.event || root.event.origin === "manual" + + signal saved() + signal cancelled() + + // The popup sizes itself from the form. The viewport is deliberately not a + // scrolling input surface: Save and Cancel stay visible as the form grows. + implicitHeight: form.implicitHeight + Style.space(36) + + function defaultTimezone() { + if (root.service && root.service.calendarState.settings.timezone) + return root.service.calendarState.settings.timezone + try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" } + catch (error) { return "UTC" } + } + + function reset() { + var value = root.event || {} + titleField.text = value.title || "" + var start = value.startAt ? new Date(value.startAt) : new Date() + var end = value.endAt ? new Date(value.endAt) : new Date(start.getTime() + 60 * 60 * 1000) + root.startAt = value.startAt || start.toISOString() + root.endAt = value.endAt || end.toISOString() + timezoneField.text = value.timezone || defaultTimezone() + descriptionField.text = value.description || "" + recurrenceField.text = value.rrule || "" + allDay = !!value.allDay + errorText = "" + } + + function save() { + if (!root.manualEvent) { + errorText = "Applied planner events are changed through the planner. Return the task to the inbox first." + return + } + var title = titleField.text.trim() + var startAt = startAtField.valid ? root.startAt : false + var endAt = endAtField.valid ? root.endAt : false + var timezone = timezoneField.text.trim() + if (title === "") { + errorText = "An event title is required." + titleField.forceActiveFocus() + return + } + if (startAt === false || endAt === false) { + errorText = "Choose a start and end date, then enter each time as HH:MM." + if (!startAtField.valid) startAtField.forceActiveFocus() + else endAtField.forceActiveFocus() + return + } + if (new Date(endAt) <= new Date(startAt)) { + errorText = "The event must end after it starts." + endAtField.forceActiveFocus() + return + } + if (timezone === "") { + errorText = "An IANA timezone is required, for example Europe/Rome." + timezoneField.forceActiveFocus() + return + } + + var input = { + title: title, + description: descriptionField.text, + startAt: startAt, + endAt: endAt, + timezone: timezone, + allDay: root.allDay, + rrule: recurrenceField.text.trim() === "" ? null : recurrenceField.text.trim() + } + var result = root.event + ? root.service.updateEvent(root.event.id, input) + : root.service.addEvent(input) + if (result) root.saved() + else errorText = root.service ? root.service.lastSolverError : "The event could not be saved." + } + + Component.onCompleted: { + reset() + Qt.callLater(function() { titleField.forceActiveFocus() }) + } + // PlannerView injects the service immediately after this editor is loaded. + // Reset once more so a new event picks up Omarchy's configured timezone + // instead of the local UTC fallback used during construction. + onServiceChanged: root.reset() + onEventChanged: reset() + + Rectangle { + anchors.fill: parent + color: Color.popups.background + border.width: Style.spacing.hairline + border.color: Color.popups.border + radius: Style.cornerRadius + } + + Item { + id: viewport + anchors.fill: parent + anchors.margins: Style.space(18) + clip: true + + Column { + id: form + width: Math.max(viewport.width, Style.space(390)) + spacing: Style.space(10) + + Text { + textFormat: Text.PlainText + text: root.event ? "Edit calendar event" : "Add calendar event" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.display + font.bold: true + } + Text { + textFormat: Text.PlainText + text: "A calendar event is fixed busy time. Planning tasks are added from Plan." + color: Qt.darker(root.foreground, 1.45) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + Text { textFormat: Text.PlainText; text: "Title"; color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.caption } + TextField { + id: titleField + width: parent.width + activeFocusOnTab: true + KeyNavigation.tab: startAtField.dateFocusTarget + enabled: root.manualEvent + foreground: root.foreground + font.family: root.fontFamily + placeholderText: "Meeting, appointment, or personal block" + Keys.onReturnPressed: root.save() + Keys.onEscapePressed: root.cancelled() + } + + Row { + width: parent.width + spacing: Style.space(10) + Column { + width: (parent.width - Style.space(10)) / 2 + spacing: Style.spacing.labelGap + PlannerDateTimeField { + id: startAtField + width: parent.width + enabled: root.manualEvent + label: "Starts" + value: root.startAt + nextFocusTarget: endAtField.dateFocusTarget + foreground: root.foreground + fontFamily: root.fontFamily + onChanged: function(next) { root.startAt = next } + onSubmitted: root.save() + onCancelled: root.cancelled() + } + } + Column { + width: (parent.width - Style.space(10)) / 2 + spacing: Style.spacing.labelGap + PlannerDateTimeField { + id: endAtField + width: parent.width + enabled: root.manualEvent + label: "Ends" + value: root.endAt + nextFocusTarget: timezoneField + foreground: root.foreground + fontFamily: root.fontFamily + onChanged: function(next) { root.endAt = next } + onSubmitted: root.save() + onCancelled: root.cancelled() + } + } + } + + Text { textFormat: Text.PlainText; text: "Timezone"; color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.caption } + TextField { + id: timezoneField + width: parent.width + enabled: root.manualEvent + foreground: root.foreground + font.family: root.fontFamily + placeholderText: "Europe/Rome" + Keys.onEscapePressed: root.cancelled() + } + + Row { + width: parent.width + spacing: Style.space(10) + Text { + textFormat: Text.PlainText + text: "All day" + anchors.verticalCenter: parent.verticalCenter + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + } + ToggleSwitch { + checked: root.allDay + enabled: root.manualEvent + foreground: root.foreground + onToggled: root.allDay = !root.allDay + } + } + + Text { textFormat: Text.PlainText; text: "Description (optional)"; color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.caption } + TextField { + id: descriptionField + width: parent.width + enabled: root.manualEvent + foreground: root.foreground + font.family: root.fontFamily + placeholderText: "Notes" + } + + Text { textFormat: Text.PlainText; text: "RRULE recurrence (optional)"; color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.caption } + TextField { + id: recurrenceField + width: parent.width + enabled: root.manualEvent + foreground: root.foreground + font.family: root.fontFamily + placeholderText: "FREQ=WEEKLY;BYDAY=MO" + } + + Text { + textFormat: Text.PlainText + text: root.errorText + visible: root.errorText !== "" + color: Color.urgent + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } + + Row { + spacing: Style.space(8) + Button { + focusable: true + visible: root.manualEvent + text: "Save event" + foreground: activeFocus || hot ? Color.foreground : Color.background + background: Color.accent + fontFamily: root.fontFamily + onClicked: root.save() + } + Button { + focusable: true + text: "Cancel" + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: root.cancelled() + } + Button { + focusable: true + visible: root.event !== null && root.event.origin === "planner" && !!root.event.taskId + text: "Return to inbox" + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: { + var result = root.service ? root.service.returnToInbox(root.event.taskId) : null + if (result) root.saved() + else root.errorText = root.service ? root.service.lastSolverError : "The task could not be returned to the inbox." + } + } + Button { + focusable: true + visible: root.event !== null && root.event.origin === "manual" + text: "Delete event" + foreground: Color.urgent + bordered: true + fontFamily: root.fontFamily + onClicked: { + var result = root.service ? root.service.deleteEvent(root.event.id) : null + if (result) root.saved() + else root.errorText = root.service ? root.service.lastSolverError : "The event could not be deleted." + } + } + } + } + } +} diff --git a/shell/plugins/panels/clock/Panel.qml b/shell/plugins/panels/clock/Panel.qml index be5d08a00d2..75751e8b1f0 100644 --- a/shell/plugins/panels/clock/Panel.qml +++ b/shell/plugins/panels/clock/Panel.qml @@ -21,6 +21,9 @@ Panel { manageIpc: false property var anchorItem: null + property var service: null + property string activeTab: "calendar" + property string plannerView: "plan" // The bar tracks the widget mounted in its slot — BarWidget.qml — not this // nested panel. Everything the bar identifies a panel by has to be that @@ -67,12 +70,6 @@ Panel { // The interface is English throughout, so day names are not taken from the // system locale. Where the week starts still is: that is a regional // convention rather than a translation, and it stays overridable above. - readonly property var labelLocale: Qt.locale("en_US") - readonly property string nextWeekStartLabel: labelLocale.dayName(Model.toggledWeekStart(weekStart), Locale.LongFormat) - readonly property var weekdays: Model.weekdayOrder(weekStart) - readonly property var weeks: Model.monthGrid(viewYear, viewMonth, weekStart, todayKey) - - // Guarded so the widget renders before the bar is injected (the bar-widget // contract instantiates it bare). readonly property color contentForeground: bar ? bar.foreground : Color.foreground @@ -102,6 +99,10 @@ Panel { // Dismissing the panel mid-edit would otherwise leave the inputs up, // waiting behind a closed popup for the next time it opens. if (root.editingLife) root.cancelEditingLife() + if (plannerLoader.item && typeof plannerLoader.item.cancelEditor === "function") + plannerLoader.item.cancelEditor() + root.activeTab = "calendar" + root.plannerView = "plan" root.controller.hide() } @@ -110,6 +111,37 @@ Panel { else root.open() } + function openPlanner() { + root.activeTab = "planner" + root.plannerView = "plan" + root.open() + } + + function openAgenda() { + root.activeTab = "planner" + root.plannerView = "agenda" + root.open() + } + + function selectView(view) { + if (view === "calendar") { + root.activeTab = "calendar" + root.plannerView = "plan" + } else { + root.activeTab = "planner" + root.plannerView = view + } + } + + function injectPlanner() { + if (!plannerLoader.item) return + plannerLoader.item.service = root.service + plannerLoader.item.bar = root.bar + plannerLoader.item.activeView = root.plannerView + if (root.activeTab !== "calendar" && plannerLoader.item.editorMode === "") + Qt.callLater(function() { if (plannerLoader.item) plannerLoader.item.focusFirst() }) + } + function switchPanel(direction) { if (root.bar && typeof root.bar.switchPanelFrom === "function") return root.bar.switchPanelFrom(root.barIdentity, direction) @@ -168,12 +200,6 @@ Panel { function startEditingLife() { root.editingLife = true - Qt.callLater(function() { - bornField.text = root.birthYear > 0 ? String(root.birthYear) : "" - expectancyField.text = String(root.lifeExpectancy) - bornField.selectAll() - bornField.forceActiveFocus() - }) } function cancelEditingLife() { @@ -181,22 +207,6 @@ Panel { Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) } - // Shared by both fields: Tab hops to the other one, Enter commits the pair, - // Escape drops the lot. - function handleLifeKey(event, other) { - if (event.key === Qt.Key_Escape) { - root.cancelEditingLife() - event.accepted = true - } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - root.commitLife() - event.accepted = true - } else if (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) { - other.selectAll() - other.forceActiveFocus() - event.accepted = true - } - } - // Double-tapping the life bar puts it away again. The expectancy stays in // the config so setting a birth year again brings your own number back // rather than the default. @@ -205,9 +215,9 @@ Panel { persistSettings({ birthYear: 0 }) } - function commitLife() { - var born = Model.parseBirthYear(bornField.text, today.getFullYear()) - var span = Model.parseLifeExpectancy(expectancyField.text) + function commitLife(bornText, expectancyText) { + var born = Model.parseBirthYear(bornText, today.getFullYear()) + var span = Model.parseLifeExpectancy(expectancyText) if (born !== root.birthYear || span !== root.lifeExpectancy) persistSettings({ birthYear: born, lifeExpectancy: span }) cancelEditingLife() @@ -217,11 +227,6 @@ Panel { setWeekStart(Model.toggledWeekStart(root.weekStart)) } - // English short day names, matching the rest of the interface. - function weekdayLabel(weekday) { - return String(labelLocale.dayName(weekday, Locale.ShortFormat)).toUpperCase() - } - SystemClock { id: clock precision: SystemClock.Minutes @@ -242,519 +247,112 @@ Panel { centerOnBar: true focusTarget: keyCatcher contentWidth: panel.fittedContentWidth(Style.space(560)) - contentHeight: panel.fittedContentHeight(calendarColumn.implicitHeight) + contentHeight: panel.fittedContentHeight(tabs.implicitHeight + Style.space(14) + + (root.activeTab === "calendar" + ? calendarView.implicitHeight + : plannerLoader.item && plannerLoader.item.editorMode !== "" + && plannerLoader.item.editorImplicitHeight > 0 + ? plannerLoader.item.editorImplicitHeight + : Math.max(calendarView.implicitHeight, Style.space(500)))) PanelKeyCatcher { id: keyCatcher anchors.fill: parent - blocked: root.editingLife + blocked: root.editingLife || root.activeTab !== "calendar" onMoveRequested: function(dx, dy) { + if (root.activeTab !== "calendar") return if (dx !== 0) root.moveMonth(dx) if (dy !== 0) root.moveYear(dy) } - onActivateRequested: root.goToToday() - onCloseRequested: root.close() + onActivateRequested: { + if (root.activeTab === "calendar") root.goToToday() + } + onCloseRequested: { + if (root.activeTab !== "calendar") { + root.activeTab = "calendar" + root.plannerView = "plan" + } else { + root.close() + } + } onTabRequested: function(direction) { root.switchPanel(direction) } onTextKey: function(t) { + if (root.activeTab !== "calendar") return if (t === "[") root.moveMonth(-1) else if (t === "]") root.moveMonth(1) else if (t === "{") root.moveYear(-1) else if (t === "}") root.moveYear(1) else if (t === "t" || t === "T") root.goToToday() else if (t === "w" || t === "W") root.toggleWeekStart() + else if (t === "a" || t === "A") root.openAgenda() + else if (t === "p" || t === "P") root.openPlanner() } - Flickable { - id: calendarScroll - anchors.fill: parent - contentWidth: calendarColumn.width - contentHeight: calendarColumn.implicitHeight - clip: true - boundsBehavior: Flickable.StopAtBounds - interactive: contentHeight > height || contentWidth > width - - Column { - id: calendarColumn - // Never narrower than the grid. The popup width is capped to what - // the screen allows, and a fixed seven-column grid would otherwise - // lose its last days off the edge instead of scrolling. - width: Math.max(calendarScroll.width, gridColumn.width) - spacing: Style.space(8) - - // ---- Hero: today, centered. Once the view has stepped back - // it is also the way home — clicking the date you are - // looking for beats hunting for a reset button. - Item { - width: parent.width - height: heroRow.height - - Row { - id: heroRow - anchors.horizontalCenter: parent.horizontalCenter - spacing: Style.space(22) - - Text { - // Baseline-aligned, not center-aligned: "July 26" carries a - // descender, so centering the two boxes leaves the icon - // sitting visibly low against the digits. - anchors.baseline: heroDate.baseline - text: "󰃭" - color: heroMouse.containsMouse - ? Style.hoverStateColor(root.contentForeground, Color.accent) - : root.contentForeground - font.family: root.contentFontFamily - // Decorative, and deliberately outside the Style.font.* - // scale. Sized so the glyph reads at the cap height of the - // date beside it rather than towering over it. - font.pixelSize: 48 - } - - Text { - id: heroDate - textFormat: Text.PlainText - anchors.verticalCenter: parent.verticalCenter - text: Qt.formatDate(root.today, "MMMM d") - color: heroMouse.containsMouse - ? Style.hoverStateColor(root.contentForeground, Color.accent) - : root.contentForeground - font.family: root.contentFontFamily - font.pixelSize: 52 - font.bold: true - } - } - - MouseArea { - id: heroMouse - x: heroRow.x - y: heroRow.y - width: heroRow.width - height: heroRow.height - enabled: !root.viewingCurrentMonth - hoverEnabled: enabled - cursorShape: Qt.PointingHandCursor - onClicked: root.goToToday() - - PanelToolTip { - visible: heroMouse.containsMouse - text: "Back to today" - fontFamily: root.contentFontFamily - } - } - } - - // ---- Year progress, doubling as the rule under the hero: - // a plain hairline said nothing, and whole days done - // over days in the year says the same thing louder. - Item { - width: parent.width - height: yearBlock.y + yearBlock.height - - Item { - id: yearBlock - y: Style.space(6) - anchors.horizontalCenter: parent.horizontalCenter - width: gridColumn.width - height: Math.max(yearLabel.implicitHeight, Style.space(10)) - - TapHandler { - enabled: !root.editingLife - onDoubleTapped: root.startEditingLife() - } - - Row { - visible: root.editingLife - anchors.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: parent.verticalCenter - spacing: Style.space(10) - - Text { - anchors.verticalCenter: parent.verticalCenter - text: "BORN" - color: Qt.darker(root.contentForeground, 1.5) - font.family: root.contentFontFamily - font.pixelSize: Style.font.bodySmall - font.letterSpacing: 1 - } - - TextField { - id: bornField - width: Style.space(70) - anchors.verticalCenter: parent.verticalCenter - placeholderText: "year" - foreground: root.contentForeground - font.family: root.contentFontFamily - inputMethodHints: Qt.ImhDigitsOnly - - Keys.onPressed: function(event) { root.handleLifeKey(event, expectancyField) } - } - - Text { - anchors.verticalCenter: parent.verticalCenter - anchors.verticalCenterOffset: 0 - leftPadding: Style.space(6) - text: "LIVE TO" - color: Qt.darker(root.contentForeground, 1.5) - font.family: root.contentFontFamily - font.pixelSize: Style.font.bodySmall - font.letterSpacing: 1 - } - - TextField { - id: expectancyField - width: Style.space(60) - anchors.verticalCenter: parent.verticalCenter - placeholderText: "90" - foreground: root.contentForeground - font.family: root.contentFontFamily - inputMethodHints: Qt.ImhDigitsOnly - - Keys.onPressed: function(event) { root.handleLifeKey(event, bornField) } - } - } - - Text { - id: yearLabel - textFormat: Text.PlainText - visible: !root.editingLife - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: root.today.getFullYear() - color: Qt.darker(root.contentForeground, 1.5) - font.family: root.contentFontFamily - font.pixelSize: Style.font.bodySmall - font.letterSpacing: 1 - } - - Text { - id: yearPercent - textFormat: Text.PlainText - visible: !root.editingLife - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - text: root.yearDonePercent + "%" - color: root.contentForeground - font.family: root.contentFontFamily - font.pixelSize: Style.font.bodySmall - } - - Rectangle { - id: yearTrack - visible: !root.editingLife - anchors.left: yearLabel.right - anchors.right: yearPercent.left - anchors.leftMargin: Style.space(12) - anchors.rightMargin: Style.space(12) - anchors.verticalCenter: parent.verticalCenter - height: Style.space(6) - radius: Style.cornerRadius > 0 ? height / 2 : 0 - color: Qt.rgba(root.contentForeground.r, root.contentForeground.g, root.contentForeground.b, 0.12) - - Rectangle { - width: Math.round(parent.width * root.yearDone) - height: parent.height - radius: parent.radius - color: Style.selectedStateColor(root.contentForeground, Color.accent) - - Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } - } - } - } - } - - // ---- Memento mori. Only here once someone has gone looking and - // given an age; the same rail as the year above it, measured - // against a nominal lifetime. - Item { - visible: root.birthYear > 0 - width: parent.width - height: visible ? lifeBlock.height : 0 - - Item { - id: lifeBlock - anchors.horizontalCenter: parent.horizontalCenter - width: gridColumn.width - height: Math.max(lifeLabel.implicitHeight, Style.space(10)) - - Text { - id: lifeLabel - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: "LIFE" - color: Qt.darker(root.contentForeground, 1.5) - font.family: root.contentFontFamily - font.pixelSize: Style.font.bodySmall - font.letterSpacing: 1 - } - - Text { - id: lifePercent - textFormat: Text.PlainText - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - text: root.lifeDonePercent + "%" - color: root.contentForeground - font.family: root.contentFontFamily - font.pixelSize: Style.font.bodySmall - } - - Rectangle { - anchors.left: lifeLabel.right - anchors.right: lifePercent.left - anchors.leftMargin: Style.space(12) - anchors.rightMargin: Style.space(12) - anchors.verticalCenter: parent.verticalCenter - height: Style.space(6) - radius: Style.cornerRadius > 0 ? height / 2 : 0 - color: Qt.rgba(root.contentForeground.r, root.contentForeground.g, root.contentForeground.b, 0.12) - - Rectangle { - width: Math.round(parent.width * root.lifeDone) - height: parent.height - radius: parent.radius - color: Style.selectedStateColor(root.contentForeground, Color.accent) - - Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } - } - } - - TapHandler { - onDoubleTapped: root.clearLife() - } - - MouseArea { - id: lifeMouse - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.NoButton - - PanelToolTip { - visible: lifeMouse.containsMouse - text: "Memento Mori" - fontFamily: root.contentFontFamily - } - } - } - } - - // ---- Month grid: week numbers down a gutter on the left, then - // the seven day columns. Always six rows, so the popup is - // exactly as tall in February as it is in August. - Item { - width: parent.width - height: gridColumn.y + gridColumn.height - - WheelHandler { - onWheel: function(event) { - // Horizontal wheels and touchpad side-scrolls report y === 0; - // without this they would every one read as "next month". - if (event.angleDelta.y === 0) return - root.moveMonth(event.angleDelta.y > 0 ? -1 : 1) - } - } - - Column { - id: gridColumn - // The meter above is a solid rule; the grid needs room to - // read as its own block rather than hanging off it. - y: Style.space(18) - anchors.horizontalCenter: parent.horizontalCenter - spacing: Style.space(3) - - Row { - id: headerRow - spacing: root.cellSpacing - - // The week-number heading doubles as the week-start toggle. - // It is the one control in the panel whose meaning is not - // self-evident, so it carries a tooltip naming the day the - // click will switch to. - Rectangle { - width: root.weekColumnWidth - height: Style.space(16) - radius: Style.cornerRadius - color: weekStartMouse.containsMouse - ? Style.hoverFillFor(root.contentForeground, Color.accent) - : "transparent" - - Text { - anchors.centerIn: parent - text: "W" - color: weekStartMouse.containsMouse - ? Style.hoverStateColor(root.contentForeground, Color.accent) - : Qt.darker(root.contentForeground, 1.9) - font.family: root.contentFontFamily - font.pixelSize: Style.font.caption - font.letterSpacing: 1 - font.bold: true - } - - MouseArea { - id: weekStartMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.toggleWeekStart() - } - - PanelToolTip { - visible: weekStartMouse.containsMouse - text: "Start weeks on " + root.nextWeekStartLabel - fontFamily: root.contentFontFamily - } - } - - Item { - width: root.gutterWidth - height: Style.space(16) - } - - Repeater { - model: root.weekdays - - Text { - textFormat: Text.PlainText - required property var modelData - width: root.cellWidth - height: Style.space(16) - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - text: root.weekdayLabel(modelData) - color: Qt.darker(root.contentForeground, 1.5) - font.family: root.contentFontFamily - font.pixelSize: Style.font.caption - font.letterSpacing: 1 - font.bold: true - } - } - } - - Repeater { - model: root.weeks - - Row { - required property var modelData - spacing: root.cellSpacing - - Text { - textFormat: Text.PlainText - width: root.weekColumnWidth - height: root.cellHeight - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - text: modelData.week - color: Qt.darker(root.contentForeground, 1.9) - font.family: root.contentFontFamily - font.pixelSize: Style.font.caption - } - - Item { - width: root.gutterWidth - height: root.cellHeight - } - - Repeater { - model: modelData.days - - Rectangle { - required property var modelData - - width: root.cellWidth - height: root.cellHeight - radius: Style.cornerRadius - // Today is outlined, not filled: a lit-up block shouts - // over a grid this quiet. - color: "transparent" - border.width: modelData.today ? Style.spacing.hairline : 0 - border.color: Style.normalBorderFor(root.contentForeground, Color.accent) - - Text { - textFormat: Text.PlainText - anchors.centerIn: parent - text: modelData.day - color: modelData.inMonth - ? (modelData.weekend ? Qt.darker(root.contentForeground, 1.45) : root.contentForeground) - : Qt.darker(root.contentForeground, 2.2) - font.family: root.contentFontFamily - font.pixelSize: Style.font.body - font.bold: modelData.today - } - } - } - } - } - } - - // Hairline down the week-number gutter, drawn only beside the - // day rows so it does not cut through the header band. - Rectangle { - x: gridColumn.x + root.weekColumnWidth + root.cellSpacing + Math.round((root.gutterWidth - width) / 2) - y: gridColumn.y + headerRow.height + gridColumn.spacing - width: Style.spacing.hairline - height: gridColumn.height - headerRow.height - gridColumn.spacing - color: root.contentForeground - opacity: 0.1 - } - } - - // ---- Month stepping, spanning the grid it drives. The chevrons - // sit on the grid's outer bounds, the same edges the year - // rail above uses, so the row reads as the panel's other - // full-width rail instead of a cluster floating in space. - // The label is centered and fixed-width, so it holds still - // from "MAY" to "SEPTEMBER". - Item { - width: parent.width - height: monthNav.height - - Item { - id: monthNav - anchors.horizontalCenter: parent.horizontalCenter - width: gridColumn.width - height: monthLabel.implicitHeight + Style.space(10) - - Text { - id: monthLabel - textFormat: Text.PlainText - anchors.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: parent.verticalCenter - // Fixed width so the chevrons hold still between a - // "MAY 2026" and a "SEPTEMBER 2026". - width: Style.space(130) - horizontalAlignment: Text.AlignHCenter - text: Qt.formatDate(root.viewDate, "MMMM yyyy").toUpperCase() - color: Qt.darker(root.contentForeground, 1.4) - font.family: root.contentFontFamily - font.pixelSize: Style.font.body - font.letterSpacing: 1 - } - - PanelActionButton { - // Pulled out by the button's own padding so the glyph, not - // its hit box, lines up with the "2026" on the year rail. - anchors.left: parent.left - anchors.leftMargin: -Style.space(8) - anchors.verticalCenter: parent.verticalCenter - iconText: "󰅁" - tooltipText: "Previous month" - foreground: root.contentForeground - fontFamily: root.contentFontFamily - onClicked: root.moveMonth(-1) - } - - PanelActionButton { - anchors.right: parent.right - anchors.rightMargin: -Style.space(8) - anchors.verticalCenter: parent.verticalCenter - iconText: "󰅂" - tooltipText: "Next month" - foreground: root.contentForeground - fontFamily: root.contentFontFamily - onClicked: root.moveMonth(1) - } - } - } + CalendarTabs { + id: tabs + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + selected: root.activeTab === "calendar" ? "calendar" : root.plannerView + foreground: root.contentForeground + fontFamily: root.contentFontFamily + onTabRequested: function(view) { root.selectView(view) } + } + + CalendarView { + id: calendarView + visible: root.activeTab === "calendar" + anchors.top: tabs.bottom + anchors.topMargin: Style.space(14) + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + service: root.service + bar: root.bar + today: root.today + viewYear: root.viewYear + viewMonth: root.viewMonth + weekStart: root.weekStart + editingLife: root.editingLife + birthYear: root.birthYear + lifeExpectancy: root.lifeExpectancy + foreground: root.contentForeground + fontFamily: root.contentFontFamily + onTodayRequested: root.goToToday() + onMonthRequested: function(delta) { root.moveMonth(delta) } + onWeekStartRequested: root.toggleWeekStart() + onPlannerRequested: root.openPlanner() + onLifeEditRequested: root.startEditingLife() + onLifeClearRequested: root.clearLife() + onLifeCommitRequested: function(birth, expectancy) { root.commitLife(birth, expectancy) } + onLifeCancelRequested: root.cancelEditingLife() + } + + Loader { + id: plannerLoader + anchors.top: tabs.bottom + anchors.topMargin: Style.space(14) + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + active: root.activeTab !== "calendar" + source: Qt.resolvedUrl("PlannerView.qml") + onLoaded: { + root.injectPlanner() + Qt.callLater(root.injectPlanner) } } + + Connections { + target: plannerLoader.item + function onCalendarRequested() { root.activeTab = "calendar" } + function onAddTaskRequested() { root.plannerView = "plan" } + function onSettingsRequested() { root.plannerView = "plan" } + function onReviewProposalRequested() { root.plannerView = "plan" } + } } } + + onServiceChanged: injectPlanner() + onPlannerViewChanged: injectPlanner() + onActiveTabChanged: injectPlanner() } diff --git a/shell/plugins/panels/clock/PlannerDateTimeField.qml b/shell/plugins/panels/clock/PlannerDateTimeField.qml new file mode 100644 index 00000000000..67d1f8ca950 --- /dev/null +++ b/shell/plugins/panels/clock/PlannerDateTimeField.qml @@ -0,0 +1,405 @@ +import QtQuick +import QtQuick.Controls +import qs.Commons +import qs.Ui +import "Model.js" as ClockModel + +// Human-friendly date/time input for planner forms. The state layer still +// stores ISO timestamps, but users choose a day from a calendar and only +// enter the small, familiar HH:MM time value. +Item { + id: root + + property string label: "" + property string value: "" + property bool allowEmpty: false + property string emptyLabel: "Choose a date" + property color foreground: Color.foreground + property string fontFamily: Style.font.family + property string errorText: "" + property Item nextFocusTarget: null + + readonly property Item dateFocusTarget: dateButton + readonly property Item timeFocusTarget: timeField + + readonly property bool valid: root.allowEmpty && root.value === "" + ? true + : root.validTime(timeField.text) && root.validValue(root.value) + + signal changed(string value) + signal submitted() + + property date selectedDate: new Date() + property int viewYear: selectedDate.getFullYear() + property int viewMonth: selectedDate.getMonth() + + signal cancelled() + + function pad2(value) { + return (Number(value) < 10 ? "0" : "") + Number(value) + } + + function validValue(value) { + if (value === "") return root.allowEmpty + var parsed = new Date(value) + return !isNaN(parsed.getTime()) + } + + function validTime(value) { + var match = /^(\d{1,2}):([0-5]\d)$/.exec(String(value || "").trim()) + return !!match && Number(match[1]) >= 0 && Number(match[1]) <= 23 + } + + function timeParts(value) { + var match = /^(\d{1,2}):([0-5]\d)$/.exec(String(value || "").trim()) + if (!match || Number(match[1]) > 23) return null + return { hour: Number(match[1]), minute: Number(match[2]) } + } + + function sourceDate() { + if (root.value !== "") { + var parsed = new Date(root.value) + if (!isNaN(parsed.getTime())) return parsed + } + return new Date() + } + + function syncFromValue() { + var source = root.sourceDate() + root.selectedDate = new Date(source.getFullYear(), source.getMonth(), source.getDate()) + root.viewYear = root.selectedDate.getFullYear() + root.viewMonth = root.selectedDate.getMonth() + timeField.text = root.value === "" ? "09:00" : root.pad2(source.getHours()) + ":" + root.pad2(source.getMinutes()) + root.errorText = "" + } + + function displayDate() { + return root.value === "" ? root.emptyLabel : Qt.formatDate(root.selectedDate, "ddd, d MMM yyyy") + } + + function selectedValue() { + var time = root.timeParts(timeField.text) + if (!time) { + root.errorText = "Enter a time as HH:MM, for example 09:00." + timeField.forceActiveFocus() + return false + } + + var localDate = new Date(root.selectedDate.getFullYear(), root.selectedDate.getMonth(), root.selectedDate.getDate(), time.hour, time.minute, 0, 0) + return localDate.toISOString() + } + + function commitSelection() { + var next = root.selectedValue() + if (next === false) return + root.errorText = "" + root.changed(next) + picker.close() + dateButton.forceActiveFocus() + } + + function submit() { + var next = root.selectedValue() + if (next === false) return + root.errorText = "" + root.changed(next) + root.submitted() + } + + function chooseDay(day) { + root.selectedDate = new Date(day.year, day.month, day.day) + root.viewYear = day.year + root.viewMonth = day.month + root.commitSelection() + } + + function stepMonth(delta) { + var next = ClockModel.stepMonth(root.viewYear, root.viewMonth, delta) + root.viewYear = next.year + root.viewMonth = next.month + } + + function moveDay(delta) { + var next = new Date(root.selectedDate) + next.setDate(next.getDate() + Number(delta)) + root.selectedDate = new Date(next.getFullYear(), next.getMonth(), next.getDate()) + root.viewYear = root.selectedDate.getFullYear() + root.viewMonth = root.selectedDate.getMonth() + var field = root + Qt.callLater(function() { field.focusSelectedDay() }) + } + + function focusSelectedDay() { + var wanted = ClockModel.keyForDate(root.selectedDate) + for (var rowIndex = 0; rowIndex < weekRows.count; rowIndex++) { + var row = weekRows.itemAt(rowIndex) + if (!row || !row.children) continue + for (var dayIndex = 0; dayIndex < row.children.length; dayIndex++) { + var day = row.children[dayIndex] + if (day && day.dayKey === wanted) { + day.forceActiveFocus() + return + } + } + } + } + + function chooseToday() { + var today = new Date() + root.selectedDate = new Date(today.getFullYear(), today.getMonth(), today.getDate()) + root.viewYear = root.selectedDate.getFullYear() + root.viewMonth = root.selectedDate.getMonth() + root.commitSelection() + } + + function clearValue() { + root.errorText = "" + root.changed("") + picker.close() + } + + Component.onCompleted: root.syncFromValue() + onValueChanged: root.syncFromValue() + + implicitHeight: form.implicitHeight + + Column { + id: form + width: parent.width + spacing: Style.spacing.labelGap + + Text { + visible: root.label !== "" + textFormat: Text.PlainText + text: root.label + color: Qt.darker(root.foreground, 1.4) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + Row { + width: parent.width + spacing: Style.space(8) + + Button { + id: dateButton + focusable: true + KeyNavigation.tab: timeField + enabled: root.enabled + width: parent.width - timeField.width - parent.spacing + text: root.displayDate() + leftAlign: true + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: { + root.syncFromValue() + picker.open() + } + Keys.onEscapePressed: root.cancelled() + } + + TextField { + id: timeField + activeFocusOnTab: true + KeyNavigation.tab: root.nextFocusTarget + KeyNavigation.backtab: dateButton + width: Style.space(78) + enabled: root.enabled + foreground: root.foreground + font.family: root.fontFamily + placeholderText: "09:00" + inputMethodHints: Qt.ImhTime + onEditingFinished: { + if (root.validTime(text)) { + var next = root.selectedValue() + if (next !== false) root.changed(next) + root.errorText = "" + if (root.nextFocusTarget) root.nextFocusTarget.forceActiveFocus() + } else { + root.errorText = "Enter a time as HH:MM, for example 09:00." + } + } + Keys.onReturnPressed: root.submit() + Keys.onEscapePressed: root.cancelled() + } + } + + Text { + visible: root.errorText !== "" + textFormat: Text.PlainText + text: root.errorText + color: Color.urgent + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } + } + + Popup { + id: picker + x: 0 + y: form.height + Style.space(4) + width: Math.max(root.width, Style.space(300)) + height: pickerContent.implicitHeight + padding * 2 + padding: Style.space(10) + modal: false + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + onOpened: { + pickerContent.forceActiveFocus() + var field = root + Qt.callLater(function() { field.focusSelectedDay() }) + } + + background: BorderSurface { + color: Color.popups.background + borderSpec: Border.localOrSurfaceSpec("popups", "border", Color.popups.border, Color.popups.border, Style.normalBorderWidth) + radius: Style.cornerRadius + } + + contentItem: Column { + id: pickerContent + focus: true + Keys.priority: Keys.BeforeItem + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Left) root.moveDay(-1) + else if (event.key === Qt.Key_Right) root.moveDay(1) + else if (event.key === Qt.Key_Up) root.moveDay(-7) + else if (event.key === Qt.Key_Down) root.moveDay(7) + else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) root.commitSelection() + else return + event.accepted = true + } + spacing: Style.space(7) + + Row { + width: parent.width + spacing: Style.space(5) + + Button { + id: previousMonth + width: Style.space(30) + height: Style.space(28) + focusable: true + text: "‹" + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: root.stepMonth(-1) + } + + Text { + width: parent.width - previousMonth.width - nextMonth.width - parent.spacing * 2 + height: previousMonth.height + textFormat: Text.PlainText + text: Qt.formatDate(new Date(root.viewYear, root.viewMonth, 1), "MMMM yyyy") + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: true + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + Button { + id: nextMonth + width: Style.space(30) + height: Style.space(28) + focusable: true + text: "›" + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: root.stepMonth(1) + } + } + + Row { + spacing: Style.space(2) + Repeater { + model: ["M", "T", "W", "T", "F", "S", "S"] + Text { + required property string modelData + width: Style.space(36) + height: Style.space(18) + textFormat: Text.PlainText + text: modelData + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + horizontalAlignment: Text.AlignHCenter + } + } + } + + Column { + id: dateGrid + spacing: Style.space(2) + Repeater { + id: weekRows + model: ClockModel.monthGrid(root.viewYear, root.viewMonth, 1, ClockModel.keyForDate(new Date())) + Row { + required property var modelData + spacing: Style.space(2) + + Repeater { + model: modelData.days + Button { + required property var modelData + width: Style.space(36) + height: Style.space(30) + focusable: true + property string dayKey: modelData.key + text: String(modelData.day) + enabled: root.enabled + selected: modelData.key === ClockModel.keyForDate(root.selectedDate) + bordered: modelData.today + foreground: modelData.inMonth ? root.foreground : Qt.darker(root.foreground, 2.0) + background: "transparent" + fontFamily: root.fontFamily + fontSize: Style.font.bodySmall + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Left) root.moveDay(-1) + else if (event.key === Qt.Key_Right) root.moveDay(1) + else if (event.key === Qt.Key_Up) root.moveDay(-7) + else if (event.key === Qt.Key_Down) root.moveDay(7) + else return + event.accepted = true + } + onClicked: root.chooseDay(modelData) + } + } + } + } + } + + Row { + spacing: Style.space(8) + + Button { + id: todayButton + focusable: true + text: "Today" + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: root.chooseToday() + } + + Button { + visible: root.allowEmpty + focusable: true + text: "Clear" + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: root.clearValue() + } + } + } + } +} diff --git a/shell/plugins/panels/clock/PlannerModel.js b/shell/plugins/panels/clock/PlannerModel.js new file mode 100644 index 00000000000..0cf7722f0cb --- /dev/null +++ b/shell/plugins/panels/clock/PlannerModel.js @@ -0,0 +1,242 @@ +// Pure planner presentation helpers. Keeping sorting, grouping, formatting, +// and explanation copy here lets the service remain the sole state owner and +// makes the model directly testable in Node. + +var PRIORITY_ORDER = { high: 0, normal: 1, low: 2 } +var LOAD_ORDER = { high: 0, medium: 1, low: 2 } + +function timestamp(value) { + var parsed = Date.parse(value || "") + return isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER +} + +function compareText(left, right) { + return String(left || "").localeCompare(String(right || "")) +} + +function compareTasks(left, right) { + return (PRIORITY_ORDER[left.priority] === undefined ? 9 : PRIORITY_ORDER[left.priority]) - + (PRIORITY_ORDER[right.priority] === undefined ? 9 : PRIORITY_ORDER[right.priority]) || + timestamp(left.deadlineAt) - timestamp(right.deadlineAt) || + (LOAD_ORDER[left.cognitiveLoad] === undefined ? 9 : LOAD_ORDER[left.cognitiveLoad]) - + (LOAD_ORDER[right.cognitiveLoad] === undefined ? 9 : LOAD_ORDER[right.cognitiveLoad]) || + compareText(left.title, right.title) || compareText(left.id, right.id) +} + +function compareEvents(left, right) { + return timestamp(left.startAt) - timestamp(right.startAt) || + timestamp(left.endAt) - timestamp(right.endAt) || + compareText(left.title, right.title) || compareText(left.id, right.id) +} + +function sortedTasks(tasks) { + return (Array.isArray(tasks) ? tasks : []).slice().sort(compareTasks) +} + +function sortedEvents(events) { + return (Array.isArray(events) ? events : []).slice().sort(compareEvents) +} + +function dayKey(value, timezone) { + var parsed = new Date(value) + if (isNaN(parsed.getTime())) return "" + if (typeof Intl !== "undefined" && Intl.DateTimeFormat && timezone) { + try { + var parts = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit" + }).formatToParts(parsed) + var fields = {} + for (var i = 0; i < parts.length; i++) fields[parts[i].type] = parts[i].value + return fields.year + "-" + fields.month + "-" + fields.day + } catch (e) {} + } + return parsed.getFullYear() + "-" + String(parsed.getMonth() + 1).padStart(2, "0") + "-" + String(parsed.getDate()).padStart(2, "0") +} + +function eventsByDay(events, timezone) { + var result = {} + var sorted = sortedEvents(events) + for (var i = 0; i < sorted.length; i++) { + var key = dayKey(sorted[i].startAt, timezone) + if (!key) continue + if (!result[key]) result[key] = [] + result[key].push(sorted[i]) + } + return result +} + +function eventsForDay(events, key, timezone) { + return eventsByDay(events, timezone)[key] || [] +} + +function eventMarkers(events, timezone) { + var grouped = eventsByDay(events, timezone) + var result = {} + for (var key in grouped) result[key] = grouped[key].length + return result +} + +function formatDuration(minutes) { + var value = Math.max(0, Number(minutes) || 0) + var hours = Math.floor(value / 60) + var rest = value % 60 + if (hours && rest) return hours + "h " + rest + "m" + if (hours) return hours + "h" + return rest + "m" +} + +function formatDeadline(value, timezone) { + if (!value) return "No deadline" + var parsed = new Date(value) + if (isNaN(parsed.getTime())) return "Invalid deadline" + var options = { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" } + if (timezone) options.timeZone = timezone + try { return new Intl.DateTimeFormat("en", options).format(parsed) } catch (e) { return parsed.toLocaleString() } +} + +function formatDayLabel(value) { + var match = String(value || "").match(/^(\d{4})-(\d{2})-(\d{2})$/) + if (!match) return String(value || "") + var year = Number(match[1]) + var month = Number(match[2]) - 1 + var day = Number(match[3]) + var date = new Date(year, month, day, 12) + if (isNaN(date.getTime())) return String(value || "") + var weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + var months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] + return weekdays[date.getDay()] + ", " + months[month] + " " + day + ", " + year +} + +function priorityLabel(priority) { + var value = String(priority || "normal") + return value.charAt(0).toUpperCase() + value.slice(1) +} + +function loadLabel(load) { + var value = String(load || "medium") + return value.charAt(0).toUpperCase() + value.slice(1) +} + +function taskSummary(task, timezone) { + return { + id: task.id, + title: task.title, + duration: formatDuration(task.durationMinutes), + deadline: formatDeadline(task.deadlineAt, timezone), + priority: priorityLabel(task.priority), + cognitiveLoad: loadLabel(task.cognitiveLoad), + state: task.state + } +} + +function outcomeLabel(item) { + var outcome = item && item.diagnostics ? item.diagnostics.outcome : "" + if (outcome === "scheduled") return "Scheduled" + if (outcome === "no_hard_feasible_slot") return "No feasible slot" + if (outcome === "feasible_but_not_selected") return "Not selected" + return "Needs review" +} + +function explanation(item) { + if (!item) return "No scheduling explanation available." + if (item.explanation) return String(item.explanation) + if (item.diagnostics && item.diagnostics.outcome === "no_hard_feasible_slot") + return "No slot satisfies the hard timing or busy-time constraints." + if (item.diagnostics && item.diagnostics.outcome === "feasible_but_not_selected") + return "A feasible slot exists, but another task was selected first." + return item.scheduled ? "Scheduled within the configured availability." : "Needs review." +} + +function proposalItemForTask(proposal, taskId) { + var items = proposal && Array.isArray(proposal.items) ? proposal.items : [] + for (var i = 0; i < items.length; i++) if (items[i] && items[i].taskId === taskId) return items[i] + return null +} + +function scheduledItems(proposal) { + var items = proposal && Array.isArray(proposal.items) ? proposal.items : [] + return items.filter(function(item) { return item && item.scheduled === true }).slice().sort(function(a, b) { + return timestamp(a.startAt) - timestamp(b.startAt) || compareText(a.taskId, b.taskId) + }) +} + +function unscheduledItems(proposal) { + var items = proposal && Array.isArray(proposal.items) ? proposal.items : [] + return items.filter(function(item) { return !item || item.scheduled !== true }).slice().sort(function(a, b) { + return compareText(a && a.taskId, b && b.taskId) + }) +} + +function proposalSummary(proposal) { + var items = proposal && Array.isArray(proposal.items) ? proposal.items : [] + var scheduled = items.filter(function(item) { return item && item.scheduled }).length + return { scheduled: scheduled, total: items.length, unscheduled: items.length - scheduled } +} + +function penaltySummary(item) { + return { + cognitive: Number(item && item.cognitivePenalty || 0), + fatigue: Number(item && item.fatiguePenalty || 0) + } +} + +function solveStateLabel(state) { + var labels = { + configuration_needed: "Configuration needed", + idle: "Idle", + queued: "Queued", + solving: "Solving", + ready: "Ready to review", + error: "Planner error" + } + return labels[String(state || "")] || "Planner" +} + +function applicabilityReasons(proposal, inputRevision) { + var reasons = [] + function add(reason) { + if (reason && reasons.indexOf(reason) === -1) reasons.push(reason) + } + var stored = proposal && Array.isArray(proposal.applicabilityReasons) + ? proposal.applicabilityReasons + : [] + for (var i = 0; i < stored.length; i++) add(stored[i]) + if (!proposal) return reasons + if (proposal.status === "stale") + add(proposal.staleReason === "inputs_changed" + ? "Planning inputs changed; generate a new proposal before applying." + : "This proposal is stale and cannot be applied.") + if (inputRevision !== undefined && Number(proposal.baseInputRevision) !== Number(inputRevision)) + add("Planning inputs changed since this proposal was generated.") + return reasons +} + +if (typeof module !== "undefined") { + module.exports = { + PRIORITY_ORDER: PRIORITY_ORDER, + sortedTasks: sortedTasks, + sortedEvents: sortedEvents, + dayKey: dayKey, + eventsByDay: eventsByDay, + eventsForDay: eventsForDay, + eventMarkers: eventMarkers, + formatDuration: formatDuration, + formatDeadline: formatDeadline, + formatDayLabel: formatDayLabel, + priorityLabel: priorityLabel, + loadLabel: loadLabel, + taskSummary: taskSummary, + outcomeLabel: outcomeLabel, + explanation: explanation, + proposalItemForTask: proposalItemForTask, + scheduledItems: scheduledItems, + unscheduledItems: unscheduledItems, + proposalSummary: proposalSummary, + penaltySummary: penaltySummary, + solveStateLabel: solveStateLabel, + applicabilityReasons: applicabilityReasons + } +} diff --git a/shell/plugins/panels/clock/PlannerSettings.qml b/shell/plugins/panels/clock/PlannerSettings.qml new file mode 100644 index 00000000000..bf8c5c50148 --- /dev/null +++ b/shell/plugins/panels/clock/PlannerSettings.qml @@ -0,0 +1,413 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Commons +import qs.Ui + +// Planner settings are persisted through Service.qml. The advanced section is +// intentionally collapsed so first-run setup only asks for timezone and +// availability, while every planning setting remains reachable and editable. +Item { + id: root + + property var service: null + property var bar: null + property color foreground: bar ? bar.foreground : Color.foreground + property string fontFamily: bar ? bar.fontFamily : Style.font.family + property string errorText: "" + property bool advanced: false + property bool availabilityOpen: false + property int horizonDays: 14 + property int slotMinutes: 15 + property int solveSeconds: 5 + property int priorityLowWeight: 1 + property int priorityNormalWeight: 5 + property int priorityHighWeight: 25 + property bool cognitiveEnabled: false + property int lowOutsidePenalty: 0 + property int mediumOutsidePenalty: 0 + property int highOutsidePenalty: 0 + property int highStreakLimit: 1 + property int recoveryMinutes: 30 + property int excessHighPenalty: 60 + + signal saved() + signal cancelled() + + // The popup grows with the form when space allows. On shorter screens only + // the settings body scrolls; the action row stays pinned in view. + implicitHeight: root.availabilityOpen && availabilityLoader.item && availabilityLoader.item.implicitHeight > 0 + ? availabilityLoader.item.implicitHeight + : form.implicitHeight + footer.implicitHeight + Style.space(44) + + function settings() { + return root.service && root.service.calendarState + ? root.service.calendarState.settings + : {} + } + + function reset() { + var value = settings() + timezoneField.text = value.timezone || "" + lowStartField.text = value.lowWindowStart || "00:00" + lowEndField.text = value.lowWindowEnd || "00:00" + mediumStartField.text = value.mediumWindowStart || "00:00" + mediumEndField.text = value.mediumWindowEnd || "00:00" + highStartField.text = value.highWindowStart || "00:00" + highEndField.text = value.highWindowEnd || "00:00" + horizonDays = Number(value.horizonDays || 14) + slotMinutes = Number(value.slotMinutes || 15) + solveSeconds = Number(value.solveSeconds || 5) + priorityLowWeight = Number(value.priorityLowWeight || 0) + priorityNormalWeight = Number(value.priorityNormalWeight || 0) + priorityHighWeight = Number(value.priorityHighWeight || 0) + cognitiveEnabled = !!value.cognitiveEnabled + lowOutsidePenalty = Number(value.lowOutsidePenalty || 0) + mediumOutsidePenalty = Number(value.mediumOutsidePenalty || 0) + highOutsidePenalty = Number(value.highOutsidePenalty || 0) + highStreakLimit = Number(value.highStreakLimit || 1) + recoveryMinutes = Number(value.recoveryMinutes || 0) + excessHighPenalty = Number(value.excessHighPenalty || 0) + errorText = "" + } + + function availabilityCount() { + var value = settings().availability || {} + var count = 0 + for (var key in value) if (Array.isArray(value[key])) count += value[key].length + return count + } + + function detectedTimezone() { + try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "" } + catch (error) { return "" } + } + + function save() { + var patch = { + timezone: timezoneField.text.trim(), + horizonDays: horizonDays, + slotMinutes: slotMinutes, + solveSeconds: solveSeconds, + priorityLowWeight: priorityLowWeight, + priorityNormalWeight: priorityNormalWeight, + priorityHighWeight: priorityHighWeight, + cognitiveEnabled: cognitiveEnabled, + lowWindowStart: lowStartField.text.trim(), + lowWindowEnd: lowEndField.text.trim(), + lowOutsidePenalty: lowOutsidePenalty, + mediumWindowStart: mediumStartField.text.trim(), + mediumWindowEnd: mediumEndField.text.trim(), + mediumOutsidePenalty: mediumOutsidePenalty, + highWindowStart: highStartField.text.trim(), + highWindowEnd: highEndField.text.trim(), + highOutsidePenalty: highOutsidePenalty, + highStreakLimit: highStreakLimit, + recoveryMinutes: recoveryMinutes, + excessHighPenalty: excessHighPenalty + } + var next = root.service ? root.service.updateSettings(patch) : null + if (next) root.saved() + else root.errorText = root.service ? root.service.lastSolverError : "Settings could not be saved." + } + + Component.onCompleted: { + reset() + Qt.callLater(function() { timezoneField.forceActiveFocus() }) + } + onServiceChanged: reset() + + Rectangle { + anchors.fill: parent + color: Color.popups.background + border.width: Style.spacing.hairline + border.color: Color.popups.border + radius: Style.cornerRadius + } + + Item { + id: viewport + anchors.fill: parent + anchors.margins: Style.space(18) + clip: true + + ScrollView { + id: bodyScroll + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: footer.top + anchors.bottomMargin: Style.space(10) + contentWidth: form.width + contentHeight: form.implicitHeight + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: form.implicitHeight > height ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff + + Column { + id: form + width: Math.max(viewport.width, Style.space(390)) + spacing: Style.space(9) + + Text { + textFormat: Text.PlainText + text: "Planner settings" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.display + font.bold: true + } + Text { + textFormat: Text.PlainText + text: "Omarchy uses this local configuration to suggest task times." + color: Qt.darker(root.foreground, 1.45) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + Text { textFormat: Text.PlainText; text: "Timezone"; color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.caption } + TextField { + id: timezoneField + width: parent.width + foreground: root.foreground + font.family: root.fontFamily + placeholderText: "Europe/Rome" + Keys.onReturnPressed: root.save() + Keys.onEscapePressed: root.cancelled() + } + Text { + textFormat: Text.PlainText + visible: timezoneField.text.trim() === "" && root.detectedTimezone() !== "" + text: "Detected timezone: " + root.detectedTimezone() + " (suggestion; save to use)" + color: Qt.darker(root.foreground, 1.45) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + Row { + width: parent.width + spacing: Style.space(8) + NumberField { + width: (parent.width - Style.space(16)) / 3 + label: "Horizon days" + value: root.horizonDays + from: 1 + to: 90 + foreground: root.foreground + onModified: root.horizonDays = value + } + NumberField { + width: (parent.width - Style.space(16)) / 3 + label: "Slot minutes" + value: root.slotMinutes + from: 5 + to: 120 + foreground: root.foreground + onModified: root.slotMinutes = value + } + NumberField { + width: (parent.width - Style.space(16)) / 3 + label: "Planning time limit" + value: root.solveSeconds + from: 1 + to: 120 + foreground: root.foreground + onModified: root.solveSeconds = value + } + } + + RowLayout { + width: parent.width + Text { + id: availabilityLabel + textFormat: Text.PlainText + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + text: availabilityCount() + " weekly availability window" + (availabilityCount() === 1 ? "" : "s") + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + elide: Text.ElideRight + } + Button { + focusable: true + id: availabilityButton + Layout.alignment: Qt.AlignVCenter + text: "Edit availability" + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: root.availabilityOpen = true + } + } + + Button { + focusable: true + text: root.advanced ? "Advanced settings ▴" : "Advanced settings ▾" + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: root.advanced = !root.advanced + } + + Column { + visible: root.advanced + width: parent.width + spacing: Style.space(9) + + Text { + textFormat: Text.PlainText + text: "Priority weights" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: true + } + Row { + width: parent.width + spacing: Style.space(8) + NumberField { + width: (parent.width - Style.space(16)) / 3 + label: "Low" + value: root.priorityLowWeight + from: 0 + to: 100000 + foreground: root.foreground + onModified: root.priorityLowWeight = value + } + NumberField { + width: (parent.width - Style.space(16)) / 3 + label: "Normal" + value: root.priorityNormalWeight + from: 0 + to: 100000 + foreground: root.foreground + onModified: root.priorityNormalWeight = value + } + NumberField { + width: (parent.width - Style.space(16)) / 3 + label: "High" + value: root.priorityHighWeight + from: 0 + to: 100000 + foreground: root.foreground + onModified: root.priorityHighWeight = value + } + } + + Row { + width: parent.width + spacing: Style.space(8) + Text { + textFormat: Text.PlainText + text: "Use cognitive-load timing preferences" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + anchors.verticalCenter: parent.verticalCenter + } + ToggleSwitch { + checked: root.cognitiveEnabled + foreground: root.foreground + onToggled: root.cognitiveEnabled = !root.cognitiveEnabled + } + } + + Text { textFormat: Text.PlainText; text: "Cognitive windows"; color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.body; font.bold: true } + Row { + width: parent.width + spacing: Style.space(6) + Text { textFormat: Text.PlainText; text: "Low"; width: Style.space(38); color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.caption; anchors.verticalCenter: parent.verticalCenter } + TextField { id: lowStartField; width: Style.space(90); foreground: root.foreground; font.family: root.fontFamily; placeholderText: "00:00" } + Text { textFormat: Text.PlainText; text: "—"; color: root.foreground; anchors.verticalCenter: parent.verticalCenter } + TextField { id: lowEndField; width: Style.space(90); foreground: root.foreground; font.family: root.fontFamily; placeholderText: "00:00" } + NumberField { width: Style.space(100); label: "Penalty"; value: root.lowOutsidePenalty; from: 0; to: 100000; foreground: root.foreground; onModified: root.lowOutsidePenalty = value } + } + Row { + width: parent.width + spacing: Style.space(6) + Text { textFormat: Text.PlainText; text: "Medium"; width: Style.space(38); color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.caption; anchors.verticalCenter: parent.verticalCenter } + TextField { id: mediumStartField; width: Style.space(90); foreground: root.foreground; font.family: root.fontFamily; placeholderText: "00:00" } + Text { textFormat: Text.PlainText; text: "—"; color: root.foreground; anchors.verticalCenter: parent.verticalCenter } + TextField { id: mediumEndField; width: Style.space(90); foreground: root.foreground; font.family: root.fontFamily; placeholderText: "00:00" } + NumberField { width: Style.space(100); label: "Penalty"; value: root.mediumOutsidePenalty; from: 0; to: 100000; foreground: root.foreground; onModified: root.mediumOutsidePenalty = value } + } + Row { + width: parent.width + spacing: Style.space(6) + Text { textFormat: Text.PlainText; text: "High"; width: Style.space(38); color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.caption; anchors.verticalCenter: parent.verticalCenter } + TextField { id: highStartField; width: Style.space(90); foreground: root.foreground; font.family: root.fontFamily; placeholderText: "00:00" } + Text { textFormat: Text.PlainText; text: "—"; color: root.foreground; anchors.verticalCenter: parent.verticalCenter } + TextField { id: highEndField; width: Style.space(90); foreground: root.foreground; font.family: root.fontFamily; placeholderText: "00:00" } + NumberField { width: Style.space(100); label: "Penalty"; value: root.highOutsidePenalty; from: 0; to: 100000; foreground: root.foreground; onModified: root.highOutsidePenalty = value } + } + + Text { textFormat: Text.PlainText; text: "Recovery"; color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.body; font.bold: true } + Row { + width: parent.width + spacing: Style.space(8) + NumberField { width: (parent.width - Style.space(16)) / 3; label: "High streak limit"; value: root.highStreakLimit; from: 1; to: 100; foreground: root.foreground; onModified: root.highStreakLimit = value } + NumberField { width: (parent.width - Style.space(16)) / 3; label: "Recovery minutes"; value: root.recoveryMinutes; from: 0; to: 1440; foreground: root.foreground; onModified: root.recoveryMinutes = value } + NumberField { width: (parent.width - Style.space(16)) / 3; label: "Excess penalty"; value: root.excessHighPenalty; from: 0; to: 100000; foreground: root.foreground; onModified: root.excessHighPenalty = value } + } + } + + Text { + textFormat: Text.PlainText + visible: root.errorText !== "" + text: root.errorText + color: Color.urgent + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } + + } + } + + Row { + id: footer + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + spacing: Style.space(8) + Button { + focusable: true + text: "Save settings" + foreground: activeFocus || hot ? Color.foreground : Color.background + background: Color.accent + fontFamily: root.fontFamily + onClicked: root.save() + } + Button { + focusable: true + text: "Cancel" + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: root.cancelled() + } + } + } + + Loader { + id: availabilityLoader + anchors.fill: parent + active: root.availabilityOpen + source: Qt.resolvedUrl("AvailabilityEditor.qml") + onLoaded: { + item.service = root.service + item.bar = root.bar + } + } + + Connections { + target: availabilityLoader.item + function onSaved() { + root.availabilityOpen = false + root.reset() + } + function onCancelled() { root.availabilityOpen = false } + } +} diff --git a/shell/plugins/panels/clock/PlannerView.qml b/shell/plugins/panels/clock/PlannerView.qml new file mode 100644 index 00000000000..ad8b4e068fd --- /dev/null +++ b/shell/plugins/panels/clock/PlannerView.qml @@ -0,0 +1,430 @@ +import QtQuick +import QtQuick.Controls +import qs.Commons +import qs.Ui +import "." +import "PlannerModel.js" as PlannerModel + +Item { + id: root + + property var service: null + property var bar: null + property string activeView: "plan" + property string editorMode: "" + property var editorEvent: null + property var editorTask: null + readonly property string planningTutorialKey: "planningFlow" + readonly property bool planningTutorialVisible: root.activeView === "plan" + && (!root.service || !root.service.tutorialDismissed(root.planningTutorialKey)) + readonly property real editorImplicitHeight: editorLoader.item && Number(editorLoader.item.implicitHeight) > 0 + ? Number(editorLoader.item.implicitHeight) + : 0 + property color foreground: bar ? bar.foreground : Color.foreground + property string fontFamily: bar ? bar.fontFamily : Style.font.family + signal calendarRequested() + signal addTaskRequested() + signal settingsRequested() + signal reviewProposalRequested() + + implicitHeight: contentColumn.implicitHeight + Style.space(24) + + function state() { + return root.service && root.service.calendarState + ? root.service.calendarState + : { settings: {}, events: [], tasks: [], proposal: null } + } + + function proposal() { return state().proposal } + function proposalSummary() { return PlannerModel.proposalSummary(proposal()) } + function showProposal() { + var value = root.proposal() + return value !== null && root.service && root.service.hasInboxTasks + } + + function planningFlow() { + if (!root.service || !root.service.configured) + return "1 Open Settings and choose your timezone and weekly availability. 2 Add a planning task. 3 Plan tasks, review the suggestion, and apply it." + if (!root.service.hasInboxTasks) + return "1 Add a planning task. 2 Choose Plan tasks. 3 Review the suggested schedule, then apply it." + return "1 Add or edit tasks. 2 Choose Plan tasks. 3 Review the suggested schedule, then apply it. The calendar changes only when you apply." + } + + function openEditor(mode, value) { + root.editorEvent = mode === "event" ? value : null + root.editorTask = mode === "task" ? value : null + root.editorMode = mode + } + + function closeEditor() { + root.editorMode = "" + root.editorEvent = null + root.editorTask = null + } + + function cancelEditor() { + root.closeEditor() + } + + function focusFirst() { + if (root.editorMode !== "") return + if (root.activeView === "agenda") agendaEventButton.forceActiveFocus() + else addTaskButton.forceActiveFocus() + } + + Keys.onEscapePressed: { + if (root.editorMode !== "") root.cancelEditor() + else root.calendarRequested() + } + + ScrollView { + id: scroll + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + contentWidth: contentColumn.width + contentHeight: contentColumn.implicitHeight + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: contentColumn.implicitHeight > height ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff + + Column { + id: contentColumn + width: scroll.availableWidth + spacing: Style.space(14) + + Item { + width: parent.width + height: titleColumn.implicitHeight + + Column { + id: titleColumn + anchors.left: parent.left + anchors.right: parent.right + spacing: Style.space(4) + + Text { + textFormat: Text.PlainText + text: root.activeView === "agenda" ? "Agenda" : "Planner inbox" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.display + font.bold: true + } + + Text { + textFormat: Text.PlainText + text: root.activeView === "agenda" + ? "Events are fixed calendar time; tasks to schedule live in Plan." + : root.service && root.service.configured + ? "Tasks are work to schedule. Events are fixed busy time. Apply Omarchy's suggested schedule when you are ready." + : (root.service ? root.service.setupMessage + " Tasks are work to schedule; events are fixed busy time." : "Planner service is loading.") + color: Qt.darker(root.foreground, 1.45) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } + } + } + + Row { + width: parent.width + spacing: Style.space(8) + + Button { + focusable: true + id: addTaskButton + visible: root.activeView === "plan" + text: "Add planning task" + foreground: activeFocus || hot ? Color.foreground : Color.background + background: Color.accent + fontFamily: root.fontFamily + onClicked: root.openEditor("task", null) + } + + Button { + focusable: true + id: planTasksButton + visible: root.activeView === "plan" + enabled: !!root.service && root.service.solveState !== "solving" + text: root.service && root.service.solveState === "solving" ? "Planning…" : "Plan tasks" + foreground: activeFocus || hot ? Color.foreground : Color.background + background: Color.accent + fontFamily: root.fontFamily + onClicked: if (root.service) root.service.planNow() + } + + Button { + focusable: true + id: agendaEventButton + visible: root.activeView === "agenda" + text: "Add calendar event" + foreground: activeFocus || hot ? Color.foreground : Color.background + background: Color.accent + fontFamily: root.fontFamily + onClicked: root.openEditor("event", null) + } + + Button { + focusable: true + visible: root.activeView === "plan" + text: "Settings" + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: root.openEditor("settings", null) + } + } + + Rectangle { + visible: root.planningTutorialVisible + width: parent.width + height: flowColumn.implicitHeight + Style.space(20) + radius: Style.cornerRadius + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.06) + + Column { + id: flowColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + spacing: Style.space(4) + + Row { + id: flowHeading + width: parent.width + spacing: Style.space(4) + + Text { + textFormat: Text.PlainText + text: "How planning works" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: true + width: flowHeading.width - dismissPlanningFlowButton.width - flowHeading.spacing + elide: Text.ElideRight + } + + PanelActionButton { + id: dismissPlanningFlowButton + iconText: "×" + tooltipText: "Dismiss planning help" + foreground: root.foreground + hoverColor: root.foreground + fontFamily: root.fontFamily + focusable: true + onClicked: if (root.service) root.service.dismissTutorial(root.planningTutorialKey) + } + } + Text { + textFormat: Text.PlainText + text: root.planningFlow() + color: Qt.darker(root.foreground, 1.35) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } + } + } + + Text { + textFormat: Text.PlainText + visible: root.activeView === "plan" && root.service && root.service.lastSolverError !== "" + text: root.service ? root.service.lastSolverError : "" + color: Color.urgent + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } + + AgendaView { + visible: root.activeView === "agenda" + width: parent.width + service: root.service + bar: root.bar + onEditEventRequested: function(event) { root.openEditor("event", event) } + } + + Column { + visible: root.activeView === "plan" + width: parent.width + spacing: Style.space(6) + + Text { + textFormat: Text.PlainText + visible: root.service && root.service.solveState === "solving" + text: "Omarchy is planning your tasks…" + color: Color.accent + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + Repeater { + model: PlannerModel.sortedTasks(root.state().tasks.filter(function(task) { return task.state === "inbox" })) + delegate: Rectangle { + required property var modelData + width: parent.width + height: taskText.implicitHeight + Style.space(18) + radius: Style.cornerRadius + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.06) + + Column { + id: taskText + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + spacing: Style.space(3) + Row { + width: parent.width + spacing: Style.space(8) + Text { + textFormat: Text.PlainText + text: modelData.title + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: true + elide: Text.ElideRight + width: parent.width - duration.implicitWidth - parent.spacing + } + Text { + textFormat: Text.PlainText + id: duration + text: PlannerModel.formatDuration(modelData.durationMinutes) + color: Qt.darker(root.foreground, 1.45) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + Button { + focusable: true + text: "Edit" + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: root.openEditor("task", modelData) + } + } + Text { + textFormat: Text.PlainText + text: PlannerModel.priorityLabel(modelData.priority) + " · " + + PlannerModel.loadLabel(modelData.cognitiveLoad) + " · " + + PlannerModel.formatDeadline(modelData.deadlineAt, root.state().settings.timezone) + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + } + } + } + + Text { + textFormat: Text.PlainText + visible: root.state().tasks.filter(function(task) { return task.state === "inbox" }).length === 0 + text: "No tasks in the inbox." + color: Qt.darker(root.foreground, 1.45) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + Rectangle { + visible: root.showProposal() + width: parent.width + height: proposalText.implicitHeight + Style.space(24) + radius: Style.cornerRadius + color: Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.10) + border.width: Style.spacing.hairline + border.color: Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.45) + + Column { + id: proposalText + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + spacing: Style.space(4) + Text { + textFormat: Text.PlainText + text: "Suggested schedule" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: true + } + Text { + textFormat: Text.PlainText + text: { + var summary = root.proposalSummary() + return summary.scheduled + " of " + summary.total + " inbox tasks scheduled" + } + color: Qt.darker(root.foreground, 1.3) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + Text { + textFormat: Text.PlainText + text: root.service && root.service.solveState === "ready" + ? "Ready to review — the calendar is unchanged until you apply." + : (root.service ? root.service.lastSolverError : "") + color: root.service && root.service.solveState === "error" ? Color.urgent : root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } + Button { + focusable: true + text: "Review schedule" + foreground: activeFocus || hot ? Color.foreground : Color.background + background: Color.accent + fontFamily: root.fontFamily + onClicked: if (root.showProposal()) root.openEditor("proposal", null) + } + } + } + } + } + } + + Loader { + id: editorLoader + anchors.fill: parent + active: root.editorMode !== "" + source: root.editorMode === "task" + ? Qt.resolvedUrl("TaskEditor.qml") + : root.editorMode === "event" + ? Qt.resolvedUrl("EventEditor.qml") + : root.editorMode === "settings" + ? Qt.resolvedUrl("PlannerSettings.qml") + : Qt.resolvedUrl("ProposalReview.qml") + onLoaded: { + item.service = root.service + item.bar = root.bar + if (root.editorMode === "task" && "task" in item) item.task = root.editorTask + if (root.editorMode === "event" && "event" in item) item.event = root.editorEvent + } + } + + Connections { + ignoreUnknownSignals: true + target: editorLoader.item && (("saved" in editorLoader.item) || ("cancelled" in editorLoader.item)) + ? editorLoader.item + : null + function onSaved() { root.closeEditor() } + function onCancelled() { root.closeEditor() } + } + + onServiceChanged: if (editorLoader.item) editorLoader.item.service = root.service + onBarChanged: if (editorLoader.item) editorLoader.item.bar = root.bar + onEditorModeChanged: if (root.editorMode === "") Qt.callLater(root.focusFirst) + onActiveViewChanged: if (root.editorMode === "") Qt.callLater(root.focusFirst) +} diff --git a/shell/plugins/panels/clock/ProposalReview.qml b/shell/plugins/panels/clock/ProposalReview.qml new file mode 100644 index 00000000000..9ebd5f1450d --- /dev/null +++ b/shell/plugins/panels/clock/ProposalReview.qml @@ -0,0 +1,309 @@ +import QtQuick +import qs.Commons +import qs.Ui +import "PlannerModel.js" as PlannerModel + +// Schedule review is the explicit commit boundary. It displays every planning +// item, including unscheduled explanations, and never changes the calendar +// until Apply is pressed. +Item { + id: root + + property var service: null + property var bar: null + property color foreground: bar ? bar.foreground : Color.foreground + property string fontFamily: bar ? bar.fontFamily : Style.font.family + property string message: "" + signal cancelled() + signal saved() + + function state() { + return root.service && root.service.calendarState + ? root.service.calendarState + : { settings: {}, tasks: [], proposal: null } + } + + function proposal() { return state().proposal } + + function taskFor(id) { + var tasks = state().tasks || [] + for (var i = 0; i < tasks.length; i++) if (tasks[i].id === id) return tasks[i] + return { title: id, durationMinutes: 0 } + } + + function currentReady() { + var value = proposal() + return value && value.status === "ready" && Number(value.baseInputRevision) === state().inputRevision + } + + function applicabilityReasons() { + return PlannerModel.applicabilityReasons(root.proposal(), state().inputRevision) + } + + function apply() { + var result = root.service ? root.service.applyProposal() : null + if (result) { + root.message = "Applied. Planner events are now on the calendar." + root.saved() + } + else root.message = root.service ? root.service.lastSolverError : "The suggested schedule could not be applied." + } + + Component.onCompleted: Qt.callLater(function() { applyButton.forceActiveFocus() }) + + Rectangle { + anchors.fill: parent + color: Color.popups.background + border.width: Style.spacing.hairline + border.color: Color.popups.border + radius: Style.cornerRadius + } + + Flickable { + id: scroll + anchors.fill: parent + anchors.margins: Style.space(18) + contentWidth: form.width + contentHeight: form.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + interactive: contentHeight > height + + Column { + id: form + width: Math.max(scroll.width, Style.space(390)) + spacing: Style.space(9) + + Row { + width: parent.width + Text { + textFormat: Text.PlainText + width: parent.width - closeButton.width + text: "Review suggested schedule" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.display + font.bold: true + } + Button { + focusable: true + id: closeButton + text: "Close" + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: root.cancelled() + } + } + + Text { + textFormat: Text.PlainText + visible: root.proposal() !== null + text: { + var summary = PlannerModel.proposalSummary(root.proposal()) + var proposalValue = root.proposal() || {} + return summary.scheduled + " scheduled · " + summary.unscheduled + " still in the inbox" + + " · " + (proposalValue.timezone || "") + } + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + } + Text { + textFormat: Text.PlainText + visible: root.proposal() !== null + text: "This is a preview from Omarchy. The calendar stays unchanged until you choose Apply schedule." + color: Qt.darker(root.foreground, 1.35) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } + Text { + textFormat: Text.PlainText + visible: root.proposal() === null + text: "No suggested schedule is available yet. Add an inbox task and configure planning settings." + color: Qt.darker(root.foreground, 1.45) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } + + Column { + visible: root.proposal() !== null && root.applicabilityReasons().length > 0 + width: parent.width + spacing: Style.space(3) + Repeater { + model: root.applicabilityReasons() + delegate: Text { + required property string modelData + textFormat: Text.PlainText + text: modelData + color: root.currentReady() ? Qt.darker(root.foreground, 1.4) : Color.urgent + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } + } + } + + Text { + textFormat: Text.PlainText + visible: root.proposal() !== null + text: "Scheduled" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: true + } + Repeater { + model: PlannerModel.scheduledItems(root.proposal()) + delegate: Rectangle { + required property var modelData + width: form.width + height: scheduledColumn.implicitHeight + Style.space(16) + radius: Style.cornerRadius + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.06) + + Column { + id: scheduledColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + spacing: Style.space(3) + Text { + textFormat: Text.PlainText + text: root.taskFor(modelData.taskId).title + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: true + } + Text { + textFormat: Text.PlainText + text: Qt.formatDateTime(new Date(modelData.startAt), "ddd d MMM HH:mm") + + " — " + Qt.formatDateTime(new Date(modelData.endAt), "HH:mm") + color: Qt.darker(root.foreground, 1.35) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + Text { + textFormat: Text.PlainText + text: "Priority " + PlannerModel.priorityLabel(root.taskFor(modelData.taskId).priority) + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + Button { + focusable: true + text: "Return to inbox" + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: root.service.returnToInbox(modelData.taskId) + } + } + } + } + + Text { + textFormat: Text.PlainText + visible: root.proposal() !== null && PlannerModel.scheduledItems(root.proposal()).length === 0 + text: "No task received a feasible slot." + color: Qt.darker(root.foreground, 1.45) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + Text { + textFormat: Text.PlainText + visible: root.proposal() !== null + text: "Unscheduled and diagnostics" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: true + } + Repeater { + model: PlannerModel.unscheduledItems(root.proposal()) + delegate: Rectangle { + required property var modelData + width: form.width + height: unscheduledColumn.implicitHeight + Style.space(16) + radius: Style.cornerRadius + color: Qt.rgba(Color.urgent.r, Color.urgent.g, Color.urgent.b, 0.08) + + Column { + id: unscheduledColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + spacing: Style.space(3) + Text { + textFormat: Text.PlainText + text: root.taskFor(modelData.taskId).title + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: true + } + Text { + textFormat: Text.PlainText + text: PlannerModel.outcomeLabel(modelData) + color: Color.urgent + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + Text { + textFormat: Text.PlainText + text: PlannerModel.explanation(modelData) + color: Qt.darker(root.foreground, 1.35) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } + Text { + textFormat: Text.PlainText + text: { + var penalty = PlannerModel.penaltySummary(modelData) + return "Penalties · cognitive " + penalty.cognitive + " · fatigue " + penalty.fatigue + } + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + } + } + + Text { + textFormat: Text.PlainText + visible: root.message !== "" + text: root.message + color: root.currentReady() ? root.foreground : Color.accent + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } + + Button { + focusable: true + id: applyButton + text: "Apply schedule" + enabled: root.currentReady() + foreground: activeFocus || hot ? Color.foreground : Color.background + background: Color.accent + fontFamily: root.fontFamily + onClicked: root.apply() + } + } + } +} diff --git a/shell/plugins/panels/clock/Service.qml b/shell/plugins/panels/clock/Service.qml new file mode 100644 index 00000000000..2fde5f991d2 --- /dev/null +++ b/shell/plugins/panels/clock/Service.qml @@ -0,0 +1,494 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "State.js" as State + +// The calendar service is the only owner of planner state. Panels call the +// immutable State.js reducers through this object. Planning is local and +// one-shot; this service sends a bounded JSON request to the packaged solver +// and never starts a daemon or accesses the network. +Item { + id: root + + property var shell: null + property string omarchyPath: Quickshell.env("OMARCHY_PATH") + property var manifest: null + + property var calendarState: State.baseState() + property bool stateLoaded: false + property bool stateDirectoryReady: false + property bool _hydrating: false + property string lastInputFingerprint: "" + property string lastPersistedJson: "" + + property string solveState: "configuration_needed" + property string lastSolverError: "" + property string activeRequestId: "" + property int activeRequestRevision: -1 + property bool forcePlanRequested: false + property string pendingPersistJson: "" + property string lastPlanningDay: "" + readonly property string solverPath: { + var override = Quickshell.env("OMARCHY_CALENDAR_SOLVER") + return override && String(override).trim() !== "" + ? String(override) + : "omarchy-calendar-solver" + } + property string pendingSolveJson: "" + property string solverStdoutText: "" + property string solverStderrText: "" + property bool solveAgain: false + property bool expectedSolverStop: false + + readonly property string stateHome: { + var configured = Quickshell.env("XDG_STATE_HOME") + return configured && configured !== "" + ? configured + : (Quickshell.env("HOME") || "") + "/.local/state" + } + readonly property string statePath: stateHome + "/omarchy/calendar.json" + readonly property bool configured: State.settingsReady(calendarState.settings).ok + readonly property bool hasInboxTasks: State.allTasksInInbox(calendarState) + readonly property bool solving: debounceTimer.running || solverProcess.running + readonly property string setupMessage: configured + ? "Add a task to generate a suggested schedule." + : "Choose a timezone and at least one availability window to enable planning." + + function parseState(raw) { + if (!raw || String(raw).trim() === "") return State.baseState() + try { return State.normalizeState(JSON.parse(String(raw))) } + catch (error) { + console.warn("calendar: state parse failed:", error) + return State.baseState() + } + } + + function loadState(raw) { + var next = parseState(raw) + var reconciled = State.reconcileLinkedEvents(next, true) + next = reconciled.state + var fingerprint = State.problemFingerprint(next) + var previousFingerprint = root.lastInputFingerprint + root._hydrating = true + root.calendarState = next + root.lastInputFingerprint = fingerprint + root.stateLoaded = true + root._hydrating = false + + if (root.stateDirectoryReady && (reconciled.changed || String(raw || "").trim() === "")) root.persistState() + if (previousFingerprint !== "" && previousFingerprint !== fingerprint) root.scheduleSolve() + else if (previousFingerprint === "" && root.shouldSolve()) root.scheduleSolve() + } + + function persistState() { + if (!root.stateLoaded) return + var json = JSON.stringify(root.calendarState, null, 2) + "\n" + root.lastPersistedJson = json + root.lastInputFingerprint = State.problemFingerprint(root.calendarState) + root.pendingPersistJson = json + persistTimer.restart() + } + + function shouldSolve() { + if (!root.stateLoaded || !root.configured || !root.hasInboxTasks) return false + var proposal = root.calendarState.proposal + return root.forcePlanRequested + || !proposal + || proposal.status !== "ready" + || Number(proposal.baseInputRevision) !== root.calendarState.inputRevision + } + + function planNow() { + if (!root.stateLoaded) { + root.lastSolverError = "The calendar service is still loading." + return false + } + if (!root.configured) { + root.lastSolverError = "Open Settings and choose a timezone and weekly availability before planning." + root.solveState = "configuration_needed" + return false + } + if (!root.hasInboxTasks) { + root.lastSolverError = "Add a planning task before choosing Plan tasks." + root.solveState = "idle" + return false + } + root.forcePlanRequested = true + root.lastSolverError = "" + root.scheduleSolve() + return true + } + + function scheduleSolve() { + if (!root.stateLoaded) return + if (!root.configured || !root.hasInboxTasks) { + root.solveState = root.configured ? "idle" : "configuration_needed" + root.lastSolverError = "" + return + } + root.solveState = "queued" + debounceTimer.restart() + } + + function startSolve() { + if (solverProcess.running) { + root.solveAgain = true + root.expectedSolverStop = true + solverProcess.running = false + return + } + if (!root.shouldSolve()) { + root.solveState = root.configured ? "idle" : "configuration_needed" + return + } + var requestId = State.newId("solve", Date.now()) + root.activeRequestId = requestId + root.activeRequestRevision = root.calendarState.inputRevision + var request = { + protocolVersion: 1, + requestId: requestId, + baseInputRevision: root.calendarState.inputRevision, + now: new Date().toISOString(), + settings: root.calendarState.settings, + events: root.calendarState.events, + tasks: root.calendarState.tasks, + dependencies: root.calendarState.dependencies + } + root.lastSolverError = "" + root.solveState = "solving" + root.pendingSolveJson = JSON.stringify(request) + root.solverStdoutText = "" + root.solverStderrText = "" + root.expectedSolverStop = false + solverProcess.command = [root.solverPath] + solverProcess.running = true + } + + function commit(next) { + if (next === root.calendarState) return next + root.calendarState = next + root.persistState() + root.scheduleSolve() + return next + } + + function tutorialDismissed(key) { + return State.tutorialDismissed(root.calendarState, key) + } + + function dismissTutorial(key) { + try { + var next = State.dismissTutorial(root.calendarState, key) + root.calendarState = next + root.persistState() + return true + } catch (error) { + root.lastSolverError = error.message + return false + } + } + + function addEvent(input) { + try { return root.commit(State.addEvent(root.calendarState, input, new Date())) } + catch (error) { root.lastSolverError = error.message; return null } + } + + function updateEvent(id, patch) { + try { return root.commit(State.updateEvent(root.calendarState, id, patch, new Date())) } + catch (error) { root.lastSolverError = error.message; return null } + } + + function deleteEvent(id) { + try { return root.commit(State.deleteEvent(root.calendarState, id)) } + catch (error) { root.lastSolverError = error.message; return null } + } + + function addTask(input) { + try { return root.commit(State.addTask(root.calendarState, input, new Date())) } + catch (error) { root.lastSolverError = error.message; return null } + } + + function updateTask(id, patch) { + try { return root.commit(State.updateTask(root.calendarState, id, patch, new Date())) } + catch (error) { root.lastSolverError = error.message; return null } + } + + function deleteTask(id) { + try { return root.commit(State.deleteTask(root.calendarState, id)) } + catch (error) { root.lastSolverError = error.message; return null } + } + + function updateSettings(patch) { + try { return root.commit(State.updateSettings(root.calendarState, patch, new Date())) } + catch (error) { root.lastSolverError = error.message; return null } + } + + function addDependency(fromTaskId, toTaskId) { + try { return root.commit(State.addDependency(root.calendarState, fromTaskId, toTaskId)) } + catch (error) { root.lastSolverError = error.message; return null } + } + + function deleteDependency(fromTaskId, toTaskId) { + try { return root.commit(State.deleteDependency(root.calendarState, fromTaskId, toTaskId)) } + catch (error) { root.lastSolverError = error.message; return null } + } + + function replaceTaskDependencies(taskId, predecessorIds) { + try { return root.commit(State.replaceTaskDependencies(root.calendarState, taskId, predecessorIds)) } + catch (error) { root.lastSolverError = error.message; return null } + } + + function applyProposal() { + try { + var next = State.applyProposal(root.calendarState, new Date()) + return root.commit(next) + } catch (error) { root.lastSolverError = error.message; return null } + } + + function returnToInbox(taskId) { + try { return root.commit(State.returnToInbox(root.calendarState, taskId, new Date())) } + catch (error) { root.lastSolverError = error.message; return null } + } + + // The CLI is a client of this service rather than a second state owner. It + // sends JSON strings through IPC so the exact same reducers, revision rules, + // stale-proposal checks, and atomic persistence serve both interfaces. + function ipcState() { + return { + state: root.calendarState, + loaded: root.stateLoaded, + configured: root.configured, + solveState: root.solveState, + error: root.lastSolverError, + errorOutput: "" + } + } + + function ipcResponse(next) { + if (!next) return JSON.stringify({ ok: false, error: root.lastSolverError || "calendar operation failed" }) + return JSON.stringify({ ok: true, state: root.calendarState }) + } + + function ipcJson(raw, expectArray) { + var value + try { value = JSON.parse(String(raw || "")) } + catch (error) { root.lastSolverError = "The calendar command received invalid JSON."; return null } + if (expectArray ? !Array.isArray(value) : !value || typeof value !== "object" || Array.isArray(value)) { + root.lastSolverError = "The calendar command received the wrong JSON value type." + return null + } + return value + } + + IpcHandler { + target: "omarchy.calendar" + + function status(): string { return JSON.stringify(root.ipcState()) } + function state(): string { return JSON.stringify({ ok: true, state: root.calendarState }) } + + function addEvent(inputJson: string): string { + var input = root.ipcJson(inputJson, false) + return input ? root.ipcResponse(root.addEvent(input)) : root.ipcResponse(null) + } + + function updateEvent(id: string, patchJson: string): string { + var patch = root.ipcJson(patchJson, false) + return patch ? root.ipcResponse(root.updateEvent(id, patch)) : root.ipcResponse(null) + } + + function deleteEvent(id: string): string { return root.ipcResponse(root.deleteEvent(id)) } + + function addTask(inputJson: string): string { + var input = root.ipcJson(inputJson, false) + return input ? root.ipcResponse(root.addTask(input)) : root.ipcResponse(null) + } + + function updateTask(id: string, patchJson: string): string { + var patch = root.ipcJson(patchJson, false) + return patch ? root.ipcResponse(root.updateTask(id, patch)) : root.ipcResponse(null) + } + + function deleteTask(id: string): string { return root.ipcResponse(root.deleteTask(id)) } + + function setSettings(patchJson: string): string { + var patch = root.ipcJson(patchJson, false) + return patch ? root.ipcResponse(root.updateSettings(patch)) : root.ipcResponse(null) + } + + function addDependency(fromTaskId: string, toTaskId: string): string { + return root.ipcResponse(root.addDependency(fromTaskId, toTaskId)) + } + + function deleteDependency(fromTaskId: string, toTaskId: string): string { + return root.ipcResponse(root.deleteDependency(fromTaskId, toTaskId)) + } + + function setDependencies(taskId: string, predecessorJson: string): string { + var predecessors = root.ipcJson(predecessorJson, true) + return predecessors ? root.ipcResponse(root.replaceTaskDependencies(taskId, predecessors)) : root.ipcResponse(null) + } + + function recompute(): string { + return root.planNow() ? JSON.stringify(root.ipcState()) : root.ipcResponse(null) + } + + function plan(): string { + return root.planNow() ? JSON.stringify(root.ipcState()) : root.ipcResponse(null) + } + + function apply(): string { return root.ipcResponse(root.applyProposal()) } + function returnToInbox(taskId: string): string { return root.ipcResponse(root.returnToInbox(taskId)) } + } + + FileView { + id: stateFile + path: root.statePath + watchChanges: true + atomicWrites: true + printErrors: false + onLoaded: root.loadState(text()) + onFileChanged: reload() + onLoadFailed: root.loadState("") + } + + Process { + id: ensureStateDirectoryProcess + command: ["mkdir", "-p", root.stateHome + "/omarchy"] + onExited: function(exitCode) { + if (exitCode !== 0) { + root.solveState = "error" + root.lastSolverError = "The planner could not create its local state directory." + return + } + root.stateDirectoryReady = true + if (root.stateLoaded && root.lastPersistedJson === "") root.persistState() + stateFile.reload() + } + } + + Timer { + id: persistTimer + interval: 100 + repeat: false + onTriggered: { + if (root.pendingPersistJson !== "") { + stateFile.setText(root.pendingPersistJson) + root.pendingPersistJson = "" + } + } + } + + Timer { + id: debounceTimer + interval: 250 + repeat: false + onTriggered: root.startSolve() + } + + Process { + id: solverProcess + stdinEnabled: true + + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: if (!root.expectedSolverStop) root.solverStdoutText = String(text || "") + } + stderr: StdioCollector { + waitForEnd: true + onStreamFinished: if (!root.expectedSolverStop) root.solverStderrText = String(text || "") + } + + onStarted: write(root.pendingSolveJson + "\n") + + onExited: function(exitCode) { + var superseded = root.expectedSolverStop + if (root.solveAgain) { + root.solveAgain = false + root.solveState = "queued" + // Keep expectedSolverStop set until the replacement starts. Buffered + // output from the canceled process must not become the new result. + Qt.callLater(root.startSolve) + return + } + + if (superseded) return + + var requestId = root.activeRequestId + var requestRevision = root.activeRequestRevision + var stderr = String(root.solverStderrText || "").trim() + if (stderr !== "") console.warn("calendar planner solver:", stderr) + + if (exitCode !== 0) { + root.forcePlanRequested = false + root.activeRequestId = "" + root.solveState = "error" + root.lastSolverError = "Omarchy could not generate a suggested schedule." + return + } + + var response + try { + response = JSON.parse(String(root.solverStdoutText || "").trim()) + } catch (error) { + root.forcePlanRequested = false + root.activeRequestId = "" + root.solveState = "error" + root.lastSolverError = "Omarchy could not read the planner result." + console.warn("calendar planner: invalid solver response") + return + } + + if (!response || response.requestId !== requestId) { + root.solveState = "queued" + root.scheduleSolve() + return + } + if (requestRevision !== root.calendarState.inputRevision) { + root.solveState = "queued" + root.scheduleSolve() + return + } + if (!response.ok || !response.proposal) { + root.forcePlanRequested = false + root.activeRequestId = "" + root.solveState = "error" + root.lastSolverError = "Omarchy could not generate a suggested schedule." + return + } + + try { + root.calendarState = State.writeProposal(root.calendarState, response.proposal) + root.forcePlanRequested = false + root.activeRequestId = "" + root.solveState = "ready" + root.persistState() + } catch (error) { + root.forcePlanRequested = false + root.activeRequestId = "" + root.solveState = "error" + root.lastSolverError = "Omarchy could not save the planner result." + console.warn("calendar planner:", error) + } + } + } + + SystemClock { + id: planningClock + precision: SystemClock.Minutes + onDateChanged: { + var planningDay = Qt.formatDate(planningClock.date, "yyyy-MM-dd") + if (root.lastPlanningDay === "") { + root.lastPlanningDay = planningDay + } else if (root.lastPlanningDay !== planningDay) { + root.lastPlanningDay = planningDay + root.scheduleSolve() + } + } + } + + Component.onCompleted: { + ensureStateDirectoryProcess.running = true + root.lastPlanningDay = Qt.formatDate(new Date(), "yyyy-MM-dd") + Qt.callLater(function() { stateFile.reload() }) + } +} diff --git a/shell/plugins/panels/clock/State.js b/shell/plugins/panels/clock/State.js new file mode 100644 index 00000000000..b53a850f7b9 --- /dev/null +++ b/shell/plugins/panels/clock/State.js @@ -0,0 +1,872 @@ +// Native calendar state model. +// +// This module deliberately has no QML, filesystem, or process concerns. QML +// owns persistence and calls these immutable reducers; Node can load the same +// functions for fast state and migration tests. + +var SCHEMA_VERSION = 1 +var WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] +var EVENT_ORIGINS = ["manual", "planner"] +var TASK_PRIORITIES = ["low", "normal", "high"] +var COGNITIVE_LOADS = ["low", "medium", "high"] +var DEADLINE_KINDS = ["none", "hard", "soft"] +var TASK_STATES = ["inbox", "applied", "missing_event"] +var PROPOSAL_STATUSES = ["ready", "stale", "applied"] + +var DEFAULT_SETTINGS = { + timezone: "", + availability: {}, + horizonDays: 14, + slotMinutes: 15, + solveSeconds: 5, + priorityLowWeight: 1, + priorityNormalWeight: 5, + priorityHighWeight: 25, + cognitiveEnabled: false, + lowWindowStart: "00:00", + lowWindowEnd: "00:00", + lowOutsidePenalty: 0, + mediumWindowStart: "00:00", + mediumWindowEnd: "00:00", + mediumOutsidePenalty: 0, + highWindowStart: "00:00", + highWindowEnd: "00:00", + highOutsidePenalty: 0, + highStreakLimit: 1, + recoveryMinutes: 30, + excessHighPenalty: 60 +} + +var idCounter = 0 + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)) +} + +function own(object, key) { + return Object.prototype.hasOwnProperty.call(object, key) +} + +function error(code, path, message) { + var problem = new Error(message || code) + problem.code = code + problem.path = path || "" + return problem +} + +function fail(code, path, message) { + throw error(code, path, message) +} + +function nowIso(value) { + var date + if (value instanceof Date) date = value + else if (typeof value === "number") date = new Date(value) + else if (typeof value === "string" && value !== "") date = new Date(value) + else date = new Date() + if (isNaN(date.getTime())) date = new Date() + return date.toISOString() +} + +function newId(kind, now, randomValue) { + idCounter += 1 + var millis = now === undefined || now === null ? Date.now() : Number(now) + if (!isFinite(millis)) millis = Date.now() + var random = randomValue === undefined || randomValue === null + ? Math.random().toString(36).slice(2, 10) + : String(randomValue).replace(/[^a-zA-Z0-9]/g, "").slice(0, 16) + if (!random) random = "0" + return String(kind) + "-" + Math.floor(millis) + "-" + idCounter + "-" + random +} + +function validClock(value) { + if (typeof value !== "string") return false + var match = value.match(/^(\d{2}):(\d{2})$/) + if (!match) return false + return Number(match[1]) < 24 && Number(match[2]) < 60 +} + +function normalizeClock(value) { + if (typeof value !== "string") return null + var text = value.trim() + var match = text.match(/^(\d{1,2}):(\d{1,2})$/) + if (!match) return null + var hours = Number(match[1]) + var minutes = Number(match[2]) + if (hours >= 24 || minutes >= 60) return null + return (hours < 10 ? "0" : "") + hours + ":" + (minutes < 10 ? "0" : "") + minutes +} + +function validTimezone(value) { + if (typeof value !== "string" || value.trim() === "") return false + if (value === "UTC" || value === "Etc/UTC") return true + try { + // Intl is available in Node and current QML JavaScript engines. The + // fallback keeps the model useful in minimal test harnesses. + if (typeof Intl !== "undefined" && Intl.DateTimeFormat) { + Intl.DateTimeFormat("en-US", { timeZone: value }).format() + return true + } + } catch (e) { + return false + } + return /^[A-Za-z0-9._+-]+\/[A-Za-z0-9._+\/-]+$/.test(value) +} + +function validTimestamp(value, path) { + if (typeof value !== "string" || value.trim() === "") + fail("invalid_timestamp", path, "timestamp must be an ISO-8601 string") + var time = Date.parse(value) + if (!isFinite(time)) fail("invalid_timestamp", path, "timestamp is not valid ISO-8601") + return value +} + +function validId(value, path) { + if (typeof value !== "string" || value.trim() === "") fail("invalid_id", path, "id is required") + return value +} + +function defaultSettings() { + return clone(DEFAULT_SETTINGS) +} + +function normalizeAvailability(value) { + var result = {} + if (!isObject(value)) return result + + for (var i = 0; i < WEEKDAYS.length; i++) { + var day = WEEKDAYS[i] + if (!Array.isArray(value[day])) continue + var windows = [] + for (var j = 0; j < value[day].length; j++) { + var window = value[day][j] + if (!isObject(window)) continue + var start = normalizeClock(window.start) + var end = normalizeClock(window.end) + if (start !== null && end !== null && start < end) + windows.push({ start: start, end: end }) + } + if (windows.length > 0) { + windows.sort(function(a, b) { + return a.start.localeCompare(b.start) || a.end.localeCompare(b.end) + }) + result[day] = windows + } + } + return result +} + +function normalizeSettings(value) { + var input = isObject(value) ? value : {} + var result = defaultSettings() + for (var key in result) { + if (own(input, key)) result[key] = clone(input[key]) + } + result.timezone = typeof input.timezone === "string" ? input.timezone.trim() : "" + result.availability = normalizeAvailability(input.availability) + + var integerKeys = [ + "horizonDays", "slotMinutes", "solveSeconds", + "priorityLowWeight", "priorityNormalWeight", "priorityHighWeight", + "lowOutsidePenalty", "mediumOutsidePenalty", "highOutsidePenalty", + "highStreakLimit", "recoveryMinutes", "excessHighPenalty" + ] + for (var i = 0; i < integerKeys.length; i++) { + var integerKey = integerKeys[i] + var number = Number(result[integerKey]) + result[integerKey] = isFinite(number) ? Math.round(number) : DEFAULT_SETTINGS[integerKey] + } + result.cognitiveEnabled = !!result.cognitiveEnabled + + var clockKeys = [ + "lowWindowStart", "lowWindowEnd", "mediumWindowStart", "mediumWindowEnd", + "highWindowStart", "highWindowEnd" + ] + for (var c = 0; c < clockKeys.length; c++) { + var clockKey = clockKeys[c] + var normalized = normalizeClock(result[clockKey]) + result[clockKey] = normalized === null ? DEFAULT_SETTINGS[clockKey] : normalized + } + return result +} + +function configuredAvailability(settings) { + var availability = settings && settings.availability + if (!isObject(availability)) return 0 + var count = 0 + for (var i = 0; i < WEEKDAYS.length; i++) { + var windows = availability[WEEKDAYS[i]] + if (Array.isArray(windows)) count += windows.length + } + return count +} + +function validateSettings(value, requireReady) { + var settings = normalizeSettings(value) + var problems = [] + var ranges = [ + ["horizonDays", 1, 90], ["slotMinutes", 5, 120], ["solveSeconds", 1, 120], + ["priorityLowWeight", 0, Infinity], ["priorityNormalWeight", 0, Infinity], + ["priorityHighWeight", 0, Infinity], ["lowOutsidePenalty", 0, Infinity], + ["mediumOutsidePenalty", 0, Infinity], ["highOutsidePenalty", 0, Infinity], + ["highStreakLimit", 1, Infinity], ["recoveryMinutes", 0, Infinity], + ["excessHighPenalty", 0, Infinity] + ] + for (var i = 0; i < ranges.length; i++) { + var range = ranges[i] + if (!Number.isInteger(settings[range[0]]) || settings[range[0]] < range[1] || settings[range[0]] > range[2]) + problems.push({ code: "out_of_range", path: "settings." + range[0] }) + } + + var clockKeys = [ + "lowWindowStart", "lowWindowEnd", "mediumWindowStart", "mediumWindowEnd", + "highWindowStart", "highWindowEnd" + ] + for (var c = 0; c < clockKeys.length; c++) { + if (!validClock(settings[clockKeys[c]])) problems.push({ code: "invalid_clock", path: "settings." + clockKeys[c] }) + } + var windowsByLoad = [ + ["lowWindowStart", "lowWindowEnd"], + ["mediumWindowStart", "mediumWindowEnd"], + ["highWindowStart", "highWindowEnd"] + ] + for (var w = 0; w < windowsByLoad.length; w++) { + if (settings[windowsByLoad[w][1]] <= settings[windowsByLoad[w][0]] && + (settings[windowsByLoad[w][0]] !== "00:00" || settings[windowsByLoad[w][1]] !== "00:00")) + problems.push({ code: "invalid_window", path: "settings." + windowsByLoad[w][0] }) + } + + if (settings.timezone !== "" && !validTimezone(settings.timezone)) + problems.push({ code: "invalid_timezone", path: "settings.timezone" }) + if (requireReady && settings.timezone === "") + problems.push({ code: "timezone_required", path: "settings.timezone" }) + if (requireReady && configuredAvailability(settings) === 0) + problems.push({ code: "availability_required", path: "settings.availability" }) + + if (isObject(value) && isObject(value.availability)) { + for (var day in value.availability) { + if (WEEKDAYS.indexOf(day) === -1) + problems.push({ code: "unknown_weekday", path: "settings.availability." + day }) + else if (!Array.isArray(value.availability[day])) + problems.push({ code: "invalid_day_windows", path: "settings.availability." + day }) + else { + for (var n = 0; n < value.availability[day].length; n++) { + var rawWindow = value.availability[day][n] + if (!isObject(rawWindow) || !validClock(rawWindow.start) || !validClock(rawWindow.end) || rawWindow.end <= rawWindow.start) + problems.push({ code: "invalid_availability_window", path: "settings.availability." + day + "[" + n + "]" }) + } + } + } + } + return { ok: problems.length === 0, settings: settings, problems: problems } +} + +function settingsReady(value) { + return validateSettings(value, true) +} + +function defaultUi() { + return { dismissedTutorials: {} } +} + +function normalizeUi(value) { + var result = defaultUi() + if (!isObject(value) || !isObject(value.dismissedTutorials)) return result + for (var key in value.dismissedTutorials) { + if (key !== "" && value.dismissedTutorials[key] === true) + result.dismissedTutorials[key] = true + } + return result +} + +function baseState() { + return { + schemaVersion: SCHEMA_VERSION, + inputRevision: 0, + settings: defaultSettings(), + events: [], + tasks: [], + dependencies: [], + proposal: null, + ui: defaultUi() + } +} + +function normalizeEvent(value) { + if (!isObject(value)) return null + if (typeof value.id !== "string" || value.id === "") return null + if (typeof value.title !== "string" || value.title.trim() === "") return null + if (typeof value.startAt !== "string" || !isFinite(Date.parse(value.startAt))) return null + if (typeof value.endAt !== "string" || !isFinite(Date.parse(value.endAt))) return null + if (Date.parse(value.endAt) <= Date.parse(value.startAt)) return null + if (!validTimezone(value.timezone)) return null + if (EVENT_ORIGINS.indexOf(value.origin) === -1) return null + var event = { + id: value.id, + title: value.title, + description: typeof value.description === "string" ? value.description : "", + startAt: value.startAt, + endAt: value.endAt, + timezone: value.timezone, + allDay: !!value.allDay, + rrule: typeof value.rrule === "string" && value.rrule !== "" ? value.rrule : null, + origin: value.origin, + taskId: typeof value.taskId === "string" ? value.taskId : null, + proposalId: typeof value.proposalId === "string" ? value.proposalId : null, + createdAt: typeof value.createdAt === "string" ? value.createdAt : nowIso(), + updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : nowIso() + } + return event +} + +function normalizeTask(value) { + if (!isObject(value)) return null + if (typeof value.id !== "string" || value.id === "") return null + if (typeof value.title !== "string" || value.title.trim() === "") return null + var duration = Number(value.durationMinutes) + if (!Number.isInteger(duration) || duration <= 0) return null + if (TASK_PRIORITIES.indexOf(value.priority) === -1) return null + if (COGNITIVE_LOADS.indexOf(value.cognitiveLoad) === -1) return null + if (DEADLINE_KINDS.indexOf(value.deadlineKind) === -1) return null + if (TASK_STATES.indexOf(value.state) === -1) return null + if (value.earliestAt !== undefined && value.earliestAt !== null && !isFinite(Date.parse(value.earliestAt))) return null + if (value.deadlineKind === "none" && value.deadlineAt !== undefined && value.deadlineAt !== null) return null + if (value.deadlineKind !== "none" && (!value.deadlineAt || !isFinite(Date.parse(value.deadlineAt)))) return null + return { + id: value.id, + title: value.title, + durationMinutes: duration, + priority: value.priority, + cognitiveLoad: value.cognitiveLoad, + earliestAt: value.earliestAt || null, + deadlineKind: value.deadlineKind, + deadlineAt: value.deadlineAt || null, + state: value.state, + linkedEventId: value.linkedEventId || null, + createdAt: typeof value.createdAt === "string" ? value.createdAt : nowIso(), + updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : nowIso() + } +} + +function dependencyKey(dependency) { + return dependency.fromTaskId + "\u0000" + dependency.toTaskId +} + +function hasDependencyCycle(tasks, dependencies) { + var outgoing = {} + for (var i = 0; i < dependencies.length; i++) { + var dependency = dependencies[i] + if (!outgoing[dependency.fromTaskId]) outgoing[dependency.fromTaskId] = [] + outgoing[dependency.fromTaskId].push(dependency.toTaskId) + } + var visiting = {} + var visited = {} + function visit(id) { + if (visiting[id]) return true + if (visited[id]) return false + visiting[id] = true + var next = outgoing[id] || [] + for (var i = 0; i < next.length; i++) if (visit(next[i])) return true + delete visiting[id] + visited[id] = true + return false + } + for (var t = 0; t < tasks.length; t++) if (visit(tasks[t].id)) return true + return false +} + +function normalizeProposal(value) { + if (!isObject(value) || typeof value.id !== "string" || value.id === "") return null + if (PROPOSAL_STATUSES.indexOf(value.status) === -1) return null + var proposal = clone(value) + proposal.applicabilityReasons = Array.isArray(proposal.applicabilityReasons) ? proposal.applicabilityReasons : [] + proposal.items = Array.isArray(proposal.items) ? proposal.items : [] + return proposal +} + +function normalizeState(value) { + var state = baseState() + if (!isObject(value)) return state + state.inputRevision = Number.isInteger(value.inputRevision) && value.inputRevision >= 0 ? value.inputRevision : 0 + state.settings = normalizeSettings(value.settings) + + var seenEvents = {} + if (Array.isArray(value.events)) { + for (var i = 0; i < value.events.length; i++) { + var event = normalizeEvent(value.events[i]) + if (event && !seenEvents[event.id]) { + seenEvents[event.id] = true + state.events.push(event) + } + } + } + var seenTasks = {} + if (Array.isArray(value.tasks)) { + for (var t = 0; t < value.tasks.length; t++) { + var task = normalizeTask(value.tasks[t]) + if (task && !seenTasks[task.id]) { + seenTasks[task.id] = true + state.tasks.push(task) + } + } + } + var seenDependencies = {} + if (Array.isArray(value.dependencies)) { + for (var d = 0; d < value.dependencies.length; d++) { + var rawDependency = value.dependencies[d] + if (!isObject(rawDependency)) continue + var dependency = { fromTaskId: rawDependency.fromTaskId, toTaskId: rawDependency.toTaskId } + var key = dependencyKey(dependency) + if (typeof dependency.fromTaskId !== "string" || typeof dependency.toTaskId !== "string" || + dependency.fromTaskId === dependency.toTaskId || !seenTasks[dependency.fromTaskId] || + !seenTasks[dependency.toTaskId] || seenDependencies[key]) continue + var candidate = state.dependencies.concat([dependency]) + if (hasDependencyCycle(state.tasks, candidate)) continue + seenDependencies[key] = true + state.dependencies.push(dependency) + } + } + state.proposal = normalizeProposal(value.proposal) + state.ui = normalizeUi(value.ui) + return state +} + +function tutorialDismissed(state, key) { + if (typeof key !== "string" || key === "") return false + var current = normalizeState(state) + return current.ui.dismissedTutorials[key] === true +} + +function dismissTutorial(state, key) { + if (typeof key !== "string" || key.trim() === "") + fail("invalid_tutorial_key", "ui.dismissedTutorials", "tutorial key is required") + var current = normalizeState(state) + if (current.ui.dismissedTutorials[key] === true) return current + var next = clone(current) + next.ui.dismissedTutorials[key] = true + return next +} + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize) + if (isObject(value)) { + var result = {} + var keys = Object.keys(value).sort() + for (var i = 0; i < keys.length; i++) result[keys[i]] = canonicalize(value[keys[i]]) + return result + } + return value +} + +function canonicalProblem(state) { + var normalized = normalizeState(state) + return canonicalize({ + settings: normalized.settings, + events: normalized.events.slice().sort(function(a, b) { return a.id.localeCompare(b.id) }), + tasks: normalized.tasks.slice().sort(function(a, b) { return a.id.localeCompare(b.id) }), + dependencies: normalized.dependencies.slice().sort(function(a, b) { + return dependencyKey(a).localeCompare(dependencyKey(b)) + }) + }) +} + +function canonicalJson(value) { + return JSON.stringify(canonicalize(value)) +} + +function problemFingerprint(state) { + var text = canonicalJson(canonicalProblem(state)) + // FNV-1a keeps the fingerprint compact without requiring Node-only crypto; + // the canonical JSON is exported for diagnostics and collision-resistant + // request construction can include it when needed. + var hash = 2166136261 + for (var i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0).toString(16).padStart(8, "0") +} + +function materialEventFields(event) { + return { + title: event.title, + description: event.description || "", + startAt: event.startAt, + endAt: event.endAt, + timezone: event.timezone, + allDay: !!event.allDay, + rrule: event.rrule || null + } +} + +function sameValue(a, b) { + return canonicalJson(a) === canonicalJson(b) +} + +function proposalForTask(state, taskId) { + if (!state.proposal || !Array.isArray(state.proposal.items)) return null + for (var i = 0; i < state.proposal.items.length; i++) + if (state.proposal.items[i] && state.proposal.items[i].taskId === taskId) return state.proposal.items[i] + return null +} + +function staleProposal(proposal, reason) { + if (!proposal || proposal.status === "applied") return proposal + var result = clone(proposal) + result.status = "stale" + result.staleReason = reason || "inputs_changed" + return result +} + +function commitInput(state, next, reason) { + next.inputRevision = state.inputRevision + 1 + next.proposal = staleProposal(next.proposal, reason) + return next +} + +function entityTimes(entity, now) { + var stamp = nowIso(now) + if (!entity.createdAt) entity.createdAt = stamp + entity.updatedAt = stamp + return entity +} + +function eventFromInput(input, now) { + var value = clone(input || {}) + if (!value.id) value.id = newId("event", Date.now()) + if (!value.origin) value.origin = "manual" + if (!value.description) value.description = "" + if (!value.rrule) value.rrule = null + if (!value.taskId) value.taskId = null + if (!value.proposalId) value.proposalId = null + entityTimes(value, now) + var event = normalizeEvent(value) + if (!event) fail("invalid_event", "event", "event is invalid") + return event +} + +function taskFromInput(input, now) { + var value = clone(input || {}) + if (!value.id) value.id = newId("task", Date.now()) + if (!value.priority) value.priority = "normal" + if (!value.cognitiveLoad) value.cognitiveLoad = "medium" + if (!value.deadlineKind) value.deadlineKind = "none" + if (!value.state) value.state = "inbox" + if (!value.linkedEventId) value.linkedEventId = null + if (!value.earliestAt) value.earliestAt = null + if (!value.deadlineAt) value.deadlineAt = null + entityTimes(value, now) + var task = normalizeTask(value) + if (!task) fail("invalid_task", "task", "task is invalid") + if (task.state !== "inbox") fail("invalid_task_state", "task.state", "new tasks must start in the inbox") + return task +} + +function addEvent(state, input, now) { + var current = normalizeState(state) + var event = eventFromInput(input, now) + for (var i = 0; i < current.events.length; i++) if (current.events[i].id === event.id) fail("duplicate_id", "event.id") + var next = clone(current) + next.events.push(event) + return commitInput(current, next, "event_changed") +} + +function updateEvent(state, id, patch, now) { + var current = normalizeState(state) + var next = clone(current) + var found = false + for (var i = 0; i < next.events.length; i++) { + if (next.events[i].id !== id) continue + found = true + var candidate = next.events[i] + for (var key in patch || {}) if (key !== "id" && key !== "createdAt" && key !== "updatedAt") candidate[key] = clone(patch[key]) + candidate.updatedAt = nowIso(now) + var normalized = normalizeEvent(candidate) + if (!normalized) fail("invalid_event", "event") + next.events[i] = normalized + break + } + if (!found) fail("missing_event", "event.id") + return reconcileLinkedEvents(commitInput(current, next, "event_changed"), false).state +} + +function deleteEvent(state, id) { + var current = normalizeState(state) + var next = clone(current) + var before = next.events.length + next.events = next.events.filter(function(event) { return event.id !== id }) + if (next.events.length === before) fail("missing_event", "event.id") + return reconcileLinkedEvents(commitInput(current, next, "event_changed"), false).state +} + +function addTask(state, input, now) { + var current = normalizeState(state) + var task = taskFromInput(input, now) + for (var i = 0; i < current.tasks.length; i++) if (current.tasks[i].id === task.id) fail("duplicate_id", "task.id") + var next = clone(current) + next.tasks.push(task) + return commitInput(current, next, "task_changed") +} + +function updateTask(state, id, patch, now) { + var current = normalizeState(state) + var next = clone(current) + var found = false + for (var i = 0; i < next.tasks.length; i++) { + if (next.tasks[i].id !== id) continue + found = true + if (next.tasks[i].state === "applied" || next.tasks[i].state === "missing_event") + fail("applied_task_locked", "task.state", "return the task to the inbox before editing it") + var candidate = next.tasks[i] + for (var key in patch || {}) if (key !== "id" && key !== "createdAt" && key !== "updatedAt" && key !== "state" && key !== "linkedEventId") candidate[key] = clone(patch[key]) + candidate.updatedAt = nowIso(now) + var normalized = normalizeTask(candidate) + if (!normalized) fail("invalid_task", "task") + next.tasks[i] = normalized + break + } + if (!found) fail("missing_task", "task.id") + return commitInput(current, next, "task_changed") +} + +function deleteTask(state, id) { + var current = normalizeState(state) + var task = null + for (var i = 0; i < current.tasks.length; i++) if (current.tasks[i].id === id) task = current.tasks[i] + if (!task) fail("missing_task", "task.id") + if (task.state === "applied") fail("applied_task_locked", "task.state", "return the task to the inbox before deleting it") + var next = clone(current) + next.tasks = next.tasks.filter(function(candidate) { return candidate.id !== id }) + next.dependencies = next.dependencies.filter(function(dependency) { + return dependency.fromTaskId !== id && dependency.toTaskId !== id + }) + return commitInput(current, next, "task_changed") +} + +function updateSettings(state, patch, now) { + var current = normalizeState(state) + var candidate = clone(current.settings) + for (var key in patch || {}) candidate[key] = clone(patch[key]) + var validation = validateSettings(candidate, false) + if (!validation.ok) fail(validation.problems[0].code, validation.problems[0].path, "settings are invalid") + var next = clone(current) + next.settings = validation.settings + return commitInput(current, next, "settings_changed") +} + +function addDependency(state, fromTaskId, toTaskId) { + var current = normalizeState(state) + if (fromTaskId === toTaskId) fail("self_dependency", "dependency") + var taskIds = {} + for (var i = 0; i < current.tasks.length; i++) taskIds[current.tasks[i].id] = true + if (!taskIds[fromTaskId] || !taskIds[toTaskId]) fail("missing_task", "dependency") + var dependency = { fromTaskId: fromTaskId, toTaskId: toTaskId } + for (var d = 0; d < current.dependencies.length; d++) + if (dependencyKey(current.dependencies[d]) === dependencyKey(dependency)) fail("duplicate_dependency", "dependency") + var next = clone(current) + next.dependencies.push(dependency) + if (hasDependencyCycle(next.tasks, next.dependencies)) fail("dependency_cycle", "dependency") + return commitInput(current, next, "dependency_changed") +} + +function deleteDependency(state, fromTaskId, toTaskId) { + var current = normalizeState(state) + var key = dependencyKey({ fromTaskId: fromTaskId, toTaskId: toTaskId }) + var next = clone(current) + next.dependencies = next.dependencies.filter(function(dependency) { return dependencyKey(dependency) !== key }) + if (next.dependencies.length === current.dependencies.length) fail("missing_dependency", "dependency") + return commitInput(current, next, "dependency_changed") +} + +function replaceTaskDependencies(state, taskId, predecessorIds) { + var current = normalizeState(state) + var taskIds = {} + var found = false + for (var i = 0; i < current.tasks.length; i++) { + taskIds[current.tasks[i].id] = true + if (current.tasks[i].id === taskId) found = true + } + if (!found) fail("missing_task", "task.id") + var next = clone(current) + next.dependencies = next.dependencies.filter(function(dependency) { return dependency.toTaskId !== taskId }) + var seen = {} + var ids = Array.isArray(predecessorIds) ? predecessorIds : [] + for (var p = 0; p < ids.length; p++) { + var predecessorId = String(ids[p]) + if (!taskIds[predecessorId]) fail("missing_task", "dependency.fromTaskId") + if (predecessorId === taskId) fail("self_dependency", "dependency") + if (seen[predecessorId]) continue + seen[predecessorId] = true + next.dependencies.push({ fromTaskId: predecessorId, toTaskId: taskId }) + if (hasDependencyCycle(next.tasks, next.dependencies)) fail("dependency_cycle", "dependency") + } + var currentKeys = current.dependencies.map(dependencyKey).sort() + var nextKeys = next.dependencies.map(dependencyKey).sort() + if (sameValue(currentKeys, nextKeys)) return current + return commitInput(current, next, "dependency_changed") +} + +function writeProposal(state, proposal) { + var current = normalizeState(state) + var nextProposal = normalizeProposal(proposal) + if (!nextProposal) fail("invalid_proposal", "proposal") + if (Number(nextProposal.baseInputRevision) !== current.inputRevision) + fail("stale_proposal", "proposal.baseInputRevision") + nextProposal.status = "ready" + var next = clone(current) + next.proposal = nextProposal + // Proposal persistence is derived output and must not advance the input + // revision or trigger another automatic solve. + return next +} + +function applyProposal(state, appliedAt) { + var current = normalizeState(state) + var proposal = current.proposal + if (!proposal) fail("missing_proposal", "proposal") + if (proposal.status !== "ready") fail("proposal_not_ready", "proposal.status") + if (Number(proposal.baseInputRevision) !== current.inputRevision) + fail("stale_proposal", "proposal.baseInputRevision") + + var next = clone(current) + var stamp = nowIso(appliedAt) + var taskById = {} + for (var i = 0; i < next.tasks.length; i++) taskById[next.tasks[i].id] = next.tasks[i] + var scheduled = {} + var items = Array.isArray(proposal.items) ? proposal.items : [] + for (var p = 0; p < items.length; p++) { + var item = items[p] + if (!item || !item.scheduled) continue + var task = taskById[item.taskId] + if (!task) fail("missing_task", "proposal.items[" + p + "].taskId") + if (task.state !== "inbox") fail("task_not_inbox", "proposal.items[" + p + "].taskId") + if (!item.startAt || !item.endAt || !isFinite(Date.parse(item.startAt)) || !isFinite(Date.parse(item.endAt)) || Date.parse(item.endAt) <= Date.parse(item.startAt)) + fail("invalid_proposal_interval", "proposal.items[" + p + "]") + if (!validTimezone(proposal.timezone || next.settings.timezone)) fail("invalid_timezone", "proposal.timezone") + var event = { + id: newId("event", Date.now()), + title: task.title, + description: "", + startAt: item.startAt, + endAt: item.endAt, + timezone: proposal.timezone || next.settings.timezone, + allDay: false, + rrule: null, + origin: "planner", + taskId: task.id, + proposalId: proposal.id, + createdAt: stamp, + updatedAt: stamp + } + next.events.push(event) + task.state = "applied" + task.linkedEventId = event.id + task.updatedAt = stamp + scheduled[task.id] = true + } + for (var t = 0; t < next.tasks.length; t++) { + if (scheduled[next.tasks[t].id]) continue + // Unscheduled tasks remain inbox tasks, including their explanations in + // the proposal item rather than silently changing their state. + if (next.tasks[t].state === "inbox") next.tasks[t].updatedAt = next.tasks[t].updatedAt + } + next.proposal.status = "applied" + next.proposal.appliedAt = stamp + return commitInput(current, next, "proposal_applied") +} + +function returnToInbox(state, taskId, now) { + var current = normalizeState(state) + var next = clone(current) + var task = null + for (var i = 0; i < next.tasks.length; i++) if (next.tasks[i].id === taskId) task = next.tasks[i] + if (!task) fail("missing_task", "task.id") + if (task.state !== "applied" && task.state !== "missing_event") fail("task_not_applied", "task.state") + var linkedId = task.linkedEventId + if (linkedId) next.events = next.events.filter(function(event) { return event.id !== linkedId }) + task.state = "inbox" + task.linkedEventId = null + task.updatedAt = nowIso(now) + return commitInput(current, next, "task_returned_to_inbox") +} + +function reconcileLinkedEvents(state, advanceRevision) { + var current = normalizeState(state) + var next = clone(current) + var changed = false + for (var i = 0; i < next.tasks.length; i++) { + var task = next.tasks[i] + if (task.state !== "applied" || !task.linkedEventId) continue + var event = null + for (var e = 0; e < next.events.length; e++) if (next.events[e].id === task.linkedEventId) event = next.events[e] + var item = proposalForTask(next, task.id) + var material = event && item && item.startAt && item.endAt ? { + title: task.title, + description: "", + startAt: item.startAt, + endAt: item.endAt, + timezone: next.proposal.timezone || next.settings.timezone, + allDay: false, + rrule: null + } : null + if (!event || (material && !sameValue(materialEventFields(event), material))) { + task.state = "missing_event" + task.updatedAt = nowIso() + changed = true + } + } + if (changed && advanceRevision) return { state: commitInput(current, next, "linked_event_changed"), changed: true } + return { state: next, changed: changed } +} + +function allTasksInInbox(state) { + var current = normalizeState(state) + for (var i = 0; i < current.tasks.length; i++) if (current.tasks[i].state === "inbox") return true + return false +} + +if (typeof module !== "undefined") { + module.exports = { + SCHEMA_VERSION: SCHEMA_VERSION, + WEEKDAYS: WEEKDAYS.slice(), + DEFAULT_SETTINGS: defaultSettings(), + defaultSettings: defaultSettings, + normalizeClock: normalizeClock, + validClock: validClock, + validTimezone: validTimezone, + validateSettings: validateSettings, + settingsReady: settingsReady, + defaultUi: defaultUi, + normalizeUi: normalizeUi, + baseState: baseState, + normalizeState: normalizeState, + tutorialDismissed: tutorialDismissed, + dismissTutorial: dismissTutorial, + canonicalize: canonicalize, + canonicalJson: canonicalJson, + canonicalProblem: canonicalProblem, + problemFingerprint: problemFingerprint, + hasDependencyCycle: hasDependencyCycle, + newId: newId, + addEvent: addEvent, + updateEvent: updateEvent, + deleteEvent: deleteEvent, + addTask: addTask, + updateTask: updateTask, + deleteTask: deleteTask, + updateSettings: updateSettings, + addDependency: addDependency, + deleteDependency: deleteDependency, + replaceTaskDependencies: replaceTaskDependencies, + writeProposal: writeProposal, + applyProposal: applyProposal, + returnToInbox: returnToInbox, + reconcileLinkedEvents: reconcileLinkedEvents, + allTasksInInbox: allTasksInInbox, + error: error + } +} diff --git a/shell/plugins/panels/clock/TaskEditor.qml b/shell/plugins/panels/clock/TaskEditor.qml new file mode 100644 index 00000000000..3b2ac5b280c --- /dev/null +++ b/shell/plugins/panels/clock/TaskEditor.qml @@ -0,0 +1,305 @@ +import QtQuick +import qs.Commons +import qs.Ui +import "." + +// Small, keyboard-friendly editor for an inbox task. The editor deliberately +// emits no state of its own: Service.qml remains the only owner and the +// reducer decides whether a submitted value is valid. +Item { + id: root + + property var service: null + property var bar: null + property var task: null + property color foreground: bar ? bar.foreground : Color.foreground + property string fontFamily: bar ? bar.fontFamily : Style.font.family + property string errorText: "" + property string priority: "normal" + property string cognitiveLoad: "medium" + property string deadlineKind: "none" + property string earliestAt: "" + property string deadlineAt: "" + property var dependencyIds: [] + property int durationMinutes: 30 + + signal saved() + signal cancelled() + + // The popup sizes itself from the form. The viewport is deliberately not a + // scrolling input surface: Save and Cancel stay visible as the form grows. + implicitHeight: form.implicitHeight + Style.space(36) + + function reset() { + var value = root.task || {} + titleField.text = value.title || "" + durationMinutes = Number(value.durationMinutes || 30) + priority = value.priority || "normal" + cognitiveLoad = value.cognitiveLoad || "medium" + root.earliestAt = value.earliestAt || "" + deadlineKind = value.deadlineKind || "none" + root.deadlineAt = value.deadlineAt || "" + dependencyIds = predecessorsFor(value.id || "") + errorText = "" + } + + function predecessorsFor(taskId) { + if (!root.service || !taskId) return [] + var result = [] + var dependencies = root.service.calendarState.dependencies || [] + for (var i = 0; i < dependencies.length; i++) + if (dependencies[i].toTaskId === taskId) result.push(dependencies[i].fromTaskId) + return result + } + + function dependencyOptions() { + if (!root.service) return [] + var tasks = root.service.calendarState.tasks || [] + var result = [] + for (var i = 0; i < tasks.length; i++) { + if (root.task && tasks[i].id === root.task.id) continue + result.push({ value: tasks[i].id, label: tasks[i].title, description: tasks[i].state }) + } + return result + } + + function save() { + var title = titleField.text.trim() + if (title === "") { + errorText = "A task title is required." + titleField.forceActiveFocus() + return + } + + var earliest = earliestField.valid ? root.earliestAt || null : false + var deadline = deadlineKind === "none" ? null : (deadlineField.valid ? root.deadlineAt || null : false) + if (earliest === false) { + errorText = "Choose an earliest date and enter its time as HH:MM." + earliestField.forceActiveFocus() + return + } + if (deadline === false || (deadlineKind !== "none" && deadline === null)) { + errorText = "Choose a deadline date and enter its time as HH:MM." + deadlineField.forceActiveFocus() + return + } + + var input = { + title: title, + durationMinutes: durationMinutes, + priority: priority, + cognitiveLoad: cognitiveLoad, + earliestAt: earliest, + deadlineKind: deadlineKind, + deadlineAt: deadline + } + var result = root.task + ? root.service.updateTask(root.task.id, input) + : root.service.addTask(input) + if (!result) { + errorText = root.service ? root.service.lastSolverError : "The task could not be saved." + return + } + var savedTaskId = root.task ? root.task.id : result.tasks[result.tasks.length - 1].id + var dependencies = root.service.replaceTaskDependencies(savedTaskId, root.dependencyIds) + // The reducer returns the full state. For a newly-added task the last + // task is the one just created; existing edits retain their id. + if (dependencies) root.saved() + else errorText = root.service.lastSolverError + } + + Component.onCompleted: { + reset() + Qt.callLater(function() { titleField.forceActiveFocus() }) + } + onTaskChanged: reset() + + Rectangle { + anchors.fill: parent + color: Color.popups.background + border.width: Style.spacing.hairline + border.color: Color.popups.border + radius: Style.cornerRadius + } + + Item { + id: viewport + anchors.fill: parent + anchors.margins: Style.space(18) + clip: true + + Column { + id: form + width: Math.max(viewport.width, Style.space(390)) + spacing: Style.space(10) + + Text { + textFormat: Text.PlainText + text: root.task ? "Edit planning task" : "Add planning task" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.display + font.bold: true + } + + Text { + textFormat: Text.PlainText + text: "A planning task is work Omarchy may schedule. Calendar events are fixed busy time. Tasks stay in the inbox until you apply a schedule." + color: Qt.darker(root.foreground, 1.45) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } + + Text { textFormat: Text.PlainText; text: "Title"; color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.caption } + TextField { + id: titleField + width: parent.width + foreground: root.foreground + font.family: root.fontFamily + placeholderText: "Write the next actionable task" + Keys.onReturnPressed: root.save() + Keys.onEscapePressed: root.cancelled() + } + + Row { + width: parent.width + spacing: Style.space(10) + + NumberField { + label: "Duration (minutes)" + width: Style.space(150) + value: root.durationMinutes + from: 5 + to: 1440 + stepSize: 5 + foreground: root.foreground + onModified: root.durationMinutes = value + } + + Dropdown { + width: Style.space(140) + label: "Priority" + value: root.priority + options: [ + { value: "low", label: "Low" }, + { value: "normal", label: "Normal" }, + { value: "high", label: "High" } + ] + foreground: root.foreground + onChanged: root.priority = value + } + + Dropdown { + width: Style.space(150) + label: "Cognitive load" + value: root.cognitiveLoad + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" } + ] + foreground: root.foreground + onChanged: root.cognitiveLoad = value + } + } + + PlannerDateTimeField { + id: earliestField + width: parent.width + label: "Earliest start (optional)" + value: root.earliestAt + allowEmpty: true + emptyLabel: "No earliest time" + foreground: root.foreground + fontFamily: root.fontFamily + onChanged: function(next) { root.earliestAt = next } + onCancelled: root.cancelled() + } + + Row { + width: parent.width + spacing: Style.space(10) + Dropdown { + width: Style.space(150) + label: "Deadline" + value: root.deadlineKind + options: [ + { value: "none", label: "None" }, + { value: "soft", label: "Soft" }, + { value: "hard", label: "Hard" } + ] + foreground: root.foreground + onChanged: root.deadlineKind = value + } + PlannerDateTimeField { + id: deadlineField + width: parent.width - Style.space(160) + label: "Deadline time" + value: root.deadlineAt + allowEmpty: true + emptyLabel: "No deadline" + enabled: root.deadlineKind !== "none" + foreground: root.foreground + fontFamily: root.fontFamily + onChanged: function(next) { root.deadlineAt = next } + onCancelled: root.cancelled() + } + } + + MultiSelect { + label: "Depends on" + width: parent.width + values: root.dependencyIds + options: root.dependencyOptions() + foreground: root.foreground + onChanged: function(next) { root.dependencyIds = next } + } + + Text { + textFormat: Text.PlainText + visible: root.errorText !== "" + text: root.errorText + color: Color.urgent + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } + + Row { + spacing: Style.space(8) + Button { + focusable: true + text: "Save task" + foreground: activeFocus || hot ? Color.foreground : Color.background + background: Color.accent + fontFamily: root.fontFamily + onClicked: root.save() + } + Button { + focusable: true + text: "Cancel" + foreground: root.foreground + bordered: true + fontFamily: root.fontFamily + onClicked: root.cancelled() + } + Button { + focusable: true + visible: root.task !== null + text: "Delete task" + foreground: Color.urgent + bordered: true + fontFamily: root.fontFamily + onClicked: { + var result = root.service ? root.service.deleteTask(root.task.id) : null + if (result) root.saved() + else root.errorText = root.service ? root.service.lastSolverError : "The task could not be deleted." + } + } + } + } + } +} diff --git a/shell/plugins/panels/clock/manifest.json b/shell/plugins/panels/clock/manifest.json index 1640121687c..eb61ea3d8f8 100644 --- a/shell/plugins/panels/clock/manifest.json +++ b/shell/plugins/panels/clock/manifest.json @@ -4,16 +4,18 @@ "name": "Clock", "version": "1.0.0", "author": "Omarchy", - "description": "Date/time label with a calendar popup", + "description": "Date/time label with a local calendar and planner", "kinds": [ + "service", "bar-widget" ], "entryPoints": { + "service": "Service.qml", "barWidget": "BarWidget.qml" }, "barWidget": { "displayName": "Clock", - "description": "Date/time label with a calendar popup", + "description": "Date/time label with a local calendar and planner", "category": "Time", "allowMultiple": false } diff --git a/test/acceptance.d/calendar-planner-test.sh b/test/acceptance.d/calendar-planner-test.sh new file mode 100755 index 00000000000..4bafbd51216 --- /dev/null +++ b/test/acceptance.d/calendar-planner-test.sh @@ -0,0 +1,238 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +STATE_FILE="$HOME/.local/state/omarchy/calendar.json" +STATE_BACKUP=$(mktemp) +STATE_EXISTED=0 + +if [[ -f $STATE_FILE ]]; then + cp "$STATE_FILE" "$STATE_BACKUP" + STATE_EXISTED=1 +fi + +restore_state() { + omarchy-shell shell hide omarchy.clock >/dev/null 2>&1 || true + if ((STATE_EXISTED)); then + mkdir -p "$(dirname "$STATE_FILE")" + cp "$STATE_BACKUP" "$STATE_FILE" + else + rm -f "$STATE_FILE" + fi + rm -f "$STATE_BACKUP" +} + +trap restore_state EXIT + +press_tabs() { + local count="$1" + for ((i = 0; i < count; i++)); do + wtype -k Tab + done +} + +press_rights() { + local count="$1" + for ((i = 0; i < count; i++)); do + wtype -k Right + sleep 0.1 + done +} + +open_clock() { + omarchy-shell shell summon omarchy.clock >/dev/null + wait_until "clock popup opens" 15 layer_present "omarchy-keyboard-panel" +} + +restart_source_shell() { + # The acceptance run is against the checked-out shell, while the session's + # normal restart command follows the installed OMARCHY_PATH. Restart only + # this exact development shell so the host's normal shell is untouched. + timeout 5 quickshell kill -p "$OMARCHY_PATH/shell" --any-display >/dev/null 2>&1 || true + for ((attempt = 0; attempt < 50; attempt++)); do + pgrep -f "^quickshell -n -p $OMARCHY_PATH/shell$" >/dev/null || break + sleep 0.1 + done + setsid env OMARCHY_PATH="$OMARCHY_PATH" QS_DISABLE_FILE_WATCHER=1 QS_NO_RELOAD_POPUP=1 \ + quickshell -n -p "$OMARCHY_PATH/shell" >/tmp/omarchy-calendar-acceptance-shell.log 2>&1 /dev/null +wait_until "agenda opens" 15 screen_contains "No events yet." +screenshot "success-calendar-planner-06-agenda-empty" +# PlannerView focuses the first action in each view, so Return activates the +# already-focused Add calendar event action. +wtype -k Return +wait_until "event editor opens" 15 screen_contains "Add calendar event" +busy_start=$(date -d 'next monday 11:00' --iso-8601=seconds) +busy_end=$(date -d 'next monday 12:00' --iso-8601=seconds) +days_to_next_monday=$(( (8 - $(date +%u)) % 7 )) +((days_to_next_monday == 0)) && days_to_next_monday=7 +wtype "Busy block" +wtype -k Tab +wtype -k Return +sleep 0.5 +press_rights "$days_to_next_monday" +wtype -k Return +wtype -k Tab +wtype -M ctrl -k a -m ctrl +wtype "11:00" +wtype -k Tab +wtype -k Return +sleep 0.5 +press_rights "$days_to_next_monday" +wtype -k Return +wtype -k Tab +wtype -M ctrl -k a -m ctrl +wtype "12:00" +wtype -k Return +wait_until "manual event appears in agenda" 15 screen_contains "Busy block" +wait_until "manual event is persisted" 15 jq -e --arg title "Busy block" 'any(.events[]; .title == $title and .origin == "manual")' "$STATE_FILE" +screenshot "success-calendar-planner-07-manual-event" + +# Add two tasks through the native editor, then edit the second task to depend +# on the first through the native MultiSelect control. +wtype -k Escape +wtype "p" +wait_until "planner reopens after event creation" 15 screen_contains "Planner inbox" + +wtype -k Return +wait_until "first task editor opens" 15 screen_contains "Add planning task" +wtype "Prepare acceptance task" +wtype -k Return +wait_until "first task enters the inbox" 15 screen_contains "Prepare acceptance task" + +wtype -k Return +wait_until "second task editor opens" 15 screen_contains "Add planning task" +wtype "Follow-up acceptance task" +wtype -k Return +wait_until "second task enters the inbox" 15 screen_contains "Follow-up acceptance task" + +first_task=$(jq -r '.tasks[] | select(.title == "Prepare acceptance task") | .id' "$STATE_FILE") +second_task=$(jq -r '.tasks[] | select(.title == "Follow-up acceptance task") | .id' "$STATE_FILE") +[[ -n $first_task && -n $second_task ]] || fail "acceptance tasks are persisted" + +# Add task, Settings, first Edit, second Edit. +press_tabs 4 +wtype -k Return +wait_until "second task editor reopens" 15 screen_contains "Edit planning task" +sleep 1 +press_tabs 7 +wtype -k Return +# Let the searchable popup finish its window-focus handoff before typing into +# its search field. This is also the small delay a user naturally provides +# while reading the open dropdown. +sleep 1 +wtype "Prepare acceptance task" +wtype -k Down +wtype -k Return +wtype -k Escape +press_tabs 1 +wtype -k Return +wait_until "dependency is persisted" 15 jq -e --arg from "$first_task" --arg to "$second_task" 'any(.dependencies[]; .fromTaskId == $from and .toTaskId == $to)' "$STATE_FILE" +screenshot "success-calendar-planner-08-dependent-tasks" + +# Ask Omarchy to plan explicitly after the final input change. It must avoid +# the manual event and leave the calendar untouched until Apply schedule. +wait_until "planner is ready for an explicit solve" 30 screen_contains "Plan tasks" +press_tabs 1 +wtype -k Return +wait_until "explicit suggested schedule is ready" 30 jq -e '.proposal.status == "ready" and (.proposal.baseInputRevision == .inputRevision)' "$STATE_FILE" +wait_until "suggested schedule appears in planner" 15 screen_contains "Suggested schedule" +screenshot "success-calendar-planner-09-proposal" + +busy_start_epoch=$(date -d "$busy_start" +%s) +busy_end_epoch=$(date -d "$busy_end" +%s) +while IFS=$'\t' read -r start_at end_at; do + start_epoch=$(date -d "$start_at" +%s) + end_epoch=$(date -d "$end_at" +%s) + ((end_epoch <= busy_start_epoch || start_epoch >= busy_end_epoch)) || + fail "proposal avoids the manual busy event" "$start_at — $end_at overlaps $busy_start — $busy_end" +done < <(jq -r '.proposal.items[] | select(.scheduled) | [.startAt, .endAt] | @tsv' "$STATE_FILE") +pass "proposal avoids the manual busy event" + +jq -e '[.events[] | select(.origin == "planner")] | length == 0' "$STATE_FILE" >/dev/null || + fail "calendar remains unchanged before Apply schedule" +pass "calendar remains unchanged before Apply schedule" + +# Review and explicitly apply. ProposalReview focuses its apply action when it +# opens, so this is the same keyboard path a user can use without a pointer. +# Plan tasks remains focused after the explicit request. Settings, the +# tutorial dismiss action, the two task actions, and then Review schedule +# follow it in the keyboard order. +press_tabs 5 +wtype -k Return +wait_until "proposal review opens" 15 screen_contains "Apply schedule" +screenshot "success-calendar-planner-10-proposal-review" +wtype -k Return +wait_until "proposal is explicitly applied" 15 jq -e '.proposal.status == "applied" and ([.events[] | select(.origin == "planner")] | length) > 0' "$STATE_FILE" +screenshot "success-calendar-planner-11-applied-proposal" + +# Restart the checked-out shell, reopen the clock, and prove that applied +# events are state-file data rather than process-local UI state. +wtype -k Escape +restart_source_shell +open_clock +omarchy-shell omarchy.clock openView agenda >/dev/null +wait_until "agenda survives shell restart" 15 screen_contains "Agenda" +wait_until "applied event survives shell restart" 15 jq -e 'any(.events[]; .origin == "planner" and .taskId != null)' "$STATE_FILE" +screenshot "success-calendar-planner-12-restarted-agenda" + +# Return the applied planner event to the inbox from the agenda. The planner +# event is ordered before the later manual busy block, so the first event +# action is the explicit Return to inbox. +press_tabs 1 +wtype -k Return +wait_until "applied task returns to inbox" 15 jq -e --arg id "$first_task" 'any(.tasks[]; .id == $id and .state == "inbox")' "$STATE_FILE" +wait_until "return to inbox removes only the linked planner event" 15 jq -e --arg id "$first_task" 'all(.events[]; .origin != "planner" or .taskId != $id)' "$STATE_FILE" +pass "return to inbox removes only the linked planner event" diff --git a/test/shell.d/calendar-cli-test.sh b/test/shell.d/calendar-cli-test.sh new file mode 100755 index 00000000000..d027062116a --- /dev/null +++ b/test/shell.d/calendar-cli-test.sh @@ -0,0 +1,108 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT + +stub_bin="$tmp_dir/bin" +log="$tmp_dir/calls" +mkdir -p "$stub_bin" + +cat >"$stub_bin/omarchy-shell" <<'SH' +#!/bin/bash +set -euo pipefail + +printf '%s\n' "$*" >>"${OMARCHY_CLI_TEST_LOG:?}" +target=${1:-} +method=${2:-} + +state='{"schemaVersion":1,"inputRevision":4,"settings":{"timezone":"Europe/Rome","availability":{"monday":[{"start":"09:00","end":"17:00"}]},"horizonDays":14,"slotMinutes":15,"solveSeconds":5,"priorityLowWeight":1,"priorityNormalWeight":5,"priorityHighWeight":25,"cognitiveEnabled":false,"lowWindowStart":"00:00","lowWindowEnd":"00:00","lowOutsidePenalty":0,"mediumWindowStart":"00:00","mediumWindowEnd":"00:00","mediumOutsidePenalty":0,"highWindowStart":"00:00","highWindowEnd":"00:00","highOutsidePenalty":0,"highStreakLimit":1,"recoveryMinutes":30,"excessHighPenalty":60},"events":[{"id":"event-1","title":"Busy","description":"","startAt":"2026-09-07T10:00:00+02:00","endAt":"2026-09-07T11:00:00+02:00","timezone":"Europe/Rome","allDay":false,"rrule":null,"origin":"manual","taskId":null,"proposalId":null,"createdAt":"2026-09-04T08:00:00Z","updatedAt":"2026-09-04T08:00:00Z"}],"tasks":[],"dependencies":[],"proposal":null}' +planning_state=$(jq -c '.tasks=[{id:"task-existing",title:"Existing task",state:"inbox"}] | .proposal={status:"ready",baseInputRevision:4,items:[]}' <<<"$state") +empty_planning_state=$(jq -c '.tasks=[{id:"task-applied",title:"Applied task",state:"applied"}] | .proposal={status:"applied",baseInputRevision:4,items:[{taskId:"task-applied",scheduled:true}]}' <<<"$state") + +if [[ $target == omarchy.clock && $method == openView ]]; then + [[ ${3:-} == plan ]] || exit 1 + printf 'ok\n' + exit 0 +fi + +case "$method" in +status) + if [[ ${OMARCHY_CLI_TEST_NO_INBOX:-} == 1 ]]; then + jq -cn --argjson state "$empty_planning_state" '{state:$state,loaded:true,configured:true,solveState:"idle",error:"",errorOutput:""}' + else + jq -cn --argjson state "$planning_state" '{state:$state,loaded:true,configured:true,solveState:"idle",error:"",errorOutput:""}' + fi + ;; +state) + jq -cn --argjson state "$state" '{ok:true,state:$state}' + ;; +addEvent) + jq -e '.title == "New" and .startAt == "2026-09-08T10:00:00+02:00" and .endAt == "2026-09-08T11:00:00+02:00"' <<<"${3:-}" >/dev/null + jq -cn --argjson state "$state" --argjson input "${3:-}" '{ok:true,state:($state | .events += [($input + {id:"event-new"})])}' + ;; +addTask) + jq -e '.title == "Task" and .durationMinutes == 45 and .priority == "high" and .deadlineKind == "none"' <<<"${3:-}" >/dev/null + jq -cn --argjson state "$state" --argjson input "${3:-}" '{ok:true,state:($state | .tasks += [($input + {id:"task-new"})])}' + ;; +plan) + jq -cn --argjson state "$planning_state" '{state:$state,loaded:true,configured:true,solveState:"ready",error:"",errorOutput:""}' + ;; +setSettings) + jq -e '.timezone == "Europe/Rome" and .availability.monday[0].start == "09:00"' <<<"${3:-}" >/dev/null + jq -cn --argjson state "$state" '{ok:true,state:$state}' + ;; +addDependency) + [[ ${3:-} == first && ${4:-} == second ]] || exit 1 + jq -cn --argjson state "$state" '{ok:true,state:$state}' + ;; +*) + echo "unknown method: $target $method" >&2 + exit 1 + ;; +esac +SH +chmod +x "$stub_bin/omarchy-shell" + +export OMARCHY_CLI_TEST_LOG="$log" +export PATH="$stub_bin:$ROOT/bin:$PATH" + +events_json=$(OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-calendar" --json events) +jq -e 'length == 1 and .[0].title == "Busy"' <<<"$events_json" >/dev/null || fail "CLI lists events as JSON" +pass "CLI lists events as JSON" + +OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-calendar" add-event \ + --title New --start 2026-09-08T10:00:00+02:00 --end 2026-09-08T11:00:00+02:00 >/dev/null +pass "CLI adds an event through the calendar service" + +OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-calendar" add-task \ + --title Task --duration 45 --priority high >/dev/null +pass "CLI adds a planner task through the calendar service" + +OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-calendar" settings \ + --timezone Europe/Rome --availability monday=09:00-17:00 >/dev/null +pass "CLI updates planner settings through the calendar service" + +OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-calendar" dependency add first second >/dev/null +pass "CLI updates task dependencies through the calendar service" + +OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-calendar" plan >/dev/null +pass "CLI explicitly asks Omarchy to plan tasks" + +OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-calendar" open plan >/dev/null +pass "CLI opens the Plan view in the existing clock popup" + +grep -Fqx 'omarchy.calendar addEvent {"title":"New","startAt":"2026-09-08T10:00:00+02:00","endAt":"2026-09-08T11:00:00+02:00","allDay":false,"timezone":"Europe/Rome"}' "$log" || fail "CLI sends event JSON through IPC" +grep -Fqx 'omarchy.calendar addTask {"title":"Task","durationMinutes":45,"priority":"high","cognitiveLoad":"medium","deadlineKind":"none","earliestAt":null,"deadlineAt":null}' "$log" || fail "CLI sends task JSON through IPC" +pass "CLI sends native JSON requests through IPC" +grep -Fqx 'omarchy.calendar plan' "$log" || fail "CLI sends explicit plan request through IPC" +pass "CLI sends explicit plan requests through IPC" + +if OMARCHY_CLI_TEST_NO_INBOX=1 OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-calendar" plan >"$tmp_dir/no-inbox-output" 2>"$tmp_dir/no-inbox-error"; then + fail "CLI refuses to plan when the inbox is empty" +fi +grep -Fq 'there are no planning tasks in the inbox' "$tmp_dir/no-inbox-error" || fail "CLI explains why an empty inbox cannot be planned" +pass "CLI explains why an empty inbox cannot be planned" diff --git a/test/shell.d/calendar-planner-model-test.sh b/test/shell.d/calendar-planner-model-test.sh new file mode 100755 index 00000000000..5d70f9e6c7d --- /dev/null +++ b/test/shell.d/calendar-planner-model-test.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const planner = requireFromRoot('shell/plugins/panels/clock/PlannerModel.js') + +const tasks = [ + { id: 'low', title: 'Later', priority: 'low', cognitiveLoad: 'low', durationMinutes: 15 }, + { id: 'normal', title: 'Middle', priority: 'normal', cognitiveLoad: 'medium', durationMinutes: 60 }, + { id: 'high', title: 'First', priority: 'high', cognitiveLoad: 'high', durationMinutes: 90 } +] +assertDeepEqual( + planner.sortedTasks(tasks).map(task => task.id), + ['high', 'normal', 'low'], + 'planner sorts tasks by priority before stable labels' +) +assertEqual(planner.formatDuration(90), '1h 30m', 'planner formats mixed-hour durations') +assertEqual(planner.formatDuration(45), '45m', 'planner formats minute durations') +assertEqual(planner.formatDayLabel('2026-09-04'), 'Friday, September 4, 2026', 'planner formats agenda day labels for people') +assertEqual(planner.priorityLabel('high'), 'High', 'planner formats priority labels') +assertEqual(planner.loadLabel('medium'), 'Medium', 'planner formats cognitive labels') + +const events = [ + { id: 'later', title: 'Later', startAt: '2026-09-04T12:00:00+02:00', endAt: '2026-09-04T13:00:00+02:00' }, + { id: 'first', title: 'First', startAt: '2026-09-04T09:00:00+02:00', endAt: '2026-09-04T10:00:00+02:00' } +] +assertDeepEqual(planner.sortedEvents(events).map(event => event.id), ['first', 'later'], 'planner orders events by start') +assertDeepEqual(planner.eventsForDay(events, '2026-09-04', 'Europe/Rome').map(event => event.id), ['first', 'later'], 'planner groups events in the configured timezone') +assertDeepEqual(planner.eventMarkers(events, 'Europe/Rome'), { '2026-09-04': 2 }, 'planner counts event markers by local day') + +const proposal = { + items: [ + { taskId: 'b', scheduled: false, diagnostics: { outcome: 'no_hard_feasible_slot' } }, + { taskId: 'a', scheduled: true, startAt: '2026-09-04T09:00:00+02:00', diagnostics: { outcome: 'scheduled' } } + ] +} +assertDeepEqual(planner.proposalSummary(proposal), { scheduled: 1, total: 2, unscheduled: 1 }, 'planner summarizes proposal outcomes') +assertDeepEqual(planner.scheduledItems(proposal).map(item => item.taskId), ['a'], 'planner sorts scheduled proposal items') +assertEqual(planner.outcomeLabel(proposal.items[0]), 'No feasible slot', 'planner maps no-slot diagnostics') +assert(planner.explanation(proposal.items[0]).length > 0, 'planner exposes explanation copy') +assertEqual(planner.solveStateLabel('configuration_needed'), 'Configuration needed', 'planner formats service states') +assertDeepEqual( + planner.applicabilityReasons({ status: 'stale', staleReason: 'inputs_changed', applicabilityReasons: [] }, 3), + ['Planning inputs changed; generate a new proposal before applying.', 'Planning inputs changed since this proposal was generated.'], + 'planner explains stale proposals' +) +JS diff --git a/test/shell.d/calendar-planner-service-test.sh b/test/shell.d/calendar-planner-service-test.sh new file mode 100755 index 00000000000..d259858d941 --- /dev/null +++ b/test/shell.d/calendar-planner-service-test.sh @@ -0,0 +1,74 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR="" +QS_PID="" + +cleanup() { + if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then + kill "$QS_PID" 2>/dev/null || true + wait "$QS_PID" 2>/dev/null || true + fi + [[ -n $TMPDIR && -d $TMPDIR ]] && rm -rf "$TMPDIR" +} +trap cleanup EXIT + +require_compositor "calendar planner service test" + +if ! command -v quickshell >/dev/null 2>&1; then + pass "quickshell not installed; skipping calendar planner service test" + exit 0 +fi + +require_command jq + +TMPDIR=$(mktemp -d) +test_home="$TMPDIR/home" +config_dir="$TMPDIR/calendar-planner-service" +result="$TMPDIR/result.json" +log="$TMPDIR/quickshell.log" +mkdir -p "$test_home" "$config_dir" +cp "$SHELL_TEST_DIR/fixtures/calendar-planner-service/shell.qml" "$config_dir/shell.qml" +cp "$SHELL_TEST_DIR/fixtures/calendar-planner-service/solver" "$config_dir/solver" +chmod 755 "$config_dir/solver" +ln -s "$ROOT/shell/Commons" "$config_dir/Commons" + +service_url="file://$ROOT/shell/plugins/panels/clock/Service.qml" +OMARCHY_PATH="$ROOT" \ +OMARCHY_QML_TEST_RESULT="$result" \ +OMARCHY_QML_SERVICE_URL="$service_url" \ +OMARCHY_CALENDAR_SOLVER="${OMARCHY_CALENDAR_SOLVER:-$config_dir/solver}" \ +HOME="$test_home" \ +XDG_CONFIG_HOME="$test_home/.config" \ +XDG_CACHE_HOME="$test_home/.cache" \ +XDG_STATE_HOME="$test_home/.local/state" \ +QML2_IMPORT_PATH="$ROOT/shell${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}" \ +QML_IMPORT_PATH="$ROOT/shell${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}" \ +PATH="$ROOT/bin:$PATH" \ + quickshell -p "$config_dir" --no-color >"$log" 2>&1 & +QS_PID=$! + +for _ in {1..160}; do + [[ -s $result ]] && break + if ! kill -0 "$QS_PID" 2>/dev/null; then + sed -n '1,240p' "$log" >&2 + fail "calendar planner service quickshell exited before writing result" + fi + sleep 0.1 +done + +[[ -s $result ]] || { + sed -n '1,240p' "$log" >&2 + fail "calendar planner service test timed out" +} + +jq -e '.ok == true' "$result" >/dev/null || { + jq . "$result" >&2 + sed -n '1,240p' "$log" >&2 + fail "calendar planner service lifecycle checks pass" +} + +pass "calendar planner service lifecycle checks pass" diff --git a/test/shell.d/calendar-planner-wiring-test.sh b/test/shell.d/calendar-planner-wiring-test.sh new file mode 100755 index 00000000000..5b198227675 --- /dev/null +++ b/test/shell.d/calendar-planner-wiring-test.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') +const manifest = JSON.parse(fs.readFileSync(root + '/shell/plugins/panels/clock/manifest.json', 'utf8')) +const service = fs.readFileSync(root + '/shell/plugins/panels/clock/Service.qml', 'utf8') +const dateTimeField = fs.readFileSync(root + '/shell/plugins/panels/clock/PlannerDateTimeField.qml', 'utf8') +const eventEditor = fs.readFileSync(root + '/shell/plugins/panels/clock/EventEditor.qml', 'utf8') +const taskEditor = fs.readFileSync(root + '/shell/plugins/panels/clock/TaskEditor.qml', 'utf8') +const settingsEditor = fs.readFileSync(root + '/shell/plugins/panels/clock/PlannerSettings.qml', 'utf8') +const availabilityEditor = fs.readFileSync(root + '/shell/plugins/panels/clock/AvailabilityEditor.qml', 'utf8') +const proposalReview = fs.readFileSync(root + '/shell/plugins/panels/clock/ProposalReview.qml', 'utf8') +const plannerView = fs.readFileSync(root + '/shell/plugins/panels/clock/PlannerView.qml', 'utf8') +const widget = fs.readFileSync(root + '/shell/plugins/panels/clock/BarWidget.qml', 'utf8') +const panel = fs.readFileSync(root + '/shell/plugins/panels/clock/Panel.qml', 'utf8') +const basePackages = fs.readFileSync(root + '/install/omarchy-base.packages', 'utf8') +const solverMigration = fs.readFileSync(root + '/migrations/1788584591.sh', 'utf8') + +assert(manifest.kinds.includes('service'), 'clock manifest declares a service') +assert(manifest.kinds.includes('bar-widget'), 'clock manifest retains the bar widget') +assertEqual(manifest.entryPoints.service, 'Service.qml', 'clock manifest points at the service') +assertEqual(manifest.entryPoints.barWidget, 'BarWidget.qml', 'clock manifest retains the widget entry point') +assert(service.includes('atomicWrites: true'), 'calendar state uses atomic FileView writes') +assert(service.includes('watchChanges: true'), 'calendar state watches external changes') +assert(service.includes('onFileChanged: reload()'), 'calendar state reloads when another process changes the file') +assert(service.includes('XDG_STATE_HOME'), 'calendar state respects XDG_STATE_HOME') +assert(service.includes('command: ["mkdir", "-p", root.stateHome + "/omarchy"]'), 'calendar service creates its state directory') +assert(service.includes('id: persistTimer'), 'calendar service debounces state writes') +assert(service.includes('OMARCHY_CALENDAR_SOLVER'), 'service supports a development solver override') +assert(service.includes('"omarchy-calendar-solver"'), 'service resolves the packaged solver through PATH') +assert(service.includes('id: solverProcess'), 'service owns the one-shot solver process') +assert(service.includes('stdinEnabled: true'), 'service sends planner input over stdin') +assert(service.includes('onStarted: write(root.pendingSolveJson + "\\n")'), 'service writes one JSON request to the solver') +assert(service.includes('solverStdoutText'), 'service captures solver stdout') +assert(service.includes('solverStderrText'), 'service captures solver stderr') +assert(service.includes('response.requestId !== requestId'), 'service discards stale solver responses') +assert(service.includes('requestRevision !== root.calendarState.inputRevision'), 'service discards obsolete revisions') +assert(!service.includes('PlannerSolver.js'), 'service has no JavaScript solver fallback') +assert(!fs.existsSync(root + '/shell/plugins/panels/clock/PlannerSolver.js'), 'JavaScript solver implementation is removed') +assert(dateTimeField.includes('ClockModel.monthGrid'), 'planner date input renders a calendar picker') +assert(dateTimeField.includes('function chooseDay(day)'), 'planner date input accepts a selected calendar day') +assert(dateTimeField.includes('function moveDay(delta)'), 'planner date input supports keyboard date navigation') +assert(dateTimeField.includes('HH:MM'), 'planner date input keeps time entry simple') +assert(dateTimeField.includes('property Item nextFocusTarget'), 'planner date input exposes explicit focus chaining') +assert(dateTimeField.includes('KeyNavigation.tab: timeField'), 'planner date input moves from date to time') +assert(eventEditor.includes('nextFocusTarget: endAtField.dateFocusTarget'), 'event start input moves to the end date') +assert(eventEditor.includes('nextFocusTarget: timezoneField'), 'event end input moves to timezone') +assert(eventEditor.includes('PlannerDateTimeField'), 'event editor uses the visual date input') +assert(taskEditor.includes('PlannerDateTimeField'), 'task editor uses the visual date input') +assert(!eventEditor.includes('ISO dates'), 'event editor does not ask users for ISO dates') +assert(!taskEditor.includes('ISO date'), 'task editor does not ask users for ISO dates') +assert(plannerView.includes('"task" in item') && plannerView.includes('"event" in item'), 'editor loader assigns only to matching editor types') +assert(plannerView.includes('? editorLoader.item') && plannerView.includes(': null'), 'editor signals connect only to a loaded editor') +assert(plannerView.includes('ignoreUnknownSignals: true'), 'editor signal connection tolerates proposal-only editors') +assert(plannerView.includes('editorImplicitHeight'), 'planner exposes the active editor height to the popup') +assert(panel.includes('plannerLoader.item.editorImplicitHeight'), 'clock popup sizes itself from the active editor') +assert(eventEditor.includes('implicitHeight: form.implicitHeight'), 'event input modal sizes itself from its form') +assert(taskEditor.includes('implicitHeight: form.implicitHeight'), 'task input modal sizes itself from its form') +assert(settingsEditor.includes('form.implicitHeight'), 'settings input modal sizes itself from its form') +assert(availabilityEditor.includes('form.implicitHeight'), 'availability input modal sizes itself from its form') +assert(proposalReview.includes('signal saved()') && proposalReview.includes('root.saved()'), 'applying a proposal closes the review before another view opens') +assert(!eventEditor.includes('Flickable'), 'event input modal does not scroll') +assert(!taskEditor.includes('Flickable'), 'task input modal does not scroll') +assert(settingsEditor.includes('ScrollView') && settingsEditor.includes('id: bodyScroll') && settingsEditor.includes('ScrollBar.vertical.policy') && settingsEditor.includes('anchors.bottom: footer.top'), 'settings body scrolls while actions stay visible') +assert(availabilityEditor.includes('ScrollView') && availabilityEditor.includes('id: bodyScroll') && availabilityEditor.includes('ScrollBar.vertical.policy') && availabilityEditor.includes('anchors.bottom: footer.top'), 'availability body scrolls while actions stay visible') +assert(availabilityEditor.includes('activeFocusOnTab: true'), 'availability time fields are reachable independently with Tab') +assert(availabilityEditor.includes('endField.forceActiveFocus()'), 'availability start time advances to the end time field') +assert(availabilityEditor.includes('startField.forceActiveFocus()'), 'availability end time supports reverse focus navigation') +assert(plannerView.includes('activeFocus || hot ? Color.foreground : Color.background'), 'primary planner actions stay readable in focus and hover states') +assert(plannerView.includes('ScrollView') && plannerView.includes('ScrollBar.vertical.policy') && plannerView.includes('AgendaView'), 'planner inbox and agenda retain scrolling content') +assert(plannerView.includes('duration.implicitWidth - parent.spacing'), 'planner task rows use their row spacing') +assert(eventEditor.includes('endAtField.forceActiveFocus()'), 'event validation focuses the end date input') +assert(plannerView.includes('Add planning task'), 'plan labels its action as a planning task') +assert(plannerView.includes('Add calendar event'), 'agenda labels its action as a calendar event') +assert(plannerView.includes('Plan tasks'), 'plan exposes an explicit planning action') +assert(plannerView.includes('How planning works'), 'plan explains the review and apply flow') +assert(plannerView.includes('planningTutorialKey'), 'plan gives the tutorial a stable persisted key') +assert(plannerView.includes('planningTutorialVisible'), 'plan hides dismissed tutorial content') +assert(plannerView.includes('PanelActionButton'), 'plan uses the shared small action button for tutorial dismissal') +assert(plannerView.includes('Dismiss planning help'), 'tutorial dismissal button explains its action') +assert(service.includes('function tutorialDismissed(key)'), 'calendar service reads persisted tutorial state') +assert(service.includes('function dismissTutorial(key)'), 'calendar service persists tutorial dismissal') +assert(service.includes('State.dismissTutorial'), 'calendar service uses the immutable tutorial reducer') +assert(service.includes('target: "omarchy.calendar"'), 'calendar service exposes the CLI IPC target') +assert(service.includes('function addEvent(inputJson: string)'), 'calendar service exposes event creation over IPC') +assert(service.includes('function addTask(inputJson: string)'), 'calendar service exposes task creation over IPC') +assert(service.includes('function setSettings(patchJson: string)'), 'calendar service exposes settings over IPC') +assert(service.includes('function apply(): string'), 'calendar service exposes explicit Apply over IPC') +assert(service.includes('function plan(): string'), 'calendar service exposes explicit planning over IPC') +assert(service.includes('function returnToInbox(taskId: string)'), 'calendar service exposes Return to inbox over IPC') +assert(service.includes('root.activeRequestId = ""'), 'planner requests are cleared after solving') +assert(service.includes('SystemClock.Minutes'), 'planner watches the system clock for local-midnight refreshes') +assert(widget.includes('serviceFor("omarchy.clock")'), 'bar widget receives the matching service') +assert(widget.includes('function openView(view)'), 'clock widget can open a selected popup view') +assert(widget.includes('function openView(view: string): void'), 'clock widget exposes selected view IPC') +assert(widget.includes('target.service = root.calendarService'), 'bar widget injects service into panel') +assert(panel.includes('property var service: null'), 'panel accepts the injected calendar service') +assert(panel.includes('PlannerView.qml'), 'panel loads the planner view') +assert(fs.existsSync(root + '/shell/plugins/panels/clock/CalendarView.qml'), 'calendar view component exists') +assert(fs.existsSync(root + '/shell/plugins/panels/clock/AgendaView.qml'), 'agenda view component exists') +assert(fs.existsSync(root + '/shell/plugins/panels/clock/ProposalReview.qml'), 'proposal review component exists') +assert(!service.includes('notify-send'), 'planner service does not send raw notifications') +assert(!service.includes('sqlite'), 'planner service does not use SQLite') +assert(!service.includes('curl'), 'planner service does not use a network URL') +assert(!service.includes('systemd'), 'planner service does not add a systemd unit') +assert(basePackages.split(/\s+/).includes('omarchy-calendar-solver'), 'fresh installs include the calendar solver package') +assert(solverMigration.includes('omarchy-pkg-add omarchy-calendar-solver'), 'existing installs receive the calendar solver through migration') +JS diff --git a/test/shell.d/calendar-state-test.sh b/test/shell.d/calendar-state-test.sh new file mode 100755 index 00000000000..6f09a491ab5 --- /dev/null +++ b/test/shell.d/calendar-state-test.sh @@ -0,0 +1,145 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const State = requireFromRoot('shell/plugins/panels/clock/State.js') + +function assertThrows(fn, description) { + let threw = false + try { fn() } catch (error) { threw = true } + assert(threw, description) +} + +const defaults = State.defaultSettings() +assertDeepEqual(defaults, { + timezone: '', + availability: {}, + horizonDays: 14, + slotMinutes: 15, + solveSeconds: 5, + priorityLowWeight: 1, + priorityNormalWeight: 5, + priorityHighWeight: 25, + cognitiveEnabled: false, + lowWindowStart: '00:00', + lowWindowEnd: '00:00', + lowOutsidePenalty: 0, + mediumWindowStart: '00:00', + mediumWindowEnd: '00:00', + mediumOutsidePenalty: 0, + highWindowStart: '00:00', + highWindowEnd: '00:00', + highOutsidePenalty: 0, + highStreakLimit: 1, + recoveryMinutes: 30, + excessHighPenalty: 60, +}, 'calendar settings expose every documented default') + +assertEqual(State.baseState().schemaVersion, 1, 'calendar state starts at schema version one') +assertEqual(State.baseState().inputRevision, 0, 'calendar state starts at input revision zero') +assertDeepEqual(State.baseState().ui, {dismissedTutorials: {}}, 'calendar state starts with no dismissed tutorials') +assertDeepEqual(State.normalizeState(null).events, [], 'missing state recovers with empty events') +assertDeepEqual(State.normalizeState({}).ui, {dismissedTutorials: {}}, 'older calendar state gets default UI state') +const tutorialState = State.dismissTutorial(State.baseState(), 'planningFlow') +assert(State.tutorialDismissed(tutorialState, 'planningFlow'), 'tutorial dismissal is recorded in calendar state') +assertEqual(tutorialState.inputRevision, 0, 'tutorial dismissal does not change planning inputs') +assertEqual(State.problemFingerprint(tutorialState), State.problemFingerprint(State.baseState()), 'tutorial dismissal does not change the planning problem') +assert(State.tutorialDismissed(State.normalizeState(JSON.parse(JSON.stringify(tutorialState))), 'planningFlow'), 'tutorial dismissal survives state persistence and reload') +assert(!State.tutorialDismissed(State.baseState(), 'planningFlow'), 'tutorial remains visible until it is dismissed') +assertThrows(() => State.dismissTutorial(State.baseState(), ''), 'tutorial dismissals require a key') +assertDeepEqual(State.normalizeState({settings: {horizonDays: 90}}).settings.horizonDays, 90, 'loaded settings are deeply normalized') +assertDeepEqual(State.normalizeState({settings: {availability: {monday: [{start: '9:00', end: '17:00'}]}}}).settings.availability, {monday: [{start: '09:00', end: '17:00'}]}, 'availability clocks are normalized') +assertDeepEqual(State.normalizeState({settings: {availability: {noday: [{start: '09:00', end: '17:00'}]}}}).settings.availability, {}, 'unknown loaded weekdays do not enter normalized state') + +assertEqual(State.normalizeClock('9:05'), '09:05', 'clock values accept single-digit input for normalization') +assertEqual(State.normalizeClock('09:60'), null, 'clock values reject minutes outside the day') +assert(State.validTimezone('Europe/Rome'), 'IANA timezone names are accepted') +assert(State.validTimezone('UTC'), 'UTC is accepted as a valid timezone') +assert(!State.validTimezone('Not/A_Timezone'), 'invalid timezone names are rejected') + +const invalidReady = State.settingsReady({timezone: 'Europe/Rome', availability: {monday: [{start: '09:00', end: '17:00'}]}}) +assert(invalidReady.ok, 'configured timezone and weekly availability are solve-ready') +assert(!State.settingsReady({timezone: 'Europe/Rome', availability: {}}).ok, 'empty weekly availability is not solve-ready') +assert(!State.validateSettings({timezone: 'Europe/Rome', availability: {monday: [{start: '17:00', end: '09:00'}]}}, false).ok, 'backwards availability windows are rejected') +assert(!State.validateSettings({timezone: 'Europe/Rome', availability: {funday: [{start: '09:00', end: '17:00'}]}}, false).ok, 'unknown availability weekdays are rejected') +assert(!State.validateSettings({timezone: 'Europe/Rome', availability: {monday: [{start: '09:00', end: '17:00'}]}, horizonDays: 0}, false).ok, 'settings enforce the horizon lower bound') +assert(!State.validateSettings({timezone: 'Europe/Rome', availability: {monday: [{start: '09:00', end: '17:00'}]}, slotMinutes: 121}, false).ok, 'settings enforce the slot-size upper bound') + +const first = State.newId('task', 1700000000000, 'abc') +const second = State.newId('task', 1700000000000, 'def') +assert(/^task-1700000000000-\d+-abc$/.test(first), 'local ids carry kind, time, counter, and random suffix') +assert(first !== second, 'local ids remain unique within a process') + +const manualEvent = { + id: 'event-manual', title: 'Busy', startAt: '2026-09-07T09:00:00+02:00', endAt: '2026-09-07T10:00:00+02:00', timezone: 'Europe/Rome' +} +let state = State.addEvent(State.baseState(), manualEvent, '2026-09-01T10:00:00Z') +assertEqual(state.inputRevision, 1, 'event creation advances the input revision') +assertEqual(state.events[0].origin, 'manual', 'events default to manual origin') +const eventFingerprint = State.problemFingerprint(state) +state = State.updateEvent(state, 'event-manual', {title: 'Busy updated'}, '2026-09-01T10:01:00Z') +assertEqual(state.inputRevision, 2, 'event edits advance the input revision') +assert(State.problemFingerprint(state) !== eventFingerprint, 'problem fingerprints change with planning inputs') + +state = State.addTask(state, {id: 'task-a', title: 'First', durationMinutes: 30}, '2026-09-01T10:02:00Z') +state = State.addTask(state, {id: 'task-b', title: 'Second', durationMinutes: 30}, '2026-09-01T10:03:00Z') +assertEqual(state.tasks[0].state, 'inbox', 'new tasks enter the inbox') +state = State.addDependency(state, 'task-a', 'task-b') +assertEqual(state.dependencies.length, 1, 'dependency creation is persisted') +const sameDependencies = State.replaceTaskDependencies(state, 'task-b', ['task-a']) +assertEqual(sameDependencies.inputRevision, state.inputRevision, 'unchanged dependencies do not advance the revision') +assertThrows(() => State.addDependency(state, 'task-b', 'task-a'), 'dependency cycles are rejected') +assertThrows(() => State.addDependency(state, 'task-a', 'task-a'), 'self-dependencies are rejected') +assertThrows(() => State.addDependency(state, 'task-a', 'missing'), 'dependencies require existing task ids') +state = State.deleteDependency(state, 'task-a', 'task-b') +assertEqual(state.dependencies.length, 0, 'dependency deletion is persisted') +state = State.replaceTaskDependencies(state, 'task-b', ['task-a']) +assertEqual(state.dependencies[0].fromTaskId, 'task-a', 'task editor replaces incoming dependencies') +assertThrows(() => State.replaceTaskDependencies(state, 'task-a', ['task-b']), 'task editor rejects dependency cycles') +state = State.replaceTaskDependencies(state, 'task-b', []) +assertEqual(state.dependencies.length, 0, 'task editor can clear dependencies') + +const beforeSettingsRevision = state.inputRevision +state = State.updateSettings(state, {timezone: 'Europe/Rome', availability: {monday: [{start: '09:00', end: '17:00'}]}}, '2026-09-01T10:04:00Z') +assertEqual(state.inputRevision, beforeSettingsRevision + 1, 'settings changes advance the input revision') +assert(State.settingsReady(state.settings).ok, 'settings reducer leaves a ready configuration') + +const proposal = { + id: 'proposal-1', status: 'ready', baseInputRevision: state.inputRevision, requestId: 'request-1', + horizonStart: '2026-09-07T00:00:00+02:00', horizonDays: 14, timezone: 'Europe/Rome', score: {hard: 0, medium: 0, soft: 0}, + items: [ + {taskId: 'task-a', startAt: '2026-09-07T09:00:00+02:00', endAt: '2026-09-07T09:30:00+02:00', scheduled: true}, + {taskId: 'task-b', scheduled: false, diagnostics: {outcome: 'no_hard_feasible_slot'}} + ], applicabilityReasons: [] +} +state = State.writeProposal(state, proposal) +assertEqual(state.inputRevision, proposal.baseInputRevision, 'writing derived proposal output does not advance input revision') +assertEqual(state.proposal.status, 'ready', 'written proposals are ready for review') +state = State.updateTask(state, 'task-a', {title: 'First changed'}, '2026-09-01T10:05:00Z') +assertEqual(state.proposal.status, 'stale', 'input changes stale the current proposal') +assertThrows(() => State.applyProposal(state), 'stale proposals cannot be applied') + +state = State.writeProposal(state, {...proposal, baseInputRevision: state.inputRevision, requestId: 'request-2'}) +const appliedRevision = state.inputRevision + 1 +state = State.applyProposal(state, '2026-09-01T10:06:00Z') +assertEqual(state.inputRevision, appliedRevision, 'applying a proposal advances the input revision exactly once') +assertEqual(state.proposal.status, 'applied', 'applying a proposal marks it applied') +assertEqual(state.tasks.find(task => task.id === 'task-a').state, 'applied', 'scheduled tasks become applied') +assertEqual(state.tasks.find(task => task.id === 'task-b').state, 'inbox', 'unscheduled tasks remain in the inbox') +assertEqual(state.events.filter(event => event.origin === 'planner').length, 1, 'applying creates one linked planner event') + +const linkedEventId = state.tasks.find(task => task.id === 'task-a').linkedEventId +state = State.deleteEvent(state, linkedEventId) +assertEqual(state.tasks.find(task => task.id === 'task-a').state, 'missing_event', 'deleting a linked planner event marks its task missing_event') +assertThrows(() => State.updateTask(state, 'task-a', {title: 'unsafe edit'}), 'applied task edits are locked') +state = State.returnToInbox(state, 'task-a', '2026-09-01T10:07:00Z') +assertEqual(state.tasks.find(task => task.id === 'task-a').state, 'inbox', 'return to inbox is explicit') +assertEqual(state.events.filter(event => event.taskId === 'task-a').length, 0, 'return to inbox removes only the linked planner event') + +const stableA = State.problemFingerprint(state) +const stableB = State.problemFingerprint(State.normalizeState(JSON.parse(JSON.stringify(state)))) +assertEqual(stableA, stableB, 'problem fingerprints are stable across JSON round trips') +JS diff --git a/test/shell.d/clock-test.sh b/test/shell.d/clock-test.sh index e10d62f1226..b89eff0c4e7 100755 --- a/test/shell.d/clock-test.sh +++ b/test/shell.d/clock-test.sh @@ -8,6 +8,7 @@ run_node_test <<'JS' const fs = require('fs') const calendar = requireFromRoot('shell/plugins/panels/clock/Model.js') const panelSource = fs.readFileSync(root + '/shell/plugins/panels/clock/Panel.qml', 'utf8') +const calendarViewSource = fs.readFileSync(root + '/shell/plugins/panels/clock/CalendarView.qml', 'utf8') // Comments stripped: a wiring assertion that a commented-out line can satisfy // passes while the widget is broken. const widgetSource = fs.readFileSync(root + '/shell/plugins/panels/clock/BarWidget.qml', 'utf8') @@ -203,31 +204,33 @@ assert(/onDateChanged: root\.displayDate = date/.test(widgetSource), 'clock repa assert(/setting\("weekStartDay", null\)/.test(panelSource) && /persistSettings\(\{ weekStartDay:/.test(panelSource), 'calendar reads and writes the week start as weekStartDay') assert(/updateEntryInline/.test(panelSource), 'calendar panel persists the week start to shell.json') assert(/function moveMonth\(delta\)/.test(panelSource), 'calendar panel steps between months') +assert(/function openAgenda\(\)/.test(panelSource) && /t === "a" \|\| t === "A"/.test(panelSource), 'calendar panel exposes the agenda shortcut') assert(!/property bool onToday/.test(panelSource) && !/root\.onToday/.test(panelSource), 'calendar panel avoids the on-prefixed property name QML reads as a signal handler') -assert(/readonly property bool viewingCurrentMonth:/.test(panelSource), 'calendar panel tracks whether the current month is on screen') -assert(!/MouseArea/.test(panelSource.slice(panelSource.indexOf('model: modelData.days'), panelSource.indexOf('// Hairline'))), 'calendar day cells are not selectable') -assert(/yearDone: Model\.yearProgress\(today\./.test(panelSource), 'calendar year bar stays pinned to today while months are stepped') +assert(/CalendarView\s*\{/.test(panelSource), 'calendar panel delegates the month view to CalendarView') +assert(/readonly property bool viewingCurrentMonth:/.test(calendarViewSource), 'calendar view tracks whether the current month is on screen') +assert(!/MouseArea/.test(calendarViewSource.slice(calendarViewSource.indexOf('model: modelData.days'), calendarViewSource.indexOf('Rectangle {\n x: gridColumn.x'))), 'calendar day cells are not selectable') +assert(/yearDone: Model\.yearProgress\(today\./.test(calendarViewSource), 'calendar year bar stays pinned to today while months are stepped') assert(/Qt\.callLater\(function\(\) \{\s*\n\s*if \(root\.opened\) setCenterHoverRevealSuppressed\(true\)/.test(panelSource), 'calendar claims the shared hover-reveal flag after the popout handoff, so the panel taking over wins') assert(/function close\(\) \{\s*\n\s*setCenterHoverRevealSuppressed\(false\)/.test(panelSource), 'calendar always releases the shared hover-reveal flag on close') -assert(/width: Math\.max\(calendarScroll\.width, gridColumn\.width\)/.test(panelSource), 'calendar scrolls rather than clipping the grid on a narrow popup') -assert(/enabled: !root\.viewingCurrentMonth/.test(panelSource) && /onClicked: root\.goToToday\(\)/.test(panelSource), 'calendar hero returns to today once the view has stepped away') +assert(/width: Math\.max\(calendarScroll\.width, gridColumn\.width\)/.test(calendarViewSource), 'calendar scrolls rather than clipping the grid on a narrow popup') +assert(/enabled: !root\.viewingCurrentMonth/.test(calendarViewSource) && /onClicked: root\.todayRequested\(\)/.test(calendarViewSource), 'calendar hero returns to today once the view has stepped away') assert(!/clampMonth/.test(panelSource), 'calendar steps freely into future months') -assert(/Qt\.formatDate\(root\.today, "MMMM d"\)/.test(panelSource), 'calendar hero spells out today') -assert(/id: yearLabel/.test(panelSource) && /root\.yearDone/.test(panelSource), 'calendar panel shows the year progress bar') +assert(/Qt\.formatDate\(root\.today, "MMMM d"\)/.test(calendarViewSource), 'calendar hero spells out today') +assert(/id: yearLabel/.test(calendarViewSource) && /root\.yearDone/.test(calendarViewSource), 'calendar view shows the year progress bar') // The memento mori bar is opt-in: double-tapping the year bar asks for an age, // and nothing shows until one has been given. -assert(/onDoubleTapped: root\.startEditingLife\(\)/.test(panelSource), 'calendar asks for an age when the year bar is double-tapped') +assert(/onDoubleTapped: root\.lifeEditRequested\(\)/.test(calendarViewSource), 'calendar asks for an age when the year bar is double-tapped') assert(/persistSettings\(\{ birthYear: born, lifeExpectancy: span \}\)/.test(panelSource), 'calendar saves birth year and expectancy together, so neither lands on a stale copy') assert(/readonly property int birthYear: Model\.parseBirthYear\(setting\("birthYear", 0\)/.test(panelSource), 'calendar reads the saved birth year back') assert(/readonly property int age: Model\.ageFromBirthYear\(birthYear/.test(panelSource), 'calendar derives the age from the stored birth year') assert(/readonly property int lifeExpectancy: Model\.parseLifeExpectancy\(setting\("lifeExpectancy", 0\)\)/.test(panelSource), 'calendar reads the saved expectancy back') -assert(/id: expectancyField/.test(panelSource) && /id: bornField/.test(panelSource), 'calendar offers both inputs') -assert(/visible: root\.editingLife\s*\n\s*anchors\.horizontalCenter: parent\.horizontalCenter/.test(panelSource), 'calendar centers the inputs over the bar they replace') -assert(/visible: root\.birthYear > 0/.test(panelSource), 'calendar hides the life bar until a birth year is known') -assert(/text: "LIFE"/.test(panelSource) && /root\.lifeDone/.test(panelSource), 'calendar shows the life bar') -assert(/text: "Memento Mori"/.test(panelSource), 'calendar names the life bar on hover') -assert(/onDoubleTapped: root\.clearLife\(\)/.test(panelSource), 'calendar puts the life bar away when it is double-tapped') +assert(/id: expectancyField/.test(calendarViewSource) && /id: bornField/.test(calendarViewSource), 'calendar offers both inputs') +assert(/visible: root\.editingLife\s*\n\s*anchors\.horizontalCenter: parent\.horizontalCenter/.test(calendarViewSource), 'calendar centers the inputs over the bar they replace') +assert(/visible: root\.birthYear > 0/.test(calendarViewSource), 'calendar hides the life bar until a birth year is known') +assert(/text: "LIFE"/.test(calendarViewSource) && /root\.lifeDone/.test(calendarViewSource), 'calendar shows the life bar') +assert(/text: "Memento Mori"/.test(calendarViewSource), 'calendar names the life bar on hover') +assert(/onDoubleTapped: root\.lifeClearRequested\(\)/.test(calendarViewSource), 'calendar puts the life bar away when it is double-tapped') assert(/persistSettings\(\{ birthYear: 0 \}\)/.test(panelSource), 'calendar clears the birth year to hide the life bar') assertEqual(calendar.parseBirthYear(0, 2026), 0, 'a cleared birth year reads back as unset') assert(/blocked: root\.editingLife/.test(panelSource), 'calendar lets the inputs have the keyboard while they are up') @@ -257,7 +260,7 @@ assert(/readonly property real labelWidth:/.test(fs.readFileSync(root + '/shell/ // A horizontal wheel reports angleDelta.y === 0; without the guard every one // of them would read as a forward step. -assert(/if \(event\.angleDelta\.y === 0\) return/.test(panelSource), 'calendar ignores wheel events with no vertical delta') +assert(/if \(event\.angleDelta\.y === 0\) return/.test(calendarViewSource), 'calendar ignores wheel events with no vertical delta') JS shell_json=$(cd "$ROOT" && jq -r '[.bar.layout.center[].id] | join(",")' config/omarchy/shell.json) diff --git a/test/shell.d/fixtures/calendar-planner-service/shell.qml b/test/shell.d/fixtures/calendar-planner-service/shell.qml new file mode 100644 index 00000000000..7bfe412a560 --- /dev/null +++ b/test/shell.d/fixtures/calendar-planner-service/shell.qml @@ -0,0 +1,129 @@ +import QtQuick +import Quickshell + +ShellRoot { + id: root + + property string resultPath: Quickshell.env("OMARCHY_QML_TEST_RESULT") + property string serviceUrl: Quickshell.env("OMARCHY_QML_SERVICE_URL") + property var failures: [] + property var service: null + property string firstTaskId: "" + property string secondTaskId: "" + property int phase: 0 + + function fail(message) { root.failures.push(String(message)) } + + function shellQuote(value) { + return "'" + String(value).replace(/'/g, "'\\''") + "'" + } + + function writeResult() { + var payload = JSON.stringify({ ok: root.failures.length === 0, failures: root.failures }) + Quickshell.execDetached([ + "bash", "-lc", + "printf '%s' " + root.shellQuote(payload) + " > " + root.shellQuote(root.resultPath) + ]) + } + + function start() { + var component = Qt.createComponent(root.serviceUrl, Component.PreferSynchronous) + if (component.status !== Component.Ready) { + root.fail("service component failed to load: " + component.errorString()) + root.writeResult() + return + } + root.service = component.createObject(host) + if (!root.service) { + root.fail("service component failed to instantiate") + root.writeResult() + return + } + poll.start() + } + + function item(taskId) { + var proposal = root.service.calendarState.proposal + var items = proposal && proposal.items || [] + for (var i = 0; i < items.length; i++) if (items[i].taskId === taskId) return items[i] + return null + } + + function advance() { + if (!root.service || !root.service.stateLoaded) return + + if (root.phase === 0) { + if (root.service.solveState !== "configuration_needed") { + root.fail("first-run service state should require configuration") + root.phase = 99 + root.writeResult() + return + } + if (!root.service.updateSettings({ + timezone: "Europe/Rome", + availability: { friday: [{ start: "09:00", end: "17:00" }] } + })) { + root.fail("service rejected valid first-run settings") + root.phase = 99 + root.writeResult() + return + } + var first = root.service.addTask({ + title: "Service integration task", durationMinutes: 30, priority: "high", + cognitiveLoad: "medium", deadlineKind: "none", deadlineAt: null, earliestAt: null + }) + var second = root.service.addTask({ + title: "Dependent integration task", durationMinutes: 30, priority: "normal", + cognitiveLoad: "medium", deadlineKind: "none", deadlineAt: null, earliestAt: null + }) + root.firstTaskId = first.tasks[first.tasks.length - 1].id + root.secondTaskId = second.tasks[second.tasks.length - 1].id + if (!root.service.addDependency(root.firstTaskId, root.secondTaskId)) { + root.fail("service rejected a valid dependency") + root.phase = 99 + root.writeResult() + return + } + root.phase = 1 + return + } + + if (root.phase === 1) { + var proposal = root.service.calendarState.proposal + if (root.service.solveState !== "ready" || !proposal || Number(proposal.baseInputRevision) !== root.service.calendarState.inputRevision) return + var firstItem = root.item(root.firstTaskId) + var secondItem = root.item(root.secondTaskId) + if (!firstItem || !firstItem.scheduled) root.fail("solver scheduled the prerequisite task") + if (!secondItem || !secondItem.scheduled) root.fail("solver scheduled the dependent task") + if (firstItem && secondItem && Date.parse(firstItem.endAt) > Date.parse(secondItem.startAt)) root.fail("solver respected dependency order") + if (root.service.calendarState.events.length !== 0) root.fail("proposal changed events before Apply") + if (!root.service.updateTask(root.firstTaskId, { title: "Updated integration task" })) root.fail("service rejected an input update") + if (!root.service.planNow()) root.fail("service rejected an explicit Plan tasks request") + if (!root.service.forcePlanRequested) root.fail("explicit Plan tasks request did not force a fresh suggestion") + root.phase = 2 + return + } + + if (root.phase === 2) { + var refreshed = root.service.calendarState.proposal + if (root.service.solveState !== "ready" || !refreshed || Number(refreshed.baseInputRevision) !== root.service.calendarState.inputRevision) return + if (root.service.forcePlanRequested) root.fail("explicit Plan tasks request remained pending after planning") + if (!root.service.applyProposal()) root.fail("service rejected a ready proposal") + if (root.service.calendarState.events.length !== 2) root.fail("Apply created planner events") + root.phase = 3 + root.writeResult() + poll.stop() + } + } + + Item { id: host } + + Timer { + id: poll + interval: 25 + repeat: true + onTriggered: root.advance() + } + + Component.onCompleted: Qt.callLater(root.start) +} diff --git a/test/shell.d/fixtures/calendar-planner-service/solver b/test/shell.d/fixtures/calendar-planner-service/solver new file mode 100644 index 00000000000..810a37e726e --- /dev/null +++ b/test/shell.d/fixtures/calendar-planner-service/solver @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +request=$(head -n 1) +request_id=$(jq -r '.requestId' <<<"$request") +revision=$(jq -r '.baseInputRevision' <<<"$request") +tasks=$(jq -c '.tasks' <<<"$request") + +jq -n \ + --arg request_id "$request_id" \ + --argjson revision "$revision" \ + --argjson tasks "$tasks" ' + def item($task; $offset): { + taskId: $task.id, + startAt: ("2026-09-04T" + (if $offset == 0 then "09:00:00+02:00" else "09:30:00+02:00" end)), + endAt: ("2026-09-04T" + (if $offset == 0 then "09:30:00+02:00" else "10:00:00+02:00" end)), + scheduled: true, + cognitivePenalty: 0, + fatiguePenalty: 0, + explanation: "Fits the configured availability without hard conflicts.", + diagnostics: {outcome: "scheduled"}, + busyBlockers: [], + omittedBlockerCount: 0 + }; + { + protocolVersion: 1, + requestId: $request_id, + ok: true, + proposal: { + id: "fixture-proposal", + status: "ready", + baseInputRevision: $revision, + requestId: $request_id, + horizonStart: "2026-09-04T00:00:00+02:00", + horizonDays: 14, + timezone: "Europe/Rome", + score: {hard: 0, medium: 0, soft: 0}, + createdAt: "2026-09-04T08:00:00+02:00", + items: [item($tasks[0]; 0), item($tasks[1]; 1)], + applicabilityReasons: [] + }, + error: null + } + ' diff --git a/test/shell.d/plugin-clone-test.sh b/test/shell.d/plugin-clone-test.sh index 2c9c815525e..10bc380212e 100755 --- a/test/shell.d/plugin-clone-test.sh +++ b/test/shell.d/plugin-clone-test.sh @@ -77,7 +77,8 @@ jq -e ' .name == "My Clock" and .barWidget.displayName == "My Clock" and .omarchy.clonedFrom == "omarchy.clock" and - .kinds == ["bar-widget"] and + .kinds == ["service", "bar-widget"] and + .entryPoints.service == "Service.qml" and .entryPoints.barWidget == "BarWidget.qml" ' "$clock/manifest.json" >/dev/null || fail "clock clone manifest is incorrect" pass "clone updates identity without replacing the manifest"