Files
inbuxa-admin/src/features/dashboard/components/Greeting.tsx
T
jcoffey-dev 5bf09ceed5 Dashboard: every number leads somewhere, real counts, and new charts
- Cards and charts link to the page they're about: pending messages to the
  queue, bans to blocked IPs, report warnings to the reports, and so on,
  shown only to viewers who may open that page.
- A one-line status under the greeting: what needs a look (failed tasks,
  messages retrying, recipients given up on) or, when nothing does, what's
  there. Each phrase is a link.
- Counts from the server's own objects stand in for live metrics it can't
  report, and a live number with no source reads as unknown, not zero.
- Who uses the space: a treemap of people sized by storage, colored by how
  near their quota they are, each tile opening the account.
- Where mail is waiting: queued recipients by destination, split into
  waiting, retrying and given up, each row opening the filtered queue.
- The weekly rhythm: messages by hour and weekday, shown once metric
  history exists.
- Dashboard tabs are titled by their label.
2026-09-19 01:50:07 -07:00

85 lines
2.9 KiB
TypeScript

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/stores/authStore';
import { getAccountId, jmapQueryAndGet } from '@/services/jmap/client';
import inbuxaMark from '@/assets/inbuxa-mark.png';
function partOfDay(hour: number): 'morning' | 'afternoon' | 'evening' {
if (hour < 12) return 'morning';
if (hour < 18) return 'afternoon';
return 'evening';
}
/**
* The name to greet: the account's full name where it has one, otherwise the
* name it signs in with. Looked up by the local part and matched on the whole
* address, so an account of the same name on another domain can't answer.
*/
function useFullName(username: string | null): string | null {
const [fullName, setFullName] = useState<string | null>(null);
useEffect(() => {
if (!username) return;
let live = true;
const localPart = username.split('@')[0];
jmapQueryAndGet('x:Account', getAccountId('x:Account'), { filter: { name: localPart } }, [
'description',
'emailAddress',
])
.then((responses) => {
const list =
(responses[1]?.[1] as { list?: { description?: string | null; emailAddress?: string }[] }).list ?? [];
const own = list.find((a) => a.emailAddress?.toLowerCase() === username.toLowerCase());
const name = own?.description?.trim();
if (live && name) setFullName(name);
})
.catch(() => {
/* the sign-in name stands */
});
return () => {
live = false;
};
}, [username]);
return fullName;
}
/** The dashboard's hello: to whoever is signed in, with a nod to the time of day. */
export function Greeting() {
const { t } = useTranslation();
const username = useAuthStore((s) => s.username);
const fullName = useFullName(username);
const name = fullName ?? username?.split('@')[0] ?? '';
const part = partOfDay(new Date().getHours());
const hello =
part === 'morning'
? t('greeting.morning', 'Good morning')
: part === 'afternoon'
? t('greeting.afternoon', 'Good afternoon')
: t('greeting.evening', 'Good evening');
return (
<div className="flex items-center gap-4 rounded-2xl border bg-gradient-to-br from-accent/70 via-card to-card px-5 py-4 shadow-soft">
<img src={inbuxaMark} alt="" className="h-12 w-auto drop-shadow-sm" />
<div className="min-w-0">
<h1 className="truncate text-2xl font-semibold">
{hello}
{name && `, ${name}`}
</h1>
<p className="truncate text-sm text-muted-foreground">
{username && (
<>
{t('greeting.signedInAs', 'Signed in as {{username}}', { username })}
{' · '}
</>
)}
{t('greeting.subtitle', "Here's how your mail server is doing.")}
</p>
</div>
</div>
);
}