From 4aa9ee54e6a7cb3354f6a3fb823e66a9b0a90aca Mon Sep 17 00:00:00 2001 From: John Cardinal Date: Mon, 26 Oct 2026 07:26:55 -0700 Subject: [PATCH] case 4697 - relative date filters use the user's time zone Token date ranges (*today*, *lastmonth* ...) are built from the user's local time in clientTimeStamp but were converted to UTC with ToUniversalTime(), the server's time zone. They are now converted with clientTimeStamp.Offset. Lists and dashboard widgets share this builder. Deferred: daylight saving edge and dashboard grouping by UTC day (both need the client's time zone name). Changelog. Co-Authored-By: Claude Opus 5.5 --- devdocs/modernization/NET10-PORT.md | 5 +- docs/8.0/ayanova/docs/changelog.md | 1 + .../DataListSqlFilterCriteriaBuilder.cs | 88 ++++++++++--------- 3 files changed, 52 insertions(+), 42 deletions(-) diff --git a/devdocs/modernization/NET10-PORT.md b/devdocs/modernization/NET10-PORT.md index 707a85c0..073e9742 100644 --- a/devdocs/modernization/NET10-PORT.md +++ b/devdocs/modernization/NET10-PORT.md @@ -122,7 +122,7 @@ in this branch or a follow-up release. UPDATE: John has decided these all go in - [x] **Report browser download location.** Fixed: downloads into `/report-browser` (derived `ServerBootConfig.REPORT_RENDER_BROWSER_DOWNLOAD_PATH`, not a new setting; empty when no data path is set, then beside the app as before) through `BrowserFetcherOptions.Path`, and launches the downloaded executable explicitly. `AYANOVA_REPORT_RENDER_BROWSER_PATH` still takes precedence. Existing installs keep an unused old download beside the app until it is removed. Confirmed: `new BrowserFetcher()` with the default path beside the app (`ReportBiz.cs:800`). Point it under the data directory in code. Beside the app it is read-only under a hardened systemd unit (`ProtectSystem=strict`), and a redeploy that replaces the app folder deletes it - [x] **Report failures: return a specific error.** Done: download and launch each in their own `try`; on failure `HandleBrowserFailure` logs at Error (was Debug via the job processor) and writes `rendererror.exception` ("Report rendering browser could not be downloaded to … / started from … with arguments …: (reason)"), which the client already shows as `JobFailed: …`; no client change. The Sockeye crash is n/a: rendering runs as a job, and `JobsBiz.ProcessJobAsync` catches every exception, logs it through NLog and marks the job Failed (`JobsBiz.cs:390-439`), so the process survives. What remains: a browser download or launch failure (`ReportBiz.cs:800`, `:838`, outside the render `try`) reaches the client only as a generic job failure. Catch it in `DoRenderJob` and return a render error that says what failed - [x] **Data-list date filters, small fixes.** Fixed: `>=` and `<` use `ZeroSeconds`; `!=` is `< ZeroSeconds OR > MaxSeconds`; the `DataListField` null check runs first and in all builds, throwing `ArgumentException` naming the column. (An unknown column sent to `/data-list` is in practice rejected earlier by `DataListSqlSelectBuilder`, with its own "DEV ERROR" message; unchanged.) Sockeye 8.1.0 still has the old code. Confirmed: `>=` (`:790-793`) and `<` (`:795-798`) use `MaxSeconds` and should use `ZeroSeconds`; date `NotEqual` (`:806-814`) uses `MaxSeconds` on both sides and should exclude the whole minute (`< ZeroSeconds OR > MaxSeconds`). `DataListField` is dereferenced at `:24` and `:27` before the DEBUG-only null check at `:33`, so **release builds** throw a NullReferenceException on an unknown column, not only DEBUG builds -- [ ] **Relative date tokens use the server's time zone** (`*today*`, `*yesterday*` …). Confirmed: `ClientToday = clientTimeStamp.Date` (`:399-400`) has `Kind=Unspecified`, and `ToUniversalTime()` (`:1060`, `:1065`) converts it with the server's zone. Wrong for any user in a different zone from the server. The fix builds the range from `clientTimeStamp.Offset`, with care for ranges that cross a DST change. **This changes behaviour** and affects the integration tests' `TIME_ZONE_ADJUSTMENT`. Probably better as a follow-up release +- [x] **Relative date tokens use the server's time zone** **Fixed in case 4697** (boundaries converted to UTC with `clientTimeStamp.Offset`). (`*today*`, `*yesterday*` …). Confirmed: `ClientToday = clientTimeStamp.Date` (`:399-400`) has `Kind=Unspecified`, and `ToUniversalTime()` (`:1060`, `:1065`) converts it with the server's zone. Wrong for any user in a different zone from the server. The fix builds the range from `clientTimeStamp.Offset`, with care for ranges that cross a DST change. **This changes behaviour** and affects the integration tests' `TIME_ZONE_ADJUSTMENT`. Probably better as a follow-up release Not port items, listed so they are not forgotten: the three server bugs the integration suite is waiting on (case 4653, case 4654, the contract issue). @@ -209,7 +209,7 @@ John's "next release" cases for AyaNova (exported 2026-10-24). Each is its own s | 4694 | Standalone launcher not shutting down PostgreSQL (raven-launcher) | 1. **Done** (launcher: `pg_ctl stop -m fast -w` when the server exits, replacing the `StandardInput.Close()` that threw, and on window close (SIGHUP/CTRL_CLOSE); `standalone.iss`: `PrepareToInstall` and uninstall stop a running embedded PostgreSQL with the installed `pg_ctl`. Sandbox: 7 `postgres` processes left running as by 8.2.4, then the upgrade showed no "files in use" page and stopped them; Shut down server and closing the window each left no `postgres`. Note: closing the window or Ctrl+C already ended PostgreSQL in 8.2.4 (same console); only the in-app shutdown left it) | | 4695 | Shipping the Visual C++ runtime DLLs with the standalone install | 1. **Done** (`msvcp140.dll`, `vcruntime140.dll`, `vcruntime140_1.dll` 14.44.35211.0, Microsoft-signed, added to `dist/win-x64/postgres-standalone/bin`, app-local; BUILD.md notes they must be replaced by hand. Sandbox: fresh Windows with no runtime, install started PostgreSQL, trial, report 1.6 s) | | 4696 | JWT secret missing from the standalone config.json | 1. **Done, resolved by 4711, no change** (sandbox 2026-10-25: 8.2.4 warned at every boot that AYANOVA_JWT_SECRET is missing; after the upgrade to 8.3.0 the first boot generated `C:\ProgramDatayanova\jwt-signing.key`, the next boot reused it with no warning, and a fresh 8.3.0 install did the same) | -| 4697 | Relative date filters using the server's time zone | 1 | +| 4697 | Relative date filters using the server's time zone | 1. **Done** (`BuildSQLBetweenTwoTokenDerivedDates` converts the user's local boundaries to UTC with `clientTimeStamp.Offset`, was `ToUniversalTime()` with the server's zone; the dashboard KPIs use the same builder, so their ranges are fixed too. Test `WorkOrderList_TodayIsTheUsersToday`: a work order at 00:30 today for a +10:00 user, before missing from *today*, after included; full suite = baseline. The manual already promised the user's time zone (`ay-start-data-types.md`, `ay-start-form-data-tables.md`), no manual change; changelog) | | 4699 | Linux desktop data-path note in the manual | 1, docs | | 4700 | Restore note for customers with a dedicated database role | 1, docs | | 4703 | Report JS console error logging | 1 | @@ -277,6 +277,7 @@ Found during the port, out of scope. One line each. - **Improvement, own case after the port (John to create):** bring back logging of JavaScript error details from report page console errors, as Sockeye's port did. AyaNova logs only `ERROR: {message text}`; the per-argument detail (`arg instanceof Error ? arg.message : arg`) is commented out at `biz/ReportBiz.cs:873-876` since case 4586, because `IJSHandle.ExecutionContext` was removed. Sockeye 8.1.0 (`server/biz/ReportBiz.cs:575`) evaluates on the handle instead: `args.Message.Args.Select(arg => arg.EvaluateFunctionAsync("(arg) => arg instanceof Error ? arg.message : arg"))`, which also needs the console handler to be `async` again - Pre-existing, same in 8.2.4 (found in the 4579 sandbox test, John: Deferred): rendering a report for a record that does not exist (e.g. work order ids 1 and 2 in a trial without sample data) fails the job with a generic `ArgumentNullException` ("Value cannot be null. (Parameter 'o')") from `JObject.FromObject` in `WorkOrderBiz.GetReportData` (`WorkOrderBiz.cs:1030`), instead of skipping the missing record or saying it is not found. Other object types' `GetReportData` likely do the same - **Roles audit, case 4735 (John, 2026-10-25, this release; found in 4719):** check every object's rights as documented in the manual (each page's "Authorization Roles required" section and `ay-biz-admin-roles.md`) against `biz/BizRoles.cs`. Report templates were changeable by Business administration - restricted although the roles page uses exactly that case as its example of a view-only restricted role (fixed in 4719). If that slipped through there could be others; for each mismatch decide whether the code or the page is wrong +- From case 4697 (John: Deferred): (1) a relative date range that crosses a daylight saving change can be an hour off at its far end, because the client sends only its current offset with list requests; exact would need the client to send its IANA time zone name too (front-end change). (2) Dashboard widgets group by day, week or month with `date_trunc` on the stored UTC times, so for a user far from UTC an item near midnight lands in the neighbouring bar; same need for the time zone name - Pre-existing (found in the 4632 IIS test, John: Deferred): emailed report attachments have the content type `application/pdf/pdf`, because `Mailer.cs:51` builds `new MimePart("application/pdf", "pdf")` (media type and subtype) instead of `new MimePart("application", "pdf")`. Mail programs still open the file by its `.pdf` name ## Log diff --git a/docs/8.0/ayanova/docs/changelog.md b/docs/8.0/ayanova/docs/changelog.md index 9e84bea8..d91a8350 100644 --- a/docs/8.0/ayanova/docs/changelog.md +++ b/docs/8.0/ayanova/docs/changelog.md @@ -31,6 +31,7 @@ IN PROGRESS - Server: new [trusted reverse proxies](ops-config-trusted-proxies.md) setting for a reverse proxy that runs on a different computer from the AyaNova server, so AyaNova sees each user's own address. - Server: while a report renders, its template no longer holds the User's actual access token or download key; the AyaNova server adds the User's access only to the report's requests to the `ayApiUrl` address. Templates that call the AyaNova API through any other address need to be changed. See [API usage in reports](ay-report-edit.md#api-usage). - Server: users with the "Business administration - restricted" role can view [report templates](adm-report-templates.md) but no longer create, edit or import them, as described for that [role](ay-biz-admin-roles.md). Give the full "Business administration" role to users who edit report templates. +- Server: relative date filters such as "today" or "last month" (in lists and dashboard widgets) now start and end the period at midnight in the user's own [time zone](ay-start-form-data-tables.md#filtering-dates-and-time-zones). Previously, for a user in a different time zone from the server, records close to the start or end of the period could be included or left out by mistake. - Server: the SuperUser account can no longer be deleted. - Server: role rights corrected to match the documentation: "Service technician - restricted" users can view tax codes, projects, unit models and meter readings; "Business administration" users can view server metrics; Accounting users can no longer enter unit meter readings; only "Business administration" users can switch an [integrated application](api-integrated-applications.md) on or off or delete it. - Server: fixed direct notification messages to a user: only the first one within 12 hours was delivered, now every message is. diff --git a/server/AyaNova/DataList/DataListSqlFilterCriteriaBuilder.cs b/server/AyaNova/DataList/DataListSqlFilterCriteriaBuilder.cs index 5c9d3eb2..89214c80 100644 --- a/server/AyaNova/DataList/DataListSqlFilterCriteriaBuilder.cs +++ b/server/AyaNova/DataList/DataListSqlFilterCriteriaBuilder.cs @@ -416,20 +416,20 @@ namespace AyaNova.DataList dtAfter = ClientToday.AddDays(-1); dtAfter = dtAfter.AddSeconds(-1); dtBefore = ClientToday; - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*today*": //Between yesterday at midnight and tommorow at midnight dtAfter = ClientToday.AddSeconds(-1); dtBefore = ClientToday.AddDays(1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*tomorrow*": //Between Tonight at midnight and day after tommorow at midnight dtAfter = ClientToday.AddDays(1).AddSeconds(-1); dtBefore = ClientToday.AddDays(2); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*lastweek*": //Between two Sundays ago at midnight and last sunday at midnight @@ -442,7 +442,7 @@ namespace AyaNova.DataList //go to very start of eighth dayahead dtBefore = dtAfter.AddDays(8); dtAfter = dtAfter.AddSeconds(-1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*thisweek*": //Between Sunday at midnight and Next sunday at midnight @@ -467,7 +467,7 @@ namespace AyaNova.DataList dtBefore = dtBefore.AddDays(1); } - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*nextweek*": //Between Next Sunday at midnight and Next Next sunday at midnight @@ -486,7 +486,7 @@ namespace AyaNova.DataList dtBefore = dtAfter.AddDays(7); dtAfter = dtAfter.AddSeconds(-1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*lastmonth*": //start with the first day of this month @@ -500,7 +500,7 @@ namespace AyaNova.DataList //case 1155 dtAfter = dtAfter.AddSeconds(-1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*thismonth*": //start with the first day of this month @@ -512,7 +512,7 @@ namespace AyaNova.DataList //case 1155 dtAfter = dtAfter.AddSeconds(-1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*nextmonth*": @@ -526,7 +526,7 @@ namespace AyaNova.DataList //case 1155 dtAfter = dtAfter.AddSeconds(-1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*14daywindow*": @@ -540,21 +540,21 @@ namespace AyaNova.DataList //case 1155 dtAfter = dtAfter.AddSeconds(-1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*past*": //Forever up to Now dtAfter = new DateTime(1753, 1, 2);//this was for sql server but even then was probably outdated good enough though for our purposes dtBefore = ClientNow; - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*future*": //From Now to forever (999 years from now) dtAfter = ClientNow; dtBefore = dtAfter.AddYears(999); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*lastyear*": @@ -562,7 +562,7 @@ namespace AyaNova.DataList dtAfter = new DateTime(ClientNow.AddYears(-1).Year, 1, 1, 0, 0, 00).AddSeconds(-1); //To zero hour January 1 this year dtBefore = new DateTime(ClientNow.Year, 1, 1, 0, 0, 00); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*thisyear*": @@ -570,7 +570,7 @@ namespace AyaNova.DataList dtAfter = new DateTime(ClientNow.Year, 1, 1).AddSeconds(-1); //To zero hour Jan 1 next year dtBefore = new DateTime(ClientNow.AddYears(1).Year, 1, 1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*last3months*": @@ -578,7 +578,7 @@ namespace AyaNova.DataList dtAfter = ClientNow.AddMonths(-3).AddSeconds(-1); //To Now dtBefore = ClientNow; - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*last6months*": @@ -586,7 +586,7 @@ namespace AyaNova.DataList dtAfter = ClientNow.AddMonths(-6).AddSeconds(-1); //To Now dtBefore = ClientNow; - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*pastyear*": //within the prior 365 days before today @@ -594,7 +594,7 @@ namespace AyaNova.DataList dtAfter = ClientNow.AddDays(-365).AddSeconds(-1); //To Now dtBefore = ClientNow; - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*past90days*": @@ -602,21 +602,21 @@ namespace AyaNova.DataList dtAfter = ClientNow.AddDays(-90).AddSeconds(-1); //To Now dtBefore = ClientNow; - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*past30days*": //From Now minus 30 days dtAfter = ClientNow.AddDays(-30).AddSeconds(-1); //To Now dtBefore = ClientNow; - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*past7days*": //From Now minus 7 days dtAfter = ClientNow.AddDays(-7).AddSeconds(-1); //To Now dtBefore = ClientNow; - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*past24hours*": @@ -624,7 +624,7 @@ namespace AyaNova.DataList dtAfter = ClientNow.AddHours(-24).AddSeconds(-1); //To Now dtBefore = ClientNow; - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*past6hours*": @@ -632,7 +632,7 @@ namespace AyaNova.DataList dtAfter = ClientNow.AddHours(-6).AddSeconds(-1); //To Now dtBefore = ClientNow; - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*january*": @@ -640,67 +640,67 @@ namespace AyaNova.DataList dtAfter = new DateTime(ClientNow.Year, 1, 1).AddSeconds(-1); //To zero hour feb 1 this year dtBefore = new DateTime(ClientNow.Year, 2, 1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*february*": dtAfter = new DateTime(ClientNow.Year, 2, 1).AddSeconds(-1); dtBefore = new DateTime(ClientNow.Year, 3, 1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*march*": dtAfter = new DateTime(ClientNow.Year, 3, 1).AddSeconds(-1); dtBefore = new DateTime(ClientNow.Year, 4, 1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*april*": dtAfter = new DateTime(ClientNow.Year, 4, 1).AddSeconds(-1); dtBefore = new DateTime(ClientNow.Year, 5, 1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*may*": dtAfter = new DateTime(ClientNow.Year, 5, 1).AddSeconds(-1); dtBefore = new DateTime(ClientNow.Year, 6, 1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*june*": dtAfter = new DateTime(ClientNow.Year, 6, 1).AddSeconds(-1); dtBefore = new DateTime(ClientNow.Year, 7, 1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*july*": dtAfter = new DateTime(ClientNow.Year, 7, 1).AddSeconds(-1); dtBefore = new DateTime(ClientNow.Year, 8, 1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*august*": dtAfter = new DateTime(ClientNow.Year, 8, 1).AddSeconds(-1); dtBefore = new DateTime(ClientNow.Year, 9, 1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*september*": dtAfter = new DateTime(ClientNow.Year, 9, 1).AddSeconds(-1); dtBefore = new DateTime(ClientNow.Year, 10, 1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*october*": dtAfter = new DateTime(ClientNow.Year, 10, 1).AddSeconds(-1); dtBefore = new DateTime(ClientNow.Year, 11, 1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*november*": dtAfter = new DateTime(ClientNow.Year, 11, 1).AddSeconds(-1); dtBefore = new DateTime(ClientNow.Year, 12, 1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*december*": @@ -708,7 +708,7 @@ namespace AyaNova.DataList dtAfter = new DateTime(ClientNow.Year, 12, 1).AddSeconds(-1); //To zero hour Jan 1 next year dtBefore = new DateTime(ClientNow.AddYears(1).Year, 1, 1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*lastyearlastmonth*": //start with the first day of this month @@ -718,7 +718,7 @@ namespace AyaNova.DataList //Add one month to dtAfter to get end date dtBefore = dtAfter.AddMonths(1); dtAfter = dtAfter.AddSeconds(-1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*lastyearthismonth*": @@ -729,7 +729,7 @@ namespace AyaNova.DataList //Add one month to dtAfter to get end date dtBefore = dtAfter.AddMonths(1); dtAfter = dtAfter.AddSeconds(-1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; case "*lastyearnextmonth*": @@ -742,7 +742,7 @@ namespace AyaNova.DataList //Add one month to dtAfter to get end date dtBefore = dtAfter.AddMonths(1); dtAfter = dtAfter.AddSeconds(-1); - BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore); + BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset); break; default: @@ -1075,19 +1075,27 @@ namespace AyaNova.DataList //This is only used by the token date query code above and that code does NOT convert to UTC time to match the DB so this function will handle that //The other non tokenized date criteria builders are all working with dates that come from the client in UTC already and they don't use this method //so nothing required there - private static void BuildSQLBetweenTwoTokenDerivedDates(string sColumn, StringBuilder sb, DateTime dtAfter, DateTime dtBefore) + //case 4697: the token dates are the user's local times (from clientTimeStamp), so they are converted to UTC with the + //user's offset; ToUniversalTime() used the server's time zone. A range that crosses a daylight saving change can be + //an hour off at its far end, as the client sends only its current offset + private static void BuildSQLBetweenTwoTokenDerivedDates(string sColumn, StringBuilder sb, DateTime dtAfter, DateTime dtBefore, TimeSpan clientOffset) { sb.Append(">'"); - sb.Append(PostgresDateFormat(MaxSeconds(dtAfter.ToUniversalTime()))); + sb.Append(PostgresDateFormat(MaxSeconds(ClientLocalToUtc(dtAfter, clientOffset)))); sb.Append("' AND "); sb.Append(sColumn); sb.Append(" "); sb.Append("<'"); - sb.Append(PostgresDateFormat(ZeroSeconds(dtBefore.ToUniversalTime()))); + sb.Append(PostgresDateFormat(ZeroSeconds(ClientLocalToUtc(dtBefore, clientOffset)))); sb.Append("'"); } + private static DateTime ClientLocalToUtc(DateTime clientLocal, TimeSpan clientOffset) + { + return DateTime.SpecifyKind(clientLocal - clientOffset, DateTimeKind.Utc); + } + private static DateTime ZeroSeconds(DateTime d) {