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.