Skip to content

Commit c9908ad

Browse files
author
Diogo Ferraz
committed
feat(settings): add app preferences page with persisted UI, notification, and kanban options
1 parent ff39652 commit c9908ad

7 files changed

Lines changed: 443 additions & 0 deletions

File tree

src/app/app.routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { UserProfileSecurityComponent } from './features/profile/components/user
2222
import { ProjectMembersComponent } from './features/projects/components/project-members/project-members.component';
2323
import { ActivityLogComponent } from './features/activity/components/activity-log/activity-log.component';
2424
import { managerOrAdminGuard } from './core/auth/guards/manager-or-admin.guard';
25+
import { AppSettingsComponent } from './features/settings/components/app-settings/app-settings.component';
2526

2627
export const routes: Routes = [
2728
{ path: '', component: LandingPageComponent },
@@ -37,6 +38,7 @@ export const routes: Routes = [
3738
{ path: 'calendar', component: TaskCalendarComponent, canActivate: [authGuard] },
3839
{ path: 'activity/my', component: MyActivityComponent, canActivate: [authGuard] },
3940
{ path: 'activity/log', component: ActivityLogComponent, canActivate: [authGuard, managerOrAdminGuard] },
41+
{ path: 'settings', component: AppSettingsComponent, canActivate: [authGuard] },
4042
{ path: 'profile', component: UserProfileSecurityComponent, canActivate: [authGuard] },
4143
{ path: 'admin', component: AdminDashboardComponent, canActivate: [authGuard, adminRoleGuard] },
4244
{ path: 'tasks/create', component: TaskItemCreateComponent, canActivate: [authGuard] },

src/app/core/layout/component/app-menu/app-menu.component.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export class AppMenuComponent {
4444
items: [
4545
{ label: 'My Activity', icon: 'pi pi-fw pi-history', routerLink: ['/activity/my'] },
4646
{ label: 'Activity Log', icon: 'pi pi-fw pi-database', routerLink: ['/activity/log'], visible: this.authService.hasAnyRole(['Administrator', 'ProjectManager']) },
47+
{ label: 'Settings', icon: 'pi pi-fw pi-cog', routerLink: ['/settings'] },
4748
{ label: 'Profile & Security', icon: 'pi pi-fw pi-user-edit', routerLink: ['/profile'] },
4849
{ label: 'Search & Filters', icon: 'pi pi-fw pi-search', routerLink: ['/search'] },
4950
{ label: 'Calendar', icon: 'pi pi-fw pi-calendar', routerLink: ['/calendar'] },
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { Injectable, computed, effect, signal } from '@angular/core';
2+
3+
export type UiDensity = 'comfortable' | 'compact';
4+
export type DateFormatPreference = 'medium' | 'short' | 'iso';
5+
export type TaskDialogMode = 'dialog' | 'drawer';
6+
7+
export interface AppPreferences {
8+
uiDensity: UiDensity;
9+
dateFormat: DateFormatPreference;
10+
useRelativeDates: boolean;
11+
showActivityToasts: boolean;
12+
showCalendarReminders: boolean;
13+
enableDesktopNotifications: boolean;
14+
showKanbanDragPreview: boolean;
15+
showKanbanAssigneeAvatars: boolean;
16+
taskDialogMode: TaskDialogMode;
17+
}
18+
19+
const PREFERENCES_STORAGE_KEY = 'task_management.app.preferences';
20+
21+
const DEFAULT_PREFERENCES: AppPreferences = {
22+
uiDensity: 'comfortable',
23+
dateFormat: 'medium',
24+
useRelativeDates: true,
25+
showActivityToasts: true,
26+
showCalendarReminders: true,
27+
enableDesktopNotifications: false,
28+
showKanbanDragPreview: true,
29+
showKanbanAssigneeAvatars: true,
30+
taskDialogMode: 'dialog'
31+
};
32+
33+
@Injectable({ providedIn: 'root' })
34+
export class AppPreferencesService {
35+
private readonly preferencesSignal = signal<AppPreferences>(DEFAULT_PREFERENCES);
36+
37+
readonly preferences = this.preferencesSignal.asReadonly();
38+
readonly density = computed(() => this.preferencesSignal().uiDensity);
39+
readonly dateFormat = computed(() => this.preferencesSignal().dateFormat);
40+
41+
constructor() {
42+
this.hydratePreferences();
43+
44+
effect(() => {
45+
const preferences = this.preferencesSignal();
46+
this.persistPreferences(preferences);
47+
this.applyDensityClass(preferences.uiDensity);
48+
});
49+
}
50+
51+
update(patch: Partial<AppPreferences>): void {
52+
this.preferencesSignal.update((current) => ({
53+
...current,
54+
...patch
55+
}));
56+
}
57+
58+
reset(): void {
59+
this.preferencesSignal.set({ ...DEFAULT_PREFERENCES });
60+
}
61+
62+
formatDate(value: string | Date): string {
63+
const date = typeof value === 'string' ? new Date(value) : value;
64+
if (this.dateFormat() === 'iso') {
65+
return date.toISOString().slice(0, 10);
66+
}
67+
68+
if (this.dateFormat() === 'short') {
69+
return new Intl.DateTimeFormat(undefined, {
70+
year: 'numeric',
71+
month: '2-digit',
72+
day: '2-digit'
73+
}).format(date);
74+
}
75+
76+
return new Intl.DateTimeFormat(undefined, {
77+
year: 'numeric',
78+
month: 'short',
79+
day: 'numeric'
80+
}).format(date);
81+
}
82+
83+
private hydratePreferences(): void {
84+
const rawValue = localStorage.getItem(PREFERENCES_STORAGE_KEY);
85+
if (!rawValue) {
86+
return;
87+
}
88+
89+
try {
90+
const parsed = JSON.parse(rawValue) as Partial<AppPreferences>;
91+
this.preferencesSignal.set({
92+
...DEFAULT_PREFERENCES,
93+
...parsed
94+
});
95+
} catch {
96+
this.preferencesSignal.set({ ...DEFAULT_PREFERENCES });
97+
}
98+
}
99+
100+
private persistPreferences(preferences: AppPreferences): void {
101+
localStorage.setItem(PREFERENCES_STORAGE_KEY, JSON.stringify(preferences));
102+
}
103+
104+
private applyDensityClass(density: UiDensity): void {
105+
const root = document.documentElement;
106+
root.classList.toggle('app-density-compact', density === 'compact');
107+
}
108+
}
109+
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<div class="settings-page">
2+
<p-card class="settings-header-card">
3+
<div class="settings-header">
4+
<div class="settings-header__title">
5+
<h2>Settings</h2>
6+
<p>Personalize application behavior, visual density, and workspace defaults.</p>
7+
</div>
8+
9+
<div class="settings-header__actions">
10+
<button pButton type="button" icon="pi pi-refresh" label="Reset Defaults" class="p-button-outlined" (click)="resetDefaults()"></button>
11+
</div>
12+
</div>
13+
</p-card>
14+
15+
<div class="settings-grid">
16+
<p-card header="Appearance" styleClass="settings-card">
17+
<div class="settings-form">
18+
<div class="field">
19+
<label for="themeMode">Theme</label>
20+
<p-dropdown
21+
inputId="themeMode"
22+
[options]="themeOptions"
23+
optionLabel="label"
24+
optionValue="value"
25+
[ngModel]="isDarkTheme() ? 'dark' : 'light'"
26+
[appendTo]="'body'"
27+
(onChange)="onThemeChange($event.value)">
28+
</p-dropdown>
29+
</div>
30+
31+
<div class="field">
32+
<label for="densityMode">Density</label>
33+
<p-dropdown
34+
inputId="densityMode"
35+
[options]="densityOptions"
36+
optionLabel="label"
37+
optionValue="value"
38+
[ngModel]="preferences().uiDensity"
39+
[appendTo]="'body'"
40+
(onChange)="onDensityChange($event.value)">
41+
</p-dropdown>
42+
</div>
43+
</div>
44+
</p-card>
45+
46+
<p-card header="Locale & Date" styleClass="settings-card">
47+
<div class="settings-form">
48+
<div class="field">
49+
<label for="dateFormat">Date Format</label>
50+
<p-dropdown
51+
inputId="dateFormat"
52+
[options]="dateFormatOptions"
53+
optionLabel="label"
54+
optionValue="value"
55+
[ngModel]="preferences().dateFormat"
56+
[appendTo]="'body'"
57+
(onChange)="onDateFormatChange($event.value)">
58+
</p-dropdown>
59+
</div>
60+
61+
<div class="field-inline">
62+
<span>Use relative dates</span>
63+
<p-inputSwitch
64+
[ngModel]="preferences().useRelativeDates"
65+
(ngModelChange)="onToggle('useRelativeDates', $event)">
66+
</p-inputSwitch>
67+
</div>
68+
69+
<div class="preview-line">
70+
<span>Preview:</span>
71+
<strong>{{ datePreview() }}</strong>
72+
</div>
73+
</div>
74+
</p-card>
75+
76+
<p-card header="Kanban Preferences" styleClass="settings-card">
77+
<div class="settings-form">
78+
<div class="field-inline">
79+
<span>Show drag preview ghost</span>
80+
<p-inputSwitch
81+
[ngModel]="preferences().showKanbanDragPreview"
82+
(ngModelChange)="onToggle('showKanbanDragPreview', $event)">
83+
</p-inputSwitch>
84+
</div>
85+
86+
<div class="field-inline">
87+
<span>Show assignee avatars</span>
88+
<p-inputSwitch
89+
[ngModel]="preferences().showKanbanAssigneeAvatars"
90+
(ngModelChange)="onToggle('showKanbanAssigneeAvatars', $event)">
91+
</p-inputSwitch>
92+
</div>
93+
94+
<div class="field">
95+
<label for="taskDialogMode">Task Edit Surface</label>
96+
<p-dropdown
97+
inputId="taskDialogMode"
98+
[options]="taskDialogModeOptions"
99+
optionLabel="label"
100+
optionValue="value"
101+
[ngModel]="preferences().taskDialogMode"
102+
[appendTo]="'body'"
103+
(onChange)="onTaskDialogModeChange($event.value)">
104+
</p-dropdown>
105+
</div>
106+
</div>
107+
</p-card>
108+
109+
<p-card header="Notifications" styleClass="settings-card">
110+
<div class="settings-form">
111+
<div class="field-inline">
112+
<span>Activity toasts</span>
113+
<p-inputSwitch
114+
[ngModel]="preferences().showActivityToasts"
115+
(ngModelChange)="onToggle('showActivityToasts', $event)">
116+
</p-inputSwitch>
117+
</div>
118+
119+
<div class="field-inline">
120+
<span>Calendar reminders</span>
121+
<p-inputSwitch
122+
[ngModel]="preferences().showCalendarReminders"
123+
(ngModelChange)="onToggle('showCalendarReminders', $event)">
124+
</p-inputSwitch>
125+
</div>
126+
127+
<div class="field-inline">
128+
<span>Desktop notifications</span>
129+
<p-inputSwitch
130+
[ngModel]="preferences().enableDesktopNotifications"
131+
(ngModelChange)="onToggle('enableDesktopNotifications', $event)">
132+
</p-inputSwitch>
133+
</div>
134+
</div>
135+
</p-card>
136+
</div>
137+
</div>
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
.settings-page {
2+
display: flex;
3+
flex-direction: column;
4+
gap: 2rem;
5+
}
6+
7+
.settings-header {
8+
display: flex;
9+
justify-content: space-between;
10+
align-items: flex-start;
11+
flex-wrap: wrap;
12+
gap: 1rem;
13+
}
14+
15+
.settings-header__title h2 {
16+
margin: 0;
17+
}
18+
19+
.settings-header__title p {
20+
margin: 0.3rem 0 0;
21+
color: var(--text-color-secondary);
22+
}
23+
24+
.settings-grid {
25+
display: grid;
26+
grid-template-columns: repeat(2, minmax(0, 1fr));
27+
grid-auto-rows: minmax(0, 1fr);
28+
gap: 2rem;
29+
align-items: stretch;
30+
}
31+
32+
.settings-grid > p-card {
33+
display: block;
34+
height: 100%;
35+
}
36+
37+
:host ::ng-deep .settings-card {
38+
height: 100%;
39+
}
40+
41+
:host ::ng-deep .settings-card .p-card,
42+
:host ::ng-deep .settings-card .p-card-body,
43+
:host ::ng-deep .settings-card .p-card-content {
44+
height: 100%;
45+
}
46+
47+
.settings-form {
48+
display: flex;
49+
flex-direction: column;
50+
gap: 1rem;
51+
}
52+
53+
.field {
54+
display: flex;
55+
flex-direction: column;
56+
gap: 0.35rem;
57+
}
58+
59+
.field label {
60+
font-size: 0.85rem;
61+
color: var(--text-color-secondary);
62+
}
63+
64+
.field-inline {
65+
border: 1px solid var(--surface-border);
66+
border-radius: 10px;
67+
padding: 0.7rem 0.75rem;
68+
display: flex;
69+
align-items: center;
70+
justify-content: space-between;
71+
gap: 0.6rem;
72+
}
73+
74+
.preview-line {
75+
border-radius: 10px;
76+
background: color-mix(in srgb, var(--surface-100) 60%, transparent);
77+
border: 1px solid var(--surface-border);
78+
padding: 0.65rem 0.75rem;
79+
display: inline-flex;
80+
align-items: center;
81+
gap: 0.4rem;
82+
color: var(--text-color-secondary);
83+
}
84+
85+
.preview-line strong {
86+
color: var(--text-color);
87+
}
88+
89+
@media (max-width: 992px) {
90+
.settings-grid {
91+
grid-template-columns: 1fr;
92+
grid-auto-rows: auto;
93+
gap: 1rem;
94+
}
95+
}

0 commit comments

Comments
 (0)