0

Update: I've added the fix in here for anyone looking for a front-to-back example of it working correctly.

I know this is a bit of a repeat but I seem to keep finding partial answers and nowhere a complete solution. I see a lot of comments about storing dates in UTC in the back-end, which I do want to do, but no real holistic solutions on how/where to handle the translations that are within the last few years.

I understand that a JS Date() object stores timezone information and then when you use JSON.stringify() on it it will swap it to a UTC time. This pushes the time ahead 7 hours (for my locale), so every time I save a date it jumps ahead by 7 hours. How do I fix this? I do want the dates to be stored as UTC in the database, but I'm not sure where I need to perform the conversion to get it back from UTC and into my locale time when it returns to the browser.

I've attempted to include the entire code path below in the hopes that someone can point out what I'm missing.

First I have two EFCore5 classes That I use to scaffold my DB with a data first approach. An income Source and a Schedule, these objects have a 1-to-1 relationship with each other.

public class Schedule
{
    [Key]
    public int ScheduleID { get; set; }

    [Required]
    public ScheduleFrequency Frequency { get; set; }

    [DataType(DataType.Date)]
    public DateTime Occurrence_First { get; set; }

    [DataType(DataType.Date)]
    public DateTime? Occurrence_LastConfirmed { get; set; }

    [DataType(DataType.Date)]
    public DateTime? Occurrence_LastPlanned { get; set; }

    [DataType(DataType.Date)]
    public DateTime? Occurrence_Final { get; set; }

    public bool IsAutoConfirm { get; set; }

    [DataType(DataType.Date)]
    public DateTime DateTime_Created { get; set; }

    [DataType(DataType.Date)]
    public DateTime? DateTime_Deactivated { get; set; }

    public bool HasCustomTransactionTime { get; set; }

    [System.Text.Json.Serialization.JsonIgnore]
    public IncomeSource IncomeSource { get; set; }
}

public class IncomeSource
{
    [Key]
    public int IncomeSourceID { get; set; }

    [DataType(DataType.Currency)]
    [Column(TypeName = "money")]
    public decimal ExpectedAmount { get; set; }

    [DataType(DataType.Currency)]
    [Column(TypeName = "money")]
    public decimal TotalFromSource { get; set; }

    public int? DefaultToAccountID { get; set; }

    public int ScheduleID { get; set; }

    public BudgetorAccount Account { get; set; }

    public Schedule Schedule { get; set; }


    public int AccountID { get; set; }

    [ForeignKey("DefaultToAccountID")]
    public BudgetorAccount DefaultToAccount { get; set; }
}

public class BudgetorDbContext : DbContext
{
    public BudgetorDbContext(DbContextOptions<BudgetorDbContext> options) : base (options)
    {

    }

    public DbSet<BudgetorAccount> Accounts { get; set; }
    public DbSet<IncomeSource> IncomeSources { get; set; }
    public DbSet<Schedule> Schedules { get; set; }

}

There is a service that is called by the API controller to converting to/from a view model and handle all CRUD operations. The service leverages EFCore's ability to save related objects automatically to save the Schedule attached to the IncomeSource

public class IncomeSourceDetailVM : AccountDetailVM
{
    public int IncomeSourceId { get; set; }
    public decimal ExpectedAmount { get; set; }
    public decimal TotalFromSource { get; set; }
    public int? DefaultToAccountID { get; set; }
    public Schedule Schedule { get; set; }

    public  IncomeSourceDetailVM() : base(AccountType.IncomeSource)
    {
        this.Schedule = new Schedule()
        {
            Occurrence_First = new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day)                
        };
    }
}

public class BudgetorDataService : IBudgetorService
{
    public async Task<IncomeSourceDetailVM> GetIncomeSourceDetailVM(int id)
    {
        var vm = await getIncomeSourceData()
            .Where(inc => inc.AccountID == id)
            .Select(incSrc => new IncomeSourceDetailVM()
            {
                IncomeSourceId = incSrc.IncomeSourceID,
                ExpectedAmount = incSrc.ExpectedAmount,
                AccountId = incSrc.AccountID,
                AccountName = incSrc.Account.Name,
                DateTime_Created = incSrc.Account.DateTime_Created,
                DateTime_Deactivated = incSrc.Account.DateTime_Deactivated,
                DefaultToAccountID = incSrc.DefaultToAccountID,
                Schedule = incSrc.Schedule,
                Notes = incSrc.Account.Notes,
            })
            .FirstOrDefaultAsync();

        return vm;
    }

    public async Task<IncomeSourceDetailVM> CreateIncomeSource(IncomeSourceDetailVM incomeSourceToAdd)
    {
        if (incomeSourceToAdd == null)
        {
            throw new ArgumentNullException(nameof(incomeSourceToAdd));
        }

        try
        {
            BudgetorAccount newAcct = new BudgetorAccount()
            {
                AccountType = AccountType.IncomeSource,
                DateTime_Created = DateTime.Now,
                Name = incomeSourceToAdd.AccountName,
                Notes = incomeSourceToAdd.Notes,
                IsSystem = false
            };
            await _context.Accounts.AddAsync(newAcct);
            await _context.SaveChangesAsync();

            incomeSourceToAdd.AccountId = newAcct.AccountID;
            incomeSourceToAdd.DateTime_Created = newAcct.DateTime_Created;

            IncomeSource newIncSrc = new IncomeSource()
            {
                AccountID = newAcct.AccountID,
                DefaultToAccountID = incomeSourceToAdd.DefaultToAccountID,
                ExpectedAmount = incomeSourceToAdd.ExpectedAmount,
                Schedule = incomeSourceToAdd.Schedule
            };

            await _context.IncomeSources.AddAsync(newIncSrc);
            await _context.SaveChangesAsync();

            incomeSourceToAdd.IncomeSourceId = newIncSrc.IncomeSourceID;
        }
        catch(Exception err)
        {
            throw err;
        }
        return incomeSourceToAdd;
    }
}

