+ {habits.data.length === 0 ? (
+
No habits yet. Each member checks in for themselves, and streaks are counted per person.
+ ) : (
+
+ {habits.data.map((habit) => (
+ -
+
+
+
+ {editable && (
+
+ )}
+
+ ))}
+
+ )}
+
+ {editable && (
+
+ )}
+
+ {editing && (
+
setEditing(undefined)}
+ onSubmit={(form) => {
+ if (!editForm.check(form, { name: nameRules })) return;
+ const values = new FormData(form);
+ void saving
+ .run(async () => {
+ replace(
+ await auth.request(`/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"
+ >
+
+
+
+
+ )}
+
+ {deleting && (
+
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.
+
+ )}
+
+ );
+}
diff --git a/app/frontend/src/pages/spaces/SpaceLayout.tsx b/app/frontend/src/pages/spaces/SpaceLayout.tsx
index 5a83754..89d1584 100644
--- a/app/frontend/src/pages/spaces/SpaceLayout.tsx
+++ b/app/frontend/src/pages/spaces/SpaceLayout.tsx
@@ -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'],
diff --git a/app/frontend/src/styles/global.css b/app/frontend/src/styles/global.css
index 34e7346..662420e 100644
--- a/app/frontend/src/styles/global.css
+++ b/app/frontend/src/styles/global.css
@@ -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);
+}
diff --git a/app/frontend/test/habits.spec.tsx b/app/frontend/test/habits.spec.tsx
new file mode 100644
index 0000000..9a97619
--- /dev/null
+++ b/app/frontend/test/habits.spec.tsx
@@ -0,0 +1,122 @@
+import { act } from 'react';
+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 { AuthProvider } from '../src/auth';
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const json = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+const user = { id: 'me', email: 'amr@example.com', handle: 'amr', displayName: 'Amr', role: 'member', timeZone: 'Etc/UTC' };
+const flat = { id: 'flat', name: 'Flat', color: 'teal', kind: 'shared', role: 'editor', createdAt: '2026-10-01T00:00:00Z' };
+const plants = {
+ id: 'plants',
+ spaceId: 'flat',
+ spaceName: 'Flat',
+ name: 'Water the plants',
+ perWeek: 7,
+ position: 0,
+ today: '2026-10-05',
+ checkedToday: false,
+ thisWeek: 0,
+ streak: 2,
+ recent: ['2026-10-03', '2026-10-04'],
+};
+const gym = { ...plants, id: 'gym', name: 'Gym', perWeek: 3, thisWeek: 1, streak: 0, recent: ['2026-10-05'], checkedToday: true };
+
+describe('Habits', () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+ });
+
+ function serve(route: (path: string, init?: RequestInit) => Response | undefined) {
+ const fetchMock = 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([flat]));
+ if (path === '/spaces/flat') return Promise.resolve(json(flat));
+ if (path === '/me/inbox/summary') return Promise.resolve(json({ unread: 0 }));
+ return Promise.resolve(route(path, init) ?? new Response(null, { status: 404 }));
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+ }
+
+ async function render(path: string) {
+ await act(async () =>
+ root.render(
+