Add datetime system (Rsx_Time/Rsx_Date) and .expect file documentation system

Tighten CLAUDE.dist.md for LLM audience - 15% size reduction
Add Repeater_Simple_Input component for managing lists of simple values
Add Polymorphic_Field_Helper for JSON-encoded polymorphic form fields
Fix incorrect data-sid selector in route-debug help example
Fix Form_Utils to use component.$sid() instead of data-sid selector
Add response helper functions and use _message as reserved metadata key

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-12-24 21:47:53 +00:00
parent eb3ccd722d
commit 1b57ec2785
76 changed files with 4778 additions and 289 deletions

View File

@@ -11,27 +11,27 @@ class JsFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract
{ {
return 'JS-FALLBACK-01'; return 'JS-FALLBACK-01';
} }
public function get_name(): string public function get_name(): string
{ {
return 'JavaScript Fallback/Legacy Code Check'; return 'JavaScript Fallback/Legacy Code Check';
} }
public function get_description(): string public function get_description(): string
{ {
return 'Enforces fail-loud principle - no fallback implementations allowed'; return 'Enforces fail-loud principle - no fallback implementations allowed';
} }
public function get_file_patterns(): array public function get_file_patterns(): array
{ {
return ['*.js']; return ['*.js'];
} }
public function get_default_severity(): string public function get_default_severity(): string
{ {
return 'critical'; return 'critical';
} }
/** /**
* Check JavaScript file for fallback/legacy code in comments and function calls (from line 1415) * Check JavaScript file for fallback/legacy code in comments and function calls (from line 1415)
* Enforces fail-loud principle - no fallback implementations allowed * 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/')) { if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return; return;
} }
// Skip CodeQuality directory // Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) { if (str_contains($file_path, '/CodeQuality/')) {
return; return;
} }
// Use original content to check comments before sanitization // Use original content to check comments before sanitization
$original_content = file_get_contents($file_path); $original_content = file_get_contents($file_path);
$lines = explode("\n", $original_content); $lines = explode("\n", $original_content);
// Also get sanitized content to check for function calls // Also get sanitized content to check for function calls
$sanitized_data = FileSanitizer::sanitize_javascript($file_path); $sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines']; $sanitized_lines = $sanitized_data['lines'];
foreach ($lines as $line_num => $line) { foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1; $line_number = $line_num + 1;
// Check for fallback or legacy in comments (case insensitive, with word boundaries) // Check for fallback or legacy in comments (case insensitive, with word boundaries)
// But allow fallback* or legacy* as marked exceptions // 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) ||
preg_match('/\*.*\b(fallback|legacy)\b(?!\*)/i', $line)) { preg_match('/\*.*\b(fallback|legacy)\b(?!\*)/i', $line)) {
$this->add_violation( $this->add_violation(
$file_path, $file_path,
$line_number, $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), 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' 'critical'
); );
} }
@@ -111,4 +111,111 @@ class JsFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract
} }
} }
} }
}
/**
* 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;
}
}

View File

@@ -10,27 +10,27 @@ class PhpFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract
{ {
return 'PHP-FALLBACK-01'; return 'PHP-FALLBACK-01';
} }
public function get_name(): string public function get_name(): string
{ {
return 'PHP Fallback/Legacy Code Check'; return 'PHP Fallback/Legacy Code Check';
} }
public function get_description(): string public function get_description(): string
{ {
return 'Enforces fail-loud principle - no fallback implementations allowed'; return 'Enforces fail-loud principle - no fallback implementations allowed';
} }
public function get_file_patterns(): array public function get_file_patterns(): array
{ {
return ['*.php']; return ['*.php'];
} }
public function get_default_severity(): string public function get_default_severity(): string
{ {
return 'critical'; return 'critical';
} }
/** /**
* Check PHP file for fallback/legacy code in comments and function calls (from line 1474) * Check PHP file for fallback/legacy code in comments and function calls (from line 1474)
* Enforces fail-loud principle - no fallback implementations allowed * Enforces fail-loud principle - no fallback implementations allowed
@@ -41,41 +41,41 @@ class PhpFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract
if (str_contains($file_path, '/vendor/')) { if (str_contains($file_path, '/vendor/')) {
return; return;
} }
// Skip CodeQuality directory // Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) { if (str_contains($file_path, '/CodeQuality/')) {
return; return;
} }
// Skip RsxCheckCommand.php - it documents what the checks do // Skip RsxCheckCommand.php - it documents what the checks do
if (str_contains($file_path, 'RsxCheckCommand.php')) { if (str_contains($file_path, 'RsxCheckCommand.php')) {
return; return;
} }
// Use original content to check comments // Use original content to check comments
$original_content = file_get_contents($file_path); $original_content = file_get_contents($file_path);
$lines = explode("\n", $original_content); $lines = explode("\n", $original_content);
foreach ($lines as $line_num => $line) { foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1; $line_number = $line_num + 1;
// Skip line if it's entirely a comment (starts with // after whitespace) // Skip line if it's entirely a comment (starts with // after whitespace)
$trimmed_line = trim($line); $trimmed_line = trim($line);
$is_line_comment = str_starts_with($trimmed_line, '//'); $is_line_comment = str_starts_with($trimmed_line, '//');
// Check for fallback or legacy in comments (case insensitive, with word boundaries) // Check for fallback or legacy in comments (case insensitive, with word boundaries)
// But allow fallback* or legacy* as marked exceptions // 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) ||
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 preg_match('/#.*\b(fallback|legacy)\b(?!\*)/i', $line)) { // Also check # comments
$this->add_violation( $this->add_violation(
$file_path, $file_path,
$line_number, $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), 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' 'critical'
); );
} }
@@ -103,7 +103,7 @@ class PhpFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract
// Remove inline comments before checking // Remove inline comments before checking
$code_part = preg_replace('/\/\/.*$/', '', $line); $code_part = preg_replace('/\/\/.*$/', '', $line);
$code_part = preg_replace('/\/\*.*?\*\//', '', $code_part); $code_part = preg_replace('/\/\*.*?\*\//', '', $code_part);
// Check if function starting or ending with "fallback" exists in non-comment 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)) { if (preg_match('/\b(fallback\w*|\w+fallback)\s*\(/i', $code_part)) {
$this->add_violation( $this->add_violation(
@@ -118,4 +118,111 @@ class PhpFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract
} }
} }
} }
}
/**
* 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;
}
}

View File

@@ -175,6 +175,7 @@ class Man_Command extends Command
/** /**
* Get all documentation files, deduplicated by filename. * Get all documentation files, deduplicated by filename.
* Files from earlier directories in docs_dirs take precedence. * Files from earlier directories in docs_dirs take precedence.
* Excludes .expect files (behavioral expectation documentation for testing).
* *
* @return array * @return array
*/ */
@@ -186,6 +187,10 @@ class Man_Command extends Command
$dir_files = glob($dir . '/*.txt'); $dir_files = glob($dir . '/*.txt');
if ($dir_files) { if ($dir_files) {
foreach ($dir_files as $file) { foreach ($dir_files as $file) {
// Skip .expect files (behavioral expectation docs)
if (str_ends_with($file, '.expect')) {
continue;
}
$name = basename($file, '.txt'); $name = basename($file, '.txt');
// First occurrence wins (higher priority directory) // First occurrence wins (higher priority directory)
if (!isset($files_by_name[$name])) { if (!isset($files_by_name[$name])) {

View File

@@ -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(' php artisan rsx:debug /admin --user=admin@example.com # Test as user by email');
$this->line(''); $this->line('');
$this->comment('TESTING RSX JAVASCRIPT:'); $this->comment('TESTING RSX JAVASCRIPT (use return or console.log for output):');
$this->line(' php artisan rsx:debug /demo --eval="Rsx.Route(\'Demo_Controller\').url()" --no-body'); $this->line(' php artisan rsx:debug / --eval="return typeof Rsx_Time" # Check if class exists');
$this->line(' php artisan rsx:debug /demo --eval="JSON.stringify(Rsx._routes)" --no-body'); $this->line(' php artisan rsx:debug / --eval="return Rsx_Time.now_iso()" # Get current time');
$this->line(' php artisan rsx:debug /demo --eval="Rsx.is_dev()" --no-body'); $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->line('');
$this->comment('POST-LOAD INTERACTIONS (click buttons, test modals, etc):'); $this->comment('POST-LOAD INTERACTIONS (click buttons, test modals, etc):');

View File

@@ -368,6 +368,8 @@ class Ajax
'error_code' => $response->get_error_code(), 'error_code' => $response->get_error_code(),
'reason' => $response->get_reason(), 'reason' => $response->get_reason(),
'metadata' => $response->get_metadata(), '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 // Add console debug messages if any
@@ -467,6 +469,8 @@ class Ajax
$json_response = [ $json_response = [
'_success' => true, '_success' => true,
'_ajax_return_value' => $response, '_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 // Add console debug messages if any

View File

@@ -301,6 +301,10 @@ abstract class Rsx_Bundle_Abstract
$rsxapp_data['flash_alerts'] = $flash_messages; $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 // Add console_debug config in non-production mode
if (!app()->environment('production')) { if (!app()->environment('production')) {
$console_debug_config = config('rsx.console_debug', []); $console_debug_config = config('rsx.console_debug', []);

View File

@@ -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 // Handle flash_alerts from server
if (response.flash_alerts && Array.isArray(response.flash_alerts)) { if (response.flash_alerts && Array.isArray(response.flash_alerts)) {
Server_Side_Flash.process(response.flash_alerts); Server_Side_Flash.process(response.flash_alerts);
@@ -348,8 +356,16 @@ class Ajax {
__local_integration: true, // Bypass $.ajax override __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 // 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) { for (const response_key in response) {
if (!response_key.startsWith('C_')) { if (!response_key.startsWith('C_')) {
continue; continue;

239
app/RSpade/Core/Js/Rsx_Date.js Executable file
View File

@@ -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));
}
}

612
app/RSpade/Core/Js/Rsx_Time.js Executable file
View File

@@ -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)
};
}
}

View File

@@ -0,0 +1,360 @@
<?php
namespace App\RSpade\Core;
use Carbon\Carbon;
/**
* Schedule Field Helper
*
* Handles JSON-encoded schedule field values from form components.
* Provides parsing, validation, and model assignment for scheduling data.
*
* Schedule fields are submitted as JSON:
* {
* "date": "2025-12-23",
* "start_time": "09:00",
* "duration_minutes": 60,
* "is_all_day": false,
* "timezone": "America/Chicago"
* }
*
* Usage in controllers:
*
* use App\RSpade\Core\Schedule_Field_Helper;
*
* // Parse and validate a schedule field
* $schedule = Schedule_Field_Helper::parse($params['schedule']);
*
* // Quick validation with error messages (returns dot-notation errors)
* $errors = $schedule->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;
}
}

245
app/RSpade/Core/Time/Rsx_Date.php Executable file
View File

@@ -0,0 +1,245 @@
<?php
namespace App\RSpade\Core\Time;
use Carbon\Carbon;
use App\RSpade\Core\Time\Rsx_Time;
/**
* 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
*/
private const 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 mixed $input
* @return string|null Returns "YYYY-MM-DD" or null
* @throws \InvalidArgumentException If input is a datetime
*/
public static function parse($input): ?string
{
if ($input === null || $input === '') {
return null;
}
// Already a valid date string
if (is_string($input) && preg_match(self::DATE_PATTERN, $input)) {
// Validate it's a real date
$parts = explode('-', $input);
if (checkdate((int)$parts[1], (int)$parts[2], (int)$parts[0])) {
return $input;
}
return null;
}
// Reject Carbon/DateTime objects - these are datetimes
if ($input instanceof \DateTimeInterface) {
throw new \InvalidArgumentException(
"Rsx_Date::parse() received DateTime object. " .
"Use Rsx_Time::parse() for datetimes with time components."
);
}
// Reject timestamps - these are datetimes
if (is_numeric($input)) {
throw new \InvalidArgumentException(
"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 (is_string($input)) {
if (str_contains($input, 'T') || preg_match('/\d{2}:\d{2}/', $input)) {
throw new \InvalidArgumentException(
"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 mixed $input
* @return bool
*/
public static function is_date($input): bool
{
if (!is_string($input)) {
return false;
}
if (!preg_match(self::DATE_PATTERN, $input)) {
return false;
}
// Validate it's a real date
$parts = explode('-', $input);
return checkdate((int)$parts[1], (int)$parts[2], (int)$parts[0]);
}
// =========================================================================
// CURRENT DATE
// =========================================================================
/**
* Get today's date as "YYYY-MM-DD"
* Uses the user's timezone to determine what "today" is
*
* @return string
*/
public static function today(): string
{
$user_tz = Rsx_Time::get_user_timezone();
return Carbon::now($user_tz)->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);
}
}

537
app/RSpade/Core/Time/Rsx_Time.php Executable file
View File

@@ -0,0 +1,537 @@
<?php
namespace App\RSpade\Core\Time;
use Carbon\Carbon;
use App\RSpade\Core\Session\Session;
/**
* Rsx_Time - Datetime handling for RSpade (moments in time with timezone)
*
* 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 methods are static. Time values are represented as:
* - Carbon objects (internal)
* - ISO 8601 strings for serialization: "2024-12-24T15:30:45.123Z"
* - Unix timestamps (milliseconds) for JavaScript interop
*
* Core Principles:
* - All datetimes stored in database as UTC
* - All serialization uses ISO 8601 format
* - User timezone stored per user (login_users.timezone)
* - Formatting happens on-demand, not on storage
* - 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
{
// =========================================================================
// CURRENT TIME
// =========================================================================
/**
* Get current time as UTC Carbon instance
*
* @return Carbon
*/
public static function now(): Carbon
{
return Carbon::now('UTC');
}
/**
* Get current time as ISO 8601 UTC string
* Format: "2024-12-24T15:30:45.123Z"
*
* @return string
*/
public static function now_iso(): string
{
return static::now()->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');
}
}

View File

@@ -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):
<Form_Field $name="schedule" $label="Date & Time">
<Schedule_Input />
</Form_Field>
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:
<Form_Field $name="schedule">
<Schedule_Input />
</Form_Field>
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:
<Form_Field $name="schedule" $label="Date & Time" $required=true>
<Schedule_Input />
</Form_Field>
Without timezone picker:
<Schedule_Input $name="schedule" $show_timezone=false />
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');
$('<div class="invalid-feedback">').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
<Rsx_Form $data=this.data.form_data $controller="Events_Controller" $method="save">
<Form_Field $name="title" $label="Title" $required=true>
<Text_Input />
</Form_Field>
<Form_Field $name="schedule" $label="Date & Time" $required=true>
<Schedule_Input $sid="schedule_input" />
</Form_Field>
<button type="submit" class="btn btn-primary">Save</button>
</Rsx_Form>
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)

172
app/RSpade/man/expect_files.txt Executable file
View File

@@ -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: <short description>
GIVEN: <preconditions>
WHEN: <action>
THEN: <expected outcome>
---
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

View File

@@ -178,13 +178,28 @@ JAVASCRIPT EVALUATION
behavior. Use `await` for async operations and `await sleep(ms)` to behavior. Use `await` for async operations and `await sleep(ms)` to
wait for side effects to complete before the DOM snapshot. wait for side effects to complete before the DOM snapshot.
Simple examples: GETTING OUTPUT FROM EVAL:
--eval="Rsx.Route('Demo_Controller').url()"
--eval="JSON.stringify(Rsx._routes)"
--eval="typeof jQuery"
--eval="document.title"
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 # Click a pagination button and wait for results
--eval="$('.page-link[data-page=\"2\"]').click(); await sleep(2000)" --eval="$('.page-link[data-page=\"2\"]').click(); await sleep(2000)"

383
app/RSpade/man/time.txt Executable file
View File

@@ -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)

View File

View File

View File

View File

0
app/RSpade/resource/vscode_extension/out/config.js Executable file → Normal file
View File

0
app/RSpade/resource/vscode_extension/out/config.js.map Executable file → Normal file
View File

View File

View File

View File

View File

View File

View File

View File

0
app/RSpade/resource/vscode_extension/out/extension.js Executable file → Normal file
View File

View File

View File

View File

View File

@@ -35,6 +35,7 @@ const path = __importStar(require("path"));
* - system/ - Muted gray * - system/ - Muted gray
* - app/ - Muted gray (legacy structure) * - app/ - Muted gray (legacy structure)
* - routes/ - Muted gray (legacy structure) * - routes/ - Muted gray (legacy structure)
* - *.expect files - Muted gray (behavioral expectation documentation)
*/ */
class FolderColorProvider { class FolderColorProvider {
constructor() { constructor() {
@@ -72,6 +73,11 @@ class FolderColorProvider {
return undefined; return undefined;
} }
const uriPath = uri.fsPath.replace(/\\/g, '/'); 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) // 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); const workspaceFolder = vscode.workspace.workspaceFolders.find(folder => folder.uri.fsPath.replace(/\\/g, '/') === uriPath);
if (workspaceFolder) { if (workspaceFolder) {

View File

@@ -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"} {"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"}

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

@@ -2,7 +2,7 @@
"name": "rspade-framework", "name": "rspade-framework",
"displayName": "RSpade Framework Support", "displayName": "RSpade Framework Support",
"description": "VS Code extension for RSpade framework with code folding, formatting, and namespace management", "description": "VS Code extension for RSpade framework with code folding, formatting, and namespace management",
"version": "0.1.220", "version": "0.1.221",
"publisher": "rspade", "publisher": "rspade",
"engines": { "engines": {
"vscode": "^1.74.0" "vscode": "^1.74.0"

View File

@@ -10,6 +10,7 @@ import * as path from 'path';
* - system/ - Muted gray * - system/ - Muted gray
* - app/ - Muted gray (legacy structure) * - app/ - Muted gray (legacy structure)
* - routes/ - Muted gray (legacy structure) * - routes/ - Muted gray (legacy structure)
* - *.expect files - Muted gray (behavioral expectation documentation)
*/ */
export class FolderColorProvider implements vscode.FileDecorationProvider { export class FolderColorProvider implements vscode.FileDecorationProvider {
private readonly _onDidChangeFileDecorations: vscode.EventEmitter<vscode.Uri | vscode.Uri[] | undefined> = private readonly _onDidChangeFileDecorations: vscode.EventEmitter<vscode.Uri | vscode.Uri[] | undefined> =
@@ -59,6 +60,16 @@ export class FolderColorProvider implements vscode.FileDecorationProvider {
const uriPath = uri.fsPath.replace(/\\/g, '/'); 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) // Check if this URI is a workspace folder root (for multi-root workspaces)
const workspaceFolder = vscode.workspace.workspaceFolders.find( const workspaceFolder = vscode.workspace.workspaceFolders.find(
folder => folder.uri.fsPath.replace(/\\/g, '/') === uriPath folder => folder.uri.fsPath.replace(/\\/g, '/') === uriPath

View File

@@ -574,4 +574,25 @@ return [
// 'email' => ['max_workers' => 5], // '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,
],
]; ];

View File

@@ -346,6 +346,11 @@
"created_at": "2025-12-11T06:15:51+00:00", "created_at": "2025-12-11T06:15:51+00:00",
"created_by": "root", "created_by": "root",
"command": "php artisan make:migration:safe create_groups_and_group_users_tables" "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"
} }
} }
} }

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* IMPORTANT: Use raw MySQL queries for clarity and auditability
* DB::statement("ALTER TABLE login_users ADD COLUMN new_field VARCHAR(255)")
* Schema::table() with Blueprint
*
* Migrations must be self-contained - no Model/Service references
*
* @return void
*/
public function up()
{
// Add timezone column to login_users table
// Stores IANA timezone identifier (e.g., "America/Chicago")
// NULL = use system/site default
DB::statement("
ALTER TABLE login_users
ADD COLUMN timezone VARCHAR(50) DEFAULT NULL
AFTER status_id
");
}
/**
* down() method is prohibited in RSpade framework
* Migrations should only move forward, never backward
* You may remove this comment as soon as you see it and understand.
*/
};

1336
docs/CLAUDE.archive.12.24.25.md Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -51,7 +51,7 @@ When editing /var/www/html/CLAUDE.md:
**FORBIDDEN** (unless explicitly instructed): **FORBIDDEN** (unless explicitly instructed):
- `npm run compile/build` - Don't exist - `npm run compile/build` - Don't exist
- `bin/publish` - For releases, not testing - `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 - ANY "build", "compile", or "publish" command
Edit → Save → Refresh browser → Changes live (< 1 second) 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 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 ### Fail Loud - No Silent Fallbacks
**ALWAYS fail visibly.** No redundant fallbacks, silent failures, or alternative code paths. **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` 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 ### Trust Code Quality Rules
Each `rsx:check` rule has remediation text that tells AI assistants exactly what to do: 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; 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 ### Type-Safe URLs
**MANDATORY**: All URLs must be generated using `Rsx::Route()` - hardcoded URLs are forbidden. **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) // PHP - Controller (defaults to 'index' method)
Rsx::Route('User_Controller') 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); 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']); Rsx::Route('Login_Controller::logout', ['redirect' => '/dashboard']);
// Generates: /logout?redirect=%2Fdashboard // Generates: /logout?redirect=%2Fdashboard
@@ -284,16 +270,9 @@ Rsx.Route('User_Controller')
Rsx.Route('User_Controller::show', 123); Rsx.Route('User_Controller::show', 123);
Rsx.Route('Login_Controller::logout', {redirect: '/dashboard'}); 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 - **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` 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 ```javascript
on_create() { on_create() {
this.data.record = { name: '' }; // Stub prevents undefined errors this.data.record = { name: '' }; // Stub
this.data.error_data = null; this.data.error_data = null;
this.data.loading = true; this.data.loading = true;
} }
async on_load() { async on_load() {
try { try { this.data.record = await Controller.get({id: this.args.id}); }
this.data.record = await Controller.get({id: this.args.id}); catch (e) { this.data.error_data = e; }
} catch (e) {
this.data.error_data = e;
}
this.data.loading = false; this.data.loading = false;
} }
``` ```
Template uses three states: `<Loading_Spinner>``<Universal_Error_Page_Component>` → content. Template: `<Loading_Spinner>``<Universal_Error_Page_Component>` → content. Details: `rsx:man view_action_patterns`
**Details**: `php artisan rsx:man view_action_patterns`
--- ---
@@ -446,42 +420,27 @@ The process involves creating Action classes with @route decorators and converti
### SCSS Component-First Architecture ### 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: `<div class="Component_Name__element">` (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. **Variables**: `rsx/theme/variables.scss` - must be included before directory includes in bundles. Multiple SCSS files can target same component if primary file exists.
**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: `<div class="Component_Name__element">` not `<div class="component-name__element">`. 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` Details: `php artisan rsx:man scss`
### Responsive Breakpoints ### 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**: | Tier 1 | Range | Tier 2 | Range |
- `mobile`: 0 - 1023px (phone + tablet) |--------|-------|--------|-------|
- `desktop`: 1024px+ | `mobile` | 0-1023px | `phone` | 0-799px |
| `desktop` | 1024px+ | `tablet` | 800-1023px |
| | | `desktop-sm/md/lg` | 1024+ |
**Tier 2 - Granular**: **SCSS**: `@include mobile { }`, `@include desktop { }`, `@include phone { }`, etc.
- `phone`: 0 - 799px | `tablet`: 800 - 1023px | `desktop-sm`: 1024 - 1699px | `desktop-md`: 1700 - 2199px | `desktop-lg`: 2200px+ **Classes**: `.col-mobile-6`, `.d-desktop-none`, `.mobile-only`, `.hide-tablet`
**JS**: `Responsive.is_mobile()`, `Responsive.is_phone()`, `Responsive.is_desktop_sm()`, etc.
**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` 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 ## 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 ```php
class Frontend_Bundle extends Rsx_Bundle_Abstract class Frontend_Bundle extends Rsx_Bundle_Abstract
@@ -539,7 +492,8 @@ class Frontend_Bundle extends Rsx_Bundle_Abstract
'jquery', // Required 'jquery', // Required
'lodash', // Required 'lodash', // Required
'rsx/theme/variables.scss', // Order matters '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 'rsx/models', // For JS stubs
], ],
]; ];
@@ -576,48 +530,30 @@ For mechanical thinkers who see structure, not visuals. Write `<User_Card>` not
``` ```
**Interpolation**: `<%= escaped %>` | `<%!= unescaped %>` | `<% javascript %>` **Interpolation**: `<%= escaped %>` | `<%!= unescaped %>` | `<% javascript %>`
**Conditional Attributes** `<input <% if (this.args.required) { %>required="required"<% } %> />`
**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: `<input <% if (this.args.required) { %>required="required"<% } %> />`
**Inline Logic**: `<% this.handler = () => action(); %>` then `@click=this.handler` - No JS file needed for simple components **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 **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 **Validation**: `<% if (!this.args.required) throw new Error('Missing arg'); %>` - Fail loud in template
### Simple Components (No JS File Needed) ### Simple Components (No JS File)
For simple components without external data or complex state, write JS directly in the template:
```jqhtml ```jqhtml
<Define:CSV_Renderer> <Define:CSV_Renderer>
<% <%
// Validate input
if (!this.args.csv_data) throw new Error('csv_data required'); 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(',')); 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(); }; this.toggle = () => { this.args.expanded = !this.args.expanded; this.render(); };
%> %>
<table> <table>
<% for (let row of rows) { %> <% for (let row of rows) { %>
<tr> <tr><% for (let cell of row) { %><td><%= cell %></td><% } %></tr>
<% for (let cell of row) { %>
<td><%= cell %></td>
<% } %>
</tr>
<% } %> <% } %>
</table> </table>
<button @click=this.toggle>Toggle View</button> <button @click=this.toggle>Toggle View</button>
</Define:CSV_Renderer> </Define:CSV_Renderer>
``` ```
**When to use inline JS**: Simple data transformations, conditionals, loops, basic event handlers Use inline JS for simple transformations/handlers. Create .js file when JS overwhelms template or needs external data.
**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) ### State Management Rules (ENFORCED)
@@ -665,90 +601,43 @@ class Toggle_Button extends Component {
### Lifecycle ### Lifecycle
1. **on_create()** → Setup default state BEFORE template (sync) 1. **on_create()** → Setup defaults (sync) - `this.data.rows = []; this.data.loading = true;`
2. **render** → Template executes with initialized state 2. **render** → Template executes
3. **on_render()** → Hide uninitialized UI (sync) 3. **on_render()** → Hide uninitialized UI (sync)
4. **on_load()** → Fetch data into `this.data` (async) 4. **on_load()** → Fetch data into `this.data` (async)
5. **on_ready()** → DOM manipulation safe (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 ### Component API
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). **DOM Access:**
### Component API - CRITICAL FOR LLM AGENTS
This section clarifies common misunderstandings. Read carefully.
**DOM Access Methods:**
| Method | Returns | Purpose | | Method | Returns | Purpose |
|--------|---------|---------| |--------|---------|---------|
| `this.$` | jQuery | Root element of component (stable, survives redraws) | | `this.$` | jQuery | Root element (NOT `this.$el`) |
| `this.$sid('name')` | jQuery | Child element with `$sid="name"` (always returns jQuery, even if empty) | | `this.$sid('name')` | jQuery | Child with `$sid="name"` |
| `this.sid('name')` | Component or null | Child component instance (null if not found or not a component) | | `this.sid('name')` | Component/null | Child component instance |
| `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() vs render():**
``` ```
reload() = on_load() → render() → on_ready() reload() = on_load() → render() → on_ready() ← ALWAYS USE THIS
render() = template redraw only (NO on_ready) render() = template only (no on_ready) ← NEVER USE
``` ```
**LLM agents must ALWAYS use `reload()`, NEVER call `render()` directly.** After mutations, call `this.reload()` - the server round-trip is intentional:
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 ```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() { async add_item() {
await Controller.add({name: 'Test'}); 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_render():** Ignore - use `on_ready()` for post-render work.
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 ### Loading Pattern
@@ -824,26 +713,15 @@ this.$sid('result_container').component('My_Component', {
</Dashboard> </Dashboard>
``` ```
### Key Pitfalls (ABSOLUTE RULES) ### Key Pitfalls
1. `<Define>` IS the element - use `tag=""` attribute - `<Define>` IS the element - use `tag=""` attribute
2. `this.data` starts empty `{}` - MUST set defaults in `on_create()` - `this.data` starts `{}` - set defaults in `on_create()`
3. ONLY modify `this.data` in `on_create()` and `on_load()` (enforced by framework) - `this.data` writable only in `on_create()` and `on_load()`
4. `on_load()` can ONLY access `this.args` and `this.data` (no DOM, no `this.state`) - `on_load()`: only `this.args` and `this.data` (no DOM, no `this.state`)
5. Use `this.state = {}` in `on_create()` for UI state (not from Ajax) - `this.state` for UI state, `this.args` + `reload()` for refetch
6. Use `this.args` for reload parameters, call `reload()` to re-fetch - `Controller.method()` not `$.ajax()` - #[Ajax_Endpoint] auto-callable
7. Use `Controller.method()` not `$.ajax()` - PHP methods with #[Ajax_Endpoint] auto-callable from JS - `on_create/render/stop` sync; `this.sid()` → component, `$(el).component()` → component
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 --}}
<User_Card user_id="123" /> {{-- Now JS executes --}}
```
For advanced topics: `php artisan rsx:man jqhtml`
--- ---
@@ -952,55 +830,69 @@ Details: `php artisan rsx:man polymorphic`
## MODALS ## 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 ```javascript
await Modal.alert("File saved"); await Modal.alert("File saved");
if (await Modal.confirm("Delete?")) { /* confirmed */ } 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 ```javascript
const result = await Modal.form({ const result = await Modal.form({
title: "Edit User", title: "Edit User",
component: "User_Form", component: "User_Form",
component_args: {data: user}, component_args: {data: user},
on_submit: async (form) => { on_submit: async (form) => {
const values = form.vals(); const response = await User_Controller.save(form.vals());
const response = await User_Controller.save(values);
if (response.errors) { if (response.errors) {
Form_Utils.apply_form_errors(form.$, response.errors); Form_Utils.apply_form_errors(form.$, response.errors);
return false; // Keep open return false; // Keep open
} }
return response.data; // Close and return return response.data; // Close and return
} }
}); });
``` ```
**Requirements**: Form component must implement `vals()` and include `<div $sid="error_container"></div>`. Form component must implement `vals()` and include `<div $sid="error_container"></div>`.
### Modal Classes
For complex/reusable modals, create dedicated classes:
**Modal Classes** (for complex/reusable modals):
```javascript ```javascript
// Define modal class
class Add_User_Modal extends Modal_Abstract { class Add_User_Modal extends Modal_Abstract {
static async show() { static async show() {
const result = await Modal.form({...}); return await Modal.form({...}) || false;
return result || false;
} }
} }
// Use from page JS // Usage
const user = await Add_User_Modal.show(); const user = await Add_User_Modal.show();
if (user) { if (user) {
// Orchestrate post-modal actions
grid.reload(); grid.reload();
await Next_Modal.show(user.id); 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` 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 ## AUTHENTICATION
**Always use Session** - Static methods only. Never Laravel Auth or $_SESSION. **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 DECORATORS
```javascript ```javascript
@@ -1285,30 +1212,19 @@ db:query "SQL" --json
### Testing Routes ### Testing Routes
**`rsx:debug /path`** - Preferred method for testing routes **`rsx:debug /path`** - Uses Playwright to render pages with full JS execution.
Uses Playwright to render the page and show rendered output, JavaScript errors, and console messages.
```bash ```bash
rsx:debug /clients # Test route rsx:debug /dashboard --user=1 # Authenticated user
rsx:debug /dashboard --user=1 # Simulate authenticated user rsx:debug /page --screenshot-path=/tmp/page.png # Capture screenshot
rsx:debug /contacts --console # Show console.log output rsx:debug /contacts --eval="$('.btn').click(); await sleep(1000)" # Simulate interaction
rsx:debug /page --screenshot-path=/tmp/page.png --screenshot-width=mobile # Capture screenshot rsx:debug / --eval="return Rsx_Time.now_iso()" # Get eval result (use return)
rsx:debug /page --dump-dimensions=".card" # Add position/size data attributes to elements rsx:debug / --console --eval="console.log(Rsx_Date.today())" # Or console.log with --console
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 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. **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".
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 ### 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 ## KEY REMINDERS
1. **Fail loud** - No silent failures 1. **Fail loud** - No silent failures
@@ -1382,14 +1283,4 @@ php artisan list rsx # All commands
## PROJECT DOCUMENTATION ## 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. 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.
**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.