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":";;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,+CAAiC;AACjC,uCAAyB;AACzB,2CAA6B;AAE7B;;;;;;;;GAQG;AACH,MAAa,mBAAmB;IAAhC;QACqB,gCAA2B,GACxC,IAAI,MAAM,CAAC,YAAY,EAAyC,CAAC;QAErD,+BAA0B,GACtC,IAAI,CAAC,2BAA2B,CAAC,KAAK,CAAC;IAsG/C,CAAC;IApGG;;;OAGG;IACK,gBAAgB;QACpB,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,gBAAgB,EAAE;YACpC,OAAO,SAAS,CAAC;SACpB;QAED,8EAA8E;QAC9E,oCAAoC;QACpC,KAAK,MAAM,MAAM,IAAI,MAAM,CAAC,SAAS,CAAC,gBAAgB,EAAE;YACpD,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;YACpD,MAAM,iBAAiB,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC;YAElF,2DAA2D;YAC3D,IAAI,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,UAAU,CAAC,iBAAiB,CAAC,EAAE;gBAC5D,OAAO,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC;aAC5B;YAED,qCAAqC;YACrC,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC;YACjE,IAAI,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,EAAE;gBAC3B,OAAO,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC;aAC5B;SACJ;QAED,OAAO,SAAS,CAAC;IACrB,CAAC;IAED,qBAAqB,CACjB,GAAe,EACf,KAA+B;QAE/B,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,gBAAgB,EAAE;YACpC,OAAO,CAAC,GAAG,CAAC,oCAAoC,CAAC,CAAC;YAClD,OAAO,SAAS,CAAC;SACpB;QAED,MAAM,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QAE/C,2EAA2E;QAC3E,MAAM,eAAe,GAAG,MAAM,CAAC,SAAS,CAAC,gBAAgB,CAAC,IAAI,CAC1D,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,KAAK,OAAO,CAC9D,CAAC;QAEF,IAAI,eAAe,EAAE;YACjB,MAAM,UAAU,GAAG,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC;YACtD,OAAO,CAAC,GAAG,CAAC,iCAAiC,EAAE,UAAU,CAAC,CAAC;YAE3D,wCAAwC;YACxC,IAAI,UAAU,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE;gBAC5B,OAAO,IAAI,MAAM,CAAC,cAAc,CAC5B,SAAS,EACT,SAAS,EACT,IAAI,MAAM,CAAC,UAAU,CAAC,aAAa,CAAC,CACvC,CAAC;aACL;YACD,iDAAiD;YACjD,IAAI,UAAU,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE;gBAC/B,OAAO,IAAI,MAAM,CAAC,cAAc,CAC5B,SAAS,EACT,SAAS,EACT,IAAI,MAAM,CAAC,UAAU,CAAC,uBAAuB,CAAC,CACjD,CAAC;aACL;SACJ;QAED,4DAA4D;QAC5D,MAAM,aAAa,GAAG,IAAI,CAAC,gBAAgB,EAAE,CAAC;QAC9C,IAAI,CAAC,aAAa,EAAE;YAChB,OAAO,SAAS,CAAC;SACpB;QACD,MAAM,YAAY,GAAG,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,EAAE,CAAC,CAAC;QAElF,uDAAuD;QACvD,IAAI,YAAY,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE;YAC5B,OAAO,SAAS,CAAC;SACpB;QAED,qBAAqB;QACrB,IAAI,YAAY,KAAK,KAAK,EAAE;YACxB,OAAO,IAAI,MAAM,CAAC,cAAc,CAC5B,SAAS,EACT,SAAS,EACT,IAAI,MAAM,CAAC,UAAU,CAAC,aAAa,CAAC,CACvC,CAAC;SACL;QAED,+CAA+C;QAC/C,IAAI,YAAY,KAAK,QAAQ,IAAI,YAAY,KAAK,KAAK,IAAI,YAAY,KAAK,QAAQ,EAAE;YAClF,OAAO,IAAI,MAAM,CAAC,cAAc,CAC5B,SAAS,EACT,SAAS,EACT,IAAI,MAAM,CAAC,UAAU,CAAC,uBAAuB,CAAC,CACjD,CAAC;SACL;QAED,OAAO,SAAS,CAAC;IACrB,CAAC;CACJ;AA3GD,kDA2GC"}
\ No newline at end of file
+{"version":3,"file":"folder_color_provider.js","sourceRoot":"","sources":["../src/folder_color_provider.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,+CAAiC;AACjC,uCAAyB;AACzB,2CAA6B;AAE7B;;;;;;;;;GASG;AACH,MAAa,mBAAmB;IAAhC;QACqB,gCAA2B,GACxC,IAAI,MAAM,CAAC,YAAY,EAAyC,CAAC;QAErD,+BAA0B,GACtC,IAAI,CAAC,2BAA2B,CAAC,KAAK,CAAC;IAgH/C,CAAC;IA9GG;;;OAGG;IACK,gBAAgB;QACpB,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,gBAAgB,EAAE;YACpC,OAAO,SAAS,CAAC;SACpB;QAED,8EAA8E;QAC9E,oCAAoC;QACpC,KAAK,MAAM,MAAM,IAAI,MAAM,CAAC,SAAS,CAAC,gBAAgB,EAAE;YACpD,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;YACpD,MAAM,iBAAiB,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC;YAElF,2DAA2D;YAC3D,IAAI,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,UAAU,CAAC,iBAAiB,CAAC,EAAE;gBAC5D,OAAO,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC;aAC5B;YAED,qCAAqC;YACrC,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC;YACjE,IAAI,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,EAAE;gBAC3B,OAAO,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC;aAC5B;SACJ;QAED,OAAO,SAAS,CAAC;IACrB,CAAC;IAED,qBAAqB,CACjB,GAAe,EACf,KAA+B;QAE/B,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,gBAAgB,EAAE;YACpC,OAAO,CAAC,GAAG,CAAC,oCAAoC,CAAC,CAAC;YAClD,OAAO,SAAS,CAAC;SACpB;QAED,MAAM,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QAE/C,4DAA4D;QAC5D,8DAA8D;QAC9D,IAAI,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,IAAI,CAAC,gBAAgB,EAAE,EAAE;YACxD,OAAO,IAAI,MAAM,CAAC,cAAc,CAC5B,SAAS,EACT,SAAS,EACT,IAAI,MAAM,CAAC,UAAU,CAAC,uBAAuB,CAAC,CACjD,CAAC;SACL;QAED,2EAA2E;QAC3E,MAAM,eAAe,GAAG,MAAM,CAAC,SAAS,CAAC,gBAAgB,CAAC,IAAI,CAC1D,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,KAAK,OAAO,CAC9D,CAAC;QAEF,IAAI,eAAe,EAAE;YACjB,MAAM,UAAU,GAAG,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC;YACtD,OAAO,CAAC,GAAG,CAAC,iCAAiC,EAAE,UAAU,CAAC,CAAC;YAE3D,wCAAwC;YACxC,IAAI,UAAU,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE;gBAC5B,OAAO,IAAI,MAAM,CAAC,cAAc,CAC5B,SAAS,EACT,SAAS,EACT,IAAI,MAAM,CAAC,UAAU,CAAC,aAAa,CAAC,CACvC,CAAC;aACL;YACD,iDAAiD;YACjD,IAAI,UAAU,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE;gBAC/B,OAAO,IAAI,MAAM,CAAC,cAAc,CAC5B,SAAS,EACT,SAAS,EACT,IAAI,MAAM,CAAC,UAAU,CAAC,uBAAuB,CAAC,CACjD,CAAC;aACL;SACJ;QAED,4DAA4D;QAC5D,MAAM,aAAa,GAAG,IAAI,CAAC,gBAAgB,EAAE,CAAC;QAC9C,IAAI,CAAC,aAAa,EAAE;YAChB,OAAO,SAAS,CAAC;SACpB;QACD,MAAM,YAAY,GAAG,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,EAAE,CAAC,CAAC;QAElF,uDAAuD;QACvD,IAAI,YAAY,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE;YAC5B,OAAO,SAAS,CAAC;SACpB;QAED,qBAAqB;QACrB,IAAI,YAAY,KAAK,KAAK,EAAE;YACxB,OAAO,IAAI,MAAM,CAAC,cAAc,CAC5B,SAAS,EACT,SAAS,EACT,IAAI,MAAM,CAAC,UAAU,CAAC,aAAa,CAAC,CACvC,CAAC;SACL;QAED,+CAA+C;QAC/C,IAAI,YAAY,KAAK,QAAQ,IAAI,YAAY,KAAK,KAAK,IAAI,YAAY,KAAK,QAAQ,EAAE;YAClF,OAAO,IAAI,MAAM,CAAC,cAAc,CAC5B,SAAS,EACT,SAAS,EACT,IAAI,MAAM,CAAC,UAAU,CAAC,uBAAuB,CAAC,CACjD,CAAC;SACL;QAED,OAAO,SAAS,CAAC;IACrB,CAAC;CACJ;AArHD,kDAqHC"}
\ 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 b68a002ba..a61ec8763 100755
Binary files a/app/RSpade/resource/vscode_extension/rspade-framework.vsix and b/app/RSpade/resource/vscode_extension/rspade-framework.vsix differ
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) { %>
-
<%= cell %>
- <% } %>
-
+
<% 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)
+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.