Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 4 additions & 8 deletions client/src/components/calendar/EventDetail.jsx
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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 (
<div className="fixed inset-0 z-50 flex justify-end">
Expand Down Expand Up @@ -47,13 +43,13 @@ export default function EventDetail({ event, onClose }) {
</div>
) : (
<>
<div>{formatDateTime(event.startTime, false)}</div>
<div>{formatEventDateTime(event.startTime)}</div>
<div className="text-gray-500">to</div>
<div>{formatDateTime(event.endTime, false)}</div>
<div>{formatEventDateTime(event.endTime)}</div>
</>
)}
{event.isAllDay && (
<div className="mt-1 text-gray-500">{formatDateTime(event.startTime, true)}</div>
<div className="mt-1 text-gray-500">{formatEventDateTime(event.startTime, { allDay: true })}</div>
)}
</div>
</div>
Expand Down
13 changes: 2 additions & 11 deletions client/src/components/cos/tabs/TaskItem.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <Clock size={16} aria-hidden="true" className="text-yellow-500" />,
Expand Down Expand Up @@ -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 '';
Expand Down Expand Up @@ -261,7 +252,7 @@ export default function TaskItem({ task, isSystem, awaitingApproval, onRefresh,
title={`Based on ${durationEstimate.basedOn} completed ${durationEstimate.taskType} tasks`}
>
<Timer size={10} aria-hidden="true" />
{formatDurationMin(durationEstimate.estimatedMin)}
{formatDurationMin(durationEstimate.estimatedMin, { approximate: true })}
</span>
)}
{/* Success rate indicator for pending tasks */}
Expand Down
39 changes: 34 additions & 5 deletions client/src/utils/formatters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
Comment on lines 241 to 249
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' });
}

/**
Expand Down
67 changes: 67 additions & 0 deletions client/src/utils/formatters.test.js
Original file line number Diff line number Diff line change
@@ -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' })
);
});
});
Comment on lines +33 to +67