super-productivity/src/app/features/simple-counter/habit-tracker/habit-tracker.component.ts
Johannes Millan eab30198dc fix(locale): localize remaining ISO 8601 spelled-out date names (#8987)
Under the ISO 8601 option, dateTimeLocale is the 'sv' sync sentinel, so any
spelled-out weekday/month name rendered with currentLocale() prints in Swedish
regardless of UI language. Follow-up to #9013/#9055 covering the surfaces those
PRs didn't: spelled-out names now follow the UI language (isoTextLocale), while
numeric dates and clock times keep currentLocale().

- scheduled-list-page: locale signal -> isoTextLocale ?? currentLocale (all 6
  localeDate labels are spelled-out; fixes the reproduced 'ons, 15 juli' leak)
- dialog-focus-session-edit.formatSelectedDate: weekday+month long
- habit-tracker.dateRangeLabel: month short
- scheduled-date-group.pipe: work-view group-header tooltip weekday
- worklog (formatDayStr via mapArchiveToWorklog): day-header weekday

Inlines the isoTextLocale ?? currentLocale expression (not #9055's textLocale)
so this PR shares no files with #9055 and is conflict-free / mergeable in any
order. Adds an ISO regression test to scheduled-date-group.pipe.spec and updates
mocks so the new isoTextLocale() calls resolve. Also adds docs/handover.md.
2026-07-16 00:03:42 +02:00

317 lines
9.5 KiB
TypeScript

import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import { SimpleCounter, SimpleCounterType } from '../simple-counter.model';
import { SimpleCounterService } from '../simple-counter.service';
import { DateService } from '../../../core/date/date.service';
import { T } from '../../../t.const';
import { TranslateModule } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatDialog } from '@angular/material/dialog';
import { DialogSimpleCounterEditComponent } from '../dialog-simple-counter-edit/dialog-simple-counter-edit.component';
import { DialogSimpleCounterEditSettingsComponent } from '../dialog-simple-counter-edit-settings/dialog-simple-counter-edit-settings.component';
import { DialogConfirmComponent } from '../../../ui/dialog-confirm/dialog-confirm.component';
import { EMPTY_SIMPLE_COUNTER } from '../simple-counter.const';
import { MatTooltipModule } from '@angular/material/tooltip';
import { moveItemInArray } from '../../../util/move-item-in-array';
import { dragDelayForTouch } from '../../../util/input-intent';
import { LocaleDatePipe } from 'src/app/ui/pipes/locale-date.pipe';
import { DateTimeFormatService } from 'src/app/core/date-time-format/date-time-format.service';
interface HabitDay {
str: string;
date: Date;
dow: number;
weekdayLabel?: string;
}
@Component({
selector: 'habit-tracker',
standalone: true,
imports: [
CommonModule,
TranslateModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
CdkDropList,
CdkDrag,
LocaleDatePipe,
],
templateUrl: './habit-tracker.component.html',
styleUrl: './habit-tracker.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HabitTrackerComponent {
simpleCounters = input.required<SimpleCounter[]>();
disabledSimpleCounters = input<SimpleCounter[]>([]);
private _simpleCounterService = inject(SimpleCounterService);
private _dateTimeFormatService = inject(DateTimeFormatService);
private _dateService = inject(DateService);
private _matDialog = inject(MatDialog);
// Exposed so templates can pass the reactive locale to the now-pure
// `localeDate` pipe, preserving re-render on a locale change.
readonly locale = this._dateTimeFormatService.currentLocale;
showDisabled = signal(false);
T = T;
SimpleCounterType = SimpleCounterType;
dragDelayForTouch = dragDelayForTouch;
dayOffset = signal(0);
days = computed(() => {
const days: HabitDay[] = [];
const isoTextLocale = this._dateTimeFormatService.isoTextLocale();
const weekdayFormatter = isoTextLocale
? new Intl.DateTimeFormat(isoTextLocale, { weekday: 'short' })
: null;
const today = new Date();
const offset = this.dayOffset();
for (let i = 6; i >= 0; i--) {
const d = new Date();
d.setDate(today.getDate() - i + offset);
days.push({
str: this._dateService.todayStr(d),
date: d,
dow: d.getDay(),
weekdayLabel: weekdayFormatter?.format(d),
});
}
return days;
});
prevWeek(): void {
this.dayOffset.update((offset) => offset - 7);
}
nextWeek(): void {
this.dayOffset.update((offset) => Math.min(0, offset + 7));
}
resetToToday(): void {
this.dayOffset.set(0);
}
drop(event: CdkDragDrop<SimpleCounter[]>): void {
if (event.previousIndex === event.currentIndex) {
return;
}
const counters = this.simpleCounters();
this._simpleCounterService.updateOrder(
moveItemInArray(counters, event.previousIndex, event.currentIndex).map((c) => c.id),
);
}
dateRangeLabel = computed(() => {
const days = this.days();
if (days.length === 0) return '';
const first = days[0].date;
const last = days[days.length - 1].date;
// Spelled-out `month: 'short'` name follows the UI language under the ISO
// 8601 option (the `sv` sentinel would otherwise leak Swedish). #8987 f/u.
const locale =
this._dateTimeFormatService.isoTextLocale() ??
this._dateTimeFormatService.currentLocale();
const formatOptions: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
const firstStr = first.toLocaleDateString(locale, formatOptions);
const lastStr = last.toLocaleDateString(locale, formatOptions);
return `${firstStr} - ${lastStr}`;
});
private _longPressTimer?: number;
private _isLongPress = false;
private _pendingLongPressAction?: { counter: SimpleCounter; date: string };
onCellClick(counter: SimpleCounter, date: string, dow: number): void {
if (!this.isDayEnabled(counter, dow)) {
return;
}
if (this._isLongPress) {
this._isLongPress = false;
return;
}
const currentValue = this.getVal(counter, date);
if (
counter.type === SimpleCounterType.ClickCounter ||
counter.type === SimpleCounterType.RepeatedCountdownReminder
) {
// Increment for ClickCounters on left click
const newVal = currentValue + 1;
this._simpleCounterService.setCounterForDate(counter.id, date, newVal);
} else {
// For StopWatch or others, open dialog on left click
this.openEditDialog(counter, date);
}
}
onCellContextMenu(
event: MouseEvent,
counter: SimpleCounter,
date: string,
dow: number,
): void {
event.preventDefault();
if (!this.isDayEnabled(counter, dow)) {
return;
}
this.openEditDialog(counter, date);
}
onPressStart(counter: SimpleCounter, date: string, dow: number): void {
if (!this.isDayEnabled(counter, dow)) {
return;
}
this._isLongPress = false;
this._pendingLongPressAction = undefined;
this._longPressTimer = window.setTimeout(() => {
this._isLongPress = true;
this._pendingLongPressAction = { counter, date };
}, 700); // 700ms for long press
}
onPressEnd(): void {
if (this._longPressTimer) {
window.clearTimeout(this._longPressTimer);
this._longPressTimer = undefined;
}
// If long press was triggered, open dialog on release
if (this._pendingLongPressAction) {
const { counter, date } = this._pendingLongPressAction;
this._pendingLongPressAction = undefined;
this.openEditDialog(counter, date);
}
}
openEditDialog(counter: SimpleCounter, date: string): void {
const counterCopy = {
...counter,
countOnDay: { ...counter.countOnDay },
};
this._matDialog.open(DialogSimpleCounterEditComponent, {
data: { simpleCounter: counterCopy, selectedDate: date },
restoreFocus: true,
});
}
isDayEnabled(counter: SimpleCounter, dow: number): boolean {
if (!counter.isTrackStreaks || counter.streakMode === 'weekly-frequency') {
return true;
}
// Default to 'specific-days' logic
if (!counter.streakWeekDays) {
return true;
}
return !!counter.streakWeekDays[dow];
}
isSimpleCompletion(counter: SimpleCounter): boolean {
// Simple completion: ClickCounter type with no specific goal or goal of 1
return (
counter.type === SimpleCounterType.ClickCounter &&
(!counter.streakMinValue || counter.streakMinValue === 1)
);
}
getVal(counter: SimpleCounter, day: string): number {
return counter.countOnDay?.[day] ?? 0;
}
getDisplayValue(counter: SimpleCounter, day: string): string {
const value = this.getVal(counter, day);
if (value === 0) return '';
// For simple completion, just show checkmark (handled in template)
if (this.isSimpleCompletion(counter)) {
return '';
}
// For StopWatch, show time
if (counter.type === SimpleCounterType.StopWatch) {
// Convert ms to minutes for display
const minutes = Math.round(value / 60000);
if (minutes < 60) {
return `${minutes}m`;
} else {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
}
}
// For ClickCounter with specific values, show the count
return value.toString();
}
getProgress(counter: SimpleCounter, day: string): number {
const value = this.getVal(counter, day);
if (value === 0) return 0;
const goal = counter.streakMinValue || 1;
return Math.min(100, (value / goal) * 100);
}
addHabit(): void {
const newHabit = {
...EMPTY_SIMPLE_COUNTER,
isEnabled: true,
};
this._matDialog.open(DialogSimpleCounterEditSettingsComponent, {
data: { simpleCounter: newHabit },
restoreFocus: true,
width: '600px',
});
}
openEditSettings(counter: SimpleCounter): void {
const counterCopy = {
...counter,
countOnDay: { ...counter.countOnDay },
};
this._matDialog.open(DialogSimpleCounterEditSettingsComponent, {
data: { simpleCounter: counterCopy },
restoreFocus: true,
width: '600px',
});
}
enableHabit(id: string): void {
this._simpleCounterService.updateSimpleCounter(id, { isEnabled: true });
}
deleteHabit(counter: SimpleCounter): void {
this._matDialog
.open(DialogConfirmComponent, {
restoreFocus: true,
data: {
message: T.F.SIMPLE_COUNTER.D_CONFIRM_REMOVE.MSG,
okTxt: T.F.SIMPLE_COUNTER.D_CONFIRM_REMOVE.OK,
},
})
.afterClosed()
.subscribe((confirmed: boolean) => {
if (confirmed) {
this._simpleCounterService.deleteSimpleCounter(counter.id);
}
});
}
}