Compare commits

...

10 Commits

Author SHA1 Message Date
5638a8ecb5 🐛 Matching any substitution on empty lessons
If a timetable lesson is empty (eg. has no teacher),
just matching any lesson to it is not intended behaviour,
like it is the other way around.
2023-10-04 11:53:09 +02:00
c9e48fe8b4 🚑 Prevent duplicate substitutions within a file 2023-08-28 12:14:40 +02:00
607e304c50 🚑 Fix timetable and profile export charset 2023-08-28 11:51:36 +02:00
cce7bd55da 🚑 Prevent duplicate substitutions
- Prevent duplication if two substitution plan files are the same
2023-08-28 11:19:46 +02:00
38246db16b 🚸 Allow scrolling on login page 2023-08-27 22:01:46 +02:00
7705a299a0 🧑‍💻 Add simple filesystem file provider
- Allows parsing files from a local folder
2023-08-27 18:01:28 +02:00
0cb55eaf68 Add support for other substitution types
- Store and display original substitution type text
- Treat "eigenverantwortliches Arbeiten" as cancellation
2023-08-27 18:00:09 +02:00
2895efc43b 💄 Add "Darker" theme 2023-08-27 15:37:18 +02:00
f11fe89835 🐛 Fix timetable card in admin settings
- Now displays timetable class and ID again
2023-08-27 14:14:22 +02:00
493a6ee05b Implement caching of timetable data 2023-08-27 14:12:38 +02:00
18 changed files with 204 additions and 73 deletions

View File

