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
77 changes: 75 additions & 2 deletions app/frontend/src/dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,49 @@ import { atMost, required, useFormErrors } from '../validation';
import { widgetKinds, type Widget, type WidgetSize, type WidgetType } from './widgets';
import { LoadError } from '../components/LoadError';

type Device = 'any' | 'phone' | 'desktop';

interface Dashboard {
id: string;
name: string;
position: number;
layout: { widgets: Widget[] };
showFrom: number | null;
showUntil: number | null;
showOn: Device;
}

const chosenKey = 'strata-dashboard';

function rememberedDashboard() {
try {
return sessionStorage.getItem(chosenKey);
} catch {
return null;
}
}

function rememberDashboard(id: string) {
try {
sessionStorage.setItem(chosenKey, id);
} catch {
return;
}
}

export function automaticDashboard<T extends Pick<Dashboard, 'showFrom' | 'showUntil' | 'showOn'>>(dashboards: T[], now: Date, phone: boolean) {
const minute = now.getHours() * 60 + now.getMinutes();
const fits = dashboards.filter(
({ showFrom, showUntil, showOn }) =>
(showFrom === null || showUntil === null || (showFrom < showUntil ? minute >= showFrom && minute < showUntil : minute >= showFrom || minute < showUntil)) &&
(showOn === 'any' || showOn === (phone ? 'phone' : 'desktop')),
);
return fits.find((dashboard) => dashboard.showFrom !== null || dashboard.showOn !== 'any') ?? fits[0] ?? dashboards[0];
}

const clockTime = (minutes: number | null) => (minutes === null ? '' : `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`);
const minutesOf = (time: string) => (time ? Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5)) : null);

