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: 2 additions & 0 deletions app/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { SpaceActivity } from './pages/spaces/SpaceActivity';
import { SpaceItems } from './pages/spaces/SpaceItems';
import { SpaceLayout } from './pages/spaces/SpaceLayout';
import { SpaceMembers } from './pages/spaces/SpaceMembers';
import { SpaceHabits } from './pages/spaces/SpaceHabits';
import { SpaceNotes } from './pages/spaces/SpaceNotes';
import { SpacesPage } from './pages/spaces/SpacesPage';
import { SpaceSubscriptions } from './pages/spaces/SpaceSubscriptions';
Expand Down Expand Up @@ -96,6 +97,7 @@ export function App() {
<Route path="notes" element={<SpaceNotes />} />
<Route path="tasks" element={<SpaceTasks />} />
<Route path="recurring" element={<SpaceSubscriptions />} />
<Route path="habits" element={<SpaceHabits />} />
<Route path="tags" element={<SpaceTags />} />
<Route path="members" element={<SpaceMembers />} />
<Route path="activity" element={<SpaceActivity />} />
Expand Down
20 changes: 19 additions & 1 deletion app/frontend/src/dashboard/widgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Avatar, ListSkeleton, timeAgo } from '@project-graphite/ui';
import type { Page } from '../api';
import { useAuth } from '../auth';
import { fileSize } from '../files';
import { HabitRow, type Habit } from '../habits';
import { addDays, dayKey, entryLink, entryTime, type AgendaEntry } from '../agenda';
import type { InboxNotification } from '../inbox';
import { useSpaces } from '../spaces';
Expand All @@ -16,7 +17,7 @@ import { Capture } from './capture-widget';
import { News, NewsSettings, Weather, WeatherSettings } from './outside-widgets';
import { Empty, type SettingsProps, type WidgetProps } from './widget-parts';

export type WidgetType = 'clock' | 'today' | 'tasks' | 'shortcuts' | 'recurring' | 'inbox' | 'agenda' | 'countdown' | 'focus' | 'tidy' | 'weather' | 'news' | 'capture';
export type WidgetType = 'clock' | 'today' | 'tasks' | 'shortcuts' | 'recurring' | 'inbox' | 'agenda' | 'countdown' | 'focus' | 'tidy' | 'weather' | 'news' | 'capture' | 'habits';
export type WidgetSize = 'small' | 'medium' | 'wide' | 'full';

export interface Widget {
Expand Down Expand Up @@ -424,6 +425,22 @@ interface TidyScanSummary {
subscriptions: { unused: { id: string }[]; duplicates: { items: { id: string }[] }[]; overlapping: { items: { id: string }[] }[] };
}

function Habits() {
const habits = useResource<Habit[]>('/me/habits', true);
if (habits.error) return <LoadError error={habits.error} onRetry={habits.reload} />;
if (!habits.data) return <ListSkeleton label="Loading your habits" rows={3} />;
if (habits.data.length === 0) return <Empty>No habits yet. Add them in a space’s Habits tab.</Empty>;
return (
<ul className="m-0 grid list-none gap-3 p-0">
{habits.data.map((habit) => (
<li key={habit.id}>
<HabitRow habit={habit} onChange={(next) => habits.mutate((current) => current.map((shown) => (shown.id === next.id ? next : shown)))} showSpace />
</li>
))}
</ul>
);
}

function Tidy() {
const scan = useResource<TidyScanSummary>('/tidy/scan', true);
const history = useResource<{ createdAt: string }[]>('/tidy/history', true);
Expand Down Expand Up @@ -481,4 +498,5 @@ export const widgetKinds: Record<
weather: { title: 'Weather', size: 'small', settings: { unit: 'celsius' }, View: Weather, Settings: WeatherSettings },
news: { title: 'News', size: 'medium', settings: { feeds: [], count: 5 }, View: News, Settings: NewsSettings },
capture: { title: 'Quick note', size: 'medium', settings: {}, View: Capture },
habits: { title: 'Habits', size: 'medium', settings: {}, View: Habits },
};
82 changes: 82 additions & 0 deletions app/frontend/src/habits.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useAuth } from './auth';
import { useAction } from './useAction';

export interface Habit {
id: string;
spaceId: string;
spaceName: string;
name: string;
perWeek: number;
position: number;
today: string;
checkedToday: boolean;
thisWeek: number;
streak: number;
recent: string[];
}

