From 53eb2eff724331897548638950c83b7dcf9b9301 Mon Sep 17 00:00:00 2001
From: FluffyFire <62662486+sturla1511@users.noreply.github.com>
Date: Fri, 18 Sep 2026 19:08:37 +0200
Subject: [PATCH 1/4] =?UTF-8?q?la=20til=20dato=20og=20role=20p=C3=A5=20eme?=
=?UTF-8?q?ritus?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/routes/(app)/emeritus/+page.svelte | 107 +++++++++++-------
1 file changed, 65 insertions(+), 42 deletions(-)
diff --git a/programmerbar-web/src/routes/(app)/emeritus/+page.svelte b/programmerbar-web/src/routes/(app)/emeritus/+page.svelte
index a3e30ea7..8ec0c54e 100644
--- a/programmerbar-web/src/routes/(app)/emeritus/+page.svelte
+++ b/programmerbar-web/src/routes/(app)/emeritus/+page.svelte
@@ -14,44 +14,51 @@
{ name: 'Eivind D. Halderaker', role: 'Co-founder' }
];
- const members = [
- 'Erik Fjelltveit Nyhuus',
- 'Lene Soltveit',
- 'Steffen Andre Pettersen',
- 'Siren Bjorøy',
- 'Ole Straumland',
- 'Tony Lam',
- 'Eirik Rekve Thorsheim',
- 'Ole Magnus Fon Johnsen',
- 'August Ebne Røeggen',
- 'Fredrik Hast Sørli',
- 'Sturla Rognskog Mella',
- 'Tord Vikøren Vikestad',
- 'Henrik Sætre Breivik'
+ type Member = { name: string; role?: string; start?: string; end?: string };
+
+ const members: Member[] = [
+ { name: 'Erik Fjelltveit Nyhuus' },
+ { name: 'Steffen Andre Pettersen' },
+ { name: 'Siren Bjorøy' },
+ { name: 'Ole Straumland' },
+ { name: 'August Ebne Røeggen' },
+ { name: 'Sturla Rognskog Mella', start: 'Høst 2025' },
+ { name: 'Tord Vikøren Vikestad', start: 'Høst 2025' },
+ { name: 'Henrik Sætre Breivik', start: 'Høst 2025' },
+ { name: 'Anna Sviland', start: 'Høst 2026' },
+ { name: 'Anna Valencia Fari', start: 'Høst 2026' },
+ { name: 'Oddmund Gullbrå Steinsland', start: 'Høst 2026' },
+ { name: 'Maya Shantseva', start: 'Høst 2026' },
+ { name: 'Torjus Berntsen', start: 'Høst 2026' }
];
- const pastMembers = [
- 'Mina Tolfsen',
- 'Ask Rud Persson',
- 'Palma Rud Persson',
- 'Sigurd Johnsen Setså',
- 'Erlend Raa Vågset',
- 'Sofia Hestenes Eika',
- 'Kristoffer Borg Nilsen',
- 'Lars Lismoen',
- 'Yoeri Otten',
- 'Eirik Øygard',
- 'Arne Natskår',
- 'Alexander Alf Iversen',
- 'Henrik Trondseth',
- 'Emil Johannessen',
- 'Andre Normann',
- 'Sofie Nhu Nguyen',
- 'Simen Hauge Østbø',
- 'Stian Munkejord',
- 'Gard Heine Kalland',
- 'Lars Bysheim',
- 'Lars Haukland'
+ const pastMembers: Member[] = [
+ { name: 'Mina Tolfsen' },
+ { name: 'Ask Rud Persson' },
+ { name: 'Palma Rud Persson' },
+ { name: 'Sigurd Johnsen Setså' },
+ { name: 'Erlend Raa Vågset' },
+ { name: 'Sofia Hestenes Eika' },
+ { name: 'Kristoffer Borg Nilsen' },
+ { name: 'Lars Lismoen' },
+ { name: 'Yoeri Otten' },
+ { name: 'Eirik Øygard' },
+ { name: 'Arne Natskår' },
+ { name: 'Alexander Alf Iversen' },
+ { name: 'Henrik Trondseth' },
+ { name: 'Emil Johannessen' },
+ { name: 'Andre Normann' },
+ { name: 'Sofie Nhu Nguyen' },
+ { name: 'Simen Hauge Østbø' },
+ { name: 'Stian Munkejord' },
+ { name: 'Gard Heine Kalland' },
+ { name: 'Lars Bysheim' },
+ { name: 'Lars Haukland' },
+ { name: 'Eirik Rekve Thorsheim' },
+ { name: 'Tony Bao Lam', role: 'Innkjøp', end: 'Vår 2026' },
+ { name: 'Lene Soltveit', role: 'Økonomiansvarlig', end: 'Vår 2026' },
+ { name: 'Ole Magnus Fon Johnsen', role: 'Webansvarlig', end: 'Vår 2026' },
+ { name: 'Fredrik Hast Sørli', role: 'Økonomiassistent', start: 'Høst 2025', end: 'Vår 2026' }
];
@@ -85,8 +92,8 @@
{#each coFounders as founder (founder.name)}
- {founder.name}
- ({founder.role})
+ {founder.name}
+ {founder.role}
{/each}
@@ -98,9 +105,13 @@
## Styremedlemmer
- {#each members as member (member)}
+ {#each members as member (member.name)}
- {member}
+ {member.name}
+ {#if member.start}
+ {member.start} – nå
+ {/if}
{/each}
@@ -112,9 +123,21 @@
## Tidligere Styremedlemmer
- {#each pastMembers as member (member)}
+ {#each pastMembers as member (member.name)}
- {member}
+ {member.name}
+ {#if member.role}
+ {member.role}
+ {/if}
+ {#if member.end}
+
+ {#if member.start}
+ {member.start} – {member.end.toLowerCase()}
+ {:else}
+ Til og med {member.end.toLowerCase()}
+ {/if}
+
+ {/if}
{/each}
From 99f29fbf7269c5256cd108f3771cb6498571f614 Mon Sep 17 00:00:00 2001
From: FluffyFire <62662486+sturla1511@users.noreply.github.com>
Date: Fri, 18 Sep 2026 19:27:01 +0200
Subject: [PATCH 2/4] fixes
---
.../src/routes/(app)/emeritus/+page.svelte | 196 +++++++++++-------
1 file changed, 123 insertions(+), 73 deletions(-)
diff --git a/programmerbar-web/src/routes/(app)/emeritus/+page.svelte b/programmerbar-web/src/routes/(app)/emeritus/+page.svelte
index 8ec0c54e..650c8494 100644
--- a/programmerbar-web/src/routes/(app)/emeritus/+page.svelte
+++ b/programmerbar-web/src/routes/(app)/emeritus/+page.svelte
@@ -14,54 +14,134 @@
{ name: 'Eivind D. Halderaker', role: 'Co-founder' }
];
- type Member = { name: string; role?: string; start?: string; end?: string };
+ type Semester = `Vår ${number}` | `Høst ${number}`;
+ type Period = { start?: Semester; end?: Semester };
+ type Role = {
+ title: string;
+ period?: Period;
+ };
+ type Member = {
+ name: string;
+ membership?: Period;
+ roles: Role[];
+ };
const members: Member[] = [
- { name: 'Erik Fjelltveit Nyhuus' },
- { name: 'Steffen Andre Pettersen' },
- { name: 'Siren Bjorøy' },
- { name: 'Ole Straumland' },
- { name: 'August Ebne Røeggen' },
- { name: 'Sturla Rognskog Mella', start: 'Høst 2025' },
- { name: 'Tord Vikøren Vikestad', start: 'Høst 2025' },
- { name: 'Henrik Sætre Breivik', start: 'Høst 2025' },
- { name: 'Anna Sviland', start: 'Høst 2026' },
- { name: 'Anna Valencia Fari', start: 'Høst 2026' },
- { name: 'Oddmund Gullbrå Steinsland', start: 'Høst 2026' },
- { name: 'Maya Shantseva', start: 'Høst 2026' },
- { name: 'Torjus Berntsen', start: 'Høst 2026' }
+ { name: 'Erik Fjelltveit Nyhuus', roles: [{ title: 'Leder' }] },
+ { name: 'Steffen Andre Pettersen', roles: [{ title: 'Sosialansvarlig' }] },
+ { name: 'Siren Bjorøy', roles: [{ title: 'Bookingansvarlig' }] },
+ { name: 'Ole Straumland', roles: [{ title: 'Frivilligansvarlig' }] },
+ { name: 'August Ebne Røeggen', roles: [{ title: 'Nestleder' }] },
+ {
+ name: 'Sturla Rognskog Mella',
+ membership: { start: 'Høst 2025' },
+ roles: [
+ { title: 'Frivilligansvarlig og webansvarlig', period: { start: 'Vår 2026' } },
+ {
+ title: 'Assistent for frivilligansvarlig',
+ period: { start: 'Høst 2025', end: 'Høst 2025' }
+ }
+ ]
+ },
+ {
+ name: 'Tord Vikøren Vikestad',
+ membership: { start: 'Høst 2025' },
+ roles: [{ title: 'Bygg/admin' }]
+ },
+ {
+ name: 'Henrik Sætre Breivik',
+ membership: { start: 'Høst 2025' },
+ roles: [{ title: 'Quiz- og arrangementsansvarlig' }]
+ },
+ { name: 'Anna Sviland', membership: { start: 'Høst 2026' }, roles: [] },
+ { name: 'Anna Valencia Fari', membership: { start: 'Høst 2026' }, roles: [] },
+ { name: 'Oddmund Gullbrå Steinsland', membership: { start: 'Høst 2026' }, roles: [] },
+ { name: 'Maya Shantseva', membership: { start: 'Høst 2026' }, roles: [] },
+ { name: 'Torjus Berntsen', membership: { start: 'Høst 2026' }, roles: [] }
];
const pastMembers: Member[] = [
- { name: 'Mina Tolfsen' },
- { name: 'Ask Rud Persson' },
- { name: 'Palma Rud Persson' },
- { name: 'Sigurd Johnsen Setså' },
- { name: 'Erlend Raa Vågset' },
- { name: 'Sofia Hestenes Eika' },
- { name: 'Kristoffer Borg Nilsen' },
- { name: 'Lars Lismoen' },
- { name: 'Yoeri Otten' },
- { name: 'Eirik Øygard' },
- { name: 'Arne Natskår' },
- { name: 'Alexander Alf Iversen' },
- { name: 'Henrik Trondseth' },
- { name: 'Emil Johannessen' },
- { name: 'Andre Normann' },
- { name: 'Sofie Nhu Nguyen' },
- { name: 'Simen Hauge Østbø' },
- { name: 'Stian Munkejord' },
- { name: 'Gard Heine Kalland' },
- { name: 'Lars Bysheim' },
- { name: 'Lars Haukland' },
- { name: 'Eirik Rekve Thorsheim' },
- { name: 'Tony Bao Lam', role: 'Innkjøp', end: 'Vår 2026' },
- { name: 'Lene Soltveit', role: 'Økonomiansvarlig', end: 'Vår 2026' },
- { name: 'Ole Magnus Fon Johnsen', role: 'Webansvarlig', end: 'Vår 2026' },
- { name: 'Fredrik Hast Sørli', role: 'Økonomiassistent', start: 'Høst 2025', end: 'Vår 2026' }
+ { name: 'Mina Tolfsen', roles: [] },
+ { name: 'Ask Rud Persson', roles: [] },
+ { name: 'Palma Rud Persson', roles: [] },
+ { name: 'Sigurd Johnsen Setså', roles: [] },
+ { name: 'Erlend Raa Vågset', roles: [] },
+ { name: 'Sofia Hestenes Eika', roles: [] },
+ { name: 'Kristoffer Borg Nilsen', roles: [] },
+ { name: 'Lars Lismoen', roles: [] },
+ { name: 'Yoeri Otten', roles: [] },
+ { name: 'Eirik Øygard', roles: [] },
+ { name: 'Arne Natskår', roles: [] },
+ { name: 'Alexander Alf Iversen', roles: [] },
+ { name: 'Henrik Trondseth', roles: [] },
+ { name: 'Emil Johannessen', roles: [] },
+ { name: 'Andre Normann', roles: [] },
+ { name: 'Sofie Nhu Nguyen', roles: [] },
+ { name: 'Simen Hauge Østbø', roles: [] },
+ { name: 'Stian Munkejord', roles: [] },
+ { name: 'Gard Heine Kalland', roles: [] },
+ { name: 'Lars Bysheim', roles: [] },
+ { name: 'Lars Haukland', roles: [] },
+ { name: 'Eirik Rekve Thorsheim', roles: [] },
+ { name: 'Tony Bao Lam', membership: { end: 'Vår 2026' }, roles: [{ title: 'Innkjøp' }] },
+ {
+ name: 'Lene Soltveit',
+ membership: { end: 'Vår 2026' },
+ roles: [{ title: 'Økonomiansvarlig' }]
+ },
+ {
+ name: 'Ole Magnus Fon Johnsen',
+ membership: { end: 'Vår 2026' },
+ roles: [{ title: 'Webansvarlig' }]
+ },
+ {
+ name: 'Fredrik Hast Sørli',
+ membership: { start: 'Høst 2025', end: 'Vår 2026' },
+ roles: [{ title: 'Økonomiassistent' }]
+ }
];
+
+ function formatPeriod(start?: string, end?: string, current = false): string {
+ if (start && end) return start === end ? start : `${start} – ${end.toLowerCase()}`;
+ if (end) return `Til og med ${end.toLowerCase()}`;
+ if (start) return current ? `${start} – nå` : `Fra ${start.toLowerCase()}`;
+ return '';
+ }
+{#snippet memberList(list: Member[], current: boolean)}
+
+ {#each list as member (member.name)}
+ {@const membershipPeriod = formatPeriod(
+ member.membership?.start,
+ member.membership?.end,
+ current
+ )}
+
+ {member.name}
+ {#if membershipPeriod}
+ I styret: {membershipPeriod}
+ {/if}
+ {#if member.roles.length}
+
+ {#each member.roles as entry}
+ {@const period = formatPeriod(entry.period?.start, entry.period?.end, current)}
+
+ {entry.title}
+ {#if period}
+ {period}
+ {/if}
+
+ {/each}
+
+ {/if}
+
+ {/each}
+
+{/snippet}
+
-
## Styremedlemmer
-
- {#each members as member (member.name)}
-
- {member.name}
- {#if member.start}
- {member.start} – nå
- {/if}
-
- {/each}
-
+ {@render memberList(members, true)}
-
-
+
## Tidligere Styremedlemmer
-
- {#each pastMembers as member (member.name)}
-
- {member.name}
- {#if member.role}
- {member.role}
- {/if}
- {#if member.end}
-
- {#if member.start}
- {member.start} – {member.end.toLowerCase()}
- {:else}
- Til og med {member.end.toLowerCase()}
- {/if}
-
- {/if}
-
- {/each}
-
+ {@render memberList(pastMembers, false)}
From e5789b8de708fb11644464b67f78628e404d2020 Mon Sep 17 00:00:00 2001
From: FluffyFire <62662486+sturla1511@users.noreply.github.com>
Date: Fri, 18 Sep 2026 19:42:28 +0200
Subject: [PATCH 3/4] added member periode liste
---
.../src/routes/(app)/emeritus/+page.svelte | 155 ++++++++++++++----
1 file changed, 123 insertions(+), 32 deletions(-)
diff --git a/programmerbar-web/src/routes/(app)/emeritus/+page.svelte b/programmerbar-web/src/routes/(app)/emeritus/+page.svelte
index 650c8494..e9413ab4 100644
--- a/programmerbar-web/src/routes/(app)/emeritus/+page.svelte
+++ b/programmerbar-web/src/routes/(app)/emeritus/+page.svelte
@@ -46,12 +46,12 @@
{
name: 'Tord Vikøren Vikestad',
membership: { start: 'Høst 2025' },
- roles: [{ title: 'Bygg/admin' }]
+ roles: [{ title: 'Bygg/admin', period: { start: 'Høst 2025' } }]
},
{
name: 'Henrik Sætre Breivik',
membership: { start: 'Høst 2025' },
- roles: [{ title: 'Quiz- og arrangementsansvarlig' }]
+ roles: [{ title: 'Quiz- og arrangementsansvarlig', period: { start: 'Høst 2025' } }]
},
{ name: 'Anna Sviland', membership: { start: 'Høst 2026' }, roles: [] },
{ name: 'Anna Valencia Fari', membership: { start: 'Høst 2026' }, roles: [] },
@@ -83,21 +83,25 @@
{ name: 'Lars Bysheim', roles: [] },
{ name: 'Lars Haukland', roles: [] },
{ name: 'Eirik Rekve Thorsheim', roles: [] },
- { name: 'Tony Bao Lam', membership: { end: 'Vår 2026' }, roles: [{ title: 'Innkjøp' }] },
+ {
+ name: 'Tony Bao Lam',
+ membership: { end: 'Vår 2026' },
+ roles: [{ title: 'Innkjøp', period: { end: 'Vår 2026' } }]
+ },
{
name: 'Lene Soltveit',
membership: { end: 'Vår 2026' },
- roles: [{ title: 'Økonomiansvarlig' }]
+ roles: [{ title: 'Økonomiansvarlig', period: { end: 'Vår 2026' } }]
},
{
name: 'Ole Magnus Fon Johnsen',
membership: { end: 'Vår 2026' },
- roles: [{ title: 'Webansvarlig' }]
+ roles: [{ title: 'Webansvarlig', period: { end: 'Vår 2026' } }]
},
{
name: 'Fredrik Hast Sørli',
membership: { start: 'Høst 2025', end: 'Vår 2026' },
- roles: [{ title: 'Økonomiassistent' }]
+ roles: [{ title: 'Økonomiassistent', period: { start: 'Høst 2025', end: 'Vår 2026' } }]
}
];
@@ -107,11 +111,54 @@
if (start) return current ? `${start} – nå` : `Fra ${start.toLowerCase()}`;
return '';
}
+
+ // Spring comes before autumn within a calendar year.
+ function semesterIndex(semester: Semester): number {
+ const [season, year] = semester.split(' ');
+ return Number(year) * 2 + (season === 'Høst' ? 1 : 0);
+ }
+
+ function overlapsYear(period: Period | undefined, year: number, current: boolean): boolean {
+ if (!period?.start && !period?.end) return false;
+ // An unknown boundary must not imply membership in earlier/later years.
+ const start = semesterIndex((period.start ?? period.end)!);
+ const end = period.end ? semesterIndex(period.end) : current ? Infinity : start;
+ return start <= (year + 1) * 2 && end >= year * 2 + 1;
+ }
+
+ const allMembers = [...members, ...pastMembers];
+ const recordedYears = allMembers.flatMap((member) =>
+ [member.membership, ...member.roles.map((role) => role.period)].flatMap((period) =>
+ [period?.start, period?.end].flatMap((semester) =>
+ semester ? [Math.floor((semesterIndex(semester) - 1) / 2)] : []
+ )
+ )
+ );
+ const boardYears = recordedYears.length
+ ? Array.from(
+ { length: Math.max(...recordedYears) - Math.min(...recordedYears) + 1 },
+ (_, index) => Math.max(...recordedYears) - index
+ )
+ : [];
+ let selectedYear = $state('all');
+ const filteredMembers = $derived(
+ allMembers.filter((member) => {
+ const current = members.includes(member);
+ return (
+ overlapsYear(member.membership, Number(selectedYear), current) ||
+ member.roles.some((role) => overlapsYear(role.period, Number(selectedYear), current))
+ );
+ })
+ );
-{#snippet memberList(list: Member[], current: boolean)}
+{#snippet memberList(list: Member[])}
{#each list as member (member.name)}
+ {@const current = members.includes(member)}
+ {@const roles = member.roles.filter(
+ (role) => selectedYear === 'all' || overlapsYear(role.period, Number(selectedYear), current)
+ )}
{@const membershipPeriod = formatPeriod(
member.membership?.start,
member.membership?.end,
@@ -124,9 +171,9 @@
>I styret: {membershipPeriod}
{/if}
- {#if member.roles.length}
+ {#if roles.length}
- {#each member.roles as entry}
+ {#each roles as entry}
{@const period = formatPeriod(entry.period?.start, entry.period?.end, current)}
{entry.title}
@@ -164,34 +211,78 @@
-
-
- ## Co-founders
-
-
- {#each coFounders as founder (founder.name)}
-
- {founder.name}
- {founder.role}
-
+ Vis styre
+
+ Hele listen
+ {#each boardYears as year (year)}
+ Høst {year} – vår {year + 1}
{/each}
-
+
+
+ Historikken er ikke komplett. Flere kan ha sittet i styret i de ulike semestrene uten å
+ være lagt til her ennå.
+ {#if selectedYear !== 'all'}
+ Utvalget viser medlemmer med registrert periode i dette styreåret. Medlemmer uten
+ kjent periode finner du i hele listen.
+ {/if}
+
+ {#if selectedYear === 'all'}
+
+
+
+ ## Co-founders
+
+
+ {#each coFounders as founder (founder.name)}
+
+ {founder.name}
+ {founder.role}
+
+ {/each}
+
+
-
-
- ## Styremedlemmer
-
- {@render memberList(members, true)}
-
+
+
+ ## Styremedlemmer
+
+ {@render memberList(members)}
+
-
-
- ## Tidligere Styremedlemmer
-
- {@render memberList(pastMembers, false)}
-
+
+
+ ## Tidligere Styremedlemmer
+
+ {@render memberList(pastMembers)}
+
+ {:else}
+
+
+ ## Høst {selectedYear} – vår {Number(
+ selectedYear
+ ) + 1}
+
+
+ {filteredMembers.length} registrerte medlemmer i løpet av styreåret
+
+ {#if filteredMembers.length}
+ {@render memberList(filteredMembers)}
+ {:else}
+
+ Ingen medlemmer er registrert med periode i dette styreåret ennå.
+
+ {/if}
+
+ {/if}
From 1b5426853209ad1ed51cca58fbfa079fe05ea490 Mon Sep 17 00:00:00 2001
From: FluffyFire <62662486+sturla1511@users.noreply.github.com>
Date: Sat, 19 Sep 2026 18:25:21 +0200
Subject: [PATCH 4/4] changed emeritus
---
.../migrations/0029_dear_mattie_franklin.sql | 56 +
.../migrations/meta/0029_snapshot.json | 1208 +++++++++++++++++
.../migrations/meta/_journal.json | 9 +-
programmerbar-web/src/app.d.ts | 1 +
programmerbar-web/src/hooks.server.ts | 2 +
.../src/lib/board-history.test.ts | 120 ++
programmerbar-web/src/lib/board-history.ts | 109 ++
.../components/app/BoardMemberEditor.svelte | 214 +++
.../lib/server/db/schemas/board-members.ts | 12 +
.../src/lib/server/db/schemas/index.ts | 1 +
.../services/board-history.service.test.ts | 86 ++
.../server/services/board-history.service.ts | 54 +
.../src/routes/(app)/emeritus/+page.server.ts | 62 +
.../src/routes/(app)/emeritus/+page.svelte | 328 ++---
.../routes/(app)/emeritus/page.server.test.ts | 66 +
15 files changed, 2164 insertions(+), 164 deletions(-)
create mode 100644 programmerbar-web/migrations/0029_dear_mattie_franklin.sql
create mode 100644 programmerbar-web/migrations/meta/0029_snapshot.json
create mode 100644 programmerbar-web/src/lib/board-history.test.ts
create mode 100644 programmerbar-web/src/lib/board-history.ts
create mode 100644 programmerbar-web/src/lib/components/app/BoardMemberEditor.svelte
create mode 100644 programmerbar-web/src/lib/server/db/schemas/board-members.ts
create mode 100644 programmerbar-web/src/lib/server/services/board-history.service.test.ts
create mode 100644 programmerbar-web/src/lib/server/services/board-history.service.ts
create mode 100644 programmerbar-web/src/routes/(app)/emeritus/+page.server.ts
create mode 100644 programmerbar-web/src/routes/(app)/emeritus/page.server.test.ts
diff --git a/programmerbar-web/migrations/0029_dear_mattie_franklin.sql b/programmerbar-web/migrations/0029_dear_mattie_franklin.sql
new file mode 100644
index 00000000..e812fe1a
--- /dev/null
+++ b/programmerbar-web/migrations/0029_dear_mattie_franklin.sql
@@ -0,0 +1,56 @@
+CREATE TABLE `board_member` (
+ `id` text PRIMARY KEY NOT NULL,
+ `name` text NOT NULL,
+ `category` text NOT NULL,
+ `membership` text,
+ `roles` text NOT NULL,
+ `sort_order` integer DEFAULT 0 NOT NULL,
+ `revision` integer DEFAULT 1 NOT NULL
+);
+
+--> statement-breakpoint
+-- Preserve the existing public history when moving it into D1.
+INSERT INTO board_member (id, name, category, membership, roles, sort_order) VALUES
+('board-history-001', 'Brigt A.T Håvardstun', 'founder', NULL, '[{"title":"Co-founder"}]', 0),
+('board-history-002', 'Sandra Lekve', 'founder', NULL, '[{"title":"Co-founder"}]', 1),
+('board-history-003', 'Vegard Jensløkken', 'founder', NULL, '[{"title":"Co-founder"}]', 2),
+('board-history-004', 'Eivind D. Halderaker', 'founder', NULL, '[{"title":"Co-founder"}]', 3),
+('board-history-005', 'Erik Fjelltveit Nyhuus', 'current', NULL, '[{"title":"Leder"}]', 4),
+('board-history-006', 'Steffen Andre Pettersen', 'current', NULL, '[{"title":"Sosialansvarlig"}]', 5),
+('board-history-007', 'Siren Bjorøy', 'current', NULL, '[{"title":"Bookingansvarlig"}]', 6),
+('board-history-008', 'Ole Straumland', 'current', NULL, '[{"title":"Frivilligansvarlig"}]', 7),
+('board-history-009', 'August Ebne Røeggen', 'current', NULL, '[{"title":"Nestleder"}]', 8),
+('board-history-010', 'Sturla Rognskog Mella', 'current', '{"start":"Høst 2025"}', '[{"title":"Frivilligansvarlig og webansvarlig","period":{"start":"Vår 2026"}},{"title":"Assistent for frivilligansvarlig","period":{"start":"Høst 2025","end":"Høst 2025"}}]', 9),
+('board-history-011', 'Tord Vikøren Vikestad', 'current', '{"start":"Høst 2025"}', '[{"title":"Bygg/admin","period":{"start":"Høst 2025"}}]', 10),
+('board-history-012', 'Henrik Sætre Breivik', 'current', '{"start":"Høst 2025"}', '[{"title":"Quiz- og arrangementsansvarlig","period":{"start":"Høst 2025"}}]', 11),
+('board-history-013', 'Anna Sviland', 'current', '{"start":"Høst 2026"}', '[]', 12),
+('board-history-014', 'Anna Valencia Fari', 'current', '{"start":"Høst 2026"}', '[]', 13),
+('board-history-015', 'Oddmund Gullbrå Steinsland', 'current', '{"start":"Høst 2026"}', '[]', 14),
+('board-history-016', 'Maya Shantseva', 'current', '{"start":"Høst 2026"}', '[]', 15),
+('board-history-017', 'Torjus Berntsen', 'current', '{"start":"Høst 2026"}', '[]', 16),
+('board-history-018', 'Mina Tolfsen', 'past', NULL, '[]', 17),
+('board-history-019', 'Ask Rud Persson', 'past', NULL, '[]', 18),
+('board-history-020', 'Palma Rud Persson', 'past', NULL, '[]', 19),
+('board-history-021', 'Sigurd Johnsen Setså', 'past', NULL, '[]', 20),
+('board-history-022', 'Erlend Raa Vågset', 'past', NULL, '[]', 21),
+('board-history-023', 'Sofia Hestenes Eika', 'past', NULL, '[]', 22),
+('board-history-024', 'Kristoffer Borg Nilsen', 'past', NULL, '[]', 23),
+('board-history-025', 'Lars Lismoen', 'past', NULL, '[]', 24),
+('board-history-026', 'Yoeri Otten', 'past', NULL, '[]', 25),
+('board-history-027', 'Eirik Øygard', 'past', NULL, '[]', 26),
+('board-history-028', 'Arne Natskår', 'past', NULL, '[]', 27),
+('board-history-029', 'Alexander Alf Iversen', 'past', NULL, '[]', 28),
+('board-history-030', 'Henrik Trondseth', 'past', NULL, '[]', 29),
+('board-history-031', 'Emil Johannessen', 'past', NULL, '[]', 30),
+('board-history-032', 'Andre Normann', 'past', NULL, '[]', 31),
+('board-history-033', 'Sofie Nhu Nguyen', 'past', NULL, '[]', 32),
+('board-history-034', 'Simen Hauge Østbø', 'past', NULL, '[]', 33),
+('board-history-035', 'Stian Munkejord', 'past', NULL, '[]', 34),
+('board-history-036', 'Gard Heine Kalland', 'past', NULL, '[]', 35),
+('board-history-037', 'Lars Bysheim', 'past', NULL, '[]', 36),
+('board-history-038', 'Lars Haukland', 'past', NULL, '[]', 37),
+('board-history-039', 'Eirik Rekve Thorsheim', 'past', NULL, '[]', 38),
+('board-history-040', 'Tony Bao Lam', 'past', '{"end":"Vår 2026"}', '[{"title":"Innkjøp","period":{"end":"Vår 2026"}}]', 39),
+('board-history-041', 'Lene Soltveit', 'past', '{"end":"Vår 2026"}', '[{"title":"Økonomiansvarlig","period":{"end":"Vår 2026"}}]', 40),
+('board-history-042', 'Ole Magnus Fon Johnsen', 'past', '{"end":"Vår 2026"}', '[{"title":"Webansvarlig","period":{"end":"Vår 2026"}}]', 41),
+('board-history-043', 'Fredrik Hast Sørli', 'past', '{"start":"Høst 2025","end":"Vår 2026"}', '[{"title":"Økonomiassistent","period":{"start":"Høst 2025","end":"Vår 2026"}}]', 42);
diff --git a/programmerbar-web/migrations/meta/0029_snapshot.json b/programmerbar-web/migrations/meta/0029_snapshot.json
new file mode 100644
index 00000000..11930225
--- /dev/null
+++ b/programmerbar-web/migrations/meta/0029_snapshot.json
@@ -0,0 +1,1208 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "7cf34021-def4-4271-a8d3-ee7ef8f93136",
+ "prevId": "e982dbc1-a960-4bbf-ad49-b48bdc24d6d8",
+ "tables": {
+ "claimed_credits": {
+ "name": "claimed_credits",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "product_id": {
+ "name": "product_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credit_cost": {
+ "name": "credit_cost",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "claimed_credits_user_id_user_id_fk": {
+ "name": "claimed_credits_user_id_user_id_fk",
+ "tableFrom": "claimed_credits",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "board_member": {
+ "name": "board_member",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "membership": {
+ "name": "membership",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "roles": {
+ "name": "roles",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contact_submission": {
+ "name": "contact_submission",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "submitted_at": {
+ "name": "submitted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "event": {
+ "name": "event",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "event_slug_idx": {
+ "name": "event_slug_idx",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invitation": {
+ "name": "invitation",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "claimed_at": {
+ "name": "claimed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification": {
+ "name": "notification",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notifications_user_id": {
+ "name": "idx_notifications_user_id",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_archived_at": {
+ "name": "idx_notifications_archived_at",
+ "columns": [
+ "archived_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "notification_user_id_user_id_fk": {
+ "name": "notification_user_id_user_id_fk",
+ "tableFrom": "notification",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "push_subscription": {
+ "name": "push_subscription",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "p256dh": {
+ "name": "p256dh",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth": {
+ "name": "auth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "push_subscription_endpoint_unique": {
+ "name": "push_subscription_endpoint_unique",
+ "columns": [
+ "endpoint"
+ ],
+ "isUnique": true
+ },
+ "idx_push_subscriptions_user_id": {
+ "name": "idx_push_subscriptions_user_id",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "push_subscription_user_id_user_id_fk": {
+ "name": "push_subscription_user_id_user_id_fk",
+ "tableFrom": "push_subscription",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "shift": {
+ "name": "shift",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_at": {
+ "name": "start_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "end_at": {
+ "name": "end_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "shift_event_id_event_id_fk": {
+ "name": "shift_event_id_event_id_fk",
+ "tableFrom": "shift",
+ "tableTo": "event",
+ "columnsFrom": [
+ "event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user_shift": {
+ "name": "user_shift",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "shift_id": {
+ "name": "shift_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_beer_claimed": {
+ "name": "is_beer_claimed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'accepted'"
+ }
+ },
+ "indexes": {
+ "user_shift_user_id_shift_id_unique": {
+ "name": "user_shift_user_id_shift_id_unique",
+ "columns": [
+ "user_id",
+ "shift_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "user_shift_user_id_user_id_fk": {
+ "name": "user_shift_user_id_user_id_fk",
+ "tableFrom": "user_shift",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_shift_shift_id_shift_id_fk": {
+ "name": "user_shift_shift_id_shift_id_fk",
+ "tableFrom": "user_shift",
+ "tableTo": "shift",
+ "columnsFrom": [
+ "shift_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "feide_id": {
+ "name": "feide_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'normal'"
+ },
+ "additional_beers": {
+ "name": "additional_beers",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "alt_email": {
+ "name": "alt_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_deleted": {
+ "name": "is_deleted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_trained": {
+ "name": "is_trained",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "can_refer": {
+ "name": "can_refer",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {
+ "email_idx": {
+ "name": "email_idx",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ },
+ "feide_id_idx": {
+ "name": "feide_id_idx",
+ "columns": [
+ "feide_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "producers": {
+ "name": "producers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "image_id": {
+ "name": "image_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "product_types": {
+ "name": "product_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "products": {
+ "name": "products",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sku": {
+ "name": "sku",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_sold_out": {
+ "name": "is_sold_out",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "ordinary_price": {
+ "name": "ordinary_price",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "student_price": {
+ "name": "student_price",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "internal_price": {
+ "name": "internal_price",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credits": {
+ "name": "credits",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "volume": {
+ "name": "volume",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "alcohol_content": {
+ "name": "alcohol_content",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "variants": {
+ "name": "variants",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_id": {
+ "name": "image_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "producer_id": {
+ "name": "producer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "products_producer_id_producers_id_fk": {
+ "name": "products_producer_id_producers_id_fk",
+ "tableFrom": "products",
+ "tableTo": "producers",
+ "columnsFrom": [
+ "producer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "product_product_types": {
+ "name": "product_product_types",
+ "columns": {
+ "product_id": {
+ "name": "product_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "product_type_id": {
+ "name": "product_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "product_product_types_product_id_products_id_fk": {
+ "name": "product_product_types_product_id_products_id_fk",
+ "tableFrom": "product_product_types",
+ "tableTo": "products",
+ "columnsFrom": [
+ "product_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "product_product_types_product_type_id_product_types_id_fk": {
+ "name": "product_product_types_product_type_id_product_types_id_fk",
+ "tableFrom": "product_product_types",
+ "tableTo": "product_types",
+ "columnsFrom": [
+ "product_type_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "images": {
+ "name": "images",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "referral": {
+ "name": "referral",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "referred_by": {
+ "name": "referred_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "referred": {
+ "name": "referred",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "referral_referred_by_user_id_fk": {
+ "name": "referral_referred_by_user_id_fk",
+ "tableFrom": "referral",
+ "tableTo": "user",
+ "columnsFrom": [
+ "referred_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "referral_referred_user_id_fk": {
+ "name": "referral_referred_user_id_fk",
+ "tableFrom": "referral",
+ "tableTo": "user",
+ "columnsFrom": [
+ "referred"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "pending_application": {
+ "name": "pending_application",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "feide_id": {
+ "name": "feide_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "pending_application_email_idx": {
+ "name": "pending_application_email_idx",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ },
+ "pending_application_feide_id_idx": {
+ "name": "pending_application_feide_id_idx",
+ "columns": [
+ "feide_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/programmerbar-web/migrations/meta/_journal.json b/programmerbar-web/migrations/meta/_journal.json
index 76f030a8..dceb5387 100644
--- a/programmerbar-web/migrations/meta/_journal.json
+++ b/programmerbar-web/migrations/meta/_journal.json
@@ -190,6 +190,13 @@
"when": 1789595596194,
"tag": "0026_thankful_kylun",
"breakpoints": true
+ },
+ {
+ "idx": 29,
+ "version": "6",
+ "when": 1789753789061,
+ "tag": "0029_dear_mattie_franklin",
+ "breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/programmerbar-web/src/app.d.ts b/programmerbar-web/src/app.d.ts
index a8617d95..48f71129 100644
--- a/programmerbar-web/src/app.d.ts
+++ b/programmerbar-web/src/app.d.ts
@@ -19,6 +19,7 @@ declare global {
feideProvider: import('$lib/server/auth/feide').FeideProvider;
// Serivces
+ boardHistoryService: import('$lib/server/services/board-history.service').BoardHistoryService;
emailService: import('$lib/server/services/email.service').EmailService;
statusService: import('$lib/server/services/status.service').StatusService;
rateLimitService: import('$lib/server/services/rate-limit.service').RateLimitService;
diff --git a/programmerbar-web/src/hooks.server.ts b/programmerbar-web/src/hooks.server.ts
index dfe34533..16cfb011 100644
--- a/programmerbar-web/src/hooks.server.ts
+++ b/programmerbar-web/src/hooks.server.ts
@@ -1,4 +1,5 @@
import { createAuth } from '$lib/server/auth/lucia';
+import { BoardHistoryService } from '$lib/server/services/board-history.service';
import { FeideProvider } from '$lib/server/auth/feide';
import { createDatabase } from '$lib/server/db/drizzle';
import { BanService } from '$lib/server/services/ban.service';
@@ -50,6 +51,7 @@ const setup: Handle = async ({ event, resolve }) => {
// Setup database
const db = createDatabase(DB);
event.locals.db = db;
+ event.locals.boardHistoryService = new BoardHistoryService(db);
// Setup auth
const auth = createAuth(db);
diff --git a/programmerbar-web/src/lib/board-history.test.ts b/programmerbar-web/src/lib/board-history.test.ts
new file mode 100644
index 00000000..c838043e
--- /dev/null
+++ b/programmerbar-web/src/lib/board-history.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from 'vitest';
+import {
+ boardMemberSchema,
+ getBoardYears,
+ overlapsYear,
+ sortByFirstMembership,
+ visibleRoles,
+ type BoardMemberInput
+} from './board-history';
+
+describe('board history display', () => {
+ const member: BoardMemberInput = {
+ name: 'Test',
+ category: 'current',
+ roles: [
+ { title: 'Assistent', period: { start: 'Høst 2025', end: 'Høst 2025' } },
+ { title: 'Ansvarlig', period: { start: 'Vår 2026' } },
+ { title: 'Fremtidig verv', period: { start: 'Vår 2027' } }
+ ]
+ };
+ it('puts unknown dates first then sorts by the earliest known start without mutating input', () => {
+ const list: BoardMemberInput[] = [
+ { name: 'Ukjent', category: 'past', roles: [], membership: { end: 'Vår 2025' } },
+ { name: 'Ny', category: 'current', roles: [], membership: { start: 'Høst 2026' } },
+ { ...member, membership: { start: 'Høst 2026' } }
+ ];
+ expect(sortByFirstMembership(list).map((entry) => entry.name)).toEqual([
+ 'Ukjent',
+ 'Test',
+ 'Ny'
+ ]);
+ expect(list[0].name).toBe('Ukjent');
+ });
+ it('shows only active roles by default and expands the full history on request', () => {
+ const now = new Date('2026-09-19T12:00:00Z');
+ expect(visibleRoles(member, 'all', false, now).map((role) => role.title)).toEqual([
+ 'Ansvarlig'
+ ]);
+ expect(visibleRoles(member, 'all', true, now)).toEqual(member.roles);
+ expect(visibleRoles({ ...member, category: 'past' }, 'all', false, now)).toEqual(member.roles);
+ });
+ it('keeps year filtering independent of the current-role display', () => {
+ expect(visibleRoles(member, '2025', false).map((role) => role.title)).toEqual([
+ 'Assistent',
+ 'Ansvarlig'
+ ]);
+ expect(visibleRoles(member, '2026', true).map((role) => role.title)).toEqual([
+ 'Ansvarlig',
+ 'Fremtidig verv'
+ ]);
+ });
+});
+
+describe('board history validation and periods', () => {
+ it('allows unknown dates and overlapping roles', () => {
+ expect(
+ boardMemberSchema.safeParse({ name: 'Medlem', category: 'past', roles: [] }).success
+ ).toBe(true);
+ expect(
+ boardMemberSchema.safeParse({
+ name: 'Medlem',
+ category: 'current',
+ roles: [
+ { title: 'Leder', period: { start: 'Høst 2025' } },
+ { title: 'Webansvarlig', period: { start: 'Høst 2025' } }
+ ]
+ }).success
+ ).toBe(true);
+ });
+
+ it('rejects invalid semesters, backwards periods and current membership with an end', () => {
+ for (const membership of [
+ { start: 'Sommer 2025' },
+ { start: 'Høst 2026', end: 'Vår 2026' },
+ { start: 'Høst 20250' }
+ ])
+ expect(
+ boardMemberSchema.safeParse({ name: 'Medlem', category: 'past', membership, roles: [] })
+ .success
+ ).toBe(false);
+ expect(
+ boardMemberSchema.safeParse({
+ name: 'Medlem',
+ category: 'current',
+ membership: { end: 'Vår 2026' },
+ roles: []
+ }).success
+ ).toBe(false);
+ expect(
+ boardMemberSchema.safeParse({
+ name: 'Medlem',
+ category: 'past',
+ roles: [{ title: 'Leder', period: { start: 'Høst 2026', end: 'Vår 2025' } }]
+ }).success
+ ).toBe(false);
+ });
+
+ it('filters completed roles and does not invent unknown membership boundaries', () => {
+ expect(overlapsYear({ start: 'Høst 2025', end: 'Vår 2026' }, 2025, false)).toBe(true);
+ expect(overlapsYear({ start: 'Høst 2025', end: 'Vår 2026' }, 2026, false)).toBe(false);
+ expect(overlapsYear({ start: 'Høst 2025' }, 2026, true)).toBe(true);
+ expect(overlapsYear({ start: 'Høst 2025' }, 2026, false)).toBe(false);
+ expect(overlapsYear({ end: 'Vår 2026' }, 2024, false)).toBe(false);
+ expect(overlapsYear(undefined, 2025, true)).toBe(false);
+ });
+
+ it('adds future board years when dates are registered', () => {
+ expect(
+ getBoardYears([
+ {
+ name: 'Medlem',
+ category: 'past',
+ membership: { start: 'Vår 2026', end: 'Høst 2027' },
+ roles: []
+ }
+ ])
+ ).toEqual([2027, 2026, 2025]);
+ expect(getBoardYears([])).toEqual([]);
+ });
+});
diff --git a/programmerbar-web/src/lib/board-history.ts b/programmerbar-web/src/lib/board-history.ts
new file mode 100644
index 00000000..b9518d02
--- /dev/null
+++ b/programmerbar-web/src/lib/board-history.ts
@@ -0,0 +1,109 @@
+import { z } from 'zod';
+
+export type Semester = `Vår ${number}` | `Høst ${number}`;
+
+export function semesterIndex(semester: Semester): number {
+ const [season, year] = semester.split(' ');
+ return Number(year) * 2 + (season === 'Høst' ? 1 : 0);
+}
+
+const semesterSchema = z
+ .string()
+ .regex(/^(Vår|Høst) (19|20|21)\d{2}$/, 'Bruk for eksempel Høst 2025 eller Vår 2026.')
+ .transform((value) => value as Semester);
+
+const periodSchema = z
+ .object({
+ start: semesterSchema.optional(),
+ end: semesterSchema.optional()
+ })
+ .refine(
+ (period) =>
+ !period.start || !period.end || semesterIndex(period.start) <= semesterIndex(period.end),
+ {
+ message: 'Sluttsemester kan ikke være før startsemester.'
+ }
+ );
+
+export const boardMemberSchema = z
+ .object({
+ name: z.string().trim().min(1, 'Skriv inn et navn.').max(150),
+ category: z.enum(['current', 'past', 'founder']),
+ membership: periodSchema.optional(),
+ roles: z
+ .array(
+ z.object({
+ title: z.string().trim().min(1, 'Skriv inn navnet på vervet.').max(150),
+ period: periodSchema.optional()
+ })
+ )
+ .max(30)
+ })
+ .refine((member) => member.category !== 'current' || !member.membership?.end, {
+ message: 'Velg tidligere medlem når medlemsperioden har et sluttsemester.'
+ });
+
+export type BoardMemberInput = z.infer;
+export type Period = NonNullable;
+export type BoardMember = BoardMemberInput & { id: string; revision: number; sortOrder: number };
+
+export function sortByFirstMembership(members: T[]): T[] {
+ function firstStart(member: T): number {
+ const starts = [member.membership?.start, ...member.roles.map((role) => role.period?.start)]
+ .filter((start): start is Semester => !!start)
+ .map(semesterIndex);
+ return starts.length ? Math.min(...starts) : -Infinity;
+ }
+ // Stable sorting preserves the existing order for equal or unknown start dates.
+ return [...members].sort((a, b) => firstStart(a) - firstStart(b));
+}
+
+export function visibleRoles(
+ member: BoardMemberInput,
+ year: string,
+ history: boolean,
+ now = new Date()
+): BoardMemberInput['roles'] {
+ if (year !== 'all')
+ return member.roles.filter((role) =>
+ overlapsYear(role.period, Number(year), member.category === 'current')
+ );
+ if (member.category !== 'current' || history) return member.roles;
+ const semester = now.getUTCFullYear() * 2 + (now.getUTCMonth() >= 6 ? 1 : 0);
+ return member.roles.filter(
+ (role) =>
+ (!role.period?.start || semesterIndex(role.period.start) <= semester) &&
+ (!role.period?.end || semesterIndex(role.period.end) >= semester)
+ );
+}
+
+export function formatPeriod(start?: string, end?: string, current = false): string {
+ if (start && end) return start === end ? start : `${start} – ${end.toLowerCase()}`;
+ if (end) return `Til og med ${end.toLowerCase()}`;
+ if (start) return current ? `${start} – nå` : `Fra ${start.toLowerCase()}`;
+ return '';
+}
+
+export function overlapsYear(period: Period | undefined, year: number, current: boolean): boolean {
+ if (!period?.start && !period?.end) return false;
+ // Unknown boundaries must not imply membership in earlier/later years.
+ const start = semesterIndex((period.start ?? period.end)!);
+ const end = period.end ? semesterIndex(period.end) : current ? Infinity : start;
+ return start <= (year + 1) * 2 && end >= year * 2 + 1;
+}
+
+export function getBoardYears(members: BoardMemberInput[]): number[] {
+ const years = members.flatMap((member) =>
+ [member.membership, ...member.roles.map((role) => role.period)].flatMap((period) =>
+ [period?.start, period?.end].flatMap((semester) =>
+ semester ? [Math.floor((semesterIndex(semester) - 1) / 2)] : []
+ )
+ )
+ );
+ return years.length
+ ? Array.from(
+ { length: Math.max(...years) - Math.min(...years) + 1 },
+ (_, index) => Math.max(...years) - index
+ )
+ : [];
+}
diff --git a/programmerbar-web/src/lib/components/app/BoardMemberEditor.svelte b/programmerbar-web/src/lib/components/app/BoardMemberEditor.svelte
new file mode 100644
index 00000000..659ae739
--- /dev/null
+++ b/programmerbar-web/src/lib/components/app/BoardMemberEditor.svelte
@@ -0,0 +1,214 @@
+
+
+
+ {member ? 'Rediger medlem' : 'Legg til medlem'}
+
+ La ukjente semestre stå tomme. Bruk «Høst 2025» eller «Vår 2026». Endringene blir synlige for
+ alle når du lagrer.
+
+ {#if message}{message}
{/if}
+
+ {#if member}
+ {#if confirmDelete}
+
+ {:else}
+ (confirmDelete = true)}>Slett oppføringen
+ {/if}
+ {/if}
+
diff --git a/programmerbar-web/src/lib/server/db/schemas/board-members.ts b/programmerbar-web/src/lib/server/db/schemas/board-members.ts
new file mode 100644
index 00000000..0e21a65d
--- /dev/null
+++ b/programmerbar-web/src/lib/server/db/schemas/board-members.ts
@@ -0,0 +1,12 @@
+import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
+import type { BoardMemberInput, Period } from '../../../board-history';
+
+export const boardMembers = sqliteTable('board_member', {
+ id: text().primaryKey().notNull(),
+ name: text().notNull(),
+ category: text({ enum: ['current', 'past', 'founder'] }).notNull(),
+ membership: text({ mode: 'json' }).$type(),
+ roles: text({ mode: 'json' }).$type().notNull(),
+ sortOrder: integer().notNull().default(0),
+ revision: integer().notNull().default(1)
+});
diff --git a/programmerbar-web/src/lib/server/db/schemas/index.ts b/programmerbar-web/src/lib/server/db/schemas/index.ts
index e84f8a07..dd5c632f 100644
--- a/programmerbar-web/src/lib/server/db/schemas/index.ts
+++ b/programmerbar-web/src/lib/server/db/schemas/index.ts
@@ -1,4 +1,5 @@
export * from './claimed-credit';
+export * from './board-members';
export * from './contact-submissions';
export * from './events';
export * from './invitations';
diff --git a/programmerbar-web/src/lib/server/services/board-history.service.test.ts b/programmerbar-web/src/lib/server/services/board-history.service.test.ts
new file mode 100644
index 00000000..0d62147f
--- /dev/null
+++ b/programmerbar-web/src/lib/server/services/board-history.service.test.ts
@@ -0,0 +1,86 @@
+import { readFileSync } from 'node:fs';
+import { DatabaseSync } from 'node:sqlite';
+import { drizzle } from 'drizzle-orm/sqlite-proxy';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { boardMemberSchema } from '../../board-history';
+import type { Database } from '../db/drizzle';
+import { BoardHistoryService } from './board-history.service';
+
+describe('board history persistence', () => {
+ let sqlite: DatabaseSync;
+ let service: BoardHistoryService;
+ beforeEach(() => {
+ sqlite = new DatabaseSync(':memory:');
+ sqlite.exec(
+ readFileSync(
+ new URL('../../../../migrations/0029_dear_mattie_franklin.sql', import.meta.url),
+ 'utf8'
+ )
+ );
+ const db = drizzle(
+ async (query, params) => {
+ const statement = sqlite.prepare(query);
+ statement.setReturnArrays(true);
+ return { rows: statement.all(...params) };
+ },
+ undefined,
+ { casing: 'snake_case' }
+ );
+ service = new BoardHistoryService(db as unknown as Database);
+ });
+ afterEach(() => sqlite.close());
+
+ it('migrates all 43 existing people, categories and role history', async () => {
+ const members = await service.getAll();
+ expect(members).toHaveLength(43);
+ expect(members.filter((member) => member.category === 'founder')).toHaveLength(4);
+ expect(members.filter((member) => member.category === 'current')).toHaveLength(13);
+ for (const member of members) expect(boardMemberSchema.safeParse(member).success).toBe(true);
+ expect(members.find((member) => member.name === 'Sturla Rognskog Mella')?.roles).toHaveLength(
+ 2
+ );
+ expect(members.find((member) => member.name === 'Fredrik Hast Sørli')?.membership).toEqual({
+ start: 'Høst 2025',
+ end: 'Vår 2026'
+ });
+ });
+
+ it('creates, moves and clears a member without changing other people', async () => {
+ const before = await service.getAll();
+ const id = await service.create({
+ name: 'Testperson',
+ category: 'current',
+ membership: { start: 'Høst 2026' },
+ roles: [{ title: 'Leder', period: { start: 'Høst 2026' } }]
+ });
+ expect((await service.getAll()).find((member) => member.id === id)?.roles[0].title).toBe(
+ 'Leder'
+ );
+ expect(await service.update(id, 1, { name: 'Nytt navn', category: 'past', roles: [] })).toBe(
+ true
+ );
+ const after = await service.getAll();
+ expect(after.find((member) => member.id === id)).toMatchObject({
+ name: 'Nytt navn',
+ category: 'past',
+ revision: 2,
+ roles: [],
+ membership: undefined
+ });
+ expect(after.filter((member) => member.id !== id)).toEqual(before);
+ });
+
+ it('rejects stale updates/deletes and deletes only the chosen entry', async () => {
+ const id = await service.create({ name: 'Testperson', category: 'current', roles: [] });
+ expect(await service.update(id, 1, { name: 'Oppdatert', category: 'current', roles: [] })).toBe(
+ true
+ );
+ expect(await service.update(id, 1, { name: 'Utdatert', category: 'past', roles: [] })).toBe(
+ false
+ );
+ expect(await service.delete(id, 1)).toBe(false);
+ expect(await service.delete(id, 2)).toBe(true);
+ expect(await service.delete(id, 2)).toBe(false);
+ expect(await service.getAll()).toHaveLength(43);
+ });
+});
diff --git a/programmerbar-web/src/lib/server/services/board-history.service.ts b/programmerbar-web/src/lib/server/services/board-history.service.ts
new file mode 100644
index 00000000..5304b4da
--- /dev/null
+++ b/programmerbar-web/src/lib/server/services/board-history.service.ts
@@ -0,0 +1,54 @@
+import { and, asc, eq, max, sql } from 'drizzle-orm';
+import { nanoid } from 'nanoid';
+import type { BoardMember, BoardMemberInput } from '../../board-history';
+import type { Database } from '../db/drizzle';
+import { boardMembers } from '../db/schemas/board-members';
+
+export class BoardHistoryService {
+ #db: Database;
+
+ constructor(db: Database) {
+ this.#db = db;
+ }
+
+ async getAll(): Promise {
+ const rows = await this.#db
+ .select()
+ .from(boardMembers)
+ .orderBy(asc(boardMembers.sortOrder), asc(boardMembers.id));
+ return rows.map((row) => ({ ...row, membership: row.membership ?? undefined }));
+ }
+
+ async create(input: BoardMemberInput): Promise {
+ const [last] = await this.#db.select({ order: max(boardMembers.sortOrder) }).from(boardMembers);
+ const id = nanoid();
+ await this.#db.insert(boardMembers).values({
+ ...input,
+ id,
+ membership: input.membership ?? null,
+ sortOrder: (last.order ?? -1) + 1
+ });
+ return id;
+ }
+
+ async update(id: string, revision: number, input: BoardMemberInput): Promise {
+ const rows = await this.#db
+ .update(boardMembers)
+ .set({
+ ...input,
+ membership: input.membership ?? null,
+ revision: sql`${boardMembers.revision} + 1`
+ })
+ .where(and(eq(boardMembers.id, id), eq(boardMembers.revision, revision)))
+ .returning({ id: boardMembers.id });
+ return rows.length > 0;
+ }
+
+ async delete(id: string, revision: number): Promise {
+ const rows = await this.#db
+ .delete(boardMembers)
+ .where(and(eq(boardMembers.id, id), eq(boardMembers.revision, revision)))
+ .returning({ id: boardMembers.id });
+ return rows.length > 0;
+ }
+}
diff --git a/programmerbar-web/src/routes/(app)/emeritus/+page.server.ts b/programmerbar-web/src/routes/(app)/emeritus/+page.server.ts
new file mode 100644
index 00000000..e1f5d15f
--- /dev/null
+++ b/programmerbar-web/src/routes/(app)/emeritus/+page.server.ts
@@ -0,0 +1,62 @@
+import { error, fail } from '@sveltejs/kit';
+import { z } from 'zod';
+import { boardMemberSchema } from '../../../lib/board-history';
+import type { Actions, PageServerLoad } from './$types';
+
+const identitySchema = z.object({
+ id: z.string().min(1).max(100),
+ revision: z.coerce.number().int().positive()
+});
+const conflict =
+ 'Oppføringen er endret eller slettet av noen andre. Last siden på nytt før du prøver igjen.';
+
+export const load: PageServerLoad = async ({ locals }) => ({
+ boardMembers: await locals.boardHistoryService.getAll(),
+ canEdit: locals.user?.role === 'board'
+});
+
+export const actions: Actions = {
+ save: async ({ locals, request }) => {
+ if (locals.user?.role !== 'board') error(403, 'Bare styret kan redigere styrehistorikken.');
+ const form = await request.formData();
+ const raw = form.get('member');
+ if (typeof raw !== 'string' || raw.length > 30000)
+ return fail(400, { message: 'Ugyldig skjema.' });
+ let input: unknown;
+ try {
+ input = JSON.parse(raw);
+ } catch {
+ return fail(400, { message: 'Ugyldig skjema.' });
+ }
+ const parsed = boardMemberSchema.safeParse(input);
+ if (!parsed.success) return fail(400, { message: parsed.error.issues[0].message });
+ const id = form.get('id');
+ if (id) {
+ const identity = identitySchema.safeParse({ id, revision: form.get('revision') });
+ if (!identity.success) return fail(400, { message: 'Ugyldig oppføring.' });
+ if (
+ !(await locals.boardHistoryService.update(
+ identity.data.id,
+ identity.data.revision,
+ parsed.data
+ ))
+ )
+ return fail(409, { message: conflict });
+ } else {
+ await locals.boardHistoryService.create(parsed.data);
+ }
+ return { success: true };
+ },
+ delete: async ({ locals, request }) => {
+ if (locals.user?.role !== 'board') error(403, 'Bare styret kan redigere styrehistorikken.');
+ const form = await request.formData();
+ const identity = identitySchema.safeParse({
+ id: form.get('id'),
+ revision: form.get('revision')
+ });
+ if (!identity.success) return fail(400, { message: 'Ugyldig oppføring.' });
+ if (!(await locals.boardHistoryService.delete(identity.data.id, identity.data.revision)))
+ return fail(409, { message: conflict });
+ return { success: true };
+ }
+};
diff --git a/programmerbar-web/src/routes/(app)/emeritus/+page.svelte b/programmerbar-web/src/routes/(app)/emeritus/+page.svelte
index e9413ab4..26a5bfa9 100644
--- a/programmerbar-web/src/routes/(app)/emeritus/+page.svelte
+++ b/programmerbar-web/src/routes/(app)/emeritus/+page.svelte
@@ -1,189 +1,165 @@
-{#snippet memberList(list: Member[])}
+{#snippet checkboxMark(checked: boolean)}
+
+
+
+
+
+{/snippet}
+
+{#snippet displayControls(options: DisplayOptions, label: string, allowHistory = false)}
+ {@const choiceClass =
+ 'border-foreground-muted/25 bg-background text-foreground-secondary hover:border-primary/60 hover:bg-primary/5 has-[:checked]:border-primary has-[:checked]:bg-primary/10 has-[:checked]:text-foreground-primary has-[:focus-visible]:ring-primary has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-40 flex min-h-11 cursor-pointer items-center gap-2.5 rounded-lg border px-3 py-2 text-sm transition-colors motion-reduce:transition-none'}
+
+ Vis informasjon: {label}
+
+
+ {@render checkboxMark(options.roles)}
+ Vis verv
+
+
+
+ {@render checkboxMark(options.periods)}
+ Vis perioder
+
+ {#if allowHistory}
+
+ {@render checkboxMark(options.history)}
+ Vis alle verv
+
+ {/if}
+
+{/snippet}
+
+{#snippet memberList(list: Member[], options: DisplayOptions)}
@@ -211,12 +187,41 @@
+ {#if data.canEdit}
+
+
{
+ adding = true;
+ editing = null;
+ saved = false;
+ }}>Legg til medlem
+
+ Du er innlogget som styremedlem og kan redigere historikken her.
+
+ {#if saved}
Endringen er lagret.
{/if}
+
+ {#if adding}
+
{
+ closeEditor();
+ saved = true;
+ }}
+ />
+ {/if}
+ {/if}
Vis styre
- Historikken er ikke komplett. Flere kan ha sittet i styret i de ulike semestrene uten å
- være lagt til her ennå.
+ Listen er sortert etter første registrerte startsemester, eldste først. Ukjent start
+ vises øverst. Historikken er ikke komplett. Flere kan ha sittet i styret i de ulike
+ semestrene uten å være lagt til her ennå.
{#if selectedYear !== 'all'}
Utvalget viser medlemmer med registrert periode i dette styreåret. Medlemmer uten
kjent periode finner du i hele listen.
@@ -241,28 +247,23 @@
## Co-founders
-
- {#each coFounders as founder (founder.name)}
-
- {founder.name}
- {founder.role}
-
- {/each}
-
+ {@render memberList(coFounders, { roles: true, periods: false, history: true })}
## Styremedlemmer
- {@render memberList(members)}
+ {@render displayControls(currentDisplay, 'Visning av nåværende medlemmer', true)}
+ {@render memberList(members, currentDisplay)}
## Tidligere Styremedlemmer
- {@render memberList(pastMembers)}
+ {@render displayControls(pastDisplay, 'Visning av tidligere medlemmer')}
+ {@render memberList(pastMembers, pastDisplay)}
{:else}
@@ -275,7 +276,8 @@
{filteredMembers.length} registrerte medlemmer i løpet av styreåret
{#if filteredMembers.length}
- {@render memberList(filteredMembers)}
+ {@render displayControls(yearDisplay, 'Visning av valgt styreår')}
+ {@render memberList(filteredMembers, yearDisplay)}
{:else}
Ingen medlemmer er registrert med periode i dette styreåret ennå.
diff --git a/programmerbar-web/src/routes/(app)/emeritus/page.server.test.ts b/programmerbar-web/src/routes/(app)/emeritus/page.server.test.ts
new file mode 100644
index 00000000..b7a9cc20
--- /dev/null
+++ b/programmerbar-web/src/routes/(app)/emeritus/page.server.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it, vi } from 'vitest';
+import { actions } from './+page.server';
+
+function setup(role: string | null, fields: Record = {}) {
+ const service = {
+ create: vi.fn(),
+ update: vi.fn().mockResolvedValue(true),
+ delete: vi.fn().mockResolvedValue(true)
+ };
+ const event = {
+ locals: { user: role ? { role } : null, boardHistoryService: service },
+ request: new Request('https://example.test/emeritus', {
+ method: 'POST',
+ body: new URLSearchParams(fields)
+ })
+ };
+ return { service, event };
+}
+
+describe('emeritus editor authorization and validation', () => {
+ it.each([null, 'normal'])('blocks save and delete for %s', async (role) => {
+ const { event, service } = setup(role);
+ await expect(actions.save!(event as never)).rejects.toMatchObject({ status: 403 });
+ await expect(actions.delete!(event as never)).rejects.toMatchObject({ status: 403 });
+ expect(service.create).not.toHaveBeenCalled();
+ expect(service.update).not.toHaveBeenCalled();
+ expect(service.delete).not.toHaveBeenCalled();
+ });
+
+ it('validates submitted data before writes', async () => {
+ for (const member of [
+ 'not json',
+ JSON.stringify({ name: '', category: 'current', roles: [] })
+ ]) {
+ const { event, service } = setup('board', { member });
+ expect(await actions.save!(event as never)).toMatchObject({ status: 400 });
+ expect(service.create).not.toHaveBeenCalled();
+ }
+ });
+
+ it('allows board members to create and edit history', async () => {
+ const input = { name: 'Testperson', category: 'current', roles: [] };
+ const create = setup('board', { member: JSON.stringify(input) });
+ expect(await actions.save!(create.event as never)).toEqual({ success: true });
+ expect(create.service.create).toHaveBeenCalledWith(input);
+ const edit = setup('board', { member: JSON.stringify(input), id: 'member-id', revision: '3' });
+ expect(await actions.save!(edit.event as never)).toEqual({ success: true });
+ expect(edit.service.update).toHaveBeenCalledWith('member-id', 3, input);
+ });
+
+ it('reports a stale edit and requires revision for deletion', async () => {
+ const edit = setup('board', {
+ member: JSON.stringify({ name: 'Testperson', category: 'past', roles: [] }),
+ id: 'member-id',
+ revision: '1'
+ });
+ edit.service.update.mockResolvedValue(false);
+ expect(await actions.save!(edit.event as never)).toMatchObject({ status: 409 });
+ const invalid = setup('board', { id: 'member-id' });
+ expect(await actions.delete!(invalid.event as never)).toMatchObject({ status: 400 });
+ expect(invalid.service.delete).not.toHaveBeenCalled();
+ const valid = setup('board', { id: 'member-id', revision: '1' });
+ expect(await actions.delete!(valid.event as never)).toEqual({ success: true });
+ expect(valid.service.delete).toHaveBeenCalledWith('member-id', 1);
+ });
+});