diff --git a/.github/workflows/azure-static-web-apps-brave-stone-019bd1110.yml b/.github/workflows/azure-static-web-apps-brave-stone-019bd1110.yml
index 1ebf338..4c88e8b 100644
--- a/.github/workflows/azure-static-web-apps-brave-stone-019bd1110.yml
+++ b/.github/workflows/azure-static-web-apps-brave-stone-019bd1110.yml
@@ -4,6 +4,8 @@ on:
push:
tags:
- v*.**
+ pull_request:
+ branches: main
jobs:
build_and_deploy_job:
diff --git a/Bylaws/index.md b/Bylaws/index.md
index 8888526..c0464c6 100644
--- a/Bylaws/index.md
+++ b/Bylaws/index.md
@@ -6,6 +6,7 @@
Sample](https://drive.google.com/file/d/0B7aFfmk4wQfbWVRPQzFkU1ZHLVE/view?resourcekey=0-Ldj_CiYRwRyAUyaGo6ThXg)
Alternatives:
+- [REI](https://www.rei.com/assets/about-rei/governance/rei-bylaws/live.pdf)
- [Boston Techcollective
Cooperative](https://drive.google.com/file/d/0B7aFfmk4wQfbbWxoYjNPVEVGbjg/view?resourcekey=0-ejmpEn33O9JxmiOXgF-tMA)
- [Sample with Two Classes at page
diff --git a/astro/src/business-logic/patronage.ts b/astro/src/business-logic/patronage.ts
new file mode 100644
index 0000000..8286f33
--- /dev/null
+++ b/astro/src/business-logic/patronage.ts
@@ -0,0 +1,126 @@
+import { parseISO, addDays } from 'date-fns';
+
+// One per day of the week
+type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6;
+type TenureSchedule = Record;
+export const noWorkSchedule: TenureSchedule = [0,0,0,0,0,0,0];
+// TODO: it's unclear in the bylaws when Tenure is allocated; what happens for
+// partial weeks at beginning/end of the year? This code allows for a tenure
+// schedule array.
+export const fullWorkWeekSchedule: TenureSchedule = [0, 2, 1, 1, 1, 0, 0];
+
+export interface PatronageEvents {
+ addMember: { name: string; tenureSchedule: TenureSchedule };
+ tenureRate: { name: string; tenureSchedule: TenureSchedule };
+ patronage: { name: string; additional: number; comment: string };
+}
+
+export type PatronageEvent =
+ & { date: Date }
+ & {
+ [P in keyof PatronageEvents]: { type: P } & PatronageEvents[P]
+ }[T];
+
+export type PatronageEntry = {
+ memberDate: Date;
+ weeksYTD: number;
+ tenureSchedule: TenureSchedule;
+ tenure: number;
+ tenureYTD: number;
+ patronageScheduleYTD: number;
+ patronageYTD: number;
+};
+
+export type PatronageSnapshot = {
+ date: Date;
+ details: Record;
+}
+
+function normalizeDate(date: Date | string): Date {
+ return typeof date === 'string' ? parseISO(date) : date;
+}
+
+function newPatronageEntry(memberDate: Date): PatronageEntry {
+ return {
+ memberDate,
+ weeksYTD: 0,
+ tenureSchedule: noWorkSchedule,
+ tenure: 0,
+ tenureYTD: 0,
+ patronageScheduleYTD: 0,
+ patronageYTD: 0,
+ };
+}
+
+export class PatronageRecord {
+ private events: PatronageEvent[];
+ constructor(events?: PatronageEvent[]) {
+ this.events = events ? [...events] : [];
+ }
+
+ add(date: Date | string, type: P, data: PatronageEvents[P]): this {
+ date = normalizeDate(date);
+ const after = this.events.findIndex(e => e.date > date);
+ const newEvent = { date, type, ...data } as PatronageEvent;
+ if (after === -1)
+ this.events.push(newEvent);
+ else
+ this.events.splice(after, 0, newEvent);
+ return this;
+ }
+
+ getDetailsAt(date: Date | string): PatronageSnapshot {
+ date = normalizeDate(date);
+ const results: Record = {};
+ if (!this.events[0]) return { date, details: results };
+ let nextEvent: PatronageEvent | undefined;
+ for (let current = this.events[0].date, nextEventIndex = 0; current <= date; current = addDays(current, 1)) {
+ // reset from end of year
+ if (current.getMonth() === 0 && current.getDate() === 1)
+ for (const entry of Object.values(results)) {
+ entry.patronageScheduleYTD = 0;
+ entry.patronageYTD = 0;
+ entry.tenureYTD = 0;
+ }
+
+ // process events
+ while ((nextEvent = this.events[nextEventIndex]) && nextEvent.date <= current) {
+ nextEventIndex++;
+ switch (nextEvent.type) {
+ case 'addMember':
+ const newMember = newPatronageEntry(current);
+ newMember.tenureSchedule = nextEvent.tenureSchedule;
+ results[nextEvent.name] = newMember;
+ break;
+ case 'patronage':
+ results[nextEvent.name]!.patronageScheduleYTD += nextEvent.additional;
+ break;
+ case 'tenureRate':
+ results[nextEvent.name]!.tenureSchedule = nextEvent.tenureSchedule;
+ break;
+ }
+ }
+
+ // process tenure
+ const dayOfWeek = current.getDay() as DayOfWeek;
+ for (const entry of Object.values(results)) {
+ if (dayOfWeek === 6 /* Saturday */)
+ entry.weeksYTD += 1;
+ entry.tenure += entry.tenureSchedule[dayOfWeek];
+ entry.tenureYTD += entry.tenureSchedule[dayOfWeek];
+ }
+ }
+
+ for (const entry of Object.values(results)) {
+ const base = entry.tenureYTD + entry.patronageScheduleYTD;
+ entry.patronageYTD = base + Math.min(entry.tenure, base * 2);
+ }
+ return { date, details: results };
+ }
+
+ get allEvents() { return [...this.events]; }
+
+ clone(): PatronageRecord {
+ return new PatronageRecord(this.events);
+ }
+}
\ No newline at end of file
diff --git a/astro/src/components/patronage/point-in-time.tsx b/astro/src/components/patronage/point-in-time.tsx
new file mode 100644
index 0000000..41fd9de
--- /dev/null
+++ b/astro/src/components/patronage/point-in-time.tsx
@@ -0,0 +1,82 @@
+import { compareAsc, format } from 'date-fns';
+import type { PatronageSnapshot } from "../../business-logic/patronage";
+const { amber, blue, cyan, emerald, orange, purple, rose } = (await import('tailwindcss/colors')).default;
+
+const colorIndices = [
+ 500,
+ 900,
+ 100,
+ 700,
+ 300,
+] as const;
+type ColorIndices = typeof colorIndices[number];
+
+const colorMaps: { [P in ColorIndices]: string }[] = [
+ amber,
+ emerald,
+ blue,
+ orange,
+ cyan,
+ purple,
+ rose
+];
+
+const colors = (Array(colorIndices.length * colorMaps.length).fill(0).map((_, index) => {
+ const colorRange = colorMaps[index % colorMaps.length]!;
+ return colorRange[colorIndices[index % colorIndices.length]!];
+}))
+
+export function PointInTime({ data: { date, details: data}, membersOrder }: { data: PatronageSnapshot; membersOrder?: string[] }) {
+ const allEntries = Object.entries(data);
+ const order = membersOrder ?? allEntries.sort((a, b) => compareAsc(a[1].memberDate, b[1].memberDate)).map(([name]) => name);
+ return (
+ <>
+ {format(date, 'MMM d, yyyy')}
+
+ {order.map((name, index) =>
+
+ )}
+
+
+
+
+ {order.map((name, index) =>
+
+
+ {' '}{name}
+
+ )}
+
+
+
+
+
+ Name |
+ Member Date |
+ Weeks YTD |
+ Tenure (YTD/total) |
+ Patronage Schedule (YTD) |
+ Patronage (YTD) |
+
+
+
+ {order.map((name, index) =>
+ {
+ const current = data[name]!;
+ return
+
+ {' '}{name} |
+ {format(current.memberDate, 'MMM d, yyyy')} |
+ {current.weeksYTD.toFixed(0)} |
+ {current.tenureYTD.toFixed(0)} / {current.tenure.toFixed(0)} |
+ {current.patronageScheduleYTD.toFixed(0)} |
+ {current.patronageYTD.toFixed(0)} |
+
;
+ }
+ )}
+
+
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/astro/src/components/sets/documents/legal-styles.module.css b/astro/src/components/sets/documents/legal-styles.module.css
index 5432da8..1b87ba6 100644
--- a/astro/src/components/sets/documents/legal-styles.module.css
+++ b/astro/src/components/sets/documents/legal-styles.module.css
@@ -1,7 +1,35 @@
-@import url(./md-styles.module.css);
-.container {
- composes: container from './md-styles.module.css';
+.container :global h1 {
+ @apply text-lg font-bold text-center;
+ font-variant: small-caps;
+}
+
+.container :global h2 {
+ @apply font-bold text-center;
+ counter-increment: article;
+ font-variant: small-caps;
+}
+
+.container :global a {
+ @apply text-blue-700 dark:text-blue-400 underline;
+}
+.container :global p {
+ @apply my-4;
+}
+.container :global ol {
+ @apply my-4;
+ --indentSize: 4rem;
+ counter-reset: item;
+ margin-left: var(--indentSize);
+}
+.container :global li {
+ @apply my-4;
+}
+.container :global ul {
+ @apply my-4 ml-6 list-disc;
+}
+.container :global blockquote {
+ @apply my-4 px-4 border-l-emerald-500 border-l-4;
}
.container :global h2:before {
diff --git a/astro/src/components/sets/documents/md-styles.module.css b/astro/src/components/sets/documents/md-styles.module.css
index f3adc3b..8ecae8e 100644
--- a/astro/src/components/sets/documents/md-styles.module.css
+++ b/astro/src/components/sets/documents/md-styles.module.css
@@ -1,12 +1,16 @@
.container :global h1 {
- @apply text-lg font-bold text-center;
+ @apply text-2xl font-bold mt-8 mb-4;
font-variant: small-caps;
}
.container :global h2 {
- @apply font-bold text-center;
- counter-increment: article;
+ @apply text-xl font-bold mt-8 mb-4;
+ font-variant: small-caps;
+}
+
+.container :global h3 {
+ @apply text-lg font-bold mt-8 mb-4;
font-variant: small-caps;
}
@@ -17,10 +21,7 @@
@apply my-4;
}
.container :global ol {
- @apply my-4;
- --indentSize: 4rem;
- counter-reset: item;
- margin-left: var(--indentSize);
+ @apply my-4 ml-6 list-decimal;
}
.container :global li {
@apply my-4;
@@ -29,5 +30,5 @@
@apply my-4 ml-6 list-disc;
}
.container :global blockquote {
- @apply my-4 px-4 border-l-emerald-500 border-l-4;
+ @apply my-4 px-4 border-l-slate-500 border-l-4;
}
diff --git a/astro/src/content/config.ts b/astro/src/content/config.ts
index dc2bbd8..981fed3 100644
--- a/astro/src/content/config.ts
+++ b/astro/src/content/config.ts
@@ -1,5 +1,6 @@
// Import utilities from `astro:content`
import { z, defineCollection } from "astro:content";
+
// Define a `type` and `schema` for each collection
const documentsCollection = defineCollection({
type: 'content',
@@ -8,7 +9,18 @@ const documentsCollection = defineCollection({
description: z.string(),
})
});
+
+const storiesCollection = defineCollection({
+ type: 'content',
+ schema: z.object({
+ title: z.string(),
+ description: z.string(),
+ order: z.number(),
+ })
+});
+
// Export a single `collections` object to register your collection(s)
export const collections = {
documents: documentsCollection,
+ stories: storiesCollection,
};
diff --git a/astro/src/content/documents/bylaws.mdx b/astro/src/content/documents/bylaws.mdx
index d3e840a..e030f17 100644
--- a/astro/src/content/documents/bylaws.mdx
+++ b/astro/src/content/documents/bylaws.mdx
@@ -116,7 +116,7 @@ https://drive.google.com/file/d/0B7aFfmk4wQfbWVRPQzFkU1ZHLVE/view?resourcekey=0-
responsible for any tax liability incurred as a result of Patronage with the
Cooperative, as defined in . Each Member shall indemnify and
forever hold harmless the Cooperative from any claims of any kind arising out
- of their patronage or their Membership of the Cooperative.
+ of their Patronage or their Membership of the Cooperative.
9. Record of Members. A record of the Members and their full names, addresses,
and, if required for tax reporting purposes, social security or tax
@@ -732,6 +732,9 @@ https://drive.google.com/file/d/0B7aFfmk4wQfbWVRPQzFkU1ZHLVE/view?resourcekey=0-
* 4 if at least 24 hours were worked for the Cooperative.
* 5 if at least 32 hours were worked for the Cooperative.
+ > TODO: Make it clear that tenuer is earned at the completion of 1 hour,
+ > 8 hours, etc. rather than at the end of the week.
+
8. "Patronage" shall be calculated per Member within a fiscal year as the
sum of the following:
@@ -769,7 +772,7 @@ https://drive.google.com/file/d/0B7aFfmk4wQfbWVRPQzFkU1ZHLVE/view?resourcekey=0-
amounts of Patronage may be allocated to Members.
2. The Patronage Schedule shall have an effective date no earlier than 120
days after approval by the Board or a committee appointed by the board to
- handle patronage.
+ handle Patronage.
3. Within 30 days of the approval of a new Patronage Schedule, the President
or Secretary shall cause notice to be given to the Members including the
new Patronage Schedule.
@@ -810,6 +813,8 @@ https://drive.google.com/file/d/0B7aFfmk4wQfbWVRPQzFkU1ZHLVE/view?resourcekey=0-
2. Patronage Dividends may be by qualified or non-qualified written notices
of allocation or a combination of the two.
+ > TODO: When are dividends allocated/distributed?
+
6. Distributions of Interest on Member Accounts. The Cooperative may, by a
decision of the Board, pay interest to Members on the Members Accounts. The
interest may be paid in cash or as an additional credit to the Member
diff --git a/astro/src/content/stories/patronage/1-primer.mdx b/astro/src/content/stories/patronage/1-primer.mdx
new file mode 100644
index 0000000..10b7571
--- /dev/null
+++ b/astro/src/content/stories/patronage/1-primer.mdx
@@ -0,0 +1,108 @@
+---
+title: Patronage Primer
+description: A primer on how Patronage is set up for Dark Patterns Digital
+order: 0
+---
+
+# Patronage
+
+The concept of Patronage is common to cooperatives, but each one does it a bit
+differently. Patronage is essentially the measure by which surplus generated by
+a co-op is distributed back to its members. Patronage can be calculated many
+ways:
+
+- Number of dollars spent with a retailer cooperative, like REI
+- Amount of products produced for a producer cooperative, like Ocean Spray
+- Amount of services purchased, like in state-recognized healthcare co-ops
+- Number of hours worked, such as in co-ops like ours
+
+Patronage can be granted by a co-op for any number of activities that the co-op
+wishes to encourage, but primarily it is granted for efforts contributed to the
+coop.
+
+## Patronage for Dark Patterns
+
+As a technical consulting co-op, our patronage allocations will be more
+complicated than "hours worked". As of this writing, the relevant definitions
+include:
+
+
+
+- "Tenure" shall be accumulated on a per-week basis, for all Members and
+ Prospective Members within their candidacy periods, Sunday through Saturday,
+ as the greatest of the following:
+
+ * 1 if at least 1 hour was worked for the Cooperative, or if an employee
+ of the Cooperative.
+ * 2 if at least 8 hours were worked for the Cooperative.
+ * 3 if at least 16 hours were worked for the Cooperative.
+ * 4 if at least 24 hours were worked for the Cooperative.
+ * 5 if at least 32 hours were worked for the Cooperative.
+
+- "Patronage" shall be calculated per Member within a fiscal year as the sum of
+ the following:
+
+ * Tenure accumulated within the current fiscal year;
+ * A number as determined via Patronage Schedules; plus
+ * The lesser of (a) the Member's total Tenure at the end of the fiscal year
+ or (b) twice the Patronage earned in the above.
+
+- While no Patronage Schedule is in effect, the amount of determined via
+ Patronage Schedules shall be calculated as 1 for every $5,000 paid to the
+ Cooperative via executed contracts referred by the Member.
+
+
+
+## Tenure
+
+Tenure is designed to give a measure of how long someone has worked for the
+cooperative while allowing a part-time worker to accumulate Tenure at a slower
+pace than full-time workers.
+
+- The work-week is defined for Tenure as Sunday through Saturday so that a
+ member who works overtime during a weekend can more easily reach maximum
+ Tenure during both the preceding and the following week. (This will also be
+ helpful for workers who are moonlighting for the coop, as working both
+ Saturday and Sunday in the same weekend will count as two separate weeks.)
+- Any amount of work for the Cooperative earns 1 point of Tenure, even if it is
+ only 1 hour.
+ - Once we start employing people regularly, 1 point of Tenure will be
+ accumulated even during a week of PTO.
+ - Board meetings and correct accounting are very important for a successful
+ business; Tenure should be accumulated for that time, too, even when not
+ employed.
+- Tenure reaches its maximum with 32 hours per week. This should encourage us to
+ not continually work 40 hour weeks, but encourage more frequent long weekends.
+
+## Patronage
+
+Patronage is the number by which surplus (revenue generated by Members in excess
+of expenses) is distributed to the Members. Patronage is frequently used by
+coops as a way to encourage particular behaviors. As such, Patronage is defined
+with a few key points:
+
+1. Encourage the same concepts as Tenure; as workers, our time is the most
+ valuable resource we bring to the coop.
+2. Scale up over time. A new Member to the coop will not receive as much
+ Patronage as a Member who has accumulated Tenure in previous years; this is
+ to reflect onboarding time and to encourage a constant stream of growth. (See
+ the examples for how this encourages growth.)
+3. Show appreciation for contracts brought to the coop. Especially in our
+ initial phase, new contracts will likely earn a commission; instead,
+ additional Patronage is earned.
+
+We have the ability to redefine the Patronage Schedule without an amendment to
+the Bylaws so that we may add other non-labor incentives later. Some examples
+that have already been discussed:
+
+- Volunteer work
+- Tutoring for college students in the fields in which we work
+- Participating in local meet-up groups
+
+### A note about Surplus
+
+As a coop, just like in an LLC, tax reporting is the responsibility of the
+individual Members. Surplus is distinct from Profits because federal tax law
+says that only “patronage-sourced” income may be distributed as tax-deductible
+patronage refunds/patronage dividends. As a result, money paid as patronage
+dividends or advances on patronage dividends have a special tax class.
diff --git a/astro/src/content/stories/patronage/2-basic-example.mdx b/astro/src/content/stories/patronage/2-basic-example.mdx
new file mode 100644
index 0000000..12d9fd6
--- /dev/null
+++ b/astro/src/content/stories/patronage/2-basic-example.mdx
@@ -0,0 +1,55 @@
+---
+title: Patronage Basic Example
+description: A simple example on how Patronage is intended to work
+order: 10
+---
+
+import { PatronageRecord, fullWorkWeekSchedule } from '../../../business-logic/patronage';
+import { PointInTime } from '../../../components/patronage/point-in-time';
+
+export const patronageExample = new PatronageRecord()
+ .add('2024-04-29', 'addMember', { name: 'Alfred', tenureSchedule: fullWorkWeekSchedule })
+ .add('2024-07-29', 'addMember', { name: 'Betty', tenureSchedule: fullWorkWeekSchedule })
+ .add('2024-12-30', 'addMember', { name: 'Caitlin', tenureSchedule: fullWorkWeekSchedule })
+
+As a simple example, let's consider the following events:
+
+1. On Monday, April 29, 2024, Alfred starts the coop and he is all in, working a full-time
+ 40-hours every week. Every week, Alfred earns 5 tenure points.
+2. On Monday, July 29, 2024, Betty joins Alfred, working a full-time 40 hours every
+ week. Starting from then, both Alfred and Betty are earning 5 tenure points
+ every week.
+3. On Monday, December 30, 2024, Caitlin joins them both, also managing a full-time 40
+ hours every week. Starting with the end of that week, all three of them are
+ earning 5 tenure points every week.
+
+Before Betty joined, it didn't matter how much Alfred earned; any surplus
+distributed would be allocated entirely to him.
+
+
+
+However, the week after Betty starts, Betty begins to accumulate patronage:
+
+
+
+By the end of Q3, Betty has worked for 2 full months and Alfred has worked for
+5, and the allocation of patronage reflects this split:
+
+
+
+At the end of the year, Caitlin joins, but does not work a full week before the
+end of year calculations are made.
+
+
+
+Assuming no further team members are added, by the end of the following year,
+even though all three worked full-time for the entire year, Alfred has slightly
+more patronage due to having started earning tenure earlier.
+
+
+
+However, by the end of the year after that, all the tenure has reached its
+maximum contribution to patronage; as a result, the patronage shares have
+stabilized with all three members earning an equal share.
+
+
diff --git a/astro/src/pages/index.astro b/astro/src/pages/index.astro
index 5b45359..a81cd6d 100644
--- a/astro/src/pages/index.astro
+++ b/astro/src/pages/index.astro
@@ -5,6 +5,8 @@ import mdStyles from '../components/sets/documents/md-styles.module.css'
import { organizationName, orgAbbrev } from '../constants'
const documents = await getCollection('documents');
+const stories = await getCollection('stories');
+stories.sort((a, b) => a.data.order - b.data.order);
---
For now, open each page and use "Print to PDF" within your browser.
+ Explanations
+
+ Concepts, such as Patronage, are hard to understand from just the definitions themselves. The following pages help to explain how it works in practice.
+
+
+
diff --git a/astro/src/pages/stories/[...slug].astro b/astro/src/pages/stories/[...slug].astro
new file mode 100644
index 0000000..4601e58
--- /dev/null
+++ b/astro/src/pages/stories/[...slug].astro
@@ -0,0 +1,43 @@
+---
+import { getCollection } from 'astro:content';
+import Layout from '../../layouts/Layout.astro';
+import mdStyles from '../../components/sets/documents/md-styles.module.css'
+
+export async function getStaticPaths() {
+ const blogEntries = await getCollection('stories');
+ blogEntries.sort((a, b) => a.data.order - b.data.order);
+ return blogEntries.map((entry, index, allEntries) => {
+ const prev = allEntries[index - 1];
+ const next = allEntries[index + 1];
+ return {
+ params: { slug: entry.slug },
+ props: {
+ entry,
+ prev: prev ? {
+ title: prev.data.title,
+ slug: prev.slug,
+ } : null,
+ next: next ? {
+ title: next.data.title,
+ slug: next.slug,
+ } : null,
+ },
+ };
+ });
+}
+
+const { entry, prev, next } = Astro.props;
+const { Content } = await entry.render();
+---
+
+
+ Keep Reading
+
+