From bcd747721df4dc65092f285c0358b5df42dd9835 Mon Sep 17 00:00:00 2001 From: Iqbal Date: Thu, 29 Oct 2026 12:18:28 +0700 Subject: [PATCH] Show who created each schedule and start with your own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Org admins see every Express schedule in the organization, so a shared organization mixed everyone's schedules together. The Schedule page now shows "Created by …" on each one and, when others' schedules are listed, switches between Mine and Everyone in the organization. Co-Authored-By: Claude Opus 5.5 --- src/components/Icon.tsx | 1 + src/express/schedules.test.ts | 21 ++++++++++++++-- src/express/schedules.ts | 18 ++++++++++++++ src/i18n.ts | 14 +++++++++++ src/pages/Schedule.tsx | 47 +++++++++++++++++++++++++++++++---- src/styles/app.css | 2 ++ 6 files changed, 96 insertions(+), 7 deletions(-) diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index c114aab..dea3fe1 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -23,6 +23,7 @@ const paths: Record = { sidebar: '', 'chevron-updown': '', logout:'', + user: '', plus: '', trash: '', video: '', diff --git a/src/express/schedules.test.ts b/src/express/schedules.test.ts index 033bea8..2df68de 100644 --- a/src/express/schedules.test.ts +++ b/src/express/schedules.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { ContentRule, ResearchRule } from './parse' -import { budgetFor, canSchedule, configFor, needsTwoSources, productionError, ruleOf, VIDEO, type ExpressAgent } from './schedules' +import { budgetFor, canSchedule, configFor, isMine, needsTwoSources, productionError, ruleOf, VIDEO, type ExpressAgent } from './schedules' const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'week', topic = 'onboarding', language: ContentRule['language'] = 'English'): ContentRule => ({ type: 'content', kind, count, per, topic, language }) @@ -9,7 +9,7 @@ const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'w function agentFrom(config: ReturnType): ExpressAgent { const c = config as Record return { - id: 1, status: 'active', next_run_at: null, created_at: '2026-10-01T00:00:00Z', auto_approve_sources: false, + id: 1, status: 'active', next_run_at: null, created_at: '2026-10-01T00:00:00Z', created_by_user_id: 7, auto_approve_sources: false, name: c.name as string, goal: c.goal as string, instructions: c.instructions as string, enabled_tools: c.enabled_tools as string[], material_outputs: (c.material_outputs as ExpressAgent['material_outputs']) ?? [], @@ -122,3 +122,20 @@ describe('production sources', () => { expect(productionError('Something else')).toBe('Something else') }) }) + +describe('isMine', () => { + const owners = { me: 7, names: new Map() } + const schedule = (by: number | null) => { + const agent = { ...agentFrom(configFor(rule('summary'))), created_by_user_id: by } + return { agent, rule: ruleOf(agent), active: true } + } + + it('keeps only the viewer’s own schedules', () => { + expect(isMine(schedule(7), owners)).toBe(true) + expect(isMine(schedule(8), owners)).toBe(false) + }) + + it('counts a schedule whose creator was removed as someone else’s', () => { + expect(isMine(schedule(null), owners)).toBe(false) + }) +}) diff --git a/src/express/schedules.ts b/src/express/schedules.ts index d756cf2..07c26e0 100644 --- a/src/express/schedules.ts +++ b/src/express/schedules.ts @@ -41,6 +41,7 @@ export interface ExpressAgent extends Agent { run_budget: number monthly_budget: number created_at: string + created_by_user_id: number | null } export interface Schedule { @@ -107,6 +108,23 @@ export async function expressAgents({ archived = false } = {}): Promise expressAgents() +// An organization admin sees every schedule in the organization, a member only their own (and any +// shared with them), so the Schedule page says who made each one and starts with the viewer's own. +export interface Owners { me: number; names: Map } + +/** The viewer's engine user id and the organization roster's names. The roster is admin-only; + * a member gets no names, which is fine: a member's list holds their own schedules. */ +export async function scheduleOwners(): Promise { + const [me, roster] = await Promise.all([ + request<{ id: number }>('/auth/me'), + request<{ members: { user_id: number; name: string | null; email: string | null }[] }>('/organizations/current/dashboard/members') + .catch(() => ({ members: [] })), + ]) + return { me: me.id, names: new Map(roster.members.map(m => [m.user_id, m.name || m.email || `#${m.user_id}`])) } +} + +export const isMine = (s: Schedule, owners: Owners) => s.agent.created_by_user_id === owners.me + /** Schedules made of one engine run per slot (research, video), rather than weekly production plans. */ export const isRunSchedule = (rule: Rule) => rule.type === 'research' || rule.kind === 'video' diff --git a/src/i18n.ts b/src/i18n.ts index 31281ad..cd99e39 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -206,6 +206,13 @@ const en = { 'sch.loading': 'Loading schedules…', 'sch.loadFailed': 'Could not load schedules', 'sch.empty': 'No schedules yet', + 'sch.mine': 'Mine', + 'sch.all': 'Everyone in the organization', + 'sch.emptyMine': 'You have no schedules yet', + 'sch.emptyMineCopy': 'Other people in this organization have schedules; see them under “Everyone in the organization”.', + 'sch.byYou': 'Created by you', + 'sch.by': 'Created by {name}', + 'sch.byUnknown': 'Created by someone else', 'sch.emptyCopy': 'Give an instruction like “2 summaries per day about leadership” to start.', 'sch.nextResearch': 'Next research: {when}', 'sch.paused': 'Paused', @@ -697,6 +704,13 @@ const id: Record = { 'sch.loading': 'Memuat jadwal…', 'sch.loadFailed': 'Jadwal tidak dapat dimuat', 'sch.empty': 'Belum ada jadwal', + 'sch.mine': 'Punya saya', + 'sch.all': 'Semua di organisasi', + 'sch.emptyMine': 'Anda belum punya jadwal', + 'sch.emptyMineCopy': 'Orang lain di organisasi ini punya jadwal; lihat di “Semua di organisasi”.', + 'sch.byYou': 'Dibuat oleh Anda', + 'sch.by': 'Dibuat oleh {name}', + 'sch.byUnknown': 'Dibuat oleh orang lain', 'sch.emptyCopy': 'Beri perintah seperti “2 ringkasan per hari soal leadership” untuk mulai.', 'sch.nextResearch': 'Riset berikutnya: {when}', 'sch.paused': 'Dijeda', diff --git a/src/pages/Schedule.tsx b/src/pages/Schedule.tsx index c210f04..66b8446 100644 --- a/src/pages/Schedule.tsx +++ b/src/pages/Schedule.tsx @@ -7,8 +7,8 @@ import { toast } from '../components/Toast' import { describeRule } from '../express/parse' import { locale, t, type Key } from '../i18n' import { - archiveSchedule, confirmProduction, isRunSchedule, knowledgeReadiness, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule, - planProgress, productionError, runNow as startRun, updateTopic, type ContentPlan, type RunSummary, type Schedule, + archiveSchedule, confirmProduction, isMine, isRunSchedule, knowledgeReadiness, listRuns, listSchedules, scheduleOwners, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule, + planProgress, productionError, runNow as startRun, updateTopic, type ContentPlan, type Owners, type RunSummary, type Schedule, } from '../express/schedules' const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL @@ -33,25 +33,61 @@ const runError = (code: string) => (RUN_ERRORS.has(code) ? t(`runErr.${code}` as const issueReason = (reason: string) => (ISSUE_REASONS.has(reason) ? t(`issue.${reason}` as Key) : reason) const isNotReady = (r: RunSummary) => r.safe_error_code === 'knowledge_not_ready' || r.has_readiness_issues +// Which schedules the page starts with; remembered per device. +type Scope = 'mine' | 'all' +const SCOPE_KEY = 'express.scheduleScope' +const savedScope = (): Scope => { + try { return localStorage.getItem(SCOPE_KEY) === 'all' ? 'all' : 'mine' } catch { return 'mine' } +} + export function SchedulePage() { const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 }) + const owners = useQuery({ queryKey: ['scheduleOwners'], queryFn: scheduleOwners, staleTime: 5 * 60_000 }) + const [scope, setScope] = useState(savedScope) + const choose = (next: Scope) => { + setScope(next) + try { localStorage.setItem(SCOPE_KEY, next) } catch { /* keep it for this visit only */ } + } + + const all = schedules.data + const mine = all && owners.data ? all.filter(s => isMine(s, owners.data)) : undefined + // Members only ever see their own, so the switch appears only when someone else's schedules are listed. + const mixed = !!all && !!mine && mine.length < all.length + const shown = mixed && scope === 'mine' ? mine : all return ( <> {t('sch.newInstruction')}} /> + {mixed && ( +
+ + +
+ )}

{t('sch.cardTitle')}

{schedules.isPending && } {schedules.isError && {schedules.error.message}} - {schedules.data?.length === 0 && {t('sch.emptyCopy')}} - {!!schedules.data?.length &&
{schedules.data.map(s => )}
} + {all?.length === 0 && {t('sch.emptyCopy')}} + {mixed && shown?.length === 0 && {t('sch.emptyMineCopy')}} + {!!shown?.length &&
{shown.map(s => )}
}
) } -function ScheduleRow({ schedule }: { schedule: Schedule }) { +function createdBy(schedule: Schedule, owners: Owners) { + if (isMine(schedule, owners)) return t('sch.byYou') + const name = schedule.agent.created_by_user_id != null ? owners.names.get(schedule.agent.created_by_user_id) : undefined + return name ? t('sch.by', { name }) : t('sch.byUnknown') +} + +function ScheduleRow({ schedule, owners }: { schedule: Schedule; owners?: Owners }) { const { agent, rule, active } = schedule const queryClient = useQueryClient() const [editing, setEditing] = useState(false) @@ -84,6 +120,7 @@ function ScheduleRow({ schedule }: { schedule: Schedule }) {
{describeRule(rule)} + {owners &&
{createdBy(schedule, owners)}
}
{production ? : t(video ? 'sch.nextVideo' : 'sch.nextResearch', { when: active ? fmt(agent.next_run_at) : t('sch.paused') })}
diff --git a/src/styles/app.css b/src/styles/app.css index ddcd042..fc41d0a 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -40,6 +40,8 @@ textarea{resize:vertical;line-height:1.6} .rule-outcomes .error{margin:0;font-size:11px} .schedule-row{border-bottom:1px solid var(--line);padding-bottom:14px} .schedule-row:last-child{border-bottom:0} +.schedule-owner small{display:inline-flex;align-items:center;gap:5px;color:var(--muted)} +.schedule-owner svg{width:12px;height:12px} .schedule-row .result-row{border-bottom:0;padding-bottom:10px} .schedule-row .error{display:block;margin-top:6px;font-size:11px} .schedule-actions{display:flex;gap:7px;flex-wrap:wrap;padding-left:61px}