Files
TREK/client/src/mobile/screens/trip/tabs/collabModel.ts
T
Maurice d4eccb39a3 fix(client): three more places follow the reading direction in Arabic
On a phone in Arabic every system notice, the release notes included,
opened as an empty sheet: its three-slot strip is shifted to show the
middle slot, and in a right-to-left row that shift moved all three out
of view. The shifts point the other way in a right-to-left page now,
which the swipe handling already assumed.

The budget's total amount kept its parts in order but sat on the
opposite edge from its label, because the whole row was made left to
right. Only the amount itself is now. The phone chat's bubble tails
were still physical corners while the desktop ones follow the row; both
use logical corners now.

lint:rtl takes comments from the TypeScript parser instead of scanning
lines, so a "/*" inside a string (accept="image/*") no longer hides the
lines after it.
2026-10-08 00:01:43 +00:00

266 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Collab view-model — types + pure helpers shared by 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.
* 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
/* ------------------------------------------------------------------ */
/* Chat */
/* ------------------------------------------------------------------ */
export interface ChatReactionUser {
user_id: number
username: string
}
export interface ChatReaction {
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 }>
/** Client-only: set once a delete (own or via WS) has landed. */
_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, redefined locally since we don't import Desktop Collab files. */
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
// SQLite stores UTC without a 'Z' suffix — append one so JS parses it as UTC
// rather than local time (same fix the desktop helpers apply).
export function parseUtcDate(iso: string): Date {
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())
}
export function formatChatClockTime(iso: string, is12h: boolean): string {
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}`
}
return `${String(h).padStart(2, '0')}:${mm}`
}
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' })
}
export function shouldShowChatDateSeparator(msg: ChatMessage, prevMsg?: ChatMessage): boolean {
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)
}
/* ------------------------------------------------------------------ */
/* Notes */
/* ------------------------------------------------------------------ */
export interface CollabNoteFile {
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[]
}
/** 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']
/** 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> = {}
for (const n of notes) {
if (n.category && n.color && !map[n.category]) map[n.category] = n.color
}
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]
}
/** 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[] = []
for (const n of notes) {
if (n.category && !seen.has(n.category)) {
seen.add(n.category)
out.push(n.category)
}
}
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)
.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
})
}
/* ------------------------------------------------------------------ */
/* Polls */
/* ------------------------------------------------------------------ */
export interface PollVoter {
id: number
user_id: number
username: string
avatar: string | null
avatar_url: string | null
}
export interface PollOptionData {
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
}
export function totalPollVotes(poll: CollabPollData): number {
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)
}
export function hasUserVoted(poll: CollabPollData, userId: number): boolean {
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()
}
export function isPollActive(poll: CollabPollData): boolean {
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 }
}
/** "2d 4h left" / "3h 45m left" / "12m left", localized; null once expired
* or when there is no deadline. Same three-tier breakdown the desktop poll
* 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 })
}