@ -177,6 +177,7 @@ export async function getSubstitutions(req, res) {
id: element.id, id: element.id,
class: element.class, class: element.class,
type: element.type, type: element.type,
rawType: element.rawType,
lesson: element.lesson, lesson: element.lesson,
date: new Date(element.date).getTime(), date: new Date(element.date).getTime(),
notes: element.notes, notes: element.notes,

View File

@ -0,0 +1,24 @@
import fs from "fs";
/*
This file provider allows the application to use a local folder containing
parsable (.html) files, instead of downloading them from the internet.
This can be especially useful for development.
*/
export class FileSystemClient {
constructor(path) {
this.path = path;
}
getFiles() {
const contents = [];
const files = fs.readdirSync(this.path);
for (const file of files) {
const data = fs.readFileSync(this.path + "/" + file).toString();
contents.push(data);
}
return contents;
}
}

View File

@ -39,8 +39,32 @@ export class Parser {
const dayPlans = []; const dayPlans = [];
for (const plan of plans) { for (const plan of plans) {
const foundPlan = dayPlans.find((e) => e.date == plan.date); const foundPlan = dayPlans.find((e) => e.date == plan.date);
if (!foundPlan) dayPlans.push(plan); if (!foundPlan) {
else foundPlan.changes.push(...plan.changes); // Make sure to not insert duplicate substitutions within a file
const changes = structuredClone(plan.changes);
const cleanedChanges = [];
for (const change of changes) {
const changeExists = cleanedChanges.find(
(e) => JSON.stringify(e) == JSON.stringify(change),
);
if (!changeExists) {
cleanedChanges.push(change);
}
// Use the new array of changes
plan.changes = cleanedChanges;
}
dayPlans.push(plan);
} else {
for (const change of plan.changes) {
// Make sure to not insert a substitution that already exists in the changes
const changeExists = foundPlan.changes.find(
(e) => JSON.stringify(e) == JSON.stringify(change),
);
if (!changeExists) {
foundPlan.changes.push(change);
}
}
}
} }
// Insert substitutions of all substitution plans // Insert substitutions of all substitution plans
for (const plan of dayPlans) { for (const plan of dayPlans) {
@ -96,15 +120,15 @@ export class Parser {
// (Date, Type, Lesson, Classes and Subject need to be the same) // (Date, Type, Lesson, Classes and Subject need to be the same)
const matchingSubstitutionId = knownSubstitutions.findIndex( const matchingSubstitutionId = knownSubstitutions.findIndex(
(substitution) => { (substitution) => {
return substitution.date == new Date(date).setUTCHours(0, 0, 0, 0) && return (
substitution.type == (change.type == "Entfall") substitution.date.getTime() ==
? "cancellation" new Date(date).setUTCHours(0, 0, 0, 0) &&
: "change" && substitution.rawType == change.type &&
substitution.lesson == change.lesson && substitution.lesson == change.lesson &&
classes.sort().join(",") == classes.sort().join(",") == substitution.class.sort().join(",") &&
substitution.class.sort().join(",") &&
substitution.changedSubject == change.subject && substitution.changedSubject == change.subject &&
substitution.teacher == (change.teacher || ""); substitution.teacher == (change.teacher || "")
);
}, },
); );
const matchingSubstitution = knownSubstitutions[matchingSubstitutionId]; const matchingSubstitution = knownSubstitutions[matchingSubstitutionId];
@ -115,7 +139,12 @@ export class Parser {
data: { data: {
class: classes, class: classes,
date: new Date(date), date: new Date(date),
type: change.type == "Entfall" ? "cancellation" : "change", type:
change.type == "Entfall" ||
change.type == "eigenverantwortliches Arbeiten"
? "cancellation"
: "change",
rawType: change.type,
lesson: parseInt(change.lesson), lesson: parseInt(change.lesson),
teacher: change.teacher || "", teacher: change.teacher || "",
changedTeacher: change.changedTeacher, changedTeacher: change.changedTeacher,
@ -133,6 +162,7 @@ export class Parser {
changes: { changes: {
class: classes, class: classes,
type: change.type == "Entfall" ? "cancellation" : "change", type: change.type == "Entfall" ? "cancellation" : "change",
rawType: change.type,
lesson: parseInt(change.lesson), lesson: parseInt(change.lesson),
date: new Date(date), date: new Date(date),
notes: change.notes, notes: change.notes,
@ -204,6 +234,7 @@ export class Parser {
changes: { changes: {
class: remainingSubstitution.class, class: remainingSubstitution.class,
type: remainingSubstitution.type, type: remainingSubstitution.type,
rawType: remainingSubstitution.rawType,
lesson: remainingSubstitution.lesson, lesson: remainingSubstitution.lesson,
date: remainingSubstitution.date.getTime(), date: remainingSubstitution.date.getTime(),
notes: remainingSubstitution.notes, notes: remainingSubstitution.notes,

View File

@ -27,6 +27,7 @@ model Substitution {
class String[] class String[]
date DateTime date DateTime
type String type String
rawType String @default("unknown")
lesson Int lesson Int
teacher String teacher String
changedTeacher String? changedTeacher String?

View File

@ -16,7 +16,7 @@ import {
} from "@/store"; } from "@/store";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
const autoThemes = { true: "dark", false: "light" }; const autoThemes = { true: "darker", false: "light" };
const autoTheme = ref("dark"); const autoTheme = ref("dark");
const colorSchemeMedia = window.matchMedia("(prefers-color-scheme: dark)"); const colorSchemeMedia = window.matchMedia("(prefers-color-scheme: dark)");
autoTheme.value = autoThemes[colorSchemeMedia.matches]; autoTheme.value = autoThemes[colorSchemeMedia.matches];

View File

@ -3,7 +3,6 @@
--element-color: #212121; --element-color: #212121;
--element-color-hover: #1b1b1b; --element-color-hover: #1b1b1b;
--element-border-input: #4e7a3a; --element-border-input: #4e7a3a;
--element-border-focus: #9ac982;
--element-border-action: #1f5b63; --element-border-action: #1f5b63;
--text-color: #bdbdbd; --text-color: #bdbdbd;
--font-family: "sourcesanspro"; --font-family: "sourcesanspro";
@ -31,7 +30,6 @@
--element-color: #bdbdbd; --element-color: #bdbdbd;
--element-color-hover: #ada9a9; --element-color-hover: #ada9a9;
--element-border-input: #4caf50; --element-border-input: #4caf50;
--element-border-focus: #7ba764;
--element-border-action: #3f51b5; --element-border-action: #3f51b5;
--text-color: #353131; --text-color: #353131;
--font-family: "sourcesanspro"; --font-family: "sourcesanspro";
@ -53,3 +51,30 @@
--timetable-trust-warning-border: #b71c1c; --timetable-trust-warning-border: #b71c1c;
--timetable-trust-warning-background: #dabcbc; --timetable-trust-warning-background: #dabcbc;
} }
.app.theme-darker {
--bg-color: #111111;
--element-color: #252525;
--element-color-hover: #1d1d1d;
--element-border-input: #4e7a3a;
--element-border-action: #1f5b63;
--text-color: #bdbdbd;
--font-family: "sourcesanspro";
--titlebar-color: #1d1d1d;
--titlebar-element-active-color: #44573b;
--titlebar-shadow: 0px 0px 10px 5px rgba(0, 0, 0, 0.2);
--bottomnav-color: hsl(137, 8%, 17%);
--bottomnav-icon-color: #899c8b;
--bottomnav-icon-active-color: #1e271f;
--bottomnav-active-color: #7c8670;
--bottomnav-shadow: 5px 7px 19px 0px rgba(0, 0, 0, 0.25);
--loader-color: #7ca74b;
--loader-error-color: #a54a4a;
--substitution-background-change: #095079;
--substitution-background-cancellation: #7d2b2d;
--substitution-background-addition: #326430;
--substitution-background-deletion: #7d2b2d;
--substitution-background-unchanged: #1c1e1d;
--timetable-trust-warning-border: #b71c1c;
--timetable-trust-warning-background: #412727;
}

View File

@ -79,8 +79,11 @@ const chars = {
room: event.change.change.room, room: event.change.change.room,
}, },
) )
}}<span class="notes" v-if="event.change.notes"> }}<span class="notes">
{{ $t("timetable.notes") }} {{ event.change.notes }} <i>{{ event.change.rawType }}</i>
<span v-if="event.change.notes">
/ {{ $t("timetable.notes") }} {{ event.change.notes }}</span
>
</span> </span>
</span> </span>
<span class="notes"> <span class="notes">

View File

@ -130,6 +130,9 @@ function getTime(index) {
>, {{ $t("timetable.notes") }} {{ getNotes(lesson.substitution) }} >, {{ $t("timetable.notes") }} {{ getNotes(lesson.substitution) }}
</span> </span>
</div> </div>
<span class="info type" v-if="lesson.substitution">
<i>{{ lesson.substitution.rawType }}</i>
</span>
</div> </div>
<div class="times" v-if="getTime(index).start && !edit"> <div class="times" v-if="getTime(index).start && !edit">
<span>{{ getTime(index).start }} -</span> <span>{{ getTime(index).start }} -</span>

View File

@ -60,8 +60,11 @@ const substitutionsForDate = computed(() => {
}, },
) )
}}</span> }}</span>
<span class="notes" v-if="substitution.notes"> <span class="detail">
{{ $t("timetable.notes") }} {{ substitution.notes }} <i>{{ substitution.rawType }}</i>
<span v-if="substitution.notes">
/ {{ $t("timetable.notes") }} {{ substitution.notes }}</span
>
</span> </span>
</div> </div>
</div> </div>
@ -98,7 +101,7 @@ const substitutionsForDate = computed(() => {
word-wrap: break-word; word-wrap: break-word;
} }
.notes { .detail {
font-size: 13px; font-size: 13px;
font-weight: 100; font-weight: 100;
} }

