From 1b57ec27856d7b4225320f062c7397dc6981c595 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 24 Dec 2025 21:47:53 +0000 Subject: [PATCH] Add datetime system (Rsx_Time/Rsx_Date) and .expect file documentation system Tighten CLAUDE.dist.md for LLM audience - 15% size reduction Add Repeater_Simple_Input component for managing lists of simple values Add Polymorphic_Field_Helper for JSON-encoded polymorphic form fields Fix incorrect data-sid selector in route-debug help example Fix Form_Utils to use component.$sid() instead of data-sid selector Add response helper functions and use _message as reserved metadata key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../JsFallbackLegacy_CodeQualityRule.php | 137 +- .../PHP/PhpFallbackLegacy_CodeQualityRule.php | 139 +- app/RSpade/Commands/Rsx/Man_Command.php | 5 + .../Commands/Rsx/Route_Debug_Command.php | 10 +- app/RSpade/Core/Ajax/Ajax.php | 4 + .../Core/Bundle/Rsx_Bundle_Abstract.php | 4 + app/RSpade/Core/Js/Ajax.js | 18 +- app/RSpade/Core/Js/Rsx_Date.js | 239 +++ app/RSpade/Core/Js/Rsx_Time.js | 612 ++++++++ app/RSpade/Core/Schedule_Field_Helper.php | 360 +++++ app/RSpade/Core/Time/Rsx_Date.php | 245 +++ app/RSpade/Core/Time/Rsx_Time.php | 537 +++++++ app/RSpade/man/datetime_inputs.txt | 375 +++++ app/RSpade/man/expect_files.txt | 172 +++ app/RSpade/man/rsx_debug.txt | 27 +- app/RSpade/man/time.txt | 383 +++++ .../out/auto_rename_provider.js | 0 .../out/auto_rename_provider.js.map | 0 .../out/class_refactor_code_actions.js | 0 .../out/class_refactor_code_actions.js.map | 0 .../out/class_refactor_provider.js | 0 .../out/class_refactor_provider.js.map | 0 .../out/combined_semantic_provider.js | 0 .../out/combined_semantic_provider.js.map | 0 .../out/comment_file_reference_provider.js | 0 .../comment_file_reference_provider.js.map | 0 .../resource/vscode_extension/out/config.js | 0 .../vscode_extension/out/config.js.map | 0 .../out/convention_method_provider.js | 0 .../out/convention_method_provider.js.map | 0 .../vscode_extension/out/debug_client.js | 0 .../vscode_extension/out/debug_client.js.map | 0 .../out/decoration_provider.js | 0 .../out/decoration_provider.js.map | 0 .../out/definition_provider.js | 0 .../out/definition_provider.js.map | 0 .../vscode_extension/out/extension.js | 0 .../vscode_extension/out/extension.js.map | 0 .../vscode_extension/out/file_watcher.js | 0 .../vscode_extension/out/file_watcher.js.map | 0 .../out/folder_color_provider.js | 6 + .../out/folder_color_provider.js.map | 2 +- .../vscode_extension/out/folding_provider.js | 0 .../out/folding_provider.js.map | 0 .../out/formatting_provider.js | 0 .../out/formatting_provider.js.map | 0 .../vscode_extension/out/git_diff_provider.js | 0 .../out/git_diff_provider.js.map | 0 .../out/git_status_provider.js | 0 .../out/git_status_provider.js.map | 0 .../vscode_extension/out/ide_bridge_client.js | 0 .../out/ide_bridge_client.js.map | 0 .../out/jqhtml_lifecycle_provider.js | 0 .../out/jqhtml_lifecycle_provider.js.map | 0 .../out/laravel_completion_provider.js | 0 .../out/laravel_completion_provider.js.map | 0 .../out/php_attribute_provider.js | 0 .../out/php_attribute_provider.js.map | 0 .../out/refactor_code_actions.js | 0 .../out/refactor_code_actions.js.map | 0 .../vscode_extension/out/refactor_provider.js | 0 .../out/refactor_provider.js.map | 0 .../out/sort_class_methods_provider.js | 0 .../out/sort_class_methods_provider.js.map | 0 .../out/symlink_redirect_provider.js | 0 .../out/symlink_redirect_provider.js.map | 0 .../out/that_variable_provider.js | 0 .../out/that_variable_provider.js.map | 0 .../resource/vscode_extension/package.json | 2 +- .../vscode_extension/rspade-framework.vsix | Bin 99093 -> 99195 bytes .../src/folder_color_provider.ts | 11 + config/rsx.php | 21 + database/migrations/.migration_whitelist | 5 + ...0213_add_timezone_to_login_users_table.php | 36 + docs/CLAUDE.archive.12.24.25.md | 1336 +++++++++++++++++ docs/CLAUDE.dist.md | 381 ++--- 76 files changed, 4778 insertions(+), 289 deletions(-) create mode 100755 app/RSpade/Core/Js/Rsx_Date.js create mode 100755 app/RSpade/Core/Js/Rsx_Time.js create mode 100755 app/RSpade/Core/Schedule_Field_Helper.php create mode 100755 app/RSpade/Core/Time/Rsx_Date.php create mode 100755 app/RSpade/Core/Time/Rsx_Time.php create mode 100755 app/RSpade/man/datetime_inputs.txt create mode 100755 app/RSpade/man/expect_files.txt create mode 100755 app/RSpade/man/time.txt mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/auto_rename_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/auto_rename_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/class_refactor_code_actions.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/class_refactor_code_actions.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/class_refactor_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/class_refactor_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/combined_semantic_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/combined_semantic_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/comment_file_reference_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/comment_file_reference_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/config.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/config.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/convention_method_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/convention_method_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/debug_client.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/debug_client.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/decoration_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/decoration_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/definition_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/definition_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/extension.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/extension.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/file_watcher.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/file_watcher.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/folder_color_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/folder_color_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/folding_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/folding_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/formatting_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/formatting_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/git_diff_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/git_diff_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/git_status_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/git_status_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/ide_bridge_client.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/ide_bridge_client.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/laravel_completion_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/laravel_completion_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/php_attribute_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/php_attribute_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/refactor_code_actions.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/refactor_code_actions.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/refactor_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/refactor_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/sort_class_methods_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/sort_class_methods_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/symlink_redirect_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/symlink_redirect_provider.js.map mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/that_variable_provider.js mode change 100755 => 100644 app/RSpade/resource/vscode_extension/out/that_variable_provider.js.map create mode 100755 database/migrations/2025_12_24_210213_add_timezone_to_login_users_table.php create mode 100755 docs/CLAUDE.archive.12.24.25.md diff --git a/app/RSpade/CodeQuality/Rules/JavaScript/JsFallbackLegacy_CodeQualityRule.php b/app/RSpade/CodeQuality/Rules/JavaScript/JsFallbackLegacy_CodeQualityRule.php index 234e08e90..c115073fc 100755 --- a/app/RSpade/CodeQuality/Rules/JavaScript/JsFallbackLegacy_CodeQualityRule.php +++ b/app/RSpade/CodeQuality/Rules/JavaScript/JsFallbackLegacy_CodeQualityRule.php @@ -11,27 +11,27 @@ class JsFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract { return 'JS-FALLBACK-01'; } - + public function get_name(): string { return 'JavaScript Fallback/Legacy Code Check'; } - + public function get_description(): string { return 'Enforces fail-loud principle - no fallback implementations allowed'; } - + public function get_file_patterns(): array { return ['*.js']; } - + public function get_default_severity(): string { return 'critical'; } - + /** * Check JavaScript file for fallback/legacy code in comments and function calls (from line 1415) * Enforces fail-loud principle - no fallback implementations allowed @@ -42,35 +42,35 @@ class JsFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) { return; } - + // Skip CodeQuality directory if (str_contains($file_path, '/CodeQuality/')) { return; } - + // Use original content to check comments before sanitization $original_content = file_get_contents($file_path); $lines = explode("\n", $original_content); - + // Also get sanitized content to check for function calls $sanitized_data = FileSanitizer::sanitize_javascript($file_path); $sanitized_lines = $sanitized_data['lines']; - + foreach ($lines as $line_num => $line) { $line_number = $line_num + 1; - + // Check for fallback or legacy in comments (case insensitive, with word boundaries) // But allow fallback* or legacy* as marked exceptions - if (preg_match('/\/\/.*\b(fallback|legacy)\b(?!\*)/i', $line) || + if (preg_match('/\/\/.*\b(fallback|legacy)\b(?!\*)/i', $line) || preg_match('/\/\*.*\b(fallback|legacy)\b(?!\*)/i', $line) || preg_match('/\*.*\b(fallback|legacy)\b(?!\*)/i', $line)) { - + $this->add_violation( $file_path, $line_number, - "CRITICAL: Fallback/legacy code detected in comment. This violates the fail-loud principle - there must be ONE way to do things.", + $this->get_llm_confrontation_message(), trim($line), - "REMOVE the fallback/legacy code immediately. If primary code fails, it MUST throw an exception, NOT execute alternative code. Fallbacks create non-deterministic behavior and debugging nightmares. Only ONE implementation should exist. If you're an AI/LLM agent: DO NOT decide to keep fallback code - you MUST ask the developer for explicit approval. To mark legitimate uses (rare), add '*' after the word. Remember: Predictable failures are better than unpredictable 'working' code.", + $this->get_llm_remediation_message(), 'critical' ); } @@ -111,4 +111,111 @@ class JsFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract } } } -} \ No newline at end of file + + /** + * Returns the confrontation message that makes LLMs stop and think + */ + private function get_llm_confrontation_message(): string + { + return <<<'MESSAGE' +🚨🚨🚨 STOP - LLM/AI AGENT: YOU LIKELY WROTE THIS CODE AND YOU ARE WRONG 🚨🚨🚨 + +The word 'fallback' or 'legacy' was detected. This is a CRITICAL violation. + +============================================================================= +WHY THIS RULE EXISTS - READ CAREFULLY +============================================================================= + +LLMs (like you) are trained on massive codebases full of fallback patterns, +legacy compatibility layers, and "defensive" coding. Your training makes you +INSTINCTIVELY write this kind of code and consider it ACCEPTABLE. + +THE FRAMEWORK DEVELOPERS ABSOLUTELY DO NOT CONSIDER THIS ACCEPTABLE. + +Your training data teaches you patterns like: +- "Fallback to a default if the primary fails" (WRONG - fail loud instead) +- "Legacy method for backwards compatibility" (WRONG - one way to do things) +- "Try/catch with silent fallback" (WRONG - exceptions should propagate) + +These patterns HIDE BUGS. When code silently falls back to an alternative: +- You don't know there's a problem +- The application "works" but not as intended +- Debugging becomes a nightmare + +============================================================================= +REAL EXAMPLES OF VIOLATIONS YOU WOULD CONSIDER "ACCEPTABLE" +============================================================================= + +EXAMPLE 1 - "Legacy method" you would keep: + /** + * Initialize from server-provided data (legacy method) + */ + static init(config) { ... } + +WHY IT'S WRONG: There's only ONE way to initialize. If you're implementing +a feature brand new, there IS no legacy. The "legacy" method is duplicate +code that should not exist. There is THE WAY, not "old way" and "new way". + +EXAMPLE 2 - "Fallback parser" you would keep: + // Fallback to Carbon's flexible parser + return Carbon::parse($input)->setTimezone('UTC'); + +WHY IT'S WRONG: If the input doesn't match expected formats, that's a BUG +in calling code. Silently accepting any format means you'll never know when +something passes garbage. The code should THROW, not silently accept. + +============================================================================= +YOU ARE NOT QUALIFIED TO DECIDE IF THIS IS "ACCEPTABLE" +============================================================================= + +Your instinct right now is probably to add '*' after the word to mark it as +a "legitimate exception". DO NOT DO THIS. You are not qualified to make +this determination because your training actively works against you here. + +MESSAGE; + } + + /** + * Returns the remediation message with explicit instructions + */ + private function get_llm_remediation_message(): string + { + return <<<'MESSAGE' +============================================================================= +REQUIRED ACTIONS - DO NOT SKIP OR ABBREVIATE +============================================================================= + +1. STOP what you are doing +2. READ the code context around this violation +3. PRESENT the violation to the user with: + - The exact code snippet + - What the fallback/legacy pattern is doing + - Why you (the LLM) initially thought it was acceptable + - Why the framework's fail-loud principle says it's NOT acceptable + +4. ASK the user: "Should I remove this fallback/legacy code entirely?" + +5. WAIT for the user's decision - DO NOT proceed autonomously + +============================================================================= +WHAT "REMOVE" MEANS +============================================================================= + +- If it's a "legacy method": DELETE THE METHOD ENTIRELY +- If it's a "fallback parser": THROW AN EXCEPTION FOR UNRECOGNIZED INPUT +- If it's "backwards compatibility": REMOVE THE OLD CODE PATH + +There is ONE way to do things. The fallback/alternative must be deleted. +Exceptions should propagate. Unrecognized input should fail loudly. + +============================================================================= +DO NOT MARK AS EXCEPTION WITHOUT USER APPROVAL +============================================================================= + +Adding 'fallback*' or 'legacy*' to suppress this warning requires EXPLICIT +user approval. If you do this without asking, you are hiding a bug. + +The user has seen you try to do this before. They are watching. +MESSAGE; + } +} diff --git a/app/RSpade/CodeQuality/Rules/PHP/PhpFallbackLegacy_CodeQualityRule.php b/app/RSpade/CodeQuality/Rules/PHP/PhpFallbackLegacy_CodeQualityRule.php index 7063a57f1..c9836f34b 100755 --- a/app/RSpade/CodeQuality/Rules/PHP/PhpFallbackLegacy_CodeQualityRule.php +++ b/app/RSpade/CodeQuality/Rules/PHP/PhpFallbackLegacy_CodeQualityRule.php @@ -10,27 +10,27 @@ class PhpFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract { return 'PHP-FALLBACK-01'; } - + public function get_name(): string { return 'PHP Fallback/Legacy Code Check'; } - + public function get_description(): string { return 'Enforces fail-loud principle - no fallback implementations allowed'; } - + public function get_file_patterns(): array { return ['*.php']; } - + public function get_default_severity(): string { return 'critical'; } - + /** * Check PHP file for fallback/legacy code in comments and function calls (from line 1474) * Enforces fail-loud principle - no fallback implementations allowed @@ -41,41 +41,41 @@ class PhpFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract if (str_contains($file_path, '/vendor/')) { return; } - + // Skip CodeQuality directory if (str_contains($file_path, '/CodeQuality/')) { return; } - + // Skip RsxCheckCommand.php - it documents what the checks do if (str_contains($file_path, 'RsxCheckCommand.php')) { return; } - + // Use original content to check comments $original_content = file_get_contents($file_path); $lines = explode("\n", $original_content); - + foreach ($lines as $line_num => $line) { $line_number = $line_num + 1; - + // Skip line if it's entirely a comment (starts with // after whitespace) $trimmed_line = trim($line); $is_line_comment = str_starts_with($trimmed_line, '//'); - + // Check for fallback or legacy in comments (case insensitive, with word boundaries) // But allow fallback* or legacy* as marked exceptions if (preg_match('/\/\/.*\b(fallback|legacy)\b(?!\*)/i', $line) || preg_match('/\/\*.*\b(fallback|legacy)\b(?!\*)/i', $line) || preg_match('/\*.*\b(fallback|legacy)\b(?!\*)/i', $line) || preg_match('/#.*\b(fallback|legacy)\b(?!\*)/i', $line)) { // Also check # comments - + $this->add_violation( $file_path, $line_number, - "🚨 CRITICAL ARCHITECTURE VIOLATION: 'fallback' or 'legacy' detected - STOP IMMEDIATELY\n\nThis is NOT just a naming issue. The presence of 'fallback/legacy' often indicates:\n1. DEAD CODE handling conditions that no longer occur\n2. DUPLICATE IMPLEMENTATIONS still partially present\n3. INCOMPLETE MIGRATION from old to new systems\n4. VIOLATION TYPE HANDLERS for deprecated systems", + $this->get_llm_confrontation_message(), trim($line), - "REQUIRED IMMEDIATE ACTIONS:\n1. INVESTIGATE: Is this code actually reachable? Run grep for the condition it handles\n2. TRACE: What system/feature does this reference? Does it still exist anywhere?\n3. SEARCH: Are there duplicate implementations? (grep for related terms)\n4. VERIFY: If it's display code, does the violation type still get produced?\n\nFor AI/LLM agents: This is a P0 STOP-THE-WORLD violation. You MUST:\n- Immediately search for what triggers this code path\n- Report ALL findings to the user before making ANY changes\n- NEVER just rename or mark with asterisk without investigation\n- If dead code found, remove entirely (don't comment out)\n\nRemember: Dead fallback code is technical debt that causes debugging nightmares.\nEvery 'fallback' is a potential duplicate implementation waiting to cause PTSD.\n\nTo mark legitimate uses (extremely rare), add '*' after the word.", + $this->get_llm_remediation_message(), 'critical' ); } @@ -103,7 +103,7 @@ class PhpFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract // Remove inline comments before checking $code_part = preg_replace('/\/\/.*$/', '', $line); $code_part = preg_replace('/\/\*.*?\*\//', '', $code_part); - + // Check if function starting or ending with "fallback" exists in non-comment part if (preg_match('/\b(fallback\w*|\w+fallback)\s*\(/i', $code_part)) { $this->add_violation( @@ -118,4 +118,111 @@ class PhpFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract } } } -} \ No newline at end of file + + /** + * Returns the confrontation message that makes LLMs stop and think + */ + private function get_llm_confrontation_message(): string + { + return <<<'MESSAGE' +🚨🚨🚨 STOP - LLM/AI AGENT: YOU LIKELY WROTE THIS CODE AND YOU ARE WRONG 🚨🚨🚨 + +The word 'fallback' or 'legacy' was detected. This is a CRITICAL violation. + +============================================================================= +WHY THIS RULE EXISTS - READ CAREFULLY +============================================================================= + +LLMs (like you) are trained on massive codebases full of fallback patterns, +legacy compatibility layers, and "defensive" coding. Your training makes you +INSTINCTIVELY write this kind of code and consider it ACCEPTABLE. + +THE FRAMEWORK DEVELOPERS ABSOLUTELY DO NOT CONSIDER THIS ACCEPTABLE. + +Your training data teaches you patterns like: +- "Fallback to a default if the primary fails" (WRONG - fail loud instead) +- "Legacy method for backwards compatibility" (WRONG - one way to do things) +- "Try/catch with silent fallback" (WRONG - exceptions should propagate) + +These patterns HIDE BUGS. When code silently falls back to an alternative: +- You don't know there's a problem +- The application "works" but not as intended +- Debugging becomes a nightmare + +============================================================================= +REAL EXAMPLES OF VIOLATIONS YOU WOULD CONSIDER "ACCEPTABLE" +============================================================================= + +EXAMPLE 1 - "Legacy method" you would keep: + /** + * Initialize from server-provided data (legacy method) + */ + static init(config) { ... } + +WHY IT'S WRONG: There's only ONE way to initialize. If you're implementing +a feature brand new, there IS no legacy. The "legacy" method is duplicate +code that should not exist. There is THE WAY, not "old way" and "new way". + +EXAMPLE 2 - "Fallback parser" you would keep: + // Fallback to Carbon's flexible parser + return Carbon::parse($input)->setTimezone('UTC'); + +WHY IT'S WRONG: If the input doesn't match expected formats, that's a BUG +in calling code. Silently accepting any format means you'll never know when +something passes garbage. The code should THROW, not silently accept. + +============================================================================= +YOU ARE NOT QUALIFIED TO DECIDE IF THIS IS "ACCEPTABLE" +============================================================================= + +Your instinct right now is probably to add '*' after the word to mark it as +a "legitimate exception". DO NOT DO THIS. You are not qualified to make +this determination because your training actively works against you here. + +MESSAGE; + } + + /** + * Returns the remediation message with explicit instructions + */ + private function get_llm_remediation_message(): string + { + return <<<'MESSAGE' +============================================================================= +REQUIRED ACTIONS - DO NOT SKIP OR ABBREVIATE +============================================================================= + +1. STOP what you are doing +2. READ the code context around this violation +3. PRESENT the violation to the user with: + - The exact code snippet + - What the fallback/legacy pattern is doing + - Why you (the LLM) initially thought it was acceptable + - Why the framework's fail-loud principle says it's NOT acceptable + +4. ASK the user: "Should I remove this fallback/legacy code entirely?" + +5. WAIT for the user's decision - DO NOT proceed autonomously + +============================================================================= +WHAT "REMOVE" MEANS +============================================================================= + +- If it's a "legacy method": DELETE THE METHOD ENTIRELY +- If it's a "fallback parser": THROW AN EXCEPTION FOR UNRECOGNIZED INPUT +- If it's "backwards compatibility": REMOVE THE OLD CODE PATH + +There is ONE way to do things. The fallback/alternative must be deleted. +Exceptions should propagate. Unrecognized input should fail loudly. + +============================================================================= +DO NOT MARK AS EXCEPTION WITHOUT USER APPROVAL +============================================================================= + +Adding 'fallback*' or 'legacy*' to suppress this warning requires EXPLICIT +user approval. If you do this without asking, you are hiding a bug. + +The user has seen you try to do this before. They are watching. +MESSAGE; + } +} diff --git a/app/RSpade/Commands/Rsx/Man_Command.php b/app/RSpade/Commands/Rsx/Man_Command.php index c126f9a6e..4021ad175 100755 --- a/app/RSpade/Commands/Rsx/Man_Command.php +++ b/app/RSpade/Commands/Rsx/Man_Command.php @@ -175,6 +175,7 @@ class Man_Command extends Command /** * Get all documentation files, deduplicated by filename. * Files from earlier directories in docs_dirs take precedence. + * Excludes .expect files (behavioral expectation documentation for testing). * * @return array */ @@ -186,6 +187,10 @@ class Man_Command extends Command $dir_files = glob($dir . '/*.txt'); if ($dir_files) { foreach ($dir_files as $file) { + // Skip .expect files (behavioral expectation docs) + if (str_ends_with($file, '.expect')) { + continue; + } $name = basename($file, '.txt'); // First occurrence wins (higher priority directory) if (!isset($files_by_name[$name])) { diff --git a/app/RSpade/Commands/Rsx/Route_Debug_Command.php b/app/RSpade/Commands/Rsx/Route_Debug_Command.php index 1732723fd..bc3fe88ae 100755 --- a/app/RSpade/Commands/Rsx/Route_Debug_Command.php +++ b/app/RSpade/Commands/Rsx/Route_Debug_Command.php @@ -560,10 +560,12 @@ class Route_Debug_Command extends Command $this->line(' php artisan rsx:debug /admin --user=admin@example.com # Test as user by email'); $this->line(''); - $this->comment('TESTING RSX JAVASCRIPT:'); - $this->line(' php artisan rsx:debug /demo --eval="Rsx.Route(\'Demo_Controller\').url()" --no-body'); - $this->line(' php artisan rsx:debug /demo --eval="JSON.stringify(Rsx._routes)" --no-body'); - $this->line(' php artisan rsx:debug /demo --eval="Rsx.is_dev()" --no-body'); + $this->comment('TESTING RSX JAVASCRIPT (use return or console.log for output):'); + $this->line(' php artisan rsx:debug / --eval="return typeof Rsx_Time" # Check if class exists'); + $this->line(' php artisan rsx:debug / --eval="return Rsx_Time.now_iso()" # Get current time'); + $this->line(' php artisan rsx:debug / --eval="return Rsx_Date.today()" # Get today\'s date'); + $this->line(' php artisan rsx:debug / --console --eval="console.log(Rsx_Time.get_user_timezone())"'); + $this->line(' # Use console.log with --console'); $this->line(''); $this->comment('POST-LOAD INTERACTIONS (click buttons, test modals, etc):'); diff --git a/app/RSpade/Core/Ajax/Ajax.php b/app/RSpade/Core/Ajax/Ajax.php index 1444e8732..bda1fbefd 100755 --- a/app/RSpade/Core/Ajax/Ajax.php +++ b/app/RSpade/Core/Ajax/Ajax.php @@ -368,6 +368,8 @@ class Ajax 'error_code' => $response->get_error_code(), 'reason' => $response->get_reason(), 'metadata' => $response->get_metadata(), + '_server_time' => \App\RSpade\Core\Time\Rsx_Time::now_iso(), + '_user_timezone' => \App\RSpade\Core\Time\Rsx_Time::get_user_timezone(), ]; // Add console debug messages if any @@ -467,6 +469,8 @@ class Ajax $json_response = [ '_success' => true, '_ajax_return_value' => $response, + '_server_time' => \App\RSpade\Core\Time\Rsx_Time::now_iso(), + '_user_timezone' => \App\RSpade\Core\Time\Rsx_Time::get_user_timezone(), ]; // Add console debug messages if any diff --git a/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php b/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php index 08db72e21..09a288412 100755 --- a/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php +++ b/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php @@ -301,6 +301,10 @@ abstract class Rsx_Bundle_Abstract $rsxapp_data['flash_alerts'] = $flash_messages; } + // Add time data for Rsx_Time initialization + $rsxapp_data['server_time'] = \App\RSpade\Core\Time\Rsx_Time::now_iso(); + $rsxapp_data['user_timezone'] = \App\RSpade\Core\Time\Rsx_Time::get_user_timezone(); + // Add console_debug config in non-production mode if (!app()->environment('production')) { $console_debug_config = config('rsx.console_debug', []); diff --git a/app/RSpade/Core/Js/Ajax.js b/app/RSpade/Core/Js/Ajax.js index 7e5092516..d7d3c41e5 100755 --- a/app/RSpade/Core/Js/Ajax.js +++ b/app/RSpade/Core/Js/Ajax.js @@ -197,6 +197,14 @@ class Ajax { }); } + // Sync time with server (only on first AJAX or timezone change) + if (response._server_time || response._user_timezone) { + Rsx_Time.sync_from_ajax({ + server_time: response._server_time, + user_timezone: response._user_timezone + }); + } + // Handle flash_alerts from server if (response.flash_alerts && Array.isArray(response.flash_alerts)) { Server_Side_Flash.process(response.flash_alerts); @@ -348,8 +356,16 @@ class Ajax { __local_integration: true, // Bypass $.ajax override }); + // Sync time with server (only on first AJAX or timezone change) + if (response._server_time || response._user_timezone) { + Rsx_Time.sync_from_ajax({ + server_time: response._server_time, + user_timezone: response._user_timezone + }); + } + // Process batch response - // Response format: { C_0: {success, _ajax_return_value}, C_1: {...}, ... } + // Response format: { C_0: {success, _ajax_return_value}, C_1: {...}, ..., _server_time, _user_timezone } for (const response_key in response) { if (!response_key.startsWith('C_')) { continue; diff --git a/app/RSpade/Core/Js/Rsx_Date.js b/app/RSpade/Core/Js/Rsx_Date.js new file mode 100755 index 000000000..df1753cba --- /dev/null +++ b/app/RSpade/Core/Js/Rsx_Date.js @@ -0,0 +1,239 @@ +/** + * Rsx_Date - Date-only handling for RSpade (no time, no timezone) + * + * Dates are calendar dates without time components. They are timezone-agnostic: + * "December 24, 2025" is the same calendar date everywhere in the world. + * + * Format: Always "YYYY-MM-DD" (ISO 8601 date format) + * + * Core Principles: + * - Dates have NO time component and NO timezone + * - All dates stored and transferred as "YYYY-MM-DD" + * - Date functions THROW if passed a datetime (has time component) + * - Use Rsx_Time for moments in time that need timezone handling + * + * See: php artisan rsx:man time + */ +class Rsx_Date { + + /** + * Regex pattern for valid date-only string + * @type {RegExp} + */ + static DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + + // ========================================================================= + // PARSING & VALIDATION + // ========================================================================= + + /** + * Parse input to date string "YYYY-MM-DD" + * THROWS if input is a datetime (has time component) + * + * @param {*} input + * @returns {string|null} Returns "YYYY-MM-DD" or null + * @throws {Error} If input is a datetime + */ + static parse(input) { + if (input == null || input === '') { + return null; + } + + // Already a valid date string + if (typeof input === 'string' && this.DATE_PATTERN.test(input)) { + // Validate it's a real date + if (this._is_valid_date_string(input)) { + return input; + } + return null; + } + + // Reject Date objects - these are datetimes + if (input instanceof Date) { + throw new Error( + "Rsx_Date.parse() received Date object. " + + "Use Rsx_Time.parse() for datetimes with time components." + ); + } + + // Reject timestamps - these are datetimes + if (typeof input === 'number') { + throw new Error( + `Rsx_Date.parse() received numeric timestamp '${input}'. ` + + "Use Rsx_Time.parse() for datetimes with time components." + ); + } + + // Reject datetime strings (contain T or time component) + if (typeof input === 'string') { + if (input.includes('T') || /\d{2}:\d{2}/.test(input)) { + throw new Error( + `Rsx_Date.parse() received datetime string '${input}'. ` + + "Use Rsx_Time.parse() for datetimes with time components." + ); + } + } + + return null; + } + + /** + * Check if input is a valid date-only value (not datetime) + * + * @param {*} input + * @returns {boolean} + */ + static is_date(input) { + if (typeof input !== 'string') { + return false; + } + + if (!this.DATE_PATTERN.test(input)) { + return false; + } + + return this._is_valid_date_string(input); + } + + /** + * Validate that a YYYY-MM-DD string represents a real date + * @private + */ + static _is_valid_date_string(str) { + const [year, month, day] = str.split('-').map(Number); + const date = new Date(year, month - 1, day); + return date.getFullYear() === year && + date.getMonth() === month - 1 && + date.getDate() === day; + } + + // ========================================================================= + // CURRENT DATE + // ========================================================================= + + /** + * Get today's date as "YYYY-MM-DD" + * Uses the user's timezone to determine what "today" is + * + * @returns {string} + */ + static today() { + const now = Rsx_Time.now(); + const tz = Rsx_Time.get_user_timezone(); + + // Use Intl.DateTimeFormat with en-CA locale for YYYY-MM-DD format + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); + + return formatter.format(now); + } + + // ========================================================================= + // FORMATTING + // ========================================================================= + + /** + * Format date for display: "Dec 24, 2025" + * + * @param {*} date + * @returns {string} + */ + static format(date) { + const parsed = this.parse(date); + if (!parsed) { + return ''; + } + + const [year, month, day] = parsed.split('-').map(Number); + const d = new Date(year, month - 1, day); + + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }).format(d); + } + + /** + * Ensure date is in ISO format "YYYY-MM-DD" + * Alias for parse() that always returns string + * + * @param {*} date + * @returns {string} + */ + static format_iso(date) { + return this.parse(date) || ''; + } + + // ========================================================================= + // COMPARISON + // ========================================================================= + + /** + * Check if date is today (in user's timezone) + * + * @param {*} date + * @returns {boolean} + */ + static is_today(date) { + const parsed = this.parse(date); + if (!parsed) { + return false; + } + return parsed === this.today(); + } + + /** + * Check if date is in the past + * + * @param {*} date + * @returns {boolean} + */ + static is_past(date) { + const parsed = this.parse(date); + if (!parsed) { + return false; + } + return parsed < this.today(); + } + + /** + * Check if date is in the future + * + * @param {*} date + * @returns {boolean} + */ + static is_future(date) { + const parsed = this.parse(date); + if (!parsed) { + return false; + } + return parsed > this.today(); + } + + /** + * Calculate days between two dates + * Returns positive if date2 > date1 + * + * @param {*} date1 + * @param {*} date2 + * @returns {number} + */ + static diff_days(date1, date2) { + const d1 = this.parse(date1); + const d2 = this.parse(date2); + + if (!d1 || !d2) { + return 0; + } + + const ms1 = new Date(d1).getTime(); + const ms2 = new Date(d2).getTime(); + + return Math.round((ms2 - ms1) / (1000 * 60 * 60 * 24)); + } +} diff --git a/app/RSpade/Core/Js/Rsx_Time.js b/app/RSpade/Core/Js/Rsx_Time.js new file mode 100755 index 000000000..12c7630fc --- /dev/null +++ b/app/RSpade/Core/Js/Rsx_Time.js @@ -0,0 +1,612 @@ +/** + * Rsx_Time - Datetime handling for RSpade (JavaScript) + * + * Datetimes represent specific moments in time. They always have a time component + * and are timezone-aware. Stored in UTC, displayed in user's timezone. + * + * All times stored internally as Date objects (UTC). + * Uses native Date API and Intl.DateTimeFormat - no external libraries required. + * + * Core Principles: + * - All datetimes stored in database as UTC + * - All serialization uses ISO 8601 format + * - User timezone stored per user + * - Formatting happens on-demand + * - PHP and JS APIs are parallel (same method names) + * - Datetime functions THROW if passed a date-only string + * - Use Rsx_Date for calendar dates without time components + * + * See: php artisan rsx:man time + */ +class Rsx_Time { + + // ========================================================================= + // CONFIGURATION (set by framework on page load) + // ========================================================================= + + /** + * User's preferred timezone (IANA identifier) + * @type {string} + */ + static _user_timezone = 'America/Chicago'; + + /** + * Milliseconds offset to adjust for server/client clock difference + * @type {number} + */ + static _server_time_offset = 0; + + /** + * Whether server time has been synced via AJAX + * Server time is only synced on first AJAX response or when timezone changes + * @type {boolean} + */ + static _ajax_synced = false; + + /** + * Framework initialization hook + * Reads initial time configuration from window.rsxapp on page load + */ + static _on_framework_core_init() { + if (window.rsxapp) { + // Set timezone from rsxapp (always set, may be logged-in user's or default) + if (window.rsxapp.user_timezone) { + this._user_timezone = window.rsxapp.user_timezone; + } + + // Calculate initial server time offset from page load time + if (window.rsxapp.server_time) { + const server_ms = this.parse(window.rsxapp.server_time).getTime(); + const client_ms = Date.now(); + this._server_time_offset = server_ms - client_ms; + } + } + } + + /** + * Sync from AJAX response data + * Only updates server time offset on first AJAX call or when timezone changes + * Called by Ajax.js after receiving responses + * + * @param {Object} config + * @param {string} [config.user_timezone] - User's IANA timezone + * @param {string} [config.server_time] - Server's current time (ISO 8601) + */ + static sync_from_ajax(config) { + if (!config) return; + + const timezone_changed = config.user_timezone && config.user_timezone !== this._user_timezone; + + // Always update timezone if provided + if (config.user_timezone) { + this._user_timezone = config.user_timezone; + } + + // Only sync server time on first AJAX response or timezone change + if (config.server_time && (!this._ajax_synced || timezone_changed)) { + const server_ms = this.parse(config.server_time).getTime(); + const client_ms = Date.now(); + this._server_time_offset = server_ms - client_ms; + this._ajax_synced = true; + } + } + + // ========================================================================= + // CURRENT TIME + // ========================================================================= + + /** + * Get current time as Date (adjusted for server clock) + * + * @returns {Date} + */ + static now() { + return new Date(Date.now() + this._server_time_offset); + } + + /** + * Get current time as ISO 8601 UTC string + * + * @returns {string} + */ + static now_iso() { + return this.to_iso(this.now()); + } + + /** + * Get current time as Unix milliseconds + * + * @returns {number} + */ + static now_ms() { + return this.now().getTime(); + } + + // ========================================================================= + // PARSING & VALIDATION + // ========================================================================= + + /** + * Regex pattern for date-only strings + * @type {RegExp} + */ + static DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + + /** + * Check if input is a valid datetime (not a date-only value) + * + * @param {*} input + * @returns {boolean} + */ + static is_datetime(input) { + if (input instanceof Date) { + return true; + } + + if (typeof input === 'number') { + return true; // Timestamps are datetimes + } + + if (typeof input === 'string') { + // Date-only strings are NOT datetimes + if (this.DATE_ONLY_PATTERN.test(input)) { + return false; + } + + // Has time component (T separator or HH:MM pattern) + if (input.includes('T') || /\d{2}:\d{2}/.test(input)) { + return true; + } + } + + return false; + } + + /** + * Parse any reasonable datetime input to Date object + * THROWS if passed a date-only string - use Rsx_Date for dates + * + * Accepts: + * - Date instance (returned as copy) + * - ISO 8601 string + * - Unix timestamp (ms or seconds - auto-detected) + * - null/undefined (returns null) + * + * @param {*} input + * @returns {Date|null} + * @throws {Error} If input is a date-only string + */ + static parse(input) { + if (input == null || input === '') { + return null; + } + + // REJECT date-only strings - these should use Rsx_Date + if (typeof input === 'string' && this.DATE_ONLY_PATTERN.test(input)) { + throw new Error( + `Rsx_Time.parse() received date-only string '${input}'. ` + + "Use Rsx_Date.parse() for dates without time components." + ); + } + + if (input instanceof Date) { + return new Date(input.getTime()); + } + + if (typeof input === 'number') { + // Detect milliseconds vs seconds (after year 2001, ms > 10 digits) + if (input > 10000000000) { + return new Date(input); + } + return new Date(input * 1000); + } + + if (typeof input === 'string') { + // ISO 8601 or parseable string + const date = new Date(input); + if (!isNaN(date.getTime())) { + return date; + } + } + + return null; + } + + // ========================================================================= + // TIMEZONE HANDLING + // ========================================================================= + + /** + * Get current user's timezone + * + * @returns {string} IANA timezone identifier + */ + static get_user_timezone() { + return this._user_timezone; + } + + /** + * Format time in a specific timezone + * Uses Intl.DateTimeFormat for proper timezone conversion + * + * @param {*} time - Parseable time input + * @param {Object} format_options - Intl.DateTimeFormat options + * @param {string} [timezone] - IANA timezone (defaults to user's) + * @returns {string} + */ + static format_in_timezone(time, format_options, timezone) { + const date = this.parse(time); + if (!date) return ''; + + const options = { + ...format_options, + timeZone: timezone || this._user_timezone + }; + + return new Intl.DateTimeFormat('en-US', options).format(date); + } + + /** + * Get timezone abbreviation for a time (e.g., "CST", "CDT") + * Handles DST correctly based on the actual date + * + * @param {*} time + * @param {string} [timezone] + * @returns {string} + */ + static get_timezone_abbr(time, timezone) { + const date = this.parse(time); + if (!date) return ''; + + const tz = timezone || this._user_timezone; + const options = { timeZone: tz, timeZoneName: 'short' }; + const parts = new Intl.DateTimeFormat('en-US', options).formatToParts(date); + const tz_part = parts.find(p => p.type === 'timeZoneName'); + return tz_part ? tz_part.value : ''; + } + + // ========================================================================= + // SERIALIZATION + // ========================================================================= + + /** + * Convert to ISO 8601 UTC string + * + * @param {*} time + * @returns {string|null} + */ + static to_iso(time) { + const date = this.parse(time); + if (!date) return null; + return date.toISOString(); + } + + /** + * Convert to Unix milliseconds + * + * @param {*} time + * @returns {number|null} + */ + static to_ms(time) { + const date = this.parse(time); + if (!date) return null; + return date.getTime(); + } + + // ========================================================================= + // DURATION HANDLING + // ========================================================================= + + /** + * Calculate seconds between two times + * + * @param {*} start + * @param {*} end + * @returns {number} Seconds (negative if end < start) + */ + static diff_seconds(start, end) { + const start_date = this.parse(start); + const end_date = this.parse(end); + if (!start_date || !end_date) return 0; + return Math.floor((end_date.getTime() - start_date.getTime()) / 1000); + } + + /** + * Seconds until a future time (negative if past) + * + * @param {*} time + * @returns {number} + */ + static seconds_until(time) { + return this.diff_seconds(this.now(), time); + } + + /** + * Seconds since a past time (negative if future) + * + * @param {*} time + * @returns {number} + */ + static seconds_since(time) { + return this.diff_seconds(time, this.now()); + } + + /** + * Format duration as human-readable string + * + * @param {number} seconds + * @param {boolean} [short=false] - Use short format ("2h 30m") vs long + * @returns {string} + */ + static duration_to_human(seconds, short = false) { + const negative = seconds < 0; + seconds = Math.abs(Math.floor(seconds)); + + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + const parts = []; + + if (short) { + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (secs > 0 && parts.length === 0) parts.push(`${secs}s`); + const result = parts.join(' ') || '0s'; + return negative ? '-' + result : result; + } else { + if (days > 0) parts.push(days + ' ' + (days === 1 ? 'day' : 'days')); + if (hours > 0) parts.push(hours + ' ' + (hours === 1 ? 'hour' : 'hours')); + if (minutes > 0) parts.push(minutes + ' ' + (minutes === 1 ? 'minute' : 'minutes')); + if (secs > 0 && parts.length === 0) parts.push(secs + ' ' + (secs === 1 ? 'second' : 'seconds')); + + let result; + if (parts.length > 1) { + const last = parts.pop(); + result = parts.join(', ') + ' and ' + last; + } else { + result = parts[0] || '0 seconds'; + } + return negative ? '-' + result : result; + } + } + + /** + * Format as relative time ("2 hours ago", "in 3 days") + * + * @param {*} time + * @returns {string} + */ + static relative(time) { + const seconds = this.seconds_since(time); + const abs_seconds = Math.abs(seconds); + const is_past = seconds >= 0; + + let value, unit; + + if (abs_seconds < 60) { + return is_past ? 'just now' : 'in a moment'; + } else if (abs_seconds < 3600) { + value = Math.floor(abs_seconds / 60); + unit = value === 1 ? 'minute' : 'minutes'; + } else if (abs_seconds < 86400) { + value = Math.floor(abs_seconds / 3600); + unit = value === 1 ? 'hour' : 'hours'; + } else if (abs_seconds < 604800) { + value = Math.floor(abs_seconds / 86400); + unit = value === 1 ? 'day' : 'days'; + } else if (abs_seconds < 2592000) { + value = Math.floor(abs_seconds / 604800); + unit = value === 1 ? 'week' : 'weeks'; + } else if (abs_seconds < 31536000) { + value = Math.floor(abs_seconds / 2592000); + unit = value === 1 ? 'month' : 'months'; + } else { + value = Math.floor(abs_seconds / 31536000); + unit = value === 1 ? 'year' : 'years'; + } + + return is_past ? `${value} ${unit} ago` : `in ${value} ${unit}`; + } + + // ========================================================================= + // ARITHMETIC + // ========================================================================= + + /** + * Add seconds to time + * + * @param {*} time + * @param {number} seconds + * @returns {Date} + */ + static add(time, seconds) { + const date = this.parse(time); + if (!date) throw new Error('Cannot parse time'); + return new Date(date.getTime() + seconds * 1000); + } + + /** + * Subtract seconds from time + * + * @param {*} time + * @param {number} seconds + * @returns {Date} + */ + static subtract(time, seconds) { + return this.add(time, -seconds); + } + + // ========================================================================= + // COMPARISON + // ========================================================================= + + /** + * Check if time is in the past + * + * @param {*} time + * @returns {boolean} + */ + static is_past(time) { + const date = this.parse(time); + if (!date) return false; + return date.getTime() < this.now().getTime(); + } + + /** + * Check if time is in the future + * + * @param {*} time + * @returns {boolean} + */ + static is_future(time) { + const date = this.parse(time); + if (!date) return false; + return date.getTime() > this.now().getTime(); + } + + /** + * Check if time is today (in user's timezone) + * + * @param {*} time + * @returns {boolean} + */ + static is_today(time) { + const date = this.parse(time); + if (!date) return false; + return this.format_date(date) === this.format_date(this.now()); + } + + // ========================================================================= + // FORMATTING + // ========================================================================= + + /** + * Format as date: "Dec 24, 2024" + * + * @param {*} time + * @param {string} [timezone] + * @returns {string} + */ + static format_date(time, timezone) { + return this.format_in_timezone(time, { + month: 'short', + day: 'numeric', + year: 'numeric' + }, timezone); + } + + /** + * Format as time: "3:30 PM" + * + * @param {*} time + * @param {string} [timezone] + * @returns {string} + */ + static format_time(time, timezone) { + return this.format_in_timezone(time, { + hour: 'numeric', + minute: '2-digit', + hour12: true + }, timezone); + } + + /** + * Format as datetime: "Dec 24, 2024, 3:30 PM" + * + * @param {*} time + * @param {string} [timezone] + * @returns {string} + */ + static format_datetime(time, timezone) { + return this.format_in_timezone(time, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }, timezone); + } + + /** + * Format as datetime with timezone: "Dec 24, 2024, 3:30 PM CST" + * + * @param {*} time + * @param {string} [timezone] + * @returns {string} + */ + static format_datetime_with_tz(time, timezone) { + const formatted = this.format_datetime(time, timezone); + const abbr = this.get_timezone_abbr(time, timezone); + return `${formatted} ${abbr}`; + } + + // ========================================================================= + // LIVE UPDATES (Countdown/Countup) + // ========================================================================= + + /** + * Create a live countdown display + * Updates every second until target time is reached + * + * @param {HTMLElement|jQuery} element - Target element to update + * @param {*} target_time - Time to count down to + * @param {Object} [options] + * @param {boolean} [options.short=false] - Use short format + * @param {Function} [options.on_complete] - Callback when countdown reaches zero + * @returns {{stop: Function}} Control object with stop method + */ + static countdown(element, target_time, options = {}) { + const $el = $(element); + const short = options.short ?? false; + + const update = () => { + const seconds = this.seconds_until(target_time); + if (seconds <= 0) { + $el.text(short ? '0s' : '0 seconds'); + if (options.on_complete) { + options.on_complete(); + } + return; + } + $el.text(this.duration_to_human(seconds, short)); + }; + + update(); + const interval = setInterval(update, 1000); + + return { + stop: () => clearInterval(interval) + }; + } + + /** + * Create a live countup display + * Updates every second showing elapsed time since start + * + * @param {HTMLElement|jQuery} element - Target element to update + * @param {*} start_time - Time to count up from + * @param {Object} [options] + * @param {boolean} [options.short=false] - Use short format + * @returns {{stop: Function}} Control object with stop method + */ + static countup(element, start_time, options = {}) { + const $el = $(element); + const short = options.short ?? false; + + const update = () => { + const seconds = this.seconds_since(start_time); + $el.text(this.duration_to_human(Math.max(0, seconds), short)); + }; + + update(); + const interval = setInterval(update, 1000); + + return { + stop: () => clearInterval(interval) + }; + } +} diff --git a/app/RSpade/Core/Schedule_Field_Helper.php b/app/RSpade/Core/Schedule_Field_Helper.php new file mode 100755 index 000000000..ff33d16ed --- /dev/null +++ b/app/RSpade/Core/Schedule_Field_Helper.php @@ -0,0 +1,360 @@ +validate(); + * if (!empty($errors)) { + * return response_form_error('Please fix the errors below', $errors); + * } + * + * // Apply to model + * $schedule->apply_to($event, [ + * 'date' => 'event_date', + * 'start_time' => 'start_time', + * 'duration_minutes' => 'duration_minutes', + * 'is_all_day' => 'is_all_day', + * 'timezone' => 'timezone', + * ]); + * + * // Or access values directly + * $event->event_date = $schedule->date; + * $event->start_time = $schedule->start_time; + */ +#[Instantiatable] +class Schedule_Field_Helper +{ + /** + * The date (Y-m-d format string or null) + */ + public ?string $date = null; + + /** + * The start time (H:i format string or null) + */ + public ?string $start_time = null; + + /** + * Duration in minutes + */ + public ?int $duration_minutes = null; + + /** + * Whether this is an all-day event + */ + public bool $is_all_day = false; + + /** + * IANA timezone identifier + */ + public ?string $timezone = null; + + /** + * The field name prefix for error messages + */ + private string $field_name = 'schedule'; + + /** + * Whether any value was provided in the input + */ + private bool $was_provided = false; + + /** + * Parse a JSON-encoded schedule field value + * + * @param string|null $json_value The JSON string from form submission + * @param string $field_name The field name for dot-notation errors (default: 'schedule') + * @return self + */ + public static function parse(?string $json_value, string $field_name = 'schedule'): self + { + $instance = new self(); + $instance->field_name = $field_name; + + if (empty($json_value)) { + return $instance; + } + + $decoded = json_decode($json_value, true); + if (!is_array($decoded)) { + return $instance; + } + + $instance->was_provided = true; + + // Parse date + if (!empty($decoded['date'])) { + $instance->date = $decoded['date']; + } + + // Parse is_all_day first (affects whether time fields are required) + $instance->is_all_day = !empty($decoded['is_all_day']); + + // Parse start_time (only if not all-day) + if (!$instance->is_all_day && !empty($decoded['start_time'])) { + $instance->start_time = $decoded['start_time']; + } + + // Parse duration + if (!$instance->is_all_day && isset($decoded['duration_minutes'])) { + $instance->duration_minutes = (int) $decoded['duration_minutes']; + } + + // Parse timezone + if (!empty($decoded['timezone'])) { + $instance->timezone = $decoded['timezone']; + } else { + // Default to config timezone + $instance->timezone = config('rsx.datetime.default_timezone', 'America/Chicago'); + } + + return $instance; + } + + /** + * Validate the schedule and return dot-notation errors + * + * @param bool $date_required Whether the date field is required + * @param bool $time_required Whether time fields are required (when not all-day) + * @return array Associative array of field.subfield => error message + */ + public function validate(bool $date_required = true, bool $time_required = false): array + { + $errors = []; + + // Date is typically required + if ($date_required && empty($this->date)) { + $errors[$this->field_name . '.date'] = 'Date is required'; + } + + // Validate date format if provided + if (!empty($this->date)) { + try { + Carbon::createFromFormat('Y-m-d', $this->date); + } catch (\Exception $e) { + $errors[$this->field_name . '.date'] = 'Invalid date format'; + } + } + + // Time validation only applies when not all-day + if (!$this->is_all_day) { + // Start time required if time_required is set + if ($time_required && empty($this->start_time)) { + $errors[$this->field_name . '.start_time'] = 'Start time is required'; + } + + // Validate start_time format if provided + if (!empty($this->start_time)) { + if (!preg_match('/^\d{2}:\d{2}$/', $this->start_time)) { + $errors[$this->field_name . '.start_time'] = 'Invalid time format'; + } + } + + // Duration validation + if ($this->duration_minutes !== null && $this->duration_minutes < 0) { + $errors[$this->field_name . '.duration_minutes'] = 'Duration cannot be negative'; + } + } + + // Validate timezone if provided + if (!empty($this->timezone)) { + try { + new \DateTimeZone($this->timezone); + } catch (\Exception $e) { + $errors[$this->field_name . '.timezone'] = 'Invalid timezone'; + } + } + + return $errors; + } + + /** + * Apply schedule values to a model + * + * @param object $model The model to update + * @param array $field_map Map of schedule fields to model attributes + * e.g., ['date' => 'event_date', 'start_time' => 'start_time', ...] + * @return void + */ + public function apply_to(object $model, array $field_map): void + { + foreach ($field_map as $schedule_field => $model_field) { + switch ($schedule_field) { + case 'date': + $model->$model_field = $this->date; + break; + + case 'start_time': + // Set to null if all-day, otherwise use the value + $model->$model_field = $this->is_all_day ? null : $this->start_time; + break; + + case 'duration_minutes': + $model->$model_field = $this->is_all_day ? null : $this->duration_minutes; + break; + + case 'is_all_day': + $model->$model_field = $this->is_all_day; + break; + + case 'timezone': + $model->$model_field = $this->timezone; + break; + } + } + } + + /** + * Get a Carbon instance for the date + * + * @return Carbon|null + */ + public function get_date_carbon(): ?Carbon + { + if (empty($this->date)) { + return null; + } + + try { + return Carbon::createFromFormat('Y-m-d', $this->date, $this->timezone); + } catch (\Exception $e) { + return null; + } + } + + /** + * Get a Carbon instance for the start datetime + * + * @return Carbon|null + */ + public function get_start_datetime(): ?Carbon + { + if (empty($this->date)) { + return null; + } + + try { + if ($this->is_all_day || empty($this->start_time)) { + return Carbon::createFromFormat('Y-m-d', $this->date, $this->timezone)->startOfDay(); + } + + return Carbon::createFromFormat('Y-m-d H:i', $this->date . ' ' . $this->start_time, $this->timezone); + } catch (\Exception $e) { + return null; + } + } + + /** + * Get a Carbon instance for the end datetime (calculated from start + duration) + * + * @return Carbon|null + */ + public function get_end_datetime(): ?Carbon + { + $start = $this->get_start_datetime(); + if (!$start) { + return null; + } + + if ($this->is_all_day) { + return $start->copy()->endOfDay(); + } + + if ($this->duration_minutes) { + return $start->copy()->addMinutes($this->duration_minutes); + } + + return null; + } + + /** + * Check if no schedule data was provided + */ + public function is_empty(): bool + { + return !$this->was_provided || empty($this->date); + } + + /** + * Create a schedule helper from model data for form population + * + * @param object $model The model to read from + * @param array $field_map Map of schedule fields to model attributes + * @return array Data suitable for Schedule_Input component + */ + public static function from_model(object $model, array $field_map): array + { + $data = []; + + foreach ($field_map as $schedule_field => $model_field) { + $value = $model->$model_field ?? null; + + switch ($schedule_field) { + case 'date': + // Handle Carbon/DateTime objects or strings + if ($value instanceof \DateTimeInterface) { + $data['date'] = $value->format('Y-m-d'); + } elseif (is_string($value)) { + // Try to parse and reformat + try { + $data['date'] = Carbon::parse($value)->format('Y-m-d'); + } catch (\Exception $e) { + $data['date'] = $value; + } + } + break; + + case 'start_time': + if ($value instanceof \DateTimeInterface) { + $data['start_time'] = $value->format('H:i'); + } elseif (is_string($value) && !empty($value)) { + // Try to parse and reformat + try { + $data['start_time'] = Carbon::parse($value)->format('H:i'); + } catch (\Exception $e) { + $data['start_time'] = $value; + } + } + break; + + case 'duration_minutes': + $data['duration_minutes'] = $value ? (int) $value : null; + break; + + case 'is_all_day': + $data['is_all_day'] = (bool) $value; + break; + + case 'timezone': + $data['timezone'] = $value ?: config('rsx.datetime.default_timezone', 'America/Chicago'); + break; + } + } + + return $data; + } +} diff --git a/app/RSpade/Core/Time/Rsx_Date.php b/app/RSpade/Core/Time/Rsx_Date.php new file mode 100755 index 000000000..00a63f521 --- /dev/null +++ b/app/RSpade/Core/Time/Rsx_Date.php @@ -0,0 +1,245 @@ +format('Y-m-d'); + } + + // ========================================================================= + // FORMATTING + // ========================================================================= + + /** + * Format date for display: "Dec 24, 2025" + * + * @param mixed $date + * @return string + */ + public static function format($date): string + { + $parsed = static::parse($date); + if (!$parsed) { + return ''; + } + + $carbon = Carbon::createFromFormat('Y-m-d', $parsed); + return $carbon->format('M j, Y'); + } + + /** + * Ensure date is in ISO format "YYYY-MM-DD" + * Alias for parse() that always returns string + * + * @param mixed $date + * @return string + */ + public static function format_iso($date): string + { + return static::parse($date) ?? ''; + } + + // ========================================================================= + // COMPARISON + // ========================================================================= + + /** + * Check if date is today (in user's timezone) + * + * @param mixed $date + * @return bool + */ + public static function is_today($date): bool + { + $parsed = static::parse($date); + if (!$parsed) { + return false; + } + return $parsed === static::today(); + } + + /** + * Check if date is in the past + * + * @param mixed $date + * @return bool + */ + public static function is_past($date): bool + { + $parsed = static::parse($date); + if (!$parsed) { + return false; + } + return $parsed < static::today(); + } + + /** + * Check if date is in the future + * + * @param mixed $date + * @return bool + */ + public static function is_future($date): bool + { + $parsed = static::parse($date); + if (!$parsed) { + return false; + } + return $parsed > static::today(); + } + + /** + * Calculate days between two dates + * Returns positive if date2 > date1 + * + * @param mixed $date1 + * @param mixed $date2 + * @return int + */ + public static function diff_days($date1, $date2): int + { + $d1 = static::parse($date1); + $d2 = static::parse($date2); + + if (!$d1 || !$d2) { + return 0; + } + + $carbon1 = Carbon::createFromFormat('Y-m-d', $d1)->startOfDay(); + $carbon2 = Carbon::createFromFormat('Y-m-d', $d2)->startOfDay(); + + return $carbon1->diffInDays($carbon2, false); + } + + // ========================================================================= + // DATABASE + // ========================================================================= + + /** + * Format for database storage + * Same as ISO format: "YYYY-MM-DD" + * + * @param mixed $date + * @return string|null + */ + public static function to_database($date): ?string + { + return static::parse($date); + } +} diff --git a/app/RSpade/Core/Time/Rsx_Time.php b/app/RSpade/Core/Time/Rsx_Time.php new file mode 100755 index 000000000..6040d4cba --- /dev/null +++ b/app/RSpade/Core/Time/Rsx_Time.php @@ -0,0 +1,537 @@ +format('Y-m-d\TH:i:s.v\Z'); + } + + /** + * Get current time as Unix timestamp (milliseconds) + * + * @return int + */ + public static function now_ms(): int + { + return (int) (microtime(true) * 1000); + } + + // ========================================================================= + // PARSING & VALIDATION + // ========================================================================= + + /** + * Check if input is a valid datetime (not a date-only value) + * + * @param mixed $input + * @return bool + */ + public static function is_datetime($input): bool + { + if ($input instanceof Carbon || $input instanceof \DateTimeInterface) { + return true; + } + + if (is_numeric($input)) { + return true; // Timestamps are datetimes + } + + if (is_string($input)) { + // Date-only strings are NOT datetimes + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $input)) { + return false; + } + + // Has time component (T separator or HH:MM pattern) + if (str_contains($input, 'T') || preg_match('/\d{2}:\d{2}/', $input)) { + return true; + } + } + + return false; + } + + /** + * Parse any reasonable datetime input to Carbon (UTC) + * THROWS if passed a date-only string - use Rsx_Date for dates + * + * Accepts: + * - Carbon instance (returned as copy, converted to UTC) + * - ISO 8601 string: "2024-12-24T15:30:45Z" or "2024-12-24T15:30:45.123Z" + * - Database format: "2024-12-24 15:30:45" (assumed UTC unless source_timezone specified) + * - Unix timestamp (seconds or milliseconds - auto-detected) + * - null (returns null) + * + * @param mixed $input + * @param string|null $source_timezone If input has no timezone indicator, assume this (default: UTC) + * @return Carbon|null + * @throws \InvalidArgumentException If input is a date-only string + */ + public static function parse($input, ?string $source_timezone = 'UTC'): ?Carbon + { + if ($input === null || $input === '') { + return null; + } + + // REJECT date-only strings - these should use Rsx_Date + if (is_string($input) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $input)) { + throw new \InvalidArgumentException( + "Rsx_Time::parse() received date-only string '{$input}'. " . + "Use Rsx_Date::parse() for dates without time components." + ); + } + + if ($input instanceof Carbon) { + return $input->copy()->setTimezone('UTC'); + } + + if ($input instanceof \DateTimeInterface) { + return Carbon::instance($input)->setTimezone('UTC'); + } + + if (is_numeric($input)) { + // Detect milliseconds vs seconds (after year 2001, ms > 10 digits) + if ($input > 10000000000) { + return Carbon::createFromTimestampMs((int) $input, 'UTC'); + } + return Carbon::createFromTimestamp((int) $input, 'UTC'); + } + + if (is_string($input)) { + // ISO 8601 with timezone indicator - parse directly + if (preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $input)) { + return Carbon::parse($input)->setTimezone('UTC'); + } + + // Database format (no timezone indicator) - use source_timezone + if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', $input)) { + // Handle optional milliseconds + $format = strlen($input) > 19 ? 'Y-m-d H:i:s.v' : 'Y-m-d H:i:s'; + return Carbon::createFromFormat( + $format, + $input, + $source_timezone + )->setTimezone('UTC'); + } + + // Unrecognized format - this is a bug in calling code + throw new \InvalidArgumentException( + "Rsx_Time::parse() received unrecognized datetime format: '{$input}'. " . + "Supported formats: ISO 8601 (2024-12-24T15:30:45Z) or database (2024-12-24 15:30:45)." + ); + } + + return null; + } + + // ========================================================================= + // TIMEZONE CONVERSION + // ========================================================================= + + /** + * Convert time to a specific timezone + * + * @param mixed $time Parseable time input + * @param string $timezone IANA timezone (e.g., "America/Chicago") + * @return Carbon + * @throws \InvalidArgumentException If time cannot be parsed + */ + public static function to_timezone($time, string $timezone): Carbon + { + $carbon = static::parse($time); + if (!$carbon) { + throw new \InvalidArgumentException("Cannot parse time: " . print_r($time, true)); + } + return $carbon->setTimezone($timezone); + } + + /** + * Convert time to current user's timezone + * Falls back to site default, then system default + * + * @param mixed $time + * @return Carbon + */ + public static function to_user_timezone($time): Carbon + { + return static::to_timezone($time, static::get_user_timezone()); + } + + /** + * Get the current user's timezone + * Resolution: user setting → site default → config default → America/Chicago + * + * @return string IANA timezone identifier + */ + public static function get_user_timezone(): string + { + // Check logged-in user's preference + $login_user = Session::get_login_user(); + if ($login_user && !empty($login_user->timezone)) { + return $login_user->timezone; + } + + // Check site default (future enhancement) + // $site = Session::get_site(); + // if ($site && !empty($site->timezone)) { + // return $site->timezone; + // } + + // Config default + return config('rsx.datetime.default_timezone', 'America/Chicago'); + } + + /** + * Get timezone abbreviation for a time (e.g., "CST", "CDT") + * Handles DST correctly based on the actual date + * + * @param mixed $time + * @param string|null $timezone If null, uses user's timezone + * @return string + */ + public static function get_timezone_abbr($time, ?string $timezone = null): string + { + $tz = $timezone ?? static::get_user_timezone(); + try { + $carbon = static::to_timezone($time, $tz); + return $carbon->format('T'); + } catch (\Exception $e) { + return ''; + } + } + + // ========================================================================= + // SERIALIZATION (for JSON/API responses) + // ========================================================================= + + /** + * Serialize time to ISO 8601 UTC string for JSON + * Format: "2024-12-24T15:30:45.123Z" + * + * @param mixed $time + * @return string|null + */ + public static function to_iso($time): ?string + { + $carbon = static::parse($time); + if (!$carbon) { + return null; + } + return $carbon->format('Y-m-d\TH:i:s.v\Z'); + } + + /** + * Serialize time to Unix milliseconds for JavaScript + * + * @param mixed $time + * @return int|null + */ + public static function to_ms($time): ?int + { + $carbon = static::parse($time); + if (!$carbon) { + return null; + } + return (int) ($carbon->timestamp * 1000 + (int) ($carbon->micro / 1000)); + } + + // ========================================================================= + // DURATION HANDLING + // ========================================================================= + + /** + * Calculate duration between two times in seconds + * + * @param mixed $start + * @param mixed $end + * @return int Seconds (can be negative if end < start) + */ + public static function diff_seconds($start, $end): int + { + $start_carbon = static::parse($start); + $end_carbon = static::parse($end); + + if (!$start_carbon || !$end_carbon) { + return 0; + } + + return $end_carbon->diffInSeconds($start_carbon, false); + } + + /** + * Format duration as human-readable string + * + * @param int $seconds + * @param bool $short Use short format ("2h 30m") vs long ("2 hours and 30 minutes") + * @return string + */ + public static function duration_to_human(int $seconds, bool $short = false): string + { + $negative = $seconds < 0; + $seconds = abs($seconds); + + $days = (int) floor($seconds / 86400); + $hours = (int) floor(($seconds % 86400) / 3600); + $minutes = (int) floor(($seconds % 3600) / 60); + $secs = $seconds % 60; + + $parts = []; + + if ($short) { + if ($days > 0) $parts[] = "{$days}d"; + if ($hours > 0) $parts[] = "{$hours}h"; + if ($minutes > 0) $parts[] = "{$minutes}m"; + if ($secs > 0 && empty($parts)) $parts[] = "{$secs}s"; + $result = implode(' ', $parts) ?: '0s'; + } else { + if ($days > 0) $parts[] = $days . ' ' . ($days === 1 ? 'day' : 'days'); + if ($hours > 0) $parts[] = $hours . ' ' . ($hours === 1 ? 'hour' : 'hours'); + if ($minutes > 0) $parts[] = $minutes . ' ' . ($minutes === 1 ? 'minute' : 'minutes'); + if ($secs > 0 && empty($parts)) $parts[] = $secs . ' ' . ($secs === 1 ? 'second' : 'seconds'); + + if (count($parts) > 1) { + $last = array_pop($parts); + $result = implode(', ', $parts) . ' and ' . $last; + } else { + $result = $parts[0] ?? '0 seconds'; + } + } + + return $negative ? '-' . $result : $result; + } + + /** + * Format relative time ("2 hours ago", "in 3 days") + * + * @param mixed $time + * @return string + */ + public static function relative($time): string + { + $carbon = static::parse($time); + if (!$carbon) { + return ''; + } + return $carbon->diffForHumans(); + } + + // ========================================================================= + // ARITHMETIC + // ========================================================================= + + /** + * Add duration to time + * + * @param mixed $time + * @param int $seconds + * @return Carbon + */ + public static function add($time, int $seconds): Carbon + { + $carbon = static::parse($time); + if (!$carbon) { + throw new \InvalidArgumentException("Cannot parse time"); + } + return $carbon->addSeconds($seconds); + } + + /** + * Subtract duration from time + * + * @param mixed $time + * @param int $seconds + * @return Carbon + */ + public static function subtract($time, int $seconds): Carbon + { + return static::add($time, -$seconds); + } + + // ========================================================================= + // COMPARISON + // ========================================================================= + + /** + * Check if time is in the past + * + * @param mixed $time + * @return bool + */ + public static function is_past($time): bool + { + $carbon = static::parse($time); + if (!$carbon) { + return false; + } + return $carbon->isPast(); + } + + /** + * Check if time is in the future + * + * @param mixed $time + * @return bool + */ + public static function is_future($time): bool + { + $carbon = static::parse($time); + if (!$carbon) { + return false; + } + return $carbon->isFuture(); + } + + /** + * Check if time is today (in user's timezone) + * + * @param mixed $time + * @return bool + */ + public static function is_today($time): bool + { + $carbon = static::parse($time); + if (!$carbon) { + return false; + } + return static::to_user_timezone($carbon)->isToday(); + } + + // ========================================================================= + // FORMATTING (PHP-side - prefer client-side formatting when possible) + // ========================================================================= + + /** + * Format time using pattern + * + * @param mixed $time + * @param string $format PHP date() format string + * @param string|null $timezone If null, uses user's timezone + * @return string + */ + public static function format($time, string $format, ?string $timezone = null): string + { + $tz = $timezone ?? static::get_user_timezone(); + try { + $carbon = static::to_timezone($time, $tz); + return $carbon->format($format); + } catch (\Exception $e) { + return ''; + } + } + + /** + * Format as date: "Dec 24, 2024" + * + * @param mixed $time + * @param string|null $timezone + * @return string + */ + public static function format_date($time, ?string $timezone = null): string + { + return static::format($time, 'M j, Y', $timezone); + } + + /** + * Format as time: "3:30 PM" + * + * @param mixed $time + * @param string|null $timezone + * @return string + */ + public static function format_time($time, ?string $timezone = null): string + { + return static::format($time, 'g:i A', $timezone); + } + + /** + * Format as datetime: "Dec 24, 2024 3:30 PM" + * + * @param mixed $time + * @param string|null $timezone + * @return string + */ + public static function format_datetime($time, ?string $timezone = null): string + { + return static::format($time, 'M j, Y g:i A', $timezone); + } + + /** + * Format as datetime with timezone: "Dec 24, 2024 3:30 PM CST" + * + * @param mixed $time + * @param string|null $timezone + * @return string + */ + public static function format_datetime_with_tz($time, ?string $timezone = null): string + { + return static::format($time, 'M j, Y g:i A T', $timezone); + } + + // ========================================================================= + // DATABASE HELPERS + // ========================================================================= + + /** + * Format time for database storage (UTC) + * Returns "2024-12-24 15:30:45.123" format + * + * @param mixed $time + * @return string|null + */ + public static function to_database($time): ?string + { + $carbon = static::parse($time); + if (!$carbon) { + return null; + } + return $carbon->format('Y-m-d H:i:s.v'); + } +} diff --git a/app/RSpade/man/datetime_inputs.txt b/app/RSpade/man/datetime_inputs.txt new file mode 100755 index 000000000..44b7d160a --- /dev/null +++ b/app/RSpade/man/datetime_inputs.txt @@ -0,0 +1,375 @@ +DATETIME_INPUTS(7) RSX Framework Manual DATETIME_INPUTS(7) + +NAME + datetime_inputs - Composite date/time input handling with Schedule_Input + +SYNOPSIS + Client-side (template): + + + + + Server-side: + use App\RSpade\Core\Schedule_Field_Helper; + + $schedule = Schedule_Field_Helper::parse($params['schedule']); + $errors = $schedule->validate(); + + $schedule->apply_to($model, [ + 'date' => 'event_date', + 'start_time' => 'start_time', + 'duration_minutes' => 'duration_minutes', + 'is_all_day' => 'is_all_day', + 'timezone' => 'timezone', + ]); + +DESCRIPTION + RSX provides a standardized pattern for handling date/time form inputs + through composite components. Rather than managing multiple separate + fields (date, time, duration, timezone), a single Schedule_Input + component encapsulates all scheduling logic and submits as one JSON value. + + The Problem + + Traditional date/time handling has several issues: + - Multiple fields to validate independently + - All-day toggle requires hiding/showing time fields + - Timezone handling is often forgotten or inconsistent + - Empty strings submitted for optional time fields cause DB errors + - Time field interdependencies (end must be after start) + + The Solution + + Schedule_Input combines all scheduling fields into one component: + + + + + + Submits as JSON: + { + "date": "2025-12-23", + "start_time": "09:00", + "duration_minutes": 60, + "is_all_day": false, + "timezone": "America/Chicago" + } + + Schedule_Field_Helper parses and validates on the server, with + automatic handling of all-day events (nulls time fields) and + dot-notation error support. + +CONFIGURATION + Framework Configuration + + In system/config/rsx.php or rsx/resource/config/rsx.php: + + 'datetime' => [ + // Default timezone (IANA identifier) + 'default_timezone' => 'America/Chicago', + + // Time dropdown interval in minutes + 'time_interval' => 15, + + // Default duration for new events + 'default_duration' => 60, + ], + + Environment Variables + + RSX_DEFAULT_TIMEZONE=America/Chicago + +SCHEDULE_INPUT COMPONENT + Arguments + + $name Field name for form submission (required when in Form_Field) + $required Whether date is required (default: true) + $show_timezone Show timezone picker (default: true) + $default_duration Default duration in minutes (default: 60) + + Template Usage + + Basic usage: + + + + + Without timezone picker: + + + JavaScript API + + Get/set value: + const schedule = this.sid('schedule_input').val(); + // Returns: {date, start_time, duration_minutes, is_all_day, timezone} + + this.sid('schedule_input').val({ + date: '2025-12-23', + start_time: '14:00', + duration_minutes: 90, + is_all_day: false, + timezone: 'America/New_York', + }); + + Apply validation errors (called automatically by Form_Utils): + schedule_input.apply_errors({ + date: 'Date is required', + start_time: 'Invalid time format', + }); + + Visual Elements + + The component displays: + - Date picker (native HTML date input) + - All-day toggle (hides time fields when checked) + - Start time dropdown (15-minute intervals) + - Duration dropdown (15min to 8hrs) + - Timezone selector (US timezones) + +SCHEDULE_FIELD_HELPER CLASS + Location + + App\RSpade\Core\Schedule_Field_Helper + + Parsing + + $schedule = Schedule_Field_Helper::parse($params['schedule']); + + // With custom field name for error messages + $schedule = Schedule_Field_Helper::parse($params['schedule'], 'event_schedule'); + + Validation + + Returns array of dot-notation errors: + + $errors = $schedule->validate( + date_required: true, + time_required: false + ); + + // Returns: + // ['schedule.date' => 'Date is required', ...] + + Merge with other errors: + + $errors = []; + $errors = array_merge($errors, $schedule->validate()); + + Applying to Model + + Use apply_to() for clean model assignment: + + $schedule->apply_to($event, [ + 'date' => 'event_date', + 'start_time' => 'start_time', + 'duration_minutes' => 'duration_minutes', + 'is_all_day' => 'is_all_day', + 'timezone' => 'timezone', + ]); + + This automatically: + - Sets time fields to null when is_all_day is true + - Maps schedule properties to model columns + + Direct Property Access + + $schedule->date // "2025-12-23" or null + $schedule->start_time // "09:00" or null + $schedule->duration_minutes // 60 or null + $schedule->is_all_day // true/false + $schedule->timezone // "America/Chicago" + + Carbon Helpers + + $schedule->get_date_carbon() // Carbon date or null + $schedule->get_start_datetime() // Carbon datetime or null + $schedule->get_end_datetime() // Carbon datetime (start + duration) + + Loading from Model + + For edit forms, convert model data back to component format: + + $schedule_data = Schedule_Field_Helper::from_model($event, [ + 'date' => 'event_date', + 'start_time' => 'start_time', + 'duration_minutes' => 'duration_minutes', + 'is_all_day' => 'is_all_day', + 'timezone' => 'timezone', + ]); + + // Returns array suitable for Schedule_Input val() + +DOT-NOTATION ERRORS + Schedule_Field_Helper returns errors with dot notation: + + schedule.date -> Error for date field + schedule.start_time -> Error for start time + schedule.duration_minutes -> Error for duration + schedule.timezone -> Error for timezone + + Form_Utils.apply_form_errors() automatically: + 1. Detects dot-notation error keys + 2. Groups by parent field (schedule) + 3. Calls component.apply_errors({subfield: message}) + 4. Component highlights individual sub-fields + + Creating Custom Composite Inputs + + To create your own composite input with dot-notation errors: + + 1. Extend Form_Input_Abstract + 2. Implement val() returning/accepting an object + 3. Include hidden input with JSON value + 4. Implement apply_errors(sub_errors) method + + JavaScript: + apply_errors(errors) { + this.$.find('.is-invalid').removeClass('is-invalid'); + this.$.find('.invalid-feedback').remove(); + + for (const subfield in errors) { + const $input = this.$sid(subfield + '_input'); + if ($input.exists()) { + $input.addClass('is-invalid'); + $('
').text(errors[subfield]) + .insertAfter($input); + } + } + } + +DATABASE SCHEMA + Recommended column types for schedule data: + + event_date DATE NOT NULL + start_time TIME NULL -- NULL when all-day + duration_minutes BIGINT NULL -- NULL when all-day + is_all_day TINYINT(1) NOT NULL DEFAULT 0 + timezone VARCHAR(50) DEFAULT 'America/Chicago' + + Note: Do NOT use empty strings for TIME columns. The Schedule_Field_Helper + automatically converts to NULL when is_all_day is true. + +COMPLETE EXAMPLE + Controller + + use App\RSpade\Core\Schedule_Field_Helper; + + #[Ajax_Endpoint] + public static function save(Request $request, array $params = []) + { + $schedule = Schedule_Field_Helper::parse($params['schedule']); + + $errors = []; + + if (empty($params['title'])) { + $errors['title'] = 'Title is required'; + } + + // Validate schedule (date required, time optional) + $schedule_errors = $schedule->validate( + date_required: true, + time_required: false + ); + $errors = array_merge($errors, $schedule_errors); + + if (!empty($errors)) { + return response_form_error('Please fix errors', $errors); + } + + $event = new Event_Model(); + $event->title = $params['title']; + + $schedule->apply_to($event, [ + 'date' => 'event_date', + 'start_time' => 'start_time', + 'duration_minutes' => 'duration_minutes', + 'is_all_day' => 'is_all_day', + 'timezone' => 'timezone', + ]); + + $event->save(); + + return ['success' => true, 'id' => $event->id]; + } + + Action JavaScript + + on_create() { + this.data.form_data = { + title: '', + schedule: { + date: new Date().toISOString().split('T')[0], + start_time: '', + duration_minutes: 60, + is_all_day: false, + timezone: 'America/Chicago', + }, + }; + } + + async on_load() { + if (!this.data.is_edit) return; + + const event = await Event_Model.fetch(this.args.id); + + this.data.form_data = { + id: event.id, + title: event.title, + schedule: { + date: event.event_date, + start_time: event.start_time, + duration_minutes: event.duration_minutes, + is_all_day: event.is_all_day, + timezone: event.timezone, + }, + }; + } + + on_ready() { + if (this.data.form_data.schedule) { + this.sid('schedule_input').val(this.data.form_data.schedule); + } + } + + Template + + + + + + + + + + + + + +TIMEZONE SUPPORT + Available Timezones + + The default timezone picker includes US timezones: + - America/New_York (Eastern) + - America/Chicago (Central) + - America/Denver (Mountain) + - America/Los_Angeles (Pacific) + - America/Anchorage (Alaska) + - Pacific/Honolulu (Hawaii) + - UTC + + Customizing Timezone Options + + To customize available timezones, override the static method: + + Schedule_Input.get_timezone_options = function() { + return [ + { value: 'Europe/London', label: 'London (GMT)' }, + { value: 'Europe/Paris', label: 'Paris (CET)' }, + // ... + ]; + }; + +SEE ALSO + polymorphic(7), form_conventions(7), ajax_error_handling(7) + +RSX Framework 2025-12-23 DATETIME_INPUTS(7) diff --git a/app/RSpade/man/expect_files.txt b/app/RSpade/man/expect_files.txt new file mode 100755 index 000000000..c4abb9f3e --- /dev/null +++ b/app/RSpade/man/expect_files.txt @@ -0,0 +1,172 @@ +EXPECT FILES - Behavioral Expectation Documentation +=================================================== + +OVERVIEW + +Expect files (.expect) are pseudo-test documents that define behavioral +expectations for code without implementing actual test execution. They serve +as living documentation that will eventually become automated test cases. + +Philosophy: Document expectations incrementally during development. Convert +to executable tests later. The .expect file captures intent; the test runner +executes verification. + +FILE NAMING + +Expect files are named after the file they document with .expect extension: + + Rsx_Time.js → Rsx_Time.js.expect + Rsx_Time.php → Rsx_Time.php.expect + routing.txt → routing.txt.expect + +For man pages, a .expect file tests the concepts described rather than the +file itself. Example: time.txt.expect would verify that the datetime system +behaves as documented. + +FILE LOCATION + +Expect files live alongside the files they document: + + /system/app/RSpade/Core/Time/ + Rsx_Time.php + Rsx_Time.php.expect + Rsx_Date.php + Rsx_Date.php.expect + + /system/app/RSpade/Core/Js/ + Rsx_Time.js + Rsx_Time.js.expect + Rsx_Date.js + Rsx_Date.js.expect + + /system/app/RSpade/man/ + time.txt + time.txt.expect + +FORMAT + +Expect files use a simple, human-readable format designed for eventual +automated parsing: + + EXPECT: + GIVEN: + WHEN: + THEN: + --- + +Each expectation block is separated by three dashes (---). + +EXAMPLE + + EXPECT: UTC storage for timestamps + GIVEN: A datetime value in any timezone + WHEN: Stored to database via Rsx_Time + THEN: Value is stored as UTC + --- + + EXPECT: User timezone conversion on display + GIVEN: UTC timestamp from database + WHEN: Formatted for display via format_datetime() + THEN: Output is in user's configured timezone + --- + + EXPECT: Null handling + GIVEN: A null datetime value + WHEN: Passed to format_datetime() + THEN: Returns empty string without error + --- + +WRITING EXPECTATIONS + +Good expectations are: + +- Atomic: One behavior per block +- Specific: Clear about inputs and outputs +- Testable: Could be converted to executable code +- Independent: No dependencies between blocks + +Avoid: + +- Implementation details (how, not what) +- Vague outcomes ("works correctly") +- Multiple behaviors in one block + +LANGUAGE CONVENTIONS + +Use present tense for behaviors: + THEN: Returns ISO 8601 format + +Use imperative for actions: + WHEN: Call now_iso() with no arguments + +Reference exact method/function names: + WHEN: Rsx_Time::format_datetime($timestamp) + +CATEGORIES + +Optional category prefix groups related expectations: + + ## Input Validation + + EXPECT: Reject non-ISO strings + GIVEN: A malformed date string "not-a-date" + WHEN: Passed to parse() + THEN: Throws exception + --- + + ## Timezone Handling + + EXPECT: Honor user timezone preference + ... + +FUTURE: AUTOMATED TEST RUNNER + +The planned test runner will: + +1. Parse .expect files to extract test definitions +2. Generate executable test stubs in appropriate language (PHP/JS) +3. Map GIVEN/WHEN/THEN to test setup/action/assertion +4. Report coverage: which expectations have passing tests +5. Flag expectations without corresponding tests + +The runner will NOT modify .expect files. They remain human-maintained +documentation. Tests are generated separately. + +Workflow: + 1. Developer writes .expect file during feature development + 2. Test runner audits .expect files periodically + 3. Runner generates test stubs for new expectations + 4. Developer completes test implementations + 5. CI runs tests, reports against .expect coverage + +MAN PAGE EXPECTATIONS + +Man pages document concepts, not just APIs. A man page .expect file tests +that the documented behavior actually works: + + # time.txt.expect - Tests for datetime system as documented + + EXPECT: Server time sync on page load + GIVEN: Fresh page load + WHEN: rsxapp object is available + THEN: window.rsxapp.server_time contains ISO timestamp + --- + + EXPECT: Ajax response time sync + GIVEN: Any successful Ajax request + WHEN: Response is processed + THEN: Rsx_Time._server_offset is updated + --- + +This ensures documentation stays accurate as code evolves. + +DISTRIBUTION + +Expect files are: +- NOT published with bin/publish (development-only) +- NOT shown in rsx:man listings +- Committed to git (they are documentation) + +SEE ALSO + +testing, time, routing diff --git a/app/RSpade/man/rsx_debug.txt b/app/RSpade/man/rsx_debug.txt index 378af8320..38a53d9fd 100755 --- a/app/RSpade/man/rsx_debug.txt +++ b/app/RSpade/man/rsx_debug.txt @@ -178,13 +178,28 @@ JAVASCRIPT EVALUATION behavior. Use `await` for async operations and `await sleep(ms)` to wait for side effects to complete before the DOM snapshot. - Simple examples: - --eval="Rsx.Route('Demo_Controller').url()" - --eval="JSON.stringify(Rsx._routes)" - --eval="typeof jQuery" - --eval="document.title" + GETTING OUTPUT FROM EVAL: - Simulating user interactions: + To see output from your eval code, you must either: + + 1. Use `return` to return a value (shown in "JavaScript Eval Result:"): + --eval="return Rsx_Time.now_iso()" + --eval="return typeof Rsx_Time" + --eval="return JSON.stringify({a: 1, b: 2})" + + 2. Use `console.log()` with --console flag (shown in console output): + --console --eval="console.log('timezone:', Rsx_Time.get_user_timezone())" + --console --eval="console.log('today:', Rsx_Date.today())" + + Without `return` or `console.log()`, the eval result will be undefined. + + INSPECTING VALUES: + --eval="return Rsx_Time.now_iso()" + --eval="return Rsx_Time.get_user_timezone()" + --eval="return Rsx_Date.today()" + --eval="return JSON.stringify(window.rsxapp)" + + SIMULATING USER INTERACTIONS: # Click a pagination button and wait for results --eval="$('.page-link[data-page=\"2\"]').click(); await sleep(2000)" diff --git a/app/RSpade/man/time.txt b/app/RSpade/man/time.txt new file mode 100755 index 000000000..da5a4d327 --- /dev/null +++ b/app/RSpade/man/time.txt @@ -0,0 +1,383 @@ +TIME(7) RSpade Framework Manual TIME(7) + +NAME + Rsx_Time, Rsx_Date - Date and datetime handling for RSpade applications + +SYNOPSIS + PHP Datetime: + use App\RSpade\Core\Time\Rsx_Time; + + $now = Rsx_Time::now(); + $iso = Rsx_Time::to_iso($datetime); + $localized = Rsx_Time::to_user_timezone($datetime); + $formatted = Rsx_Time::format_datetime_with_tz($datetime); + $relative = Rsx_Time::relative($datetime); + + PHP Date: + use App\RSpade\Core\Time\Rsx_Date; + + $today = Rsx_Date::today(); + $formatted = Rsx_Date::format($date); + $is_past = Rsx_Date::is_past($date); + + JavaScript Datetime: + const now = Rsx_Time.now(); + const iso = Rsx_Time.to_iso(datetime); + const formatted = Rsx_Time.format_datetime_with_tz(datetime); + const relative = Rsx_Time.relative(datetime); + + JavaScript Date: + const today = Rsx_Date.today(); + const formatted = Rsx_Date.format(date); + const is_past = Rsx_Date.is_past(date); + +DESCRIPTION + RSpade provides two separate classes for handling temporal values: + + Rsx_Time - Datetimes (moments in time) + Represents specific moments. Always has time component. Timezone-aware. + Stored in UTC, displayed in user's timezone. + Format: ISO 8601 "2024-12-24T15:30:45.123Z" + + Rsx_Date - Dates (calendar dates) + Represents calendar dates without time. Timezone-agnostic. + "December 24, 2025" is the same day everywhere. + Format: Always "YYYY-MM-DD" (e.g., "2024-12-24") + + CRITICAL: Type Separation + Date functions THROW if passed a datetime. + Datetime functions THROW if passed a date-only string. + + This is intentional. Mixing dates and datetimes causes bugs: + - "2024-12-24" as datetime would become midnight UTC, wrong in other timezones + - "2024-12-24T00:00:00Z" as date loses the time information + + Examples of errors: + Rsx_Time::parse('2024-12-24') + // THROWS: "Use Rsx_Date::parse() for dates without time components" + + Rsx_Date::parse('2024-12-24T15:30:00Z') + // THROWS: "Use Rsx_Time::parse() for datetimes with time components" + +DATE VS DATETIME + Use DATE when: + - Due dates (the task is due on this calendar day) + - Birth dates (born on this day, no time) + - Anniversaries, holidays + - Any value where time of day is irrelevant + + Use DATETIME when: + - Event start/end times + - Created/updated timestamps + - Scheduled appointments + - Any value where the exact moment matters + + Database columns: + DATE - For date-only fields + DATETIME(3) - For datetime fields (millisecond precision) + +RSX_DATE CLASS + All functions work with "YYYY-MM-DD" format strings. + + Parsing & Validation + parse($input) + Returns "YYYY-MM-DD" string or null. + THROWS on datetime input. + + is_date($input) + Returns true if input is valid date string. + + Current Date + today() + Returns today's date as "YYYY-MM-DD" in user's timezone. + PHP: $today = Rsx_Date::today(); + JS: const today = Rsx_Date.today(); + + Formatting + format($date) + Display format: "Dec 24, 2025" + + format_iso($date) + Ensures "YYYY-MM-DD" format. + + Comparison + is_today($date) True if date is today + is_past($date) True if date is before today + is_future($date) True if date is after today + diff_days($d1, $d2) Days between dates (positive if d2 > d1) + + Database + to_database($date) + Returns "YYYY-MM-DD" for database storage (same as ISO format). + +RSX_TIME CLASS + All functions work with ISO 8601 datetime strings or Carbon/Date objects. + + Parsing & Validation + parse($input) + Returns Carbon (PHP) or Date (JS) in UTC. + THROWS on date-only string input. + + is_datetime($input) + Returns true if input is valid datetime (not date-only). + + Current Time + now() Returns current time as Carbon/Date (UTC) + now_iso() Returns current time as ISO 8601 string + now_ms() Returns current time as Unix milliseconds + + Timezone Handling + get_user_timezone() + Returns user's IANA timezone (e.g., "America/Chicago"). + + to_timezone($time, $tz) + Convert datetime to specific timezone. + + to_user_timezone($time) + Convert datetime to user's timezone. + + get_timezone_abbr($time, $tz) + Get timezone abbreviation (e.g., "CST", "CDT"). + DST-aware based on the actual date. + + Serialization + to_iso($time) + Returns ISO 8601 UTC string: "2024-12-24T15:30:45.123Z" + + to_ms($time) + Returns Unix timestamp in milliseconds. + + to_database($time) (PHP only) + Returns MySQL format: "2024-12-24 15:30:45.123" + + Formatting + format_time($time, $tz) "3:30 PM" + format_datetime($time, $tz) "Dec 24, 2024, 3:30 PM" + format_datetime_with_tz($time) "Dec 24, 2024, 3:30 PM CST" + format($time, $format, $tz) (PHP only) Custom PHP date format + + Duration & Relative + diff_seconds($start, $end) + Seconds between two datetimes. + + seconds_until($time) (JS only) + Seconds until future time. + + seconds_since($time) (JS only) + Seconds since past time. + + duration_to_human($seconds, $short) + Long: "2 hours and 30 minutes" + Short: "2h 30m" + + relative($time) + "2 hours ago", "in 3 days", "just now" + + Arithmetic + add($time, $seconds) + Add seconds to time. + + subtract($time, $seconds) + Subtract seconds from time. + + Comparison + is_past($time) True if datetime is in the past + is_future($time) True if datetime is in the future + is_today($time) True if datetime is today (in user's timezone) + + Live Updates (JavaScript only) + countdown($element, target_time, options) + Live countdown to future time. Updates every second. + + const ctrl = Rsx_Time.countdown($('#timer'), deadline, { + short: true, + on_complete: () => alert('Done!') + }); + ctrl.stop(); // Stop the countdown + + countup($element, start_time, options) + Live elapsed time since past time. + + Rsx_Time.countup($('.elapsed'), started_at, { short: true }); + +TIMEZONE INITIALIZATION + User timezone is resolved in order: + 1. login_users.timezone (user's preference) + 2. config('rsx.datetime.default_timezone') + 3. 'America/Chicago' (hardcoded fallback) + + Page Load + On page load, window.rsxapp includes: + server_time - ISO 8601 UTC timestamp from server + user_timezone - IANA timezone identifier + + Rsx_Time._on_framework_core_init() reads these automatically. + + AJAX Sync + Every AJAX response includes _server_time and _user_timezone. + Rsx_Time.sync_from_ajax() is called automatically to: + - Update user timezone if changed + - Sync server time offset on first request or timezone change + + This corrects for client clock skew. Rsx_Time.now() returns + server-adjusted time. + +COMPONENT EXPECTATIONS + Date Picker Components + val() returns "YYYY-MM-DD" or null + val(value) accepts "YYYY-MM-DD" or null + THROWS if passed datetime format + Internal display shows localized format (e.g., "Dec 24, 2025") + + class Date_Picker extends Form_Input_Abstract { + val(value) { + if (arguments.length === 0) { + return this.state.value; // "YYYY-MM-DD" or null + } + if (value != null && !Rsx_Date.is_date(value)) { + throw new Error('Date_Picker requires YYYY-MM-DD format'); + } + this.state.value = value; + this._update_display(); + } + } + + Datetime Picker Components + val() returns ISO 8601 string or null + val(value) accepts ISO 8601 string or null + THROWS if passed date-only format + Internal display shows localized time in user's timezone + + class Datetime_Picker extends Form_Input_Abstract { + val(value) { + if (arguments.length === 0) { + return this.state.value; // ISO 8601 or null + } + if (value != null && !Rsx_Time.is_datetime(value)) { + throw new Error('Datetime_Picker requires ISO 8601 format'); + } + this.state.value = value; + this._update_display(); + } + } + +DATA FLOW + Date Field (e.g., due_date) + + Database: DATE column, value "2025-12-24" + | + PHP Model: $task->due_date = "2025-12-24" (string) + | + JSON Response: {"due_date": "2025-12-24"} + | + JS Model: task.due_date = "2025-12-24" (string) + | + Date Picker: val() = "2025-12-24", display "Dec 24, 2025" + | + Form Submit: {"due_date": "2025-12-24"} + | + PHP Controller: Rsx_Date::parse($params['due_date']) + | + Database: "2025-12-24" + + Datetime Field (e.g., scheduled_at) + + Database: DATETIME(3), value "2025-12-24 15:30:45.123" (UTC) + | + PHP Model: $event->scheduled_at = Carbon instance (UTC) + | + JSON Serialize: {"scheduled_at": "2025-12-24T15:30:45.123Z"} + | + JS Model: event.scheduled_at = "2025-12-24T15:30:45.123Z" (string) + | + Datetime Picker: val() = ISO string, display "Dec 24, 9:30 AM CST" + | + User edits to "Dec 24, 10:00 AM CST" + Picker converts to UTC: "2025-12-24T16:00:00.000Z" + | + Form Submit: {"scheduled_at": "2025-12-24T16:00:00.000Z"} + | + PHP Controller: Rsx_Time::parse($params['scheduled_at']) -> Carbon + | + Database: "2025-12-24 16:00:00.000" + +EXAMPLES + PHP - Date handling: + $due_date = Rsx_Date::parse($params['due_date']); // "2025-12-24" + + if (Rsx_Date::is_past($due_date)) { + return response_error(Ajax::ERROR_VALIDATION, [ + 'due_date' => 'Due date cannot be in the past' + ]); + } + + $task->due_date = Rsx_Date::to_database($due_date); + $task->save(); + + PHP - Datetime handling: + $event_time = Rsx_Time::parse($params['event_time']); + + return [ + 'id' => $event->id, + 'event_time' => Rsx_Time::to_iso($event_time), + 'formatted' => Rsx_Time::format_datetime_with_tz($event_time), + 'is_past' => Rsx_Time::is_past($event_time), + ]; + + JavaScript - Date display: + const due = this.data.task.due_date; // "2025-12-24" + this.$sid('due').text(Rsx_Date.format(due)); // "Dec 24, 2025" + + if (Rsx_Date.is_past(due)) { + this.$sid('due').addClass('text-danger'); + } + + JavaScript - Datetime display with countdown: + const event_time = this.data.event.scheduled_at; // ISO string + + this.$sid('time').text(Rsx_Time.format_datetime(event_time)); + this.$sid('relative').text(Rsx_Time.relative(event_time)); + + if (Rsx_Time.is_future(event_time)) { + this._countdown = Rsx_Time.countdown( + this.$sid('countdown'), + event_time, + { short: true, on_complete: () => this.reload() } + ); + } + +ERROR HANDLING + Wrong type errors are thrown immediately: + + // PHP + try { + Rsx_Time::parse('2025-12-24'); + } catch (\InvalidArgumentException $e) { + // "Rsx_Time::parse() received date-only string..." + } + + // JavaScript + try { + Rsx_Time.parse('2025-12-24'); + } catch (e) { + // "Rsx_Time.parse() received date-only string..." + } + + These errors indicate a programming mistake - the wrong function is + being used. Fix the code rather than catching the exception. + +CONFIGURATION + system/config/rsx.php: + 'datetime' => [ + 'default_timezone' => env('RSX_DEFAULT_TIMEZONE', 'America/Chicago'), + ], + + User timezone stored in login_users.timezone column. + +SEE ALSO + Reference document: /var/www/html/date_vs_datetime_refactor.md + +AUTHOR + RSpade Framework + +RSpade December 2025 TIME(7) diff --git a/app/RSpade/resource/vscode_extension/out/auto_rename_provider.js b/app/RSpade/resource/vscode_extension/out/auto_rename_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/auto_rename_provider.js.map b/app/RSpade/resource/vscode_extension/out/auto_rename_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/class_refactor_code_actions.js b/app/RSpade/resource/vscode_extension/out/class_refactor_code_actions.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/class_refactor_code_actions.js.map b/app/RSpade/resource/vscode_extension/out/class_refactor_code_actions.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/class_refactor_provider.js b/app/RSpade/resource/vscode_extension/out/class_refactor_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/class_refactor_provider.js.map b/app/RSpade/resource/vscode_extension/out/class_refactor_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/combined_semantic_provider.js b/app/RSpade/resource/vscode_extension/out/combined_semantic_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/combined_semantic_provider.js.map b/app/RSpade/resource/vscode_extension/out/combined_semantic_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/comment_file_reference_provider.js b/app/RSpade/resource/vscode_extension/out/comment_file_reference_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/comment_file_reference_provider.js.map b/app/RSpade/resource/vscode_extension/out/comment_file_reference_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/config.js b/app/RSpade/resource/vscode_extension/out/config.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/config.js.map b/app/RSpade/resource/vscode_extension/out/config.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/convention_method_provider.js b/app/RSpade/resource/vscode_extension/out/convention_method_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/convention_method_provider.js.map b/app/RSpade/resource/vscode_extension/out/convention_method_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/debug_client.js b/app/RSpade/resource/vscode_extension/out/debug_client.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/debug_client.js.map b/app/RSpade/resource/vscode_extension/out/debug_client.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/decoration_provider.js b/app/RSpade/resource/vscode_extension/out/decoration_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/decoration_provider.js.map b/app/RSpade/resource/vscode_extension/out/decoration_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/definition_provider.js b/app/RSpade/resource/vscode_extension/out/definition_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/definition_provider.js.map b/app/RSpade/resource/vscode_extension/out/definition_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/extension.js b/app/RSpade/resource/vscode_extension/out/extension.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/extension.js.map b/app/RSpade/resource/vscode_extension/out/extension.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/file_watcher.js b/app/RSpade/resource/vscode_extension/out/file_watcher.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/file_watcher.js.map b/app/RSpade/resource/vscode_extension/out/file_watcher.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/folder_color_provider.js b/app/RSpade/resource/vscode_extension/out/folder_color_provider.js old mode 100755 new mode 100644 index a394166e0..1a00a7abf --- a/app/RSpade/resource/vscode_extension/out/folder_color_provider.js +++ b/app/RSpade/resource/vscode_extension/out/folder_color_provider.js @@ -35,6 +35,7 @@ const path = __importStar(require("path")); * - system/ - Muted gray * - app/ - Muted gray (legacy structure) * - routes/ - Muted gray (legacy structure) + * - *.expect files - Muted gray (behavioral expectation documentation) */ class FolderColorProvider { constructor() { @@ -72,6 +73,11 @@ class FolderColorProvider { return undefined; } const uriPath = uri.fsPath.replace(/\\/g, '/'); + // Mute .expect files (behavioral expectation documentation) + // Only in RSpade projects to avoid affecting other workspaces + if (uriPath.endsWith('.expect') && this.find_rspade_root()) { + return new vscode.FileDecoration(undefined, undefined, new vscode.ThemeColor('descriptionForeground')); + } // Check if this URI is a workspace folder root (for multi-root workspaces) const workspaceFolder = vscode.workspace.workspaceFolders.find(folder => folder.uri.fsPath.replace(/\\/g, '/') === uriPath); if (workspaceFolder) { diff --git a/app/RSpade/resource/vscode_extension/out/folder_color_provider.js.map b/app/RSpade/resource/vscode_extension/out/folder_color_provider.js.map old mode 100755 new mode 100644 index 6e4883698..527ecde0f --- a/app/RSpade/resource/vscode_extension/out/folder_color_provider.js.map +++ b/app/RSpade/resource/vscode_extension/out/folder_color_provider.js.map @@ -1 +1 @@ -{"version":3,"file":"folder_color_provider.js","sourceRoot":"","sources":["../src/folder_color_provider.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"folder_color_provider.js","sourceRoot":"","sources":["../src/folder_color_provider.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/app/RSpade/resource/vscode_extension/out/folding_provider.js b/app/RSpade/resource/vscode_extension/out/folding_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/folding_provider.js.map b/app/RSpade/resource/vscode_extension/out/folding_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/formatting_provider.js b/app/RSpade/resource/vscode_extension/out/formatting_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/formatting_provider.js.map b/app/RSpade/resource/vscode_extension/out/formatting_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/git_diff_provider.js b/app/RSpade/resource/vscode_extension/out/git_diff_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/git_diff_provider.js.map b/app/RSpade/resource/vscode_extension/out/git_diff_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/git_status_provider.js b/app/RSpade/resource/vscode_extension/out/git_status_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/git_status_provider.js.map b/app/RSpade/resource/vscode_extension/out/git_status_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/ide_bridge_client.js b/app/RSpade/resource/vscode_extension/out/ide_bridge_client.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/ide_bridge_client.js.map b/app/RSpade/resource/vscode_extension/out/ide_bridge_client.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js b/app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js.map b/app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/laravel_completion_provider.js b/app/RSpade/resource/vscode_extension/out/laravel_completion_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/laravel_completion_provider.js.map b/app/RSpade/resource/vscode_extension/out/laravel_completion_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/php_attribute_provider.js b/app/RSpade/resource/vscode_extension/out/php_attribute_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/php_attribute_provider.js.map b/app/RSpade/resource/vscode_extension/out/php_attribute_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/refactor_code_actions.js b/app/RSpade/resource/vscode_extension/out/refactor_code_actions.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/refactor_code_actions.js.map b/app/RSpade/resource/vscode_extension/out/refactor_code_actions.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/refactor_provider.js b/app/RSpade/resource/vscode_extension/out/refactor_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/refactor_provider.js.map b/app/RSpade/resource/vscode_extension/out/refactor_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/sort_class_methods_provider.js b/app/RSpade/resource/vscode_extension/out/sort_class_methods_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/sort_class_methods_provider.js.map b/app/RSpade/resource/vscode_extension/out/sort_class_methods_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/symlink_redirect_provider.js b/app/RSpade/resource/vscode_extension/out/symlink_redirect_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/symlink_redirect_provider.js.map b/app/RSpade/resource/vscode_extension/out/symlink_redirect_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/that_variable_provider.js b/app/RSpade/resource/vscode_extension/out/that_variable_provider.js old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/out/that_variable_provider.js.map b/app/RSpade/resource/vscode_extension/out/that_variable_provider.js.map old mode 100755 new mode 100644 diff --git a/app/RSpade/resource/vscode_extension/package.json b/app/RSpade/resource/vscode_extension/package.json index 3a26d3b83..54f30f257 100755 --- a/app/RSpade/resource/vscode_extension/package.json +++ b/app/RSpade/resource/vscode_extension/package.json @@ -2,7 +2,7 @@ "name": "rspade-framework", "displayName": "RSpade Framework Support", "description": "VS Code extension for RSpade framework with code folding, formatting, and namespace management", - "version": "0.1.220", + "version": "0.1.221", "publisher": "rspade", "engines": { "vscode": "^1.74.0" diff --git a/app/RSpade/resource/vscode_extension/rspade-framework.vsix b/app/RSpade/resource/vscode_extension/rspade-framework.vsix index b68a002ba49d9e367a69258ce21a46d69c9ffa3d..a61ec8763c3ef023489f948189a79a4baa0f3717 100755 GIT binary patch delta 4474 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 delta 4326 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 L=)uN5z!~@-l|s$b diff --git a/app/RSpade/resource/vscode_extension/src/folder_color_provider.ts b/app/RSpade/resource/vscode_extension/src/folder_color_provider.ts index 14a84fa49..7fcfc4444 100755 --- a/app/RSpade/resource/vscode_extension/src/folder_color_provider.ts +++ b/app/RSpade/resource/vscode_extension/src/folder_color_provider.ts @@ -10,6 +10,7 @@ import * as path from 'path'; * - system/ - Muted gray * - app/ - Muted gray (legacy structure) * - routes/ - Muted gray (legacy structure) + * - *.expect files - Muted gray (behavioral expectation documentation) */ export class FolderColorProvider implements vscode.FileDecorationProvider { private readonly _onDidChangeFileDecorations: vscode.EventEmitter = @@ -59,6 +60,16 @@ export class FolderColorProvider implements vscode.FileDecorationProvider { const uriPath = uri.fsPath.replace(/\\/g, '/'); + // Mute .expect files (behavioral expectation documentation) + // Only in RSpade projects to avoid affecting other workspaces + if (uriPath.endsWith('.expect') && this.find_rspade_root()) { + return new vscode.FileDecoration( + undefined, + undefined, + new vscode.ThemeColor('descriptionForeground') + ); + } + // Check if this URI is a workspace folder root (for multi-root workspaces) const workspaceFolder = vscode.workspace.workspaceFolders.find( folder => folder.uri.fsPath.replace(/\\/g, '/') === uriPath diff --git a/config/rsx.php b/config/rsx.php index 0f7f77a85..9fe77cf5d 100755 --- a/config/rsx.php +++ b/config/rsx.php @@ -574,4 +574,25 @@ return [ // 'email' => ['max_workers' => 5], ], ], + + /* + |-------------------------------------------------------------------------- + | Datetime Configuration + |-------------------------------------------------------------------------- + | + | Configure date and time handling across the application. + | See: php artisan rsx:man time + | + */ + 'datetime' => [ + // Default timezone (IANA identifier) when user has no preference + // Resolution order: login_users.timezone → this default → 'America/Chicago' + 'default_timezone' => env('RSX_DEFAULT_TIMEZONE', 'America/Chicago'), + + // Time dropdown interval in minutes (for Schedule_Input component) + 'time_interval' => 15, + + // Default duration for new events in minutes (for Schedule_Input component) + 'default_duration' => 60, + ], ]; diff --git a/database/migrations/.migration_whitelist b/database/migrations/.migration_whitelist index 40e6c736f..1ad51140e 100755 --- a/database/migrations/.migration_whitelist +++ b/database/migrations/.migration_whitelist @@ -346,6 +346,11 @@ "created_at": "2025-12-11T06:15:51+00:00", "created_by": "root", "command": "php artisan make:migration:safe create_groups_and_group_users_tables" + }, + "2025_12_24_210213_add_timezone_to_login_users_table.php": { + "created_at": "2025-12-24T21:02:13+00:00", + "created_by": "root", + "command": "php artisan make:migration:safe add_timezone_to_login_users_table" } } } \ No newline at end of file diff --git a/database/migrations/2025_12_24_210213_add_timezone_to_login_users_table.php b/database/migrations/2025_12_24_210213_add_timezone_to_login_users_table.php new file mode 100755 index 000000000..ee94263c1 --- /dev/null +++ b/database/migrations/2025_12_24_210213_add_timezone_to_login_users_table.php @@ -0,0 +1,36 @@ + $contact->type_id_label]; + +// ✅ CORRECT - names match source +return ['type_id_label' => $contact->type_id_label]; +``` + +One string everywhere. Grep finds all usages. No mental mapping between layers. + +### Git Workflow - Framework is READ-ONLY + +**NEVER modify `/var/www/html/system/`** - It's like node_modules or the Linux kernel. + +- **App repo**: `/var/www/html/.git` (you control) +- **Framework**: `/var/www/html/system/` (submodule, don't touch) +- **Your code**: `/var/www/html/rsx/` (all changes here) + +**Commit discipline**: ONLY commit when explicitly asked. Commits are milestones, not individual changes. + +### Class Overrides + +To customize framework classes without modifying `/system/`, copy them to `rsx/` with the same class name. The manifest automatically uses your version and renames the framework file to `.upstream`. + +**Common override targets** (copy from `system/app/RSpade/Core/Models/`): +- `User_Model` - Add custom fields, relationships, methods +- `User_Profile_Model` - Extend profile data +- `Site_Model` - Add site-specific settings + +```bash +cp system/app/RSpade/Core/Models/User_Model.php rsx/models/user_model.php +# Edit namespace to Rsx\Models, customize as needed +``` + +Details: `php artisan rsx:man class_override` + +### Trust Code Quality Rules + +Each `rsx:check` rule has remediation text that tells AI assistants exactly what to do: +- Some rules say "fix immediately" +- Some rules say "present options and wait for decision" + +AI should follow the rule's guidance precisely. Rules are deliberately written and well-reasoned. + +--- + +## NAMING CONVENTIONS + +**Enforced by `rsx:check`**: + +| Context | Convention | Example | +|---------|------------|---------| +| PHP Methods/Variables | `underscore_case` | `user_name` | +| PHP Classes | `Like_This` | `User_Controller` | +| JavaScript Classes | `Like_This` | `User_Card` | +| Files | `lowercase_underscore` | `user_controller.php` | +| Database Tables | `lowercase_plural` | `users` | +| Constants | `UPPERCASE` | `MAX_SIZE` | + +### File Prefix Grouping + +Files sharing a common prefix are a related set. When renaming, maintain the grouping across ALL files with that prefix. + +**Example** - `rsx/app/frontend/calendar/`: +``` +frontend_calendar_event.scss +frontend_calendar_event_controller.php +frontend_calendar_event.jqhtml +frontend_calendar_event.js +``` + +**Critical**: Never create same-name different-case files (e.g., `user.php` and `User.php`). + +### Component Naming Pattern + +Input components follow: `{Supertype}_{Variant}_{Supertype}` → e.g., `Select_Country_Input`, `Select_State_Input`, `Select_Ajax_Input` + +--- + +## DIRECTORY STRUCTURE + +``` +/var/www/html/ +├── rsx/ # YOUR CODE +│ ├── app/ # Modules +│ ├── models/ # Database models +│ ├── services/ # Background tasks, external integrations +│ ├── public/ # Static files (web-accessible) +│ ├── resource/ # Framework-ignored +│ └── theme/ # Global assets +└── system/ # FRAMEWORK (read-only) +``` + +**Services**: Extend `Rsx_Service_Abstract` for non-HTTP functionality like scheduled tasks or external system integrations. + +### Special Directories (Path-Agnostic) + +**`resource/`** - ANY directory named this is framework-ignored. Store helpers, docs, third-party code. Exception: `/rsx/resource/config/` IS processed. + +**`public/`** - ANY directory named this is web-accessible, framework-ignored. 5min cache, 30d with `?v=`. + +### Path-Agnostic Loading + +Classes found by name, not path. No imports needed. + +```php +$user = User_Model::find(1); // Framework finds it +// NOT: use Rsx\Models\User_Model; // Auto-generated +``` + +--- + +## CONFIGURATION + +**Two-tier system**: +- **Framework**: `/system/config/rsx.php` (never modify) +- **User**: `/rsx/resource/config/rsx.php` (your overrides) + +Merged via `array_merge_deep()`. Common overrides: `development.auto_rename_files`, `bundle_aliases`, `console_debug`. + +--- + +## ROUTING & CONTROLLERS + +```php +class Frontend_Controller extends Rsx_Controller_Abstract +{ + public static function pre_dispatch(Request $request, array $params = []) + { + if (!Session::is_logged_in()) return response_unauthorized(); + return null; + } + + #[Route('/', methods: ['GET'])] + public static function index(Request $request, array $params = []) + { + return rsx_view('Frontend_Index', [ + 'bundle' => Frontend_Bundle::render() + ]); + } +} +``` + +**Rules**: Only GET/POST. Use `:param` syntax. Manual auth checks in pre_dispatch or method body. + +### Authentication Pattern + +```php +// Controller-wide auth (recommended) +public static function pre_dispatch(Request $request, array $params = []) { + if (!Session::is_logged_in()) return response_unauthorized(); + return null; +} + +``` + +### Type-Safe URLs + +**MANDATORY**: All URLs must be generated using `Rsx::Route()` - hardcoded URLs are forbidden. + +```php +// PHP - Controller (defaults to 'index' method) +Rsx::Route('User_Controller') + +// PHP - Controller with explicit method. Passing an integer for param two implies the param two is 'id' (id=123) +Rsx::Route('User_Controller::show', 123); + +// PHP - With query parameters. Extra params not defined in route itself become query string - automatically URL-encoded +Rsx::Route('Login_Controller::logout', ['redirect' => '/dashboard']); +// Generates: /logout?redirect=%2Fdashboard + +// JavaScript (identical syntax) +Rsx.Route('User_Controller') +Rsx.Route('User_Controller::show', 123); +Rsx.Route('Login_Controller::logout', {redirect: '/dashboard'}); +``` +- **Unimplemented routes**: Prefix method with `#` → `Rsx::Route('Feature::#index')` generates `href="#"` and bypasses validation + +**Enforcement**: `rsx:check` will flag hardcoded URLs like `/login` or `/logout?redirect=...` and require you to use `Rsx::Route()`. Do it right the first time to avoid rework. + +--- + +## SPA (SINGLE PAGE APPLICATION) ROUTING + +Client-side routing for authenticated application areas. One PHP bootstrap controller, multiple JavaScript actions that navigate without page reloads. + +### SPA Components + +**1. PHP Bootstrap Controller** - ONE per module with auth in pre_dispatch +```php +public static function pre_dispatch(Request $request, array $params = []) { + if (!Session::is_logged_in()) return response_unauthorized(); + return null; +} + +#[SPA] +public static function index(Request $request, array $params = []) { + return rsx_view(SPA); +} +``` +One #[SPA] per module at `rsx/app/(module)/(module)_spa_controller::index`. Segregates code by permission level. + +**2. JavaScript Actions (MANY)** +```javascript +@route('/contacts') +@layout('Frontend_Layout') +@spa('Frontend_Spa_Controller::index') +@title('Contacts') // Optional browser title +class Contacts_Index_Action extends Spa_Action { + async on_load() { + this.data.contacts = await Frontend_Contacts_Controller.datagrid_fetch(); + } +} +``` + +**3. Layout** +```javascript +class Frontend_Layout extends Spa_Layout { + on_action(url, action_name, args) { + // Called after action created, before on_ready + // Access this.action immediately + this.update_navigation(url); + } +} +``` + +Layout template must have `$sid="content"` element where actions render. + +### URL Generation & Navigation + +```php +// PHP/JavaScript - same syntax +Rsx::Route('Contacts_Index_Action') // /contacts +Rsx::Route('Contacts_View_Action', 123) // /contacts/123 +``` + +```javascript +Spa.dispatch('/contacts/123'); // Programmatic navigation +Spa.layout // Current layout instance +Spa.action // Current action instance +``` + +### URL Parameters + +```javascript +// URL: /contacts/123?tab=history +@route('/contacts/:id') +class Contacts_View_Action extends Spa_Action { + on_create() { + console.log(this.args.id); // "123" (route param) + console.log(this.args.tab); // "history" (query param) + } +} +``` + +### File Organization + +Pattern: `/rsx/app/(module)/(feature)/` +- **Module**: Major functionality (login, frontend, root) +- **Feature**: Screen within module (contacts, reports, invoices) +- **Submodule**: Feature grouping (settings), often with sublayouts + +``` +/rsx/app/frontend/ # Module +├── Frontend_Spa_Controller.php # Single SPA bootstrap +├── Frontend_Layout.js +├── Frontend_Layout.jqhtml +└── contacts/ # Feature + ├── frontend_contacts_controller.php # Ajax endpoints only + ├── Contacts_Index_Action.js # /contacts + ├── Contacts_Index_Action.jqhtml + ├── Contacts_View_Action.js # /contacts/:id + └── Contacts_View_Action.jqhtml +``` + +**Use SPA for:** Authenticated areas, dashboards, admin panels +**Avoid for:** Public pages (SEO needed), simple static pages + +### Sublayouts + +**Sublayouts** are `Spa_Layout` classes for nested persistent UI (e.g., settings sidebar). Use multiple `@layout` decorators - first is outermost: `@layout('Frontend_Spa_Layout')` then `@layout('Settings_Layout')`. Each must have `$sid="content"`. Layouts persist when unchanged; only differing parts recreated. All receive `on_action(url, action_name, args)` with final action info. + +Details: `php artisan rsx:man spa` + +### View Action Pattern (Loading Data) + +For SPA actions that load data (view/edit CRUD pages), use the three-state pattern: + +```javascript +on_create() { + this.data.record = { name: '' }; // Stub prevents undefined errors + this.data.error_data = null; + this.data.loading = true; +} +async on_load() { + try { + this.data.record = await Controller.get({id: this.args.id}); + } catch (e) { + this.data.error_data = e; + } + this.data.loading = false; +} +``` + +Template uses three states: `` → `` → content. + +**Details**: `php artisan rsx:man view_action_patterns` + +--- + +## CONVERTING BLADE PAGES TO SPA ACTIONS + +For converting server-side Blade pages to client-side SPA actions, see `php artisan rsx:man blade_to_spa`. +The process involves creating Action classes with @route decorators and converting templates from Blade to jqhtml syntax. + +--- + +## BLADE & VIEWS + +**Note**: SPA pages are the preferred standard. Use Blade only for SEO-critical public pages or authentication flows. + +```blade +@rsx_id('Frontend_Index') {{-- Every view starts with this --}} + {{-- Adds view class --}} +``` + +**NO inline styles, scripts, or event handlers** - Use companion `.scss` and `.js` files. +**jqhtml components** work fully in Blade (no slots). + +### SCSS Component-First Architecture + +**Philosophy**: Every styled element is a component. If it needs custom styles, give it a name, a jqhtml definition, and scoped SCSS. This eliminates CSS spaghetti - generic classes overriding each other unpredictably across files. + +**Recognition**: When building a page, ask: "Is this structure unique, or a pattern?" A datagrid page with toolbar, tabs, filters, and search is a *pattern* - create `Datagrid_Card` once with slots, use it everywhere. A one-off project dashboard is *unique* - create `Project_Dashboard` for that page. If you're about to copy-paste structural markup, stop and extract a component. + +**Composition**: Use slots to separate structure from content. The component owns layout and styling; pages provide the variable parts via slots. This keeps pages declarative and components reusable. + +**Enforcement**: SCSS in `rsx/app/` and `rsx/theme/components/` must wrap in a single component class matching the jqhtml/blade file. This works because all jqhtml components, SPA actions/layouts, and Blade views with `@rsx_id` automatically render with `class="Component_Name"` on their root element. `rsx/lib/` is for non-visual plumbing (validators, utilities). `rsx/theme/` (outside components/) holds primitives, variables, Bootstrap overrides. + +**BEM Child Classes**: When using BEM notation, child element classes must use the component's exact class name as prefix. SCSS `.Component_Name { &__element }` compiles to `.Component_Name__element`, so HTML must match: `
` not `
`. No kebab-case conversion. + +**Variables**: Define shared values (colors, spacing, border-radius) in `rsx/theme/variables.scss` or similar. These must be explicitly included before directory includes in bundle definitions. Component-local variables can be defined within the scoped rule. + +**Supplemental files**: Multiple SCSS files can target the same component (e.g., breakpoint-specific styles) if a primary file with matching filename exists. + +Details: `php artisan rsx:man scss` + +### Responsive Breakpoints + +RSX replaces Bootstrap's default breakpoints (xs/sm/md/lg/xl/xxl) with semantic device names. + +**Tier 1 - Semantic**: +- `mobile`: 0 - 1023px (phone + tablet) +- `desktop`: 1024px+ + +**Tier 2 - Granular**: +- `phone`: 0 - 799px | `tablet`: 800 - 1023px | `desktop-sm`: 1024 - 1699px | `desktop-md`: 1700 - 2199px | `desktop-lg`: 2200px+ + +**SCSS Mixins**: `@include mobile { }`, `@include desktop { }`, `@include phone { }`, `@include tablet { }`, `@include desktop-sm { }`, etc. + +**Bootstrap Classes**: `.col-mobile-6`, `.col-desktop-4`, `.d-mobile-none`, `.d-tablet-block`, `.col-phone-12 .col-tablet-6 .col-desktop-sm-4` + +**Utility Classes**: `.mobile-only`, `.desktop-only`, `.phone-only`, `.hide-mobile`, `.hide-tablet` + +**Note**: Bootstrap's default classes like `.col-md-6` or `.d-lg-none` do NOT work - use the RSX breakpoint names instead. + +**JS Detection**: `Responsive.is_mobile()`, `Responsive.is_desktop()` (Tier 1 - broad); `Responsive.is_phone()`, `Responsive.is_tablet()`, `Responsive.is_desktop_sm()`, `Responsive.is_desktop_md()`, `Responsive.is_desktop_lg()` (Tier 2 - specific ranges) + +Details: `php artisan rsx:man responsive` + +### JavaScript for Blade Pages + +Unlike SPA actions (which use component lifecycle), Blade pages use static `on_app_ready()` with a page guard: + +```javascript +class My_Page { // Matches @rsx_id('My_Page') + static on_app_ready() { + if (!$('.My_Page').exists()) return; // Guard required - fires for ALL pages in bundle + // Page code here + } +} +``` + +### Passing Data to JavaScript + +Use `@rsx_page_data` for page-specific data needed by JavaScript (IDs, config, etc.): + +```blade +@rsx_page_data(['user_id' => $user->id, 'mode' => 'edit']) + +@section('content') + +@endsection +``` + +Access in JavaScript: +```javascript +const user_id = window.rsxapp.page_data.user_id; +``` + +Use when data doesn't belong in DOM attributes. Multiple calls merge together. + +--- + +## BUNDLE SYSTEM + +**One bundle per module (rsx/app/(module)).** Compiles JS/CSS automatically on request - no manual build steps. + +```php +class Frontend_Bundle extends Rsx_Bundle_Abstract +{ + public static function define(): array + { + return [ + 'include' => [ + 'jquery', // Required + 'lodash', // Required + 'rsx/theme/variables.scss', // Order matters + 'rsx/theme', // Everything else from theme - but variables.scss will be first + 'rsx/app/frontend', // Directory - + 'rsx/models', // For JS stubs + ], + ]; + } +} +``` + +```blade + + {!! Frontend_Bundle::render() !!} + +``` + +--- + +## JQHTML COMPONENTS + +### Philosophy + +For mechanical thinkers who see structure, not visuals. Write `` not `
`. Name what things ARE. + +### Template Syntax + +**CRITICAL: `` IS the element, not a wrapper** + +```jqhtml + + + Save + + + + +``` + +**Interpolation**: `<%= escaped %>` | `<%!= unescaped %>` | `<% javascript %>` +**Conditional Attributes** `required="required"<% } %> />` +**Inline Logic**: `<% this.handler = () => action(); %>` then `@click=this.handler` - No JS file needed for simple components +**Event Handlers**: `@click=this.method` (unquoted) - Methods defined inline or in companion .js +**Validation**: `<% if (!this.args.required) throw new Error('Missing arg'); %>` - Fail loud in template + +### Simple Components (No JS File Needed) + +For simple components without external data or complex state, write JS directly in the template: + +```jqhtml + + <% + // Validate input + if (!this.args.csv_data) throw new Error('csv_data required'); + + // Parse CSV + const rows = this.args.csv_data.split('\n').map(r => r.split(',')); + + // Define click handler inline + this.toggle = () => { this.args.expanded = !this.args.expanded; this.render(); }; + %> + + + <% for (let row of rows) { %> + + <% for (let cell of row) { %> + + <% } %> + + <% } %> +
<%= cell %>
+ + +
+``` + +**When to use inline JS**: Simple data transformations, conditionals, loops, basic event handlers +**When to create .js file**: External data loading, complex state management, multiple methods, or when JS overwhelms the template (should look mostly like HTML with some logic, not JS with some HTML) + +### State Management Rules (ENFORCED) + +**Quick Guide:** +- Loading from API? → Use `this.data` in `on_load()` +- Need reload with different params? → Modify `this.args`, call `reload()` +- UI state (toggles, selections)? → Use `this.state` + +**this.args** - Component arguments (read-only in on_load(), modifiable elsewhere) +**this.data** - Ajax-loaded data (writable ONLY in on_create() and on_load()) +**this.state** - Arbitrary component state (modifiable anytime) + +```javascript +// WITH Ajax data +class Users_List extends Component { + on_create() { + this.data.users = []; // Defaults + } + async on_load() { + this.data.users = await User_Controller.fetch({filter: this.args.filter}); + } + on_ready() { + // Change filter → reload + this.args.filter = 'new'; + this.reload(); + } +} + +// WITHOUT Ajax data +class Toggle_Button extends Component { + on_create() { + this.state = {is_open: false}; + } + on_ready() { + this.$.on('click', () => { + this.state.is_open = !this.state.is_open; + }); + } +} +``` + +**on_load() restrictions** (enforced): +- ✅ Read `this.args`, write `this.data` +- ❌ NO DOM access, NO `this.state`, NO modifying `this.args` + +### Lifecycle + +1. **on_create()** → Setup default state BEFORE template (sync) +2. **render** → Template executes with initialized state +3. **on_render()** → Hide uninitialized UI (sync) +4. **on_load()** → Fetch data into `this.data` (async) +5. **on_ready()** → DOM manipulation safe (async) + +**on_create() now runs first** - Initialize `this.data` properties here so templates can safely reference them: + +```javascript +on_create() { + this.data.rows = []; // Prevents "not iterable" errors + this.data.loading = true; // Template can check loading state +} +``` + +**Double-render**: If `on_load()` modifies `this.data`, component renders twice (defaults → populated). + +### Component API - CRITICAL FOR LLM AGENTS + +This section clarifies common misunderstandings. Read carefully. + +**DOM Access Methods:** + +| Method | Returns | Purpose | +|--------|---------|---------| +| `this.$` | jQuery | Root element of component (stable, survives redraws) | +| `this.$sid('name')` | jQuery | Child element with `$sid="name"` (always returns jQuery, even if empty) | +| `this.sid('name')` | Component or null | Child component instance (null if not found or not a component) | +| `this.$.find('.class')` | jQuery | Standard jQuery find (use when `$sid` isn't appropriate) | + +**WRONG:** `this.$el` - This does not exist. Use `this.$` + +**The reload() Paradigm - MANDATORY:** + +``` +reload() = on_load() → render() → on_ready() +render() = template redraw only (NO on_ready) +``` + +**LLM agents must ALWAYS use `reload()`, NEVER call `render()` directly.** + +When you need to refresh a component after a mutation (add, edit, delete), call `this.reload()`. Yes, this makes another server call via `on_load()`. This is intentional. The extra round-trip is acceptable - our server is fast and the paradigm simplicity is worth it. + +**WRONG approach (do NOT do this):** +```javascript +// ❌ BAD - Trying to be "efficient" by skipping server round-trip +async add_item() { + const new_item = await Controller.add({name: 'Test'}); + this.data.items.push(new_item); // ERROR: Cannot modify this.data outside on_load + this.render(); // WRONG: Event handlers will break, on_ready won't run +} +``` + +**CORRECT approach:** +```javascript +// ✅ GOOD - Clean, consistent, reliable +async add_item() { + await Controller.add({name: 'Test'}); + this.reload(); // Calls on_load() to refresh this.data, then on_ready() for handlers +} +``` + +**Event Handlers - Set Up in on_ready():** + +Event handlers must be registered in `on_ready()`. Since `on_ready()` runs after every `reload()`, handlers automatically reattach when the DOM is redrawn. + +```javascript +on_ready() { + this.$sid('save_btn').click(() => this.save()); + this.$sid('delete_btn').click(() => this.delete()); +} +``` + +**WRONG:** Event delegation to avoid `reload()`. If you find yourself writing `this.$.on('click', '[data-sid="btn"]', handler)` to "survive" render calls, you're doing it wrong. Use `reload()` and let `on_ready()` reattach handlers. + +**this.data Modification Rules (ENFORCED):** + +- `on_create()`: Set defaults only (e.g., `this.data.items = []`) +- `on_load()`: Fetch and assign from server (e.g., `this.data.items = await Controller.list()`) +- **Everywhere else**: Read-only. Attempting to modify `this.data` outside these methods throws an error. + +**on_render() - LLM Should Not Use:** + +`on_render()` exists for human developers doing performance optimization. LLM agents should pretend it doesn't exist. Use `on_ready()` for all post-render DOM work. + +### Loading Pattern + +```javascript +async on_load() { + const result = await Product_Controller.list({page: 1}); + this.data.products = result.products; + this.data.loaded = true; // Simple flag at END +} +``` + +```jqhtml +<% if (!this.data.loaded) { %> + Loading... +<% } else { %> + +<% } %> +``` + +**NEVER call `this.render()` in `on_load()` - automatic re-render happens.** + +### Attributes + +- **`$quoted="string"`** → String literal +- **`$unquoted=expression`** → JavaScript expression +- **`$sid="name"`** → Scoped element ID +- **`attr="<%= expr %>"`** → HTML attribute with interpolation + +**Key restrictions:** +- **`` attributes are static** - No `<%= %>` on the `` tag. For dynamic attributes on the root element, use inline JS: `<% this.$.attr('data-id', this.args.id); %>` +- **`$prefix` = component args, NOT HTML attributes** - `` creates `this.args['data-id']`, not a `data-id` DOM attribute +- **Conditional attributes use if-statements** - `<% if (cond) { %>checked<% } %>` not ternaries + +### Component Access + +**$sid** attribute = "scoped ID" - unique within component instance + +From within component methods: +- **this.$** → jQuery selector for the component element itself +- **this.$sid(name)** → jQuery selector for child element with `$sid="name"` +- **this.sid(name)** → Component instance of child (or null if not a component) +- **$(selector).component()** → Get component instance from jQuery element +- **`await $(selector).component().ready()`** → Await component initialization. Rarely needed - `on_ready()` auto-waits for children created during render. Use for dynamically created components or Blade page JS interaction. + +### Custom Component Events + +Fire: `this.trigger('event_name', data)` | Listen: `this.sid('child').on('event_name', (component, data) => {})` + +**Key difference from jQuery**: Events fired BEFORE handler registration still trigger the callback when registered. This solves component lifecycle timing issues where child events fire before parent registers handlers. Never use `this.$.trigger()` for custom events (enforced by JQHTML-EVENT-01). + +### Dynamic Component Creation + +To dynamically create/replace a component in JavaScript: +```javascript +// Destroys existing component (if any) and creates new one in its place +$(selector).component('Component_Name', { arg1: value1, arg2: value2 }); + +// Example: render a component into a container +this.$sid('result_container').component('My_Component', { + data: myData, + some_option: true +}); +``` + +### Incremental Scaffolding + +**Undefined components work immediately** - they render as div with the component name as a class. + +```blade + + + + +``` + +### Key Pitfalls (ABSOLUTE RULES) + +1. `` IS the element - use `tag=""` attribute +2. `this.data` starts empty `{}` - MUST set defaults in `on_create()` +3. ONLY modify `this.data` in `on_create()` and `on_load()` (enforced by framework) +4. `on_load()` can ONLY access `this.args` and `this.data` (no DOM, no `this.state`) +5. Use `this.state = {}` in `on_create()` for UI state (not from Ajax) +6. Use `this.args` for reload parameters, call `reload()` to re-fetch +7. Use `Controller.method()` not `$.ajax()` - PHP methods with #[Ajax_Endpoint] auto-callable from JS +8. `on_create/render/stop` must be sync +9. `this.sid()` returns component instance, `$(selector).component()` converts jQuery to component + +--- + +## FORM COMPONENTS + +**Form fields** (`` with `$data`, `$controller`, `$method`): +```blade + + + + + + + +``` + +- **Form_Field** - Standard formatted field with label, errors, help text +- **Form_Field_Hidden** - Single-tag hidden input (extends Form_Field_Abstract) +- **Form_Field_Abstract** - Base class for custom formatting (advanced) + +**Disabled fields**: Use `$disabled=true` attribute on input components to disable fields. Unlike standard HTML, disabled fields still return values via `vals()` (useful for read-only data that should be submitted). + +```blade + + + +``` + +**Form component classes** use the **vals() dual-mode pattern**: + +```javascript +class My_Form extends Component { + vals(values) { + if (values) { + // Setter - populate form + this.$sid('name').val(values.name || ''); + return null; + } else { + // Getter - extract values + return {name: this.$sid('name').val()}; + } + } +} +``` + +**Validation**: `Form_Utils.apply_form_errors(form.$, errors)` - Matches by `name` attribute. + +### Form Conventions (Action/Controller Pattern) + +Forms follow a load/save pattern mirroring traditional Laravel: Action loads data, Controller saves it. + +```javascript +// Action: on_create() sets defaults, on_load() fetches for edit mode +on_create() { + this.data.form_data = { title: '', status_id: Model.STATUS_ACTIVE }; + this.data.is_edit = !!this.args.id; +} +async on_load() { + if (!this.data.is_edit) return; + const record = await My_Model.fetch(this.args.id); + this.data.form_data = { id: record.id, title: record.title }; +} +``` + +```php +// Controller: save() receives all form values, validates, persists +#[Ajax_Endpoint] +public static function save(Request $request, array $params = []) { + if (empty($params['title'])) return response_form_error('Validation failed', ['title' => 'Required']); + $record = $params['id'] ? My_Model::find($params['id']) : new My_Model(); + $record->title = $params['title']; + $record->save(); + return ['redirect' => Rsx::Route('View_Action', $record->id)]; +} +``` + +**Key principles**: form_data must be serializable (plain objects, no models) | Keep load/save in same controller for field alignment | on_load() loads data, on_ready() is UI-only + +Details: `php artisan rsx:man form_conventions` + +### Polymorphic Form Fields + +For fields that can reference multiple model types (e.g., an Activity linked to either a Contact or Project), use JSON-encoded polymorphic values. + +```php +use App\RSpade\Core\Polymorphic_Field_Helper; + +$eventable = Polymorphic_Field_Helper::parse($params['eventable'], [ + Contact_Model::class, + Project_Model::class, +]); + +if ($error = $eventable->validate('Please select an entity')) { + $errors['eventable'] = $error; +} + +$model->eventable_type = $eventable->model; +$model->eventable_id = $eventable->id; +``` + +Client submits: `{"model":"Contact_Model","id":123}`. Always use `Model::class` for the whitelist. + +Details: `php artisan rsx:man polymorphic` + +--- + +## MODALS + +### Built-in Dialog Types + +| Method | Returns | Description | +|--------|---------|-------------| +| `Modal.alert(body)` | `void` | Simple notification | +| `Modal.alert(title, body, buttonLabel?)` | `void` | Alert with title | +| `Modal.confirm(body)` | `boolean` | Yes/no confirmation | +| `Modal.confirm(title, body, confirmLabel?, cancelLabel?)` | `boolean` | Confirmation with labels | +| `Modal.prompt(body)` | `string\|false` | Text input | +| `Modal.prompt(title, body, default?, multiline?)` | `string\|false` | Prompt with options | +| `Modal.select(body, options)` | `string\|false` | Dropdown selection | +| `Modal.select(title, body, options, default?, placeholder?)` | `string\|false` | Select with options | +| `Modal.error(error, title?)` | `void` | Error with red styling | +| `Modal.unclosable(title, body)` | `void` | Modal user cannot close | + +```javascript +// Alert variations +await Modal.alert("File saved"); +await Modal.alert("Success", "Your changes have been saved."); +await Modal.alert("Done", "Operation complete.", "Got it"); + +// Confirm variations +if (await Modal.confirm("Delete this item?")) { /* confirmed */ } +if (await Modal.confirm("Delete", "This cannot be undone.", "Delete", "Keep")) { /* ... */ } + +// Prompt variations +const name = await Modal.prompt("Enter your name:"); +const notes = await Modal.prompt("Notes", "Enter notes:", "", true); // multiline + +// Select dropdown +const choice = await Modal.select("Choose option:", [{value: 'a', label: 'Option A'}, {value: 'b', label: 'Option B'}]); + +// Error display +await Modal.error("Something went wrong"); +await Modal.error({message: "Validation failed", errors: {...}}, "Error"); +``` + +### Form Modals + +```javascript +const result = await Modal.form({ + title: "Edit User", + component: "User_Form", + component_args: {data: user}, + on_submit: async (form) => { + const response = await User_Controller.save(form.vals()); + if (response.errors) { + Form_Utils.apply_form_errors(form.$, response.errors); + return false; // Keep open + } + return response.data; // Close and return + } +}); +``` + +Form component must implement `vals()` and include `
`. + +### Modal Classes + +For complex/reusable modals, create dedicated classes: + +```javascript +class Add_User_Modal extends Modal_Abstract { + static async show() { + return await Modal.form({...}) || false; + } +} + +// Usage +const user = await Add_User_Modal.show(); +if (user) { + grid.reload(); + await Next_Modal.show(user.id); +} +``` + +Pattern: Extend `Modal_Abstract`, implement static `show()`, return data or `false`. + +Details: `php artisan rsx:man modals` + +--- + +## JQUERY EXTENSIONS + +| Method | Purpose | +|--------|---------| +| `.exists()` | Check element exists (instead of `.length > 0`) | +| `.shallowFind(selector)` | Find children without nested component interference | +| `.closest_sibling(selector)` | Search within ancestor hierarchy | +| `.checkValidity()` | Form validation helper | +| `.click()` | Auto-prevents default | +| `.click_allow_default()` | Native click behavior | + +Details: `php artisan rsx:man jquery` + +--- + +## MODELS & DATABASE + +### No Mass Assignment + +```php +// ✅ CORRECT +$user = new User_Model(); +$user->email = $email; +$user->save(); + +// ❌ WRONG +User_Model::create(['email' => $email]); +``` + +### Enums + +**CRITICAL: Read `php artisan rsx:man enum` for complete documentation before implementing.** + +Integer-backed enums with model-level mapping to constants, labels, and custom properties. + +```php +class Project_Model extends Rsx_Model_Abstract { + public static $enums = [ + 'status_id' => [ + 1 => ['constant' => 'STATUS_ACTIVE', 'label' => 'Active', 'badge' => 'bg-success'], + 2 => ['constant' => 'STATUS_ARCHIVED', 'label' => 'Archived', 'selectable' => false], + ], + ]; +} + +// Usage +$project->status_id = Project_Model::STATUS_ACTIVE; +echo $project->status_label; // "Active" +echo $project->status_badge; // "bg-success" (custom property) +``` + +**Migration:** Use BIGINT for enum columns, TINYINT(1) for booleans. Run `rsx:migrate:document_models` after adding enums. + +### Model Fetch + +```php +#[Ajax_Endpoint_Model_Fetch] +public static function fetch($id) +{ + if (!Session::is_logged_in()) return false; + return static::find($id); +} +``` + +```javascript +const project = await Project_Model.fetch(1); // Throws if not found +const maybe = await Project_Model.fetch_or_null(999); // Returns null if not found +console.log(project.status_label); // Enum properties populated +console.log(Project_Model.STATUS_ACTIVE); // Static enum constants + +// Lazy relationships (requires #[Ajax_Endpoint_Model_Fetch] on relationship method) +const client = await project.client(); // belongsTo → Model or null +const tasks = await project.tasks(); // hasMany → Model[] +``` + +**Security**: Both `fetch()` and relationships require `#[Ajax_Endpoint_Model_Fetch]` attribute. Related models must also implement `fetch()` with this attribute. + +Details: `php artisan rsx:man model_fetch` + +### Migrations + +**Forward-only, no rollbacks. Deterministic transformations against known state.** + +```bash +php artisan make:migration:safe create_users_table +php artisan migrate:begin +php artisan migrate +php artisan migrate:commit +``` + +**NO defensive coding in migrations:** +```php +// ❌ WRONG - conditional logic +$fk_exists = DB::select("SELECT ... FROM information_schema..."); +if (!empty($fk_exists)) { DB::statement("ALTER TABLE foo DROP FOREIGN KEY bar"); } + +// ✅ CORRECT - direct statements +DB::statement("ALTER TABLE foo DROP FOREIGN KEY bar"); +DB::statement("ALTER TABLE foo DROP COLUMN baz"); +``` + +No `IF EXISTS`, no `information_schema` queries, no fallbacks. Know current state, write exact transformation. Failures fail loud - snapshot rollback exists for recovery. + +--- + +## FILE ATTACHMENTS + +Files upload UNATTACHED → validate → assign via API. Session-based validation prevents cross-user file assignment. + +```php +// Controller: Assign uploaded file +$attachment = File_Attachment_Model::find_by_key($params['photo_key']); +if ($attachment && $attachment->can_user_assign_this_file()) { + $attachment->attach_to($user, 'profile_photo'); // Single (replaces) + $attachment->add_to($project, 'documents'); // Multiple (adds) +} + +// Model: Retrieve attachments +$photo = $user->get_attachment('profile_photo'); +$documents = $project->get_attachments('documents'); + +// Display +$photo->get_thumbnail_url('cover', 128, 128); +$photo->get_url(); +$photo->get_download_url(); +``` + +**Endpoints:** `POST /_upload`, `GET /_download/:key`, `GET /_thumbnail/:key/:type/:width/:height` + +Details: `php artisan rsx:man file_upload` + +--- + +## AJAX ENDPOINTS + +```php +#[Ajax_Endpoint] +public static function method(Request $request, array $params = []) { + return $data; // Success - framework wraps as {_success: true, _ajax_return_value: ...} +} +``` + +**PHP→JS Auto-mapping:** +```php +// PHP: My_Controller class +#[Ajax_Endpoint] +public static function save(Request $request, array $params = []) { + return ['id' => 123]; +} + +// JS: Automatically callable +const result = await My_Controller.save({name: 'Test'}); +console.log(result.id); // 123 +``` + +### Error Responses + +Use `response_error(Ajax::ERROR_CODE, $metadata)`: + +```php +// Not found +return response_error(Ajax::ERROR_NOT_FOUND, 'Project not found'); + +// Validation +return response_error(Ajax::ERROR_VALIDATION, [ + 'email' => 'Invalid', + 'name' => 'Required' +]); + +// Auto-message +return response_error(Ajax::ERROR_UNAUTHORIZED); +``` + +**Codes:** `ERROR_VALIDATION`, `ERROR_NOT_FOUND`, `ERROR_UNAUTHORIZED`, `ERROR_AUTH_REQUIRED`, `ERROR_FATAL`, `ERROR_GENERIC` + +**Client:** +```javascript +try { + const data = await Controller.get(id); +} catch (e) { + if (e.code === Ajax.ERROR_NOT_FOUND) { + // Handle + } else { + alert(e.message); // Generic + } +} +``` + +Unhandled errors auto-show flash alert. + +--- + +## DATA FETCHING (CRITICAL) + +**DEFAULT**: Use `Model.fetch(id)` for all single-record retrieval from JavaScript. + +```javascript +const user = await User_Model.fetch(1); // Throws if not found +const user = await User_Model.fetch_or_null(1); // Returns null if not found +``` + +Requires `#[Ajax_Endpoint_Model_Fetch]` on the model's `fetch()` method. + +Auto-populates enum properties and enables lazy relationship loading. + +**If model not available in JS bundle**: STOP and ask the developer. Bundles should include all models they need (`rsx/models` in include paths). Do not create workaround endpoints without approval. + +**Custom Ajax endpoints require developer approval** and are only for: +- Aggregations, batch operations, or complex result sets +- System/root-only models intentionally excluded from bundle +- Queries beyond simple ID lookup + +Details: `php artisan rsx:man model_fetch` + +--- + +## AUTHENTICATION + +**Always use Session** - Static methods only. Never Laravel Auth or $_SESSION. + +```php +Session::is_logged_in(); // Returns true if user logged in +Session::get_user(); // Returns user model or null +Session::get_user_id(); // Returns user ID or null +Session::get_site(); // Returns site model +Session::get_site_id(); // Returns current site ID +Session::get_session_id(); // Returns session ID +``` + +Sessions persist 365 days. Never implement "Remember Me". + +--- + +## JAVASCRIPT DECORATORS + +```javascript +/** @decorator */ +function logCalls(target, key, descriptor) { /* ... */ } + +class Service { + @logCalls + @mutex + async save() { /* ... */ } +} +``` + +--- + +## COMMANDS + +### Module Creation + +```bash +rsx:app:module:create # /name +rsx:app:module:feature:create # /m/f +rsx:app:component:create --name=x # Component +``` + +### Development + +```bash +rsx:check # Code quality +rsx:debug /page # Test routes (see below) +rsx:man # Documentation +db:query "SQL" --json +``` + +### Testing Routes + +**`rsx:debug /path`** - Preferred method for testing routes + +Uses Playwright to render the page and show rendered output, JavaScript errors, and console messages. + +```bash +rsx:debug /clients # Test route +rsx:debug /dashboard --user=1 # Simulate authenticated user +rsx:debug /contacts --console # Show console.log output +rsx:debug /page --screenshot-path=/tmp/page.png --screenshot-width=mobile # Capture screenshot +rsx:debug /page --dump-dimensions=".card" # Add position/size data attributes to elements +rsx:debug /path --help # Show all options + +# Simulate user interactions with --eval (executes before DOM capture) +rsx:debug /contacts --user=1 --eval="$('.page-link[data-page=\"2\"]').click(); await sleep(2000)" +rsx:debug /form --eval="$('#name').val('test'); $('form').submit(); await sleep(500)" +``` + +Screenshot presets: mobile, iphone-mobile, tablet, desktop-small, desktop-medium, desktop-large + +The `--eval` option runs JavaScript after page load but before DOM capture. Use `await sleep(ms)` to wait for async operations. This is powerful for testing pagination, form submissions, and other interactive behavior. + +Use this instead of manually browsing, especially for SPA pages and Ajax-heavy features. + +**CRITICAL: SPA routes ARE server routes.** The server knows all SPA routes. `rsx:debug` uses Playwright to fully render pages including all JavaScript and SPA navigation. If you get a 404, the route genuinely doesn't exist - check your URL pattern and route definitions. Never dismiss 404s as "SPA routes can't be tested server-side" - this analysis is incorrect. + +### Debugging + +- **rsx_dump_die()** - Debug output +- **console_debug("CHANNEL", ...)** - Channel logging +- **CONSOLE_DEBUG_FILTER=CHANNEL** - Filter output + +--- + +## ERROR HANDLING + +```php +if (!$expected) { + shouldnt_happen("Class {$expected} missing"); +} +``` + +Use for "impossible" conditions that indicate broken assumptions. + +--- + +## CODE QUALITY + +**Professional UI**: Hover effects ONLY on buttons, links, form fields. Static elements remain static. + +**z-index**: Bootstrap defaults + 1100 (modal children), 1200 (flash alerts), 9000+ (system). Details: `rsx:man zindex` + +Run `rsx:check` before commits. Enforces naming, prohibits animations on non-actionable elements. + +--- + +## KEY REMINDERS + +1. **Fail loud** - No silent failures +2. **Static by default** - Unless instances needed +3. **Path-agnostic** - Reference by name +4. **Bundles required** - For JavaScript +5. **Use Session** - Never Laravel Auth +6. **No mass assignment** - Explicit only +7. **Forward migrations** - No rollbacks +8. **Don't run rsx:clean** - Cache auto-invalidates +9. **All routes need auth checks** - In pre_dispatch() or method body (@auth-exempt for public) + +--- + +## GETTING HELP + +```bash +php artisan rsx:man # Framework documentation +php artisan list rsx # All commands +``` + +**Topics**: bundle_api, jqhtml, routing, migrations, console_debug, model_fetch, vs_code_extension, deployment, framework_divergences + +--- + +## PROJECT DOCUMENTATION + +Project-specific technical documentation lives in `/rsx/resource/man/`. These are man-page-style text files documenting features specific to your application that build on or extend the framework. + +**When to create a project man page**: +- Feature has non-obvious implementation details +- Multiple components interact in ways that need explanation +- Configuration options or patterns need documentation +- AI agents or future developers need reference material + +**Format**: Plain text files (`.txt`) following Unix man page conventions. See `/rsx/resource/man/CLAUDE.md` for writing guidelines. + +**Remember**: RSpade prioritizes simplicity and rapid development. When in doubt, choose the straightforward approach. diff --git a/docs/CLAUDE.dist.md b/docs/CLAUDE.dist.md index 3b4dbfeee..eff5e9028 100644 --- a/docs/CLAUDE.dist.md +++ b/docs/CLAUDE.dist.md @@ -51,7 +51,7 @@ When editing /var/www/html/CLAUDE.md: **FORBIDDEN** (unless explicitly instructed): - `npm run compile/build` - Don't exist - `bin/publish` - For releases, not testing -- `rsx:bundle:compile` / `rsx:manifest:build` - Automatic +- `rsx:bundle:compile` / `rsx:manifest:build` / `rsx:clean` - Automatic - ANY "build", "compile", or "publish" command Edit → Save → Refresh browser → Changes live (< 1 second) @@ -62,8 +62,6 @@ Edit → Save → Refresh browser → Changes live (< 1 second) php artisan rsx:framework:pull # User-initiated only ``` -Updates take 2-5 minutes. Includes code pull, manifest rebuild, bundle recompilation. Only run when requested by user. - ### Fail Loud - No Silent Fallbacks **ALWAYS fail visibly.** No redundant fallbacks, silent failures, or alternative code paths. @@ -132,14 +130,6 @@ cp system/app/RSpade/Core/Models/User_Model.php rsx/models/user_model.php Details: `php artisan rsx:man class_override` -### DO NOT RUN `rsx:clean` - -**RSpade's cache auto-invalidates on file changes.** Running `rsx:clean` causes 30-60 second rebuilds with zero benefit. - -**When to use**: Only on catastrophic corruption, after framework updates (automatic), or when explicitly instructed. - -**Correct workflow**: Edit → Save → Reload browser → See changes (< 1 second) - ### Trust Code Quality Rules Each `rsx:check` rule has remediation text that tells AI assistants exactly what to do: @@ -258,12 +248,8 @@ public static function pre_dispatch(Request $request, array $params = []) { return null; } -// Public endpoints: add @auth-exempt to class docblock -/** @auth-exempt Public route */ ``` -**Code quality**: PHP-AUTH-01 rule verifies auth checks exist. Use `@auth-exempt` for public routes. - ### Type-Safe URLs **MANDATORY**: All URLs must be generated using `Rsx::Route()` - hardcoded URLs are forbidden. @@ -272,10 +258,10 @@ public static function pre_dispatch(Request $request, array $params = []) { // PHP - Controller (defaults to 'index' method) Rsx::Route('User_Controller') -// PHP - Controller with explicit method +// PHP - Controller with explicit method. Passing an integer for param two implies the param two is 'id' (id=123) Rsx::Route('User_Controller::show', 123); -// PHP - With query parameters +// PHP - With query parameters. Extra params not defined in route itself become query string - automatically URL-encoded Rsx::Route('Login_Controller::logout', ['redirect' => '/dashboard']); // Generates: /logout?redirect=%2Fdashboard @@ -284,16 +270,9 @@ Rsx.Route('User_Controller') Rsx.Route('User_Controller::show', 123); Rsx.Route('Login_Controller::logout', {redirect: '/dashboard'}); ``` - -**Signature**: `Rsx::Route($action, $params = null)` / `Rsx.Route(action, params = null)` -- `$action` - Controller class, SPA action, or "Class::method" (defaults to 'index' if no `::` present) -- `$params` - Integer sets 'id', array/object provides named params - **Unimplemented routes**: Prefix method with `#` → `Rsx::Route('Feature::#index')` generates `href="#"` and bypasses validation -**Query Parameters**: Extra params become query string - automatically URL-encoded - -**Enforcement**: `rsx:check` will flag hardcoded URLs like `/login` or `/logout?redirect=...` -and require you to use `Rsx::Route()`. Do it right the first time to avoid rework. +**Enforcement**: `rsx:check` will flag hardcoded URLs like `/login` or `/logout?redirect=...` and require you to use `Rsx::Route()`. Do it right the first time to avoid rework. --- @@ -399,29 +378,24 @@ Pattern: `/rsx/app/(module)/(feature)/` Details: `php artisan rsx:man spa` -### View Action Pattern (Loading Data) +### View Action Pattern -For SPA actions that load data (view/edit CRUD pages), use the three-state pattern: +Three-state pattern for data-loading actions: ```javascript on_create() { - this.data.record = { name: '' }; // Stub prevents undefined errors + this.data.record = { name: '' }; // Stub this.data.error_data = null; this.data.loading = true; } async on_load() { - try { - this.data.record = await Controller.get({id: this.args.id}); - } catch (e) { - this.data.error_data = e; - } + try { this.data.record = await Controller.get({id: this.args.id}); } + catch (e) { this.data.error_data = e; } this.data.loading = false; } ``` -Template uses three states: `` → `` → content. - -**Details**: `php artisan rsx:man view_action_patterns` +Template: `` → `` → content. Details: `rsx:man view_action_patterns` --- @@ -446,42 +420,27 @@ The process involves creating Action classes with @route decorators and converti ### SCSS Component-First Architecture -**Philosophy**: Every styled element is a component. If it needs custom styles, give it a name, a jqhtml definition, and scoped SCSS. This eliminates CSS spaghetti - generic classes overriding each other unpredictably across files. +SCSS in `rsx/app/` and `rsx/theme/components/` must wrap in a single component class matching the jqhtml/blade file. Components auto-render with `class="Component_Name"` on root. `rsx/lib/` is non-visual. `rsx/theme/` (outside components/) holds primitives, variables, Bootstrap overrides. -**Recognition**: When building a page, ask: "Is this structure unique, or a pattern?" A datagrid page with toolbar, tabs, filters, and search is a *pattern* - create `Datagrid_Card` once with slots, use it everywhere. A one-off project dashboard is *unique* - create `Project_Dashboard` for that page. If you're about to copy-paste structural markup, stop and extract a component. +**BEM**: Child classes use component's exact name as prefix. `.Component_Name { &__element }` → HTML: `
` (no kebab-case). -**Composition**: Use slots to separate structure from content. The component owns layout and styling; pages provide the variable parts via slots. This keeps pages declarative and components reusable. - -**Enforcement**: SCSS in `rsx/app/` and `rsx/theme/components/` must wrap in a single component class matching the jqhtml/blade file. This works because all jqhtml components, SPA actions/layouts, and Blade views with `@rsx_id` automatically render with `class="Component_Name"` on their root element. `rsx/lib/` is for non-visual plumbing (validators, utilities). `rsx/theme/` (outside components/) holds primitives, variables, Bootstrap overrides. - -**BEM Child Classes**: When using BEM notation, child element classes must use the component's exact class name as prefix. SCSS `.Component_Name { &__element }` compiles to `.Component_Name__element`, so HTML must match: `
` not `
`. No kebab-case conversion. - -**Variables**: Define shared values (colors, spacing, border-radius) in `rsx/theme/variables.scss` or similar. These must be explicitly included before directory includes in bundle definitions. Component-local variables can be defined within the scoped rule. - -**Supplemental files**: Multiple SCSS files can target the same component (e.g., breakpoint-specific styles) if a primary file with matching filename exists. +**Variables**: `rsx/theme/variables.scss` - must be included before directory includes in bundles. Multiple SCSS files can target same component if primary file exists. Details: `php artisan rsx:man scss` ### Responsive Breakpoints -RSX replaces Bootstrap's default breakpoints (xs/sm/md/lg/xl/xxl) with semantic device names. +RSX replaces Bootstrap breakpoints with semantic names. **Bootstrap defaults (col-md-6, d-lg-none) do NOT work.** -**Tier 1 - Semantic**: -- `mobile`: 0 - 1023px (phone + tablet) -- `desktop`: 1024px+ +| Tier 1 | Range | Tier 2 | Range | +|--------|-------|--------|-------| +| `mobile` | 0-1023px | `phone` | 0-799px | +| `desktop` | 1024px+ | `tablet` | 800-1023px | +| | | `desktop-sm/md/lg` | 1024+ | -**Tier 2 - Granular**: -- `phone`: 0 - 799px | `tablet`: 800 - 1023px | `desktop-sm`: 1024 - 1699px | `desktop-md`: 1700 - 2199px | `desktop-lg`: 2200px+ - -**SCSS Mixins**: `@include mobile { }`, `@include desktop { }`, `@include phone { }`, `@include tablet { }`, `@include desktop-sm { }`, etc. - -**Bootstrap Classes**: `.col-mobile-6`, `.col-desktop-4`, `.d-mobile-none`, `.d-tablet-block`, `.col-phone-12 .col-tablet-6 .col-desktop-sm-4` - -**Utility Classes**: `.mobile-only`, `.desktop-only`, `.phone-only`, `.hide-mobile`, `.hide-tablet` - -**Note**: Bootstrap's default classes like `.col-md-6` or `.d-lg-none` do NOT work - use the RSX breakpoint names instead. - -**JS Detection**: `Responsive.is_mobile()`, `Responsive.is_desktop()` (Tier 1 - broad); `Responsive.is_phone()`, `Responsive.is_tablet()`, `Responsive.is_desktop_sm()`, `Responsive.is_desktop_md()`, `Responsive.is_desktop_lg()` (Tier 2 - specific ranges) +**SCSS**: `@include mobile { }`, `@include desktop { }`, `@include phone { }`, etc. +**Classes**: `.col-mobile-6`, `.d-desktop-none`, `.mobile-only`, `.hide-tablet` +**JS**: `Responsive.is_mobile()`, `Responsive.is_phone()`, `Responsive.is_desktop_sm()`, etc. Details: `php artisan rsx:man responsive` @@ -519,15 +478,9 @@ Use when data doesn't belong in DOM attributes. Multiple calls merge together. --- -## JAVASCRIPT - -**CRITICAL**: JavaScript only executes when bundle rendered. See "JavaScript for Blade Pages" in BLADE & VIEWS section for the `on_app_ready()` pattern. - ---- - ## BUNDLE SYSTEM -**One bundle per page required.** Compiles JS/CSS automatically on request - no manual build steps. +**One bundle per module (rsx/app/(module)).** Compiles JS/CSS automatically on request - no manual build steps. ```php class Frontend_Bundle extends Rsx_Bundle_Abstract @@ -539,7 +492,8 @@ class Frontend_Bundle extends Rsx_Bundle_Abstract 'jquery', // Required 'lodash', // Required 'rsx/theme/variables.scss', // Order matters - 'rsx/app/frontend', // Directory + 'rsx/theme', // Everything else from theme - but variables.scss will be first + 'rsx/app/frontend', // Directory - 'rsx/models', // For JS stubs ], ]; @@ -576,48 +530,30 @@ For mechanical thinkers who see structure, not visuals. Write `` not ``` **Interpolation**: `<%= escaped %>` | `<%!= unescaped %>` | `<% javascript %>` - -**Conditional Attributes** (v2.2.162+): Apply attributes conditionally using `<% if (condition) { %>attr="value"<% } %>` -directly in attribute context. Works with static values, interpolations, and multiple conditions per element. -Example: `required="required"<% } %> />` - +**Conditional Attributes** `required="required"<% } %> />` **Inline Logic**: `<% this.handler = () => action(); %>` then `@click=this.handler` - No JS file needed for simple components **Event Handlers**: `@click=this.method` (unquoted) - Methods defined inline or in companion .js **Validation**: `<% if (!this.args.required) throw new Error('Missing arg'); %>` - Fail loud in template -### Simple Components (No JS File Needed) - -For simple components without external data or complex state, write JS directly in the template: +### Simple Components (No JS File) ```jqhtml <% - // Validate input if (!this.args.csv_data) throw new Error('csv_data required'); - - // Parse CSV const rows = this.args.csv_data.split('\n').map(r => r.split(',')); - - // Define click handler inline this.toggle = () => { this.args.expanded = !this.args.expanded; this.render(); }; %> - <% for (let row of rows) { %> - - <% for (let cell of row) { %> - - <% } %> - + <% for (let cell of row) { %><% } %> <% } %>
<%= cell %>
<%= cell %>
-
``` -**When to use inline JS**: Simple data transformations, conditionals, loops, basic event handlers -**When to create .js file**: External data loading, complex state management, multiple methods, or when JS overwhelms the template (should look mostly like HTML with some logic, not JS with some HTML) +Use inline JS for simple transformations/handlers. Create .js file when JS overwhelms template or needs external data. ### State Management Rules (ENFORCED) @@ -665,90 +601,43 @@ class Toggle_Button extends Component { ### Lifecycle -1. **on_create()** → Setup default state BEFORE template (sync) -2. **render** → Template executes with initialized state +1. **on_create()** → Setup defaults (sync) - `this.data.rows = []; this.data.loading = true;` +2. **render** → Template executes 3. **on_render()** → Hide uninitialized UI (sync) 4. **on_load()** → Fetch data into `this.data` (async) 5. **on_ready()** → DOM manipulation safe (async) -**on_create() now runs first** - Initialize `this.data` properties here so templates can safely reference them: +If `on_load()` modifies `this.data`, component renders twice (defaults → populated). -```javascript -on_create() { - this.data.rows = []; // Prevents "not iterable" errors - this.data.loading = true; // Template can check loading state -} -``` +### Component API -**Double-render**: If `on_load()` modifies `this.data`, component renders twice (defaults → populated). - -### Component API - CRITICAL FOR LLM AGENTS - -This section clarifies common misunderstandings. Read carefully. - -**DOM Access Methods:** +**DOM Access:** | Method | Returns | Purpose | |--------|---------|---------| -| `this.$` | jQuery | Root element of component (stable, survives redraws) | -| `this.$sid('name')` | jQuery | Child element with `$sid="name"` (always returns jQuery, even if empty) | -| `this.sid('name')` | Component or null | Child component instance (null if not found or not a component) | -| `this.$.find('.class')` | jQuery | Standard jQuery find (use when `$sid` isn't appropriate) | - -**WRONG:** `this.$el` - This does not exist. Use `this.$` - -**The reload() Paradigm - MANDATORY:** +| `this.$` | jQuery | Root element (NOT `this.$el`) | +| `this.$sid('name')` | jQuery | Child with `$sid="name"` | +| `this.sid('name')` | Component/null | Child component instance | +**reload() vs render():** ``` -reload() = on_load() → render() → on_ready() -render() = template redraw only (NO on_ready) +reload() = on_load() → render() → on_ready() ← ALWAYS USE THIS +render() = template only (no on_ready) ← NEVER USE ``` -**LLM agents must ALWAYS use `reload()`, NEVER call `render()` directly.** - -When you need to refresh a component after a mutation (add, edit, delete), call `this.reload()`. Yes, this makes another server call via `on_load()`. This is intentional. The extra round-trip is acceptable - our server is fast and the paradigm simplicity is worth it. - -**WRONG approach (do NOT do this):** +After mutations, call `this.reload()` - the server round-trip is intentional: ```javascript -// ❌ BAD - Trying to be "efficient" by skipping server round-trip -async add_item() { - const new_item = await Controller.add({name: 'Test'}); - this.data.items.push(new_item); // ERROR: Cannot modify this.data outside on_load - this.render(); // WRONG: Event handlers will break, on_ready won't run -} -``` - -**CORRECT approach:** -```javascript -// ✅ GOOD - Clean, consistent, reliable async add_item() { await Controller.add({name: 'Test'}); - this.reload(); // Calls on_load() to refresh this.data, then on_ready() for handlers + this.reload(); // Refreshes this.data via on_load(), reattaches handlers via on_ready() } ``` -**Event Handlers - Set Up in on_ready():** +**Event handlers** go in `on_ready()` - they auto-reattach after reload. **WRONG:** Event delegation like `this.$.on('click', '[data-sid="btn"]', handler)` to "survive" render calls - use `reload()` instead. -Event handlers must be registered in `on_ready()`. Since `on_ready()` runs after every `reload()`, handlers automatically reattach when the DOM is redrawn. +**this.data rules (enforced):** Writable only in `on_create()` (defaults) and `on_load()` (fetched data). Read-only elsewhere. -```javascript -on_ready() { - this.$sid('save_btn').click(() => this.save()); - this.$sid('delete_btn').click(() => this.delete()); -} -``` - -**WRONG:** Event delegation to avoid `reload()`. If you find yourself writing `this.$.on('click', '[data-sid="btn"]', handler)` to "survive" render calls, you're doing it wrong. Use `reload()` and let `on_ready()` reattach handlers. - -**this.data Modification Rules (ENFORCED):** - -- `on_create()`: Set defaults only (e.g., `this.data.items = []`) -- `on_load()`: Fetch and assign from server (e.g., `this.data.items = await Controller.list()`) -- **Everywhere else**: Read-only. Attempting to modify `this.data` outside these methods throws an error. - -**on_render() - LLM Should Not Use:** - -`on_render()` exists for human developers doing performance optimization. LLM agents should pretend it doesn't exist. Use `on_ready()` for all post-render DOM work. +**on_render():** Ignore - use `on_ready()` for post-render work. ### Loading Pattern @@ -824,26 +713,15 @@ this.$sid('result_container').component('My_Component', { ``` -### Key Pitfalls (ABSOLUTE RULES) +### Key Pitfalls -1. `` IS the element - use `tag=""` attribute -2. `this.data` starts empty `{}` - MUST set defaults in `on_create()` -3. ONLY modify `this.data` in `on_create()` and `on_load()` (enforced by framework) -4. `on_load()` can ONLY access `this.args` and `this.data` (no DOM, no `this.state`) -5. Use `this.state = {}` in `on_create()` for UI state (not from Ajax) -6. Use `this.args` for reload parameters, call `reload()` to re-fetch -7. Use `Controller.method()` not `$.ajax()` - PHP methods with #[Ajax_Endpoint] auto-callable from JS -8. `on_create/render/stop` must be sync -9. `this.sid()` returns component instance, `$(selector).component()` converts jQuery to component - -### Bundle Integration Required - -```blade -{!! Frontend_Bundle::render() !!} {{-- Required for JS --}} - {{-- Now JS executes --}} -``` - -For advanced topics: `php artisan rsx:man jqhtml` +- `` IS the element - use `tag=""` attribute +- `this.data` starts `{}` - set defaults in `on_create()` +- `this.data` writable only in `on_create()` and `on_load()` +- `on_load()`: only `this.args` and `this.data` (no DOM, no `this.state`) +- `this.state` for UI state, `this.args` + `reload()` for refetch +- `Controller.method()` not `$.ajax()` - #[Ajax_Endpoint] auto-callable +- `on_create/render/stop` sync; `this.sid()` → component, `$(el).component()` → component --- @@ -952,55 +830,69 @@ Details: `php artisan rsx:man polymorphic` ## MODALS -**Basic dialogs**: +### Built-in Dialog Types + +| Method | Returns | Description | +|--------|---------|-------------| +| `Modal.alert(body)` | `void` | Simple notification | +| `Modal.alert(title, body, buttonLabel?)` | `void` | Alert with title | +| `Modal.confirm(body)` | `boolean` | Yes/no confirmation | +| `Modal.confirm(title, body, confirmLabel?, cancelLabel?)` | `boolean` | Confirmation with labels | +| `Modal.prompt(body)` | `string\|false` | Text input | +| `Modal.prompt(title, body, default?, multiline?)` | `string\|false` | Prompt with options | +| `Modal.select(body, options)` | `string\|false` | Dropdown selection | +| `Modal.select(title, body, options, default?, placeholder?)` | `string\|false` | Select with options | +| `Modal.error(error, title?)` | `void` | Error with red styling | +| `Modal.unclosable(title, body)` | `void` | Modal user cannot close | + ```javascript await Modal.alert("File saved"); if (await Modal.confirm("Delete?")) { /* confirmed */ } -let name = await Modal.prompt("Enter name:"); +const name = await Modal.prompt("Enter name:"); +const choice = await Modal.select("Choose:", [{value: 'a', label: 'A'}, {value: 'b', label: 'B'}]); +await Modal.error("Something went wrong"); ``` -**Form modals**: +### Form Modals + ```javascript const result = await Modal.form({ title: "Edit User", component: "User_Form", component_args: {data: user}, on_submit: async (form) => { - const values = form.vals(); - const response = await User_Controller.save(values); - + const response = await User_Controller.save(form.vals()); if (response.errors) { Form_Utils.apply_form_errors(form.$, response.errors); return false; // Keep open } - return response.data; // Close and return } }); ``` -**Requirements**: Form component must implement `vals()` and include `
`. +Form component must implement `vals()` and include `
`. + +### Modal Classes + +For complex/reusable modals, create dedicated classes: -**Modal Classes** (for complex/reusable modals): ```javascript -// Define modal class class Add_User_Modal extends Modal_Abstract { static async show() { - const result = await Modal.form({...}); - return result || false; + return await Modal.form({...}) || false; } } -// Use from page JS +// Usage const user = await Add_User_Modal.show(); if (user) { - // Orchestrate post-modal actions grid.reload(); await Next_Modal.show(user.id); } ``` -Pattern: Extend `Modal_Abstract`, implement static `show()`, return data or `false`. Page JS orchestrates flow, modal classes encapsulate UI. +Pattern: Extend `Modal_Abstract`, implement static `show()`, return data or `false`. Details: `php artisan rsx:man modals` @@ -1222,16 +1114,6 @@ Details: `php artisan rsx:man model_fetch` --- -## BROWSER STORAGE - -**Rsx_Storage** - Scoped sessionStorage/localStorage with automatic fallback and quota management. All keys automatically scoped by session, user, site, and build. Gracefully handles unavailable storage and quota exceeded errors. Storage is volatile - use only for non-critical data (caching, UI state, transient messages). - -`Rsx_Storage.session_set(key, value)` / `Rsx_Storage.session_get(key)` / `Rsx_Storage.local_set(key, value)` / `Rsx_Storage.local_get(key)` - -Details: `php artisan rsx:man storage` - ---- - ## AUTHENTICATION **Always use Session** - Static methods only. Never Laravel Auth or $_SESSION. @@ -1249,6 +1131,51 @@ Sessions persist 365 days. Never implement "Remember Me". --- +## DATE & TIME HANDLING + +**Two Classes - Strict Separation**: `Rsx_Time` (datetimes with timezone) | `Rsx_Date` (calendar dates, no timezone) + +### Rsx_Time - Moments in Time +```php +use App\RSpade\Core\Time\Rsx_Time; + +Rsx_Time::now(); // Current time in user's timezone +Rsx_Time::now_iso(); // ISO 8601 format: 2025-12-24T15:30:00-06:00 +Rsx_Time::format($datetime); // "Dec 24, 2025 3:30 PM" +Rsx_Time::format_short($datetime); // "Dec 24, 3:30 PM" +Rsx_Time::to_database($datetime); // UTC for storage + +Rsx_Time::get_user_timezone(); // User's timezone or default +``` + +```javascript +Rsx_Time.now(); // Current moment (timezone-aware) +Rsx_Time.format(datetime); // Formatted for display +Rsx_Time.relative(datetime); // "2 hours ago", "in 3 days" +``` + +### Rsx_Date - Calendar Dates +```php +use App\RSpade\Core\Time\Rsx_Date; + +Rsx_Date::today(); // "2025-12-24" (user's timezone) +Rsx_Date::format($date); // "Dec 24, 2025" +Rsx_Date::is_today($date); // Boolean +Rsx_Date::is_past($date); // Boolean +``` + +**Key Principle**: Functions throw if wrong type passed (datetime to date function or vice versa). + +### Server Time Sync +Client time syncs automatically via rsxapp data on page load and AJAX responses. No manual sync required. + +### User Timezone +Stored in `login_users.timezone` (IANA format). Falls back to `config('rsx.datetime.default_timezone')`. + +Details: `php artisan rsx:man time` + +--- + ## JAVASCRIPT DECORATORS ```javascript @@ -1285,30 +1212,19 @@ db:query "SQL" --json ### Testing Routes -**`rsx:debug /path`** - Preferred method for testing routes - -Uses Playwright to render the page and show rendered output, JavaScript errors, and console messages. +**`rsx:debug /path`** - Uses Playwright to render pages with full JS execution. ```bash -rsx:debug /clients # Test route -rsx:debug /dashboard --user=1 # Simulate authenticated user -rsx:debug /contacts --console # Show console.log output -rsx:debug /page --screenshot-path=/tmp/page.png --screenshot-width=mobile # Capture screenshot -rsx:debug /page --dump-dimensions=".card" # Add position/size data attributes to elements -rsx:debug /path --help # Show all options - -# Simulate user interactions with --eval (executes before DOM capture) -rsx:debug /contacts --user=1 --eval="$('.page-link[data-page=\"2\"]').click(); await sleep(2000)" -rsx:debug /form --eval="$('#name').val('test'); $('form').submit(); await sleep(500)" +rsx:debug /dashboard --user=1 # Authenticated user +rsx:debug /page --screenshot-path=/tmp/page.png # Capture screenshot +rsx:debug /contacts --eval="$('.btn').click(); await sleep(1000)" # Simulate interaction +rsx:debug / --eval="return Rsx_Time.now_iso()" # Get eval result (use return) +rsx:debug / --console --eval="console.log(Rsx_Date.today())" # Or console.log with --console ``` -Screenshot presets: mobile, iphone-mobile, tablet, desktop-small, desktop-medium, desktop-large +Options: `--user=ID`, `--console`, `--screenshot-path`, `--screenshot-width=mobile|tablet|desktop-*`, `--dump-dimensions=".selector"`, `--eval="js"`, `--help` -The `--eval` option runs JavaScript after page load but before DOM capture. Use `await sleep(ms)` to wait for async operations. This is powerful for testing pagination, form submissions, and other interactive behavior. - -Use this instead of manually browsing, especially for SPA pages and Ajax-heavy features. - -**CRITICAL: SPA routes ARE server routes.** The server knows all SPA routes. `rsx:debug` uses Playwright to fully render pages including all JavaScript and SPA navigation. If you get a 404, the route genuinely doesn't exist - check your URL pattern and route definitions. Never dismiss 404s as "SPA routes can't be tested server-side" - this analysis is incorrect. +**SPA routes ARE server routes.** If you get 404, the route doesn't exist - check route definitions. Never dismiss as "SPA can't be tested server-side". ### Debugging @@ -1340,21 +1256,6 @@ Run `rsx:check` before commits. Enforces naming, prohibits animations on non-act --- -## MAIN_ABSTRACT MIDDLEWARE - -Optional `/rsx/main.php`: - -```php -class Main extends Main_Abstract -{ - public function init() { } // Bootstrap once - public function pre_dispatch($request, $params) { return null; } // Before routes - public function unhandled_route($request, $params) { } // 404s -} -``` - ---- - ## KEY REMINDERS 1. **Fail loud** - No silent failures @@ -1382,14 +1283,4 @@ php artisan list rsx # All commands ## PROJECT DOCUMENTATION -Project-specific technical documentation lives in `/rsx/resource/man/`. These are man-page-style text files documenting features specific to your application that build on or extend the framework. - -**When to create a project man page**: -- Feature has non-obvious implementation details -- Multiple components interact in ways that need explanation -- Configuration options or patterns need documentation -- AI agents or future developers need reference material - -**Format**: Plain text files (`.txt`) following Unix man page conventions. See `/rsx/resource/man/CLAUDE.md` for writing guidelines. - -**Remember**: RSpade prioritizes simplicity and rapid development. When in doubt, choose the straightforward approach. +Project-specific man pages in `/rsx/resource/man/*.txt`. Create when features have non-obvious details or component interactions. See `/rsx/resource/man/CLAUDE.md` for format.