openuispec 0.1.18 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +52 -34
  2. package/cli/index.ts +1 -1
  3. package/docs/stress-test-maturity-report.md +97 -0
  4. package/examples/todo-orbit/AGENTS.md +127 -0
  5. package/examples/todo-orbit/CLAUDE.md +127 -0
  6. package/examples/todo-orbit/README.md +62 -0
  7. package/examples/todo-orbit/generated/android/Todo Orbit/README.md +14 -0
  8. package/examples/todo-orbit/generated/android/Todo Orbit/app/build.gradle.kts +58 -0
  9. package/examples/todo-orbit/generated/android/Todo Orbit/app/proguard-rules.pro +1 -0
  10. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/AndroidManifest.xml +20 -0
  11. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/MainActivity.kt +14 -0
  12. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/TodoOrbitApp.kt +345 -0
  13. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/support/AppLogic.kt +231 -0
  14. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/support/Models.kt +169 -0
  15. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/support/Strings.kt +8 -0
  16. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/components/CommonComponents.kt +185 -0
  17. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/AnalyticsScreen.kt +193 -0
  18. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/SettingsScreen.kt +102 -0
  19. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/TasksScreen.kt +342 -0
  20. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/sheets/EditorSheets.kt +344 -0
  21. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/theme/TodoOrbitTheme.kt +59 -0
  22. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values/strings.xml +148 -0
  23. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values-ru/strings.xml +154 -0
  24. package/examples/todo-orbit/generated/android/Todo Orbit/build.gradle.kts +4 -0
  25. package/examples/todo-orbit/generated/android/Todo Orbit/gradle/wrapper/gradle-wrapper.jar +0 -0
  26. package/examples/todo-orbit/generated/android/Todo Orbit/gradle/wrapper/gradle-wrapper.properties +7 -0
  27. package/examples/todo-orbit/generated/android/Todo Orbit/gradle.properties +4 -0
  28. package/examples/todo-orbit/generated/android/Todo Orbit/gradlew +248 -0
  29. package/examples/todo-orbit/generated/android/Todo Orbit/gradlew.bat +93 -0
  30. package/examples/todo-orbit/generated/android/Todo Orbit/settings.gradle.kts +18 -0
  31. package/examples/todo-orbit/generated/ios/Todo Orbit/README.md +29 -0
  32. package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/en.lproj/Localizable.strings +118 -0
  33. package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/ru.lproj/Localizable.strings +118 -0
  34. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/App/TodoOrbitApp.swift +50 -0
  35. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Components/OrbitChrome.swift +204 -0
  36. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Components/SchedulePreviewView.swift +126 -0
  37. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Components/TrendChartView.swift +70 -0
  38. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/RecurringRuleSheet.swift +123 -0
  39. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/TaskEditorSheet.swift +60 -0
  40. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Models/DomainModels.swift +238 -0
  41. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/AnalyticsView.swift +94 -0
  42. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/SettingsView.swift +74 -0
  43. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/TasksHomeView.swift +363 -0
  44. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Support/AppModel.swift +324 -0
  45. package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/project.pbxproj +408 -0
  46. package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  47. package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/xcshareddata/xcschemes/TodoOrbit.xcscheme +79 -0
  48. package/examples/todo-orbit/generated/ios/Todo Orbit/project.yml +24 -0
  49. package/examples/todo-orbit/generated/web/Todo Orbit/index.html +16 -0
  50. package/examples/todo-orbit/generated/web/Todo Orbit/package-lock.json +1087 -0
  51. package/examples/todo-orbit/generated/web/Todo Orbit/package.json +24 -0
  52. package/examples/todo-orbit/generated/web/Todo Orbit/src/App.tsx +2114 -0
  53. package/examples/todo-orbit/generated/web/Todo Orbit/src/main.tsx +13 -0
  54. package/examples/todo-orbit/generated/web/Todo Orbit/src/styles.css +886 -0
  55. package/examples/todo-orbit/generated/web/Todo Orbit/tsconfig.json +19 -0
  56. package/examples/todo-orbit/generated/web/Todo Orbit/vite.config.ts +6 -0
  57. package/examples/todo-orbit/openuispec/README.md +158 -0
  58. package/examples/todo-orbit/openuispec/contracts/.gitkeep +0 -0
  59. package/examples/todo-orbit/openuispec/contracts/action_trigger.yaml +28 -0
  60. package/examples/todo-orbit/openuispec/contracts/collection.yaml +32 -0
  61. package/examples/todo-orbit/openuispec/contracts/data_display.yaml +38 -0
  62. package/examples/todo-orbit/openuispec/contracts/feedback.yaml +32 -0
  63. package/examples/todo-orbit/openuispec/contracts/input_field.yaml +52 -0
  64. package/examples/todo-orbit/openuispec/contracts/nav_container.yaml +47 -0
  65. package/examples/todo-orbit/openuispec/contracts/surface.yaml +28 -0
  66. package/examples/todo-orbit/openuispec/contracts/x_schedule_preview.yaml +134 -0
  67. package/examples/todo-orbit/openuispec/contracts/x_task_trend_chart.yaml +139 -0
  68. package/examples/todo-orbit/openuispec/flows/.gitkeep +0 -0
  69. package/examples/todo-orbit/openuispec/flows/create_recurring_rule.yaml +253 -0
  70. package/examples/todo-orbit/openuispec/flows/create_task.yaml +118 -0
  71. package/examples/todo-orbit/openuispec/flows/edit_task.yaml +126 -0
  72. package/examples/todo-orbit/openuispec/locales/.gitkeep +0 -0
  73. package/examples/todo-orbit/openuispec/locales/en.json +150 -0
  74. package/examples/todo-orbit/openuispec/locales/ru.json +150 -0
  75. package/examples/todo-orbit/openuispec/openuispec.yaml +122 -0
  76. package/examples/todo-orbit/openuispec/platform/.gitkeep +0 -0
  77. package/examples/todo-orbit/openuispec/platform/android.yaml +19 -0
  78. package/examples/todo-orbit/openuispec/platform/ios.yaml +20 -0
  79. package/examples/todo-orbit/openuispec/platform/web.yaml +22 -0
  80. package/examples/todo-orbit/openuispec/screens/.gitkeep +0 -0
  81. package/examples/todo-orbit/openuispec/screens/analytics.yaml +139 -0
  82. package/examples/todo-orbit/openuispec/screens/home.yaml +172 -0
  83. package/examples/todo-orbit/openuispec/screens/settings.yaml +148 -0
  84. package/examples/todo-orbit/openuispec/screens/task_detail.yaml +223 -0
  85. package/examples/todo-orbit/openuispec/tokens/.gitkeep +0 -0
  86. package/examples/todo-orbit/openuispec/tokens/color.yaml +93 -0
  87. package/examples/todo-orbit/openuispec/tokens/elevation.yaml +25 -0
  88. package/examples/todo-orbit/openuispec/tokens/icons.yaml +92 -0
  89. package/examples/todo-orbit/openuispec/tokens/layout.yaml +107 -0
  90. package/examples/todo-orbit/openuispec/tokens/motion.yaml +39 -0
  91. package/examples/todo-orbit/openuispec/tokens/spacing.yaml +18 -0
  92. package/examples/todo-orbit/openuispec/tokens/themes.yaml +23 -0
  93. package/examples/todo-orbit/openuispec/tokens/typography.yaml +52 -0
  94. package/package.json +1 -1
  95. package/schema/validate.ts +0 -2
  96. package/spec/openuispec-v0.1.md +76 -12
