@ciridae/ui 0.12.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import * as React from "react";
3
2
  import { MessageSquareTextIcon } from "lucide-react";
4
3
  import { CommentComposer } from "./comment-composer.js";
5
4
  import { CommentItem } from "./comment-item.js";
5
+ import { useRevealThreadEnd } from "./use-reveal-thread-end.js";
6
6
  import { Skeleton } from "../ui/skeleton.js";
7
7
  function CommentThread({ comments, composerAuthor, composerPlaceholder, composerSubmitLabel, emptyDescription = "Start the conversation with a short update or question.", emptyTitle = "No comments yet", isLoading = false, isSubmitting = false, onSubmit, readOnly = false, renderCommentActions, showHeader = true, title = "Comments", }) {
8
8
  const { armReveal, endRef } = useRevealThreadEnd(comments.length);
@@ -14,29 +14,6 @@ function CommentThread({ comments, composerAuthor, composerPlaceholder, composer
14
14
  : undefined;
15
15
  return (_jsxs("section", { className: "relative grid w-full gap-(--panel-gap)", "aria-label": title, children: [showHeader ? _jsx(CommentThreadHeader, { count: comments.length, title: title }) : null, _jsx(CommentThreadBody, { comments: comments, emptyDescription: emptyDescription, emptyTitle: emptyTitle, isLoading: isLoading, renderCommentActions: renderCommentActions }), submitAndReveal ? (_jsx(CommentComposer, { author: composerAuthor, isSubmitting: isSubmitting, onSubmit: submitAndReveal, placeholder: composerPlaceholder, submitLabel: composerSubmitLabel })) : null, _jsx("div", { "aria-hidden": true, className: "absolute bottom-0", ref: endRef })] }));
16
16
  }
17
- /**
18
- * After a send, the thread follows its own tail: scroll once when the submit
19
- * resolves, and again when the posted comment actually lands in `comments` —
20
- * the app may only append it after a refetch.
21
- */
22
- function useRevealThreadEnd(commentCount) {
23
- const endRef = React.useRef(null);
24
- // A deadline, not a flag: the follow-up scroll only chases the send it
25
- // belongs to, so a comment arriving minutes later cannot yank a reader
26
- // who has scrolled away.
27
- const armedUntil = React.useRef(0);
28
- React.useEffect(() => {
29
- if (armedUntil.current === 0 || Date.now() > armedUntil.current)
30
- return;
31
- armedUntil.current = 0;
32
- endRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
33
- }, [commentCount]);
34
- const armReveal = React.useCallback(() => {
35
- armedUntil.current = Date.now() + 5000;
36
- endRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
37
- }, []);
38
- return { armReveal, endRef };
39
- }
40
17
  function CommentThreadHeader({ count, title }) {
41
18
  return (_jsxs("header", { className: "flex items-center justify-between gap-3", children: [_jsx("h3", { className: "text-sm font-medium text-foreground", children: title }), _jsx("span", { className: "font-mono text-xs text-muted-foreground tabular-nums", children: count })] }));
42
19
  }
@@ -1,5 +1,5 @@
1
1
  import type { CommentItemData } from "./comment-types.js";
2
- import type { FeedbackStatusEvent, UserFeedbackItem } from "./feedback-tracking-types.js";
2
+ import type { UserFeedbackItem, UserFeedbackStatusEvent } from "./feedback-tracking-types.js";
3
3
  type FeedbackCommentSubmitHandler = (feedback: UserFeedbackItem, body: string) => void | Promise<void>;
4
4
  type FeedbackCenterCommentThreadProps = {
5
5
  comments: CommentItemData[];
@@ -9,7 +9,7 @@ type FeedbackCenterCommentThreadProps = {
9
9
  isCommentsLoading: boolean;
10
10
  onRetry?: () => void;
11
11
  onSubmitComment?: FeedbackCommentSubmitHandler;
12
- statusEvents: FeedbackStatusEvent[];
12
+ statusEvents: UserFeedbackStatusEvent[];
13
13
  };
14
14
  declare function FeedbackCenterCommentThread({ comments, error, feedback, isCommentSubmitting, isCommentsLoading, onRetry, onSubmitComment, statusEvents, }: FeedbackCenterCommentThreadProps): import("@types/react").JSX.Element;
15
15
  export { FeedbackCenterCommentThread, type FeedbackCommentSubmitHandler };
@@ -1,20 +1,25 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { AlertCircleIcon } from "lucide-react";
2
+ import { AlertCircleIcon, MessageSquareTextIcon } from "lucide-react";
3
3
  import { CommentComposer } from "./comment-composer.js";
4
4
  import { CommentItem } from "./comment-item.js";
5
- import { formatFeedbackTimestamp, getFeedbackTimestampDateTime } from "./feedback-date-format.js";
5
+ import { formatFeedbackTimestamp, formatFeedbackTimestampFull, getFeedbackTimestampDateTime, } from "./feedback-date-format.js";
6
6
  import { FeedbackStatusBadge } from "./feedback-status-badge.js";
7
+ import { useRevealThreadEnd } from "./use-reveal-thread-end.js";
7
8
  import { Alert, AlertDescription, AlertTitle } from "../ui/alert.js";
8
9
  import { Button } from "../ui/button.js";
9
10
  import { Skeleton } from "../ui/skeleton.js";
10
11
  function FeedbackCenterCommentThread({ comments, error, feedback, isCommentSubmitting, isCommentsLoading, onRetry, onSubmitComment, statusEvents, }) {
12
+ const activity = mergeFeedbackActivity(statusEvents, comments);
13
+ const { armReveal, endRef } = useRevealThreadEnd(activity.length);
11
14
  // A retry in flight shows the thread's loading state, not the stale error.
12
15
  if (error && !isCommentsLoading) {
13
16
  return (_jsxs(Alert, { variant: "destructive", children: [_jsx(AlertCircleIcon, {}), _jsx(AlertTitle, { children: "Conversation could not be loaded" }), _jsxs(AlertDescription, { className: "grid gap-3", children: [_jsx("span", { children: error.message }), onRetry ? (_jsx(Button, { className: "justify-self-start", onClick: onRetry, size: "sm", type: "button", variant: "outline", children: "Try again" })) : null] })] }));
14
17
  }
15
- const submitComment = getCommentSubmitHandler(feedback, onSubmitComment);
16
- const activity = mergeFeedbackActivity(statusEvents, comments);
17
- return (_jsxs("section", { "aria-label": "Activity", className: "grid gap-(--panel-gap)", children: [_jsxs("div", { className: "grid gap-1", children: [activity.map((entry) => entry.kind === "comment" ? (_jsx(CommentItem, { comment: entry.comment }, entry.comment.id)) : (_jsx(FeedbackStatusActivity, { event: entry.event }, entry.event.id))), isCommentsLoading ? _jsx(FeedbackActivityLoading, {}) : null] }), submitComment ? (_jsx(CommentComposer, { isSubmitting: isCommentSubmitting, onSubmit: submitComment, placeholder: "Write a reply...", submitLabel: "Reply" })) : null] }));
18
+ const submitComment = getCommentSubmitHandler(feedback, onSubmitComment, armReveal);
19
+ return (_jsxs("section", { "aria-label": "Activity", className: "relative grid gap-(--panel-gap)", children: [_jsxs("div", { className: "grid gap-1", children: [activity.length === 0 && !isCommentsLoading ? _jsx(FeedbackActivityEmpty, {}) : null, activity.map((entry) => entry.kind === "comment" ? (_jsx(CommentItem, { comment: entry.comment }, entry.comment.id)) : (_jsx(FeedbackStatusActivity, { event: entry.event }, entry.event.id))), isCommentsLoading ? _jsx(FeedbackActivityLoading, {}) : null] }), submitComment ? (_jsx(CommentComposer, { isSubmitting: isCommentSubmitting, onSubmit: submitComment, placeholder: "Write a reply...", submitLabel: "Reply" })) : null, _jsx("div", { "aria-hidden": true, className: "absolute bottom-0", ref: endRef })] }));
20
+ }
21
+ function FeedbackActivityEmpty() {
22
+ return (_jsxs("div", { className: "flex items-start gap-2 py-2 text-sm text-muted-foreground", children: [_jsx(MessageSquareTextIcon, { className: "mt-0.5 size-4 shrink-0" }), _jsx("span", { children: "No activity yet." })] }));
18
23
  }
19
24
  function mergeFeedbackActivity(statusEvents, comments) {
20
25
  return [
@@ -31,20 +36,25 @@ function mergeFeedbackActivity(statusEvents, comments) {
31
36
  ].sort(compareActivityEntries);
32
37
  }
33
38
  function compareActivityEntries(left, right) {
34
- const timestampOrder = left.createdAt.localeCompare(right.createdAt);
39
+ const leftTimestamp = getFeedbackTimestampDateTime(left.createdAt) ?? left.createdAt;
40
+ const rightTimestamp = getFeedbackTimestampDateTime(right.createdAt) ?? right.createdAt;
41
+ const timestampOrder = leftTimestamp.localeCompare(rightTimestamp);
35
42
  if (timestampOrder !== 0)
36
43
  return timestampOrder;
37
44
  return left.kind === right.kind ? 0 : left.kind === "status" ? -1 : 1;
38
45
  }
39
46
  function FeedbackStatusActivity({ event }) {
40
- return (_jsxs("div", { className: "flex min-w-0 items-center gap-2 py-2 text-xs text-muted-foreground", children: [_jsx("span", { "aria-hidden": true, className: "size-1.5 shrink-0 rounded-full bg-border" }), _jsx("span", { children: "Status changed to" }), _jsx(FeedbackStatusBadge, { status: event.status }), _jsx("time", { className: "ml-auto shrink-0 font-mono tabular-nums", dateTime: getFeedbackTimestampDateTime(event.createdAt), children: formatFeedbackTimestamp(event.createdAt) })] }));
47
+ return (_jsxs("div", { className: "flex min-w-0 items-center gap-2 py-2 text-xs text-muted-foreground", children: [_jsx("span", { "aria-hidden": true, className: "size-1.5 shrink-0 rounded-full bg-border" }), _jsx("span", { children: "Status changed to" }), _jsx(FeedbackStatusBadge, { status: event.status }), _jsx("time", { className: "ml-auto shrink-0 font-mono tabular-nums", dateTime: getFeedbackTimestampDateTime(event.createdAt), title: formatFeedbackTimestampFull(event.createdAt), children: formatFeedbackTimestamp(event.createdAt) })] }));
41
48
  }
42
49
  function FeedbackActivityLoading() {
43
50
  return (_jsxs("div", { "aria-label": "Loading activity", className: "flex gap-3 px-1 py-2", role: "status", children: [_jsx(Skeleton, { className: "size-6 rounded-full" }), _jsxs("div", { className: "grid flex-1 gap-2", children: [_jsx(Skeleton, { className: "h-3 w-32" }), _jsx(Skeleton, { className: "h-3 w-4/5" })] })] }));
44
51
  }
45
- function getCommentSubmitHandler(feedback, onSubmitComment) {
52
+ function getCommentSubmitHandler(feedback, onSubmitComment, armReveal) {
46
53
  if (!feedback || !onSubmitComment)
47
54
  return undefined;
48
- return (body) => onSubmitComment(feedback, body);
55
+ return async (body) => {
56
+ await onSubmitComment(feedback, body);
57
+ armReveal();
58
+ };
49
59
  }
50
60
  export { FeedbackCenterCommentThread };
@@ -3,7 +3,7 @@ import type { CommentItemData } from "./comment-types.js";
3
3
  import { type FeedbackCommentSubmitHandler } from "./feedback-center-comments.js";
4
4
  import { type FeedbackComposerProps } from "./feedback-composer.js";
5
5
  import type { FeedbackUrgency } from "./feedback-composer-types.js";
6
- import type { FeedbackStatusEvent, UserFeedbackItem } from "./feedback-tracking-types.js";
6
+ import type { UserFeedbackItem, UserFeedbackStatusEvent } from "./feedback-tracking-types.js";
7
7
  type FeedbackCenterTab = "mine" | "submit";
8
8
  type FeedbackCenterView = "detail" | "list";
9
9
  type FeedbackCenterComposerProps = Omit<FeedbackComposerProps, "actions" | "formId" | "onCancel" | "onDirtyChange" | "onSubmittedChange" | "onUrgencyChange">;
@@ -29,7 +29,7 @@ type FeedbackCenterTabsProps = {
29
29
  onSubmitComment?: FeedbackCommentSubmitHandler;
30
30
  selectedFeedback?: UserFeedbackItem;
31
31
  renderEvidence?: (item: UserFeedbackItem) => React.ReactNode;
32
- statusEventsByFeedbackId: Record<string, FeedbackStatusEvent[]>;
32
+ statusEventsByFeedbackId: Record<string, UserFeedbackStatusEvent[]>;
33
33
  view: FeedbackCenterView;
34
34
  };
35
35
  declare function FeedbackCenterSheetHeader({ unreadReplyCount }: {
@@ -24,21 +24,16 @@ function FeedbackCenterMineContent(props) {
24
24
  }
25
25
  function FeedbackCenterMineTab({ canComment, isLoading, commentsByFeedbackId, commentsError, isCommentSubmitting, isCommentsLoading, items, itemsError, onBack, onSelectFeedback, onRetryComments, onRetryItems, onSubmitComment, selectedFeedback, renderEvidence, statusEventsByFeedbackId, view, }) {
26
26
  if (view === "detail") {
27
- return (_jsx(FeedbackCenterDetailPane, { comments: getFeedbackComments(commentsByFeedbackId, selectedFeedback), canComment: canComment, commentsError: commentsError, feedback: selectedFeedback, isCommentSubmitting: isCommentSubmitting, isCommentsLoading: isCommentsLoading, onBack: onBack, onRetryComments: onRetryComments, onSubmitComment: onSubmitComment, renderEvidence: renderEvidence, statusEvents: getFeedbackStatusEvents(statusEventsByFeedbackId, selectedFeedback) }));
27
+ return (_jsx(FeedbackCenterDetailPane, { comments: getFeedbackScopedItems(commentsByFeedbackId, selectedFeedback), canComment: canComment, commentsError: commentsError, feedback: selectedFeedback, isCommentSubmitting: isCommentSubmitting, isCommentsLoading: isCommentsLoading, onBack: onBack, onRetryComments: onRetryComments, onSubmitComment: onSubmitComment, renderEvidence: renderEvidence, statusEvents: getFeedbackScopedItems(statusEventsByFeedbackId, selectedFeedback) }));
28
28
  }
29
29
  return (_jsx(FeedbackUserList, { error: itemsError, isLoading: isLoading, items: items, onSelectFeedback: onSelectFeedback, onRetry: onRetryItems, selectedId: selectedFeedback?.id }));
30
30
  }
31
31
  function FeedbackCenterDetailPane({ canComment, comments, commentsError, feedback, isCommentSubmitting, isCommentsLoading, onBack, onRetryComments, onSubmitComment, renderEvidence, statusEvents, }) {
32
32
  return (_jsx(FeedbackUserDetail, { activityLabel: "Activity", activitySlot: _jsx(FeedbackCenterCommentThread, { comments: comments, error: commentsError, feedback: feedback, isCommentSubmitting: isCommentSubmitting, isCommentsLoading: isCommentsLoading, onSubmitComment: feedback && canComment?.(feedback) === false ? undefined : onSubmitComment, onRetry: onRetryComments, statusEvents: statusEvents }), evidenceSlot: feedback ? renderEvidence?.(feedback) : undefined, feedback: feedback, onBack: onBack }));
33
33
  }
34
- function getFeedbackComments(commentsByFeedbackId, feedback) {
34
+ function getFeedbackScopedItems(itemsByFeedbackId, feedback) {
35
35
  if (!feedback)
36
36
  return [];
37
- return commentsByFeedbackId[feedback.id] ?? [];
38
- }
39
- function getFeedbackStatusEvents(statusEventsByFeedbackId, feedback) {
40
- if (!feedback)
41
- return [];
42
- return statusEventsByFeedbackId[feedback.id] ?? [];
37
+ return itemsByFeedbackId[feedback.id] ?? [];
43
38
  }
44
39
  export { FeedbackCenterSheetHeader, FeedbackCenterTabs, };
@@ -3,7 +3,7 @@ import type { CommentItemData } from "./comment-types.js";
3
3
  import type { FeedbackCommentSubmitHandler } from "./feedback-center-comments.js";
4
4
  import { type FeedbackCenterTab } from "./feedback-center-sheet-parts.js";
5
5
  import type { FeedbackComposerProps } from "./feedback-composer.js";
6
- import type { FeedbackStatusEvent, UserFeedbackItem } from "./feedback-tracking-types.js";
6
+ import type { UserFeedbackItem, UserFeedbackStatusEvent } from "./feedback-tracking-types.js";
7
7
  type FeedbackCenterSheetProps = Omit<FeedbackComposerProps, "actions" | "formId" | "onCancel" | "onDirtyChange" | "onSubmittedChange" | "onUrgencyChange"> & {
8
8
  canComment?: (item: UserFeedbackItem) => boolean;
9
9
  commentsByFeedbackId?: Record<string, CommentItemData[]>;
@@ -21,7 +21,7 @@ type FeedbackCenterSheetProps = Omit<FeedbackComposerProps, "actions" | "formId"
21
21
  onOpenChange: (open: boolean) => void;
22
22
  open: boolean;
23
23
  renderEvidence?: (item: UserFeedbackItem) => React.ReactNode;
24
- statusEventsByFeedbackId?: Record<string, FeedbackStatusEvent[]>;
24
+ statusEventsByFeedbackId?: Record<string, UserFeedbackStatusEvent[]>;
25
25
  };
26
26
  declare function FeedbackCenterSheet(props: FeedbackCenterSheetProps): React.JSX.Element;
27
27
  export { FeedbackCenterSheet, type FeedbackCenterSheetProps, type FeedbackCenterTab };
@@ -4,7 +4,7 @@ import { PaperclipIcon } from "lucide-react";
4
4
  import { formatFeedbackTimestampFull, getFeedbackTimestampDateTime } from "./feedback-date-format.js";
5
5
  import { FeedbackStatusBadge } from "./feedback-status-badge.js";
6
6
  import { FeedbackTypeBadge } from "./feedback-type-badge.js";
7
- import { feedbackStatusLabels, feedbackStatusValues, getFeedbackInitials, } from "./feedback-tracking-types.js";
7
+ import { feedbackStatusLabels, feedbackTriageStatusValues, getFeedbackInitials, } from "./feedback-tracking-types.js";
8
8
  import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar.js";
9
9
  import { Button } from "../ui/button.js";
10
10
  import { Card } from "../ui/card.js";
@@ -27,11 +27,11 @@ function FeedbackDetailContent({ feedback, onStatusChange, pendingStatus, }) {
27
27
  */
28
28
  function FeedbackDetailStatusSection({ onStatusChange, pendingStatus, status, }) {
29
29
  const selectId = React.useId();
30
- return (_jsxs("div", { className: "grid gap-2", children: [_jsx("label", { className: "text-meta text-muted-foreground", htmlFor: selectId, children: "Status" }), _jsxs(Select, { disabled: pendingStatus !== undefined, onValueChange: (value) => {
31
- const next = feedbackStatusValues.find((candidate) => candidate === value);
30
+ return (_jsxs("div", { className: "grid gap-2", children: [_jsx("label", { className: "text-meta text-muted-foreground", htmlFor: selectId, children: "Status" }), _jsxs(Select, { disabled: status === "withdrawn" || pendingStatus !== undefined, onValueChange: (value) => {
31
+ const next = feedbackTriageStatusValues.find((candidate) => candidate === value);
32
32
  if (next && next !== status)
33
33
  onStatusChange(next);
34
- }, value: pendingStatus ?? status, children: [_jsx(SelectTrigger, { className: "w-full", id: selectId, children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: feedbackStatusValues.map((value) => (_jsx(SelectItem, { value: value, children: feedbackStatusLabels[value] }, value))) })] })] }));
34
+ }, value: pendingStatus ?? status, children: [_jsx(SelectTrigger, { className: "w-full", id: selectId, children: _jsx(SelectValue, {}) }), _jsxs(SelectContent, { children: [status === "withdrawn" ? (_jsx(SelectItem, { value: "withdrawn", children: feedbackStatusLabels.withdrawn })) : null, feedbackTriageStatusValues.map((value) => (_jsx(SelectItem, { value: value, children: feedbackStatusLabels[value] }, value)))] })] })] }));
35
35
  }
36
36
  function FeedbackDetailSummary({ feedback }) {
37
37
  const submitter = feedback.submittedBy;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { MetricsStrip, MetricsStripDetail, MetricsStripItem, MetricsStripLabel, MetricsStripValue, } from "../ui/metrics-strip.js";
3
3
  function FeedbackMetricsStrip({ metrics }) {
4
- return (_jsxs(MetricsStrip, { className: "md:grid-cols-3 xl:grid-cols-5", children: [_jsx(FeedbackMetricItem, { detail: "Visible tickets", label: "Total", tone: "neutral", value: metrics.total }), _jsx(FeedbackMetricItem, { detail: "Awaiting triage", label: "Received", tone: "warning", value: metrics.received }), _jsx(FeedbackMetricItem, { detail: "Active work", label: "In progress", tone: "info", value: metrics.inProgress }), _jsx(FeedbackMetricItem, { detail: "Needs a decision", label: "Discussion required", tone: "warning", value: metrics.discussionRequired }), _jsx(FeedbackMetricItem, { detail: "Finished", label: "Completed", tone: "success", value: metrics.completed })] }));
4
+ return (_jsxs(MetricsStrip, { className: "md:grid-cols-3 xl:grid-cols-5", children: [_jsx(FeedbackMetricItem, { detail: "Visible tickets", label: "Total", tone: "neutral", value: metrics.total }), _jsx(FeedbackMetricItem, { detail: "Awaiting triage", label: "Received", tone: "warning", value: metrics.received }), _jsx(FeedbackMetricItem, { detail: "Active work", label: "In progress", tone: "info", value: metrics.inProgress }), _jsx(FeedbackMetricItem, { detail: "Needs a decision", label: "Discussion required", tone: "warning", value: metrics.discussionRequired }), _jsx(FeedbackMetricItem, { detail: "Resolved", label: "Closed", tone: "success", value: metrics.completed })] }));
5
5
  }
6
6
  function FeedbackMetricItem({ detail, label, tone, value, }) {
7
7
  return (_jsxs(MetricsStripItem, { children: [_jsx(MetricsStripLabel, { children: label }), _jsx(MetricsStripValue, { children: value }), _jsx(MetricsStripDetail, { className: feedbackMetricToneClasses[tone], children: detail })] }));
@@ -1,5 +1,6 @@
1
1
  declare const feedbackStatusValues: readonly ["received", "in_progress", "discussion_required", "completed", "no_action", "withdrawn"];
2
2
  type FeedbackStatus = (typeof feedbackStatusValues)[number];
3
+ declare const feedbackTriageStatusValues: readonly ["received", "in_progress", "discussion_required", "completed", "no_action"];
3
4
  type StakeholderFeedbackType = "bug" | "feature" | "general";
4
5
  type FeedbackUrgency = "emergency" | "normal";
5
6
  type FeedbackAffectedScope = "everyone" | "just_me" | "my_team" | "unknown";
@@ -42,7 +43,7 @@ type UserFeedbackItem = {
42
43
  updatedAt?: string;
43
44
  urgency?: FeedbackUrgency;
44
45
  };
45
- type FeedbackStatusEvent = {
46
+ type UserFeedbackStatusEvent = {
46
47
  createdAt: string;
47
48
  id: string;
48
49
  status: FeedbackStatus;
@@ -98,4 +99,4 @@ declare const feedbackTypeOptions: readonly [{
98
99
  readonly value: "general";
99
100
  }];
100
101
  declare function getFeedbackInitials(name: string, email: string | undefined): string;
101
- export { feedbackStatusLabels, feedbackStatusOptions, feedbackStatusValues, feedbackTypeLabels, feedbackTypeOptions, getFeedbackInitials, type FeedbackAffectedScope, type FeedbackAttachment, type FeedbackMetrics, type FeedbackStatus, type FeedbackStatusEvent, type FeedbackSubmittedBy, type FeedbackTimelineEvent, type FeedbackUrgency, type StakeholderFeedbackItem, type StakeholderFeedbackType, type UserFeedbackItem, };
102
+ export { feedbackStatusLabels, feedbackStatusOptions, feedbackTriageStatusValues, feedbackStatusValues, feedbackTypeLabels, feedbackTypeOptions, getFeedbackInitials, type FeedbackAffectedScope, type FeedbackAttachment, type FeedbackMetrics, type FeedbackStatus, type FeedbackSubmittedBy, type FeedbackTimelineEvent, type FeedbackUrgency, type StakeholderFeedbackItem, type StakeholderFeedbackType, type UserFeedbackItem, type UserFeedbackStatusEvent, };
@@ -7,8 +7,15 @@ const feedbackStatusValues = [
7
7
  "no_action",
8
8
  "withdrawn",
9
9
  ];
10
+ const feedbackTriageStatusValues = [
11
+ "received",
12
+ "in_progress",
13
+ "discussion_required",
14
+ "completed",
15
+ "no_action",
16
+ ];
10
17
  const feedbackStatusLabels = {
11
- completed: "Completed",
18
+ completed: "Closed",
12
19
  discussion_required: "Discussion required",
13
20
  in_progress: "In progress",
14
21
  no_action: "No action",
@@ -44,4 +51,4 @@ function getFeedbackInitials(name, email) {
44
51
  .map((part) => part.charAt(0).toUpperCase())
45
52
  .join("");
46
53
  }
47
- export { feedbackStatusLabels, feedbackStatusOptions, feedbackStatusValues, feedbackTypeLabels, feedbackTypeOptions, getFeedbackInitials, };
54
+ export { feedbackStatusLabels, feedbackStatusOptions, feedbackTriageStatusValues, feedbackStatusValues, feedbackTypeLabels, feedbackTypeOptions, getFeedbackInitials, };
@@ -1,15 +1,27 @@
1
- import { format, formatDistanceToNowStrict, isValid, parseISO } from "date-fns";
1
+ import { isValid, parseISO } from "date-fns";
2
2
  function formatTimestamp(value, options = {}) {
3
3
  const date = parseTimestamp(value);
4
4
  if (!date)
5
5
  return getTimestampFallback(value, options.fallback);
6
- return formatDistanceToNowStrict(date, { addSuffix: true });
6
+ const time = timeFormatter.format(date);
7
+ const dayDifference = getLocalCalendarDayDifference(date, new Date());
8
+ if (dayDifference === 0)
9
+ return `Today, ${time}`;
10
+ if (dayDifference === -1)
11
+ return `Yesterday, ${time}`;
12
+ if (dayDifference < 0 && dayDifference > -7) {
13
+ return `${weekdayFormatter.format(date)}, ${time}`;
14
+ }
15
+ if (date.getFullYear() === new Date().getFullYear()) {
16
+ return `${monthDayFormatter.format(date)}, ${time}`;
17
+ }
18
+ return dateTimeFormatter.format(date);
7
19
  }
8
20
  function formatTimestampFull(value, options = {}) {
9
21
  const date = parseTimestamp(value);
10
22
  if (!date)
11
23
  return getTimestampFallback(value, options.fallback);
12
- return format(date, "PPp");
24
+ return dateTimeFormatter.format(date);
13
25
  }
14
26
  function getTimestampDateTime(value) {
15
27
  const date = parseTimestamp(value);
@@ -28,10 +40,34 @@ function toUtcTimestamp(value) {
28
40
  function parseTimestamp(value) {
29
41
  if (!value)
30
42
  return undefined;
31
- const date = parseISO(value);
43
+ const date = parseISO(toUtcTimestamp(value));
32
44
  return isValid(date) ? date : undefined;
33
45
  }
34
46
  function getTimestampFallback(value, fallback = "No update") {
35
47
  return value?.trim() || fallback;
36
48
  }
49
+ function getLocalCalendarDayDifference(date, comparisonDate) {
50
+ const dateStart = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
51
+ const comparisonStart = new Date(comparisonDate.getFullYear(), comparisonDate.getMonth(), comparisonDate.getDate()).getTime();
52
+ return Math.round((dateStart - comparisonStart) / millisecondsPerDay);
53
+ }
54
+ const millisecondsPerDay = 24 * 60 * 60 * 1000;
55
+ const timeFormatter = new Intl.DateTimeFormat(undefined, {
56
+ hour: "numeric",
57
+ minute: "2-digit",
58
+ });
59
+ const weekdayFormatter = new Intl.DateTimeFormat(undefined, {
60
+ weekday: "short",
61
+ });
62
+ const monthDayFormatter = new Intl.DateTimeFormat(undefined, {
63
+ day: "numeric",
64
+ month: "short",
65
+ });
66
+ const dateTimeFormatter = new Intl.DateTimeFormat(undefined, {
67
+ day: "numeric",
68
+ hour: "numeric",
69
+ minute: "2-digit",
70
+ month: "short",
71
+ year: "numeric",
72
+ });
37
73
  export { formatTimestamp, formatTimestampFull, getTimestampDateTime, toUtcTimestamp };
@@ -0,0 +1,11 @@
1
+ import * as React from "react";
2
+ /**
3
+ * After a send, a thread follows its own tail: scroll once when the submit
4
+ * resolves, and again when the posted item actually lands in the thread. The
5
+ * app may only append it after a refetch.
6
+ */
7
+ declare function useRevealThreadEnd(itemCount: number): {
8
+ armReveal: () => void;
9
+ endRef: React.RefObject<HTMLDivElement | null>;
10
+ };
11
+ export { useRevealThreadEnd };
@@ -0,0 +1,25 @@
1
+ import * as React from "react";
2
+ /**
3
+ * After a send, a thread follows its own tail: scroll once when the submit
4
+ * resolves, and again when the posted item actually lands in the thread. The
5
+ * app may only append it after a refetch.
6
+ */
7
+ function useRevealThreadEnd(itemCount) {
8
+ const endRef = React.useRef(null);
9
+ // A deadline, not a flag: the follow-up scroll only chases the send it
10
+ // belongs to, so a comment arriving minutes later cannot yank a reader
11
+ // who has scrolled away.
12
+ const armedUntil = React.useRef(0);
13
+ React.useEffect(() => {
14
+ if (armedUntil.current === 0 || Date.now() > armedUntil.current)
15
+ return;
16
+ armedUntil.current = 0;
17
+ endRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
18
+ }, [itemCount]);
19
+ const armReveal = React.useCallback(() => {
20
+ armedUntil.current = Date.now() + 5000;
21
+ endRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
22
+ }, []);
23
+ return { armReveal, endRef };
24
+ }
25
+ export { useRevealThreadEnd };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciridae/ui",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "engines": {