diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md
index 18100a3dd..ad882c450 100644
--- a/.changelog/NEXT.md
+++ b/.changelog/NEXT.md
@@ -13,6 +13,7 @@
- **[wire-proactive-cos-speech-to-real-triggers] The assistant can now speak up on its own.** With proactive voice enabled, your Chief of Staff speaks first on three kinds of moments — a critical system error, a new task becoming ready, and a high-priority notification — instead of only talking back when you address it. Each kind is rate-limited on its own timer so a burst can't talk over you, and all of it still respects quiet hours and the proactive-voice switch.
## Changed
+- **[migrate-two-remaining-local-formatters] Consolidated duplicate date/time and duration formatters.** The calendar event-detail panel and the Chief of Staff task list now use the shared formatting helpers instead of near-identical local copies — no change to how event dates/times or task duration estimates appear.
- **[dedupe-hhmm-time-window-helpers] Shared time-of-day helpers.** Voice quiet-hours and time-windowed dashboard layouts now share one implementation for parsing HH:MM times and deciding whether the current time falls inside a window, so the two can't quietly drift apart.
- **[extract-compare-helpers-once-eight-callers] Shared dashboard refresh-dedupe helpers.** The dashboard widgets that skip needless re-renders when their polled data hasn't visibly changed now share one comparison implementation, so the dedupe logic can't drift between widgets.
- **Universe page route renamed `/universe-builder` → `/universes`.** `/universes` is now the list/table index; the editor lives at `/universes/:universeId`, and `/universes/new` is the create-mode entry point (the `UniverseBuilder` editor treats the `new` sentinel as no-id → blank draft; real ids are UUIDs so no collision). The "Universe" sidebar entry is relabeled "Universes" and points at the index; the editor header gains a "← All Universes" back link. Legacy `/universe-builder*` (and the older `/media/universe-builder*`) paths redirect to `/universes*` preserving search + `#canon`, so existing bookmarks and in-app deep-links keep working. The `/api/universe-builder/*` backend endpoints are unchanged. `useUniverseNav`'s exported `universeBuilderBasePath` is renamed `universesBasePath`; the nav-manifest entry + voice/`⌘K` aliases follow the new path.
diff --git a/PLAN.md b/PLAN.md
index 9722a8644..f267f36ab 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -5,7 +5,7 @@ For project goals, see [GOALS.md](./GOALS.md). For completed work, see [.changel
## Next Up
- [ ] [codex5-onboarding-capability-map] **[P2][ONBOARDING]** Capability map of connected systems. One page showing each integration's status: Providers (per-provider configured/available/throttled), Calendar, Brain/memory embeddings, Voice (mic + TTS), Tailscale + HTTPS, Genome/health imports, Telegram/messages, App registry/PM2. Each row links to the relevant settings page. Doubles as a setup checklist and a runtime health overview.
-- [ ] [migrate-two-remaining-local-formatters] **Migrate two remaining local formatters surfaced during the module-discovery PR.** Two additional local format definitions were found that aren't in the original tracker: (1) `client/src/components/calendar/EventDetail.jsx:10` defines `formatDateTime(dateStr, isAllDay)` with an all-day branch the shared `formatDateTime` lacks — extend the shared helper to take `{ allDay: true }` or keep this local and rename to `formatEventDateTime`. (2) `client/src/components/cos/tabs/TaskItem.jsx:78` defines `formatDurationMin(mins)` with a `~` approximation prefix (`~3h 30m`) — extend shared `formatDurationMin` with an `{ approximate: true }` option. Low priority; visual semantics need to be preserved exactly.
+- [ ] [migrate-taskitem-formatattachmentsize-to-formatbytes] **Migrate `TaskItem.jsx`'s local `formatAttachmentSize` to shared `formatBytes`.** `client/src/components/cos/tabs/TaskItem.jsx` still defines a local `formatAttachmentSize(bytes)` (B/KB/MB with `.toFixed(1)`) that duplicates `formatBytes` in `client/src/utils/formatters.js`. Surfaced by gemini review during the `[migrate-two-remaining-local-formatters]` claim 2026-05-24; deferred as out-of-scope for that item (which named only `formatDateTime` + `formatDurationMin`). Note the edge-case semantics differ: the local helper returns `''` for falsy/0 size (used inside an attachment `title` like `(1.5 KB)`), while `formatBytes(0)` returns `'0 B'` — preserve the empty-on-missing behavior with a guard (`att.size ? formatBytes(att.size) : ''`) so the title doesn't render `(0 B)` for sizeless attachments.
- [ ] [optimize-voice-ui-index-text-payload-lazy-only-run] **Optimize `voice:ui:index` text payload.** Lazy: only run `extractVisibleText` when server requests via `voice:ui:read-request`. Keep current behavior as fallback.
- [ ] [voice-agent-explicit-long-term-memory-routing-on] **Voice agent — explicit long-term memory routing.** On retrieval-shaped voice turns, inject top-N relevant memories into the system prompt via `brain_search`.
- [ ] [flux2-multi-reference-python-runner] **FLUX.2 multi-reference Python runner.** The UI + server contract for multi-reference editing shipped 2026-05-17 (slug `multi-reference-image-editing-for-flux-2-ui`); the Python runner (`scripts/flux2_macos.py`) currently ignores the `--reference-images`/`--reference-strengths` args that `local.js` now passes. Wire diffusers' multi-reference API in the runner and swap `server/lib/mediaModels.js#flux2-klein-9b` `tokenizerRepo` to `FLUX.2-klein-9B-kv` (gated repo — requires the user to accept the license on HF). Validate end-to-end with 2–4 uploaded refs.
diff --git a/client/src/components/calendar/EventDetail.jsx b/client/src/components/calendar/EventDetail.jsx
index aab1ae11d..ca64b8539 100644
--- a/client/src/components/calendar/EventDetail.jsx
+++ b/client/src/components/calendar/EventDetail.jsx
@@ -1,4 +1,5 @@
import { X, MapPin, Clock, Users, Repeat, CalendarDays } from 'lucide-react';
+import { formatEventDateTime } from '../../utils/formatters';
const RSVP_STYLES = {
accepted: 'bg-port-success/20 text-port-success',
@@ -7,11 +8,6 @@ const RSVP_STYLES = {
none: 'bg-gray-700 text-gray-400'
};
-function formatDateTime(dateStr, isAllDay) {
- if (isAllDay) return new Date(dateStr).toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
- return new Date(dateStr).toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
-}
-
export default function EventDetail({ event, onClose }) {
return (
@@ -47,13 +43,13 @@ export default function EventDetail({ event, onClose }) {
) : (
<>
- {formatDateTime(event.startTime, false)}
+ {formatEventDateTime(event.startTime)}
to
- {formatDateTime(event.endTime, false)}
+ {formatEventDateTime(event.endTime)}
>
)}
{event.isAllDay && (
- {formatDateTime(event.startTime, true)}
+ {formatEventDateTime(event.startTime, { allDay: true })}
)}
diff --git a/client/src/components/cos/tabs/TaskItem.jsx b/client/src/components/cos/tabs/TaskItem.jsx
index c65fea962..fc5904fdd 100644
--- a/client/src/components/cos/tabs/TaskItem.jsx
+++ b/client/src/components/cos/tabs/TaskItem.jsx
@@ -20,6 +20,7 @@ import {
import toast from '../../ui/Toast';
import * as api from '../../../services/api';
import { filterSelectableModels } from '../../../utils/providers';
+import { formatDurationMin } from '../../../utils/formatters';
const statusIcons = {
pending: ,
@@ -75,16 +76,6 @@ function extractTaskType(description) {
return 'feature';
}
-// Format duration in minutes
-function formatDurationMin(mins) {
- if (mins >= 60) {
- const hours = Math.floor(mins / 60);
- const remainingMins = mins % 60;
- return remainingMins > 0 ? `~${hours}h ${remainingMins}m` : `~${hours}h`;
- }
- return `~${mins}m`;
-}
-
// Format file size for display
function formatAttachmentSize(bytes) {
if (!bytes) return '';
@@ -261,7 +252,7 @@ export default function TaskItem({ task, isSystem, awaitingApproval, onRefresh,
title={`Based on ${durationEstimate.basedOn} completed ${durationEstimate.taskType} tasks`}
>
- {formatDurationMin(durationEstimate.estimatedMin)}
+ {formatDurationMin(durationEstimate.estimatedMin, { approximate: true })}
)}
{/* Success rate indicator for pending tasks */}
diff --git a/client/src/utils/formatters.js b/client/src/utils/formatters.js
index 3d56575fc..fc49a339c 100644
--- a/client/src/utils/formatters.js
+++ b/client/src/utils/formatters.js
@@ -239,17 +239,46 @@ export function formatDurationMs(ms) {
/**
* Format a duration in minutes as a human-readable string
- * @param {number} minutes - Duration in minutes
- * @returns {string} Formatted duration (e.g., "30m", "1h 30m", "2h")
+ * @param {number|null|undefined} minutes - Duration in minutes; nullish → ''
+ * @param {object} [options]
+ * @param {boolean} [options.approximate=false] - Prefix the result with `~`
+ * to signal an estimate (e.g., "~1h 30m") for predicted/averaged durations.
+ * @returns {string} Formatted duration (e.g., "30m", "1h 30m", "2h", "~2h")
*/
-export function formatDurationMin(minutes) {
+export function formatDurationMin(minutes, options = {}) {
if (minutes == null) return '';
+ const { approximate = false } = options ?? {};
+ const prefix = approximate ? '~' : '';
if (minutes >= 60) {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
- return m ? `${h}h ${m}m` : `${h}h`;
+ return m ? `${prefix}${h}h ${m}m` : `${prefix}${h}h`;
}
- return `${minutes}m`;
+ return `${prefix}${minutes}m`;
+}
+
+/**
+ * Format a calendar event's date+time, with a distinct all-day rendering.
+ * Tuned for the event-detail panel: timed events show a short weekday plus
+ * time (e.g. "Sat, Apr 1, 1:30 PM"); all-day events show a full weekday and
+ * year (e.g. "Saturday, April 1, 2026"). Kept separate from `formatDateTime`
+ * because the weekday-led shape is event-specific.
+ * Behavior-identical to the local formatter it replaced: any input is passed
+ * straight to `new Date(...)`, so malformed/empty values render the same
+ * `Invalid Date` / epoch string the original did. The two call sites always
+ * pass a real event time, so this degenerate path is never exercised — kept
+ * faithful so the migration introduces zero visual change.
+ * @param {string|Date|null} dateStr - ISO timestamp or Date object
+ * @param {object} [options]
+ * @param {boolean} [options.allDay=false] - Render date-only (all-day event).
+ * @returns {string} Formatted event date/time
+ */
+export function formatEventDateTime(dateStr, options = {}) {
+ const { allDay = false } = options ?? {};
+ const date = new Date(dateStr);
+ // All-day events render exactly like `formatDateFull` (full weekday + year).
+ if (allDay) return formatDateFull(date);
+ return date.toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
/**
diff --git a/client/src/utils/formatters.test.js b/client/src/utils/formatters.test.js
new file mode 100644
index 000000000..f9711dbf6
--- /dev/null
+++ b/client/src/utils/formatters.test.js
@@ -0,0 +1,67 @@
+import { describe, it, expect } from 'vitest';
+import { formatDurationMin, formatEventDateTime } from './formatters.js';
+
+describe('formatDurationMin', () => {
+ it('formats sub-hour, exact-hour, and hour+min durations', () => {
+ expect(formatDurationMin(30)).toBe('30m');
+ expect(formatDurationMin(60)).toBe('1h');
+ expect(formatDurationMin(90)).toBe('1h 30m');
+ expect(formatDurationMin(120)).toBe('2h');
+ });
+
+ it('returns empty string for null/undefined', () => {
+ expect(formatDurationMin(null)).toBe('');
+ expect(formatDurationMin(undefined)).toBe('');
+ });
+
+ it('does not prefix by default — existing callers stay unchanged', () => {
+ expect(formatDurationMin(90)).toBe('1h 30m');
+ expect(formatDurationMin(45)).toBe('45m');
+ });
+
+ it('prefixes with ~ when approximate (TaskItem estimate semantics)', () => {
+ expect(formatDurationMin(30, { approximate: true })).toBe('~30m');
+ expect(formatDurationMin(60, { approximate: true })).toBe('~1h');
+ expect(formatDurationMin(210, { approximate: true })).toBe('~3h 30m');
+ });
+
+ it('tolerates a null options argument', () => {
+ expect(formatDurationMin(90, null)).toBe('1h 30m');
+ });
+});
+
+describe('formatEventDateTime', () => {
+ // Local-time ISO (no trailing Z) so parsing is deterministic relative to
+ // the test runtime's timezone.
+ const sample = '2026-04-01T13:30:00';
+
+ it('renders a timed event with short weekday + time', () => {
+ expect(formatEventDateTime(sample)).toBe(
+ new Date(sample).toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
+ );
+ });
+
+ it('renders an all-day event as a full weekday + year date', () => {
+ expect(formatEventDateTime(sample, { allDay: true })).toBe(
+ new Date(sample).toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
+ );
+ });
+
+ it('all-day and timed renderings differ', () => {
+ expect(formatEventDateTime(sample, { allDay: true })).not.toBe(formatEventDateTime(sample));
+ });
+
+ it('tolerates a null options argument', () => {
+ expect(formatEventDateTime(sample, null)).toBe(formatEventDateTime(sample));
+ });
+
+ it('passes malformed input straight through, like the original local formatter (no guard, by design)', () => {
+ // The migration is deliberately behavior-identical: unparseable input
+ // yields the raw toLocaleString result ("Invalid Date"), not an empty
+ // string. Locks the no-guard decision so a future change does not re-add
+ // a guard and silently alter the (unreachable) degenerate path.
+ expect(formatEventDateTime('not-a-date')).toBe(
+ new Date('not-a-date').toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
+ );
+ });
+});