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}