refactor(collab): format the moved collab model and drop its stale comments

The model left the format baseline when it moved into components/Collab,
so it is now Prettier formatted. Its header no longer points at the
deleted services layer, and the colour palette no longer cites a rule
that stopped applying once the file sat beside the desktop panel. The
chat data hook says why it still returns setters nobody writes.
This commit is contained in:
Maurice
2026-10-09 11:20:08 +02:00
parent c9c3cc6175
commit f190aa5a01
2 changed files with 141 additions and 132 deletions
+139 -132
View File
@@ -1,111 +1,119 @@
/**
* Collab view-model — types + pure helpers shared by the desktop Collab panel
* Collab view model: types and pure helpers shared by the desktop Collab panel
* and the phone's MCollabChat/Notes/Polls.
* Mirrors the real `/api/trips/:tripId/collab/*` response shapes (verified
* against `server/src/nest/collab/collab.controller.ts` +
* `server/src/services/collabService.ts`, byte-identical to the legacy Express
* route), NOT the desktop demo state — see analysis/10-tab-databindings.md §8.
* against `server/src/nest/collab/collab.controller.ts`), not the desktop demo
* state; see analysis/10-tab-databindings.md §8.
* Collab has no store slice, so unlike `transportsModel.ts` these helpers never
* touch `useTripStore` data.
*/
/** Same shape `t()` has everywhere else (TranslationContext.tsx) — kept local
* so this file stays planner-agnostic like transportsModel.ts. */
export type Translate = (key: string, params?: Record<string, string | number>) => string
export type Translate = (key: string, params?: Record<string, string | number>) => string;
/* ------------------------------------------------------------------ */
/* Chat */
/* ------------------------------------------------------------------ */
export interface ChatReactionUser {
user_id: number
username: string
user_id: number;
username: string;
}
export interface ChatReaction {
emoji: string
users: ChatReactionUser[]
count: number
emoji: string;
users: ChatReactionUser[];
count: number;
}
export interface ChatMessage {
id: number
trip_id: number
user_id: number
text: string
reply_to: number | null
reply_text?: string | null
reply_username?: string | null
username: string
avatar: string | null
avatar_url: string | null
user_avatar?: string | null
created_at: string
deleted?: number | boolean
reactions: ChatReaction[]
attachments?: Array<{ id: number; url: string; original_name?: string; mime_type?: string }>
id: number;
trip_id: number;
user_id: number;
text: string;
reply_to: number | null;
reply_text?: string | null;
reply_username?: string | null;
username: string;
avatar: string | null;
avatar_url: string | null;
user_avatar?: string | null;
created_at: string;
deleted?: number | boolean;
reactions: ChatReaction[];
attachments?: Array<{ id: number; url: string; original_name?: string; mime_type?: string }>;
/** Client-only: set once a delete (own or via WS) has landed. */
_deleted?: boolean
_deleted?: boolean;
}
/** The 8 quick-tap reactions (long-press / double-tap popover). Same set the
* desktop right-click menu offers — kept in sync for cross-platform muscle
* memory. */
export const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
export const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉'];
/** Bubble corner radii, spec 03 §6.1 — constant per sender, not per group
* position (grouping is expressed through spacing + a single trailing
* timestamp instead, see MCollabChat). Logical corners, so the tail follows
* the row to the other side in a right-to-left language. */
export const OWN_BUBBLE_RADIUS = { borderStartStartRadius: 16, borderStartEndRadius: 16, borderEndEndRadius: 4, borderEndStartRadius: 16 } as const
export const OTHER_BUBBLE_RADIUS = { borderStartStartRadius: 4, borderStartEndRadius: 16, borderEndEndRadius: 16, borderEndStartRadius: 16 } as const
export const OWN_BUBBLE_RADIUS = {
borderStartStartRadius: 16,
borderStartEndRadius: 16,
borderEndEndRadius: 4,
borderEndStartRadius: 16,
} as const;
export const OTHER_BUBBLE_RADIUS = {
borderStartStartRadius: 4,
borderStartEndRadius: 16,
borderEndEndRadius: 16,
borderEndStartRadius: 16,
} as const;
// SQLite stores UTC without a 'Z' suffix — append one so JS parses it as UTC
// rather than local time.
export function parseUtcDate(iso: string): Date {
return new Date(iso && !iso.endsWith('Z') ? `${iso}Z` : iso)
return new Date(iso && !iso.endsWith('Z') ? `${iso}Z` : iso);
}
/** 1-3 emoji and nothing else → render large, no bubble background. */
export function isEmojiOnlyText(text: string): boolean {
const emojiRegex =
/^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}️?(?:‍\p{Extended_Pictographic}️?)*){1,3}$/u
return emojiRegex.test(text.trim())
const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}️?(?:‍\p{Extended_Pictographic}️?)*){1,3}$/u;
return emojiRegex.test(text.trim());
}
export function formatChatClockTime(iso: string, is12h: boolean): string {
const d = parseUtcDate(iso)
const h = d.getHours()
const mm = String(d.getMinutes()).padStart(2, '0')
const d = parseUtcDate(iso);
const h = d.getHours();
const mm = String(d.getMinutes()).padStart(2, '0');
if (is12h) {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
return `${h12}:${mm} ${period}`
const period = h >= 12 ? 'PM' : 'AM';
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${h12}:${mm} ${period}`;
}
return `${String(h).padStart(2, '0')}:${mm}`
return `${String(h).padStart(2, '0')}:${mm}`;
}
/** Today / Yesterday, else the date; without a locale the browser's own (the desktop chat). */
export function formatChatDateSeparator(iso: string, t: Translate, locale?: string): string {
const d = parseUtcDate(iso)
const now = new Date()
const yesterday = new Date()
yesterday.setDate(now.getDate() - 1)
if (d.toDateString() === now.toDateString()) return t('collab.chat.today')
if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday')
return d.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
const d = parseUtcDate(iso);
const now = new Date();
const yesterday = new Date();
yesterday.setDate(now.getDate() - 1);
if (d.toDateString() === now.toDateString()) return t('collab.chat.today');
if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday');
return d.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' });
}
export function shouldShowChatDateSeparator(msg: ChatMessage, prevMsg?: ChatMessage): boolean {
if (!prevMsg) return true
return parseUtcDate(msg.created_at).toDateString() !== parseUtcDate(prevMsg.created_at).toDateString()
if (!prevMsg) return true;
return parseUtcDate(msg.created_at).toDateString() !== parseUtcDate(prevMsg.created_at).toDateString();
}
/** Same sender → part of the same visual group (spacing tightens, avatar and
* name collapse to the first message, timestamp moves to the last one). */
export function isSameSender(a: ChatMessage | undefined, b: ChatMessage | undefined): boolean {
if (!a || !b) return false
return String(a.user_id) === String(b.user_id)
if (!a || !b) return false;
return String(a.user_id) === String(b.user_id);
}
/* ------------------------------------------------------------------ */
@@ -113,79 +121,78 @@ export function isSameSender(a: ChatMessage | undefined, b: ChatMessage | undefi
/* ------------------------------------------------------------------ */
export interface CollabNoteFile {
id: number
filename: string
original_name: string
file_size?: number | null
mime_type?: string | null
url: string
id: number;
filename: string;
original_name: string;
file_size?: number | null;
mime_type?: string | null;
url: string;
}
export interface CollabNoteData {
id: number
trip_id: number
user_id: number
title: string
content: string | null
category: string | null
color: string | null
website: string | null
pinned: number | boolean
username: string
avatar: string | null
avatar_url: string | null
created_at: string
updated_at?: string
attachments: CollabNoteFile[]
id: number;
trip_id: number;
user_id: number;
title: string;
content: string | null;
category: string | null;
color: string | null;
website: string | null;
pinned: number | boolean;
username: string;
avatar: string | null;
avatar_url: string | null;
created_at: string;
updated_at?: string;
attachments: CollabNoteFile[];
}
/** Category swatch palette — same 6 hex values the desktop picker uses
* (product-level consistency), redefined locally per the no-Collab-reuse rule. */
export const NOTE_COLORS = ['#6366f1', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6']
/** Category swatch palette, the same 6 hex values the desktop picker uses. */
export const NOTE_COLORS = ['#6366f1', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6'];
/** One color per category, taken from whatever any note in that category
* already has stored (every note in a category is written with the same
* color at create time, so first-seen === all-seen in practice). */
export function buildCategoryColorMap(notes: CollabNoteData[]): Record<string, string> {
const map: Record<string, string> = {}
const map: Record<string, string> = {};
for (const n of notes) {
if (n.category && n.color && !map[n.category]) map[n.category] = n.color
if (n.category && n.color && !map[n.category]) map[n.category] = n.color;
}
return map
return map;
}
/** Round-robins a fresh category onto the next unused palette color. */
export function getCategoryColor(category: string | null | undefined, colorMap: Record<string, string>): string {
if (!category) return NOTE_COLORS[0]
if (colorMap[category]) return colorMap[category]
return NOTE_COLORS[Object.keys(colorMap).length % NOTE_COLORS.length]
if (!category) return NOTE_COLORS[0];
if (colorMap[category]) return colorMap[category];
return NOTE_COLORS[Object.keys(colorMap).length % NOTE_COLORS.length];
}
/** Distinct categories in first-appearance order (for the filter pill row). */
export function noteCategoriesList(notes: CollabNoteData[]): string[] {
const seen = new Set<string>()
const out: string[] = []
const seen = new Set<string>();
const out: string[] = [];
for (const n of notes) {
if (n.category && !seen.has(n.category)) {
seen.add(n.category)
out.push(n.category)
seen.add(n.category);
out.push(n.category);
}
}
return out
return out;
}
/** Pinned first, then most-recently-touched — same order the desktop grid uses. */
export function sortNotes(notes: CollabNoteData[], activeCategory: string | null): CollabNoteData[] {
return notes
.filter(n => activeCategory === null || n.category === activeCategory)
.filter((n) => activeCategory === null || n.category === activeCategory)
.slice()
.sort((a, b) => {
if (a.pinned && !b.pinned) return -1
if (!a.pinned && b.pinned) return 1
const tA = new Date(a.updated_at || a.created_at).getTime()
const tB = new Date(b.updated_at || b.created_at).getTime()
return tB - tA
})
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
const tA = new Date(a.updated_at || a.created_at).getTime();
const tB = new Date(b.updated_at || b.created_at).getTime();
return tB - tA;
});
}
/* ------------------------------------------------------------------ */
@@ -193,61 +200,61 @@ export function sortNotes(notes: CollabNoteData[], activeCategory: string | null
/* ------------------------------------------------------------------ */
export interface PollVoter {
id: number
user_id: number
username: string
avatar: string | null
avatar_url: string | null
id: number;
user_id: number;
username: string;
avatar: string | null;
avatar_url: string | null;
}
export interface PollOptionData {
text: string
label: string
voters: PollVoter[]
text: string;
label: string;
voters: PollVoter[];
}
export interface CollabPollData {
id: number
trip_id: number
user_id: number
question: string
options: PollOptionData[]
multiple_choice: boolean
is_closed: boolean
deadline: string | null
username: string
avatar: string | null
avatar_url: string | null
created_at: string
id: number;
trip_id: number;
user_id: number;
question: string;
options: PollOptionData[];
multiple_choice: boolean;
is_closed: boolean;
deadline: string | null;
username: string;
avatar: string | null;
avatar_url: string | null;
created_at: string;
}
export function totalPollVotes(poll: CollabPollData): number {
return (poll.options || []).reduce((sum, o) => sum + (o.voters?.length || 0), 0)
return (poll.options || []).reduce((sum, o) => sum + (o.voters?.length || 0), 0);
}
/** Highest single-option vote count — used to mark the winning option once closed. */
export function pollMaxVoteCount(poll: CollabPollData): number {
return (poll.options || []).reduce((max, o) => Math.max(max, o.voters?.length || 0), 0)
return (poll.options || []).reduce((max, o) => Math.max(max, o.voters?.length || 0), 0);
}
export function hasUserVoted(poll: CollabPollData, userId: number): boolean {
return (poll.options || []).some(o => (o.voters || []).some(v => String(v.user_id) === String(userId)))
return (poll.options || []).some((o) => (o.voters || []).some((v) => String(v.user_id) === String(userId)));
}
export function isPollExpired(deadline: string | null): boolean {
if (!deadline) return false
return new Date(deadline).getTime() <= Date.now()
if (!deadline) return false;
return new Date(deadline).getTime() <= Date.now();
}
export function isPollActive(poll: CollabPollData): boolean {
return !poll.is_closed && !isPollExpired(poll.deadline)
return !poll.is_closed && !isPollExpired(poll.deadline);
}
export function splitPolls(polls: CollabPollData[]): { active: CollabPollData[]; closed: CollabPollData[] } {
const active: CollabPollData[] = []
const closed: CollabPollData[] = []
for (const p of polls) (isPollActive(p) ? active : closed).push(p)
return { active, closed }
const active: CollabPollData[] = [];
const closed: CollabPollData[] = [];
for (const p of polls) (isPollActive(p) ? active : closed).push(p);
return { active, closed };
}
/** "2d 4h left" / "3h 45m left" / "12m left", localized; null once expired
@@ -255,13 +262,13 @@ export function splitPolls(polls: CollabPollData[]): { active: CollabPollData[];
* card uses, ported here because the desktop create form never actually
* exposes a deadline input (see MCollabPolls / task report). */
export function formatPollCountdown(deadline: string | null, t: Translate): string | null {
if (!deadline) return null
const diffMs = new Date(deadline).getTime() - Date.now()
if (diffMs <= 0) return null
const mins = Math.floor(diffMs / 60000)
const hrs = Math.floor(mins / 60)
const days = Math.floor(hrs / 24)
if (days > 0) return t('collab.polls.countdownDaysHours', { d: days, h: hrs % 24 })
if (hrs > 0) return t('collab.polls.countdownHoursMinutes', { h: hrs, m: mins % 60 })
return t('collab.polls.countdownMinutes', { m: mins })
if (!deadline) return null;
const diffMs = new Date(deadline).getTime() - Date.now();
if (diffMs <= 0) return null;
const mins = Math.floor(diffMs / 60000);
const hrs = Math.floor(mins / 60);
const days = Math.floor(hrs / 24);
if (days > 0) return t('collab.polls.countdownDaysHours', { d: days, h: hrs % 24 });
if (hrs > 0) return t('collab.polls.countdownHoursMinutes', { h: hrs, m: mins % 60 });
return t('collab.polls.countdownMinutes', { m: mins });
}
@@ -236,6 +236,8 @@ export function useCollabChatData({
[tripId, toast, t]
);
// The setters, messagesRef and isAtBottom are only here because useCollabChat hands them
// on in the return shape the desktop chat panel has always had; nothing writes them.
return {
messages,
setMessages,