[ApiController]
public class IncSrcController : ControllerBase
{
    private readonly IBudgetorService _budgetorService;

    public IncSrcController(IBudgetorService budgetorService)
    {
        this._budgetorService = budgetorService;
    }

    [HttpPost]
    [Consumes("application/json")]
    public async Task<ActionResult<IncomeSourceDetailVM>> PostIncSrc([FromBody] IncomeSourceDetailVM incSrcToAdd)
    {
        IncomeSourceDetailVM createdIncSrc = incSrcToAdd.AccountId == 0
            ? await this._budgetorService.CreateIncomeSource(incSrcToAdd)
            : await this._budgetorService.UpdateIncomeSource(incSrcToAdd);

        return StatusCode(201, createdIncSrc);
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<IncSrcManagementVM>> GetAccount(int? id)
    {
        IncSrcManagementVM result = new IncSrcManagementVM()
        {
            Account = (id == 0 || !id.HasValue)
                ? new IncomeSourceDetailVM()
                : await this._budgetorService.GetIncomeSourceDetailVM(id.Value),
            ToAccounts = await this._budgetorService.GetIncSrcToAccounts()
        };

        if (result.Account.DefaultToAccountID.HasValue)
        {
            result.ToAccounts
                .Find(s => s.AccountId == result.Account.DefaultToAccountID)
                .IsDefault = true;
        }

        return result;
    }
}

On the front end, I call JSON.stringify() on a JS object shaped exactly like the IncomeSourceDetailVM view model from the C# above to send the object back to the server. (I read in one article to use a replacer on the JSON.stringify() call to manually handle the string conversion and call .toLocalDateString() but then the API controller doesn't recognize it as a date and rejects the call. Plus I want this stored as UTC in the back-end so the date is always localized in the view.)

const saveIncomeSource = async (incSrcDetailVM) => {
    const transportObj = JSON.stringify(incSrcDetailVM);
    const { data } = await axios.post(INC_SRC_ROUTE_PATH, transportObj, headerConfig);
    await getIncSrcListItemVMs();
}

When the object comes back into my React app's state, I use an effect to update the local states. As part of this I convert all of the string dates the server has sent to new Date(). This process interprets these UTC date strings as local times instead of UTC.

useEffect(() => {
    const { account, toAccounts} = incSrcEditorVM;
    if ((account.schedule 
        && !(account.schedule instanceof ScheduleBase))
    ) {
        account.schedule = ScheduleBase.clone(account.schedule);
    }
    setIncSrc(account)
    setToAccounts(toAccounts);
    setLoading(false);
    return () => {
        setLoading(true);
        setToAccounts([...blankIncSrc.toAccounts]);
    }
}, [incSrcEditorVM]);
export default class ScheduleBase {
    scheduleID = 0;
    frequency = 0;
    occurrence_First;
    occurrence_LastConfirmed = null;
    occurrence_LastPlanned = null;
    occurrence_Final = null;
    dateTime_Created = new Date();
    dateTime_Deactivated = null;
    hasCustomTransactionTime = false;
    isAutoConfirm = false;

    constructor() {
        this.occurrence_First = new Date(Date.now());
        this.dateTime_Created = new Date(Date.now());
    }

    static clone(schedBaseShapedObj) {
        const clone = new ScheduleBase();
        clone.frequency = schedBaseShapedObj.frequency;
        clone.scheduleID = schedBaseShapedObj.scheduleID;
        clone.occurrence_First = new Date(schedBaseShapedObj.occurrence_First);
        clone.occurrence_LastConfirmed = populateDate(schedBaseShapedObj.occurrence_LastConfirmed);
        clone.occurrence_LastPlanned = populateDate(schedBaseShapedObj.occurrence_LastPlanned);
        clone.occurrence_Final = populateDate(schedBaseShapedObj.occurrence_Final);
        clone.dateTime_Created = new Date(schedBaseShapedObj.dateTime_Created);
        clone.dateTime_Deactivated = populateDate(schedBaseShapedObj.dateTime_Deactivated);
        clone.hasCustomTransactionTime = schedBaseShapedObj.hasCustomTransactionTime;
        clone.isAutoConfirm = schedBaseShapedObj.isAutoConfirm;
        return clone;
    }
}

// **Fix goes in here**
function populateDate(nullableDate) {
    return (nullableDate === null) 
        ? null
        : new Date(nullableDate + "Z");
}

I feel like this has to be a common enough issue that there is a more straight-forward path to do this that I'm just missing.

Thanks in advance to anyone whose taken the time to read through this.

JakeB
  • 153
  • 2
  • 9
  • There have been no changes to the ECMAScript Date object since the language was invented. There have been a couple of changes to supported formats for parsing and *toString\** methods, but that's it. It's been sorely neglected. Date objects do not have any associated timezone information, *Date.prototype.getTimezoneOffset* uses information from the host system. The time value associated with a Date instance (millisecond offset from the ECMAScript epoch) is inherently UTC and is its only data property. – RobG Mar 17 '21 at 21:29
  • @RobG Thanks for the clarification on that, do you also have a solution to my problem? – JakeB Mar 17 '21 at 22:13
  • No, else I'd post it. :-) – RobG Mar 18 '21 at 00:37

1 Answers1

0

For anyone stuck on this, the answer was here: I have a UTC string and I want to convert it to UTC Date Object in JavaScript

Adding the "Z" to indicate the time from the server was UTC or ZULU got it to stop trying to read the server's UTC timestamp as a local time.

JakeB
  • 153
  • 2
  • 9