TreeMap
A free, open-source disk-space visualizer. Squarified treemap, duplicate hunting, disk-full forecasting, and cleanup that only ever moves files to the Trash.
翻译准备中
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证正在加载完整 README。 以下为已保存的摘录。
A personal scheduling assistant.
• Next.js 16.3.4 — App Router, Turbopack, TypeScript strict
• React 19.2.8
• Tailwind CSS v4 (CSS-first config via @theme)
• Prisma + PostgreSQL (Neon)
• Auth.js (next-auth v5) — Google OAuth + email magic link
• Luxon for timezone-aware date handling
• shadcn/ui + Lucide icons
• Telegram Bot API — optional second reminder-delivery channel alongside email
See docs/adr/ for the architectural decisions behind the project structure and date/time library choice.查看完整 README ↗A personal scheduling assistant.
@theme)next-auth v5) — Google OAuth + email magic link (off unless EMAIL_SIGN_IN_ENABLED=true)See docs/adr/ for the architectural decisions behind the project structure and date/time library choice.
Copy .env.example to .env.local and fill in the values (see comments in the file for where to get each one — Neon connection string, Google OAuth credentials, Resend API key). TELEGRAM_BOT_TOKEN/TELEGRAM_WEBHOOK_SECRET/TELEGRAM_BOT_USERNAME are optional — leave all three blank to skip the Telegram section on /settings; see "Telegram setup" below if you want it. GOOGLE_CALENDAR_ENABLED is optional as well — anything but true leaves Google Calendar off; see "Google Calendar setup" below.
Install dependencies:
npm install
Apply database migrations:
npm run db:migrate
Start the dev server:
npm run dev
Open http://localhost:3000.
| Script | Purpose |
|---|---|
npm run dev | Start the dev server (Turbopack) |
npm run build | Applies pending migrations, then production build |
npm run start | Start the production server |
npm run lint | ESLint |
npm run typecheck | tsc --noEmit |
npm run format | Prettier |
npm run test | Vitest |
npm run db:migrate | Apply migrations locally (prisma migrate dev) |
npm run db:migrate:deploy | Apply migrations in CI/production (prisma migrate deploy) |
npm run db:studio | Open Prisma Studio |
npm run db:reset | Reset the database and re-seed (prisma migrate reset) |
The app is designed to deploy to Vercel:
.env.example in the project's Vercel dashboard (Settings → Environment Variables) — DATABASE_URL, AUTH_SECRET, AUTH_URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, RESEND_API_KEY, EMAIL_FROM, CRON_SECRET. The three TELEGRAM_* variables and GOOGLE_CALENDAR_ENABLED are optional — add them to enable Telegram notifications / the Google Calendar busy check in production, or leave them out. EMAIL_SIGN_IN_ENABLED=true adds "Email me a link" to /login — only once EMAIL_FROM is a sender on a domain verified in Resend (the sandbox [email protected] delivers only to the Resend account's owner); without it /login offers Google only.AUTH_URL to the app's real production domain (not http://localhost:3000), and add that domain's /api/auth/callback/google as an authorized redirect URI in the Google Cloud Console.npm run build (Vercel's default build command) runs prisma migrate deploy before next build, so pending migrations are applied automatically on every deploy — no separate migration step needed.vercel.json registers the daily /api/cron/extend-occurrences job; Vercel schedules it automatically once the project is deployed. /api/cron/send-notifications needs to run every 5 minutes, which the Vercel Hobby plan doesn't allow (daily crons only), so it's triggered by an external scheduler instead:
https://<your-domain>/api/cron/send-notifications, schedule: every 5 minutes, method: GET.Authorization with value Bearer <your CRON_SECRET>.200 with {"sent": N}; 401 means the header doesn't match CRON_SECRET in Vercel.On the Vercel Pro plan you can instead add { "path": "/api/cron/send-notifications", "schedule": "*/5 * * * *" } back to vercel.json and skip the external scheduler.
GET /api/health (no auth) does a lightweight database ping — point an external uptime monitor at it.
Skip this entirely if you don't want the Telegram notification channel — the three TELEGRAM_* variables are optional and the /settings page just won't show that section without them.
Message @BotFather on Telegram, send /newbot, follow the prompts. It gives you a token (TELEGRAM_BOT_TOKEN) and you choose the bot's @username (TELEGRAM_BOT_USERNAME, without the @).
Generate a webhook secret: openssl rand -hex 32 → TELEGRAM_WEBHOOK_SECRET.
Set all three in .env.local (or Vercel's environment variables) and deploy/restart.
Register the webhook once — Telegram doesn't know your URL until you tell it:
curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
-H "Content-Type: application/json" \
-d "{\"url\": \"$AUTH_URL/api/telegram/webhook\", \"secret_token\": \"$TELEGRAM_WEBHOOK_SECRET\"}"
Only needs to be re-run if the token, secret, or deployment URL changes.
Register the command menu once, the same way (the "/" button next to the message box):
curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setMyCommands" \
-H "Content-Type: application/json" \
-d '{"commands": [{"command": "today", "description": "What is planned for today"}, {"command": "next", "description": "The next task"}, {"command": "help", "description": "What I can do"}]}'
Register the Mini App menu button once (the button left of the message box that opens the app inside Telegram):
curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setChatMenuButton" \
-H "Content-Type: application/json" \
-d "{\"menu_button\": {\"type\": \"web_app\", \"text\": \"Open app\", \"web_app\": {\"url\": \"$AUTH_URL/telegram\"}}}"
$AUTH_URL must be the public https URL. To take it back: the same call with {"menu_button": {"type": "default"}}.
On /settings, click "Connect" and open the resulting t.me/... link — Telegram sends the bot /start <code>, which the webhook uses to link your account.
If /start gets no answer, check getWebhookInfo first (curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo"): an empty url means step 4 hasn't been run for this bot.
A task from a picture (sprint-24-tasks.md): a photo or an image file sent to the bot — a ticket, a booking confirmation, a chat screenshot — is downloaded into memory, read with tesseract.js on the server (English, Spanish, Russian, Ukrainian; no API keys; the language data is fetched from tesseract.js's CDN on a function's first run and cached in /tmp), and turned into a draft by rules (features/telegram/picture-task.ts). The bot answers "Reading the picture…" at once and edits it into the draft: ✓ Add, a button per reading when it can't tell which date and time is meant, ✎ Edit (New task in the Mini App, filled in) and ✕ Cancel. The picture is never stored; drafts live in TaskDraft until Add, Save or Cancel, and the daily cron deletes those older than 24 hours. A PDF gets "send a screenshot" — PDFs aren't read.
The webhook only reaches a public URL, so a local next dev never gets updates from Telegram; to try the bot locally, POST updates to /api/telegram/webhook yourself with the x-telegram-bot-api-secret-token header.
Skip this if you don't need it — without GOOGLE_CALENDAR_ENABLED=true there's no "Google Calendar" section on /settings, no request ever goes to Google, and conflict checks and free-time suggestions only look at your own tasks.
With it, a connected user's primary calendar is checked for busy times whenever they create or edit a task, and an overlap shows up the same way an overlapping task does — in the overlap notice of the New task and Edit task forms; either way the task can still be saved. Free-time suggestions skip those busy times too (see "Finding free time"), and the busy times are drawn where the day is planned:
Both pages render the tasks first and fill the busy time in when Google answers — Google is never waited for, one free/busy request per page (the visible week, or today). If Google doesn't answer, there's one line, "Google Calendar didn't respond — busy time isn't shown."; if the connection can no longer be used, "Reconnect Google Calendar in Settings". With the flag off or nothing connected, both pages look as they did and ask Google nothing. There are no titles anywhere, because free/busy has none. Only free/busy is read — scope calendar.freebusy, never event titles or details — and the busy times aren't stored. The connection is a second Auth.js provider, google-calendar, on the same OAuth client as "Sign in with Google"; its tokens live in the Account row with provider = "google-calendar". Connecting writes real OAuth tokens, so test it locally against a Neon dev branch, not production.
In the Google Cloud project that owns GOOGLE_CLIENT_ID:
https://www.googleapis.com/auth/calendar.freebusy (non-sensitive), save.http://localhost:3000/api/auth/callback/google-calendar and https://<your-domain>/api/auth/callback/google-calendar.https://<your-domain>, Privacy policy https://<your-domain>/privacy, Terms of service https://<your-domain>/terms, and <your-domain> under Authorized domains. Use the contact address from src/app/(legal)/legal.tsx (LEGAL_CONTACT_EMAIL) as the User support email. Google requires these links before an external app can be published.GOOGLE_CALENDAR_ENABLED=true in .env.local (or Vercel's environment variables) and restart/redeploy./settings, click "Connect" under Google Calendar and keep the calendar box ticked on Google's consent screen — if it's unticked, /settings says so and offers "Connect" again. "Disconnect" revokes the access at Google and deletes the tokens.A new account starts at /onboarding (the app sends it there until the setup is finished or skipped — User.onboardedAt; accounts from before it were marked done by its migration). Three steps, each skippable, and Skip setup at the top ends it at any point; "You can change any of this in Settings at any time." is under each.
The name is also a row in Settings. An empty Home shows the same example sentences under "Add a task".
Before sharing the app, the sign-in has to reach other people: the magic-link email needs a verified sender domain in Resend (EMAIL_FROM; [email protected] only delivers to the Resend account's owner), and Google sign-in needs the OAuth consent screen published, or each person added under Test users (see "Google Calendar setup").
The app suggests free times in three places. It only fills in a date and time — nothing is saved until you press "Create task" or save the edit.
Your hours are on /settings, each user's own:
| Setting | Default | What it does |
|---|---|---|
| Start of day / End of day | 08:00 / 21:00 | Nothing is suggested outside them. Morning is start of day–12:00, afternoon 12:00–18:00, evening 18:00–end of day. |
| Work hours | Mon–Fri 09:00–17:00 | Busy for suggestions, unless the task can be done during work. No days picked — no work hours. |
| Workouts start by | 20:00 | The latest a workout may start (a one-hour workout can be 20:00–21:00). "No limit" turns it off. |
What counts as busy:
GOOGLE_CALENDAR_ENABLED=true and you've connected it: one free/busy request per search, however many days it covers. "Free nearby" asks nothing extra — it reuses the overlap check's request. Home's suggestion uses the request Home already makes for its busy rows (see "Google Calendar setup").What a task is comes from words in its title, in all three languages — nothing is stored:
A title with both reads as a workout ("Pay for the gym"); the switch is there to say otherwise.
Why "Google Calendar wasn't checked": Google was asked and didn't answer — it's down, or the connection has expired (in Google's Testing mode, every 7 days; see "Google Calendar setup"). The slots are still free of your own tasks, but a meeting may be in the way. With the flag off or no calendar connected, the search uses your tasks alone, sends nothing to Google and shows no note.
Phrases the parser reads as a search — a request word, then any of date, part of day and length, in any order:
| Request | Part of day | Length | |
|---|---|---|---|
| English | find / look for / search for (me) (a free) time, slot, window | morning, afternoon, evening, tonight | an hour, 30 minutes, half an hour… |
| Русский | найди / подбери / поищи (мне) (свободное) время, окно, слот | утром, днём, после обеда, вечером | час, полчаса, на 30 минут… |
| Українська | знайди / підбери / пошукай (мені) (вільний) час, вікно, слот | вранці, вдень, по обіді, ввечері | годину, півгодини, на 30 хвилин… |
Suggested times start on :00, :15, :30 or :45 of your local time, never in the past, and the ones offered never overlap each other.
Your habits (from "Your patterns", the last 30 days) shape suggestions in two small ways:
/progress). A search for a workout then starts from it: day by day, the free slots closest to it first, so the one filled in is the one that fits. Other tasks, "Free nearby" and Home keep their order.A task can have no time — "Buy groceries", "Plan the trip" — and just belong to a day. In New task that's the default: with nothing typed the date is today and the time reads Any time; a time in the sentence ("at 18") or one picked sets it, and the × beside it ("Remove time") takes it off again. "Take vitamins every morning" is every day without a time.
/today and the morning summary, after the timed tasks.Task.hasTime = false. Changing your timezone in Settings moves such days still ahead to the same date in the new zone, so they stay on their day.A series can end and skip days. Under Repeat: Every few days ("Every 2 days", up to 30, counted from the first day), and Ends — Never, On a date (the last day, inclusive) or After a number of times (saved as the date of the last one; "Last day Oct 15" shows under it). The task reads "↻ Every 2 days until Nov 2" on the task page, in Tasks and in Calendar. The nightly run creates no day past the last one, and the day after it ends the series by itself — it moves to Tasks → Ended like one ended by hand. Resume series on a series that ran to its end carries it on with no end. Editing offers Never / On a date.
In a sentence (New task and the bot, en / ru / uk): "every other day", "every 3 days", "через день", "каждые 3 дня", "кожні 3 дні"; "for a month", "for 2 weeks", "for 10 days", "на месяц", "в течение 2 недель", "на місяць", "протягом місяця" — counted from the task's date, so "for a month" from Oct 6 ends on Nov 5; "until Nov 3", "until 03/11", "до 3 ноября", "до 3 листопада". Only with a repeat — "Vacation for 2 weeks" stays the title.
A course — "Pills twice a day for a month", "Таблетки 2 раза в день утром и вечером на месяц", "Ліки вранці та ввечері протягом 2 тижнів" — is one task per dose ("Таблетки — утро" at 09:00, "Таблетки — вечер" at 20:00; three a day adds 14:00), listed under "Will be added as 2 tasks", each with the same repeat and end. One dose — "через день вечером в течение месяца" — is one task at that time. The time is a default: the task is Flexible and reminded at its start. Only for a course (it says how long or every N days); "every morning" alone is still a task without a time.
A deadline — "Send the report by 12", "by noon", "до 12", "до 12:30", "до 12-ї", or No deadline → by 12:00 in the When row — on a task without a time. It stays in "Any time" and reads "by 12:00" on Home, in Tasks, in Calendar, on the task page and in the bot. Its reminder defaults to 30 min before deadline (also 15 min, 1 or 2 hours), which Done or Skip cancels like any other; one already past isn't sent. Once the deadline passes and it's still open, it's overdue — "Overdue since 12:00" on Home, red in Calendar, under Overdue in Tasks — not only after the day ends. Stored as Task.dueMinutes (720) with reminderKind = BEFORE_DUE.
A usual length. When the sentence names no duration, the title's words suggest one, shown in Duration with "Usually 1 hour for this — change it if needed." (a length given in the text, or picked, wins): dance, a workout, massage, a lesson, the pool, shopping and chores — 1 hour; a doctor's visit — 30 min; papers (apply, renew a passport, visa, insurance) — 15 min; pay a bill, a loan, a subscription, or call / write to someone — 5 min; a film — 2 hours; pills and vitamins — none. What you do counts before what it's about: "Купить таблетки" is shopping, "Позвонить врачу" a call. The bot uses the same guess.
Several days, each with its own time — "Танцы по средам 19 и пятницам в 20" (the hour right after the day counts, without "в"), "Dance every Wed at 19 and Fri at 20" — is one task per day; New task shows a Duration row for all of them (the When row isn't shown — their days and times come from the sentence).
Dates in digits are day/month: "03/10", "3/10", "03/10/2026", "03.10.26" is October 3 (without a year — this year's, or next year's once it has passed). With a dot only with a year: "03.10" alone is still the time 03:10, as in "в 9.30".
/tasks/[id]/edit is built from the same parts as New task (components/tasks/task-fields/), with what editing needs instead of the sentence:
Only this day or the whole series. Edit on a day of a repeating task — the task page's Edit, "Edit" next to each of its next days, a Tasks row's Edit, or a Calendar event (it opens the task page on that day) — asks first: Only this day or Whole series. Only this day (/tasks/[id]/edit?occurrence=<id>) changes that day's date, time and length (a series without a time: only its date), with the same overlap notice and "Free nearby" as a one-off task. Not into the past, and not onto a date that already has a day of the series. The day is marked Edited on the task page, in Tasks and in Calendar, and its reminder moves with it. Title, reminder, repeat and note belong to the whole series. Changing the series' time, days or length later leaves a day changed on its own as it is (if the series gains or loses its time, the day stays on its date and takes the series' kind), and never puts a second day on the date it was moved from.
One day of a repeating task — "Remove this one" on the task's page (and "Remove" next to each of its next days), or in a Tasks row's ··· actions:
| What happens | |
|---|---|
| Skip | The day stays, marked Skipped. It counts as not done in "How it's going". |
| Remove this one | The day goes — from Home, Tasks and Calendar — with its reminder. It isn't counted anywhere. Changing the series' time or days later leaves it removed. Undo in the toast (10 s) brings it back; later, Restore under "Removed days" on the task page, while the day is still ahead. |
Stopping a task — on the task page, each with a dialog that says what happens first:
| What happens | |
|---|---|
| End series (repeating) | Every day from today on goes, with its reminders. Days done or skipped stay and still count in Progress. The task moves to Tasks → Ended ("Ended Oct 2"). Resume series brings back the days still ahead and fills the next 30 days again; days moved or removed on their own stay as they were. |
| Archive (one-off) | Its reminder is cancelled and it leaves Home, Tasks and Calendar; a mark it has stays in Progress. Restore brings it back — if its day has passed, as an overdue task with "Move to today". |
| Delete | The task, all its days and reminders go for good, and its history leaves Progress. |
In Week (sprint-22-tasks.md):
Two settings on /settings, each user's own:
| Setting | Default | What it does |
|---|---|---|
| Default reminder | 15 min before | Where a new task's reminder starts: at start time, 5 / 10 / 15 / 30 min, 1 hour or 1 day before. Each task can still have its own. |
| Email reminders | On | Off: no reminder emails. Telegram (if connected) and the in-app reminder on Home still come; with no Telegram, reminders only show in the app. |
Kinds of reminder (each task's own, in New task and Edit):
| Task | Choices | Default |
|---|---|---|
| With a time | No reminder · At start time · 5 / 10 / 15 / 30 min · 1 hour · 1 day before · Custom… | Default reminder |
| Without a time | No reminder · That morning, 09:00 · Evening before, 19:00 | No reminder |
| With a deadline (no time) | The above · 15 / 30 min · 1 / 2 hours before deadline | 30 min before deadline |
Custom… (tasks with a time): a number of minutes or hours before the start, from 1 minute to 24 hours — "2 hours before", "45 min before". The Default reminder on /settings stays a list.
"That morning" and "Evening before" are local wall-clock time — 09:00 on the task's day and 19:00 the day before in your timezone, through clock changes (stored as a kind, Task.reminderKind, not as "minutes before midnight", which DST would turn into 08:00 or 10:00). One whose moment has already passed isn't sent — a task for today made at 10:00 with "That morning" gets none, and the form says so. A minutes-before reminder that's already late still goes out at once, as before. The reminder of a task without a time reads "Buy groceries is scheduled for today." (subject "Reminder: Buy groceries — today"); "Snooze → tomorrow" on it is 09:00 tomorrow. Changing a task's reminder, date or time moves its pending reminders, cancels them for "No reminder", and creates missing ones — never re-sending one that already went out.
A reminder whose email fails is retried; Telegram is sent once, not again on every retry. With Email reminders off and Telegram connected, Telegram is the reminder: if it doesn't go through, it's retried the same way an email would be (up to 3 attempts, on the next runs).
The day's messages — the morning summary, /today, /next, reminders and the evening check-in — are formatted (HTML: bold headings, a clock emoji per hour, done tasks crossed out, habits in a folding quote with progress bars); what you typed is escaped. Buttons have colours (Bot API style): green to do it, blue for the main other choice, red to take something away.
Once a chat is connected (see "Telegram setup"), it works as a second way in, on the same data as the app:
/settings, under Telegram: Off (default), 07:00, 08:00, 09:00 or 10:00. The /today text with a ✓ button per open task (up to 8); pressing one marks it and redraws the summary. Below the tasks, today's habits, a line each ("Daily · 2/7", "✓ Sleep · 7.5/7.5 h", "○ Water · 0.5/1.5 L") with the day's good news, and a button for each still to do, up to 8 ("✓ Vitamins", "✓ Reading 1 h", "Water +250 ml"), which also redraw it. Sent once a day by the same 5-minute send-notifications run, so up to 5 minutes after the time you picked, and not at all if it couldn't go out within 2 hours or there's nothing planned, overdue or no habit today./settings, under Telegram: Off (default), 18:00–22:00. "Evening check-in — 2 habits left today: Water 1.5/2 L, Steps", with opening Home. The same run and 2-hour window as the summary; nothing is sent when every habit is done.A chat that isn't connected gets "Connect it from Settings" and nothing else. The bot's messages are in English, like the app.
The same app opens inside Telegram — from the Open app menu button (setup step 6), or from Open under a reminder, /next or a new task, and Open New task, which go straight to that page.
initData), /telegram sends it to POST /api/telegram/session, and the server checks the signature with the bot token and that it's under an hour old, then creates an ordinary 30-day session — the same as after Google. A forged, stale or other bot's string gets a 401 and no session./settings can sign in. Any other Telegram account sees "Connect this Telegram account first" with a button that opens Settings in the browser; no account is created from Telegram./login doesn't offer Google or email (neither works in Telegram's built-in browser) — it signs in through Telegram. Sign out works as usual; afterwards the page offers "Sign in with Telegram" instead of signing straight back in./telegram opened in a normal browser doesn't sign in and points to the usual sign-in.AUTH_URL is https (Telegram rejects anything else); with a local http://localhost they stay plain links. The Mini App itself only opens from a public https URL, so locally /telegram can be tried by adding signed data to the hash — #tgWebAppData=<initData> — the way Telegram passes it.Things you do every day — sleep, steps, water, a morning workout — kept apart from tasks: a habit has no time, is never overdue, and stays out of Calendar, Overdue and "How it's going".
The card under the greeting ("From your assistant") is on Home every day, empty days too, above the habits. Its headline and a few sentences read the day as it is (assistant-message.ts):
User.theme) and on the device; applied before the page draws, so there's no white flash. Warm espresso surfaces, ivory text, burgundy shifted to wine and used only for large fills; thin lines in brick (--accent-line); burgundy text turns ivory (--accent-text); overdue is the one warm-red text. Every colour is a token in globals.css with a light and a dark value — no hex in components. The Telegram Mini App's header follows the theme.MOTION_SPEC.md): one set of curves and durations (--ease-*, --dur-1…4; lib/motion.ts for script). Buttons give 2% on press; marking done draws the check and fades the strike in; menus fade in 4px; dialogs slide up as a sheet on a phone; inline blocks expand smoothly (Expand); tabs cross-fade, pages further in rise 12px from the side; each tab keeps its scroll; skeletons show only after 300ms. No glow, bounce or decorative loops — except the next task's dot and what was asked for (the Undo button's glow). prefers-reduced-motion turns it off.On a task's page the note's links open in a new tab, and a line that reads as an address (a number and a street word — ул., вул., St, Ave, Calle, Carrer, Rue, …straße — or after "Address:" / "Адрес:") opens in Google Maps. Inside Telegram they open through the Mini App's openLink. features/tasks/note-links.ts.
The app counts what happened to your past tasks and shows it back to you. It's plain counting over each task's status and planned time — no model, nothing new is tracked or stored.
Each task counts once:
| Outcome | When |
|---|---|
| Completed | marked Done |
| Partial | marked Partially done |
| Skipped | marked Skip |
| Missed | its day is over and it was never marked (still Scheduled or Snoozed) |
| not counted | cancelled, or still open today or later |
Completion rate = Completed ÷ everything counted. Partial and Missed count as not done: a late task you never opened isn't a finished one.
Where it shows:
/progress/how, "How it's going" (redesigned in Sprint 23) — a row on the Progress tab, and Home's sentence, lead there: the share done in the last 7 days as a large number with a line, "Your rhythm" (each part of the day over the last 30 days, a bar per part, "limited data" under 5 tasks, the strongest in burgundy), and "A small pattern" ("You tend to finish more tasks in the evening.") only when two parts with enough data are 20 points apart, with "Adjust your schedule →" to Calendar. It reveals once on arrival (counting up, rows one by one). Weekdays vs weekends and the usual workout time sit quietly at the end. Before Sprint 23 it had two blocks:
/progress, "Usual workout time: around 19:00 (6 of 8 workouts)." — under "Last 30 days", with its own threshold (see "Finding free time" → "Your habits"), so it can show before the rest does. Free-time suggestions use it too.When there's a sentence:
Each task is counted on its own, so a daily task can carry a whole part of the day — the "N of M" next to each bar shows how much it rests on. Parts of the day use your current time zone; a task moved to another time counts at the time it ended up at.
Layering rule (see docs/adr/001-project-structure.md): components never call Prisma directly.
React component → Server Action → Application service → Repository → PostgreSQL
Habit (dayTargets — seven goals Monday first when they differ, tapSetsGoal) and HabitLog — one row per habit per local date ("YYYY-MM-DD" in your time zone) with the value and that day's goal, none for a day with nothing done.