Compare commits

..

5 Commits

Author SHA1 Message Date
Maurice 280e55bbc9 test(vacay): a day in a year the plan has not opened also announces the new year
The broadcast parity cases toggle a day in 2027, which the seeded plan does
not hold, so both MCP and REST now send vacay:settings for the opened year
before vacay:update.
2026-10-24 17:31:47 +02:00
Maurice e84060cdf7 refactor(vacay): the pure constants live in the helpers, so the service keeps its size budget
The holiday feeds' limits and patterns and the colour palette move to
vacay.helpers.ts, which holds the vacay domain's pure half already. That
keeps the service under its line budget after the missing-year fix.
2026-10-24 17:31:47 +02:00
Maurice fbc85b05a1 fix(vacay): a day set for a year the plan has not opened adds that year
The calendar offers only the plan's years, but a vacation day or company
holiday could be written for any date. A day set months ahead, as the
assistant did for 6 January 2027, was stored and counted yet never shown,
because 2027 was not a year of the plan. Writing a day now adds its year,
with every member's allowance, the way opening it by hand does; the year
is the writer's leave period that holds the date. This holds for the app,
MCP, plugins and the assistant alike, since they all write through here.
2026-10-24 17:31:47 +02:00
Maurice 01333c6ff3 chore(client): hold the complexity count at the notifications redesign's lower one 2026-10-24 17:31:46 +02:00
Maurice aecef03090 feat(notifications): a tile per kind, the list cut into days, and clear actions
Every notification gets a tile in the colour of what it is about (a trip, a
message, a booking, packing, a to-do, Vacay, a collection, shared photos, a
tour, an update or an alert) with the sender's face on its corner, the page
and the bell's panel group the list under Today, Yesterday, the weekday or
the date, and answering or following a notification is a proper button.
The server's UTC timestamps are read as UTC for the grouping and the time.
2026-10-24 17:31:46 +02:00
13 changed files with 487 additions and 161 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"@typescript-eslint/no-non-null-assertion": 384,
"@typescript-eslint/no-misused-promises": 849,
"no-console": 88,
"complexity": 222,
"complexity": 221,
"@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 130,
"max-params": 32,
-2
View File
@@ -72,7 +72,6 @@
"components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1,
"components/Notifications/InAppNotificationItem.tsx": 1,
"components/PDF/TripPDF.tsx": 53,
"components/Packing/PackingListPanelBagModal.tsx": 2,
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
@@ -295,7 +294,6 @@
"pages/FilesPage.tsx": 11,
"pages/ForgotPasswordPage.tsx": 18,
"pages/HelpPage.tsx": 21,
"pages/InAppNotificationsPage.tsx": 2,
"pages/JoinTripPage.tsx": 4,
"pages/JourneyDetailPage.tsx": 53,
"pages/JourneyPage.tsx": 64,
@@ -5,7 +5,7 @@ import { Bell, Trash2, CheckCheck } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
import { useAuthStore } from '../../store/authStore'
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
import NotificationDayList from '../Notifications/NotificationDayList'
import ConfirmDialog from '../shared/ConfirmDialog'
export default function InAppNotificationBell(): React.ReactElement {
@@ -73,9 +73,9 @@ export default function InAppNotificationBell(): React.ReactElement {
position: 'fixed',
top: 'var(--nav-h)',
insetInlineEnd: 8,
width: 380,
width: 400,
maxWidth: 'calc(100vw - 16px)',
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
maxHeight: 'min(560px, calc(100vh - var(--nav-h) - 16px))',
zIndex: 9999,
display: 'flex',
flexDirection: 'column',
@@ -119,7 +119,7 @@ export default function InAppNotificationBell(): React.ReactElement {
</div>
{/* Notification list */}
<div className="overflow-y-auto flex-1 py-1 border-t border-edge-secondary">
<div className="overflow-y-auto flex-1 pb-1 border-t border-edge-secondary">
{isLoading && notifications.length === 0 ? (
<div className="flex items-center justify-center py-10">
<div className="w-5 h-5 border-2 rounded-full animate-spin border-edge border-t-content" />
@@ -133,9 +133,7 @@ export default function InAppNotificationBell(): React.ReactElement {
<p className="text-caption text-content-muted">{t('notifications.emptyDescription')}</p>
</div>
) : (
notifications.slice(0, 10).map(n => (
<InAppNotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />
))
<NotificationDayList notifications={notifications.slice(0, 10)} onClose={() => setOpen(false)} compact />
)}
</div>
@@ -1,29 +1,53 @@
import React from 'react'
import { User, Check, X, ArrowRight, Trash2, CheckCheck, Mail } from 'lucide-react'
import { Check, X, ArrowRight, Trash2, CheckCheck, Mail } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { InAppNotification } from '../../store/inAppNotificationStore'
import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
function relativeTime(dateStr: string, locale: string): string {
return compactTime(dateStr, locale === 'ar' ? 'الآن' : 'just now')
}
import { KIND_LOOK, createdAt, kindOf } from './notificationModel'
interface NotificationItemProps {
notification: InAppNotification
onClose?: () => void
}
/** The round tool beside a notification, quiet until it is pointed at. */
const TOOL = 'grid place-items-center w-7 h-7 rounded-lg transition-colors text-content-faint hover:text-content hover:bg-surface-card focus-visible:bg-surface-card focus-visible:text-content'
/** The quiet tool beside a notification, forward only when it is pointed at. */
const TOOL = 'grid place-items-center w-7 h-7 rounded-lg transition-colors text-content-faint hover:text-content hover:bg-surface-secondary focus-visible:bg-surface-secondary focus-visible:text-content'
/** An answer or a way on, as a pill under the words. */
const PILL = 'inline-flex items-center gap-1.5 h-7 px-3 rounded-full text-caption font-medium transition-colors'
/** An answer or a way on, as a button under the words. */
const ACTION = 'inline-flex items-center gap-1.5 h-8 px-3 rounded-lg text-caption font-semibold transition-colors'
/** Under an hour it reads "5m"; after that the clock time, since the list is headed by its day. */
function timeOf(value: string, locale: string, justNow: string): string {
const date = createdAt(value)
if (Date.now() - date.getTime() < 3_600_000) return compactTime(date.toISOString(), justNow)
return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)
}
/** The tile on the start side: the kind's symbol on its colour, and who sent it as a small face on its corner. */
function KindTile({ notification }: { notification: InAppNotification }) {
const { icon: Icon, tone } = KIND_LOOK[kindOf(notification.title_key)]
const initial = notification.sender_username?.charAt(0).toUpperCase()
return (
<span className="relative block self-start flex-shrink-0">
<span className={`grid place-items-center w-10 h-10 rounded-xl ${tone}`}>
<Icon className="w-[18px] h-[18px]" aria-hidden />
</span>
{notification.sender_avatar ? (
<img src={notification.sender_avatar} alt="" className="absolute -bottom-1 -end-1 w-5 h-5 rounded-full object-cover ring-2 ring-surface-card" />
) : initial ? (
<span aria-hidden className="absolute -bottom-1 -end-1 grid place-items-center w-5 h-5 rounded-full text-caption leading-none font-bold bg-accent text-accent-text ring-2 ring-surface-card">
{initial}
</span>
) : null}
</span>
)
}
/**
* One notification, as a card in the list: who sent it, what it says, and what
* can be done with it. Unread ones carry a dot in the accent and a faint
* ground; the tools to mark and delete come forward when the card is pointed
* at or focused, so a list reads as messages rather than as rows of buttons.
* One notification: a tile that says at a glance what it is about and who sent
* it, the message itself, and what can be done with it. Unread ones are set in
* bold with a dot in the accent; marking and deleting come forward when the row
* is pointed at or focused, so a list reads as messages rather than as buttons.
*/
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
const { t, locale } = useTranslation()
@@ -32,112 +56,96 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
const titleText = t(notification.title_key, notification.title_params)
const bodyText = t(notification.text_key, notification.text_params)
const hasUnknownTitle = titleText === notification.title_key
const hasUnknownBody = bodyText === notification.text_key
const unread = !notification.is_read
const answered = notification.response !== null
const { positive_text_key: yesKey, negative_text_key: noKey, navigate_text_key: goKey } = notification
const asks = notification.type === 'boolean' && yesKey && noKey
const leads = notification.type === 'navigate' && goKey && notification.navigate_target
return (
<div
className={`group relative mx-2 my-1 rounded-xl px-3 py-3 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover ${unread ? 'bg-surface-secondary' : ''}`}
>
{unread && <span aria-hidden className="absolute start-1 top-5 w-1.5 h-1.5 rounded-full bg-accent" />}
<div className="group relative flex gap-3.5 px-4 py-3.5 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover">
<KindTile notification={notification} />
<div className="flex gap-3 items-start">
{/* Sender avatar */}
<div className="flex-shrink-0">
{notification.sender_avatar ? (
<img src={notification.sender_avatar} alt="" className="w-9 h-9 rounded-xl object-cover" />
) : (
<div className="w-9 h-9 rounded-xl flex items-center justify-center text-body font-semibold text-accent bg-accent-subtle">
{notification.sender_username
? notification.sender_username.charAt(0).toUpperCase()
: <User className="w-4 h-4" />}
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>
{hasUnknownTitle ? notification.title_key : titleText}
</p>
<div className="relative flex-shrink-0 h-7 flex items-center">
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
{relativeTime(notification.created_at, locale)}
</span>
<div className="absolute end-0 top-0 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
{notification.is_read ? (
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
<Mail className="w-3.5 h-3.5" />
</button>
) : (
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={() => deleteNotification(notification.id)}
title={t('notifications.delete')}
aria-label={t('notifications.delete')}
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
>
<Trash2 className="w-3.5 h-3.5" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-3">
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>{titleText}</p>
<div className="relative flex-shrink-0 h-6 flex items-center gap-2">
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
{timeOf(notification.created_at, locale, t('common.justNow'))}
</span>
{unread && (
<span aria-hidden className="w-2 h-2 rounded-full bg-accent transition-opacity group-hover:opacity-0 group-focus-within:opacity-0" />
)}
<div className="absolute end-0 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
{notification.is_read ? (
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
<Mail className="w-3.5 h-3.5" />
</button>
</div>
) : (
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={() => deleteNotification(notification.id)}
title={t('notifications.delete')}
aria-label={t('notifications.delete')}
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
<p className="text-caption mt-0.5 leading-relaxed text-content-muted">
{hasUnknownBody ? notification.text_key : bodyText}
</p>
<p className="text-body mt-0.5 leading-relaxed text-content-muted">{bodyText}</p>
{/* Boolean actions */}
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
<div className="flex gap-2 mt-2.5">
<button
type="button"
onClick={() => handleRespond('positive')}
disabled={responding || answered}
className={`${PILL} ${
notification.response === 'positive'
? 'bg-accent text-accent-text'
: `bg-surface-card shadow-sm ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
>
<Check className="w-3.5 h-3.5" />
{t(notification.positive_text_key)}
</button>
<button
type="button"
onClick={() => handleRespond('negative')}
disabled={responding || answered}
className={`${PILL} ${
notification.response === 'negative'
? 'bg-danger text-white'
: `bg-surface-card shadow-sm ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
>
<X className="w-3.5 h-3.5" />
{t(notification.negative_text_key)}
</button>
</div>
)}
{/* Navigate action */}
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
{asks && (
<div className="flex flex-wrap gap-2 mt-3">
<button
type="button"
onClick={handleNavigate}
className={`${PILL} mt-2.5 bg-surface-card shadow-sm text-content hover:bg-accent hover:text-accent-text focus-visible:bg-accent focus-visible:text-accent-text`}
onClick={() => handleRespond('positive')}
disabled={responding || answered}
className={`${ACTION} ${
notification.response === 'positive'
? 'bg-success-soft text-success'
: answered
? 'border border-edge text-content-faint'
: 'bg-accent text-accent-text hover:bg-accent-hover'
} disabled:cursor-default`}
>
{t(notification.navigate_text_key)}
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
<Check className="w-3.5 h-3.5" />
{t(yesKey)}
</button>
)}
</div>
<button
type="button"
onClick={() => handleRespond('negative')}
disabled={responding || answered}
className={`${ACTION} ${
notification.response === 'negative'
? 'bg-danger-soft text-danger'
: answered
? 'border border-edge text-content-faint'
: 'border border-edge text-content hover:bg-surface-secondary'
} disabled:cursor-default`}
>
<X className="w-3.5 h-3.5" />
{t(noKey)}
</button>
</div>
)}
{leads && (
<button
type="button"
onClick={handleNavigate}
className={`${ACTION} mt-3 border border-edge text-content hover:bg-accent hover:text-accent-text hover:border-transparent focus-visible:bg-accent focus-visible:text-accent-text`}
>
{t(goKey)}
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
</button>
)}
</div>
</div>
)
@@ -0,0 +1,56 @@
import React, { useMemo } from 'react';
import { useTranslation } from '../../i18n';
import type { InAppNotification } from '../../store/inAppNotificationStore';
import InAppNotificationItem from './InAppNotificationItem';
import { groupByDay } from './notificationModel';
interface NotificationDayListProps {
notifications: InAppNotification[];
/** Closes the bell's panel after a notification led somewhere. */
onClose?: () => void;
/** The bell's panel: headings stick to its top and the days need no cards of their own. */
compact?: boolean;
}
/**
* The notifications cut into days, each under its heading (Today, Yesterday,
* a weekday, then the date). On the page every day is a card of its own; in
* the bell's panel the headings stay at the top while the day scrolls by.
*/
export default function NotificationDayList({
notifications,
onClose,
compact = false,
}: NotificationDayListProps): React.ReactElement {
const { locale } = useTranslation();
const groups = useMemo(() => groupByDay(notifications, locale), [notifications, locale]);
return (
<div className={compact ? '' : 'flex flex-col gap-6'}>
{groups.map((group) => (
<section key={group.key} aria-label={group.label}>
<h2
className={
compact
? 'sticky top-0 z-10 bg-surface-card px-4 pb-1.5 pt-3 text-caption font-semibold text-content-muted'
: 'mb-2 px-1 text-caption font-semibold text-content-muted'
}
>
{group.label}
</h2>
<div
className={
compact
? 'divide-y divide-edge-secondary'
: 'divide-y divide-edge-secondary overflow-hidden rounded-2xl border border-edge bg-surface-card'
}
>
{group.items.map((n) => (
<InAppNotificationItem key={n.id} notification={n} onClose={onClose} />
))}
</div>
</section>
))}
</div>
);
}
@@ -0,0 +1,91 @@
/**
* FE-NOTIF-MODEL-001 to FE-NOTIF-MODEL-007
*
* What a notification's tile shows, how the server's time is read, and how the
* list is cut into days under headings in the reader's language.
*/
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import type { InAppNotification } from '../../store/inAppNotificationStore';
import NotificationDayList from './NotificationDayList';
import { KIND_LOOK, createdAt, dayLabel, groupByDay, kindOf } from './notificationModel';
const note = (id: number, created_at: string, title_key = 'notif.trip_invite.title'): InAppNotification => ({
id,
type: 'simple',
scope: 'trip',
target: 1,
sender_id: null,
sender_username: null,
sender_avatar: null,
recipient_id: 1,
title_key,
title_params: {},
text_key: 'notifications.empty',
text_params: {},
positive_text_key: null,
negative_text_key: null,
response: null,
navigate_text_key: null,
navigate_target: null,
is_read: false,
created_at,
});
const NOW = new Date(2026, 9, 24, 15, 0);
const at = (days: number, hour = 10) => new Date(2026, 9, 24 - days, hour, 0);
describe('notificationModel', () => {
it('FE-NOTIF-MODEL-001: every known title has its kind, anything else is the plain bell', () => {
expect(kindOf('notif.collab_message.title')).toBe('message');
expect(kindOf('notif.replica_failure.title')).toBe('alert');
expect(kindOf('plugin.something.title')).toBe('other');
for (const look of Object.values(KIND_LOOK)) expect(look.tone).toMatch(/^bg-/);
});
it("FE-NOTIF-MODEL-002: the server's UTC text is read as UTC, an ISO string as it is", () => {
expect(createdAt('2026-10-24 08:30:00').toISOString()).toBe('2026-10-24T08:30:00.000Z');
expect(createdAt('2026-10-24T08:30:00.000Z').toISOString()).toBe('2026-10-24T08:30:00.000Z');
});
it('FE-NOTIF-MODEL-003: today and yesterday by name, the week by weekday, older by date', () => {
expect(dayLabel(at(0), 'en', NOW)).toBe('Today');
expect(dayLabel(at(1), 'en', NOW)).toBe('Yesterday');
expect(dayLabel(at(3), 'en', NOW)).toBe('Wednesday');
expect(dayLabel(at(20), 'en', NOW)).toBe('October 4');
expect(dayLabel(new Date(2025, 11, 31), 'en', NOW)).toBe('December 31, 2025');
});
it('FE-NOTIF-MODEL-004: the headings follow the language', () => {
expect(dayLabel(at(0), 'de', NOW)).toBe('Heute');
expect(dayLabel(at(1), 'de', NOW)).toBe('Gestern');
});
it('FE-NOTIF-MODEL-005: the list is cut into days in the order it came', () => {
const groups = groupByDay(
[note(1, at(0, 12).toISOString()), note(2, at(0, 9).toISOString()), note(3, at(2).toISOString())],
'en',
NOW
);
expect(groups.map((g) => [g.label, g.items.map((n) => n.id)])).toEqual([
['Today', [1, 2]],
['Thursday', [3]],
]);
});
it('FE-NOTIF-MODEL-006: an empty list has no days', () => {
expect(groupByDay([], 'en', NOW)).toEqual([]);
});
});
describe('NotificationDayList', () => {
it('FE-NOTIF-MODEL-007: each day is a section under its heading', () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 12);
render(<NotificationDayList notifications={[note(1, now.toISOString()), note(2, yesterday.toISOString())]} />);
expect(screen.getByRole('heading', { name: 'Today' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Yesterday' })).toBeInTheDocument();
expect(screen.getAllByRole('region')).toHaveLength(2);
});
});
@@ -0,0 +1,119 @@
import type { LucideIcon } from 'lucide-react';
import {
Backpack,
Bell,
BookmarkPlus,
CalendarDays,
Images,
ListChecks,
MessageCircle,
Mountain,
Plane,
Sparkles,
Ticket,
TriangleAlert,
} from 'lucide-react';
import type { InAppNotification } from '../../store/inAppNotificationStore';
/** What a notification is about, read from its title key: it picks the symbol and colour of its tile. */
export type NotificationKind =
| 'trip'
| 'message'
| 'booking'
| 'packing'
| 'todo'
| 'vacay'
| 'collection'
| 'photos'
| 'tour'
| 'update'
| 'alert'
| 'other';
const KIND_BY_TITLE: Record<string, NotificationKind> = {
'notif.trip_invite.title': 'trip',
'notif.trip_reminder.title': 'trip',
'notif.collab_message.title': 'message',
'notif.booking_change.title': 'booking',
'notif.packing_tagged.title': 'packing',
'notif.todo_due.title': 'todo',
'notif.vacay_invite.title': 'vacay',
'notif.vacay_share.title': 'vacay',
'notif.collection_invite.title': 'collection',
'notif.photos_shared.title': 'photos',
'notif.wanderer_source_hint.title': 'tour',
'notif.paddle_region_requested.title': 'tour',
'notif.version_available.title': 'update',
'notifications.versionAvailable.title': 'update',
'notif.replica_failure.title': 'alert',
'notifications.synologySessionCleared.title': 'alert',
};
export function kindOf(titleKey: string): NotificationKind {
return KIND_BY_TITLE[titleKey] ?? 'other';
}
/** The symbol of each kind and the tinted tile it sits on. */
export const KIND_LOOK: Record<NotificationKind, { icon: LucideIcon; tone: string }> = {
trip: { icon: Plane, tone: 'bg-info-soft text-info' },
message: { icon: MessageCircle, tone: 'bg-success-soft text-success' },
booking: { icon: Ticket, tone: 'bg-warning-soft text-warning' },
packing: { icon: Backpack, tone: 'bg-warning-soft text-warning' },
todo: { icon: ListChecks, tone: 'bg-success-soft text-success' },
vacay: { icon: CalendarDays, tone: 'bg-info-soft text-info' },
collection: { icon: BookmarkPlus, tone: 'bg-accent-subtle text-accent' },
photos: { icon: Images, tone: 'bg-warning-soft text-warning' },
tour: { icon: Mountain, tone: 'bg-success-soft text-success' },
update: { icon: Sparkles, tone: 'bg-accent-subtle text-accent' },
alert: { icon: TriangleAlert, tone: 'bg-danger-soft text-danger' },
other: { icon: Bell, tone: 'bg-surface-secondary text-content-secondary' },
};
/**
* When a notification was made. The server writes `YYYY-MM-DD HH:MM:SS` in UTC,
* which a bare `new Date()` would read as local time.
*/
export function createdAt(value: string): Date {
return new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(value) ? `${value.replace(' ', 'T')}Z` : value);
}
const dayStart = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
/**
* The heading of the day a notification belongs to, in the reader's language:
* today and yesterday by name, the rest of the week by weekday, anything older
* by its date (with the year when it is not this one).
*/
export function dayLabel(date: Date, locale: string, now: Date = new Date()): string {
const days = Math.round((dayStart(now) - dayStart(date)) / 86_400_000);
if (days <= 1) {
const word = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-Math.max(days, 0), 'day');
return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);
}
if (days < 7) return new Intl.DateTimeFormat(locale, { weekday: 'long' }).format(date);
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'long',
...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
}).format(date);
}
export interface NotificationGroup {
key: string;
label: string;
items: InAppNotification[];
}
/** The list cut into days, in the order it came (newest first). */
export function groupByDay(list: InAppNotification[], locale: string, now: Date = new Date()): NotificationGroup[] {
const groups: NotificationGroup[] = [];
for (const n of list) {
const date = createdAt(n.created_at);
const key = String(dayStart(date));
const current = groups[groups.length - 1];
if (current?.key === key) current.items.push(n);
else groups.push({ key, label: dayLabel(date, locale, now), items: [n] });
}
return groups;
}
+18 -20
View File
@@ -6,7 +6,7 @@ import HelpAnchor from '../components/Help/HelpAnchor'
import EmptyState from '../components/shared/EmptyState'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import { Spinner } from '../components/shared/Spinner'
import InAppNotificationItem from '../components/Notifications/InAppNotificationItem.tsx'
import NotificationDayList from '../components/Notifications/NotificationDayList'
import { useInAppNotifications } from './inAppNotifications/useInAppNotifications'
export default function InAppNotificationsPage(): React.ReactElement {
@@ -88,27 +88,25 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
</button>
</div>
{/* Notification list */}
<div className="rounded-2xl border overflow-hidden border-edge bg-surface-card py-1">
{isLoading && displayed.length === 0 ? (
<div className="flex items-center justify-center py-16">
<Spinner className="w-6 h-6 border-2 border-slate-200 border-t-current" />
</div>
) : displayed.length === 0 ? (
{/* The list, one card per day. */}
{isLoading && displayed.length === 0 ? (
<div className="flex items-center justify-center py-16">
<Spinner className="w-6 h-6 border-2 border-edge border-t-current" />
</div>
) : displayed.length === 0 ? (
<div className="rounded-2xl border border-edge bg-surface-card">
<EmptyState scene="notifications" title={t('notifications.empty')} />
) : (
displayed.map(n => (
<InAppNotificationItem key={n.id} notification={n} />
))
)}
</div>
) : (
<NotificationDayList notifications={displayed} />
)}
{/* Infinite scroll trigger */}
{hasMore && (
<div ref={loaderRef} className="flex items-center justify-center py-4">
{isLoading && <Spinner className="w-5 h-5 border-2 border-slate-200 border-t-current" />}
</div>
)}
</div>
{/* Infinite scroll trigger */}
{hasMore && (
<div ref={loaderRef} className="flex items-center justify-center py-4">
{isLoading && <Spinner className="w-5 h-5 border-2 border-edge border-t-current" />}
</div>
)}
</div>
<ConfirmDialog
isOpen={confirmingDeleteAll}
+1 -1
View File
@@ -17,5 +17,5 @@
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
"src/nest/reservations/reservations.service.ts": 1237,
"src/nest/trips/trips.service.ts": 1006,
"src/nest/vacay/vacay.service.ts": 1416
"src/nest/vacay/vacay.service.ts": 1412
}
+20
View File
@@ -69,3 +69,23 @@ export function shiftIsoDate(isoDate: string, days: number): string {
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
export const CACHE_TTL = 24 * 60 * 60 * 1000;
export const FETCH_TIMEOUT_MS = 10_000;
// A country's holidays for one year are a few kilobytes; two megabytes means the
// provider is misbehaving and we would rather report the usual error than buffer it.
export const MAX_HOLIDAY_BYTES = 2 * 1024 * 1024;
// Both providers key on a 4-digit year and an ISO 3166-1 alpha-2 code. Anything
// else has no business reaching the URL path.
export const YEAR_RE = /^\d{4}$/;
export const COUNTRY_RE = /^[A-Za-z]{2}$/;
// ---------------------------------------------------------------------------
// Color palette for auto-assign
// ---------------------------------------------------------------------------
export const COLORS = [
'#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444',
'#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7',
'#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488',
];
+18 -23
View File
@@ -30,7 +30,7 @@ import { SchoolHolidayRegions } from '../../db/entities/SchoolHolidayRegions.ent
import type { SchoolHolidayRegionsRepository } from '../../db/repositories/SchoolHolidayRegions.repository';
import {
daysAlwaysInMonth, isBlockedWeekend, normalizeFraction, normalizeKind, normalizeYearType, pad2, parseAsUtcMillis,
shiftIsoDate, windowEndYear,
shiftIsoDate, windowEndYear, CACHE_TTL, COLORS, COUNTRY_RE, FETCH_TIMEOUT_MS, MAX_HOLIDAY_BYTES, YEAR_RE,
} from './vacay.helpers';
// ---------------------------------------------------------------------------
@@ -125,26 +125,6 @@ export interface VacayShare {
created_at?: string;
}
const CACHE_TTL = 24 * 60 * 60 * 1000;
const FETCH_TIMEOUT_MS = 10_000;
// A country's holidays for one year are a few kilobytes; two megabytes means the
// provider is misbehaving and we would rather report the usual error than buffer it.
const MAX_HOLIDAY_BYTES = 2 * 1024 * 1024;
// Both providers key on a 4-digit year and an ISO 3166-1 alpha-2 code. Anything
// else has no business reaching the URL path.
const YEAR_RE = /^\d{4}$/;
const COUNTRY_RE = /^[A-Za-z]{2}$/;
// ---------------------------------------------------------------------------
// Color palette for auto-assign
// ---------------------------------------------------------------------------
const COLORS = [
'#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444',
'#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7',
'#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488',
];
/**
* Vacay domain service — owns the vacay SQL, now through the ten vacay
* repositories (+ `SchoolHolidayRegionsRepository`'s cross-domain read,
@@ -1150,16 +1130,29 @@ export class VacayService {
// Removing a stray entry on a blocked day stays possible; keeping one
// there (converted in place) does not.
if (weekendBlocked) return { error: 'weekend_blocked' };
await this.entries.updateFractionKind(existing.id, frac, knd);
await this.uow.transactional(async () => {
await this.entries.updateFractionKind(existing.id, frac, knd);
await this.ensureYearOf(planId, userId, date);
});
await this.notifyPlanUsers(planId, socketId);
return { action: 'updated', fraction: frac, kind: knd };
}
if (weekendBlocked) return { error: 'weekend_blocked' };
await this.entries.insertEntry(planId, userId, date, '', frac, knd);
await this.uow.transactional(async () => {
await this.entries.insertEntry(planId, userId, date, '', frac, knd);
await this.ensureYearOf(planId, userId, date);
});
await this.notifyPlanUsers(planId, socketId);
return { action: 'added', fraction: frac, kind: knd };
}
/** A day written for a year the plan has not opened adds that year, or the calendar never shows it (#737 picks the period). */
private async ensureYearOf(planId: number, userId: number, date: string): Promise<void> {
const [y, m, d] = date.split('-').map(Number);
const year = await this.currentPeriodYear(userId, new Date(y, m - 1, d));
if (!(await this.years.exists(planId, year))) await this.addYear(planId, year, undefined);
}
/**
* A company holiday on or off, whole or half (#2439). The same click again clears
* it; the other size converts it in place, the way a vacation entry toggles. A
@@ -1179,6 +1172,8 @@ export class VacayService {
await this.companyHolidays.insertHoliday(planId, date, note || '', frac); // VC120
}
await this.makeRoomForCompanyHoliday(planId, date, frac);
const owner = await this.plans.findOwnerId(planId);
if (owner) await this.ensureYearOf(planId, owner.owner_id, date);
return { action: existing ? 'updated' : 'added', fraction: frac };
});
await this.notifyPlanUsers(planId, socketId);
@@ -602,14 +602,22 @@ const vacayCases: ParityCase[] = [
needs: ['vacay'],
args: () => ({ date: '2027-06-01' }),
rest: { method: 'post', path: () => `${VACAY}/entries/toggle`, body: () => ({ date: '2027-06-01' }) },
expect: [{ event: 'vacay:update', user: 'actor' }],
// 2027 is not a year of the plan yet, so the day opens it too.
expect: [
{ event: 'vacay:settings', user: 'actor' },
{ event: 'vacay:update', user: 'actor' },
],
},
{
tool: 'toggle_company_holiday',
needs: ['vacay'],
args: () => ({ date: '2027-06-02' }),
rest: { method: 'post', path: () => `${VACAY}/entries/company-holiday`, body: () => ({ date: '2027-06-02' }) },
expect: [{ event: 'vacay:update', user: 'actor' }],
// 2027 is not a year of the plan yet, so the day opens it too.
expect: [
{ event: 'vacay:settings', user: 'actor' },
{ event: 'vacay:update', user: 'actor' },
],
},
{
tool: 'update_vacay_stats',
@@ -1875,3 +1875,38 @@ describe('quirk fixes', () => {
expect(await storedFields(VacayYears, { plan: plan.id, year: year + 1 }, ['id'])).toBeUndefined();
});
});
describe('a day written for a year the plan has not opened', () => {
it('VACAY-SVC-079: adds that year with the member allowance, so the day shows in the calendar', async () => {
const { user, plan } = await setupUserWithPlan();
await allowWeekends(plan.id);
expect(await svc.listYears(plan.id)).not.toContain(2031);
const result = await svc.toggleEntry(user.id, plan.id, '2031-01-06', 1, 'vacation');
expect(result.action).toBe('added');
expect(await svc.listYears(plan.id)).toContain(2031);
expect(await storedRow(VacayUserYears, { user: user.id, plan: plan.id, year: 2031 })).toBeDefined();
});
it('VACAY-SVC-080: taking the day away again keeps the year it opened', async () => {
const { user, plan } = await setupUserWithPlan();
await allowWeekends(plan.id);
await svc.toggleEntry(user.id, plan.id, '2032-03-02', 1, 'vacation');
await svc.deleteYear(plan.id, 2032, undefined);
const before = await svc.listYears(plan.id);
await svc.toggleEntry(user.id, plan.id, '2032-03-02', 1, 'vacation');
await svc.toggleEntry(user.id, plan.id, '2032-03-02', 1, 'vacation');
expect(await svc.listYears(plan.id)).toEqual([...before, 2032].sort((a, b) => a - b));
});
it('VACAY-SVC-081: a company holiday in a year not opened yet adds that year too', async () => {
const { plan } = await setupUserWithPlan();
await svc.toggleCompanyHoliday(plan.id, '2033-12-25', 'Christmas', undefined);
expect(await svc.listYears(plan.id)).toContain(2033);
});
});