/* @param {string} isoString - ISO 8601 timestamp without timezone
**                             e.g. YYYY-MM-DDTHH:mm:ss or YYYY-MM-DD HH:mm:ss
** @param {string} loc - IANA representateive location
**                       e.g. Europe/Berlin
** @param {boolean} returnOffset - if true, return the offset instead of timestamp
** @returns {string} if returnOffset is true, offset is ±HH:mm[:ss] (seconds only if not zero)
**                   if returnOffset is false, equivalent ISO 8601 UTC timestamp
*/
let getUTCTime = (function() {
  let n = 'numeric';
  let formatterOpts = {year:n, month:n, day:n, hour:n, minute:n, second:n, hour12: false};
  // Parse YYYY-MM-DDTHH:mm:ss as UTC (T can be space)
  function parse (isoString) {
    let [Y,M,D,H,m,s] = isoString.split(/[\DT\s]/);
    return new Date(Date.UTC(Y,M-1,D,H,m,s));
  }
  
  // Get date parts, use supplied formatter
  function toParts(date, formatter) {
    return formatter.formatToParts(date).reduce((acc, part) => {
      acc[part.type] = part.value;
      return acc;
    }, Object.create(null));
  }
  return function (isoString, loc, returnOffset = false) {
 
    // Update formatter options with loc so get target loc values
    formatterOpts.timeZone = loc;
    // Formatter
    let formatter = new Intl.DateTimeFormat('en', formatterOpts);
  
    // Parse input string as UTC
    let oDate = parse(isoString);
    // Date to adjust to wanted values
    let utcDate = new Date(oDate);
    // maxLoops limits do..while in dilemma zone, ensures sensible value
    let maxLoops = 3,
        p, diff;
    // Adjust utcDate so it generates required local date values
    // Adjustment may shift over DST boundary so may need 2nd adjustment
    // Limit number of loops (shouldn't be required but just in case...)
    do {
      // Get date parts in target timezone
      p = toParts(utcDate, formatter);
   
      // Get difference between local and adjusted values
      diff = new Date(Date.UTC(p.year, p.month-1, p.day, p.hour, p.minute, p.second)) - oDate;
   
      // If necessary, adjust utcDate so it generates required values when shifted
      if (diff) {
        utcDate.setTime(utcDate.getTime() - diff);
      }
    // Loop until generated values match original or maxLoops
    } while (diff && maxLoops--)
    
    // If maxLoops is -1, hit DST dilemma zone: time doesn't exist on that date
    // E.g. going into daylight saving at 02:00, then 02:30 doesn't exist
    // and loop will flip in/out of DST until stopped by maxLoops
    // So generate valid date and offset in DST period
    let dDiff = null;
    if (maxLoops < 0) {
      p = toParts(utcDate, formatter);
      dDiff = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - utcDate;
      let msg = isoString + ' does not exist at ' + loc + ' due to ' +
                'daylight saving change-over, shifting into DST';
      // console.log(msg);
      // throw new RangeError(msg);
    }
    // Convert diff between local and adjusted to get ±HH:mm offset
    // Use dilemma diff (dDiff) if has been set
    let oDiff = dDiff || oDate - utcDate;
    let sign = oDiff > 0? '+' : '-';
    oDiff = Math.abs(oDiff);
//    console.log(sign + new Date(oDiff).toISOString().substring(11,19).replace(/:00$/,''));
    let offH = oDiff / 3.6e6 | 0;
    let offM = (oDiff % 3.6e6) / 6e4 | 0;
    let offS = (oDiff % 6e4) / 1e3 | 0;
    let z = n=>(n<10?'0':'')+n;
    // Return offset (with offset seconds if not zero) or ISO 8601 UTC string
    return returnOffset? `${sign}${z(offH)}:${z(offM)}${offS? ':' + z(offS) : ''}` :
                         utcDate.toISOString();
  }
})();
// Given a local timestmap in format YYYY-MM-DDTHH:mm:ss and
// loc as IANA representative location
// Return equivalent ISO 8061 UTC timestmap
function getUTCString(timestamp, loc) {
  return getUTCTime(timestamp, loc);
}
// Given a local timestmap in format YYYY-MM-DDTHH:mm:ss and
// loc as IANA representative location
// Return offset at loc as ±HH:mm[:ss]
//  - seconds only included if not zero (typically pre-1900)
function getUTCOffset(timestamp, loc) {
  return getUTCTime(timestamp, loc, true);
}
// Examples
window.onload = function() {
  let t = document.getElementById('t0');
  let params = ['Local time', 'UTC time', false];
  let showData = (localTime, loc, offset, timeUTC) => {
    let row = t.insertRow();
    [localTime, loc, timeUTC, null, null].forEach((s, i) => {
      cell = row.insertCell();
      cell.textContent = i == 0? localTime.replace('T',' '):
                         i == 1? loc:
                         i == 2? offset  :
                         i == 3? timeUTC :
                         new Date(timeUTC).toLocaleString('en-CA', {timeZone: loc, hour12: false}).replace(',','') ;
    });
    return new Date(timeUTC).toLocaleString('en-CA', {timeZone: loc, hour12: false}).replace(',','');
  };
  // Local time required   Location
  [['2020-04-22T09:00:00','Europe/Berlin'],   // DST offset +2
   ['2020-01-22T09:00:00','Europe/Berlin'],   // Std offset +1
   ['2020-04-22T09:00:00','America/Denver'],  // DST offset -6
   ['2020-01-22T09:00:00','America/Denver'],  // Std offset -7
   ['2020-04-22T09:00:00','US/Aleutian'],     // DST offset -9
   ['2020-01-22T09:00:00','US/Aleutian'],     // Std offset -10
   ['2020-01-22T09:00:00','Pacific/Honolulu'],// Std offset -11
   ['2020-01-22T19:00:00','Pacific/Honolulu'],// Std offset -11
   ['2020-04-22T09:00:00','Asia/Singapore'],  // Std offset +8
   ['2020-04-22T09:00:00','Pacific/Apia'],    // Std offset +13
   ['2020-01-22T09:00:00','Pacific/Apia'],    // DST offset +14
   ['2020-01-22T09:00:00','Asia/Yangon'],     // Std offset +6:30
   ['2020-04-22T09:00:00','Pacific/Chatham'], // Std offset +12:45
   ['2020-01-22T09:00:00','Pacific/Chatham'], // DST offset +13:45
   
   // Historic offsets pre 1900
   ['1857-01-01T00:00:00','Europe/Berlin'],   // Std offset +00:53:28
   ['1857-01-01T00:00:00','Australia/Sydney'],// Std offset +10:04:52
   ['1857-01-01T00:00:00','America/New_York'],// Std offset -04:56:02
   ['1857-01-01T00:00:00','America/Sao_Paulo'],//Std offset -03:06:28
   
   // DST boundary check out of DST (2 to 3 am counted as "out")
   ['2020-04-05T01:45:00','Australia/Sydney'],// DST offset +11:00
   ['2020-04-05T01:59:59','Australia/Sydney'],// DST offset +11:00
   ['2020-04-05T02:00:00','Australia/Sydney'],// Std offset +10:00
   ['2020-04-05T02:30:00','Australia/Sydney'],// Std offset +10:00
   ['2020-04-05T03:00:00','Australia/Sydney'],// Std offset +10:00
   ['2020-04-05T03:15:00','Australia/Sydney'],// Std offset +10:00
   
   // DST boundary check into DST (2 to 3 am counted as "in")
   ['2020-10-04T01:45:00','Australia/Sydney'],// Std offset +10:00
   ['2020-10-04T02:00:00','Australia/Sydney'],// DST offset +11:00
   ['2020-10-04T02:30:00','Australia/Sydney'],// DST offset +11:00
   ['2020-10-04T02:59:59','Australia/Sydney'],// DST offset +11:00
   ['2020-10-04T03:00:00','Australia/Sydney'],// DST offset +11:00
   ['2020-10-04T03:15:00','Australia/Sydney'] // DST offset +11:00
  ].forEach(([localTime,loc]) => {
    // Show results
    let timeUTC = getUTCString(localTime, loc);
    let offset  = getUTCOffset(localTime, loc);
    showData(localTime, loc, offset, timeUTC);
  });
};
// Example use
let timestamp = '2020-06-30 08:30:00';
let locBer = 'Europe/Berlin';
let locSng = 'Asia/Singapore';
// Get UTC timestamp and offset for Berlin
let utc = getUTCString(timestamp, locBer);
let off = getUTCOffset(timestamp, locBer);
// Show times and offset - offset is just for display, not used to
// generate Singapore timestamp
console.log('Berlin   : ' + timestamp + ' ' + off); // 
console.log('Singapore: ' + new Date(utc).toLocaleString(
  'en-CA',{hour12:false, timeZone:locSng, timeZoneName:'short'}
).replace(',',''));
table {
  border-collapse: collapse;
}
td {
  font-family: geneva, arial;
  font-size: 80%;
  padding: 5px;
  border: 1px solid #bbbbbb;
}
td:nth-child(2) {
  font-family: monospace;
  font-size: 100%;
}
<table id="t0">
  <tr><th>Local time<th>Place<th>Offset<th>UTC<th>Check
</table>