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
2 changes: 1 addition & 1 deletion app/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@excalidraw/excalidraw": "0.18.1",
"@hocuspocus/provider": "4.7.0",
"@mozilla/readability": "0.6.0",
"@project-graphite/ui": "https://github.com/project-graphite/graphite-ui/releases/download/v0.4.2/project-graphite-ui-0.4.2.tgz",
"@project-graphite/ui": "https://github.com/project-graphite/graphite-ui/releases/download/v0.4.3/project-graphite-ui-0.4.3.tgz",
"@tiptap/core": "3.31.4",
"@tiptap/extension-collaboration": "3.31.4",
"@tiptap/extension-collaboration-caret": "3.31.4",
Expand Down
29 changes: 29 additions & 0 deletions app/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { BookmarkPage } from './pages/BookmarkPage';
import { BookmarksPage } from './pages/BookmarksPage';
import { CalendarsPage } from './pages/CalendarsPage';
import { EventPage } from './pages/EventPage';
import { GoalPage } from './pages/GoalPage';
import { GoalsPage } from './pages/GoalsPage';
import { HomePage } from './pages/HomePage';
import { InboxPage } from './pages/InboxPage';
import { InvitationsPage } from './pages/InvitationsPage';
Expand All @@ -28,6 +30,7 @@ import { RsvpPage, SharePage } from './pages/RsvpPages';
import { SavePage } from './pages/SavePage';
import { DataSettings } from './pages/settings/DataSettings';
import { DeveloperSettings } from './pages/settings/DeveloperSettings';
import { FeaturesSettings } from './pages/settings/FeaturesSettings';
import { ProfileSettings } from './pages/settings/ProfileSettings';
import { SecuritySettings } from './pages/settings/SecuritySettings';
import { SettingsLayout } from './pages/settings/SettingsLayout';
Expand Down Expand Up @@ -55,6 +58,11 @@ function Protected({ children }: { children: ReactNode }) {
return auth.user ? children : <Navigate replace state={{ from: location }} to="/login" />;
}

function FeatureRoute({ children, feature }: { children: ReactNode; feature: string }) {
const auth = useAuth();
return auth.user?.features.includes(feature) ? children : <Navigate replace state={{ feature }} to="/settings/features" />;
}