export const goalText = (perWeek: number) => (perWeek >= 7 ? 'every day' : `${perWeek} day${perWeek === 1 ? '' : 's'} a week`);

function progressText(habit: Habit) {
const streak = habit.streak === 0 ? 'no streak yet' : `${habit.streak}-${habit.perWeek >= 7 ? 'day' : 'week'} streak`;
return habit.perWeek >= 7 ? streak : `${habit.thisWeek} of ${habit.perWeek} this week · ${streak}`;
}

function shiftDay(day: string, days: number) {
const date = new Date(`${day}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}

export function HabitRow({ habit, onChange, showSpace = false }: { habit: Habit; onChange: (habit: Habit) => void; showSpace?: boolean }) {
const auth = useAuth();
const saving = useAction();
const days = Array.from({ length: 7 }, (_, index) => shiftDay(habit.today, index - 6));

function toggle(day: string) {
void saving.run(async () => {
onChange(await auth.request<Habit>(`/habits/${habit.id}/check-ins/${day}`, { method: habit.recent.includes(day) ? 'DELETE' : 'PUT' }));
return '';
}, 'Could not save the check-in');
}

return (
<div className="flex items-center gap-3">
<button
aria-label={`${habit.name} today`}
aria-pressed={habit.checkedToday}
className={`habit-check ${habit.checkedToday ? 'is-done' : ''}`}
disabled={saving.busy}
onClick={() => toggle(habit.today)}
type="button"
>
{habit.checkedToday && <span aria-hidden="true">✓</span>}
</button>
<div className="min-w-0 flex-1">
<p className="m-0 truncate text-ink">{habit.name}</p>
<p className="mono-sm m-0 mt-0.5 text-faint">
{showSpace ? `${habit.spaceName} · ` : ''}
{progressText(habit)}
</p>
</div>
<div aria-label="Last seven days" className="flex shrink-0 items-center gap-1" role="group">
{days.map((day, index) =>
index === 5 ? (
<button
aria-label={`${habit.name} yesterday`}
aria-pressed={habit.recent.includes(day)}
className={`habit-day ${habit.recent.includes(day) ? 'is-done' : ''}`}
disabled={saving.busy}
key={day}
onClick={() => toggle(day)}
title="Yesterday"
type="button"
/>
) : (
<span aria-hidden="true" className={`habit-day ${habit.recent.includes(day) ? 'is-done' : ''}`} key={day} title={day} />
),
)}
</div>
</div>
);
}
151 changes: 151 additions & 0 deletions app/frontend/src/pages/spaces/SpaceHabits.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { useState } from 'react';
import { ConfirmDialog, ListSkeleton, TextField } from '@project-graphite/ui';
import { useAuth } from '../../auth';
import { FormDialog } from '../../components/FormDialog';
import { LoadError } from '../../components/LoadError';
import { goalText, HabitRow, type Habit } from '../../habits';
import { useAction } from '../../useAction';
import { useResource } from '../../useResource';
import { atMost, required, useFormErrors } from '../../validation';
import { useSpace } from './SpaceLayout';

function GoalSelect({ defaultValue = 7 }: { defaultValue?: number }) {
return (
<label className="field-label">
Goal
<select defaultValue={defaultValue} name="perWeek">
{[7, 6, 5, 4, 3, 2, 1].map((days) => (
<option key={days} value={days}>
{goalText(days).replace(/^./, (first) => first.toUpperCase())}
</option>
))}
</select>
</label>
);
}

const nameRules = [required('Name the habit.'), atMost(80, 'Use at most 80 characters.')];

export function SpaceHabits() {
const auth = useAuth();
const space = useSpace();
const habits = useResource<Habit[]>(`/spaces/${space.id}/habits`, true);
const adding = useAction();
const saving = useAction();
const addForm = useFormErrors();
const editForm = useFormErrors();
const [editing, setEditing] = useState<Habit>();
const [deleting, setDeleting] = useState<Habit>();
const editable = space.role !== 'viewer';
const replace = (next: Habit) => habits.mutate((current) => current.map((habit) => (habit.id === next.id ? next : habit)));

if (habits.error) return <LoadError error={habits.error} onRetry={habits.reload} />;
if (!habits.data) return <ListSkeleton label="Loading the habits in this space" rows={3} />;

return (
<div className="fade-in grid max-w-3xl gap-6">
{habits.data.length === 0 ? (
<p className="m-0 text-muted">No habits yet. Each member checks in for themselves, and streaks are counted per person.</p>
) : (
<ul className="m-0 grid list-none gap-0 p-0">
{habits.data.map((habit) => (
<li className="flex items-center gap-3 border-b border-line-soft py-3" key={habit.id}>
<div className="min-w-0 flex-1">
<HabitRow habit={habit} onChange={replace} />
</div>
{editable && (
<button className="text-button shrink-0 text-sm" onClick={() => setEditing(habit)} type="button">
Edit
</button>
)}
</li>
))}
</ul>
)}

{editable && (
<form
className="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-start"
noValidate
onSubmit={(event) => {
event.preventDefault();
const target = event.currentTarget;
if (!addForm.check(target, { name: nameRules })) return;
const values = new FormData(target);
void adding
.run(async () => {
const created = await auth.request<Habit>(`/spaces/${space.id}/habits`, {
method: 'POST',
body: JSON.stringify({ name: String(values.get('name')).trim(), perWeek: Number(values.get('perWeek')) }),
});
habits.mutate((current) => [...current, created]);
return '';
}, 'Could not add the habit')
.then((added) => added && target.reset());
}}
>
<TextField label="New habit" maxLength={80} {...addForm.field('name')} />
<GoalSelect />
<button className="primary-button w-fit" disabled={adding.busy} type="submit">
{adding.busy ? 'Adding…' : 'Add habit'}
</button>
</form>
)}

{editing && (
<FormDialog
busy={saving.busy}
busyLabel="Saving…"
onClose={() => setEditing(undefined)}
onSubmit={(form) => {
if (!editForm.check(form, { name: nameRules })) return;
const values = new FormData(form);
void saving
.run(async () => {
replace(
await auth.request<Habit>(`/habits/${editing.id}`, {
method: 'PATCH',
body: JSON.stringify({ name: String(values.get('name')).trim(), perWeek: Number(values.get('perWeek')) }),
}),
);
return '';
}, 'Could not save the habit')
.then((saved) => saved && setEditing(undefined));
}}
submitLabel="Save"
title="Edit habit"
>
<TextField defaultValue={editing.name} label="Name" maxLength={80} {...editForm.field('name')} />
<GoalSelect defaultValue={editing.perWeek} />
<button
className="text-button w-fit text-sm"
onClick={() => {
setDeleting(editing);
setEditing(undefined);
}}
type="button"
>
Delete this habit
</button>
</FormDialog>
)}

{deleting && (
<ConfirmDialog
busyLabel="Deleting…"
confirmLabel="Delete habit"
errorFallback="Could not delete the habit"
onClose={() => setDeleting(undefined)}
onConfirm={async () => {
await auth.request(`/habits/${deleting.id}`, { method: 'DELETE' });
habits.mutate((current) => current.filter((habit) => habit.id !== deleting.id));
setDeleting(undefined);
}}
title={`Delete “${deleting.name}”?`}
>
Everyone’s check-ins for it are deleted too.
</ConfirmDialog>
)}
</div>
);
}
1 change: 1 addition & 0 deletions app/frontend/src/pages/spaces/SpaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function SpaceLayout() {
[`${base}/notes`, 'Notes'],
[`${base}/tasks`, 'Tasks'],
[`${base}/recurring`, 'Recurring'],
[`${base}/habits`, 'Habits'],
[`${base}/tags`, 'Tags'],
[`${base}/members`, 'Members'],
[`${base}/activity`, 'Activity'],
Expand Down
34 changes: 34 additions & 0 deletions app/frontend/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,37 @@
.database-card[draggable='true'] {
cursor: grab;
}

.habit-check {
display: inline-flex;
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 1.5px solid var(--color-line);
border-radius: 999px;
color: var(--color-paper);
font-size: 0.85rem;
}

.habit-check.is-done {
border-color: var(--color-ink);
background: var(--color-ink);
}

.habit-day {
display: inline-block;
width: 0.6rem;
height: 0.6rem;
border-radius: 999px;
background: var(--color-line);
}

button.habit-day {
outline-offset: 2px;
}

.habit-day.is-done {
background: var(--color-ink);
}
Loading
Loading