View File

@ -24,7 +24,7 @@ const linkedTimetable = computed(() => {
return ( return (
entry.lesson == index + 1 && entry.lesson == index + 1 &&
entryDay == props.date.getTime() && entryDay == props.date.getTime() &&
(entry.teacher == e.teacher || !entry.teacher || !e.teacher) (entry.teacher == e.teacher || !entry.teacher)
); );
}, },
); );

View File

@ -89,8 +89,4 @@ select {
select:hover { select:hover {
background-color: var(--element-color-hover); background-color: var(--element-color-hover);
} }
select:focus {
border-color: var(--element-border-focus);
}
</style> </style>

View File

@ -30,19 +30,6 @@ export const activeProfileId = ref(
localStorage.getItem("activeProfile") || profiles.value[0].id, localStorage.getItem("activeProfile") || profiles.value[0].id,
); );
watch(
() => activeProfile.value.classFilter,
() => {
fetchData(getNextAndPrevDay(selectedDate.value), false);
},
);
export const localTimetables = ref(
JSON.parse(localStorage.getItem("timetables")) || [],
);
export const theme = ref(localStorage.getItem("theme") || "auto");
watch( watch(
profiles, profiles,
(newValue) => { (newValue) => {
@ -53,6 +40,27 @@ watch(
watch(activeProfileId, (newValue) => { watch(activeProfileId, (newValue) => {
localStorage.setItem("activeProfile", newValue); localStorage.setItem("activeProfile", newValue);
}); });
watch(
() => activeProfile.value.classFilter,
() => {
fetchData(getNextAndPrevDay(selectedDate.value), false);
},
);
export const cachedTimetables = ref(
JSON.parse(localStorage.getItem("cachedTimetables")) || {},
);
export const localTimetables = ref(
JSON.parse(localStorage.getItem("timetables")) || [],
);
watch(
cachedTimetables,
(newValue) => {
localStorage.setItem("cachedTimetables", JSON.stringify(newValue));
},
{ deep: true },
);
watch( watch(
localTimetables, localTimetables,
(newValue) => { (newValue) => {
@ -60,6 +68,8 @@ watch(
}, },
{ deep: true }, { deep: true },
); );
export const theme = ref(localStorage.getItem("theme") || "auto");
watch(theme, (newValue) => { watch(theme, (newValue) => {
localStorage.setItem("theme", newValue); localStorage.setItem("theme", newValue);
}); });
@ -92,8 +102,12 @@ export const timetable = computed(() => {
); );
}); });
export const sessionInfo = ref({}); export const sessionInfo = ref({});
export const timetables = ref([]); export const timetables = ref(
export const times = ref([]); (cachedTimetables.value[activeProfileId.value] || {}).timetables || [],
);
export const times = ref(
(cachedTimetables.value[activeProfileId.value] || {}).times || [],
);
export const substitutions = ref({}); export const substitutions = ref({});
export const history = ref({}); export const history = ref({});
export const classList = ref([]); export const classList = ref([]);
@ -182,6 +196,13 @@ export async function fetchTimetables() {
} else { } else {
timetables.value = timetableData.timetables; timetables.value = timetableData.timetables;
times.value = timetableData.times; times.value = timetableData.times;
cachedTimetables.value[activeProfileId.value] =
structuredClone(timetableData);
for (const timetable of cachedTimetables.value[activeProfileId.value]
.timetables) {
timetable.fromCache = true;
}
} }
} }

