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:
@@ -68,9 +68,9 @@ class JsFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
$this->add_violation(
|
||||
$file_path,
|
||||
$line_number,
|
||||
"CRITICAL: Fallback/legacy code detected in comment. This violates the fail-loud principle - there must be ONE way to do things.",
|
||||
$this->get_llm_confrontation_message(),
|
||||
trim($line),
|
||||
"REMOVE the fallback/legacy code immediately. If primary code fails, it MUST throw an exception, NOT execute alternative code. Fallbacks create non-deterministic behavior and debugging nightmares. Only ONE implementation should exist. If you're an AI/LLM agent: DO NOT decide to keep fallback code - you MUST ask the developer for explicit approval. To mark legitimate uses (rare), add '*' after the word. Remember: Predictable failures are better than unpredictable 'working' code.",
|
||||
$this->get_llm_remediation_message(),
|
||||
'critical'
|
||||
);
|
||||
}
|
||||
@@ -111,4 +111,111 @@ class JsFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -73,9 +73,9 @@ class PhpFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
$this->add_violation(
|
||||
$file_path,
|
||||
$line_number,
|
||||
"🚨 CRITICAL ARCHITECTURE VIOLATION: 'fallback' or 'legacy' detected - STOP IMMEDIATELY\n\nThis is NOT just a naming issue. The presence of 'fallback/legacy' often indicates:\n1. DEAD CODE handling conditions that no longer occur\n2. DUPLICATE IMPLEMENTATIONS still partially present\n3. INCOMPLETE MIGRATION from old to new systems\n4. VIOLATION TYPE HANDLERS for deprecated systems",
|
||||
$this->get_llm_confrontation_message(),
|
||||
trim($line),
|
||||
"REQUIRED IMMEDIATE ACTIONS:\n1. INVESTIGATE: Is this code actually reachable? Run grep for the condition it handles\n2. TRACE: What system/feature does this reference? Does it still exist anywhere?\n3. SEARCH: Are there duplicate implementations? (grep for related terms)\n4. VERIFY: If it's display code, does the violation type still get produced?\n\nFor AI/LLM agents: This is a P0 STOP-THE-WORLD violation. You MUST:\n- Immediately search for what triggers this code path\n- Report ALL findings to the user before making ANY changes\n- NEVER just rename or mark with asterisk without investigation\n- If dead code found, remove entirely (don't comment out)\n\nRemember: Dead fallback code is technical debt that causes debugging nightmares.\nEvery 'fallback' is a potential duplicate implementation waiting to cause PTSD.\n\nTo mark legitimate uses (extremely rare), add '*' after the word.",
|
||||
$this->get_llm_remediation_message(),
|
||||
'critical'
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,7 @@ class Man_Command extends Command
|
||||
/**
|
||||
* Get all documentation files, deduplicated by filename.
|
||||
* Files from earlier directories in docs_dirs take precedence.
|
||||
* Excludes .expect files (behavioral expectation documentation for testing).
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
@@ -186,6 +187,10 @@ class Man_Command extends Command
|
||||
$dir_files = glob($dir . '/*.txt');
|
||||
if ($dir_files) {
|
||||
foreach ($dir_files as $file) {
|
||||
// Skip .expect files (behavioral expectation docs)
|
||||
if (str_ends_with($file, '.expect')) {
|
||||
continue;
|
||||
}
|
||||
$name = basename($file, '.txt');
|
||||
// First occurrence wins (higher priority directory)
|
||||
if (!isset($files_by_name[$name])) {
|
||||
|
||||
@@ -560,10 +560,12 @@ class Route_Debug_Command extends Command
|
||||
$this->line(' php artisan rsx:debug /admin --user=admin@example.com # Test as user by email');
|
||||
$this->line('');
|
||||
|
||||
$this->comment('TESTING RSX JAVASCRIPT:');
|
||||
$this->line(' php artisan rsx:debug /demo --eval="Rsx.Route(\'Demo_Controller\').url()" --no-body');
|
||||
$this->line(' php artisan rsx:debug /demo --eval="JSON.stringify(Rsx._routes)" --no-body');
|
||||
$this->line(' php artisan rsx:debug /demo --eval="Rsx.is_dev()" --no-body');
|
||||
$this->comment('TESTING RSX JAVASCRIPT (use return or console.log for output):');
|
||||
$this->line(' php artisan rsx:debug / --eval="return typeof Rsx_Time" # Check if class exists');
|
||||
$this->line(' php artisan rsx:debug / --eval="return Rsx_Time.now_iso()" # Get current time');
|
||||
$this->line(' php artisan rsx:debug / --eval="return Rsx_Date.today()" # Get today\'s date');
|
||||
$this->line(' php artisan rsx:debug / --console --eval="console.log(Rsx_Time.get_user_timezone())"');
|
||||
$this->line(' # Use console.log with --console');
|
||||
$this->line('');
|
||||
|
||||
$this->comment('POST-LOAD INTERACTIONS (click buttons, test modals, etc):');
|
||||
|
||||
@@ -368,6 +368,8 @@ class Ajax
|
||||
'error_code' => $response->get_error_code(),
|
||||
'reason' => $response->get_reason(),
|
||||
'metadata' => $response->get_metadata(),
|
||||
'_server_time' => \App\RSpade\Core\Time\Rsx_Time::now_iso(),
|
||||
'_user_timezone' => \App\RSpade\Core\Time\Rsx_Time::get_user_timezone(),
|
||||
];
|
||||
|
||||
// Add console debug messages if any
|
||||
@@ -467,6 +469,8 @@ class Ajax
|
||||
$json_response = [
|
||||
'_success' => true,
|
||||
'_ajax_return_value' => $response,
|
||||
'_server_time' => \App\RSpade\Core\Time\Rsx_Time::now_iso(),
|
||||
'_user_timezone' => \App\RSpade\Core\Time\Rsx_Time::get_user_timezone(),
|
||||
];
|
||||
|
||||
// Add console debug messages if any
|
||||
|
||||
@@ -301,6 +301,10 @@ abstract class Rsx_Bundle_Abstract
|
||||
$rsxapp_data['flash_alerts'] = $flash_messages;
|
||||
}
|
||||
|
||||
// Add time data for Rsx_Time initialization
|
||||
$rsxapp_data['server_time'] = \App\RSpade\Core\Time\Rsx_Time::now_iso();
|
||||
$rsxapp_data['user_timezone'] = \App\RSpade\Core\Time\Rsx_Time::get_user_timezone();
|
||||
|
||||
// Add console_debug config in non-production mode
|
||||
if (!app()->environment('production')) {
|
||||
$console_debug_config = config('rsx.console_debug', []);
|
||||
|
||||
@@ -197,6 +197,14 @@ class Ajax {
|
||||
});
|
||||
}
|
||||
|
||||
// Sync time with server (only on first AJAX or timezone change)
|
||||
if (response._server_time || response._user_timezone) {
|
||||
Rsx_Time.sync_from_ajax({
|
||||
server_time: response._server_time,
|
||||
user_timezone: response._user_timezone
|
||||
});
|
||||
}
|
||||
|
||||
// Handle flash_alerts from server
|
||||
if (response.flash_alerts && Array.isArray(response.flash_alerts)) {
|
||||
Server_Side_Flash.process(response.flash_alerts);
|
||||
@@ -348,8 +356,16 @@ class Ajax {
|
||||
__local_integration: true, // Bypass $.ajax override
|
||||
});
|
||||
|
||||
// Sync time with server (only on first AJAX or timezone change)
|
||||
if (response._server_time || response._user_timezone) {
|
||||
Rsx_Time.sync_from_ajax({
|
||||
server_time: response._server_time,
|
||||
user_timezone: response._user_timezone
|
||||
});
|
||||
}
|
||||
|
||||
// Process batch response
|
||||
// Response format: { C_0: {success, _ajax_return_value}, C_1: {...}, ... }
|
||||
// Response format: { C_0: {success, _ajax_return_value}, C_1: {...}, ..., _server_time, _user_timezone }
|
||||
for (const response_key in response) {
|
||||
if (!response_key.startsWith('C_')) {
|
||||
continue;
|
||||
|
||||
239
app/RSpade/Core/Js/Rsx_Date.js
Executable file
239
app/RSpade/Core/Js/Rsx_Date.js
Executable 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
612
app/RSpade/Core/Js/Rsx_Time.js
Executable 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
360
app/RSpade/Core/Schedule_Field_Helper.php
Executable file
360
app/RSpade/Core/Schedule_Field_Helper.php
Executable 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
245
app/RSpade/Core/Time/Rsx_Date.php
Executable 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
537
app/RSpade/Core/Time/Rsx_Time.php
Executable 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');
|
||||
}
|
||||
}
|
||||
375
app/RSpade/man/datetime_inputs.txt
Executable file
375
app/RSpade/man/datetime_inputs.txt
Executable 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
172
app/RSpade/man/expect_files.txt
Executable 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
|
||||
@@ -178,13 +178,28 @@ JAVASCRIPT EVALUATION
|
||||
behavior. Use `await` for async operations and `await sleep(ms)` to
|
||||
wait for side effects to complete before the DOM snapshot.
|
||||
|
||||
Simple examples:
|
||||
--eval="Rsx.Route('Demo_Controller').url()"
|
||||
--eval="JSON.stringify(Rsx._routes)"
|
||||
--eval="typeof jQuery"
|
||||
--eval="document.title"
|
||||
GETTING OUTPUT FROM EVAL:
|
||||
|
||||
Simulating user interactions:
|
||||
To see output from your eval code, you must either:
|
||||
|
||||
1. Use `return` to return a value (shown in "JavaScript Eval Result:"):
|
||||
--eval="return Rsx_Time.now_iso()"
|
||||
--eval="return typeof Rsx_Time"
|
||||
--eval="return JSON.stringify({a: 1, b: 2})"
|
||||
|
||||
2. Use `console.log()` with --console flag (shown in console output):
|
||||
--console --eval="console.log('timezone:', Rsx_Time.get_user_timezone())"
|
||||
--console --eval="console.log('today:', Rsx_Date.today())"
|
||||
|
||||
Without `return` or `console.log()`, the eval result will be undefined.
|
||||
|
||||
INSPECTING VALUES:
|
||||
--eval="return Rsx_Time.now_iso()"
|
||||
--eval="return Rsx_Time.get_user_timezone()"
|
||||
--eval="return Rsx_Date.today()"
|
||||
--eval="return JSON.stringify(window.rsxapp)"
|
||||
|
||||
SIMULATING USER INTERACTIONS:
|
||||
# Click a pagination button and wait for results
|
||||
--eval="$('.page-link[data-page=\"2\"]').click(); await sleep(2000)"
|
||||
|
||||
|
||||
383
app/RSpade/man/time.txt
Executable file
383
app/RSpade/man/time.txt
Executable 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)
|
||||
0
app/RSpade/resource/vscode_extension/out/auto_rename_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/auto_rename_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/auto_rename_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/auto_rename_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/class_refactor_code_actions.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/class_refactor_code_actions.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/class_refactor_code_actions.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/class_refactor_code_actions.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/class_refactor_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/class_refactor_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/class_refactor_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/class_refactor_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/combined_semantic_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/combined_semantic_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/combined_semantic_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/combined_semantic_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/comment_file_reference_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/comment_file_reference_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/comment_file_reference_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/comment_file_reference_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/config.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/config.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/config.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/config.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/convention_method_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/convention_method_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/convention_method_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/convention_method_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/debug_client.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/debug_client.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/debug_client.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/debug_client.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/decoration_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/decoration_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/decoration_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/decoration_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/definition_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/definition_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/definition_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/definition_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/extension.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/extension.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/extension.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/extension.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/file_watcher.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/file_watcher.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/file_watcher.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/file_watcher.js.map
Executable file → Normal file
6
app/RSpade/resource/vscode_extension/out/folder_color_provider.js
Executable file → Normal file
6
app/RSpade/resource/vscode_extension/out/folder_color_provider.js
Executable file → Normal file
@@ -35,6 +35,7 @@ const path = __importStar(require("path"));
|
||||
* - system/ - Muted gray
|
||||
* - app/ - Muted gray (legacy structure)
|
||||
* - routes/ - Muted gray (legacy structure)
|
||||
* - *.expect files - Muted gray (behavioral expectation documentation)
|
||||
*/
|
||||
class FolderColorProvider {
|
||||
constructor() {
|
||||
@@ -72,6 +73,11 @@ class FolderColorProvider {
|
||||
return undefined;
|
||||
}
|
||||
const uriPath = uri.fsPath.replace(/\\/g, '/');
|
||||
// Mute .expect files (behavioral expectation documentation)
|
||||
// Only in RSpade projects to avoid affecting other workspaces
|
||||
if (uriPath.endsWith('.expect') && this.find_rspade_root()) {
|
||||
return new vscode.FileDecoration(undefined, undefined, new vscode.ThemeColor('descriptionForeground'));
|
||||
}
|
||||
// Check if this URI is a workspace folder root (for multi-root workspaces)
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders.find(folder => folder.uri.fsPath.replace(/\\/g, '/') === uriPath);
|
||||
if (workspaceFolder) {
|
||||
|
||||
2
app/RSpade/resource/vscode_extension/out/folder_color_provider.js.map
Executable file → Normal file
2
app/RSpade/resource/vscode_extension/out/folder_color_provider.js.map
Executable file → Normal 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"}
|
||||
0
app/RSpade/resource/vscode_extension/out/folding_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/folding_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/folding_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/folding_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/formatting_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/formatting_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/formatting_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/formatting_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/git_diff_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/git_diff_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/git_diff_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/git_diff_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/git_status_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/git_status_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/git_status_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/git_status_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/ide_bridge_client.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/ide_bridge_client.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/ide_bridge_client.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/ide_bridge_client.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/laravel_completion_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/laravel_completion_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/laravel_completion_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/laravel_completion_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/php_attribute_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/php_attribute_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/php_attribute_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/php_attribute_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/refactor_code_actions.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/refactor_code_actions.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/refactor_code_actions.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/refactor_code_actions.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/refactor_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/refactor_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/refactor_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/refactor_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/sort_class_methods_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/sort_class_methods_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/sort_class_methods_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/sort_class_methods_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/symlink_redirect_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/symlink_redirect_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/symlink_redirect_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/symlink_redirect_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/that_variable_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/that_variable_provider.js
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/that_variable_provider.js.map
Executable file → Normal file
0
app/RSpade/resource/vscode_extension/out/that_variable_provider.js.map
Executable file → Normal file
@@ -2,7 +2,7 @@
|
||||
"name": "rspade-framework",
|
||||
"displayName": "RSpade Framework Support",
|
||||
"description": "VS Code extension for RSpade framework with code folding, formatting, and namespace management",
|
||||
"version": "0.1.220",
|
||||
"version": "0.1.221",
|
||||
"publisher": "rspade",
|
||||
"engines": {
|
||||
"vscode": "^1.74.0"
|
||||
|
||||
Binary file not shown.
@@ -10,6 +10,7 @@ import * as path from 'path';
|
||||
* - system/ - Muted gray
|
||||
* - app/ - Muted gray (legacy structure)
|
||||
* - routes/ - Muted gray (legacy structure)
|
||||
* - *.expect files - Muted gray (behavioral expectation documentation)
|
||||
*/
|
||||
export class FolderColorProvider implements vscode.FileDecorationProvider {
|
||||
private readonly _onDidChangeFileDecorations: vscode.EventEmitter<vscode.Uri | vscode.Uri[] | undefined> =
|
||||
@@ -59,6 +60,16 @@ export class FolderColorProvider implements vscode.FileDecorationProvider {
|
||||
|
||||
const uriPath = uri.fsPath.replace(/\\/g, '/');
|
||||
|
||||
// Mute .expect files (behavioral expectation documentation)
|
||||
// Only in RSpade projects to avoid affecting other workspaces
|
||||
if (uriPath.endsWith('.expect') && this.find_rspade_root()) {
|
||||
return new vscode.FileDecoration(
|
||||
undefined,
|
||||
undefined,
|
||||
new vscode.ThemeColor('descriptionForeground')
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this URI is a workspace folder root (for multi-root workspaces)
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders.find(
|
||||
folder => folder.uri.fsPath.replace(/\\/g, '/') === uriPath
|
||||
|
||||
@@ -574,4 +574,25 @@ return [
|
||||
// 'email' => ['max_workers' => 5],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Datetime Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure date and time handling across the application.
|
||||
| See: php artisan rsx:man time
|
||||
|
|
||||
*/
|
||||
'datetime' => [
|
||||
// Default timezone (IANA identifier) when user has no preference
|
||||
// Resolution order: login_users.timezone → this default → 'America/Chicago'
|
||||
'default_timezone' => env('RSX_DEFAULT_TIMEZONE', 'America/Chicago'),
|
||||
|
||||
// Time dropdown interval in minutes (for Schedule_Input component)
|
||||
'time_interval' => 15,
|
||||
|
||||
// Default duration for new events in minutes (for Schedule_Input component)
|
||||
'default_duration' => 60,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -346,6 +346,11 @@
|
||||
"created_at": "2025-12-11T06:15:51+00:00",
|
||||
"created_by": "root",
|
||||
"command": "php artisan make:migration:safe create_groups_and_group_users_tables"
|
||||
},
|
||||
"2025_12_24_210213_add_timezone_to_login_users_table.php": {
|
||||
"created_at": "2025-12-24T21:02:13+00:00",
|
||||
"created_by": "root",
|
||||
"command": "php artisan make:migration:safe add_timezone_to_login_users_table"
|
||||
}
|
||||
}
|
||||
}
|
||||
36
database/migrations/2025_12_24_210213_add_timezone_to_login_users_table.php
Executable file
36
database/migrations/2025_12_24_210213_add_timezone_to_login_users_table.php
Executable 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
1336
docs/CLAUDE.archive.12.24.25.md
Executable file
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@ When editing /var/www/html/CLAUDE.md:
|
||||
**FORBIDDEN** (unless explicitly instructed):
|
||||
- `npm run compile/build` - Don't exist
|
||||
- `bin/publish` - For releases, not testing
|
||||
- `rsx:bundle:compile` / `rsx:manifest:build` - Automatic
|
||||
- `rsx:bundle:compile` / `rsx:manifest:build` / `rsx:clean` - Automatic
|
||||
- ANY "build", "compile", or "publish" command
|
||||
|
||||
Edit → Save → Refresh browser → Changes live (< 1 second)
|
||||
@@ -62,8 +62,6 @@ Edit → Save → Refresh browser → Changes live (< 1 second)
|
||||
php artisan rsx:framework:pull # User-initiated only
|
||||
```
|
||||
|
||||
Updates take 2-5 minutes. Includes code pull, manifest rebuild, bundle recompilation. Only run when requested by user.
|
||||
|
||||
### Fail Loud - No Silent Fallbacks
|
||||
|
||||
**ALWAYS fail visibly.** No redundant fallbacks, silent failures, or alternative code paths.
|
||||
@@ -132,14 +130,6 @@ cp system/app/RSpade/Core/Models/User_Model.php rsx/models/user_model.php
|
||||
|
||||
Details: `php artisan rsx:man class_override`
|
||||
|
||||
### DO NOT RUN `rsx:clean`
|
||||
|
||||
**RSpade's cache auto-invalidates on file changes.** Running `rsx:clean` causes 30-60 second rebuilds with zero benefit.
|
||||
|
||||
**When to use**: Only on catastrophic corruption, after framework updates (automatic), or when explicitly instructed.
|
||||
|
||||
**Correct workflow**: Edit → Save → Reload browser → See changes (< 1 second)
|
||||
|
||||
### Trust Code Quality Rules
|
||||
|
||||
Each `rsx:check` rule has remediation text that tells AI assistants exactly what to do:
|
||||
@@ -258,12 +248,8 @@ public static function pre_dispatch(Request $request, array $params = []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Public endpoints: add @auth-exempt to class docblock
|
||||
/** @auth-exempt Public route */
|
||||
```
|
||||
|
||||
**Code quality**: PHP-AUTH-01 rule verifies auth checks exist. Use `@auth-exempt` for public routes.
|
||||
|
||||
### Type-Safe URLs
|
||||
|
||||
**MANDATORY**: All URLs must be generated using `Rsx::Route()` - hardcoded URLs are forbidden.
|
||||
@@ -272,10 +258,10 @@ public static function pre_dispatch(Request $request, array $params = []) {
|
||||
// PHP - Controller (defaults to 'index' method)
|
||||
Rsx::Route('User_Controller')
|
||||
|
||||
// PHP - Controller with explicit method
|
||||
// PHP - Controller with explicit method. Passing an integer for param two implies the param two is 'id' (id=123)
|
||||
Rsx::Route('User_Controller::show', 123);
|
||||
|
||||
// PHP - With query parameters
|
||||
// PHP - With query parameters. Extra params not defined in route itself become query string - automatically URL-encoded
|
||||
Rsx::Route('Login_Controller::logout', ['redirect' => '/dashboard']);
|
||||
// Generates: /logout?redirect=%2Fdashboard
|
||||
|
||||
@@ -284,16 +270,9 @@ Rsx.Route('User_Controller')
|
||||
Rsx.Route('User_Controller::show', 123);
|
||||
Rsx.Route('Login_Controller::logout', {redirect: '/dashboard'});
|
||||
```
|
||||
|
||||
**Signature**: `Rsx::Route($action, $params = null)` / `Rsx.Route(action, params = null)`
|
||||
- `$action` - Controller class, SPA action, or "Class::method" (defaults to 'index' if no `::` present)
|
||||
- `$params` - Integer sets 'id', array/object provides named params
|
||||
- **Unimplemented routes**: Prefix method with `#` → `Rsx::Route('Feature::#index')` generates `href="#"` and bypasses validation
|
||||
|
||||
**Query Parameters**: Extra params become query string - automatically URL-encoded
|
||||
|
||||
**Enforcement**: `rsx:check` will flag hardcoded URLs like `/login` or `/logout?redirect=...`
|
||||
and require you to use `Rsx::Route()`. Do it right the first time to avoid rework.
|
||||
**Enforcement**: `rsx:check` will flag hardcoded URLs like `/login` or `/logout?redirect=...` and require you to use `Rsx::Route()`. Do it right the first time to avoid rework.
|
||||
|
||||
---
|
||||
|
||||
@@ -399,29 +378,24 @@ Pattern: `/rsx/app/(module)/(feature)/`
|
||||
|
||||
Details: `php artisan rsx:man spa`
|
||||
|
||||
### View Action Pattern (Loading Data)
|
||||
### View Action Pattern
|
||||
|
||||
For SPA actions that load data (view/edit CRUD pages), use the three-state pattern:
|
||||
Three-state pattern for data-loading actions:
|
||||
|
||||
```javascript
|
||||
on_create() {
|
||||
this.data.record = { name: '' }; // Stub prevents undefined errors
|
||||
this.data.record = { name: '' }; // Stub
|
||||
this.data.error_data = null;
|
||||
this.data.loading = true;
|
||||
}
|
||||
async on_load() {
|
||||
try {
|
||||
this.data.record = await Controller.get({id: this.args.id});
|
||||
} catch (e) {
|
||||
this.data.error_data = e;
|
||||
}
|
||||
try { this.data.record = await Controller.get({id: this.args.id}); }
|
||||
catch (e) { this.data.error_data = e; }
|
||||
this.data.loading = false;
|
||||
}
|
||||
```
|
||||
|
||||
Template uses three states: `<Loading_Spinner>` → `<Universal_Error_Page_Component>` → content.
|
||||
|
||||
**Details**: `php artisan rsx:man view_action_patterns`
|
||||
Template: `<Loading_Spinner>` → `<Universal_Error_Page_Component>` → content. Details: `rsx:man view_action_patterns`
|
||||
|
||||
---
|
||||
|
||||
@@ -446,42 +420,27 @@ The process involves creating Action classes with @route decorators and converti
|
||||
|
||||
### SCSS Component-First Architecture
|
||||
|
||||
**Philosophy**: Every styled element is a component. If it needs custom styles, give it a name, a jqhtml definition, and scoped SCSS. This eliminates CSS spaghetti - generic classes overriding each other unpredictably across files.
|
||||
SCSS in `rsx/app/` and `rsx/theme/components/` must wrap in a single component class matching the jqhtml/blade file. Components auto-render with `class="Component_Name"` on root. `rsx/lib/` is non-visual. `rsx/theme/` (outside components/) holds primitives, variables, Bootstrap overrides.
|
||||
|
||||
**Recognition**: When building a page, ask: "Is this structure unique, or a pattern?" A datagrid page with toolbar, tabs, filters, and search is a *pattern* - create `Datagrid_Card` once with slots, use it everywhere. A one-off project dashboard is *unique* - create `Project_Dashboard` for that page. If you're about to copy-paste structural markup, stop and extract a component.
|
||||
**BEM**: Child classes use component's exact name as prefix. `.Component_Name { &__element }` → HTML: `<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.
|
||||
|
||||
**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.
|
||||
**Variables**: `rsx/theme/variables.scss` - must be included before directory includes in bundles. Multiple SCSS files can target same component if primary file exists.
|
||||
|
||||
Details: `php artisan rsx:man scss`
|
||||
|
||||
### Responsive Breakpoints
|
||||
|
||||
RSX replaces Bootstrap's default breakpoints (xs/sm/md/lg/xl/xxl) with semantic device names.
|
||||
RSX replaces Bootstrap breakpoints with semantic names. **Bootstrap defaults (col-md-6, d-lg-none) do NOT work.**
|
||||
|
||||
**Tier 1 - Semantic**:
|
||||
- `mobile`: 0 - 1023px (phone + tablet)
|
||||
- `desktop`: 1024px+
|
||||
| Tier 1 | Range | Tier 2 | Range |
|
||||
|--------|-------|--------|-------|
|
||||
| `mobile` | 0-1023px | `phone` | 0-799px |
|
||||
| `desktop` | 1024px+ | `tablet` | 800-1023px |
|
||||
| | | `desktop-sm/md/lg` | 1024+ |
|
||||
|
||||
**Tier 2 - Granular**:
|
||||
- `phone`: 0 - 799px | `tablet`: 800 - 1023px | `desktop-sm`: 1024 - 1699px | `desktop-md`: 1700 - 2199px | `desktop-lg`: 2200px+
|
||||
|
||||
**SCSS Mixins**: `@include mobile { }`, `@include desktop { }`, `@include phone { }`, `@include tablet { }`, `@include desktop-sm { }`, etc.
|
||||
|
||||
**Bootstrap Classes**: `.col-mobile-6`, `.col-desktop-4`, `.d-mobile-none`, `.d-tablet-block`, `.col-phone-12 .col-tablet-6 .col-desktop-sm-4`
|
||||
|
||||
**Utility Classes**: `.mobile-only`, `.desktop-only`, `.phone-only`, `.hide-mobile`, `.hide-tablet`
|
||||
|
||||
**Note**: Bootstrap's default classes like `.col-md-6` or `.d-lg-none` do NOT work - use the RSX breakpoint names instead.
|
||||
|
||||
**JS Detection**: `Responsive.is_mobile()`, `Responsive.is_desktop()` (Tier 1 - broad); `Responsive.is_phone()`, `Responsive.is_tablet()`, `Responsive.is_desktop_sm()`, `Responsive.is_desktop_md()`, `Responsive.is_desktop_lg()` (Tier 2 - specific ranges)
|
||||
**SCSS**: `@include mobile { }`, `@include desktop { }`, `@include phone { }`, etc.
|
||||
**Classes**: `.col-mobile-6`, `.d-desktop-none`, `.mobile-only`, `.hide-tablet`
|
||||
**JS**: `Responsive.is_mobile()`, `Responsive.is_phone()`, `Responsive.is_desktop_sm()`, etc.
|
||||
|
||||
Details: `php artisan rsx:man responsive`
|
||||
|
||||
@@ -519,15 +478,9 @@ Use when data doesn't belong in DOM attributes. Multiple calls merge together.
|
||||
|
||||
---
|
||||
|
||||
## JAVASCRIPT
|
||||
|
||||
**CRITICAL**: JavaScript only executes when bundle rendered. See "JavaScript for Blade Pages" in BLADE & VIEWS section for the `on_app_ready()` pattern.
|
||||
|
||||
---
|
||||
|
||||
## BUNDLE SYSTEM
|
||||
|
||||
**One bundle per page required.** Compiles JS/CSS automatically on request - no manual build steps.
|
||||
**One bundle per module (rsx/app/(module)).** Compiles JS/CSS automatically on request - no manual build steps.
|
||||
|
||||
```php
|
||||
class Frontend_Bundle extends Rsx_Bundle_Abstract
|
||||
@@ -539,7 +492,8 @@ class Frontend_Bundle extends Rsx_Bundle_Abstract
|
||||
'jquery', // Required
|
||||
'lodash', // Required
|
||||
'rsx/theme/variables.scss', // Order matters
|
||||
'rsx/app/frontend', // Directory
|
||||
'rsx/theme', // Everything else from theme - but variables.scss will be first
|
||||
'rsx/app/frontend', // Directory -
|
||||
'rsx/models', // For JS stubs
|
||||
],
|
||||
];
|
||||
@@ -576,48 +530,30 @@ For mechanical thinkers who see structure, not visuals. Write `<User_Card>` not
|
||||
```
|
||||
|
||||
**Interpolation**: `<%= escaped %>` | `<%!= unescaped %>` | `<% javascript %>`
|
||||
|
||||
**Conditional Attributes** (v2.2.162+): Apply attributes conditionally using `<% if (condition) { %>attr="value"<% } %>`
|
||||
directly in attribute context. Works with static values, interpolations, and multiple conditions per element.
|
||||
Example: `<input <% if (this.args.required) { %>required="required"<% } %> />`
|
||||
|
||||
**Conditional Attributes** `<input <% if (this.args.required) { %>required="required"<% } %> />`
|
||||
**Inline Logic**: `<% this.handler = () => action(); %>` then `@click=this.handler` - No JS file needed for simple components
|
||||
**Event Handlers**: `@click=this.method` (unquoted) - Methods defined inline or in companion .js
|
||||
**Validation**: `<% if (!this.args.required) throw new Error('Missing arg'); %>` - Fail loud in template
|
||||
|
||||
### Simple Components (No JS File Needed)
|
||||
|
||||
For simple components without external data or complex state, write JS directly in the template:
|
||||
### Simple Components (No JS File)
|
||||
|
||||
```jqhtml
|
||||
<Define:CSV_Renderer>
|
||||
<%
|
||||
// Validate input
|
||||
if (!this.args.csv_data) throw new Error('csv_data required');
|
||||
|
||||
// Parse CSV
|
||||
const rows = this.args.csv_data.split('\n').map(r => r.split(','));
|
||||
|
||||
// Define click handler inline
|
||||
this.toggle = () => { this.args.expanded = !this.args.expanded; this.render(); };
|
||||
%>
|
||||
|
||||
<table>
|
||||
<% for (let row of rows) { %>
|
||||
<tr>
|
||||
<% for (let cell of row) { %>
|
||||
<td><%= cell %></td>
|
||||
<% } %>
|
||||
</tr>
|
||||
<tr><% for (let cell of row) { %><td><%= cell %></td><% } %></tr>
|
||||
<% } %>
|
||||
</table>
|
||||
|
||||
<button @click=this.toggle>Toggle View</button>
|
||||
</Define:CSV_Renderer>
|
||||
```
|
||||
|
||||
**When to use inline JS**: Simple data transformations, conditionals, loops, basic event handlers
|
||||
**When to create .js file**: External data loading, complex state management, multiple methods, or when JS overwhelms the template (should look mostly like HTML with some logic, not JS with some HTML)
|
||||
Use inline JS for simple transformations/handlers. Create .js file when JS overwhelms template or needs external data.
|
||||
|
||||
### State Management Rules (ENFORCED)
|
||||
|
||||
@@ -665,90 +601,43 @@ class Toggle_Button extends Component {
|
||||
|
||||
### Lifecycle
|
||||
|
||||
1. **on_create()** → Setup default state BEFORE template (sync)
|
||||
2. **render** → Template executes with initialized state
|
||||
1. **on_create()** → Setup defaults (sync) - `this.data.rows = []; this.data.loading = true;`
|
||||
2. **render** → Template executes
|
||||
3. **on_render()** → Hide uninitialized UI (sync)
|
||||
4. **on_load()** → Fetch data into `this.data` (async)
|
||||
5. **on_ready()** → DOM manipulation safe (async)
|
||||
|
||||
**on_create() now runs first** - Initialize `this.data` properties here so templates can safely reference them:
|
||||
If `on_load()` modifies `this.data`, component renders twice (defaults → populated).
|
||||
|
||||
```javascript
|
||||
on_create() {
|
||||
this.data.rows = []; // Prevents "not iterable" errors
|
||||
this.data.loading = true; // Template can check loading state
|
||||
}
|
||||
```
|
||||
### Component API
|
||||
|
||||
**Double-render**: If `on_load()` modifies `this.data`, component renders twice (defaults → populated).
|
||||
|
||||
### Component API - CRITICAL FOR LLM AGENTS
|
||||
|
||||
This section clarifies common misunderstandings. Read carefully.
|
||||
|
||||
**DOM Access Methods:**
|
||||
**DOM Access:**
|
||||
|
||||
| Method | Returns | Purpose |
|
||||
|--------|---------|---------|
|
||||
| `this.$` | jQuery | Root element of component (stable, survives redraws) |
|
||||
| `this.$sid('name')` | jQuery | Child element with `$sid="name"` (always returns jQuery, even if empty) |
|
||||
| `this.sid('name')` | Component or null | Child component instance (null if not found or not a component) |
|
||||
| `this.$.find('.class')` | jQuery | Standard jQuery find (use when `$sid` isn't appropriate) |
|
||||
|
||||
**WRONG:** `this.$el` - This does not exist. Use `this.$`
|
||||
|
||||
**The reload() Paradigm - MANDATORY:**
|
||||
| `this.$` | jQuery | Root element (NOT `this.$el`) |
|
||||
| `this.$sid('name')` | jQuery | Child with `$sid="name"` |
|
||||
| `this.sid('name')` | Component/null | Child component instance |
|
||||
|
||||
**reload() vs render():**
|
||||
```
|
||||
reload() = on_load() → render() → on_ready()
|
||||
render() = template redraw only (NO on_ready)
|
||||
reload() = on_load() → render() → on_ready() ← ALWAYS USE THIS
|
||||
render() = template only (no on_ready) ← NEVER USE
|
||||
```
|
||||
|
||||
**LLM agents must ALWAYS use `reload()`, NEVER call `render()` directly.**
|
||||
|
||||
When you need to refresh a component after a mutation (add, edit, delete), call `this.reload()`. Yes, this makes another server call via `on_load()`. This is intentional. The extra round-trip is acceptable - our server is fast and the paradigm simplicity is worth it.
|
||||
|
||||
**WRONG approach (do NOT do this):**
|
||||
After mutations, call `this.reload()` - the server round-trip is intentional:
|
||||
```javascript
|
||||
// ❌ BAD - Trying to be "efficient" by skipping server round-trip
|
||||
async add_item() {
|
||||
const new_item = await Controller.add({name: 'Test'});
|
||||
this.data.items.push(new_item); // ERROR: Cannot modify this.data outside on_load
|
||||
this.render(); // WRONG: Event handlers will break, on_ready won't run
|
||||
}
|
||||
```
|
||||
|
||||
**CORRECT approach:**
|
||||
```javascript
|
||||
// ✅ GOOD - Clean, consistent, reliable
|
||||
async add_item() {
|
||||
await Controller.add({name: 'Test'});
|
||||
this.reload(); // Calls on_load() to refresh this.data, then on_ready() for handlers
|
||||
this.reload(); // Refreshes this.data via on_load(), reattaches handlers via on_ready()
|
||||
}
|
||||
```
|
||||
|
||||
**Event Handlers - Set Up in on_ready():**
|
||||
**Event handlers** go in `on_ready()` - they auto-reattach after reload. **WRONG:** Event delegation like `this.$.on('click', '[data-sid="btn"]', handler)` to "survive" render calls - use `reload()` instead.
|
||||
|
||||
Event handlers must be registered in `on_ready()`. Since `on_ready()` runs after every `reload()`, handlers automatically reattach when the DOM is redrawn.
|
||||
**this.data rules (enforced):** Writable only in `on_create()` (defaults) and `on_load()` (fetched data). Read-only elsewhere.
|
||||
|
||||
```javascript
|
||||
on_ready() {
|
||||
this.$sid('save_btn').click(() => this.save());
|
||||
this.$sid('delete_btn').click(() => this.delete());
|
||||
}
|
||||
```
|
||||
|
||||
**WRONG:** Event delegation to avoid `reload()`. If you find yourself writing `this.$.on('click', '[data-sid="btn"]', handler)` to "survive" render calls, you're doing it wrong. Use `reload()` and let `on_ready()` reattach handlers.
|
||||
|
||||
**this.data Modification Rules (ENFORCED):**
|
||||
|
||||
- `on_create()`: Set defaults only (e.g., `this.data.items = []`)
|
||||
- `on_load()`: Fetch and assign from server (e.g., `this.data.items = await Controller.list()`)
|
||||
- **Everywhere else**: Read-only. Attempting to modify `this.data` outside these methods throws an error.
|
||||
|
||||
**on_render() - LLM Should Not Use:**
|
||||
|
||||
`on_render()` exists for human developers doing performance optimization. LLM agents should pretend it doesn't exist. Use `on_ready()` for all post-render DOM work.
|
||||
**on_render():** Ignore - use `on_ready()` for post-render work.
|
||||
|
||||
### Loading Pattern
|
||||
|
||||
@@ -824,26 +713,15 @@ this.$sid('result_container').component('My_Component', {
|
||||
</Dashboard>
|
||||
```
|
||||
|
||||
### Key Pitfalls (ABSOLUTE RULES)
|
||||
### Key Pitfalls
|
||||
|
||||
1. `<Define>` IS the element - use `tag=""` attribute
|
||||
2. `this.data` starts empty `{}` - MUST set defaults in `on_create()`
|
||||
3. ONLY modify `this.data` in `on_create()` and `on_load()` (enforced by framework)
|
||||
4. `on_load()` can ONLY access `this.args` and `this.data` (no DOM, no `this.state`)
|
||||
5. Use `this.state = {}` in `on_create()` for UI state (not from Ajax)
|
||||
6. Use `this.args` for reload parameters, call `reload()` to re-fetch
|
||||
7. Use `Controller.method()` not `$.ajax()` - PHP methods with #[Ajax_Endpoint] auto-callable from JS
|
||||
8. `on_create/render/stop` must be sync
|
||||
9. `this.sid()` returns component instance, `$(selector).component()` converts jQuery to component
|
||||
|
||||
### Bundle Integration Required
|
||||
|
||||
```blade
|
||||
{!! Frontend_Bundle::render() !!} {{-- Required for JS --}}
|
||||
<User_Card user_id="123" /> {{-- Now JS executes --}}
|
||||
```
|
||||
|
||||
For advanced topics: `php artisan rsx:man jqhtml`
|
||||
- `<Define>` IS the element - use `tag=""` attribute
|
||||
- `this.data` starts `{}` - set defaults in `on_create()`
|
||||
- `this.data` writable only in `on_create()` and `on_load()`
|
||||
- `on_load()`: only `this.args` and `this.data` (no DOM, no `this.state`)
|
||||
- `this.state` for UI state, `this.args` + `reload()` for refetch
|
||||
- `Controller.method()` not `$.ajax()` - #[Ajax_Endpoint] auto-callable
|
||||
- `on_create/render/stop` sync; `this.sid()` → component, `$(el).component()` → component
|
||||
|
||||
---
|
||||
|
||||
@@ -952,55 +830,69 @@ Details: `php artisan rsx:man polymorphic`
|
||||
|
||||
## MODALS
|
||||
|
||||
**Basic dialogs**:
|
||||
### Built-in Dialog Types
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `Modal.alert(body)` | `void` | Simple notification |
|
||||
| `Modal.alert(title, body, buttonLabel?)` | `void` | Alert with title |
|
||||
| `Modal.confirm(body)` | `boolean` | Yes/no confirmation |
|
||||
| `Modal.confirm(title, body, confirmLabel?, cancelLabel?)` | `boolean` | Confirmation with labels |
|
||||
| `Modal.prompt(body)` | `string\|false` | Text input |
|
||||
| `Modal.prompt(title, body, default?, multiline?)` | `string\|false` | Prompt with options |
|
||||
| `Modal.select(body, options)` | `string\|false` | Dropdown selection |
|
||||
| `Modal.select(title, body, options, default?, placeholder?)` | `string\|false` | Select with options |
|
||||
| `Modal.error(error, title?)` | `void` | Error with red styling |
|
||||
| `Modal.unclosable(title, body)` | `void` | Modal user cannot close |
|
||||
|
||||
```javascript
|
||||
await Modal.alert("File saved");
|
||||
if (await Modal.confirm("Delete?")) { /* confirmed */ }
|
||||
let name = await Modal.prompt("Enter name:");
|
||||
const name = await Modal.prompt("Enter name:");
|
||||
const choice = await Modal.select("Choose:", [{value: 'a', label: 'A'}, {value: 'b', label: 'B'}]);
|
||||
await Modal.error("Something went wrong");
|
||||
```
|
||||
|
||||
**Form modals**:
|
||||
### Form Modals
|
||||
|
||||
```javascript
|
||||
const result = await Modal.form({
|
||||
title: "Edit User",
|
||||
component: "User_Form",
|
||||
component_args: {data: user},
|
||||
on_submit: async (form) => {
|
||||
const values = form.vals();
|
||||
const response = await User_Controller.save(values);
|
||||
|
||||
const response = await User_Controller.save(form.vals());
|
||||
if (response.errors) {
|
||||
Form_Utils.apply_form_errors(form.$, response.errors);
|
||||
return false; // Keep open
|
||||
}
|
||||
|
||||
return response.data; // Close and return
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Requirements**: Form component must implement `vals()` and include `<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
|
||||
// Define modal class
|
||||
class Add_User_Modal extends Modal_Abstract {
|
||||
static async show() {
|
||||
const result = await Modal.form({...});
|
||||
return result || false;
|
||||
return await Modal.form({...}) || false;
|
||||
}
|
||||
}
|
||||
|
||||
// Use from page JS
|
||||
// Usage
|
||||
const user = await Add_User_Modal.show();
|
||||
if (user) {
|
||||
// Orchestrate post-modal actions
|
||||
grid.reload();
|
||||
await Next_Modal.show(user.id);
|
||||
}
|
||||
```
|
||||
|
||||
Pattern: Extend `Modal_Abstract`, implement static `show()`, return data or `false`. Page JS orchestrates flow, modal classes encapsulate UI.
|
||||
Pattern: Extend `Modal_Abstract`, implement static `show()`, return data or `false`.
|
||||
|
||||
Details: `php artisan rsx:man modals`
|
||||
|
||||
@@ -1222,16 +1114,6 @@ Details: `php artisan rsx:man model_fetch`
|
||||
|
||||
---
|
||||
|
||||
## BROWSER STORAGE
|
||||
|
||||
**Rsx_Storage** - Scoped sessionStorage/localStorage with automatic fallback and quota management. All keys automatically scoped by session, user, site, and build. Gracefully handles unavailable storage and quota exceeded errors. Storage is volatile - use only for non-critical data (caching, UI state, transient messages).
|
||||
|
||||
`Rsx_Storage.session_set(key, value)` / `Rsx_Storage.session_get(key)` / `Rsx_Storage.local_set(key, value)` / `Rsx_Storage.local_get(key)`
|
||||
|
||||
Details: `php artisan rsx:man storage`
|
||||
|
||||
---
|
||||
|
||||
## AUTHENTICATION
|
||||
|
||||
**Always use Session** - Static methods only. Never Laravel Auth or $_SESSION.
|
||||
@@ -1249,6 +1131,51 @@ Sessions persist 365 days. Never implement "Remember Me".
|
||||
|
||||
---
|
||||
|
||||
## DATE & TIME HANDLING
|
||||
|
||||
**Two Classes - Strict Separation**: `Rsx_Time` (datetimes with timezone) | `Rsx_Date` (calendar dates, no timezone)
|
||||
|
||||
### Rsx_Time - Moments in Time
|
||||
```php
|
||||
use App\RSpade\Core\Time\Rsx_Time;
|
||||
|
||||
Rsx_Time::now(); // Current time in user's timezone
|
||||
Rsx_Time::now_iso(); // ISO 8601 format: 2025-12-24T15:30:00-06:00
|
||||
Rsx_Time::format($datetime); // "Dec 24, 2025 3:30 PM"
|
||||
Rsx_Time::format_short($datetime); // "Dec 24, 3:30 PM"
|
||||
Rsx_Time::to_database($datetime); // UTC for storage
|
||||
|
||||
Rsx_Time::get_user_timezone(); // User's timezone or default
|
||||
```
|
||||
|
||||
```javascript
|
||||
Rsx_Time.now(); // Current moment (timezone-aware)
|
||||
Rsx_Time.format(datetime); // Formatted for display
|
||||
Rsx_Time.relative(datetime); // "2 hours ago", "in 3 days"
|
||||
```
|
||||
|
||||
### Rsx_Date - Calendar Dates
|
||||
```php
|
||||
use App\RSpade\Core\Time\Rsx_Date;
|
||||
|
||||
Rsx_Date::today(); // "2025-12-24" (user's timezone)
|
||||
Rsx_Date::format($date); // "Dec 24, 2025"
|
||||
Rsx_Date::is_today($date); // Boolean
|
||||
Rsx_Date::is_past($date); // Boolean
|
||||
```
|
||||
|
||||
**Key Principle**: Functions throw if wrong type passed (datetime to date function or vice versa).
|
||||
|
||||
### Server Time Sync
|
||||
Client time syncs automatically via rsxapp data on page load and AJAX responses. No manual sync required.
|
||||
|
||||
### User Timezone
|
||||
Stored in `login_users.timezone` (IANA format). Falls back to `config('rsx.datetime.default_timezone')`.
|
||||
|
||||
Details: `php artisan rsx:man time`
|
||||
|
||||
---
|
||||
|
||||
## JAVASCRIPT DECORATORS
|
||||
|
||||
```javascript
|
||||
@@ -1285,30 +1212,19 @@ db:query "SQL" --json
|
||||
|
||||
### Testing Routes
|
||||
|
||||
**`rsx:debug /path`** - Preferred method for testing routes
|
||||
|
||||
Uses Playwright to render the page and show rendered output, JavaScript errors, and console messages.
|
||||
**`rsx:debug /path`** - Uses Playwright to render pages with full JS execution.
|
||||
|
||||
```bash
|
||||
rsx:debug /clients # Test route
|
||||
rsx:debug /dashboard --user=1 # Simulate authenticated user
|
||||
rsx:debug /contacts --console # Show console.log output
|
||||
rsx:debug /page --screenshot-path=/tmp/page.png --screenshot-width=mobile # Capture screenshot
|
||||
rsx:debug /page --dump-dimensions=".card" # Add position/size data attributes to elements
|
||||
rsx:debug /path --help # Show all options
|
||||
|
||||
# Simulate user interactions with --eval (executes before DOM capture)
|
||||
rsx:debug /contacts --user=1 --eval="$('.page-link[data-page=\"2\"]').click(); await sleep(2000)"
|
||||
rsx:debug /form --eval="$('#name').val('test'); $('form').submit(); await sleep(500)"
|
||||
rsx:debug /dashboard --user=1 # Authenticated user
|
||||
rsx:debug /page --screenshot-path=/tmp/page.png # Capture screenshot
|
||||
rsx:debug /contacts --eval="$('.btn').click(); await sleep(1000)" # Simulate interaction
|
||||
rsx:debug / --eval="return Rsx_Time.now_iso()" # Get eval result (use return)
|
||||
rsx:debug / --console --eval="console.log(Rsx_Date.today())" # Or console.log with --console
|
||||
```
|
||||
|
||||
Screenshot presets: mobile, iphone-mobile, tablet, desktop-small, desktop-medium, desktop-large
|
||||
Options: `--user=ID`, `--console`, `--screenshot-path`, `--screenshot-width=mobile|tablet|desktop-*`, `--dump-dimensions=".selector"`, `--eval="js"`, `--help`
|
||||
|
||||
The `--eval` option runs JavaScript after page load but before DOM capture. Use `await sleep(ms)` to wait for async operations. This is powerful for testing pagination, form submissions, and other interactive behavior.
|
||||
|
||||
Use this instead of manually browsing, especially for SPA pages and Ajax-heavy features.
|
||||
|
||||
**CRITICAL: SPA routes ARE server routes.** The server knows all SPA routes. `rsx:debug` uses Playwright to fully render pages including all JavaScript and SPA navigation. If you get a 404, the route genuinely doesn't exist - check your URL pattern and route definitions. Never dismiss 404s as "SPA routes can't be tested server-side" - this analysis is incorrect.
|
||||
**SPA routes ARE server routes.** If you get 404, the route doesn't exist - check route definitions. Never dismiss as "SPA can't be tested server-side".
|
||||
|
||||
### Debugging
|
||||
|
||||
@@ -1340,21 +1256,6 @@ Run `rsx:check` before commits. Enforces naming, prohibits animations on non-act
|
||||
|
||||
---
|
||||
|
||||
## MAIN_ABSTRACT MIDDLEWARE
|
||||
|
||||
Optional `/rsx/main.php`:
|
||||
|
||||
```php
|
||||
class Main extends Main_Abstract
|
||||
{
|
||||
public function init() { } // Bootstrap once
|
||||
public function pre_dispatch($request, $params) { return null; } // Before routes
|
||||
public function unhandled_route($request, $params) { } // 404s
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## KEY REMINDERS
|
||||
|
||||
1. **Fail loud** - No silent failures
|
||||
@@ -1382,14 +1283,4 @@ php artisan list rsx # All commands
|
||||
|
||||
## PROJECT DOCUMENTATION
|
||||
|
||||
Project-specific technical documentation lives in `/rsx/resource/man/`. These are man-page-style text files documenting features specific to your application that build on or extend the framework.
|
||||
|
||||
**When to create a project man page**:
|
||||
- Feature has non-obvious implementation details
|
||||
- Multiple components interact in ways that need explanation
|
||||
- Configuration options or patterns need documentation
|
||||
- AI agents or future developers need reference material
|
||||
|
||||
**Format**: Plain text files (`.txt`) following Unix man page conventions. See `/rsx/resource/man/CLAUDE.md` for writing guidelines.
|
||||
|
||||
**Remember**: RSpade prioritizes simplicity and rapid development. When in doubt, choose the straightforward approach.
|
||||
Project-specific man pages in `/rsx/resource/man/*.txt`. Create when features have non-obvious details or component interactions. See `/rsx/resource/man/CLAUDE.md` for format.
|
||||
|
||||
Reference in New Issue
Block a user