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.
- package/README.md +3 -1
- package/check/index.ts +17 -0
- package/cli/index.ts +21 -3
- package/cli/init.ts +224 -10
- package/docs/cli.md +13 -8
- package/docs/file-formats.md +36 -0
- package/docs/implementation-notes.md +7 -0
- package/drift/index.ts +281 -40
- package/mcp-server/index.ts +179 -119
- package/mcp-server/screenshot.ts +19 -4
- package/package.json +5 -2
- package/prepare/index.ts +155 -18
- package/schema/openuispec.schema.json +59 -0
- package/schema/semantic-lint.ts +25 -1
- package/scripts/take-all-screenshots.ts +507 -0
- package/spec/openuispec-v0.1.md +13 -0
- package/status/index.ts +72 -2
- package/examples/social-app/.mcp.json +0 -10
- package/examples/social-app/AGENTS.md +0 -124
- package/examples/social-app/CLAUDE.md +0 -124
- package/examples/social-app/backend/.gitkeep +0 -1
- package/examples/social-app/generated/android/social-app/app/.paparazzi-hashes.json +0 -3
- package/examples/social-app/generated/android/social-app/app/build.gradle.kts +0 -94
- package/examples/social-app/generated/android/social-app/app/src/main/AndroidManifest.xml +0 -26
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/AppContainer.kt +0 -20
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/MainActivity.kt +0 -35
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/SocialAppApplication.kt +0 -13
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/data/MockData.kt +0 -98
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/data/preferences/AppPreferences.kt +0 -19
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/data/preferences/DataStorePreferencesRepository.kt +0 -68
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/data/preferences/PreferencesRepository.kt +0 -15
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/model/Models.kt +0 -34
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/MainShell.kt +0 -390
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/components/Components.kt +0 -234
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/components/ContractPrimitives.kt +0 -641
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/navigation/RootComponent.kt +0 -113
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/ChatDetailScreen.kt +0 -212
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/CreatePostScreen.kt +0 -113
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/DiscoverScreen.kt +0 -137
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/EditProfileScreen.kt +0 -180
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/HomeFeedScreen.kt +0 -169
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/MessagesInboxScreen.kt +0 -85
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/NotificationsScreen.kt +0 -74
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/PostDetailScreen.kt +0 -293
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/ProfileSelfScreen.kt +0 -116
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/SearchResultsScreen.kt +0 -161
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/SettingsScreen.kt +0 -164
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/SettingsStore.kt +0 -95
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/UserProfileScreen.kt +0 -123
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/theme/Color.kt +0 -33
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/theme/Shape.kt +0 -41
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/theme/Spacing.kt +0 -20
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/theme/Theme.kt +0 -82
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/theme/Type.kt +0 -60
- package/examples/social-app/generated/android/social-app/app/src/main/res/drawable/ic_launcher_foreground.xml +0 -9
- package/examples/social-app/generated/android/social-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +0 -5
- package/examples/social-app/generated/android/social-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +0 -5
- package/examples/social-app/generated/android/social-app/app/src/main/res/values/strings.xml +0 -91
- package/examples/social-app/generated/android/social-app/app/src/main/res/values/themes.xml +0 -10
- package/examples/social-app/generated/android/social-app/app/src/main/res/values-ru/strings.xml +0 -79
- package/examples/social-app/generated/android/social-app/app/src/main/res/values-uz/strings.xml +0 -79
- package/examples/social-app/generated/android/social-app/app/src/main/xml/AndroidManifest.xml +0 -23
- package/examples/social-app/generated/android/social-app/app/src/test/kotlin/com/social/app/screenshots/HomeFeedScreenshotTest.kt +0 -34
- package/examples/social-app/generated/android/social-app/build.gradle.kts +0 -7
- package/examples/social-app/generated/android/social-app/gradle/libs.versions.toml +0 -50
- package/examples/social-app/generated/android/social-app/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/examples/social-app/generated/android/social-app/gradle/wrapper/gradle-wrapper.properties +0 -8
- package/examples/social-app/generated/android/social-app/gradle.properties +0 -11
- package/examples/social-app/generated/android/social-app/gradlew +0 -248
- package/examples/social-app/generated/android/social-app/settings.gradle.kts +0 -27
- package/examples/social-app/generated/web/social-app/index.html +0 -12
- package/examples/social-app/generated/web/social-app/package-lock.json +0 -2517
- package/examples/social-app/generated/web/social-app/package.json +0 -27
- package/examples/social-app/generated/web/social-app/src/app/App.tsx +0 -58
- package/examples/social-app/generated/web/social-app/src/components/Shell.tsx +0 -259
- package/examples/social-app/generated/web/social-app/src/components/cards.tsx +0 -317
- package/examples/social-app/generated/web/social-app/src/components/ui.tsx +0 -340
- package/examples/social-app/generated/web/social-app/src/flows/CreatePostFlow.tsx +0 -86
- package/examples/social-app/generated/web/social-app/src/i18n.tsx +0 -59
- package/examples/social-app/generated/web/social-app/src/lib/icons.tsx +0 -85
- package/examples/social-app/generated/web/social-app/src/lib/tokens.ts +0 -70
- package/examples/social-app/generated/web/social-app/src/lib/utils.ts +0 -97
- package/examples/social-app/generated/web/social-app/src/locales/en.json +0 -67
- package/examples/social-app/generated/web/social-app/src/locales/ru.json +0 -67
- package/examples/social-app/generated/web/social-app/src/locales/uz.json +0 -67
- package/examples/social-app/generated/web/social-app/src/main.tsx +0 -16
- package/examples/social-app/generated/web/social-app/src/screens/ChatDetailScreen.tsx +0 -90
- package/examples/social-app/generated/web/social-app/src/screens/DiscoverScreen.tsx +0 -86
- package/examples/social-app/generated/web/social-app/src/screens/EditProfileScreen.tsx +0 -57
- package/examples/social-app/generated/web/social-app/src/screens/HomeFeedScreen.tsx +0 -103
- package/examples/social-app/generated/web/social-app/src/screens/MessagesInboxScreen.tsx +0 -52
- package/examples/social-app/generated/web/social-app/src/screens/NotificationsScreen.tsx +0 -41
- package/examples/social-app/generated/web/social-app/src/screens/PostDetailScreen.tsx +0 -115
- package/examples/social-app/generated/web/social-app/src/screens/ProfileSelfScreen.tsx +0 -57
- package/examples/social-app/generated/web/social-app/src/screens/ProfileUserScreen.tsx +0 -76
- package/examples/social-app/generated/web/social-app/src/screens/SearchResultsScreen.tsx +0 -96
- package/examples/social-app/generated/web/social-app/src/screens/SettingsScreen.tsx +0 -79
- package/examples/social-app/generated/web/social-app/src/state/store.ts +0 -592
- package/examples/social-app/generated/web/social-app/src/styles.css +0 -125
- package/examples/social-app/generated/web/social-app/src/vite-env.d.ts +0 -1
- package/examples/social-app/generated/web/social-app/tsconfig.json +0 -22
- package/examples/social-app/generated/web/social-app/tsconfig.node.json +0 -13
- package/examples/social-app/generated/web/social-app/tsconfig.node.tsbuildinfo +0 -1
- package/examples/social-app/generated/web/social-app/tsconfig.tsbuildinfo +0 -1
- package/examples/social-app/generated/web/social-app/vite.config.d.ts +0 -2
- package/examples/social-app/generated/web/social-app/vite.config.js +0 -6
- package/examples/social-app/generated/web/social-app/vite.config.ts +0 -7
- package/examples/social-app/package.json +0 -13
- package/examples/social-app/take-web-screenshots.ts +0 -97
- package/examples/taskflow/.codex/config.toml +0 -4
- package/examples/taskflow/.mcp.json +0 -10
- package/examples/taskflow/AGENTS.md +0 -124
- package/examples/taskflow/CLAUDE.md +0 -124
- package/examples/taskflow/backend/.gitkeep +0 -1
- package/examples/taskflow/generated/android/TaskFlow/README.md +0 -43
- package/examples/taskflow/generated/android/TaskFlow/app/build.gradle.kts +0 -76
- package/examples/taskflow/generated/android/TaskFlow/app/proguard-rules.pro +0 -1
- package/examples/taskflow/generated/android/TaskFlow/app/src/main/AndroidManifest.xml +0 -21
- package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/MainActivity.kt +0 -19
- package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/TaskFlowApp.kt +0 -283
- package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/model/DomainModels.kt +0 -106
- package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/model/SampleData.kt +0 -57
- package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/components/Common.kt +0 -109
- package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/screens/HomeScreen.kt +0 -112
- package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/screens/ProjectsScreen.kt +0 -61
- package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/screens/SettingsScreen.kt +0 -82
- package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/screens/TaskDetailScreen.kt +0 -111
- package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/sheets/Sheets.kt +0 -77
- package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/theme/Color.kt +0 -30
- package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/theme/Theme.kt +0 -86
- package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/theme/Type.kt +0 -57
- package/examples/taskflow/generated/android/TaskFlow/app/src/main/res/values/strings.xml +0 -155
- package/examples/taskflow/generated/android/TaskFlow/app/src/main/res/values/themes.xml +0 -4
- package/examples/taskflow/generated/android/TaskFlow/build.gradle.kts +0 -5
- package/examples/taskflow/generated/android/TaskFlow/gradle/gradle-daemon-jvm.properties +0 -12
- package/examples/taskflow/generated/android/TaskFlow/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/examples/taskflow/generated/android/TaskFlow/gradle/wrapper/gradle-wrapper.properties +0 -7
- package/examples/taskflow/generated/android/TaskFlow/gradle.properties +0 -4
- package/examples/taskflow/generated/android/TaskFlow/gradlew +0 -18
- package/examples/taskflow/generated/android/TaskFlow/gradlew.bat +0 -12
- package/examples/taskflow/generated/android/TaskFlow/settings.gradle.kts +0 -18
- package/examples/taskflow/generated/ios/TaskFlow/README.md +0 -21
- package/examples/taskflow/generated/ios/TaskFlow/Resources/en.lproj/Localizable.strings +0 -115
- package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/App/TaskFlowApp.swift +0 -24
- package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Components/AppChrome.swift +0 -150
- package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Flows/TaskEditorSheet.swift +0 -220
- package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Models/DomainModels.swift +0 -122
- package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Screens/CalendarView.swift +0 -21
- package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Screens/HomeView.swift +0 -201
- package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Screens/ProfileEditView.swift +0 -48
- package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Screens/ProjectDetailView.swift +0 -59
- package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Screens/ProjectsView.swift +0 -63
- package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Screens/SettingsView.swift +0 -85
- package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Screens/TaskDetailView.swift +0 -219
- package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Support/AppModel.swift +0 -320
- package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Support/AppSupport.swift +0 -41
- package/examples/taskflow/generated/ios/TaskFlow/project.yml +0 -31
- package/examples/taskflow/generated/web/TaskFlow/README.md +0 -19
- package/examples/taskflow/generated/web/TaskFlow/index.html +0 -12
- package/examples/taskflow/generated/web/TaskFlow/package-lock.json +0 -1908
- package/examples/taskflow/generated/web/TaskFlow/package.json +0 -24
- package/examples/taskflow/generated/web/TaskFlow/src/App.tsx +0 -58
- package/examples/taskflow/generated/web/TaskFlow/src/AppShell.tsx +0 -55
- package/examples/taskflow/generated/web/TaskFlow/src/components/Common.tsx +0 -82
- package/examples/taskflow/generated/web/TaskFlow/src/components/Modals.tsx +0 -191
- package/examples/taskflow/generated/web/TaskFlow/src/components/Nav.tsx +0 -41
- package/examples/taskflow/generated/web/TaskFlow/src/generated/messages.ts +0 -131
- package/examples/taskflow/generated/web/TaskFlow/src/hooks.ts +0 -25
- package/examples/taskflow/generated/web/TaskFlow/src/i18n.ts +0 -39
- package/examples/taskflow/generated/web/TaskFlow/src/locales.en.json +0 -111
- package/examples/taskflow/generated/web/TaskFlow/src/main.tsx +0 -13
- package/examples/taskflow/generated/web/TaskFlow/src/screens/HomeScreen.tsx +0 -111
- package/examples/taskflow/generated/web/TaskFlow/src/screens/ProjectsScreen.tsx +0 -82
- package/examples/taskflow/generated/web/TaskFlow/src/screens/SettingsScreens.tsx +0 -132
- package/examples/taskflow/generated/web/TaskFlow/src/screens/TaskDetail.tsx +0 -105
- package/examples/taskflow/generated/web/TaskFlow/src/store.ts +0 -216
- package/examples/taskflow/generated/web/TaskFlow/src/styles.css +0 -617
- package/examples/taskflow/generated/web/TaskFlow/src/types.ts +0 -64
- package/examples/taskflow/generated/web/TaskFlow/src/utils.ts +0 -78
- package/examples/taskflow/generated/web/TaskFlow/tsconfig.json +0 -21
- package/examples/taskflow/generated/web/TaskFlow/vite.config.ts +0 -6
- package/examples/todo-orbit/.codex/config.toml +0 -4
- package/examples/todo-orbit/.mcp.json +0 -10
- package/examples/todo-orbit/AGENTS.md +0 -124
- package/examples/todo-orbit/CLAUDE.md +0 -124
- package/examples/todo-orbit/backend/.gitkeep +0 -1
- package/examples/todo-orbit/generated/android/Todo Orbit/README.md +0 -14
- package/examples/todo-orbit/generated/android/Todo Orbit/app/build.gradle.kts +0 -58
- package/examples/todo-orbit/generated/android/Todo Orbit/app/proguard-rules.pro +0 -1
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/AndroidManifest.xml +0 -20
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/MainActivity.kt +0 -14
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/TodoOrbitApp.kt +0 -345
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/support/AppLogic.kt +0 -231
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/support/Models.kt +0 -169
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/support/Strings.kt +0 -8
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/components/CommonComponents.kt +0 -236
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/AnalyticsScreen.kt +0 -193
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/SettingsScreen.kt +0 -102
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/TasksScreen.kt +0 -347
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/sheets/EditorSheets.kt +0 -347
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/theme/TodoOrbitTheme.kt +0 -59
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values/strings.xml +0 -149
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values-ru/strings.xml +0 -155
- package/examples/todo-orbit/generated/android/Todo Orbit/build.gradle.kts +0 -4
- package/examples/todo-orbit/generated/android/Todo Orbit/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/gradle/wrapper/gradle-wrapper.properties +0 -7
- package/examples/todo-orbit/generated/android/Todo Orbit/gradle.properties +0 -4
- package/examples/todo-orbit/generated/android/Todo Orbit/gradlew +0 -248
- package/examples/todo-orbit/generated/android/Todo Orbit/gradlew.bat +0 -93
- package/examples/todo-orbit/generated/android/Todo Orbit/settings.gradle.kts +0 -18
- package/examples/todo-orbit/generated/ios/Todo Orbit/.screenshot-uitest/Sources/ScreenshotUITest.swift +0 -36
- package/examples/todo-orbit/generated/ios/Todo Orbit/README.md +0 -29
- package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/en.lproj/Localizable.strings +0 -119
- package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/ru.lproj/Localizable.strings +0 -119
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/App/TodoOrbitApp.swift +0 -50
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Components/OrbitChrome.swift +0 -204
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Components/SchedulePreviewView.swift +0 -126
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Components/TrendChartView.swift +0 -70
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/RecurringRuleSheet.swift +0 -126
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/TaskEditorSheet.swift +0 -61
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Models/DomainModels.swift +0 -238
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/AnalyticsView.swift +0 -94
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/SettingsView.swift +0 -76
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/TasksHomeView.swift +0 -364
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Support/AppModel.swift +0 -324
- package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/project.pbxproj +0 -439
- package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
- package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/xcshareddata/xcschemes/TodoOrbit.xcscheme +0 -89
- package/examples/todo-orbit/generated/ios/Todo Orbit/project.yml +0 -32
- package/examples/todo-orbit/generated/web/Todo Orbit/index.html +0 -16
- package/examples/todo-orbit/generated/web/Todo Orbit/package-lock.json +0 -1087
- package/examples/todo-orbit/generated/web/Todo Orbit/package.json +0 -24
- package/examples/todo-orbit/generated/web/Todo Orbit/src/App.tsx +0 -2167
- package/examples/todo-orbit/generated/web/Todo Orbit/src/main.tsx +0 -13
- package/examples/todo-orbit/generated/web/Todo Orbit/src/styles.css +0 -926
- package/examples/todo-orbit/generated/web/Todo Orbit/tsconfig.json +0 -19
- 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
|
-
}
|