View File

@ -62,6 +62,7 @@ export const strings = {
auto: "Auto", auto: "Auto",
dark: "Dark", dark: "Dark",
light: "Light", light: "Light",
darker: "Darker",
}, },
}, },
timetable: { timetable: {
@ -192,6 +193,7 @@ export const strings = {
auto: "Automatisch", auto: "Automatisch",
dark: "Dunkel", dark: "Dunkel",
light: "Hell", light: "Hell",
darker: "Darker",
}, },
}, },
timetable: { timetable: {

View File

@ -1,4 +1,5 @@
<template> <template>
<div class="container">
<div class="login"> <div class="login">
<h1>Timetable V2</h1> <h1>Timetable V2</h1>
<form action="/auth/login" method="POST"> <form action="/auth/login" method="POST">
@ -11,11 +12,13 @@
<button type="submit">Login</button> <button type="submit">Login</button>
</form> </form>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
.login { .container {
padding: 50px 10px; height: 100%;
overflow-y: scroll;
} }
.login { .login {
@ -25,6 +28,7 @@
flex-direction: column; flex-direction: column;
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
color: var(--text-color); color: var(--text-color);
padding: 30px 0px;
} }
form { form {

View File

@ -1,9 +1,16 @@
<script setup> <script setup>
import ExpandSection from "@/components/settings/expand-section.vue"; import ExpandSection from "@/components/settings/expand-section.vue";
import KeyCard from "@/components/settings/key-card.vue"; import KeyCard from "@/components/settings/key-card.vue";
import TimetableCard from "@/components/settings/timetable-card.vue"; import RadioCard from "@/components/settings/radio-card.vue";
import { baseUrl } from "@/store"; import { baseUrl } from "@/store";
import { PlusIcon, SaveIcon, XIcon, RefreshCwIcon } from "lucide-vue-next"; import {
PlusIcon,
SaveIcon,
XIcon,
RefreshCwIcon,
Edit2Icon,
TrashIcon,
} from "lucide-vue-next";
import { ref } from "vue"; import { ref } from "vue";
function confirm(message) { function confirm(message) {
@ -146,15 +153,17 @@ updateData();
<span>Cancel edit</span> <span>Cancel edit</span>
</div> </div>
</div> </div>
<TimetableCard <RadioCard
v-for="timetable in timetables" v-for="timetable in timetables"
:key="timetable" :key="timetable"
:timetable="timetable" :title="timetable.title"
:editable="true" :subtitle="`${$t('settings.source')}: ${timetable.source}, Class: ${
timetable.class
}, ID: ${timetable.id}`"
:selected="timetable.id == timetableEditId" :selected="timetable.id == timetableEditId"
:admin="true" >
@delete="deleteObject('timetable', timetable.id)" <Edit2Icon
@edit=" @click="
() => { () => {
timetableEditId = timetable.id; timetableEditId = timetable.id;
timetableName = timetable.title; timetableName = timetable.title;
@ -164,6 +173,13 @@ updateData();
} }
" "
/> />
<TrashIcon
@click="
if (confirm('Delete this timetable?'))
deleteObject('timetable', timetable.id);
"
/>
</RadioCard>
</div> </div>
</ExpandSection> </ExpandSection>
<ExpandSection title="Keys"> <ExpandSection title="Keys">

View File

@ -12,8 +12,9 @@ import i18n, { language, localeNames } from "@/i18n";
$t('settings.theme.auto'), $t('settings.theme.auto'),
$t('settings.theme.light'), $t('settings.theme.light'),
$t('settings.theme.dark'), $t('settings.theme.dark'),
$t('settings.theme.darker'),
]" ]"
:values="['auto', 'light', 'dark']" :values="['auto', 'light', 'dark', 'darker']"
v-model="theme" v-model="theme"
/> />
<div class="spacer" /> <div class="spacer" />

View File

@ -51,7 +51,7 @@ function importProfile(event) {
function exportProfile(profile) { function exportProfile(profile) {
download( download(
JSON.stringify(profile), new Blob(["\ufeff", JSON.stringify(profile)]),
`profile-${profile.id}.json`, `profile-${profile.id}.json`,
"application/json", "application/json",
); );

View File

@ -51,7 +51,7 @@ function importTimetable(event) {
function exportTimetable(timetable) { function exportTimetable(timetable) {
download( download(
JSON.stringify(timetable), new Blob(["\ufeff", JSON.stringify(timetable)]),
`timetable-${timetable.id}.json`, `timetable-${timetable.id}.json`,
"application/json", "application/json",
); );