From f70ca09f788bf79d9b2e85974884f6a41b90a533 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 12 Jan 2026 17:25:07 +0000 Subject: [PATCH] Framework updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Core/Files/File_Attachment_Model.php | 73 ++-- app/RSpade/Core/Js/Rsx_Date.js | 316 +++++++++++++++++- app/RSpade/Core/Js/Rsx_Time.js | 153 +++++++++ app/RSpade/Core/Models/User_Model.php | 70 ++-- .../Core/Models/User_Verification_Model.php | 49 ++- app/RSpade/Core/Time/Rsx_Date.php | 309 ++++++++++++++++- app/RSpade/Core/Time/Rsx_Time.php | 273 ++++++++++++++- app/RSpade/Lib/Flash/Flash_Alert_Model.php | 42 +-- app/RSpade/man/time.txt | 75 ++++- database/migrations/.migration_whitelist | 5 + ..._12_073624_add_timezone_to_sites_table.php | 23 ++ docs/CLAUDE.dist.md | 2 +- 12 files changed, 1234 insertions(+), 156 deletions(-) create mode 100755 database/migrations/2026_01_12_073624_add_timezone_to_sites_table.php diff --git a/app/RSpade/Core/Files/File_Attachment_Model.php b/app/RSpade/Core/Files/File_Attachment_Model.php index d7dee2732..bc59884af 100644 --- a/app/RSpade/Core/Files/File_Attachment_Model.php +++ b/app/RSpade/Core/Files/File_Attachment_Model.php @@ -30,50 +30,42 @@ use App\RSpade\Core\Files\File_Storage_Model; * provides the basic structure for categorizing uploaded files. */ + /** - * _AUTO_GENERATED_ Database type hints - do not edit manually - * Generated on: 2025-12-26 02:43:29 - * Table: _file_attachments - * - * @property int $id - * @property mixed $key - * @property int $file_storage_id - * @property mixed $file_name - * @property mixed $file_extension - * @property int $file_type_id - * @property int $width - * @property int $height - * @property int $duration - * @property bool $is_animated - * @property int $frame_count - * @property mixed $fileable_type - * @property int $fileable_id - * @property mixed $fileable_category - * @property mixed $fileable_type_meta - * @property int $fileable_order + * _AUTO_GENERATED_ + * @property integer $id + * @property string $key + * @property integer $file_storage_id + * @property string $file_name + * @property string $file_extension + * @property integer $file_type_id + * @property integer $width + * @property integer $height + * @property integer $duration + * @property boolean $is_animated + * @property integer $frame_count + * @property integer $fileable_type + * @property integer $fileable_id + * @property string $fileable_category + * @property string $fileable_type_meta + * @property integer $fileable_order * @property string $fileable_meta - * @property int $site_id - * @property mixed $session_id - * @property string $created_at - * @property string $updated_at - * @property int $created_by - * @property int $updated_by - * - * @property-read string $file_type_id__label - * @property-read string $file_type_id__constant - * - * @method static array file_type_id__enum() Get all enum definitions with full metadata - * @method static array file_type_id__enum_select() Get selectable items for dropdowns - * @method static array file_type_id__enum_labels() Get simple id => label map - * @method static array file_type_id__enum_ids() Get array of all valid enum IDs - * + * @property integer $site_id + * @property string $session_id + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at + * @property integer $created_by + * @property integer $updated_by + * @method static mixed file_type_id_enum() + * @method static mixed file_type_id_enum_select() + * @method static mixed file_type_id_enum_ids() + * @property-read mixed $file_type_id_constant + * @property-read mixed $file_type_id_label * @mixin \Eloquent */ class File_Attachment_Model extends Rsx_Site_Model_Abstract - { - /** - * _AUTO_GENERATED_ Enum constants - */ +{ + /** __AUTO_GENERATED: */ const FILE_TYPE_IMAGE = 1; const FILE_TYPE_ANIMATED_IMAGE = 2; const FILE_TYPE_VIDEO = 3; @@ -81,9 +73,6 @@ class File_Attachment_Model extends Rsx_Site_Model_Abstract const FILE_TYPE_TEXT = 5; const FILE_TYPE_DOCUMENT = 6; const FILE_TYPE_OTHER = 7; - - /** __AUTO_GENERATED: */ - /** __/AUTO_GENERATED */ /** diff --git a/app/RSpade/Core/Js/Rsx_Date.js b/app/RSpade/Core/Js/Rsx_Date.js index df1753cba..e9e7a8606 100755 --- a/app/RSpade/Core/Js/Rsx_Date.js +++ b/app/RSpade/Core/Js/Rsx_Date.js @@ -111,9 +111,18 @@ class Rsx_Date { // CURRENT DATE // ========================================================================= + /** + * Alias for today() + * + * @returns {string} + */ + static now() { + return this.today(); + } + /** * Get today's date as "YYYY-MM-DD" - * Uses the user's timezone to determine what "today" is + * Uses user → site → default timezone from rsxapp * * @returns {string} */ @@ -236,4 +245,309 @@ class Rsx_Date { return Math.round((ms2 - ms1) / (1000 * 60 * 60 * 24)); } + + /** + * Format as relative date ("Today", "Yesterday", "3 days ago", "in 5 days") + * + * @param {*} date + * @returns {string} + */ + static relative(date) { + const parsed = this.parse(date); + if (!parsed) { + return ''; + } + + const days = this.diff_days(this.today(), parsed); + + if (days === 0) { + return 'Today'; + } else if (days === 1) { + return 'Tomorrow'; + } else if (days === -1) { + return 'Yesterday'; + } else if (days > 1) { + return `in ${days} days`; + } else { + return `${Math.abs(days)} days ago`; + } + } + + // ========================================================================= + // ARITHMETIC + // ========================================================================= + + /** + * Add days to a date + * + * @param {*} date + * @param {number} days Can be negative to subtract + * @returns {string|null} "YYYY-MM-DD" or null if invalid input + */ + static add_days(date, days) { + const parsed = this.parse(date); + if (!parsed) { + return null; + } + + const [year, month, day] = parsed.split('-').map(Number); + const d = new Date(year, month - 1, day); + d.setDate(d.getDate() + days); + + return d.getFullYear() + '-' + + String(d.getMonth() + 1).padStart(2, '0') + '-' + + String(d.getDate()).padStart(2, '0'); + } + + // ========================================================================= + // WEEK/MONTH BOUNDARIES + // ========================================================================= + + /** + * Get the Monday of the week containing the date + * + * @param {*} date + * @returns {string|null} "YYYY-MM-DD" or null if invalid input + */ + static start_of_week(date) { + const parsed = this.parse(date); + if (!parsed) { + return null; + } + + const [year, month, day] = parsed.split('-').map(Number); + const d = new Date(year, month - 1, day); + const dow = d.getDay(); + // Convert to Monday=0 based, then subtract to get Monday + const daysToSubtract = (dow === 0) ? 6 : dow - 1; + d.setDate(d.getDate() - daysToSubtract); + + return d.getFullYear() + '-' + + String(d.getMonth() + 1).padStart(2, '0') + '-' + + String(d.getDate()).padStart(2, '0'); + } + + /** + * Get the Sunday of the week containing the date + * + * @param {*} date + * @returns {string|null} "YYYY-MM-DD" or null if invalid input + */ + static end_of_week(date) { + const parsed = this.parse(date); + if (!parsed) { + return null; + } + + const [year, month, day] = parsed.split('-').map(Number); + const d = new Date(year, month - 1, day); + const dow = d.getDay(); + // Days to add to get to Sunday + const daysToAdd = (dow === 0) ? 0 : 7 - dow; + d.setDate(d.getDate() + daysToAdd); + + return d.getFullYear() + '-' + + String(d.getMonth() + 1).padStart(2, '0') + '-' + + String(d.getDate()).padStart(2, '0'); + } + + /** + * Get the first day of the month containing the date + * + * @param {*} date + * @returns {string|null} "YYYY-MM-DD" or null if invalid input + */ + static start_of_month(date) { + const parsed = this.parse(date); + if (!parsed) { + return null; + } + + const [year, month] = parsed.split('-').map(Number); + return year + '-' + String(month).padStart(2, '0') + '-01'; + } + + /** + * Get the last day of the month containing the date + * + * @param {*} date + * @returns {string|null} "YYYY-MM-DD" or null if invalid input + */ + static end_of_month(date) { + const parsed = this.parse(date); + if (!parsed) { + return null; + } + + const [year, month] = parsed.split('-').map(Number); + // Day 0 of next month = last day of current month + const d = new Date(year, month, 0); + + return d.getFullYear() + '-' + + String(d.getMonth() + 1).padStart(2, '0') + '-' + + String(d.getDate()).padStart(2, '0'); + } + + /** + * Check if date falls on a weekend (Saturday or Sunday) + * + * @param {*} date + * @returns {boolean} + */ + static is_weekend(date) { + const dow = this.dow(date); + if (dow === null) { + return false; + } + return dow === 0 || dow === 6; + } + + /** + * Check if date falls on a weekday (Monday-Friday) + * + * @param {*} date + * @returns {boolean} + */ + static is_weekday(date) { + const dow = this.dow(date); + if (dow === null) { + return false; + } + return dow >= 1 && dow <= 5; + } + + // ========================================================================= + // COMPONENT EXTRACTORS + // ========================================================================= + + /** + * Get day of month (1-31) + * + * @param {*} date + * @returns {number|null} + */ + static day(date) { + const parsed = this.parse(date); + if (!parsed) { + return null; + } + return parseInt(parsed.split('-')[2], 10); + } + + /** + * Get day of week (0=Sunday, 6=Saturday) + * + * @param {*} date + * @returns {number|null} + */ + static dow(date) { + const parsed = this.parse(date); + if (!parsed) { + return null; + } + + const [year, month, day] = parsed.split('-').map(Number); + const d = new Date(year, month - 1, day); + return d.getDay(); + } + + /** + * Get full day name ("Monday", "Tuesday", etc.) + * + * @param {*} date + * @returns {string} + */ + static dow_human(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', { weekday: 'long' }).format(d); + } + + /** + * Get short day name ("Mon", "Tue", etc.) + * + * @param {*} date + * @returns {string} + */ + static dow_short(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', { weekday: 'short' }).format(d); + } + + /** + * Get month (1-12) + * + * @param {*} date + * @returns {number|null} + */ + static month(date) { + const parsed = this.parse(date); + if (!parsed) { + return null; + } + return parseInt(parsed.split('-')[1], 10); + } + + /** + * Get full month name ("January", "February", etc.) + * + * @param {*} date + * @returns {string} + */ + static month_human(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: 'long' }).format(d); + } + + /** + * Get short month name ("Jan", "Feb", etc.) + * + * @param {*} date + * @returns {string} + */ + static month_human_short(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' }).format(d); + } + + /** + * Get year (e.g., 2025) + * + * @param {*} date + * @returns {number|null} + */ + static year(date) { + const parsed = this.parse(date); + if (!parsed) { + return null; + } + return parseInt(parsed.split('-')[0], 10); + } } diff --git a/app/RSpade/Core/Js/Rsx_Time.js b/app/RSpade/Core/Js/Rsx_Time.js index 12c7630fc..cf81cb1c0 100755 --- a/app/RSpade/Core/Js/Rsx_Time.js +++ b/app/RSpade/Core/Js/Rsx_Time.js @@ -609,4 +609,157 @@ class Rsx_Time { stop: () => clearInterval(interval) }; } + + // ========================================================================= + // COMPONENT EXTRACTORS + // ========================================================================= + + /** + * Get day of month (1-31) + * Uses user's timezone + * + * @param {*} time + * @returns {number|null} + */ + static day(time) { + const date = this.parse(time); + if (!date) return null; + + return parseInt(this.format_in_timezone(time, { day: 'numeric' }), 10); + } + + /** + * Get day of week (0=Sunday, 6=Saturday) + * Uses user's timezone + * + * @param {*} time + * @returns {number|null} + */ + static dow(time) { + const date = this.parse(time); + if (!date) return null; + + // Get the day name and map to number + const dayName = this.format_in_timezone(time, { weekday: 'short' }); + const dayMap = { 'Sun': 0, 'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6 }; + return dayMap[dayName] ?? null; + } + + /** + * Get full day name ("Monday", "Tuesday", etc.) + * Uses user's timezone + * + * @param {*} time + * @returns {string} + */ + static dow_human(time) { + const date = this.parse(time); + if (!date) return ''; + + return this.format_in_timezone(time, { weekday: 'long' }); + } + + /** + * Get short day name ("Mon", "Tue", etc.) + * Uses user's timezone + * + * @param {*} time + * @returns {string} + */ + static dow_short(time) { + const date = this.parse(time); + if (!date) return ''; + + return this.format_in_timezone(time, { weekday: 'short' }); + } + + /** + * Get month (1-12) + * Uses user's timezone + * + * @param {*} time + * @returns {number|null} + */ + static month(time) { + const date = this.parse(time); + if (!date) return null; + + // Use 2-digit to get padded month, then parse + const monthStr = this.format_in_timezone(time, { month: '2-digit' }); + return parseInt(monthStr, 10); + } + + /** + * Get full month name ("January", "February", etc.) + * Uses user's timezone + * + * @param {*} time + * @returns {string} + */ + static month_human(time) { + const date = this.parse(time); + if (!date) return ''; + + return this.format_in_timezone(time, { month: 'long' }); + } + + /** + * Get short month name ("Jan", "Feb", etc.) + * Uses user's timezone + * + * @param {*} time + * @returns {string} + */ + static month_human_short(time) { + const date = this.parse(time); + if (!date) return ''; + + return this.format_in_timezone(time, { month: 'short' }); + } + + /** + * Get year (e.g., 2025) + * Uses user's timezone + * + * @param {*} time + * @returns {number|null} + */ + static year(time) { + const date = this.parse(time); + if (!date) return null; + + return parseInt(this.format_in_timezone(time, { year: 'numeric' }), 10); + } + + /** + * Get hour (0-23) + * Uses user's timezone + * + * @param {*} time + * @returns {number|null} + */ + static hour(time) { + const date = this.parse(time); + if (!date) return null; + + // Use hour12: false and 2-digit for consistent 24-hour format + const hourStr = this.format_in_timezone(time, { hour: '2-digit', hour12: false }); + // "24" is returned for midnight in some locales, treat as 0 + const hour = parseInt(hourStr, 10); + return hour === 24 ? 0 : hour; + } + + /** + * Get minute (0-59) + * Uses user's timezone + * + * @param {*} time + * @returns {number|null} + */ + static minute(time) { + const date = this.parse(time); + if (!date) return null; + + return parseInt(this.format_in_timezone(time, { minute: '2-digit' }), 10); + } } diff --git a/app/RSpade/Core/Models/User_Model.php b/app/RSpade/Core/Models/User_Model.php index 45092f390..6a16a2c69 100644 --- a/app/RSpade/Core/Models/User_Model.php +++ b/app/RSpade/Core/Models/User_Model.php @@ -23,46 +23,41 @@ use App\RSpade\Core\Models\User_Profile_Model; * See: php artisan rsx:man acls */ + /** - * _AUTO_GENERATED_ Database type hints - do not edit manually - * Generated on: 2025-12-26 02:43:29 - * Table: users - * - * @property int $id - * @property int $login_user_id - * @property int $site_id - * @property mixed $first_name - * @property mixed $last_name - * @property mixed $phone - * @property int $role_id - * @property bool $is_enabled - * @property int $user_role_id - * @property mixed $email - * @property string $deleted_at - * @property string $created_at - * @property string $updated_at - * @property int $created_by - * @property int $updated_by - * @property int $deleted_by - * @property mixed $invite_code - * @property string $invite_accepted_at - * @property string $invite_expires_at - * - * @property-read string $role_id__label - * @property-read string $role_id__constant - * - * @method static array role_id__enum() Get all enum definitions with full metadata - * @method static array role_id__enum_select() Get selectable items for dropdowns - * @method static array role_id__enum_labels() Get simple id => label map - * @method static array role_id__enum_ids() Get array of all valid enum IDs - * + * _AUTO_GENERATED_ + * @property integer $id + * @property integer $login_user_id + * @property integer $site_id + * @property string $first_name + * @property string $last_name + * @property string $phone + * @property integer $role_id + * @property boolean $is_enabled + * @property integer $user_role_id + * @property string $email + * @property \Carbon\Carbon $deleted_at + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at + * @property integer $created_by + * @property integer $updated_by + * @property integer $deleted_by + * @property string $invite_code + * @property \Carbon\Carbon $invite_accepted_at + * @property \Carbon\Carbon $invite_expires_at + * @method static mixed role_id_enum() + * @method static mixed role_id_enum_select() + * @method static mixed role_id_enum_ids() + * @property-read mixed $role_id_constant + * @property-read mixed $role_id_label + * @property-read mixed $role_id_permissions + * @property-read mixed $role_id_can_admin_roles + * @property-read mixed $role_id_selectable * @mixin \Eloquent */ class User_Model extends Rsx_Site_Model_Abstract - { - /** - * _AUTO_GENERATED_ Enum constants - */ +{ + /** __AUTO_GENERATED: */ const ROLE_DEVELOPER = 100; const ROLE_ROOT_ADMIN = 200; const ROLE_SITE_OWNER = 300; @@ -71,9 +66,6 @@ class User_Model extends Rsx_Site_Model_Abstract const ROLE_USER = 600; const ROLE_VIEWER = 700; const ROLE_DISABLED = 800; - - /** __AUTO_GENERATED: */ - /** __/AUTO_GENERATED */ // ========================================================================= diff --git a/app/RSpade/Core/Models/User_Verification_Model.php b/app/RSpade/Core/Models/User_Verification_Model.php index 2427a5210..bdad29a8e 100644 --- a/app/RSpade/Core/Models/User_Verification_Model.php +++ b/app/RSpade/Core/Models/User_Verification_Model.php @@ -11,44 +11,33 @@ use App\RSpade\Core\Database\Models\Rsx_Model_Abstract; * and two-factor authentication via email or SMS. */ + /** - * _AUTO_GENERATED_ Database type hints - do not edit manually - * Generated on: 2025-12-26 02:43:29 - * Table: user_verifications - * - * @property int $id - * @property mixed $email - * @property mixed $verification_code - * @property int $verification_type_id - * @property string $verified_at - * @property string $expires_at - * @property string $created_at - * @property string $updated_at - * @property int $created_by - * @property int $updated_by - * - * @property-read string $verification_type_id__label - * @property-read string $verification_type_id__constant - * - * @method static array verification_type_id__enum() Get all enum definitions with full metadata - * @method static array verification_type_id__enum_select() Get selectable items for dropdowns - * @method static array verification_type_id__enum_labels() Get simple id => label map - * @method static array verification_type_id__enum_ids() Get array of all valid enum IDs - * + * _AUTO_GENERATED_ + * @property integer $id + * @property string $email + * @property string $verification_code + * @property integer $verification_type_id + * @property \Carbon\Carbon $verified_at + * @property \Carbon\Carbon $expires_at + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at + * @property integer $created_by + * @property integer $updated_by + * @method static mixed verification_type_id_enum() + * @method static mixed verification_type_id_enum_select() + * @method static mixed verification_type_id_enum_ids() + * @property-read mixed $verification_type_id_constant + * @property-read mixed $verification_type_id_label * @mixin \Eloquent */ class User_Verification_Model extends Rsx_Model_Abstract - { - /** - * _AUTO_GENERATED_ Enum constants - */ +{ + /** __AUTO_GENERATED: */ const VERIFICATION_TYPE_EMAIL = 1; const VERIFICATION_TYPE_SMS = 2; const VERIFICATION_TYPE_EMAIL_RECOVERY = 3; const VERIFICATION_TYPE_SMS_RECOVERY = 4; - - /** __AUTO_GENERATED: */ - /** __/AUTO_GENERATED */ /** diff --git a/app/RSpade/Core/Time/Rsx_Date.php b/app/RSpade/Core/Time/Rsx_Date.php index 636438611..1b0483425 100644 --- a/app/RSpade/Core/Time/Rsx_Date.php +++ b/app/RSpade/Core/Time/Rsx_Date.php @@ -113,16 +113,26 @@ class Rsx_Date // CURRENT DATE // ========================================================================= + /** + * Alias for today() + * + * @return string + */ + public static function now(): string + { + return static::today(); + } + /** * Get today's date as "YYYY-MM-DD" - * Uses the user's timezone to determine what "today" is + * Uses user → site → default timezone resolution * * @return string */ public static function today(): string { - $user_tz = Rsx_Time::get_user_timezone(); - return Carbon::now($user_tz)->format('Y-m-d'); + $tz = Rsx_Time::get_user_timezone(); + return Carbon::now($tz)->format('Y-m-d'); } // ========================================================================= @@ -230,6 +240,299 @@ class Rsx_Date return $carbon1->diffInDays($carbon2, false); } + /** + * Format as relative date ("Today", "Yesterday", "3 days ago", "in 5 days") + * + * @param mixed $date + * @return string + */ + public static function relative($date): string + { + $parsed = static::parse($date); + if (!$parsed) { + return ''; + } + + $days = static::diff_days(static::today(), $parsed); + + if ($days === 0) { + return 'Today'; + } elseif ($days === 1) { + return 'Tomorrow'; + } elseif ($days === -1) { + return 'Yesterday'; + } elseif ($days > 1) { + return "in {$days} days"; + } else { + return abs($days) . ' days ago'; + } + } + + // ========================================================================= + // ARITHMETIC + // ========================================================================= + + /** + * Add days to a date + * + * @param mixed $date + * @param int $days Can be negative to subtract + * @return string|null "YYYY-MM-DD" or null if invalid input + */ + public static function add_days($date, int $days): ?string + { + $parsed = static::parse($date); + if (!$parsed) { + return null; + } + + $carbon = Carbon::createFromFormat('Y-m-d', $parsed)->addDays($days); + return $carbon->format('Y-m-d'); + } + + // ========================================================================= + // WEEK/MONTH BOUNDARIES + // ========================================================================= + + /** + * Get the Monday of the week containing the date + * + * @param mixed $date + * @return string|null "YYYY-MM-DD" or null if invalid input + */ + public static function start_of_week($date): ?string + { + $parsed = static::parse($date); + if (!$parsed) { + return null; + } + + $carbon = Carbon::createFromFormat('Y-m-d', $parsed)->startOfWeek(Carbon::MONDAY); + return $carbon->format('Y-m-d'); + } + + /** + * Get the Sunday of the week containing the date + * + * @param mixed $date + * @return string|null "YYYY-MM-DD" or null if invalid input + */ + public static function end_of_week($date): ?string + { + $parsed = static::parse($date); + if (!$parsed) { + return null; + } + + $carbon = Carbon::createFromFormat('Y-m-d', $parsed)->endOfWeek(Carbon::SUNDAY); + return $carbon->format('Y-m-d'); + } + + /** + * Get the first day of the month containing the date + * + * @param mixed $date + * @return string|null "YYYY-MM-DD" or null if invalid input + */ + public static function start_of_month($date): ?string + { + $parsed = static::parse($date); + if (!$parsed) { + return null; + } + + $carbon = Carbon::createFromFormat('Y-m-d', $parsed)->startOfMonth(); + return $carbon->format('Y-m-d'); + } + + /** + * Get the last day of the month containing the date + * + * @param mixed $date + * @return string|null "YYYY-MM-DD" or null if invalid input + */ + public static function end_of_month($date): ?string + { + $parsed = static::parse($date); + if (!$parsed) { + return null; + } + + $carbon = Carbon::createFromFormat('Y-m-d', $parsed)->endOfMonth(); + return $carbon->format('Y-m-d'); + } + + /** + * Check if date falls on a weekend (Saturday or Sunday) + * + * @param mixed $date + * @return bool + */ + public static function is_weekend($date): bool + { + $parsed = static::parse($date); + if (!$parsed) { + return false; + } + + $carbon = Carbon::createFromFormat('Y-m-d', $parsed); + return $carbon->isWeekend(); + } + + /** + * Check if date falls on a weekday (Monday-Friday) + * + * @param mixed $date + * @return bool + */ + public static function is_weekday($date): bool + { + $parsed = static::parse($date); + if (!$parsed) { + return false; + } + + $carbon = Carbon::createFromFormat('Y-m-d', $parsed); + return $carbon->isWeekday(); + } + + // ========================================================================= + // COMPONENT EXTRACTORS + // ========================================================================= + + /** + * Get day of month (1-31) + * + * @param mixed $date + * @return int|null + */ + public static function day($date): ?int + { + $parsed = static::parse($date); + if (!$parsed) { + return null; + } + + return (int) explode('-', $parsed)[2]; + } + + /** + * Get day of week (0=Sunday, 6=Saturday) + * + * @param mixed $date + * @return int|null + */ + public static function dow($date): ?int + { + $parsed = static::parse($date); + if (!$parsed) { + return null; + } + + $carbon = Carbon::createFromFormat('Y-m-d', $parsed); + return $carbon->dayOfWeek; + } + + /** + * Get full day name ("Monday", "Tuesday", etc.) + * + * @param mixed $date + * @return string + */ + public static function dow_human($date): string + { + $parsed = static::parse($date); + if (!$parsed) { + return ''; + } + + $carbon = Carbon::createFromFormat('Y-m-d', $parsed); + return $carbon->format('l'); + } + + /** + * Get short day name ("Mon", "Tue", etc.) + * + * @param mixed $date + * @return string + */ + public static function dow_short($date): string + { + $parsed = static::parse($date); + if (!$parsed) { + return ''; + } + + $carbon = Carbon::createFromFormat('Y-m-d', $parsed); + return $carbon->format('D'); + } + + /** + * Get month (1-12) + * + * @param mixed $date + * @return int|null + */ + public static function month($date): ?int + { + $parsed = static::parse($date); + if (!$parsed) { + return null; + } + + return (int) explode('-', $parsed)[1]; + } + + /** + * Get full month name ("January", "February", etc.) + * + * @param mixed $date + * @return string + */ + public static function month_human($date): string + { + $parsed = static::parse($date); + if (!$parsed) { + return ''; + } + + $carbon = Carbon::createFromFormat('Y-m-d', $parsed); + return $carbon->format('F'); + } + + /** + * Get short month name ("Jan", "Feb", etc.) + * + * @param mixed $date + * @return string + */ + public static function month_human_short($date): string + { + $parsed = static::parse($date); + if (!$parsed) { + return ''; + } + + $carbon = Carbon::createFromFormat('Y-m-d', $parsed); + return $carbon->format('M'); + } + + /** + * Get year (e.g., 2025) + * + * @param mixed $date + * @return int|null + */ + public static function year($date): ?int + { + $parsed = static::parse($date); + if (!$parsed) { + return null; + } + + return (int) explode('-', $parsed)[0]; + } + // ========================================================================= // DATABASE // ========================================================================= diff --git a/app/RSpade/Core/Time/Rsx_Time.php b/app/RSpade/Core/Time/Rsx_Time.php index d54895eb9..5007c11ae 100644 --- a/app/RSpade/Core/Time/Rsx_Time.php +++ b/app/RSpade/Core/Time/Rsx_Time.php @@ -31,6 +31,40 @@ use App\RSpade\Core\Session\Session; */ class Rsx_Time { + // ========================================================================= + // TIMEZONE CACHING + // ========================================================================= + + /** + * Cached user timezone + * @var string|null + */ + private static ?string $_cached_user_timezone = null; + + /** + * User ID when timezone was cached (for invalidation) + * @var int|null + */ + private static ?int $_cached_user_id = null; + + /** + * Site ID when timezone was cached (for invalidation) + * @var int|null + */ + private static ?int $_cached_site_id = null; + + /** + * Clear cached timezone (called when session user/site changes) + * + * @return void + */ + public static function clear_timezone_cache(): void + { + static::$_cached_user_timezone = null; + static::$_cached_user_id = null; + static::$_cached_site_id = null; + } + // ========================================================================= // CURRENT TIME // ========================================================================= @@ -210,24 +244,65 @@ class Rsx_Time /** * Get the current user's timezone * Resolution: user setting → site default → config default → America/Chicago + * Result is cached and invalidated when session user/site changes * * @return string IANA timezone identifier */ public static function get_user_timezone(): string { + $current_user_id = Session::get_login_user_id(); + $current_site_id = Session::get_site_id(); + + // Check if cache is valid + if (static::$_cached_user_timezone !== null + && static::$_cached_user_id === $current_user_id + && static::$_cached_site_id === $current_site_id) { + return static::$_cached_user_timezone; + } + + // Cache miss - recalculate + $timezone = null; + // Check logged-in user's preference $login_user = Session::get_login_user(); if ($login_user && !empty($login_user->timezone)) { - return $login_user->timezone; + $timezone = $login_user->timezone; } - // Check site default (future enhancement) - // $site = Session::get_site(); - // if ($site && !empty($site->timezone)) { - // return $site->timezone; - // } + // Check site default + if ($timezone === null) { + $site = Session::get_site(); + if ($site && !empty($site->timezone)) { + $timezone = $site->timezone; + } + } // Config default + if ($timezone === null) { + $timezone = config('rsx.datetime.default_timezone', 'America/Chicago'); + } + + // Cache the result + static::$_cached_user_timezone = $timezone; + static::$_cached_user_id = $current_user_id; + static::$_cached_site_id = $current_site_id; + + return $timezone; + } + + /** + * Get the current site's timezone (ignoring user preference) + * Resolution: site default → config default → America/Chicago + * + * @return string IANA timezone identifier + */ + public static function get_site_timezone(): string + { + $site = Session::get_site(); + if ($site && !empty($site->timezone)) { + return $site->timezone; + } + return config('rsx.datetime.default_timezone', 'America/Chicago'); } @@ -308,6 +383,28 @@ class Rsx_Time return $end_carbon->diffInSeconds($start_carbon, false); } + /** + * Seconds until a future time (negative if past) + * + * @param mixed $time + * @return int + */ + public static function seconds_until($time): int + { + return static::diff_seconds(static::now(), $time); + } + + /** + * Seconds since a past time (negative if future) + * + * @param mixed $time + * @return int + */ + public static function seconds_since($time): int + { + return static::diff_seconds($time, static::now()); + } + /** * Format duration as human-readable string * @@ -517,6 +614,170 @@ class Rsx_Time return static::format($time, 'M j, Y g:i A T', $timezone); } + // ========================================================================= + // COMPONENT EXTRACTORS + // ========================================================================= + + /** + * Get day of month (1-31) + * Uses user's timezone + * + * @param mixed $time + * @return int|null + */ + public static function day($time): ?int + { + $carbon = static::parse($time); + if (!$carbon) { + return null; + } + return (int) static::to_user_timezone($carbon)->format('j'); + } + + /** + * Get day of week (0=Sunday, 6=Saturday) + * Uses user's timezone + * + * @param mixed $time + * @return int|null + */ + public static function dow($time): ?int + { + $carbon = static::parse($time); + if (!$carbon) { + return null; + } + return static::to_user_timezone($carbon)->dayOfWeek; + } + + /** + * Get full day name ("Monday", "Tuesday", etc.) + * Uses user's timezone + * + * @param mixed $time + * @return string + */ + public static function dow_human($time): string + { + $carbon = static::parse($time); + if (!$carbon) { + return ''; + } + return static::to_user_timezone($carbon)->format('l'); + } + + /** + * Get short day name ("Mon", "Tue", etc.) + * Uses user's timezone + * + * @param mixed $time + * @return string + */ + public static function dow_short($time): string + { + $carbon = static::parse($time); + if (!$carbon) { + return ''; + } + return static::to_user_timezone($carbon)->format('D'); + } + + /** + * Get month (1-12) + * Uses user's timezone + * + * @param mixed $time + * @return int|null + */ + public static function month($time): ?int + { + $carbon = static::parse($time); + if (!$carbon) { + return null; + } + return (int) static::to_user_timezone($carbon)->format('n'); + } + + /** + * Get full month name ("January", "February", etc.) + * Uses user's timezone + * + * @param mixed $time + * @return string + */ + public static function month_human($time): string + { + $carbon = static::parse($time); + if (!$carbon) { + return ''; + } + return static::to_user_timezone($carbon)->format('F'); + } + + /** + * Get short month name ("Jan", "Feb", etc.) + * Uses user's timezone + * + * @param mixed $time + * @return string + */ + public static function month_human_short($time): string + { + $carbon = static::parse($time); + if (!$carbon) { + return ''; + } + return static::to_user_timezone($carbon)->format('M'); + } + + /** + * Get year (e.g., 2025) + * Uses user's timezone + * + * @param mixed $time + * @return int|null + */ + public static function year($time): ?int + { + $carbon = static::parse($time); + if (!$carbon) { + return null; + } + return (int) static::to_user_timezone($carbon)->format('Y'); + } + + /** + * Get hour (0-23) + * Uses user's timezone + * + * @param mixed $time + * @return int|null + */ + public static function hour($time): ?int + { + $carbon = static::parse($time); + if (!$carbon) { + return null; + } + return (int) static::to_user_timezone($carbon)->format('G'); + } + + /** + * Get minute (0-59) + * Uses user's timezone + * + * @param mixed $time + * @return int|null + */ + public static function minute($time): ?int + { + $carbon = static::parse($time); + if (!$carbon) { + return null; + } + return (int) static::to_user_timezone($carbon)->format('i'); + } + // ========================================================================= // DATABASE HELPERS // ========================================================================= diff --git a/app/RSpade/Lib/Flash/Flash_Alert_Model.php b/app/RSpade/Lib/Flash/Flash_Alert_Model.php index 0c685d35d..e6361dada 100644 --- a/app/RSpade/Lib/Flash/Flash_Alert_Model.php +++ b/app/RSpade/Lib/Flash/Flash_Alert_Model.php @@ -5,41 +5,29 @@ namespace App\RSpade\Lib\Flash; use App\RSpade\Core\Database\Models\Rsx_Model_Abstract; /** - * _AUTO_GENERATED_ Database type hints - do not edit manually - * Generated on: 2025-12-26 02:43:30 - * Table: _flash_alerts - * - * @property int $id - * @property int $session_id - * @property int $type_id + * _AUTO_GENERATED_ + * @property integer $id + * @property integer $session_id + * @property integer $type_id * @property string $message - * @property string $created_at - * @property int $created_by - * @property int $updated_by - * @property string $updated_at - * - * @property-read string $type_id__label - * @property-read string $type_id__constant - * - * @method static array type_id__enum() Get all enum definitions with full metadata - * @method static array type_id__enum_select() Get selectable items for dropdowns - * @method static array type_id__enum_labels() Get simple id => label map - * @method static array type_id__enum_ids() Get array of all valid enum IDs - * + * @property \Carbon\Carbon $created_at + * @property integer $created_by + * @property integer $updated_by + * @property \Carbon\Carbon $updated_at + * @method static mixed type_id_enum() + * @method static mixed type_id_enum_select() + * @method static mixed type_id_enum_ids() + * @property-read mixed $type_id_constant + * @property-read mixed $type_id_label * @mixin \Eloquent */ class Flash_Alert_Model extends Rsx_Model_Abstract - { - /** - * _AUTO_GENERATED_ Enum constants - */ +{ + /** __AUTO_GENERATED: */ const TYPE_SUCCESS = 1; const TYPE_ERROR = 2; const TYPE_INFO = 3; const TYPE_WARNING = 4; - - /** __AUTO_GENERATED: */ - /** __/AUTO_GENERATED */ // Enum constants (auto-generated by rsx:migrate:document_models) diff --git a/app/RSpade/man/time.txt b/app/RSpade/man/time.txt index 770b63908..e0e6301a3 100755 --- a/app/RSpade/man/time.txt +++ b/app/RSpade/man/time.txt @@ -18,7 +18,11 @@ SYNOPSIS $today = Rsx_Date::today(); $formatted = Rsx_Date::format($date); + $relative = Rsx_Date::relative($date); $is_past = Rsx_Date::is_past($date); + $next_week = Rsx_Date::add_days($date, 7); + $monday = Rsx_Date::start_of_week($date); + $month_name = Rsx_Date::month_human($date); JavaScript Datetime: const now = Rsx_Time.now(); @@ -29,7 +33,10 @@ SYNOPSIS JavaScript Date: const today = Rsx_Date.today(); const formatted = Rsx_Date.format(date); + const relative = Rsx_Date.relative(date); const is_past = Rsx_Date.is_past(date); + const next_week = Rsx_Date.add_days(date, 7); + const monday = Rsx_Date.start_of_week(date); DESCRIPTION RSpade provides two separate classes for handling temporal values: @@ -111,6 +118,9 @@ RSX_DATE CLASS PHP: $today = Rsx_Date::today(); JS: const today = Rsx_Date.today(); + now() + Alias for today(). Provided for consistency with Rsx_Time::now(). + Formatting format($date) Display format: "Dec 24, 2025" @@ -118,12 +128,42 @@ RSX_DATE CLASS format_iso($date) Ensures "YYYY-MM-DD" format. + relative($date) + Human-readable relative date. + "Today", "Yesterday", "Tomorrow", "3 days ago", "in 5 days" + 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) + Arithmetic + add_days($date, $days) + Add (or subtract with negative) days from date. + Returns "YYYY-MM-DD" or null if invalid input. + + Week/Month Boundaries + start_of_week($date) Monday of the week containing date + end_of_week($date) Sunday of the week containing date + start_of_month($date) First day of month (YYYY-MM-01) + end_of_month($date) Last day of month + + is_weekend($date) True if Saturday or Sunday + is_weekday($date) True if Monday-Friday + + Component Extractors + All extractors return null for invalid input. + + day($date) Day of month (1-31) + dow($date) Day of week integer (0=Sunday, 6=Saturday) + dow_human($date) Full day name ("Monday", "Tuesday", etc.) + dow_short($date) Short day name ("Mon", "Tue", etc.) + month($date) Month number (1-12) + month_human($date) Full month name ("January", "February") + month_human_short($date) Short month name ("Jan", "Feb") + year($date) Four-digit year (e.g., 2025) + Database to_database($date) Returns "YYYY-MM-DD" for database storage (same as ISO format). @@ -181,11 +221,11 @@ RSX_TIME CLASS diff_seconds($start, $end) Seconds between two datetimes. - seconds_until($time) (JS only) - Seconds until future time. + seconds_until($time) + Seconds until future time (negative if in past). - seconds_since($time) (JS only) - Seconds since past time. + seconds_since($time) + Seconds since past time (negative if in future). duration_to_human($seconds, $short) Long: "2 hours and 30 minutes" @@ -206,6 +246,22 @@ RSX_TIME CLASS is_future($time) True if datetime is in the future is_today($time) True if datetime is today (in user's timezone) + Component Extractors + Extract date/time components from a datetime. All require timezone + parameter ($tz) or use user's timezone if omitted. + Return null for invalid input. + + day($time, $tz) Day of month (1-31) + dow($time, $tz) Day of week integer (0=Sunday, 6=Saturday) + dow_human($time, $tz) Full day name ("Monday", "Tuesday", etc.) + dow_short($time, $tz) Short day name ("Mon", "Tue", etc.) + month($time, $tz) Month number (1-12) + month_human($time, $tz) Full month name ("January", "February") + month_human_short($time, $tz) Short month name ("Jan", "Feb") + year($time, $tz) Four-digit year (e.g., 2025) + hour($time, $tz) Hour (0-23) + minute($time, $tz) Minute (0-59) + Live Updates (JavaScript only) countdown($element, target_time, options) Live countdown to future time. Updates every second. @@ -224,8 +280,12 @@ RSX_TIME CLASS 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) + 2. sites.timezone (site's default timezone) + 3. config('rsx.datetime.default_timezone') + 4. 'America/Chicago' (hardcoded fallback) + + Timezone is cached per-request and auto-invalidates when user or site + changes (e.g., after login/logout or site switch). Page Load On page load, window.rsxapp includes: @@ -397,6 +457,7 @@ CONFIGURATION ], User timezone stored in login_users.timezone column. + Site timezone stored in sites.timezone column (default: 'America/Chicago'). MODEL CASTING Model date/datetime columns are automatically cast to strings via custom @@ -434,4 +495,4 @@ SEE ALSO AUTHOR RSpade Framework -RSpade December 2025 TIME(7) +RSpade January 2026 TIME(7) diff --git a/database/migrations/.migration_whitelist b/database/migrations/.migration_whitelist index f08e0d808..2e8392a48 100755 --- a/database/migrations/.migration_whitelist +++ b/database/migrations/.migration_whitelist @@ -376,6 +376,11 @@ "created_at": "2025-12-27T22:53:05+00:00", "created_by": "root", "command": "php artisan make:migration:safe convert_tasks_polymorphic_to_type_refs" + }, + "2026_01_12_073624_add_timezone_to_sites_table.php": { + "created_at": "2026-01-12T07:36:24+00:00", + "created_by": "root", + "command": "php artisan make:migration:safe add_timezone_to_sites_table" } } } \ No newline at end of file diff --git a/database/migrations/2026_01_12_073624_add_timezone_to_sites_table.php b/database/migrations/2026_01_12_073624_add_timezone_to_sites_table.php new file mode 100755 index 000000000..280b14e2c --- /dev/null +++ b/database/migrations/2026_01_12_073624_add_timezone_to_sites_table.php @@ -0,0 +1,23 @@ +