EDIT: Robust Solution
This approach accounts for the issue that Autonomy brought up in the comments.
// We need 2 Datetimes to go between local and non-local
// - One Datetime which functions as input.
// - Another "reference" Datetime we use to get the TimeZone offset.
//
// We will initially estimate the "reference" but our
// goal is to find an "input" which can function as its
// own "reference"
//
Datetime offsetFromLocal(
Datetime inputLocalAsGmt,
TimeZone tz,
Datetime reference
) {
return Datetime.newInstance(
inputLocalAsGmt.getTime() - tz.getOffset(reference)
);
}
Datetime offsetToLocal(Datetime input, TimeZone tz) {
return Datetime.newInstance(
input.getTime() + tz.getOffset(input)
);
}
// If we use the input as its own "reference",
// as intended, how close do we get to the
// expected value?
//
Long error(
Date expectedLocalDate,
Time expectedLocalTime,
TimeZone localTimezone,
Datetime actual
) {
Datetime expectedAsGmt = Datetime.newInstanceGmt(
expectedLocalDate, expectedLocalTime);
return (
offsetToLocal(actual, localTimezone).getTime()
-
expectedAsGmt.getTime()
);
}
// This will return a Datetime which will work out to
// the specified local Date and Time (if they exist)
// after we adjust it for the given TimeZone.
//
// This accounts for all DST transitions known to
// Salesforce.
//
// we use a guess-and-check approach which is guaranteed
// to stabilize in one iteration, provided the Local time
// we give it exists. Otherwise, we adjust the time to
// something equivalent.
//
Datetime datetimeFromLocal(Date localDate, Time localTime, TimeZone localTimeZone) {
// 1. Estimate a reference. Can be anything.
// Choosing a time in the middle of the same year.
Datetime firstReference = Datetime.newInstanceGmt(
Date.newInstance(localDate.year(), 6, 15),
Time.newInstance(12, 0, 0, 0)
);
Datetime targetLocalAsGmt = Datetime.newInstanceGmt(localDate, localTime);
// 2. Compute an estimate and its error.
Datetime estimate = offsetFromLocal(targetLocalAsGmt, localTimeZone, firstReference);
Long error = error(localDate, localTime, localTimeZone, estimate);
// 3. Subtract the error from the estimate, to get a
// new, adjusted (improved) estimate.
//
Datetime adjustedEstimate = Datetime.newInstance(
estimate.getTime() - error
);
// 4. Inspect the error on our final estimate.
//
Long adjustedEstimateError = error(localDate, localTime, localTimeZone, adjustedEstimate);
// If still non-zero ...
if(adjustedEstimateError != 0) {
// ... we are in the interval skipped by a
// "Spring Forward" transition.
//
// In this case, the magnitude of the error
// tells us the size of the skipped interval.
// To get us out of the invalid zone, we
// jump ahead by the error amount ...
//
Datetime roundedUp = Datetime.newInstance(
adjustedEstimate.getTime() + Math.abs(adjustedEstimateError)
);
Datetime roundedUpLocalAsGmt = offsetToLocal(roundedUp, localTimeZone);
// ... and try again with the new value.
return datetimeFromLocal(
roundedUpLocalAsGmt.dateGmt(),
roundedUpLocalAsGmt.timeGmt(),
localTimeZone
);
}
// The value we have is valid, with zero error.
//
return adjustedEstimate;
}
// Examples
// ---------------
String targetTimeZoneId = 'Australia/Brisbane';
String formatLocal(Datetime input) {
return input.format('yyyy-MM-dd HH:mm:ssXXX', targetTimeZoneId);
}
String formatGmt(Datetime input) {
return input.formatGmt('yyyy-MM-dd HH:mm:ssXXX');
}
// The original example
targetTimeZoneId = 'Australia/Brisbane';
Datetime targetDatetime = datetimeFromLocal(
Date.newInstance(2018, 3, 18),
Time.newInstance(18, 0, 0, 0),
TimeZone.getTimeZone(targetTimeZoneId)
);
Assert.areEqual('2018-03-18 18:00:00+10:00', formatLocal(targetDatetime));
Assert.areEqual('2018-03-18 08:00:00Z', formatGmt(targetDatetime));
// Here are some trickier examples.
//
// Brisbane has not observed DST since 1992.
// Here we select a place which has.
targetTimeZoneId = 'Australia/Sydney';
// - Spring forward on Oct 7, 2018 at 2AM standard time
Datetime preSpringForward = datetimeFromLocal(
Date.newInstance(2018, 10, 7),
Time.newInstance(1, 45, 0, 0),
TimeZone.getTimeZone(targetTimeZoneId)
);
Assert.areEqual('2018-10-07 01:45:00+10:00', formatLocal(preSpringForward));
Assert.areEqual('2018-10-06 15:45:00Z', formatGmt(preSpringForward));
Datetime postSpringForward = datetimeFromLocal(
Date.newInstance(2018, 10, 7),
Time.newInstance(3, 15, 0, 0),
TimeZone.getTimeZone(targetTimeZoneId)
);
Assert.areEqual('2018-10-07 03:15:00+11:00', formatLocal(postSpringForward));
Assert.areEqual('2018-10-06 16:15:00Z', formatGmt(postSpringForward));
// 2:30 AM is skipped by the "Spring Forward".
// So our method bumps the value up to 3:30 AM.
//
Datetime midSpringForward = datetimeFromLocal(
Date.newInstance(2018, 10, 7),
Time.newInstance(2, 30, 0, 0),
TimeZone.getTimeZone(targetTimeZoneId)
);
Assert.areEqual('2018-10-07 03:30:00+11:00', formatLocal(midSpringForward));
Assert.areEqual('2018-10-06 16:30:00Z', formatGmt(midSpringForward));
Original, Less Robust Solution
This approach does not work in some cases.
Example for March 18, 2018 at 6:00 PM in the Australia/Brisbane
time zone:
Date targetDate = Date.newInstance(2018, 3, 18);
Time targetTime = Time.newInstance(18, 0, 0, 0);
TimeZone targetTimezone = TimeZone.getTimeZone('Australia/Brisbane');
// The targetDate is used here to decide whether
// Daylight Savings should apply.
Integer offsetSeconds = targetTimezone.getOffset(targetDate) / 1000;
Datetime targetDatetime = Datetime.newInstanceGmt(targetDate, targetTime)
.addSeconds(-offsetSeconds);
System.assertEquals(
'2018-03-18 18:00:00+10:00',
targetDatetime.format('yyyy-MM-dd HH:mm:ssXXX', targetTimezone.getID()),
'Wrong local time.'
);
System.assertEquals(
'2018-03-18 08:00:00Z',
targetDatetime.formatGmt('yyyy-MM-dd HH:mm:ssXXX'),
'Wrong UTC time.'
);