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) {