From ba5eaa0b631a7c68d332f69e5e85367fe958e3c4 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:09:14 +0300 Subject: [PATCH] feat: track habits with daily check-ins and streaks --- app/frontend/src/App.tsx | 2 + app/frontend/src/dashboard/widgets.tsx | 20 ++- app/frontend/src/habits.tsx | 82 ++++++++++ app/frontend/src/pages/spaces/SpaceHabits.tsx | 151 ++++++++++++++++++ app/frontend/src/pages/spaces/SpaceLayout.tsx | 1 + app/frontend/src/styles/global.css | 34 ++++ app/frontend/test/habits.spec.tsx | 122 ++++++++++++++ .../20261005140000_habits/migration.sql | 27 ++++ app/server/prisma/schema.prisma | 29 ++++ app/server/src/app.module.ts | 2 + .../src/dashboards/dto/dashboards.dto.ts | 2 +- app/server/src/habits/dto/habits.dto.ts | 32 ++++ app/server/src/habits/habits.controller.ts | 57 +++++++ app/server/src/habits/habits.module.ts | 11 ++ app/server/src/habits/habits.service.ts | 98 ++++++++++++ app/server/src/habits/streaks.ts | 27 ++++ app/server/test/habit-streaks.spec.ts | 23 +++ app/server/test/integration/habits.spec.ts | 45 ++++++ 18 files changed, 763 insertions(+), 2 deletions(-) create mode 100644 app/frontend/src/habits.tsx create mode 100644 app/frontend/src/pages/spaces/SpaceHabits.tsx create mode 100644 app/frontend/test/habits.spec.tsx create mode 100644 app/server/prisma/migrations/20261005140000_habits/migration.sql create mode 100644 app/server/src/habits/dto/habits.dto.ts create mode 100644 app/server/src/habits/habits.controller.ts create mode 100644 app/server/src/habits/habits.module.ts create mode 100644 app/server/src/habits/habits.service.ts create mode 100644 app/server/src/habits/streaks.ts create mode 100644 app/server/test/habit-streaks.spec.ts create mode 100644 app/server/test/integration/habits.spec.ts diff --git a/app/frontend/src/App.tsx b/app/frontend/src/App.tsx index 2c6cf9c..e7e845a 100644 --- a/app/frontend/src/App.tsx +++ b/app/frontend/src/App.tsx @@ -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'; @@ -96,6 +97,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/app/frontend/src/dashboard/widgets.tsx b/app/frontend/src/dashboard/widgets.tsx index bfb4299..2b982f9 100644 --- a/app/frontend/src/dashboard/widgets.tsx +++ b/app/frontend/src/dashboard/widgets.tsx @@ -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'; @@ -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 { @@ -424,6 +425,22 @@ interface TidyScanSummary { subscriptions: { unused: { id: string }[]; duplicates: { items: { id: string }[] }[]; overlapping: { items: { id: string }[] }[] }; } +function Habits() { + const habits = useResource('/me/habits', true); + if (habits.error) return ; + if (!habits.data) return ; + if (habits.data.length === 0) return No habits yet. Add them in a space’s Habits tab.; + return ( +
    + {habits.data.map((habit) => ( +
  • + habits.mutate((current) => current.map((shown) => (shown.id === next.id ? next : shown)))} showSpace /> +
  • + ))} +
+ ); +} + function Tidy() { const scan = useResource('/tidy/scan', true); const history = useResource<{ createdAt: string }[]>('/tidy/history', true); @@ -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 }, }; diff --git a/app/frontend/src/habits.tsx b/app/frontend/src/habits.tsx new file mode 100644 index 0000000..810d30b --- /dev/null +++ b/app/frontend/src/habits.tsx @@ -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(`/habits/${habit.id}/check-ins/${day}`, { method: habit.recent.includes(day) ? 'DELETE' : 'PUT' })); + return ''; + }, 'Could not save the check-in'); + } + + return ( +
+ +
+

{habit.name}

+

+ {showSpace ? `${habit.spaceName} · ` : ''} + {progressText(habit)} +

+
+
+ {days.map((day, index) => + index === 5 ? ( +
+
+ ); +} diff --git a/app/frontend/src/pages/spaces/SpaceHabits.tsx b/app/frontend/src/pages/spaces/SpaceHabits.tsx new file mode 100644 index 0000000..5b90f48 --- /dev/null +++ b/app/frontend/src/pages/spaces/SpaceHabits.tsx @@ -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 ( + + ); +} + +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(`/spaces/${space.id}/habits`, true); + const adding = useAction(); + const saving = useAction(); + const addForm = useFormErrors(); + const editForm = useFormErrors(); + const [editing, setEditing] = useState(); + const [deleting, setDeleting] = useState(); + 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 ; + if (!habits.data) return ; + + return ( +
+ {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 && ( +
{ + 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(`/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()); + }} + > + + + + + )} + + {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( + + + + + , + ), + ); + } + + const button = (label: string) => + [...container.querySelectorAll('button')].find((candidate) => candidate.textContent === label || candidate.getAttribute('aria-label') === label)!; + + it('checks in for today and yesterday and adds a habit with a weekly goal', async () => { + const fetchMock = serve((path, init) => { + if (path === '/spaces/flat/habits' && init?.method === 'POST') return json({ ...gym, id: 'read', name: 'Read', checkedToday: false, recent: [], thisWeek: 0 }, 201); + if (path === '/spaces/flat/habits') return json([plants, gym]); + if (path === '/habits/plants/check-ins/2026-10-05') return json({ ...plants, checkedToday: true, streak: 3, recent: [...plants.recent, '2026-10-05'] }); + if (path === '/habits/gym/check-ins/2026-10-04') return json({ ...gym, thisWeek: 1, recent: ['2026-10-04', '2026-10-05'] }); + return undefined; + }); + await render('/spaces/flat/habits'); + + expect(container.textContent).toContain('2-day streak'); + expect(container.textContent).toContain('1 of 3 this week · no streak yet'); + await act(async () => button('Water the plants today').click()); + expect(fetchMock).toHaveBeenCalledWith('/api/v1/habits/plants/check-ins/2026-10-05', expect.objectContaining({ method: 'PUT' })); + expect(button('Water the plants today').getAttribute('aria-pressed')).toBe('true'); + expect(container.textContent).toContain('3-day streak'); + + await act(async () => button('Gym yesterday').click()); + expect(fetchMock).toHaveBeenCalledWith('/api/v1/habits/gym/check-ins/2026-10-04', expect.objectContaining({ method: 'PUT' })); + expect(button('Gym yesterday').getAttribute('aria-pressed')).toBe('true'); + + const form = [...container.querySelectorAll('form')].find((candidate) => candidate.textContent?.includes('New habit'))!; + form.querySelector('input[name="name"]')!.value = 'Read'; + form.querySelector('select[name="perWeek"]')!.value = '3'; + await act(async () => { + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + }); + expect(fetchMock).toHaveBeenCalledWith('/api/v1/spaces/flat/habits', expect.objectContaining({ method: 'POST', body: JSON.stringify({ name: 'Read', perWeek: 3 }) })); + expect([...container.querySelectorAll('li p:first-child')].map((line) => line.textContent)).toEqual(['Water the plants', 'Gym', 'Read']); + }); + + it('undoes a check-in from the Home widget', async () => { + const fetchMock = serve((path, init) => { + if (path === '/me/dashboards') { + return json([{ id: 'home', name: 'Home', position: 0, showFrom: null, showUntil: null, showOn: 'any', layout: { widgets: [{ id: 'h', type: 'habits', size: 'medium', settings: {} }] } }]); + } + if (path === '/me/habits') return json([gym]); + if (path === '/habits/gym/check-ins/2026-10-05' && init?.method === 'DELETE') return json({ ...gym, checkedToday: false, thisWeek: 0, recent: [] }); + return undefined; + }); + await render('/'); + + const widget = container.querySelector('article[aria-label="Habits"]')!; + expect(widget.textContent).toContain('Flat · 1 of 3 this week'); + await act(async () => button('Gym today').click()); + expect(fetchMock).toHaveBeenCalledWith('/api/v1/habits/gym/check-ins/2026-10-05', expect.objectContaining({ method: 'DELETE' })); + expect(button('Gym today').getAttribute('aria-pressed')).toBe('false'); + }); +}); diff --git a/app/server/prisma/migrations/20261005140000_habits/migration.sql b/app/server/prisma/migrations/20261005140000_habits/migration.sql new file mode 100644 index 0000000..f2f3715 --- /dev/null +++ b/app/server/prisma/migrations/20261005140000_habits/migration.sql @@ -0,0 +1,27 @@ +CREATE TABLE "habits" ( + "id" UUID NOT NULL, + "space_id" UUID NOT NULL, + "name" TEXT NOT NULL, + "per_week" INTEGER NOT NULL DEFAULT 7, + "position" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "habits_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "habit_check_ins" ( + "habit_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "day" DATE NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "habit_check_ins_pkey" PRIMARY KEY ("habit_id","user_id","day") +); + +CREATE INDEX "habits_space_id_position_idx" ON "habits"("space_id", "position"); + +CREATE INDEX "habit_check_ins_user_id_day_idx" ON "habit_check_ins"("user_id", "day"); + +ALTER TABLE "habits" ADD CONSTRAINT "habits_space_id_fkey" FOREIGN KEY ("space_id") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "habit_check_ins" ADD CONSTRAINT "habit_check_ins_habit_id_fkey" FOREIGN KEY ("habit_id") REFERENCES "habits"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "habit_check_ins" ADD CONSTRAINT "habit_check_ins_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/app/server/prisma/schema.prisma b/app/server/prisma/schema.prisma index 7a7780f..63ec6ca 100644 --- a/app/server/prisma/schema.prisma +++ b/app/server/prisma/schema.prisma @@ -105,6 +105,7 @@ model User { tidyBatches TidyBatch[] tidyRules TidyRule[] noteVersions NoteVersion[] + habitCheckIns HabitCheckIn[] @@map("users") } @@ -203,6 +204,7 @@ model Space { searchDocuments SearchDocument[] activity ActivityEvent[] tidyRules TidyRule[] + habits Habit[] @@map("spaces") } @@ -713,3 +715,30 @@ model NoteProperty { @@id([itemId, propertyId]) @@map("note_properties") } + +model Habit { + id String @id @default(uuid()) @db.Uuid + spaceId String @map("space_id") @db.Uuid + name String + perWeek Int @default(7) @map("per_week") + position Int @default(0) + createdAt DateTime @default(now()) @map("created_at") + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + checkIns HabitCheckIn[] + + @@index([spaceId, position]) + @@map("habits") +} + +model HabitCheckIn { + habitId String @map("habit_id") @db.Uuid + userId String @map("user_id") @db.Uuid + day DateTime @db.Date + createdAt DateTime @default(now()) @map("created_at") + habit Habit @relation(fields: [habitId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([habitId, userId, day]) + @@index([userId, day]) + @@map("habit_check_ins") +} diff --git a/app/server/src/app.module.ts b/app/server/src/app.module.ts index 42e2d87..c0358c9 100644 --- a/app/server/src/app.module.ts +++ b/app/server/src/app.module.ts @@ -12,6 +12,7 @@ import { DashboardsModule } from './dashboards/dashboards.module'; import { EventsModule } from './events/events.module'; import { ExchangeRatesModule } from './exchange-rates/exchange-rates.module'; import { FilesModule } from './files/files.module'; +import { HabitsModule } from './habits/habits.module'; import { HealthModule } from './health/health.module'; import { InboxModule } from './inbox/inbox.module'; import { ItemsModule } from './items/items.module'; @@ -61,6 +62,7 @@ import { WidgetsModule } from './widgets/widgets.module'; EventsModule, ExchangeRatesModule, TidyModule, + HabitsModule, NotesModule, RealtimeModule, WidgetsModule, diff --git a/app/server/src/dashboards/dto/dashboards.dto.ts b/app/server/src/dashboards/dto/dashboards.dto.ts index f7ec811..991b66f 100644 --- a/app/server/src/dashboards/dto/dashboards.dto.ts +++ b/app/server/src/dashboards/dto/dashboards.dto.ts @@ -3,7 +3,7 @@ import { ArrayMaxSize, IsArray, IsIn, IsInt, IsObject, IsOptional, IsString, Len 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 widgetTypes = ['clock', 'today', 'tasks', 'shortcuts', 'recurring', 'inbox', 'agenda', 'countdown', 'focus', 'tidy', 'weather', 'news', 'capture', 'habits'] 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; diff --git a/app/server/src/habits/dto/habits.dto.ts b/app/server/src/habits/dto/habits.dto.ts new file mode 100644 index 0000000..7200fe7 --- /dev/null +++ b/app/server/src/habits/dto/habits.dto.ts @@ -0,0 +1,32 @@ +import { IsInt, IsOptional, IsString, Length, Max, Min } from 'class-validator'; +import { IsOptionalNotNull } from '../../validation/is-optional-not-null.decorator'; +import { Trimmed } from '../../validation/trimmed.decorator'; + +const HabitName = () => Length(1, 80, { message: 'Habit names are 1 to 80 characters long.' }); + +export class CreateHabitDto { + @Trimmed() + @IsString() + @HabitName() + name!: string; + + @IsOptional() + @IsInt() + @Min(1, { message: 'Aim for 1 to 7 days a week.' }) + @Max(7, { message: 'Aim for 1 to 7 days a week.' }) + perWeek?: number; +} + +export class UpdateHabitDto { + @IsOptionalNotNull() + @Trimmed() + @IsString() + @HabitName() + name?: string; + + @IsOptionalNotNull() + @IsInt() + @Min(1, { message: 'Aim for 1 to 7 days a week.' }) + @Max(7, { message: 'Aim for 1 to 7 days a week.' }) + perWeek?: number; +} diff --git a/app/server/src/habits/habits.controller.ts b/app/server/src/habits/habits.controller.ts new file mode 100644 index 0000000..d16493c --- /dev/null +++ b/app/server/src/habits/habits.controller.ts @@ -0,0 +1,57 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Put, UseGuards } from '@nestjs/common'; +import { Scope } from '../access-tokens/scopes'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { UuidPipe } from '../validation/uuid.pipe'; +import { CreateHabitDto, UpdateHabitDto } from './dto/habits.dto'; +import { HabitsService } from './habits.service'; + +@Controller() +@UseGuards(JwtAuthGuard) +export class HabitsController { + constructor(private readonly habits: HabitsService) {} + + @Get('spaces/:spaceId/habits') + @Scope('items:read') + list(@CurrentUser() user: AuthenticatedUser, @Param('spaceId', UuidPipe) spaceId: string) { + return this.habits.list(user.id, spaceId); + } + + @Post('spaces/:spaceId/habits') + @Scope('items:write') + create(@CurrentUser() user: AuthenticatedUser, @Param('spaceId', UuidPipe) spaceId: string, @Body() input: CreateHabitDto) { + return this.habits.create(user.id, spaceId, input); + } + + @Get('me/habits') + @Scope('items:read') + mine(@CurrentUser() user: AuthenticatedUser) { + return this.habits.mine(user.id); + } + + @Patch('habits/:id') + @Scope('items:write') + update(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string, @Body() input: UpdateHabitDto) { + return this.habits.update(user.id, id, input); + } + + @Delete('habits/:id') + @Scope('items:write') + @HttpCode(204) + async remove(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string) { + await this.habits.remove(user.id, id); + } + + @Put('habits/:id/check-ins/:day') + @Scope('items:write') + checkIn(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string, @Param('day') day: string) { + return this.habits.checkIn(user.id, id, day, true); + } + + @Delete('habits/:id/check-ins/:day') + @Scope('items:write') + undoCheckIn(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string, @Param('day') day: string) { + return this.habits.checkIn(user.id, id, day, false); + } +} diff --git a/app/server/src/habits/habits.module.ts b/app/server/src/habits/habits.module.ts new file mode 100644 index 0000000..59e3097 --- /dev/null +++ b/app/server/src/habits/habits.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { HabitsController } from './habits.controller'; +import { HabitsService } from './habits.service'; + +@Module({ + imports: [AuthModule], + controllers: [HabitsController], + providers: [HabitsService], +}) +export class HabitsModule {} diff --git a/app/server/src/habits/habits.service.ts b/app/server/src/habits/habits.service.ts new file mode 100644 index 0000000..570e485 --- /dev/null +++ b/app/server/src/habits/habits.service.ts @@ -0,0 +1,98 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { AccessService } from '../access/access.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { dateText, dateValue, localDate } from '../recurrence/dates'; +import { CreateHabitDto, UpdateHabitDto } from './dto/habits.dto'; +import { habitStats, shiftDay } from './streaks'; + +const maxHabits = 50; +const historyDays = 400; +const habitFields = { id: true, spaceId: true, name: true, perWeek: true, position: true, space: { select: { name: true } } } satisfies Prisma.HabitSelect; + +type HabitRow = Prisma.HabitGetPayload<{ select: typeof habitFields }>; + +@Injectable() +export class HabitsService { + constructor( + private readonly prisma: PrismaService, + private readonly access: AccessService, + ) {} + + async list(userId: string, spaceId: string) { + await this.access.assertSpace(userId, spaceId, 'read'); + const habits = await this.prisma.habit.findMany({ where: { spaceId }, orderBy: [{ position: 'asc' }, { createdAt: 'asc' }], select: habitFields }); + return this.withStats(userId, habits); + } + + async mine(userId: string) { + const habits = await this.prisma.habit.findMany({ + where: { space: this.access.spacesOf(userId) }, + orderBy: [{ space: { createdAt: 'asc' } }, { position: 'asc' }, { createdAt: 'asc' }], + select: habitFields, + }); + return this.withStats(userId, habits); + } + + async create(userId: string, spaceId: string, input: CreateHabitDto) { + await this.access.assertSpace(userId, spaceId, 'edit'); + const count = await this.prisma.habit.count({ where: { spaceId } }); + if (count >= maxHabits) throw new ConflictException(`A space can track ${maxHabits} habits. Delete one you no longer keep.`); + const habit = await this.prisma.habit.create({ data: { spaceId, name: input.name, perWeek: input.perWeek ?? 7, position: count }, select: habitFields }); + return (await this.withStats(userId, [habit]))[0]!; + } + + async update(userId: string, habitId: string, input: UpdateHabitDto) { + await this.habit(userId, habitId, 'edit'); + const habit = await this.prisma.habit.update({ where: { id: habitId }, data: { name: input.name, perWeek: input.perWeek }, select: habitFields }); + return (await this.withStats(userId, [habit]))[0]!; + } + + async remove(userId: string, habitId: string) { + await this.habit(userId, habitId, 'edit'); + await this.prisma.habit.deleteMany({ where: { id: habitId } }); + } + + async checkIn(userId: string, habitId: string, day: string, done: boolean) { + const habit = await this.habit(userId, habitId, 'read'); + const today = await this.today(userId); + if (day !== today && day !== shiftDay(today, -1)) throw new BadRequestException('Check in for today or yesterday'); + const key = { habitId, userId, day: dateValue(day) }; + if (done) { + await this.prisma.habitCheckIn.upsert({ where: { habitId_userId_day: key }, create: key, update: {} }); + } else { + await this.prisma.habitCheckIn.deleteMany({ where: key }); + } + return (await this.withStats(userId, [habit]))[0]!; + } + + private async habit(userId: string, habitId: string, access: 'read' | 'edit') { + const habit = await this.prisma.habit.findFirst({ where: { id: habitId, space: this.access.spacesOf(userId) }, select: habitFields }); + if (!habit) throw new NotFoundException('Habit not found'); + if (access === 'edit') await this.access.assertSpace(userId, habit.spaceId, 'edit'); + return habit; + } + + private async today(userId: string) { + const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { timeZone: true } }); + return localDate(user.timeZone); + } + + private async withStats(userId: string, habits: HabitRow[]) { + const today = await this.today(userId); + const checkIns = await this.prisma.habitCheckIn.findMany({ + where: { userId, habitId: { in: habits.map((habit) => habit.id) }, day: { gte: dateValue(shiftDay(today, -historyDays)) } }, + select: { habitId: true, day: true }, + }); + return habits.map(({ space, ...habit }) => ({ + ...habit, + spaceName: space.name, + today, + ...habitStats( + checkIns.filter((checkIn) => checkIn.habitId === habit.id).map((checkIn) => dateText(checkIn.day)), + habit.perWeek, + today, + ), + })); + } +} diff --git a/app/server/src/habits/streaks.ts b/app/server/src/habits/streaks.ts new file mode 100644 index 0000000..7e38616 --- /dev/null +++ b/app/server/src/habits/streaks.ts @@ -0,0 +1,27 @@ +import { dateText, dateValue, dayMs } from '../recurrence/dates'; + +export const shiftDay = (day: string, days: number) => dateText(new Date(dateValue(day).getTime() + days * dayMs)); + +const weekStart = (day: string) => shiftDay(day, -((dateValue(day).getUTCDay() + 6) % 7)); + +export function habitStats(days: string[], perWeek: number, today: string) { + const checked = new Set(days); + const thisWeekStart = weekStart(today); + const thisWeek = [...checked].filter((day) => day >= thisWeekStart && day <= today).length; + let streak = 0; + if (perWeek >= 7) { + for (let day = checked.has(today) ? today : shiftDay(today, -1); checked.has(day); day = shiftDay(day, -1)) streak += 1; + } else { + const counts = new Map(); + for (const day of checked) counts.set(weekStart(day), (counts.get(weekStart(day)) ?? 0) + 1); + for (let week = thisWeek >= perWeek ? thisWeekStart : shiftDay(thisWeekStart, -7); (counts.get(week) ?? 0) >= perWeek; week = shiftDay(week, -7)) { + streak += 1; + } + } + return { + checkedToday: checked.has(today), + thisWeek, + streak, + recent: Array.from({ length: 7 }, (_, index) => shiftDay(today, index - 6)).filter((day) => checked.has(day)), + }; +} diff --git a/app/server/test/habit-streaks.spec.ts b/app/server/test/habit-streaks.spec.ts new file mode 100644 index 0000000..3cd849b --- /dev/null +++ b/app/server/test/habit-streaks.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { habitStats } from '../src/habits/streaks'; + +describe('habit streaks', () => { + it('counts daily streaks up to today, or up to yesterday before today’s check-in', () => { + expect(habitStats(['2026-10-03', '2026-10-04', '2026-10-05'], 7, '2026-10-05')).toMatchObject({ checkedToday: true, streak: 3 }); + expect(habitStats(['2026-10-03', '2026-10-04'], 7, '2026-10-05')).toMatchObject({ checkedToday: false, streak: 2 }); + expect(habitStats(['2026-10-01', '2026-10-02', '2026-10-04'], 7, '2026-10-05')).toMatchObject({ streak: 1 }); + expect(habitStats(['2026-10-02'], 7, '2026-10-05').streak).toBe(0); + }); + + it('counts weeks that met the goal, including this week only once it is met', () => { + const lastTwoWeeks = ['2026-09-22', '2026-09-24', '2026-09-29', '2026-10-01']; + expect(habitStats(lastTwoWeeks, 2, '2026-10-05')).toMatchObject({ thisWeek: 0, streak: 2 }); + expect(habitStats([...lastTwoWeeks, '2026-10-05'], 2, '2026-10-05')).toMatchObject({ thisWeek: 1, streak: 2 }); + expect(habitStats([...lastTwoWeeks, '2026-10-05', '2026-10-06'], 2, '2026-10-06')).toMatchObject({ thisWeek: 2, streak: 3 }); + expect(habitStats(['2026-09-22', '2026-10-01'], 2, '2026-10-05').streak).toBe(0); + }); + + it('lists the check-ins of the last seven days', () => { + expect(habitStats(['2026-09-28', '2026-09-29', '2026-10-05'], 7, '2026-10-05').recent).toEqual(['2026-09-29', '2026-10-05']); + }); +}); diff --git a/app/server/test/integration/habits.spec.ts b/app/server/test/integration/habits.spec.ts new file mode 100644 index 0000000..45819e8 --- /dev/null +++ b/app/server/test/integration/habits.spec.ts @@ -0,0 +1,45 @@ +import { SpaceRole } from '@prisma/client'; +import { describe, expect, it } from 'vitest'; +import { localDate } from '../../src/recurrence/dates'; +import { shiftDay } from '../../src/habits/streaks'; +import { integrationApp } from './harness'; + +describe('Habits against Postgres', () => { + const strata = integrationApp(); + const { member } = strata; + + it('keeps habits in a space and check-ins for each member', async () => { + const owner = await member('keeper'); + const viewer = await member('follower'); + const outsider = await member('nosy'); + const flat = (await owner.call('POST', '/spaces', { name: 'Flat' })).body; + await strata.join(flat.id, viewer, SpaceRole.VIEWER); + const today = localDate('Etc/UTC'); + const yesterday = shiftDay(today, -1); + + expect((await viewer.call('POST', `/spaces/${flat.id}/habits`, { name: 'Water the plants' })).status).toBe(403); + expect((await owner.call('POST', `/spaces/${flat.id}/habits`, { name: ' ', perWeek: 3 })).status).toBe(400); + expect((await owner.call('POST', `/spaces/${flat.id}/habits`, { name: 'Run', perWeek: 8 })).status).toBe(400); + const plants = (await owner.call('POST', `/spaces/${flat.id}/habits`, { name: 'Water the plants' })).body; + expect(plants).toMatchObject({ name: 'Water the plants', perWeek: 7, spaceName: 'Flat', today, checkedToday: false, streak: 0, recent: [] }); + + expect((await owner.call('PUT', `/habits/${plants.id}/check-ins/${yesterday}`)).body).toMatchObject({ streak: 1, checkedToday: false }); + expect((await owner.call('PUT', `/habits/${plants.id}/check-ins/${today}`)).body).toMatchObject({ streak: 2, checkedToday: true, recent: [yesterday, today] }); + expect((await owner.call('PUT', `/habits/${plants.id}/check-ins/${today}`)).body.streak).toBe(2); + expect((await owner.call('PUT', `/habits/${plants.id}/check-ins/${shiftDay(today, -2)}`)).status).toBe(400); + expect((await owner.call('PUT', `/habits/${plants.id}/check-ins/not-a-day`)).status).toBe(400); + + expect((await viewer.call('PUT', `/habits/${plants.id}/check-ins/${today}`)).body).toMatchObject({ streak: 1, checkedToday: true }); + expect((await viewer.call('GET', `/spaces/${flat.id}/habits`)).body).toMatchObject([{ id: plants.id, streak: 1 }]); + expect((await owner.call('GET', '/me/habits')).body).toMatchObject([{ id: plants.id, streak: 2 }]); + expect((await viewer.call('PATCH', `/habits/${plants.id}`, { name: 'Mine now' })).status).toBe(403); + expect((await outsider.call('PUT', `/habits/${plants.id}/check-ins/${today}`)).status).toBe(404); + expect((await outsider.call('GET', '/me/habits')).body).toEqual([]); + + expect((await owner.call('DELETE', `/habits/${plants.id}/check-ins/${today}`)).body).toMatchObject({ streak: 1, checkedToday: false }); + expect((await owner.call('PATCH', `/habits/${plants.id}`, { name: 'Plants', perWeek: 3 })).body).toMatchObject({ name: 'Plants', perWeek: 3 }); + expect((await viewer.call('DELETE', `/habits/${plants.id}`)).status).toBe(403); + expect((await owner.call('DELETE', `/habits/${plants.id}`)).status).toBe(204); + expect(await strata.prisma.habitCheckIn.count({ where: { habitId: plants.id } })).toBe(0); + }); +});