From 399778df6222e9649cc23f4b3d96d9208cbb8f24 Mon Sep 17 00:00:00 2001 From: Ashish Kumar Date: Thu, 22 Oct 2026 12:09:37 +0530 Subject: [PATCH 1/3] Spec 007: standup days are a default, and people's days are per standup --- specs/007-standup-days-default.md | 164 ++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 specs/007-standup-days-default.md diff --git a/specs/007-standup-days-default.md b/specs/007-standup-days-default.md new file mode 100644 index 0000000..94e4007 --- /dev/null +++ b/specs/007-standup-days-default.md @@ -0,0 +1,164 @@ +# 007: Standup days are a default, and people's days are per standup + +| | | +| --- | --- | +| Status | Draft | +| Size | Cross-cutting (scheduler, API, Chat, web app, no migration) | +| Figma | [Specs › 007 Standup days are a default](https://www.figma.com/design/OQfcL61rbaGakS2FjBKasI?node-id=178-73) | +| PR | Link once opened | + +## Problem + +A standup's days are a hard limit. A standup that runs Monday to Friday +can't prompt someone who works Wednesday to Sunday: their Saturday and +Sunday boxes are greyed out, and the scheduler never opens a run on +those days. The only way round it is to add Saturday and Sunday to the +standup, which then prompts everyone who follows the standup, and every +new joiner, seven days a week. Separately, a person's days are one week +shared by every standup they're on, so working weekends on one team +changes their days on every other team. Each change in the People table +also saves at once, with nothing to say it did, so people look for a +Save button. + +## User stories + +- As a standup manager, I want the standup's days to be the default, + so new people start with them. +- As a standup manager, I want to give one person any days of the week + on my standup, including days the rest of the team doesn't work. +- As someone on two standups, I want my days on one to leave the other + alone. +- As anyone changing days, I want to see that the change saved. + +## Acceptance criteria + +Defaults and per-standup days + +1. A standup's days are the default for everyone on it who hasn't been + given their own days, including group joiners and people added by + hand. Changing the standup's days changes theirs. +2. A person's days on one standup never change their days on another. +3. A person's days can be any of the seven days, including days outside + the standup's own. + +Running + +4. On one of the standup's days, the run opens as today. Everyone on + the default is prompted, and so is anyone whose own days include + that day; the others are off, as today. +5. On a day outside the standup's days, a run opens at the usual times + when at least one active person on the standup has that day in their + own days, or an active `working ` override for that date. + Only those people are prompted and reminded. +6. On such a day nobody else is on the run: they aren't prompted, aren't + listed as away in the wrap-up, and aren't counted in participation. +7. When nobody is expected on a day, no run opens. +8. Runs on extra days behave like any other run: wrap-up, late answers, + edit requests, blockers and webhooks. +9. The weekly digest still posts after the standup's last default day. + Answers from an extra day count in the week they fall in. + +People table (standup settings and the create form) + +10. Anyone with `schedules.manage` on the standup can tick any day for + anyone on it. The change applies to this standup only. +11. The standup's days are marked as the default in the header. A person + on the default shows "Default"; a person with their own days shows + "Custom" and a **Reset to default** action. +12. Ticking exactly the standup's days puts the person back on the + default, so later changes to the standup's days reach them. +13. Each change shows "Saved" beside the row, or the error. There is no + Save button. +14. The note under the table says days apply to this standup only. + +Setting your own days + +15. My schedule lists each standup the person is on, with their days on + it. Each can follow the standup, have custom days, or be ad hoc. +16. Team › Edit schedule lists the person's standups that the manager + manages, the same way. +17. In a DM, `days mon-thu` sets the person's only standup. On several, + it changes nothing and replies with one line per standup, such as + "`days #12 mon-thu` for Developers". `days #12 mon-thu` sets that + standup. +18. In a space, `days @Asha tue-sat` sets this standup only. +19. Changing your own days is refused only when that standup's time-off + policy isn't self-service. Other standups' policies don't apply. +20. Replies, the DM to the person when a manager changes their days, the + managers' digest and the `schedule_change` webhook name the one + standup changed. + +API and MCP + +21. `PATCH /me/schedule` and `PATCH /people/:userName/schedule` accept an + optional `standupId`. With it, only that standup changes, and setting + someone else's days needs `schedules.manage` on that standup. + Without it, every standup changes, as today. That form is kept for + existing integrations and documented as deprecated. +22. `GET /me/schedule` and `GET /people/:userName/schedule` add + `standups`: each standup's id, name, days, the person's + `workingDays` and its label. The top-level `workingDays` stays, and + is deprecated. +23. MCP `set_working_days` and `get_schedule` take and return the same. +24. The Team list shows a person's days when they match on every + standup the viewer can see, otherwise "Varies by standup". + +## Non-goals + +- Per-standup dated overrides and vacation. They stay per person, + across all their standups. +- Different prompt or deadline times on a person's extra days. +- Setting every standup at once from Chat or the web app. +- Changing when the weekly digest posts. + +## Design impact + +| Area | Change | +| --- | --- | +| Web app | People table in standup settings and the create form: every day can be ticked, default days marked in the header, Default/Custom per row, **Reset to default**, "Saved" per row, new note. My schedule and the Edit schedule drawer list one row of days per standup. Team list shows "Varies by standup" | +| Chat cards and dialogs | None. Text replies only: `days` in a DM and in a space, and the DM to a person whose days a manager changed | +| API and OpenAPI | `WorkingDaysBody` gains optional `standupId`. `Schedule` gains `standups`. Top-level `workingDays` on `Schedule` and the all-standups `PATCH` are marked deprecated. The `schedule_change` webhook is sent for the one standup changed | +| MCP | `set_working_days` takes optional `standupId`; `get_schedule` returns `standups` | +| Database | None. `participants.working_days` is already stored per standup. Today's values, copied to every standup, become each standup's starting value | +| Permissions | None new. Setting someone's days on a standup needs `schedules.manage` on that standup | +| Docs | `schedules.md` (defaults, per-standup days, extra days), `web-app.md` (People table, My schedule), `commands.md` (`days` in a DM and a space), `api.md` (`standupId`, `standups`, deprecations), `mcp.md`, `user-guide.md` | +| Website | None. "personal weeks" in `site/index.html` still holds | +| Google APIs and scopes | None | + +How it works: + +- `dayStatus` takes the standup's days. A person on the default + (`working_days` null) is expected only on them; custom days, ad hoc + and overrides work as today. +- The scheduler's weekday check (`src/core/scheduler.ts:113`) becomes + "is anyone on this standup expected today", using `dayStatus` and + today's active overrides. On a day outside the standup's days, the run + is created with only the people expected, which covers criterion 6. +- A new repo write sets one person's days on one standup. + `setWorkingDaysForUser` stays only for the deprecated all-standups + `PATCH`. +- `ScheduleService.setWorkingDays` takes a standup, judges the policy of + that standup only, and applies the change to that standup's run today, + in that standup's timezone. Today it uses the first standup's + timezone for all of them. +- The People table saves a week equal to the standup's days as + "follow" (`reset`). +- Leave presence already reads each standup's own value + (`src/core/leave-presence.ts:192`), so it needs no change. + +## Open questions + +None. Decided with the maintainer on 22 Oct 2026: + +- A person's days are set per standup, not once for all their standups. +- Every way of setting days changes one standup: the People table, `days` + in Chat, My schedule, Team › Edit schedule, the API and MCP. + +Proposed in this spec, for approval: + +- Ticking exactly the standup's days puts a person back on the default + (criterion 12). +- The API keeps its all-standups form, deprecated, so existing + integrations don't break (criterion 21). +- Dated overrides and vacation stay per person (Non-goals). +- On an extra day, only the people expected are on the run (criterion 6). From 28818727167a7b1235c3d4bef2536b1f1d7142b4 Mon Sep 17 00:00:00 2001 From: Ashish Kumar Date: Thu, 22 Oct 2026 12:33:46 +0530 Subject: [PATCH 2/3] Make standup days a default, and set people's days per standup --- docs/guide/api.md | 14 +- docs/guide/commands.md | 4 +- docs/guide/configuration.md | 2 +- docs/guide/mcp.md | 2 +- docs/guide/roles.md | 2 +- docs/guide/schedules.md | 59 +++++--- docs/guide/user-guide.md | 4 +- docs/guide/web-app.md | 4 +- specs/007-standup-days-default.md | 7 +- src/adapters/gchat/events.ts | 8 +- src/api/schedule.ts | 72 ++++++---- src/api/spec/routes.ts | 8 +- src/api/spec/schemas.ts | 22 ++- src/api/views.ts | 1 + src/core/commands.ts | 2 +- src/core/schedule.ts | 94 ++++++++++--- src/core/scheduler.ts | 17 ++- src/db/repo.ts | 14 +- src/mcp/tools.ts | 22 +-- tests/api-schedule.test.ts | 46 +++++++ tests/mcp.test.ts | 6 + tests/schedule-chat.test.ts | 4 +- tests/schedule.test.ts | 30 ++-- tests/standup-days.test.ts | 181 +++++++++++++++++++++++++ web/src/components/people-matrix.tsx | 64 +++++---- web/src/components/schedule-drawer.tsx | 114 ++++++++-------- web/src/lib/roster.test.tsx | 60 ++++++-- web/src/lib/roster.ts | 21 ++- web/src/lib/schedule.ts | 7 +- web/src/pages/me.tsx | 19 ++- web/src/pages/schedule.test.tsx | 50 ++++--- web/src/pages/setup/create.tsx | 6 +- web/src/pages/setup/setup.test.tsx | 4 +- web/src/pages/standup/settings.tsx | 23 +++- web/src/pages/standup/tabs.test.tsx | 9 +- web/src/styles.css | 5 + 36 files changed, 726 insertions(+), 281 deletions(-) create mode 100644 tests/standup-days.test.ts diff --git a/docs/guide/api.md b/docs/guide/api.md index 4bf3b6b..f1c18d6 100644 --- a/docs/guide/api.md +++ b/docs/guide/api.md @@ -97,8 +97,8 @@ hand; `syncedAt` and `error` describe the last sync. The same object plus: - `participants`: `userName`, `displayName`, `mandatory`, `timezone`, - `onVacation`, `workingDays` (their own week, or `null` to follow the - standup), `otherStandups` (how many other standups they're on) and `source` + `onVacation`, `workingDays` (their own days on this standup, or `null` to + follow its days), `otherStandups` (how many other standups they're on) and `source` (`manual` or `group`); - `admins`; - `keptOff`: group members taken off by hand, whom syncs keep off; @@ -169,14 +169,14 @@ Moods are withheld per person when the standup keeps them anonymous; only the te ### Team -`GET /people` — needs `people.view`. Everyone on a roster of a standup where the caller has it, with `email` (when known), `timezone`, `onVacation`, `workingDays` / `workingDaysLabel` and their `standups` (`mandatory`, `admin`). +`GET /people` — needs `people.view`. Everyone on a roster of a standup where the caller has it, with `email` (when known), `timezone`, `onVacation`, `workingDays` / `workingDaysLabel` (*Varies by standup* when their days differ) and their `standups` (`mandatory`, `admin`). Schedules of others need `schedules.manage` on a standup the person is on: | Method | Path | Notes | | --- | --- | --- | -| `GET` | `/people/:userName/schedule` | Same shape as `/me/schedule` | -| `PATCH` | `/people/:userName/schedule` | `{ workingDays }` — the person is DM'd | +| `GET` | `/people/:userName/schedule` | Same shape as `/me/schedule`; `standups` lists only the standups the caller manages | +| `PATCH` | `/people/:userName/schedule` | `{ workingDays, standupId }` — needs `schedules.manage` on that standup; the person is DM'd | | `POST` | `/people/:userName/overrides` | Same body as `/me/overrides`; managers may set past dates and are never subject to the policy — the person is DM'd | | `DELETE` | `/people/:userName/overrides/:date` | Withdraw | | `GET` | `/requests` | Pending time-off requests: all of them with `schedules.manage` across the workspace, otherwise those of people on standups where the caller has it | @@ -189,8 +189,8 @@ Schedules of others need `schedules.manage` on a standup the person is on: | `GET` | `/me/standups` | `linked: false` until the account has a Chat identity; otherwise `timezone` (own override or `null`), `chat.dmUrl` (deep link to the bot's DM once one exists) and each standup with `today` = `submitted`, `waiting`, `closed` or `null` plus `progress` (`{ submitted, expected }` while a run exists) | | `GET` | `/me/submissions?limit=10` | My recent answers, with my own `moodComment` | | `PATCH` | `/me` | `{ timezone?: string \| null, onVacation?: boolean }`; `409 not_linked` without a Chat identity | -| `GET` | `/me/schedule` | My personal week (`workingDays`, `workingDaysLabel`), the `policies` of my standups and upcoming `overrides` (each with `status`, `setBy`, `channel`, `decidedBy`) | -| `PATCH` | `/me/schedule` | `{ workingDays: "mon-thu" \| "adhoc" \| "reset" }` — the same words the DM `days` command takes; `400 invalid` otherwise | +| `GET` | `/me/schedule` | My days on each standup (`standups`: `standupId`, `name`, the standup's `days`, my `workingDays` and `workingDaysLabel`, `timeOffPolicy`), the `policies` of my standups and upcoming `overrides` (each with `status`, `setBy`, `channel`, `decidedBy`). The top-level `workingDays` and `workingDaysLabel` are deprecated | +| `PATCH` | `/me/schedule` | `{ workingDays: "mon-thu" \| "adhoc" \| "reset", standupId }` — the same words the DM `days` command takes, for one standup; `400 invalid` otherwise. Without `standupId` every standup changes; that form is deprecated | | `POST` | `/me/overrides` | `{ date }`, `{ dates: [] }` or `{ from, to }` (ISO, ≤ 31 days), `working?: boolean`, `reason?` — a day off or an extra working day; `403 refused` when the standup's time-off policy says so, with the reason | | `DELETE` | `/me/overrides/:date` | Withdraw an entry | | `POST` | `/me/skip` | `{ standupId, reason }` — skip today's run with a one-line reason (up to 150 characters), posted in the day's thread; `400 invalid` on `reason` when it is missing or too long, `409 no_open_run` before it opens or after it closes, `409 already_submitted` once answered | diff --git a/docs/guide/commands.md b/docs/guide/commands.md index 89676db..62a1e00 100644 --- a/docs/guide/commands.md +++ b/docs/guide/commands.md @@ -28,7 +28,7 @@ space. `status` without a `#id` prefix reports every standup in the space. | `mandatory @user…` / `optional @user…` | Count toward the wrap-up report, or not | | `vacation @user…` / `back @user…` | Mark people away — no prompts, not counted as missing | | `off @user [reason]` / `working @user ` | Mark someone away or working on given days; `off @user cancel ` undoes ([Personal schedules](./schedules)) | -| `days @user mon-thu\|adhoc\|reset` | Someone's personal week | +| `days @user mon-thu\|adhoc\|reset` | Someone's days on this standup | | `admin @user…` / `unadmin @user…` | Manage the standup's admins, who are its Managers | | `role @user ` / `role @user remove ` | Grant or revoke a role on this standup, say `Viewer` for a stakeholder. Roles follow email, so `role pat@example.com Viewer` works for someone not yet in Chat. You can only grant a role whose permissions you hold here | | `roles` | Who holds a role on this standup, and the roles you can grant | @@ -100,7 +100,7 @@ Anyone can DM the bot directly: | `vacation` (or `ooo`) | Pause prompts for yourself across all your standups | | `off [reason]` / `working ` | A day off (e.g. `off tomorrow comp off`) or an extra working day | | `off list` / `off cancel ` | Upcoming entries / withdraw one | -| `days mon-thu\|adhoc\|reset` | Your own week ([Personal schedules](./schedules)) | +| `days [#standup] mon-thu\|adhoc\|reset` | Your days on one standup; on several, the bot asks which and gives the number ([Personal schedules](./schedules)) | | `back` | Resume prompts | | `unskip` | Take back today's skip while the run is open — or press **↩️ Undo skip** on the prompt card | | `timezone ` | Get prompts at the standup's prompt time in *your* timezone (all your standups) | diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index e84667a..3580ca2 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -95,7 +95,7 @@ Zapier/n8n, or your own service: | --- | --- | --- | | `submission` | Every submission and edit | `standup`, `date`, `user`, `answers`, `mood`, `late`, `edited`, `editApprovedBy` (who approved an edit after the deadline, otherwise `null`). Mood comments are never sent | | `wrap_up` | The run closes | `standup`, `date`, `summary` (the posted wrap-up's numbers, plus `teamMood` on anonymous standups) | -| `schedule_change` | Someone's week or days off change | person, summary, who, channel — see [Personal schedules](./schedules#integrations) | +| `schedule_change` | Someone's days on the standup, or their days off, change | person, summary, who, channel — see [Personal schedules](./schedules#integrations) | | `time_off_request` | A request needs approval | person, dates, working, reason, request ids | | `test` | **Send test** in the standup's settings | `standup` | diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 26e303e..83e6f6d 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -33,7 +33,7 @@ Pick only the scopes the assistant needs: | `read` | `get_me`, `list_standups`, `get_standup`, `get_my_standups`, `list_runs`, `get_run`, `list_blockers`, `get_team`, `get_insights`, `get_schedule`, `list_time_off_requests` | | `blockers:write` | `acknowledge_blocker`, `update_blocker`, `resolve_blocker` | | `submit` | `submit_answers` (every answer required, and the mood when the standup asks for one; `moodComment` is optional), `skip_today` (takes a one-line `reason`, posted in the day's thread) | -| `schedule` | `update_my_settings` (timezone, vacation), `set_working_days`, `set_days_off`, `cancel_day_off`, `decide_time_off_request` | +| `schedule` | `update_my_settings` (timezone, vacation), `set_working_days` (with `standupId`), `set_days_off`, `cancel_day_off`, `decide_time_off_request` | | `manage` | `run_standup_now`, `nudge_standup` — only on standups you manage | The token is shown once. Copy it before closing the panel. diff --git a/docs/guide/roles.md b/docs/guide/roles.md index 9cfce9f..560d7a4 100644 --- a/docs/guide/roles.md +++ b/docs/guide/roles.md @@ -41,7 +41,7 @@ Built-in roles can't be changed. Copy one to start a custom role. | `standups.configure` | Change the schedule, questions, mood, escalation and webhook; run now, nudge, close polls | ✓ | ✓ | | | | `standups.archive` | Archive a standup and bring it back | ✓ | ✓ | | | | `people.manage` | Add and remove people, mark them required, make managers, grant roles on the standup | ✓ | ✓ | | | -| `schedules.manage` | Change people's weeks and days off, decide time-off requests | ✓ | ✓ | | | +| `schedules.manage` | Change people's days and days off on the standup, decide time-off requests | ✓ | ✓ | | | | `blockers.manage` | Resolve anyone's blocker | ✓ | ✓ | | | | `standups.create` | Create standups | ✓ | | | | | `standups.delete` | Delete an archived standup with its history | ✓ | | | | diff --git a/docs/guide/schedules.md b/docs/guide/schedules.md index 6f1e4ed..c013502 100644 --- a/docs/guide/schedules.md +++ b/docs/guide/schedules.md @@ -7,24 +7,37 @@ on days they don't work — and from being counted as missing. ## Two building blocks -**A personal week** overrides the standup's days for one person: +**A person's days** on a standup. The standup's own days are the default, +and anyone can be given different days, including days the standup doesn't +run for everyone else. Days belong to one standup: someone on two standups +can work Monday to Friday on one and Wednesday to Sunday on the other. | Setting | Meaning | | --- | --- | -| Follow the standup (default) | Expected on every day the standup runs | -| Custom week, e.g. `mon-thu` | Expected only on those weekdays | +| Follow the standup (default) | Expected on the standup's days, and on any it adds later | +| Custom days, e.g. `mon-thu` or `wed-sun` | Expected only on those days, whichever they are | | Ad hoc | No fixed days — expected only on dates marked *working* | +New people, whether they join a group roster or are added by hand, follow +the standup's days. + **Dated overrides** mark single days or ranges as *off* or *working*, with a short reason. Comp-off after a weekend shift is an *off* override on the weekday; an ad hoc person marks their *working* days. Both can be set ahead. When a run opens, someone is expected only if the date is a working day for -them and not overridden off. Everyone else starts the run as **away** with a -reason — day off, not a working day, vacation, or calendar out-of-office — gets -no prompt, is not counted as missing, and shows under *Away* on the overview -and in reports. A standup still only runs on its own days, so a Saturday -worker is not prompted on Saturday unless the standup itself includes Saturday. +them and not overridden off. On the standup's own days, everyone else starts +the run as **away** with a reason — day off, not a working day, vacation, or +calendar out-of-office — gets no prompt, is not counted as missing, and shows +under *Away* on the overview and in reports. + +On any other day, the standup runs only if someone on it works that day: their +own days include it, or they marked it *working*. That run, at the usual +times, is just for them. Nobody else is prompted, listed as away or counted. +The weekly digest still posts after the standup's last day, so answers from a +Saturday or Sunday after it show in that week's Insights rather than in the +digest already posted. Days off and *working* days are per person, so a +*working* Saturday opens a Saturday run on every standup they are on. ## From Chat @@ -37,7 +50,7 @@ DM the bot: | `working sat` | An extra working day (ad hoc people use this for every day they work) | | `off list` | What's coming up, with each entry's status | | `off cancel ` | Withdraw an entry | -| `days mon-thu` · `days adhoc` · `days reset` | Your own week, or back to the standup's | +| `days mon-thu` · `days adhoc` · `days reset` | Your days on your standup, or back to its days. On several standups, the bot asks which and replies with the command for each, such as `days #12 mon-thu` | Dates read naturally: `today`, `tomorrow`, a weekday name (the next one), `19 sep`, `sep 19`, `2026-09-19`. A date without a year means the coming one unless it is @@ -50,7 +63,7 @@ Managers, in the team space with an @mention of someone on that standup: | --- | --- | | `off @Asha tomorrow dentist` / `working @Asha sat` | Mark someone away or working | | `off @Asha cancel ` | Undo it | -| `days @Asha tue-sat` | Someone's personal week (`days mon,tue` without a mention still sets the standup's days) | +| `days @Asha tue-sat` | Someone's days on this standup only (`days mon,tue` without a mention still sets the standup's days) | The person is DM'd every time a manager changes their schedule, with the one-word reply that undoes it. @@ -61,13 +74,17 @@ one-word reply that undoes it. space, or everyone in the Google Group it follows ([group rosters](./directory#rosters-that-follow-a-google-group)). Per person, tick whether they are on the standup, whether they are required, and - which of the standup's days they work. Days the standup doesn't run are - greyed out. The create form uses the same grid, with everyone in the space - ticked to start with. -- **Team → Edit schedule** on any person: the week, the overrides list with - who set each one, an add row, and *Mark away today*. -- **My page → My schedule**: the same controls for yourself, plus *Off today* - and *Off tomorrow*. + which days they work on it. The standup's days are marked *default*; any + other day can be ticked too. Each row says *Default* or *Custom*, with + *Reset to default*, and each tick saves straight away and shows *Saved*. + Ticking exactly the standup's days puts the person back on the default. The + create form uses the same grid, with everyone in the space ticked to start + with. +- **Team → Edit schedule** on any person: their days on each standup you + manage, the overrides list with who set each one, an add row, and *Mark + away today*. +- **My page → My schedule**: your days on each standup, the same controls, + plus *Off today* and *Off tomorrow*. - **Standup → Overview**: the *Away* group under today's run, with reason chips. - **Standup → Settings → Time off**: the policy below. @@ -77,9 +94,10 @@ One setting per standup, under *Settings › Schedule*. A manager is an admin of a standup the person is on; managers and workspace admins can mark that person away or working in every mode. -A person changing their own schedule is bound by the policy of every standup +A person changing their own days off is bound by the policy of every standup they are on and don't administer. Someone who runs one team and sits on -another still needs the other team's approval. +another still needs the other team's approval. Days belong to one standup, so +changing them is bound by that standup's policy only. | Policy | A person marking themselves | Managers hear about it | | --- | --- | --- | @@ -95,7 +113,7 @@ person counts as expected for that run and is told so. | A person under *Needs approval* or *Managers only* | Allowed? | | --- | --- | -| Changing their personal week | No — there is no request for a week, so managers set it | +| Changing their days on that standup | No — there is no request for days, so its managers set them | | Asking again for a day that is already approved | Yes — only the reason changes | | Asking for the opposite of an approved day | No — ask a manager | | Withdrawing a pending request, or giving back a day off | Yes | @@ -140,6 +158,7 @@ statuses on Business and Enterprise editions. Webhooks receive two more events: `schedule_change` (person, summary, who, channel) and `time_off_request` (person, dates, working, reason, request ids). +A change to someone's days goes to that standup's webhook and digest only. The JSON API exposes the same controls under `/me/schedule`, `/me/overrides`, `/people/:userName/schedule`, `/people/:userName/overrides` and `/requests` — see [JSON API](./api). MCP tokens with the `schedule` scope get diff --git a/docs/guide/user-guide.md b/docs/guide/user-guide.md index 377bf96..10e635c 100644 --- a/docs/guide/user-guide.md +++ b/docs/guide/user-guide.md @@ -67,7 +67,7 @@ blocker is tagged or has updates, only an explicit resolve closes it. | `timezone` / `timezone reset` | Show / clear your personal timezone | | `off tomorrow comp off` / `off 22 sep to 24 sep sick` / `working sat` | A day (or range) off, or an extra working day — see [Personal schedules](./schedules) | | `off list` / `off cancel ` | What's coming up / withdraw an entry | -| `days mon-thu` / `days adhoc` / `days reset` | Your own week, or back to the standup's | +| `days mon-thu` / `days adhoc` / `days reset` | Your days on your standup, or back to its days. On several standups, the bot asks which, for example `days #12 mon-thu` | ## Your own page — `/app/me` @@ -77,7 +77,7 @@ SSO**. You get: - today's standups with an **Answer in Chat** link and **Skip today** (it asks for the one-line reason too); - your recent answers; -- your timezone, vacation mode and **My schedule** (your week and days off). +- your timezone, vacation mode and **My schedule** (your days on each standup, and days off). If your account isn't linked to Chat yet, it links the first time you use the bot, or straight away when your admins have set up the diff --git a/docs/guide/web-app.md b/docs/guide/web-app.md index 2abb91a..609b5c9 100644 --- a/docs/guide/web-app.md +++ b/docs/guide/web-app.md @@ -67,7 +67,7 @@ rosters](./directory)). Per person: | --- | --- | | On standup | Prompted for this standup | | Required | Listed as missing when they don't answer. The others still get the prompt but never count as missing | -| Mon–Sun | The days they work. Unticked days mean no prompt, and never missing. The standup's own off days are greyed out | +| Mon–Sun | The days they work on this standup. The standup's days are marked *default*; any other day can be ticked, and the standup then runs that day for them. Each row says *Default* or *Custom* (with *Reset to default*), and each tick saves at once and shows *Saved*. Unticked days mean no prompt, and never missing | **Roster** switches between *Picked from the space* and *Everyone in a Google Group*. Either way, **Last sync** shows when the roster was last checked, with @@ -82,7 +82,7 @@ Group*. Either way, **Last sync** shows when the roster was last checked, with The create form uses the same grid, with everyone in the space ticked to start. -Personal weeks, days off and time-off approvals also live under **Team → Edit +People's days on each standup, days off and time-off approvals also live under **Team → Edit schedule**, **My page → My schedule** and **Team → Requests** — see [Personal schedules](./schedules). diff --git a/specs/007-standup-days-default.md b/specs/007-standup-days-default.md index 94e4007..8e0207d 100644 --- a/specs/007-standup-days-default.md +++ b/specs/007-standup-days-default.md @@ -2,7 +2,7 @@ | | | | --- | --- | -| Status | Draft | +| Status | Done | | Size | Cross-cutting (scheduler, API, Chat, web app, no migration) | | Figma | [Specs › 007 Standup days are a default](https://www.figma.com/design/OQfcL61rbaGakS2FjBKasI?node-id=178-73) | | PR | Link once opened | @@ -56,7 +56,8 @@ Running 8. Runs on extra days behave like any other run: wrap-up, late answers, edit requests, blockers and webhooks. 9. The weekly digest still posts after the standup's last default day. - Answers from an extra day count in the week they fall in. + Answers from an extra day after it count in that week's Insights, not + in the digest already posted. People table (standup settings and the create form) @@ -154,7 +155,7 @@ None. Decided with the maintainer on 22 Oct 2026: - Every way of setting days changes one standup: the People table, `days` in Chat, My schedule, Team › Edit schedule, the API and MCP. -Proposed in this spec, for approval: +Proposed in this spec and approved with it: - Ticking exactly the standup's days puts a person back on the default (criterion 12). diff --git a/src/adapters/gchat/events.ts b/src/adapters/gchat/events.ts index 91c1bc9..de373e3 100644 --- a/src/adapters/gchat/events.ts +++ b/src/adapters/gchat/events.ts @@ -146,7 +146,7 @@ export class EventRouter { 'When a standup is due you\'ll get a card here with a *Fill standup* button.\n' + 'DM commands: `vacation` (pause prompts while you\'re away) · `back` (resume) · `unskip` (take back today\'s skip) · ' + '`off [reason]` (a day off, e.g. `off tomorrow comp off`) · `working ` · `off list` · `off cancel ` · ' + - '`days mon-thu|adhoc|reset` (your own week) · ' + + '`days [#standup] mon-thu|adhoc|reset` (your own days) · ' + '`timezone ` (get your prompts at the standup time in *your* zone; `timezone reset` to follow the standup zone).\n' + 'Team configuration happens in the team space — mention me with `help` there.', }; @@ -162,7 +162,11 @@ export class EventRouter { const zone = standups[0]?.timezone ?? 'UTC'; const now = schedule.nowIn(zone); const actor = { userName: user.userName, displayName: user.displayName, admin: false }; - if (command === 'days') return (await schedule.setWorkingDays(user, text, actor, 'chat')).message; + if (command === 'days') { + // `days #12 mon-thu` names the standup; without it, someone on several is asked which. + const named = /^#(\d+)\s+([\s\S]*)$/.exec(text); + return (await schedule.setWorkingDays(user, named ? named[2]! : text, actor, 'chat', named ? Number(named[1]) : undefined)).message; + } if (command === 'off' && /^list\b/i.test(text)) { const upcoming = await schedule.listUpcoming(user.userName, zone); if (upcoming.length === 0) return 'Nothing coming up — your usual week applies. `off ` to mark a day off.'; diff --git a/src/api/schedule.ts b/src/api/schedule.ts index f78cc3e..e3fc256 100644 --- a/src/api/schedule.ts +++ b/src/api/schedule.ts @@ -4,7 +4,7 @@ import type { ScheduleOverride } from '../core/types.js'; import type { Repo } from '../db/repo.js'; import type { Principal } from './principal.js'; import { canSomewhere, standupsWith } from '../core/permissions.js'; -import { allowed, apiError, principalOf, type ApiContext } from './shared.js'; +import { allowed, apiError, principalOf, standupDays, type ApiContext } from './shared.js'; /** Whether the caller manages schedules on a standup this person is on, or across the workspace. */ export async function managesPerson(repo: Repo, p: Principal, userName: string): Promise { @@ -24,21 +24,46 @@ export async function displayNameOf(repo: Repo, userName: string): Promise x.userName === userName)?.displayName ?? userName) : userName; } -/** The schedule view the console and the MCP tools share. */ -export async function scheduleView(repo: Repo, schedule: ScheduleService, userName: string, displayName: string) { +/** + * The schedule view the console and the MCP tools share. `visible` limits the + * per-standup days to the standups a manager looking at someone manages. + */ +export async function scheduleView(repo: Repo, schedule: ScheduleService, userName: string, displayName: string, visible: (standupId: number) => boolean = () => true) { const workingDays = await repo.getUserWorkingDays(userName); const standups = await repo.listStandupsForUser(userName); const zone = standups[0]?.timezone ?? 'UTC'; + const days = new Map(); + for (const s of standups) days.set(s.id, (await repo.listParticipants(s.id)).find((p) => p.userName === userName)?.workingDays ?? null); return { userName, displayName, workingDays, workingDaysLabel: describeWorkingDays(workingDays), + standups: standups.filter((s) => visible(s.id)).map((s) => ({ + standupId: s.id, + name: s.name, + days: standupDays(s), + workingDays: days.get(s.id) ?? null, + workingDaysLabel: describeWorkingDays(days.get(s.id) ?? null), + timeOffPolicy: s.timeOffPolicy, + })), policies: standups.map((s) => ({ standupId: s.id, name: s.name, timeOffPolicy: s.timeOffPolicy })), overrides: (await schedule.listUpcoming(userName, zone)).map(overrideView), }; } +/** Which standup a days change is for: `standupId` from the body, else every standup (deprecated). */ +export function daysScope(body: any): number | 'all' | null { + const id = body?.standupId; + if (id === undefined || id === null) return 'all'; + return Number.isInteger(id) && id > 0 ? id : null; +} + +/** Standups the caller manages schedules on, for someone else's view. */ +export function managedBy(p: Principal): (standupId: number) => boolean { + return (id) => allowed(p, 'schedules.manage', id); +} + /** * Personal weeks, days off and time-off approvals — the console's side of * what the Chat commands `off`, `working` and `days` do. @@ -61,7 +86,21 @@ export function registerScheduleRoutes(api: Router, ctx: ApiContext): void { }; const managerOf = (p: Principal, userName: string) => managesPerson(repo, p, userName); - const view = (userName: string, displayName: string) => scheduleView(repo, ctx.schedule!, userName, displayName); + const view = (userName: string, displayName: string, visible?: (standupId: number) => boolean) => scheduleView(repo, ctx.schedule!, userName, displayName, visible); + + const setDays = async (req: Request, res: Response, who: { userName: string; displayName: string }, actor: Actor, visible?: (standupId: number) => boolean) => { + const scope = daysScope(req.body); + if (scope === null) { + apiError(res, 400, 'invalid', 'standupId must be a positive whole number.', 'standupId'); + return; + } + const result = await ctx.schedule!.setWorkingDays(who, String(req.body?.workingDays ?? 'reset'), actor, 'console', scope); + if (!result.ok) { + apiError(res, 400, 'invalid', result.message, 'workingDays'); + return; + } + res.json(await view(who.userName, who.displayName, visible)); + }; const datesFrom = (body: any, res: Response): string[] | null => { const raw: unknown[] = Array.isArray(body.dates) ? body.dates : body.date ? [body.date] : []; @@ -113,17 +152,10 @@ export function registerScheduleRoutes(api: Router, ctx: ApiContext): void { }); api.patch('/me/schedule', async (req, res) => { - const svc = schedule(res); - if (!svc) return; + if (!schedule(res)) return; const me = self(req, res); if (!me) return; - const p = principalOf(req); - const result = await svc.setWorkingDays(me, String(req.body?.workingDays ?? 'reset'), actorFor(p), 'console'); - if (!result.ok) { - apiError(res, 400, 'invalid', result.message, 'workingDays'); - return; - } - res.json(await view(me.userName, me.displayName)); + await setDays(req, res, me, actorFor(principalOf(req))); }); api.post('/me/overrides', async (req, res) => { @@ -156,27 +188,21 @@ export function registerScheduleRoutes(api: Router, ctx: ApiContext): void { apiError(res, 403, 'forbidden', 'Only admins and this person’s managers can do that.'); return null; } - return { userName, displayName: await displayNameOf(repo, userName), actor: actorFor(p) }; + return { userName, displayName: await displayNameOf(repo, userName), actor: actorFor(p), visible: managedBy(p) }; }; api.get('/people/:userName/schedule', async (req, res) => { if (!schedule(res)) return; const t = await target(req, res); if (!t) return; - res.json(await view(t.userName, t.displayName)); + res.json(await view(t.userName, t.displayName, t.visible)); }); api.patch('/people/:userName/schedule', async (req, res) => { - const svc = schedule(res); - if (!svc) return; + if (!schedule(res)) return; const t = await target(req, res); if (!t) return; - const result = await svc.setWorkingDays(t, String(req.body?.workingDays ?? 'reset'), t.actor, 'console'); - if (!result.ok) { - apiError(res, 400, 'invalid', result.message, 'workingDays'); - return; - } - res.json(await view(t.userName, t.displayName)); + await setDays(req, res, { userName: t.userName, displayName: t.displayName }, t.actor, t.visible); }); api.post('/people/:userName/overrides', async (req, res) => { diff --git a/src/api/spec/routes.ts b/src/api/spec/routes.ts index 0787206..5ef1993 100644 --- a/src/api/spec/routes.ts +++ b/src/api/spec/routes.ts @@ -157,12 +157,12 @@ export const ROUTES: RouteSpec[] = [ { method: 'patch', path: '/me', summary: 'My timezone and vacation', tag: 'Me', access: 'person', body: S.MyPatch, responses: { 200: S.MySettings }, errors: [400, 409] }, // schedules - { method: 'get', path: '/me/schedule', summary: 'My week, policies and upcoming days', tag: 'Schedules', access: 'person', responses: { 200: S.Schedule }, errors: [409, 503] }, - { method: 'patch', path: '/me/schedule', summary: 'Set my week', tag: 'Schedules', access: 'person', body: S.WorkingDaysBody, responses: { 200: S.Schedule }, errors: [400, 409, 503] }, + { method: 'get', path: '/me/schedule', summary: 'My days on each standup, policies and upcoming days', tag: 'Schedules', access: 'person', responses: { 200: S.Schedule }, errors: [409, 503] }, + { method: 'patch', path: '/me/schedule', summary: 'Set my days on a standup', tag: 'Schedules', access: 'person', body: S.WorkingDaysBody, responses: { 200: S.Schedule }, errors: [400, 409, 503] }, { method: 'post', path: '/me/overrides', summary: 'Mark my days off or extra working days', tag: 'Schedules', access: 'person', body: S.OverrideBody, responses: { 200: S.ScheduleChange }, errors: [400, 409, 503] }, { method: 'delete', path: '/me/overrides/:date', summary: 'Withdraw a day or a pending request', tag: 'Schedules', access: 'person', params: dateParam, responses: { 200: S.ScheduleChange }, errors: [400, 409, 503] }, - { method: 'get', path: '/people/:userName/schedule', summary: 'Someone’s week and upcoming days', tag: 'Schedules', access: 'manager', permission: 'schedules.manage', params: userNameParam, responses: { 200: S.Schedule }, errors: [503] }, - { method: 'patch', path: '/people/:userName/schedule', summary: 'Set someone’s week', tag: 'Schedules', access: 'manager', permission: 'schedules.manage', params: userNameParam, body: S.WorkingDaysBody, responses: { 200: S.Schedule }, errors: [400, 503] }, + { method: 'get', path: '/people/:userName/schedule', summary: 'Someone’s days and upcoming days', tag: 'Schedules', access: 'manager', permission: 'schedules.manage', params: userNameParam, responses: { 200: S.Schedule }, errors: [503] }, + { method: 'patch', path: '/people/:userName/schedule', summary: 'Set someone’s days on a standup', tag: 'Schedules', access: 'manager', permission: 'schedules.manage', params: userNameParam, body: S.WorkingDaysBody, responses: { 200: S.Schedule }, errors: [400, 503] }, { method: 'post', path: '/people/:userName/overrides', summary: 'Mark someone’s days off or extra working days', tag: 'Schedules', access: 'manager', permission: 'schedules.manage', params: userNameParam, body: S.OverrideBody, responses: { 200: S.ScheduleChange }, errors: [400, 503] }, { method: 'delete', path: '/people/:userName/overrides/:date', summary: 'Withdraw someone’s day', tag: 'Schedules', access: 'manager', permission: 'schedules.manage', params: { ...userNameParam, ...dateParam }, responses: { 200: S.ScheduleChange }, errors: [400, 503] }, { method: 'get', path: '/requests', summary: 'Time-off requests waiting for a decision', tag: 'Schedules', access: 'manager', permission: 'schedules.manage', responses: { 200: S.TimeOffRequests }, errors: [503] }, diff --git a/src/api/spec/schemas.ts b/src/api/spec/schemas.ts index 0789b74..f172149 100644 --- a/src/api/spec/schemas.ts +++ b/src/api/spec/schemas.ts @@ -360,12 +360,22 @@ export const Schedule = named( z.strictObject({ userName: z.string(), displayName: z.string(), - workingDays: z.string().nullable(), - workingDaysLabel: z.string(), + workingDays: z.string().nullable().describe('Deprecated: days differ per standup, so read `standups`.'), + workingDaysLabel: z.string().describe('Deprecated, with `workingDays`.'), + standups: z.array( + z.strictObject({ + standupId: id, + name: z.string(), + days: z.array(Weekday).describe('The standup’s own days: the default.'), + workingDays: z.string().nullable(), + workingDaysLabel: z.string(), + timeOffPolicy: TimeOffPolicy, + }), + ), policies: z.array(z.strictObject({ standupId: id, name: z.string(), timeOffPolicy: TimeOffPolicy })), overrides: z.array(Override), }), - '`workingDays` is null when the person follows the standup, `adhoc`, or a comma list of weekdays.', + 'A person’s days on each standup: `workingDays` is null when they follow the standup’s days, `adhoc`, or a comma list of weekdays. Someone else’s schedule lists only the standups the caller manages.', ); export const ScheduleChange = named( @@ -534,7 +544,11 @@ export const UpdateParticipant = named('UpdateParticipant', z.strictObject({ man export const BlockerUpdateBody = named('BlockerUpdateBody', z.strictObject({ text: text.min(1) })); export const SkipBody = named('SkipBody', z.strictObject({ standupId: id, reason: z.string().min(1).max(LIMITS.skipNoteMax) }), 'The reason is one line, posted in the day’s thread.'); export const MyPatch = named('MyPatch', z.strictObject({ timezone: z.string().nullable(), onVacation: z.boolean() }).partial(), 'A null or empty timezone follows the standups’ zones again.'); -export const WorkingDaysBody = named('WorkingDaysBody', z.strictObject({ workingDays: z.string().optional() }), '"mon-thu", "mon,wed,fri", "adhoc", or "reset" (the default) to follow the standup.'); +export const WorkingDaysBody = named( + 'WorkingDaysBody', + z.strictObject({ workingDays: z.string().optional(), standupId: id.optional() }), + '"mon-thu", "mon,wed,fri", "adhoc", or "reset" (the default) to follow the standup, on the standup `standupId`. Without `standupId` every standup changes; that form is deprecated.', +); export const OverrideBody = named( 'OverrideBody', z.strictObject({ diff --git a/src/api/views.ts b/src/api/views.ts index f919950..1e110bc 100644 --- a/src/api/views.ts +++ b/src/api/views.ts @@ -249,6 +249,7 @@ export async function peopleView(repo: Repo, p: Principal) { standups: [], }; entry.onVacation = entry.onVacation || row.onVacation; + if (row.workingDays !== entry.workingDays) entry.workingDaysLabel = 'Varies by standup'; entry.standups.push({ id: row.standupId, name: row.standupName, diff --git a/src/core/commands.ts b/src/core/commands.ts index b9884f7..e3982ea 100644 --- a/src/core/commands.ts +++ b/src/core/commands.ts @@ -251,7 +251,7 @@ export class CommandHandler { const elsewhere = await this.notHere(standup, target); if (elsewhere) return elsewhere; const value = this.withoutMentions(rest, ctx.mentions); - const result = await this.schedule.setWorkingDays(target, value, this.actorFor(ctx), 'chat'); + const result = await this.schedule.setWorkingDays(target, value, this.actorFor(ctx), 'chat', standup.id); return result.message; } diff --git a/src/core/schedule.ts b/src/core/schedule.ts index 67efaa4..00a0a1c 100644 --- a/src/core/schedule.ts +++ b/src/core/schedule.ts @@ -4,6 +4,7 @@ import type { Mention } from './commands.js'; import { can, resolveAccess } from './permissions.js'; import type { Repo } from '../db/repo.js'; import { + standupDays, WEEKDAYS, type AwayReason, type Participant, @@ -89,19 +90,24 @@ export type DayStatus = { expected: true } | { expected: false; reason: AwayReas /** * Whether a person is expected on a date. A dated override wins, then - * vacation, then the personal week; the standup's own days are not consulted - * because a run only exists on them. + * vacation, then their own days on the standup, else the standup's days. */ export function dayStatus( participant: Pick, date: string, override: ScheduleOverride | null | undefined, + standupDays: readonly string[], ): DayStatus { if (override?.status === 'active') return override.working ? { expected: true } : { expected: false, reason: 'day_off' }; if (participant.onVacation) return { expected: false, reason: 'vacation' }; - if (participant.workingDays === null) return { expected: true }; if (participant.workingDays === ADHOC) return { expected: false, reason: 'off_day' }; - return participant.workingDays.split(',').includes(weekdayOf(date)) ? { expected: true } : { expected: false, reason: 'off_day' }; + const week = participant.workingDays === null ? standupDays : participant.workingDays.split(','); + return week.includes(weekdayOf(date)) ? { expected: true } : { expected: false, reason: 'off_day' }; +} + +/** "Mon–Fri": the standup's own days, as people read them. */ +export function standupDaysLabel(standup: Standup): string { + return describeWorkingDays(standupDays(standup).join(',')); } export const AWAY_LABEL: Record = { @@ -267,26 +273,62 @@ export class ScheduleService { return { ok: true, self, governed: standups.filter((s) => !administered.has(s.id)) }; } - async setWorkingDays(target: Mention, value: string, actor: Actor, channel: ScheduleChannel): Promise { + /** + * Sets a person's days on one standup. Without a standup, someone on one + * standup sets that one and someone on several is asked which; `all` is the + * deprecated form that sets every standup at once. + */ + async setWorkingDays(target: Mention, value: string, actor: Actor, channel: ScheduleChannel, scope?: number | 'all'): Promise { const parsed = parseWorkingDays(value); if (!parsed.ok) return parsed; const standups = await this.repo.listStandupsForUser(target.userName); if (standups.length === 0) return { ok: false, message: notOnRoster(target, actor) }; + if (scope === 'all') return this.setEveryWeek(target, parsed.value, actor, channel, standups); + if (scope === undefined && standups.length > 1) { + const lines = standups.map((s) => `\`days #${s.id} ${value.trim()}\` for ${s.name}`); + return { ok: false, message: `Which standup? Reply with one of these:\n${lines.join('\n')}` }; + } + const standup = scope === undefined ? standups[0]! : standups.find((s) => s.id === scope); + const self = actor.userName === target.userName; + if (!standup) return { ok: false, message: self ? 'You’re not on that standup.' : `${target.displayName} isn’t on that standup.` }; + const manages = actor.admin || can(await resolveAccess(this.repo, { tenantId: standup.tenantId, userName: actor.userName }, false), 'schedules.manage', standup.id); + if (!self && !manages) return { ok: false, message: `Only the managers of *${standup.name}* can change ${target.displayName}'s days there.` }; + // Days have no request flow, so under approval they are the managers' call too. + if (self && !manages && standup.timeOffPolicy !== 'self') { + return { ok: false, message: `Only your managers can change your days on *${standup.name}* — ask them, or DM them the days you need.` }; + } + await this.repo.setWorkingDays(standup.id, target.userName, parsed.value); + const days = describeWorkingDays(parsed.value); + const summary = parsed.value === null ? `${target.displayName} now follows ${standup.name}'s days` : `${target.displayName}'s days on ${standup.name} are now ${days}`; + await this.recordChange(target, summary, actor, self, channel, [standup], standups); + await this.applyToRuns(target.userName, [this.today(standup)], true, [standup]); + if (!self) { + const tag = standups.length > 1 ? `#${standup.id} ` : ''; + const told = parsed.value === null ? `put you back on *${standup.name}*’s days (*${standupDaysLabel(standup)}*)` : `set your days on *${standup.name}* to *${days}*`; + await this.dm(target.userName, `📅 ${actor.displayName} ${told}.${parsed.value === null ? '' : ` Reply \`days ${tag}reset\` to follow the standup again.`}`); + } + const adhoc = parsed.value === ADHOC ? ' Mark working days with `working `.' : ''; + if (parsed.value === null) return { ok: true, status: 'active', message: `✅ ${self ? 'You now follow' : `${target.displayName} now follows`} *${standup.name}*’s days (*${standupDaysLabel(standup)}*).` }; + return { ok: true, status: 'active', message: `✅ ${self ? 'Your' : `${target.displayName}'s`} days on *${standup.name}* are now *${days}*.${adhoc}` }; + } + + /** The deprecated all-standups form, kept for existing API and MCP callers. */ + private async setEveryWeek(target: Mention, value: string | null, actor: Actor, channel: ScheduleChannel, standups: Standup[]): Promise { const auth = await this.authority(target, actor, standups); if (!auth.ok) return auth; // A week has no request flow, so under approval it is the managers' call too. if (auth.governed.some((s) => s.timeOffPolicy !== 'self')) { return { ok: false, message: 'Only your managers can change your week here — ask them, or DM them the days you need.' }; } - await this.repo.setWorkingDaysForUser(target.userName, parsed.value); - const summary = `${target.displayName}'s week is now ${describeWorkingDays(parsed.value)}`; - await this.recordChange(target, summary, actor, auth.self, channel, standups); - await this.applyToRuns(target.userName, [this.today(standups[0]!)], true); + await this.repo.setWorkingDaysForUser(target.userName, value); + const summary = `${target.displayName}'s week is now ${describeWorkingDays(value)}`; + await this.recordChange(target, summary, actor, auth.self, channel, standups, standups); + for (const standup of standups) await this.applyToRuns(target.userName, [this.today(standup)], true, [standup]); if (!auth.self) { - await this.dm(target.userName, `📅 ${actor.displayName} set your week to *${describeWorkingDays(parsed.value)}*. Reply \`days reset\` to follow the standup again, or \`days mon-thu\` to choose your own.`); + await this.dm(target.userName, `📅 ${actor.displayName} set your week to *${describeWorkingDays(value)}* on every standup you’re on.`); } const who = auth.self ? 'Your' : `${target.displayName}'s`; - return { ok: true, status: 'active', message: `✅ ${who} week is now *${describeWorkingDays(parsed.value)}* across ${plural(standups.length, 'standup')}.${parsed.value === ADHOC ? ' Mark working days with `working `.' : ''}` }; + return { ok: true, status: 'active', message: `✅ ${who} week is now *${describeWorkingDays(value)}* across ${plural(standups.length, 'standup')}.${value === ADHOC ? ' Mark working days with `working `.' : ''}` }; } async setOverride(input: OverrideRequest): Promise { @@ -342,7 +384,7 @@ export class ScheduleService { const names = [...new Set(approvers.map((s) => s.name))].join(', '); return { ok: true, status, message: `📨 Sent to the managers of ${names} for approval — you'll hear back here. Withdraw with \`off cancel ${dates[0]}\`.` }; } - await this.recordChange(target, `${target.displayName} is ${what}`, actor, self, channel, standups); + await this.recordChange(target, `${target.displayName} is ${what}`, actor, self, channel, standups, standups); await this.applyToRuns(target.userName, dates); if (!self) { await this.dm( @@ -398,7 +440,7 @@ export class ScheduleService { await this.repo.setOverrideStatus(existing.id, 'withdrawn', this.now().toISO()!); if (wasActive) { await this.applyToRuns(target.userName, [date]); - await this.recordChange(target, `${target.displayName} cancelled ${existing.working ? 'working' : 'off'} ${describeDates([date])}`, actor, auth.self, 'chat', standups); + await this.recordChange(target, `${target.displayName} cancelled ${existing.working ? 'working' : 'off'} ${describeDates([date])}`, actor, auth.self, 'chat', standups, standups); } return { ok: true, status: 'active', message: `✅ ${describeDates([date])} is back to ${auth.self ? 'your' : `${target.displayName}'s`} usual week.` }; } @@ -436,11 +478,15 @@ export class ScheduleService { return { ok: true, status: 'active', message: `${approve ? '✅ Approved' : '❌ Declined'} — ${person.displayName} has been told.${approve ? await this.leaveLine(working, 'Their') : ''}` }; } - /** When a run opens: people whose week or overrides exclude the date start it as away. */ - async applyToNewRun(standup: Standup, run: Run): Promise { + /** + * When a run opens: people whose days or overrides exclude the date start it + * as away. `run now` counts the day as one of the standup's own. + */ + async applyToNewRun(standup: Standup, run: Run, asStandupDay = false): Promise { const overrides = await this.repo.listActiveOverridesOn(run.date); + const days = asStandupDay ? [...standupDays(standup), weekdayOf(run.date)] : standupDays(standup); for (const p of await this.repo.listParticipants(standup.id)) { - const status = dayStatus(p, run.date, overrides.get(p.userName)); + const status = dayStatus(p, run.date, overrides.get(p.userName), days); if (!status.expected && status.reason !== 'vacation') await this.repo.setRunAway(run.id, p.userName, status.reason); if (status.expected && p.onVacation) await this.repo.setRunAway(run.id, p.userName, null); } @@ -475,8 +521,8 @@ export class ScheduleService { * Re-evaluates the person's runs on these dates after a schedule change. * Only schedule-driven reasons move, and a closed run keeps anyone who submitted. */ - async applyToRuns(userName: string, dates: string[], openOnly = false): Promise { - const standups = await this.repo.listStandupsForUser(userName); + async applyToRuns(userName: string, dates: string[], openOnly = false, only?: Standup[]): Promise { + const standups = only ?? (await this.repo.listStandupsForUser(userName)); for (const date of dates) { const override = await this.repo.getOverride(userName, date); for (const standup of standups) { @@ -486,7 +532,7 @@ export class ScheduleService { const rp = (await this.repo.listRunParticipants(run.id)).find((p) => p.userName === userName); if (!participant || !rp) continue; if (run.status !== 'open' && (await this.repo.getSubmission(run.id, userName))) continue; - const status = dayStatus(participant, date, override); + const status = dayStatus(participant, date, override, standupDays(standup)); const movable = rp.awayReason === null || rp.awayReason === 'day_off' || rp.awayReason === 'off_day'; if (!status.expected && movable && rp.awayReason !== status.reason) await this.repo.setRunAway(run.id, userName, status.reason); if (status.expected && rp.awayReason !== null && movable) await this.repo.setRunAway(run.id, userName, null); @@ -503,11 +549,15 @@ export class ScheduleService { return this.now().setZone(standup.timezone).toISODate()!; } - private async recordChange(target: Mention, summary: string, actor: Actor, self: boolean, channel: ScheduleChannel, standups: Standup[]): Promise { + /** Logs a self-service change for the managers' digest of the standups it affects, and sends their webhooks. */ + private async recordChange(target: Mention, summary: string, actor: Actor, self: boolean, channel: ScheduleChannel, affected: Standup[], all: Standup[]): Promise { if (self) { - await this.repo.logScheduleChange({ userName: target.userName, displayName: target.displayName, summary, byDisplayName: actor.displayName, channel, at: this.now().toISO()! }); + const at = this.now().toISO()!; + const id = await this.repo.logScheduleChange({ userName: target.userName, displayName: target.displayName, summary, byDisplayName: actor.displayName, channel, at }); + // The other standups' managers have nothing to review. + for (const standup of all) if (!affected.some((s) => s.id === standup.id)) await this.repo.markChangesDigested(standup.id, [id], at); } - for (const standup of standups) await this.webhooks?.scheduleChange(standup, { person: target, summary, by: actor.displayName, channel }); + for (const standup of affected) await this.webhooks?.scheduleChange(standup, { person: target, summary, by: actor.displayName, channel }); } private async dm(userName: string, text: string): Promise { diff --git a/src/core/scheduler.ts b/src/core/scheduler.ts index 3120ab3..36ffead 100644 --- a/src/core/scheduler.ts +++ b/src/core/scheduler.ts @@ -4,7 +4,7 @@ import type { ChatAdapter } from './adapter.js'; import type { LeavePresence } from './leave-presence.js'; import type { OooChecker } from './ooo.js'; import type { RosterSync } from './roster-sync.js'; -import type { ScheduleService } from './schedule.js'; +import { dayStatus, type ScheduleService } from './schedule.js'; import { directoryKey, type UserDirectory } from './directory.js'; import type { Repo } from '../db/repo.js'; import type { StandupService } from './standup-service.js'; @@ -110,7 +110,7 @@ export class Scheduler { }; await syncRoster(60); - if (!standupDays(standup).includes(WEEKDAY_BY_LUXON[localNow.weekday]!)) return; + const standupDay = standupDays(standup).includes(WEEKDAY_BY_LUXON[localNow.weekday]!); const promptAt = timeOn(today, standup.promptTime, zone); const deadlineAt = timeOn(today, standup.deadlineTime, zone); @@ -122,7 +122,14 @@ export class Scheduler { if (now >= deadlineAt) return; if (now >= promptAt.minus({ hours: 1 })) await syncRoster(10); const people = await this.repo.listParticipants(standup.id); - const active = people.filter((p) => !p.onVacation); + let active = people.filter((p) => !p.onVacation); + // On a day outside the standup's own, the run is only for those who work it. + let only: Set | undefined; + if (!standupDay) { + const overrides = await this.repo.listActiveOverridesOn(today); + active = people.filter((p) => dayStatus(p, today, overrides.get(p.userName), standupDays(standup)).expected); + only = new Set(active.map((p) => p.userName)); + } if (active.length === 0) return; // The run opens once promptTime arrives in the earliest zone on the // roster, so participants east of the standup zone aren't held back. @@ -130,7 +137,7 @@ export class Scheduler { now >= promptAt || active.some((p) => p.timezone && now >= timeOn(today, standup.promptTime, p.timezone)); if (!due) return; - run = await this.repo.createRun(standup.id, today, `standup-${standup.id}-${today}`); + run = await this.repo.createRun(standup.id, today, `standup-${standup.id}-${today}`, only); this.log(`opened run ${run.id} for "${standup.name}" ${today}`); await this.providers.schedule?.applyToNewRun(standup, run); await this.applyCalendarOoo(standup, run); @@ -196,7 +203,7 @@ export class Scheduler { if (roster.filter((p) => !p.onVacation).length === 0) return 'no_participants'; run = await this.repo.createRun(standup.id, today, `standup-${standup.id}-${today}`); this.log(`opened run ${run.id} for "${standup.name}" ${today} (run now)`); - await this.providers.schedule?.applyToNewRun(standup, run); + await this.providers.schedule?.applyToNewRun(standup, run, true); await this.applyCalendarOoo(standup, run); try { await this.adapter.postThreadParent(standup, run); diff --git a/src/db/repo.ts b/src/db/repo.ts index d3fdcf4..4346028 100644 --- a/src/db/repo.ts +++ b/src/db/repo.ts @@ -1394,6 +1394,11 @@ export class Repo { return result.changes; } + /** One person's days on one standup (null = follow the standup). */ + async setWorkingDays(standupId: number, userName: string, workingDays: string | null): Promise { + await this.db.run('UPDATE participants SET working_days = ? WHERE standup_id = ? AND user_name = ?', [workingDays, standupId, userName]); + } + async getUserWorkingDays(userName: string): Promise { const row = await this.db.get( 'SELECT working_days FROM participants WHERE user_name = ? AND active = 1 AND working_days IS NOT NULL LIMIT 1', @@ -1494,8 +1499,8 @@ export class Repo { // --- schedule change log (digested to managers) --- - async logScheduleChange(input: { userName: string; displayName: string; summary: string; byDisplayName: string; channel: ScheduleChannel; at: string }): Promise { - await this.db.run( + async logScheduleChange(input: { userName: string; displayName: string; summary: string; byDisplayName: string; channel: ScheduleChannel; at: string }): Promise { + return this.db.insert( 'INSERT INTO schedule_changes (user_name, display_name, summary, by_display_name, channel, at) VALUES (?, ?, ?, ?, ?, ?)', [input.userName, input.displayName, input.summary, input.byDisplayName, input.channel, input.at], ); @@ -1607,14 +1612,15 @@ export class Repo { // --- runs --- - /** Creates the run and snapshots the active roster atomically. */ - async createRun(standupId: number, date: string, threadKey: string): Promise { + /** Creates the run and snapshots the active roster atomically; `only` limits it to those people. */ + async createRun(standupId: number, date: string, threadKey: string, only?: ReadonlySet): Promise { const runId = await this.db.transaction(async () => { const id = await this.db.insert( 'INSERT INTO runs (standup_id, date, thread_key) VALUES (?, ?, ?)', [standupId, date, threadKey], ); for (const p of await this.listParticipants(standupId)) { + if (only && !only.has(p.userName)) continue; await this.db.run( `INSERT INTO run_participants (run_id, user_name, display_name, timezone, mandatory, on_vacation, away_reason) VALUES (?, ?, ?, ?, ?, ?, ?)`, diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 979e983..68caa32 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -4,7 +4,7 @@ import type { DateTime } from 'luxon'; import { z } from 'zod'; import { nudgeToday, skipToday, updateMe, type Refusal } from '../api/actions.js'; import type { Principal } from '../api/principal.js'; -import { actorFor, displayNameOf, managesPerson, overrideView, scheduleView } from '../api/schedule.js'; +import { actorFor, displayNameOf, managedBy, managesPerson, overrideView, scheduleView } from '../api/schedule.js'; import { allowed, findVisibleStandup, meView, summarise, visibleStandups } from '../api/shared.js'; import { canSomewhere, PERMISSIONS, standupsWith, type Permission } from '../core/permissions.js'; import * as S from '../api/spec/schemas.js'; @@ -391,10 +391,10 @@ export function buildMcpServer(deps: McpToolDeps, token: McpToken, p: Principal) const targetFor = async (userName?: string) => { if (!userName || userName === p.user?.userName) { const who = me(); - return { ...who, actor: actorFor(p) }; + return { ...who, actor: actorFor(p), visible: undefined }; } if (!(await managesPerson(repo, p, userName))) throw new ToolRefused('Only admins and this person’s managers can do that.'); - return { userName, displayName: await displayNameOf(repo, userName), actor: actorFor(p) }; + return { userName, displayName: await displayNameOf(repo, userName), actor: actorFor(p), visible: managedBy(p) }; }; const expand = (dates: string[] | undefined, from: string | undefined, to: string | undefined): string[] => { const out = new Set(dates ?? []); @@ -412,14 +412,14 @@ export function buildMcpServer(deps: McpToolDeps, token: McpToken, p: Principal) 'read', { title: 'Get a schedule', - description: 'A person’s working week, the time-off policy of their standups and their upcoming days off. Omit userName for the owner.', + description: 'A person’s days on each standup, the time-off policy of their standups and their upcoming days off. Omit userName for the owner.', inputSchema: z.object({ userName: z.string().optional() }), outputSchema: S.Schedule, }, async ({ userName }) => { const svc = schedule(); const t = await targetFor(userName); - return scheduleView(repo, svc, t.userName, t.displayName); + return scheduleView(repo, svc, t.userName, t.displayName, t.visible); }, ); @@ -458,18 +458,18 @@ export function buildMcpServer(deps: McpToolDeps, token: McpToken, p: Principal) 'set_working_days', 'schedule', { - title: 'Set a working week', - description: 'Set a personal week: "mon-thu", "mon,wed,fri", "adhoc" (only dated working days count) or "reset" to follow the standup. Omit userName for the owner.', - inputSchema: z.object({ userName: z.string().optional(), workingDays: z.string().trim().min(1) }), + title: 'Set working days', + description: 'Set a person’s days on one standup: "mon-thu", "mon,wed,fri", "adhoc" (only dated working days count) or "reset" to follow the standup’s days. Omit userName for the owner. Without standupId every standup changes; that form is deprecated.', + inputSchema: z.object({ userName: z.string().optional(), standupId: ID.optional(), workingDays: z.string().trim().min(1) }), outputSchema: WeekSet, write: { destructive: true, idempotent: true }, }, - async ({ userName, workingDays }) => { + async ({ userName, standupId, workingDays }) => { const svc = schedule(); const t = await targetFor(userName); - const r = await svc.setWorkingDays(t, workingDays, t.actor, 'api'); + const r = await svc.setWorkingDays(t, workingDays, t.actor, 'api', standupId ?? 'all'); if (!r.ok) throw new ToolRefused(r.message); - return { message: r.message, schedule: await scheduleView(repo, svc, t.userName, t.displayName) }; + return { message: r.message, schedule: await scheduleView(repo, svc, t.userName, t.displayName, t.visible) }; }, ); diff --git a/tests/api-schedule.test.ts b/tests/api-schedule.test.ts index f0f4d0e..da24511 100644 --- a/tests/api-schedule.test.ts +++ b/tests/api-schedule.test.ts @@ -203,3 +203,49 @@ describe('schedule API — managers and admins', () => { expect(await res.json()).toMatchObject({ error: { field: 'timeOffPolicy', message: 'Time-off policy must be self, approval or managers.' } }); }); }); + +describe('schedule API — days per standup', () => { + const MON_FRI = ['mon', 'tue', 'wed', 'thu', 'fri']; + async function withSupport() { + const s = await startServer(); + const support = await s.repo.createStandup({ tenantId: TENANT, spaceName: 'spaces/support', name: 'Support', timezone: 'Asia/Kolkata' }); + await s.repo.upsertParticipant({ standupId: support.id, userName: 'users/alice', displayName: 'Alice' }); + return { ...s, support }; + } + + it('sets days on one standup with standupId, and every standup without it (criteria 21, 22)', async () => { + const { as, cookieFor, json, standup, support } = await withSupport(); + const alice = cookieFor('alice', 'Alice'); + const patch = (body: unknown) => as(alice, '/me/schedule', { method: 'PATCH', body: json(body) }); + let view: any = await (await patch({ workingDays: 'mon-wed,sat,sun', standupId: standup.id })).json(); + expect(view.standups).toEqual([ + { standupId: standup.id, name: 'Daily Standup', days: MON_FRI, workingDays: 'mon,tue,wed,sat,sun', workingDaysLabel: 'Mon–Wed, Sat, Sun', timeOffPolicy: 'self' }, + { standupId: support.id, name: 'Support', days: MON_FRI, workingDays: null, workingDaysLabel: 'Follows the standup', timeOffPolicy: 'self' }, + ]); + expect((await patch({ workingDays: 'mon', standupId: 0 })).status).toBe(400); + expect(await (await patch({ workingDays: 'mon', standupId: 999 })).json()).toMatchObject({ error: { code: 'invalid', message: 'You’re not on that standup.' } }); + view = await (await patch({ workingDays: 'mon-thu' })).json(); + expect(view.standups.map((s: any) => s.workingDays)).toEqual(['mon,tue,wed,thu', 'mon,tue,wed,thu']); + }); + + it('shows and changes only the standups a manager manages (criteria 16, 21)', async () => { + const { as, op, cookieFor, json, repo, standup, support } = await withSupport(); + await repo.addAdmin(standup.id, 'users/mgr', 'Manager'); + const mgr = cookieFor('mgr', 'Manager'); + const path = '/people/users%2Falice/schedule'; + expect(((await (await as(mgr, path)).json()) as any).standups.map((s: any) => s.name)).toEqual(['Daily Standup']); + expect(((await (await op(path)).json()) as any).standups.map((s: any) => s.name)).toEqual(['Daily Standup', 'Support']); + let res = await as(mgr, path, { method: 'PATCH', body: json({ workingDays: 'sat', standupId: standup.id }) }); + expect(((await res.json()) as any).standups[0].workingDays).toBe('sat'); + res = await as(mgr, path, { method: 'PATCH', body: json({ workingDays: 'sat', standupId: support.id }) }); + expect(await res.json()).toMatchObject({ error: { code: 'invalid', message: "Only the managers of *Support* can change Alice's days there." } }); + }); + + it('says when a person’s days vary by standup on the Team list (criterion 24)', async () => { + const { op, repo, standup } = await withSupport(); + await repo.setWorkingDays(standup.id, 'users/alice', 'sat'); + const people: any = await (await op('/people')).json(); + expect(people.people.find((p: any) => p.userName === 'users/alice').workingDaysLabel).toBe('Varies by standup'); + expect(people.people.find((p: any) => p.userName === 'users/bob').workingDaysLabel).toBe('Follows the standup'); + }); +}); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index e8de797..93f98b2 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -394,6 +394,12 @@ describe('schedule tools', () => { // own week and days off expect((await call(alice, 'set_working_days', { workingDays: 'mon-thu' })).json).toMatchObject({ schedule: { workingDays: 'mon,tue,wed,thu', workingDaysLabel: 'Mon–Thu' } }); expect((await call(alice, 'set_working_days', { workingDays: 'nope' })).error).toContain("isn't a day"); + // one standup by id (criterion 23) + const days: any = (await call(alice, 'set_working_days', { workingDays: 'sat', standupId: s.id })).json; + expect(days.message).toBe('✅ Your days on *Daily Standup* are now *Sat*.'); + expect(days.schedule.standups.find((x: any) => x.standupId === s.id)).toMatchObject({ workingDays: 'sat', workingDaysLabel: 'Sat' }); + expect((await call(alice, 'get_schedule')).json.standups.map((x: any) => x.workingDays)).toContain('sat'); + await call(alice, 'set_working_days', { workingDays: 'mon-thu' }); expect((await call(alice, 'set_days_off', { from: '2026-06-22', to: '2026-06-23', reason: 'comp off' })).json).toMatchObject({ status: 'active' }); expect((await call(alice, 'set_days_off', { dates: ['2026-06-13'], working: true })).json.status).toBe('active'); expect((await call(alice, 'get_schedule')).json.overrides.map((o: any) => [o.date, o.label])).toEqual([['2026-06-13', 'Working'], ['2026-06-22', 'Day off'], ['2026-06-23', 'Day off']]); diff --git a/tests/schedule-chat.test.ts b/tests/schedule-chat.test.ts index 19b1f87..72b99f3 100644 --- a/tests/schedule-chat.test.ts +++ b/tests/schedule-chat.test.ts @@ -37,7 +37,7 @@ describe('schedule DM commands', () => { reply = await router.handle(dm(alice, 'off cancel')); expect(reply.text).toContain('Say when'); reply = await router.handle(dm(alice, 'days mon-thu')); - expect(reply.text).toContain('Your week is now *Mon–Thu*'); + expect(reply.text).toBe('✅ Your days on *Daily Standup* are now *Mon–Thu*.'); expect(await repo.getUserWorkingDays('users/alice')).toBe('mon,tue,wed,thu'); reply = await router.handle(dm(alice, 'off')); expect(reply.text).toContain('Say when'); @@ -79,7 +79,7 @@ describe('schedule space commands', () => { reply = await router.handle(spaceMsg('working @Alice sat', [{ name: 'users/alice', displayName: 'Alice' }])); expect(reply.text).toContain('Alice is marked *working* on Sat 13 Jun'); reply = await router.handle(spaceMsg('days @Alice tue-fri', [{ name: 'users/alice', displayName: 'Alice' }])); - expect(reply.text).toContain("Alice's week is now *Tue–Fri*"); + expect(reply.text).toBe("✅ Alice's days on *Daily Standup* are now *Tue–Fri*."); reply = await router.handle(spaceMsg('off @Alice cancel tomorrow', [{ name: 'users/alice', displayName: 'Alice' }])); expect(reply.text).toContain("back to Alice's usual week"); reply = await router.handle(spaceMsg('off tomorrow')); diff --git a/tests/schedule.test.ts b/tests/schedule.test.ts index 0b6c8e4..d4cd7fb 100644 --- a/tests/schedule.test.ts +++ b/tests/schedule.test.ts @@ -57,16 +57,20 @@ describe('describeWorkingDays', () => { describe('dayStatus', () => { const override = (working: boolean, status = 'active') => ({ working, status }) as any; + const MON_FRI = ['mon', 'tue', 'wed', 'thu', 'fri']; it('lets a dated override win, then vacation, then the personal week', () => { expect(weekdayOf('2026-06-10')).toBe('wed'); - expect(dayStatus({ onVacation: true, workingDays: null }, '2026-06-10', override(true))).toEqual({ expected: true }); - expect(dayStatus({ onVacation: false, workingDays: null }, '2026-06-10', override(false))).toEqual({ expected: false, reason: 'day_off' }); - expect(dayStatus({ onVacation: false, workingDays: null }, '2026-06-10', override(false, 'pending'))).toEqual({ expected: true }); - expect(dayStatus({ onVacation: true, workingDays: null }, '2026-06-10', null)).toEqual({ expected: false, reason: 'vacation' }); - expect(dayStatus({ onVacation: false, workingDays: null }, '2026-06-10', undefined)).toEqual({ expected: true }); - expect(dayStatus({ onVacation: false, workingDays: ADHOC }, '2026-06-10', null)).toEqual({ expected: false, reason: 'off_day' }); - expect(dayStatus({ onVacation: false, workingDays: 'mon,tue,wed' }, '2026-06-10', null)).toEqual({ expected: true }); - expect(dayStatus({ onVacation: false, workingDays: 'mon,tue' }, '2026-06-10', null)).toEqual({ expected: false, reason: 'off_day' }); + expect(dayStatus({ onVacation: true, workingDays: null }, '2026-06-10', override(true), MON_FRI)).toEqual({ expected: true }); + expect(dayStatus({ onVacation: false, workingDays: null }, '2026-06-10', override(false), MON_FRI)).toEqual({ expected: false, reason: 'day_off' }); + expect(dayStatus({ onVacation: false, workingDays: null }, '2026-06-10', override(false, 'pending'), MON_FRI)).toEqual({ expected: true }); + expect(dayStatus({ onVacation: true, workingDays: null }, '2026-06-10', null, MON_FRI)).toEqual({ expected: false, reason: 'vacation' }); + expect(dayStatus({ onVacation: false, workingDays: null }, '2026-06-10', undefined, MON_FRI)).toEqual({ expected: true }); + expect(dayStatus({ onVacation: false, workingDays: ADHOC }, '2026-06-10', null, MON_FRI)).toEqual({ expected: false, reason: 'off_day' }); + expect(dayStatus({ onVacation: false, workingDays: 'mon,tue,wed' }, '2026-06-10', null, MON_FRI)).toEqual({ expected: true }); + expect(dayStatus({ onVacation: false, workingDays: 'mon,tue' }, '2026-06-10', null, MON_FRI)).toEqual({ expected: false, reason: 'off_day' }); + // Without days of their own, a person follows the standup's. + expect(dayStatus({ onVacation: false, workingDays: null }, '2026-06-13', null, MON_FRI)).toEqual({ expected: false, reason: 'off_day' }); + expect(dayStatus({ onVacation: false, workingDays: 'sat' }, '2026-06-13', null, MON_FRI)).toEqual({ expected: true }); expect(AWAY_LABEL.off_day).toBe('Not a working day'); }); }); @@ -132,9 +136,9 @@ describe('ScheduleService', () => { await scheduler.tick(); // opens today's run (Wed) with everyone expected const run = (await repo.getRun(standup.id, '2026-06-10'))!; const r = await schedule.setWorkingDays(alice, 'mon-tue', selfActor(alice), 'chat'); - expect(r).toEqual({ ok: true, status: 'active', message: expect.stringContaining('Your week is now *Mon, Tue* across 1 standup') }); + expect(r).toEqual({ ok: true, status: 'active', message: '✅ Your days on *Daily Standup* are now *Mon, Tue*.' }); expect(await repo.getUserWorkingDays('users/alice')).toBe('mon,tue'); - expect((await repo.listUndigestedChanges(standup.id, ['users/alice'], '')).map((c) => c.summary)).toEqual(["Alice's week is now Mon, Tue"]); + expect((await repo.listUndigestedChanges(standup.id, ['users/alice'], '')).map((c) => c.summary)).toEqual(["Alice's days on Daily Standup are now Mon, Tue"]); const rp = (await repo.listRunParticipants(run.id)).find((p) => p.userName === 'users/alice')!; expect(rp).toMatchObject({ onVacation: true, awayReason: 'off_day' }); expect(adapter.dms).toHaveLength(3); // prompts only — no DM to yourself @@ -159,8 +163,8 @@ describe('ScheduleService', () => { it('a manager sets someone else’s week and the person is told', async () => { const { schedule, adapter } = await stack(); const r = await schedule.setWorkingDays(bob, 'tue-sat', managerActor, 'console'); - expect(r.message).toContain("Bob's week is now *Tue–Sat*"); - expect(adapter.dms.at(-1)).toMatchObject({ kind: 'text', userName: 'users/bob', text: expect.stringContaining('Admin set your week to *Tue–Sat*') }); + expect(r.message).toBe("✅ Bob's days on *Daily Standup* are now *Tue–Sat*."); + expect(adapter.dms.at(-1)).toEqual({ kind: 'text', userName: 'users/bob', text: '📅 Admin set your days on *Daily Standup* to *Tue–Sat*. Reply `days reset` to follow the standup again.' }); }); it('self-service days off apply immediately, reach today’s open run and can be cancelled', async () => { @@ -327,7 +331,7 @@ describe('ScheduleService across standups', () => { const r = await schedule.setOverride({ target: alice, dates: ['2026-06-12'], working: false, reason: '', actor: selfActor(alice), channel: 'chat' }); expect(r.ok && r.status).toBe('pending'); expect(await schedule.setOverride({ target: alice, dates: ['2026-06-09'], working: false, reason: '', actor: selfActor(alice), channel: 'chat' })).toEqual({ ok: false, message: 'Past days can only be changed by a manager.' }); - expect(await schedule.setWorkingDays(alice, 'mon-thu', selfActor(alice), 'chat')).toMatchObject({ ok: false, message: expect.stringContaining('Only your managers can change your week') }); + expect(await schedule.setWorkingDays(alice, 'mon-thu', selfActor(alice), 'chat', (await repo.listStandupsForUser(alice.userName)).find((s) => s.name === 'Daily Standup')!.id)).toEqual({ ok: false, message: 'Only your managers can change your days on *Daily Standup* — ask them, or DM them the days you need.' }); // she cannot approve her own request, even though she manages a standup she is on const ids = (await repo.listPendingOverrides(['users/alice'])).map((o) => o.id); expect(await schedule.decide(ids, true, selfActor(alice), null)).toEqual({ ok: false, message: 'Your own requests go to your managers.' }); diff --git a/tests/standup-days.test.ts b/tests/standup-days.test.ts new file mode 100644 index 0000000..e787cbd --- /dev/null +++ b/tests/standup-days.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'bun:test'; +import { EventRouter } from '../src/adapters/gchat/events.js'; +import type { RunSummary } from '../src/core/types.js'; +import { ANSWERS, makeStack, seedStandup, TENANT, TZ } from './helpers.js'; + +const alice = { userName: 'users/alice', displayName: 'Alice' }; +const bob = { userName: 'users/bob', displayName: 'Bob' }; +const admin = { userName: 'users/admin', displayName: 'Admin' }; +const selfActor = (m: { userName: string; displayName: string }) => ({ ...m, admin: false }); +const manager = { ...admin, admin: false }; + +/** Daily Standup (Mon–Fri, managed by Admin) has Alice, Bob and Carol; Support (Mon–Fri) has Alice and Dan. */ +async function stack(opts: Parameters[0] = {}) { + const s = await makeStack(opts); + const team = await seedStandup(s.repo); + await s.repo.addAdmin(team.id, admin.userName, admin.displayName); + const support = await s.repo.createStandup({ tenantId: TENANT, spaceName: 'spaces/support', name: 'Support', timezone: TZ }); + await s.repo.upsertParticipant({ standupId: support.id, userName: alice.userName, displayName: alice.displayName }); + await s.repo.upsertParticipant({ standupId: support.id, userName: 'users/dan', displayName: 'Dan' }); + s.clock.set('2026-06-10T08:00'); // Wednesday + const router = new EventRouter(s.commands, s.service, s.blockers, s.repo, TENANT, s.polls, async () => null, s.schedule); + const week = async (standupId: number, userName: string) => (await s.repo.listParticipants(standupId)).find((p) => p.userName === userName)!.workingDays; + return { ...s, router, week, team: (await s.repo.getStandupById(team.id))!, support: (await s.repo.getStandupById(support.id))! }; +} + +const dm = (user: { userName: string; displayName: string }, text: string) => ({ + type: 'MESSAGE', + space: { name: `spaces/dm-${user.userName.slice(6)}`, type: 'DM' }, + message: { argumentText: text }, + user: { name: user.userName, displayName: user.displayName, type: 'HUMAN' }, +}); +const inTeam = (text: string, mentions: { userName: string; displayName: string }[]) => ({ + type: 'MESSAGE', + space: { name: 'spaces/team', type: 'ROOM' }, + message: { argumentText: text, annotations: mentions.map((m) => ({ type: 'USER_MENTION', userMention: { user: { name: m.userName, displayName: m.displayName, type: 'HUMAN' } } })) }, + user: { name: admin.userName, displayName: admin.displayName, type: 'HUMAN' }, +}); + +describe('standup days are a default', () => { + it('people without their own days follow the standup, including a change to it (criterion 1)', async () => { + const { repo, scheduler, clock, adapter, team } = await stack(); + clock.set('2026-06-13T09:30'); // Saturday + await scheduler.tick(); + expect(await repo.getRun(team.id, '2026-06-13')).toBeNull(); + await repo.updateStandup(team.id, { days: 'mon,tue,wed,thu,fri,sat' }); + await scheduler.tick(); + const run = (await repo.getRun(team.id, '2026-06-13'))!; + expect((await repo.listRunParticipants(run.id)).map((p) => [p.userName, p.onVacation])).toEqual([['users/alice', false], ['users/bob', false], ['users/carol', false]]); + expect(adapter.dms.filter((d) => d.kind === 'prompt').map((d) => d.userName).sort()).toEqual(['users/alice', 'users/bob', 'users/carol']); + }); + + it('days set on one standup leave the person’s other standups alone, and can be any day (criteria 2, 3)', async () => { + const { schedule, week, team, support } = await stack(); + const r = await schedule.setWorkingDays(alice, 'mon-wed,sat,sun', selfActor(alice), 'chat', team.id); + expect(r).toEqual({ ok: true, status: 'active', message: '✅ Your days on *Daily Standup* are now *Mon–Wed, Sat, Sun*.' }); + expect(await week(team.id, alice.userName)).toBe('mon,tue,wed,sat,sun'); + expect(await week(support.id, alice.userName)).toBeNull(); + expect((await schedule.setWorkingDays(alice, 'reset', selfActor(alice), 'chat', team.id)).message).toBe('✅ You now follow *Daily Standup*’s days (*Mon–Fri*).'); + expect(await week(team.id, alice.userName)).toBeNull(); + }); + + it('on a standup day, people whose own days skip it are off, as before (criterion 4)', async () => { + const { repo, schedule, scheduler, clock, team } = await stack(); + await schedule.setWorkingDays(alice, 'sat,sun', selfActor(alice), 'chat', team.id); + clock.set('2026-06-10T09:30'); + await scheduler.tick(); + const run = (await repo.getRun(team.id, '2026-06-10'))!; + expect((await repo.listRunParticipants(run.id)).map((p) => [p.userName, p.awayReason])).toEqual([['users/alice', 'off_day'], ['users/bob', null], ['users/carol', null]]); + }); + + it('opens a run on an extra day for only the people who work it (criteria 5, 6, 7, 8)', async () => { + const { repo, schedule, scheduler, clock, adapter, service, team, support } = await stack(); + await schedule.setWorkingDays(alice, 'mon-wed,sat,sun', selfActor(alice), 'chat', team.id); + clock.set('2026-06-13T09:30'); // Saturday + await scheduler.tick(); + const run = (await repo.getRun(team.id, '2026-06-13'))!; + expect((await repo.listRunParticipants(run.id)).map((p) => p.userName)).toEqual(['users/alice']); + expect(adapter.dms.filter((d) => d.kind === 'prompt').map((d) => d.userName)).toEqual(['users/alice']); + // Alice follows Support's own days, so Support has nobody on Saturday. + expect(await repo.getRun(support.id, '2026-06-13')).toBeNull(); + + clock.set('2026-06-13T10:31'); + await scheduler.tick(); + expect(adapter.dms.filter((d) => d.kind === 'reminder').map((d) => d.userName)).toEqual(['users/alice']); + + clock.set('2026-06-13T11:31'); + await scheduler.tick(); + const summary = adapter.posts.find((p) => p.kind === 'summary')!.payload as RunSummary; + expect(summary).toMatchObject({ mandatoryTotal: 1, mandatorySubmitted: 0, missingMandatory: ['Alice'], away: [] }); + clock.set('2026-06-13T12:00'); + expect(await service.submit(run.id, alice.userName, alice.displayName, ANSWERS)).toEqual({ ok: true, late: true, edited: false }); + }); + + it('an active working override opens a run on an extra day too (criterion 5)', async () => { + const { repo, schedule, scheduler, clock, team } = await stack(); + await schedule.setOverride({ target: bob, dates: ['2026-06-13'], working: true, reason: 'release', actor: manager, channel: 'console' }); + clock.set('2026-06-13T09:30'); + await scheduler.tick(); + const run = (await repo.getRun(team.id, '2026-06-13'))!; + expect((await repo.listRunParticipants(run.id)).map((p) => p.userName)).toEqual(['users/bob']); + }); + + it('run now on any day still treats it as a standup day', async () => { + const { repo, scheduler, clock, team } = await stack(); + clock.set('2026-06-13T09:00'); + expect(await scheduler.runNow(team)).toBe('started'); + const run = (await repo.getRun(team.id, '2026-06-13'))!; + expect((await repo.listRunParticipants(run.id)).filter((p) => !p.onVacation).map((p) => p.userName)).toEqual(['users/alice', 'users/bob', 'users/carol']); + }); + + it('posts the weekly digest after the standup’s last default day only (criterion 9)', async () => { + const { repo, schedule, scheduler, clock, adapter, team } = await stack(); + await repo.updateStandup(team.id, { digestEnabled: true }); + await schedule.setWorkingDays(alice, 'mon-sat', selfActor(alice), 'chat', team.id); + const digests = () => adapter.posts.filter((p) => p.kind === 'text' && p.text?.includes('weekly digest')); + for (const day of ['2026-06-12', '2026-06-13']) { + clock.set(`${day}T09:30`); + await scheduler.tick(); + clock.set(`${day}T11:31`); + await scheduler.tick(); + } + expect(digests()).toHaveLength(1); + expect(digests()[0]!.text).toContain('2026-06-08 → 2026-06-14'); + }); +}); + +describe('setting days on one standup', () => { + it('in a DM, asks which standup when the person is on several, and sets the one named (criterion 17)', async () => { + const { router, week, team, support } = await stack(); + let reply: any = await router.handle(dm(alice, 'days mon-wed,sat,sun')); + expect(reply.text).toBe(`Which standup? Reply with one of these:\n\`days #${team.id} mon-wed,sat,sun\` for Daily Standup\n\`days #${support.id} mon-wed,sat,sun\` for Support`); + expect(await week(team.id, alice.userName)).toBeNull(); + reply = await router.handle(dm(alice, `days #${team.id} mon-wed,sat,sun`)); + expect(reply.text).toBe('✅ Your days on *Daily Standup* are now *Mon–Wed, Sat, Sun*.'); + expect(await week(team.id, alice.userName)).toBe('mon,tue,wed,sat,sun'); + expect(await week(support.id, alice.userName)).toBeNull(); + reply = await router.handle(dm(alice, 'days #999 mon')); + expect(reply.text).toBe('You’re not on that standup.'); + // On one standup, no number is needed. + reply = await router.handle(dm(bob, 'days mon-thu')); + expect(reply.text).toBe('✅ Your days on *Daily Standup* are now *Mon–Thu*.'); + }); + + it('in a space, days @person sets that standup only and tells them (criteria 18, 20)', async () => { + const { router, week, adapter, team, support } = await stack(); + const reply: any = await router.handle(inTeam('days @Alice tue-sat', [alice])); + expect(reply.text).toBe("✅ Alice's days on *Daily Standup* are now *Tue–Sat*."); + expect(await week(team.id, alice.userName)).toBe('tue,wed,thu,fri,sat'); + expect(await week(support.id, alice.userName)).toBeNull(); + expect(adapter.dms.at(-1)).toEqual({ kind: 'text', userName: alice.userName, text: `📅 Admin set your days on *Daily Standup* to *Tue–Sat*. Reply \`days #${team.id} reset\` to follow the standup again.` }); + }); + + it('judges only that standup’s policy and managers (criteria 10, 19)', async () => { + const { schedule, repo, team, support } = await stack(); + await repo.updateStandup(support.id, { timeOffPolicy: 'managers' }); + expect((await schedule.setWorkingDays(alice, 'mon-thu', selfActor(alice), 'chat', team.id)).ok).toBe(true); + expect(await schedule.setWorkingDays(alice, 'mon-thu', selfActor(alice), 'chat', support.id)).toEqual({ ok: false, message: 'Only your managers can change your days on *Support* — ask them, or DM them the days you need.' }); + // Admin manages Daily Standup but not Support. + expect((await schedule.setWorkingDays(alice, 'sat', manager, 'console', team.id)).ok).toBe(true); + expect(await schedule.setWorkingDays(alice, 'sat', manager, 'console', support.id)).toEqual({ ok: false, message: "Only the managers of *Support* can change Alice's days there." }); + expect(await schedule.setWorkingDays({ userName: 'users/dan', displayName: 'Dan' }, 'sat', manager, 'console', team.id)).toEqual({ ok: false, message: 'Dan isn’t on that standup.' }); + }); + + it('names, digests and sends the change for the one standup (criterion 20)', async () => { + const bodies: any[] = []; + const webhookFetch = (async (_u: string, init: RequestInit) => (bodies.push(JSON.parse(String(init.body))), new Response('ok'))) as unknown as typeof fetch; + const { schedule, repo, team, support } = await stack({ webhookFetch }); + for (const s of [team, support]) await repo.updateStandup(s.id, { webhookUrl: `https://hooks.example.com/${s.id}` }); + await schedule.setWorkingDays(alice, 'mon-wed,sat,sun', selfActor(alice), 'chat', team.id); + expect(bodies.filter((b) => b.event === 'schedule_change').map((b) => [b.standup.id, b.summary])).toEqual([[team.id, "Alice's days on Daily Standup are now Mon–Wed, Sat, Sun"]]); + expect((await repo.listUndigestedChanges(team.id, [alice.userName], '')).map((c) => c.summary)).toEqual(["Alice's days on Daily Standup are now Mon–Wed, Sat, Sun"]); + expect(await repo.listUndigestedChanges(support.id, [alice.userName], '')).toEqual([]); + }); + + it('keeps the all-standups form for existing callers', async () => { + const { schedule, week, team, support } = await stack(); + const r = await schedule.setWorkingDays(alice, 'mon-thu', selfActor(alice), 'api', 'all'); + expect(r.message).toBe('✅ Your week is now *Mon–Thu* across 2 standups.'); + expect([await week(team.id, alice.userName), await week(support.id, alice.userName)]).toEqual(['mon,tue,wed,thu', 'mon,tue,wed,thu']); + }); +}); diff --git a/web/src/components/people-matrix.tsx b/web/src/components/people-matrix.tsx index cdad220..a7b2302 100644 --- a/web/src/components/people-matrix.tsx +++ b/web/src/components/people-matrix.tsx @@ -6,18 +6,23 @@ export interface MatrixRow { onStandup: boolean; mandatory: boolean; workingDays: string | null; - /** Other standups the person is on; their week changes there too. */ - otherStandups: number; badges?: string[]; } export type MatrixChange = { onStandup: boolean } | { mandatory: boolean } | { week: string }; +/** The outcome of the last change to a row: saved, or the error. */ +export interface RowStatus { + userName: string; + error: string | null; +} + /** - * Who is on a standup, whether they count when they miss it, and which of - * its days they work — one row per person in the space. + * Who is on a standup, whether they count when they miss it, and which days + * they work on it — one row per person in the space. The standup's own days + * are the default; anyone can be given any other days. */ -export function PeopleMatrix({ rows, standupDays, busy = false, weeks = true, onChange }: { rows: MatrixRow[]; standupDays: readonly Weekday[]; busy?: boolean; weeks?: boolean; onChange: (row: MatrixRow, change: MatrixChange) => void }) { +export function PeopleMatrix({ rows, standupDays, busy = false, weeks = true, status = null, onChange }: { rows: MatrixRow[]; standupDays: readonly Weekday[]; busy?: boolean; weeks?: boolean; status?: RowStatus | null; onChange: (row: MatrixRow, change: MatrixChange) => void }) { if (rows.length === 0) return

Nobody in this space yet.

; return (
@@ -28,7 +33,10 @@ export function PeopleMatrix({ rows, standupDays, busy = false, weeks = true, on On standup Required {WEEK.map((d) => ( - {DAY_LABEL[d]} + + {DAY_LABEL[d]} + {standupDays.includes(d) ? default : null} + ))} @@ -37,8 +45,9 @@ export function PeopleMatrix({ rows, standupDays, busy = false, weeks = true, on const ticked = tickedDays(row.workingDays, standupDays); const toggleDay = (d: Weekday) => { const picked = ticked.includes(d) ? ticked.filter((x) => x !== d) : [...ticked, d]; - onChange(row, { week: weekAfter(row.workingDays, standupDays, picked) }); + onChange(row, { week: weekAfter(standupDays, picked) }); }; + const mine = status?.userName === row.userName ? status : null; return ( @@ -46,8 +55,16 @@ export function PeopleMatrix({ rows, standupDays, busy = false, weeks = true, on {row.displayName} {row.badges?.map((b) => {b})}
- {row.onStandup && row.otherStandups > 0 ? ( -
Days also apply to their {row.otherStandups === 1 ? 'other standup' : `${row.otherStandups} other standups`}
+ {row.onStandup ? ( +
+ {row.workingDays === null ? 'Default' : ( + <> + Custom ·{' '} + + + )} + {mine ? mine.error ? · {mine.error} : · ✓ Saved : null} +
) : null} @@ -56,28 +73,25 @@ export function PeopleMatrix({ rows, standupDays, busy = false, weeks = true, on onChange(row, { mandatory: e.target.checked })} /> - {WEEK.map((d) => { - const runs = standupDays.includes(d); - return ( - - toggleDay(d)} - /> - - ); - })} + {WEEK.map((d) => ( + + toggleDay(d)} + /> + + ))} ); })}

- Required people are listed as missing when they don’t answer; the others still get the prompt. Unticked days mean no prompt, and the person is never counted missing that day. Greyed days are not standup days. A person’s days are their own week, so they apply to every standup they are on. + Required people are listed as missing when they don’t answer; the others still get the prompt. A person’s days apply to this standup only. “Default” follows the standup’s days under Schedule. Tick any other day and the standup also runs that day, for that person only. Each tick saves straight away. Unticked days mean no prompt, and the person is never counted missing that day.

); diff --git a/web/src/components/schedule-drawer.tsx b/web/src/components/schedule-drawer.tsx index e1665fc..243ebc8 100644 --- a/web/src/components/schedule-drawer.tsx +++ b/web/src/components/schedule-drawer.tsx @@ -1,29 +1,67 @@ -import { useEffect, useState } from 'react'; -import { DAY_LABEL, DAYS, dateSpan, isoToday, POLICY_LABEL, STATUS_TONE, useAddOverride, useCancelOverride, useSaveWeek, useSchedule, weekToDays, type ScheduleTarget } from '../lib/schedule'; +import { useState } from 'react'; +import { daysLabel } from '../lib/format'; +import { DAY_LABEL, DAYS, dateSpan, isoToday, POLICY_LABEL, STATUS_TONE, useAddOverride, useCancelOverride, useSaveWeek, useSchedule, weekToDays, type ScheduleTarget, type ScheduleView } from '../lib/schedule'; type WeekMode = 'follow' | 'custom' | 'adhoc'; +type StandupDays = ScheduleView['standups'][number]; + +const modeOf = (workingDays: string | null): WeekMode => (workingDays === null ? 'follow' : workingDays === 'adhoc' ? 'adhoc' : 'custom'); + +/** One standup's days for the person: follow it, custom days, or ad hoc. */ +function StandupDaysBlock({ standup, busy, onSave }: { standup: StandupDays; busy: boolean; onSave: (workingDays: string) => Promise }) { + const [mode, setMode] = useState(modeOf(standup.workingDays)); + const [days, setDays] = useState(weekToDays(standup.workingDays)); + const value = mode === 'follow' ? 'reset' : mode === 'adhoc' ? 'adhoc' : days.join(','); + const dirty = value !== (standup.workingDays === null ? 'reset' : standup.workingDays); + const runs = daysLabel(standup.days); + const revert = () => { + setMode(modeOf(standup.workingDays)); + setDays(weekToDays(standup.workingDays)); + }; + return ( +
+
+ {standup.name} + {POLICY_LABEL[standup.timeOffPolicy].split(' — ')[0]} +
+ + {mode === 'custom' ? ( +
+ {DAYS.map((d) => ( + + ))} +
+ ) : null} +
+ {mode === 'follow' ? `Expected on every day ${standup.name} runs: ${runs}.` : mode === 'adhoc' ? 'No fixed days — prompted only on dates marked working below.' : `${standup.name} runs ${runs}. Any other day ticked opens a run for this person.`} +
+ {dirty ? ( +
+ + +
+ ) : null} +
+ ); +} /** - * The Edit schedule drawer: a person's week (follow / custom / ad hoc) and - * their dated overrides, for themselves or, from the Team page, for a manager. + * The Edit schedule drawer: a person's days on each standup and their dated + * overrides, for themselves or, from the Team page, for a manager. */ export function ScheduleDrawer({ target, title, onClose }: { target: ScheduleTarget; title: string; onClose: () => void }) { const schedule = useSchedule(target); const saveWeek = useSaveWeek(target); const add = useAddOverride(target); const cancel = useCancelOverride(target); - const [mode, setMode] = useState(null); - const [days, setDays] = useState([]); const [form, setForm] = useState({ from: '', to: '', working: false, reason: '' }); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const s = schedule.data; - useEffect(() => { - if (s && mode === null) { - setMode(s.workingDays === null ? 'follow' : s.workingDays === 'adhoc' ? 'adhoc' : 'custom'); - setDays(weekToDays(s.workingDays)); - } - }, [s, mode]); const run = async (fn: () => Promise<{ message?: string } | unknown>) => { try { setError(null); @@ -34,11 +72,8 @@ export function ScheduleDrawer({ target, title, onClose }: { target: ScheduleTar setError(err instanceof Error ? err.message : 'That did not work.'); } }; - const weekValue = mode === 'follow' ? 'reset' : mode === 'adhoc' ? 'adhoc' : days.join(','); - const dirty = !!s && mode !== null && weekValue !== (s.workingDays === null ? 'reset' : s.workingDays); const busy = saveWeek.isPending || add.isPending || cancel.isPending; const upcoming = (s?.overrides ?? []).filter((o) => o.status !== 'withdrawn'); - const policy = s?.policies.map((p) => p.timeOffPolicy).reduce<'self' | 'approval' | 'managers'>((acc, p) => (p === 'managers' || acc === 'managers' ? 'managers' : p === 'approval' || acc === 'approval' ? 'approval' : 'self'), 'self') ?? 'self'; return (
e.stopPropagation()}> @@ -53,43 +88,14 @@ export function ScheduleDrawer({ target, title, onClose }: { target: ScheduleTar {schedule.isError ?
Could not load the schedule: {schedule.error.message}
: null} {error ?
{error}
: null} {notice ?
{notice}
: null} - {s && mode !== null ? ( + {s ? ( <> -
-
Working days
-
- - - {mode === 'custom' ? ( -
- {DAYS.map((d) => ( - - ))} -
- ) : null} - -
- {dirty ? ( -
- - -
- ) : null} +
+
Working days
+ {s.standups.length === 0 ?
Not on any standup you manage.
: null} + {s.standups.map((st) => ( + run(async () => { await saveWeek.mutateAsync({ standupId: st.standupId, workingDays }); return { message: `Days on ${st.name} saved.` }; })} /> + ))}
@@ -132,11 +138,7 @@ export function ScheduleDrawer({ target, title, onClose }: { target: ScheduleTar setForm({ ...form, reason: e.target.value })} /> -
{target === 'me' ? 'Past days can only be changed by a manager.' : 'Past dates change that run’s participation and are recorded with your name.'}
-
-
-
Time-off policy: {POLICY_LABEL[policy].split(' — ')[0]}
-
{POLICY_LABEL[policy]}. Set per standup under Settings › Schedule.
+
{target === 'me' ? 'Past days can only be changed by a manager.' : 'Past dates change that run’s participation and are recorded with your name.'} Days off and working days apply to every standup {target === 'me' ? 'you are' : 'they are'} on.
) : null} diff --git a/web/src/lib/roster.test.tsx b/web/src/lib/roster.test.tsx index 4fb6b57..760adde 100644 --- a/web/src/lib/roster.test.tsx +++ b/web/src/lib/roster.test.tsx @@ -1,28 +1,25 @@ -import { describe, expect, it } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { PeopleMatrix } from '../components/people-matrix'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { PeopleMatrix, type MatrixRow } from '../components/people-matrix'; import { tickedDays, weekAfter, type Weekday } from './roster'; const WEEKDAYS: Weekday[] = ['mon', 'tue', 'wed', 'thu', 'fri']; describe('people grid weeks', () => { - it('ticks the standup’s days a person works', () => { + it('ticks a person’s own days, or the standup’s when they have none (criterion 3)', () => { expect(tickedDays(null, WEEKDAYS)).toEqual(WEEKDAYS); expect(tickedDays('adhoc', WEEKDAYS)).toEqual([]); - expect(tickedDays('tue,thu,sat', WEEKDAYS)).toEqual(['tue', 'thu']); + expect(tickedDays('tue,thu,sat', WEEKDAYS)).toEqual(['tue', 'thu', 'sat']); }); - it('saves the smallest week that means what was ticked', () => { - // every standup day ticked, nothing else: follow the standup - expect(weekAfter(null, WEEKDAYS, WEEKDAYS)).toBe('reset'); - expect(weekAfter('mon,tue,wed,thu,fri', WEEKDAYS, WEEKDAYS)).toBe('reset'); - expect(weekAfter(null, WEEKDAYS, ['mon', 'wed'])).toBe('mon,wed'); + it('saves exactly the standup’s days as the default (criterion 12)', () => { + expect(weekAfter(WEEKDAYS, ['fri', 'mon', 'tue', 'wed', 'thu'])).toBe('reset'); + expect(weekAfter(WEEKDAYS, ['mon', 'wed'])).toBe('mon,wed'); + expect(weekAfter(WEEKDAYS, ['sat', 'mon', 'tue'])).toBe('mon,tue,sat'); + expect(weekAfter(WEEKDAYS, [...WEEKDAYS, 'sat'])).toBe('mon,tue,wed,thu,fri,sat'); // nothing ticked: ad hoc, so only dated working days count - expect(weekAfter(null, WEEKDAYS, [])).toBe('adhoc'); - // days outside this standup belong to the person's other standups and are kept - expect(weekAfter('tue,sat', WEEKDAYS, ['mon', 'tue'])).toBe('mon,tue,sat'); - expect(weekAfter('sat', WEEKDAYS, WEEKDAYS)).toBe('mon,tue,wed,thu,fri,sat'); - expect(weekAfter('sat', ['sat'], [])).toBe('adhoc'); + expect(weekAfter(WEEKDAYS, [])).toBe('adhoc'); }); it('says so when the space has nobody in it', () => { @@ -30,3 +27,36 @@ describe('people grid weeks', () => { expect(screen.getByText('Nobody in this space yet.')).toBeInTheDocument(); }); }); + +describe('people grid', () => { + const rows: MatrixRow[] = [ + { userName: 'users/asha', displayName: 'Asha', onStandup: true, mandatory: true, workingDays: null }, + { userName: 'users/bob', displayName: 'Bob', onStandup: true, mandatory: true, workingDays: 'mon,tue,wed,sat,sun' }, + ]; + + it('marks the default days, says Default or Custom, and lets any day be ticked (criteria 10, 11, 14)', async () => { + const onChange = vi.fn(); + render(); + const user = userEvent.setup(); + const head = screen.getAllByRole('columnheader'); + expect(head.filter((h) => h.textContent?.endsWith('default')).map((h) => h.textContent)).toEqual(['Mondefault', 'Tuedefault', 'Weddefault', 'Thudefault', 'Fridefault']); + const [, asha, bob] = screen.getAllByRole('row'); + expect(within(asha!).getByText('Default')).toBeInTheDocument(); + expect(within(bob!).getByText(/Custom/)).toBeInTheDocument(); + expect(screen.getByLabelText('Bob works Sat')).toBeChecked(); + expect(screen.getByLabelText('Asha works Sat')).toBeEnabled(); + await user.click(screen.getByLabelText('Asha works Sat')); + expect(onChange).toHaveBeenLastCalledWith(rows[0], { week: 'mon,tue,wed,thu,fri,sat' }); + await user.click(within(bob!).getByRole('button', { name: 'Reset to default' })); + expect(onChange).toHaveBeenLastCalledWith(rows[1], { week: 'reset' }); + expect(screen.getByText(/A person’s days apply to this standup only/)).toBeInTheDocument(); + }); + + it('shows Saved, or the error, beside the row that changed (criterion 13)', () => { + const { rerender } = render( {}} />); + expect(within(screen.getAllByRole('row')[2]!).getByRole('status')).toHaveTextContent('✓ Saved'); + rerender( {}} />); + expect(within(screen.getAllByRole('row')[1]!).getByText(/Only managers can do that/)).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/lib/roster.ts b/web/src/lib/roster.ts index 0912cd0..8f03099 100644 --- a/web/src/lib/roster.ts +++ b/web/src/lib/roster.ts @@ -9,21 +9,18 @@ function storedDays(workingDays: string | null): Weekday[] | null { return WEEK.filter((d) => workingDays.split(',').includes(d)); } -/** Which of the standup's days the person works, as the grid ticks them. */ +/** Which days the person works on this standup: their own, else the standup's. */ export function tickedDays(workingDays: string | null, standupDays: readonly Weekday[]): Weekday[] { - const own = storedDays(workingDays); - return own === null ? [...standupDays] : standupDays.filter((d) => own.includes(d)); + return storedDays(workingDays) ?? [...standupDays]; } /** - * The week to save after ticking `picked` among the standup's days, in the - * form the schedule API takes. Days outside this standup are kept, since a - * week belongs to the person; ticking every standup day with nothing else - * set goes back to following the standup. + * The days to save after ticking `picked`, in the form the schedule API + * takes. Ticking exactly the standup's days puts the person back on them, + * so later changes to the standup reach them; ticking none is ad hoc. */ -export function weekAfter(workingDays: string | null, standupDays: readonly Weekday[], picked: readonly Weekday[]): string { - const outside = (storedDays(workingDays) ?? []).filter((d) => !standupDays.includes(d)); - if (outside.length === 0 && standupDays.every((d) => picked.includes(d))) return 'reset'; - const all = WEEK.filter((d) => outside.includes(d) || (standupDays.includes(d) && picked.includes(d))); - return all.length ? all.join(',') : 'adhoc'; +export function weekAfter(standupDays: readonly Weekday[], picked: readonly Weekday[]): string { + const days = WEEK.filter((d) => picked.includes(d)); + if (days.length === standupDays.length && standupDays.every((d) => days.includes(d))) return 'reset'; + return days.length ? days.join(',') : 'adhoc'; } diff --git a/web/src/lib/schedule.ts b/web/src/lib/schedule.ts index 354a0a6..5fa84f1 100644 --- a/web/src/lib/schedule.ts +++ b/web/src/lib/schedule.ts @@ -41,20 +41,21 @@ function invalidate(client: ReturnType, target: ScheduleT ]); } +/** A person's days on one standup, from the schedule drawer. */ export function useSaveWeek(target: ScheduleTarget) { const client = useQueryClient(); return useMutation({ - mutationFn: (workingDays: string) => api(`${base(target)}/schedule`, { method: 'PATCH', body: { workingDays } }), + mutationFn: ({ standupId, workingDays }: { standupId: number; workingDays: string }) => api(`${base(target)}/schedule`, { method: 'PATCH', body: { workingDays, standupId } }), onSuccess: () => invalidate(client, target), }); } -/** Someone's week, set from a standup's people grid; the standup is refetched too. */ +/** Someone's days on this standup, set from its people grid; the standup is refetched too. */ export function useSetWeek(standupId: number) { const client = useQueryClient(); return useMutation({ mutationFn: ({ userName, workingDays }: { userName: string; workingDays: string }) => - api(`/people/${encodeURIComponent(userName)}/schedule`, { method: 'PATCH', body: { workingDays } }), + api(`/people/${encodeURIComponent(userName)}/schedule`, { method: 'PATCH', body: { workingDays, standupId } }), onSuccess: (_d, v) => Promise.all([invalidate(client, { userName: v.userName }), client.invalidateQueries({ queryKey: ['standup', standupId] })]), }); } diff --git a/web/src/pages/me.tsx b/web/src/pages/me.tsx index 1979cc4..4d058ac 100644 --- a/web/src/pages/me.tsx +++ b/web/src/pages/me.tsx @@ -217,13 +217,18 @@ export function MePage() { {schedule.isError ?
Could not load your schedule: {schedule.error.message}
: null} {schedule.data ? ( <> -
-
Working days · {schedule.data.workingDaysLabel === 'Follows the standup' ? 'Standup days' : schedule.data.workingDaysLabel}
- {schedule.data.workingDays && schedule.data.workingDays !== 'adhoc' ? ( -
- {DAYS.map((day) => {DAY_LABEL[day]})} +
+
Working days
+ {schedule.data.standups.map((st) => ( +
+
{st.name} · {st.workingDays === null ? `Standup days (${daysLabel(st.days)})` : st.workingDays === 'adhoc' ? 'Ad hoc' : 'Custom'}
+ {st.workingDays && st.workingDays !== 'adhoc' ? ( +
+ {DAYS.map((day) => {DAY_LABEL[day]})} +
+ ) : null}
- ) : null} + ))}
Days off @@ -240,7 +245,7 @@ export function MePage() { {o.status === 'active' ? o.label : o.status}
))} -
Also from Chat: DM me “off tomorrow”, “off 22 sep to 24 sep sick”, “working sat” or “days mon-thu”.
+
Also from Chat: DM me “off tomorrow”, “off 22 sep to 24 sep sick”, “working sat” or “days {schedule.data.standups.length > 1 ? `#${schedule.data.standups[0]!.standupId} ` : ''}mon-thu”.
) : null}
diff --git a/web/src/pages/schedule.test.tsx b/web/src/pages/schedule.test.tsx index 20b6e50..1266f4e 100644 --- a/web/src/pages/schedule.test.tsx +++ b/web/src/pages/schedule.test.tsx @@ -17,7 +17,11 @@ const EDITS = [ ]; const PRIYA_SCHEDULE = { userName: 'users/priya', displayName: 'Priya Sharma', workingDays: 'mon,tue,wed,thu', workingDaysLabel: 'Mon–Thu', - policies: [{ standupId: 1, name: 'Engineering', timeOffPolicy: 'approval' }], + standups: [ + { standupId: 1, name: 'Engineering', days: ['mon', 'tue', 'wed', 'thu', 'fri'], workingDays: 'mon,tue,wed,thu', workingDaysLabel: 'Mon–Thu', timeOffPolicy: 'approval' }, + { standupId: 2, name: 'Support', days: ['mon', 'tue', 'wed', 'thu', 'fri'], workingDays: null, workingDaysLabel: 'Follows the standup', timeOffPolicy: 'self' }, + ], + policies: [{ standupId: 1, name: 'Engineering', timeOffPolicy: 'approval' }, { standupId: 2, name: 'Support', timeOffPolicy: 'self' }], overrides: [ { id: 3, userName: 'users/priya', displayName: 'Priya Sharma', date: '2026-09-19', working: false, reason: 'comp off', status: 'active', label: 'Day off', setBy: 'Priya Sharma', channel: 'chat', decidedBy: null, decisionNote: null, createdAt: 'x' }, { id: 4, userName: 'users/priya', displayName: 'Priya Sharma', date: '2026-09-27', working: true, reason: '', status: 'active', label: 'Working', setBy: 'Asha Verma', channel: 'console', decidedBy: null, decisionNote: null, createdAt: 'x' }, @@ -63,25 +67,33 @@ describe('team schedules', () => { await user.click(screen.getByRole('button', { name: 'Actions for Priya Sharma' })); await user.click(screen.getByRole('menuitem', { name: 'Edit schedule…' })); const drawer = await screen.findByRole('dialog', { name: 'Edit schedule' }); - expect(await within(drawer).findByText('Priya Sharma · Engineering')).toBeInTheDocument(); - expect(within(drawer).getByRole('radio', { name: 'Custom week' })).toBeChecked(); - expect(within(drawer).getByRole('button', { name: 'Thu' })).toHaveAttribute('aria-pressed', 'true'); - expect(within(drawer).getByRole('button', { name: 'Fri' })).toHaveAttribute('aria-pressed', 'false'); + expect(await within(drawer).findByText('Priya Sharma · Engineering, Support')).toBeInTheDocument(); + // one block of days per standup, each with its own policy (criteria 15, 16) + const eng = within(within(drawer).getByRole('region', { name: 'Days on Engineering' })); + const support = within(within(drawer).getByRole('region', { name: 'Days on Support' })); + expect(eng.getByRole('combobox', { name: 'Days on Engineering' })).toHaveValue('custom'); + expect(eng.getByText('Needs a manager’s approval')).toBeInTheDocument(); + expect(eng.getByRole('button', { name: 'Thu' })).toHaveAttribute('aria-pressed', 'true'); + expect(eng.getByRole('button', { name: 'Fri' })).toHaveAttribute('aria-pressed', 'false'); + expect(support.getByRole('combobox', { name: 'Days on Support' })).toHaveValue('follow'); + expect(support.getByText('Expected on every day Support runs: Mon–Fri.')).toBeInTheDocument(); // overrides list: withdrawn entries are hidden, others show their status expect(within(drawer).getByText('Sat 19 Sep')).toBeInTheDocument(); expect(within(drawer).getByText('Sun 27 Sep')).toBeInTheDocument(); expect(within(drawer).queryByText('Wed 30 Sep')).not.toBeInTheDocument(); - expect(within(drawer).getByText(/Time-off policy: Needs a manager/)).toBeInTheDocument(); + expect(within(drawer).getByText(/apply to every standup they are on/)).toBeInTheDocument(); - // untoggle Thursday and save the week - await user.click(within(drawer).getByRole('button', { name: 'Thu' })); - await user.click(within(drawer).getByRole('button', { name: 'Save week' })); - await waitFor(() => expect(bodies).toContainEqual({ workingDays: 'mon,tue,wed' })); + // untoggle Thursday and save Engineering's days only + await user.click(eng.getByRole('button', { name: 'Thu' })); + await user.click(eng.getByRole('button', { name: 'Save days' })); + await waitFor(() => expect(bodies).toContainEqual({ workingDays: 'mon,tue,wed', standupId: 1 })); + expect(await within(drawer).findByRole('status')).toHaveTextContent('Days on Engineering saved.'); - // switch to ad hoc then revert without saving - await user.click(within(drawer).getByRole('radio', { name: /Ad hoc/ })); - await user.click(within(drawer).getByRole('button', { name: 'Revert' })); - expect(within(drawer).getByRole('radio', { name: 'Custom week' })).toBeChecked(); + // Support: ad hoc, then revert without saving + await user.selectOptions(support.getByRole('combobox', { name: 'Days on Support' }), 'adhoc'); + expect(support.getByText(/prompted only on dates marked working/)).toBeInTheDocument(); + await user.click(support.getByRole('button', { name: 'Revert' })); + expect(support.getByRole('combobox', { name: 'Days on Support' })).toHaveValue('follow'); // add a range, mark away today, cancel one await user.type(within(drawer).getByLabelText('From date'), '2026-10-05'); @@ -108,8 +120,8 @@ describe('team schedules', () => { await user.click(await screen.findByRole('button', { name: 'Actions for Priya Sharma' })); await user.click(screen.getByRole('menuitem', { name: 'Edit schedule…' })); const drawer = await screen.findByRole('dialog', { name: 'Edit schedule' }); - await user.click(await within(drawer).findByRole('radio', { name: /Follow the standup/ })); - await user.click(within(drawer).getByRole('button', { name: 'Save week' })); + await user.selectOptions(await within(drawer).findByRole('combobox', { name: 'Days on Engineering' }), 'follow'); + await user.click(within(drawer).getByRole('button', { name: 'Save days' })); expect(await within(drawer).findByRole('alert')).toHaveTextContent('Only your managers'); await user.click(within(drawer).getByRole('button', { name: 'Dismiss' })); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); @@ -221,8 +233,10 @@ describe('my schedule', () => { const { calls } = mine(); renderApp('/me'); const user = userEvent.setup(); - expect(await screen.findByText('Working days · Mon–Thu')).toBeInTheDocument(); - const week = screen.getByLabelText('My working days'); + expect(await screen.findByText('Engineering · Custom')).toBeInTheDocument(); + expect(screen.getByText('Support · Standup days (Mon–Fri)')).toBeInTheDocument(); + expect(screen.getByText(/“days #1 mon-thu”/)).toBeInTheDocument(); + const week = screen.getByLabelText('My days on Engineering'); expect(within(week).getByText('Mon')).toHaveAttribute('aria-pressed', 'true'); expect(within(week).getByText('Sat')).toHaveAttribute('aria-pressed', 'false'); expect(screen.getByText('awaiting a manager')).toBeInTheDocument(); diff --git a/web/src/pages/setup/create.tsx b/web/src/pages/setup/create.tsx index 4891d1d..bfe3d48 100644 --- a/web/src/pages/setup/create.tsx +++ b/web/src/pages/setup/create.tsx @@ -102,7 +102,7 @@ export function CreateStandupPage() { }); for (const p of f.rosterGroup !== null ? [] : f.people.filter((m) => m.week !== null)) { try { - await api(`/people/${encodeURIComponent(p.userName)}/schedule`, { method: 'PATCH', body: { workingDays: p.week } }); + await api(`/people/${encodeURIComponent(p.userName)}/schedule`, { method: 'PATCH', body: { workingDays: p.week, standupId: created.id } }); } catch (err) { throw new Error(`The standup was created, but ${p.displayName}’s days did not save (${err instanceof Error ? err.message : String(err)}). Set them under the standup’s People.`); } @@ -124,10 +124,10 @@ export function CreateStandupPage() { } const set = (patch: Partial) => setForm({ ...form, ...patch }); const rows: MatrixRow[] = [ - ...form.people.map((p) => ({ ...p, onStandup: true, workingDays: p.week, otherStandups: 0 })), + ...form.people.map((p) => ({ ...p, onStandup: true, workingDays: p.week })), ...(members.data?.members ?? []) .filter((m) => !form.people.some((p) => p.userName === m.userName)) - .map((m) => ({ ...m, onStandup: false, mandatory: true, workingDays: null, otherStandups: 0 })), + .map((m) => ({ ...m, onStandup: false, mandatory: true, workingDays: null })), ]; const change = (row: MatrixRow, c: MatrixChange) => { const update = (patch: Partial) => set({ people: form.people.map((m) => (m.userName === row.userName ? { ...m, ...patch } : m)) }); diff --git a/web/src/pages/setup/setup.test.tsx b/web/src/pages/setup/setup.test.tsx index 40a7cc8..839da02 100644 --- a/web/src/pages/setup/setup.test.tsx +++ b/web/src/pages/setup/setup.test.tsx @@ -307,7 +307,7 @@ describe('setup: sign-in, template, create and live', () => { await user.click(screen.getByRole('button', { name: 'Sat' })); await user.click(screen.getByRole('button', { name: 'Mon' })); - expect(screen.getByLabelText('Rohit works Mon')).toBeDisabled(); + expect(screen.getByLabelText('Rohit works Mon')).not.toBeChecked(); // Rohit works the standup's days except Saturday await user.click(screen.getByLabelText('Rohit works Sat')); await user.click(screen.getByLabelText('Rohit works Tue')); @@ -326,7 +326,7 @@ describe('setup: sign-in, template, create and live', () => { expect(body.days).toEqual(['tue', 'wed', 'thu', 'fri', 'sat']); expect(body.questions).toEqual(['What will you do today?', 'Anything to celebrate?', 'Any blockers?']); expect(body.participants).toEqual([{ userName: 'users/rohit', displayName: 'Rohit', mandatory: true }]); - expect(state.weeks).toEqual([{ workingDays: 'tue,wed,thu,fri' }]); + expect(state.weeks).toEqual([{ workingDays: 'tue,wed,thu,fri', standupId: 7 }]); expect(state.patches.at(-1)).toEqual({ setupComplete: true }); expect(screen.getByText('1 of 3 submitted')).toBeInTheDocument(); diff --git a/web/src/pages/standup/settings.tsx b/web/src/pages/standup/settings.tsx index 426555a..d5e65ac 100644 --- a/web/src/pages/standup/settings.tsx +++ b/web/src/pages/standup/settings.tsx @@ -2,7 +2,7 @@ import { useNavigate } from '@tanstack/react-router'; import { useEffect, useState } from 'react'; import { ConfirmDialog } from '../../components/confirm-dialog'; import { GrantForm, GrantTable } from '../../components/grants'; -import { PeopleMatrix, type MatrixChange, type MatrixRow } from '../../components/people-matrix'; +import { PeopleMatrix, type MatrixChange, type MatrixRow, type RowStatus } from '../../components/people-matrix'; import { SectionCard, SettingsRow } from '../../components/settings-row'; import { fromMutation, VerifyStatus } from '../../components/verify-status'; import { useMutation } from '@tanstack/react-query'; @@ -56,6 +56,7 @@ export function StandupSettingsPage() { const sync = useRosterSync(id); const [groupDraft, setGroupDraft] = useState(null); const [synced, setSynced] = useState(null); + const [rowStatus, setRowStatus] = useState(null); const followsGroup = !!standup.data?.roster.group; const members = useStandupMembers(id, !!standup.data && !followsGroup && may.people); useEffect(() => { @@ -100,7 +101,7 @@ export function StandupSettingsPage() { ...(p.onVacation ? ['Away'] : []), ], })), - ...offRows.map((m) => ({ ...m, onStandup: false, mandatory: true, workingDays: null, otherStandups: 0 })), + ...offRows.map((m) => ({ ...m, onStandup: false, mandatory: true, workingDays: null })), ]; const runSync = () => void roster(async () => { @@ -108,13 +109,23 @@ export function StandupSettingsPage() { setSynced(await sync.mutateAsync()); }); const draft = groupDraft ?? s.roster.group ?? ''; + // Each change in the grid saves at once and says so beside the row. + const inRow = async (row: MatrixRow, fn: () => Promise) => { + setRowStatus(null); + try { + await fn(); + setRowStatus({ userName: row.userName, error: null }); + } catch (err) { + setRowStatus({ userName: row.userName, error: err instanceof Error ? err.message : 'That did not save.' }); + } + }; const change = (row: MatrixRow, c: MatrixChange) => { const person = { userName: row.userName, displayName: row.displayName }; if ('onStandup' in c) { - if (c.onStandup) void roster(() => pAdd.mutateAsync({ standupId: id, person, mandatory: true })); + if (c.onStandup) void inRow(row, () => pAdd.mutateAsync({ standupId: id, person, mandatory: true })); else setConfirm(person); - } else if ('mandatory' in c) void roster(() => pPatch.mutateAsync({ standupId: id, userName: row.userName, patch: { mandatory: c.mandatory } })); - else void roster(() => setWeek.mutateAsync({ userName: row.userName, workingDays: c.week })); + } else if ('mandatory' in c) void inRow(row, () => pPatch.mutateAsync({ standupId: id, userName: row.userName, patch: { mandatory: c.mandatory } })); + else void inRow(row, () => setWeek.mutateAsync({ userName: row.userName, workingDays: c.week })); }; return ( <> @@ -227,7 +238,7 @@ export function StandupSettingsPage() { ) : null} {members.isError ?
Could not list the space’s members: {members.error.message}. Showing the current roster only.
: null} - + {roles.isError || grants.isError ?
Could not load roles: {(roles.error ?? grants.error)?.message}
: null} diff --git a/web/src/pages/standup/tabs.test.tsx b/web/src/pages/standup/tabs.test.tsx index c11aaf5..0a405c5 100644 --- a/web/src/pages/standup/tabs.test.tsx +++ b/web/src/pages/standup/tabs.test.tsx @@ -126,19 +126,20 @@ describe('standup settings tab', () => { const grid = await screen.findByRole('table', { name: 'People and their days' }); expect(within(grid).getByText('Manager')).toBeInTheDocument(); expect(within(grid).getByText('Away')).toBeInTheDocument(); - expect(within(grid).getByText('Days also apply to their other standup')).toBeInTheDocument(); - // the standup runs Mon and Tue; Bob works Tue (and Sat, which this standup never runs) + // the standup runs Mon and Tue; Bob works Tue and Sat, a day the standup doesn't run by default expect(screen.getByLabelText('Alice works Mon')).toBeChecked(); expect(screen.getByLabelText('Bob works Mon')).not.toBeChecked(); expect(screen.getByLabelText('Bob works Tue')).toBeChecked(); - expect(screen.getByLabelText('Bob works Sat')).toBeDisabled(); + expect(screen.getByLabelText('Bob works Sat')).toBeChecked(); + expect(screen.getByLabelText('Alice works Sat')).toBeEnabled(); expect(await screen.findByLabelText('Carol on this standup')).not.toBeChecked(); expect(screen.getByLabelText('Carol works Mon')).toBeDisabled(); await user.click(screen.getByLabelText('Bob required')); await waitFor(() => expect(calls).toContain('PATCH /api/v1/standups/1/participants/users%2Fbob')); await user.click(screen.getByLabelText('Bob works Mon')); - await waitFor(() => expect(weeks).toEqual([{ workingDays: 'mon,tue,sat' }])); + await waitFor(() => expect(weeks).toEqual([{ workingDays: 'mon,tue,sat', standupId: 1 }])); + expect(await within(grid).findByRole('status')).toHaveTextContent('✓ Saved'); await user.click(screen.getByLabelText('Carol on this standup')); await waitFor(() => expect(calls).toContain('POST /api/v1/standups/1/participants')); await user.click(screen.getByLabelText('Bob on this standup')); diff --git a/web/src/styles.css b/web/src/styles.css index d3463dc..ca200e2 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -245,6 +245,11 @@ a:hover { .people-matrix .center { text-align: center; } .people-matrix tr.muted td { color: var(--text-muted); } .people-matrix input[type='checkbox']:disabled { opacity: 0.35; } +.people-matrix .day-default { display: block; font-size: 11px; line-height: 14px; font-weight: 400; color: var(--accent-text); } +.people-matrix .success { color: var(--success-fg); font-weight: 500; } +.people-matrix .danger { color: var(--danger-fg); } +.link-btn { padding: 0; border: 0; background: none; font: inherit; font-weight: 500; color: var(--text-link); cursor: pointer; } +.link-btn:disabled { color: var(--text-muted); cursor: default; } /* Sign-in */ .signin { min-height: 100%; display: grid; place-items: center; padding: var(--space-6); } From b19d159747ecdc9ddbb3a9d06c54c9a8e4523d7c Mon Sep 17 00:00:00 2001 From: Ashish Kumar Date: Thu, 22 Oct 2026 12:35:52 +0530 Subject: [PATCH 3/3] Link spec 007 to its PR --- specs/007-standup-days-default.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/007-standup-days-default.md b/specs/007-standup-days-default.md index 8e0207d..49a98c2 100644 --- a/specs/007-standup-days-default.md +++ b/specs/007-standup-days-default.md @@ -5,7 +5,7 @@ | Status | Done | | Size | Cross-cutting (scheduler, API, Chat, web app, no migration) | | Figma | [Specs › 007 Standup days are a default](https://www.figma.com/design/OQfcL61rbaGakS2FjBKasI?node-id=178-73) | -| PR | Link once opened | +| PR | [#110](https://github.com/asyncup-dev/asyncup/pull/110) | ## Problem