feat(frontend): analytics shows minutes online by hour of week and by day, sessions with start and end, says when last seen is hidden, chat status is live and in russian
This commit is contained in:
1 parent
f44597b2dc
commit
73b8903de1
11 files changed
+1275
-373
No files matched your search
@@ -9,19 +9,25 @@ const OPTIONS = {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
},
|
||||
longDay: { day: "numeric", month: "long", weekday: "long" },
|
||||
offset: { timeZoneName: "shortOffset" },
|
||||
time: { hour: "2-digit", minute: "2-digit" },
|
||||
} satisfies Record<string, Intl.DateTimeFormatOptions>;
|
||||
|
||||
const formatters = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function format(kind: keyof typeof OPTIONS, date: Date): string {
|
||||
function formatter(kind: keyof typeof OPTIONS): Intl.DateTimeFormat {
|
||||
const key = `${locale.tag}:${kind}`;
|
||||
let formatter = formatters.get(key);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat(locale.tag, OPTIONS[kind]);
|
||||
formatters.set(key, formatter);
|
||||
let cached = formatters.get(key);
|
||||
if (!cached) {
|
||||
cached = new Intl.DateTimeFormat(locale.tag, OPTIONS[kind]);
|
||||
formatters.set(key, cached);
|
||||
}
|
||||
return formatter.format(date);
|
||||
return cached;
|
||||
}
|
||||
|
||||
function format(kind: keyof typeof OPTIONS, date: Date): string {
|
||||
return formatter(kind).format(date);
|
||||
}
|
||||
|
||||
export function formatTime(iso: string): string {
|
||||
@@ -47,3 +53,24 @@ export function formatListDate(iso: string | null): string {
|
||||
}
|
||||
return format("day", date);
|
||||
}
|
||||
|
||||
export function formatLongDay(date: Date): string {
|
||||
return format("longDay", date);
|
||||
}
|
||||
|
||||
export function parseDay(value: string): Date {
|
||||
const [year, month, date] = value.split("-").map(Number);
|
||||
return new Date(year ?? 0, (month ?? 1) - 1, date ?? 1);
|
||||
}
|
||||
|
||||
export function browserTimeZone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
|
||||
export function timeZoneLabel(): string {
|
||||
return (
|
||||
formatter("offset")
|
||||
.formatToParts(new Date())
|
||||
.find((part) => part.type === "timeZoneName")?.value ?? browserTimeZone()
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,117 @@
|
||||
import type { PresenceSample } from "#lib/api/types.js";
|
||||
import { formatListDate } from "#lib/format/datetime.js";
|
||||
import { formatDay, formatTime } from "#lib/format/datetime.js";
|
||||
import { plural } from "#lib/format/shares.js";
|
||||
|
||||
export function isOnline(sample: PresenceSample | null): boolean {
|
||||
if (sample === null || sample.status !== "online") {
|
||||
const SECONDS_IN_MINUTE = 60;
|
||||
const MINUTES_IN_HOUR = 60;
|
||||
const SECONDS_IN_HOUR = 3600;
|
||||
const MS_IN_MINUTE = 60_000;
|
||||
const SECONDS_IN_DAY = 86_400;
|
||||
const MS_IN_DAY = 86_400_000;
|
||||
|
||||
const TEXT = {
|
||||
ago: (minutes: number) =>
|
||||
`был(а) ${minutes} ${plural(minutes, "минуту", "минуты", "минут")} назад`,
|
||||
days: (days: number) => `${days} ${plural(days, "день", "дня", "дней")}`,
|
||||
hours: (hours: number) => `${hours} ч`,
|
||||
hoursMinutes: (hours: number, minutes: number) => `${hours} ч ${minutes} мин`,
|
||||
justNow: "был(а) только что",
|
||||
lastMonth: "был(а) в этом месяце",
|
||||
lastWeek: "был(а) на этой неделе",
|
||||
longAgo: "был(а) давно",
|
||||
minutes: (minutes: number) => `${minutes} мин`,
|
||||
offline: "не в сети",
|
||||
on: (day: string, time: string) => `был(а) ${day} в ${time}`,
|
||||
online: "в сети",
|
||||
recently: "был(а) недавно",
|
||||
seconds: (seconds: number) => `${seconds} с`,
|
||||
today: (time: string) => `был(а) сегодня в ${time}`,
|
||||
yesterday: (time: string) => `был(а) вчера в ${time}`,
|
||||
};
|
||||
|
||||
const FUZZY: Record<string, string> = {
|
||||
last_month: TEXT.lastMonth,
|
||||
last_week: TEXT.lastWeek,
|
||||
long_time_ago: TEXT.longAgo,
|
||||
recently: TEXT.recently,
|
||||
};
|
||||
|
||||
export function isOnline(
|
||||
sample: PresenceSample | null,
|
||||
now = Date.now()
|
||||
): boolean {
|
||||
if (sample?.status !== "online" || sample.next_offline_date === null) {
|
||||
return false;
|
||||
}
|
||||
if (sample.next_offline_date === null) {
|
||||
return false;
|
||||
}
|
||||
return new Date(sample.next_offline_date).getTime() > Date.now();
|
||||
return new Date(sample.next_offline_date).getTime() > now;
|
||||
}
|
||||
|
||||
export function formatPresence(sample: PresenceSample): string {
|
||||
switch (sample.status) {
|
||||
case "online":
|
||||
return isOnline(sample)
|
||||
? "online"
|
||||
: lastSeen(sample.last_online_date ?? sample.ts);
|
||||
case "recently":
|
||||
return "last seen recently";
|
||||
case "last_week":
|
||||
return "last seen within a week";
|
||||
case "last_month":
|
||||
return "last seen within a month";
|
||||
case "long_time_ago":
|
||||
return "last seen a long time ago";
|
||||
default:
|
||||
return sample.last_online_date
|
||||
? lastSeen(sample.last_online_date)
|
||||
: "offline";
|
||||
function lastSeen(iso: string, now: number): string {
|
||||
const date = new Date(iso);
|
||||
const minutes = Math.floor((now - date.getTime()) / MS_IN_MINUTE);
|
||||
if (minutes < 1) {
|
||||
return TEXT.justNow;
|
||||
}
|
||||
if (minutes < MINUTES_IN_HOUR) {
|
||||
return TEXT.ago(minutes);
|
||||
}
|
||||
const time = formatTime(iso);
|
||||
const today = new Date(now);
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return TEXT.today(time);
|
||||
}
|
||||
if (date.toDateString() === new Date(now - MS_IN_DAY).toDateString()) {
|
||||
return TEXT.yesterday(time);
|
||||
}
|
||||
return TEXT.on(formatDay(iso), time);
|
||||
}
|
||||
|
||||
function lastSeen(date: string): string {
|
||||
return `last seen ${formatListDate(date)}`;
|
||||
export function formatPresence(
|
||||
sample: PresenceSample,
|
||||
now = Date.now()
|
||||
): string {
|
||||
if (sample.status === "online") {
|
||||
return isOnline(sample, now)
|
||||
? TEXT.online
|
||||
: lastSeen(sample.next_offline_date ?? sample.ts, now);
|
||||
}
|
||||
const fuzzy = FUZZY[sample.status];
|
||||
if (fuzzy) {
|
||||
return fuzzy;
|
||||
}
|
||||
return sample.last_online_date
|
||||
? lastSeen(sample.last_online_date, now)
|
||||
: TEXT.offline;
|
||||
}
|
||||
|
||||
export function formatSpan(seconds: number): string {
|
||||
if (seconds < SECONDS_IN_MINUTE) {
|
||||
return TEXT.seconds(Math.round(seconds));
|
||||
}
|
||||
const minutes = Math.round(seconds / SECONDS_IN_MINUTE);
|
||||
if (minutes < MINUTES_IN_HOUR) {
|
||||
return TEXT.minutes(minutes);
|
||||
}
|
||||
const hours = Math.floor(minutes / MINUTES_IN_HOUR);
|
||||
const rest = minutes % MINUTES_IN_HOUR;
|
||||
return rest === 0 ? TEXT.hours(hours) : TEXT.hoursMinutes(hours, rest);
|
||||
}
|
||||
|
||||
export function formatClock(secondsOfDay: number): string {
|
||||
const hours = Math.floor(secondsOfDay / SECONDS_IN_HOUR);
|
||||
const minutes = Math.floor(
|
||||
(secondsOfDay % SECONDS_IN_HOUR) / SECONDS_IN_MINUTE
|
||||
);
|
||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatLongSpan(seconds: number): string {
|
||||
if (seconds < 2 * SECONDS_IN_DAY) {
|
||||
return formatSpan(seconds);
|
||||
}
|
||||
const days = Math.floor(seconds / SECONDS_IN_DAY);
|
||||
const hours = Math.round((seconds % SECONDS_IN_DAY) / SECONDS_IN_HOUR);
|
||||
return hours === 0
|
||||
? TEXT.days(days)
|
||||
: `${TEXT.days(days)} ${TEXT.hours(hours)}`;
|
||||
}
|
||||
Reference in new issue
Block a user