Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -181,30 +177,46 @@ 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.
Comment thread
nllong marked this conversation as resolved.

| 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
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
Expand Down
3 changes: 3 additions & 0 deletions public/i18n/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@
"DIRECTIONS_FOR_UPDATING_MQ_API_KEY": "If you'd like to geocode your data using the MapQuest service, please provide a valid API key within your organization's settings.",
"DISABLED": "DISABLED",
"Dashboard": "Dashboard",
"Dark": "Dark",
"Data": "Data",
"Data Administrator Account Name Column": "Data Administrator Account Name Column",
"Data Administrator Contact Field": "Data Administrator Contact Field",
Expand Down Expand Up @@ -537,6 +538,7 @@
"Diesel": "Diesel",
"Disabled": "Disabled",
"Dismiss": "Dismiss",
"Display": "Display",
"Display Columns": "Display Columns",
"Display Fields": "Display Fields",
"Display Name": "Display Name",
Expand Down Expand Up @@ -936,6 +938,7 @@
"Left Half": "Left Half",
"Level": "Level",
"Level Instance": "Level Instance",
"Light": "Light",
"Linking ID": "Linking ID",
"Loading Summary Data...": "Loading Summary Data...",
"Loading data...": "Loading data...",
Expand Down
1 change: 1 addition & 0 deletions src/@seed/api/organization/organization.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export type OrganizationUser = {

export type OrganizationUserSettings = {
[key: string]: unknown;
colorScheme?: 'dark' | 'light';
cycleId?: number;
sorts?: UserSettingsSorts;
filters?: UserSettingsFilters;
Expand Down
62 changes: 62 additions & 0 deletions src/@seed/services/terms/terms.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { TestBed } from '@angular/core/testing'
import { ConfirmationService } from '../confirmation'
import { TermsService } from './terms.service'

const TEST_EMAIL = 'test@example.com'
const ACCEPTED_AT_KEY = `nlrTermsAcceptedAt:${TEST_EMAIL}`
const ACCEPTANCE_DAYS = 90

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keep accepted terms for 90 days

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(TEST_EMAIL)

expect(localStorage.getItem(ACCEPTED_AT_KEY)).toBe(Date.now().toString())
expect(service.hasAcceptedTerms(TEST_EMAIL)).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(TEST_EMAIL)).toBeTrue()
})

it('expires acceptance after 90 days', () => {
localStorage.setItem(ACCEPTED_AT_KEY, (Date.now() - ACCEPTANCE_DAYS * MILLISECONDS_PER_DAY).toString())

expect(service.hasAcceptedTerms(TEST_EMAIL)).toBeFalse()
})

it('rejects missing, malformed, and future acceptance dates', () => {
expect(service.hasAcceptedTerms(TEST_EMAIL)).toBeFalse()

localStorage.setItem(ACCEPTED_AT_KEY, 'not-a-date')
expect(service.hasAcceptedTerms(TEST_EMAIL)).toBeFalse()

localStorage.setItem(ACCEPTED_AT_KEY, (Date.now() + 1).toString())
expect(service.hasAcceptedTerms(TEST_EMAIL)).toBeFalse()
})

it('does not share acceptance between different accounts', () => {
service.recordTermsAcceptance(TEST_EMAIL)

expect(service.hasAcceptedTerms('other@example.com')).toBeFalse()
})
})
22 changes: 22 additions & 0 deletions src/@seed/services/terms/terms.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
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
Comment on lines +4 to +6

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest commit. hasAcceptedTerms and recordTermsAcceptance now both take an email: string parameter and scope the localStorage key to nlrTermsAcceptedAt:${email.toLowerCase().trim()}, so acceptance for one account can no longer satisfy the requirement for a different account. In sign-in.component.ts, the check is done reactively via email.valueChanges (so it evaluates only once a valid address is entered), and the email is passed when recording acceptance after successful sign-in.


