openuispec 0.2.10 → 0.2.12

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