diff --git a/app/RSpade/Core/Database/Models/Rsx_Model_Abstract.php b/app/RSpade/Core/Database/Models/Rsx_Model_Abstract.php index 41b4f857b..3e6c9f816 100755 --- a/app/RSpade/Core/Database/Models/Rsx_Model_Abstract.php +++ b/app/RSpade/Core/Database/Models/Rsx_Model_Abstract.php @@ -430,9 +430,12 @@ abstract class Rsx_Model_Abstract extends Model * * Auto-detected casts: * - TINYINT(1) columns → 'boolean' - * - DATETIME/TIMESTAMP columns → 'datetime' - * - DATE columns → 'date' - * - TIME columns → 'time' + * - DATETIME/TIMESTAMP columns → Rsx_DateTime_Cast (returns ISO 8601 UTC strings) + * - DATE columns → Rsx_Date_Cast (returns YYYY-MM-DD strings) + * - TIME columns → 'time' (string) + * + * IMPORTANT: Date and datetime columns return STRINGS, not Carbon objects. + * This prevents timezone bugs and keeps PHP/JS in sync with identical formats. * * Users can still override by defining their own $casts property. * @@ -455,26 +458,29 @@ abstract class Rsx_Model_Abstract extends Model } // Auto-detect datetime columns (DATETIME, TIMESTAMP) + // Returns ISO 8601 UTC strings, NOT Carbon objects $datetime_columns = \App\RSpade\Core\Manifest\Manifest::db_get_columns_by_type($table_name, 'datetime'); foreach ($datetime_columns as $column) { if (!isset($casts[$column])) { - $casts[$column] = 'datetime'; + $casts[$column] = \App\RSpade\Core\Time\Rsx_DateTime_Cast::class; } } // Auto-detect date columns (DATE) + // Returns YYYY-MM-DD strings, NOT Carbon objects $date_columns = \App\RSpade\Core\Manifest\Manifest::db_get_columns_by_type($table_name, 'date'); foreach ($date_columns as $column) { if (!isset($casts[$column])) { - $casts[$column] = 'date'; + $casts[$column] = \App\RSpade\Core\Time\Rsx_Date_Cast::class; } } // Auto-detect time columns (TIME) + // Laravel's 'time' cast returns strings, which is what we want $time_columns = \App\RSpade\Core\Manifest\Manifest::db_get_columns_by_type($table_name, 'time'); foreach ($time_columns as $column) { if (!isset($casts[$column])) { - $casts[$column] = 'time'; + $casts[$column] = 'string'; } } diff --git a/app/RSpade/Core/Time/Rsx_DateTime_Cast.php b/app/RSpade/Core/Time/Rsx_DateTime_Cast.php new file mode 100755 index 000000000..dd1f3a025 --- /dev/null +++ b/app/RSpade/Core/Time/Rsx_DateTime_Cast.php @@ -0,0 +1,118 @@ + 19 ? 'Y-m-d H:i:s.v' : 'Y-m-d H:i:s', + $value, + 'UTC' + ); + return $carbon->format('Y-m-d\TH:i:s.v\Z'); + } + + // Carbon object (shouldn't happen but handle gracefully during transition) + if ($value instanceof Carbon) { + return $value->setTimezone('UTC')->format('Y-m-d\TH:i:s.v\Z'); + } + + // Unexpected type - fail loud + throw new \InvalidArgumentException( + "Rsx_DateTime_Cast: Expected datetime string, got " . gettype($value) . ": " . var_export($value, true) + ); + } + + /** + * Cast the value when writing to database + * + * Converts ISO 8601 format to MySQL format for storage + * + * @param Model $model + * @param string $key + * @param mixed $value + * @param array $attributes + * @return string|null + */ + public function set(Model $model, string $key, mixed $value, array $attributes): ?string + { + if ($value === null || $value === '') { + return null; + } + + // ISO 8601 format - convert to MySQL format + if (is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $value)) { + $carbon = Carbon::parse($value)->setTimezone('UTC'); + return $carbon->format('Y-m-d H:i:s'); + } + + // MySQL format already - validate and pass through + if (is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', $value)) { + return $value; + } + + // Unix timestamp (seconds or milliseconds) + if (is_int($value)) { + // Detect milliseconds vs seconds + if ($value > 10000000000) { + $carbon = Carbon::createFromTimestampMs($value, 'UTC'); + } else { + $carbon = Carbon::createFromTimestamp($value, 'UTC'); + } + return $carbon->format('Y-m-d H:i:s'); + } + + // Carbon object (shouldn't happen but handle gracefully during transition) + if ($value instanceof Carbon) { + return $value->setTimezone('UTC')->format('Y-m-d H:i:s'); + } + + // Reject other types - fail loud + throw new \InvalidArgumentException( + "Rsx_DateTime_Cast: Invalid datetime format for '$key': " . var_export($value, true) . + ". Expected ISO 8601 string (YYYY-MM-DDTHH:MM:SS.sssZ) or Unix timestamp." + ); + } +} diff --git a/app/RSpade/Core/Time/Rsx_Date_Cast.php b/app/RSpade/Core/Time/Rsx_Date_Cast.php new file mode 100755 index 000000000..8bfb32de3 --- /dev/null +++ b/app/RSpade/Core/Time/Rsx_Date_Cast.php @@ -0,0 +1,87 @@ +created_at) also return ISO strings via Rsx_DateTime_Cast. * * Core Principles: * - All datetimes stored in database as UTC * - All serialization uses ISO 8601 format + * - External APIs return strings, not Carbon * - User timezone stored per user (login_users.timezone) * - Formatting happens on-demand, not on storage * - PHP and JS APIs are parallel (same method names) @@ -372,15 +374,15 @@ class Rsx_Time * * @param mixed $time * @param int $seconds - * @return Carbon + * @return string ISO 8601 UTC string */ - public static function add($time, int $seconds): Carbon + public static function add($time, int $seconds): string { $carbon = static::parse($time); if (!$carbon) { throw new \InvalidArgumentException("Cannot parse time"); } - return $carbon->addSeconds($seconds); + return $carbon->addSeconds($seconds)->format('Y-m-d\TH:i:s.v\Z'); } /** @@ -388,9 +390,9 @@ class Rsx_Time * * @param mixed $time * @param int $seconds - * @return Carbon + * @return string ISO 8601 UTC string */ - public static function subtract($time, int $seconds): Carbon + public static function subtract($time, int $seconds): string { return static::add($time, -$seconds); } diff --git a/app/RSpade/man/expect_files.txt b/app/RSpade/man/expect_files.txt index c4abb9f3e..0dcc700b4 100755 --- a/app/RSpade/man/expect_files.txt +++ b/app/RSpade/man/expect_files.txt @@ -119,6 +119,55 @@ Optional category prefix groups related expectations: EXPECT: Honor user timezone preference ... +TODO EXPECTATIONS + +Use TODO: prefix for planned functionality that doesn't exist yet: + + TODO: Batch timezone conversion + GIVEN: Array of timestamps + WHEN: Passed to convert_batch() + THEN: All timestamps converted in single operation + --- + + TODO: Timezone autodetection from browser + GIVEN: No user timezone preference set + WHEN: User visits page for first time + THEN: Browser timezone is detected and stored + --- + +TODO expectations document future requirements without implying the feature +exists. The test runner will skip these until converted to EXPECT blocks. + +COMMENTS AND NOTES + +Expect files can include freeform comments and explanations. These help +when eventually writing tests by capturing context, edge cases, and +implementation hints. + +Use # for inline comments within blocks: + + EXPECT: Parse Unix timestamps + GIVEN: Integer 1703432400 + WHEN: Passed to parse() + THEN: Returns correct datetime + # Note: Must detect seconds vs milliseconds automatically + # Threshold: values > 10 billion are milliseconds + --- + +Use plain text between blocks for extended notes: + + ## Edge Cases + + The following expectations cover boundary conditions. When implementing + tests, pay special attention to timezone transitions (DST changes) and + leap seconds. The framework deliberately ignores leap seconds. + + EXPECT: Handle DST transition + ... + +These comments are preserved in the file but ignored by the test runner. +They serve as institutional knowledge for future test authors. + FUTURE: AUTOMATED TEST RUNNER The planned test runner will: diff --git a/app/RSpade/man/time.txt b/app/RSpade/man/time.txt index da5a4d327..770b63908 100755 --- a/app/RSpade/man/time.txt +++ b/app/RSpade/man/time.txt @@ -44,6 +44,24 @@ DESCRIPTION "December 24, 2025" is the same day everywhere. Format: Always "YYYY-MM-DD" (e.g., "2024-12-24") + STRINGS, NOT OBJECTS + All external-facing APIs return STRINGS, not Carbon or Date objects: + + $model->created_at // "2024-12-24T15:30:45.123Z" (string) + $model->due_date // "2024-12-24" (string) + Rsx_Time::now_iso() // "2024-12-24T15:30:45.123Z" (string) + Rsx_Time::to_iso($x) // "2024-12-24T15:30:45.123Z" (string) + Rsx_Time::add($x, 60) // "2024-12-24T15:31:45.123Z" (string) + + This design keeps PHP and JavaScript in sync - identical string formats + on both sides, no serialization surprises. + + Carbon is used internally for calculations, but never exposed externally. + The only method returning Carbon is parse(), for internal calculations: + + $carbon = Rsx_Time::parse($time); // Carbon for calculations + $result = Rsx_Time::to_iso($carbon); // Back to string for output + CRITICAL: Type Separation Date functions THROW if passed a datetime. Datetime functions THROW if passed a date-only string. @@ -111,19 +129,22 @@ RSX_DATE CLASS 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. + All functions accept ISO 8601 datetime strings or Carbon/Date objects. + All functions RETURN strings (except parse, which returns Carbon for + internal calculations). Parsing & Validation parse($input) Returns Carbon (PHP) or Date (JS) in UTC. + Used for internal calculations, not for output. 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() Returns current time as Carbon/Date (for calculations) + now_iso() Returns current time as ISO 8601 string (preferred) now_ms() Returns current time as Unix milliseconds Timezone Handling @@ -175,10 +196,10 @@ RSX_TIME CLASS Arithmetic add($time, $seconds) - Add seconds to time. + Add seconds to time. Returns ISO 8601 string. subtract($time, $seconds) - Subtract seconds from time. + Subtract seconds from time. Returns ISO 8601 string. Comparison is_past($time) True if datetime is in the past @@ -267,6 +288,7 @@ DATA FLOW Database: DATE column, value "2025-12-24" | PHP Model: $task->due_date = "2025-12-24" (string) + | (Rsx_Date_Cast returns YYYY-MM-DD strings, NOT Carbon) | JSON Response: {"due_date": "2025-12-24"} | @@ -284,7 +306,8 @@ DATA FLOW Database: DATETIME(3), value "2025-12-24 15:30:45.123" (UTC) | - PHP Model: $event->scheduled_at = Carbon instance (UTC) + PHP Model: $event->scheduled_at = "2025-12-24T15:30:45.123Z" (string) + | (Rsx_DateTime_Cast converts MySQL format to ISO 8601) | JSON Serialize: {"scheduled_at": "2025-12-24T15:30:45.123Z"} | @@ -298,6 +321,7 @@ DATA FLOW Form Submit: {"scheduled_at": "2025-12-24T16:00:00.000Z"} | PHP Controller: Rsx_Time::parse($params['scheduled_at']) -> Carbon + | (only if calculations needed; can also save string directly) | Database: "2025-12-24 16:00:00.000" @@ -374,8 +398,38 @@ CONFIGURATION User timezone stored in login_users.timezone column. +MODEL CASTING + Model date/datetime columns are automatically cast to strings via custom + Eloquent casts. No configuration needed - columns are detected from the + database schema. + + Rsx_DateTime_Cast (for DATETIME/TIMESTAMP columns) + - Database read: "2024-12-24 15:30:45" -> "2024-12-24T15:30:45.000Z" + - Database write: "2024-12-24T15:30:45.000Z" -> "2024-12-24 15:30:45" + - Accepts: ISO 8601 strings, MySQL datetime strings, Unix timestamps + - Rejects: Carbon objects (use to_iso() to convert first) + + Rsx_Date_Cast (for DATE columns) + - Database read: "2024-12-24" -> "2024-12-24" + - Database write: "2024-12-24" -> "2024-12-24" + - Accepts: YYYY-MM-DD strings only + - Rejects: Carbon objects, datetime strings + + Why strings instead of Carbon? + 1. Prevents timezone bugs - dates have no timezone, Carbon assumes one + 2. PHP/JS parity - identical formats on both sides + 3. No serialization surprises - what you see is what you get + 4. Simpler mental model - just strings everywhere + + Overriding casts: + If a model needs different behavior, override $casts in the model: + + protected $casts = [ + 'special_date' => 'date', // Use Laravel's default + ]; + SEE ALSO - Reference document: /var/www/html/date_vs_datetime_refactor.md + Rsx_Time, Rsx_Date, Rsx_DateTime_Cast, Rsx_Date_Cast AUTHOR RSpade Framework