@Injectable({ providedIn: 'root' })
export class TermsService {
private _confirmationService = inject(ConfirmationService)

hasAcceptedTerms(email: string): boolean {
const key = `${NLR_TERMS_ACCEPTED_AT_KEY}:${email.toLowerCase().trim()}`
const acceptedAt = Number(localStorage.getItem(key))
const acceptanceAge = Date.now() - acceptedAt

return (
Number.isFinite(acceptedAt)
&& acceptedAt > 0
&& acceptanceAge >= 0
&& acceptanceAge < NLR_TERMS_ACCEPTANCE_DAYS * MILLISECONDS_PER_DAY
)
}

recordTermsAcceptance(email: string): void {
const key = `${NLR_TERMS_ACCEPTED_AT_KEY}:${email.toLowerCase().trim()}`
localStorage.setItem(key, Date.now().toString())
}

showTermsOfService(): void {
this._confirmationService.open({
title: 'DOE Standard Energy Efficiency Data Platform | NLR Data Terms',
Expand Down
2 changes: 1 addition & 1 deletion src/app/layout/common/user/user.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<mat-icon class="text-secondary" svgIcon="fa-solid:circle-user"></mat-icon>
<span>Profile</span>
</button>
<button mat-menu-item>
<button (click)="goToSettings()" mat-menu-item>
<mat-icon class="text-secondary" svgIcon="fa-solid:gear"></mat-icon>
<span>Settings</span>
</button>
Expand Down
4 changes: 4 additions & 0 deletions src/app/layout/common/user/user.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,8 @@ export class UserComponent implements OnInit, OnDestroy {
goToProfile() {
void this._router.navigate(['/profile'])
}

goToSettings(): void {
void this._router.navigate(['/profile/display'])
}
}
8 changes: 7 additions & 1 deletion src/app/layout/layout.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -37,6 +38,11 @@ export class LayoutComponent implements OnInit, OnDestroy {
private readonly _unsubscribeAll$ = new Subject<void>()

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$,
Expand Down
46 changes: 27 additions & 19 deletions src/app/modules/auth/sign-in/sign-in.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,26 +62,34 @@
>
</div>

<div class="mt-1.5 flex w-full flex-col">
<mat-checkbox
class="-ml-2"
#termsCheckbox
[class.mb-5]="!isTermsInvalid"
(keydown.enter)="termsCheckbox.toggle(); $event.preventDefault()"
color="primary"
formControlName="terms"
>
<span>I agree with the</span>
<a
class="ml-1 cursor-pointer text-primary-500 hover:underline focus:underline"
(click)="$event.preventDefault(); showTermsOfService()"
>NLR Data Terms</a
@if (termsPreviouslyAccepted) {
<div class="text-secondary mt-1.5 flex w-full items-center gap-2">
<mat-icon class="text-green-600 icon-size-5" svgIcon="fa-solid:circle-check"></mat-icon>
<span>Already accepted NLR terms</span>
<button class="text-primary-500" (click)="showTermsOfService()" mat-button type="button">Review terms</button>
</div>
} @else {
<div class="mt-1.5 flex w-full flex-col">
<mat-checkbox
class="-ml-2"
#termsCheckbox
[class.mb-5]="!isTermsInvalid"
(keydown.enter)="termsCheckbox.toggle(); $event.preventDefault()"
color="primary"
formControlName="terms"
>
</mat-checkbox>
@if (isTermsInvalid) {
<mat-error class="h-5 text-sm font-medium" @fadeInTop>You must accept the NLR Data Terms.</mat-error>
}
</div>
<span>I agree with the</span>
<a
class="ml-1 cursor-pointer text-primary-500 hover:underline focus:underline"
(click)="$event.preventDefault(); showTermsOfService()"
>NLR Data Terms</a
>
</mat-checkbox>
@if (isTermsInvalid) {
<mat-error class="h-5 text-sm font-medium" @fadeInTop>You must accept the NLR Data Terms.</mat-error>
}
</div>
}

<!-- Submit button -->
<button class="seed-mat-button-large mt-6 w-full" [disabled]="signInForm.disabled" (click)="signIn()" mat-flat-button color="primary">
Expand Down
98 changes: 98 additions & 0 deletions src/app/modules/auth/sign-in/sign-in.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { ComponentFixture } from '@angular/core/testing'
import { TestBed } from '@angular/core/testing'
import { ActivatedRoute, Router } from '@angular/router'
import { of } from 'rxjs'
import { ConfigService } from '@seed/api'
import { TermsService } from '@seed/services'
import { AuthService } from 'app/core/auth/auth.service'
import { AuthSignInComponent } from './sign-in.component'

describe('AuthSignInComponent', () => {
let fixture: ComponentFixture<AuthSignInComponent>
let component: AuthSignInComponent
let hasAcceptedTerms: jasmine.Spy
let recordTermsAcceptance: jasmine.Spy
let signIn: jasmine.Spy
let navigateByUrl: jasmine.Spy

beforeEach(async () => {
hasAcceptedTerms = jasmine.createSpy('hasAcceptedTerms').and.returnValue(false)
recordTermsAcceptance = jasmine.createSpy('recordTermsAcceptance')
signIn = jasmine.createSpy('signIn').and.returnValue(of({ access: 'access', refresh: 'refresh' }))
navigateByUrl = jasmine.createSpy('navigateByUrl').and.returnValue(Promise.resolve(true))

await TestBed.configureTestingModule({
imports: [AuthSignInComponent],
providers: [
{ provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: { get: () => null } } } },
{ provide: Router, useValue: { navigateByUrl } },
{ provide: ConfigService, useValue: { config$: of({ allow_signup: false }) } },
{ provide: AuthService, useValue: { signIn } },
{
provide: TermsService,
useValue: { hasAcceptedTerms, recordTermsAcceptance, showTermsOfService: jasmine.createSpy('showTermsOfService') },
},
],
})
.overrideComponent(AuthSignInComponent, { set: { template: '' } })
.compileComponents()
})

afterEach(() => {
fixture?.destroy()
})

function createComponent(): void {
fixture = TestBed.createComponent(AuthSignInComponent)
component = fixture.componentInstance
fixture.detectChanges()
}

it('requires terms acceptance when the cached acceptance has expired', () => {
createComponent()
component.signInForm.patchValue({ email: 'user@example.com', password: 'password' })

expect(component.termsPreviouslyAccepted).toBeFalse()
expect(component.signInForm.invalid).toBeTrue()

component.signInForm.controls.terms.setValue(true)
expect(component.signInForm.valid).toBeTrue()
})

it('uses a current cached acceptance without recording a new timestamp', () => {
hasAcceptedTerms.and.returnValue(true)
createComponent()
component.signInForm.setValue({ email: 'user@example.com', password: 'password', terms: true })

component.signIn()

expect(recordTermsAcceptance).not.toHaveBeenCalled()
expect(navigateByUrl).toHaveBeenCalledOnceWith('/signed-in-redirect')
})

it('records a new acceptance after sign-in succeeds', () => {
createComponent()
component.signInForm.setValue({ email: 'user@example.com', password: 'password', terms: true })

component.signIn()

expect(recordTermsAcceptance).toHaveBeenCalledTimes(1)
expect(component.termsPreviouslyAccepted).toBeTrue()
expect(navigateByUrl).toHaveBeenCalledOnceWith('/signed-in-redirect')
})

it('waits for successful two-factor verification before recording acceptance', () => {
signIn.and.returnValues(of({ two_factor_required: true, two_factor_method: 'email' }), of({ access: 'access', refresh: 'refresh' }))
createComponent()
component.signInForm.setValue({ email: 'user@example.com', password: 'password', terms: true })

component.signIn()
expect(recordTermsAcceptance).not.toHaveBeenCalled()

component.otpForm.controls.otp_token.setValue('123456')
component.submitOtp()

expect(recordTermsAcceptance).toHaveBeenCalledTimes(1)
expect(navigateByUrl).toHaveBeenCalledOnceWith('/signed-in-redirect')
})
})
Loading