mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* fix(issues): store start_date/due_date as DATE, not timestamp (MUL-2925) These fields are calendar days (the pickers offer no time-of-day), but were stored as TIMESTAMPTZ. A client serializing local midnight via toISOString() folded its timezone into the instant, so the day shifted by the local offset (GH #3618). Migrate the columns to DATE and parse/serialize date-only "YYYY-MM-DD". ParseCalendarDate still accepts legacy RFC3339 (truncated to the UTC day) so older clients keep working. Co-authored-by: multica-agent <github@multica.ai> * fix(issues): render start_date/due_date as timezone-stable calendar days (MUL-2925) Pickers now emit date-only "YYYY-MM-DD" (local calendar day) instead of toISOString(), and every read formats via the shared @multica/core/issues/date helpers with timeZone:"UTC" so the day never shifts with the viewer's offset. The Gantt's existing UTC bucketing is now correct. Covers web/desktop pickers, quick-set menu, list/board/detail/activity, and the mobile due-date picker. Co-authored-by: multica-agent <github@multica.ai> * fix(issues): address date-only review — loud-fail ambiguous dates, finish display sweep (MUL-2925) Review follow-ups on #3692: - ParseCalendarDate no longer silently truncates a legacy non-midnight RFC3339 to the wrong UTC day; it accepts only YYYY-MM-DD or an exact UTC-midnight instant and rejects ambiguous ones loudly. Adds util unit tests. - migration 112 pins the TIMESTAMPTZ->DATE conversion to UTC explicitly via AT TIME ZONE 'UTC' (was session-timezone dependent); down migration too. - Convert remaining date-change display sites to formatDateOnly: inbox detail label (web) and mobile activity + inbox labels (were new Date()+local format). - CLI --start-date/--due-date help now says YYYY-MM-DD, not RFC3339. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
130 lines
4.2 KiB
TypeScript
130 lines
4.2 KiB
TypeScript
/**
|
|
* Activity-row text formatter. Subset of the web `formatActivity` in
|
|
* packages/views/issues/components/issue-detail.tsx:95 — same actions,
|
|
* English-only copy (mobile v1 is English-only; mirror the structure when
|
|
* mobile gains i18n).
|
|
*
|
|
* Unknown actions fall through to the raw string in `entry.action`. NEVER
|
|
* throw and NEVER drop the row — that's the API Response Compatibility rule
|
|
* from repo-root CLAUDE.md (server may add new action enum values; older
|
|
* mobile clients in the wild must render them as a generic fallback, not
|
|
* crash).
|
|
*/
|
|
import type {
|
|
IssuePriority,
|
|
IssueStatus,
|
|
TimelineEntry,
|
|
} from "@multica/core/types";
|
|
import { formatDateOnly } from "@multica/core/issues/date";
|
|
|
|
const STATUS_LABEL: Record<IssueStatus, string> = {
|
|
backlog: "Backlog",
|
|
todo: "Todo",
|
|
in_progress: "In Progress",
|
|
in_review: "In Review",
|
|
done: "Done",
|
|
blocked: "Blocked",
|
|
cancelled: "Cancelled",
|
|
};
|
|
|
|
const PRIORITY_LABEL: Record<IssuePriority, string> = {
|
|
urgent: "Urgent",
|
|
high: "High",
|
|
medium: "Medium",
|
|
low: "Low",
|
|
none: "No priority",
|
|
};
|
|
|
|
function statusName(s: string | undefined): string {
|
|
if (s && s in STATUS_LABEL) return STATUS_LABEL[s as IssueStatus];
|
|
return s ?? "?";
|
|
}
|
|
|
|
function priorityName(p: string | undefined): string {
|
|
if (p && p in PRIORITY_LABEL) return PRIORITY_LABEL[p as IssuePriority];
|
|
return p ?? "?";
|
|
}
|
|
|
|
// start_date / due_date are calendar days — format timezone-safely (no offset
|
|
// day shift). Mirrors web's formatActivity in issue-detail.tsx.
|
|
function shortDate(date: string | undefined): string {
|
|
if (!date) return "?";
|
|
return formatDateOnly(date, { month: "short", day: "numeric" }, "en-US");
|
|
}
|
|
|
|
export function formatActivity(
|
|
entry: TimelineEntry,
|
|
resolveActorName: (
|
|
type: string | null | undefined,
|
|
id: string | null | undefined,
|
|
) => string,
|
|
): string {
|
|
const details = (entry.details ?? {}) as Record<string, string>;
|
|
switch (entry.action) {
|
|
case "created":
|
|
return "created the issue";
|
|
case "status_changed":
|
|
return `changed status: ${statusName(details.from)} → ${statusName(details.to)}`;
|
|
case "priority_changed":
|
|
return `changed priority: ${priorityName(details.from)} → ${priorityName(details.to)}`;
|
|
case "assignee_changed": {
|
|
const isSelf =
|
|
details.to_type === entry.actor_type &&
|
|
details.to_id === entry.actor_id;
|
|
if (isSelf) return "self-assigned";
|
|
if (details.from_id && !details.to_id) return "removed assignee";
|
|
const toName =
|
|
details.to_id && details.to_type
|
|
? resolveActorName(details.to_type, details.to_id)
|
|
: null;
|
|
if (toName) return `assigned to ${toName}`;
|
|
return "changed assignee";
|
|
}
|
|
case "start_date_changed": {
|
|
if (!details.to) return "removed start date";
|
|
return `set start date to ${shortDate(details.to)}`;
|
|
}
|
|
case "due_date_changed": {
|
|
if (!details.to) return "removed due date";
|
|
return `set due date to ${shortDate(details.to)}`;
|
|
}
|
|
case "title_changed":
|
|
return `renamed: "${details.from ?? "?"}" → "${details.to ?? "?"}"`;
|
|
case "description_updated":
|
|
return "updated description";
|
|
case "task_completed": {
|
|
const n = entry.coalesced_count ?? 1;
|
|
return n > 1 ? `completed ${n} tasks` : "completed a task";
|
|
}
|
|
case "task_failed": {
|
|
const n = entry.coalesced_count ?? 1;
|
|
return n > 1 ? `failed ${n} tasks` : "failed a task";
|
|
}
|
|
case "squad_leader_evaluated": {
|
|
// Copy mirrors packages/views/locales/en/issues.json
|
|
// (squad_leader_action / squad_leader_no_action / squad_leader_failed,
|
|
// each with an optional `_reason` variant).
|
|
const reason = details.reason?.trim();
|
|
switch (details.outcome) {
|
|
case "action":
|
|
return reason
|
|
? `evaluated and took action: ${reason}`
|
|
: "evaluated and took action";
|
|
case "no_action":
|
|
return reason
|
|
? `evaluated: no action needed (${reason})`
|
|
: "evaluated: no action needed";
|
|
case "failed":
|
|
return reason
|
|
? `evaluation failed: ${reason}`
|
|
: "evaluation failed";
|
|
default:
|
|
return "evaluated the squad trigger";
|
|
}
|
|
}
|
|
default:
|
|
return entry.action ?? "";
|
|
}
|
|
}
|
|
|