const templates = [
['morning', 'Morning briefing', 'Clock, Today, Agenda, Renewals and spend, Inbox'],
['work', 'Work day', 'Today, Task list, Shortcuts, Clock'],
Expand Down Expand Up @@ -134,11 +170,20 @@ export function DashboardHome() {
const [name, setName] = useState('');
const [deleting, setDeleting] = useState(false);
const [adding, setAdding] = useState(false);
const [schedule, setSchedule] = useState({ from: '', until: '', on: 'any' as Device });
const addForm = useFormErrors();
const chosen = params.get('d');

useEffect(() => {
if (chosen) rememberDashboard(chosen);
}, [chosen]);

if (dashboards.error) return <LoadError error={dashboards.error} onRetry={dashboards.reload} />;
if (!dashboards.data) return <PageSkeleton label="Loading your dashboard" />;
const current = dashboards.data.find((dashboard) => dashboard.id === params.get('d')) ?? dashboards.data[0]!;
const picked = chosen ?? rememberedDashboard();
const current =
dashboards.data.find((dashboard) => dashboard.id === picked) ??
automaticDashboard(dashboards.data, new Date(), window.matchMedia('(max-width: 767px)').matches)!;
const widgets = draft ?? current.layout.widgets;
const editing = draft !== undefined;
if (params.get('display') === 'wall' && !editing) return <WallDisplay dashboard={current} />;
Expand All @@ -157,9 +202,17 @@ export function DashboardHome() {
}
void saving
.run(async () => {
if (Boolean(schedule.from) !== Boolean(schedule.until)) throw new Error('Set both times, or neither.');
const showFrom = minutesOf(schedule.from);
const showUntil = minutesOf(schedule.until);
const saved = await auth.request<Dashboard>(`/me/dashboards/${current.id}`, {
method: 'PATCH',
body: JSON.stringify({ layout: { widgets }, ...(name.trim() ? { name: name.trim() } : {}) }),
body: JSON.stringify({
layout: { widgets },
...(name.trim() ? { name: name.trim() } : {}),
...(showFrom !== current.showFrom || showUntil !== current.showUntil ? { showFrom, showUntil } : {}),
...(schedule.on !== current.showOn ? { showOn: schedule.on } : {}),
}),
});
dashboards.mutate((all) => all.map((dashboard) => (dashboard.id === saved.id ? saved : dashboard)));
return '';
Expand Down Expand Up @@ -203,6 +256,7 @@ export function DashboardHome() {
className="secondary-button px-3 py-2 text-sm"
onClick={() => {
setName(current.name);
setSchedule({ from: clockTime(current.showFrom), until: clockTime(current.showUntil), on: current.showOn });
setDraft(current.layout.widgets);
}}
type="button"
Expand All @@ -229,6 +283,25 @@ export function DashboardHome() {
Name
<input maxLength={40} onChange={(event) => setName(event.currentTarget.value)} value={name} />
</label>
<fieldset className="m-0 flex flex-wrap items-end gap-3 border-0 p-0">
<legend className="field-label mb-1.5 p-0">Opens by itself</legend>
<label className="field-label">
From
<input onChange={(event) => setSchedule({ ...schedule, from: event.currentTarget.value })} type="time" value={schedule.from} />
</label>
<label className="field-label">
Until
<input onChange={(event) => setSchedule({ ...schedule, until: event.currentTarget.value })} type="time" value={schedule.until} />
</label>
<label className="field-label">
On
<select onChange={(event) => setSchedule({ ...schedule, on: event.currentTarget.value as Device })} value={schedule.on}>
<option value="any">Any device</option>
<option value="phone">Phones</option>
<option value="desktop">Computers</option>
</select>
</label>
</fieldset>
{dashboards.data.length > 1 && (
<button className="secondary-button px-3 py-2 text-sm" onClick={() => setDeleting(true)} type="button">
Delete this dashboard
Expand Down
87 changes: 86 additions & 1 deletion app/frontend/test/dashboard.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { App } from '../src/App';
import { automaticDashboard } from '../src/dashboard/Dashboard';
import { AuthProvider } from '../src/auth';

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
Expand All @@ -16,7 +17,7 @@ const widgets = [
{ id: 'links', type: 'shortcuts', size: 'wide', settings: { links: [{ label: 'Mail', url: 'https://mail.example.com' }] } },
{ id: 'inbox', type: 'inbox', size: 'medium', settings: {} },
];
const home = { id: 'home', name: 'Home', position: 0, layout: { widgets } };
const home = { id: 'home', name: 'Home', position: 0, layout: { widgets }, showFrom: null, showUntil: null, showOn: 'any' };

describe('Home dashboard', () => {
let container: HTMLDivElement;
Expand Down Expand Up @@ -330,4 +331,88 @@ describe('Home dashboard', () => {
await act(async () => button('Save as task').click());
expect(fetchMock).toHaveBeenCalledWith('/api/v1/spaces/flat/tasks', expect.objectContaining({ method: 'POST', body: JSON.stringify({ title: 'Buy bin bags' }) }));
});

it('opens the dashboard that fits the time and device unless one was chosen in this visit', async () => {
const plain = { id: 'plain', showFrom: null, showUntil: null, showOn: 'any' as const };
const night = { id: 'night', showFrom: 22 * 60, showUntil: 6 * 60, showOn: 'any' as const };
const commute = { id: 'commute', showFrom: 7 * 60, showUntil: 9 * 60, showOn: 'phone' as const };
const all = [plain, night, commute];
expect(automaticDashboard(all, new Date(2026, 9, 5, 23, 30), false)?.id).toBe('night');
expect(automaticDashboard(all, new Date(2026, 9, 5, 5, 59), true)?.id).toBe('night');
expect(automaticDashboard(all, new Date(2026, 9, 5, 8, 0), true)?.id).toBe('commute');
expect(automaticDashboard(all, new Date(2026, 9, 5, 8, 0), false)?.id).toBe('plain');
expect(automaticDashboard([commute], new Date(2026, 9, 5, 12, 0), false)?.id).toBe('commute');

vi.useFakeTimers({ now: new Date(2026, 9, 5, 23, 0), toFake: ['Date'] });
sessionStorage.clear();
let saved: unknown;
const late = { ...home, id: 'late', name: 'Late', position: 1, showFrom: 22 * 60, showUntil: 6 * 60 };
vi.stubGlobal(
'fetch',
vi.fn((input: string, init?: RequestInit) => {
const path = input.replace('/api/v1', '');
if (path === '/auth/refresh') return Promise.resolve(json({ accessToken: 'token', user }));
if (path === '/spaces') return Promise.resolve(json([]));
if (path === '/me/inbox/summary') return Promise.resolve(json({ unread: 0 }));
if (path === '/me/dashboards') return Promise.resolve(json([{ ...home, layout: { widgets: [] } }, { ...late, layout: { widgets: [] } }]));
if (path === '/me/dashboards/late' && init?.method === 'PATCH') {
saved = JSON.parse(String(init.body));
return Promise.resolve(json({ ...late, layout: { widgets: [] }, showFrom: 21 * 60, showOn: 'desktop' }));
}
return Promise.resolve(new Response(null, { status: 404 }));
}),
);
try {
await act(async () =>
root.render(
<MemoryRouter initialEntries={['/']}>
<AuthProvider>
<App />
</AuthProvider>
</MemoryRouter>,
),
);
expect(container.querySelector('h1')?.textContent).toBe('Late');

await act(async () => button('Edit').click());
const time = (label: string) => [...container.querySelectorAll('label')].find((candidate) => candidate.textContent?.startsWith(label))!.querySelector('input')!;
expect(time('From').value).toBe('22:00');
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(time('From'), '21:00');
time('From').dispatchEvent(new Event('input', { bubbles: true }));
const device = [...container.querySelectorAll('label')].find((candidate) => candidate.textContent?.startsWith('On'))!.querySelector('select')!;
device.value = 'desktop';
device.dispatchEvent(new Event('change', { bubbles: true }));
});
await act(async () => button('Done').click());
expect(saved).toEqual({ layout: { widgets: [] }, name: 'Late', showFrom: 1260, showUntil: 360, showOn: 'desktop' });

act(() => root.unmount());
root = createRoot(container);
await act(async () =>
root.render(
<MemoryRouter initialEntries={['/?d=home']}>
<AuthProvider>
<App />
</AuthProvider>
</MemoryRouter>,
),
);
act(() => root.unmount());
root = createRoot(container);
await act(async () =>
root.render(
<MemoryRouter initialEntries={['/']}>
<AuthProvider>
<App />
</AuthProvider>
</MemoryRouter>,
),
);
expect(container.querySelector('h1')?.textContent).toBe('Home');
} finally {
vi.useRealTimers();
sessionStorage.clear();
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE "dashboards" ADD COLUMN "show_from" INTEGER,
ADD COLUMN "show_until" INTEGER,
ADD COLUMN "show_on" TEXT NOT NULL DEFAULT 'any';
3 changes: 3 additions & 0 deletions app/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,9 @@ model Dashboard {
name String
position Int @default(0)
layout Json
showFrom Int? @map("show_from")
showUntil Int? @map("show_until")
showOn String @default("any") @map("show_on")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
Expand Down
16 changes: 14 additions & 2 deletions app/server/src/dashboards/dashboards.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const starters: Record<Template, Pick<WidgetDto, 'type' | 'size' | 'settings'>[]
],
};

const dashboardFields = { id: true, name: true, position: true, layout: true, updatedAt: true } satisfies Prisma.DashboardSelect;
const dashboardFields = { id: true, name: true, position: true, layout: true, showFrom: true, showUntil: true, showOn: true, updatedAt: true } satisfies Prisma.DashboardSelect;

function starterLayout(template: Template): Prisma.InputJsonObject {
return {
Expand Down Expand Up @@ -161,9 +161,21 @@ export class DashboardsService {
}

async update(userId: string, dashboardId: string, input: UpdateDashboardDto) {
if ((input.showFrom ?? null) === null ? (input.showUntil ?? null) !== null : (input.showUntil ?? null) === null) {
throw new BadRequestException('Set both the start and the end of the time range');
}
if (input.showFrom != null && input.showFrom === input.showUntil) {
throw new BadRequestException('The time range needs a start and an end that differ');
}
const [updated] = await this.prisma.dashboard.updateManyAndReturn({
where: { id: dashboardId, userId },
data: { name: input.name, layout: input.layout ? checkedLayout(input.layout) : undefined },
data: {
name: input.name,
layout: input.layout ? checkedLayout(input.layout) : undefined,
showFrom: input.showFrom,
showUntil: input.showUntil,
showOn: input.showOn,
},
select: dashboardFields,
});
if (!updated) throw new NotFoundException('Dashboard not found');
Expand Down
21 changes: 20 additions & 1 deletion app/server/src/dashboards/dto/dashboards.dto.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Type } from 'class-transformer';
import { ArrayMaxSize, IsArray, IsIn, IsObject, IsOptional, IsString, Length, ValidateNested } from 'class-validator';
import { ArrayMaxSize, IsArray, IsIn, IsInt, IsObject, IsOptional, IsString, Length, Max, Min, ValidateIf, ValidateNested } from 'class-validator';
import { IsOptionalNotNull } from '../../validation/is-optional-not-null.decorator';
import { Trimmed } from '../../validation/trimmed.decorator';

export const widgetTypes = ['clock', 'today', 'tasks', 'shortcuts', 'recurring', 'inbox', 'agenda', 'countdown', 'focus', 'tidy', 'weather', 'news', 'capture'] as const;
export const widgetSizes = ['small', 'medium', 'wide', 'full'] as const;
export const templates = ['morning', 'work', 'student', 'travel'] as const;
export const devices = ['any', 'phone', 'desktop'] as const;

export class WidgetDto {
@IsString()
Expand Down Expand Up @@ -54,4 +55,22 @@ export class UpdateDashboardDto {
@ValidateNested()
@Type(() => LayoutDto)
layout?: LayoutDto;

@IsOptional()
@ValidateIf((_input, value) => value !== null)
@IsInt()
@Min(0)
@Max(1439)
showFrom?: number | null;

@IsOptional()
@ValidateIf((_input, value) => value !== null)
@IsInt()
@Min(0)
@Max(1439)
showUntil?: number | null;

@IsOptionalNotNull()
@IsIn(devices, { message: 'Choose any device, phones or computers.' })
showOn?: (typeof devices)[number];
}
21 changes: 21 additions & 0 deletions app/server/test/integration/dashboards.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,25 @@ describe('Dashboards against Postgres', () => {
expect((await owner.call('DELETE', `/me/dashboards/${home.id}`)).status).toBe(204);
expect((await owner.call('GET', '/me/dashboards')).body.map((dashboard: { name: string }) => dashboard.name)).toEqual(['Trip']);
});

it('keeps when and where a dashboard opens by itself', async () => {
const owner = await member('scheduler');
const [home] = (await owner.call('GET', '/me/dashboards')).body;
expect(home).toMatchObject({ showFrom: null, showUntil: null, showOn: 'any' });

const morning = await owner.call('PATCH', `/me/dashboards/${home.id}`, { showFrom: 22 * 60, showUntil: 6 * 60, showOn: 'phone' });
expect(morning.body).toMatchObject({ showFrom: 1320, showUntil: 360, showOn: 'phone' });
expect((await owner.call('PATCH', `/me/dashboards/${home.id}`, { name: 'Night' })).body).toMatchObject({ name: 'Night', showFrom: 1320, showOn: 'phone' });

expect((await owner.call('PATCH', `/me/dashboards/${home.id}`, { showFrom: 60 })).status).toBe(400);
expect((await owner.call('PATCH', `/me/dashboards/${home.id}`, { showFrom: 60, showUntil: 60 })).status).toBe(400);
expect((await owner.call('PATCH', `/me/dashboards/${home.id}`, { showFrom: 60, showUntil: 1440 })).status).toBe(400);
expect((await owner.call('PATCH', `/me/dashboards/${home.id}`, { showOn: 'tablet' })).status).toBe(400);

expect((await owner.call('PATCH', `/me/dashboards/${home.id}`, { showFrom: null, showUntil: null, showOn: 'any' })).body).toMatchObject({
showFrom: null,
showUntil: null,
showOn: 'any',
});
});
});
Loading