Skip to content

Commit db7bbc5

Browse files
authored
fix: use NumberValue to calculate datetime instead of parsing (#379)
**What**: This PR makes use of the the locale-independent `NumberValue` (decimal datetime value) from the Google Sheets API instead of parsing the locale-dependent `FormattedValue` string. The decimal datetime value stores dates as decimal numbers where: - Integer part = days since December 30, 1899 - Fractional part = time of day (for DATE_TIME types) This is documented in the [Google Sheets API formats guide](https://developers.google.com/workspace/sheets/api/guides/formats) It maintains a fallback to the old parsing in case `NumberValue` is missing (this is unexpected and we can consider returning an error if we want to be a bit strict). Some examples: Unhinged custom date/time formatting: <img width="165" height="72" alt="Screenshot 2025-12-04 at 17 16 49" src="https://github.com/user-attachments/assets/c8923ae9-fa40-475d-8ce2-ce7c4a39b807" /> This PR would render the above as (note that I set time to UTC +2): <img width="162" height="113" alt="Screenshot 2025-12-04 at 16 21 54" src="https://github.com/user-attachments/assets/c6c38c66-80d3-4479-88b8-c3f07f6e5296" /> **Why**: The original implementation used `dateparse.ParseLocal()` to parse the `FormattedValue` string (e.g., "1/5/2020" or "5/1/2020"), which caused different date interpretations depending on the server's locale settings. For example, A date displayed as "1/5/2020" could be parsed as January 5th instead of the May 1st. **Fixes**: Fixes #123
1 parent d077435 commit db7bbc5

File tree

3 files changed

+89
-4
lines changed

3 files changed

+89
-4
lines changed

.changeset/khaki-apes-own.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'grafana-google-sheets-datasource': patch
3+
---
4+
5+
use NumberValue to calculate datetime instead of parsing

pkg/googlesheets/googlesheets.go

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -246,11 +246,36 @@ var timeConverter = data.FieldConverter{
246246
if !ok {
247247
return t, fmt.Errorf("expected type *sheets.CellData, but got %T", i)
248248
}
249-
parsedTime, err := dateparse.ParseLocal(cellData.FormattedValue)
250-
if err != nil {
251-
return t, fmt.Errorf("error while parsing date '%v'", cellData.FormattedValue)
249+
250+
switch {
251+
// Convert time based on decimal "Number Value" if possible; format agnostic
252+
case cellData.EffectiveValue != nil && cellData.EffectiveValue.NumberValue != nil:
253+
const (
254+
secondsPerDay = 24 * 60 * 60
255+
nanosecondsPerSecond = 1e9
256+
)
257+
258+
// Dates are stored as decimal values where each whole number represents a day counted from December 30, 1899.
259+
// See https://developers.google.com/workspace/sheets/api/guides/formats
260+
decimalDateTime := *cellData.EffectiveValue.NumberValue
261+
baseDate := time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
262+
days := int64(decimalDateTime)
263+
calculatedDate := baseDate.AddDate(0, 0, int(days))
264+
265+
timeRemainder := decimalDateTime - float64(days)
266+
267+
// Put calculated date and time remainder together
268+
calculatedDateTime := calculatedDate.Add(time.Duration(timeRemainder * secondsPerDay * nanosecondsPerSecond))
269+
return &calculatedDateTime, nil
270+
271+
// Else, fallback to the old parsing for backwards compatibility
272+
default:
273+
parsedTime, err := dateparse.ParseLocal(cellData.FormattedValue)
274+
if err != nil {
275+
return t, fmt.Errorf("error while parsing date '%v'", cellData.FormattedValue)
276+
}
277+
return &parsedTime, nil
252278
}
253-
return &parsedTime, nil
254279
},
255280
}
256281

pkg/googlesheets/googlesheets_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,3 +370,58 @@ func TestGooglesheets(t *testing.T) {
370370
assert.Equal(t, "Plain Text Value", *strVal)
371371
})
372372
}
373+
374+
func Test_timeConverter(t *testing.T) {
375+
t.Run("timeConverter with Number Value converts to time.Time", func(t *testing.T) {
376+
serialDate := 43845.5 // 15 January 2020 12:00:00 (0.5 days = 12 hours)
377+
cell := &sheets.CellData{
378+
EffectiveValue: &sheets.ExtendedValue{
379+
NumberValue: &serialDate,
380+
},
381+
}
382+
383+
result, err := timeConverter.Converter(cell)
384+
require.NoError(t, err)
385+
386+
require.NotNil(t, result)
387+
date, ok := result.(*time.Time)
388+
require.True(t, ok)
389+
assert.Equal(t, 2020, date.Year())
390+
assert.Equal(t, time.January, date.Month())
391+
assert.Equal(t, 15, date.Day())
392+
assert.Equal(t, 12, date.Hour())
393+
assert.Equal(t, 0, date.Minute())
394+
assert.Equal(t, 0, date.Second())
395+
})
396+
397+
t.Run("timeConverter without Number Value falls back to parsing FormattedValue", func(t *testing.T) {
398+
cell := &sheets.CellData{
399+
FormattedValue: "2020-01-15 12:00:00",
400+
}
401+
402+
result, err := timeConverter.Converter(cell)
403+
require.NoError(t, err)
404+
405+
require.NotNil(t, result)
406+
date, ok := result.(*time.Time)
407+
require.True(t, ok)
408+
assert.Equal(t, 2020, date.Year())
409+
assert.Equal(t, time.January, date.Month())
410+
assert.Equal(t, 15, date.Day())
411+
assert.Equal(t, 12, date.Hour())
412+
assert.Equal(t, 0, date.Minute())
413+
assert.Equal(t, 0, date.Second())
414+
})
415+
416+
t.Run("timeConverter returns error when parsing FormattedValue fails", func(t *testing.T) {
417+
cell := &sheets.CellData{
418+
FormattedValue: "not a valid date",
419+
}
420+
421+
_, err := timeConverter.Converter(cell)
422+
423+
assert.Error(t, err)
424+
assert.Contains(t, err.Error(), "error while parsing date")
425+
assert.Contains(t, err.Error(), "not a valid date")
426+
})
427+
}

0 commit comments

Comments
 (0)