diff --git a/src/app/app.html b/src/app/app.html index d29cf09..c296417 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,13 +1,21 @@ - @if (showPlanner()) { -
-

beach_access {{ title() }}

-
- - - - -
-
+
+
+
+ +

{{ title() }}

+
+ +
+ +
+
+ +
+
+ +
+
+
} diff --git a/src/app/app.routes.spec.ts b/src/app/app.routes.spec.ts new file mode 100644 index 0000000..499464e --- /dev/null +++ b/src/app/app.routes.spec.ts @@ -0,0 +1,7 @@ +import { routes } from './app.routes'; + +describe('application routes', () => { + it('declares the root URL for the planner shell', () => { + expect(routes).toContainEqual({ path: '', pathMatch: 'full', children: [] }); + }); +}); diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 2a7ee89..27dc42c 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,6 +1,7 @@ import { Routes } from '@angular/router'; export const routes: Routes = [ + { path: '', pathMatch: 'full', children: [] }, { path: 'benchmark', loadComponent: () => diff --git a/src/app/app.scss b/src/app/app.scss index 0db9d7b..c3d79a2 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -1,79 +1,71 @@ -.main { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 100vh; +:host { + display: block; } -.content { +.app-shell { + min-height: 100dvh; +} + +.app-header { display: flex; - flex-direction: row; - align-items: flex-start; + flex-wrap: wrap; + align-items: center; justify-content: space-between; - width: 100%; - padding: 0 24px; + gap: 1rem; + width: min(100%, 120rem); + margin-inline: auto; + padding: 1rem clamp(1rem, 2vw, 2rem); + box-sizing: border-box; + border-block-end: 1px solid var(--ui-border, #c7c7d0); +} + +.brand { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + + mat-icon { + color: var(--mat-sys-primary, #005cbb); + } + + h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.025em; + line-height: 1.2; + } +} + +.planner-layout { + display: grid; + grid-template-columns: minmax(23rem, 29rem) minmax(0, 1fr); + align-items: start; + gap: 2rem; + width: min(100%, 120rem); + margin-inline: auto; + padding: 1.5rem clamp(1rem, 2vw, 2rem) 3rem; box-sizing: border-box; } -app-user-input { - display: block; - flex: 3; - min-width: 350px; - margin-right: 16px; -} - +.configuration-panel, +.results-panel, +app-user-input, app-calendar-year { display: block; - flex: 7; - min-width: 700px; - margin-left: 16px; + min-width: 0; } -@media (max-width: 1024px) { - .content { - flex-direction: column; - align-items: stretch; - padding: 0 16px; - } - - app-user-input, - app-calendar-year { - flex: none; - width: 100%; - min-width: 0; - margin: 0; - } - - app-user-input { - margin-bottom: 24px; - } - - .mat-divider-vertical { - display: none; - } - - .mat-divider-horizontal { - display: block !important; - } +.configuration-panel { + padding: 1.25rem; + background: var(--ui-panel-bg, #f7f7fa); + border: 1px solid var(--ui-border, #c7c7d0); + border-radius: var(--ui-radius-lg, 1rem); } -.modeButton { - position: fixed; - top: 20px; - right: 20px; - z-index: 1000; -} - -.mat-divider-vertical { - height: 80vh; - width: 1px; - margin: 0 16px; -} - -.mat-divider-horizontal { - width: 80%; - height: 1px; - margin: 16px 0; - display: none; +@media (max-width: 75rem) { + .planner-layout { + grid-template-columns: minmax(0, 1fr); + } } diff --git a/src/app/app.ts b/src/app/app.ts index 477e68f..389acdc 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,7 +1,6 @@ import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { provideNativeDateAdapter } from '@angular/material/core'; -import { MatDivider } from '@angular/material/divider'; import { UserInput } from './components/user-input/user-input'; import { CalendarYear } from './components/calendar-year/calendar-year'; import { DarkLightModeButton } from './components/dark-light-mode-button/dark-light-mode-button'; @@ -16,7 +15,6 @@ function isBenchmarkUrl(url: string): boolean { selector: 'app-root', imports: [ MatDatepickerModule, - MatDivider, UserInput, CalendarYear, DarkLightModeButton, diff --git a/src/app/components/calendar-month/calendar-month.html b/src/app/components/calendar-month/calendar-month.html index b6a6489..4af8566 100644 --- a/src/app/components/calendar-month/calendar-month.html +++ b/src/app/components/calendar-month/calendar-month.html @@ -1,4 +1,4 @@ -
+

{{ getMonthName() }} {{ year }}

@@ -25,6 +25,7 @@ @if (day === null) {

} @else { + @let dayTooltip = getDayTooltip(day);

{{ day.getDate() }}

diff --git a/src/app/components/calendar-month/calendar-month.scss b/src/app/components/calendar-month/calendar-month.scss index 74aa136..e163de5 100644 --- a/src/app/components/calendar-month/calendar-month.scss +++ b/src/app/components/calendar-month/calendar-month.scss @@ -69,8 +69,6 @@ .today { border-color: var(--today-border-color); border-width: 2px; - width: 26px !important; - height: 22px !important; border-style: dashed !important; :has(.dark-mode) { @@ -88,8 +86,6 @@ .closed-day { border: 2px solid var(--closed-day-bg); - width: 26px !important; - height: 22px !important; } .rtt { diff --git a/src/app/components/calendar-settings/calendar-settings.html b/src/app/components/calendar-settings/calendar-settings.html index eddedcd..6712189 100644 --- a/src/app/components/calendar-settings/calendar-settings.html +++ b/src/app/components/calendar-settings/calendar-settings.html @@ -1,4 +1,4 @@ - diff --git a/src/app/components/calendar-settings/calendar-settings.scss b/src/app/components/calendar-settings/calendar-settings.scss index e69de29..1079938 100644 --- a/src/app/components/calendar-settings/calendar-settings.scss +++ b/src/app/components/calendar-settings/calendar-settings.scss @@ -0,0 +1,7 @@ +:host { + display: inline-flex; +} + +button { + min-height: 2.75rem; +} diff --git a/src/app/components/calendar-year/calendar-year-planning.spec.ts b/src/app/components/calendar-year/calendar-year-planning.spec.ts index 6378fc3..5d8749c 100644 --- a/src/app/components/calendar-year/calendar-year-planning.spec.ts +++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts @@ -32,15 +32,30 @@ const incomplete: PlanningIncompleteOutcome = { const plan: PlanOutcome = { kind: 'Plan', horizon: { start: '2024-01-01', end: '2025-01-01' }, - placements: [], + placements: [ + { + date: '2024-05-03', + balanceId: 'cp', + type: 'cp', + ruleIds: [], + supporting: false, + }, + { + date: '2024-05-06', + balanceId: 'cp', + type: 'cp', + ruleIds: [], + supporting: false, + }, + ], timeOffPeriods: [], preferences: [], remainingBalances: [], metrics: { unplacedAtRisk: 0, holidayWeekValue: 0, - nonWorkingDayGain: 0, - longestWorkSequence: 0, + nonWorkingDayGain: 4, + longestWorkSequence: 6, shortSequenceDeficit: 0, supportingPlacements: 0, preferenceSatisfaction: [], @@ -84,7 +99,7 @@ describe('CalendarYear planning outcome boundary', () => { expect(userInput.remainingVacationDaysSignal()).toEqual([]); }); - it('keeps a returned incumbent/result visible only when an outcome supplies one', async () => { + it('keeps and summarizes a returned result', async () => { const calendar = { getSelectedDatesForYear: vi.fn(() => new SelectedDates()), getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })), @@ -103,7 +118,13 @@ describe('CalendarYear planning outcome boundary', () => { const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); expect(userInput.remainingVacationDaysSignal()).toEqual([]); + const summaryValues = Array.from( + fixture.nativeElement.querySelectorAll('.plan-summary dd') as NodeListOf, + ).map((element) => element.textContent?.trim()); + expect(summaryValues).toEqual(['2', '4', '6']); }); it('turns planner rejection into an incomplete error and clears loading', async () => { diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html index 9f86263..695c331 100644 --- a/src/app/components/calendar-year/calendar-year.html +++ b/src/app/components/calendar-year/calendar-year.html @@ -3,33 +3,107 @@
- +
{{ loadingProgress() }}%

{{ tr().loading }}

} - @if (planningOutcome()?.kind === 'InvalidInput') { - - } @else if (planningOutcome()?.kind === 'PlanningConflict') { - - } @else if (planningOutcome()?.kind === 'PlanningIncomplete') { -

- {{ hasPlannerError() ? tr().planning.error : tr().planning.incomplete }} -

- } @else if (planningOutcome()?.kind === 'Plan') { -

{{ tr().planning.complete }}

- } - @for (month of months; track $index) { - - - - - + +
+
+

{{ tr().planning.title }}

+ @if (planningOutcome()?.kind === 'InvalidInput') { + + } @else if (planningOutcome()?.kind === 'PlanningConflict') { + + } @else if (planningOutcome()?.kind === 'PlanningIncomplete') { +

+ + {{ hasPlannerError() ? tr().planning.error : tr().planning.incomplete }} +

+ } @else if (planningOutcome()?.kind === 'Plan') { +

+ + {{ tr().planning.complete }} +

+ } +
+ +
+ + @if (displayedPlan(); as plan) { +
+
+
{{ tr().planning.daysPlaced }}
+
{{ plan.placements.length }}
+
+
+
{{ tr().planning.daysGained }}
+
{{ plan.metrics.nonWorkingDayGain }}
+
+
+
{{ tr().planning.longestWorkSequence }}
+
{{ plan.metrics.longestWorkSequence }}
+
+
} + +
+ {{ tr().planning.legend }} +
    +
  • + {{ tr().dayTypes.weekend }} +
  • +
  • + {{ tr().dayTypes.closedDay }} +
  • +
  • {{ tr().dayTypes.cp }}
  • +
  • + {{ tr().dayTypes.rtt }} +
  • +
  • + {{ tr().dayTypes.other }} +
  • +
  • + {{ tr().dayTypes.today }} +
  • +
+
+ +
+ @for (month of months; track $index) { + + + + + + } +
diff --git a/src/app/components/calendar-year/calendar-year.scss b/src/app/components/calendar-year/calendar-year.scss index baa9817..1ac06c6 100644 --- a/src/app/components/calendar-year/calendar-year.scss +++ b/src/app/components/calendar-year/calendar-year.scss @@ -1,40 +1,41 @@ -@use '@angular/material' as mat; +:host { + display: block; + min-width: 0; +} .calendar-year { - display: flex; - flex-direction: row; - flex-wrap: wrap; - justify-content: space-evenly; + display: grid; + container: calendar-year / inline-size; + gap: 1.25rem; width: 100%; - max-width: 100%; + min-width: 0; position: relative; } .loading-overlay { position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(255, 255, 255, 0.9); + inset: 0; display: flex; + flex-wrap: wrap; justify-content: center; align-items: center; - border-radius: 4px; - z-index: 1000; - background-color: var(--loading-overlay-bg); + min-height: 18rem; + border-radius: var(--ui-radius-lg, 1rem); + z-index: 1; + background: var(--loading-overlay-bg, rgba(255, 255, 255, 0.92)); } .loading-spinner { display: flex; + flex-wrap: wrap; flex-direction: column; align-items: center; - gap: 16px; + gap: 1rem; p { - font-size: 16px; + font-size: 1rem; font-weight: 500; - color: var(--loading-text-color); + color: var(--loading-text-color, #333); margin: 0; } } @@ -43,24 +44,183 @@ position: relative; display: inline-block; - ::ng-deep .mat-mdc-progress-spinner { - // Override Material spinner size - width: 100px; - height: 100px; - } - .spinner-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); - font-size: 20px; + font-size: 1.125rem; font-weight: bold; - color: var(--loading-text-color); + color: var(--loading-text-color, #333); pointer-events: none; } } -.card { +.results-header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding-block-end: 1rem; + border-block-end: 1px solid var(--ui-border, #c7c7d0); +} + +.results-heading { + display: grid; + gap: 0.5rem; + min-width: 0; + + h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + line-height: 1.2; + text-wrap: balance; + } +} + +.planning-status { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + width: fit-content; + max-width: 65ch; + margin: 0; + padding: 0.5rem 0.75rem; + border-radius: var(--ui-radius-sm, 0.5rem); + font-weight: 600; + line-height: 1.4; + + mat-icon { + flex-shrink: 0; + } + + &.success { + background: var(--ui-success-bg, #d8f5df); + color: var(--ui-success-text, #174d26); + } + + &.warning { + background: var(--ui-warning-bg, #fff4d6); + color: var(--ui-warning-text, #4f3800); + } + + &.error { + background: var(--ui-error-bg, #ffe0e0); + color: var(--ui-error-text, #751b1b); + } +} + +.plan-summary { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr)); + gap: 0.75rem; + margin: 0; + + div { + display: flex; + flex-wrap: wrap; + flex-direction: column; + gap: 0.25rem; + min-width: 0; + padding: 0.875rem 1rem; + background: var(--ui-panel-bg, #f7f7fa); + border-radius: var(--ui-radius-md, 0.75rem); + } + + dt { + color: var(--ui-text-secondary, #45464f); + font-size: 0.875rem; + line-height: 1.4; + } + + dd { + order: -1; + margin: 0; + font-size: 1.5rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1.1; + } +} + +.calendar-legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1rem; + padding: 0.75rem 1rem; + background: var(--ui-panel-bg, #f7f7fa); + border-radius: var(--ui-radius-md, 0.75rem); + + strong { + font-size: 0.875rem; + } + + ul { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1rem; + margin: 0; + padding: 0; + list-style: none; + } + + li { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: var(--ui-text-secondary, #45464f); + font-size: 0.875rem; + } +} + +.legend-swatch { + display: inline-block; + flex-shrink: 0; + width: 1rem; + height: 1rem; + box-sizing: border-box; + border-radius: 0.25rem; + background: var(--ui-card-bg, #f1f1f5); + + &.weekend { + background: var(--weekend-bg, #ffca39); + } + + &.closed-day { + border: 0.125rem solid var(--closed-day-bg, #0ee3ff); + } + + &.cp { + background: var(--cp-bg, #eca0ff); + } + + &.rtt { + background: var(--rtt-bg, #99ff87); + } + + &.other { + background: var(--other-bg, #ff9b9b); + } + + &.today { + border: 0.125rem dashed var(--today-border-color, #1976d2); + } +} + +.month-grid { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-evenly; + width: 100%; + max-width: 100%; +} + +.month-card { margin: 16px; } diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index bb6b332..e41abdc 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -1,4 +1,4 @@ -import { Component, effect, signal, inject, untracked } from '@angular/core'; +import { Component, computed, effect, signal, inject, untracked } from '@angular/core'; import { UserInputService } from '../../services/user-input-service'; import { MatCard, MatCardContent } from '@angular/material/card'; import { CalendarMonth } from '../calendar-month/calendar-month'; @@ -9,10 +9,19 @@ import { RuleService } from '../../services/rule-service'; import { TranslationService } from '../../services/translation-service'; import { PlanOutcome, PlanningOutcome } from '../../services/leave-planner.service'; import { SelectableDayType, VacDay } from '../../services/calendar-service'; +import { MatIcon } from '@angular/material/icon'; +import { CalendarSettings } from '../calendar-settings/calendar-settings'; @Component({ selector: 'app-calendar-year', - imports: [MatCard, CalendarMonth, MatCardContent, MatProgressSpinner], + imports: [ + MatCard, + CalendarMonth, + MatCardContent, + MatProgressSpinner, + MatIcon, + CalendarSettings, + ], templateUrl: './calendar-year.html', styleUrl: './calendar-year.scss', }) @@ -31,6 +40,14 @@ export class CalendarYear { protected loadingProgress = signal(0); protected planningOutcome = signal(null); + protected displayedPlan = computed(() => { + const outcome = this.planningOutcome(); + return outcome?.kind === 'Plan' + ? outcome + : outcome?.kind === 'PlanningIncomplete' + ? outcome.incumbent + : undefined; + }); private calculationVersion = 0; protected hasPlannerError(): boolean { @@ -127,12 +144,7 @@ export class CalendarYear { private applyOutcome(outcome: PlanningOutcome): void { this.planningOutcome.set(outcome); - const plan = - outcome.kind === 'Plan' - ? outcome - : outcome.kind === 'PlanningIncomplete' - ? outcome.incumbent - : undefined; + const plan = this.displayedPlan(); if (plan) { this.selectedDates = this.calendarService.selectedDatesForPlan(plan); this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan)); diff --git a/src/app/components/dark-light-mode-button/dark-light-mode-button.scss b/src/app/components/dark-light-mode-button/dark-light-mode-button.scss index 716ffbb..27e9da2 100644 --- a/src/app/components/dark-light-mode-button/dark-light-mode-button.scss +++ b/src/app/components/dark-light-mode-button/dark-light-mode-button.scss @@ -2,13 +2,18 @@ button { margin: 0; padding: 0; display: flex; + flex-wrap: wrap; align-items: center; justify-content: center; border: none; border-radius: 50%; - width: 40px; - height: 40px; - transition: all 0.3s ease; + width: 2.75rem; + height: 2.75rem; + min-width: 2.75rem; + min-height: 2.75rem; + transition: + background-color 200ms ease, + color 200ms ease; &:hover { background-color: rgba(0, 0, 0, 0); diff --git a/src/app/components/rules-panel/rules-panel.html b/src/app/components/rules-panel/rules-panel.html index 80db3fe..4161592 100644 --- a/src/app/components/rules-panel/rules-panel.html +++ b/src/app/components/rules-panel/rules-panel.html @@ -1,8 +1,9 @@ - rule - {{ tr().rules.title }} + + {{ tr().rules.title }} + {{ activeRuleCount }} {{ tr().rules.active }} {{ tr().rules.subtitle }} @@ -37,6 +38,22 @@ >
+ + -

{{ tr().userInput.addVacationDay }}

- -
-
- -
- + + + @if (hasRemainingDays()) { - + - warning +
{{ @@ -49,4 +55,6 @@ } + +
diff --git a/src/app/components/user-input/user-input.scss b/src/app/components/user-input/user-input.scss index fdc74e7..f222801 100644 --- a/src/app/components/user-input/user-input.scss +++ b/src/app/components/user-input/user-input.scss @@ -1,34 +1,38 @@ .user-input { display: flex; flex-direction: column; - margin: 1rem; + gap: 1.25rem; + min-width: 0; +} - label { - font-weight: bold; - margin-bottom: 0.5rem; +.section-heading { + display: flex; + flex-direction: column; + gap: 0.375rem; + + h2, + p { + margin: 0; } - .error-message { - color: #ff0000; - font-size: 0.875rem; - margin-top: 0.25rem; + h2 { + font-size: 1.5rem; + font-weight: 700; + line-height: 1.2; + text-wrap: balance; } - .warning-card { - margin-bottom: 1rem; - } - - .warning-content { - display: flex; - align-items: center; - gap: 0.5rem; + p { + max-width: 55ch; + color: var(--ui-text-secondary, #45464f); + line-height: 1.5; } } .listOfDays { display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.75rem; } .day-input-wrapper { @@ -68,36 +72,47 @@ form { flex-direction: column; } -.add-day-card { - display: flex; - flex-direction: row; - align-items: center; - padding: 16px; - gap: 1rem; - margin-bottom: 1rem; - cursor: pointer; +.add-day-button, +.calculate-button { + width: 100%; + min-height: 3rem; } -.add-day-card h3 { - flex-grow: 1; - margin: 0; - font-weight: normal; +.add-day-button { + display: flex; + justify-content: flex-start; } -.action-buttons { +.calculate-button { + min-height: 3.25rem; + font-weight: 600; +} + +.warning-card { + background: var(--ui-warning-bg, #fff4d6); + color: var(--ui-warning-text, #4f3800); +} + +.warning-content { display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; - gap: 1rem; + align-items: flex-start; + gap: 0.75rem; + + mat-icon { + flex-shrink: 0; + } } .warning { display: flex; flex-direction: column; + gap: 0.25rem; + min-width: 0; + overflow-wrap: break-word; } :host { + display: block; + container: user-input / inline-size; max-width: 100%; } diff --git a/src/app/components/user-input/user-input.ts b/src/app/components/user-input/user-input.ts index 4df099e..5fc58a2 100644 --- a/src/app/components/user-input/user-input.ts +++ b/src/app/components/user-input/user-input.ts @@ -1,7 +1,6 @@ import { Component, computed, inject, ChangeDetectionStrategy } from '@angular/core'; import { MatInputModule } from '@angular/material/input'; import { UserInputService } from '../../services/user-input-service'; -import { CalendarSettings } from '../calendar-settings/calendar-settings'; import { RulesPanel } from '../rules-panel/rules-panel'; import { MatCard, MatCardContent } from '@angular/material/card'; import { MatIcon } from '@angular/material/icon'; @@ -14,7 +13,6 @@ import { TranslationService } from '../../services/translation-service'; selector: 'app-user-input', imports: [ MatInputModule, - CalendarSettings, RulesPanel, MatCard, MatCardContent, diff --git a/src/app/components/userInput/day-input/day-input.html b/src/app/components/userInput/day-input/day-input.html index 6e6bd5c..d477407 100644 --- a/src/app/components/userInput/day-input/day-input.html +++ b/src/app/components/userInput/day-input/day-input.html @@ -1,15 +1,18 @@ - - + + {{ tr().userInput.days }} - + + {{ tr().userInput.type }} @for (dayType of selectableDayTypes; track $index) { @@ -19,31 +22,37 @@ } - - {{ tr().userInput.availableDate }} - - - - - - {{ tr().userInput.chooseDate }} - - - - - +
+ + {{ tr().userInput.availableDate }} + + + + + + {{ tr().userInput.chooseDate }} + + + + +
- diff --git a/src/app/components/userInput/day-input/day-input.scss b/src/app/components/userInput/day-input/day-input.scss index 266d2a9..c49458a 100644 --- a/src/app/components/userInput/day-input/day-input.scss +++ b/src/app/components/userInput/day-input/day-input.scss @@ -1,72 +1,76 @@ :host { display: block; - container-type: inline-size; + container: day-input / inline-size; width: 100%; } .card { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - padding: 16px; - gap: 16px; + display: grid; + grid-template-columns: 5.25rem minmax(0, 1fr) 2.75rem; + align-items: start; + gap: 0.75rem; + min-width: 0; + padding: 1rem; + background: var(--ui-card-bg, #f1f1f5); + border: 1px solid var(--ui-border, #c7c7d0); + box-shadow: none; } -.dateInput { - flex-grow: 1; +.card mat-form-field { + width: 100%; + min-width: 0; } .numberInput { - width: 80px; + grid-column: 1; } -::ng-deep .mat-mdc-form-field-subscript-wrapper { - display: none; +.dayTypeInput { + grid-column: 2; } -@container (max-width: 480px) { +.delete-button { + grid-column: 3; + grid-row: 1; + min-width: 2.75rem; + min-height: 2.75rem; + color: var(--ui-text-secondary, #45464f); +} + +.date-fields { + grid-column: 1 / -1; + grid-row: 2; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + min-width: 0; +} + +@container day-input (max-width: 24rem) { .card { - flex-wrap: wrap; - gap: 12px; - padding: 12px; - align-items: center; + grid-template-columns: 4.75rem minmax(0, 1fr) 2.75rem; + gap: 0.5rem; + padding: 0.75rem; } - .numberInput { - width: 70px; - flex-grow: 0; + .date-fields { + grid-template-columns: minmax(0, 1fr); + gap: 0.5rem; } +} - .dayTypeInput { - flex-grow: 1; - width: 0; - min-width: 120px; +@container day-input (min-width: 52rem) { + .card { + grid-template-columns: + 5.25rem minmax(9rem, 1fr) minmax(11rem, 1fr) minmax(11rem, 1fr) + 2.75rem; } - .dateInput { - width: 100%; - flex-grow: 1; - flex-basis: 100%; - margin-top: 4px; + .date-fields { + display: contents; } - button[matFab] { - width: 40px; - height: 40px; - min-width: 40px; - line-height: 40px; - - ::ng-deep .mat-mdc-button-touch-target { - width: 40px; - height: 40px; - } - - mat-icon { - margin: 0; - display: flex; - align-items: center; - justify-content: center; - } + .delete-button { + grid-column: 5; } } diff --git a/src/app/components/userInput/day-input/day-input.spec.ts b/src/app/components/userInput/day-input/day-input.spec.ts index 206bf42..c3edb85 100644 --- a/src/app/components/userInput/day-input/day-input.spec.ts +++ b/src/app/components/userInput/day-input/day-input.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DayInput } from './day-input'; import { SelectableDayType, VacDay } from '../../../services/calendar-service'; +import { TranslationService } from '../../../services/translation-service'; describe('DayInput', () => { let component: DayInput; @@ -18,6 +19,7 @@ describe('DayInput', () => { await TestBed.configureTestingModule({ imports: [DayInput], }).compileComponents(); + TestBed.inject(TranslationService).setLanguage('en'); fixture = TestBed.createComponent(DayInput); component = fixture.componentInstance; @@ -29,6 +31,14 @@ describe('DayInput', () => { expect(component).toBeTruthy(); }); + it('labels every balance field', () => { + const text = fixture.nativeElement.textContent as string; + expect(text).toContain('Days'); + expect(text).toContain('Leave type'); + expect(text).toContain('Available from'); + expect(text).toContain('Expires on'); + }); + it('emits an availability-date change without mutating the input value', () => { const emitted: VacDay[] = []; component.dayChange.subscribe((value) => emitted.push(value)); diff --git a/src/app/services/translation-service.ts b/src/app/services/translation-service.ts index a6f02c4..331d066 100644 --- a/src/app/services/translation-service.ts +++ b/src/app/services/translation-service.ts @@ -9,6 +9,8 @@ export interface AppTranslations { description: string; }; userInput: { + title: string; + description: string; addVacationDay: string; calculate: string; remainingDaysWarning: string; @@ -16,6 +18,8 @@ export interface AppTranslations { deleteEntry: string; chooseDate: string; availableDate: string; + days: string; + type: string; daySuffix: string; daysSuffix: string; expires: string; @@ -49,6 +53,9 @@ export interface AppTranslations { rules: { title: string; subtitle: string; + active: string; + moveRuleUp: string; + moveRuleDown: string; priorityInfo: string; ifThenTemplate: string; timeWindowTemplate: string; @@ -92,6 +99,11 @@ export interface AppTranslations { }; }; planning: { + title: string; + daysPlaced: string; + daysGained: string; + longestWorkSequence: string; + legend: string; invalidInput: string; conflict: string; incomplete: string; diff --git a/src/i18n/de.json b/src/i18n/de.json index c578bb7..3973fcf 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -5,13 +5,17 @@ "description": "Optimieren Sie Ihre Urlaubstage basierend auf Feiertagen und Wochenenden!" }, "userInput": { - "addVacationDay": "Urlaubstag hinzufügen", - "calculate": "Berechnen", + "title": "Urlaub konfigurieren", + "description": "Fügen Sie jedes Urlaubskonto und seinen Gültigkeitszeitraum hinzu.", + "addVacationDay": "Urlaubskonto hinzufügen", + "calculate": "Plan berechnen", "remainingDaysWarning": "{count} nicht platziert:", "remainingDaysManualPlace": "Bitte manuell platzieren.", "deleteEntry": "Eintrag löschen", - "chooseDate": "Datum auswählen", + "chooseDate": "Gültig bis", "availableDate": "Verfügbar ab", + "days": "Tage", + "type": "Urlaubsart", "daySuffix": "Tag", "daysSuffix": "Tage", "expires": "abl.", @@ -43,8 +47,11 @@ "selectLanguage": "Sprache auswählen" }, "rules": { - "title": "Regeln", + "title": "Planungsregeln", "subtitle": "Urlaubsregeln konfigurieren", + "active": "aktiv", + "moveRuleUp": "Regel nach oben verschieben", + "moveRuleDown": "Regel nach unten verschieben", "priorityInfo": "Regeln werden von oben nach unten angewendet", "ifThenTemplate": "Wenn {type} dann {placement}", "timeWindowTemplate": "Zeitfenster {startDate} bis {endDate}", @@ -88,6 +95,11 @@ } }, "planning": { + "title": "Ihr optimiertes Jahr", + "daysPlaced": "Eingeplante Urlaubstage", + "daysGained": "Gewonnene arbeitsfreie Tage", + "longestWorkSequence": "Längste Arbeitsphase", + "legend": "Kalenderlegende", "invalidInput": "Die Planungsdaten sind ungültig.", "conflict": "Die Verpflichtungen können nicht gemeinsam erfüllt werden.", "incomplete": "Die Berechnung wurde vor Abschluss aller Qualitätsstufen beendet.", diff --git a/src/i18n/en.json b/src/i18n/en.json index 047c269..a592641 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -5,13 +5,17 @@ "description": "Optimize your vacation days based on holidays and weekends!" }, "userInput": { - "addVacationDay": "Add a vacation day", - "calculate": "Calculate", + "title": "Configure your leave", + "description": "Add each leave balance and its validity period.", + "addVacationDay": "Add leave balance", + "calculate": "Calculate plan", "remainingDaysWarning": "{count} vacation day{plural} not placed:", "remainingDaysManualPlace": "Please place them manually.", "deleteEntry": "Delete entry", - "chooseDate": "Choose a date", + "chooseDate": "Expires on", "availableDate": "Available from", + "days": "Days", + "type": "Leave type", "daySuffix": "day", "daysSuffix": "days", "expires": "exp.", @@ -43,8 +47,11 @@ "selectLanguage": "Language" }, "rules": { - "title": "Custom Rules", + "title": "Planning rules", "subtitle": "Manage optimization rules", + "active": "active", + "moveRuleUp": "Move rule up", + "moveRuleDown": "Move rule down", "priorityInfo": "The order of rules defines their execution priority (from top to bottom). Drag and drop to reorder.", "ifThenTemplate": "If a holiday falls on a {day}, place {placeDays} day(s) {placeType} {placement} and get {rewardDays} day(s) {rewardType} as a bonus.", "timeWindowTemplate": "Place {placeDays} day(s) {placeType} between {startDate} and {endDate}.", @@ -88,6 +95,11 @@ } }, "planning": { + "title": "Your optimized year", + "daysPlaced": "Leave days placed", + "daysGained": "Non-working days gained", + "longestWorkSequence": "Longest work sequence", + "legend": "Calendar legend", "invalidInput": "The planning input is invalid.", "conflict": "The obligations cannot be satisfied together.", "incomplete": "Calculation stopped before all quality stages completed.", diff --git a/src/i18n/es.json b/src/i18n/es.json index aa856fc..fd06727 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -5,13 +5,17 @@ "description": "¡Optimice sus días de vacaciones según los días festivos y los fines de semana!" }, "userInput": { - "addVacationDay": "Añadir un día de vacaciones", - "calculate": "Calcular", + "title": "Configura tus vacaciones", + "description": "Añade cada saldo de vacaciones y su periodo de validez.", + "addVacationDay": "Añadir saldo de vacaciones", + "calculate": "Calcular plan", "remainingDaysWarning": "{count} día{plural} de vacaciones no colocado{plural}:", "remainingDaysManualPlace": "Por favor, colóquelos manualmente.", "deleteEntry": "Eliminar entrada", - "chooseDate": "Elija una fecha", + "chooseDate": "Caduca el", "availableDate": "Disponible desde", + "days": "Días", + "type": "Tipo de permiso", "daySuffix": "día", "daysSuffix": "días", "expires": "exp.", @@ -43,8 +47,11 @@ "selectLanguage": "Idioma" }, "rules": { - "title": "Reglas personalizadas", + "title": "Reglas de planificación", "subtitle": "Gestionar reglas de optimización", + "active": "activas", + "moveRuleUp": "Subir regla", + "moveRuleDown": "Bajar regla", "priorityInfo": "El orden de las reglas define su prioridad de ejecución (de arriba a abajo). Arrastre y suelte para reorganizar.", "ifThenTemplate": "Si un día festivo cae en {day}, colocar {placeDays} día(s) {placeType} {placement} y obtener {rewardDays} día(s) {rewardType} de bonificación.", "timeWindowTemplate": "Colocar {placeDays} día(s) {placeType} entre el {startDate} y el {endDate}.", @@ -88,6 +95,11 @@ } }, "planning": { + "title": "Tu año optimizado", + "daysPlaced": "Días de vacaciones asignados", + "daysGained": "Días no laborables ganados", + "longestWorkSequence": "Período de trabajo más largo", + "legend": "Leyenda del calendario", "invalidInput": "Los datos de planificación no son válidos.", "conflict": "Las obligaciones no se pueden satisfacer juntas.", "incomplete": "El cálculo se detuvo antes de completar todas las etapas de calidad.", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 7552702..3c60b5e 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -5,13 +5,17 @@ "description": "Optimisez vos jours de vacances en fonction des jours fériés et des week-ends !" }, "userInput": { - "addVacationDay": "Ajouter un jour de vacances", - "calculate": "Calculer", + "title": "Configurez vos congés", + "description": "Ajoutez chaque solde de congés et sa période de validité.", + "addVacationDay": "Ajouter un solde de congés", + "calculate": "Calculer le planning", "remainingDaysWarning": "{count} jour{plural} de vacances non placé{plural}:", "remainingDaysManualPlace": "Veuillez les placer manuellement.", "deleteEntry": "Supprimer l’entrée", - "chooseDate": "Choisissez une date", + "chooseDate": "Expire le", "availableDate": "Disponible à partir du", + "days": "Jours", + "type": "Type de congé", "daySuffix": "jour", "daysSuffix": "jours", "expires": "exp.", @@ -43,8 +47,11 @@ "selectLanguage": "Langue" }, "rules": { - "title": "Règles Personnalisées", + "title": "Règles de planification", "subtitle": "Gérer les règles d'optimisation", + "active": "actives", + "moveRuleUp": "Monter la règle", + "moveRuleDown": "Descendre la règle", "priorityInfo": "L'ordre des règles définit leur priorité d'exécution (de haut en bas). Glissez-déposez pour réorganiser.", "ifThenTemplate": "Si un jour férié tombe un {day}, placer {placeDays} jour(s) {placeType} {placement} et obtenir {rewardDays} jour(s) {rewardType} en bonus.", "timeWindowTemplate": "Placer {placeDays} jour(s) {placeType} entre le {startDate} et le {endDate}.", @@ -88,6 +95,11 @@ } }, "planning": { + "title": "Votre année optimisée", + "daysPlaced": "Jours de congé placés", + "daysGained": "Jours non travaillés gagnés", + "longestWorkSequence": "Plus longue période travaillée", + "legend": "Légende du calendrier", "invalidInput": "Les données de planification sont invalides.", "conflict": "Les obligations ne peuvent pas être satisfaites ensemble.", "incomplete": "Le calcul s’est arrêté avant la fin des critères de qualité.", diff --git a/src/styles.scss b/src/styles.scss index 137d2b2..982b4b0 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -5,6 +5,8 @@ // custom components at https://material.angular.dev/guide/theming @use '@angular/material' as mat; +@layer reset, base, layout, utilities, blocks, exceptions; + html { @include mat.theme( ( @@ -18,77 +20,116 @@ html { ); } -:root { - --number-bg-hover: #e0e0e0; - --number-color: black; - --number-color-hover: black; - --past-color: #b0b0b0; - --week-number-bg: #e0e0e0; - --week-number-bg-hover: #c0c0c0; - --week-number-color: black; - - --today-border-color: #1976d2; - --other-month-color: #9e9e9e; - --weekend-bg: #ffca39; - --closed-day-bg: #0ee3ff; - --rtt-bg: #99ff87; - --cp-bg: #eca0ff; - --other-bg: #ff9b9b; - --loading-overlay-bg: rgba(255, 255, 255, 0.9); - --loading-text-color: #333; +@layer reset { + *, + *::before, + *::after { + box-sizing: border-box; + } } -.dark-mode { - --number-bg-hover: #424242; - --number-color: white; - --number-color-hover: white; - --past-color: #757575; - --week-number-bg: #424242; - --week-number-bg-hover: #616161; - --week-number-color: white; +@layer base { + :root { + --ui-panel-bg: var(--mat-sys-surface-container-low, #f7f7fa); + --ui-card-bg: var(--mat-sys-surface-container, #f1f1f5); + --ui-border: var(--mat-sys-outline-variant, #c7c7d0); + --ui-text-secondary: var(--mat-sys-on-surface-variant, #45464f); + --ui-radius-sm: 0.5rem; + --ui-radius-md: 0.75rem; + --ui-radius-lg: 1rem; - --today-border-color: #0b385c; - --other-month-color: #757575; - --weekend-bg: #b17927; - --closed-day-bg: #098a9b; - --rtt-bg: #468149; - --cp-bg: #703e79; - --other-bg: #9b3939; - --loading-overlay-bg: rgba(0, 0, 0, 0.7); - --loading-text-color: #ddd; -} + --ui-success-bg: oklch(92% 0.05 145); + --ui-success-text: oklch(31% 0.1 145); + --ui-warning-bg: oklch(93% 0.06 82); + --ui-warning-text: oklch(31% 0.08 75); + --ui-error-bg: oklch(93% 0.05 25); + --ui-error-text: oklch(32% 0.12 25); -body { - // Default the application to a light color theme. This can be changed to - // `dark` to enable the dark color theme, or to `light dark` to defer to the - // user's system settings. - &.dark-mode { - color-scheme: dark; + --number-bg-hover: #e0e0e0; + --number-color: black; + --number-color-hover: black; + --past-color: #b0b0b0; + --week-number-bg: #e0e0e0; + --week-number-bg-hover: #c0c0c0; + --week-number-color: black; + --today-border-color: #1976d2; + --other-month-color: #9e9e9e; + --weekend-bg: #ffca39; + --closed-day-bg: #0ee3ff; + --rtt-bg: #99ff87; + --cp-bg: #eca0ff; + --other-bg: #ff9b9b; + --loading-overlay-bg: rgba(255, 255, 255, 0.92); + --loading-text-color: #333; } - &.light-mode { - color-scheme: light; + .dark-mode { + --ui-success-bg: oklch(30% 0.06 145); + --ui-success-text: oklch(90% 0.07 145); + --ui-warning-bg: oklch(31% 0.06 82); + --ui-warning-text: oklch(91% 0.07 82); + --ui-error-bg: oklch(30% 0.07 25); + --ui-error-text: oklch(90% 0.07 25); + + --number-bg-hover: #424242; + --number-color: white; + --number-color-hover: white; + --past-color: #757575; + --week-number-bg: #424242; + --week-number-bg-hover: #616161; + --week-number-color: white; + --today-border-color: #0b385c; + --other-month-color: #757575; + --weekend-bg: #b17927; + --closed-day-bg: #098a9b; + --rtt-bg: #468149; + --cp-bg: #703e79; + --other-bg: #9b3939; + --loading-overlay-bg: rgba(18, 18, 20, 0.92); + --loading-text-color: #f0f0f0; } - // Set a default background, font and text colors for the application using - // Angular Material's system-level CSS variables. Learn more about these - // variables at https://material.angular.dev/guide/system-variables - background-color: var(--mat-sys-surface); - color: var(--mat-sys-on-surface); - font: var(--mat-sys-body-medium); + html, + body { + min-height: 100%; + } - // Reset the user agent margin. - margin: 0; + html { + scrollbar-gutter: stable; + } + + body { + margin: 0; + background-color: var(--mat-sys-surface, #fff); + color: var(--mat-sys-on-surface, #1c1b1f); + font: var(--mat-sys-body-medium, 400 1rem/1.5 Roboto, sans-serif); + font-family: Roboto, 'Helvetica Neue', sans-serif; + line-height: 1.5; + + &.dark-mode { + color-scheme: dark; + } + + &.light-mode { + color-scheme: light; + } + } + + :where(button, a, input, select, textarea, [tabindex]):focus-visible { + outline: 0.1875rem solid var(--mat-sys-primary, #005cbb); + outline-offset: 0.125rem; + } } -/* You can add global styles to this file, and also import other style files */ - -html, -body { - height: 100%; -} - -body { - margin: 0; - font-family: Roboto, 'Helvetica Neue', sans-serif; +@layer exceptions { + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } + } }