@cosmicdrift/kumiko-samples 0.388.0 → 0.389.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.
Files changed (129) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/agent-tools/changes.json +6 -0
  4. package/packages/bundled-features/src/agent-tools/index.ts +1 -1
  5. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +42 -26
  6. package/packages/bundled-features/src/audit/feature.ts +1 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +1 -0
  9. package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
  11. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -0
  12. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -0
  13. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +16 -3
  14. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -0
  15. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +8 -2
  16. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +23 -21
  17. package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +6 -3
  18. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +11 -1
  19. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  20. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +16 -5
  21. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +7 -1
  22. package/packages/bundled-features/src/auth-email-password/web/return-path.ts +35 -0
  23. package/packages/bundled-features/src/auth-email-password/web/session.tsx +6 -1
  24. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
  25. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +12 -2
  26. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  27. package/packages/bundled-features/src/auth-mfa/config.ts +3 -7
  28. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +8 -5
  29. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +20 -7
  30. package/packages/bundled-features/src/cap-counter/changes.json +12 -0
  31. package/packages/bundled-features/src/cap-counter/feature.ts +1 -0
  32. package/packages/bundled-features/src/cap-overview/feature.ts +1 -0
  33. package/packages/bundled-features/src/config/changes.json +6 -0
  34. package/packages/bundled-features/src/config/handlers/set.write.ts +19 -2
  35. package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
  36. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +1 -1
  37. package/packages/bundled-features/src/custom-fields/lib/parse-serialized-field.ts +1 -1
  38. package/packages/bundled-features/src/delivery/changes.json +12 -0
  39. package/packages/bundled-features/src/delivery/feature.ts +1 -0
  40. package/packages/bundled-features/src/delivery/index.ts +5 -0
  41. package/packages/bundled-features/src/delivery/system-channel-context.ts +33 -0
  42. package/packages/bundled-features/src/document-ingest-foundation/append-missing-extract-event.ts +84 -0
  43. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  44. package/packages/bundled-features/src/document-ingest-foundation/events.ts +15 -0
  45. package/packages/bundled-features/src/document-ingest-foundation/index.ts +11 -0
  46. package/packages/bundled-features/src/feature-toggles/feature.ts +1 -0
  47. package/packages/bundled-features/src/host-routing/changes.json +6 -0
  48. package/packages/bundled-features/src/host-routing/validate-options.ts +5 -0
  49. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -0
  50. package/packages/bundled-features/src/jobs/feature.ts +1 -0
  51. package/packages/bundled-features/src/ledger/web/index.ts +1 -1
  52. package/packages/bundled-features/src/legal-pages/seed.ts +5 -2
  53. package/packages/bundled-features/src/mail-transport-smtp/changes.json +6 -0
  54. package/packages/bundled-features/src/mail-transport-smtp/config-keys.ts +1 -1
  55. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +9 -11
  56. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  57. package/packages/bundled-features/src/managed-pages/constants.ts +1 -0
  58. package/packages/bundled-features/src/managed-pages/feature.ts +3 -1
  59. package/packages/bundled-features/src/managed-pages/index.ts +1 -0
  60. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
  61. package/packages/bundled-features/src/personal-access-tokens/feature.ts +1 -0
  62. package/packages/bundled-features/src/secrets/changes.json +6 -0
  63. package/packages/bundled-features/src/secrets/write-gate.ts +9 -2
  64. package/packages/bundled-features/src/sessions/changes.json +7 -0
  65. package/packages/bundled-features/src/sessions/feature.ts +1 -0
  66. package/packages/bundled-features/src/sessions/index.ts +1 -1
  67. package/packages/bundled-features/src/sessions/session-callbacks.ts +1 -9
  68. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +24 -2
  69. package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +5 -5
  70. package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
  71. package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +17 -3
  72. package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +2 -1
  73. package/packages/bundled-features/src/tags/feature.ts +1 -0
  74. package/packages/bundled-features/src/template-resolver/changes.json +36 -0
  75. package/packages/bundled-features/src/template-resolver/document-ops.ts +20 -1
  76. package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
  77. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +36 -11
  78. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +30 -5
  79. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +1 -1
  80. package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +22 -19
  81. package/packages/bundled-features/src/template-resolver/seeding.ts +10 -3
  82. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +52 -8
  83. package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
  84. package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
  85. package/packages/bundled-features/src/tenant/feature.ts +1 -0
  86. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -3
  87. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  88. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +35 -1
  89. package/packages/bundled-features/src/user/feature.ts +1 -0
  90. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  91. package/packages/bundled-features/src/user-data-rights/feature.ts +1 -0
  92. package/packages/bundled-features/src/user-data-rights/index.ts +6 -1
  93. package/packages/bundled-features/src/user-data-rights/web/constants.ts +6 -0
  94. package/packages/bundled-features/src/user-data-rights/web/index.ts +1 -0
  95. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  96. package/packages/bundled-features/src/waitlist/feature.ts +1 -0
  97. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +27 -8
  98. package/packages/bundled-features/src/waitlist/import.ts +23 -3
  99. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +2 -1
  100. package/packages/bundled-features/src/waitlist/options.ts +2 -1
  101. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +1 -1
  102. package/samples/apps/styleguide/src/app/client.tsx +2 -0
  103. package/samples/apps/styleguide/src/app/seed.ts +6 -4
  104. package/samples/apps/styleguide/src/app/server.ts +4 -1
  105. package/samples/apps/styleguide/src/features/widgets/feature.ts +15 -4
  106. package/samples/apps/styleguide/src/features/workboard/feature.ts +185 -0
  107. package/samples/apps/styleguide/src/features/workboard/i18n.ts +27 -0
  108. package/samples/apps/styleguide/src/features/workboard/seed.ts +121 -0
  109. package/samples/apps/styleguide/src/features/workboard/web.ts +13 -0
  110. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +2 -0
  111. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +9 -0
  112. package/samples/apps/use-all-bundled/bin/main.ts +2 -0
  113. package/samples/apps/use-all-bundled/feature-manifest.json +16 -0
  114. package/samples/apps/use-all-bundled/kumiko/schema.ts +2 -0
  115. package/samples/apps/use-all-bundled/kumiko.config.ts +2 -0
  116. package/samples/apps/use-all-bundled/package.json +2 -1
  117. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +2 -0
  118. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +2 -0
  119. package/samples/apps/use-all-bundled/src/bundled-lists.ts +15 -0
  120. package/samples/recipes/golden-path/src/feature.ts +2 -1
  121. package/samples/recipes/lifecycle-hooks/README.md +4 -0
  122. package/samples/recipes/lifecycle-hooks/src/feature.ts +26 -1
  123. package/samples/recipes/list-table-options/README.md +50 -0
  124. package/samples/recipes/list-table-options/package.json +11 -0
  125. package/samples/recipes/list-table-options/src/feature.ts +221 -0
  126. package/samples/recipes/pdf-audit-export/src/feature.ts +2 -1
  127. package/samples/recipes/state-machine/src/events.ts +3 -2
  128. package/samples/recipes/tenant-isolation/README.md +1 -0
  129. package/samples/recipes/tenant-isolation/src/feature.ts +36 -0
@@ -1,8 +1,14 @@
1
- import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
1
+ import {
2
+ acquireNamespacedAdvisoryLock,
3
+ createSystemDbView,
4
+ createTenantDb,
5
+ runInSavepointIfSupported,
6
+ } from "@cosmicdrift/kumiko-framework/db";
2
7
  import {
3
8
  access,
4
9
  defineWriteHandler,
5
10
  type HandlerContext,
11
+ type TenantId,
6
12
  type WriteResult,
7
13
  } from "@cosmicdrift/kumiko-framework/engine";
8
14
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
@@ -25,7 +31,7 @@ import { WAITLIST_NOTIFICATION_TYPES, WAITLIST_STATUS } from "../web/constants.t
25
31
  // framework's other fixed advisory-lock keys.
26
32
  const SUBMIT_LOCK_NAMESPACE = 0x776c7374;
27
33
  const SUBMIT_LOCK_REASON =
28
- "takes the per-email advisory lock so parallel submits cannot both pass the duplicate check; TenantDb has no lock API";
34
+ "takes the per-email advisory lock so parallel submits cannot both pass the duplicate check and wraps the admin-recipient resolver in a savepoint so a failing statement cannot abort the submit transaction; TenantDb has neither API";
29
35
 
30
36
  // Logged-in callers carry no anonymous role, so every signed-in role must be listed.
31
37
  const SUBMIT_ROLES = access.roles(
@@ -73,16 +79,24 @@ async function hasOpenEntryForEmail(email: string, ctx: HandlerContext): Promise
73
79
  return found.rows.length > 0;
74
80
  }
75
81
 
76
- // The resolver usually reads config. It runs before the per-email advisory lock so a
77
- // slow resolver does not extend the lock. It still shares the handler transaction: a
78
- // thrown JS error is swallowed here, but a failed SQL statement inside it would abort
79
- // the transaction, so resolvers must not issue statements that can fail.
82
+ // The resolver usually reads config. It runs before the per-email advisory lock so a slow
83
+ // resolver does not extend the lock. Postgres aborts the whole transaction after a failed
84
+ // statement, even one whose JS error is caught here, so the resolver gets a ctx whose
85
+ // systemDb is bound to its own savepoint: a failing statement rolls back only that.
86
+ // Statements through any other ctx handle (ctx.query, ctx.write) stay in the handler transaction.
80
87
  async function resolveAdminRecipient(
81
88
  opts: WaitlistOptions,
82
89
  ctx: HandlerContext,
90
+ systemDb: NonNullable<HandlerContext["systemDb"]>,
91
+ tenantId: TenantId,
83
92
  ): Promise<string | null> {
84
93
  try {
85
- return (await opts.notifyRecipient?.(ctx)) || null;
94
+ return await runInSavepointIfSupported(systemDb.unsafeRaw(SUBMIT_LOCK_REASON), async (sp) => {
95
+ const savepointSystemDb = createSystemDbView(
96
+ createTenantDb({ db: sp, tenantId, mode: "system" }),
97
+ );
98
+ return (await opts.notifyRecipient?.({ ...ctx, systemDb: savepointSystemDb })) || null;
99
+ });
86
100
  } catch (err) {
87
101
  ctx.log?.warn("[waitlist:submit] admin notice recipient resolution failed", {
88
102
  error: err instanceof Error ? err.message : String(err),
@@ -130,7 +144,12 @@ export function createSubmitHandler(opts: WaitlistOptions) {
130
144
  message: "waitlist:submit requires ctx.systemDb — is r.systemScope() set?",
131
145
  });
132
146
  }
133
- const adminRecipient = await resolveAdminRecipient(opts, ctx);
147
+ const adminRecipient = await resolveAdminRecipient(
148
+ opts,
149
+ ctx,
150
+ ctx.systemDb,
151
+ event.user.tenantId,
152
+ );
134
153
  await acquireNamespacedAdvisoryLock(
135
154
  ctx.systemDb.unsafeRaw(SUBMIT_LOCK_REASON),
136
155
  SUBMIT_LOCK_NAMESPACE,
@@ -3,7 +3,7 @@ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
5
5
  import { normalizeEmail, platformActor, waitlistExecutor } from "./lib.ts";
6
- import type { WaitlistStatus } from "./web/constants.ts";
6
+ import { WAITLIST_FIELD_LIMITS, type WaitlistStatus } from "./web/constants.ts";
7
7
 
8
8
  export type WaitlistImportEntry = {
9
9
  /** Stable identity of the source row (e.g. `offlot:<uuid>`); the new entry id derives from it. */
@@ -43,18 +43,38 @@ export function isWaitlistImportId(id: string): boolean {
43
43
  return id[14] === "5";
44
44
  }
45
45
 
46
+ // The live submit schema caps these fields; an imported value above the cap would be
47
+ // stored but could never pass a later update through the same payload schema.
48
+ export function waitlistImportEntryWithinLimits(entry: WaitlistImportEntry): boolean {
49
+ const limited: ReadonlyArray<readonly [string | undefined, number]> = [
50
+ [entry.name, WAITLIST_FIELD_LIMITS.name],
51
+ [entry.email, WAITLIST_FIELD_LIMITS.email],
52
+ [entry.locale, WAITLIST_FIELD_LIMITS.locale],
53
+ [entry.company, WAITLIST_FIELD_LIMITS.company],
54
+ [entry.portfolio, WAITLIST_FIELD_LIMITS.portfolio],
55
+ [entry.message, WAITLIST_FIELD_LIMITS.message],
56
+ ];
57
+ return limited.every(([value, max]) => value === undefined || value.length <= max);
58
+ }
59
+
46
60
  function presentOnly(fields: Record<string, unknown>): Record<string, unknown> {
47
61
  return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
48
62
  }
49
63
 
50
- // Writes through the entity executor, so events, projection and subject encryption
51
- // behave exactly as for a live submit. No mail, no rate limit, no duplicate-by-email
64
+ // Writes through the entity executor, so events and projection match a live submit; subject
65
+ // encryption applies only when the caller configured a PII KMS (the CLI wires it from env
66
+ // and aborts without one). No mail, no rate limit, no duplicate-by-email
52
67
  // check: the source row is the truth and the id makes the call idempotent.
53
68
  export async function importWaitlistEntry(
54
69
  db: TenantDb,
55
70
  entry: WaitlistImportEntry,
56
71
  options: { readonly dryRun?: boolean } = {},
57
72
  ): Promise<WaitlistImportOutcome> {
73
+ if (!waitlistImportEntryWithinLimits(entry)) {
74
+ throw new InternalError({
75
+ message: `waitlist import entry ${entry.sourceKey} exceeds a field length limit`,
76
+ });
77
+ }
58
78
  const id = waitlistImportId(entry.sourceKey);
59
79
  const existing = await waitlistExecutor.detail({ id }, platformActor(), db);
60
80
  if (existing) return "skipped";
@@ -28,6 +28,7 @@ import {
28
28
  importWaitlistEntry,
29
29
  isWaitlistImportId,
30
30
  type WaitlistImportEntry,
31
+ waitlistImportEntryWithinLimits,
31
32
  waitlistImportId,
32
33
  } from "./import.ts";
33
34
  import { WAITLIST_STATUS, type WaitlistStatus } from "./web/constants.ts";
@@ -265,7 +266,7 @@ export async function migrateLegacyWaitlist(
265
266
  options.from === "offlot"
266
267
  ? await mapOfflotRow(row)
267
268
  : mapPublicstatusRow(row, options.defaultLocale);
268
- if (!entry) {
269
+ if (!entry || !waitlistImportEntryWithinLimits(entry)) {
269
270
  unreadable += 1;
270
271
  continue;
271
272
  }
@@ -22,7 +22,8 @@ export type WaitlistInviteOptions =
22
22
  export type WaitlistOptions = {
23
23
  /** Address for an admin notification mail on each new entry. Null, an empty
24
24
  * string or an absent option sends none. Best effort, never fails the submit,
25
- * also when the resolver throws or rejects. */
25
+ * also when the resolver throws or rejects. `ctx.systemDb` is bound to a savepoint, so a
26
+ * failing statement through it is rolled back without aborting the submit transaction. */
26
27
  readonly notifyRecipient?: (ctx: HandlerContext) => string | null | Promise<string | null>;
27
28
  /** Language of the admin notice mail (en, de or es; anything else falls back to en).
28
29
  * Not taken from the request: a submit comes from the entrant, not the operator. */
@@ -229,7 +229,7 @@ function readTriggerSource(startedPayload: unknown): TriggerSource | null {
229
229
  }
230
230
 
231
231
  async function loadReferencedEvent(ctx: HandlerContext, ref: TriggerEventRef) {
232
- const events = await ctx.loadAggregate(ref.aggregateId);
232
+ const events = await ctx.loadAggregate(ref.aggregateId, { atVersion: ref.version });
233
233
  return events.find((e) => e.id === ref.eventId);
234
234
  }
235
235
 
@@ -7,6 +7,7 @@ import { styleguideClient } from "../features/demo/web";
7
7
  import { examplesClient } from "../features/examples/web";
8
8
  import { galleryClient } from "../features/gallery/web";
9
9
  import { widgetsClient } from "../features/widgets/web";
10
+ import { workboardClient } from "../features/workboard/web";
10
11
  import { AppShell } from "./shell";
11
12
 
12
13
  // createKumikoApp fetches the schema itself from the authenticated
@@ -24,5 +25,6 @@ createKumikoApp({
24
25
  widgetsClient,
25
26
  examplesClient,
26
27
  contentClient(),
28
+ workboardClient,
27
29
  ],
28
30
  });
@@ -1,5 +1,5 @@
1
1
  import type { SeedFn } from "@cosmicdrift/kumiko-dev-server";
2
- import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import { buildEntityTable, createTenantDb, fetchOne } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
5
5
 
@@ -8,9 +8,6 @@ import { demoEntity } from "../features/demo/feature";
8
8
  // Gibt der List-View echte Rows mit Status-/Flag-Vielfalt. price (money)
9
9
  // bleibt leer — der Money-Input zeigt sich im Edit-Screen, im Seed lassen wir
10
10
  // ihn weg um nicht von der money-Payload-Form abzuhängen.
11
- // ponytail: kein Idempotenz-Check — die Screenshot-DB ist ephemer (frisch pro
12
- // Boot). Beim `bun run dev` gegen die persistente Dev-DB dupliziert ein
13
- // Neustart die Rows; das ist hier nur eine Demo, daher egal.
14
11
  const STATUSES = ["draft", "review", "published", "archived"] as const;
15
12
 
16
13
  // Fixed across boots (unlike the framework-minted ids for every other seed
@@ -19,7 +16,12 @@ const STATUSES = ["draft", "review", "published", "archived"] as const;
19
16
  // otherwise change every run.
20
17
  export const FIRST_STYLEGUIDE_ITEM_ID = "00000000-0000-4000-8000-0000000000f1";
21
18
 
19
+ const demoItemTable = buildEntityTable("item", demoEntity);
20
+
22
21
  export const seedStyleguideItems: SeedFn = async (stack) => {
22
+ // The first row has a fixed id, so its presence means a persistent dev DB was seeded already.
23
+ if (await fetchOne(stack.db, demoItemTable, { id: FIRST_STYLEGUIDE_ITEM_ID })) return;
24
+
23
25
  // Bypass the write-handler HTTP path for the first row — its schema has
24
26
  // no `id` field (buildInsertSchema only exposes entity fields), so an
25
27
  // explicit id would be silently stripped before reaching the executor.
@@ -20,6 +20,8 @@ import { examplesFeature } from "../features/examples/feature";
20
20
  import { galleryFeature } from "../features/gallery/feature";
21
21
  import { aiTextDemoFeature } from "../features/widgets/ai-text-demo";
22
22
  import { widgetsFeature } from "../features/widgets/feature";
23
+ import { workboardFeature } from "../features/workboard/feature";
24
+ import { seedWorkboardTasks } from "../features/workboard/seed";
23
25
  import { seedStyleguideItems } from "./seed";
24
26
 
25
27
  const configResolver = createConfigResolver();
@@ -35,6 +37,7 @@ await runDevApp({
35
37
  contentFeature,
36
38
  aiTextDemoFeature,
37
39
  aiSettingsFeature,
40
+ workboardFeature,
38
41
  ],
39
42
  // Aktiviert die Upload-Routes (/api/files) + ctx.files für den Avatar-
40
43
  // Stresstest. In-Memory reicht für die Demo (nicht-persistent).
@@ -43,7 +46,7 @@ await runDevApp({
43
46
  clientEntry: "./src/app/client.tsx",
44
47
  htmlPath: "./public/index.html",
45
48
  watchDirs: ["./src", "../../../packages/*/src"],
46
- seeds: [seedStyleguideItems, seedAiConnections],
49
+ seeds: [seedStyleguideItems, seedAiConnections, seedWorkboardTasks],
47
50
  extraContext: ({ registry }) => ({
48
51
  configResolver,
49
52
  _configAccessorFactory: createConfigAccessorFactory(registry, configResolver),
@@ -28,13 +28,21 @@ const loanMonthMs = (monthIndex: number): number => Date.UTC(2020, monthIndex, 1
28
28
  // screenshots stay stable.
29
29
  const growth = (1 + LOAN_MONTHLY_RATE) ** LOAN_MONTHS;
30
30
  const annuityMinor = (LOAN_PRINCIPAL_MINOR * LOAN_MONTHLY_RATE * growth) / (growth - 1);
31
+ // Month index -> extra repayment, which shortens the term below LOAN_MONTHS.
32
+ const EXTRA_REPAYMENTS_MINOR: ReadonlyMap<number, number> = new Map([
33
+ [84, 3_000_000],
34
+ [150, 2_000_000],
35
+ ]);
31
36
  let balanceMinor: number = LOAN_PRINCIPAL_MINOR;
32
37
  let interestPaidMinor = 0;
38
+ let payoffMonth: number | undefined;
33
39
  const LOAN_REMAINING_POINTS = [{ atMs: loanMonthMs(0), value: LOAN_PRINCIPAL_MINOR }];
34
40
  const LOAN_INTEREST_POINTS = [{ atMs: loanMonthMs(0), value: 0 }];
35
41
  for (let i = 1; i <= LOAN_MONTHS; i++) {
36
42
  const interest = balanceMinor * LOAN_MONTHLY_RATE;
37
- balanceMinor = Math.max(0, balanceMinor - (annuityMinor - interest));
43
+ const extraRepayment = EXTRA_REPAYMENTS_MINOR.get(i) ?? 0;
44
+ balanceMinor = Math.max(0, balanceMinor - (annuityMinor - interest) - extraRepayment);
45
+ if (balanceMinor === 0) payoffMonth ??= i;
38
46
  interestPaidMinor += interest;
39
47
  LOAN_REMAINING_POINTS.push({ atMs: loanMonthMs(i), value: Math.round(balanceMinor) });
40
48
  LOAN_INTEREST_POINTS.push({ atMs: loanMonthMs(i), value: Math.round(interestPaidMinor) });
@@ -44,10 +52,13 @@ const LOAN_SERIES = [
44
52
  { key: "interest", label: "widgets:dashboard:loan-interest", points: LOAN_INTEREST_POINTS },
45
53
  ];
46
54
  const LOAN_MARKERS = [
47
- { atMs: loanMonthMs(84), label: { i18nKey: "widgets:dashboard:marker-extra-1" }, kind: "extra" },
48
- { atMs: loanMonthMs(150), label: { i18nKey: "widgets:dashboard:marker-extra-2" }, kind: "extra" },
55
+ ...[...EXTRA_REPAYMENTS_MINOR.keys()].map((monthIndex, i) => ({
56
+ atMs: loanMonthMs(monthIndex),
57
+ label: { i18nKey: `widgets:dashboard:marker-extra-${i + 1}` },
58
+ kind: "extra",
59
+ })),
49
60
  {
50
- atMs: loanMonthMs(LOAN_MONTHS),
61
+ atMs: loanMonthMs(payoffMonth ?? LOAN_MONTHS),
51
62
  label: { i18nKey: "widgets:dashboard:marker-payoff" },
52
63
  kind: "payoff",
53
64
  },
@@ -0,0 +1,185 @@
1
+ import {
2
+ createDateField,
3
+ createEntity,
4
+ createNumberField,
5
+ createSelectField,
6
+ createTextField,
7
+ defineFeature,
8
+ } from "@cosmicdrift/kumiko-framework/engine";
9
+
10
+ import { WORKBOARD_I18N } from "./i18n";
11
+
12
+ export const taskEntity = createEntity({
13
+ table: "read_workboard_tasks",
14
+ fields: {
15
+ title: createTextField({
16
+ personal: false,
17
+ reason: "is_business_data",
18
+ required: true,
19
+ searchable: true,
20
+ sortable: true,
21
+ }),
22
+ status: createSelectField({
23
+ options: ["open", "inProgress", "blocked", "done"] as const,
24
+ default: "open",
25
+ sortable: true,
26
+ filterable: true,
27
+ }),
28
+ priority: createSelectField({
29
+ options: ["low", "medium", "high"] as const,
30
+ default: "medium",
31
+ sortable: true,
32
+ filterable: true,
33
+ }),
34
+ dueDate: createDateField({ sortable: true }),
35
+ assignee: createTextField({ personal: false, reason: "is_business_data", sortable: true }),
36
+ estimateHours: createNumberField({ sortable: true, filterable: true }),
37
+ },
38
+ });
39
+
40
+ const open = {
41
+ access: {
42
+ openToAll: {
43
+ reason: "demo app: any signed-in user manages every task; there is no per-user ownership",
44
+ },
45
+ },
46
+ } as const;
47
+
48
+ // All of this round's list options on one screen: remembered state, one reset
49
+ // button, the column picker (estimateHours starts hidden) and the density choice.
50
+ export const workboardFeature = defineFeature("workboard", (r) => {
51
+ r.translations({ keys: WORKBOARD_I18N });
52
+ r.crud("task", taskEntity, { write: open, read: open });
53
+
54
+ r.screen({
55
+ id: "task-list",
56
+ type: "entityList",
57
+ entity: "task",
58
+ columns: [
59
+ "title",
60
+ "status",
61
+ "priority",
62
+ { field: "dueDate", hideOnNarrow: true },
63
+ { field: "assignee", hideOnNarrow: true },
64
+ { field: "estimateHours", hidden: true, hideOnNarrow: true },
65
+ ],
66
+ pagination: "pages",
67
+ pageSize: 25,
68
+ defaultSort: { field: "dueDate", dir: "asc" },
69
+ searchable: true,
70
+ facets: { status: { display: "chips" }, priority: {} },
71
+ table: {
72
+ rememberState: true,
73
+ resetAll: true,
74
+ columnPicker: true,
75
+ density: { default: "comfortable", userChoice: true },
76
+ export: { xlsx: true },
77
+ },
78
+ rowActions: [
79
+ { kind: "navigate", id: "edit", label: "Edit", screen: "task-edit", rowClick: true },
80
+ ],
81
+ });
82
+
83
+ // The same tasks as a spreadsheet-like board: fixed column widths, a pinned title,
84
+ // keyboard navigation between rows, row checkboxes with a bulk delete.
85
+ r.screen({
86
+ id: "task-board",
87
+ type: "entityList",
88
+ entity: "task",
89
+ columns: [
90
+ { field: "title", pinned: "left", width: 260, minWidth: 140 },
91
+ { field: "status", width: 180, minWidth: 100 },
92
+ { field: "priority", width: 180, minWidth: 100 },
93
+ { field: "dueDate", width: 180, minWidth: 100, hideOnNarrow: true },
94
+ { field: "assignee", width: 220, minWidth: 100, hideOnNarrow: true },
95
+ { field: "estimateHours", width: 180, minWidth: 100, hideOnNarrow: true },
96
+ ],
97
+ pagination: "pages",
98
+ pageSize: 25,
99
+ defaultSort: { field: "dueDate", dir: "asc" },
100
+ searchable: true,
101
+ table: {
102
+ rememberState: true,
103
+ columnPicker: true,
104
+ pinning: true,
105
+ resizing: true,
106
+ keyboardNavigation: true,
107
+ density: { default: "comfortable", userChoice: true },
108
+ },
109
+ rowActions: [
110
+ { kind: "navigate", id: "edit", label: "Edit", screen: "task-edit", rowClick: true },
111
+ {
112
+ kind: "writeHandler",
113
+ id: "delete",
114
+ label: "Delete",
115
+ handler: "workboard:write:task:delete",
116
+ style: "danger",
117
+ visible: { field: "status", ne: "done" },
118
+ },
119
+ ],
120
+ bulkActions: [{ id: "delete", rowAction: "delete" }],
121
+ });
122
+
123
+ // The same tasks grouped by status on the server: each header carries the
124
+ // group's count and estimate sum, a footer row the sum over all filtered tasks.
125
+ r.screen({
126
+ id: "task-estimates",
127
+ type: "entityList",
128
+ entity: "task",
129
+ columns: ["title", "status", "priority", "assignee", "estimateHours"],
130
+ pagination: "pages",
131
+ pageSize: 3,
132
+ defaultSort: { field: "title", dir: "asc" },
133
+ searchable: true,
134
+ facets: { estimateHours: { display: "range" } },
135
+ table: {
136
+ grouping: { field: "status", collapsedWhen: "done", userChoice: true, fields: ["priority"] },
137
+ totals: [{ field: "estimateHours", kind: "sum" }],
138
+ },
139
+ rowActions: [
140
+ { kind: "navigate", id: "edit", label: "Edit", screen: "task-edit", rowClick: true },
141
+ ],
142
+ });
143
+
144
+ r.screen({
145
+ id: "task-edit",
146
+ type: "entityEdit",
147
+ entity: "task",
148
+ dormant: true,
149
+ listScreenId: "task-list",
150
+ layout: {
151
+ sections: [
152
+ {
153
+ columns: 2,
154
+ fields: ["title", "assignee", "status", "priority", "dueDate", "estimateHours"],
155
+ },
156
+ ],
157
+ },
158
+ });
159
+
160
+ r.nav({ id: "workboard", label: "workboard:nav.workboard", icon: "list", order: 40 });
161
+ r.nav({
162
+ id: "task-list",
163
+ label: "workboard:nav.tasks",
164
+ parent: "workboard:nav:workboard",
165
+ screen: "workboard:screen:task-list",
166
+ icon: "table",
167
+ order: 10,
168
+ });
169
+ r.nav({
170
+ id: "task-board",
171
+ label: "workboard:nav.board",
172
+ parent: "workboard:nav:workboard",
173
+ screen: "workboard:screen:task-board",
174
+ icon: "table",
175
+ order: 20,
176
+ });
177
+ r.nav({
178
+ id: "task-estimates",
179
+ label: "workboard:nav.estimates",
180
+ parent: "workboard:nav:workboard",
181
+ screen: "workboard:screen:task-estimates",
182
+ icon: "table",
183
+ order: 30,
184
+ });
185
+ });
@@ -0,0 +1,27 @@
1
+ import type { LocalizedString } from "../shared-i18n";
2
+
3
+ export const WORKBOARD_I18N: Readonly<Record<string, LocalizedString>> = {
4
+ "workboard:nav.workboard": { de: "Aufgaben", en: "Workboard" },
5
+ "workboard:nav.tasks": { de: "Alle Aufgaben", en: "All tasks" },
6
+ "workboard:nav.board": { de: "Aufgaben-Board", en: "Task board" },
7
+ "workboard:nav.estimates": { de: "Aufwand je Status", en: "Estimates by status" },
8
+ "screen:task-board.title": { de: "Aufgaben-Board", en: "Task board" },
9
+ "screen:task-estimates.title": { de: "Aufwand je Status", en: "Estimates by status" },
10
+ "screen:task-list.title": { de: "Aufgaben", en: "Tasks" },
11
+ "screen:task-edit.title": { de: "Aufgabe bearbeiten", en: "Edit task" },
12
+ "screen:task-edit.create.title": { de: "Aufgabe anlegen", en: "Create task" },
13
+ "screen:task-edit.edit.title": { de: "Aufgabe bearbeiten", en: "Edit task" },
14
+ "workboard:entity:task:field:title": { de: "Titel", en: "Title" },
15
+ "workboard:entity:task:field:status": { de: "Status", en: "Status" },
16
+ "workboard:entity:task:field:status:option:open": { de: "Offen", en: "Open" },
17
+ "workboard:entity:task:field:status:option:inProgress": { de: "In Arbeit", en: "In progress" },
18
+ "workboard:entity:task:field:status:option:blocked": { de: "Blockiert", en: "Blocked" },
19
+ "workboard:entity:task:field:status:option:done": { de: "Erledigt", en: "Done" },
20
+ "workboard:entity:task:field:priority": { de: "Priorität", en: "Priority" },
21
+ "workboard:entity:task:field:priority:option:low": { de: "Niedrig", en: "Low" },
22
+ "workboard:entity:task:field:priority:option:medium": { de: "Mittel", en: "Medium" },
23
+ "workboard:entity:task:field:priority:option:high": { de: "Hoch", en: "High" },
24
+ "workboard:entity:task:field:dueDate": { de: "Fällig am", en: "Due date" },
25
+ "workboard:entity:task:field:assignee": { de: "Zuständig", en: "Assignee" },
26
+ "workboard:entity:task:field:estimateHours": { de: "Aufwand (Std.)", en: "Estimate (h)" },
27
+ };
@@ -0,0 +1,121 @@
1
+ import type { SeedFn } from "@cosmicdrift/kumiko-dev-server";
2
+ import { TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
3
+
4
+ type SeedTask = {
5
+ readonly title: string;
6
+ readonly status: "open" | "inProgress" | "blocked" | "done";
7
+ readonly priority: "low" | "medium" | "high";
8
+ readonly dueDate: string;
9
+ readonly assignee: string;
10
+ readonly estimateHours: number;
11
+ };
12
+
13
+ const TASKS: readonly SeedTask[] = [
14
+ {
15
+ title: "Quarterly VAT filing",
16
+ status: "inProgress",
17
+ priority: "high",
18
+ dueDate: "2026-11-10",
19
+ assignee: "Ines Vogel",
20
+ estimateHours: 6,
21
+ },
22
+ {
23
+ title: "Renew office lease",
24
+ status: "open",
25
+ priority: "medium",
26
+ dueDate: "2026-12-01",
27
+ assignee: "Tomas Berg",
28
+ estimateHours: 3,
29
+ },
30
+ {
31
+ title: "Onboard new support hire",
32
+ status: "open",
33
+ priority: "high",
34
+ dueDate: "2026-11-03",
35
+ assignee: "Ines Vogel",
36
+ estimateHours: 12,
37
+ },
38
+ {
39
+ title: "Migrate invoices to new numbering",
40
+ status: "blocked",
41
+ priority: "high",
42
+ dueDate: "2026-11-15",
43
+ assignee: "Mara Lindqvist",
44
+ estimateHours: 16,
45
+ },
46
+ {
47
+ title: "Update privacy policy",
48
+ status: "done",
49
+ priority: "medium",
50
+ dueDate: "2026-10-20",
51
+ assignee: "Tomas Berg",
52
+ estimateHours: 4,
53
+ },
54
+ {
55
+ title: "Order replacement laptops",
56
+ status: "inProgress",
57
+ priority: "low",
58
+ dueDate: "2026-11-25",
59
+ assignee: "Mara Lindqvist",
60
+ estimateHours: 2,
61
+ },
62
+ {
63
+ title: "Prepare annual audit documents",
64
+ status: "open",
65
+ priority: "high",
66
+ dueDate: "2027-01-15",
67
+ assignee: "Ines Vogel",
68
+ estimateHours: 24,
69
+ },
70
+ {
71
+ title: "Fix mobile checkout layout",
72
+ status: "inProgress",
73
+ priority: "medium",
74
+ dueDate: "2026-11-05",
75
+ assignee: "Jonas Keller",
76
+ estimateHours: 8,
77
+ },
78
+ {
79
+ title: "Review supplier contracts",
80
+ status: "blocked",
81
+ priority: "medium",
82
+ dueDate: "2026-11-30",
83
+ assignee: "Tomas Berg",
84
+ estimateHours: 10,
85
+ },
86
+ {
87
+ title: "Archive 2025 customer files",
88
+ status: "done",
89
+ priority: "low",
90
+ dueDate: "2026-10-31",
91
+ assignee: "Jonas Keller",
92
+ estimateHours: 5,
93
+ },
94
+ {
95
+ title: "Plan team offsite",
96
+ status: "open",
97
+ priority: "low",
98
+ dueDate: "2027-02-20",
99
+ assignee: "Mara Lindqvist",
100
+ estimateHours: 7,
101
+ },
102
+ {
103
+ title: "Set up weekly sales report",
104
+ status: "inProgress",
105
+ priority: "medium",
106
+ dueDate: "2026-11-12",
107
+ assignee: "Jonas Keller",
108
+ estimateHours: 9,
109
+ },
110
+ ];
111
+
112
+ export const seedWorkboardTasks: SeedFn = async (stack) => {
113
+ for (const task of TASKS) {
114
+ const res = await stack.http.write("workboard:write:task:create", task, TestUsers.admin);
115
+ if (!res.ok) {
116
+ // biome-ignore lint/suspicious/noConsole: dev-seed surfaces failures in the runner log
117
+ console.error(`[seed] workboard task "${task.title}" failed:`, await res.text());
118
+ return;
119
+ }
120
+ }
121
+ };
@@ -0,0 +1,13 @@
1
+ import type {
2
+ ClientFeatureDefinition,
3
+ TranslationsByLocale,
4
+ } from "@cosmicdrift/kumiko-renderer/web";
5
+ import { toClientTranslations } from "../shared-i18n";
6
+ import { WORKBOARD_I18N } from "./i18n";
7
+
8
+ const translations: TranslationsByLocale = toClientTranslations(WORKBOARD_I18N);
9
+
10
+ export const workboardClient: ClientFeatureDefinition = {
11
+ name: "workboard",
12
+ translations,
13
+ };
@@ -50,6 +50,7 @@ export const rentalTranslations: TranslationsByLocale = {
50
50
  "rental.party.role.main": "Hauptmieter",
51
51
  "rental.party.role.co": "Mitmieterin",
52
52
  "rental.action.addParty": "Partei hinzufügen",
53
+ "rental.action.showTerminated": "Beendete Verträge",
53
54
 
54
55
  "rental.tab.parties": "Vertragsparteien",
55
56
  "rental.tab.positions": "Positionen",
@@ -214,6 +215,7 @@ export const rentalTranslations: TranslationsByLocale = {
214
215
  "rental.party.role.main": "Main tenant",
215
216
  "rental.party.role.co": "Co-tenant",
216
217
  "rental.action.addParty": "Add party",
218
+ "rental.action.showTerminated": "Terminated leases",
217
219
 
218
220
  "rental.tab.parties": "Parties",
219
221
  "rental.tab.positions": "Positions",
@@ -52,6 +52,15 @@ export const leaseListScreen: EntityListScreenDefinition = {
52
52
  pageSize: 25,
53
53
  createLabel: "rental.action.createLease",
54
54
  searchPlaceholder: "rental.search.placeholder",
55
+ toolbarActions: [
56
+ {
57
+ kind: "navigate",
58
+ id: "show-terminated",
59
+ label: "rental.action.showTerminated",
60
+ style: "primary",
61
+ screen: "lease-list-short",
62
+ },
63
+ ],
55
64
  rowActions: [openLeaseDetail],
56
65
  access: OPEN_ACCESS,
57
66
  };