From f190aa5a01e0bf63b445d96dbf9cf60ef6b306d4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 11:20:08 +0200 Subject: [PATCH] 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. --- client/src/components/Collab/collabModel.ts | 271 +++++++++--------- .../components/Collab/useCollabChatData.ts | 2 + 2 files changed, 141 insertions(+), 132 deletions(-) diff --git a/client/src/components/Collab/collabModel.ts b/client/src/components/Collab/collabModel.ts index 42f7efa8c..59c2b6b3e 100644 --- a/client/src/components/Collab/collabModel.ts +++ b/client/src/components/Collab/collabModel.ts @@ -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 +export type Translate = (key: string, params?: Record) => 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 { - const map: Record = {} + const map: Record = {}; 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 { - 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() - const out: string[] = [] + const seen = new Set(); + 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 }); } diff --git a/client/src/components/Collab/useCollabChatData.ts b/client/src/components/Collab/useCollabChatData.ts index 8237033c0..b6cb038c5 100644 --- a/client/src/components/Collab/useCollabChatData.ts +++ b/client/src/components/Collab/useCollabChatData.ts @@ -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,