@@ -0,0 +1,2114 @@
1
+ import {
2
+ startTransition,
3
+ useDeferredValue,
4
+ useEffect,
5
+ useMemo,
6
+ useState
7
+ } from "react";
8
+ import { Navigate, NavLink, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
9
+ import { create } from "zustand";
10
+
11
+ type Locale = "en" | "ru";
12
+ type Theme = "light" | "dark";
13
+ type TaskStatus = "open" | "done";
14
+ type Priority = "low" | "medium" | "high";
15
+ type Filter = "all" | "open" | "done";
16
+ type Period = "week" | "month" | "quarter";
17
+ type Cadence = "daily" | "weekly" | "monthly";
18
+ type Weekday = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
19
+ type SummaryChannel = "push" | "email";
20
+
21
+ type Task = {
22
+ id: string;
23
+ title: string;
24
+ notes?: string;
25
+ status: TaskStatus;
26
+ priority: Priority;
27
+ dueDate?: string;
28
+ createdAt: string;
29
+ updatedAt: string;
30
+ };
31
+
32
+ type Preferences = {
33
+ locale: Locale;
34
+ theme: Theme;
35
+ remindersEnabled: boolean;
36
+ dailySummaryEnabled: boolean;
37
+ };
38
+
39
+ type RecurringRule = {
40
+ id: string;
41
+ name: string;
42
+ cadence: Cadence;
43
+ interval: number;
44
+ weekday?: Weekday;
45
+ monthDay?: number;
46
+ startDate: string;
47
+ endDate?: string;
48
+ remindAt?: string;
49
+ summaryChannel?: SummaryChannel;
50
+ };
51
+
52
+ type Toast = {
53
+ id: string;
54
+ message: string;
55
+ severity: "success" | "warning" | "error" | "info";
56
+ };
57
+
58
+ type TrendPoint = {
59
+ label: string;
60
+ completed: number;
61
+ created: number;
62
+ };
63
+
64
+ type RuleDraft = {
65
+ name: string;
66
+ confirmName: string;
67
+ cadence: "" | Cadence;
68
+ interval: string;
69
+ weekday: "" | Weekday;
70
+ monthDay: string;
71
+ startDate: string;
72
+ hasEndDate: boolean;
73
+ endDate: string;
74
+ remindAt: string;
75
+ enableSummary: boolean;
76
+ summaryChannel: "" | SummaryChannel;
77
+ };
78
+
79
+ const messages: Record<Locale, Record<string, string>> = {
80
+ en: {
81
+ "nav.tasks": "Tasks",
82
+ "nav.analytics": "Analytics",
83
+ "nav.settings": "Settings",
84
+ "home.title": "Today, organized",
85
+ "home.search_label": "Search tasks",
86
+ "home.search_placeholder": "Search by title or notes",
87
+ "home.filter.all": "All",
88
+ "home.filter.open": "Open",
89
+ "home.filter.done": "Done",
90
+ "home.mark_complete": "Mark {title} complete",
91
+ "home.empty_title": "Nothing to do",
92
+ "home.empty_body": "Add a task or switch filters to see more items.",
93
+ "home.new_task": "New task",
94
+ "analytics.title": "Task analytics",
95
+ "analytics.subtitle": "Monitor throughput, overdue work, and completion trends.",
96
+ "analytics.period_week": "Week",
97
+ "analytics.period_month": "Month",
98
+ "analytics.period_quarter": "Quarter",
99
+ "analytics.completed_today": "Completed today",
100
+ "analytics.open_tasks": "Open tasks",
101
+ "analytics.overdue_tasks": "Overdue",
102
+ "analytics.completion_rate": "Completion rate",
103
+ "analytics.overdue_section": "Overdue review",
104
+ "analytics.overdue_subtitle": "Tasks that need attention first.",
105
+ "analytics.empty_trend": "No trend data yet.",
106
+ "analytics.empty_overdue": "No overdue tasks",
107
+ "analytics.empty_overdue_body": "Everything important is on track.",
108
+ "task_detail.status": "Status",
109
+ "task_detail.priority": "Priority",
110
+ "task_detail.notes": "Notes",
111
+ "task_detail.due_date": "Due date",
112
+ "task_detail.no_due_date": "No deadline",
113
+ "task_detail.created": "Created",
114
+ "task_detail.updated": "Updated",
115
+ "task_detail.edit": "Edit task",
116
+ "task_detail.toggle_status": "Toggle status",
117
+ "task_detail.more_info": "More info",
118
+ "task_detail.delete": "Delete task",
119
+ "task_detail.delete_title": "Delete this task?",
120
+ "task_detail.delete_message": "This action cannot be undone.",
121
+ "task_detail.updated_feedback": "Task updated",
122
+ "task_detail.update_error": "Could not update task",
123
+ "task_detail.deleted_feedback": "Task deleted",
124
+ "settings.title": "Preferences",
125
+ "settings.subtitle": "Adjust language and theme for every platform target.",
126
+ "settings.language": "Language",
127
+ "settings.language_en": "English",
128
+ "settings.language_ru": "Russian",
129
+ "settings.theme": "Theme",
130
+ "settings.theme_light": "Light",
131
+ "settings.theme_dark": "Dark",
132
+ "settings.reminders": "Due date reminders",
133
+ "settings.reminders_helper": "Notify me before tasks are due.",
134
+ "settings.daily_summary": "Daily summary",
135
+ "settings.daily_summary_helper": "Send a summary of open work each morning.",
136
+ "settings.automation_title": "Automation",
137
+ "settings.automation_subtitle": "Create recurring task rules to stress conditional forms and validation.",
138
+ "settings.automation_create_rule": "Create recurring rule",
139
+ "settings.save": "Save changes",
140
+ "settings.saving": "Saving...",
141
+ "settings.saved": "Preferences updated",
142
+ "settings.error_title": "Could not update preferences",
143
+ "create_task.title": "New task",
144
+ "create_task.save": "Save",
145
+ "create_task.saving": "Saving...",
146
+ "create_task.field_title": "Title",
147
+ "create_task.field_title_placeholder": "What needs to be done?",
148
+ "create_task.field_notes": "Notes",
149
+ "create_task.field_notes_placeholder": "Context, links, or next steps",
150
+ "create_task.field_priority": "Priority",
151
+ "create_task.field_due_date": "Due date",
152
+ "create_task.field_due_date_placeholder": "No deadline",
153
+ "create_task.success": "Task created",
154
+ "create_task.error_title": "Could not create task",
155
+ "edit_task.title": "Edit task",
156
+ "edit_task.save": "Save",
157
+ "edit_task.saving": "Saving...",
158
+ "edit_task.field_title": "Title",
159
+ "edit_task.field_notes": "Notes",
160
+ "edit_task.field_priority": "Priority",
161
+ "edit_task.field_due_date": "Due date",
162
+ "edit_task.success": "Task saved",
163
+ "edit_task.error_title": "Could not save task",
164
+ "recurring_rule.title": "Recurring rule",
165
+ "recurring_rule.subtitle": "Configure a reusable schedule with conditional inputs and validation.",
166
+ "recurring_rule.save": "Save rule",
167
+ "recurring_rule.saving": "Saving...",
168
+ "recurring_rule.success": "Recurring rule created",
169
+ "recurring_rule.error_title": "Could not create recurring rule",
170
+ "recurring_rule.field_name": "Rule name",
171
+ "recurring_rule.field_name_placeholder": "Daily planning ritual",
172
+ "recurring_rule.field_confirm_name": "Confirm rule name",
173
+ "recurring_rule.field_confirm_name_placeholder": "Repeat the rule name",
174
+ "recurring_rule.field_cadence": "Cadence",
175
+ "recurring_rule.cadence_daily": "Daily",
176
+ "recurring_rule.cadence_weekly": "Weekly",
177
+ "recurring_rule.cadence_monthly": "Monthly",
178
+ "recurring_rule.field_interval": "Repeat every",
179
+ "recurring_rule.field_interval_helper": "Use whole numbers between 1 and 30.",
180
+ "recurring_rule.field_weekday": "Weekday",
181
+ "recurring_rule.field_month_day": "Day of month",
182
+ "recurring_rule.field_month_day_helper": "Limited to 28 for portable scheduling.",
183
+ "recurring_rule.field_start_date": "Start date",
184
+ "recurring_rule.field_has_end_date": "Set an end date",
185
+ "recurring_rule.field_has_end_date_helper": "Stop generating tasks after a specific date.",
186
+ "recurring_rule.field_end_date": "End date",
187
+ "recurring_rule.field_remind_at": "Reminder time",
188
+ "recurring_rule.field_remind_at_placeholder": "09:00",
189
+ "recurring_rule.field_remind_at_helper": "24-hour time in HH:MM format. Shown only when reminders are enabled.",
190
+ "recurring_rule.field_enable_summary": "Attach daily summary delivery",
191
+ "recurring_rule.field_enable_summary_helper": "Choose how the summary should be delivered for this rule.",
192
+ "recurring_rule.field_summary_channel": "Summary channel",
193
+ "recurring_rule.summary_push": "Push notification",
194
+ "recurring_rule.summary_email": "Email",
195
+ "recurring_preview.title": "Upcoming schedule preview",
196
+ "recurring_preview.empty": "No upcoming dates can be generated from this rule.",
197
+ "recurring_preview.invalid": "Complete the cadence and date fields to preview the schedule.",
198
+ "priority.low": "Low",
199
+ "priority.medium": "Medium",
200
+ "priority.high": "High",
201
+ "status.open": "Open",
202
+ "status.done": "Done",
203
+ "validation.min_length": "Must be at least {min} characters",
204
+ "validation.min_value": "Must be at least {min}",
205
+ "validation.max_value": "Must be no more than {max}",
206
+ "validation.fix_errors": "Fix the highlighted fields before saving.",
207
+ "validation.rule_name_min_length": "Rule name must be at least {min} characters",
208
+ "validation.rule_name_reserved": "The default name is reserved. Choose a more specific label.",
209
+ "validation.rule_name_taken": "A recurring rule with this name already exists.",
210
+ "validation.match_field": "Fields do not match",
211
+ "validation.end_date_after_start": "End date must be the same as or later than the start date.",
212
+ "validation.time_format": "Use a 24-hour time like 09:00",
213
+ "validation.month_day_max": "Choose a day between 1 and 28",
214
+ "weekday.mon": "Monday",
215
+ "weekday.tue": "Tuesday",
216
+ "weekday.wed": "Wednesday",
217
+ "weekday.thu": "Thursday",
218
+ "weekday.fri": "Friday",
219
+ "weekday.sat": "Saturday",
220
+ "weekday.sun": "Sunday",
221
+ "common.cancel": "Cancel",
222
+ "common.delete": "Delete"
223
+ },
224
+ ru: {
225
+ "nav.tasks": "Задачи",
226
+ "nav.analytics": "Аналитика",
227
+ "nav.settings": "Настройки",
228
+ "home.title": "Сегодня все под контролем",
229
+ "home.search_label": "Поиск задач",
230
+ "home.search_placeholder": "Искать по названию или заметкам",
231
+ "home.filter.all": "Все",
232
+ "home.filter.open": "Открытые",
233
+ "home.filter.done": "Выполненные",
234
+ "home.mark_complete": "Отметить задачу «{title}» выполненной",
235
+ "home.empty_title": "Список пуст",
236
+ "home.empty_body": "Добавьте задачу или смените фильтр, чтобы увидеть элементы.",
237
+ "home.new_task": "Новая задача",
238
+ "analytics.title": "Аналитика задач",
239
+ "analytics.subtitle": "Следите за выполнением, просроченными задачами и динамикой.",
240
+ "analytics.period_week": "Неделя",
241
+ "analytics.period_month": "Месяц",
242
+ "analytics.period_quarter": "Квартал",
243
+ "analytics.completed_today": "Выполнено сегодня",
244
+ "analytics.open_tasks": "Открытые задачи",
245
+ "analytics.overdue_tasks": "Просрочено",
246
+ "analytics.completion_rate": "Процент выполнения",
247
+ "analytics.overdue_section": "Просроченные задачи",
248
+ "analytics.overdue_subtitle": "Задачи, которым нужно уделить внимание в первую очередь.",
249
+ "analytics.empty_trend": "Данные тренда пока отсутствуют.",
250
+ "analytics.empty_overdue": "Просроченных задач нет",
251
+ "analytics.empty_overdue_body": "Все важные задачи идут по плану.",
252
+ "task_detail.status": "Статус",
253
+ "task_detail.priority": "Приоритет",
254
+ "task_detail.notes": "Заметки",
255
+ "task_detail.due_date": "Срок",
256
+ "task_detail.no_due_date": "Без срока",
257
+ "task_detail.created": "Создано",
258
+ "task_detail.updated": "Обновлено",
259
+ "task_detail.edit": "Редактировать задачу",
260
+ "task_detail.toggle_status": "Сменить статус",
261
+ "task_detail.more_info": "Подробнее",
262
+ "task_detail.delete": "Удалить задачу",
263
+ "task_detail.delete_title": "Удалить эту задачу?",
264
+ "task_detail.delete_message": "Это действие нельзя отменить.",
265
+ "task_detail.updated_feedback": "Задача обновлена",
266
+ "task_detail.update_error": "Не удалось обновить задачу",
267
+ "task_detail.deleted_feedback": "Задача удалена",
268
+ "settings.title": "Параметры",
269
+ "settings.subtitle": "Измените язык и тему для всех целевых платформ.",
270
+ "settings.language": "Язык",
271
+ "settings.language_en": "Английский",
272
+ "settings.language_ru": "Русский",
273
+ "settings.theme": "Тема",
274
+ "settings.theme_light": "Светлая",
275
+ "settings.theme_dark": "Тёмная",
276
+ "settings.reminders": "Напоминания о сроках",
277
+ "settings.reminders_helper": "Уведомлять перед наступлением срока задачи.",
278
+ "settings.daily_summary": "Ежедневная сводка",
279
+ "settings.daily_summary_helper": "Присылать утреннюю сводку по открытым задачам.",
280
+ "settings.automation_title": "Автоматизация",
281
+ "settings.automation_subtitle": "Создавайте повторяющиеся правила задач, чтобы проверить условные формы и валидацию.",
282
+ "settings.automation_create_rule": "Создать правило",
283
+ "settings.save": "Сохранить",
284
+ "settings.saving": "Сохранение...",
285
+ "settings.saved": "Параметры обновлены",
286
+ "settings.error_title": "Не удалось обновить параметры",
287
+ "create_task.title": "Новая задача",
288
+ "create_task.save": "Сохранить",
289
+ "create_task.saving": "Сохранение...",
290
+ "create_task.field_title": "Название",
291
+ "create_task.field_title_placeholder": "Что нужно сделать?",
292
+ "create_task.field_notes": "Заметки",
293
+ "create_task.field_notes_placeholder": "Контекст, ссылки или следующие шаги",
294
+ "create_task.field_priority": "Приоритет",
295
+ "create_task.field_due_date": "Срок",
296
+ "create_task.field_due_date_placeholder": "Без срока",
297
+ "create_task.success": "Задача создана",
298
+ "create_task.error_title": "Не удалось создать задачу",
299
+ "edit_task.title": "Редактировать задачу",
300
+ "edit_task.save": "Сохранить",
301
+ "edit_task.saving": "Сохранение...",
302
+ "edit_task.field_title": "Название",
303
+ "edit_task.field_notes": "Заметки",
304
+ "edit_task.field_priority": "Приоритет",
305
+ "edit_task.field_due_date": "Срок",
306
+ "edit_task.success": "Задача сохранена",
307
+ "edit_task.error_title": "Не удалось сохранить задачу",
308
+ "recurring_rule.title": "Повторяющееся правило",
309
+ "recurring_rule.subtitle": "Настройте расписание с условными полями и валидацией.",
310
+ "recurring_rule.save": "Сохранить правило",
311
+ "recurring_rule.saving": "Сохранение...",
312
+ "recurring_rule.success": "Повторяющееся правило создано",
313
+ "recurring_rule.error_title": "Не удалось создать правило",
314
+ "recurring_rule.field_name": "Название правила",
315
+ "recurring_rule.field_name_placeholder": "Ежедневный ритуал планирования",
316
+ "recurring_rule.field_confirm_name": "Подтвердите название",
317
+ "recurring_rule.field_confirm_name_placeholder": "Повторите название правила",
318
+ "recurring_rule.field_cadence": "Периодичность",
319
+ "recurring_rule.cadence_daily": "Ежедневно",
320
+ "recurring_rule.cadence_weekly": "Еженедельно",
321
+ "recurring_rule.cadence_monthly": "Ежемесячно",
322
+ "recurring_rule.field_interval": "Повторять каждые",
323
+ "recurring_rule.field_interval_helper": "Используйте целые числа от 1 до 30.",
324
+ "recurring_rule.field_weekday": "День недели",
325
+ "recurring_rule.field_month_day": "День месяца",
326
+ "recurring_rule.field_month_day_helper": "Ограничено 28 днями для переносимого расписания.",
327
+ "recurring_rule.field_start_date": "Дата начала",
328
+ "recurring_rule.field_has_end_date": "Указать дату окончания",
329
+ "recurring_rule.field_has_end_date_helper": "Прекратить создание задач после определённой даты.",
330
+ "recurring_rule.field_end_date": "Дата окончания",
331
+ "recurring_rule.field_remind_at": "Время напоминания",
332
+ "recurring_rule.field_remind_at_placeholder": "09:00",
333
+ "recurring_rule.field_remind_at_helper": "24-часовой формат HH:MM. Поле показывается, только если напоминания включены.",
334
+ "recurring_rule.field_enable_summary": "Добавить ежедневную сводку",
335
+ "recurring_rule.field_enable_summary_helper": "Выберите способ доставки сводки для этого правила.",
336
+ "recurring_rule.field_summary_channel": "Канал сводки",
337
+ "recurring_rule.summary_push": "Push-уведомление",
338
+ "recurring_rule.summary_email": "Электронная почта",
339
+ "recurring_preview.title": "Предпросмотр расписания",
340
+ "recurring_preview.empty": "Для этого правила не удаётся сформировать будущие даты.",
341
+ "recurring_preview.invalid": "Заполните периодичность и даты, чтобы увидеть предпросмотр расписания.",
342
+ "priority.low": "Низкий",
343
+ "priority.medium": "Средний",
344
+ "priority.high": "Высокий",
345
+ "status.open": "Открыта",
346
+ "status.done": "Выполнена",
347
+ "validation.min_length": "Минимум {min} символа(ов)",
348
+ "validation.min_value": "Значение должно быть не меньше {min}",
349
+ "validation.max_value": "Значение должно быть не больше {max}",
350
+ "validation.fix_errors": "Исправьте выделенные поля перед сохранением.",
351
+ "validation.rule_name_min_length": "Название правила должно содержать минимум {min} символа(ов)",
352
+ "validation.rule_name_reserved": "Название по умолчанию зарезервировано. Укажите более точную метку.",
353
+ "validation.rule_name_taken": "Правило с таким названием уже существует.",
354
+ "validation.match_field": "Поля не совпадают",
355
+ "validation.end_date_after_start": "Дата окончания должна быть не раньше даты начала.",
356
+ "validation.time_format": "Используйте 24-часовой формат, например 09:00",
357
+ "validation.month_day_max": "Выберите день от 1 до 28",
358
+ "weekday.mon": "Понедельник",
359
+ "weekday.tue": "Вторник",
360
+ "weekday.wed": "Среда",
361
+ "weekday.thu": "Четверг",
362
+ "weekday.fri": "Пятница",
363
+ "weekday.sat": "Суббота",
364
+ "weekday.sun": "Воскресенье",
365
+ "common.cancel": "Отмена",
366
+ "common.delete": "Удалить"
367
+ }
368
+ };
369
+
370
+ const priorityAccent: Record<Priority, string> = {
371
+ low: "var(--priority-low)",
372
+ medium: "var(--priority-medium)",
373
+ high: "var(--priority-high)"
374
+ };
375
+
376
+ const seedTasks: Task[] = [
377
+ {
378
+ id: "task-1",
379
+ title: "Prepare bilingual launch notes",
380
+ notes: "Document the web, iOS, and Android behavior differences before review.",
381
+ status: "open",
382
+ priority: "high",
383
+ dueDate: shiftDate(2),
384
+ createdAt: shiftDateTime(-6),
385
+ updatedAt: shiftDateTime(-1)
386
+ },
387
+ {
388
+ id: "task-2",
389
+ title: "Review recurring-rule validation",
390
+ notes: "Confirm async uniqueness checks and cross-field constraints.",
391
+ status: "done",
392
+ priority: "medium",
393
+ dueDate: shiftDate(-1),
394
+ createdAt: shiftDateTime(-5),
395
+ updatedAt: shiftDateTime(0)
396
+ },
397
+ {
398
+ id: "task-3",
399
+ title: "Polish analytics empty states",
400
+ notes: "Ensure chart and overdue list degrade gracefully on zero-data snapshots.",
401
+ status: "open",
402
+ priority: "medium",
403
+ dueDate: shiftDate(5),
404
+ createdAt: shiftDateTime(-4),
405
+ updatedAt: shiftDateTime(-2)
406
+ },
407
+ {
408
+ id: "task-4",
409
+ title: "Regenerate drift snapshots",
410
+ notes: "Refresh ios, android, and web state after spec edits.",
411
+ status: "open",
412
+ priority: "low",
413
+ dueDate: shiftDate(-3),
414
+ createdAt: shiftDateTime(-3),
415
+ updatedAt: shiftDateTime(-3)
416
+ },
417
+ {
418
+ id: "task-5",
419
+ title: "Prototype schedule preview contract",
420
+ notes: "Use derived occurrences to prove custom-contract generation.",
421
+ status: "done",
422
+ priority: "high",
423
+ dueDate: shiftDate(1),
424
+ createdAt: shiftDateTime(-8),
425
+ updatedAt: shiftDateTime(-1)
426
+ }
427
+ ];
428
+
429
+ const initialPreferences: Preferences = {
430
+ locale: "en",
431
+ theme: "light",
432
+ remindersEnabled: true,
433
+ dailySummaryEnabled: false
434
+ };
435
+
436
+ type AppState = {
437
+ locale: Locale;
438
+ preferences: Preferences;
439
+ tasks: Task[];
440
+ rules: RecurringRule[];
441
+ selectedTaskId: string | null;
442
+ toasts: Toast[];
443
+ setSelectedTask: (taskId: string | null) => void;
444
+ savePreferences: (preferences: Preferences) => void;
445
+ createTask: (task: Omit<Task, "id" | "createdAt" | "updatedAt">) => Task;
446
+ updateTask: (taskId: string, patch: Partial<Omit<Task, "id">>) => void;
447
+ toggleTask: (taskId: string) => void;
448
+ deleteTask: (taskId: string) => void;
449
+ addRule: (rule: Omit<RecurringRule, "id">) => void;
450
+ pushToast: (message: string, severity: Toast["severity"]) => void;
451
+ removeToast: (toastId: string) => void;
452
+ };
453
+
454
+ const useAppStore = create<AppState>((set, get) => ({
455
+ locale: initialPreferences.locale,
456
+ preferences: initialPreferences,
457
+ tasks: seedTasks,
458
+ rules: [],
459
+ selectedTaskId: seedTasks[0]?.id ?? null,
460
+ toasts: [],
461
+ setSelectedTask: (taskId) => set({ selectedTaskId: taskId }),
462
+ savePreferences: (preferences) =>
463
+ set({
464
+ preferences,
465
+ locale: preferences.locale
466
+ }),
467
+ createTask: (task) => {
468
+ const nextTask: Task = {
469
+ ...task,
470
+ id: createId(),
471
+ createdAt: new Date().toISOString(),
472
+ updatedAt: new Date().toISOString()
473
+ };
474
+ set((state) => ({
475
+ tasks: [nextTask, ...state.tasks],
476
+ selectedTaskId: nextTask.id
477
+ }));
478
+ return nextTask;
479
+ },
480
+ updateTask: (taskId, patch) =>
481
+ set((state) => ({
482
+ tasks: state.tasks.map((task) =>
483
+ task.id === taskId
484
+ ? { ...task, ...patch, updatedAt: new Date().toISOString() }
485
+ : task
486
+ )
487
+ })),
488
+ toggleTask: (taskId) =>
489
+ set((state) => ({
490
+ tasks: state.tasks.map((task) =>
491
+ task.id === taskId
492
+ ? {
493
+ ...task,
494
+ status: task.status === "done" ? "open" : "done",
495
+ updatedAt: new Date().toISOString()
496
+ }
497
+ : task
498
+ )
499
+ })),
500
+ deleteTask: (taskId) =>
501
+ set((state) => {
502
+ const nextTasks = state.tasks.filter((task) => task.id !== taskId);
503
+ return {
504
+ tasks: nextTasks,
505
+ selectedTaskId:
506
+ state.selectedTaskId === taskId ? nextTasks[0]?.id ?? null : state.selectedTaskId
507
+ };
508
+ }),
509
+ addRule: (rule) =>
510
+ set((state) => ({
511
+ rules: [{ ...rule, id: createId() }, ...state.rules]
512
+ })),
513
+ pushToast: (message, severity) => {
514
+ const toast = { id: createId(), message, severity };
515
+ set((state) => ({ toasts: [...state.toasts, toast] }));
516
+ window.setTimeout(() => get().removeToast(toast.id), 2600);
517
+ },
518
+ removeToast: (toastId) =>
519
+ set((state) => ({
520
+ toasts: state.toasts.filter((toast) => toast.id !== toastId)
521
+ }))
522
+ }));
523
+
524
+ type ModalState =
525
+ | { type: "create-task" }
526
+ | { type: "edit-task"; taskId: string }
527
+ | { type: "recurring-rule" }
528
+ | { type: "task-meta"; taskId: string }
529
+ | null;
530
+
531
+ export default function App() {
532
+ const theme = useAppStore((state) => state.preferences.theme);
533
+ const locale = useAppStore((state) => state.locale);
534
+ const toasts = useAppStore((state) => state.toasts);
535
+ const removeToast = useAppStore((state) => state.removeToast);
536
+ const [modal, setModal] = useState<ModalState>(null);
537
+
538
+ useEffect(() => {
539
+ document.documentElement.dataset.theme = theme;
540
+ document.documentElement.lang = locale;
541
+ }, [locale, theme]);
542
+
543
+ return (
544
+ <div className="app-frame">
545
+ <AppShell onOpenModal={setModal} />
546
+ <ToastViewport toasts={toasts} onDismiss={removeToast} />
547
+ {modal?.type === "create-task" && <TaskFormModal onClose={() => setModal(null)} />}
548
+ {modal?.type === "edit-task" && (
549
+ <TaskFormModal taskId={modal.taskId} onClose={() => setModal(null)} />
550
+ )}
551
+ {modal?.type === "recurring-rule" && (
552
+ <RecurringRuleModal onClose={() => setModal(null)} />
553
+ )}
554
+ {modal?.type === "task-meta" && (
555
+ <TaskMetaModal taskId={modal.taskId} onClose={() => setModal(null)} />
556
+ )}
557
+ </div>
558
+ );
559
+ }
560
+
561
+ function AppShell({ onOpenModal }: { onOpenModal: (modal: ModalState) => void }) {
562
+ const location = useLocation();
563
+ const t = useTranslator();
564
+
565
+ return (
566
+ <div className="shell-layout">
567
+ <aside className="nav-shell cut-surface">
568
+ <div className="brand-lockup">
569
+ <div className="brand-mark">TO</div>
570
+ <div>
571
+ <p className="eyebrow">OpenUISpec generated</p>
572
+ <h1>Todo Orbit</h1>
573
+ </div>
574
+ </div>
575
+
576
+ <nav className="primary-nav" aria-label="Primary">
577
+ <NavItem to="/" active={location.pathname === "/" || location.pathname.startsWith("/tasks/")}>
578
+ {t("nav.tasks")}
579
+ </NavItem>
580
+ <NavItem to="/analytics" active={location.pathname.startsWith("/analytics")}>
581
+ {t("nav.analytics")}
582
+ </NavItem>
583
+ <NavItem to="/settings" active={location.pathname.startsWith("/settings")}>
584
+ {t("nav.settings")}
585
+ </NavItem>
586
+ </nav>
587
+
588
+ <div className="nav-note cut-panel">
589
+ <p className="eyebrow">Stress profile</p>
590
+ <strong>2 custom contracts</strong>
591
+ <span>Reactive validation, analytics, bilingual copy, and cut-corner components.</span>
592
+ </div>
593
+ </aside>
594
+
595
+ <main className="screen-shell">
596
+ <Routes>
597
+ <Route path="/" element={<HomeScreen onOpenModal={onOpenModal} />} />
598
+ <Route path="/tasks/:taskId" element={<TaskDetailRoute onOpenModal={onOpenModal} />} />
599
+ <Route path="/analytics" element={<AnalyticsScreen />} />
600
+ <Route path="/settings" element={<SettingsScreen onOpenModal={onOpenModal} />} />
601
+ <Route path="*" element={<Navigate to="/" replace />} />
602
+ </Routes>
603
+ </main>
604
+ </div>
605
+ );
606
+ }
607
+
608
+ function HomeScreen({ onOpenModal }: { onOpenModal: (modal: ModalState) => void }) {
609
+ const [activeFilter, setActiveFilter] = useState<Filter>("all");
610
+ const [searchQuery, setSearchQuery] = useState("");
611
+ const deferredSearch = useDeferredValue(searchQuery);
612
+ const tasks = useAppStore((state) => state.tasks);
613
+ const selectedTaskId = useAppStore((state) => state.selectedTaskId);
614
+ const setSelectedTask = useAppStore((state) => state.setSelectedTask);
615
+ const toggleTask = useAppStore((state) => state.toggleTask);
616
+ const pushToast = useAppStore((state) => state.pushToast);
617
+ const navigate = useNavigate();
618
+ const isDesktop = useIsDesktop();
619
+ const t = useTranslator();
620
+ const filteredTasks = useMemo(
621
+ () => filterTasks(tasks, activeFilter, deferredSearch),
622
+ [activeFilter, deferredSearch, tasks]
623
+ );
624
+ const counts = getTaskCounts(tasks);
625
+ const selectedTask = tasks.find((task) => task.id === selectedTaskId) ?? filteredTasks[0] ?? null;
626
+
627
+ useEffect(() => {
628
+ if (!selectedTaskId && filteredTasks[0]) {
629
+ setSelectedTask(filteredTasks[0].id);
630
+ }
631
+ }, [filteredTasks, selectedTaskId, setSelectedTask]);
632
+
633
+ return (
634
+ <section className="screen">
635
+ <header className="screen-header">
636
+ <div>
637
+ <p className="eyebrow">screens/home</p>
638
+ <h2>{t("home.title")}</h2>
639
+ <p className="screen-subtitle">{formatSummary(useAppStore.getState().locale, counts.open, counts.all)}</p>
640
+ </div>
641
+ <button className="cut-button primary" onClick={() => onOpenModal({ type: "create-task" })}>
642
+ <span className="button-icon">+</span>
643
+ {t("home.new_task")}
644
+ </button>
645
+ </header>
646
+
647
+ <div className={`home-layout ${isDesktop ? "desktop" : ""}`}>
648
+ <div className="home-primary">
649
+ <label className="field-block">
650
+ <span className="field-label">{t("home.search_label")}</span>
651
+ <div className="cut-input input-shell">
652
+ <span className="leading-icon">⌕</span>
653
+ <input
654
+ value={searchQuery}
655
+ onChange={(event) =>
656
+ startTransition(() => setSearchQuery(event.target.value))
657
+ }
658
+ placeholder={t("home.search_placeholder")}
659
+ />
660
+ {searchQuery ? (
661
+ <button
662
+ className="clear-button"
663
+ onClick={() => setSearchQuery("")}
664
+ type="button"
665
+ aria-label="Clear search"
666
+ >
667
+ ×
668
+ </button>
669
+ ) : null}
670
+ </div>
671
+ </label>
672
+
673
+ <div className="chip-row">
674
+ {(["all", "open", "done"] as Filter[]).map((filterId) => (
675
+ <button
676
+ key={filterId}
677
+ className={`cut-button ghost ${activeFilter === filterId ? "selected" : ""}`}
678
+ onClick={() => startTransition(() => setActiveFilter(filterId))}
679
+ >
680
+ {t(`home.filter.${filterId}`)} ({counts[filterId]})
681
+ </button>
682
+ ))}
683
+ </div>
684
+
685
+ <div className="task-list cut-surface">
686
+ {filteredTasks.length === 0 ? (
687
+ <div className="empty-state">
688
+ <div className="empty-icon">○</div>
689
+ <h3>{t("home.empty_title")}</h3>
690
+ <p>{t("home.empty_body")}</p>
691
+ </div>
692
+ ) : (
693
+ filteredTasks.map((task) => (
694
+ <button
695
+ key={task.id}
696
+ className={`task-row ${selectedTask?.id === task.id ? "selected" : ""}`}
697
+ onClick={() => {
698
+ setSelectedTask(task.id);
699
+ if (!isDesktop) {
700
+ navigate(`/tasks/${task.id}`);
701
+ }
702
+ }}
703
+ >
704
+ <label
705
+ className="checkbox-shell"
706
+ onClick={(event) => {
707
+ event.stopPropagation();
708
+ }}
709
+ >
710
+ <input
711
+ checked={task.status === "done"}
712
+ onChange={() => {
713
+ toggleTask(task.id);
714
+ pushToast(t("task_detail.updated_feedback"), "success");
715
+ }}
716
+ type="checkbox"
717
+ aria-label={t("home.mark_complete", { title: task.title })}
718
+ />
719
+ </label>
720
+
721
+ <div className="task-copy">
722
+ <strong>{task.title}</strong>
723
+ <span>{formatRelativeDate(task.dueDate, useAppStore.getState().locale, t("task_detail.no_due_date"))}</span>
724
+ </div>
725
+
726
+ <span className="priority-dot" style={{ background: priorityAccent[task.priority] }} />
727
+ </button>
728
+ ))
729
+ )}
730
+ </div>
731
+ </div>
732
+
733
+ {isDesktop && selectedTask ? (
734
+ <div className="home-secondary">
735
+ <TaskDetailCard task={selectedTask} onOpenModal={onOpenModal} />
736
+ </div>
737
+ ) : null}
738
+ </div>
739
+ </section>
740
+ );
741
+ }
742
+
743
+ function TaskDetailRoute({ onOpenModal }: { onOpenModal: (modal: ModalState) => void }) {
744
+ const { taskId } = useParams();
745
+ const task = useAppStore((state) => state.tasks.find((entry) => entry.id === taskId));
746
+
747
+ if (!task) {
748
+ return <Navigate to="/" replace />;
749
+ }
750
+
751
+ return (
752
+ <section className="screen">
753
+ <TaskDetailCard task={task} onOpenModal={onOpenModal} />
754
+ </section>
755
+ );
756
+ }
757
+
758
+ function TaskDetailCard({
759
+ task,
760
+ onOpenModal
761
+ }: {
762
+ task: Task;
763
+ onOpenModal: (modal: ModalState) => void;
764
+ }) {
765
+ const t = useTranslator();
766
+ const locale = useAppStore((state) => state.locale);
767
+ const toggleTask = useAppStore((state) => state.toggleTask);
768
+ const deleteTask = useAppStore((state) => state.deleteTask);
769
+ const pushToast = useAppStore((state) => state.pushToast);
770
+ const navigate = useNavigate();
771
+
772
+ return (
773
+ <article className="task-detail cut-surface">
774
+ <div className="hero-card">
775
+ <div>
776
+ <p className="eyebrow">screens/task_detail</p>
777
+ <h2>{task.title}</h2>
778
+ <p className="screen-subtitle">
779
+ {task.dueDate ? formatAbsoluteDate(task.dueDate, locale) : t("task_detail.no_due_date")}
780
+ </p>
781
+ </div>
782
+ <div className={`status-badge ${task.status}`}>{t(`status.${task.status}`)}</div>
783
+ </div>
784
+
785
+ <div className="stat-grid">
786
+ <StatCard label={t("task_detail.status")} value={t(`status.${task.status}`)} />
787
+ <StatCard label={t("task_detail.priority")} value={t(`priority.${task.priority}`)} />
788
+ <StatCard label={t("task_detail.created")} value={formatAbsoluteDate(task.createdAt, locale)} />
789
+ <StatCard label={t("task_detail.updated")} value={formatAbsoluteDate(task.updatedAt, locale)} />
790
+ </div>
791
+
792
+ {task.notes ? (
793
+ <section className="detail-section">
794
+ <h3>{t("task_detail.notes")}</h3>
795
+ <p>{task.notes}</p>
796
+ </section>
797
+ ) : null}
798
+
799
+ <section className="detail-list">
800
+ <DetailRow label={t("task_detail.due_date")} value={task.dueDate ? formatAbsoluteDate(task.dueDate, locale) : t("task_detail.no_due_date")} />
801
+ <DetailRow label={t("task_detail.created")} value={formatAbsoluteDate(task.createdAt, locale)} />
802
+ <DetailRow label={t("task_detail.updated")} value={formatAbsoluteDate(task.updatedAt, locale)} />
803
+ </section>
804
+
805
+ <div className="action-row">
806
+ <button className="cut-button primary" onClick={() => onOpenModal({ type: "edit-task", taskId: task.id })}>
807
+ {t("task_detail.edit")}
808
+ </button>
809
+ <button
810
+ className="cut-button ghost"
811
+ onClick={() => {
812
+ toggleTask(task.id);
813
+ pushToast(t("task_detail.updated_feedback"), "success");
814
+ }}
815
+ >
816
+ {t("task_detail.toggle_status")}
817
+ </button>
818
+ <button className="cut-button ghost" onClick={() => onOpenModal({ type: "task-meta", taskId: task.id })}>
819
+ {t("task_detail.more_info")}
820
+ </button>
821
+ <button
822
+ className="cut-button danger"
823
+ onClick={() => {
824
+ if (window.confirm(`${t("task_detail.delete_title")} ${t("task_detail.delete_message")}`)) {
825
+ deleteTask(task.id);
826
+ pushToast(t("task_detail.deleted_feedback"), "success");
827
+ navigate("/");
828
+ }
829
+ }}
830
+ >
831
+ {t("task_detail.delete")}
832
+ </button>
833
+ </div>
834
+ </article>
835
+ );
836
+ }
837
+
838
+ function AnalyticsScreen() {
839
+ const tasks = useAppStore((state) => state.tasks);
840
+ const [period, setPeriod] = useState<Period>("week");
841
+ const t = useTranslator();
842
+ const locale = useAppStore((state) => state.locale);
843
+ const overview = getAnalyticsOverview(tasks);
844
+ const trend = getTrendSeries(tasks, period, locale);
845
+ const overdue = getOverdueTasks(tasks);
846
+
847
+ return (
848
+ <section className="screen">
849
+ <header className="screen-header">
850
+ <div>
851
+ <p className="eyebrow">screens/analytics</p>
852
+ <h2>{t("analytics.title")}</h2>
853
+ <p className="screen-subtitle">{t("analytics.subtitle")}</p>
854
+ </div>
855
+ </header>
856
+
857
+ <div className="chip-row">
858
+ {(["week", "month", "quarter"] as Period[]).map((item) => (
859
+ <button
860
+ key={item}
861
+ className={`cut-button ghost ${period === item ? "selected" : ""}`}
862
+ onClick={() => setPeriod(item)}
863
+ >
864
+ {t(`analytics.period_${item}`)}
865
+ </button>
866
+ ))}
867
+ </div>
868
+
869
+ <div className="analytics-grid">
870
+ <StatCard label={t("analytics.completed_today")} value={String(overview.completedToday)} />
871
+ <StatCard label={t("analytics.open_tasks")} value={String(overview.openTasks)} />
872
+ <StatCard label={t("analytics.overdue_tasks")} value={String(overview.overdueTasks)} />
873
+ <StatCard label={t("analytics.completion_rate")} value={`${overview.completionRate}%`} />
874
+ </div>
875
+
876
+ <TaskTrendChart
877
+ emptyMessage={t("analytics.empty_trend")}
878
+ period={period}
879
+ series={trend}
880
+ />
881
+
882
+ <section className="cut-surface">
883
+ <div className="section-head">
884
+ <div>
885
+ <p className="eyebrow">collection.table</p>
886
+ <h3>{t("analytics.overdue_section")}</h3>
887
+ <p className="screen-subtitle">{t("analytics.overdue_subtitle")}</p>
888
+ </div>
889
+ </div>
890
+
891
+ {overdue.length === 0 ? (
892
+ <div className="empty-state">
893
+ <div className="empty-icon">✓</div>
894
+ <h3>{t("analytics.empty_overdue")}</h3>
895
+ <p>{t("analytics.empty_overdue_body")}</p>
896
+ </div>
897
+ ) : (
898
+ <div className="table-list">
899
+ {overdue.map((task) => (
900
+ <div className="table-row" key={task.id}>
901
+ <div>
902
+ <strong>{task.title}</strong>
903
+ <span>{t(`priority.${task.priority}`)}</span>
904
+ </div>
905
+ <span>{task.dueDate ? formatAbsoluteDate(task.dueDate, locale) : "—"}</span>
906
+ </div>
907
+ ))}
908
+ </div>
909
+ )}
910
+ </section>
911
+ </section>
912
+ );
913
+ }
914
+
915
+ function SettingsScreen({ onOpenModal }: { onOpenModal: (modal: ModalState) => void }) {
916
+ const preferences = useAppStore((state) => state.preferences);
917
+ const savePreferences = useAppStore((state) => state.savePreferences);
918
+ const pushToast = useAppStore((state) => state.pushToast);
919
+ const t = useTranslator();
920
+ const [form, setForm] = useState(preferences);
921
+
922
+ useEffect(() => {
923
+ setForm(preferences);
924
+ }, [preferences]);
925
+
926
+ return (
927
+ <section className="screen">
928
+ <header className="screen-header">
929
+ <div>
930
+ <p className="eyebrow">screens/settings</p>
931
+ <h2>{t("settings.title")}</h2>
932
+ <p className="screen-subtitle">{t("settings.subtitle")}</p>
933
+ </div>
934
+ </header>
935
+
936
+ <div className="settings-grid">
937
+ <section className="cut-surface form-stack">
938
+ <SelectField
939
+ label={t("settings.language")}
940
+ value={form.locale}
941
+ onChange={(value) => setForm((current) => ({ ...current, locale: value as Locale }))}
942
+ options={[
943
+ { label: t("settings.language_en"), value: "en" },
944
+ { label: t("settings.language_ru"), value: "ru" }
945
+ ]}
946
+ />
947
+
948
+ <SelectField
949
+ label={t("settings.theme")}
950
+ value={form.theme}
951
+ onChange={(value) => setForm((current) => ({ ...current, theme: value as Theme }))}
952
+ options={[
953
+ { label: t("settings.theme_light"), value: "light" },
954
+ { label: t("settings.theme_dark"), value: "dark" }
955
+ ]}
956
+ />
957
+
958
+ <ToggleField
959
+ label={t("settings.reminders")}
960
+ helper={t("settings.reminders_helper")}
961
+ checked={form.remindersEnabled}
962
+ onChange={(checked) => setForm((current) => ({ ...current, remindersEnabled: checked }))}
963
+ />
964
+
965
+ <ToggleField
966
+ label={t("settings.daily_summary")}
967
+ helper={t("settings.daily_summary_helper")}
968
+ checked={form.dailySummaryEnabled}
969
+ onChange={(checked) => setForm((current) => ({ ...current, dailySummaryEnabled: checked }))}
970
+ />
971
+
972
+ <button
973
+ className="cut-button primary full-width"
974
+ onClick={() => {
975
+ savePreferences(form);
976
+ pushToast(t("settings.saved"), "success");
977
+ }}
978
+ >
979
+ {t("settings.save")}
980
+ </button>
981
+ </section>
982
+
983
+ <section className="cut-surface form-stack">
984
+ <div className="section-head compact">
985
+ <div>
986
+ <p className="eyebrow">flows/create_recurring_rule</p>
987
+ <h3>{t("settings.automation_title")}</h3>
988
+ <p className="screen-subtitle">{t("settings.automation_subtitle")}</p>
989
+ </div>
990
+ </div>
991
+ <button
992
+ className="cut-button primary full-width"
993
+ onClick={() => onOpenModal({ type: "recurring-rule" })}
994
+ >
995
+ {t("settings.automation_create_rule")}
996
+ </button>
997
+ <RuleList />
998
+ </section>
999
+ </div>
1000
+ </section>
1001
+ );
1002
+ }
1003
+
1004
+ function TaskFormModal({
1005
+ taskId,
1006
+ onClose
1007
+ }: {
1008
+ taskId?: string;
1009
+ onClose: () => void;
1010
+ }) {
1011
+ const task = useAppStore((state) => state.tasks.find((entry) => entry.id === taskId));
1012
+ const createTask = useAppStore((state) => state.createTask);
1013
+ const updateTask = useAppStore((state) => state.updateTask);
1014
+ const pushToast = useAppStore((state) => state.pushToast);
1015
+ const [title, setTitle] = useState(task?.title ?? "");
1016
+ const [notes, setNotes] = useState(task?.notes ?? "");
1017
+ const [priority, setPriority] = useState<Priority>(task?.priority ?? "medium");
1018
+ const [dueDate, setDueDate] = useState(task?.dueDate ?? "");
1019
+ const [error, setError] = useState("");
1020
+ const t = useTranslator();
1021
+
1022
+ const submit = () => {
1023
+ if (title.trim().length < 2) {
1024
+ setError(t("validation.min_length", { min: 2 }));
1025
+ return;
1026
+ }
1027
+
1028
+ if (taskId) {
1029
+ updateTask(taskId, { title: title.trim(), notes: notes.trim(), priority, dueDate });
1030
+ pushToast(t("edit_task.success"), "success");
1031
+ } else {
1032
+ createTask({
1033
+ title: title.trim(),
1034
+ notes: notes.trim(),
1035
+ priority,
1036
+ dueDate,
1037
+ status: "open"
1038
+ });
1039
+ pushToast(t("create_task.success"), "success");
1040
+ }
1041
+
1042
+ onClose();
1043
+ };
1044
+
1045
+ return (
1046
+ <ModalShell
1047
+ title={taskId ? t("edit_task.title") : t("create_task.title")}
1048
+ subtitle={taskId ? "" : "flow.task_form"}
1049
+ onClose={onClose}
1050
+ action={
1051
+ <button className="cut-button primary" onClick={submit}>
1052
+ {taskId ? t("edit_task.save") : t("create_task.save")}
1053
+ </button>
1054
+ }
1055
+ >
1056
+ {error ? <InlineError message={error} /> : null}
1057
+ <TextField
1058
+ label={taskId ? t("edit_task.field_title") : t("create_task.field_title")}
1059
+ value={title}
1060
+ onChange={setTitle}
1061
+ placeholder={t("create_task.field_title_placeholder")}
1062
+ error={error}
1063
+ />
1064
+ <TextAreaField
1065
+ label={taskId ? t("edit_task.field_notes") : t("create_task.field_notes")}
1066
+ value={notes}
1067
+ onChange={setNotes}
1068
+ placeholder={t("create_task.field_notes_placeholder")}
1069
+ />
1070
+ <SelectField
1071
+ label={taskId ? t("edit_task.field_priority") : t("create_task.field_priority")}
1072
+ value={priority}
1073
+ onChange={(value) => setPriority(value as Priority)}
1074
+ options={[
1075
+ { label: t("priority.low"), value: "low" },
1076
+ { label: t("priority.medium"), value: "medium" },
1077
+ { label: t("priority.high"), value: "high" }
1078
+ ]}
1079
+ />
1080
+ <DateField
1081
+ label={taskId ? t("edit_task.field_due_date") : t("create_task.field_due_date")}
1082
+ value={dueDate}
1083
+ onChange={setDueDate}
1084
+ />
1085
+ </ModalShell>
1086
+ );
1087
+ }
1088
+
1089
+ function RecurringRuleModal({ onClose }: { onClose: () => void }) {
1090
+ const t = useTranslator();
1091
+ const preferences = useAppStore((state) => state.preferences);
1092
+ const rules = useAppStore((state) => state.rules);
1093
+ const addRule = useAppStore((state) => state.addRule);
1094
+ const pushToast = useAppStore((state) => state.pushToast);
1095
+ const [draft, setDraft] = useState<RuleDraft>({
1096
+ name: "",
1097
+ confirmName: "",
1098
+ cadence: "",
1099
+ interval: "1",
1100
+ weekday: "",
1101
+ monthDay: "",
1102
+ startDate: isoToday(),
1103
+ hasEndDate: false,
1104
+ endDate: "",
1105
+ remindAt: "",
1106
+ enableSummary: false,
1107
+ summaryChannel: ""
1108
+ });
1109
+ const [errors, setErrors] = useState<Record<string, string>>({});
1110
+ const [confirmTouched, setConfirmTouched] = useState(false);
1111
+ const [isCheckingName, setIsCheckingName] = useState(false);
1112
+
1113
+ useEffect(() => {
1114
+ const trimmed = draft.name.trim();
1115
+ if (!trimmed || trimmed.length < 4 || trimmed === "Default") {
1116
+ return;
1117
+ }
1118
+
1119
+ const timer = window.setTimeout(() => {
1120
+ setIsCheckingName(true);
1121
+ window.setTimeout(() => {
1122
+ setErrors((current) => {
1123
+ const next = { ...current };
1124
+ const duplicate = rules.some(
1125
+ (rule) => rule.name.toLowerCase() === trimmed.toLowerCase()
1126
+ );
1127
+ if (duplicate) {
1128
+ next.name = t("validation.rule_name_taken");
1129
+ } else if (current.name === t("validation.rule_name_taken")) {
1130
+ delete next.name;
1131
+ }
1132
+ return next;
1133
+ });
1134
+ setIsCheckingName(false);
1135
+ }, 200);
1136
+ }, 450);
1137
+
1138
+ return () => window.clearTimeout(timer);
1139
+ }, [draft.name, rules, t]);
1140
+
1141
+ const submit = () => {
1142
+ const nextErrors = validateRuleDraft(draft, preferences, rules, t);
1143
+ setErrors(nextErrors);
1144
+ setConfirmTouched(true);
1145
+
1146
+ if (Object.keys(nextErrors).length > 0) {
1147
+ pushToast(t("validation.fix_errors"), "warning");
1148
+ return;
1149
+ }
1150
+
1151
+ addRule({
1152
+ name: draft.name.trim(),
1153
+ cadence: draft.cadence as Cadence,
1154
+ interval: Number(draft.interval),
1155
+ weekday: draft.weekday || undefined,
1156
+ monthDay: draft.monthDay ? Number(draft.monthDay) : undefined,
1157
+ startDate: draft.startDate,
1158
+ endDate: draft.hasEndDate ? draft.endDate : undefined,
1159
+ remindAt: preferences.remindersEnabled ? draft.remindAt : undefined,
1160
+ summaryChannel: draft.enableSummary ? (draft.summaryChannel as SummaryChannel) : undefined
1161
+ });
1162
+ pushToast(t("recurring_rule.success"), "success");
1163
+ onClose();
1164
+ };
1165
+
1166
+ const preview = getSchedulePreview({
1167
+ cadence: draft.cadence || undefined,
1168
+ interval: Number(draft.interval || 0),
1169
+ weekday: draft.weekday || undefined,
1170
+ monthDay: draft.monthDay ? Number(draft.monthDay) : undefined,
1171
+ startDate: draft.startDate,
1172
+ endDate: draft.hasEndDate ? draft.endDate : undefined,
1173
+ previewCount: 4
1174
+ });
1175
+
1176
+ return (
1177
+ <ModalShell
1178
+ title={t("recurring_rule.title")}
1179
+ subtitle={t("recurring_rule.subtitle")}
1180
+ onClose={onClose}
1181
+ wide
1182
+ action={
1183
+ <button className="cut-button primary" onClick={submit}>
1184
+ {t("recurring_rule.save")}
1185
+ </button>
1186
+ }
1187
+ >
1188
+ <div className="modal-two-column">
1189
+ <div className="form-stack">
1190
+ <TextField
1191
+ label={t("recurring_rule.field_name")}
1192
+ value={draft.name}
1193
+ onChange={(value) => {
1194
+ setDraft((current) => ({ ...current, name: value }));
1195
+ setErrors((current) => {
1196
+ const next = { ...current };
1197
+ if (value.trim().length < 4) {
1198
+ next.name = t("validation.rule_name_min_length", { min: 4 });
1199
+ } else if (value.trim() === "Default") {
1200
+ next.name = t("validation.rule_name_reserved");
1201
+ } else {
1202
+ delete next.name;
1203
+ }
1204
+ return next;
1205
+ });
1206
+ }}
1207
+ placeholder={t("recurring_rule.field_name_placeholder")}
1208
+ error={errors.name}
1209
+ helper={isCheckingName ? "Checking..." : undefined}
1210
+ />
1211
+
1212
+ <TextField
1213
+ label={t("recurring_rule.field_confirm_name")}
1214
+ value={draft.confirmName}
1215
+ onChange={(value) => setDraft((current) => ({ ...current, confirmName: value }))}
1216
+ onBlur={() => setConfirmTouched(true)}
1217
+ placeholder={t("recurring_rule.field_confirm_name_placeholder")}
1218
+ error={confirmTouched ? errors.confirmName : ""}
1219
+ />
1220
+
1221
+ <SelectField
1222
+ label={t("recurring_rule.field_cadence")}
1223
+ value={draft.cadence}
1224
+ onChange={(value) =>
1225
+ setDraft((current) => ({
1226
+ ...current,
1227
+ cadence: value as RuleDraft["cadence"],
1228
+ weekday: value === "weekly" ? current.weekday : "",
1229
+ monthDay: value === "monthly" ? current.monthDay : ""
1230
+ }))
1231
+ }
1232
+ options={[
1233
+ { value: "", label: "—" },
1234
+ { value: "daily", label: t("recurring_rule.cadence_daily") },
1235
+ { value: "weekly", label: t("recurring_rule.cadence_weekly") },
1236
+ { value: "monthly", label: t("recurring_rule.cadence_monthly") }
1237
+ ]}
1238
+ error={errors.cadence}
1239
+ />
1240
+
1241
+ <NumberField
1242
+ label={t("recurring_rule.field_interval")}
1243
+ value={draft.interval}
1244
+ onChange={(value) => setDraft((current) => ({ ...current, interval: value }))}
1245
+ helper={t("recurring_rule.field_interval_helper")}
1246
+ error={errors.interval}
1247
+ />
1248
+
1249
+ {draft.cadence === "weekly" ? (
1250
+ <SelectField
1251
+ label={t("recurring_rule.field_weekday")}
1252
+ value={draft.weekday}
1253
+ onChange={(value) => setDraft((current) => ({ ...current, weekday: value as Weekday }))}
1254
+ options={[
1255
+ { value: "", label: "—" },
1256
+ { value: "mon", label: t("weekday.mon") },
1257
+ { value: "tue", label: t("weekday.tue") },
1258
+ { value: "wed", label: t("weekday.wed") },
1259
+ { value: "thu", label: t("weekday.thu") },
1260
+ { value: "fri", label: t("weekday.fri") },
1261
+ { value: "sat", label: t("weekday.sat") },
1262
+ { value: "sun", label: t("weekday.sun") }
1263
+ ]}
1264
+ error={errors.weekday}
1265
+ />
1266
+ ) : null}
1267
+
1268
+ {draft.cadence === "monthly" ? (
1269
+ <NumberField
1270
+ label={t("recurring_rule.field_month_day")}
1271
+ value={draft.monthDay}
1272
+ onChange={(value) => setDraft((current) => ({ ...current, monthDay: value }))}
1273
+ helper={t("recurring_rule.field_month_day_helper")}
1274
+ error={errors.monthDay}
1275
+ />
1276
+ ) : null}
1277
+
1278
+ <DateField
1279
+ label={t("recurring_rule.field_start_date")}
1280
+ value={draft.startDate}
1281
+ onChange={(value) => setDraft((current) => ({ ...current, startDate: value }))}
1282
+ error={errors.startDate}
1283
+ />
1284
+
1285
+ <ToggleField
1286
+ label={t("recurring_rule.field_has_end_date")}
1287
+ helper={t("recurring_rule.field_has_end_date_helper")}
1288
+ checked={draft.hasEndDate}
1289
+ onChange={(checked) => setDraft((current) => ({ ...current, hasEndDate: checked }))}
1290
+ />
1291
+
1292
+ <DateField
1293
+ label={t("recurring_rule.field_end_date")}
1294
+ value={draft.endDate}
1295
+ onChange={(value) => setDraft((current) => ({ ...current, endDate: value }))}
1296
+ disabled={!draft.hasEndDate}
1297
+ error={errors.endDate}
1298
+ />
1299
+
1300
+ {preferences.remindersEnabled ? (
1301
+ <TextField
1302
+ label={t("recurring_rule.field_remind_at")}
1303
+ value={draft.remindAt}
1304
+ onChange={(value) => setDraft((current) => ({ ...current, remindAt: value }))}
1305
+ placeholder={t("recurring_rule.field_remind_at_placeholder")}
1306
+ helper={t("recurring_rule.field_remind_at_helper")}
1307
+ error={errors.remindAt}
1308
+ />
1309
+ ) : null}
1310
+
1311
+ <ToggleField
1312
+ label={t("recurring_rule.field_enable_summary")}
1313
+ helper={t("recurring_rule.field_enable_summary_helper")}
1314
+ checked={draft.enableSummary}
1315
+ onChange={(checked) => setDraft((current) => ({ ...current, enableSummary: checked }))}
1316
+ />
1317
+
1318
+ {draft.enableSummary ? (
1319
+ <SelectField
1320
+ label={t("recurring_rule.field_summary_channel")}
1321
+ value={draft.summaryChannel}
1322
+ onChange={(value) =>
1323
+ setDraft((current) => ({ ...current, summaryChannel: value as SummaryChannel }))
1324
+ }
1325
+ options={[
1326
+ { value: "", label: "—" },
1327
+ { value: "push", label: t("recurring_rule.summary_push") },
1328
+ { value: "email", label: t("recurring_rule.summary_email") }
1329
+ ]}
1330
+ error={errors.summaryChannel}
1331
+ />
1332
+ ) : null}
1333
+ </div>
1334
+
1335
+ <SchedulePreviewCard preview={preview} />
1336
+ </div>
1337
+ </ModalShell>
1338
+ );
1339
+ }
1340
+
1341
+ function TaskMetaModal({ taskId, onClose }: { taskId: string; onClose: () => void }) {
1342
+ const task = useAppStore((state) => state.tasks.find((entry) => entry.id === taskId));
1343
+ const locale = useAppStore((state) => state.locale);
1344
+ const t = useTranslator();
1345
+
1346
+ if (!task) {
1347
+ return null;
1348
+ }
1349
+
1350
+ return (
1351
+ <ModalShell title={t("task_detail.more_info")} onClose={onClose}>
1352
+ <DetailRow label={t("task_detail.status")} value={t(`status.${task.status}`)} />
1353
+ <DetailRow label={t("task_detail.priority")} value={t(`priority.${task.priority}`)} />
1354
+ <DetailRow label={t("task_detail.created")} value={formatAbsoluteDate(task.createdAt, locale)} />
1355
+ <DetailRow label={t("task_detail.updated")} value={formatAbsoluteDate(task.updatedAt, locale)} />
1356
+ </ModalShell>
1357
+ );
1358
+ }
1359
+
1360
+ function ModalShell({
1361
+ title,
1362
+ subtitle,
1363
+ wide,
1364
+ onClose,
1365
+ action,
1366
+ children
1367
+ }: {
1368
+ title: string;
1369
+ subtitle?: string;
1370
+ wide?: boolean;
1371
+ onClose: () => void;
1372
+ action?: React.ReactNode;
1373
+ children: React.ReactNode;
1374
+ }) {
1375
+ const t = useTranslator();
1376
+
1377
+ return (
1378
+ <div className="modal-backdrop" role="presentation" onClick={onClose}>
1379
+ <div
1380
+ className={`modal-card cut-surface ${wide ? "wide" : ""}`}
1381
+ onClick={(event) => event.stopPropagation()}
1382
+ role="dialog"
1383
+ aria-modal="true"
1384
+ >
1385
+ <div className="modal-header">
1386
+ <div>
1387
+ <p className="eyebrow">surface.modal</p>
1388
+ <h3>{title}</h3>
1389
+ {subtitle ? <p className="screen-subtitle">{subtitle}</p> : null}
1390
+ </div>
1391
+ <div className="modal-actions">
1392
+ <button className="cut-button ghost" onClick={onClose}>
1393
+ {t("common.cancel")}
1394
+ </button>
1395
+ {action}
1396
+ </div>
1397
+ </div>
1398
+ {children}
1399
+ </div>
1400
+ </div>
1401
+ );
1402
+ }
1403
+
1404
+ function RuleList() {
1405
+ const rules = useAppStore((state) => state.rules);
1406
+ const locale = useAppStore((state) => state.locale);
1407
+
1408
+ if (rules.length === 0) {
1409
+ return null;
1410
+ }
1411
+
1412
+ return (
1413
+ <div className="rule-list">
1414
+ {rules.map((rule) => (
1415
+ <div className="rule-card" key={rule.id}>
1416
+ <strong>{rule.name}</strong>
1417
+ <span>{describeRule(rule, locale)}</span>
1418
+ </div>
1419
+ ))}
1420
+ </div>
1421
+ );
1422
+ }
1423
+
1424
+ function SchedulePreviewCard({
1425
+ preview
1426
+ }: {
1427
+ preview: ReturnType<typeof getSchedulePreview>;
1428
+ }) {
1429
+ const t = useTranslator();
1430
+ const locale = useAppStore((state) => state.locale);
1431
+
1432
+ return (
1433
+ <section className="cut-surface preview-card">
1434
+ <div className="section-head compact">
1435
+ <div>
1436
+ <p className="eyebrow">x_schedule_preview.detail</p>
1437
+ <h3>{t("recurring_preview.title")}</h3>
1438
+ </div>
1439
+ </div>
1440
+
1441
+ {preview.state === "invalid" ? (
1442
+ <InlineError message={t("recurring_preview.invalid")} />
1443
+ ) : null}
1444
+
1445
+ {preview.state === "empty" ? (
1446
+ <div className="empty-state compact">
1447
+ <p>{t("recurring_preview.empty")}</p>
1448
+ </div>
1449
+ ) : null}
1450
+
1451
+ {preview.state === "ready" ? (
1452
+ <div className="preview-list">
1453
+ {preview.occurrences.map((date, index) => (
1454
+ <div className={`preview-item ${index === 0 ? "next" : ""}`} key={date}>
1455
+ <strong>{index === 0 ? "Next" : `+${index}`}</strong>
1456
+ <span>{formatAbsoluteDate(date, locale)}</span>
1457
+ </div>
1458
+ ))}
1459
+ </div>
1460
+ ) : null}
1461
+ </section>
1462
+ );
1463
+ }
1464
+
1465
+ function TaskTrendChart({
1466
+ emptyMessage,
1467
+ period,
1468
+ series
1469
+ }: {
1470
+ emptyMessage: string;
1471
+ period: Period;
1472
+ series: TrendPoint[];
1473
+ }) {
1474
+ const [highlightedIndex, setHighlightedIndex] = useState(0);
1475
+
1476
+ if (series.length === 0) {
1477
+ return (
1478
+ <section className="cut-surface chart-card empty-state">
1479
+ <h3>{emptyMessage}</h3>
1480
+ </section>
1481
+ );
1482
+ }
1483
+
1484
+ const width = 620;
1485
+ const height = 260;
1486
+ const padding = 36;
1487
+ const maxValue = Math.max(...series.flatMap((point) => [point.completed, point.created]), 1);
1488
+ const xStep = (width - padding * 2) / Math.max(series.length - 1, 1);
1489
+ const completedPath = series
1490
+ .map((point, index) => {
1491
+ const x = padding + index * xStep;
1492
+ const y = height - padding - (point.completed / maxValue) * (height - padding * 2);
1493
+ return `${index === 0 ? "M" : "L"}${x},${y}`;
1494
+ })
1495
+ .join(" ");
1496
+ const createdPath = series
1497
+ .map((point, index) => {
1498
+ const x = padding + index * xStep;
1499
+ const y = height - padding - (point.created / maxValue) * (height - padding * 2);
1500
+ return `${index === 0 ? "M" : "L"}${x},${y}`;
1501
+ })
1502
+ .join(" ");
1503
+ const highlighted = series[Math.min(highlightedIndex, series.length - 1)];
1504
+
1505
+ return (
1506
+ <section className="cut-surface chart-card">
1507
+ <div className="section-head compact">
1508
+ <div>
1509
+ <p className="eyebrow">x_task_trend_chart.detail</p>
1510
+ <h3>{period.toUpperCase()} trend</h3>
1511
+ </div>
1512
+ <div className="legend">
1513
+ <span><i className="legend-dot created" />Created</span>
1514
+ <span><i className="legend-dot completed" />Completed</span>
1515
+ </div>
1516
+ </div>
1517
+
1518
+ <svg
1519
+ aria-label={`${highlighted.label}: ${highlighted.completed} completed, ${highlighted.created} created`}
1520
+ className="trend-chart"
1521
+ viewBox={`0 0 ${width} ${height}`}
1522
+ role="img"
1523
+ >
1524
+ {Array.from({ length: 4 }).map((_, index) => {
1525
+ const y = padding + ((height - padding * 2) / 3) * index;
1526
+ return <line className="grid-line" key={y} x1={padding} x2={width - padding} y1={y} y2={y} />;
1527
+ })}
1528
+ <path className="line created" d={createdPath} />
1529
+ <path className="line completed" d={completedPath} />
1530
+ {series.map((point, index) => {
1531
+ const x = padding + index * xStep;
1532
+ const completedY = height - padding - (point.completed / maxValue) * (height - padding * 2);
1533
+ return (
1534
+ <g key={point.label}>
1535
+ <circle
1536
+ className={`point ${index === highlightedIndex ? "active" : ""}`}
1537
+ cx={x}
1538
+ cy={completedY}
1539
+ r={index === highlightedIndex ? 6 : 4}
1540
+ onMouseEnter={() => setHighlightedIndex(index)}
1541
+ />
1542
+ <text className="chart-label" x={x} y={height - 10} textAnchor="middle">
1543
+ {point.label}
1544
+ </text>
1545
+ </g>
1546
+ );
1547
+ })}
1548
+ </svg>
1549
+
1550
+ <div className="chart-callout">
1551
+ <strong>{highlighted.label}</strong>
1552
+ <span>{highlighted.completed} completed</span>
1553
+ <span>{highlighted.created} created</span>
1554
+ </div>
1555
+ </section>
1556
+ );
1557
+ }
1558
+
1559
+ function StatCard({ label, value }: { label: string; value: string }) {
1560
+ return (
1561
+ <div className="stat-card cut-panel">
1562
+ <span>{label}</span>
1563
+ <strong>{value}</strong>
1564
+ </div>
1565
+ );
1566
+ }
1567
+
1568
+ function DetailRow({ label, value }: { label: string; value: string }) {
1569
+ return (
1570
+ <div className="detail-row">
1571
+ <span>{label}</span>
1572
+ <strong>{value}</strong>
1573
+ </div>
1574
+ );
1575
+ }
1576
+
1577
+ function TextField({
1578
+ label,
1579
+ value,
1580
+ onChange,
1581
+ placeholder,
1582
+ helper,
1583
+ error,
1584
+ onBlur
1585
+ }: {
1586
+ label: string;
1587
+ value: string;
1588
+ onChange: (value: string) => void;
1589
+ placeholder?: string;
1590
+ helper?: string;
1591
+ error?: string;
1592
+ onBlur?: () => void;
1593
+ }) {
1594
+ return (
1595
+ <label className="field-block">
1596
+ <span className="field-label">{label}</span>
1597
+ <div className={`cut-input input-shell ${error ? "error" : ""}`}>
1598
+ <input value={value} onBlur={onBlur} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} />
1599
+ </div>
1600
+ {error ? <span className="field-error">{error}</span> : helper ? <span className="field-helper">{helper}</span> : null}
1601
+ </label>
1602
+ );
1603
+ }
1604
+
1605
+ function TextAreaField({
1606
+ label,
1607
+ value,
1608
+ onChange,
1609
+ placeholder
1610
+ }: {
1611
+ label: string;
1612
+ value: string;
1613
+ onChange: (value: string) => void;
1614
+ placeholder?: string;
1615
+ }) {
1616
+ return (
1617
+ <label className="field-block">
1618
+ <span className="field-label">{label}</span>
1619
+ <div className="cut-input input-shell textarea-shell">
1620
+ <textarea value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} />
1621
+ </div>
1622
+ </label>
1623
+ );
1624
+ }
1625
+
1626
+ function NumberField({
1627
+ label,
1628
+ value,
1629
+ onChange,
1630
+ helper,
1631
+ error
1632
+ }: {
1633
+ label: string;
1634
+ value: string;
1635
+ onChange: (value: string) => void;
1636
+ helper?: string;
1637
+ error?: string;
1638
+ }) {
1639
+ return (
1640
+ <label className="field-block">
1641
+ <span className="field-label">{label}</span>
1642
+ <div className={`cut-input input-shell ${error ? "error" : ""}`}>
1643
+ <input inputMode="numeric" type="number" value={value} onChange={(event) => onChange(event.target.value)} />
1644
+ </div>
1645
+ {error ? <span className="field-error">{error}</span> : helper ? <span className="field-helper">{helper}</span> : null}
1646
+ </label>
1647
+ );
1648
+ }
1649
+
1650
+ function SelectField({
1651
+ label,
1652
+ value,
1653
+ onChange,
1654
+ options,
1655
+ error
1656
+ }: {
1657
+ label: string;
1658
+ value: string;
1659
+ onChange: (value: string) => void;
1660
+ options: Array<{ label: string; value: string }>;
1661
+ error?: string;
1662
+ }) {
1663
+ return (
1664
+ <label className="field-block">
1665
+ <span className="field-label">{label}</span>
1666
+ <div className={`cut-input input-shell ${error ? "error" : ""}`}>
1667
+ <select value={value} onChange={(event) => onChange(event.target.value)}>
1668
+ {options.map((option) => (
1669
+ <option key={option.value} value={option.value}>
1670
+ {option.label}
1671
+ </option>
1672
+ ))}
1673
+ </select>
1674
+ </div>
1675
+ {error ? <span className="field-error">{error}</span> : null}
1676
+ </label>
1677
+ );
1678
+ }
1679
+
1680
+ function DateField({
1681
+ label,
1682
+ value,
1683
+ onChange,
1684
+ disabled,
1685
+ error
1686
+ }: {
1687
+ label: string;
1688
+ value: string;
1689
+ onChange: (value: string) => void;
1690
+ disabled?: boolean;
1691
+ error?: string;
1692
+ }) {
1693
+ return (
1694
+ <label className="field-block">
1695
+ <span className="field-label">{label}</span>
1696
+ <div className={`cut-input input-shell ${error ? "error" : ""} ${disabled ? "disabled" : ""}`}>
1697
+ <input disabled={disabled} type="date" value={value} onChange={(event) => onChange(event.target.value)} />
1698
+ </div>
1699
+ {error ? <span className="field-error">{error}</span> : null}
1700
+ </label>
1701
+ );
1702
+ }
1703
+
1704
+ function ToggleField({
1705
+ label,
1706
+ helper,
1707
+ checked,
1708
+ onChange
1709
+ }: {
1710
+ label: string;
1711
+ helper?: string;
1712
+ checked: boolean;
1713
+ onChange: (checked: boolean) => void;
1714
+ }) {
1715
+ return (
1716
+ <label className="toggle-row">
1717
+ <div>
1718
+ <span className="field-label">{label}</span>
1719
+ {helper ? <span className="field-helper">{helper}</span> : null}
1720
+ </div>
1721
+ <button
1722
+ className={`toggle-pill ${checked ? "checked" : ""}`}
1723
+ onClick={(event) => {
1724
+ event.preventDefault();
1725
+ onChange(!checked);
1726
+ }}
1727
+ type="button"
1728
+ >
1729
+ <span />
1730
+ </button>
1731
+ </label>
1732
+ );
1733
+ }
1734
+
1735
+ function InlineError({ message }: { message: string }) {
1736
+ return <div className="inline-error">{message}</div>;
1737
+ }
1738
+
1739
+ function ToastViewport({
1740
+ toasts,
1741
+ onDismiss
1742
+ }: {
1743
+ toasts: Toast[];
1744
+ onDismiss: (toastId: string) => void;
1745
+ }) {
1746
+ return (
1747
+ <div className="toast-stack" aria-live="polite">
1748
+ {toasts.map((toast) => (
1749
+ <button
1750
+ className={`toast ${toast.severity}`}
1751
+ key={toast.id}
1752
+ onClick={() => onDismiss(toast.id)}
1753
+ >
1754
+ {toast.message}
1755
+ </button>
1756
+ ))}
1757
+ </div>
1758
+ );
1759
+ }
1760
+
1761
+ function NavItem({
1762
+ to,
1763
+ active,
1764
+ children
1765
+ }: {
1766
+ to: string;
1767
+ active: boolean;
1768
+ children: string;
1769
+ }) {
1770
+ return (
1771
+ <NavLink className={`nav-item ${active ? "active" : ""}`} to={to}>
1772
+ {children}
1773
+ </NavLink>
1774
+ );
1775
+ }
1776
+
1777
+ function useTranslator() {
1778
+ const locale = useAppStore((state) => state.locale);
1779
+ return (key: string, params?: Record<string, string | number>) => {
1780
+ const template = messages[locale][key] ?? key;
1781
+ return Object.entries(params ?? {}).reduce(
1782
+ (output, [param, value]) => output.replaceAll(`{${param}}`, String(value)),
1783
+ template
1784
+ );
1785
+ };
1786
+ }
1787
+
1788
+ function useIsDesktop() {
1789
+ const [desktop, setDesktop] = useState(() => window.matchMedia("(min-width: 1080px)").matches);
1790
+
1791
+ useEffect(() => {
1792
+ const mediaQuery = window.matchMedia("(min-width: 1080px)");
1793
+ const listener = () => setDesktop(mediaQuery.matches);
1794
+ listener();
1795
+ mediaQuery.addEventListener("change", listener);
1796
+ return () => mediaQuery.removeEventListener("change", listener);
1797
+ }, []);
1798
+
1799
+ return desktop;
1800
+ }
1801
+
1802
+ function filterTasks(tasks: Task[], activeFilter: Filter, query: string) {
1803
+ return tasks.filter((task) => {
1804
+ const byStatus = activeFilter === "all" ? true : task.status === activeFilter;
1805
+ const normalized = query.trim().toLowerCase();
1806
+ const bySearch =
1807
+ normalized.length === 0
1808
+ ? true
1809
+ : `${task.title} ${task.notes ?? ""}`.toLowerCase().includes(normalized);
1810
+ return byStatus && bySearch;
1811
+ });
1812
+ }
1813
+
1814
+ function getTaskCounts(tasks: Task[]) {
1815
+ const open = tasks.filter((task) => task.status === "open").length;
1816
+ const done = tasks.filter((task) => task.status === "done").length;
1817
+ return { all: tasks.length, open, done };
1818
+ }
1819
+
1820
+ function getAnalyticsOverview(tasks: Task[]) {
1821
+ const today = new Date().toISOString().slice(0, 10);
1822
+ const completedToday = tasks.filter(
1823
+ (task) => task.status === "done" && task.updatedAt.slice(0, 10) === today
1824
+ ).length;
1825
+ const openTasks = tasks.filter((task) => task.status === "open").length;
1826
+ const overdueTasks = getOverdueTasks(tasks).length;
1827
+ const completionRate = tasks.length === 0 ? 0 : Math.round(((tasks.length - openTasks) / tasks.length) * 100);
1828
+ return { completedToday, openTasks, overdueTasks, completionRate };
1829
+ }
1830
+
1831
+ function getOverdueTasks(tasks: Task[]) {
1832
+ const now = new Date().toISOString().slice(0, 10);
1833
+ return tasks.filter((task) => task.status === "open" && Boolean(task.dueDate) && task.dueDate! < now);
1834
+ }
1835
+
1836
+ function getTrendSeries(tasks: Task[], period: Period, locale: Locale): TrendPoint[] {
1837
+ const length = period === "week" ? 7 : period === "month" ? 6 : 8;
1838
+ const today = new Date();
1839
+ const formatter = new Intl.DateTimeFormat(locale, {
1840
+ month: "short",
1841
+ day: period === "week" ? "numeric" : undefined,
1842
+ weekday: period === "week" ? "short" : undefined
1843
+ });
1844
+
1845
+ return Array.from({ length }).map((_, index) => {
1846
+ const offset = length - index - 1;
1847
+ const pointDate = new Date(today);
1848
+ pointDate.setDate(today.getDate() - offset * (period === "week" ? 1 : 5));
1849
+ const iso = pointDate.toISOString().slice(0, 10);
1850
+ const completed = tasks.filter(
1851
+ (task) => task.status === "done" && task.updatedAt.slice(0, 10) <= iso
1852
+ ).length;
1853
+ const created = tasks.filter((task) => task.createdAt.slice(0, 10) <= iso).length;
1854
+ return {
1855
+ label: formatter.format(pointDate),
1856
+ completed,
1857
+ created
1858
+ };
1859
+ });
1860
+ }
1861
+
1862
+ function getSchedulePreview(input: {
1863
+ cadence?: Cadence;
1864
+ interval: number;
1865
+ weekday?: Weekday;
1866
+ monthDay?: number;
1867
+ startDate: string;
1868
+ endDate?: string;
1869
+ previewCount: number;
1870
+ }) {
1871
+ if (!input.cadence || !input.startDate || input.interval < 1) {
1872
+ return { state: "invalid" as const, occurrences: [] };
1873
+ }
1874
+
1875
+ if (input.endDate && input.endDate < input.startDate) {
1876
+ return { state: "invalid" as const, occurrences: [] };
1877
+ }
1878
+
1879
+ const occurrences: string[] = [];
1880
+ const start = new Date(`${input.startDate}T09:00:00`);
1881
+ const end = input.endDate ? new Date(`${input.endDate}T23:59:59`) : null;
1882
+
1883
+ if (input.cadence === "weekly" && !input.weekday) {
1884
+ return { state: "invalid" as const, occurrences: [] };
1885
+ }
1886
+
1887
+ if (input.cadence === "monthly" && !input.monthDay) {
1888
+ return { state: "invalid" as const, occurrences: [] };
1889
+ }
1890
+
1891
+ let cursor = new Date(start);
1892
+ let guard = 0;
1893
+ while (occurrences.length < input.previewCount && guard < 32) {
1894
+ guard += 1;
1895
+ const candidate = getOccurrence(input, cursor, start);
1896
+ if (!candidate) {
1897
+ break;
1898
+ }
1899
+ if (!end || candidate <= end) {
1900
+ const iso = candidate.toISOString().slice(0, 10);
1901
+ if (!occurrences.includes(iso)) {
1902
+ occurrences.push(iso);
1903
+ }
1904
+ }
1905
+ cursor = stepCursor(input, candidate);
1906
+ }
1907
+
1908
+ if (occurrences.length === 0) {
1909
+ return { state: "empty" as const, occurrences: [] };
1910
+ }
1911
+
1912
+ return { state: "ready" as const, occurrences };
1913
+ }
1914
+
1915
+ function getOccurrence(
1916
+ input: {
1917
+ cadence?: Cadence;
1918
+ interval: number;
1919
+ weekday?: Weekday;
1920
+ monthDay?: number;
1921
+ },
1922
+ cursor: Date,
1923
+ start: Date
1924
+ ) {
1925
+ if (input.cadence === "daily") {
1926
+ return new Date(cursor);
1927
+ }
1928
+
1929
+ if (input.cadence === "weekly" && input.weekday) {
1930
+ const weekdayIndex = weekdayToIndex(input.weekday);
1931
+ const next = new Date(cursor);
1932
+ while (next.getDay() !== weekdayIndex) {
1933
+ next.setDate(next.getDate() + 1);
1934
+ }
1935
+ if (next < start) {
1936
+ next.setDate(next.getDate() + 7 * input.interval);
1937
+ }
1938
+ return next;
1939
+ }
1940
+
1941
+ if (input.cadence === "monthly" && input.monthDay) {
1942
+ const next = new Date(cursor);
1943
+ next.setDate(1);
1944
+ next.setHours(9, 0, 0, 0);
1945
+ next.setDate(input.monthDay);
1946
+ if (next < start) {
1947
+ next.setMonth(next.getMonth() + 1);
1948
+ next.setDate(input.monthDay);
1949
+ }
1950
+ return next;
1951
+ }
1952
+
1953
+ return null;
1954
+ }
1955
+
1956
+ function stepCursor(
1957
+ input: {
1958
+ cadence?: Cadence;
1959
+ interval: number;
1960
+ },
1961
+ candidate: Date
1962
+ ) {
1963
+ const next = new Date(candidate);
1964
+ if (input.cadence === "daily") {
1965
+ next.setDate(next.getDate() + input.interval);
1966
+ } else if (input.cadence === "weekly") {
1967
+ next.setDate(next.getDate() + 7 * input.interval);
1968
+ } else if (input.cadence === "monthly") {
1969
+ next.setMonth(next.getMonth() + input.interval);
1970
+ }
1971
+ return next;
1972
+ }
1973
+
1974
+ function validateRuleDraft(
1975
+ draft: RuleDraft,
1976
+ preferences: Preferences,
1977
+ rules: RecurringRule[],
1978
+ t: ReturnType<typeof useTranslator>
1979
+ ) {
1980
+ const errors: Record<string, string> = {};
1981
+
1982
+ if (draft.name.trim().length < 4) {
1983
+ errors.name = t("validation.rule_name_min_length", { min: 4 });
1984
+ } else if (draft.name.trim() === "Default") {
1985
+ errors.name = t("validation.rule_name_reserved");
1986
+ } else if (
1987
+ rules.some((rule) => rule.name.toLowerCase() === draft.name.trim().toLowerCase())
1988
+ ) {
1989
+ errors.name = t("validation.rule_name_taken");
1990
+ }
1991
+
1992
+ if (draft.confirmName.trim() !== draft.name.trim()) {
1993
+ errors.confirmName = t("validation.match_field");
1994
+ }
1995
+
1996
+ if (!draft.cadence) {
1997
+ errors.cadence = "Required";
1998
+ }
1999
+
2000
+ const interval = Number(draft.interval);
2001
+ if (!Number.isFinite(interval) || interval < 1) {
2002
+ errors.interval = t("validation.min_value", { min: 1 });
2003
+ } else if (interval > 30) {
2004
+ errors.interval = t("validation.max_value", { max: 30 });
2005
+ }
2006
+
2007
+ if (draft.cadence === "weekly" && !draft.weekday) {
2008
+ errors.weekday = "Required";
2009
+ }
2010
+
2011
+ if (draft.cadence === "monthly") {
2012
+ const monthDay = Number(draft.monthDay);
2013
+ if (!Number.isFinite(monthDay) || monthDay < 1) {
2014
+ errors.monthDay = t("validation.min_value", { min: 1 });
2015
+ } else if (monthDay > 28) {
2016
+ errors.monthDay = t("validation.month_day_max");
2017
+ }
2018
+ }
2019
+
2020
+ if (!draft.startDate) {
2021
+ errors.startDate = "Required";
2022
+ }
2023
+
2024
+ if (draft.hasEndDate) {
2025
+ if (!draft.endDate) {
2026
+ errors.endDate = "Required";
2027
+ } else if (draft.endDate < draft.startDate) {
2028
+ errors.endDate = t("validation.end_date_after_start");
2029
+ }
2030
+ }
2031
+
2032
+ if (preferences.remindersEnabled) {
2033
+ if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(draft.remindAt)) {
2034
+ errors.remindAt = t("validation.time_format");
2035
+ }
2036
+ }
2037
+
2038
+ if (draft.enableSummary && !draft.summaryChannel) {
2039
+ errors.summaryChannel = "Required";
2040
+ }
2041
+
2042
+ return errors;
2043
+ }
2044
+
2045
+ function describeRule(rule: RecurringRule, locale: Locale) {
2046
+ const formatter = new Intl.DateTimeFormat(locale, { month: "short", day: "numeric" });
2047
+ const cadence =
2048
+ rule.cadence === "daily"
2049
+ ? "Daily"
2050
+ : rule.cadence === "weekly"
2051
+ ? `Weekly on ${messages[locale][`weekday.${rule.weekday}`]}`
2052
+ : `Monthly on ${rule.monthDay}`;
2053
+ return `${cadence} · ${formatter.format(new Date(rule.startDate))}`;
2054
+ }
2055
+
2056
+ function formatSummary(locale: Locale, open: number, total: number) {
2057
+ if (locale === "ru") {
2058
+ if (open === 0) return "Все задачи закрыты";
2059
+ return `Осталось ${open} из ${total}`;
2060
+ }
2061
+ if (open === 0) return "Everything is done";
2062
+ return `${open} task${open === 1 ? "" : "s"} left out of ${total}`;
2063
+ }
2064
+
2065
+ function formatRelativeDate(value: string | undefined, locale: Locale, fallback: string) {
2066
+ if (!value) {
2067
+ return fallback;
2068
+ }
2069
+
2070
+ const date = new Date(value);
2071
+ const diff = Math.round((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
2072
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
2073
+ return rtf.format(diff, "day");
2074
+ }
2075
+
2076
+ function formatAbsoluteDate(value: string, locale: Locale) {
2077
+ return new Intl.DateTimeFormat(locale, {
2078
+ month: "short",
2079
+ day: "numeric",
2080
+ year: "numeric"
2081
+ }).format(new Date(value));
2082
+ }
2083
+
2084
+ function createId() {
2085
+ return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
2086
+ }
2087
+
2088
+ function isoToday() {
2089
+ return new Date().toISOString().slice(0, 10);
2090
+ }
2091
+
2092
+ function shiftDate(days: number) {
2093
+ const date = new Date();
2094
+ date.setDate(date.getDate() + days);
2095
+ return date.toISOString().slice(0, 10);
2096
+ }
2097
+
2098
+ function shiftDateTime(days: number) {
2099
+ const date = new Date();
2100
+ date.setDate(date.getDate() + days);
2101
+ return date.toISOString();
2102
+ }
2103
+
2104
+ function weekdayToIndex(weekday: Weekday) {
2105
+ return {
2106
+ sun: 0,
2107
+ mon: 1,
2108
+ tue: 2,
2109
+ wed: 3,
2110
+ thu: 4,
2111
+ fri: 5,
2112
+ sat: 6
2113
+ }[weekday];
2114
+ }