mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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.
317 lines
9.5 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
}
|
|
}
|