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
8 changes: 5 additions & 3 deletions app/(app)/dashboard/concierge/runs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,12 @@ async function Unavailable({ message, retryHref }: { message: string; retryHref:
);
}

function whenLabel(iso: string, locale: string): string {
// `tz` is the family's zone: without it a run's clock was the host's.
function whenLabel(iso: string, locale: string, tz: string): string {
const ms = Date.parse(iso);
if (!Number.isFinite(ms)) return '';
try {
return new Date(ms).toLocaleString(locale, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
return new Date(ms).toLocaleString(locale, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz });
} catch {
return new Date(ms).toISOString().slice(0, 16).replace('T', ' ');
}
Expand All @@ -97,6 +98,7 @@ export default async function RunHistoryPage({ searchParams }: { searchParams: P
const selfHref = historyHref(filter, before);

const ctx = await requireFeature('/dashboard/concierge/runs');
const tz = ctx.active.family.timezone || 'UTC';
const supabase = await createServer();
const familyId = ctx.active.familyId;

Expand Down Expand Up @@ -179,7 +181,7 @@ export default async function RunHistoryPage({ searchParams }: { searchParams: P
<StatusBadge state={item.state} />
{item.startedByRoutine && <span className="text-xs text-muted">{t('dashboardConciergeRuns.startedByARoutine')}</span>}
<span className="ml-auto text-xs tabular-nums text-muted">
<time dateTime={item.finishedAt ?? item.createdAt}>{whenLabel(item.finishedAt ?? item.createdAt, locale.code)}</time>
<time dateTime={item.finishedAt ?? item.createdAt}>{whenLabel(item.finishedAt ?? item.createdAt, locale.code, tz)}</time>
</span>
</div>
<p className="mt-1.5 text-sm font-semibold text-fg">{item.title ?? t('runHistory.aRequestFromYourFamily')}</p>
Expand Down
19 changes: 12 additions & 7 deletions app/(app)/dashboard/conflicts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { detectConflicts, quickFixMoveAfter, type TimedEvent } from '@/lib/famil
import { ConflictResolver, type ConflictView } from '@/components/family/conflict-resolver';
import { getLocaleContext, getTranslations } from '@/lib/i18n/server';
import type { LocaleCode } from '@/lib/i18n/locales';
import { dayKeyInTz } from '@/lib/services/scope';
import { ErrorState } from '@/components/ui/states';

export async function generateMetadata(): Promise<Metadata> {
Expand All @@ -16,14 +17,17 @@ export async function generateMetadata(): Promise<Metadata> {
}
export const dynamic = 'force-dynamic';

function whenLabel(startsAt: string, endsAt: string | null, locale: LocaleCode): string {
// `tz` is the family's zone. Without it every clock here was the HOST's — a
// 5:30pm practice in Los Angeles read "Sun, Oct 4, 12:30 AM" from a UTC host,
// and `toDateString()` decided "same day" on the host's calendar too.
function whenLabel(startsAt: string, endsAt: string | null, locale: LocaleCode, tz: string): string {
const s = new Date(startsAt);
const start = s.toLocaleString(locale, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
const start = s.toLocaleString(locale, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz });
if (!endsAt) return start;
const e = new Date(endsAt);
const sameDay = s.toDateString() === e.toDateString();
const end = e.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' });
return sameDay ? `${start} – ${end}` : `${start} → ${e.toLocaleString(locale, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}`;
const sameDay = dayKeyInTz(s, tz) === dayKeyInTz(e, tz);
const end = e.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
return sameDay ? `${start} – ${end}` : `${start} → ${e.toLocaleString(locale, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz })}`;
}

/** The soonest events to consider, and the most overlaps worth showing at once.
Expand All @@ -38,6 +42,7 @@ export default async function ConflictsPage() {
const ctx = await requireFeature('/dashboard/conflicts');
const supabase = await createServer();
const familyId = ctx.active.familyId;
const tz = ctx.active.family.timezone || 'UTC';

const now = new Date();
const in14 = new Date(now.getTime() + 14 * 24 * 3_600_000);
Expand Down Expand Up @@ -83,7 +88,7 @@ export default async function ConflictsPage() {
const ev = (e: TimedEvent) => ({
id: e.id,
title: e.title,
whenLabel: whenLabel(e.starts_at, e.ends_at, locale.code),
whenLabel: whenLabel(e.starts_at, e.ends_at, locale.code, tz),
location: e.location ?? null,
assignee: e.assignee_id ? nameById.get(e.assignee_id) ?? null : null,
});
Expand All @@ -98,7 +103,7 @@ export default async function ConflictsPage() {
label: qf.label,
startsAtIso: qf.startsAtIso,
endsAtIso: qf.endsAtIso,
newWhenLabel: whenLabel(qf.startsAtIso, qf.endsAtIso, locale.code),
newWhenLabel: whenLabel(qf.startsAtIso, qf.endsAtIso, locale.code, tz),
}
: null,
aiPayload: {
Expand Down
11 changes: 7 additions & 4 deletions app/(app)/dashboard/food/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,14 @@ import { makeDegradeRead } from '@/lib/meals/degrade-read';
import { cn } from '@/lib/utils/cn';
import { getLocaleContext, getTranslations } from '@/lib/i18n/server';
import { addDaysToDayKey, dayKeyInTz } from '@/lib/services/scope';
import type { LocaleCode } from '@/lib/i18n/locales';
import { getFormat } from '@/lib/utils/format-server';

export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
return { title: t('dashboardFood.foodAmpNutrition') };
}
export const dynamic = 'force-dynamic';

const fmtDay = (d: string | null, locale: LocaleCode) => (d ? new Date(d).toLocaleDateString(locale, { month: 'short', day: 'numeric' }) : '');

// Per-query fail-safe so one erroring/not-yet-migrated table (e.g. dining_out)
// degrades to an empty card instead of crashing the whole hub — and LOGS every
Expand Down Expand Up @@ -91,6 +90,10 @@ export default async function FoodPage() {
const todayIso = dayKeyInTz(now, tz);
const weekEnd = addDaysToDayKey(todayIso, 6);
const expSoon = addDaysToDayKey(todayIso, 14);
// And the labels: `plan_date` and `expires_at` are DATEs, which the shared
// formatter renders in no zone, where `new Date(d).toLocaleDateString(locale)`
// parsed them as UTC midnight and rendered them on the host's calendar.
const { fmtDate } = await getFormat(tz);

const [
{ data: dinners, count: planCount },
Expand Down Expand Up @@ -156,7 +159,7 @@ export default async function FoodPage() {
{/* 1. Meal Planner */}
<FeatureCard index={1} title={t('dashboardFood.mealPlanner')} href="/dashboard/meals" icon={CalendarRange} tint="bg-blue-500/15 text-blue-400" count={planCount ?? 0} countLabel={t('dashboardFood.count.dinnersPlannedThisWeek')}>
{planRows.length === 0 ? <EmptyHint>{t('dashboardFood.noDinnersPlannedYet')}</EmptyHint>
: planRows.slice(0, 4).map((p) => <ListRow key={p.plan_date} label={p.meal_id ? (mealName.get(p.meal_id) ?? t('dashboardFood.plannedMeal')) : t('dashboardFood.plannedMeal')} meta={p.plan_date === todayIso ? t('dashboardFood.today') : fmtDay(p.plan_date, locale.code)} dot="bg-blue-400" />)}
: planRows.slice(0, 4).map((p) => <ListRow key={p.plan_date} label={p.meal_id ? (mealName.get(p.meal_id) ?? t('dashboardFood.plannedMeal')) : t('dashboardFood.plannedMeal')} meta={p.plan_date === todayIso ? t('dashboardFood.today') : fmtDate(p.plan_date, 'MMM d')} dot="bg-blue-400" />)}
</FeatureCard>

{/* 2. Recipes */}
Expand All @@ -174,7 +177,7 @@ export default async function FoodPage() {
{/* 4. Pantry Inventory */}
<FeatureCard index={4} title={t('dashboardFood.pantryInventory')} href="/dashboard/pantry" icon={Boxes} tint="bg-orange-500/15 text-orange-400" count={pantryCount ?? 0} countLabel={t('dashboardFood.count.expiringSoon')}>
{(pantry ?? []).length === 0 ? <EmptyHint>{t('dashboardFood.nothingExpiringSoon')}</EmptyHint>
: (pantry as P[]).map((p) => <ListRow key={p.id} label={p.name} meta={p.expires_at ? fmtDay(p.expires_at, locale.code) : `${p.quantity}${p.unit ? ' ' + p.unit : ''}`} dot="bg-orange-400" />)}
: (pantry as P[]).map((p) => <ListRow key={p.id} label={p.name} meta={p.expires_at ? fmtDate(p.expires_at, 'MMM d') : `${p.quantity}${p.unit ? ' ' + p.unit : ''}`} dot="bg-orange-400" />)}
</FeatureCard>

{/* 5. Family Favorites */}
Expand Down
29 changes: 19 additions & 10 deletions app/(app)/dashboard/memories/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import {
} from '@/lib/memories/memories';
import { pickOnThisDay } from '@/lib/memories/on-this-day';
import { getLocaleContext, getTranslations } from '@/lib/i18n/server';
import type { LocaleCode } from '@/lib/i18n/locales';
import { getFormat } from '@/lib/utils/format-server';
import type { Format } from '@/lib/utils/format';
import { FamilyMediaImg } from '@/components/media/family-media-img';

export async function generateMetadata(): Promise<Metadata> {
Expand All @@ -35,13 +36,18 @@ const TABS: { key: TabKey; label: string; icon: typeof ImageIcon }[] = [
{ key: 'stories', label: 'Stories', icon: BookOpen },
];

function fmtDay(iso: string, locale: LocaleCode): string {
return new Date(iso).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric' });
// Rendered through the formatter the page binds to the FAMILY's zone, not with
// `toLocaleDateString(locale, …)`, which on the server renders in the host's.
type FmtDate = Format['fmtDate'];
function fmtDay(iso: string, fmtDate: FmtDate): string {
return fmtDate(iso, 'MMM d, yyyy');
}
function fmtEventRange(start: string, end: string | null, locale: LocaleCode): string {
const s = new Date(start).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric' });
if (!end || end.slice(0, 10) === start.slice(0, 10)) return s;
return `${new Date(start).toLocaleDateString(locale, { month: 'short', day: 'numeric' })} – ${new Date(end).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric' })}`;
function fmtEventRange(start: string, end: string | null, fmtDate: FmtDate): string {
const s = fmtDate(start, 'MMM d, yyyy');
// Same day in the family's zone — compared on the rendered day, so an event
// that crosses Greenwich's midnight but not the family's stays one day.
if (!end || fmtDate(end, 'MMM d, yyyy') === s) return s;
return `${fmtDate(start, 'MMM d')} – ${fmtDate(end, 'MMM d, yyyy')}`;
}

const EVENT_ICON: Record<string, typeof Cake> = { birthday: Cake, holiday: Cake, school: GraduationCap, sports: GraduationCap };
Expand All @@ -68,6 +74,9 @@ export default async function MemoriesPage({ searchParams }: { searchParams: Pro
const now = new Date();
const yearStart = new Date(now.getFullYear(), 0, 1).toISOString();
const nowIso = now.toISOString();
// Every date this page prints is the family's, not the host's.
const tz = ctx.active.family.timezone || 'UTC';
const { fmtDate } = await getFormat(tz);

const [
albumsRes,
Expand Down Expand Up @@ -193,7 +202,7 @@ export default async function MemoriesPage({ searchParams }: { searchParams: Pro
<div className="flex w-28 shrink-0 flex-col">
<div className="flex items-center gap-2">
<span className="grid h-4 w-4 place-items-center rounded-full border-2 border-brand"><span className="h-1.5 w-1.5 rounded-full bg-brand" /></span>
<span className="text-sm font-semibold">{new Date(row.album.created_at).toLocaleDateString(locale.code, { month: 'short', day: 'numeric', year: 'numeric' })}</span>
<span className="text-sm font-semibold">{fmtDate(row.album.created_at, 'MMM d, yyyy')}</span>
</div>
<span className="ml-6 text-xs text-muted">{row.relative}</span>
</div>
Expand Down Expand Up @@ -281,7 +290,7 @@ export default async function MemoriesPage({ searchParams }: { searchParams: Pro
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-3">
<p className="truncate text-sm font-semibold text-white">{a.name}</p>
<div className="mt-0.5 flex items-center justify-between text-[11px] text-white/80">
<span>{fmtDay(a.created_at, locale.code)}</span>
<span>{fmtDay(a.created_at, fmtDate)}</span>
<span className="inline-flex items-center gap-1 rounded-md bg-black/40 px-1.5 py-0.5"><ImageIcon className="h-3 w-3" />{a.photo_count}</span>
</div>
</div>
Expand Down Expand Up @@ -389,7 +398,7 @@ export default async function MemoriesPage({ searchParams }: { searchParams: Pro
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-brand/10 text-brand-text"><Icon className="h-4 w-4" /></span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{e.title}</span>
<span className="block truncate text-xs text-muted">{fmtEventRange(e.starts_at, e.ends_at, locale.code)}</span>
<span className="block truncate text-xs text-muted">{fmtEventRange(e.starts_at, e.ends_at, fmtDate)}</span>
</span>
</Link>
);
Expand Down
19 changes: 9 additions & 10 deletions app/(app)/dashboard/planning/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,13 @@ import { cn } from '@/lib/utils/cn';
import { getLocaleContext, getTranslations } from '@/lib/i18n/server';
import { getFormat } from '@/lib/utils/format-server';
import { addDaysToDayKey, dayKeyInTz } from '@/lib/services/scope';
import type { LocaleCode } from '@/lib/i18n/locales';

export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
return { title: t('dashboardPlanning.planningAmpOrganization') };
}
export const dynamic = 'force-dynamic';

const fmtDay = (d: string | null, locale: LocaleCode) =>
d ? new Date(d).toLocaleDateString(locale, { month: 'short', day: 'numeric' }) : '';

// Per-query fail-safe: a single domain erroring (e.g. a table not yet migrated on
// this database, or a slow/failed count) degrades that one card to empty instead
// of taking down the whole hub via the error boundary.
Expand Down Expand Up @@ -94,7 +90,10 @@ export default async function PlanningPage() {
// The same zone the day key uses, for the clock printed on each event. It was
// the bare `fmtTime` import, which renders in the RUNTIME's zone — the server's
// here — so this page selected the family's day and then timed it in UTC.
const { fmtTime } = await getFormat(tz);
// `fmtDate` too: the day labels below were `toLocaleDateString(locale, …)`,
// which on the server is the HOST's day. A DATE column (`due_date`) renders in
// no zone at all; an instant renders in the family's.
const { fmtTime, fmtDate } = await getFormat(tz);

const [
{ data: events, count: eventCount },
Expand Down Expand Up @@ -163,25 +162,25 @@ export default async function PlanningPage() {
{/* 1. Calendar */}
<FeatureCard index={1} title={tr('dashboardPlanning.calendar')} href="/dashboard/calendar" icon={CalendarIcon} tint="bg-blue-500/15 text-blue-400" count={eventCount ?? 0} countLabel="upcoming events">
{(events ?? []).length === 0 ? <EmptyHint>{tr('dashboardPlanning.noUpcomingEvents')}</EmptyHint>
: (events as Ev[]).map((e) => <ListRow key={e.id} label={e.title} meta={e.all_day ? fmtDay(e.starts_at, locale.code) : fmtTime(e.starts_at)} dot="bg-blue-400" />)}
: (events as Ev[]).map((e) => <ListRow key={e.id} label={e.title} meta={e.all_day ? fmtDate(e.starts_at, 'MMM d') : fmtTime(e.starts_at)} dot="bg-blue-400" />)}
</FeatureCard>

{/* 2. Tasks */}
<FeatureCard index={2} title={tr('dashboardPlanning.tasks')} href="/dashboard/todos" icon={CheckSquare} tint="bg-emerald-500/15 text-emerald-400" count={taskCount ?? 0} countLabel="open tasks">
{(tasks ?? []).length === 0 ? <EmptyHint>{tr('dashboardPlanning.noOpenTasks')}</EmptyHint>
: (tasks as Td[]).map((t) => <ListRow key={t.id} label={t.title} meta={t.due_date === todayIso ? 'Today' : fmtDay(t.due_date, locale.code)} dot="bg-emerald-400" />)}
: (tasks as Td[]).map((t) => <ListRow key={t.id} label={t.title} meta={t.due_date === todayIso ? 'Today' : fmtDate(t.due_date, 'MMM d')} dot="bg-emerald-400" />)}
</FeatureCard>

{/* 3. Reminders */}
<FeatureCard index={3} title={tr('dashboardPlanning.reminders')} href="/dashboard/reminders" icon={BellRing} tint="bg-amber-500/15 text-amber-400" count={reminderCount ?? 0} countLabel="active reminders">
{(reminders ?? []).length === 0 ? <EmptyHint>{tr('dashboardPlanning.noActiveReminders')}</EmptyHint>
: (reminders as Rm[]).map((r) => <ListRow key={r.id} label={r.title} meta={r.remind_at ? fmtDay(r.remind_at, locale.code) : ''} dot="bg-amber-400" />)}
: (reminders as Rm[]).map((r) => <ListRow key={r.id} label={r.title} meta={r.remind_at ? fmtDate(r.remind_at, 'MMM d') : ''} dot="bg-amber-400" />)}
</FeatureCard>

{/* 4. Notes */}
<FeatureCard index={4} title={tr('dashboardPlanning.notes')} href="/dashboard/notes" icon={StickyNote} tint="bg-violet-500/15 text-violet-400" count={noteCount ?? 0} countLabel="notes">
{(notes ?? []).length === 0 ? <EmptyHint>{tr('dashboardPlanning.noNotesYet')}</EmptyHint>
: (notes as Nt[]).map((n) => <ListRow key={n.id} label={(n.title ?? n.body ?? 'Untitled').slice(0, 60)} meta={fmtDay(n.updated_at, locale.code)} dot="bg-violet-400" />)}
: (notes as Nt[]).map((n) => <ListRow key={n.id} label={(n.title ?? n.body ?? 'Untitled').slice(0, 60)} meta={fmtDate(n.updated_at, 'MMM d')} dot="bg-violet-400" />)}
</FeatureCard>

{/* 5. Documents */}
Expand All @@ -199,7 +198,7 @@ export default async function PlanningPage() {
{/* 7. Milestones */}
<FeatureCard index={7} title={tr('dashboardPlanning.milestones')} href="/dashboard/celebrations" icon={Award} tint="bg-fuchsia-500/15 text-fuchsia-400" count={milestoneCount ?? 0} countLabel="upcoming milestones">
{(milestones ?? []).length === 0 ? <EmptyHint>{tr('dashboardPlanning.noUpcomingMilestones')}</EmptyHint>
: (milestones as Ms[]).map((m) => <ListRow key={m.id} label={m.title} meta={fmtDay(m.milestone_date, locale.code)} dot="bg-fuchsia-400" />)}
: (milestones as Ms[]).map((m) => <ListRow key={m.id} label={m.title} meta={fmtDate(m.milestone_date, 'MMM d')} dot="bg-fuchsia-400" />)}
</FeatureCard>

{/* 8. Family Wall */}
Expand Down
5 changes: 4 additions & 1 deletion app/(app)/dashboard/playbook/playbook-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export async function refreshPlaybookAction(): Promise<Result> {
const couldNotRead = translate(messages, 'playbookActions.couldNotReadEverything');
const ctx = await requireUserContext();
const familyId = ctx.active.familyId;
// The month a tradition falls in is read in the FAMILY's zone: a New Year's
// Eve party at 9pm in California is January on a UTC host.
const tz = ctx.active.family.timezone || 'UTC';
const sb = await createServer();
const since = new Date(Date.now() - 180 * DAY).toISOString();
const since3y = new Date(Date.now() - 3 * 365 * DAY).toISOString();
Expand Down Expand Up @@ -143,7 +146,7 @@ export async function refreshPlaybookAction(): Promise<Result> {
for (const t of byTitle.values()) {
const years = t.yearly ? Math.max(2, t.years.size) : t.years.size;
if (years < 2) continue;
const when = new Date(t.earliest).toLocaleDateString(locale.code, MONTH_FMT);
const when = new Date(t.earliest).toLocaleDateString(locale.code, { ...MONTH_FMT, timeZone: tz });
signals.push({ type: 'tradition', title: t.title, when, years });
}

Expand Down
Loading
Loading