Use string-based date/datetime casts instead of Carbon objects

Add TODO expectations and comments documentation to expect_files man page

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-12-24 22:47:19 +00:00
parent 1b57ec2785
commit 8bedb8442d
6 changed files with 338 additions and 22 deletions

View File

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

View File

@@ -0,0 +1,118 @@
<?php
namespace App\RSpade\Core\Time;
use Carbon\Carbon;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use App\RSpade\Core\Time\Rsx_Time;
/**
* Custom Eloquent cast for DATETIME/TIMESTAMP columns
*
* Converts between:
* - Database: MySQL format "YYYY-MM-DD HH:MM:SS" (stored as UTC)
* - PHP/JSON: ISO 8601 format "YYYY-MM-DDTHH:MM:SS.sssZ" (UTC)
*
* Returns strings, NOT Carbon objects. This keeps PHP/JS in sync
* with identical string formats on both sides.
*/
#[Instantiatable]
class Rsx_DateTime_Cast implements CastsAttributes
{
/**
* Cast the value when reading from database
*
* Converts MySQL format to ISO 8601 UTC string
*
* @param Model $model
* @param string $key
* @param mixed $value
* @param array $attributes
* @return string|null
*/
public function get(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value === null) {
return null;
}
// Already ISO format (from cached value or manual set)
if (is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $value)) {
// Normalize to consistent format with milliseconds and Z
return Rsx_Time::to_iso(Rsx_Time::parse($value));
}
// MySQL format "YYYY-MM-DD HH:MM:SS" - convert to ISO
if (is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', $value)) {
// Database stores in UTC, parse as UTC
$carbon = Carbon::createFromFormat(
strlen($value) > 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."
);
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\RSpade\Core\Time;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use App\RSpade\Core\Time\Rsx_Date;
/**
* Custom Eloquent cast for DATE columns
*
* Returns dates as "YYYY-MM-DD" strings, NOT Carbon objects.
* This prevents timezone bugs - a date is just a calendar day, not a moment in time.
*
* Database: 2025-12-24 PHP: "2025-12-24" JSON: "2025-12-24"
*/
#[Instantiatable]
class Rsx_Date_Cast implements CastsAttributes
{
/**
* Cast the value when reading from database
*
* @param Model $model
* @param string $key
* @param mixed $value
* @param array $attributes
* @return string|null
*/
public function get(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value === null) {
return null;
}
// Already a string in YYYY-MM-DD format from MySQL
if (is_string($value)) {
// Validate it's a proper date format
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
return $value;
}
// MySQL might return datetime format for some reason - extract date part
if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $matches)) {
return $matches[1];
}
}
// Unexpected type - fail loud
throw new \InvalidArgumentException(
"Rsx_Date_Cast: Expected date string, got " . gettype($value) . ": " . var_export($value, true)
);
}
/**
* Cast the value when writing to database
*
* @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;
}
// Accept YYYY-MM-DD strings
if (is_string($value)) {
// Validate format
$parsed = Rsx_Date::parse($value);
if ($parsed === null) {
throw new \InvalidArgumentException(
"Rsx_Date_Cast: Invalid date format for '$key': '$value'. Expected YYYY-MM-DD."
);
}
return $parsed;
}
// Reject Carbon and other types - fail loud
throw new \InvalidArgumentException(
"Rsx_Date_Cast: Expected date string (YYYY-MM-DD), got " . gettype($value) .
". Use Rsx_Date::today() or string literals, not Carbon objects."
);
}
}

View File

@@ -11,14 +11,16 @@ use App\RSpade\Core\Session\Session;
* 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
* STRINGS, NOT CARBON: All external-facing methods return ISO 8601 strings
* (format: "2024-12-24T15:30:45.123Z"). This keeps PHP and JavaScript in sync
* with identical formats on both sides. Carbon is used internally for calculations.
*
* Model properties ($model->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);
}

View File

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

View File

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