From 069190289636e127305db269cb9b07ca4f47948f Mon Sep 17 00:00:00 2001 From: Nicholas Long <1907354+nllong@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:42:26 -0600 Subject: [PATCH 1/5] allow user to configure light/dark mode in setting; cache NLR accepted terms for 90 days --- MIGRATION.md | 36 ++++--- .../api/organization/organization.types.ts | 1 + .../services/terms/terms.service.spec.ts | 55 +++++++++++ src/@seed/services/terms/terms.service.ts | 20 ++++ .../layout/common/user/user.component.html | 2 +- src/app/layout/common/user/user.component.ts | 4 + src/app/layout/layout.component.ts | 8 +- .../auth/sign-in/sign-in.component.html | 46 +++++---- .../auth/sign-in/sign-in.component.spec.ts | 98 +++++++++++++++++++ .../modules/auth/sign-in/sign-in.component.ts | 16 ++- src/app/modules/profile/profile.component.ts | 9 +- src/app/modules/profile/profile.routes.ts | 6 ++ .../profile/settings/settings.component.html | 26 +++++ .../settings/settings.component.spec.ts | 92 +++++++++++++++++ .../profile/settings/settings.component.ts | 69 +++++++++++++ 15 files changed, 452 insertions(+), 36 deletions(-) create mode 100644 src/@seed/services/terms/terms.service.spec.ts create mode 100644 src/app/modules/auth/sign-in/sign-in.component.spec.ts create mode 100644 src/app/modules/profile/settings/settings.component.html create mode 100644 src/app/modules/profile/settings/settings.component.spec.ts create mode 100644 src/app/modules/profile/settings/settings.component.ts diff --git a/MIGRATION.md b/MIGRATION.md index ef2d5cfa..f4c7e041 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -111,10 +111,6 @@ against this app's route files. Update this table as pages move between columns. - [ ] **Salesforce login callback** — Angular implementation exists in PR #56, but callback success/failure landing behavior still needs parity verification. -- [ ] **Public Data Sharing** — Angular implementation exists in PR #77, with reviewer request - `kflemin`; verify API behavior, permissions, translations, and real-data save/reload. -- [ ] **Portfolio Summary enhancement** — PR #56 remains in progress; verify goals, cycles, - partner approvals, Salesforce behavior, and remaining source-audit TODOs. The older “Not yet migrated” entries above are retained as historical route references; the daily source-audit table below is the current classification. @@ -181,20 +177,21 @@ its line to "Won't migrate" with a reason instead. ## Daily source-code parity snapshot -Snapshot refreshed **2026-08-03 04:07 PDT** from the legacy route/template inventory, the current -Angular source tree, local worktrees, and refreshed remote refs. Shared fragments and modal HTML -remain counted under their owning page rather than as separate pages. +Snapshot refreshed **2026-08-27 09:58 MDT** from the legacy route/template inventory, current GitHub +PR metadata, and refreshed branch refs. Local remote-tracking refs match the current core +`develop` and Angular `main` heads; +shared fragments and modal HTML remain counted under their owning page rather than as separate pages. | Inventory / status | Current count | Evidence | |---|---:|---| | Legacy partial HTML files | **166** | `seed/static/seed/partials/**/*.html` | | Unique route-owned legacy templates | **59** | 63 legacy states in `seed.js` collapse to 59 unique `partials/*.html` references | | Shared/modal legacy fragments | **107** | Parent-page burndown; 86 filenames contain `modal` | -| Angular application HTML templates | **198** | `src/app/**/*.html` | +| Angular application HTML templates | **216** | `src/app/**/*.html` on refreshed `origin/main` | | Angular shared HTML templates | **33** | `src/@seed/**/*.html` | -| Baseline migrated | **51 / 59** | Route/component exists on the baseline, subject to full parity sign-off | -| Ported but incomplete | **3 / 59** | Salesforce login, Public Data Sharing, Portfolio Summary enhancement | -| Needs port | **2 / 59** | Personal two-factor setup and full Program Setup | +| Baseline migrated | **54 / 59** | Includes merged Public Data Sharing (#77), Personal Two-Factor (#80), and Portfolio Summary improvements (#79/#83) | +| Ported but incomplete | **1 / 59** | Salesforce login callback | +| Needs port | **1 / 59** | Full Program Setup | | Won't migrate | **3 / 59** | Pairing settings, Inventory Plots, Sub-organizations | ```mermaid @@ -202,9 +199,24 @@ xychart-beta title "Legacy route-template migration burndown" x-axis ["Baseline migrated", "Incomplete", "Needs port", "Won't migrate"] y-axis "Unique route templates" 0 --> 59 - bar [51, 3, 2, 3] + bar [54, 1, 1, 3] ``` +### Current source walkthrough and next action + +Shared and modal HTML remains owned by the parent page and is not double-counted. + +| Page | Legacy source surface | Angular source/evidence | Status | +|---|---|---|---| +| Salesforce login callback | `partials/salesforce_login.html`, `salesforce_login_controller.js`, Salesforce/user services, callback parameters and return navigation | `src/app/modules/salesforce-login/salesforce-login.component.ts/.html`; success/failure banners, retry behavior, invalid parameters, redirects, translations, and backend contract still need verification | **Ported, incomplete** | +| Full Program Setup | `partials/program_setup.html`, `program_setup_controller.js`, `programs`/`program_setup` states, compliance metric services, org-settings permissions/navigation | `ProgramConfigComponent` only covers the embedded Insights picker; no full organization-level CRUD route/component | **Needs port** | +| Portfolio Summary | `partials/portfolio_summary.html`, controller, goal/cycle/Salesforce/bulk-note/partner-approval modal partials and services | `src/app/modules/insights/portfolio-summary/`; merged #79 and #83 added saved-goal selection, searchable goals, filters, Building Elements, and ECM improvements; no remaining TODO/logout gap found in current source audit | **Baseline migrated** | + +**Recommended next page: Salesforce login callback.** It is the only remaining incomplete route and has +an existing Angular implementation, so close parity with a bounded callback-contract and real-data test +pass before starting the larger Program Setup port. Current highest-priority review flag is **NLLONG: +SEED core #5303**. + ### Cross Cycles parity walkthrough Cross Cycles is now **functionally ported** (properties and tax lots, list + detail routes). The diff --git a/src/@seed/api/organization/organization.types.ts b/src/@seed/api/organization/organization.types.ts index b32b22a5..5860027a 100644 --- a/src/@seed/api/organization/organization.types.ts +++ b/src/@seed/api/organization/organization.types.ts @@ -125,6 +125,7 @@ export type OrganizationUser = { export type OrganizationUserSettings = { [key: string]: unknown; + colorScheme?: 'dark' | 'light'; cycleId?: number; sorts?: UserSettingsSorts; filters?: UserSettingsFilters; diff --git a/src/@seed/services/terms/terms.service.spec.ts b/src/@seed/services/terms/terms.service.spec.ts new file mode 100644 index 00000000..7b1ecb25 --- /dev/null +++ b/src/@seed/services/terms/terms.service.spec.ts @@ -0,0 +1,55 @@ +import { TestBed } from '@angular/core/testing' +import { ConfirmationService } from '../confirmation' +import { TermsService } from './terms.service' + +const ACCEPTED_AT_KEY = 'nlrTermsAcceptedAt' +const ACCEPTANCE_DAYS = 90 +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000 + +describe('TermsService', () => { + let service: TermsService + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ provide: ConfirmationService, useValue: { open: jasmine.createSpy('open') } }], + }) + service = TestBed.inject(TermsService) + localStorage.removeItem(ACCEPTED_AT_KEY) + jasmine.clock().install() + jasmine.clock().mockDate(new Date('2026-01-01T00:00:00Z')) + }) + + afterEach(() => { + localStorage.removeItem(ACCEPTED_AT_KEY) + jasmine.clock().uninstall() + }) + + it('records acceptance in browser storage', () => { + service.recordTermsAcceptance() + + expect(localStorage.getItem(ACCEPTED_AT_KEY)).toBe(Date.now().toString()) + expect(service.hasAcceptedTerms()).toBeTrue() + }) + + it('keeps acceptance valid for less than 90 days', () => { + localStorage.setItem(ACCEPTED_AT_KEY, (Date.now() - ACCEPTANCE_DAYS * MILLISECONDS_PER_DAY + 1).toString()) + + expect(service.hasAcceptedTerms()).toBeTrue() + }) + + it('expires acceptance after 90 days', () => { + localStorage.setItem(ACCEPTED_AT_KEY, (Date.now() - ACCEPTANCE_DAYS * MILLISECONDS_PER_DAY).toString()) + + expect(service.hasAcceptedTerms()).toBeFalse() + }) + + it('rejects missing, malformed, and future acceptance dates', () => { + expect(service.hasAcceptedTerms()).toBeFalse() + + localStorage.setItem(ACCEPTED_AT_KEY, 'not-a-date') + expect(service.hasAcceptedTerms()).toBeFalse() + + localStorage.setItem(ACCEPTED_AT_KEY, (Date.now() + 1).toString()) + expect(service.hasAcceptedTerms()).toBeFalse() + }) +}) diff --git a/src/@seed/services/terms/terms.service.ts b/src/@seed/services/terms/terms.service.ts index aafbacc8..50027dd1 100644 --- a/src/@seed/services/terms/terms.service.ts +++ b/src/@seed/services/terms/terms.service.ts @@ -1,10 +1,30 @@ import { inject, Injectable } from '@angular/core' import { ConfirmationService } from '../confirmation' +const NLR_TERMS_ACCEPTED_AT_KEY = 'nlrTermsAcceptedAt' +const NLR_TERMS_ACCEPTANCE_DAYS = 90 +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000 + @Injectable({ providedIn: 'root' }) export class TermsService { private _confirmationService = inject(ConfirmationService) + hasAcceptedTerms(): boolean { + const acceptedAt = Number(localStorage.getItem(NLR_TERMS_ACCEPTED_AT_KEY)) + const acceptanceAge = Date.now() - acceptedAt + + return ( + Number.isFinite(acceptedAt) + && acceptedAt > 0 + && acceptanceAge >= 0 + && acceptanceAge < NLR_TERMS_ACCEPTANCE_DAYS * MILLISECONDS_PER_DAY + ) + } + + recordTermsAcceptance(): void { + localStorage.setItem(NLR_TERMS_ACCEPTED_AT_KEY, Date.now().toString()) + } + showTermsOfService(): void { this._confirmationService.open({ title: 'DOE Standard Energy Efficiency Data Platform | NLR Data Terms', diff --git a/src/app/layout/common/user/user.component.html b/src/app/layout/common/user/user.component.html index aa6fcb5f..abed88eb 100644 --- a/src/app/layout/common/user/user.component.html +++ b/src/app/layout/common/user/user.component.html @@ -20,7 +20,7 @@ Profile - diff --git a/src/app/layout/common/user/user.component.ts b/src/app/layout/common/user/user.component.ts index 57c3fafe..23890ddf 100644 --- a/src/app/layout/common/user/user.component.ts +++ b/src/app/layout/common/user/user.component.ts @@ -51,4 +51,8 @@ export class UserComponent implements OnInit, OnDestroy { goToProfile() { void this._router.navigate(['/profile']) } + + goToSettings(): void { + void this._router.navigate(['/profile/display']) + } } diff --git a/src/app/layout/layout.component.ts b/src/app/layout/layout.component.ts index 65a89d58..62605dbe 100644 --- a/src/app/layout/layout.component.ts +++ b/src/app/layout/layout.component.ts @@ -3,7 +3,7 @@ import type { OnDestroy, OnInit } from '@angular/core' import { Component, DOCUMENT, inject, isDevMode, Renderer2, ViewEncapsulation } from '@angular/core' import { ActivatedRoute, NavigationEnd, Router } from '@angular/router' import { combineLatest, filter, map, Subject, takeUntil } from 'rxjs' -import { VersionService } from '@seed/api' +import { UserService, VersionService } from '@seed/api' import type { Scheme, SEEDConfig } from '@seed/services' import { ConfigService, MediaWatcherService, PlatformService } from '@seed/services' import { DevSettingsComponent } from './common/dev-settings/dev-settings.component' @@ -27,6 +27,7 @@ export class LayoutComponent implements OnInit, OnDestroy { private _platformService = inject(PlatformService) private _renderer = inject(Renderer2) private _router = inject(Router) + private _userService = inject(UserService) private _versionService = inject(VersionService) config: SEEDConfig @@ -37,6 +38,11 @@ export class LayoutComponent implements OnInit, OnDestroy { private readonly _unsubscribeAll$ = new Subject() ngOnInit(): void { + this._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe(({ settings }) => { + const { colorScheme } = settings + this._configService.config = { scheme: colorScheme === 'dark' || colorScheme === 'light' ? colorScheme : 'auto' } + }) + // Set the theme and scheme based on the configuration combineLatest([ this._configService.config$, diff --git a/src/app/modules/auth/sign-in/sign-in.component.html b/src/app/modules/auth/sign-in/sign-in.component.html index 67c8d7ba..3c935f65 100644 --- a/src/app/modules/auth/sign-in/sign-in.component.html +++ b/src/app/modules/auth/sign-in/sign-in.component.html @@ -62,26 +62,34 @@ > -
- - I agree with the - NLR Data Terms + + Already accepted NLR terms + +
+ } @else { +
+ - - @if (isTermsInvalid) { - You must accept the NLR Data Terms. - } -
+ I agree with the + NLR Data Terms + + @if (isTermsInvalid) { + You must accept the NLR Data Terms. + } + + }