dsh-brief-sidebar 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +188 -178
- package/assets/.gitkeep +0 -0
- package/assets/brief.png +0 -0
- package/cordis.patch.yml +18 -18
- package/dsh.plugin.json +16 -16
- package/lib/client.js.map +1 -1
- package/lib/index.mjs.map +1 -1
- package/package.json +106 -94
- package/screenshots.json +5 -0
- package/src/client/BriefIcon.tsx +56 -56
- package/src/client/SummaryTab.tsx +73 -73
- package/src/client/TodoBoardTab.tsx +188 -188
- package/src/client/brief/board.ts +104 -104
- package/src/client/brief/dock-shadow.tsx +74 -74
- package/src/client/brief/use-todos.ts +27 -27
- package/src/client/file-open.ts +108 -108
- package/src/client/index.tsx +144 -144
- package/src/client/locales.ts +78 -78
- package/src/client/summary/DeliverablesSection.tsx +192 -192
- package/src/client/summary/deliverables.ts +93 -93
- package/src/client/use-projection.ts +117 -117
- package/src/index.ts +32 -32
- package/src/projection/deliverables-fold.ts +206 -206
- package/src/projection/keys.ts +27 -27
- package/src/projection/register.ts +99 -99
|
@@ -1,188 +1,188 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The progress section: the `todos` projection drawn as one part of the
|
|
3
|
-
* summary tab.
|
|
4
|
-
*
|
|
5
|
-
* The official composer-band panel is shadowed by this plugin (see
|
|
6
|
-
* `brief/dock-shadow.tsx`), so the projection is read here and rendered as an
|
|
7
|
-
* ordinary React view — no Canvas, no cross-package dependency. The full-height
|
|
8
|
-
* shell and the scroll container live on the summary tab (`../SummaryTab`);
|
|
9
|
-
* this component renders only its own slice and is the one section whose
|
|
10
|
-
* absent state must stay VISIBLE: the official dock is hidden while this
|
|
11
|
-
* plugin is mounted, so "unavailable" is a statement the reader needs.
|
|
12
|
-
*
|
|
13
|
-
* Colours come from `--dsw-alias-*` tokens only (skin contract), and the status
|
|
14
|
-
* palette uses tokens that actually exist in the shipping theme:
|
|
15
|
-
* `state-success-primary` / `state-business-primary` / `label-tertiary`.
|
|
16
|
-
*/
|
|
17
|
-
import { createElement } from 'react'
|
|
18
|
-
import type { CSSProperties, ReactNode } from 'react'
|
|
19
|
-
import type { Context } from '@deepseek-ai/cordis'
|
|
20
|
-
import { countTodos, progressLine, statusKey } from './brief/board'
|
|
21
|
-
import type { TodoItem, TodoStatus } from './brief/board'
|
|
22
|
-
import { useTodos } from './brief/use-todos'
|
|
23
|
-
|
|
24
|
-
/** The session scope better-sidebar hands a tab body. */
|
|
25
|
-
export interface TodoScope {
|
|
26
|
-
readonly sessionId?: string
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface TodoSectionProps {
|
|
30
|
-
/** The DSH locale lookup, passed down from `apply`. */
|
|
31
|
-
t: (key: string) => string
|
|
32
|
-
/** The client root context (the tab body's only way to reach services). */
|
|
33
|
-
ctx?: Context
|
|
34
|
-
/** The session this tab is scoped to. */
|
|
35
|
-
scope?: TodoScope
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Status → token colour for the pill's text and border. */
|
|
39
|
-
const STATUS_COLOR: Record<TodoStatus, string> = {
|
|
40
|
-
completed: 'var(--dsw-alias-state-success-primary)',
|
|
41
|
-
in_progress: 'var(--dsw-alias-state-business-primary)',
|
|
42
|
-
pending: 'var(--dsw-alias-label-tertiary)',
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const SECTION_STYLE: CSSProperties = {
|
|
46
|
-
flexShrink: 0,
|
|
47
|
-
borderBottom: '1px solid var(--dsw-alias-border-secondary)',
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const HEADER_STYLE: CSSProperties = {
|
|
51
|
-
padding: '10px 12px 0',
|
|
52
|
-
color: 'var(--dsw-alias-label-secondary)',
|
|
53
|
-
fontSize: 12,
|
|
54
|
-
fontWeight: 600,
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const PROGRESS_STYLE: CSSProperties = {
|
|
58
|
-
padding: '6px 12px 10px',
|
|
59
|
-
color: 'var(--dsw-alias-label-secondary)',
|
|
60
|
-
fontSize: 12,
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const LIST_STYLE: CSSProperties = {
|
|
64
|
-
listStyle: 'none',
|
|
65
|
-
margin: 0,
|
|
66
|
-
padding: '6px 0',
|
|
67
|
-
display: 'flex',
|
|
68
|
-
flexDirection: 'column',
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const ROW_STYLE: CSSProperties = {
|
|
72
|
-
display: 'flex',
|
|
73
|
-
alignItems: 'flex-start',
|
|
74
|
-
gap: 8,
|
|
75
|
-
padding: '6px 12px',
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Pill style, coloured per status. */
|
|
79
|
-
function pillStyle(status: TodoStatus): CSSProperties {
|
|
80
|
-
return {
|
|
81
|
-
flexShrink: 0,
|
|
82
|
-
color: STATUS_COLOR[status],
|
|
83
|
-
border: `1px solid ${STATUS_COLOR[status]}`,
|
|
84
|
-
borderRadius: 999,
|
|
85
|
-
padding: '0 6px',
|
|
86
|
-
fontSize: 11,
|
|
87
|
-
lineHeight: '16px',
|
|
88
|
-
whiteSpace: 'nowrap',
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Task text: finished entries read as done, the working one as active. */
|
|
93
|
-
function contentStyle(status: TodoStatus): CSSProperties {
|
|
94
|
-
return {
|
|
95
|
-
minWidth: 0,
|
|
96
|
-
overflowWrap: 'anywhere',
|
|
97
|
-
color:
|
|
98
|
-
status === 'completed'
|
|
99
|
-
? 'var(--dsw-alias-label-tertiary)'
|
|
100
|
-
: status === 'in_progress'
|
|
101
|
-
? 'var(--dsw-alias-label-primary)'
|
|
102
|
-
: 'var(--dsw-alias-label-secondary)',
|
|
103
|
-
textDecoration: status === 'completed' ? 'line-through' : 'none',
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Centred message block used by every non-list state. */
|
|
108
|
-
export function Notice(props: { title: string; detail?: string }): ReactNode {
|
|
109
|
-
return (
|
|
110
|
-
<div
|
|
111
|
-
style={{
|
|
112
|
-
margin: '0 auto',
|
|
113
|
-
padding: 24,
|
|
114
|
-
maxWidth: 460,
|
|
115
|
-
textAlign: 'center',
|
|
116
|
-
color: 'var(--dsw-alias-label-tertiary)',
|
|
117
|
-
lineHeight: 1.7,
|
|
118
|
-
fontSize: 12,
|
|
119
|
-
}}
|
|
120
|
-
>
|
|
121
|
-
<div style={{ marginBottom: 6, color: 'var(--dsw-alias-label-secondary)' }}>{props.title}</div>
|
|
122
|
-
{props.detail === undefined ? null : <div>{props.detail}</div>}
|
|
123
|
-
</div>
|
|
124
|
-
)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** One task row. */
|
|
128
|
-
function TodoRow(props: { item: TodoItem; t: (key: string) => string }): ReactNode {
|
|
129
|
-
const { item, t } = props
|
|
130
|
-
return createElement(
|
|
131
|
-
'li',
|
|
132
|
-
{ style: ROW_STYLE },
|
|
133
|
-
createElement('span', { style: pillStyle(item.status) }, t(statusKey(item.status))),
|
|
134
|
-
createElement('span', { style: contentStyle(item.status) }, item.content),
|
|
135
|
-
)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* The progress section. The subscription is unconditional (a plain listener,
|
|
140
|
-
* not IO); the three-state contract of the projection is preserved verbatim.
|
|
141
|
-
* @param props - translation, client context, session scope.
|
|
142
|
-
*/
|
|
143
|
-
export function TodoSection(props: TodoSectionProps): ReactNode {
|
|
144
|
-
const { t, ctx, scope } = props
|
|
145
|
-
|
|
146
|
-
const todos = useTodos(ctx, scope?.sessionId)
|
|
147
|
-
|
|
148
|
-
if (todos === undefined) {
|
|
149
|
-
// `undefined` is the store's "capability absent": no session, or the host's
|
|
150
|
-
// todo unit is unmounted. Say so rather than showing an empty board.
|
|
151
|
-
return createElement(
|
|
152
|
-
'div',
|
|
153
|
-
{ style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'progress-section' },
|
|
154
|
-
createElement('div', { style: HEADER_STYLE }, t('section.progress')),
|
|
155
|
-
createElement(Notice, { title: t('board.unavailable'), detail: t('board.unavailableHint') }),
|
|
156
|
-
)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (todos.length === 0) {
|
|
160
|
-
return createElement(
|
|
161
|
-
'div',
|
|
162
|
-
{ style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'progress-section' },
|
|
163
|
-
createElement('div', { style: HEADER_STYLE }, t('section.progress')),
|
|
164
|
-
createElement(Notice, { title: t('board.empty'), detail: t('board.emptyHint') }),
|
|
165
|
-
)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const counts = countTodos(todos)
|
|
169
|
-
|
|
170
|
-
return createElement(
|
|
171
|
-
'div',
|
|
172
|
-
{ style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'progress-section' },
|
|
173
|
-
createElement('div', { style: HEADER_STYLE }, t('section.progress')),
|
|
174
|
-
createElement('div', { style: PROGRESS_STYLE }, progressLine(counts, t)),
|
|
175
|
-
createElement(
|
|
176
|
-
'ul',
|
|
177
|
-
{ style: LIST_STYLE },
|
|
178
|
-
todos.map((item, index) => createElement(TodoRow, { key: `${index}:${item.content}`, item, t })),
|
|
179
|
-
),
|
|
180
|
-
)
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Backwards-compatible alias for the pre-summary export name. The component
|
|
185
|
-
* no longer owns the full-height shell — that moved to `SummaryTab` — so the
|
|
186
|
-
* alias exists only to keep import sites honest during the transition.
|
|
187
|
-
*/
|
|
188
|
-
export const TodoBoardTab = TodoSection
|
|
1
|
+
/**
|
|
2
|
+
* The progress section: the `todos` projection drawn as one part of the
|
|
3
|
+
* summary tab.
|
|
4
|
+
*
|
|
5
|
+
* The official composer-band panel is shadowed by this plugin (see
|
|
6
|
+
* `brief/dock-shadow.tsx`), so the projection is read here and rendered as an
|
|
7
|
+
* ordinary React view — no Canvas, no cross-package dependency. The full-height
|
|
8
|
+
* shell and the scroll container live on the summary tab (`../SummaryTab`);
|
|
9
|
+
* this component renders only its own slice and is the one section whose
|
|
10
|
+
* absent state must stay VISIBLE: the official dock is hidden while this
|
|
11
|
+
* plugin is mounted, so "unavailable" is a statement the reader needs.
|
|
12
|
+
*
|
|
13
|
+
* Colours come from `--dsw-alias-*` tokens only (skin contract), and the status
|
|
14
|
+
* palette uses tokens that actually exist in the shipping theme:
|
|
15
|
+
* `state-success-primary` / `state-business-primary` / `label-tertiary`.
|
|
16
|
+
*/
|
|
17
|
+
import { createElement } from 'react'
|
|
18
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
19
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
20
|
+
import { countTodos, progressLine, statusKey } from './brief/board'
|
|
21
|
+
import type { TodoItem, TodoStatus } from './brief/board'
|
|
22
|
+
import { useTodos } from './brief/use-todos'
|
|
23
|
+
|
|
24
|
+
/** The session scope better-sidebar hands a tab body. */
|
|
25
|
+
export interface TodoScope {
|
|
26
|
+
readonly sessionId?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TodoSectionProps {
|
|
30
|
+
/** The DSH locale lookup, passed down from `apply`. */
|
|
31
|
+
t: (key: string) => string
|
|
32
|
+
/** The client root context (the tab body's only way to reach services). */
|
|
33
|
+
ctx?: Context
|
|
34
|
+
/** The session this tab is scoped to. */
|
|
35
|
+
scope?: TodoScope
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Status → token colour for the pill's text and border. */
|
|
39
|
+
const STATUS_COLOR: Record<TodoStatus, string> = {
|
|
40
|
+
completed: 'var(--dsw-alias-state-success-primary)',
|
|
41
|
+
in_progress: 'var(--dsw-alias-state-business-primary)',
|
|
42
|
+
pending: 'var(--dsw-alias-label-tertiary)',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SECTION_STYLE: CSSProperties = {
|
|
46
|
+
flexShrink: 0,
|
|
47
|
+
borderBottom: '1px solid var(--dsw-alias-border-secondary)',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const HEADER_STYLE: CSSProperties = {
|
|
51
|
+
padding: '10px 12px 0',
|
|
52
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
53
|
+
fontSize: 12,
|
|
54
|
+
fontWeight: 600,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const PROGRESS_STYLE: CSSProperties = {
|
|
58
|
+
padding: '6px 12px 10px',
|
|
59
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
60
|
+
fontSize: 12,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const LIST_STYLE: CSSProperties = {
|
|
64
|
+
listStyle: 'none',
|
|
65
|
+
margin: 0,
|
|
66
|
+
padding: '6px 0',
|
|
67
|
+
display: 'flex',
|
|
68
|
+
flexDirection: 'column',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const ROW_STYLE: CSSProperties = {
|
|
72
|
+
display: 'flex',
|
|
73
|
+
alignItems: 'flex-start',
|
|
74
|
+
gap: 8,
|
|
75
|
+
padding: '6px 12px',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Pill style, coloured per status. */
|
|
79
|
+
function pillStyle(status: TodoStatus): CSSProperties {
|
|
80
|
+
return {
|
|
81
|
+
flexShrink: 0,
|
|
82
|
+
color: STATUS_COLOR[status],
|
|
83
|
+
border: `1px solid ${STATUS_COLOR[status]}`,
|
|
84
|
+
borderRadius: 999,
|
|
85
|
+
padding: '0 6px',
|
|
86
|
+
fontSize: 11,
|
|
87
|
+
lineHeight: '16px',
|
|
88
|
+
whiteSpace: 'nowrap',
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Task text: finished entries read as done, the working one as active. */
|
|
93
|
+
function contentStyle(status: TodoStatus): CSSProperties {
|
|
94
|
+
return {
|
|
95
|
+
minWidth: 0,
|
|
96
|
+
overflowWrap: 'anywhere',
|
|
97
|
+
color:
|
|
98
|
+
status === 'completed'
|
|
99
|
+
? 'var(--dsw-alias-label-tertiary)'
|
|
100
|
+
: status === 'in_progress'
|
|
101
|
+
? 'var(--dsw-alias-label-primary)'
|
|
102
|
+
: 'var(--dsw-alias-label-secondary)',
|
|
103
|
+
textDecoration: status === 'completed' ? 'line-through' : 'none',
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Centred message block used by every non-list state. */
|
|
108
|
+
export function Notice(props: { title: string; detail?: string }): ReactNode {
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
style={{
|
|
112
|
+
margin: '0 auto',
|
|
113
|
+
padding: 24,
|
|
114
|
+
maxWidth: 460,
|
|
115
|
+
textAlign: 'center',
|
|
116
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
117
|
+
lineHeight: 1.7,
|
|
118
|
+
fontSize: 12,
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<div style={{ marginBottom: 6, color: 'var(--dsw-alias-label-secondary)' }}>{props.title}</div>
|
|
122
|
+
{props.detail === undefined ? null : <div>{props.detail}</div>}
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One task row. */
|
|
128
|
+
function TodoRow(props: { item: TodoItem; t: (key: string) => string }): ReactNode {
|
|
129
|
+
const { item, t } = props
|
|
130
|
+
return createElement(
|
|
131
|
+
'li',
|
|
132
|
+
{ style: ROW_STYLE },
|
|
133
|
+
createElement('span', { style: pillStyle(item.status) }, t(statusKey(item.status))),
|
|
134
|
+
createElement('span', { style: contentStyle(item.status) }, item.content),
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The progress section. The subscription is unconditional (a plain listener,
|
|
140
|
+
* not IO); the three-state contract of the projection is preserved verbatim.
|
|
141
|
+
* @param props - translation, client context, session scope.
|
|
142
|
+
*/
|
|
143
|
+
export function TodoSection(props: TodoSectionProps): ReactNode {
|
|
144
|
+
const { t, ctx, scope } = props
|
|
145
|
+
|
|
146
|
+
const todos = useTodos(ctx, scope?.sessionId)
|
|
147
|
+
|
|
148
|
+
if (todos === undefined) {
|
|
149
|
+
// `undefined` is the store's "capability absent": no session, or the host's
|
|
150
|
+
// todo unit is unmounted. Say so rather than showing an empty board.
|
|
151
|
+
return createElement(
|
|
152
|
+
'div',
|
|
153
|
+
{ style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'progress-section' },
|
|
154
|
+
createElement('div', { style: HEADER_STYLE }, t('section.progress')),
|
|
155
|
+
createElement(Notice, { title: t('board.unavailable'), detail: t('board.unavailableHint') }),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (todos.length === 0) {
|
|
160
|
+
return createElement(
|
|
161
|
+
'div',
|
|
162
|
+
{ style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'progress-section' },
|
|
163
|
+
createElement('div', { style: HEADER_STYLE }, t('section.progress')),
|
|
164
|
+
createElement(Notice, { title: t('board.empty'), detail: t('board.emptyHint') }),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const counts = countTodos(todos)
|
|
169
|
+
|
|
170
|
+
return createElement(
|
|
171
|
+
'div',
|
|
172
|
+
{ style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'progress-section' },
|
|
173
|
+
createElement('div', { style: HEADER_STYLE }, t('section.progress')),
|
|
174
|
+
createElement('div', { style: PROGRESS_STYLE }, progressLine(counts, t)),
|
|
175
|
+
createElement(
|
|
176
|
+
'ul',
|
|
177
|
+
{ style: LIST_STYLE },
|
|
178
|
+
todos.map((item, index) => createElement(TodoRow, { key: `${index}:${item.content}`, item, t })),
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Backwards-compatible alias for the pre-summary export name. The component
|
|
185
|
+
* no longer owns the full-height shell — that moved to `SummaryTab` — so the
|
|
186
|
+
* alias exists only to keep import sites honest during the transition.
|
|
187
|
+
*/
|
|
188
|
+
export const TodoBoardTab = TodoSection
|
|
@@ -1,104 +1,104 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The `todos` projection — reading and classifying it.
|
|
3
|
-
*
|
|
4
|
-
* The Host computes this projection (`dsh-tool-todo` registers the key) and
|
|
5
|
-
* pushes finished whole values; the client never folds it. This module is the
|
|
6
|
-
* read side of that contract and is deliberately PURE — no React, no context,
|
|
7
|
-
* no IO — so the mapping can be tested directly.
|
|
8
|
-
*/
|
|
9
|
-
/** The projection's three-state lifecycle (`TodoItem` in `dsh-tool-todo`). */
|
|
10
|
-
export type TodoStatus = 'pending' | 'in_progress' | 'completed'
|
|
11
|
-
|
|
12
|
-
/** One entry of the `todos` projection. */
|
|
13
|
-
export interface TodoItem {
|
|
14
|
-
readonly content: string
|
|
15
|
-
readonly status: TodoStatus
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'completed']
|
|
19
|
-
|
|
20
|
-
/** Locale key per status. Kept here so this module owns the vocabulary. */
|
|
21
|
-
const STATUS_KEY: Record<TodoStatus, string> = {
|
|
22
|
-
pending: 'status.pending',
|
|
23
|
-
in_progress: 'status.inProgress',
|
|
24
|
-
completed: 'status.completed',
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Locale key for one status label. */
|
|
28
|
-
export function statusKey(status: TodoStatus): string {
|
|
29
|
-
return STATUS_KEY[status]
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Narrow a raw projection value into a todo list.
|
|
34
|
-
*
|
|
35
|
-
* Three outcomes are kept apart on purpose, mirroring the store's own contract:
|
|
36
|
-
* - `undefined` — the capability is ABSENT (no session, the host unit is
|
|
37
|
-
* unmounted, or no baseline/frame has carried the key yet);
|
|
38
|
-
* - `[]` — the projection is present and the list is empty (`null` on the wire);
|
|
39
|
-
* - a list — real entries.
|
|
40
|
-
*
|
|
41
|
-
* A malformed entry is dropped rather than thrown on: the projection is a
|
|
42
|
-
* trusted Host value, but a shape drift must not blank the whole board.
|
|
43
|
-
*
|
|
44
|
-
* @param value - the raw `unknown` snapshot of the `todos` face.
|
|
45
|
-
* @returns the validated list, or `undefined` when the capability is absent.
|
|
46
|
-
*/
|
|
47
|
-
export function readTodos(value: unknown): readonly TodoItem[] | undefined {
|
|
48
|
-
if (value === null) return []
|
|
49
|
-
if (!Array.isArray(value)) return undefined
|
|
50
|
-
|
|
51
|
-
const items: TodoItem[] = []
|
|
52
|
-
for (const raw of value) {
|
|
53
|
-
if (raw === null || typeof raw !== 'object') continue
|
|
54
|
-
const { content, status } = raw as { content?: unknown; status?: unknown }
|
|
55
|
-
if (typeof content !== 'string') continue
|
|
56
|
-
if (typeof status !== 'string' || !isStatus(status)) continue
|
|
57
|
-
items.push({ content, status })
|
|
58
|
-
}
|
|
59
|
-
return items
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Whether a string is one of the three lifecycle values. */
|
|
63
|
-
function isStatus(value: string): value is TodoStatus {
|
|
64
|
-
return (STATUSES as readonly string[]).includes(value)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Per-status counts over one list. */
|
|
68
|
-
export interface TodoCounts {
|
|
69
|
-
readonly total: number
|
|
70
|
-
readonly completed: number
|
|
71
|
-
readonly inProgress: number
|
|
72
|
-
readonly pending: number
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Count a todo list by status. */
|
|
76
|
-
export function countTodos(todos: readonly TodoItem[]): TodoCounts {
|
|
77
|
-
let completed = 0
|
|
78
|
-
let inProgress = 0
|
|
79
|
-
let pending = 0
|
|
80
|
-
for (const item of todos) {
|
|
81
|
-
if (item.status === 'completed') completed += 1
|
|
82
|
-
else if (item.status === 'in_progress') inProgress += 1
|
|
83
|
-
else pending += 1
|
|
84
|
-
}
|
|
85
|
-
return { total: todos.length, completed, inProgress, pending }
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** How many entries are not finished — the tab badge. */
|
|
89
|
-
export function unfinishedCount(todos: readonly TodoItem[]): number {
|
|
90
|
-
const counts = countTodos(todos)
|
|
91
|
-
return counts.inProgress + counts.pending
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* The one-line progress summary, composed from parts so no interpolation
|
|
96
|
-
* machinery is needed (the locale binder is a plain key → string lookup).
|
|
97
|
-
* Mirrors the official dock's own label.
|
|
98
|
-
*/
|
|
99
|
-
export function progressLine(counts: TodoCounts, t: (key: string) => string): string {
|
|
100
|
-
const parts = [`${t('status.completed')} ${counts.completed}/${counts.total}`]
|
|
101
|
-
if (counts.inProgress > 0) parts.push(`${t('status.inProgress')} ${counts.inProgress}`)
|
|
102
|
-
if (counts.pending > 0) parts.push(`${t('status.pending')} ${counts.pending}`)
|
|
103
|
-
return parts.join(' · ')
|
|
104
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The `todos` projection — reading and classifying it.
|
|
3
|
+
*
|
|
4
|
+
* The Host computes this projection (`dsh-tool-todo` registers the key) and
|
|
5
|
+
* pushes finished whole values; the client never folds it. This module is the
|
|
6
|
+
* read side of that contract and is deliberately PURE — no React, no context,
|
|
7
|
+
* no IO — so the mapping can be tested directly.
|
|
8
|
+
*/
|
|
9
|
+
/** The projection's three-state lifecycle (`TodoItem` in `dsh-tool-todo`). */
|
|
10
|
+
export type TodoStatus = 'pending' | 'in_progress' | 'completed'
|
|
11
|
+
|
|
12
|
+
/** One entry of the `todos` projection. */
|
|
13
|
+
export interface TodoItem {
|
|
14
|
+
readonly content: string
|
|
15
|
+
readonly status: TodoStatus
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'completed']
|
|
19
|
+
|
|
20
|
+
/** Locale key per status. Kept here so this module owns the vocabulary. */
|
|
21
|
+
const STATUS_KEY: Record<TodoStatus, string> = {
|
|
22
|
+
pending: 'status.pending',
|
|
23
|
+
in_progress: 'status.inProgress',
|
|
24
|
+
completed: 'status.completed',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Locale key for one status label. */
|
|
28
|
+
export function statusKey(status: TodoStatus): string {
|
|
29
|
+
return STATUS_KEY[status]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Narrow a raw projection value into a todo list.
|
|
34
|
+
*
|
|
35
|
+
* Three outcomes are kept apart on purpose, mirroring the store's own contract:
|
|
36
|
+
* - `undefined` — the capability is ABSENT (no session, the host unit is
|
|
37
|
+
* unmounted, or no baseline/frame has carried the key yet);
|
|
38
|
+
* - `[]` — the projection is present and the list is empty (`null` on the wire);
|
|
39
|
+
* - a list — real entries.
|
|
40
|
+
*
|
|
41
|
+
* A malformed entry is dropped rather than thrown on: the projection is a
|
|
42
|
+
* trusted Host value, but a shape drift must not blank the whole board.
|
|
43
|
+
*
|
|
44
|
+
* @param value - the raw `unknown` snapshot of the `todos` face.
|
|
45
|
+
* @returns the validated list, or `undefined` when the capability is absent.
|
|
46
|
+
*/
|
|
47
|
+
export function readTodos(value: unknown): readonly TodoItem[] | undefined {
|
|
48
|
+
if (value === null) return []
|
|
49
|
+
if (!Array.isArray(value)) return undefined
|
|
50
|
+
|
|
51
|
+
const items: TodoItem[] = []
|
|
52
|
+
for (const raw of value) {
|
|
53
|
+
if (raw === null || typeof raw !== 'object') continue
|
|
54
|
+
const { content, status } = raw as { content?: unknown; status?: unknown }
|
|
55
|
+
if (typeof content !== 'string') continue
|
|
56
|
+
if (typeof status !== 'string' || !isStatus(status)) continue
|
|
57
|
+
items.push({ content, status })
|
|
58
|
+
}
|
|
59
|
+
return items
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether a string is one of the three lifecycle values. */
|
|
63
|
+
function isStatus(value: string): value is TodoStatus {
|
|
64
|
+
return (STATUSES as readonly string[]).includes(value)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Per-status counts over one list. */
|
|
68
|
+
export interface TodoCounts {
|
|
69
|
+
readonly total: number
|
|
70
|
+
readonly completed: number
|
|
71
|
+
readonly inProgress: number
|
|
72
|
+
readonly pending: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Count a todo list by status. */
|
|
76
|
+
export function countTodos(todos: readonly TodoItem[]): TodoCounts {
|
|
77
|
+
let completed = 0
|
|
78
|
+
let inProgress = 0
|
|
79
|
+
let pending = 0
|
|
80
|
+
for (const item of todos) {
|
|
81
|
+
if (item.status === 'completed') completed += 1
|
|
82
|
+
else if (item.status === 'in_progress') inProgress += 1
|
|
83
|
+
else pending += 1
|
|
84
|
+
}
|
|
85
|
+
return { total: todos.length, completed, inProgress, pending }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** How many entries are not finished — the tab badge. */
|
|
89
|
+
export function unfinishedCount(todos: readonly TodoItem[]): number {
|
|
90
|
+
const counts = countTodos(todos)
|
|
91
|
+
return counts.inProgress + counts.pending
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The one-line progress summary, composed from parts so no interpolation
|
|
96
|
+
* machinery is needed (the locale binder is a plain key → string lookup).
|
|
97
|
+
* Mirrors the official dock's own label.
|
|
98
|
+
*/
|
|
99
|
+
export function progressLine(counts: TodoCounts, t: (key: string) => string): string {
|
|
100
|
+
const parts = [`${t('status.completed')} ${counts.completed}/${counts.total}`]
|
|
101
|
+
if (counts.inProgress > 0) parts.push(`${t('status.inProgress')} ${counts.inProgress}`)
|
|
102
|
+
if (counts.pending > 0) parts.push(`${t('status.pending')} ${counts.pending}`)
|
|
103
|
+
return parts.join(' · ')
|
|
104
|
+
}
|