function SignedOutLogin() {
const auth = useAuth();
const location = useLocation();
Expand Down Expand Up @@ -110,6 +118,26 @@ export function App() {
<Route path="invite/:code" element={<InvitePage />} />
<Route path="rsvp/:code" element={<RsvpPage />} />
<Route path="share/:code" element={<SharePage />} />
<Route
path="goals"
element={
<Protected>
<FeatureRoute feature="goals">
<GoalsPage />
</FeatureRoute>
</Protected>
}
/>
<Route
path="goals/:id"
element={
<Protected>
<FeatureRoute feature="goals">
<GoalPage />
</FeatureRoute>
</Protected>
}
/>
<Route
path="agenda"
element={
Expand Down Expand Up @@ -279,6 +307,7 @@ export function App() {
}
>
<Route index element={<ProfileSettings />} />
<Route path="features" element={<FeaturesSettings />} />
<Route path="security" element={<SecuritySettings />} />
<Route path="developer" element={<DeveloperSettings />} />
<Route path="sessions" element={<Navigate replace to="/settings/security" />} />
Expand Down
3 changes: 2 additions & 1 deletion app/frontend/src/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface User {
displayName: string;
role: UserRole;
timeZone: string;
features: string[];
}

interface Session {
Expand Down Expand Up @@ -49,7 +50,7 @@ interface AuthContextValue {
logout(): Promise<void>;
changePassword(proof: Proof, newPassword: string): Promise<void>;
deleteAccount(proof: Proof): Promise<void>;
updateUser(changes: Partial<Pick<User, 'displayName' | 'timeZone'>>): void;
updateUser(changes: Partial<Pick<User, 'displayName' | 'features' | 'timeZone'>>): void;
accessToken(): string;
}

Expand Down
106 changes: 106 additions & 0 deletions app/frontend/src/components/GoalEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useState } from 'react';
import { Select, TextAreaField, TextField } from '@project-graphite/ui';
import { useAuth } from '../auth';
import { periodChoices, periodLabel, periodStart, type Goal, type GoalPeriod } from '../goals';
import { spaceOptions, type Space } from '../spaces';
import { useAction } from '../useAction';
import { dayKey } from '../agenda';
import { atMost, required, useFormErrors, type Check } from '../validation';
import { FormDialog } from './FormDialog';

const numberCheck: Check = (value) => (!value.trim() || Number.isFinite(Number(value.replace(',', '.'))) ? undefined : 'Enter a number such as 12.5.');
const toNumber = (value: FormDataEntryValue | null) => {
const text = String(value ?? '').trim().replace(',', '.');
return text ? Number(text) : null;
};

const periods: { value: GoalPeriod; label: string }[] = [
{ value: 'year', label: 'A year' },
{ value: 'quarter', label: 'A quarter' },
{ value: 'month', label: 'A month' },
];

export function GoalEditor({ goal, onClose, onSaved, spaces }: { goal?: Goal; onClose: () => void; onSaved: (goal: Goal) => void; spaces: Space[] }) {
const auth = useAuth();
const saving = useAction();
const form = useFormErrors();
const today = dayKey(new Date());
const [period, setPeriod] = useState<GoalPeriod>(goal?.period ?? 'year');
const [startsOn, setStartsOn] = useState(goal?.startsOn ?? periodStart('year', today));
const [measure, setMeasure] = useState<'tasks' | 'number'>(goal?.target ? 'number' : 'tasks');
const choices = periodChoices(period, today);

return (
<FormDialog
busy={saving.busy}
busyLabel="Saving…"
onClose={onClose}
onSubmit={(target) => {
if (
!form.check(target, {
title: [required('Name the goal.'), atMost(200, 'Use at most 200 characters.')],
...(measure === 'number' ? { target: [required('Enter the number to reach.'), numberCheck], current: [numberCheck] } : {}),
})
) {
return;
}
const values = new FormData(target);
const body = {
title: String(values.get('title')).trim(),
period,
startsOn,
why: String(values.get('why') ?? '').trim(),
target: measure === 'number' ? toNumber(values.get('target')) : null,
current: measure === 'number' ? toNumber(values.get('current')) : null,
unit: measure === 'number' ? String(values.get('unit') ?? '').trim() || null : null,
};
void saving
.run(async () => {
onSaved(
await auth.request<Goal>(goal ? `/goals/${goal.id}` : `/spaces/${String(values.get('spaceId'))}/goals`, {
method: goal ? 'PATCH' : 'POST',
body: JSON.stringify(body),
}),
);
return goal ? '' : 'Goal added.';
}, 'Could not save the goal')
.then((saved) => saved && onClose());
}}
submitLabel={goal ? 'Save' : 'Add goal'}
title={goal ? 'Edit goal' : 'New goal'}
>
<TextField autoFocus defaultValue={goal?.title} label="Goal" maxLength={200} placeholder="Run a half marathon" {...form.field('title')} />
{!goal && spaces.length > 1 && <Select defaultValue={spaces.find((space) => space.kind === 'personal')?.id ?? spaces[0]!.id} label="Space" name="spaceId" options={spaceOptions(spaces)} />}
{!goal && spaces.length === 1 && <input name="spaceId" type="hidden" value={spaces[0]!.id} />}
<div className="grid gap-4 sm:grid-cols-2">
<Select
label="For"
onChange={(next) => {
setPeriod(next);
setStartsOn(periodStart(next, startsOn > today ? startsOn : today));
}}
options={periods}
value={period}
/>
<Select label="Which" onChange={setStartsOn} options={choices.some((choice) => choice.value === startsOn) ? choices : [{ value: startsOn, label: periodLabel(period, startsOn) }, ...choices]} value={startsOn} />
</div>
<Select
label="Measure progress by"
onChange={setMeasure}
options={[
{ value: 'tasks', label: 'Linked tasks', hint: 'Done out of all' },
{ value: 'number', label: 'A number', hint: 'Such as km run' },
]}
value={measure}
/>
{measure === 'number' && (
<div className="grid gap-4 sm:grid-cols-3">
<TextField defaultValue={goal?.target ?? ''} inputMode="decimal" label="Target" {...form.field('target')} />
<TextField defaultValue={goal?.current ?? ''} inputMode="decimal" label="So far" {...form.field('current')} />
<TextField defaultValue={goal?.unit ?? ''} label="Unit (optional)" maxLength={20} name="unit" placeholder="km" />
</div>
)}
<TextAreaField defaultValue={goal?.why} label="Why it matters (optional)" maxLength={2000} name="why" rows={3} />
</FormDialog>
);
}
8 changes: 7 additions & 1 deletion app/frontend/src/components/SearchPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { CommandPalette, isAbortError, type CommandItem } from '@project-graphite/ui';
import { useAuth } from '../auth';
import { enabledFeatures } from '../features';
import { itemHref, useSpaces, type Tag } from '../spaces';

interface SearchResults {
Expand All @@ -21,6 +22,7 @@ const pages = [
['/trash', 'Trash'],
['/settings', 'Profile settings'],
['/settings/security', 'Password, two-step sign-in and signed-in devices'],
['/settings/features', 'Turn features on or off'],
['/settings/developer', 'Access tokens, API and webhooks'],
['/settings/data', 'Export or delete your data'],
] as const;
Expand Down Expand Up @@ -89,7 +91,11 @@ export function SearchPalette({ onClose }: { onClose: () => void }) {
...(spaces.data ?? [])
.filter((space) => matches(space.name))
.map((space) => ({ group: 'Spaces', href: `/spaces/${space.id}`, id: `space-${space.id}`, label: space.name })),
...[...pages, ...(user?.role === 'system_manager' ? ([['/admin', 'Site settings']] as const) : [])]
...[
...pages,
...enabledFeatures(user?.features).map((feature) => [feature.href, feature.label] as const),
...(user?.role === 'system_manager' ? ([['/admin', 'Site settings']] as const) : []),
]
.filter(([, label]) => matches(label))
.map(([href, label]) => ({ group: 'Go to', href, id: `page-${href}`, label })),
];
Expand Down
3 changes: 2 additions & 1 deletion app/frontend/src/components/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { onOutage } from '../api';
import { useAuth } from '../auth';
import { InboxContext } from '../inbox';
import { enabledFeatures } from '../features';
import { areaSections, moreAreas } from '../navigation';
import { SpacesContext, type Space } from '../spaces';
import { useAction } from '../useAction';
Expand Down Expand Up @@ -148,7 +149,7 @@ function ShellFrame({ inbox, spaces }: { inbox: Resource<{ unread: number }>; sp
footer={auth.user && <PageTree />}
label="Areas"
loading={!auth.user || spaces.loading}
sections={areaSections(pathname, spaces.data, unread)}
sections={areaSections(pathname, spaces.data, unread, enabledFeatures(auth.user?.features))}
/>
)
}
Expand Down
21 changes: 21 additions & 0 deletions app/frontend/src/features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { IconName } from '@project-graphite/ui';

export interface Feature {
key: string;
label: string;
description: string;
icon: IconName;
href: string;
}

export const featureCatalogue: Feature[] = [
{
key: 'goals',
label: 'Goals',
description: 'Goals for the year, quarter or month, with progress from a number or from linked tasks and habits.',
icon: 'target',
href: '/goals',
},
];

export const enabledFeatures = (keys: string[] | undefined) => featureCatalogue.filter((feature) => keys?.includes(feature.key));
64 changes: 64 additions & 0 deletions app/frontend/src/goals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Habit } from './habits';
import type { Task } from './tasks';

export type GoalPeriod = 'year' | 'quarter' | 'month';
export type GoalStatus = 'active' | 'done' | 'dropped';

export interface Goal {
id: string;
spaceId: string;
spaceName: string;
title: string;
period: GoalPeriod;
startsOn: string;
endsOn: string;
status: GoalStatus;
why: string;
target: number | null;
current: number | null;
unit: string | null;
tasks: { done: number; total: number };
habitCount: number;
progress: number | null;
createdAt: string;
updatedAt: string;
}

export interface GoalDetail extends Goal {
taskList: (Task & { linkId: string })[];
habits: Habit[];
}

const monthName = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric', timeZone: 'UTC' });

export function periodLabel(period: GoalPeriod, startsOn: string) {
const year = startsOn.slice(0, 4);
if (period === 'year') return year;
if (period === 'quarter') return `Q${Math.floor((Number(startsOn.slice(5, 7)) - 1) / 3) + 1} ${year}`;
return monthName.format(new Date(`${startsOn}T00:00:00Z`));
}

export function periodChoices(period: GoalPeriod, today: string) {
const year = Number(today.slice(0, 4));
const month = Number(today.slice(5, 7)) - 1;
const steps = { year: { months: 12, from: -1, count: 4 }, quarter: { months: 3, from: -1, count: 6 }, month: { months: 1, from: -1, count: 14 } }[period];
const first = period === 'year' ? 0 : period === 'quarter' ? month - (month % 3) : month;
return Array.from({ length: steps.count }, (_, index) => {
const start = new Date(Date.UTC(year, first + (steps.from + index) * steps.months, 1)).toISOString().slice(0, 10);
return { value: start, label: periodLabel(period, start) };
});
}

export function periodStart(period: GoalPeriod, day: string) {
return periodChoices(period, day)[1]!.value;
}

const amount = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 2 });

export function progressText(goal: Goal) {
if (goal.target) return `${amount(goal.current ?? 0)} of ${amount(goal.target)}${goal.unit ? ` ${goal.unit}` : ''}`;
if (goal.tasks.total) return `${goal.tasks.done} of ${goal.tasks.total} task${goal.tasks.total === 1 ? '' : 's'} done`;
return 'Link tasks or set a number to track progress';
}

export const statusLabels: Record<GoalStatus, string> = { active: 'Active', done: 'Done', dropped: 'Dropped' };
32 changes: 30 additions & 2 deletions app/frontend/src/navigation.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import type { SidebarSection } from '@project-graphite/ui';
import { featureCatalogue, type Feature } from './features';
import { SpaceMark, type Space } from './spaces';

export const moreAreas = ['/menu', '/pages', '/boards', '/bookmarks', '/journal', '/recurring', '/tidy', '/inbox', '/invitations', '/trash', '/settings', '/admin'];
export const moreAreas = [
'/menu',
'/pages',
'/boards',
'/bookmarks',
'/journal',
'/recurring',
'/tidy',
...featureCatalogue.map((feature) => feature.href),
'/inbox',
'/invitations',
'/trash',
'/settings',
'/admin',
];

export function areaSections(pathname: string, spaces: Space[] | undefined, unread: number): SidebarSection[] {
export function areaSections(pathname: string, spaces: Space[] | undefined, unread: number, features: Feature[]): SidebarSection[] {
return [
{
items: [
Expand All @@ -18,6 +33,19 @@ export function areaSections(pathname: string, spaces: Space[] | undefined, unre
{ active: pathname === '/tidy', href: '/tidy', icon: 'sparkles', label: 'Tidy' },
],
},
...(features.length
? [
{
label: 'Features',
items: features.map((feature) => ({
active: pathname === feature.href || pathname.startsWith(`${feature.href}/`),
href: feature.href,
icon: feature.icon,
label: feature.label,
})),
},
]
: []),
{
label: 'Spaces',
items: [
Expand Down
Loading
Loading