From 3fef7ff27116070963f2109f0a23eb4a7e5f614b Mon Sep 17 00:00:00 2001 From: Daniel Bejarano Date: Tue, 3 Mar 2026 14:00:22 -0600 Subject: [PATCH] feat: cross-platform date handling with GNU/BSD/python3 support Create scripts/lib/date-compat.sh with date_today, date_yesterday, date_to_epoch, date_days_ago, and date_now_iso helpers. All dates use UTC consistently. Closes DOJ-2430 Co-Authored-By: Claude Opus 4.6 --- scripts/lib/date-compat.sh | 49 ++++++++++++++++++++++++++++++++++++++ scripts/quiz-selector.sh | 12 ++++++---- scripts/session-start.sh | 16 +++++++++---- 3 files changed, 68 insertions(+), 9 deletions(-) create mode 100644 scripts/lib/date-compat.sh diff --git a/scripts/lib/date-compat.sh b/scripts/lib/date-compat.sh new file mode 100644 index 0000000..3638a7d --- /dev/null +++ b/scripts/lib/date-compat.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Cross-platform date utilities for CodeSensei +# Supports: GNU date (Linux), BSD date (macOS), python3 fallback + +# Get today's date in YYYY-MM-DD format (UTC) +date_today() { + date -u '+%Y-%m-%d' 2>/dev/null || python3 -c "from datetime import datetime; print(datetime.utcnow().strftime('%Y-%m-%d'))" +} + +# Get yesterday's date in YYYY-MM-DD format (UTC) +date_yesterday() { + # Try GNU date first + date -u -d 'yesterday' '+%Y-%m-%d' 2>/dev/null && return + # Try BSD date (macOS) + date -u -v-1d '+%Y-%m-%d' 2>/dev/null && return + # Python fallback + python3 -c "from datetime import datetime, timedelta; print((datetime.utcnow() - timedelta(days=1)).strftime('%Y-%m-%d'))" 2>/dev/null && return + # Last resort: empty string + echo "" +} + +# Convert date string (YYYY-MM-DD) to epoch seconds +date_to_epoch() { + local date_str="$1" + # Try GNU date + date -u -d "$date_str" '+%s' 2>/dev/null && return + # Try BSD date (macOS) + date -u -j -f '%Y-%m-%d' "$date_str" '+%s' 2>/dev/null && return + # Python fallback + python3 -c "from datetime import datetime; print(int(datetime.strptime('$date_str', '%Y-%m-%d').timestamp()))" 2>/dev/null && return + echo "0" +} + +# Get date N days ago in YYYY-MM-DD format (UTC) +date_days_ago() { + local days="$1" + # Try GNU date + date -u -d "${days} days ago" '+%Y-%m-%d' 2>/dev/null && return + # Try BSD date (macOS) + date -u -v-${days}d '+%Y-%m-%d' 2>/dev/null && return + # Python fallback + python3 -c "from datetime import datetime, timedelta; print((datetime.utcnow() - timedelta(days=${days})).strftime('%Y-%m-%d'))" 2>/dev/null && return + echo "" +} + +# Get current UTC timestamp in ISO format +date_now_iso() { + date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || python3 -c "from datetime import datetime; print(datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'))" +} diff --git a/scripts/quiz-selector.sh b/scripts/quiz-selector.sh index 1e130ef..d40c5d3 100755 --- a/scripts/quiz-selector.sh +++ b/scripts/quiz-selector.sh @@ -19,6 +19,10 @@ PROFILE_FILE="$PROFILE_DIR/profile.json" PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$0")")}" QUIZ_BANK="$PLUGIN_ROOT/data/quiz-bank.json" +# Source cross-platform date helpers +# shellcheck source=scripts/lib/date-compat.sh +source "$PLUGIN_ROOT/scripts/lib/date-compat.sh" + # Default output if we can't determine anything DEFAULT_OUTPUT='{"mode":"dynamic","concept":null,"reason":"No profile data available","static_question":null,"belt":"white","quiz_format":"multiple_choice"}' @@ -40,8 +44,8 @@ SESSION_CONCEPTS=$(jq -c '.session_concepts // []' "$PROFILE_FILE") TOTAL_QUIZZES=$(jq -r '.quizzes.total // 0' "$PROFILE_FILE") CORRECT_QUIZZES=$(jq -r '.quizzes.correct // 0' "$PROFILE_FILE") -TODAY=$(date -u +%Y-%m-%d) -NOW_EPOCH=$(date +%s) +TODAY=$(date_today) +NOW_EPOCH=$(date_to_epoch "$TODAY") # Determine quiz format based on belt level # Orange Belt+ gets a mix of formats; lower belts get multiple choice @@ -84,8 +88,8 @@ if [ "$QUIZ_HISTORY" != "[]" ]; then # Calculate days since last wrong answer LAST_WRONG_DATE=$(echo "$LAST_WRONG" | cut -d'T' -f1) - LAST_EPOCH=$(date -j -f "%Y-%m-%d" "$LAST_WRONG_DATE" +%s 2>/dev/null || date -d "$LAST_WRONG_DATE" +%s 2>/dev/null) - if [ -n "$LAST_EPOCH" ]; then + LAST_EPOCH=$(date_to_epoch "$LAST_WRONG_DATE") + if [ -n "$LAST_EPOCH" ] && [ "$LAST_EPOCH" != "0" ]; then DAYS_SINCE=$(( (NOW_EPOCH - LAST_EPOCH) / 86400 )) else DAYS_SINCE=999 diff --git a/scripts/session-start.sh b/scripts/session-start.sh index 5506f5a..c690613 100755 --- a/scripts/session-start.sh +++ b/scripts/session-start.sh @@ -5,7 +5,13 @@ PROFILE_DIR="$HOME/.code-sensei" PROFILE_FILE="$PROFILE_DIR/profile.json" SESSION_LOG="$PROFILE_DIR/sessions.log" -TODAY=$(date -u +%Y-%m-%d) +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$0")")}" + +# Source cross-platform date helpers +# shellcheck source=scripts/lib/date-compat.sh +source "$PLUGIN_ROOT/scripts/lib/date-compat.sh" + +TODAY=$(date_today) # Create profile directory if it doesn't exist mkdir -p "$PROFILE_DIR" @@ -17,7 +23,7 @@ if [ ! -f "$PROFILE_FILE" ]; then "version": "1.0.0", "plugin": "code-sensei", "brand": "Dojo Coding", - "created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", + "created_at": "$(date_now_iso)", "belt": "white", "xp": 0, "streak": { @@ -45,7 +51,7 @@ if [ ! -f "$PROFILE_FILE" ]; then "id": "first-session", "name": "First Steps", "description": "Started your first CodeSensei session", - "earned_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + "earned_at": "$(date_now_iso)" } ], "preferences": { @@ -73,7 +79,7 @@ if command -v jq &> /dev/null; then NEW_STREAK=$CURRENT_STREAK elif [ -n "$LAST_SESSION" ]; then # Check if last session was yesterday - YESTERDAY=$(date -u -d "yesterday" +%Y-%m-%d 2>/dev/null || date -u -v-1d +%Y-%m-%d 2>/dev/null) + YESTERDAY=$(date_yesterday) if [ "$LAST_SESSION" = "$YESTERDAY" ]; then NEW_STREAK=$((CURRENT_STREAK + 1)) else @@ -107,7 +113,7 @@ if command -v jq &> /dev/null; then echo "$UPDATED" > "$PROFILE_FILE" # Log session - echo "$TODAY $(date -u +%H:%M:%S) session_start" >> "$SESSION_LOG" + echo "$(date_now_iso) session_start" >> "$SESSION_LOG" # Show streak info if notable BELT=$(jq -r '.belt // "white"' "$PROFILE_FILE")