From 45bb98e0a6053f3fe53ebc693f2810e66cd87f47 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 22 Jul 2026 20:14:49 -0400 Subject: [PATCH 01/35] feat(teams): add requirePlanForClock team setting with admin toggle Milestone 1 of the plan-first clock flow: teams.updateSettings method, settings on the public team shape, teamApi.updateSettings, and a Switch in the Team Settings modal (admins only, default off). --- clock-post-simple-plan.md | 94 ++++++++++++++++++++++++++++++++ meteor-backend/server/teams.js | 22 ++++++++ src/features/teams/TeamsPage.tsx | 54 ++++++++++++++++++ src/lib/api.ts | 8 +++ src/lib/ddp.ts | 5 ++ 5 files changed, 183 insertions(+) create mode 100644 clock-post-simple-plan.md diff --git a/clock-post-simple-plan.md b/clock-post-simple-plan.md new file mode 100644 index 00000000..86717c4b --- /dev/null +++ b/clock-post-simple-plan.md @@ -0,0 +1,94 @@ +# Clock ↔ Huddle Plan-First Flow — Minimal Plan + +The core loop, nothing else: **write today's plan as a Huddle post → clock in → edit that same post with a wrap-up → clock out.** Gated per team by one setting, off by default. + +## What's in + +- One team setting: `settings.requirePlanForClock` (default `false`), toggled by admins in the existing Team Settings modal. +- Posts get a `postDate` (`YYYY-MM-DD`) so "today's post" is a real query. +- Gate on: Clock In disabled until you have a post for today; Clock Out blocked until you've saved a wrap-up edit to it. Clear inline message when blocked — no modals, no overrides. +- Gate off (default): everything behaves exactly as today. +- The composer edits today's post if one exists instead of creating a second one. The existing `?prompt=clockin|clockout` redirects reopen it. +- Composer = `RichEditor` from `@mieweb/ui/kerebron` (Kerebron/ProseMirror, markdown in/out). Feed = `SuperChat` panel (`order="desc"`, read-only thread) so posts render rich markdown for free. + +## Dependency prerequisite (blocks Milestones 4–5) + +Work against `mieweb/ui` as a **git submodule** so we can build it locally and PR changes upstream (the repo already uses this convention: `vendor/meteor-wormhole`). + +- [ ] `git submodule add https://github.com/mieweb/ui vendor/ui` (track `main`). +- [ ] Build it locally (`npm install && npm run build` inside `vendor/ui`) and point `package.json` at the build: `"@mieweb/ui": "file:vendor/ui"`. +- [ ] Smoke-test existing `@mieweb/ui` usage across the app (we're coming from 0.2.4 — budget for breaking changes). +- [ ] Wire the submodule build into dev docs/scripts so `nvm use && npm install` after a fresh clone doesn't silently break (submodule init + build step). +- [ ] Fallback/CI note: `@mieweb/ui@0.6.1-dev.169` is the closest published release with SuperChat + kerebron if `file:` causes CI friction; swap to the full release when it lands and drop the submodule once no local patches remain. + +### Upstream PR from the submodule + +- [ ] Branch in `vendor/ui`: add an `extensions`/`kits` prop to `RichEditor` so hosts can inject Kerebron extensions (e.g. `@kerebron/extension-yjs`) alongside the default `AdvancedEditorKit`. +- [ ] PR it to `mieweb/ui`; pin the submodule to our branch commit until merged, then move the pointer back to `main`. + +Milestones 1–3 (setting, data model, gates) have no dependency on this and can land first. + +## What's deliberately out (add later only if people ask) + +- Drafts, "My Drafts" tab, `status` field, future-dated plans +- "Plan tomorrow before clocking out" requirement (needs drafts to work) +- Next-workday/weekend date math +- Override/confirm modals, readiness-checklist endpoints +- Live clock-status dot on posts + +(Kerebron/Yjs live sync was deferred here previously — the submodule + upstream `extensions` prop PR unblocks it, so it's now stretch Milestone 7.) + +--- + +## Milestone 1 — Team setting + +- [x] `teams` doc: `settings.requirePlanForClock` (absent = `false`). +- [x] Admin-only `teams.updateSettings({ teamId, requirePlanForClock })` in `meteor-backend/server/teams.js` (copy the `teams.rename` auth pattern). +- [x] `Team` type + `teamApi.updateSettings` in `src/lib/api.ts`. +- [x] On/off toggle in the Team Settings modal in `src/features/teams/TeamsPage.tsx` (reuse `canManageTeamSettings`). + +## Milestone 2 — Today's post + +- [ ] `huddle.createPost`: accept `postDate` (client sends `toDateString(new Date())` from `src/lib/timeUtils.ts`). +- [ ] `huddle.updatePost`: accept optional `wrapUp: boolean` → sets `wrapUpAt: new Date()` on the post. +- [ ] `huddle.getMyPostForDate({ teamId, postDate })` → `{ post }` or `{ post: null }`. +- [ ] Frontend: `HuddlePost` gets `postDate` + `wrapUpAt`; `huddleApi.getMyPostForDate` wrapper; tiny `useDailyPost(teamId)` hook returning `{ todayPost, refetch }`. + +## Milestone 3 — Gates + +- [ ] `ClockPage.tsx`: when the team setting is on and there's no `todayPost`, disable Clock In with a one-line "Write today's plan first" link to Huddle. Setting off → unchanged. +- [ ] `clock.stop` in `meteor-backend/server/clock.js`: when the setting is on and today's post has no `wrapUpAt`, throw `Meteor.Error('plan-required', 'Add a wrap-up to today's post first')`. Setting off → unchanged. +- [ ] `useClockToggle` / `ClockPage.tsx`: show the `plan-required` message inline with a link to Huddle. + +## Milestone 4 — Composer → RichEditor (Kerebron) + +- [ ] Install `@kerebron/editor`, `@kerebron/editor-kits`, `@kerebron/wasm`; import `@mieweb/ui/kerebron.css`. +- [ ] Serve `@kerebron/wasm`'s `assets/` at `/kerebron-wasm` (Vite static copy or `publicDir` alias — the editor fetches tree-sitter grammars from there at runtime). +- [ ] Swap the Huddle composer's textarea for `` (markdown out). Posts store markdown in the existing `content` field — plain-text legacy posts render fine as markdown. +- [ ] `RichEditor` is uncontrolled (initial `value` applies on mount only) — remount it via `key={editingPostId ?? 'new'}` when switching between new-post and edit-today's-post. +- [ ] If `todayPost` exists, the composer opens it for editing and submit updates instead of creating. +- [ ] `?prompt=clockout` submit passes `wrapUp: true`. +- [ ] Refetch `todayPost` after create/update so the Clock In gate flips live (reuse the existing `work:refetch`-style window event). + +## Milestone 5 — Feed → SuperChat panel + +- [ ] Map huddle posts → a `SuperChatConversation`: one participant per team member (name/avatar → `participants`), one message per post (`createdAt`, markdown `text`). +- [ ] Render `` for the feed — newest-first, composer disabled (authoring goes through the RichEditor above the feed, since SuperChat's built-in composer can't be swapped out). +- [ ] Enable `renderPlugins` (`createCodePlugin`, `createImagePlugin`, `createMermaidPlugin`) as wanted; skip math/KaTeX. +- [ ] Wire `onMessageEdited` (self-authored messages only) → `huddle.updatePost`, so "edit today's post" also works inline from the feed. +- [ ] Decide comment handling: keep the existing per-post comment UI outside SuperChat for v1 (SuperChat has no per-message thread concept) — don't force-fit it. + +## Milestone 6 — Verify + +- [ ] Backend tests: gate off → clock in/out unchanged; gate on → in blocked without today's post, out blocked without wrap-up, both pass once satisfied. +- [ ] `npm run lint && npm run typecheck && npm run test:all` clean. +- [ ] Manual: publish plan (RichEditor) → clock in → wrap-up edit via clock-out prompt → clock out; confirm no duplicate post in the SuperChat feed and legacy plain-text posts still render. + +## Milestone 7 (stretch) — Live collaborative sync (Yjs) + +Only after the upstream `extensions` prop PR merges (or against our pinned submodule branch). Ship independently of 1–6. + +- [ ] `/yjs` WebSocket route on the existing server: `y-websocket`'s `setupWSConnection` attached to the HTTP server on its own path (clear of DDP's `/websocket`), auth via the same token-in-query pattern as the other WS routes. +- [ ] Room per post id; Y.Doc held in memory, seeded from the post's stored markdown on first join. Markdown stays the source of truth — saves still go through `huddle.updatePost`, no Y.Doc persistence layer. +- [ ] Wire `@kerebron/extension-yjs` into `RichEditor` via the new `extensions` prop, room = post id. +- [ ] Verify: same post open in two browsers → edits and cursors sync live; a save from either persists the merged markdown. diff --git a/meteor-backend/server/teams.js b/meteor-backend/server/teams.js index 62e9cec2..0fca3557 100644 --- a/meteor-backend/server/teams.js +++ b/meteor-backend/server/teams.js @@ -101,6 +101,9 @@ function toPublicTeam(team) { admins: team.admins, code: team.code, isPersonal: team.isPersonal ?? false, + settings: { + requirePlanForClock: team.settings?.requirePlanForClock ?? false, + }, createdAt: team.createdAt instanceof Date ? team.createdAt.toISOString() : String(team.createdAt), updatedAt: team.updatedAt instanceof Date ? team.updatedAt.toISOString() : (team.updatedAt ?? null), }; @@ -364,6 +367,25 @@ Meteor.methods({ return { team: toPublicTeam(updated) }; }, + async 'teams.updateSettings'({ teamId, requirePlanForClock }) { + const identity = await requireIdentity(this); + const userId = identity.userId; + if (!isValidId(teamId)) throw new Meteor.Error('not-found', 'Invalid team id'); + if (typeof requirePlanForClock !== 'boolean') { + throw new Meteor.Error('bad-request', 'requirePlanForClock must be a boolean'); + } + const team = await Teams.findOneAsync(new Mongo.ObjectID(teamId)); + if (!team) throw new Meteor.Error('not-found', 'Team not found'); + if (!(await isTeamAdminOrOrgOwner(team, userId))) { + throw new Meteor.Error('forbidden', 'Admin access required'); + } + await Teams.updateAsync(team._id, { + $set: { 'settings.requirePlanForClock': requirePlanForClock, updatedAt: new Date() }, + }); + const updated = await Teams.findOneAsync(team._id); + return { team: toPublicTeam(updated) }; + }, + async 'teams.delete'({ teamId }) { const identity = await requireIdentity(this); const userId = identity.userId; diff --git a/src/features/teams/TeamsPage.tsx b/src/features/teams/TeamsPage.tsx index 7d5bf555..3900ce0d 100644 --- a/src/features/teams/TeamsPage.tsx +++ b/src/features/teams/TeamsPage.tsx @@ -39,6 +39,7 @@ import { ModalHeader, ModalTitle, Spinner, + Switch, Table, TableBody, TableCell, @@ -209,6 +210,14 @@ export const TeamsPage: React.FC = () => { const [removeLoading, setRemoveLoading] = useState(false); const [revokeLoadingId, setRevokeLoadingId] = useState(null); + // Team setting: require a daily Huddle plan to clock in/out + const [requirePlanForClock, setRequirePlanForClock] = useState(false); + const [savingPlanSetting, setSavingPlanSetting] = useState(false); + + useEffect(() => { + setRequirePlanForClock(selectedTeam?.settings?.requirePlanForClock ?? false); + }, [selectedTeam?.id, selectedTeam?.settings?.requirePlanForClock]); + // Pending/sent team invitations shown in the Team Settings modal const [invitations, setInvitations] = useState([]); const [invitationsLoading, setInvitationsLoading] = useState(false); @@ -879,6 +888,51 @@ export const TeamsPage: React.FC = () => { + + Clock + +
+
+ + Require a daily plan to clock in/out + + + Members must post today’s plan in Huddle before clocking in, and add a wrap-up to + it before clocking out. + +
+ { + if (!selectedTeamId) return; + const previous = requirePlanForClock; + setRequirePlanForClock(checked); + setSavingPlanSetting(true); + setFormError(null); + try { + await teamApi.updateSettings(selectedTeamId, { requirePlanForClock: checked }); + refetchTeams(); + } catch (e: any) { + setRequirePlanForClock(previous); + setFormError(e.message || 'Failed to update setting'); + } finally { + setSavingPlanSetting(false); + } + }} + /> +
+ {formError && ( + + {formError} + + )} wormholeCall<{ team: Team }>('teams.rename', { teamId: id, newName }).then((r) => r.team), + updateSettings: (id: string, settings: { requirePlanForClock: boolean }) => + wormholeCall<{ team: Team }>('teams.updateSettings', { teamId: id, ...settings }).then( + (r) => r.team, + ), + deleteTeam: (id: string) => wormholeCall<{ ok: boolean }>('teams.delete', { teamId: id }), getMembers: (id: string) => diff --git a/src/lib/ddp.ts b/src/lib/ddp.ts index ae7709b6..5314cf93 100644 --- a/src/lib/ddp.ts +++ b/src/lib/ddp.ts @@ -544,6 +544,11 @@ export function ddpDocToTeam(doc: DdpDoc): import('./api').Team { admins: Array.isArray(doc.admins) ? doc.admins.map(String) : [], code: String(doc.code ?? ''), isPersonal: Boolean(doc.isPersonal), + settings: { + requirePlanForClock: Boolean( + (doc.settings as { requirePlanForClock?: boolean } | undefined)?.requirePlanForClock, + ), + }, createdAt: String(doc.createdAt ?? ''), updatedAt: (doc.updatedAt as string | undefined) ?? null, }; From 3fe6eb862632733ceb7a3886ed3f5c145c7855f7 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 22 Jul 2026 20:16:57 -0400 Subject: [PATCH 02/35] feat(huddle): postDate + wrapUpAt on posts, getMyPostForDate, useDailyPost hook Milestone 2 of the plan-first clock flow: posts can carry a client-local YYYY-MM-DD postDate, huddle.updatePost stamps wrapUpAt when wrapUp is passed, and huddle.getMyPostForDate + useDailyPost expose today's post. --- clock-post-simple-plan.md | 8 +++--- meteor-backend/server/huddle.js | 44 +++++++++++++++++++++++++++++++-- src/lib/api.ts | 20 ++++++++++++--- src/lib/useDailyPost.ts | 44 +++++++++++++++++++++++++++++++++ src/pages/Huddle.tsx | 6 ++++- 5 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 src/lib/useDailyPost.ts diff --git a/clock-post-simple-plan.md b/clock-post-simple-plan.md index 86717c4b..349ff093 100644 --- a/clock-post-simple-plan.md +++ b/clock-post-simple-plan.md @@ -49,10 +49,10 @@ Milestones 1–3 (setting, data model, gates) have no dependency on this and can ## Milestone 2 — Today's post -- [ ] `huddle.createPost`: accept `postDate` (client sends `toDateString(new Date())` from `src/lib/timeUtils.ts`). -- [ ] `huddle.updatePost`: accept optional `wrapUp: boolean` → sets `wrapUpAt: new Date()` on the post. -- [ ] `huddle.getMyPostForDate({ teamId, postDate })` → `{ post }` or `{ post: null }`. -- [ ] Frontend: `HuddlePost` gets `postDate` + `wrapUpAt`; `huddleApi.getMyPostForDate` wrapper; tiny `useDailyPost(teamId)` hook returning `{ todayPost, refetch }`. +- [x] `huddle.createPost`: accept `postDate` (client sends `toDateString(new Date())` from `src/lib/timeUtils.ts`). +- [x] `huddle.updatePost`: accept optional `wrapUp: boolean` → sets `wrapUpAt: new Date()` on the post. +- [x] `huddle.getMyPostForDate({ teamId, postDate })` → `{ post }` or `{ post: null }`. +- [x] Frontend: `HuddlePost` gets `postDate` + `wrapUpAt`; `huddleApi.getMyPostForDate` wrapper; tiny `useDailyPost(teamId)` hook returning `{ todayPost, refetch }`. ## Milestone 3 — Gates diff --git a/meteor-backend/server/huddle.js b/meteor-backend/server/huddle.js index 2a69ea81..9211873e 100644 --- a/meteor-backend/server/huddle.js +++ b/meteor-backend/server/huddle.js @@ -10,6 +10,9 @@ function toId(id) { return /^[a-f0-9]{24}$/i.test(id) ? new ObjectId(id) : id; } +// postDate is a plain calendar date string (client-local), e.g. "2026-07-22" +const POST_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + // Permission helpers async function getTeam(teamId) { // Try plain string first (Meteor-created teams) @@ -83,6 +86,10 @@ async function enrichPost(post) { })), likes: post.likes ?? [], commentCount: post.commentCount ?? 0, + postDate: post.postDate ?? undefined, + wrapUpAt: post.wrapUpAt instanceof Date + ? post.wrapUpAt.toISOString() + : (post.wrapUpAt ? String(post.wrapUpAt) : null), createdAt: post.createdAt instanceof Date ? post.createdAt.toISOString() : String(post.createdAt), updatedAt: post.updatedAt instanceof Date ? post.updatedAt.toISOString() : String(post.updatedAt ?? post.createdAt), }; @@ -221,7 +228,7 @@ Meteor.methods({ return { posts: enriched }; }, - async 'huddle.createPost'({ teamId, content, ticketId, attachments }) { + async 'huddle.createPost'({ teamId, content, ticketId, attachments, postDate }) { if (!this.userId) { throw new Meteor.Error('not-authorized', 'Authentication required'); } @@ -231,6 +238,9 @@ Meteor.methods({ if (!content || typeof content.text !== 'string') { throw new Meteor.Error('bad-request', 'content.text is required'); } + if (postDate !== undefined && (typeof postDate !== 'string' || !POST_DATE_RE.test(postDate))) { + throw new Meteor.Error('bad-request', 'postDate must be a YYYY-MM-DD string'); + } const team = await getTeam(teamId); if (!team) { @@ -275,6 +285,7 @@ Meteor.methods({ attachments: attachments ?? [], likes: [], commentCount: 0, + ...(postDate ? { postDate } : {}), createdAt: new Date(), updatedAt: new Date(), }; @@ -284,7 +295,7 @@ Meteor.methods({ return { id: doc._id.toHexString() }; }, - async 'huddle.updatePost'({ postId, content }) { + async 'huddle.updatePost'({ postId, content, wrapUp }) { if (!this.userId) { throw new Meteor.Error('not-authorized', 'Authentication required'); } @@ -328,6 +339,7 @@ Meteor.methods({ text: content.text, mentions: content.mentions ?? [], }, + ...(wrapUp === true ? { wrapUpAt: new Date() } : {}), updatedAt: new Date(), }, } @@ -335,6 +347,34 @@ Meteor.methods({ return { id: postId }; }, + + /** The caller's own post for a given calendar date in a team, or null. */ + async 'huddle.getMyPostForDate'({ teamId, postDate }) { + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'Authentication required'); + } + if (!teamId || typeof teamId !== 'string') { + throw new Meteor.Error('bad-request', 'teamId is required'); + } + if (typeof postDate !== 'string' || !POST_DATE_RE.test(postDate)) { + throw new Meteor.Error('bad-request', 'postDate must be a YYYY-MM-DD string'); + } + + const team = await getTeam(teamId); + if (!team) { + throw new Meteor.Error('not-found', 'Team not found'); + } + const isMember = (team.members ?? []).includes(this.userId) || (team.admins ?? []).includes(this.userId); + if (!isMember) { + throw new Meteor.Error('forbidden', 'Not a team member'); + } + + const post = await rawDb().collection('huddlePosts').findOne( + { teamId, userId: this.userId, postDate }, + { sort: { createdAt: -1 } } + ); + return { post: post ? await enrichPost(post) : null }; + }, async 'huddle.deletePost'({ postId }) { if (!this.userId) { diff --git a/src/lib/api.ts b/src/lib/api.ts index bf648ba3..848c0551 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -982,6 +982,10 @@ export interface HuddlePost { }>; likes: string[]; commentCount: number; + /** Client-local calendar date (YYYY-MM-DD) this post is the plan for. */ + postDate?: string; + /** Set when the author saved a wrap-up edit (plan-first clock flow). */ + wrapUpAt?: string | null; createdAt: string; updatedAt: string; } @@ -1006,9 +1010,19 @@ export const huddleApi = { (r) => r.posts, ), - /** Update a huddle post. */ - updatePost: (postId: string, content: { text: string; mentions: string[] }) => - getDdpClient().call('huddle.updatePost', { postId, content }), + /** The caller's own post for a calendar date (YYYY-MM-DD) in a team, or null. */ + getMyPostForDate: (teamId: string, postDate: string) => + wormholeCall<{ post: HuddlePost | null }>('huddle.getMyPostForDate', { + teamId, + postDate, + }).then((r) => r.post), + + /** Update a huddle post. Pass wrapUp to stamp wrapUpAt (plan-first clock flow). */ + updatePost: ( + postId: string, + content: { text: string; mentions: string[] }, + options?: { wrapUp?: boolean }, + ) => getDdpClient().call('huddle.updatePost', { postId, content, ...options }), /** Delete a huddle post. */ deletePost: (postId: string) => getDdpClient().call('huddle.deletePost', { postId }), diff --git a/src/lib/useDailyPost.ts b/src/lib/useDailyPost.ts new file mode 100644 index 00000000..68a9d757 --- /dev/null +++ b/src/lib/useDailyPost.ts @@ -0,0 +1,44 @@ +/** + * useDailyPost — the caller's own Huddle post for today in a team. + * + * Backs the plan-first clock flow gates: Clock In requires today's post to + * exist, Clock Out requires it to have a wrap-up. Listens for the + * `huddle:refetch` window event so gates flip live after posting. + */ +import { useCallback, useEffect, useState } from 'react'; + +import { huddleApi, type HuddlePost } from './api'; +import { toDateString } from './timeUtils'; + +export function useDailyPost(teamId: string | null) { + const [todayPost, setTodayPost] = useState(null); + const [loading, setLoading] = useState(false); + + const refetch = useCallback(async () => { + if (!teamId) { + setTodayPost(null); + return; + } + setLoading(true); + try { + const post = await huddleApi.getMyPostForDate(teamId, toDateString(new Date())); + setTodayPost(post); + } catch { + setTodayPost(null); + } finally { + setLoading(false); + } + }, [teamId]); + + useEffect(() => { + void refetch(); + }, [refetch]); + + useEffect(() => { + const handler = () => void refetch(); + window.addEventListener('huddle:refetch', handler); + return () => window.removeEventListener('huddle:refetch', handler); + }, [refetch]); + + return { todayPost, loading, refetch }; +} diff --git a/src/pages/Huddle.tsx b/src/pages/Huddle.tsx index bf8aa7fb..6d643a78 100644 --- a/src/pages/Huddle.tsx +++ b/src/pages/Huddle.tsx @@ -11,6 +11,7 @@ import { useSession } from '@lib/useSession'; import { useTeam } from '@lib/TeamContext'; import { teamApi, type HuddlePost, type Team } from '@lib/api'; import { getDdpClient } from '@lib/ddp'; +import { toDateString } from '@lib/timeUtils'; function getUserInitials(name: string): string { const parts = name.trim().split(/\s+/); @@ -146,9 +147,12 @@ export default function Huddle() { content: { text: content.text, mentions: mentionUserIds }, ticketId: content.ticketId, attachments, + postDate: toDateString(new Date()), }); - // DDP subscription will automatically reflect the new post + // DDP subscription will automatically reflect the new post; the clock-in + // gate (useDailyPost) listens for this event to flip live. + window.dispatchEvent(new CustomEvent('huddle:refetch')); } catch (error) { console.error('[Huddle] Error in addPost:', error); alert('Failed to create post. Please try again.'); From 9a71fb228035aa6412383f2f285029c38fde321e Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 22 Jul 2026 20:38:38 -0400 Subject: [PATCH 03/35] feat(clock): gate clock in/out behind today's Huddle plan when enabled Milestone 3 of the plan-first clock flow: - clock.stop throws Meteor.Error('plan-required') when the team setting is on and today's post has no wrap-up; accepts an optional client-local localDate so 'today' matches the user's timezone - ClockPage disables Clock In without today's post and Clock Out without a wrap-up, with inline messages linking to Huddle - useClockToggle surfaces plan-required refusals inline instead of alert - wormhole exposure for teams.updateSettings + huddle.getMyPostForDate - integration tests: meteor-backend/tests/plan-gate.test.ts (9 passing) --- clock-post-simple-plan.md | 10 +- meteor-backend/server/clock.js | 29 +++- meteor-backend/server/huddle.js | 10 +- meteor-backend/server/main.js | 23 ++- meteor-backend/tests/helpers.ts | 2 +- meteor-backend/tests/plan-gate.test.ts | 208 +++++++++++++++++++++++++ src/features/clock/ClockPage.tsx | 36 ++++- src/lib/api.ts | 13 +- src/lib/useClockToggle.test.ts | 46 +++++- src/lib/useClockToggle.ts | 19 ++- src/ui/BottomNav.tsx | 4 +- 11 files changed, 371 insertions(+), 29 deletions(-) create mode 100644 meteor-backend/tests/plan-gate.test.ts diff --git a/clock-post-simple-plan.md b/clock-post-simple-plan.md index 349ff093..482efad2 100644 --- a/clock-post-simple-plan.md +++ b/clock-post-simple-plan.md @@ -56,9 +56,9 @@ Milestones 1–3 (setting, data model, gates) have no dependency on this and can ## Milestone 3 — Gates -- [ ] `ClockPage.tsx`: when the team setting is on and there's no `todayPost`, disable Clock In with a one-line "Write today's plan first" link to Huddle. Setting off → unchanged. -- [ ] `clock.stop` in `meteor-backend/server/clock.js`: when the setting is on and today's post has no `wrapUpAt`, throw `Meteor.Error('plan-required', 'Add a wrap-up to today's post first')`. Setting off → unchanged. -- [ ] `useClockToggle` / `ClockPage.tsx`: show the `plan-required` message inline with a link to Huddle. +- [x] `ClockPage.tsx`: when the team setting is on and there's no `todayPost`, disable Clock In with a one-line "Write today's plan first" link to Huddle. Setting off → unchanged. +- [x] `clock.stop` in `meteor-backend/server/clock.js`: when the setting is on and today's post has no `wrapUpAt`, throw `Meteor.Error('plan-required', 'Add a wrap-up to today's post first')`. Setting off → unchanged. (Accepts an optional client-local `localDate` so "today" matches the user's timezone; falls back to the server-local date.) +- [x] `useClockToggle` / `ClockPage.tsx`: show the `plan-required` message inline with a link to Huddle. ## Milestone 4 — Composer → RichEditor (Kerebron) @@ -80,8 +80,8 @@ Milestones 1–3 (setting, data model, gates) have no dependency on this and can ## Milestone 6 — Verify -- [ ] Backend tests: gate off → clock in/out unchanged; gate on → in blocked without today's post, out blocked without wrap-up, both pass once satisfied. -- [ ] `npm run lint && npm run typecheck && npm run test:all` clean. +- [x] Backend tests: gate off → clock in/out unchanged; gate on → in blocked without today's post, out blocked without wrap-up, both pass once satisfied. (`meteor-backend/tests/plan-gate.test.ts`) +- [x] `npm run lint && npm run typecheck` clean; unit tests + clock/teams integration tests pass. (`test:all` — full Playwright e2e — still pending for Milestones 4–5.) - [ ] Manual: publish plan (RichEditor) → clock in → wrap-up edit via clock-out prompt → clock out; confirm no duplicate post in the SuperChat feed and legacy plain-text posts still render. ## Milestone 7 (stretch) — Live collaborative sync (Yjs) diff --git a/meteor-backend/server/clock.js b/meteor-backend/server/clock.js index 210bd02b..18b6f33e 100644 --- a/meteor-backend/server/clock.js +++ b/meteor-backend/server/clock.js @@ -46,6 +46,15 @@ import { const { ObjectId } = MongoInternals.NpmModules.mongodb.module; const oid = (hex) => new Mongo.ObjectID(hex); +const LOCAL_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** Server-local YYYY-MM-DD fallback when the client didn't send its date. */ +function localDateString(date) { + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${date.getFullYear()}-${m}-${d}`; +} + /** Load one team the user belongs to (member or admin), or null. */ async function findUserTeam(userId, teamId) { if (!isValidId(teamId)) return null; @@ -184,12 +193,29 @@ Meteor.methods({ }, /** Clock out: cancel jobs, close timers + open break, recompute, notify, log. */ - async 'clock.stop'({ teamId } = {}) { + async 'clock.stop'({ teamId, localDate } = {}) { const identity = await requireIdentity(this); const userId = identity.userId; const event = await ClockEvents.findOneAsync({ userId, teamId, endTime: null }); if (!event) throw new Meteor.Error('not-found', 'No active clock event'); + const team = await findUserTeam(userId, teamId); + + // Plan-first flow: when the team requires a daily plan, block clock-out + // until today's Huddle post has a wrap-up (see clock-post-simple-plan.md). + if (team?.settings?.requirePlanForClock) { + const postDate = + typeof localDate === 'string' && LOCAL_DATE_RE.test(localDate) + ? localDate + : localDateString(new Date()); + const post = await rawDb() + .collection('huddlePosts') + .findOne({ teamId, userId, postDate }, { sort: { createdAt: -1 } }); + if (!post?.wrapUpAt) { + throw new Meteor.Error('plan-required', "Add a wrap-up to today's post first"); + } + } + const now = Date.now(); const eventId = event._id.toHexString(); @@ -221,7 +247,6 @@ Meteor.methods({ const updated = await ClockEvents.findOneAsync(event._id); const pub = toPublicClockEvent(updated, closedBreaks); - const team = await findUserTeam(userId, teamId); if (team) { const userName = await userDisplayName(userId); const totalSecs = pub.accumulatedTime ?? 0; diff --git a/meteor-backend/server/huddle.js b/meteor-backend/server/huddle.js index 9211873e..8707da70 100644 --- a/meteor-backend/server/huddle.js +++ b/meteor-backend/server/huddle.js @@ -350,9 +350,9 @@ Meteor.methods({ /** The caller's own post for a given calendar date in a team, or null. */ async 'huddle.getMyPostForDate'({ teamId, postDate }) { - if (!this.userId) { - throw new Meteor.Error('not-authorized', 'Authentication required'); - } + // requireIdentity: reachable via wormhole REST (bearer) and DDP alike. + const identity = await requireIdentity(this); + const userId = identity.userId; if (!teamId || typeof teamId !== 'string') { throw new Meteor.Error('bad-request', 'teamId is required'); } @@ -364,13 +364,13 @@ Meteor.methods({ if (!team) { throw new Meteor.Error('not-found', 'Team not found'); } - const isMember = (team.members ?? []).includes(this.userId) || (team.admins ?? []).includes(this.userId); + const isMember = (team.members ?? []).includes(userId) || (team.admins ?? []).includes(userId); if (!isMember) { throw new Meteor.Error('forbidden', 'Not a team member'); } const post = await rawDb().collection('huddlePosts').findOne( - { teamId, userId: this.userId, postDate }, + { teamId, userId, postDate }, { sort: { createdAt: -1 } } ); return { post: post ? await enrichPost(post) : null }; diff --git a/meteor-backend/server/main.js b/meteor-backend/server/main.js index 55d8113a..173dc803 100644 --- a/meteor-backend/server/main.js +++ b/meteor-backend/server/main.js @@ -807,7 +807,10 @@ Meteor.startup(async() => { description: 'Clock out of a team (computes worked time minus meal breaks)', inputSchema: { type: 'object', - properties: { teamId: { type: 'string' } }, + properties: { + teamId: { type: 'string' }, + localDate: { type: 'string', description: "Caller's local YYYY-MM-DD (plan-first gate)" }, + }, required: ['teamId'], }, }); @@ -960,6 +963,15 @@ Meteor.startup(async() => { }, }); + Wormhole.expose('huddle.getMyPostForDate', { + description: "The caller's own huddle post for a calendar date in a team, or null", + inputSchema: { + type: 'object', + properties: { teamId: { type: 'string' }, postDate: { type: 'string' } }, + required: ['teamId', 'postDate'], + }, + }); + // ─── Timers ──────────────────────────────────────────────────────────────── Wormhole.expose('timers.getDay', { description: 'List WorkItems with timers for a local calendar day', @@ -1186,6 +1198,15 @@ Meteor.startup(async() => { }, }); + Wormhole.expose('teams.updateSettings', { + description: 'Update team settings, e.g. requirePlanForClock (admin only)', + inputSchema: { + type: 'object', + properties: { teamId: { type: 'string' }, requirePlanForClock: { type: 'boolean' } }, + required: ['teamId', 'requirePlanForClock'], + }, + }); + Wormhole.expose('teams.delete', { description: 'Delete a team (admin only)', inputSchema: { diff --git a/meteor-backend/tests/helpers.ts b/meteor-backend/tests/helpers.ts index 690dff94..53a80f9e 100644 --- a/meteor-backend/tests/helpers.ts +++ b/meteor-backend/tests/helpers.ts @@ -19,7 +19,7 @@ interface DDPMessage { error?: { error: string; reason: string; message: string }; } -class DDPConnection { +export class DDPConnection { private ws: any; private messageId = 0; private pending = new Map void; reject: (err: Error) => void }>(); diff --git a/meteor-backend/tests/plan-gate.test.ts b/meteor-backend/tests/plan-gate.test.ts new file mode 100644 index 00000000..235679b1 --- /dev/null +++ b/meteor-backend/tests/plan-gate.test.ts @@ -0,0 +1,208 @@ +/** + * Plan-first clock flow — wormhole REST + DDP integration tests. + * + * Covers the `settings.requirePlanForClock` team setting (Milestone 1), the + * postDate/wrapUpAt post fields (Milestone 2), and the clock-out gate + * (Milestone 3): gate off → clock in/out unchanged; gate on → clock-out is + * blocked until today's post has a wrap-up. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + createUserAndGetJwt, + wormhole, + getDb, + closeDb, + purgeUser, + ObjectId, + DDPConnection, +} from './helpers'; +import { METEOR_URL } from './setup'; + +const ADMIN = { name: 'Plan Admin', email: 'wh-plan-admin@test.dev', password: 'Password1!' }; +const MEMBER = { name: 'Plan Member', email: 'wh-plan-member@test.dev', password: 'Password1!' }; + +function todayString(): string { + const d = new Date(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${d.getFullYear()}-${m}-${day}`; +} + +let adminJwt: string; +let memberJwt: string; +let memberUserId: string; +let teamId: string; +let memberDdp: DDPConnection; + +beforeAll(async () => { + await purgeUser(ADMIN.email); + await purgeUser(MEMBER.email); + const adminAuth = await createUserAndGetJwt(ADMIN); + const memberAuth = await createUserAndGetJwt(MEMBER); + adminJwt = adminAuth.jwt; + memberJwt = memberAuth.jwt; + + const db = await getDb(); + const adminUserId = String( + (await db.collection('users').findOne({ 'emails.address': ADMIN.email }))!._id, + ); + memberUserId = String( + (await db.collection('users').findOne({ 'emails.address': MEMBER.email }))!._id, + ); + + const teamDoc = { + _id: new ObjectId(), + name: 'WH Plan Team', + members: [adminUserId, memberUserId], + admins: [adminUserId], + code: 'WHPLAN01', + isPersonal: false, + createdAt: new Date(), + }; + await db.collection('teams').insertOne(teamDoc); + teamId = teamDoc._id.toHexString(); + + // DDP session as the member — huddle.createPost/updatePost are DDP-only + // (they authenticate via this.userId). + memberDdp = new DDPConnection(METEOR_URL.replace('http://', 'ws://') + '/websocket'); + await memberDdp.connect(); + await memberDdp.login(MEMBER.email, MEMBER.password); +}); + +afterAll(async () => { + memberDdp?.close(); + const db = await getDb(); + await db.collection('teams').deleteMany({ code: 'WHPLAN01' }); + await db.collection('clockevents').deleteMany({ teamId }); + await db.collection('clockbreaks').deleteMany({ teamId }); + await db.collection('huddlePosts').deleteMany({ teamId }); + await purgeUser(ADMIN.email); + await purgeUser(MEMBER.email); + await closeDb(); +}); + +describe('plan-first clock flow (wormhole)', () => { + let postId: string; + + it('gate off by default: clock in and out work with no post', async () => { + const start = await wormhole<{ id: string }>('clock.start', { teamId }, memberJwt); + expect(start.ok).toBe(true); + const stop = await wormhole<{ id: string }>('clock.stop', { teamId }, memberJwt); + expect(stop.ok).toBe(true); + }); + + it('rejects teams.updateSettings from a non-admin member', async () => { + const res = await wormhole( + 'teams.updateSettings', + { teamId, requirePlanForClock: true }, + memberJwt, + ); + expect(res.ok).toBe(false); + expect(res.error).toMatch(/admin/i); + }); + + it('lets a team admin enable requirePlanForClock', async () => { + const res = await wormhole<{ team: { settings: { requirePlanForClock: boolean } } }>( + 'teams.updateSettings', + { teamId, requirePlanForClock: true }, + adminJwt, + ); + expect(res.ok).toBe(true); + expect(res.result.team.settings.requirePlanForClock).toBe(true); + }); + + it('getMyPostForDate returns null before any post exists', async () => { + const res = await wormhole<{ post: null }>( + 'huddle.getMyPostForDate', + { teamId, postDate: todayString() }, + memberJwt, + ); + expect(res.ok).toBe(true); + expect(res.result.post).toBeNull(); + }); + + it('gate on: clock-in itself is not backend-blocked', async () => { + const res = await wormhole<{ id: string }>('clock.start', { teamId }, memberJwt); + expect(res.ok).toBe(true); + }); + + it('gate on: clock-out is blocked when there is no post for today', async () => { + const res = await wormhole( + 'clock.stop', + { teamId, localDate: todayString() }, + memberJwt, + ); + expect(res.ok).toBe(false); + expect(res.error).toMatch(/wrap-up/i); + }); + + it("creating today's post (no wrap-up yet) still blocks clock-out", async () => { + const created = (await memberDdp.call('huddle.createPost', [ + { + teamId, + content: { text: 'Today I will ship the plan-first flow.' }, + postDate: todayString(), + }, + ])) as { id: string }; + postId = created.id; + expect(postId).toBeTruthy(); + + const fetched = await wormhole<{ post: { id: string; wrapUpAt: string | null } }>( + 'huddle.getMyPostForDate', + { teamId, postDate: todayString() }, + memberJwt, + ); + expect(fetched.ok).toBe(true); + expect(fetched.result.post?.id).toBe(postId); + expect(fetched.result.post?.wrapUpAt).toBeNull(); + + // No localDate sent — exercises the server-local date fallback. + const stop = await wormhole('clock.stop', { teamId }, memberJwt); + expect(stop.ok).toBe(false); + expect(stop.error).toMatch(/wrap-up/i); + }); + + it('saving a wrap-up edit stamps wrapUpAt and unblocks clock-out', async () => { + await memberDdp.call('huddle.updatePost', [ + { + postId, + content: { text: 'Today I shipped the plan-first flow. Wrap-up: it works.' }, + wrapUp: true, + }, + ]); + + const fetched = await wormhole<{ post: { wrapUpAt: string | null } }>( + 'huddle.getMyPostForDate', + { teamId, postDate: todayString() }, + memberJwt, + ); + expect(fetched.ok).toBe(true); + expect(fetched.result.post?.wrapUpAt).toBeTruthy(); + + const stop = await wormhole<{ id: string; endTime: number }>( + 'clock.stop', + { teamId, localDate: todayString() }, + memberJwt, + ); + expect(stop.ok).toBe(true); + expect(stop.result.endTime).toBeGreaterThan(0); + }); + + it('gate turned back off: clock in/out works again without a post', async () => { + const db = await getDb(); + await db.collection('huddlePosts').deleteMany({ teamId }); + + const res = await wormhole<{ team: { settings: { requirePlanForClock: boolean } } }>( + 'teams.updateSettings', + { teamId, requirePlanForClock: false }, + adminJwt, + ); + expect(res.ok).toBe(true); + expect(res.result.team.settings.requirePlanForClock).toBe(false); + + const start = await wormhole<{ id: string }>('clock.start', { teamId }, memberJwt); + expect(start.ok).toBe(true); + const stop = await wormhole<{ id: string }>('clock.stop', { teamId }, memberJwt); + expect(stop.ok).toBe(true); + }); +}); diff --git a/src/features/clock/ClockPage.tsx b/src/features/clock/ClockPage.tsx index d4228db9..72c29e65 100644 --- a/src/features/clock/ClockPage.tsx +++ b/src/features/clock/ClockPage.tsx @@ -10,12 +10,13 @@ import { useTeam } from '../../lib/TeamContext'; import { formatTimer, getActiveClockSeconds } from '../../lib/timeUtils'; import { AppPage } from '../../ui/AppPage'; import { useClockToggle } from '../../lib/useClockToggle'; +import { useDailyPost } from '../../lib/useDailyPost'; import { useRouter } from '../../ui/router'; // ─── ClockPage ──────────────────────────────────────────────────────────────── export const ClockPage: React.FC = () => { - const { selectedTeamId, activeClockEvent, currentTime, teamsReady } = useTeam(); + const { selectedTeamId, selectedTeam, activeClockEvent, currentTime, teamsReady } = useTeam(); const { navigate } = useRouter(); const { @@ -26,8 +27,21 @@ export const ClockPage: React.FC = () => { clockInLoading, clockOutLoading, clockPauseLoading, + clockOutBlockedReason, } = useClockToggle(); + // ── Plan-first gates (team setting, default off) ── + const { todayPost } = useDailyPost(selectedTeamId); + const requirePlan = !!selectedTeam?.settings?.requirePlanForClock; + // Clock In requires today's post to exist. + const planMissing = requirePlan && !todayPost; + // Clock Out requires today's post to have a wrap-up — only meaningful when + // the active session is on the currently selected team. + const wrapUpMissing = + requirePlan && + activeClockEvent?.teamId === selectedTeamId && + (!todayPost || !todayPost.wrapUpAt); + // Session duration const sessionSeconds = getActiveClockSeconds(activeClockEvent, currentTime); const isPaused = !!activeClockEvent?.isPaused; @@ -66,6 +80,7 @@ export const ClockPage: React.FC = () => { + + + ) : null} {timeZone} diff --git a/src/lib/api.ts b/src/lib/api.ts index 848c0551..90a2b07f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -6,6 +6,7 @@ */ // autoReconnectWs removed - no longer needed after migrating tickets to wormhole import { getDdpClient } from './ddp.js'; +import { toDateString } from './timeUtils'; // ─── Config ─────────────────────────────────────────────────────────────────── @@ -121,6 +122,8 @@ export class ApiError extends Error { constructor( message: string, public readonly status: number, + /** Meteor.Error code (e.g. 'plan-required'), when the backend provided one. */ + public readonly code?: string, ) { super(message); this.name = 'ApiError'; @@ -849,6 +852,7 @@ async function wormholeCall( const data = (await res.json().catch(() => ({}))) as { result?: T; + error?: string; reason?: string; message?: string; }; @@ -865,7 +869,11 @@ async function wormholeCall( } if (!res.ok) { - throw new ApiError(data.reason || data.message || `Request failed (${res.status})`, res.status); + throw new ApiError( + data.reason || data.message || `Request failed (${res.status})`, + res.status, + data.error, + ); } return data.result as T; } @@ -1220,7 +1228,8 @@ export const clockApi = { start: (teamId: string) => wormholeCall('clock.start', { teamId }), /** Clock out of a team. */ - stop: (teamId: string) => wormholeCall('clock.stop', { teamId }), + stop: (teamId: string) => + wormholeCall('clock.stop', { teamId, localDate: toDateString(new Date()) }), /** Pause an active clock session (break start). */ pause: (teamId: string) => wormholeCall('clock.pause', { teamId }), diff --git a/src/lib/useClockToggle.test.ts b/src/lib/useClockToggle.test.ts index d40ff464..27c63b41 100644 --- a/src/lib/useClockToggle.test.ts +++ b/src/lib/useClockToggle.test.ts @@ -2,19 +2,32 @@ import { act, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useTeam } from './TeamContext'; -import { clockApi } from './api'; +import { ApiError, clockApi } from './api'; import { useClockToggle } from './useClockToggle'; // ─── Mocks ──────────────────────────────────────────────────────────────────── vi.mock('./TeamContext', () => ({ useTeam: vi.fn() })); -vi.mock('./api', () => ({ - clockApi: { - start: vi.fn(), - stop: vi.fn(), - }, -})); +vi.mock('./api', () => { + class ApiError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly code?: string, + ) { + super(message); + this.name = 'ApiError'; + } + } + return { + ApiError, + clockApi: { + start: vi.fn(), + stop: vi.fn(), + }, + }; +}); const mockUseTeam = vi.mocked(useTeam); const mockStart = vi.mocked(clockApi.start); @@ -174,6 +187,25 @@ describe('useClockToggle', () => { expect(result.current.clockOutLoading).toBe(false); }); + it('exposes plan-required errors inline instead of alerting', async () => { + setupTeam({ activeClockEvent: { id: 'evt1', teamId: 'team1' } }); + mockStop.mockRejectedValueOnce( + new ApiError("Add a wrap-up to today's post first", 500, 'plan-required'), + ); + + const { result } = renderHook(() => useClockToggle()); + + await act(() => result.current.clockOut()); + + expect(window.alert).not.toHaveBeenCalled(); + expect(result.current.clockOutBlockedReason).toBe("Add a wrap-up to today's post first"); + + // A successful clock-out clears the blocked reason. + mockStop.mockResolvedValueOnce({} as any); + await act(() => result.current.clockOut()); + expect(result.current.clockOutBlockedReason).toBeNull(); + }); + it('sets clockOutLoading=true during the call and false after', async () => { setupTeam({ activeClockEvent: { id: 'evt1', teamId: 'team1' } }); let resolveStop!: (value?: any) => void; diff --git a/src/lib/useClockToggle.ts b/src/lib/useClockToggle.ts index 0d922c12..6c4ea0b8 100644 --- a/src/lib/useClockToggle.ts +++ b/src/lib/useClockToggle.ts @@ -7,7 +7,7 @@ */ import { useCallback, useState } from 'react'; -import { clockApi } from './api'; +import { ApiError, clockApi } from './api'; import { useTeam } from './TeamContext'; export function useClockToggle() { @@ -16,6 +16,9 @@ export function useClockToggle() { const [clockInLoading, setClockInLoading] = useState(false); const [clockOutLoading, setClockOutLoading] = useState(false); const [clockPauseLoading, setClockPauseLoading] = useState(false); + // Set when clock-out is refused by the plan-first gate ('plan-required'); + // pages render it inline with a link to Huddle instead of an alert. + const [clockOutBlockedReason, setClockOutBlockedReason] = useState(null); const isClockedIn = !!activeClockEvent; @@ -32,16 +35,25 @@ export function useClockToggle() { const clockOut = useCallback(async () => { const teamId = activeClockEvent?.teamId ?? selectedTeamId; - if (!teamId) return; + if (!teamId) return false; setClockOutLoading(true); + setClockOutBlockedReason(null); try { await clockApi.stop(teamId); await refetchClock(); // Notify all timer-displaying pages to refetch immediately window.dispatchEvent(new CustomEvent('work:refetch')); window.dispatchEvent(new CustomEvent('tickets:refetch')); + return true; } catch (err) { - window.alert(err instanceof Error ? err.message : 'Failed to clock out. Please try again.'); + if (err instanceof ApiError && err.code === 'plan-required') { + setClockOutBlockedReason(err.message); + } else { + window.alert( + err instanceof Error ? err.message : 'Failed to clock out. Please try again.', + ); + } + return false; } finally { setClockOutLoading(false); } @@ -92,5 +104,6 @@ export function useClockToggle() { clockInLoading, clockOutLoading, clockPauseLoading, + clockOutBlockedReason, }; } diff --git a/src/ui/BottomNav.tsx b/src/ui/BottomNav.tsx index 4caed27a..6d1cf81a 100644 --- a/src/ui/BottomNav.tsx +++ b/src/ui/BottomNav.tsx @@ -45,7 +45,9 @@ export const BottomNav: React.FC = () => { const handleClockToggle = useCallback(async () => { try { if (isClockedIn) { - await clockOut(); + const ok = await clockOut(); + // Blocked (e.g. plan-required gate) — the clock page explains why. + if (!ok) navigate('/app/clock'); } else { await clockIn(); } From 7c8937f40e6a2ffca78a696e8bd7077bdc610f9c Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 22 Jul 2026 20:48:09 -0400 Subject: [PATCH 04/35] docs(plan): record in-browser smoke test of Milestones 1-3 --- clock-post-simple-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clock-post-simple-plan.md b/clock-post-simple-plan.md index 482efad2..520b6f69 100644 --- a/clock-post-simple-plan.md +++ b/clock-post-simple-plan.md @@ -82,7 +82,7 @@ Milestones 1–3 (setting, data model, gates) have no dependency on this and can - [x] Backend tests: gate off → clock in/out unchanged; gate on → in blocked without today's post, out blocked without wrap-up, both pass once satisfied. (`meteor-backend/tests/plan-gate.test.ts`) - [x] `npm run lint && npm run typecheck` clean; unit tests + clock/teams integration tests pass. (`test:all` — full Playwright e2e — still pending for Milestones 4–5.) -- [ ] Manual: publish plan (RichEditor) → clock in → wrap-up edit via clock-out prompt → clock out; confirm no duplicate post in the SuperChat feed and legacy plain-text posts still render. +- [ ] Manual: publish plan (RichEditor) → clock in → wrap-up edit via clock-out prompt → clock out; confirm no duplicate post in the SuperChat feed and legacy plain-text posts still render. (M1–3 slice smoke-tested in-browser 2026-07-22: toggle persists, Clock In blocked until today's post exists, Clock Out blocked until wrap-up, gate-off restores old behavior; RichEditor/SuperChat parts pending M4–5.) ## Milestone 7 (stretch) — Live collaborative sync (Yjs) From 2713ff2bc54641dc6283a9b8b4a55fd3b18146a3 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 22 Jul 2026 21:12:36 -0400 Subject: [PATCH 05/35] feat(clock): satisfy plan gates inline with the shared Huddle composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clock-page gate now embeds the same HuddleComposer used on the Huddle tab (PlanComposer wrapper): plan mode creates today's post, wrap-up mode appends a wrap-up and stamps wrapUpAt — no navigation needed. Gates now target the active session's team, not the selected team, so clock-out feedback shows even after switching teams. Extracted shared avatar helpers and toPostAttachment to remove duplication. --- src/features/clock/ClockPage.tsx | 56 ++++++++++-------- src/features/clock/PlanComposer.tsx | 92 +++++++++++++++++++++++++++++ src/features/huddle/api.ts | 27 +++++++++ src/features/huddle/avatar.ts | 16 +++++ src/pages/Huddle.tsx | 45 +------------- 5 files changed, 169 insertions(+), 67 deletions(-) create mode 100644 src/features/clock/PlanComposer.tsx create mode 100644 src/features/huddle/avatar.ts diff --git a/src/features/clock/ClockPage.tsx b/src/features/clock/ClockPage.tsx index 72c29e65..57cc7ad2 100644 --- a/src/features/clock/ClockPage.tsx +++ b/src/features/clock/ClockPage.tsx @@ -11,12 +11,14 @@ import { formatTimer, getActiveClockSeconds } from '../../lib/timeUtils'; import { AppPage } from '../../ui/AppPage'; import { useClockToggle } from '../../lib/useClockToggle'; import { useDailyPost } from '../../lib/useDailyPost'; +import { PlanComposer } from './PlanComposer'; import { useRouter } from '../../ui/router'; // ─── ClockPage ──────────────────────────────────────────────────────────────── export const ClockPage: React.FC = () => { - const { selectedTeamId, selectedTeam, activeClockEvent, currentTime, teamsReady } = useTeam(); + const { teams, selectedTeamId, selectedTeam, activeClockEvent, currentTime, teamsReady } = + useTeam(); const { navigate } = useRouter(); const { @@ -31,16 +33,20 @@ export const ClockPage: React.FC = () => { } = useClockToggle(); // ── Plan-first gates (team setting, default off) ── - const { todayPost } = useDailyPost(selectedTeamId); - const requirePlan = !!selectedTeam?.settings?.requirePlanForClock; + // Clock In targets the selected team; Clock Out targets the team of the + // active session (which may differ if the user switched teams after + // clocking in — see useClockToggle). Gate against whichever applies. + const clockOutTeamId = activeClockEvent?.teamId ?? selectedTeamId; + const gateTeamId = activeClockEvent ? clockOutTeamId : selectedTeamId; + const gateTeam = activeClockEvent + ? (teams.find((t) => t.id === clockOutTeamId) ?? null) + : selectedTeam; + const { todayPost } = useDailyPost(gateTeamId); + const requirePlan = !!gateTeam?.settings?.requirePlanForClock; // Clock In requires today's post to exist. - const planMissing = requirePlan && !todayPost; - // Clock Out requires today's post to have a wrap-up — only meaningful when - // the active session is on the currently selected team. - const wrapUpMissing = - requirePlan && - activeClockEvent?.teamId === selectedTeamId && - (!todayPost || !todayPost.wrapUpAt); + const planMissing = !activeClockEvent && requirePlan && !todayPost; + // Clock Out requires today's post (in the active session's team) to have a wrap-up. + const wrapUpMissing = !!activeClockEvent && requirePlan && (!todayPost || !todayPost.wrapUpAt); // Session duration const sessionSeconds = getActiveClockSeconds(activeClockEvent, currentTime); @@ -141,21 +147,21 @@ export const ClockPage: React.FC = () => { )} - {/* Plan-first gate messages — one-liners linking to Huddle */} - {(planMissing && !activeClockEvent) || wrapUpMissing || clockOutBlockedReason ? ( -
- - {activeClockEvent - ? (clockOutBlockedReason ?? 'Add a wrap-up to today’s post before clocking out.') - : 'Write today’s plan first.'}{' '} - - + {/* Plan-first gate — inline composer so the gate can be satisfied right here */} + {gateTeamId && (planMissing || wrapUpMissing) ? ( +
+ navigate('/app/huddle')} + /> + {clockOutBlockedReason && ( + + {clockOutBlockedReason} + + )}
) : null} diff --git a/src/features/clock/PlanComposer.tsx b/src/features/clock/PlanComposer.tsx new file mode 100644 index 00000000..544db95a --- /dev/null +++ b/src/features/clock/PlanComposer.tsx @@ -0,0 +1,92 @@ +/** + * PlanComposer — the Huddle composer embedded in the clock-page plan-first + * gate, so users can satisfy the gate without navigating to the Huddle tab. + * Plan mode creates today's post; wrap-up mode appends a wrap-up to it and + * stamps wrapUpAt. + */ +import { Text } from '@mieweb/ui'; +import React from 'react'; + +import { huddleApi, type HuddlePost } from '../../lib/api'; +import { getDdpClient } from '../../lib/ddp'; +import { toDateString } from '../../lib/timeUtils'; +import { useSession } from '../../lib/useSession'; +import { HuddleComposer } from '../huddle/HuddleComposer'; +import { toPostAttachment } from '../huddle/api'; +import { getUserColor, getUserInitials } from '../huddle/avatar'; +import type { ComposerContent } from '../huddle/types'; + +interface PlanComposerProps { + teamId: string; + /** Today's post — present in wrap-up mode, null in plan mode. */ + todayPost: HuddlePost | null; + mode: 'plan' | 'wrapup'; + /** Shown when the gated team differs from the selected team. */ + teamName?: string; + /** Secondary link to the full Huddle feed. */ + onGoToHuddle: () => void; +} + +export const PlanComposer: React.FC = ({ + teamId, + todayPost, + mode, + teamName, + onGoToHuddle, +}) => { + const { user } = useSession(); + const isPlan = mode === 'plan'; + + async function handlePost(content: ComposerContent) { + try { + if (isPlan) { + await getDdpClient().call('huddle.createPost', { + teamId, + content: { text: content.text, mentions: content.mentions.map((m) => m.userId) }, + ticketId: content.ticketId, + attachments: content.attachments.map(toPostAttachment), + postDate: toDateString(new Date()), + }); + } else if (todayPost) { + await huddleApi.updatePost( + todayPost.id, + { + text: `${todayPost.content.text}\n\n**Wrap-up:** ${content.text}`, + mentions: [ + ...new Set([...todayPost.content.mentions, ...content.mentions.map((m) => m.userId)]), + ], + }, + { wrapUp: true }, + ); + } + // useDailyPost listens for this and refetches, flipping the clock gates. + window.dispatchEvent(new CustomEvent('huddle:refetch')); + } catch (error) { + console.error('[PlanComposer] Failed to post:', error); + alert('Failed to post. Please try again.'); + } + } + + return ( +
+ + {isPlan + ? `Write today’s plan${teamName ? ` for “${teamName}”` : ''} before clocking in.` + : `Add a wrap-up to today’s post${teamName ? ` in “${teamName}”` : ''} before clocking out.`}{' '} + + + +
+ ); +}; diff --git a/src/features/huddle/api.ts b/src/features/huddle/api.ts index d4f00367..f4478d64 100644 --- a/src/features/huddle/api.ts +++ b/src/features/huddle/api.ts @@ -1,8 +1,35 @@ // Huddle feature API helpers import { teamApi, ticketApi, mediaApi, videoApi, METEOR_BASE_URL } from '@lib/api'; +import type { HuddlePost } from '@lib/api'; import * as tus from 'tus-js-client'; import type { TeamMember, MediaItem } from './types'; +export type PostAttachment = HuddlePost['attachments'][number]; + +/** + * Convert a composer MediaItem into the attachment shape stored on a post. + */ +export function toPostAttachment(media: MediaItem): PostAttachment { + let type: PostAttachment['type']; + if (media.type === 'image') { + type = 'image'; + } else if (media.type === 'video') { + type = 'video'; + } else if (media.type === 'document') { + type = 'file'; + } else { + // Fallback based on mimeType + type = media.mimeType?.startsWith('image/') ? 'image' : 'file'; + } + + return { + mediaId: media.id, + type, + url: media.url, + filename: media.filename, + }; +} + /** * Fetch team members for mention autocomplete */ diff --git a/src/features/huddle/avatar.ts b/src/features/huddle/avatar.ts new file mode 100644 index 00000000..ee22b3b8 --- /dev/null +++ b/src/features/huddle/avatar.ts @@ -0,0 +1,16 @@ +// Shared avatar helpers for huddle surfaces (feed + clock-page composer). +import type { AvatarColor } from './types'; + +export function getUserInitials(name: string): string { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); +} + +export function getUserColor(userId: string): AvatarColor { + const colors: AvatarColor[] = ['indigo', 'teal', 'coral', 'amber', 'pink', 'green']; + const hash = userId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + return colors[hash % colors.length]; +} diff --git a/src/pages/Huddle.tsx b/src/pages/Huddle.tsx index 6d643a78..020c0e31 100644 --- a/src/pages/Huddle.tsx +++ b/src/pages/Huddle.tsx @@ -4,6 +4,8 @@ import { Button, Input } from '@mieweb/ui'; import { useState, useEffect } from 'react'; import { HuddleComposer } from '../features/huddle/HuddleComposer'; import { PostCard } from '../features/huddle/PostCard'; +import { toPostAttachment } from '../features/huddle/api'; +import { getUserColor, getUserInitials } from '../features/huddle/avatar'; import type { ComposerContent } from '../features/huddle/types'; import { AppPage } from '../ui/AppPage'; import { useRouter } from '../ui/router'; @@ -13,27 +15,6 @@ import { teamApi, type HuddlePost, type Team } from '@lib/api'; import { getDdpClient } from '@lib/ddp'; import { toDateString } from '@lib/timeUtils'; -function getUserInitials(name: string): string { - const parts = name.trim().split(/\s+/); - if (parts.length >= 2) { - return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); - } - return name.substring(0, 2).toUpperCase(); -} - -function getUserColor(userId: string): 'indigo' | 'teal' | 'coral' | 'amber' | 'pink' | 'green' { - const colors: Array<'indigo' | 'teal' | 'coral' | 'amber' | 'pink' | 'green'> = [ - 'indigo', - 'teal', - 'coral', - 'amber', - 'pink', - 'green', - ]; - const hash = userId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); - return colors[hash % colors.length]; -} - export default function Huddle() { const { navigate } = useRouter(); const [posts, setPosts] = useState([]); @@ -116,27 +97,7 @@ export default function Huddle() { } // Prepare attachments for API - const attachments = content.attachments.map((att) => { - // Map MediaItem type to attachment type - let type: 'image' | 'video' | 'file'; - if (att.type === 'image') { - type = 'image'; - } else if (att.type === 'video') { - type = 'video'; - } else if (att.type === 'document') { - type = 'file'; - } else { - // Fallback based on mimeType - type = att.mimeType?.startsWith('image/') ? 'image' : 'file'; - } - - return { - mediaId: att.id, - type, - url: att.url, - filename: att.filename, - }; - }); + const attachments = content.attachments.map(toPostAttachment); // Extract user IDs from mentions const mentionUserIds = (content.mentions || []).map((m) => m.userId); From b6dfbb3ae327f3a4a449dd819c80691d3574a964 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 22 Jul 2026 23:23:56 -0400 Subject: [PATCH 06/35] feat(clock): realtime plan gates via DDP; bottom-nav FAB opens clock page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useDailyPost now subscribes to the huddlePosts.byTeam publication, so the clock-in/clock-out gates flip in realtime on post create/edit/ delete — no reload and no manual refetch events needed - the mobile bottom-nav clock FAB navigates to the clock page instead of toggling directly, so the gates and inline composer are always visible - dropped the now-unneeded huddle:refetch window events --- src/features/clock/PlanComposer.tsx | 4 +- src/lib/useDailyPost.ts | 62 ++++++++++++++++------------- src/pages/Huddle.tsx | 5 +-- src/ui/BottomNav.tsx | 25 +++--------- 4 files changed, 44 insertions(+), 52 deletions(-) diff --git a/src/features/clock/PlanComposer.tsx b/src/features/clock/PlanComposer.tsx index 544db95a..f382cd67 100644 --- a/src/features/clock/PlanComposer.tsx +++ b/src/features/clock/PlanComposer.tsx @@ -59,8 +59,8 @@ export const PlanComposer: React.FC = ({ { wrapUp: true }, ); } - // useDailyPost listens for this and refetches, flipping the clock gates. - window.dispatchEvent(new CustomEvent('huddle:refetch')); + // The huddlePosts.byTeam publication delivers the change; useDailyPost + // flips the clock gates in realtime. } catch (error) { console.error('[PlanComposer] Failed to post:', error); alert('Failed to post. Please try again.'); diff --git a/src/lib/useDailyPost.ts b/src/lib/useDailyPost.ts index 68a9d757..fd6c2f02 100644 --- a/src/lib/useDailyPost.ts +++ b/src/lib/useDailyPost.ts @@ -1,44 +1,52 @@ /** - * useDailyPost — the caller's own Huddle post for today in a team. + * useDailyPost — the caller's own Huddle post for today in a team, kept live + * via the `huddlePosts.byTeam` DDP publication (oplog/change-stream backed). * * Backs the plan-first clock flow gates: Clock In requires today's post to - * exist, Clock Out requires it to have a wrap-up. Listens for the - * `huddle:refetch` window event so gates flip live after posting. + * exist, Clock Out requires it to have a wrap-up. Creates, edits, and deletes + * flip the gates in realtime — no reload or manual refetch needed. */ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; -import { huddleApi, type HuddlePost } from './api'; +import type { HuddlePost } from './api'; +import { getDdpClient } from './ddp'; import { toDateString } from './timeUtils'; +import { useSession } from './useSession'; export function useDailyPost(teamId: string | null) { + const { user } = useSession(); + const userId = user?.id ?? null; const [todayPost, setTodayPost] = useState(null); - const [loading, setLoading] = useState(false); - const refetch = useCallback(async () => { - if (!teamId) { + useEffect(() => { + if (!teamId || !userId) { setTodayPost(null); return; } - setLoading(true); - try { - const post = await huddleApi.getMyPostForDate(teamId, toDateString(new Date())); - setTodayPost(post); - } catch { - setTodayPost(null); - } finally { - setLoading(false); - } - }, [teamId]); - useEffect(() => { - void refetch(); - }, [refetch]); + const ddp = getDdpClient(); + const today = toDateString(new Date()); - useEffect(() => { - const handler = () => void refetch(); - window.addEventListener('huddle:refetch', handler); - return () => window.removeEventListener('huddle:refetch', handler); - }, [refetch]); + const sync = () => { + const mine = ddp + .docs('huddlePosts') + .filter((p) => p.teamId === teamId && p.userId === userId && p.postDate === today) + .map((p) => ({ ...p, id: (p.id ?? p._id) as string }) as unknown as HuddlePost) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + setTodayPost(mine[0] ?? null); + }; + + const unsubscribe = ddp.subscribe('huddlePosts.byTeam', [teamId], sync); + const offChange = ddp.onCollectionChange('huddlePosts', sync); + // Sync immediately in case the collection is already cached. + sync(); + + return () => { + offChange(); + unsubscribe(); + setTodayPost(null); + }; + }, [teamId, userId]); - return { todayPost, loading, refetch }; + return { todayPost }; } diff --git a/src/pages/Huddle.tsx b/src/pages/Huddle.tsx index 020c0e31..120a0f8d 100644 --- a/src/pages/Huddle.tsx +++ b/src/pages/Huddle.tsx @@ -111,9 +111,8 @@ export default function Huddle() { postDate: toDateString(new Date()), }); - // DDP subscription will automatically reflect the new post; the clock-in - // gate (useDailyPost) listens for this event to flip live. - window.dispatchEvent(new CustomEvent('huddle:refetch')); + // The DDP subscription reflects the new post automatically (feed and + // clock gates alike). } catch (error) { console.error('[Huddle] Error in addPost:', error); alert('Failed to create post. Please try again.'); diff --git a/src/ui/BottomNav.tsx b/src/ui/BottomNav.tsx index 6d1cf81a..c26f4520 100644 --- a/src/ui/BottomNav.tsx +++ b/src/ui/BottomNav.tsx @@ -5,6 +5,8 @@ * Five tabs: Dashboard, Tickets, Clock In/Out (center FAB), Teams, Settings. * Active tab indicator is an animated bubble that glides between positions. * FAB uses CSS brand tokens so it follows brand/theme changes automatically. + * The FAB navigates to the clock page (rather than toggling directly) so the + * plan-first gates and their inline composer are always visible. */ import { faCircleStop, @@ -16,7 +18,7 @@ import { } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { motion, MotionConfig } from 'motion/react'; -import React, { useCallback } from 'react'; +import React from 'react'; import { useClockToggle } from '../lib/useClockToggle'; import { useRouter } from './router'; @@ -38,23 +40,7 @@ const TABS: NavTab[] = [ export const BottomNav: React.FC = () => { const { pathname, navigate } = useRouter(); - const { isClockedIn, clockIn, clockOut, clockInLoading, clockOutLoading } = useClockToggle(); - - const clockLoading = clockInLoading || clockOutLoading; - - const handleClockToggle = useCallback(async () => { - try { - if (isClockedIn) { - const ok = await clockOut(); - // Blocked (e.g. plan-required gate) — the clock page explains why. - if (!ok) navigate('/app/clock'); - } else { - await clockIn(); - } - } catch { - navigate('/app/clock'); - } - }, [isClockedIn, clockIn, clockOut, navigate]); + const { isClockedIn } = useClockToggle(); return ( @@ -71,8 +57,7 @@ export const BottomNav: React.FC = () => { + + ); + } else if (requirePlan) { + headline = 'Plan posted — you’re set to clock in.'; + } else { + headline = 'You’re set to clock in.'; + } + } else { + eyebrow = isPaused ? 'On break' : 'On shift'; + if (composerMode === 'wrapup') { + headline = `Add a wrap-up to today’s post${teamSuffix} before clocking out.`; + subline = 'Posting ends your shift.'; + } else { + headline = `Clocked in — ${formatTimer(sessionSeconds)} this shift.`; + } + } + + const { time, meridiem, date } = clockParts(currentTime); if (!teamsReady) { return ( @@ -68,129 +169,161 @@ export const ClockPage: React.FC = () => { } return ( - - {/* ── Clock Button ── */} - - - {/* Clock button — full width on mobile, 1/4 on sm+ */} -
- {activeClockEvent ? ( + +
+ {/* ── Banner — the gate, in one sentence ── */} +
+
+ + {eyebrow} +
+ + {headline} + + {subline &&

{subline}

} +
+ + {/* ── Composer — one box, one combined action ── */} + {composerMode && ( +
+